diff --git a/app/(dashboard)/agent-inbox/page.tsx b/app/(dashboard)/agent-inbox/page.tsx new file mode 100644 index 00000000..8b45144f --- /dev/null +++ b/app/(dashboard)/agent-inbox/page.tsx @@ -0,0 +1,171 @@ +import { notFound, redirect } from 'next/navigation' +import Link from 'next/link' +import { createClient } from '@/lib/supabase/server' +import { ensureInitialized } from '@/lib/init' +import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' +import { isAgentInboxEnabled } from '@/lib/ai/feature-flag' +import { requireCompanyId } from '@/lib/company/context' +import { PageHeader } from '@/components/ui/page-header' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { Sparkles } from 'lucide-react' +import AgentInbox from '@/components/agent-inbox/AgentInbox' +import type { AIProposal, AIRequest, InvoiceInboxItem, Transaction, DocumentAttachment, MatchProposalPayload } from '@/types' + +ensureInitialized() + +// Expanded card data the client component needs to render each proposal's +// context (receipt thumbnail + matched transaction summary). +export interface AgentInboxItemView { + proposal: AIProposal | null + request: AIRequest | null + inbox_item: InvoiceInboxItem & { document: DocumentAttachment | null } + transaction: Transaction | null +} + +export default async function AgentInboxPage() { + // Hard gate: extension not enabled at build time, OR the feature flag is + // off in this environment (prod by default) → 404. + if (!ENABLED_EXTENSION_IDS.has('ai-agent') || !isAgentInboxEnabled()) { + notFound() + } + + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) redirect('/login') + + const companyId = await requireCompanyId(supabase, user.id) + + // Soft gate: per-company toggle. If not enabled, show an empty-state + // pointing to settings rather than 404 — the extension exists, the user + // just hasn't opted in yet. + const { data: settings } = await supabase + .from('company_settings') + .select('ai_flow_enabled') + .eq('company_id', companyId) + .maybeSingle() + + if (!settings?.ai_flow_enabled) { + return ( +
+ + + +
+ +
+

AI-agenten är inte aktiverad

+

+ Aktivera AI-agenten under bokföringsinställningar. Varje transaktion blir då ett + granskningsförslag istället för automatisk bokföring. +

+ +
+
+
+ ) + } + + // Load all pending proposals + open requests for the company, newest first. + const [{ data: proposals }, { data: requests }] = await Promise.all([ + supabase + .from('ai_proposals') + .select('*') + .eq('company_id', companyId) + .in('status', ['pending']) + .order('created_at', { ascending: false }) + .limit(50), + supabase + .from('ai_requests') + .select('*') + .eq('company_id', companyId) + .eq('status', 'open') + .order('created_at', { ascending: false }) + .limit(50), + ]) + + const typedProposals = (proposals ?? []) as AIProposal[] + const typedRequests = (requests ?? []) as AIRequest[] + + // Collect subject_ids and fetch inbox items + transactions in one pass. + const subjectIds = new Set([ + ...typedProposals.map((p) => p.subject_id), + ...typedRequests.map((r) => r.subject_id), + ]) + + const items: AgentInboxItemView[] = [] + if (subjectIds.size > 0) { + const { data: inboxRows } = await supabase + .from('invoice_inbox_items') + .select('*, document:document_attachments!document_id(*)') + .in('id', [...subjectIds]) + .eq('company_id', companyId) + + const inboxMap = new Map() + for (const row of inboxRows ?? []) { + inboxMap.set(row.id, row as InvoiceInboxItem & { document: DocumentAttachment | null }) + } + + // Collect transaction IDs from two sources: + // 1. inbox_item.matched_transaction_id — set after a match is accepted + // (used by booking cards to show the paired transaction). + // 2. proposal_json.matched_transaction_id on pending match proposals — + // the transaction the AI is *proposing*; needed so match cards can + // show a human-readable description instead of a raw UUID. + const matchedTxIds = [ + ...new Set([ + ...[...inboxMap.values()] + .map((i) => i.matched_transaction_id) + .filter((id): id is string => Boolean(id)), + ...typedProposals + .filter((p) => p.step_type === 'match') + .map((p) => (p.proposal_json as MatchProposalPayload).matched_transaction_id) + .filter((id): id is string => Boolean(id)), + ]), + ] + + const txMap = new Map() + if (matchedTxIds.length > 0) { + const { data: txRows } = await supabase + .from('transactions') + .select('*') + .in('id', matchedTxIds) + .eq('company_id', companyId) + for (const tx of txRows ?? []) txMap.set(tx.id, tx as Transaction) + } + + // Build the view: one card per (subject, step). Proposals first, requests second. + for (const proposal of typedProposals) { + const inbox = inboxMap.get(proposal.subject_id) + if (!inbox) continue + // Match cards render the transaction being *proposed*; booking cards render + // the transaction already accepted on the inbox item. + const txId = proposal.step_type === 'match' + ? (proposal.proposal_json as MatchProposalPayload).matched_transaction_id + : inbox.matched_transaction_id + items.push({ + proposal, + request: null, + inbox_item: inbox, + transaction: txId ? txMap.get(txId) ?? null : null, + }) + } + for (const request of typedRequests) { + const inbox = inboxMap.get(request.subject_id) + if (!inbox) continue + items.push({ + proposal: null, + request, + inbox_item: inbox, + transaction: inbox.matched_transaction_id ? txMap.get(inbox.matched_transaction_id) ?? null : null, + }) + } + } + + return +} diff --git a/app/(dashboard)/receipts/page.tsx b/app/(dashboard)/receipts/page.tsx index 81f7c7db..8fb39805 100644 --- a/app/(dashboard)/receipts/page.tsx +++ b/app/(dashboard)/receipts/page.tsx @@ -1,31 +1,59 @@ -'use client' +import { notFound, redirect } from 'next/navigation' +import { createClient } from '@/lib/supabase/server' +import { ensureInitialized } from '@/lib/init' +import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' +import { requireCompanyId } from '@/lib/company/context' +import ReceiptsList from '@/components/receipts/ReceiptsList' +import type { InvoiceInboxItem, DocumentAttachment } from '@/types' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Receipt } from 'lucide-react' +ensureInitialized() -export default function ReceiptsPage() { - return ( -
-
-

Kvitton

-

- Hantera och granska dina kvitton -

-
+export type ReceiptRow = InvoiceInboxItem & { document: DocumentAttachment | null } +export type ReceiptRowWithPreview = ReceiptRow & { preview_url: string | null } - - - - - Kvitton - - - -

- Kvittoscanning är inte tillgängligt just nu. -

-
-
-
- ) +export default async function ReceiptsPage() { + // Hard gate: if the invoice-inbox extension isn't loaded, there's no + // upload pipeline and nothing would work here. + if (!ENABLED_EXTENSION_IDS.has('invoice-inbox')) notFound() + + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) redirect('/login') + + const companyId = await requireCompanyId(supabase, user.id) + + const { data } = await supabase + .from('invoice_inbox_items') + .select('*, document:document_attachments!document_id(*)') + .eq('company_id', companyId) + .eq('document_type', 'receipt') + .order('created_at', { ascending: false }) + .limit(200) + + const rows = (data ?? []) as ReceiptRow[] + + // Batch-sign all document paths so each row renders with a thumbnail. + // Supabase exposes createSignedUrls (plural) for exactly this use case. + const paths = rows + .map((r) => r.document?.storage_path) + .filter((p): p is string => Boolean(p)) + + const urlByPath = new Map() + if (paths.length > 0) { + const { data: signed } = await supabase.storage + .from('documents') + .createSignedUrls(paths, 3600) + for (const entry of signed ?? []) { + if (entry.path && entry.signedUrl) urlByPath.set(entry.path, entry.signedUrl) + } + } + + const items = rows.map((row) => ({ + ...row, + preview_url: row.document?.storage_path + ? urlByPath.get(row.document.storage_path) ?? null + : null, + })) + + return } diff --git a/app/(dashboard)/receipts/scan/page.tsx b/app/(dashboard)/receipts/scan/page.tsx deleted file mode 100644 index 8700be0e..00000000 --- a/app/(dashboard)/receipts/scan/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -'use client' - -import { useEffect } from 'react' -import { useRouter } from 'next/navigation' - -export default function ScanReceiptPage() { - const router = useRouter() - - useEffect(() => { - router.replace('/receipts') - }, [router]) - - return null -} diff --git a/app/(dashboard)/settings/bookkeeping/page.tsx b/app/(dashboard)/settings/bookkeeping/page.tsx index 6abcdfd0..d4c87e79 100644 --- a/app/(dashboard)/settings/bookkeeping/page.tsx +++ b/app/(dashboard)/settings/bookkeeping/page.tsx @@ -7,13 +7,17 @@ import { PeriodLockingSettings } from '@/components/settings/PeriodLockingSettin import { VoucherSeriesManager } from '@/components/settings/VoucherSeriesManager' import { useSettings } from '@/components/settings/useSettings' import { Label } from '@/components/ui/label' -import { ExternalLink } from 'lucide-react' +import { Switch } from '@/components/ui/switch' +import { ExternalLink, Sparkles } from 'lucide-react' +import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' +import { isAgentInboxEnabled } from '@/lib/ai/feature-flag' import type { CompanySettings } from '@/types' const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') export default function BookkeepingSettingsPage() { const { settings, isLoading, updateSettings } = useSettings() + const aiAgentAvailable = ENABLED_EXTENSION_IDS.has('ai-agent') && isAgentInboxEnabled() if (isLoading || !settings) return @@ -22,6 +26,7 @@ export default function BookkeepingSettingsPage() { const lockedThrough = (formData.get('bookkeeping_locked_through') as string) || null const accountingMethod = (formData.get('accounting_method') as string) || 'accrual' const defaultVoucherSeries = (formData.get('default_voucher_series') as string) || 'A' + const aiFlowEnabled = formData.get('ai_flow_enabled') === 'on' const updates: Record = { bookkeeping_locked_through: lockedThrough, @@ -29,6 +34,9 @@ export default function BookkeepingSettingsPage() { accounting_method: accountingMethod, default_voucher_series: defaultVoucherSeries, } + if (aiAgentAvailable) { + updates.ai_flow_enabled = aiFlowEnabled + } return { updates, onSuccess: (data: Record) => { @@ -93,6 +101,34 @@ export default function BookkeepingSettingsPage() {
+ + {/* AI agent (beta) — gated on extension availability */} + {aiAgentAvailable && ( +
+
+

+ + AI-agent (beta) +

+
+ +
+ +

+ När aktiv: varje ny banktransaktion blir ett AI-förslag du granskar i + agent-inkorgen. + Den automatiska bokföringen (≥80% regelmatchning) stängs av — inget bokförs + utan din bekräftelse. +

+
+
+
+
+ )} {/* Voucher series — read-only display */} diff --git a/app/api/ai/backfill/cancel/route.ts b/app/api/ai/backfill/cancel/route.ts new file mode 100644 index 00000000..8bd09db5 --- /dev/null +++ b/app/api/ai/backfill/cancel/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' +import { gateAgentInbox } from '@/lib/ai/feature-flag' + +ensureInitialized() + +/** + * POST /api/ai/backfill/cancel + * + * Set the kill switch flag on company_settings. The running backfill loop + * checks this between items and exits cleanly. Already-generated proposals + * stay — the cancel just stops further generation. + */ +export async function POST() { + const gate = gateAgentInbox() + if (gate) return gate + + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + const { error } = await supabase + .from('company_settings') + .update({ ai_backfill_cancel_requested: true }) + .eq('company_id', companyId) + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + return NextResponse.json({ data: { cancelled: true } }) +} diff --git a/app/api/ai/backfill/receipts/route.ts b/app/api/ai/backfill/receipts/route.ts new file mode 100644 index 00000000..b45a6075 --- /dev/null +++ b/app/api/ai/backfill/receipts/route.ts @@ -0,0 +1,222 @@ +import { NextResponse } from 'next/server' +import { createClient as createServiceClient } from '@supabase/supabase-js' +import { createClient } from '@/lib/supabase/server' +import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' +import { + generateMatchProposalFor, + generateBookingProposalFor, +} from '@/lib/ai/orchestrator' +import { gateAgentInbox } from '@/lib/ai/feature-flag' +import type { + InvoiceInboxItem, + Transaction, + CategorizationTemplate, +} from '@/types' +import type { SupabaseClient } from '@supabase/supabase-js' + +ensureInitialized() + +/** + * POST /api/ai/backfill/receipts + * + * Generate AI proposals for the existing receipt backlog: + * - inbox items with document_type='receipt' and status='ready' that + * have no pending match proposal → generate match + * - items with matched_transaction_id and no booked journal entry but + * no pending booking proposal → generate booking + * + * Fire-and-forget: returns immediately with `{ queued }`. The loop + * iterates in the background, checking `company_settings.ai_backfill_cancel_requested` + * between items so the user can stop it. Idempotent via the partial + * unique index on (subject, step) WHERE pending — re-clicking does no harm. + * + * NOTE: relies on long-lived Node/Vercel worker to complete the loop. For + * v1 dev-only this is acceptable; a proper job queue is a follow-up. + */ +export async function POST() { + const gate = gateAgentInbox() + if (gate) return gate + + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + // Gate on the per-company flag. + const { data: settings } = await supabase + .from('company_settings') + .select('ai_flow_enabled, ai_backfill_cancel_requested') + .eq('company_id', companyId) + .maybeSingle() + + if (!settings?.ai_flow_enabled) { + return NextResponse.json( + { error: 'AI-agenten är inte aktiverad.' }, + { status: 400 } + ) + } + + // Reset the cancel flag so a previous cancel doesn't kill this run. + await supabase + .from('company_settings') + .update({ ai_backfill_cancel_requested: false }) + .eq('company_id', companyId) + + // Count eligible items up front for the response. + const { data: eligibleMatch } = await supabase + .from('invoice_inbox_items') + .select('id') + .eq('company_id', companyId) + .eq('document_type', 'receipt') + .eq('status', 'ready') + .is('matched_transaction_id', null) + + const { data: eligibleBooking } = await supabase + .from('invoice_inbox_items') + .select('id') + .eq('company_id', companyId) + .eq('document_type', 'receipt') + .eq('status', 'ready') + .not('matched_transaction_id', 'is', null) + + const matchCount = eligibleMatch?.length ?? 0 + const bookingCount = eligibleBooking?.length ?? 0 + + // Kick off the background loop. Intentionally NOT awaited. + runBackfill(companyId, user.id).catch((err) => { + console.error('[ai/backfill/receipts] loop failed:', err) + }) + + return NextResponse.json({ + data: { + queued_match: matchCount, + queued_booking: bookingCount, + }, + }) +} + +/** + * Run the backfill loop using a service-role client so the orchestrator's + * inserts bypass RLS (mirrors how orchestrator writes from event handlers). + */ +async function runBackfill(companyId: string, userId: string): Promise { + const service: SupabaseClient = createServiceClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ) + + // Pass 1: generate match proposals. + const { data: matchItems } = await service + .from('invoice_inbox_items') + .select('*') + .eq('company_id', companyId) + .eq('document_type', 'receipt') + .eq('status', 'ready') + .is('matched_transaction_id', null) + + for (const item of (matchItems || []) as InvoiceInboxItem[]) { + if (await isCancelled(service, companyId)) return + + // Skip if a pending match proposal already exists. + const { data: existing } = await service + .from('ai_proposals') + .select('id') + .eq('subject_type', 'inbox_item') + .eq('subject_id', item.id) + .eq('step_type', 'match') + .eq('status', 'pending') + .maybeSingle() + + if (existing) continue + + try { + await generateMatchProposalFor(service, { + inboxItem: item, + correlationId: item.correlation_id ?? undefined, + userId, + companyId, + }) + } catch (err) { + console.error(`[ai/backfill] match failed for ${item.id}:`, err) + } + } + + // Pass 2: generate booking proposals for already-matched items. + const { data: bookingItems } = await service + .from('invoice_inbox_items') + .select('*') + .eq('company_id', companyId) + .eq('document_type', 'receipt') + .eq('status', 'ready') + .not('matched_transaction_id', 'is', null) + + const { data: settings } = await service + .from('company_settings') + .select('entity_type') + .eq('company_id', companyId) + .maybeSingle() + const entityType: 'enskild_firma' | 'aktiebolag' = + (settings?.entity_type as 'enskild_firma' | 'aktiebolag') || 'enskild_firma' + + const { data: templates } = await service + .from('categorization_templates') + .select('*') + .eq('company_id', companyId) + .eq('is_active', true) + + for (const item of (bookingItems || []) as InvoiceInboxItem[]) { + if (await isCancelled(service, companyId)) return + + const { data: existing } = await service + .from('ai_proposals') + .select('id') + .eq('subject_type', 'inbox_item') + .eq('subject_id', item.id) + .eq('step_type', 'booking') + .eq('status', 'pending') + .maybeSingle() + + if (existing) continue + + const { data: tx } = await service + .from('transactions') + .select('*') + .eq('id', item.matched_transaction_id!) + .eq('company_id', companyId) + .maybeSingle() + + if (!tx || (tx as Transaction).journal_entry_id) continue + + try { + await generateBookingProposalFor(service, { + inboxItem: item, + matchedTransaction: tx as Transaction, + existingTemplates: (templates || []) as CategorizationTemplate[], + entityType, + correlationId: item.correlation_id ?? undefined, + userId, + companyId, + }) + } catch (err) { + console.error(`[ai/backfill] booking failed for ${item.id}:`, err) + } + } +} + +async function isCancelled( + service: SupabaseClient, + companyId: string +): Promise { + const { data } = await service + .from('company_settings') + .select('ai_backfill_cancel_requested') + .eq('company_id', companyId) + .maybeSingle() + return Boolean(data?.ai_backfill_cancel_requested) +} diff --git a/app/api/ai/inbox-items/[id]/attach-file/route.ts b/app/api/ai/inbox-items/[id]/attach-file/route.ts new file mode 100644 index 00000000..8e3ef34c --- /dev/null +++ b/app/api/ai/inbox-items/[id]/attach-file/route.ts @@ -0,0 +1,154 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' +import { uploadDocument } from '@/lib/core/documents/document-service' +import { appendProcessingHistory } from '@/lib/processing-history/append' +import { gateAgentInbox } from '@/lib/ai/feature-flag' +import type { InvoiceInboxItem } from '@/types' + +ensureInitialized() + +const MAX_BYTES = 15 * 1024 * 1024 // 15 MB — matches invoice-inbox workspace +const ALLOWED_MIME = new Set([ + 'application/pdf', + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/webp', +]) + +/** + * POST /api/ai/inbox-items/[id]/attach-file + * + * Attach a receipt image/PDF to an existing inbox item that was created + * without a file (e.g. a row seeded for testing, or an email receipt where + * the attachment was stripped). Stores the file in the WORM documents bucket + * and links it via invoice_inbox_items.document_id. Does not re-run + * classification — the extracted_data is left as-is. + * + * Only allowed when the inbox item currently has no document_id; we never + * replace an existing attachment (WORM policy). + */ +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const gate = gateAgentInbox() + if (gate) return gate + + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + const { id } = await params + + const { data: inboxRow } = await supabase + .from('invoice_inbox_items') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .maybeSingle() + + if (!inboxRow) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + const inbox = inboxRow as InvoiceInboxItem + + if (inbox.document_id) { + return NextResponse.json( + { error: 'Kvittot har redan en bifogad fil.' }, + { status: 409 } + ) + } + + // Parse multipart form-data. + let formData: FormData + try { + formData = await request.formData() + } catch { + return NextResponse.json({ error: 'Invalid form data' }, { status: 400 }) + } + + const file = formData.get('file') + if (!(file instanceof File)) { + return NextResponse.json({ error: 'Missing file' }, { status: 400 }) + } + + if (file.size === 0) { + return NextResponse.json({ error: 'Filen är tom.' }, { status: 400 }) + } + + if (file.size > MAX_BYTES) { + return NextResponse.json( + { error: `Filen är för stor (max ${Math.round(MAX_BYTES / 1024 / 1024)} MB).` }, + { status: 413 } + ) + } + + if (!ALLOWED_MIME.has(file.type)) { + return NextResponse.json( + { error: 'Filtypen stöds inte. Tillåtna: PDF, JPG, PNG, WebP.' }, + { status: 415 } + ) + } + + const buffer = await file.arrayBuffer() + + let doc + try { + doc = await uploadDocument( + supabase, + user.id, + companyId, + { name: file.name, buffer, type: file.type }, + { upload_source: 'file_upload' } + ) + } catch (err) { + const message = err instanceof Error ? err.message : 'Kunde inte ladda upp filen.' + return NextResponse.json({ error: message }, { status: 500 }) + } + + const { error: linkError } = await supabase + .from('invoice_inbox_items') + .update({ document_id: doc.id }) + .eq('id', inbox.id) + .eq('company_id', companyId) + + if (linkError) { + return NextResponse.json({ error: linkError.message }, { status: 500 }) + } + + try { + if (inbox.correlation_id) { + await appendProcessingHistory({ + companyId, + correlationId: inbox.correlation_id, + aggregateType: 'Document', + aggregateId: doc.id, + eventType: 'ReceiptFileAttached', + payload: { + inbox_item_id: inbox.id, + document_id: doc.id, + file_name: file.name, + mime_type: file.type, + size_bytes: file.size, + }, + actor: { type: 'user', id: user.id }, + occurredAt: new Date(), + }) + } + } catch (err) { + console.error('[ai/inbox-items/attach-file] processing_history append failed:', err) + } + + return NextResponse.json({ + data: { + inbox_item_id: inbox.id, + document_id: doc.id, + }, + }) +} diff --git a/app/api/ai/inbox-items/[id]/request-receipt/route.ts b/app/api/ai/inbox-items/[id]/request-receipt/route.ts new file mode 100644 index 00000000..fc3be1a0 --- /dev/null +++ b/app/api/ai/inbox-items/[id]/request-receipt/route.ts @@ -0,0 +1,217 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' +import { getEmailService } from '@/lib/email/service' +import { appendProcessingHistory } from '@/lib/processing-history/append' +import { gateAgentInbox } from '@/lib/ai/feature-flag' +import type { InvoiceInboxItem } from '@/types' + +ensureInitialized() + +/** + * POST /api/ai/inbox-items/[id]/request-receipt + * + * Ask every member of the company to upload a receipt file for this inbox + * item. Used when the AI couldn't book because no source document is + * attached (BFL compliance gate) or the existing image is too poor to read. + * + * Sends one email per member with a deep link back to agent-inkorg. + * No-op when the email service isn't configured — returns 503 so the UI + * can explain. + */ +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const gate = gateAgentInbox() + if (gate) return gate + + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + const { id } = await params + + const { data: inboxRow } = await supabase + .from('invoice_inbox_items') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .maybeSingle() + + if (!inboxRow) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + const inbox = inboxRow as InvoiceInboxItem + + const emailService = getEmailService() + if (!emailService.isConfigured()) { + return NextResponse.json( + { error: 'E-posttjänsten är inte konfigurerad.' }, + { status: 503 } + ) + } + + // Load every member's email address. profiles.email is populated by the + // handle_new_user trigger and kept in sync with auth.users. + const { data: members, error: memberError } = await supabase + .from('company_members') + .select('user_id, profiles:user_id(email, full_name)') + .eq('company_id', companyId) + + if (memberError) { + return NextResponse.json({ error: memberError.message }, { status: 500 }) + } + + // Shape of the joined profiles column depends on RLS/relationship — + // defensively support both single-object and array. + const recipients: Array<{ email: string; name: string | null }> = [] + for (const row of members ?? []) { + type ProfileShape = { email?: string | null; full_name?: string | null } + const profile: ProfileShape | ProfileShape[] | null | undefined = + (row as { profiles?: ProfileShape | ProfileShape[] | null }).profiles + const profileRow = Array.isArray(profile) ? profile[0] : profile + const email = profileRow?.email + if (email) recipients.push({ email, name: profileRow?.full_name ?? null }) + } + + if (recipients.length === 0) { + return NextResponse.json( + { error: 'Inga medlemmar med e-postadress hittades.' }, + { status: 404 } + ) + } + + const { data: companyRow } = await supabase + .from('company_settings') + .select('company_name') + .eq('company_id', companyId) + .maybeSingle() + + const companyName = companyRow?.company_name ?? 'ditt företag' + + // Pull a short summary of the receipt so recipients know which one to fix. + const extracted = inbox.extracted_data as { + merchant?: { name?: string | null } | null + totals?: { total?: number | null } | null + receipt?: { date?: string | null; currency?: string | null } | null + } | null + const merchant = extracted?.merchant?.name ?? 'okänd handlare' + const total = extracted?.totals?.total ?? null + const currency = extracted?.receipt?.currency ?? 'SEK' + const date = extracted?.receipt?.date ?? null + + const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'https://gnubok.se' + const deepLink = `${appUrl.replace(/\/$/, '')}/agent-inbox` + + const subject = `[${companyName}] Kvittobild behövs för bokföring` + + const summaryLine = [ + merchant, + total != null ? `${total} ${currency}` : null, + date, + ] + .filter(Boolean) + .join(' · ') + + const html = buildHtml({ companyName, summaryLine, deepLink, senderName: user.email ?? null }) + const text = buildText({ companyName, summaryLine, deepLink, senderName: user.email ?? null }) + + // Fire emails in parallel. Track successes and failures separately so a + // single bad address doesn't block the rest. + const results = await Promise.allSettled( + recipients.map((r) => + emailService.sendEmail({ + to: r.email, + subject, + html, + text, + }) + ) + ) + + let sent = 0 + let failed = 0 + for (const r of results) { + if (r.status === 'fulfilled' && r.value.success) sent += 1 + else failed += 1 + } + + // Audit trail so the user can see "Emil requested receipt from 3 members". + try { + if (inbox.correlation_id) { + await appendProcessingHistory({ + companyId, + correlationId: inbox.correlation_id, + aggregateType: 'Document', + aggregateId: inbox.document_id ?? inbox.id, + eventType: 'ReceiptRequested', + payload: { + inbox_item_id: inbox.id, + recipients: recipients.length, + sent, + failed, + }, + actor: { type: 'user', id: user.id }, + occurredAt: new Date(), + }) + } + } catch (err) { + console.error('[ai/request-receipt] processing_history append failed:', err) + } + + return NextResponse.json({ + data: { + sent, + failed, + total: recipients.length, + }, + }) +} + +interface TemplateArgs { + companyName: string + summaryLine: string + deepLink: string + senderName: string | null +} + +function buildHtml({ companyName, summaryLine, deepLink, senderName }: TemplateArgs): string { + return ` + + +

Source documents required for receipt and transaction mapping

+

+ ${senderName ? `${senderName} ` : ''}behöver ett kvittounderlag för ${companyName} innan en transaktion kan bokföras. +

+

+ ${summaryLine || 'Kvitto utan extraherade uppgifter'} +

+

Öppna agent-inkorgen och ladda upp en tydlig bild eller PDF av kvittot:

+

+ Öppna agent-inkorg +

+

+ Send in receipts. Utan källunderlag kan bokföringen inte slutföras enligt BFL 5 kap 7§. +

+` +} + +function buildText({ companyName, summaryLine, deepLink, senderName }: TemplateArgs): string { + return [ + 'Source documents required for receipt and transaction mapping.', + '', + `${senderName ? `${senderName} ` : ''}behöver ett kvittounderlag för ${companyName} innan en transaktion kan bokföras.`, + '', + summaryLine || 'Kvitto utan extraherade uppgifter', + '', + 'Öppna agent-inkorgen och ladda upp en tydlig bild eller PDF av kvittot:', + deepLink, + '', + 'Send in receipts. Utan källunderlag kan bokföringen inte slutföras enligt BFL 5 kap 7§.', + ].join('\n') +} diff --git a/app/api/ai/learning/remember/route.ts b/app/api/ai/learning/remember/route.ts new file mode 100644 index 00000000..b6051611 --- /dev/null +++ b/app/api/ai/learning/remember/route.ts @@ -0,0 +1,127 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { ensureInitialized } from '@/lib/init' +import { validateBody } from '@/lib/api/validate' +import { RememberLearningSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' +import { calculateConfidence } from '@/lib/bookkeeping/counterparty-templates' +import { gateAgentInbox } from '@/lib/ai/feature-flag' +import type { AIProposal } from '@/types' + +ensureInitialized() + +/** + * POST /api/ai/learning/remember + * + * Called from the UI's learning-prompt dialog after a user edited and + * accepted an AI booking proposal. Upserts a categorization_templates row + * with source='ai_corrected' so next time's proposal for the same + * counterparty starts from the user's preference. + * + * This is the ONLY path that creates an ai_corrected template — the + * "silent learning" rule means every template with this source represents + * an explicit user choice. + */ +export async function POST(request: Request) { + const gate = gateAgentInbox() + if (gate) return gate + + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + const validation = await validateBody(request, RememberLearningSchema) + if (!validation.success) return validation.response + const { + proposal_id, + counterparty_name, + debit_account, + credit_account, + vat_treatment, + category, + } = validation.data + + // Verify the proposal is accepted + belongs to this company. + const { data: proposal } = await supabase + .from('ai_proposals') + .select('*') + .eq('id', proposal_id) + .eq('company_id', companyId) + .maybeSingle() + + if (!proposal) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + const typed = proposal as AIProposal + if (typed.status !== 'accepted') { + return NextResponse.json( + { error: 'Endast accepterade förslag kan lagras som mall.' }, + { status: 400 } + ) + } + if (typed.step_type !== 'booking') { + return NextResponse.json( + { error: 'Endast bokföringssteget kan lagras som mall.' }, + { status: 400 } + ) + } + + // Upsert the template. Existing row for the same (user_id, counterparty_name) + // gets its source bumped up and occurrence incremented. + const { data: existing } = await supabase + .from('categorization_templates') + .select('*') + .eq('user_id', user.id) + .eq('counterparty_name', counterparty_name) + .maybeSingle() + + const today = new Date().toISOString().slice(0, 10) + + if (existing) { + const newOccurrence = existing.occurrence_count + 1 + await supabase + .from('categorization_templates') + .update({ + debit_account, + credit_account, + vat_treatment, + category, + source: 'ai_corrected', + occurrence_count: newOccurrence, + confidence: calculateConfidence(newOccurrence), + last_seen_date: today, + is_active: true, + }) + .eq('id', existing.id) + + return NextResponse.json({ data: { template_id: existing.id, updated: true } }) + } + + const { data: created, error: insertError } = await supabase + .from('categorization_templates') + .insert({ + user_id: user.id, + company_id: companyId, + counterparty_name, + counterparty_aliases: [counterparty_name], + debit_account, + credit_account, + vat_treatment, + category, + source: 'ai_corrected', + occurrence_count: 1, + confidence: calculateConfidence(1), + last_seen_date: today, + is_active: true, + }) + .select() + .single() + + if (insertError) return NextResponse.json({ error: insertError.message }, { status: 500 }) + + return NextResponse.json({ data: { template_id: created.id, updated: false } }) +} diff --git a/app/api/ai/proposals/[id]/accept/route.ts b/app/api/ai/proposals/[id]/accept/route.ts new file mode 100644 index 00000000..01d0c20e --- /dev/null +++ b/app/api/ai/proposals/[id]/accept/route.ts @@ -0,0 +1,248 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { eventBus } from '@/lib/events/bus' +import { ensureInitialized } from '@/lib/init' +import { validateBody } from '@/lib/api/validate' +import { AcceptProposalSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' +import { reValidateProposal } from '@/lib/ai/proposals/re-validate' +import { applyProposal } from '@/lib/ai/proposals/apply' +import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' +import { appendProcessingHistory } from '@/lib/processing-history/append' +import { gateAgentInbox } from '@/lib/ai/feature-flag' +import type { + AIProposal, + BookingProposalPayload, + InvoiceInboxItem, + MatchProposalPayload, +} from '@/types' + +ensureInitialized() + +/** + * POST /api/ai/proposals/[id]/accept + * + * Accept a pending proposal: + * 1. Optimistic lock on version to catch concurrent clicks. + * 2. Re-validate (period open, transaction still unbooked, accounts active). + * 3. Apply via lib/ai/proposals/apply.ts (engine call happens there). + * 4. Mark status='accepted', set applied_entry_id, bump version. + * 5. If `edits` provided and differ from proposal_json, record edit_diff + * and return a `learning_prompt` hint so the UI can ask + * "remember this booking for ?". + * 6. Emit ai_proposal.accepted so the orchestrator can chain match -> booking. + */ +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const gate = gateAgentInbox() + if (gate) return gate + + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + const { id } = await params + + const validation = await validateBody(request, AcceptProposalSchema) + if (!validation.success) return validation.response + const { version, edits } = validation.data + + // Fetch the proposal (also enforces company scope). + const { data: proposal, error: fetchError } = await supabase + .from('ai_proposals') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .maybeSingle() + + if (fetchError) return NextResponse.json({ error: fetchError.message }, { status: 500 }) + if (!proposal) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + + const typed = proposal as AIProposal + + if (typed.status !== 'pending') { + return NextResponse.json( + { error: 'Förslaget har redan hanterats.', status: typed.status }, + { status: 409 } + ) + } + + if (typed.version !== version) { + return NextResponse.json( + { error: 'Förslaget har ändrats av en annan användare — ladda om.' }, + { status: 409 } + ) + } + + // Re-validate current state — proposal might be stale. + const check = await reValidateProposal(supabase, companyId, typed) + if (!check.ok) { + // Mark invalidated so it drops out of the pending inbox. + await supabase + .from('ai_proposals') + .update({ + status: 'invalidated', + invalidated_reason: check.code, + }) + .eq('id', typed.id) + .eq('version', version) + return NextResponse.json( + { error: check.message, code: check.code, details: check.details ?? null }, + { status: 409 } + ) + } + + const inboxItem = check.inboxItem as InvoiceInboxItem + + // Merge edits into the original payload shape (edits are partial). + let editedPayload: MatchProposalPayload | BookingProposalPayload | undefined + if (edits) { + if (typed.step_type === 'match') { + const matchEdit = edits as { matched_transaction_id: string } + const original = typed.proposal_json as MatchProposalPayload + editedPayload = { + ...original, + matched_transaction_id: matchEdit.matched_transaction_id, + } + } else { + editedPayload = edits as BookingProposalPayload + } + } + + // Compute edit diff if edits were supplied and differ. + const editDiff = computeEditDiff(typed, editedPayload) + + // Apply (calls the engine for booking steps). + let outcome + try { + outcome = await applyProposal( + supabase, + companyId, + user.id, + typed, + inboxItem, + editedPayload + ) + } catch (err) { + const typedResp = bookkeepingErrorResponse(err) + if (typedResp) return typedResp + const message = err instanceof Error ? err.message : 'Kunde inte tillämpa förslaget' + return NextResponse.json({ error: message }, { status: 500 }) + } + + // Mark proposal accepted with CAS on version. + const appliedEntryId = + outcome.kind === 'booking_applied' ? outcome.journalEntry.id : null + + const { data: updatedRows, error: updateError } = await supabase + .from('ai_proposals') + .update({ + status: 'accepted', + accepted_at: new Date().toISOString(), + accepted_by_user_id: user.id, + version: typed.version + 1, + applied_entry_id: appliedEntryId, + edit_diff: editDiff, + }) + .eq('id', typed.id) + .eq('version', version) + .select() + + if (updateError || !updatedRows || updatedRows.length === 0) { + // The domain-level apply already happened; log loud but don't unwind + // (storno would be disproportionate for a race on a status bit). + console.error('[ai/accept] proposal status update failed after apply', updateError) + } + + const finalProposal = (updatedRows?.[0] as AIProposal | undefined) ?? typed + + // Audit trail. + try { + if (inboxItem.correlation_id) { + await appendProcessingHistory({ + companyId, + correlationId: inboxItem.correlation_id, + aggregateType: 'AIProposal', + aggregateId: typed.id, + eventType: 'AIProposalAccepted', + payload: { + proposal_id: typed.id, + step_type: typed.step_type, + edited: Boolean(editDiff), + applied_entry_id: appliedEntryId, + }, + actor: { type: 'user', id: user.id }, + occurredAt: new Date(), + }) + } + } catch (err) { + console.error('[ai/accept] Failed to append AIProposalAccepted:', err) + } + + // Emit event so orchestrator can chain match -> booking. + try { + await eventBus.emit({ + type: 'ai_proposal.accepted', + payload: { + proposal: finalProposal, + appliedEntry: outcome.kind === 'booking_applied' ? outcome.journalEntry : null, + userId: user.id, + companyId, + }, + }) + } catch (err) { + console.error('[ai/accept] Event emit failed:', err) + } + + return NextResponse.json({ + data: { + proposal: finalProposal, + applied_entry_id: appliedEntryId, + learning_prompt: + editDiff && typed.step_type === 'booking' && editedPayload + ? buildLearningPromptHint(editedPayload as BookingProposalPayload, typed) + : null, + }, + }) +} + +// ── helpers ───────────────────────────────────────────────────────── + +function computeEditDiff( + proposal: AIProposal, + edits: MatchProposalPayload | BookingProposalPayload | undefined +): Record | null { + if (!edits) return null + const before = proposal.proposal_json as unknown + const after = edits as unknown + if (JSON.stringify(before) === JSON.stringify(after)) return null + return { before, after } +} + +/** + * When the user edited a booking proposal, offer to save the corrected + * shape as a counterparty template so next time's proposal starts from + * the user's preference. + */ +function buildLearningPromptHint( + edits: BookingProposalPayload, + proposal: AIProposal +): { counterparty_name: string; debit_account: string; credit_account: string; vat_treatment: string | null } | null { + const tpl = edits.counterparty_template_proposal + if (!tpl) return null + return { + counterparty_name: tpl.counterparty_name, + debit_account: tpl.debit_account, + credit_account: tpl.credit_account, + vat_treatment: tpl.vat_treatment, + } + // proposal is in signature for future refinement (e.g. embed original accounts for diff UI) + void proposal +} diff --git a/app/api/ai/proposals/[id]/change-match/route.ts b/app/api/ai/proposals/[id]/change-match/route.ts new file mode 100644 index 00000000..4250bc16 --- /dev/null +++ b/app/api/ai/proposals/[id]/change-match/route.ts @@ -0,0 +1,177 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { ensureInitialized } from '@/lib/init' +import { validateBody } from '@/lib/api/validate' +import { ChangeMatchProposalSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' +import { appendProcessingHistory } from '@/lib/processing-history/append' +import { gateAgentInbox } from '@/lib/ai/feature-flag' +import type { AIProposal, InvoiceInboxItem, MatchProposalPayload } from '@/types' + +ensureInitialized() + +/** + * POST /api/ai/proposals/[id]/change-match + * + * Swap the matched_transaction_id on a pending match proposal without + * accepting it. Lets the user verify a different candidate (AI alternative, + * AI-regenerated, or manually picked) before hitting Godkänn. + * + * - Keeps status='pending' so the user still has to explicitly accept. + * - Bumps version (optimistic lock) and records edit_diff with before/after + + * source so we can later measure how often the AI's top pick gets overridden + * and by which merchant/path. + * - Sets confidence to 1.0 (user-picked transactions are certain). + */ +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const gate = gateAgentInbox() + if (gate) return gate + + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + const { id } = await params + + const validation = await validateBody(request, ChangeMatchProposalSchema) + if (!validation.success) return validation.response + const { version, matched_transaction_id, source } = validation.data + + const { data: proposalRow } = await supabase + .from('ai_proposals') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .maybeSingle() + + if (!proposalRow) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + + const proposal = proposalRow as AIProposal + + if (proposal.status !== 'pending') { + return NextResponse.json( + { error: 'Förslaget har redan hanterats.', status: proposal.status }, + { status: 409 } + ) + } + + if (proposal.step_type !== 'match') { + return NextResponse.json( + { error: 'Bara match-förslag kan byta transaktion.' }, + { status: 400 } + ) + } + + if (proposal.version !== version) { + return NextResponse.json( + { error: 'Förslaget har ändrats av en annan användare — ladda om.' }, + { status: 409 } + ) + } + + // Validate the new transaction: same company, uncategorized, no journal entry. + const { data: tx } = await supabase + .from('transactions') + .select('id, company_id, journal_entry_id') + .eq('id', matched_transaction_id) + .eq('company_id', companyId) + .maybeSingle() + + if (!tx) { + return NextResponse.json( + { error: 'Transaktionen hittades inte.' }, + { status: 404 } + ) + } + + if (tx.journal_entry_id) { + return NextResponse.json( + { error: 'Transaktionen är redan bokförd.' }, + { status: 409 } + ) + } + + const originalPayload = proposal.proposal_json as MatchProposalPayload + + // No-op? Return current state. + if (originalPayload.matched_transaction_id === matched_transaction_id) { + return NextResponse.json({ data: { proposal } }) + } + + const newPayload: MatchProposalPayload = { + ...originalPayload, + matched_transaction_id, + top_confidence: 1, + } + + const editDiff = { + before: originalPayload, + after: newPayload, + source, + changed_at: new Date().toISOString(), + changed_by_user_id: user.id, + } + + const { data: updatedRows, error: updateError } = await supabase + .from('ai_proposals') + .update({ + proposal_json: newPayload, + confidence: 1, + edit_diff: editDiff, + version: proposal.version + 1, + }) + .eq('id', proposal.id) + .eq('version', version) + .select() + + if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 }) + if (!updatedRows || updatedRows.length === 0) { + return NextResponse.json( + { error: 'Förslaget har ändrats av en annan användare — ladda om.' }, + { status: 409 } + ) + } + + const finalProposal = updatedRows[0] as AIProposal + + // Audit trail. + try { + const { data: inboxItem } = await supabase + .from('invoice_inbox_items') + .select('correlation_id') + .eq('id', proposal.subject_id) + .maybeSingle() + + const item = inboxItem as Pick | null + + if (item?.correlation_id) { + await appendProcessingHistory({ + companyId, + correlationId: item.correlation_id, + aggregateType: 'AIProposal', + aggregateId: proposal.id, + eventType: 'AIProposalMatchChanged', + payload: { + proposal_id: proposal.id, + from_transaction_id: originalPayload.matched_transaction_id, + to_transaction_id: matched_transaction_id, + source, + }, + actor: { type: 'user', id: user.id }, + occurredAt: new Date(), + }) + } + } catch (err) { + console.error('[ai/change-match] Failed to append AIProposalMatchChanged:', err) + } + + return NextResponse.json({ data: { proposal: finalProposal } }) +} diff --git a/app/api/ai/proposals/[id]/reject/route.ts b/app/api/ai/proposals/[id]/reject/route.ts new file mode 100644 index 00000000..3e8dfc50 --- /dev/null +++ b/app/api/ai/proposals/[id]/reject/route.ts @@ -0,0 +1,117 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { eventBus } from '@/lib/events/bus' +import { ensureInitialized } from '@/lib/init' +import { validateBody } from '@/lib/api/validate' +import { RejectProposalSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' +import { appendProcessingHistory } from '@/lib/processing-history/append' +import { gateAgentInbox } from '@/lib/ai/feature-flag' +import type { AIProposal, InvoiceInboxItem } from '@/types' + +ensureInitialized() + +/** + * POST /api/ai/proposals/[id]/reject + * + * Mark a pending proposal as rejected. The orchestrator will NOT chain the + * next step — the user has signalled the AI got this one wrong. Subsequent + * action (upload new doc, manually categorize, etc.) is up to the user. + */ +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const gate = gateAgentInbox() + if (gate) return gate + + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + const { id } = await params + + const validation = await validateBody(request, RejectProposalSchema) + if (!validation.success) return validation.response + const { version, reason } = validation.data + + const { data: proposal } = await supabase + .from('ai_proposals') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .maybeSingle() + + if (!proposal) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + + const typed = proposal as AIProposal + + if (typed.status !== 'pending') { + return NextResponse.json( + { error: 'Förslaget har redan hanterats.', status: typed.status }, + { status: 409 } + ) + } + + const { data: updatedRows, error: updateError } = await supabase + .from('ai_proposals') + .update({ + status: 'rejected', + rejected_at: new Date().toISOString(), + invalidated_reason: reason ?? null, + version: typed.version + 1, + }) + .eq('id', typed.id) + .eq('version', version) + .select() + + if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 }) + if (!updatedRows || updatedRows.length === 0) { + return NextResponse.json( + { error: 'Förslaget har ändrats av en annan användare — ladda om.' }, + { status: 409 } + ) + } + + const finalProposal = updatedRows[0] as AIProposal + + // Audit trail. + try { + const { data: inboxItem } = await supabase + .from('invoice_inbox_items') + .select('correlation_id') + .eq('id', typed.subject_id) + .maybeSingle() + + const item = inboxItem as Pick | null + + if (item?.correlation_id) { + await appendProcessingHistory({ + companyId, + correlationId: item.correlation_id, + aggregateType: 'AIProposal', + aggregateId: typed.id, + eventType: 'AIProposalRejected', + payload: { proposal_id: typed.id, step_type: typed.step_type, reason: reason ?? null }, + actor: { type: 'user', id: user.id }, + occurredAt: new Date(), + }) + } + } catch (err) { + console.error('[ai/reject] Failed to append AIProposalRejected:', err) + } + + try { + await eventBus.emit({ + type: 'ai_proposal.rejected', + payload: { proposal: finalProposal, userId: user.id, companyId }, + }) + } catch { /* non-blocking */ } + + return NextResponse.json({ data: { proposal: finalProposal } }) +} diff --git a/app/api/ai/proposals/[id]/route.ts b/app/api/ai/proposals/[id]/route.ts new file mode 100644 index 00000000..0e2924db --- /dev/null +++ b/app/api/ai/proposals/[id]/route.ts @@ -0,0 +1,79 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' +import { gateAgentInbox } from '@/lib/ai/feature-flag' + +ensureInitialized() + +/** + * GET /api/ai/proposals/[id] + * + * Returns the full proposal row plus the linked inbox item and, when + * applicable, the matched transaction and already-applied journal entry. + */ +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const gate = gateAgentInbox() + if (gate) return gate + + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const companyId = await requireCompanyId(supabase, user.id) + const { id } = await params + + const { data: proposal, error } = await supabase + .from('ai_proposals') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .maybeSingle() + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + if (!proposal) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + + // Load the inbox item (only subject_type in v1) for context. + const { data: inboxItem } = await supabase + .from('invoice_inbox_items') + .select('*, document:document_attachments!document_id(*)') + .eq('id', proposal.subject_id) + .eq('company_id', companyId) + .maybeSingle() + + // Load the matched transaction if the inbox item has one. + let transaction = null + if (inboxItem?.matched_transaction_id) { + const { data: tx } = await supabase + .from('transactions') + .select('*') + .eq('id', inboxItem.matched_transaction_id) + .eq('company_id', companyId) + .maybeSingle() + transaction = tx + } + + // For accepted booking proposals, fetch the applied journal entry. + let journalEntry = null + if (proposal.applied_entry_id) { + const { data: entry } = await supabase + .from('journal_entries') + .select('*, lines:journal_entry_lines(*)') + .eq('id', proposal.applied_entry_id) + .eq('company_id', companyId) + .maybeSingle() + journalEntry = entry + } + + return NextResponse.json({ + data: { + proposal, + inbox_item: inboxItem ?? null, + transaction, + journal_entry: journalEntry, + }, + }) +} diff --git a/app/api/ai/proposals/batch-accept/route.ts b/app/api/ai/proposals/batch-accept/route.ts new file mode 100644 index 00000000..f08485ec --- /dev/null +++ b/app/api/ai/proposals/batch-accept/route.ts @@ -0,0 +1,142 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { eventBus } from '@/lib/events/bus' +import { ensureInitialized } from '@/lib/init' +import { validateBody } from '@/lib/api/validate' +import { BatchAcceptSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' +import { reValidateProposal } from '@/lib/ai/proposals/re-validate' +import { applyProposal } from '@/lib/ai/proposals/apply' +import { gateAgentInbox } from '@/lib/ai/feature-flag' +import type { AIProposal, InvoiceInboxItem } from '@/types' + +ensureInitialized() + +interface BatchOutcomePerProposal { + proposal_id: string + ok: boolean + error?: string + code?: string + applied_entry_id?: string | null +} + +/** + * POST /api/ai/proposals/batch-accept + * + * Accept multiple pending proposals in one click. Best-effort: each item is + * independently re-validated and applied. The response contains per-item + * outcomes so the UI can show checkmarks + specific failure messages + * (e.g., "fiscal period closed since you loaded the page"). + * + * No edits are supported in batch mode — edits require the user to open the + * individual proposal and approve from there. + */ +export async function POST(request: Request) { + const gate = gateAgentInbox() + if (gate) return gate + + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + const validation = await validateBody(request, BatchAcceptSchema) + if (!validation.success) return validation.response + const { proposal_ids } = validation.data + + const outcomes: BatchOutcomePerProposal[] = [] + + for (const proposalId of proposal_ids) { + const outcome = await acceptOne(supabase, companyId, user.id, proposalId) + outcomes.push(outcome) + } + + return NextResponse.json({ + data: { + outcomes, + accepted: outcomes.filter((o) => o.ok).length, + failed: outcomes.filter((o) => !o.ok).length, + }, + }) +} + +async function acceptOne( + supabase: Awaited>, + companyId: string, + userId: string, + proposalId: string +): Promise { + const { data: proposal } = await supabase + .from('ai_proposals') + .select('*') + .eq('id', proposalId) + .eq('company_id', companyId) + .maybeSingle() + + if (!proposal) return { proposal_id: proposalId, ok: false, error: 'Not found', code: 'not_found' } + + const typed = proposal as AIProposal + if (typed.status !== 'pending') { + return { proposal_id: proposalId, ok: false, error: `Already ${typed.status}`, code: 'not_pending' } + } + + const check = await reValidateProposal(supabase, companyId, typed) + if (!check.ok) { + await supabase + .from('ai_proposals') + .update({ status: 'invalidated', invalidated_reason: check.code }) + .eq('id', typed.id) + .eq('version', typed.version) + return { proposal_id: proposalId, ok: false, error: check.message, code: check.code } + } + + const inboxItem = check.inboxItem as InvoiceInboxItem + + let outcome + try { + outcome = await applyProposal(supabase, companyId, userId, typed, inboxItem) + } catch (err) { + return { + proposal_id: proposalId, + ok: false, + error: err instanceof Error ? err.message : 'apply_failed', + code: 'apply_failed', + } + } + + const appliedEntryId = + outcome.kind === 'booking_applied' ? outcome.journalEntry.id : null + + const { data: updated } = await supabase + .from('ai_proposals') + .update({ + status: 'accepted', + accepted_at: new Date().toISOString(), + accepted_by_user_id: userId, + version: typed.version + 1, + applied_entry_id: appliedEntryId, + }) + .eq('id', typed.id) + .eq('version', typed.version) + .select() + .maybeSingle() + + try { + await eventBus.emit({ + type: 'ai_proposal.accepted', + payload: { + proposal: (updated as AIProposal | null) ?? typed, + appliedEntry: outcome.kind === 'booking_applied' ? outcome.journalEntry : null, + userId, + companyId, + }, + }) + } catch { /* non-blocking */ } + + return { proposal_id: proposalId, ok: true, applied_entry_id: appliedEntryId } +} diff --git a/app/api/ai/proposals/route.ts b/app/api/ai/proposals/route.ts new file mode 100644 index 00000000..8cf0112f --- /dev/null +++ b/app/api/ai/proposals/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { ensureInitialized } from '@/lib/init' +import { validateQuery } from '@/lib/api/validate' +import { ListProposalsQuerySchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' +import { gateAgentInbox } from '@/lib/ai/feature-flag' + +ensureInitialized() + +/** + * GET /api/ai/proposals + * + * List AI proposals for the active company, newest first. + * Query params: + * status?: pending | accepted | rejected | skipped | invalidated + * step_type?: match | booking + * limit?: default 20, max 100 + * offset?: default 0 + * + * Returns { data: AIProposal[], count: number }. + */ +export async function GET(request: Request) { + const gate = gateAgentInbox() + if (gate) return gate + + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const companyId = await requireCompanyId(supabase, user.id) + + const qs = validateQuery(request, ListProposalsQuerySchema) + if (!qs.success) return qs.response + const { status, step_type, limit, offset } = qs.data + + let query = supabase + .from('ai_proposals') + .select('*', { count: 'exact' }) + .eq('company_id', companyId) + .order('created_at', { ascending: false }) + .range(offset, offset + limit - 1) + + if (status) query = query.eq('status', status) + if (step_type) query = query.eq('step_type', step_type) + + const { data, error, count } = await query + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data, count: count ?? data?.length ?? 0 }) +} diff --git a/app/api/ai/requests/[id]/resolve/route.ts b/app/api/ai/requests/[id]/resolve/route.ts new file mode 100644 index 00000000..4fa1331f --- /dev/null +++ b/app/api/ai/requests/[id]/resolve/route.ts @@ -0,0 +1,106 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { ensureInitialized } from '@/lib/init' +import { validateBody } from '@/lib/api/validate' +import { ResolveRequestSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' +import { appendProcessingHistory } from '@/lib/processing-history/append' +import { gateAgentInbox } from '@/lib/ai/feature-flag' +import type { AIRequest, InvoiceInboxItem } from '@/types' + +ensureInitialized() + +/** + * POST /api/ai/requests/[id]/resolve + * + * Mark an open ai_request as resolved. The response body is stored on the + * row for audit, but the actual follow-up action (re-upload doc, pick a + * transaction manually, set a VAT rate) is wired through the existing + * domain endpoints — the UI calls those separately. This endpoint just + * closes out the request card. + */ +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const gate = gateAgentInbox() + if (gate) return gate + + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + const { id } = await params + + const validation = await validateBody(request, ResolveRequestSchema) + if (!validation.success) return validation.response + const { response } = validation.data + + const { data: req } = await supabase + .from('ai_requests') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .maybeSingle() + + if (!req) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + const typed = req as AIRequest + if (typed.status !== 'open') { + return NextResponse.json( + { error: 'Begäran är redan hanterad.', status: typed.status }, + { status: 409 } + ) + } + + const { data: updated, error: updateError } = await supabase + .from('ai_requests') + .update({ + status: 'resolved', + resolved_at: new Date().toISOString(), + resolved_by_user_id: user.id, + response_json: response ?? null, + }) + .eq('id', typed.id) + .eq('status', 'open') + .select() + .maybeSingle() + + if (updateError || !updated) { + return NextResponse.json({ error: 'Kunde inte uppdatera' }, { status: 500 }) + } + + // Audit. + try { + const { data: inbox } = await supabase + .from('invoice_inbox_items') + .select('correlation_id') + .eq('id', typed.subject_id) + .maybeSingle() + const item = inbox as Pick | null + if (item?.correlation_id) { + await appendProcessingHistory({ + companyId, + correlationId: item.correlation_id, + aggregateType: 'AIRequest', + aggregateId: typed.id, + eventType: 'AIRequestResolved', + payload: { + request_id: typed.id, + request_type: typed.request_type, + has_response: Boolean(response), + }, + actor: { type: 'user', id: user.id }, + occurredAt: new Date(), + }) + } + } catch (err) { + console.error('[ai/requests/resolve] Failed to append AIRequestResolved:', err) + } + + return NextResponse.json({ data: { request: updated } }) +} diff --git a/app/api/transactions/uncategorized/route.ts b/app/api/transactions/uncategorized/route.ts new file mode 100644 index 00000000..f4d8620a --- /dev/null +++ b/app/api/transactions/uncategorized/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' +import { gateAgentInbox } from '@/lib/ai/feature-flag' + +ensureInitialized() + +/** + * GET /api/transactions/uncategorized + * + * Paginated list of uncategorized expense transactions for a picker UI + * (e.g. agent-inkorg's "Byt transaktion" flow). Returns expenses only — + * amount < 0 — since match proposals always pair receipts to outgoing + * payments. Includes basic range filters so callers can narrow to matches + * within ±window of a target amount/date. + * + * Query params: + * search Free-text against description/merchant_name (ILIKE) + * amount_center Target amount (signed). Must be accompanied by amount_window. + * amount_window Half-window in SEK — e.g. 50 means amount_center ± 50. + * date_center Target ISO date. Must be accompanied by date_window. + * date_window Half-window in days — e.g. 30 means ±30 days. + * limit Max rows (1-50, default 20). + * offset Row offset for pagination (default 0). + */ +export async function GET(request: Request) { + const gate = gateAgentInbox() + if (gate) return gate + + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const companyId = await requireCompanyId(supabase, user.id) + const url = new URL(request.url) + + const search = url.searchParams.get('search')?.trim() ?? '' + const amountCenterRaw = url.searchParams.get('amount_center') + const amountWindowRaw = url.searchParams.get('amount_window') + const dateCenterRaw = url.searchParams.get('date_center') + const dateWindowRaw = url.searchParams.get('date_window') + const limit = Math.min(Math.max(1, Number(url.searchParams.get('limit')) || 20), 50) + const offset = Math.max(0, Number(url.searchParams.get('offset')) || 0) + + let query = supabase + .from('transactions') + .select('id, date, description, amount, currency, merchant_name, category, is_business', { count: 'exact' }) + .eq('company_id', companyId) + .is('journal_entry_id', null) + .lt('amount', 0) + .order('date', { ascending: false }) + .range(offset, offset + limit - 1) + + if (search.length > 0) { + const escaped = search.replace(/[%_]/g, '\\$&') + query = query.or(`description.ilike.%${escaped}%,merchant_name.ilike.%${escaped}%`) + } + + if (amountCenterRaw && amountWindowRaw) { + const center = Number(amountCenterRaw) + const window = Math.abs(Number(amountWindowRaw)) + if (Number.isFinite(center) && Number.isFinite(window) && window > 0) { + query = query.gte('amount', center - window).lte('amount', center + window) + } + } + + if (dateCenterRaw && dateWindowRaw) { + const windowDays = Math.abs(Number(dateWindowRaw)) + if (Number.isFinite(windowDays) && windowDays > 0) { + const center = new Date(dateCenterRaw) + if (!Number.isNaN(center.getTime())) { + const msPerDay = 86_400_000 + const from = new Date(center.getTime() - windowDays * msPerDay) + const to = new Date(center.getTime() + windowDays * msPerDay) + query = query.gte('date', from.toISOString().slice(0, 10)) + query = query.lte('date', to.toISOString().slice(0, 10)) + } + } + } + + const { data, error, count } = await query + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + return NextResponse.json({ + data: { + transactions: data ?? [], + count: count ?? 0, + limit, + offset, + }, + }) +} diff --git a/components/agent-inbox/AgentInbox.tsx b/components/agent-inbox/AgentInbox.tsx new file mode 100644 index 00000000..a9e41a67 --- /dev/null +++ b/components/agent-inbox/AgentInbox.tsx @@ -0,0 +1,562 @@ +'use client' + +import { useState, useMemo, useCallback, useEffect, useRef } from 'react' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { useToast } from '@/components/ui/use-toast' +import { PageHeader } from '@/components/ui/page-header' +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Progress } from '@/components/ui/progress' +import { Sparkles, PlayCircle, XCircle, Loader2 } from 'lucide-react' +import ProposalCard from './ProposalCard' +import RequestCard from './RequestCard' +import EditBookingDialog from './EditBookingDialog' +import LearningPromptDialog from './LearningPromptDialog' +import ChangeTransactionDialog from './ChangeTransactionDialog' +import type { AgentInboxItemView } from '@/app/(dashboard)/agent-inbox/page' +import type { AIProposal, BookingProposalPayload } from '@/types' + +type FilterKey = 'all' | 'match' | 'booking' + +// The match step encompasses two UI states: actual match proposals waiting +// for approval AND ai_requests where Claude couldn't find a candidate and +// asked the user to pick manually. Both belong under the "Matchning" tab. +function filterKeyFor(item: AgentInboxItemView): Exclude { + 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>(new Set()) + const [busyProposalId, setBusyProposalId] = useState(null) + const [editProposal, setEditProposal] = useState(null) + const [changeMatchItem, setChangeMatchItem] = useState(null) + const [learningPrompt, setLearningPrompt] = useState(null) + const [backfillRunning, setBackfillRunning] = useState(false) + const [batchRunning, setBatchRunning] = useState(false) + const [filter, setFilter] = useState('all') + const [backfillProgress, setBackfillProgress] = useState<{ + target: number + startPending: number + currentPending: number + } | null>(null) + const pollRef = useRef | 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( + (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 => { + 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 ( +
+ + {!backfillRunning ? ( + + ) : ( + + )} +
+ } + /> + + {backfillProgress && ( + + )} + + {items.length > 0 && ( + setFilter(v as FilterKey)} className="mb-4"> + + Allt ({counts.all}) + Matchning ({counts.match}) + Bokföring ({counts.booking}) + + + )} + + {items.length === 0 ? ( + + +
+ +
+

Inga väntande förslag

+

+ När nya kvitton klassas i inkorgen kommer AI-förslagen att visas här. +

+
+
+ ) : ( + <> + {selectableProposalIds.length > 1 && ( +
+ + {selectedIds.size > 0 && ( + + )} +
+ )} + +
+ {filteredItems.length === 0 && ( + + + Inga kort i denna vy. + + + )} + {filteredItems.map((item) => { + if (item.proposal) { + return ( + toggleSelect(item.proposal!.id)} + onAccept={() => handleAccept(item.proposal!)} + onReject={() => handleReject(item.proposal!)} + onEdit={() => setEditProposal(item.proposal!)} + onChangeMatch={() => setChangeMatchItem(item)} + /> + ) + } + if (item.request) { + return ( + removeItem(null, item.request!.id)} + /> + ) + } + return null + })} +
+ + {selectedIds.size > 0 && ( +
+ {selectedIds.size} valda + +
+ )} + + )} + + {editProposal && ( + setEditProposal(null)} + onSubmit={async (edits) => { + const proposal = editProposal + setEditProposal(null) + await handleAccept(proposal, edits) + }} + /> + )} + + {changeMatchItem?.proposal && ( + { 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 && ( + setLearningPrompt(null)} + /> + )} + + ) +} + +// 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 ( + + +
+
+ + Bearbetar befintliga kvitton +
+
+ + {capped} av {progress.target} + + +
+
+ +

+ AI-agenten skapar förslag ett kvitto i taget. Nya kort dyker upp här automatiskt. +

+
+
+ ) +} diff --git a/components/agent-inbox/ChangeTransactionDialog.tsx b/components/agent-inbox/ChangeTransactionDialog.tsx new file mode 100644 index 00000000..e182a29c --- /dev/null +++ b/components/agent-inbox/ChangeTransactionDialog.tsx @@ -0,0 +1,378 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import { Loader2, Search, Sparkles, Check } from 'lucide-react' +import { formatCurrency, formatDate, cn } from '@/lib/utils' +import type { AIProposal, MatchProposalPayload } from '@/types' + +type ChangeSource = 'user_alternative' | 'user_manual' | 'ai_regenerated' + +interface ChangeTransactionDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + proposal: AIProposal + receiptTotal: number | null + receiptDate: string | null + onChanged: () => void +} + +interface PickerTx { + id: string + date: string + description: string | null + amount: number + currency: string | null + merchant_name: string | null +} + +export default function ChangeTransactionDialog({ + open, + onOpenChange, + proposal, + receiptTotal, + receiptDate, + onChanged, +}: ChangeTransactionDialogProps) { + const payload = proposal.proposal_json as MatchProposalPayload + const alternatives = payload.alternatives ?? [] + + const [selected, setSelected] = useState<{ id: string; source: ChangeSource } | null>(null) + const [saving, setSaving] = useState(false) + const [showAll, setShowAll] = useState(alternatives.length === 0) + const [search, setSearch] = useState('') + const [allTx, setAllTx] = useState([]) + const [alternativeTx, setAlternativeTx] = useState>({}) + const [loadingList, setLoadingList] = useState(false) + const [error, setError] = useState(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 = {} + 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 ( + + + + Byt transaktion + + Välj en annan transaktion att koppla kvittot till. Du kan välja bland AI:ns + alternativ eller söka i alla okategoriserade transaktioner. + + + +
+ {/* AI alternatives */} + {alternatives.length > 0 && ( +
+

+ + AI:ns alternativ ({alternatives.length}) +

+
+ {alternativesWithContext.map((alt) => ( + + setSelected({ id: alt.transaction_id, source: 'user_alternative' }) + } + /> + ))} +
+
+ )} + + {/* Toggle manual picker */} + {alternatives.length > 0 && !showAll && ( + + )} + + {/* Manual picker */} + {showAll && ( +
+

+ Alla okategoriserade transaktioner +

+
+ + setSearch(e.target.value)} + className="pl-8" + /> +
+ {receiptTotal && ( +

+ Filtrerat på belopp runt {formatCurrency(-Math.abs(receiptTotal), 'SEK')} och datum runt{' '} + {receiptDate ? formatDate(receiptDate) : '—'}. Rensa sökrutan för att se fler. +

+ )} + {loadingList ? ( +
+ +
+ ) : allTx.length === 0 ? ( +

+ Inga matchande transaktioner +

+ ) : ( +
+ {allTx.map((tx) => ( + setSelected({ id: tx.id, source: 'user_manual' })} + /> + ))} +
+ )} +
+ )} +
+ + {error && ( +

{error}

+ )} + + + + + +
+
+ ) +} + +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 ( + + ) +} + +function PickerRow({ + tx, + isCurrent, + isSelected, + onSelect, +}: { + tx: PickerTx + isCurrent: boolean + isSelected: boolean + onSelect: () => void +}) { + return ( + + ) +} + diff --git a/components/agent-inbox/EditBookingDialog.tsx b/components/agent-inbox/EditBookingDialog.tsx new file mode 100644 index 00000000..0fd63ded --- /dev/null +++ b/components/agent-inbox/EditBookingDialog.tsx @@ -0,0 +1,155 @@ +'use client' + +import { useState } from 'react' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import type { AIProposal, BookingProposalLine, BookingProposalPayload } from '@/types' + +interface EditBookingDialogProps { + proposal: AIProposal + onClose: () => void + onSubmit: (edits: BookingProposalPayload) => Promise +} + +/** + * 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(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) => { + 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 ( + !open && onClose()}> + + + Redigera bokföringsförslag + + +
+
+ + setDescription(e.target.value)} + className="mt-1" + /> +
+ +
+ +
+ + + + + + + + + + + {lines.map((line, i) => ( + + + + + + + ))} + + + + + + + + +
KontoBeskrivningDebetKredit
+ updateLine(i, { account_number: e.target.value })} + className="h-8 font-mono w-20" + maxLength={4} + /> + + updateLine(i, { description: e.target.value })} + className="h-8" + /> + + + updateLine(i, { debit_amount: parseFloat(e.target.value) || 0 }) + } + className="h-8 text-right tabular-nums w-24 ml-auto" + /> + + + updateLine(i, { credit_amount: parseFloat(e.target.value) || 0 }) + } + className="h-8 text-right tabular-nums w-24 ml-auto" + /> +
+ Summa + {totalDebit.toFixed(2)}{totalCredit.toFixed(2)}
+
+ {!balanced && ( +

+ Debet och kredit måste summera till samma belopp. +

+ )} +
+ +
+ + +
+
+
+
+ ) +} diff --git a/components/agent-inbox/LearningPromptDialog.tsx b/components/agent-inbox/LearningPromptDialog.tsx new file mode 100644 index 00000000..db59d96c --- /dev/null +++ b/components/agent-inbox/LearningPromptDialog.tsx @@ -0,0 +1,43 @@ +'use client' + +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' + +interface LearningPromptDialogProps { + counterpartyName: string + debitAccount: string + creditAccount: string + onYes: () => void + onNo: () => void +} + +export default function LearningPromptDialog({ + counterpartyName, + debitAccount, + creditAccount, + onYes, + onNo, +}: LearningPromptDialogProps) { + return ( + !open && onNo()}> + + + Kom ihåg denna bokföring? + +

+ Vill du att AI:n använder samma kontering nästa gång ett kvitto från{' '} + {counterpartyName} dyker upp? +

+

+ Debet {debitAccount} · Kredit {creditAccount} +

+
+ + +
+
+
+ ) +} diff --git a/components/agent-inbox/ProposalCard.tsx b/components/agent-inbox/ProposalCard.tsx new file mode 100644 index 00000000..6021448e --- /dev/null +++ b/components/agent-inbox/ProposalCard.tsx @@ -0,0 +1,346 @@ +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Checkbox } from '@/components/ui/checkbox' +import { Receipt as ReceiptIcon, Landmark } from 'lucide-react' +import { formatCurrency, formatDate, cn } from '@/lib/utils' +import type { AgentInboxItemView } from '@/app/(dashboard)/agent-inbox/page' +import type { BookingProposalPayload, MatchProposalPayload } from '@/types' +import ReceiptDetailDialog from './ReceiptDetailDialog' +import TransactionDetailDialog from './TransactionDetailDialog' +import { assessReceiptQuality } from './receipt-quality' + +interface ProposalCardProps { + item: AgentInboxItemView + isSelected: boolean + isBusy: boolean + onToggleSelect: () => void + onAccept: () => void + onReject: () => void + onEdit: () => void + onChangeMatch?: () => void +} + +function confidenceLabel(c: number | null): string { + if (c === null) return 'Ingen säkerhet' + const pct = Math.round(c * 100) + return `${pct}% säkerhet` +} + +function confidenceColor(c: number | null): string { + if (c === null) return 'bg-muted' + if (c >= 0.9) return 'bg-success/15 text-success-foreground' + if (c >= 0.6) return 'bg-warning/15 text-warning-foreground' + return 'bg-destructive/15 text-destructive-foreground' +} + +export default function ProposalCard({ + item, + isSelected, + isBusy, + onToggleSelect, + onAccept, + onReject, + onEdit, + onChangeMatch, +}: ProposalCardProps) { + const proposal = item.proposal! + const inbox = item.inbox_item + const tx = item.transaction + const isMatch = proposal.step_type === 'match' + const isUserEdited = Boolean(proposal.edit_diff) + // BFL compliance: can't book without a source document. Block match-accept + // when no receipt file is attached — the server-side validator enforces this + // too, but disabling the button client-side avoids a round-trip error. + const receiptMissing = isMatch && !inbox.document_id + + const matchPayload = isMatch ? (proposal.proposal_json as MatchProposalPayload) : null + const bookingPayload = !isMatch ? (proposal.proposal_json as BookingProposalPayload) : null + + return ( + + +
+
+ +
+ +
+
+ {isMatch ? 'Match' : 'Bokföring'} + + {confidenceLabel(proposal.confidence)} + + {isUserEdited && ( + + Ändrad av användare + + )} + {inbox.document && ( + + {inbox.document.file_name} + + )} +
+ + {isMatch && matchPayload && ( + + )} + + {!isMatch && bookingPayload && ( + + )} + + {receiptMissing && ( +

+ + Kvittobild saknas — ladda upp i kvittodialogen innan du kan godkänna. +

+ )} + +
+ + {isMatch && onChangeMatch && ( + + )} + {!isMatch && ( + + )} + +
+
+
+
+
+ ) +} + +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 ( +
+
+ + {proposedTx ? ( + + ) : ( +
+ Föreslagen transaktion: {payload.matched_transaction_id} +
+ )} +
+ +
+ ) +} + +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 ( +
+ + {open && ( +
+ {reasoning && ( +

“{reasoning}”

+ )} + {hasAlternatives && ( +
    + {alternatives!.map((alt) => ( +
  • + {Math.round(alt.confidence * 100)}% — {alt.reasoning} +
  • + ))} +
+ )} +
+ )} +
+ ) +} + +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 ( + <> + + + + ) +} + +function TransactionBox({ + tx, +}: { + tx: NonNullable +}) { + const [open, setOpen] = useState(false) + return ( + <> + + + + ) +} + +function BookingProposalBody({ + payload, + reasoning, +}: { + payload: BookingProposalPayload + reasoning: string | null +}) { + const totalDebit = payload.lines.reduce((s, l) => s + l.debit_amount, 0) + return ( +
+
+ + + + + + + + + + {payload.lines.map((line, i) => ( + + + + + + ))} + + + + + + + + +
KontoDebetKredit
+ {line.account_number}{' '} + {line.description} + + {line.debit_amount > 0 ? line.debit_amount.toFixed(2) : ''} + + {line.credit_amount > 0 ? line.credit_amount.toFixed(2) : ''} +
+ {payload.vat_treatment && Moms: {payload.vat_treatment}} + {payload.default_private && Privat uttag} + {totalDebit.toFixed(2)}{totalDebit.toFixed(2)}
+
+ +
+ ) +} diff --git a/components/agent-inbox/ReceiptDetailDialog.tsx b/components/agent-inbox/ReceiptDetailDialog.tsx new file mode 100644 index 00000000..d34672ec --- /dev/null +++ b/components/agent-inbox/ReceiptDetailDialog.tsx @@ -0,0 +1,408 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { useRouter } from 'next/navigation' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { useToast } from '@/components/ui/use-toast' +import { Loader2, ExternalLink, FileText, Upload, ImagePlus, MailPlus } from 'lucide-react' +import { formatCurrency, formatDate } from '@/lib/utils' +import type { AgentInboxItemView } from '@/app/(dashboard)/agent-inbox/page' +import { assessReceiptQuality } from './receipt-quality' + +// Mirrors ReceiptExtractionResult, kept local + forgiving since legacy rows +// may be sparse. +interface ExtractedReceipt { + merchant?: { + name?: string | null + orgNumber?: string | null + vatNumber?: string | null + isForeign?: boolean + } | null + receipt?: { + date?: string | null + time?: string | null + currency?: string | null + } | null + totals?: { + subtotal?: number | null + vatAmount?: number | null + total?: number | null + } | null + lineItems?: Array<{ + description?: string + quantity?: number + unitPrice?: number | null + lineTotal?: number + vatRate?: number | null + }> | null + flags?: { + isRestaurant?: boolean + isSystembolaget?: boolean + isForeignMerchant?: boolean + } | null +} + +interface Props { + open: boolean + onOpenChange: (open: boolean) => void + inbox: AgentInboxItemView['inbox_item'] +} + +export default function ReceiptDetailDialog({ open, onOpenChange, inbox }: Props) { + const data = (inbox.extracted_data as ExtractedReceipt | null) ?? {} + const merchant = data.merchant?.name ?? inbox.document?.file_name ?? 'Okänt kvitto' + const currency = data.receipt?.currency ?? 'SEK' + const lineItems = data.lineItems ?? [] + const flags = data.flags ?? {} + + // Lazily fetch a signed download URL so we can preview the file. + const [downloadUrl, setDownloadUrl] = useState(null) + const [loadingUrl, setLoadingUrl] = useState(false) + const [uploading, setUploading] = useState(false) + const [uploadError, setUploadError] = useState(null) + const [requesting, setRequesting] = useState(false) + const fileInputRef = useRef(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 ( + + + + + {merchant} + + + {data.receipt?.date ? formatDate(data.receipt.date) : 'Okänt datum'} + {data.receipt?.time && ` · ${data.receipt.time}`} + {data.totals?.total != null && ( + · {formatCurrency(data.totals.total, currency)} + )} + + + +
+ {/* File preview */} +
+ {!inbox.document ? ( +
+
+ +
+
+

Ingen kvittobild

+

+ Utan bildbevis kan bokföringen inte verifieras. Ladda upp kvittot (PDF, JPG, PNG, WebP — max 15 MB). +

+
+ { + const f = e.target.files?.[0] + if (f) handleFilePicked(f) + e.target.value = '' + }} + /> +
+ + +
+ {uploadError && ( +

{uploadError}

+ )} +
+ ) : loadingUrl ? ( + + ) : !downloadUrl ? ( +
+ Kunde inte ladda filen +
+ ) : isImage ? ( + // eslint-disable-next-line @next/next/no-img-element + {inbox.document.file_name} + ) : isPdf ? ( +