'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 { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { TD_CLASS, RowFoldout } from '@/components/ui/dry-table' import { cn, formatCurrency, formatDate } from '@/lib/utils' import { isImportedTransaction } from '@/lib/transactions/origin' import { AlertCircle, ArrowRightLeft, ChevronRight, EyeOff, FileSearch, Link2, Loader2, 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 type { TransactionWithInvoice, CategorizeHandler } from './transaction-types' import type { CashAccount } from '@/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 /** The row was just booked/ignored/deleted and is animating out during the * page's 350ms removal window: .row-exit fades and collapses it, and * pointer events are off. Instant removal under prefers-reduced-motion. */ isExiting?: boolean processingId: string | null isSelected: boolean /** Row expansion (concept foldout): controlled by the page so only one * row is open at a time, mirroring the verifikat list. */ isExpanded: boolean onToggleExpand: (id: string) => void 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 /** Open the move-to-another-cash-account dialog. Only shown when the company * has more than one enabled cash account (see `cashAccounts`). */ onMoveCashAccount?: (transaction: TransactionWithInvoice) => void /** The company's enabled cash accounts (the page's ?enabled_only=true fetch): * gates the move action, which is pointless with a single account. */ cashAccounts?: CashAccount[] onToggleSelect: (id: string) => void /** End date of the company's completed SIE-import coverage. Rows on or * before it are pre-migration history: they most likely correspond to an * already-imported verifikat, so the row carries a quiet marker steering * toward matching rather than re-booking. */ preMigrationCutoff?: string | null } /** * A bank transaction in the inbox, rendered as a dry-table row pair (concept * scene 10): main row with hover checkbox/chevron and the primary action as a * quiet pill, plus a foldout with the row's detail and full action set. The * ⋯ overflow menu stays on the row for one-click access to the same actions. */ export default function TransactionInboxCard({ transaction, skvCounterpartDate, isExiting = false, processingId, isSelected, isExpanded, onToggleExpand, onOpenMatchDialog, onOpenMatchInvoicePicker, onOpenSplitMatch, onOpenMatchVoucher, onOpenAttachDocument, onOpenCategoryDialog, onDelete, onIgnore, onEditTitle, onMoveCashAccount, cashAccounts, onToggleSelect, preMigrationCutoff = null, }: TransactionInboxCardProps) { const t = useTranslations('tx_inbox_card') const tMethod = useTranslations('tx_method') // 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() 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 selectable = isUncategorized && canWrite // 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 // ...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 const matchLabel = hasInvoiceMatch ? t('match_invoice_btn', { number: transaction.potential_invoice!.invoice_number ?? '' }) : hasSupplierInvoiceMatch ? t('match_supplier_invoice_btn', { number: transaction.potential_supplier_invoice!.supplier_invoice_number ?? '', }) : null // Primary action: invoice/supplier-invoice match keeps the 1-click // shortcut; otherwise the user opens the template picker. Rendered as the // row-level quiet pill AND as the foldout's leading pill. const runPrimary = () => { if (matchLabel) onOpenMatchDialog(transaction) else onOpenCategoryDialog(transaction) } const primaryLabel = matchLabel ?? 'Bokför' // 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 live twice, deliberately: as quiet links in the // foldout (concept vact) and in the row's ⋯ overflow menu for one-click use. // "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 // Moving between cash accounts only makes sense with somewhere to move TO, // and only for rows the server would accept: same movable gate as the title // (not booked, not confirmed-matched: mirrors PATCH .../cash-account). const showMoveAccountItem = isTitleEditable && canWrite && (cashAccounts?.length ?? 0) > 1 && !!onMoveCashAccount const showIgnoreItem = isUnbooked && isImportedTransaction(transaction) && !!onIgnore const showDeleteItem = canDelete && !!onDelete const showOverflowMenu = showInvoiceMatchButton || showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem || showMoveAccountItem || showIgnoreItem || showDeleteItem // The foldout carries row detail only (actions live on the row: pill + ⋯). // Rows with nothing to show don't expand at all; classified imported rows // always have at least the payment-method line. const hasFoldoutContent = Boolean(transaction.transaction_method) || (transaction.currency !== 'SEK' && transaction.amount_sek != null) || Boolean(transaction.title_edited_at && originalName) || Boolean(skvCounterpartDate) || (HAS_AI_EXTRACTION && (extraction.status === 'running' || extraction.status === 'failed')) const canExpand = hasFoldoutContent // An exiting row's foldout closes with it: the foldout has no exit // styling of its own and would otherwise linger un-animated. const expanded = isExpanded && canExpand && !isExiting return ( <> onToggleExpand(transaction.id) : undefined} onKeyDown={ canExpand ? (e) => { // Only when the row itself is focused: Enter/Space on a nested // control (Bokför, ⋯, checkbox) bubbles here, and preventDefault // would cancel the button's keyboard activation. if (e.target !== e.currentTarget) return if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() onToggleExpand(transaction.id) } } : undefined } > {/* Hover-revealed selection checkbox (concept .cb) */} {/* Zero-width cell: the checkbox hangs in the left page margin so the date column can sit flush with the page edge. */} e.stopPropagation()} > {selectable && ( onToggleSelect(transaction.id)} aria-label="Välj transaktion" className={cn( 'absolute -left-5 top-1/2 -translate-y-1/2 transition-opacity duration-150 md:-left-6', isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100', )} /> )} {formatDate(transaction.date)} {transaction.description} {transaction.title_edited_at && ( {t('edited_badge')} )} {skvCounterpartDate && ( Möjlig 1930↔1630 )} {/* Quiet pre-migration marker (muted text, not a chip: it is context, not an exception state). ISO dates compare lexically. */} {preMigrationCutoff && transaction.date <= preMigrationCutoff && ( {t('pre_migration_marker')} )} {isIncome ? '+' : ''} {formatCurrency(transaction.amount, transaction.currency)} {showOverflowMenu && ( {/* mr-2 tucks the button in so the dots glyph sits under the middle of the STATUS header, not at the page edge. */} {showInvoiceMatchButton && ( { e.stopPropagation() onOpenMatchInvoicePicker(transaction) }} > {invoiceMatchLabel} )} {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')} )} {showMoveAccountItem && ( { e.stopPropagation() onMoveCashAccount!(transaction) }} > {t('move_account_btn')} )} {(showIgnoreItem || showDeleteItem) && (showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem || showMoveAccountItem) && ( )} {showIgnoreItem && ( { e.stopPropagation() onIgnore!(transaction) }} > {t('ignore_btn')} )} {showDeleteItem && ( { e.stopPropagation() onDelete!(transaction.id) }} > {t('delete_aria')} )} )} {/* Expand affordance hangs in the right page margin, mirroring the selection checkbox on the left. */} {canExpand && ( )} {expanded && (
{transaction.transaction_method || (transaction.currency !== 'SEK' && transaction.amount_sek != null) || transaction.title_edited_at || skvCounterpartDate ? (
{transaction.transaction_method && (

{t('method_line', { method: tMethod(transaction.transaction_method), })}

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

{formatCurrency(transaction.amount, transaction.currency)} {' · '} {formatCurrency(transaction.amount_sek)}

)} {transaction.title_edited_at && originalName && (

{t('original_name_tooltip', { name: originalName })}

)} {skvCounterpartDate && (

{t('skv_counterpart_label')}{' '} {t('skv_counterpart_body', { date: skvCounterpartDate })}

)}
) : null} {/* Extraction status: visible only while AI is reading a freshly attached document, or briefly if reading failed. */} {HAS_AI_EXTRACTION && (extraction.status === 'running' || extraction.status === 'failed') && (
)}
)} ) }