Files
accounted/components/transactions/TransactionInboxCard.tsx
T
Jakob Wennberg 70845edf69 feat(transactions): structured transaction_method instead of channel-in-the-name (#1459)
* feat(transactions): structured transaction_method instead of channel-in-the-name

Swedish bank feeds embed the payment channel in the description string
("Vercel Jul Överföring via internet", "ANTHROPIC* ... Kortköp/uttag"):
the PSD2 remittance array is joined into one string and the ISO 20022
type codes were dropped at insert. This promotes the channel to data:

- transactions.transaction_method (text + CHECK closed vocabulary: card,
  transfer, bankgiro, plusgiro, swish, autogiro, e_invoice, international,
  deposit, withdrawal, salary, fee, interest, adjustment) plus verbatim
  bank_transaction_code / proprietary_bank_transaction_code evidence
  columns (data_quality_master Appendix B "Layer-A capture").
- classifyTransactionMethod() in lib/transactions/transaction-method.ts:
  explicit source method (Stripe txn.type) > trailing Swedish channel
  phrase > ISO 20022 family/subfamily > proprietary-code keywords > MCC.
  It also splits the clean display title off the description.
- Ingest stores the clean title as description and the full bank string
  as original_description; dedup is untouched (external_id is date+öre,
  the content bridge reads original_description and is prefix-based, and
  a trailing strip leaves a prefix). Enable Banking passes the codes
  through; the Stripe feed sets methods from its balance-txn types.
- Backfill migration classifies existing rows from the description text
  (+ MCC and Stripe prefixes) and strips unedited titles; user-edited
  titles are never rewritten.
- mapping-engine also matches original_description so user rules written
  against the full bank text keep firing.
- UI: the inbox row shows the clean name; clicking it now folds out
  "Betalsätt: Kortköp" etc. (sv/en), making every classified row
  expandable.

A card purchase implies a physical receipt, a Bankgiro/e-invoice payment
implies a supplier invoice: downstream automations can now branch on the
rail instead of regexing display strings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): anchor counterparty-template identity on original_description

Audit follow-up to the phrase-strip change: counterparty template lookup
AND learning derived their key from merchant_name || description. With
the working title now stripped ("SPOTIFY AB Kortköp" -> "SPOTIFY AB"),
templates learned from the full bank string would only re-match via the
occurrence-gated single-token tier, and single-token counterparties with
fewer than 3 bookings would silently stop matching.

Both sides now read merchant_name || original_description || description:
the immutable bank original is identical across eras (and across user
renames), so every stored key and alias keeps matching exactly. Same
anchoring rationale as buildMerchantHistory in category-suggestions.

Existing tests that relied on the fixture's default original_description
now state it explicitly; two new regression tests pin the era stability
(lookup via alias on the full string, learning key derivation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): review follow-ups on method classification

- methodFromCodes: two-pass subfamily-then-family scan so a SALA/XBCT
  refinement on the proprietary code beats a bare family match on the
  ISO code, matching the documented precedence; pinned by a test.
- mapping-engine: regression tests for merchant/description patterns
  that only match original_description, including the invalid-regex
  substring fallback and the no-match default.
- Stripe: regression test for the SDK-unmodeled 'tax' balance-txn type
  mapping to 'fee'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): scope method classification to feed rows + adjective guard

Multi-bank risk hardening before the backfill ships:

- Feed-row scope: classification and title stripping now require a real
  import feed (import_source present, not manual/mcp), both at the
  ingest boundary (USER_CREATED_IMPORT_SOURCES, now exported) and in
  every backfill statement. User-authored titles like "Egen insättning"
  on manual/MCP rows are never classified and never rewritten.
- Adjective guard (TS + SQL): a strip that would leave the title ending
  in a possessive/scope adjective (egen/eget/privat/intern/extern ...)
  is skipped, so "Egen insättning" stays whole even on bank-feed rows;
  the method column still classifies (deposit).
- Unknown bank phrasings remain untouched by construction: an unmatched
  phrase means no method and no rewrite, so the worst case for any bank
  whose vocabulary we have not seen is the status quo.

Pinned by new unit + pg-real cases (user-created exclusion for
NULL/manual/mcp, adjective guard, feed defaults in the pg fixture).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(migrations): re-timestamp transaction_method migrations after rebase

Main gained migrations dated 20260729-20260730 (already applied to prod)
while this branch carried 20260728 versions, which would have applied
out-of-order on merge. The files have never reached prod, so renaming to
current timestamps is safe and removes any dependence on the integration's
out-of-order handling. All code/doc references updated; the pg test reads
the backfill by its new filename.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(migrations): bump transaction_method versions past prod's max

Main's newest applied migration is 20260730090000 (future-leaning
timestamp), so the previous 202607300731xx rename still sorted before
prod's tail and risked a silent skip on merge-time apply. Versions are
now 20260730100000/20260730100100, strictly after everything applied to
prod. References updated; full migration stream replays clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): final review round: keyboard guard + bank_connection_id feed marker

- TransactionInboxCard: row-level Enter/Space handling now ignores events
  bubbling from nested controls, so keyboard activation of Bokför / the
  overflow menu is no longer cancelled by the (now much more common)
  expandable row.
- Feed predicate parity with isImportedTransaction(): a live
  bank_connection_id marks a feed row even when import_source is unset
  (the oldest PSD2 rows predate that column), in both the ingest
  classifier and every backfill statement: those legacy rows now get
  classified instead of being skipped as user-created.
- pg fixture typing uses the TransactionMethod union.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(migrations): re-timestamp transaction_method migrations past prod's 20260807 tail

Prod max applied is 20260807170000 (verified by name via list_migrations);
the 20260730-stamped pair would sort before it. References in code,
tests, and DECISIONS.md updated to the new versions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(migrations): enforce, not assume, original_description preservation in the title strip

The strip UPDATE now fills a NULL original_description from the
pre-strip description in the same statement. Prod has zero such rows
(0/25,566 feed-scope rows, verified read-only), and 20260605120000's
backfill plus ingest make the NULL case unreachable on any DB that
replayed history, but the migration should not depend on that history
to avoid losing the only copy of a bank string.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: record the compliance-review triage of the backfill's booked-row title strip

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
2026-08-08 11:58:51 +02:00

516 lines
22 KiB
TypeScript

'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,
ChevronRight,
EyeOff,
FileSearch,
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
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
onToggleSelect: (id: string) => void
}
/**
* 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,
processingId,
isSelected,
isExpanded,
onToggleExpand,
onOpenMatchDialog,
onOpenMatchInvoicePicker,
onOpenSplitMatch,
onOpenMatchVoucher,
onOpenAttachDocument,
onOpenCategoryDialog,
onDelete,
onIgnore,
onEditTitle,
onToggleSelect,
}: 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()
// 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<string | null>(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
// "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
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
const showIgnoreItem = isUnbooked && isImportedTransaction(transaction) && !!onIgnore
const showDeleteItem = canDelete && !!onDelete
const showOverflowMenu =
showInvoiceMatchButton || showAskAssistant || showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem || showIgnoreItem || showDeleteItem
const askAssistant = () =>
openAgentSheet({
intentId: 'transaction.categorization',
intentArgs: { transaction_id: transaction.id },
contextRef: `transaction:${transaction.id}`,
})
// 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
const expanded = isExpanded && canExpand
return (
<>
<tr
data-tx-id={transaction.id}
className={cn(
'group transition-colors duration-150',
canExpand && 'cursor-pointer',
expanded ? 'bg-secondary/25' : 'hover:bg-secondary/35',
isSelected && 'bg-secondary/40',
isDisabled && 'opacity-50',
)}
role={canExpand ? 'button' : undefined}
tabIndex={canExpand ? 0 : undefined}
aria-expanded={canExpand ? expanded : undefined}
onClick={canExpand ? () => 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. */}
<td
className={cn(TD_CLASS, 'relative w-0 !p-0')}
onClick={(e) => e.stopPropagation()}
>
{selectable && (
<Checkbox
checked={isSelected}
onCheckedChange={() => 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',
)}
/>
)}
</td>
<td className={cn(TD_CLASS, '!pl-0 whitespace-nowrap tabular-nums text-muted-foreground')}>
{formatDate(transaction.date)}
</td>
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{transaction.description}</span>
<TransactionAttachmentIndicator documentId={attachedDocumentId} />
{transaction.title_edited_at && (
<span
className="shrink-0 text-xs text-muted-foreground"
title={originalName ? t('original_name_tooltip', { name: originalName }) : undefined}
>
{t('edited_badge')}
</span>
)}
{skvCounterpartDate && (
<Badge variant="warning" className="h-4 shrink-0 gap-1 px-1.5 py-0 text-[10px]">
<AlertCircle className="h-3 w-3" />
Möjlig 19301630
</Badge>
)}
</span>
</td>
<td
className={cn(
TD_CLASS,
'whitespace-nowrap text-right tabular-nums rr-mask',
isIncome && 'text-success',
)}
>
{isIncome ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</td>
<td className={cn(TD_CLASS, 'relative whitespace-nowrap text-right !pr-0 py-[9px]')}>
<span className="inline-flex items-center justify-end gap-2">
<Button
size="sm"
variant="outline"
className="h-7 px-3.5 text-xs"
onClick={(e) => {
e.stopPropagation()
runPrimary()
}}
disabled={isProcessing || isDisabled}
>
{isProcessing && <Loader2 className="mr-1.5 h-3 w-3 animate-spin" />}
{primaryLabel}
</Button>
{showOverflowMenu && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* mr-2 tucks the button in so the dots glyph sits under
the middle of the STATUS header, not at the page edge. */}
<Button
variant="ghost"
size="icon"
className="mr-2 h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
aria-label={t('more_actions_aria')}
title={t('more_actions_aria')}
disabled={isProcessing || isDisabled}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[14rem]">
{showInvoiceMatchButton && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
onOpenMatchInvoicePicker(transaction)
}}
>
<Link2 className="h-4 w-4" />
{invoiceMatchLabel}
</DropdownMenuItem>
)}
{showAskAssistant && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
askAssistant()
}}
>
<MessageCircle className="h-4 w-4" />
{`Fråga ${assistantName}`}
</DropdownMenuItem>
)}
{showMatchVoucherItem && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
onOpenMatchVoucher!(transaction)
}}
>
<FileSearch className="h-4 w-4" />
{t('match_voucher_btn')}
</DropdownMenuItem>
)}
{showAttachDocumentItem && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
onOpenAttachDocument!(transaction)
}}
>
<Paperclip className="h-4 w-4" />
{t('attach_document_btn')}
</DropdownMenuItem>
)}
{showSplitItem && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
onOpenSplitMatch!(transaction)
}}
>
<Split className="h-4 w-4" />
{splitMatchLabel}
</DropdownMenuItem>
)}
{showEditItem && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
onEditTitle!(transaction)
}}
>
<Pencil className="h-4 w-4" />
{t('edit_title_aria')}
</DropdownMenuItem>
)}
{(showIgnoreItem || showDeleteItem) && (showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem) && (
<DropdownMenuSeparator />
)}
{showIgnoreItem && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
onIgnore!(transaction)
}}
>
<EyeOff className="h-4 w-4" />
{t('ignore_btn')}
</DropdownMenuItem>
)}
{showDeleteItem && (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation()
onDelete!(transaction.id)
}}
>
<Trash2 className="h-4 w-4" />
{t('delete_aria')}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Expand affordance hangs in the right page margin, mirroring
the selection checkbox on the left. */}
{canExpand && (
<ChevronRight
className={cn(
'absolute -right-5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground transition-all duration-200 md:-right-6',
expanded ? 'rotate-90 opacity-100' : 'opacity-0 group-hover:opacity-100',
)}
/>
)}
</span>
</td>
</tr>
{expanded && (
<tr data-no-stagger>
<td colSpan={5} className="border-b border-border p-0">
<RowFoldout>
<div className="pb-6 pt-1">
{transaction.transaction_method ||
(transaction.currency !== 'SEK' && transaction.amount_sek != null) ||
transaction.title_edited_at ||
skvCounterpartDate ? (
<div className="space-y-1 py-1 text-xs text-muted-foreground">
{transaction.transaction_method && (
<p>
{t('method_line', {
method: tMethod(transaction.transaction_method),
})}
</p>
)}
{transaction.currency !== 'SEK' && transaction.amount_sek != null && (
<p className="tabular-nums">
{formatCurrency(transaction.amount, transaction.currency)}
{' · '}
{formatCurrency(transaction.amount_sek)}
</p>
)}
{transaction.title_edited_at && originalName && (
<p>{t('original_name_tooltip', { name: originalName })}</p>
)}
{skvCounterpartDate && (
<p>
{t('skv_counterpart_label')}{' '}
{t('skv_counterpart_body', { date: skvCounterpartDate })}
</p>
)}
</div>
) : 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') && (
<div className="py-1">
<ExtractionStatus
status={extraction.status}
elapsedMs={extraction.elapsedMs}
/>
</div>
)}
</div>
</RowFoldout>
</td>
</tr>
)}
</>
)
}