From c897a906df66cf8015b46d93bda83b4747db7c6d Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Sun, 16 Aug 2026 18:44:02 +0200 Subject: [PATCH] fix(ux): actions update lists in place - no more takeovers, jumps and dead air (#1629) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ux): update lists in place on actions instead of takeover spinners and jumps Founder report: the app feels glitchy when clicking around, especially when deleting a row or booking something. The repo-wide anti-pattern behind it: single-row actions trigger whole-list skeleton/spinner takeovers (layout collapse, scroll jump, full stagger-enter replay), deletes give zero feedback then hard-jump, and the /transactions exit "animation" was filter-only and never animated. Per surface: - Never take over a rendered list for a background refresh. The skeleton/spinner swap is now reserved for an empty (or foreign) list on /transactions (fetchTransactions), /pending (fetchOperations, covering both listed Granskning findings, one file), kundfakturor (fetchInvoices), leverantörsfakturor (fetchInvoices, plus try/catch/finally so a failed fetch can no longer stick the skeleton or masquerade as an empty register) and the verifikat list (JournalEntryList now takes a refreshToken prop and refetches in place; /bookkeeping no longer key-remounts it into a spinner, so expansion/selection/pagination/scroll survive a created verifikat). Quiet inline Loader2 cues near the list headers on /transactions and /pending signal a background reconcile. - /transactions row exit: exiting rows (booked/ignored/deleted) stay rendered through the existing 350ms window with a real exit transition (.row-exit: fast fade, then the space closes by transitioning cell paddings/line metrics and a numeric max-height on the fixed-height cell spans) and pointer-events off. Instant removal under prefers-reduced-motion. Applied to the inbox cards, the skattekonto card and the history rows. - /transactions delete: routes through processingId (row spinner) and the exitingIds path, and decrements totalUncategorizedCount when the deleted row was pending (the realtime echo is not guaranteed for DELETE on a filtered subscription). - FyPicker double-fetch: the initial fetch now waits for FyPicker's onReady (fires after its persisted-scope restore), so mount does one correctly scoped fetch instead of racing an unscoped fetch against the restore refetch (list -> skeleton -> list on every visit). Period changes refetch background-only behind the client-filtered list. - Pagination survives realtime echoes: background refreshes re-fetch range(0, pagedCountRef) instead of resetting to the first 200 rows, so "Visa fler" pages no longer collapse after any action. Gates: full vitest suite green (14764 passed), tsc output byte-identical to the origin/main baseline, eslint 0 errors on touched files, check:guards green, package-lock untouched. Co-Authored-By: Claude Fable 5 * fix(ui): apply review round on action-feedback smoothness - /pending: sequence-guard fetchOperations so a stale previous-tab response can't overwrite the current tab's rows, counts, or loading cues - /pending: check res.ok on the pending fetch and both history fetches before applying payloads; failures keep current rows and surface the existing error toast - /transactions: reset fiscal scope (fyReady/fyPeriodId/fyPeriod) during render on company switch so FyPicker re-runs its persisted restore and stale bounds never scope a fetch for the wrong company - /transactions: drop a deleted row's id from selectedIds so the bulk bar can't act on a deleted row - row exit: add the inert attribute on exiting row wrappers alongside pointer-events so keyboard focus and activation are blocked too - JournalEntryList: preserve selection on refreshToken background refreshes (reconciled against the refreshed page); user-initiated reloads still clear it Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 2 + app/(dashboard)/bookkeeping/page.tsx | 5 +- app/(dashboard)/invoices/page.tsx | 6 +- app/(dashboard)/pending/page.tsx | 59 ++++++- app/(dashboard)/supplier-invoices/page.tsx | 29 +++- app/(dashboard)/transactions/page.tsx | 162 ++++++++++++++++-- app/globals.css | 51 ++++++ components/bookkeeping/JournalEntryList.tsx | 47 ++++- .../transactions/SkattekontoInboxCard.tsx | 13 +- .../transactions/TransactionHistoryList.tsx | 20 ++- .../transactions/TransactionInboxCard.tsx | 18 +- .../__tests__/booking-feedback-parity.test.ts | 8 +- messages/en.json | 4 + messages/sv.json | 4 + 14 files changed, 385 insertions(+), 43 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 0953a887..33665d5e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1019,4 +1019,6 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-15] Confirmed intentional (Swedish-review note): with override=true and an unresolvable filename, the attach endpoint links a document to any same-company, same-declared-year, posted verifikat, migrated or not. This mirrors /api/documents/[id]/link, which imposes no filename check at all, so it introduces no new capability class; tenant, year and period-lock enforcement always apply. [2026-08-15] BankID tabs bind to a random non-secret `flowId` signed into the shared flow cookie and sent as a request header after start or explicit resume: mode pinning alone cannot distinguish two same-mode tabs, so an older tab could otherwise silently follow, cancel, or complete a newer person's identification after `/start` replaced the origin-wide cookie. This supersedes the 2026-08-15 decision that deliberately skipped mode matching on active polls. [2026-08-15] Did not apply BankID migration `20260815120000` to Supabase staging during PR #1625 follow-through: read-only reconciliation found 14 staging-only and 99 branch-only migration versions, so applying on top of that divergent ledger would violate the no-orphan rule. Production is reconciled with zero remote-only versions and exactly this PR migration local-only; hosted pg-real validates the migration until staging is reconciled. +[2026-08-16] /transactions FyPicker double-fetch fixed by gating the initial fetch on FyPicker's existing onReady (fires after its restore onChange) instead of the analysis doc's literal "read the persisted period synchronously in initial state": localStorage only holds the period ID, not the FiscalPeriod bounds, so a synchronous read would suppress FyPicker's restore (value !== null) and leave the fetch permanently unscoped while the chip claimed a year. Same outcome (one scoped fetch per mount, background refetch on period change) without a stale-bounds cache or new FyPicker API. +[2026-08-16] Row exit animation for dry-table rows collapses via td padding/line-height/font-size transitions plus a numeric max-height (.row-collapsible) on the fixed-height cell spans, not grid-template-rows 0fr (the AttGoraSection pattern): table cells cannot host the grid wrapper without restructuring every td, and max-height needs a numeric rest value because auto/none does not interpolate. prefers-reduced-motion hides the exiting row instantly (display: none) while the 350ms timer does the state cleanup. [2026-08-16] Restyled QuickReviewDialog's inbox-picker trigger to the same full-width dropzone-footer row as TransactionBookingDialog even though it did not share the orphan-button layout: both surfaces come from #1620 and should present the same underlag affordance; the alternative (leaving a small outline button in one dialog and a footer row in the other) would split the visual language of one control. Presentation only, disabled-while-booking kept (PR #1628). diff --git a/app/(dashboard)/bookkeeping/page.tsx b/app/(dashboard)/bookkeeping/page.tsx index 61a723e5..e6974c22 100644 --- a/app/(dashboard)/bookkeeping/page.tsx +++ b/app/(dashboard)/bookkeeping/page.tsx @@ -265,8 +265,11 @@ export default function BookkeepingPage() { } /> + {/* refreshToken, NOT key: a created verifikat refreshes the list in + place (dim + refetch) instead of remounting it into a spinner and + losing expansion/selection/scroll. */} ( ({ from, to }) => diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index cef8a5f9..c354cf67 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect, useCallback, useMemo } from 'react' +import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' import { Badge } from '@/components/ui/badge' @@ -324,32 +324,73 @@ export default function PendingOperationsPage() { if (conv) setConversationFilter(conv) }, []) + // Which tab the rows in `operations` belong to, and whether anything is on + // screen: lets refetches decide between the first-load takeover and a + // background reconcile without making row state a useCallback dependency + // (which would re-subscribe the realtime channel on every data change). + const loadedTabRef = useRef(null) + const hasRowsRef = useRef(false) + useEffect(() => { + hasRowsRef.current = operations.length > 0 + }, [operations]) + // Background reconcile in flight: drives the quiet toolbar cue. + const [isRefreshing, setIsRefreshing] = useState(false) + // Monotonic request sequence: a fetch kicked off for a previous tab (or an + // older realtime echo) can resolve after the current one. Only the latest + // request may touch rows, counts, refs, toasts, or loading cues; stale + // responses bail out and leave the newer request's state alone. + const fetchSequenceRef = useRef(0) + const fetchOperations = useCallback(async () => { - setIsLoading(true) + const sequence = ++fetchSequenceRef.current + const isCurrent = () => sequence === fetchSequenceRef.current + // The list-for-spinner swap is reserved for a first load or a tab whose + // rows aren't on screen yet. Approving/rejecting a row (and its realtime + // echo) used to run list → spinner → list → spinner → list: a whole-page + // layout collapse plus a full stagger-enter replay, twice, for a + // single-row change. + const takeover = !hasRowsRef.current || loadedTabRef.current !== activeTab + if (takeover) setIsLoading(true) + else setIsRefreshing(true) try { if (activeTab === 'pending') { const res = await fetch('/api/pending-operations?status=pending') + // A JSON error body parses fine but carries no data: without this + // check a failed refresh would blank the list and zero the badge + // instead of keeping current rows and surfacing the error toast. + if (!res.ok) throw new Error(`HTTP ${res.status}`) const json = await res.json() + if (!isCurrent()) return setOperations(json.data ?? []) setPendingCount(json.count ?? json.data?.length ?? 0) + loadedTabRef.current = 'pending' } else { // The API is single-status per fetch: Historik merges godkända and // avvisade, newest resolution first. - const [committed, rejected] = await Promise.all([ - fetch('/api/pending-operations?status=committed').then((r) => r.json()), - fetch('/api/pending-operations?status=rejected').then((r) => r.json()), + const [committedRes, rejectedRes] = await Promise.all([ + fetch('/api/pending-operations?status=committed'), + fetch('/api/pending-operations?status=rejected'), ]) + if (!committedRes.ok || !rejectedRes.ok) { + throw new Error(`HTTP ${committedRes.status}/${rejectedRes.status}`) + } + const [committed, rejected] = await Promise.all([committedRes.json(), rejectedRes.json()]) + if (!isCurrent()) return const merged = ([...(committed.data ?? []), ...(rejected.data ?? [])] as PendingOperation[]).sort( (a, b) => (b.resolved_at ?? b.created_at).localeCompare(a.resolved_at ?? a.created_at), ) setOperations(merged) const pc = committed.counts?.pending ?? rejected.counts?.pending if (typeof pc === 'number') setPendingCount(pc) + loadedTabRef.current = 'history' } } catch { + if (!isCurrent()) return toast({ title: 'Kunde inte ladda operationer', variant: 'destructive' }) } + if (!isCurrent()) return setIsLoading(false) + setIsRefreshing(false) }, [activeTab, toast]) useEffect(() => { @@ -743,6 +784,14 @@ export default function PendingOperationsPage() { count: tab === 'pending' ? pendingCount ?? 0 : undefined, }))} /> + {/* Quiet cue that a background reconcile is running (post-action or + realtime): the list itself never swaps to a spinner for it. */} + {isRefreshing && !isLoading && ( + + )}
router.push('/supplier-invoices?new=1', { scroll: false }) async function fetchInvoices() { - setIsLoading(true) - const res = await fetch('/api/supplier-invoices?status=all') - const { data } = await res.json() - setInvoices(data || []) - setIsLoading(false) + // Skeleton takeover only while nothing is on screen: refetches after an + // action (register, betalfil, approve fallback) reconcile BEHIND the + // rendered table instead of collapsing it to 4 skeleton stubs and + // replaying the entrance animation. + if (invoices.length === 0) setIsLoading(true) + try { + const res = await fetch('/api/supplier-invoices?status=all') + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const { data } = await res.json() + setInvoices(data || []) + } catch { + // Without this, a failed fetch either stuck the skeleton forever or + // silently rendered the empty state as if the invoices were gone. + toast({ + title: t('load_failed_title'), + description: t('load_failed_description'), + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } } // Which invoices already sit in an active (not cancelled) betalfil: feeds @@ -144,6 +160,9 @@ export default function SupplierInvoicesPage() { useEffect(() => { fetchInvoices() fetchActiveBatchMembership() + // Mount-only fetch (same pattern as /invoices): fetchInvoices reads state + // only to decide skeleton vs background refresh. + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // Mirrors the old standalone page's post-create navigation: inbox diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 0a3b0f2f..389865e5 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -577,10 +577,18 @@ export default function TransactionsPage() { } }, [companyId, supabase]) - // Computed lists + // Computed lists. Exiting rows (just booked/ignored/deleted) stay IN this + // list on purpose: they render for the 350ms exit window with the .row-exit + // collapse animation instead of popping out the same frame, and the delayed + // state patch (finishBooking et al.) is what actually drops them. Logic + // surfaces that must not act on a leaving row (batch select) exclude + // exitingIds themselves. const uncategorizedTransactions = useMemo( () => transactions - .filter((t) => t.is_business === null && !t.is_ignored && !exitingIds.has(t.id)) + // The exitingIds clause pins a leaving row even when a realtime-echo + // refetch replaces its state with the booked shape mid-window: the + // animation still finishes instead of being cut to a jump. + .filter((t) => (t.is_business === null && !t.is_ignored) || exitingIds.has(t.id)) .sort((a, b) => { const aHasMatch = a.potential_invoice || a.potential_supplier_invoice ? 1 : 0 const bHasMatch = b.potential_invoice || b.potential_supplier_invoice ? 1 : 0 @@ -656,8 +664,9 @@ export default function TransactionsPage() { 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 - if (exitingIds.has(r.id)) continue + // Exiting rows stay rendered for the exit animation, even if a + // refetch already gave them a journal_entry_id mid-window (see above). + if (r.journal_entry_id && !exitingIds.has(r.id)) continue // SKV rows live client-side only, so the period filter applies here. if (!isWithinBounds(r.transaktionsdatum, periodBounds)) continue if ( @@ -788,8 +797,13 @@ export default function TransactionsPage() { // Rows the bulkbar's "Markera alla" can select: the visible bank rows // (they feed the /api/transactions/* batch handlers) ... const selectableInboxIds = useMemo( - () => inboxItems.filter((item) => item.source === 'bank').map((item) => item.data.id), - [inboxItems], + () => + inboxItems + // Rows mid-exit still render (for the animation) but must not be + // selectable: they are already booked/deleted server-side. + .filter((item) => item.source === 'bank' && !exitingIds.has(item.data.id)) + .map((item) => item.data.id), + [exitingIds, inboxItems], ) // ... plus the visible skattekonto rows whose booking is deterministic @@ -798,9 +812,14 @@ export default function TransactionsPage() { const selectableSkvIds = useMemo( () => inboxItems - .filter((item) => item.source === 'skatteverket' && isSkvBulkEligible(item.data)) + .filter( + (item) => + item.source === 'skatteverket' && + isSkvBulkEligible(item.data) && + !exitingIds.has(item.data.id), + ) .map((item) => item.data.id), - [inboxItems], + [exitingIds, inboxItems], ) const skvSelectedRows = useMemo( @@ -907,9 +926,20 @@ export default function TransactionsPage() { } }, []) - const fetchTransactions = useCallback(async (showLoading = false, includeSkvRows = false) => { + const fetchTransactions = useCallback(async ( + showLoading = false, + includeSkvRows = false, + // Background refreshes (realtime echo) re-fetch the window the user has + // already paged through instead of resetting to the first PAGE_SIZE rows: + // an action after "Visa fler" must not collapse the loaded pages and + // yank the scroll position. + preserveWindow = false, + ) => { if (!companyId) return const generation = ++fetchGenerationRef.current + const windowSize = preserveWindow + ? Math.max(pagedCountRef.current, PAGE_SIZE) + : PAGE_SIZE if (showLoading) setIsLoading(true) if (includeSkvRows) void loadSkvRows() try { @@ -934,7 +964,7 @@ export default function TransactionsPage() { windowQuery .order('date', { ascending: false }) .order('id', { ascending: true }) - .limit(PAGE_SIZE), + .range(0, windowSize - 1), supabase .from('transactions') .select('*', { count: 'exact', head: true }) @@ -1004,8 +1034,8 @@ export default function TransactionsPage() { setTransactions(transactionsWithInvoices) setTotalUncategorizedCount(uncatCount ?? 0) pagedCountRef.current = rows.length - setPagedThroughDate(rows.length >= PAGE_SIZE ? rows[rows.length - 1].date : null) - setHasMore(rows.length >= PAGE_SIZE) + setPagedThroughDate(rows.length >= windowSize ? rows[rows.length - 1].date : null) + setHasMore(rows.length >= windowSize) } finally { // Only the newest request may touch the skeleton: a stale one must not @@ -1027,7 +1057,7 @@ export default function TransactionsPage() { try { do { refreshTransactionsQueuedRef.current = false - await fetchTransactions(false, false) + await fetchTransactions(false, false, true) } while (refreshTransactionsQueuedRef.current) } finally { refreshTransactionsInFlightRef.current = false @@ -1235,10 +1265,65 @@ export default function TransactionsPage() { } } - // Fetch the initial page. Entity type is already available from CompanyContext. + // The initial fetch waits for FyPicker to finish restoring the persisted + // period scope (onReady fires after its restore onChange). Fetching at + // mount used to race the restore: an unscoped fetch painted the list, the + // restore re-created fetchTransactions and re-ran this effect with a second + // skeleton takeover, so every visit flashed list → skeleton → list. + const [fyReady, setFyReady] = useState(false) + const handleFyReady = useCallback(() => setFyReady(true), []) + // Whether anything is on screen, readable from the effect without making + // row state a dependency (which would refetch on every row patch). + const hasRowsRef = useRef(false) useEffect(() => { - void fetchTransactions(true, true) - }, [fetchTransactions]) + hasRowsRef.current = transactions.length > 0 || skvRows.length > 0 + }, [transactions, skvRows]) + const lastFetchCompanyRef = useRef(null) + // Subtle "refreshing" cue next to the period chip while a scope change + // refetches behind the rendered list. Sequence-guarded so overlapping + // scope changes can't clear the cue early. + const [isScopeRefreshing, setIsScopeRefreshing] = useState(false) + const scopeRefreshSeqRef = useRef(0) + + // Fiscal scope must not survive a company switch: periodBounds from the + // previous company would scope the first fetch for the new one, and a + // non-null fyPeriodId makes FyPicker skip its persisted-scope restore + // (it only restores when value === null). Resetting during render (React's + // adjust-state-on-prop-change pattern) rather than in an effect guarantees + // FyPicker's restore effect observes the cleared value: its effect captures + // `value` at commit time, and child effects run before a parent effect + // could reset it. fyReady = false holds the fetch effect until the new + // company's restore completes via onReady. + const [scopeCompanyId, setScopeCompanyId] = useState(companyId) + if (scopeCompanyId !== companyId) { + setScopeCompanyId(companyId) + setFyReady(false) + setFyPeriodId(null) + setFyPeriod(null) + // A scope refresh in flight belongs to the old company: invalidate its + // finally-guard and drop the cue. + scopeRefreshSeqRef.current++ + setIsScopeRefreshing(false) + } + + // Fetch the page whenever the scope changes (mount, company switch, period + // change). The skeleton takeover is reserved for an empty or foreign list: + // a period change refetches BEHIND the rendered rows, whose client-side + // isWithinBounds filters already show the new scope correctly. + useEffect(() => { + if (!fyReady) return + const companySwitched = lastFetchCompanyRef.current !== companyId + lastFetchCompanyRef.current = companyId + if (companySwitched || !hasRowsRef.current) { + void fetchTransactions(true, true) + return + } + const seq = ++scopeRefreshSeqRef.current + setIsScopeRefreshing(true) + void fetchTransactions(false, true).finally(() => { + if (scopeRefreshSeqRef.current === seq) setIsScopeRefreshing(false) + }) + }, [companyId, fetchTransactions, fyReady]) useEffect(() => { if (!companyId) return @@ -2349,9 +2434,13 @@ export default function TransactionsPage() { if (!ok) return try { + // Row-level pending state while the DELETE runs (the card renders a + // spinner for processingId): confirm-to-completion used to be dead air. + setProcessingId(id) const response = await fetch(`/api/transactions/${id}`, { method: 'DELETE' }) if (!response.ok) { const result = await response.json() + setProcessingId((prev) => (prev === id ? null : prev)) toast({ title: 'Kunde inte ta bort', description: getErrorMessage(result, { context: 'transaction' }), @@ -2359,9 +2448,36 @@ export default function TransactionsPage() { }) return } - setTransactions((prev) => prev.filter((t) => t.id !== id)) + // Same exit path as booking/ignore: the row animates out over the + // 350ms window instead of vanishing with a hard jump, then the delayed + // filter actually removes it. + setExitingIds((prev) => new Set(prev).add(id)) + // Drop the row from the batch selection immediately: leaving it there + // keeps the bulk bar counting (and acting on) a row that no longer + // exists once the timer removes it. + setSelectedIds((prev) => { + if (!prev.has(id)) return prev + const next = new Set(prev) + next.delete(id) + return next + }) + // The realtime echo is not guaranteed for DELETE on a filtered + // subscription, so the pending badge must decrement locally. + if (transaction.is_business === null && !transaction.is_ignored) { + setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1)) + } toast({ title: t('deleted_title'), description: t('deleted_description') }) + setTimeout(() => { + setTransactions((prev) => prev.filter((t) => t.id !== id)) + setExitingIds((prev) => { + const next = new Set(prev) + next.delete(id) + return next + }) + setProcessingId((prev) => (prev === id ? null : prev)) + }, 350) } catch { + setProcessingId((prev) => (prev === id ? null : prev)) toast({ title: 'Kunde inte ta bort', description: t('delete_failed_description'), @@ -3213,9 +3329,18 @@ export default function TransactionsPage() { and on a brutet rakenskapsar they were not momsdeklaration quarters, so they misled more than they scoped. */}
+ {/* Quiet cue that a scope change is reconciling behind the rendered + list (the list itself never swaps to a skeleton for it). */} + {isScopeRefreshing && ( + + )} {sourceItems.length > 1 && ( @@ -3376,6 +3501,7 @@ export default function TransactionsPage() { key={`bank-${item.data.id}`} transaction={item.data} skvCounterpartDate={bankToSkvHints.get(item.data.id)} + isExiting={exitingIds.has(item.data.id)} processingId={processingId} isSelected={selectedIds.has(item.data.id)} isExpanded={expandedTxId === item.data.id} @@ -3404,6 +3530,7 @@ export default function TransactionsPage() { row={item.data} matchSuggestion={item.data.match_suggestion} bookingSuggestion={item.data.booking_suggestion} + isExiting={exitingIds.has(item.data.id)} processing={skvBulkSubmitting} selectable={isSkvBulkEligible(item.data)} isSelected={skvSelectedIds.has(item.data.id)} @@ -3443,6 +3570,7 @@ export default function TransactionsPage() { *:nth-child(9) { animation-delay: 320ms; } .stagger-enter > *:nth-child(n+10) { animation-delay: 360ms; } +/* Row exit (booking/ignore/delete in dry-table lists). The page keeps the + row rendered for a 350ms window (see finishBooking on /transactions) and + marks it .row-exit; the row fades fast, then the space closes by + transitioning the cell paddings/line metrics to zero. Table rows cannot + animate height directly, so fixed-height cell content (action pill, + badges) carries .row-collapsible: a numeric max-height at rest, so the + transition to 0 interpolates instead of snapping. The stagger-enter + animation on the same row has long finished; `both` fill keeps it inert. + All timings stay inside the 350ms removal window. + pointer-events only guards the pointer: the exiting row also carries the + `inert` attribute (set by the row components) so keyboard focus and + activation are blocked during the window too. */ +.row-exit { + pointer-events: none; +} +.row-exit > td { + opacity: 0; + padding-top: 0; + padding-bottom: 0; + line-height: 0; + font-size: 0; + border-bottom-width: 0; + transition: + opacity 160ms var(--ease-out), + padding-top 240ms var(--ease-emphasized) 60ms, + padding-bottom 240ms var(--ease-emphasized) 60ms, + line-height 240ms var(--ease-emphasized) 60ms, + font-size 240ms var(--ease-emphasized) 60ms, + border-bottom-width 240ms var(--ease-emphasized) 60ms; +} +.row-collapsible { + /* Numeric rest value so the exit transition has something to interpolate + from; comfortably above the tallest row content (h-7 action pill). */ + max-height: 2.5rem; +} +.row-exit .row-collapsible { + max-height: 0; + overflow: hidden; + transform: translateX(0.5rem); + transition: + max-height 240ms var(--ease-emphasized) 60ms, + transform 240ms var(--ease-out); +} +/* Reduced motion: the row is removed instantly; the delayed state cleanup + in the page is invisible. */ +@media (prefers-reduced-motion: reduce) { + .row-exit { + display: none; + } +} + /* Utility animations */ .animate-fade-in { animation: fadeIn var(--duration-base) var(--ease-out); diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index 61028b86..32e12c8c 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -191,7 +191,20 @@ const PAGE_SIZE_VALUES = new Set(['20', '50', '100', 'all']) // Sentinel limit sent for "Alla". The route clamps this to its own MAX_LIMIT. const ALL_PAGE_SIZE = 100000 -export default function JournalEntryList({ pristineSlot }: { pristineSlot?: ReactNode } = {}) { +export default function JournalEntryList({ + pristineSlot, + refreshToken, +}: { + pristineSlot?: ReactNode + /** + * Parent-driven refresh: bump to re-fetch IN PLACE (list stays mounted, + * dims at opacity-60). Replaces the old key={refreshKey} remount on + * /bookkeeping, which reset hasLoaded and blanked the whole journal to a + * spinner after every created verifikat, destroying expanded rows, + * selection, pagination and scroll position. + */ + refreshToken?: number +} = {}) { const router = useRouter() const { toast } = useToast() const { canWrite } = useCanWrite() @@ -498,11 +511,15 @@ export default function JournalEntryList({ pristineSlot }: { pristineSlot?: Reac // response would overwrite the current sort's rows after they rendered. const fetchGenRef = useRef(0) - async function fetchEntries() { + // `preserveSelection` marks a parent-driven background refresh (the + // refreshToken contract: refresh in place, don't disturb the user's + // working state). User-initiated reloads (filter/sort/page/mode changes, + // commit, storno, retry) keep the default reset: selection is page-scoped. + async function fetchEntries({ preserveSelection = false }: { preserveSelection?: boolean } = {}) { const gen = ++fetchGenRef.current const isCurrent = () => fetchGenRef.current === gen setLoading(true) - setSelectedIds(new Set()) // selection is page-scoped, reset on reload + if (!preserveSelection) setSelectedIds(new Set()) // selection is page-scoped, reset on reload const params = new URLSearchParams({ limit: String(pageSize), offset: String(page * pageSize), @@ -539,6 +556,16 @@ export default function JournalEntryList({ pristineSlot }: { pristineSlot?: Reac const loadedEntries = data || [] setEntries(loadedEntries) setCount(total || 0) + if (preserveSelection) { + // Reconcile with the refreshed page: rows that left it (recommitted + // elsewhere, filtered out by the new data) must leave the selection + // too, or the bulk bar would act on rows no longer on screen. + const visibleIds = new Set(loadedEntries.map((e: JournalEntry) => e.id)) + setSelectedIds((prev) => { + const next = new Set([...prev].filter((id) => visibleIds.has(id))) + return next.size === prev.size ? prev : next + }) + } // The pristine empty card vs. the (toggle-bearing) "drafts exist" state hinges // on draftCount. When the committed list comes back empty, resolve the draft @@ -617,6 +644,20 @@ export default function JournalEntryList({ pristineSlot }: { pristineSlot?: Reac fetchEntries() }, [periodId, page, pageSize, sortParam, dateFrom, dateTo, seriesFilter, search, listMode, collapseCorrections, sortHydrated, periodHydrated, pageSizeHydrated]) + // Parent-driven in-place refresh (see the refreshToken prop). Skips the + // mount value: the main effect above owns the initial fetch, and a token + // bump before hydration is covered by that same initial fetch. + const lastRefreshTokenRef = useRef(refreshToken) + useEffect(() => { + if (refreshToken === undefined || refreshToken === lastRefreshTokenRef.current) return + lastRefreshTokenRef.current = refreshToken + if (!sortHydrated || !periodHydrated || !pageSizeHydrated) return + // In-place refresh: the user didn't ask for a reload, so their current + // selection survives (reconciled against the refreshed page). + fetchEntries({ preserveSelection: true }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [refreshToken, sortHydrated, periodHydrated, pageSizeHydrated]) + const handleAttachmentCountChange = useCallback((entryId: string, count: number) => { setAttachmentCounts((prev) => ({ ...prev, [entryId]: count })) }, []) diff --git a/components/transactions/SkattekontoInboxCard.tsx b/components/transactions/SkattekontoInboxCard.tsx index d60dd3f8..9474ba26 100644 --- a/components/transactions/SkattekontoInboxCard.tsx +++ b/components/transactions/SkattekontoInboxCard.tsx @@ -24,6 +24,7 @@ export default function SkattekontoInboxCard({ row, matchSuggestion, bookingSuggestion, + isExiting = false, processing, selectable, isSelected, @@ -34,6 +35,9 @@ export default function SkattekontoInboxCard({ row: StoredSkattekontoTransaction matchSuggestion?: SkattekontoMatchSuggestion | null bookingSuggestion?: SkattekontoBookingSuggestion | null + /** The row was just booked and is animating out during the page's 350ms + * removal window (.row-exit collapse; instant under reduced motion). */ + isExiting?: boolean processing: boolean selectable?: boolean isSelected?: boolean @@ -60,7 +64,12 @@ export default function SkattekontoInboxCard({ className={cn( 'group transition-colors duration-150 hover:bg-secondary/35', isSelected && 'bg-secondary/40', + isExiting && 'row-exit', )} + // .row-exit only blocks pointer input; `inert` also drops keyboard + // focus and activation (booking/matching controls) during the 350ms + // removal window. + inert={isExiting || undefined} > {/* Hover-revealed selection checkbox (concept .cb) */} {/* Zero-width cell: the checkbox hangs in the left page margin so @@ -84,7 +93,7 @@ export default function SkattekontoInboxCard({ {formatDate(row.transaktionsdatum)} - + {row.transaktionstext} @@ -121,7 +130,7 @@ export default function SkattekontoInboxCard({ {formatCurrency(amount)} - + {matchSuggestion ? ( <> {/* Likely duplicate: linking beats re-booking, so it leads. */} diff --git a/components/transactions/TransactionHistoryList.tsx b/components/transactions/TransactionHistoryList.tsx index f0a74097..edc4e49a 100644 --- a/components/transactions/TransactionHistoryList.tsx +++ b/components/transactions/TransactionHistoryList.tsx @@ -46,6 +46,9 @@ type HistoryRow = interface TransactionHistoryListProps { transactions: TransactionWithInvoice[] skvRows?: SkattekontoTransactionWithSuggestion[] + /** Rows animating out during the page's 350ms removal window (delete): + * rendered with .row-exit so the departure is visible, not a hard jump. */ + exitingIds?: Set searchTerm?: string /** Page-level source chip selection (the toolbar ContextPicker on * /transactions, shared with the inbox mode). */ @@ -78,6 +81,7 @@ interface TransactionHistoryListProps { export default function TransactionHistoryList({ transactions, skvRows = [], + exitingIds, searchTerm = '', sourceFilter, jeUnderlagStatus, @@ -182,6 +186,7 @@ export default function TransactionHistoryList({ onOpenMatchDialog: (transaction: TransactionWithInvoice) => void onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void @@ -282,14 +289,21 @@ function BankHistoryRow({ return ( {formatDate(transaction.date)} - + {transaction.description} - + {isBooked ? ( <> diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx index 5e5188df..b36cadd9 100644 --- a/components/transactions/TransactionInboxCard.tsx +++ b/components/transactions/TransactionInboxCard.tsx @@ -47,6 +47,10 @@ interface TransactionInboxCardProps { /** When set, this bank tx looks like the bank side of a 1930↔1630 * transfer that the user will later see on /skattekonto. */ skvCounterpartDate?: string + /** The row was just booked/ignored/deleted and is animating out during the + * page's 350ms removal window: .row-exit fades and collapses it, and + * pointer events are off. Instant removal under prefers-reduced-motion. */ + isExiting?: boolean processingId: string | null isSelected: boolean /** Row expansion (concept foldout): controlled by the page so only one @@ -98,6 +102,7 @@ interface TransactionInboxCardProps { export default function TransactionInboxCard({ transaction, skvCounterpartDate, + isExiting = false, processingId, isSelected, isExpanded, @@ -231,7 +236,9 @@ export default function TransactionInboxCard({ Boolean(skvCounterpartDate) || (HAS_AI_EXTRACTION && (extraction.status === 'running' || extraction.status === 'failed')) const canExpand = hasFoldoutContent - const expanded = isExpanded && canExpand + // An exiting row's foldout closes with it: the foldout has no exit + // styling of its own and would otherwise linger un-animated. + const expanded = isExpanded && canExpand && !isExiting return ( <> @@ -243,7 +250,12 @@ export default function TransactionInboxCard({ expanded ? 'bg-secondary/25' : 'hover:bg-secondary/35', isSelected && 'bg-secondary/40', isDisabled && 'opacity-50', + isExiting && 'row-exit', )} + // .row-exit only blocks pointer input; `inert` also drops keyboard + // focus and activation (row expand, Bokför, the ⋯ menu) during the + // 350ms removal window. + inert={isExiting || undefined} role={canExpand ? 'button' : undefined} tabIndex={canExpand ? 0 : undefined} aria-expanded={canExpand ? expanded : undefined} @@ -288,7 +300,7 @@ export default function TransactionInboxCard({ {formatDate(transaction.date)} - + {transaction.description} {transaction.title_edited_at && ( @@ -325,7 +337,7 @@ export default function TransactionInboxCard({ {formatCurrency(transaction.amount, transaction.currency)} - +