'use client' import { useEffect, useState } from 'react' import { useTranslations } from 'next-intl' import { useDocumentExtraction } from '@/lib/hooks/use-document-extraction' import ExtractionStatus from '@/components/ui/extraction-status' import { motion } from 'framer-motion' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { DataListRow, DataListPrimary, DataListMeta, DataListMetaSeparator, } from '@/components/ui/data-list' import { cn, formatCurrency, formatDate } from '@/lib/utils' import { isImportedTransaction } from '@/lib/transactions/origin' import { AlertCircle, ArrowUpRight, ArrowDownRight, EyeOff, FileSearch, FileText, Link2, Loader2, MessageCircle, MoreHorizontal, Paperclip, Pencil, Split, Trash2, } from 'lucide-react' import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, } from '@/components/ui/dropdown-menu' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' // True when the AI tier is active — gates user-facing strings that promise // AI behavior. On the free build (document-extraction disabled) we keep the // upload functional but drop the "AI:n läser dokumentet" promise. const HAS_AI_EXTRACTION = ENABLED_EXTENSION_IDS.has('document-extraction') import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator' import { useCanWrite } from '@/lib/hooks/use-can-write' import { useAgentSheet } from '@/components/agent/AgentSheetProvider' import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types' interface TransactionInboxCardProps { transaction: TransactionWithInvoice /** When set, this bank tx looks like the bank side of a 1930↔1630 * transfer that the user will later see on /skattekonto. */ skvCounterpartDate?: string processingId: string | null isBatchMode: boolean isSelected: boolean entityType?: string onCategorize: CategorizeHandler /** Confirm an auto-detected invoice match (1-click shortcut). */ onOpenMatchDialog: (transaction: TransactionWithInvoice) => void /** Open the manual picker — routes to customer or supplier picker by amount sign. */ onOpenMatchInvoicePicker: (transaction: TransactionWithInvoice) => void /** Open the split-payment allocator (1 tx → N invoices) — same direction * detection as the single-pick picker. Optional so legacy callers stay * source-compatible. */ onOpenSplitMatch?: (transaction: TransactionWithInvoice) => void /** Open the existing-verifikat matcher — link the bank tx to an already-booked * voucher (salary, Fortnox import, manual entry) with no new bokföring. */ onOpenMatchVoucher?: (transaction: TransactionWithInvoice) => void /** Open the attach-underlag dialog — pin an inbox document or a fresh upload * to the transaction (the tx→doc mirror of the Documents view's matcher). */ onOpenAttachDocument?: (transaction: TransactionWithInvoice) => void onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void onDelete?: (id: string) => void /** Mark the transaction as ignored so it leaves the inbox without a journal entry. */ onIgnore?: (transaction: TransactionWithInvoice) => void /** Open the edit-title dialog. Only wired for editable (unbooked/unmatched) rows. */ onEditTitle?: (transaction: TransactionWithInvoice) => void onToggleSelect: (id: string) => void onAnimationComplete?: (id: string) => void } export default function TransactionInboxCard({ transaction, skvCounterpartDate, processingId, isBatchMode, isSelected, onOpenMatchDialog, onOpenMatchInvoicePicker, onOpenSplitMatch, onOpenMatchVoucher, onOpenAttachDocument, onOpenCategoryDialog, onDelete, onIgnore, onEditTitle, onToggleSelect, onAnimationComplete, }: TransactionInboxCardProps) { const t = useTranslations('tx_inbox_card') // Attaching underlag is a write — hide the affordance from viewers so they // don't dead-end on a 403 (mirrors the gate in TransactionHistoryList). const { canWrite } = useCanWrite() // The transaction-side entry point to the assistant ("Lena"). openAgentSheet // hands this specific bank line to the transaction.categorization intent — // the mirror of "Fråga assistenten" in Dokumentinkorgen, so the user can // start a booking with the agent from the inbox they actually live in. const { openAgentSheet, identity } = useAgentSheet() const isProcessing = processingId === transaction.id const isDisabled = processingId !== null && processingId !== transaction.id const isIncome = transaction.amount > 0 // Optimistic override — flips the indicator to "attached" as soon as the // upload POST succeeds, without waiting for the parent to refetch. The // next parent refresh will sync; in the meantime the user sees the // correct visual state immediately. Same hook handles agent-chat uploads // via the Accounted:transaction-document-linked window event (AgentChat // dispatches it after /api/agent/upload returns). const [optimisticDocumentId, setOptimisticDocumentId] = useState(null) useEffect(() => { function onLinked(e: Event) { const detail = (e as CustomEvent<{ transaction_id?: string; document_id?: string }>).detail if (!detail || detail.transaction_id !== transaction.id || !detail.document_id) return setOptimisticDocumentId(detail.document_id) } window.addEventListener('Accounted:transaction-document-linked', onLinked) return () => window.removeEventListener('Accounted:transaction-document-linked', onLinked) }, [transaction.id]) const attachedDocumentId = optimisticDocumentId ?? (transaction as { document_id?: string | null }).document_id ?? null // Only poll extraction status for documents the user attached during THIS // session. Pre-existing attached docs from prior sessions wouldn't change // status during this view, and polling them would be wasted requests. // Gated on HAS_AI_EXTRACTION so the free tier doesn't poll an endpoint // whose pipeline never runs. const extraction = useDocumentExtraction( HAS_AI_EXTRACTION ? optimisticDocumentId : null, ) const hasInvoiceMatch = !!transaction.potential_invoice && !transaction.invoice_id const hasSupplierInvoiceMatch = !!transaction.potential_supplier_invoice && !transaction.supplier_invoice_id const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id const showCheckbox = isBatchMode && isUncategorized // Unbooked rows are still actionable (match, split, edit, categorize) — that // includes imported bank rows, which are the whole point of the inbox. const isUnbooked = !transaction.journal_entry_id // "Fråga [namn]" hands the row to the assistant for categorization/booking. // Only on unbooked rows (nothing to categorize once it's a verifikat) and // only after the user has built their agent in /onboarding/agent // (identity.isVerified) — same gate as the FAB / AgentSparkleButton. const assistantName = identity.displayName?.trim() || 'min assistent' const showAskAssistant = isUnbooked && identity.isVerified // ...but only rows the USER created in the app may be deleted. Imported rows // (bank sync / CSV) are ignore-only — mirrors the server guard in // DELETE /api/transactions/[id]. See lib/transactions/origin.ts. const canDelete = isUnbooked && !isImportedTransaction(transaction) // Title is editable only on a mutable staging row — not booked and not // confirmed-matched. Mirrors the server-side gate in PATCH /api/transactions/[id]. const isTitleEditable = !transaction.journal_entry_id && !transaction.invoice_id && !transaction.supplier_invoice_id const originalName = transaction.original_description // Primary action: invoice/supplier-invoice match keeps the 1-click shortcut; // otherwise the user opens the template picker. const primaryAction = (() => { if (hasInvoiceMatch) { return ( ) } if (hasSupplierInvoiceMatch) { return ( ) } return ( ) })() // Manual invoice-match affordance. Hidden once an auto-detected match is // already shown as the primary button — having both makes the row noisy. const showInvoiceMatchButton = isUnbooked && !hasInvoiceMatch && !hasSupplierInvoiceMatch const invoiceMatchLabel = isIncome ? 'Matcha mot kundfaktura' : 'Matcha mot leverantörsfaktura' const splitMatchLabel = isIncome ? 'Dela inbetalningen på flera fakturor' : 'Dela utbetalningen på flera leverantörsfakturor' // Secondary row actions are collapsed into a single ⋯ overflow menu to keep // the inbox row uncluttered. Bokför + the invoice-match button stay inline. // "Matcha mot befintlig verifikation" — link to an already-booked voucher. // Available on any unbooked row (income or expense), independent of whether an // invoice match was auto-detected: the user may want to point the bank line at // an existing salary/Fortnox/manual voucher instead of confirming a payment. const showMatchVoucherItem = isUnbooked && !!onOpenMatchVoucher // "Matcha mot underlag" — pin an inbox doc / fresh upload to the tx. The // tx→doc mirror of the Documents view's "Matcha mot transaktion". const showAttachDocumentItem = isUnbooked && canWrite && !!onOpenAttachDocument const showSplitItem = showInvoiceMatchButton && !!onOpenSplitMatch const showEditItem = isTitleEditable && !!onEditTitle const showIgnoreItem = isUnbooked && isImportedTransaction(transaction) && !!onIgnore const showDeleteItem = canDelete && !!onDelete const showOverflowMenu = showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem || showIgnoreItem || showDeleteItem return ( { if (typeof definition === 'object' && 'opacity' in definition && definition.opacity === 0) { onAnimationComplete?.(transaction.id) } }} > onToggleSelect(transaction.id) : undefined} leading={ showCheckbox ? ( onToggleSelect(transaction.id)} onClick={(e) => e.stopPropagation()} aria-label="Välj transaktion" /> ) : ( {isIncome ? ( ) : ( )} ) } trailing={ <>

{isIncome ? '+' : ''} {formatCurrency(transaction.amount, transaction.currency)}

{transaction.currency !== 'SEK' && transaction.amount_sek != null && (

{formatCurrency(transaction.amount_sek)}

)}
{!isBatchMode && ( <> {primaryAction} {showInvoiceMatchButton && ( )} {/* "Fråga [namn]" — hand this bank line to the assistant for categorization/booking. The transaction-side entry point to the agent, mirroring "Fråga assistenten" in Dokumentinkorgen. The intent reads any linked underlag automatically, so it works whether or not the row already has a receipt attached. Icon-only ghost so it sits quietly in the row's action group. (The Paperclip indicator next to the description stays the single click target for opening the underlag — we don't duplicate that here.) */} {showAskAssistant && ( )} {/* Secondary actions (split, edit, delete) collapse into a ⋯ overflow menu so the row stays uncluttered. */} {showOverflowMenu && ( {showMatchVoucherItem && ( { e.stopPropagation() onOpenMatchVoucher!(transaction) }} > {t('match_voucher_btn')} )} {showAttachDocumentItem && ( { e.stopPropagation() onOpenAttachDocument!(transaction) }} > {t('attach_document_btn')} )} {showSplitItem && ( { e.stopPropagation() onOpenSplitMatch!(transaction) }} > {splitMatchLabel} )} {showEditItem && ( { e.stopPropagation() onEditTitle!(transaction) }} > {t('edit_title_aria')} )} {(showIgnoreItem || showDeleteItem) && (showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem) && ( )} {showIgnoreItem && ( { e.stopPropagation() onIgnore!(transaction) }} > {t('ignore_btn')} )} {showDeleteItem && ( { e.stopPropagation() onDelete!(transaction.id) }} > {t('delete_aria')} )} )} )} } >
{transaction.description}
{formatDate(transaction.date)} {transaction.title_edited_at && ( <> {t('edited_badge')} )} {skvCounterpartDate && ( <> Möjlig 1930↔1630 )} {/* Extraction status — visible only while AI is reading a freshly attached document, or briefly if reading failed. */} {HAS_AI_EXTRACTION && !isBatchMode && (extraction.status === 'running' || extraction.status === 'failed') && (
)}
) }