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;
+9 -30
View File
@@ -30,6 +30,14 @@ import {
} from '@/components/common/FiscalYearSelector'
import { FyPicker } from '@/components/common/FyPicker'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import {
TH_CLASS,
TD_CLASS,
VTH_CLASS,
VTD_CLASS,
QUIET_LINK_CLASS,
RowFoldout,
} from '@/components/ui/dry-table'
import { ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Paperclip, CircleSlash, Loader2, BookOpen, X, Lock, Search, SlidersHorizontal, RotateCcw } from 'lucide-react'
import { cn, formatDate, formatCurrency } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
@@ -78,35 +86,6 @@ const PAGE_SIZE_VALUES = new Set<PageSizeChoice>(['20', '50', '100', 'all'])
// Sentinel limit sent for "Alla". The route clamps this to its own MAX_LIMIT.
const ALL_PAGE_SIZE = 100000
// Concept table styles (scene 9 "dry-table"): borderless table on the
// panel, uppercase hairline heads, 13px rows.
const TH_CLASS =
'px-4 py-2.5 text-left text-[11px] font-medium uppercase tracking-[0.07em] text-muted-foreground border-b border-border whitespace-nowrap'
const TD_CLASS = 'px-4 py-[11px] border-b border-border align-top'
const VTH_CLASS =
'py-2 pr-4 text-left text-[10.5px] font-medium uppercase tracking-[0.07em] text-muted-foreground border-b border-border'
const VTD_CLASS = 'py-[7px] pr-4 border-b border-border/60 align-top'
const QUIET_LINK_CLASS =
'text-[12.5px] text-muted-foreground underline decoration-border underline-offset-4 transition-colors duration-150 hover:text-foreground'
// Animated row expansion (concept vwrap/vinner): grid-rows 0fr -> 1fr on
// mount; the global reduced-motion rule collapses the transition.
function RowFoldout({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
useEffect(() => {
const raf = requestAnimationFrame(() => setOpen(true))
return () => cancelAnimationFrame(raf)
}, [])
return (
<div
className="grid transition-[grid-template-rows] duration-300 ease-out"
style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
>
<div className="min-h-0 overflow-hidden">{children}</div>
</div>
)
}
export default function JournalEntryList() {
const router = useRouter()
const { toast } = useToast()
@@ -982,7 +961,7 @@ export default function JournalEntryList() {
<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">
<span className="whitespace-nowrap">
<strong className="font-semibold tabular-nums">{selectedIds.size}</strong>{' '}
{t('bulkbar_selected')}
{t('bulkbar_selected', { count: selectedIds.size })}
</span>
<Input
value={batchReason}
+57 -13
View File
@@ -19,24 +19,26 @@ import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface BankConn {
export interface BankConn {
id: string
bank_name: string
status: string
provider: string
last_synced_at: string | null
}
/**
* On-demand "Sync now" button beside BankSyncStatusChip. Reuses the
* per-connection sync endpoint that BankingSettingsPanel already calls.
* Shared on-demand bank sync state + actions. Powers the footer "Synka nu"
* button below and the "Synka bank nu" row in the Importera split-button menu
* (TransactionStatusBar). Reuses the per-connection sync endpoint that
* BankingSettingsPanel already calls.
*
* Also handles dead PSD2 sessions: a connection whose consent has closed/expired
* shows a "Förnya anslutning" action that re-authorizes in place (no disconnect
* needed), and a sync that fails with a session-expiry surfaces the same
* reconnect action right in the error toast. If the user has multiple
* connections, a dropdown lets them pick which one to sync/reconnect.
* Also handles dead PSD2 sessions: a connection whose consent has closed or
* expired re-authorizes in place via `reconnect` (no disconnect needed), and a
* sync that fails with a session expiry surfaces the same reconnect action
* right in the error toast.
*/
export default function BankSyncNowButton() {
export function useBankSync() {
const t = useTranslations('transactions')
const { toast } = useToast()
const router = useRouter()
@@ -51,7 +53,7 @@ export default function BankSyncNowButton() {
const supabase = createClient()
supabase
.from('bank_connections')
.select('id, bank_name, status, provider')
.select('id, bank_name, status, provider, last_synced_at')
// Include expired/error so the reconnect entry point survives a reload:
// not just active connections that can sync.
.in('status', ['active', 'expired', 'error'])
@@ -64,8 +66,6 @@ export default function BankSyncNowButton() {
}
}, [company?.id])
if (!connections || connections.length === 0) return null
// Re-authorize an existing connection in place: posts the connection_id so
// the server reuses the same row, then hands off to the bank's consent screen.
async function reconnect(conn: BankConn) {
@@ -152,7 +152,51 @@ export default function BankSyncNowButton() {
return reconnect(conn)
}
const isBusy = busyId !== null
// One-shot "sync everything" for the split-button menu row: syncs every
// active connection in turn; with only dead connections it jumps straight
// to re-authorizing the first one (a retry can't revive a closed session).
async function syncAll() {
const conns = connections ?? []
const active = conns.filter((c) => c.status === 'active')
if (active.length === 0) {
if (conns[0]) await reconnect(conns[0])
return
}
for (const conn of active) {
await syncConnection(conn)
}
}
const lastSyncedAt =
(connections ?? [])
.map((c) => c.last_synced_at)
.filter((s): s is string => Boolean(s))
.sort()
.pop() ?? null
return {
connections,
busyId,
isBusy: busyId !== null,
hasBankSync,
reconnect,
syncConnection,
runFor,
syncAll,
lastSyncedAt,
}
}
/**
* On-demand "Sync now" button beside BankSyncStatusChip. If the user has
* multiple connections, a dropdown lets them pick which one to sync/reconnect.
*/
export default function BankSyncNowButton() {
const t = useTranslations('transactions')
const { connections, isBusy, hasBankSync, runFor } = useBankSync()
if (!connections || connections.length === 0) return null
const syncLabel = isBusy ? t('bank_sync_button_syncing') : t('bank_sync_button_now')
// Bank sync (and reconnect) is a paid external PSD2 call. Without the
@@ -51,7 +51,7 @@ export function getChipState(rows: ConnectionRow[], now: number = Date.now()): C
return { kind: 'healthy', mostRecent: mostRecent ?? null }
}
function useAgeFormatter() {
export function useAgeFormatter() {
const t = useTranslations('transactions')
return (iso: string): string => {
const ms = Date.now() - new Date(iso).getTime()
+79 -121
View File
@@ -1,29 +1,22 @@
'use client'
import { useTranslations } from 'next-intl'
import { motion } from 'framer-motion'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
DataListRow,
DataListPrimary,
DataListMeta,
DataListMetaSeparator,
} from '@/components/ui/data-list'
import { TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { AlertCircle, ArrowUpRight, ArrowDownRight, Landmark, Link2, Loader2 } from 'lucide-react'
import { AlertCircle, Landmark, Link2, Loader2 } from 'lucide-react'
import type {
SkattekontoMatchSuggestion,
StoredSkattekontoTransaction,
} from '@/types/skatteverket'
/**
* Skattekonto-rad in the /transactions inbox.
*
* Mirrors the visual rhythm of TransactionInboxCard. The Skatteverket badge is
* the cue that this row is fundamentally different from a bank tx: different
* counter-account (1630 vs 1930), different categorization rules.
* Skattekonto-rad in the /transactions inbox, rendered as a dry-table row
* (concept scene 10). The Skatteverket chip is the cue that this row is
* fundamentally different from a bank tx: different counter-account (1630 vs
* 1930), different categorization rules. No foldout: both actions fit inline.
*/
export default function SkattekontoInboxCard({
row,
@@ -31,14 +24,12 @@ export default function SkattekontoInboxCard({
processing,
onBokfor,
onMatch,
onAnimationComplete,
}: {
row: StoredSkattekontoTransaction
matchSuggestion?: SkattekontoMatchSuggestion | null
processing: boolean
onBokfor: (row: StoredSkattekontoTransaction) => void
onMatch: (row: StoredSkattekontoTransaction) => void
onAnimationComplete?: (id: string) => void
}) {
const t = useTranslations('tx_skattekonto_card')
const amount = Number(row.belopp_skatteverket)
@@ -55,117 +46,84 @@ export default function SkattekontoInboxCard({
: t('duplicate_title_draft')
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?.(row.id)
}
}}
>
<DataListRow
leading={
<span
className={cn(
'inline-flex h-5 w-5 items-center justify-center',
isIncome ? 'text-success' : 'text-foreground/60'
)}
aria-hidden
>
{isIncome ? (
<ArrowUpRight className="h-4 w-4" />
) : (
<ArrowDownRight className="h-4 w-4" />
)}
</span>
}
trailing={
<>
<div className="text-right">
<p
className={cn(
'font-medium tabular-nums leading-none',
isIncome && 'text-success'
)}
>
{isIncome ? '+' : ''}
{formatCurrency(amount)}
</p>
</div>
{matchSuggestion ? (
<>
<Button
size="sm"
variant="default"
className="h-8 px-3 text-xs"
onClick={() => onMatch(row)}
disabled={processing}
>
<Link2 className="mr-1 h-3 w-3" />
{t('link_to_voucher')}
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 px-3 text-xs"
onClick={() => onBokfor(row)}
disabled={processing}
>
{processing && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
{t('book_anyway')}
</Button>
</>
) : (
<>
<Button
size="sm"
variant="default"
className="h-8 px-3 text-xs"
onClick={() => onBokfor(row)}
disabled={processing}
>
{processing && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
{t('book')}
</Button>
<Button
size="sm"
variant="outline"
className="h-8 px-3 text-xs"
onClick={() => onMatch(row)}
disabled={processing}
>
<Link2 className="mr-1 h-3 w-3" />
{t('match_to_voucher')}
</Button>
</>
)}
</>
}
>
<div className="flex items-center gap-1.5 min-w-0">
<DataListPrimary>{row.transaktionstext}</DataListPrimary>
</div>
<DataListMeta>
<span className="tabular-nums">{formatDate(row.transaktionsdatum)}</span>
<DataListMetaSeparator />
<span className="inline-flex items-center gap-1 text-muted-foreground">
<tr className="group transition-colors duration-150 hover:bg-secondary/35">
<td className={cn(TD_CLASS, 'w-[26px] !pl-1')} aria-hidden="true"></td>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
{formatDate(row.transaktionsdatum)}
</td>
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{row.transaktionstext}</span>
<Badge variant="outline" className="h-4 shrink-0 gap-1 px-1.5 py-0 text-[10px] font-normal">
<Landmark className="h-3 w-3" />
{t('skv_badge')}
</span>
</Badge>
{matchSuggestion && (
<Badge variant="warning" className="h-4 shrink-0 gap-1 px-1.5 py-0 text-[10px]">
<AlertCircle className="h-3 w-3" />
{duplicateLabel}
</Badge>
)}
</span>
</td>
<td
className={cn(
TD_CLASS,
'whitespace-nowrap text-right tabular-nums sensitive-field',
isIncome && 'text-success',
)}
>
{isIncome ? '+' : ''}
{formatCurrency(amount)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right py-[9px]')}>
<span className="inline-flex items-center justify-end gap-3">
{matchSuggestion ? (
<>
<DataListMetaSeparator />
<Badge variant="warning" className="h-4 gap-1 px-1.5 py-0 text-[10px]">
<AlertCircle className="h-3 w-3" />
{duplicateLabel}
</Badge>
{/* Likely duplicate: linking beats re-booking, so it leads. */}
<Button
size="sm"
variant="outline"
className="h-7 px-3.5 text-xs"
onClick={() => onMatch(row)}
disabled={processing}
>
<Link2 className="mr-1 h-3 w-3" />
{t('link_to_voucher')}
</Button>
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => onBokfor(row)}
disabled={processing}
>
{t('book_anyway')}
</button>
</>
) : (
<>
<Button
size="sm"
variant="outline"
className="h-7 px-3.5 text-xs"
onClick={() => onBokfor(row)}
disabled={processing}
>
{processing && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
{t('book')}
</Button>
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => onMatch(row)}
disabled={processing}
>
{t('match_to_voucher')}
</button>
</>
)}
</DataListMeta>
</DataListRow>
</motion.div>
</span>
</td>
</tr>
)
}
+247 -294
View File
@@ -5,22 +5,13 @@ import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
DataList,
DataListHeader,
DataListRow,
DataListPrimary,
DataListMeta,
DataListMetaSeparator,
DataListEmpty,
} from '@/components/ui/data-list'
import { DataListEmpty } from '@/components/ui/data-list'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { ContextPicker } from '@/components/common/ContextPicker'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuItem,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
@@ -28,14 +19,10 @@ import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { isImportedTransaction } from '@/lib/transactions/origin'
import { getCategoryDisplayName } from '@/lib/tax/expense-warnings'
import {
ArrowUpRight,
ArrowDownRight,
ArrowLeftRight,
Check,
ChevronDown,
FileText,
Landmark,
Link2,
FileText,
Loader2,
MoreHorizontal,
Paperclip,
@@ -76,6 +63,11 @@ interface TransactionHistoryListProps {
onLoadMore?: () => void
}
/**
* "Alla" view: every transaction (booked and not), rendered in the same
* dry-table language as the inbox so the two modes read as one page.
* Bokförd is the normal state (muted text); Ej bokförd is the exception chip.
*/
export default function TransactionHistoryList({
transactions,
skvRows = [],
@@ -131,79 +123,101 @@ export default function TransactionHistoryList({
const showSourceFilter = sourceFilter !== 'all' || (skvRows.length > 0 && transactions.length > 0)
const filtered = merged
const showHeader = showSourceFilter
const FILTERS: Array<{ key: HistoryFilter; labelKey: string }> = [
{ key: 'all', labelKey: 'filter_all' },
{ key: 'business', labelKey: 'filter_business' },
{ key: 'private', labelKey: 'filter_private' },
]
return (
<div className="space-y-4">
{/* Business/private tabs */}
<Tabs value={filter} onValueChange={(v) => setFilter(v as HistoryFilter)}>
<TabsList>
<TabsTrigger value="all">{t('filter_all')}</TabsTrigger>
<TabsTrigger value="business">{t('filter_business')}</TabsTrigger>
<TabsTrigger value="private">{t('filter_private')}</TabsTrigger>
</TabsList>
</Tabs>
<DataList>
{showHeader && (
<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-1.5 px-2 text-xs">
{sourceFilter === 'all'
? t('source_all')
: sourceFilter === 'bank'
? t('source_bank')
: t('source_skatteverket')}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[12rem]">
<DropdownMenuRadioGroup
value={sourceFilter}
onValueChange={(v) => onSourceFilterChange(v as SourceFilter)}
>
<DropdownMenuRadioItem value="all">{t('source_all')}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="bank">{t('source_bank')}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="skatteverket">{t('source_skatteverket')}</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</DataListHeader>
{/* Business/private seg + source chip, mirroring the inbox toolbar. */}
<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">
{FILTERS.map(({ key, labelKey }) => (
<button
key={key}
type="button"
role="tab"
aria-selected={filter === key}
onClick={() => setFilter(key)}
className={cn(
'rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150',
filter === key
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{t(labelKey)}
</button>
))}
</div>
{showSourceFilter && (
<div className="ml-auto">
<ContextPicker
value={sourceFilter}
onChange={(id) => setSourceFilter(id as SourceFilter)}
triggerLabel={
sourceFilter === 'all'
? t('source_all')
: sourceFilter === 'bank'
? t('source_bank')
: t('source_skatteverket')
}
items={[
{ id: 'all', label: t('source_all') },
{ id: 'bank', label: t('source_bank') },
{ id: 'skatteverket', label: t('source_skatteverket') },
]}
/>
</div>
)}
</div>
{filtered.length === 0 ? (
<DataListEmpty
icon={<ArrowLeftRight className="h-6 w-6" />}
title={t('empty_title')}
description={searchTerm ? t('empty_search') : t('empty_filter')}
/>
) : (
filtered.map((item) =>
item.source === 'bank' ? (
<BankHistoryRow
key={`bank-${item.data.id}`}
transaction={item.data}
jeUnderlagStatus={jeUnderlagStatus}
onOpenMatchDialog={onOpenMatchDialog}
onOpenCategoryDialog={onOpenCategoryDialog}
onOpenAttachDocument={onOpenAttachDocument}
onDelete={onDelete}
/>
) : (
<SkattekontoHistoryRow
key={`skv-${item.data.id}`}
row={item.data}
onBokfor={onSkvBokfor}
onMatch={onSkvMatch}
/>
),
)
)}
</DataList>
{filtered.length === 0 ? (
<DataListEmpty
icon={<ArrowLeftRight className="h-6 w-6" />}
title={t('empty_title')}
description={searchTerm ? t('empty_search') : t('empty_filter')}
/>
) : (
<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">
{filtered.map((item) =>
item.source === 'bank' ? (
<BankHistoryRow
key={`bank-${item.data.id}`}
transaction={item.data}
jeUnderlagStatus={jeUnderlagStatus}
onOpenMatchDialog={onOpenMatchDialog}
onOpenCategoryDialog={onOpenCategoryDialog}
onOpenAttachDocument={onOpenAttachDocument}
onDelete={onDelete}
/>
) : (
<SkattekontoHistoryRow
key={`skv-${item.data.id}`}
row={item.data}
onBokfor={onSkvBokfor}
onMatch={onSkvMatch}
/>
),
)}
</tbody>
</table>
</div>
)}
{hasMore && onLoadMore && !searchTerm && filtered.length > 0 && (
<div className="flex justify-center">
@@ -261,96 +275,111 @@ function BankHistoryRow({
const hasJeDoc = jeStatus === 'has'
const missingUnderlag = isBooked && !transaction.document_id && jeStatus === 'missing'
const showAttachItem = canWrite && !!onOpenAttachDocument
const showOverflowMenu =
hasInvoiceMatch || (canDelete && !!onDelete) || (isBooked && canWrite) || showAttachItem
// Primary status badge: pick the most informative one.
const statusBadge = (() => {
if (isBooked) {
return (
<Badge variant="success" className="h-4 gap-1 px-1.5 py-0 text-[10px]">
<Check className="h-3 w-3" />
{t('posted')}
</Badge>
)
}
return (
<Badge variant="warning" className="h-4 px-1.5 py-0 text-[10px]">
{t('not_posted')}
</Badge>
)
})()
const isPrivate = transaction.is_business === false
const categoryLabel =
transaction.is_business !== null &&
!(
transaction.is_business &&
transaction.category === 'uncategorized' &&
transaction.journal_entry_id
)
? transaction.is_business
? getCategoryDisplayName(transaction.category)
: t('private_badge')
transaction.is_business === true &&
!(transaction.category === 'uncategorized' && transaction.journal_entry_id)
? getCategoryDisplayName(transaction.category)
: null
return (
<DataListRow
<tr
data-tx-id={transaction.id}
leading={
<span
className={cn(
'inline-flex h-5 w-5 items-center justify-center',
isIncome ? 'text-success' : 'text-foreground/60'
className="group transition-colors duration-150 hover:bg-secondary/35"
>
<td className={cn(TD_CLASS, 'w-[26px] !pl-1')} aria-hidden="true"></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={transaction.document_id}
journalEntryId={transaction.journal_entry_id}
hasJeDoc={hasJeDoc}
missing={missingUnderlag}
onAttach={
showAttachItem ? () => onOpenAttachDocument!(transaction) : undefined
}
/>
{categoryLabel && (
<span className="hidden shrink-0 text-xs text-muted-foreground lg:inline">
{categoryLabel}
</span>
)}
aria-hidden
>
{isIncome ? (
<ArrowUpRight className="h-4 w-4" />
) : (
<ArrowDownRight className="h-4 w-4" />
{isLinkedToInvoice && (
<span className="hidden shrink-0 items-center gap-1 text-xs text-muted-foreground md:inline-flex">
<Link2 className="h-3 w-3" />
{t('linked_to_invoice')}
</span>
)}
{hasInvoiceMatch && (
<Badge variant="secondary" className="hidden shrink-0 gap-1 font-normal md:inline-flex">
<FileText className="h-3 w-3" />
{t('possible_match_invoice', {
number: transaction.potential_invoice!.invoice_number ?? '',
})}
</Badge>
)}
</span>
}
trailing={
<>
<div className="text-right">
<p
className={cn(
'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-[11px] text-muted-foreground tabular-nums">
{formatCurrency(transaction.amount_sek)}
</p>
)}
</div>
{!isBooked && (
<Button
size="sm"
variant="default"
className="h-8 px-3 text-xs"
onClick={() => onOpenCategoryDialog(transaction)}
>
{t('book')}
</Button>
)}
{isBooked && (
<Button asChild size="sm" variant="ghost" className="h-8 px-3 text-xs">
<Link href={`/bookkeeping/${transaction.journal_entry_id}`}>
</td>
<td
className={cn(
TD_CLASS,
'whitespace-nowrap text-right tabular-nums sensitive-field',
isIncome && 'text-success',
)}
title={
transaction.currency !== 'SEK' && transaction.amount_sek != null
? formatCurrency(transaction.amount_sek)
: undefined
}
>
{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">
{isBooked ? (
<>
<span className="text-muted-foreground">
{isPrivate ? t('private_badge') : t('posted')}
</span>
<Link
href={`/bookkeeping/${transaction.journal_entry_id}`}
className={QUIET_LINK_CLASS}
>
{t('view_voucher_short')}
</Link>
</Button>
</>
) : isPrivate ? (
<span className="text-muted-foreground">{t('private_badge')}</span>
) : (
<>
<Badge variant="secondary" className="font-normal">
{t('not_posted')}
</Badge>
<Button
size="sm"
variant="outline"
className="h-7 px-3.5 text-xs"
onClick={() => onOpenCategoryDialog(transaction)}
>
{t('book')}
</Button>
</>
)}
{(hasInvoiceMatch || (canDelete && onDelete) || (isBooked && canWrite) || showAttachItem) && (
{showOverflowMenu && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
aria-label="Fler alternativ"
>
<MoreHorizontal className="h-4 w-4" />
@@ -397,53 +426,9 @@ function BankHistoryRow({
</DropdownMenuContent>
</DropdownMenu>
)}
</>
}
>
<div className="flex items-center gap-1.5 min-w-0">
<DataListPrimary>{transaction.description}</DataListPrimary>
<TransactionAttachmentIndicator
documentId={transaction.document_id}
journalEntryId={transaction.journal_entry_id}
hasJeDoc={hasJeDoc}
missing={missingUnderlag}
onAttach={
showAttachItem ? () => onOpenAttachDocument!(transaction) : undefined
}
/>
</div>
<DataListMeta>
<span className="tabular-nums">{formatDate(transaction.date)}</span>
<DataListMetaSeparator />
{statusBadge}
{categoryLabel && (
<>
<DataListMetaSeparator />
<span>{categoryLabel}</span>
</>
)}
{isLinkedToInvoice && (
<>
<DataListMetaSeparator />
<span className="inline-flex items-center gap-1">
<Link2 className="h-3 w-3" />
{t('linked_to_invoice')}
</span>
</>
)}
{hasInvoiceMatch && (
<>
<DataListMetaSeparator />
<span className="inline-flex items-center gap-1 text-primary">
<FileText className="h-3 w-3" />
{t('possible_match_invoice', {
number: transaction.potential_invoice!.invoice_number ?? '',
})}
</span>
</>
)}
</DataListMeta>
</DataListRow>
</span>
</td>
</tr>
)
}
@@ -461,101 +446,69 @@ function SkattekontoHistoryRow({
const isIncome = amount > 0
const isBooked = !!row.journal_entry_id
const statusBadge = (() => {
if (isBooked) {
return (
<Badge variant="success" className="h-4 gap-1 px-1.5 py-0 text-[10px]">
<Check className="h-3 w-3" />
{t('posted')}
</Badge>
)
}
if (row.match_suggestion) {
return (
<Badge variant="warning" className="h-4 px-1.5 py-0 text-[10px]">
{t('possible_duplicate')}
</Badge>
)
}
return (
<Badge variant="outline" className="h-4 px-1.5 py-0 text-[10px]">
{t('not_posted')}
</Badge>
)
})()
return (
<DataListRow
leading={
<span
className={cn(
'inline-flex h-5 w-5 items-center justify-center',
isIncome ? 'text-success' : 'text-foreground/60'
)}
aria-hidden
>
{isIncome ? (
<ArrowUpRight className="h-4 w-4" />
) : (
<ArrowDownRight className="h-4 w-4" />
<tr className="group transition-colors duration-150 hover:bg-secondary/35">
<td className={cn(TD_CLASS, 'w-[26px] !pl-1')} aria-hidden="true"></td>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
{formatDate(row.transaktionsdatum)}
</td>
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{row.transaktionstext}</span>
<Badge variant="outline" className="h-4 shrink-0 gap-1 px-1.5 py-0 text-[10px] font-normal">
<Landmark className="h-3 w-3" />
{t('skv_badge')}
</Badge>
{!isBooked && row.match_suggestion && (
<Badge variant="warning" className="h-4 shrink-0 px-1.5 py-0 text-[10px]">
{t('possible_duplicate')}
</Badge>
)}
</span>
}
trailing={
<>
<div className="text-right">
<p
className={cn(
'font-medium tabular-nums leading-none',
isIncome && 'text-success'
)}
>
{isIncome ? '+' : ''}
{formatCurrency(amount)}
</p>
</div>
{!isBooked && onMatch && (
<Button
size="sm"
variant={row.match_suggestion ? 'default' : 'outline'}
className="h-8 px-3 text-xs"
onClick={() => onMatch(row)}
>
<Link2 className="mr-1 h-3 w-3" />
{row.match_suggestion ? t('link') : t('match')}
</Button>
)}
{!isBooked && !row.match_suggestion && onBokfor && (
<Button
size="sm"
variant="default"
className="h-8 px-3 text-xs"
onClick={() => onBokfor(row)}
>
{t('book')}
</Button>
)}
{isBooked && (
<Button asChild size="sm" variant="ghost" className="h-8 px-3 text-xs">
<Link href={`/bookkeeping/${row.journal_entry_id}`}>
</td>
<td
className={cn(
TD_CLASS,
'whitespace-nowrap text-right tabular-nums sensitive-field',
isIncome && 'text-success',
)}
>
{isIncome ? '+' : ''}
{formatCurrency(amount)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right py-[9px]')}>
<span className="inline-flex items-center justify-end gap-2">
{isBooked ? (
<>
<span className="text-muted-foreground">{t('posted')}</span>
<Link href={`/bookkeeping/${row.journal_entry_id}`} className={QUIET_LINK_CLASS}>
{t('view_voucher_short')}
</Link>
</Button>
</>
) : (
<>
<Badge variant="secondary" className="font-normal">
{t('not_posted')}
</Badge>
{onMatch && (
<Button
size="sm"
variant="outline"
className="h-7 px-3.5 text-xs"
onClick={() => onMatch(row)}
>
{row.match_suggestion ? t('link') : t('match')}
</Button>
)}
{!row.match_suggestion && onBokfor && (
<button type="button" className={QUIET_LINK_CLASS} onClick={() => onBokfor(row)}>
{t('book')}
</button>
)}
</>
)}
</>
}
>
<DataListPrimary>{row.transaktionstext}</DataListPrimary>
<DataListMeta>
<span className="tabular-nums">{formatDate(row.transaktionsdatum)}</span>
<DataListMetaSeparator />
<span className="inline-flex items-center gap-1">
<Landmark className="h-3 w-3" />
{t('skv_badge')}
</span>
<DataListMetaSeparator />
{statusBadge}
</DataListMeta>
</DataListRow>
</td>
</tr>
)
}
+298 -309
View File
@@ -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>
)}
</>
)
}
@@ -1,82 +1,87 @@
'use client'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import Link from 'next/link'
import { Upload, Plus, CheckSquare, FileText, Lock } from 'lucide-react'
import { Upload, Plus, RefreshCw } from 'lucide-react'
import { SplitButton, type SplitButtonOption } from '@/components/ui/split-button'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { ViewMode } from './transaction-types'
import { useUiState } from '@/lib/hooks/use-ui-state'
import { resolveInitialMode } from '@/lib/ui-state/client'
import { useBankSync } from '@/components/transactions/BankSyncNowButton'
import { useAgeFormatter } from '@/components/transactions/BankSyncStatusChip'
interface TransactionStatusBarProps {
uncategorizedCount: number
invoiceMatchCount: number
mode: ViewMode
onOpenCreateDialog: () => void
isBatchMode: boolean
onToggleBatchMode: () => void
}
/**
* Page header (concept scene 10): title + one Importera split button
* holding the ways transactions arrive (bank sync, import guide for CSV/SIE,
* manual entry for cash/outlays).
*/
export default function TransactionStatusBar({
uncategorizedCount,
invoiceMatchCount,
mode,
onOpenCreateDialog,
isBatchMode,
onToggleBatchMode,
}: TransactionStatusBarProps) {
const { canWrite } = useCanWrite()
const t = useTranslations('transactions')
return (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="font-display text-2xl leading-8 tracking-tight">{t('page_title')}</h1>
{uncategorizedCount > 0 && mode === 'inbox' && (
<p className="text-muted-foreground mt-1">
<span className="text-foreground font-semibold">{uncategorizedCount}</span> {t('subtitle_to_post')}
{invoiceMatchCount > 0 && (
<span className="ml-2">
· <FileText className="inline h-3.5 w-3.5 text-primary" />{' '}
<span className="text-foreground font-semibold">{t('subtitle_matches', { count: invoiceMatchCount })}</span>
</span>
)}
</p>
)}
{mode === 'history' && (
<p className="text-muted-foreground">{t('history_subtitle')}</p>
)}
</div>
const router = useRouter()
const { uiState, loaded } = useUiState()
const { connections, hasBankSync, syncAll, lastSyncedAt } = useBankSync()
const formatAge = useAgeFormatter()
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<Link href="/import">
<Upload className="mr-2 h-4 w-4" />
{t('action_import')}
</Link>
</Button>
{mode === 'inbox' && uncategorizedCount > 0 && (
<Button
variant={isBatchMode ? 'default' : 'outline'}
size="sm"
onClick={onToggleBatchMode}
>
<CheckSquare className="mr-2 h-4 w-4" />
{isBatchMode ? t('action_select_multi_end') : t('action_select_multi_start')}
</Button>
// "Synka bank nu" (concept: first menu row) only renders once a bank is
// actually connected and the plan includes PSD2 sync; the footer
// BankSyncNowButton stays the gated conversion surface for free users.
const showSync = hasBankSync && (connections?.length ?? 0) > 0
const options: SplitButtonOption[] = [
...(showSync
? [
{
key: 'synka',
label: t('create_synka'),
icon: RefreshCw,
description: lastSyncedAt
? t('create_synka_desc_last', { age: formatAge(lastSyncedAt) })
: t('create_synka_desc'),
onSelect: () => {
void syncAll()
},
} satisfies SplitButtonOption,
]
: []),
{
key: 'importera',
label: t('action_import'),
icon: Upload,
description: t('create_import_desc'),
onSelect: () => router.push('/import'),
},
{
key: 'manuell',
label: t('action_new_transaction'),
icon: Plus,
description: t('create_manual_desc'),
disabled: !canWrite,
disabledTitle: t('viewer_disabled_tooltip'),
onSelect: () => onOpenCreateDialog(),
},
]
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="font-display text-2xl leading-8 tracking-tight">{t('page_title')}</h1>
<SplitButton
key={`${loaded ? 'loaded' : 'initial'}-${showSync ? 'sync' : 'nosync'}`}
persistKey="transactions"
initialModeKey={resolveInitialMode(
uiState,
'transactions',
options.map((o) => o.key),
'importera',
)}
<Button
size="sm"
onClick={onOpenCreateDialog}
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? (
<Plus className="mr-2 h-4 w-4" />
) : (
<Lock className="mr-2 h-4 w-4" />
)}
{t('action_new_transaction')}
</Button>
</div>
options={options}
/>
</div>
)
}
+2 -1
View File
@@ -2,8 +2,9 @@ import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
// Chips are pills (concept .chip): 99px radius, 11.5px, quiet padding.
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"inline-flex items-center rounded-full border px-2.5 py-[3px] text-[11.5px] font-medium leading-none transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
+1 -1
View File
@@ -37,7 +37,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-[var(--shadow-md)] duration-200 max-h-[calc(100dvh-2rem)] overflow-y-auto data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-[var(--shadow-md)] duration-200 max-h-[calc(100dvh-2rem)] overflow-y-auto scrollbar-visible data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
+35
View File
@@ -0,0 +1,35 @@
'use client'
import { useEffect, useState } from 'react'
// Concept dry-table (scenes 9/10): borderless table straight on the panel,
// uppercase hairline heads, 13px rows, hover-revealed row controls. Shared by
// the verifikat list and the transaction inbox so the two read as the same
// instrument. Keep these in sync with .claude/rules/design.md; page-specific
// tweaks belong at the call site via cn(), not here.
export const TH_CLASS =
'px-4 py-2.5 text-left text-[11px] font-medium uppercase tracking-[0.07em] text-muted-foreground border-b border-border whitespace-nowrap'
export const TD_CLASS = 'px-4 py-[11px] border-b border-border align-top'
export const VTH_CLASS =
'py-2 pr-4 text-left text-[10.5px] font-medium uppercase tracking-[0.07em] text-muted-foreground border-b border-border'
export const VTD_CLASS = 'py-[7px] pr-4 border-b border-border/60 align-top'
export const QUIET_LINK_CLASS =
'text-[12.5px] text-muted-foreground underline decoration-border underline-offset-4 transition-colors duration-150 hover:text-foreground'
// Animated row expansion (concept vwrap/vinner): grid-rows 0fr -> 1fr on
// mount; the global reduced-motion rule collapses the transition.
export function RowFoldout({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
useEffect(() => {
const raf = requestAnimationFrame(() => setOpen(true))
return () => cancelAnimationFrame(raf)
}, [])
return (
<div
className="grid transition-[grid-template-rows] duration-300 ease-out"
style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
>
<div className="min-h-0 overflow-hidden">{children}</div>
</div>
)
}
+1 -1
View File
@@ -88,7 +88,7 @@ function SlideOverBody({
className?: string
}) {
return (
<div className={cn('min-h-0 flex-1 overflow-y-auto px-6 py-4', className)}>
<div className={cn('min-h-0 flex-1 overflow-y-auto scrollbar-visible px-6 py-4', className)}>
{children}
</div>
)
+12 -1
View File
@@ -14,6 +14,10 @@ export interface SplitButtonOption {
icon?: LucideIcon
/** Muted second line in the menu describing what the mode does. */
description?: string
/** Disable the option (e.g. viewers): renders inert with disabledTitle
* as the tooltip instead of silently no-opping. */
disabled?: boolean
disabledTitle?: string
onSelect: () => void
}
@@ -98,6 +102,7 @@ export function SplitButton({
if (!active) return null
const runOption = (option: SplitButtonOption) => {
if (option.disabled) return
setActiveKey(option.key)
if (persistKey) rememberCreateMode(persistKey, option.key)
option.onSelect()
@@ -108,6 +113,8 @@ export function SplitButton({
<Button
variant={variant}
className="rounded-r-none"
disabled={active.disabled}
title={active.disabled ? active.disabledTitle : undefined}
onClick={() => runOption(active)}
>
{active.icon && <active.icon className="mr-1.5 h-4 w-4" />}
@@ -144,13 +151,17 @@ export function SplitButton({
key={option.key}
type="button"
role="menuitem"
disabled={option.disabled}
title={option.disabled ? option.disabledTitle : undefined}
onClick={() => {
setOpen(false)
runOption(option)
}}
className={cn(
'flex w-full items-start gap-2.5 rounded-md px-2.5 py-2 text-left transition-colors',
'text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
option.disabled
? 'cursor-not-allowed text-muted-foreground/40'
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
)}
>
{option.icon && (
+32 -5
View File
@@ -2661,7 +2661,11 @@
"possible_duplicate": "Possible duplicate",
"link": "Link",
"match": "Match",
"view_voucher_short": "View voucher"
"view_voucher_short": "View voucher",
"th_date": "Date",
"th_description": "Description",
"th_amount": "Amount",
"th_status": "Status"
},
"tx_categories": {
"expense_representation": "Representation",
@@ -4476,11 +4480,9 @@
"action_import": "Import",
"action_review_all": "Review all",
"action_review_loading": "Loading...",
"action_select_multi_start": "Select several",
"action_select_multi_end": "Done",
"action_new_transaction": "New transaction",
"viewer_disabled_tooltip": "You only have viewer access in this company",
"mode_inbox": "To post",
"mode_inbox": "To record",
"mode_history": "All transactions",
"source_label": "Source:",
"source_all": "All ({count})",
@@ -4553,7 +4555,32 @@
"bank_sync_session_expired": "Bank connection expired",
"bank_sync_session_expired_desc": "Reconnect to keep syncing transactions.",
"import_psd2_active_warning_title": "PSD2 is active for {bankName}",
"import_psd2_active_warning_body": "Transactions sync automatically each night. File imports are only needed for older history or when PSD2 isn't working; otherwise duplicates may occur."
"import_psd2_active_warning_body": "Transactions sync automatically each night. File imports are only needed for older history or when PSD2 isn't working; otherwise duplicates may occur.",
"create_import_desc": "CSV or SIE file from your bank",
"create_manual_desc": "Cash, personal outlays",
"create_synka": "Sync bank now",
"create_synka_desc": "Fetches new transactions from your bank",
"create_synka_desc_last": "Last synced {age}",
"source_all_label": "All sources",
"source_bank_label": "Bank",
"source_bank_other": "Other bank items",
"source_skatteverket_label": "Skattekonto",
"source_account_fallback": "Account",
"th_date": "Date",
"th_description": "Description",
"th_amount": "Amount",
"th_status": "Status",
"bulkbar_selected": "selected",
"batch_book": "Book",
"batch_bulk_book": "Book as one voucher",
"batch_bulk_book_hint": "Select at least two transactions from the same date and the same direction",
"batch_ignore": "Ignore",
"batch_delete": "Delete",
"batch_select_all": "Select all ({count})",
"batch_clear": "Clear selection",
"batch_progress": "Booking {done} of {total}…",
"mode_all": "All",
"footer_to_handle": "{count, plural, =0 {Nothing to handle} =1 {1 to handle} other {# to handle}}"
},
"bookkeeping": {
"edit_draft_dialog_title": "Edit draft",
+32 -5
View File
@@ -2661,7 +2661,11 @@
"possible_duplicate": "Möjlig dublett",
"link": "Koppla",
"match": "Matcha",
"view_voucher_short": "Visa verifikat"
"view_voucher_short": "Visa verifikat",
"th_date": "Datum",
"th_description": "Beskrivning",
"th_amount": "Belopp",
"th_status": "Status"
},
"tx_categories": {
"expense_representation": "Representation",
@@ -3828,7 +3832,7 @@
"confirm_post_title": "Bokför verifikat",
"confirm_post_description": "Bokförs som verifikat {voucher}: {description}, {amount}. Verifikatet kan inte ändras i efterhand, bara rättas eller stornas.",
"confirm_post_description_generic": "Verifikatet \"{description}\" bokförs med nästa lediga verifikationsnummer och kan därefter inte ändras, bara rättas eller stornas.",
"bulkbar_selected": "markerade"
"bulkbar_selected": "{count, plural, one {markerad} other {markerade}}"
},
"attachment_preview_sheet": {
"title": "Bilagor",
@@ -4476,8 +4480,6 @@
"action_import": "Importera",
"action_review_all": "Gå igenom alla",
"action_review_loading": "Laddar...",
"action_select_multi_start": "Välj flera",
"action_select_multi_end": "Avsluta",
"action_new_transaction": "Ny transaktion",
"viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
"mode_inbox": "Att bokföra",
@@ -4553,7 +4555,32 @@
"bank_sync_session_expired": "Bankanslutningen har löpt ut",
"bank_sync_session_expired_desc": "Förnya anslutningen för att fortsätta synka transaktioner.",
"import_psd2_active_warning_title": "PSD2 är aktivt för {bankName}",
"import_psd2_active_warning_body": "Transaktioner synkas automatiskt varje natt. Filimport behövs bara för äldre historik eller om PSD2 inte fungerar; annars kan dubbletter uppstå."
"import_psd2_active_warning_body": "Transaktioner synkas automatiskt varje natt. Filimport behövs bara för äldre historik eller om PSD2 inte fungerar; annars kan dubbletter uppstå.",
"create_import_desc": "CSV- eller SIE-fil från banken",
"create_manual_desc": "Kontanter, egna utlägg",
"create_synka": "Synka bank nu",
"create_synka_desc": "Hämtar nya transaktioner från banken",
"create_synka_desc_last": "Senast synkad {age}",
"source_all_label": "Alla källor",
"source_bank_label": "Bank",
"source_bank_other": "Övriga bankhändelser",
"source_skatteverket_label": "Skattekonto",
"source_account_fallback": "Konto",
"th_date": "Datum",
"th_description": "Beskrivning",
"th_amount": "Belopp",
"th_status": "Status",
"bulkbar_selected": "{count, plural, one {markerad} other {markerade}}",
"batch_book": "Bokför",
"batch_bulk_book": "Bokför i klump",
"batch_bulk_book_hint": "Välj minst två transaktioner från samma datum och samma riktning",
"batch_ignore": "Ignorera",
"batch_delete": "Ta bort",
"batch_select_all": "Markera alla ({count})",
"batch_clear": "Avmarkera",
"batch_progress": "Bokför {done} av {total}…",
"mode_all": "Alla",
"footer_to_handle": "{count, plural, =0 {Inget att hantera} =1 {1 att hantera} other {# att hantera}}"
},
"bookkeeping": {
"edit_draft_dialog_title": "Redigera utkast",