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:
Jakob Wennberg
2026-07-23 21:41:45 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent aa72a75dfc
commit 1ccf7ab81f
16 changed files with 1229 additions and 1090 deletions
+332 -245
View File
@@ -4,7 +4,6 @@ import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import type { SupabaseClient } from '@supabase/supabase-js'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { AnimatePresence } from 'framer-motion'
import { useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
@@ -13,21 +12,16 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { DataList, DataListHeader, DataListEmpty } from '@/components/ui/data-list'
import { DataList, DataListEmpty } from '@/components/ui/data-list'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
} from '@/components/ui/dropdown-menu'
import { ChevronDown, EyeOff, Layers, Search, ShieldAlert, Trash2, X } from 'lucide-react'
import { TH_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { Loader2, Search } from 'lucide-react'
import TransactionStatusBar from '@/components/transactions/TransactionStatusBar'
import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
import { ContextPicker, type ContextPickerItem } from '@/components/common/ContextPicker'
import { AttnLine } from '@/components/ui/attn-line'
import BankSyncNowButton from '@/components/transactions/BankSyncNowButton'
import BankSyncSinceLastVisit from '@/components/transactions/BankSyncSinceLastVisit'
import TransactionInboxCard from '@/components/transactions/TransactionInboxCard'
import TransactionHistoryList from '@/components/transactions/TransactionHistoryList'
import InboxZeroState from '@/components/transactions/InboxZeroState'
@@ -53,8 +47,8 @@ import { findBankSkvCounterparts } from '@/lib/skatteverket/bank-counterpart'
import { useCompany } from '@/contexts/CompanyContext'
import { useRealtimeSupabase } from '@/lib/hooks/use-realtime-supabase'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency, formatDate } from '@/lib/utils'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, SupplierInvoice, Supplier, VatTreatment, EntityType, LinePatternEntry, BookingTemplateLibrary } from '@/types'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, SupplierInvoice, Supplier, VatTreatment, EntityType, LinePatternEntry, BookingTemplateLibrary, CashAccount } from '@/types'
import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { isImportedTransaction } from '@/lib/transactions/origin'
import { computeJeUnderlagStatus, type JeUnderlagStatus } from '@/lib/transactions/underlag-status'
@@ -103,10 +97,24 @@ const TemplatePicker = dynamic(() => import('@/components/transactions/TemplateP
type InvoiceWithCustomer = Invoice & { customer?: Customer }
type SupplierInvoiceWithSupplier = SupplierInvoice & { supplier?: Supplier }
// Source filter for the merged inbox (concept scene 10 account chooser):
// everything, one cash account ('acct:<id>'), bank rows not yet tied to a
// registered cash account ('bank:other'), all bank rows ('bank': the fallback
// split when no cash accounts are registered), or the skattekonto side.
type SourceFilter = 'all' | 'bank' | 'bank:other' | 'skatteverket' | `acct:${string}`
const SOURCE_FILTER_STORAGE_KEY = 'Accounted:transaction-source-filter:v1'
// Validates a persisted value. Stale acct:<id> entries (account removed or
// disabled) are caught later by the sourceItems stale-filter guard.
function isSourceFilter(value: string | null): value is SourceFilter {
return value === 'all' || value === 'bank' || value === 'skatteverket'
return (
value === 'all' ||
value === 'bank' ||
value === 'bank:other' ||
value === 'skatteverket' ||
(value?.startsWith('acct:') ?? false)
)
}
function buildInvoiceMap(rows: InvoiceWithCustomer[] | null): Record<string, InvoiceWithCustomer> {
@@ -188,11 +196,13 @@ export default function TransactionsPage() {
const [processingId, setProcessingId] = useState<string | null>(null)
const [searchTerm, setSearchTerm] = useState('')
// Batch mode
const [isBatchMode, setIsBatchMode] = useState(false)
// Batch selection: hover checkboxes + bulkbar (concept), no mode toggle.
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showBatchSelector, setShowBatchSelector] = useState(false)
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
// Row expansion (concept foldout): one open row at a time, mirroring the
// verifikat list.
const [expandedTxId, setExpandedTxId] = useState<string | null>(null)
// Invoice match dialog
const [matchDialogOpen, setMatchDialogOpen] = useState(false)
@@ -315,7 +325,8 @@ export default function TransactionsPage() {
// ever visit the settings panel where the reconnect prompt lives.
const [skvNeedsReconnect, setSkvNeedsReconnect] = useState(false)
// One browser-wide source filter shared by the inbox and history views.
// One browser-wide source filter, persisted (#1105) so the choice
// survives reloads. Defaults to 'all'.
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
useEffect(() => {
@@ -336,6 +347,9 @@ export default function TransactionsPage() {
// localStorage may be unavailable. The in-memory filter still works.
}
}, [])
// Registered cash accounts (cash_accounts): the account chooser's rows,
// with PSD2 balances when the bank reports them.
const [cashAccounts, setCashAccounts] = useState<CashAccount[]>([])
const { toast } = useToast()
const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm()
@@ -393,6 +407,13 @@ export default function TransactionsPage() {
const query = searchTerm.trim().toLowerCase()
if (sourceFilter !== 'skatteverket') {
for (const tx of uncategorizedTransactions) {
if (
sourceFilter.startsWith('acct:') &&
tx.cash_account_id !== sourceFilter.slice('acct:'.length)
) {
continue
}
if (sourceFilter === 'bank:other' && tx.cash_account_id != null) continue
if (
query &&
!tx.description?.toLowerCase().includes(query) &&
@@ -404,7 +425,7 @@ export default function TransactionsPage() {
items.push({ source: 'bank', date: tx.date, data: tx })
}
}
if (sourceFilter !== 'bank') {
if (sourceFilter === 'all' || sourceFilter === 'skatteverket') {
// Inbox only shows SKV rows that need action (no verifikat yet).
for (const r of skvRows) {
if (r.journal_entry_id) continue
@@ -427,17 +448,87 @@ export default function TransactionsPage() {
return 0
})
}, [exitingIds, searchTerm, skvRows, sourceFilter, uncategorizedTransactions])
const transactionsWithMatches = useMemo(
() => transactions.filter(
(transaction) =>
(transaction.potential_invoice && !transaction.invoice_id) ||
(transaction.potential_supplier_invoice && !transaction.supplier_invoice_id),
),
[transactions],
// Account chooser (concept scene 10): the source picker doubles as a
// balance readout. The total sums only SEK ledgers (mixing currencies into
// one figure would be a lie); null hides the annotation entirely.
const totalSourceBalance = useMemo(() => {
const sekBalances = cashAccounts.filter((a) => a.currency === 'SEK' && a.balance != null)
if (sekBalances.length === 0) return null
return sekBalances.reduce((sum, a) => sum + (a.balance ?? 0), 0)
}, [cashAccounts])
const hasUnassignedBankRows = useMemo(
() => uncategorizedTransactions.some((tx) => tx.cash_account_id == null),
[uncategorizedTransactions],
)
const sourceItems = useMemo<ContextPickerItem[]>(() => {
const showSkvSource = skvRows.length > 0
const items: ContextPickerItem[] = [
{
id: 'all',
label: t('source_all_label'),
annotation: totalSourceBalance != null ? formatCurrency(totalSourceBalance) : undefined,
},
]
for (const account of cashAccounts) {
items.push({
id: `acct:${account.id}`,
label: `${account.name || t('source_account_fallback')} ${account.ledger_account}`,
annotation:
account.balance != null ? formatCurrency(account.balance, account.currency) : undefined,
})
}
if (cashAccounts.length === 0 && showSkvSource) {
// No registered cash accounts yet: keep the plain bank/skattekonto split.
items.push({ id: 'bank', label: t('source_bank_label') })
} else if (cashAccounts.length > 0 && hasUnassignedBankRows) {
items.push({ id: 'bank:other', label: t('source_bank_other') })
}
if (showSkvSource) {
items.push({ id: 'skatteverket', label: t('source_skatteverket_label') })
}
return items
}, [cashAccounts, hasUnassignedBankRows, skvRows.length, t, totalSourceBalance])
// A narrowed filter can go stale (account disabled, skv rows drained,
// "övriga" bucket emptied): fall back to everything rather than filtering
// the inbox down to an invisible source.
useEffect(() => {
if (sourceFilter === 'all') return
if (!sourceItems.some((item) => item.id === sourceFilter)) setSourceFilter('all')
}, [sourceFilter, sourceItems])
// Rows the bulkbar's "Markera alla" can select: the visible bank rows
// (skattekonto rows aren't batch-bookable).
const selectableInboxIds = useMemo(
() => inboxItems.filter((item) => item.source === 'bank').map((item) => item.data.id),
[inboxItems],
)
const PAGE_SIZE = 200
// Account chooser rows: the registered, enabled cash accounts. One fetch
// per company; balances refresh with the page (bank sync triggers a
// router.refresh via the sync toast flow).
useEffect(() => {
if (!companyId) return
let cancelled = false
fetch('/api/cash-accounts?enabled_only=true')
.then((res) => (res.ok ? res.json() : { data: [] }))
.then((json: { data?: CashAccount[] }) => {
if (!cancelled) setCashAccounts(json.data ?? [])
})
.catch(() => {
if (!cancelled) setCashAccounts([])
})
return () => {
cancelled = true
}
}, [companyId])
const loadSkvRows = useCallback(async () => {
// Connection health, fetched alongside the rows: any failure (extension
// disabled, capability gate, not connected) just hides the banner.
@@ -1538,7 +1629,6 @@ export default function TransactionsPage() {
})
await refreshTransactions()
setSelectedIds(new Set())
setIsBatchMode(false)
setTimeout(() => {
setExitingIds((prev) => {
const next = new Set(prev)
@@ -1792,7 +1882,6 @@ export default function TransactionsPage() {
}
function exitBatchMode() {
setIsBatchMode(false)
setSelectedIds(new Set())
}
@@ -2152,44 +2241,53 @@ export default function TransactionsPage() {
return (
<div className="space-y-8">
{/* Status bar */}
<TransactionStatusBar
uncategorizedCount={totalUncategorizedCount ?? uncategorizedTransactions.length}
invoiceMatchCount={transactionsWithMatches.length}
mode={mode}
onOpenCreateDialog={() => setIsDialogOpen(true)}
isBatchMode={isBatchMode}
onToggleBatchMode={() => (isBatchMode ? exitBatchMode() : setIsBatchMode(true))}
/>
{/* Page header (concept scene 10): title + Importera split button */}
<TransactionStatusBar onOpenCreateDialog={() => setIsDialogOpen(true)} />
<div className="flex flex-wrap items-center gap-2">
<BankSyncStatusChip />
<BankSyncNowButton />
{/* The ignore flows tell users to "återställ under Bankavstämning":
this is the path there. Bankavstämning has no nav entry of its own,
so without a link here the copy points at an unreachable place. */}
<Button asChild variant="ghost" size="sm" className="ml-auto h-9 text-sm text-muted-foreground">
<Link href="/reports/bank-reconciliation">Bankavstämning →</Link>
</Button>
</div>
<BankSyncSinceLastVisit />
{skvNeedsReconnect && (
<div className="flex flex-wrap items-start gap-3 rounded-lg border border-border bg-secondary/40 p-4 text-sm">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
<div className="min-w-0 flex-1 space-y-1">
<p className="font-medium">{t('skv_reconnect_title')}</p>
<p className="text-muted-foreground">{t('skv_reconnect_body')}</p>
</div>
<Button asChild variant="outline" size="sm" className="shrink-0">
<Link href="/settings/tax">{t('skv_reconnect_cta')}</Link>
</Button>
</div>
<AttnLine action={{ label: t('skv_reconnect_cta'), href: '/settings/tax' }}>
{t('skv_reconnect_body')}
</AttnLine>
)}
{/* Search + view dropdown */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
{/* Toolbar (concept order): [Att bokföra/Alla-seg] [sök] [Välj flera]
... [source ContextPicker far right] */}
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
<button
type="button"
role="tab"
aria-selected={mode === 'inbox'}
onClick={() => setMode('inbox')}
className={`inline-flex items-center gap-1.5 rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150 ${
mode === 'inbox'
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t('mode_inbox')}
{(totalUncategorizedCount ?? uncategorizedTransactions.length) > 0 && (
<span className="rounded-full bg-secondary px-1.5 text-[10px] font-medium tabular-nums">
{totalUncategorizedCount ?? uncategorizedTransactions.length}
</span>
)}
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'history'}
onClick={() => setMode('history')}
className={`rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150 ${
mode === 'history'
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t('mode_all')}
</button>
</div>
<div className="relative min-w-[220px] max-w-xs flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök transaktioner…"
@@ -2198,24 +2296,23 @@ export default function TransactionsPage() {
className="h-9 pl-10"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 gap-2 px-3 text-sm">
{mode === 'inbox'
? `Att bokföra${(totalUncategorizedCount ?? uncategorizedTransactions.length) > 0 ? ` (${totalUncategorizedCount ?? uncategorizedTransactions.length})` : ''}`
: 'Alla transaktioner'}
<ChevronDown className="h-3.5 w-3.5 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[14rem]">
<DropdownMenuRadioGroup value={mode} onValueChange={(v) => setMode(v as typeof mode)}>
<DropdownMenuRadioItem value="inbox">
{`Att bokföra${(totalUncategorizedCount ?? uncategorizedTransactions.length) > 0 ? ` (${totalUncategorizedCount ?? uncategorizedTransactions.length})` : ''}`}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="history">Alla transaktioner</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* Account chooser (convention 8): the one context chip, far right.
Per-cash-account rows with balances (concept scene 10); hidden
only when there is nothing beyond "Alla källor" to choose. */}
{mode === 'inbox' && sourceItems.length > 1 && (
<div className="ml-auto">
<ContextPicker
value={sourceFilter}
onChange={(id) => handleSourceFilterChange(id as SourceFilter)}
triggerLabel={(() => {
const active =
sourceItems.find((item) => item.id === sourceFilter) ?? sourceItems[0]
return active.annotation ? `${active.label} · ${active.annotation}` : active.label
})()}
items={sourceItems}
/>
</div>
)}
</div>
{/* Content based on mode */}
@@ -2233,91 +2330,130 @@ export default function TransactionsPage() {
))}
</DataList>
) : mode === 'inbox' ? (
inboxItems.length === 0 && !searchTerm && sourceFilter === 'all' ? (
<InboxZeroState
hasTransactions={transactions.length > 0 || skvRows.length > 0}
onCreateTransaction={() => setIsDialogOpen(true)}
/>
inboxItems.length === 0 ? (
searchTerm || sourceFilter !== 'all' ? (
<DataListEmpty
title="Inga träffar"
description={searchTerm ? t('no_search_results') : t('source_empty')}
/>
) : (
<InboxZeroState
hasTransactions={transactions.length > 0 || skvRows.length > 0}
onCreateTransaction={() => setIsDialogOpen(true)}
/>
)
) : (
<DataList className="stagger-enter">
{(sourceFilter !== 'all'
|| (skvUnmatched.length > 0 && uncategorizedTransactions.length > 0)) && (
<DataListHeader>
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{t('source_label')}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-7 gap-2 px-2 text-xs">
{sourceFilter === 'all'
? t('source_all', { count: uncategorizedTransactions.length + skvUnmatched.length })
: sourceFilter === 'bank'
? t('source_bank', { count: uncategorizedTransactions.length })
: t('source_skatteverket', { count: skvUnmatched.length })}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[12rem]">
<DropdownMenuRadioGroup
value={sourceFilter}
onValueChange={(v) => handleSourceFilterChange(v as SourceFilter)}
>
<DropdownMenuRadioItem value="all">
{t('source_all', { count: uncategorizedTransactions.length + skvUnmatched.length })}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="bank">
{t('source_bank', { count: uncategorizedTransactions.length })}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="skatteverket">
{t('source_skatteverket', { count: skvUnmatched.length })}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</DataListHeader>
)}
{inboxItems.length === 0 && (searchTerm || sourceFilter !== 'all') ? (
<DataListEmpty
title="Inga träffar"
description={searchTerm ? t('no_search_results') : t('source_empty')}
/>
) : null}
<AnimatePresence mode="popLayout">
{inboxItems.map(item =>
item.source === 'bank' ? (
<TransactionInboxCard
key={`bank-${item.data.id}`}
transaction={item.data}
skvCounterpartDate={bankToSkvHints.get(item.data.id)}
processingId={processingId}
isBatchMode={isBatchMode}
isSelected={selectedIds.has(item.data.id)}
entityType={entityType}
onCategorize={handleCategorize}
onOpenMatchDialog={openMatchDialog}
onOpenMatchInvoicePicker={openInvoiceMatchPicker}
onOpenSplitMatch={openSplitMatchDialog}
onOpenMatchVoucher={openMatchVoucherDialog}
onOpenAttachDocument={openAttachDocumentDialog}
onOpenCategoryDialog={openCategoryDialog}
onDelete={handleDeleteTransaction}
onIgnore={handleIgnoreTransaction}
onEditTitle={openEditTitleDialog}
onToggleSelect={toggleBatchSelect}
/>
<div>
{/* Bulkbar (concept): hidden until at least one transaction is
selected via the hover checkboxes, then it pops in with the
count and the batch actions. */}
{selectedIds.size > 0 && (
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-b border-border px-1 py-2.5 text-[12.5px] animate-fade-in">
{batchProgress ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t('batch_progress', { done: batchProgress.done, total: batchProgress.total })}
</span>
) : (
<SkattekontoInboxCard
key={`skv-${item.data.id}`}
row={item.data}
matchSuggestion={item.data.match_suggestion}
processing={skvProcessingId === item.data.id}
onBokfor={handleSkvBokfor}
onMatch={r => setSkvMatchTarget(r)}
/>
),
)}
</AnimatePresence>
</DataList>
<>
<span className="whitespace-nowrap">
<strong className="font-semibold tabular-nums">{selectedIds.size}</strong>{' '}
{t('bulkbar_selected', { count: selectedIds.size })}
</span>
<Button size="sm" onClick={() => setShowBatchSelector(true)}>
{t('batch_book')}
</Button>
{/* Bulk-book (samlingsverifikation): only when ≥2 selected
on the same date + same direction. Disabled state
explains why via title. */}
<Button
size="sm"
variant="outline"
onClick={() => setBulkBookOpen(true)}
disabled={!bulkBookEligible}
title={!bulkBookEligible ? t('batch_bulk_book_hint') : undefined}
>
{t('batch_bulk_book')}
</Button>
<button type="button" className={QUIET_LINK_CLASS} onClick={handleBatchIgnore}>
{t('batch_ignore')}
</button>
<button
type="button"
className={cn(QUIET_LINK_CLASS, 'hover:text-destructive')}
onClick={handleBatchDelete}
>
{t('batch_delete')}
</button>
{selectedIds.size < selectableInboxIds.length && (
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => setSelectedIds(new Set(selectableInboxIds))}
>
{t('batch_select_all', { count: selectableInboxIds.length })}
</button>
)}
<button type="button" className={QUIET_LINK_CLASS} onClick={exitBatchMode}>
{t('batch_clear')}
</button>
</>
)}
</div>
)}
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'w-[26px] !pl-1')} aria-hidden="true"></th>
<th className={TH_CLASS}>{t('th_date')}</th>
<th className={cn(TH_CLASS, 'w-full')}>{t('th_description')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_amount')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_status')}</th>
</tr>
</thead>
<tbody className="stagger-enter">
{inboxItems.map(item =>
item.source === 'bank' ? (
<TransactionInboxCard
key={`bank-${item.data.id}`}
transaction={item.data}
skvCounterpartDate={bankToSkvHints.get(item.data.id)}
processingId={processingId}
isSelected={selectedIds.has(item.data.id)}
isExpanded={expandedTxId === item.data.id}
onToggleExpand={(id) =>
setExpandedTxId((prev) => (prev === id ? null : id))
}
entityType={entityType}
onCategorize={handleCategorize}
onOpenMatchDialog={openMatchDialog}
onOpenMatchInvoicePicker={openInvoiceMatchPicker}
onOpenSplitMatch={openSplitMatchDialog}
onOpenMatchVoucher={openMatchVoucherDialog}
onOpenAttachDocument={openAttachDocumentDialog}
onOpenCategoryDialog={openCategoryDialog}
onDelete={handleDeleteTransaction}
onIgnore={handleIgnoreTransaction}
onEditTitle={openEditTitleDialog}
onToggleSelect={toggleBatchSelect}
/>
) : (
<SkattekontoInboxCard
key={`skv-${item.data.id}`}
row={item.data}
matchSuggestion={item.data.match_suggestion}
processing={skvProcessingId === item.data.id}
onBokfor={handleSkvBokfor}
onMatch={r => setSkvMatchTarget(r)}
/>
),
)}
</tbody>
</table>
</div>
</div>
)
) : (
<TransactionHistoryList
@@ -2339,66 +2475,22 @@ export default function TransactionsPage() {
/>
)}
{/* Batch mode floating action bar */}
{isBatchMode && selectedIds.size > 0 && (
<div className="fixed bottom-20 md:bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 bg-background border rounded-lg shadow-lg px-4 py-3">
{batchProgress ? (
<>
<Badge variant="secondary">
{batchProgress.done}/{batchProgress.total}
</Badge>
<p className="text-sm text-muted-foreground">
Bokför {batchProgress.done} av {batchProgress.total}...
</p>
</>
) : (
<>
<Badge variant="secondary">{selectedIds.size} valda</Badge>
<Button variant="ghost" size="sm" onClick={() => setSelectedIds(new Set())}>
<X className="mr-1 h-3 w-3" />
Avmarkera
</Button>
<Button
variant="outline"
size="sm"
onClick={handleBatchIgnore}
>
<EyeOff className="mr-1 h-3 w-3" />
Ignorera
</Button>
<Button
variant="outline"
size="sm"
onClick={handleBatchDelete}
className="text-destructive hover:text-destructive"
>
<Trash2 className="mr-1 h-3 w-3" />
Ta bort
</Button>
{/* Bulk-book (samlingsverifikation): only when ≥2 selected on
the same date + same direction. Disabled state explains why
via title. */}
<Button
variant="outline"
size="sm"
onClick={() => setBulkBookOpen(true)}
disabled={!bulkBookEligible}
title={
!bulkBookEligible
? 'Välj minst två transaktioner från samma datum och samma riktning'
: 'Skapa en samlingsverifikation för de valda transaktionerna'
}
>
<Layers className="mr-1 h-3 w-3" />
Bokför i klump
</Button>
<Button size="sm" onClick={() => setShowBatchSelector(true)}>
Bokför
</Button>
</>
)}
</div>
)}
{/* Footer status line (concept): honest counter from the visible
rows + bank sync status/actions + the Bankavstämning path (the
ignore flows point users there). */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 px-1 text-xs text-muted-foreground">
{mode === 'inbox' && (
<span className="tabular-nums">{t('footer_to_handle', { count: inboxItems.length })}</span>
)}
<BankSyncStatusChip />
<BankSyncNowButton />
<Link
href="/reports/bank-reconciliation"
className="ml-auto transition-colors duration-150 hover:text-foreground"
>
Bankavstämning →
</Link>
</div>
{/* Dialogs */}
{showBatchSelector && (
@@ -2477,32 +2569,28 @@ export default function TransactionsPage() {
)}
{templatePickerOpen && <Dialog open onOpenChange={setTemplatePickerOpen}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Bokför transaktion</DialogTitle>
</DialogHeader>
{templatePickerTransaction && (
<div className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="truncate text-muted-foreground">{templatePickerTransaction.description}</span>
<span className="font-medium tabular-nums flex-shrink-0 ml-3">
<span className="font-medium tabular-nums flex-shrink-0">
{templatePickerTransaction.amount > 0 ? '+' : ''}{formatCurrency(templatePickerTransaction.amount, templatePickerTransaction.currency)}
</span>
</div>
)}
<div className="space-y-1">
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={handleManualBooking}
>
Bokför manuellt…
</Button>
{/* Alternate paths as quiet links (concept vact): the templates are
the main content, not three stacked buttons. */}
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
<button type="button" className={QUIET_LINK_CLASS} onClick={handleManualBooking}>
Bokför manuellt
</button>
{templatePickerTransaction && templatePickerTransaction.amount > 0 && (
<Button
variant="outline"
size="sm"
className="w-full justify-start"
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => {
const tx = templatePickerTransaction
setTemplatePickerOpen(false)
@@ -2510,18 +2598,17 @@ export default function TransactionsPage() {
setInvoicePickerOpen(true)
}}
>
Matcha med faktura…
</Button>
Matcha med faktura
</button>
)}
{templatePickerTransaction && (
<Button
variant="ghost"
size="sm"
className="w-full justify-start text-muted-foreground"
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => void handleIgnoreTransaction(templatePickerTransaction)}
>
Ignorera transaktionen…
</Button>
Ignorera transaktionen
</button>
)}
</div>
<TemplatePicker
+23
View File
@@ -372,6 +372,29 @@ h1, h2, h3 {
}
}
/* Hover-reveal fallback for touch: coarse pointers have no hover, so the
controls that desktop reveals on hover (row checkboxes, chevrons, quiet
row actions) stay visible. Targets exactly the opacity-0 +
group-hover:opacity-100 pairing and nothing else. */
@media (pointer: coarse) {
.group .opacity-0.group-hover\:opacity-100 {
opacity: 1;
}
}
/* Always-visible thumb for modal scroll containers (DialogContent,
SlideOverBody): inside an overlay the scrollbar is the only cue that
content continues below the fold, so the at-rest auto-hide would read
as "the dialog is cut off". */
.scrollbar-visible::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.25);
}
@supports (scrollbar-color: auto) {
.scrollbar-visible {
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
}
}
/* Hide scrollbar utility */
.scrollbar-hide {
-ms-overflow-style: none;