feat(transactions): concept shell and row language (UI migration PR 5) (#1124)
* feat(transactions): concept shell for scene 10 (header, toolbar, footer)
- Header: title + Importera SplitButton (import guide / manual entry,
last-used mode persisted via ui_state.create_mode.transactions),
replacing the three-button row
- Toolbar in concept order: [Att bokföra/Alla seg with count chip]
[search] [Välj flera as quiet toggle] ... [source ContextPicker far
right merging the old in-list source dropdown (bank/Skatteverket)]
- Bank sync status + sync-now + Bankavstämning move to the concept's
footer status line together with the honest visible-row counter
("n att hantera")
- skv-reconnect banner and all dialogs/actions untouched
Row cards -> concept dry-table conversion follows separately.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(transactions): concept row language for the inbox cards
Rows read like the scene 10 table: date as the first tabular column,
description on one line, amount right (+income in sage), auto-detected
invoice matches as beige suggestion chips instead of primary pills,
tighter hairline rows at 13px. Every action unchanged: Bokför pill,
match/assistant/overflow menu, batch checkboxes, exit animations,
extraction status.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(transactions): de-bloat rows and chrome to match the concept
- Rows carry only the essentials: date, description, amount, Bokför and
one overflow menu. The inline match and ask-assistant icon buttons
fold into the overflow menu (all actions still one click away)
- The "n nya banktransaktioner sen ditt senaste besök" banner is removed;
the footer sync line covers it
- The Skatteverket reconnect banner becomes the one-ochre-sentence
AttnLine with an inline action (convention 6)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(ui): extract dry-table primitives into components/ui/dry-table
The concept table styles (TH/TD/VTH/VTD classes, quiet links, RowFoldout)
move out of JournalEntryList so the transaction inbox can render the exact
same table language.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(transactions): Synka bank nu in the Importera menu
Extracts the per-connection sync/reconnect logic into a shared useBankSync()
hook (BankSyncNowButton keeps using it) and adds the concept's first menu row
to the Importera split button: sync all active connections, with the
last-synced age as the row description. Hidden without a connection or the
bank_sync capability; the footer button stays the gated conversion surface.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(transactions): per-account source picker with balances
The context chip becomes the concept's account chooser: always visible in
inbox mode, one row per enabled cash account (PSD2 balance annotation),
an Övriga bucket for bank rows without a registered account, and a
Skattekonto row annotated with the cached saldo. The trigger reads
'Alla källor · <sum>' where the sum covers SEK ledgers + skattekonto.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(transactions): concept dry-table for the inbox
The inbox list becomes the exact Bokforing table: borderless dry-table with
hover-revealed checkboxes and chevrons, one-line rows (date, description,
amount, quiet primary pill + overflow + foldout), and a RowFoldout expansion
carrying the row detail and the full action set as quiet links. Skattekonto
rows render as chip-marked table rows with inline actions. The 'Valj flera'
toggle and the floating batch bar are replaced by the concept bulkbar that
pops in above the table once a row is selected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(i18n): singular form for bulkbar selection count
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(transactions): suggestion-first Bokfor flow (concept scene 10)
Bokfor on a row with a suggestion now goes straight to the compact
kontering confirm (QuickReview) showing the proposed verifikat; the full
template picker becomes the fallback and the 'Byt mall' path. Rows carry
the concept's 'Forslag: <mall>' chip (with D/K in the tooltip and the
foldout meta). The picker dialog is de-bloated: alternate paths (manuellt,
matcha, ignorera) collapse into quiet links. Modal scroll containers
(DialogContent, SlideOverBody) keep a visible scrollbar thumb: with the
app-wide auto-hide, a long dialog read as cut off at the fold.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(transactions): drop suggestion UI and foldout action links
Founder direction: no booking suggestions in the list for now (logic comes
later), and the foldout duplicated the overflow menu: the three dots are
the clearer surface. The foldout keeps row detail only (FX conversion,
original bank name, 1930/1630 hint, extraction status) and rows without
any detail no longer expand. The Skattekonto picker row drops its saldo
annotation; the Alla kallor sum covers bank ledgers only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(transactions): dry-table for the Alla view + pill chips app-wide
The history list joins the inbox's table language: Datum, Beskrivning,
Belopp, Status columns with Bokford as muted text + Visa verifikat quiet
link (normal state) and Ej bokford as the beige exception chip with an
inline Bokfor pill; the business/private tabs become the house seg and the
KALLA header row becomes the standard chip-picker. Skattekonto rows get
the same treatment. SEK conversion moves to a tooltip (one-line rows).
Badge itself becomes the concept chip: pill radius (99px), 11.5px, quiet
padding, replacing the boxy rounded-md look the founder flagged on the
Underlag saknas badge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): audit round: viewer-disabled split options, touch fallback
Regression-audit fixes: SplitButton options support disabled+disabledTitle
so viewer-gated create paths render inert with the viewer tooltip instead
of silently no-opping (wired on Ny transaktion); the history list gets its
Kopplad till faktura indicator back; dead transactionsWithMatches memo
removed; and coarse-pointer devices now always show hover-revealed
controls (checkboxes, chevrons, quiet row actions) since touch has no
hover.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
aa72a75dfc
commit
1ccf7ab81f
@@ -4,25 +4,17 @@ 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 { TD_CLASS, RowFoldout } from '@/components/ui/dry-table'
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { isImportedTransaction } from '@/lib/transactions/origin'
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
ChevronRight,
|
||||
EyeOff,
|
||||
FileSearch,
|
||||
FileText,
|
||||
Link2,
|
||||
Loader2,
|
||||
MessageCircle,
|
||||
@@ -56,8 +48,11 @@ interface TransactionInboxCardProps {
|
||||
* transfer that the user will later see on /skattekonto. */
|
||||
skvCounterpartDate?: string
|
||||
processingId: string | null
|
||||
isBatchMode: boolean
|
||||
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). */
|
||||
@@ -81,15 +76,21 @@ interface TransactionInboxCardProps {
|
||||
/** Open the edit-title dialog. Only wired for editable (unbooked/unmatched) rows. */
|
||||
onEditTitle?: (transaction: TransactionWithInvoice) => void
|
||||
onToggleSelect: (id: string) => void
|
||||
onAnimationComplete?: (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,
|
||||
isBatchMode,
|
||||
isSelected,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
onOpenMatchDialog,
|
||||
onOpenMatchInvoicePicker,
|
||||
onOpenSplitMatch,
|
||||
@@ -100,7 +101,6 @@ export default function TransactionInboxCard({
|
||||
onIgnore,
|
||||
onEditTitle,
|
||||
onToggleSelect,
|
||||
onAnimationComplete,
|
||||
}: TransactionInboxCardProps) {
|
||||
const t = useTranslations('tx_inbox_card')
|
||||
// Attaching underlag is a write: hide the affordance from viewers so they
|
||||
@@ -145,7 +145,7 @@ export default function TransactionInboxCard({
|
||||
const hasSupplierInvoiceMatch =
|
||||
!!transaction.potential_supplier_invoice && !transaction.supplier_invoice_id
|
||||
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
|
||||
const showCheckbox = isBatchMode && isUncategorized
|
||||
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
|
||||
@@ -165,70 +165,22 @@ export default function TransactionInboxCard({
|
||||
!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 (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-9 px-3 text-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onOpenMatchDialog(transaction)
|
||||
}}
|
||||
disabled={isProcessing || isDisabled}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<FileText className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('match_invoice_btn', {
|
||||
number: transaction.potential_invoice!.invoice_number ?? '',
|
||||
})}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
if (hasSupplierInvoiceMatch) {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-9 px-3 text-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onOpenMatchDialog(transaction)
|
||||
}}
|
||||
disabled={isProcessing || isDisabled}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<FileText className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('match_supplier_invoice_btn', {
|
||||
number: transaction.potential_supplier_invoice!.supplier_invoice_number ?? '',
|
||||
})}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-9 px-3 text-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onOpenCategoryDialog(transaction)
|
||||
}}
|
||||
disabled={isProcessing || isDisabled}
|
||||
>
|
||||
Bokför
|
||||
</Button>
|
||||
)
|
||||
})()
|
||||
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.
|
||||
@@ -243,8 +195,8 @@ export default function TransactionInboxCard({
|
||||
? '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.
|
||||
// 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
|
||||
@@ -258,252 +210,289 @@ export default function TransactionInboxCard({
|
||||
const showIgnoreItem = isUnbooked && isImportedTransaction(transaction) && !!onIgnore
|
||||
const showDeleteItem = canDelete && !!onDelete
|
||||
const showOverflowMenu =
|
||||
showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem || showIgnoreItem || showDeleteItem
|
||||
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 (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.97, x: -16 }}
|
||||
transition={{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
onAnimationComplete={(definition) => {
|
||||
if (typeof definition === 'object' && 'opacity' in definition && definition.opacity === 0) {
|
||||
onAnimationComplete?.(transaction.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DataListRow
|
||||
<>
|
||||
<tr
|
||||
data-tx-id={transaction.id}
|
||||
selected={isSelected}
|
||||
className={cn(isDisabled && 'opacity-50')}
|
||||
rowClassName="py-4 gap-4"
|
||||
onClick={showCheckbox ? () => onToggleSelect(transaction.id) : undefined}
|
||||
leading={
|
||||
showCheckbox ? (
|
||||
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) */}
|
||||
<td
|
||||
className={cn(TD_CLASS, 'w-[26px] !pl-1 py-[9px]')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{selectable && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleSelect(transaction.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Välj transaktion"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 items-center justify-center',
|
||||
isIncome ? 'text-success' : 'text-foreground/60'
|
||||
'transition-opacity duration-150',
|
||||
isSelected
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{isIncome ? (
|
||||
<ArrowUpRight className="h-5 w-5" />
|
||||
) : (
|
||||
<ArrowDownRight className="h-5 w-5" />
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
trailing={
|
||||
<>
|
||||
<div className="text-right">
|
||||
<p
|
||||
className={cn(
|
||||
'text-base font-medium tabular-nums leading-none',
|
||||
isIncome && 'text-success'
|
||||
)}
|
||||
>
|
||||
{isIncome ? '+' : ''}
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</p>
|
||||
{transaction.currency !== 'SEK' && transaction.amount_sek != null && (
|
||||
<p className="mt-1 text-xs text-muted-foreground tabular-nums">
|
||||
{formatCurrency(transaction.amount_sek)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!isBatchMode && (
|
||||
<>
|
||||
{primaryAction}
|
||||
{showInvoiceMatchButton && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onOpenMatchInvoicePicker(transaction)
|
||||
}}
|
||||
aria-label={invoiceMatchLabel}
|
||||
title={invoiceMatchLabel}
|
||||
disabled={isProcessing || isDisabled}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* "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 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
openAgentSheet({
|
||||
intentId: 'transaction.categorization',
|
||||
intentArgs: { transaction_id: transaction.id },
|
||||
contextRef: `transaction:${transaction.id}`,
|
||||
})
|
||||
}}
|
||||
aria-label={`Fråga ${assistantName}`}
|
||||
title={`Fråga ${assistantName}`}
|
||||
disabled={isProcessing || isDisabled}
|
||||
>
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* Secondary actions (split, edit, delete) collapse into a ⋯
|
||||
overflow menu so the row stays uncluttered. */}
|
||||
{showOverflowMenu && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 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]">
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<DataListPrimary className="text-base">{transaction.description}</DataListPrimary>
|
||||
<TransactionAttachmentIndicator documentId={attachedDocumentId} />
|
||||
</div>
|
||||
<DataListMeta className="mt-1">
|
||||
<span className="tabular-nums">{formatDate(transaction.date)}</span>
|
||||
{transaction.title_edited_at && (
|
||||
<>
|
||||
<DataListMetaSeparator />
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, '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="text-muted-foreground"
|
||||
className="shrink-0 text-xs text-muted-foreground"
|
||||
title={originalName ? t('original_name_tooltip', { name: originalName }) : undefined}
|
||||
>
|
||||
{t('edited_badge')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{skvCounterpartDate && (
|
||||
<>
|
||||
<DataListMetaSeparator />
|
||||
<Badge variant="warning" className="h-4 gap-1 px-1.5 py-0 text-[10px]">
|
||||
)}
|
||||
{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 sensitive-field',
|
||||
isIncome && 'text-success',
|
||||
)}
|
||||
</DataListMeta>
|
||||
{/* 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') && (
|
||||
<div className="mt-2 pt-2 border-t border-border">
|
||||
<ExtractionStatus
|
||||
status={extraction.status}
|
||||
elapsedMs={extraction.elapsedMs}
|
||||
>
|
||||
{isIncome ? '+' : ''}
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right 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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="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>
|
||||
)}
|
||||
{canExpand ? (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 text-muted-foreground transition-all duration-200',
|
||||
expanded ? 'rotate-90 opacity-100' : 'opacity-0 group-hover:opacity-100',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DataListRow>
|
||||
</motion.div>
|
||||
) : (
|
||||
<span className="h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr>
|
||||
<td colSpan={5} className="border-b border-border p-0">
|
||||
<RowFoldout>
|
||||
<div className="px-1 pb-6 pt-1 sm:pl-9 sm:pr-4">
|
||||
{(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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user