53e343ee92
* feat: add Accounted MCP namespace * fix(bookkeeping): stop flagging verifikat whose underlag lives on a referenced supplier invoice The missing-underlag surfaces only accepted a document directly linked to the entry, so payment verifikat for supplier invoices (doc on the registration entry per design) and entries whose doc was pinned to the bank transaction before matching were falsely flagged; opening the entry showed the referenced doc and cleared the warning client-side, and it came back on reload. - verifikat_without_documents + transactions_without_documents now treat an entry as covered when a supplier invoice referencing it (registration or payment FK, or a supplier_invoice_payments row) carries a document anchored to a journal entry (BFL 5 kap 7 paragraf hänvisning till underlag; anchoring required because the WORM deletion guards key on document_attachments.journal_entry_id) - match-supplier-invoice routes (dashboard + v1) propagate the transaction's pinned document onto the payment verifikat, mirroring the categorize route; migration backfills rows already written (open unlocked periods, company-guarded, never steals a linked doc) - /api/documents/counts, the transactions-page badges, the bulk "Inget underlag krävs" count and the push-notification scheduler share the same reference-aware predicate, so every surface agrees with the RPC - counts route validates journal_entry_ids as UUIDs (they are interpolated into a PostgREST or-filter) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): align table columns flush with page edges Collapse the checkbox gutter column to zero width and hang the hover-revealed checkbox/expand chevron in the page margins, drop the outer padding so DATUM sits flush left and STATUS flush right, and tuck the overflow-menu dots under the middle of the STATUS header. Applied to both the inbox and history tables so they stay identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(arsredovisning): tie anlaggningstillgangar note to booked depreciation The ARL 5:8 roll-forward note recomputed depreciation from its own day-based linear formula (365.25/12 month length, non-inclusive day count, linear only), drifting ~20 kr per year per asset from the ledger-driven resultat- and balansrakning and misstating non-linear methods entirely. Note figures now come from posted depreciation_schedules rows (the same source disposeAsset reverses), falling back to the engine's computeAnnualDepreciation when nothing is posted; pre-onboarding opening balances iterate prior years through the engine. Adds a note-vs-trial-balance tie-out warning (accounts 1000-1299, over 1 kr) surfaced before download. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(stripe): move connect and sync surface from settings to import page Stripe's transaction feed is a continuous import source in the same category as the PSD2 bank connection, so its connect/sync surface now lives on the import page as a source card (mode=stripe), gated "kommer snart" on hosted like before; self-hosted keeps the full panel. - Import page: Stripe card after Koppla bank, renders the existing StripeSettingsPanel via the settings-panel registry - OAuth callback and panel cleanup return to /import?mode=stripe - Settings > Betalningar retired: nav item removed, route redirects, PaymentsSettingsContent deleted, legacy ?tab=payments mapped - New import.stripe_* strings in sv+en; dead settings_nav.payments removed Crons and sync logic unchanged; payment-link settings stay in the invoicing section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(underlag): paginate missing-underlag cron and harden doc-surface queries Resolve PR review findings on bug/invalid-imports: - notification-scheduler: fetchAllRows on all 5 global reads; past 1000 rows the capped reads produced false "saknade underlag" notifications - bulk-missing: LOOKUP_CHUNK 300->150 so the twice-embedded .or() id list stays under the PostgREST URL limit - bulk-missing + transactions page: UUID-guard the .or()-interpolated id lists, matching documents/counts - match-supplier-invoice (dashboard + v1): log documentId/journalEntryId on the non-fatal doc-link warning - well-known/oauth-protected-resource: document the tool_namespace allow-list - messages/en: reword stripe_description - DECISIONS.md: record the asset ibAck tie-out and Tailwind !important calls Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(tic): convert registrationDate from Unix seconds to millisecond epoch in lookup and profile tests --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
503 lines
22 KiB
TypeScript
503 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')
|
|
// 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; once bank-tx metadata
|
|
// classification lands (see the transactions-metadata issue) every imported
|
|
// row will have foldout content again.
|
|
const hasFoldoutContent =
|
|
(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) => {
|
|
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 1930↔1630
|
|
</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>
|
|
<td colSpan={5} className="border-b border-border p-0">
|
|
<RowFoldout>
|
|
<div className="pb-6 pt-1">
|
|
{(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.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>
|
|
)}
|
|
</>
|
|
)
|
|
}
|