feat(transactions): concept shell and row language (UI migration PR 5) (#1124)
* feat(transactions): concept shell for scene 10 (header, toolbar, footer)
- Header: title + Importera SplitButton (import guide / manual entry,
last-used mode persisted via ui_state.create_mode.transactions),
replacing the three-button row
- Toolbar in concept order: [Att bokföra/Alla seg with count chip]
[search] [Välj flera as quiet toggle] ... [source ContextPicker far
right merging the old in-list source dropdown (bank/Skatteverket)]
- Bank sync status + sync-now + Bankavstämning move to the concept's
footer status line together with the honest visible-row counter
("n att hantera")
- skv-reconnect banner and all dialogs/actions untouched
Row cards -> concept dry-table conversion follows separately.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(transactions): concept row language for the inbox cards
Rows read like the scene 10 table: date as the first tabular column,
description on one line, amount right (+income in sage), auto-detected
invoice matches as beige suggestion chips instead of primary pills,
tighter hairline rows at 13px. Every action unchanged: Bokför pill,
match/assistant/overflow menu, batch checkboxes, exit animations,
extraction status.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(transactions): de-bloat rows and chrome to match the concept
- Rows carry only the essentials: date, description, amount, Bokför and
one overflow menu. The inline match and ask-assistant icon buttons
fold into the overflow menu (all actions still one click away)
- The "n nya banktransaktioner sen ditt senaste besök" banner is removed;
the footer sync line covers it
- The Skatteverket reconnect banner becomes the one-ochre-sentence
AttnLine with an inline action (convention 6)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(ui): extract dry-table primitives into components/ui/dry-table
The concept table styles (TH/TD/VTH/VTD classes, quiet links, RowFoldout)
move out of JournalEntryList so the transaction inbox can render the exact
same table language.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(transactions): Synka bank nu in the Importera menu
Extracts the per-connection sync/reconnect logic into a shared useBankSync()
hook (BankSyncNowButton keeps using it) and adds the concept's first menu row
to the Importera split button: sync all active connections, with the
last-synced age as the row description. Hidden without a connection or the
bank_sync capability; the footer button stays the gated conversion surface.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(transactions): per-account source picker with balances
The context chip becomes the concept's account chooser: always visible in
inbox mode, one row per enabled cash account (PSD2 balance annotation),
an Övriga bucket for bank rows without a registered account, and a
Skattekonto row annotated with the cached saldo. The trigger reads
'Alla källor · <sum>' where the sum covers SEK ledgers + skattekonto.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(transactions): concept dry-table for the inbox
The inbox list becomes the exact Bokforing table: borderless dry-table with
hover-revealed checkboxes and chevrons, one-line rows (date, description,
amount, quiet primary pill + overflow + foldout), and a RowFoldout expansion
carrying the row detail and the full action set as quiet links. Skattekonto
rows render as chip-marked table rows with inline actions. The 'Valj flera'
toggle and the floating batch bar are replaced by the concept bulkbar that
pops in above the table once a row is selected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(i18n): singular form for bulkbar selection count
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(transactions): suggestion-first Bokfor flow (concept scene 10)
Bokfor on a row with a suggestion now goes straight to the compact
kontering confirm (QuickReview) showing the proposed verifikat; the full
template picker becomes the fallback and the 'Byt mall' path. Rows carry
the concept's 'Forslag: <mall>' chip (with D/K in the tooltip and the
foldout meta). The picker dialog is de-bloated: alternate paths (manuellt,
matcha, ignorera) collapse into quiet links. Modal scroll containers
(DialogContent, SlideOverBody) keep a visible scrollbar thumb: with the
app-wide auto-hide, a long dialog read as cut off at the fold.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(transactions): drop suggestion UI and foldout action links
Founder direction: no booking suggestions in the list for now (logic comes
later), and the foldout duplicated the overflow menu: the three dots are
the clearer surface. The foldout keeps row detail only (FX conversion,
original bank name, 1930/1630 hint, extraction status) and rows without
any detail no longer expand. The Skattekonto picker row drops its saldo
annotation; the Alla kallor sum covers bank ledgers only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(transactions): dry-table for the Alla view + pill chips app-wide
The history list joins the inbox's table language: Datum, Beskrivning,
Belopp, Status columns with Bokford as muted text + Visa verifikat quiet
link (normal state) and Ej bokford as the beige exception chip with an
inline Bokfor pill; the business/private tabs become the house seg and the
KALLA header row becomes the standard chip-picker. Skattekonto rows get
the same treatment. SEK conversion moves to a tooltip (one-line rows).
Badge itself becomes the concept chip: pill radius (99px), 11.5px, quiet
padding, replacing the boxy rounded-md look the founder flagged on the
Underlag saknas badge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): audit round: viewer-disabled split options, touch fallback
Regression-audit fixes: SplitButton options support disabled+disabledTitle
so viewer-gated create paths render inert with the viewer tooltip instead
of silently no-opping (wired on Ny transaktion); the history list gets its
Kopplad till faktura indicator back; dead transactionsWithMatches memo
removed; and coarse-pointer devices now always show hover-revealed
controls (checkboxes, chevrons, quiet row actions) since touch has no
hover.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
aa72a75dfc
commit
1ccf7ab81f
@@ -4,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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user