From 1ccf7ab81f4c13f36eaf5bec7e3170b991facb85 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:41:45 +0200 Subject: [PATCH] feat(transactions): concept shell and row language (UI migration PR 5) (#1124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 * 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 · ' where the sum covers SEK ledgers + skattekonto. Co-Authored-By: Claude Fable 5 * 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 * fix(i18n): singular form for bulkbar selection count Co-Authored-By: Claude Fable 5 * 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: ' 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- app/(dashboard)/transactions/page.tsx | 577 ++++++++++------- app/globals.css | 23 + components/bookkeeping/JournalEntryList.tsx | 39 +- components/transactions/BankSyncNowButton.tsx | 70 +- .../transactions/BankSyncStatusChip.tsx | 2 +- .../transactions/SkattekontoInboxCard.tsx | 200 +++--- .../transactions/TransactionHistoryList.tsx | 541 +++++++--------- .../transactions/TransactionInboxCard.tsx | 607 +++++++++--------- .../transactions/TransactionStatusBar.tsx | 131 ++-- components/ui/badge.tsx | 3 +- components/ui/dialog.tsx | 2 +- components/ui/dry-table.tsx | 35 + components/ui/slide-over.tsx | 2 +- components/ui/split-button.tsx | 13 +- messages/en.json | 37 +- messages/sv.json | 37 +- 16 files changed, 1229 insertions(+), 1090 deletions(-) create mode 100644 components/ui/dry-table.tsx diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 94b9ede4..52e90bd4 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -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:'), 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: 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 { @@ -188,11 +196,13 @@ export default function TransactionsPage() { const [processingId, setProcessingId] = useState(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>(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(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('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([]) 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(() => { + 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 (
- {/* Status bar */} - setIsDialogOpen(true)} - isBatchMode={isBatchMode} - onToggleBatchMode={() => (isBatchMode ? exitBatchMode() : setIsBatchMode(true))} - /> + {/* Page header (concept scene 10): title + Importera split button */} + setIsDialogOpen(true)} /> -
- - - {/* 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. */} - -
- {skvNeedsReconnect && ( -
- -
-

{t('skv_reconnect_title')}

-

{t('skv_reconnect_body')}

-
- -
+ + {t('skv_reconnect_body')} + )} - {/* Search + view dropdown */} -
-
+ {/* Toolbar (concept order): [Att bokföra/Alla-seg] [sök] [Välj flera] + ... [source ContextPicker far right] */} +
+
+ + +
+
- - - - - - setMode(v as typeof mode)}> - - {`Att bokföra${(totalUncategorizedCount ?? uncategorizedTransactions.length) > 0 ? ` (${totalUncategorizedCount ?? uncategorizedTransactions.length})` : ''}`} - - Alla transaktioner - - - + {/* 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 && ( +
+ 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} + /> +
+ )}
{/* Content based on mode */} @@ -2233,91 +2330,130 @@ export default function TransactionsPage() { ))} ) : mode === 'inbox' ? ( - inboxItems.length === 0 && !searchTerm && sourceFilter === 'all' ? ( - 0 || skvRows.length > 0} - onCreateTransaction={() => setIsDialogOpen(true)} - /> + inboxItems.length === 0 ? ( + searchTerm || sourceFilter !== 'all' ? ( + + ) : ( + 0 || skvRows.length > 0} + onCreateTransaction={() => setIsDialogOpen(true)} + /> + ) ) : ( - - {(sourceFilter !== 'all' - || (skvUnmatched.length > 0 && uncategorizedTransactions.length > 0)) && ( - - - {t('source_label')} - - - - - - - handleSourceFilterChange(v as SourceFilter)} - > - - {t('source_all', { count: uncategorizedTransactions.length + skvUnmatched.length })} - - - {t('source_bank', { count: uncategorizedTransactions.length })} - - - {t('source_skatteverket', { count: skvUnmatched.length })} - - - - - - )} - {inboxItems.length === 0 && (searchTerm || sourceFilter !== 'all') ? ( - - ) : null} - - {inboxItems.map(item => - item.source === 'bank' ? ( - +
+ {/* 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 && ( +
+ {batchProgress ? ( + + + {t('batch_progress', { done: batchProgress.done, total: batchProgress.total })} + ) : ( - setSkvMatchTarget(r)} - /> - ), - )} - - + <> + + {selectedIds.size}{' '} + {t('bulkbar_selected', { count: selectedIds.size })} + + + {/* Bulk-book (samlingsverifikation): only when ≥2 selected + on the same date + same direction. Disabled state + explains why via title. */} + + + + {selectedIds.size < selectableInboxIds.length && ( + + )} + + + )} +
+ )} + +
+ + + + + + + + + + + + {inboxItems.map(item => + item.source === 'bank' ? ( + + 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} + /> + ) : ( + setSkvMatchTarget(r)} + /> + ), + )} + +
{t('th_date')}{t('th_description')}{t('th_amount')}{t('th_status')}
+
+
) ) : ( )} - {/* Batch mode floating action bar */} - {isBatchMode && selectedIds.size > 0 && ( -
- {batchProgress ? ( - <> - - {batchProgress.done}/{batchProgress.total} - -

- Bokför {batchProgress.done} av {batchProgress.total}... -

- - ) : ( - <> - {selectedIds.size} valda - - - - {/* Bulk-book (samlingsverifikation): only when ≥2 selected on - the same date + same direction. Disabled state explains why - via title. */} - - - - )} -
- )} + {/* Footer status line (concept): honest counter from the visible + rows + bank sync status/actions + the Bankavstämning path (the + ignore flows point users there). */} +
+ {mode === 'inbox' && ( + {t('footer_to_handle', { count: inboxItems.length })} + )} + + + + Bankavstämning → + +
{/* Dialogs */} {showBatchSelector && ( @@ -2477,32 +2569,28 @@ export default function TransactionsPage() { )} {templatePickerOpen && - + Bokför transaktion {templatePickerTransaction && ( -
+
{templatePickerTransaction.description} - + {templatePickerTransaction.amount > 0 ? '+' : ''}{formatCurrency(templatePickerTransaction.amount, templatePickerTransaction.currency)}
)} -
- + {/* Alternate paths as quiet links (concept vact): the templates are + the main content, not three stacked buttons. */} +
+ {templatePickerTransaction && templatePickerTransaction.amount > 0 && ( - + Matcha med faktura + )} {templatePickerTransaction && ( - + Ignorera transaktionen + )}
(['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 ( -
-
{children}
-
- ) -} - export default function JournalEntryList() { const router = useRouter() const { toast } = useToast() @@ -982,7 +961,7 @@ export default function JournalEntryList() {
{selectedIds.size}{' '} - {t('bulkbar_selected')} + {t('bulkbar_selected', { count: selectedIds.size })} 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 diff --git a/components/transactions/BankSyncStatusChip.tsx b/components/transactions/BankSyncStatusChip.tsx index e610c65f..05563cdc 100644 --- a/components/transactions/BankSyncStatusChip.tsx +++ b/components/transactions/BankSyncStatusChip.tsx @@ -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() diff --git a/components/transactions/SkattekontoInboxCard.tsx b/components/transactions/SkattekontoInboxCard.tsx index 198f263d..153d8b5d 100644 --- a/components/transactions/SkattekontoInboxCard.tsx +++ b/components/transactions/SkattekontoInboxCard.tsx @@ -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 ( - { - if (typeof definition === 'object' && 'opacity' in definition && definition.opacity === 0) { - onAnimationComplete?.(row.id) - } - }} - > - - {isIncome ? ( - - ) : ( - - )} - - } - trailing={ - <> -
-

- {isIncome ? '+' : ''} - {formatCurrency(amount)} -

-
- {matchSuggestion ? ( - <> - - - - ) : ( - <> - - - - )} - - } - > -
- {row.transaktionstext} -
- - {formatDate(row.transaktionsdatum)} - - + + + + {formatDate(row.transaktionsdatum)} + + + + {row.transaktionstext} + {t('skv_badge')} - + {matchSuggestion && ( + + + {duplicateLabel} + + )} + + + + {isIncome ? '+' : ''} + {formatCurrency(amount)} + + + + {matchSuggestion ? ( <> - - - - {duplicateLabel} - + {/* Likely duplicate: linking beats re-booking, so it leads. */} + + + + ) : ( + <> + + )} - -
-
+ + + ) } diff --git a/components/transactions/TransactionHistoryList.tsx b/components/transactions/TransactionHistoryList.tsx index a458a9c0..bd45352a 100644 --- a/components/transactions/TransactionHistoryList.tsx +++ b/components/transactions/TransactionHistoryList.tsx @@ -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 (
- {/* Business/private tabs */} - setFilter(v as HistoryFilter)}> - - {t('filter_all')} - {t('filter_business')} - {t('filter_private')} - - - - - {showHeader && ( - - - {t('source_label')} - - - - - - - onSourceFilterChange(v as SourceFilter)} - > - {t('source_all')} - {t('source_bank')} - {t('source_skatteverket')} - - - - + {/* Business/private seg + source chip, mirroring the inbox toolbar. */} +
+
+ {FILTERS.map(({ key, labelKey }) => ( + + ))} +
+ {showSourceFilter && ( +
+ 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') }, + ]} + /> +
)} +
- {filtered.length === 0 ? ( - } - title={t('empty_title')} - description={searchTerm ? t('empty_search') : t('empty_filter')} - /> - ) : ( - filtered.map((item) => - item.source === 'bank' ? ( - - ) : ( - - ), - ) - )} -
+ {filtered.length === 0 ? ( + } + title={t('empty_title')} + description={searchTerm ? t('empty_search') : t('empty_filter')} + /> + ) : ( +
+ + + + + + + + + + + + {filtered.map((item) => + item.source === 'bank' ? ( + + ) : ( + + ), + )} + +
{t('th_date')}{t('th_description')}{t('th_amount')}{t('th_status')}
+
+ )} {hasMore && onLoadMore && !searchTerm && filtered.length > 0 && (
@@ -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 ( - - - {t('posted')} - - ) - } - return ( - - {t('not_posted')} - - ) - })() - + 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 ( - + + + {formatDate(transaction.date)} + + + + {transaction.description} + onOpenAttachDocument!(transaction) : undefined + } + /> + {categoryLabel && ( + + {categoryLabel} + )} - aria-hidden - > - {isIncome ? ( - - ) : ( - + {isLinkedToInvoice && ( + + + {t('linked_to_invoice')} + + )} + {hasInvoiceMatch && ( + + + {t('possible_match_invoice', { + number: transaction.potential_invoice!.invoice_number ?? '', + })} + )} - } - trailing={ - <> -
-

- {isIncome ? '+' : ''} - {formatCurrency(transaction.amount, transaction.currency)} -

- {transaction.currency !== 'SEK' && transaction.amount_sek != null && ( -

- {formatCurrency(transaction.amount_sek)} -

- )} -
- {!isBooked && ( - - )} - {isBooked && ( - + + ) : isPrivate ? ( + {t('private_badge')} + ) : ( + <> + + {t('not_posted')} + + + )} - {(hasInvoiceMatch || (canDelete && onDelete) || (isBooked && canWrite) || showAttachItem) && ( + {showOverflowMenu && ( - )} - {!isBooked && !row.match_suggestion && onBokfor && ( - - )} - {isBooked && ( - + + ) : ( + <> + + {t('not_posted')} + + {onMatch && ( + + )} + {!row.match_suggestion && onBokfor && ( + + )} + )} - - } - > - {row.transaktionstext} - - {formatDate(row.transaktionsdatum)} - - - - {t('skv_badge')} - - {statusBadge} - -
+ + ) } diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx index 53c79237..d014d52b 100644 --- a/components/transactions/TransactionInboxCard.tsx +++ b/components/transactions/TransactionInboxCard.tsx @@ -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 ( - - ) - } - if (hasSupplierInvoiceMatch) { - return ( - - ) - } - return ( - - ) - })() + 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 ( - { - if (typeof definition === 'object' && 'opacity' in definition && definition.opacity === 0) { - onAnimationComplete?.(transaction.id) - } - }} - > - + 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) */} + e.stopPropagation()} + > + {selectable && ( onToggleSelect(transaction.id)} - onClick={(e) => e.stopPropagation()} aria-label="Välj transaktion" - /> - ) : ( - - {isIncome ? ( - - ) : ( - - )} - - ) - } - trailing={ - <> -
-

- {isIncome ? '+' : ''} - {formatCurrency(transaction.amount, transaction.currency)} -

- {transaction.currency !== 'SEK' && transaction.amount_sek != null && ( -

- {formatCurrency(transaction.amount_sek)} -

- )} -
- {!isBatchMode && ( - <> - {primaryAction} - {showInvoiceMatchButton && ( - - )} - {/* "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 && ( - - )} - {/* Secondary actions (split, edit, delete) collapse into a ⋯ - overflow menu so the row stays uncluttered. */} - {showOverflowMenu && ( - - - - - - {showMatchVoucherItem && ( - { - e.stopPropagation() - onOpenMatchVoucher!(transaction) - }} - > - - {t('match_voucher_btn')} - - )} - {showAttachDocumentItem && ( - { - e.stopPropagation() - onOpenAttachDocument!(transaction) - }} - > - - {t('attach_document_btn')} - - )} - {showSplitItem && ( - { - e.stopPropagation() - onOpenSplitMatch!(transaction) - }} - > - - {splitMatchLabel} - - )} - {showEditItem && ( - { - e.stopPropagation() - onEditTitle!(transaction) - }} - > - - {t('edit_title_aria')} - - )} - {(showIgnoreItem || showDeleteItem) && (showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem) && ( - - )} - {showIgnoreItem && ( - { - e.stopPropagation() - onIgnore!(transaction) - }} - > - - {t('ignore_btn')} - - )} - {showDeleteItem && ( - { - e.stopPropagation() - onDelete!(transaction.id) - }} - > - - {t('delete_aria')} - - )} - - - )} - - )} - - } - > -
- {transaction.description} - -
- - {formatDate(transaction.date)} - {transaction.title_edited_at && ( - <> - + /> + )} + + + {formatDate(transaction.date)} + + + + {transaction.description} + + {transaction.title_edited_at && ( {t('edited_badge')} - - )} - {skvCounterpartDate && ( - <> - - + )} + {skvCounterpartDate && ( + Möjlig 1930↔1630 - + )} + + + - {/* 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') && ( -
- + {isIncome ? '+' : ''} + {formatCurrency(transaction.amount, transaction.currency)} + + + + + {showOverflowMenu && ( + + + + + + {showInvoiceMatchButton && ( + { + e.stopPropagation() + onOpenMatchInvoicePicker(transaction) + }} + > + + {invoiceMatchLabel} + + )} + {showAskAssistant && ( + { + e.stopPropagation() + askAssistant() + }} + > + + {`Fråga ${assistantName}`} + + )} + {showMatchVoucherItem && ( + { + e.stopPropagation() + onOpenMatchVoucher!(transaction) + }} + > + + {t('match_voucher_btn')} + + )} + {showAttachDocumentItem && ( + { + e.stopPropagation() + onOpenAttachDocument!(transaction) + }} + > + + {t('attach_document_btn')} + + )} + {showSplitItem && ( + { + e.stopPropagation() + onOpenSplitMatch!(transaction) + }} + > + + {splitMatchLabel} + + )} + {showEditItem && ( + { + e.stopPropagation() + onEditTitle!(transaction) + }} + > + + {t('edit_title_aria')} + + )} + {(showIgnoreItem || showDeleteItem) && (showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem) && ( + + )} + {showIgnoreItem && ( + { + e.stopPropagation() + onIgnore!(transaction) + }} + > + + {t('ignore_btn')} + + )} + {showDeleteItem && ( + { + e.stopPropagation() + onDelete!(transaction.id) + }} + > + + {t('delete_aria')} + + )} + + + )} + {canExpand ? ( + -
- )} -
-
+ ) : ( + + )} + + + + {expanded && ( + + + +
+ {(transaction.currency !== 'SEK' && transaction.amount_sek != null) || + transaction.title_edited_at || + skvCounterpartDate ? ( +
+ {transaction.currency !== 'SEK' && transaction.amount_sek != null && ( +

+ {formatCurrency(transaction.amount, transaction.currency)} + {' · '} + {formatCurrency(transaction.amount_sek)} +

+ )} + {transaction.title_edited_at && originalName && ( +

{t('original_name_tooltip', { name: originalName })}

+ )} + {skvCounterpartDate && ( +

+ {t('skv_counterpart_label')}{' '} + {t('skv_counterpart_body', { date: skvCounterpartDate })} +

+ )} +
+ ) : 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') && ( +
+ +
+ )} + +
+
+ + + )} + ) } diff --git a/components/transactions/TransactionStatusBar.tsx b/components/transactions/TransactionStatusBar.tsx index e0ec312e..63bfd86a 100644 --- a/components/transactions/TransactionStatusBar.tsx +++ b/components/transactions/TransactionStatusBar.tsx @@ -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 ( -
-
-

{t('page_title')}

- {uncategorizedCount > 0 && mode === 'inbox' && ( -

- {uncategorizedCount} {t('subtitle_to_post')} - {invoiceMatchCount > 0 && ( - - · {' '} - {t('subtitle_matches', { count: invoiceMatchCount })} - - )} -

- )} - {mode === 'history' && ( -

{t('history_subtitle')}

- )} -
+ const router = useRouter() + const { uiState, loaded } = useUiState() + const { connections, hasBankSync, syncAll, lastSyncedAt } = useBankSync() + const formatAge = useAgeFormatter() -
- - {mode === 'inbox' && uncategorizedCount > 0 && ( - + // "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 ( +
+

{t('page_title')}

+ o.key), + 'importera', )} - -
+ options={options} + />
) } diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx index dcfcc0ca..1be6c51f 100644 --- a/components/ui/badge.tsx +++ b/components/ui/badge.tsx @@ -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: { diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index c711cfad..7286e717 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -37,7 +37,7 @@ const DialogContent = React.forwardRef< 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 ( +
+
{children}
+
+ ) +} diff --git a/components/ui/slide-over.tsx b/components/ui/slide-over.tsx index c5f5cd94..1f1b4ff8 100644 --- a/components/ui/slide-over.tsx +++ b/components/ui/slide-over.tsx @@ -88,7 +88,7 @@ function SlideOverBody({ className?: string }) { return ( -
+
{children}
) diff --git a/components/ui/split-button.tsx b/components/ui/split-button.tsx index ad181b9a..fb070f05 100644 --- a/components/ui/split-button.tsx +++ b/components/ui/split-button.tsx @@ -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({