Files
accounted/components/transactions/SkattekontoInboxCard.tsx
T
c897a906df fix(ux): actions update lists in place - no more takeovers, jumps and dead air (#1629)
* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 18:44:02 +02:00

183 lines
6.5 KiB
TypeScript

'use client'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
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, Landmark, Link2, Loader2 } from 'lucide-react'
import type {
SkattekontoBookingSuggestion,
SkattekontoMatchSuggestion,
StoredSkattekontoTransaction,
} from '@/types/skatteverket'
/**
* 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,
matchSuggestion,
bookingSuggestion,
isExiting = false,
processing,
selectable,
isSelected,
onToggleSelect,
onBokfor,
onMatch,
}: {
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
onToggleSelect?: (id: string) => void
onBokfor: (row: StoredSkattekontoTransaction) => void
onMatch: (row: StoredSkattekontoTransaction) => void
}) {
const t = useTranslations('tx_skattekonto_card')
const amount = Number(row.belopp_skatteverket)
const isIncome = amount > 0
const duplicateLabel =
matchSuggestion?.voucher_series && matchSuggestion?.voucher_number
? t('duplicate_title_with_voucher', {
label: formatVoucher({
voucher_series: matchSuggestion.voucher_series,
voucher_number: matchSuggestion.voucher_number,
}),
})
: t('duplicate_title_draft')
return (
<tr
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
the date column can sit flush with the page edge. */}
<td className={cn(TD_CLASS, 'relative w-0 !p-0')}>
{selectable && (
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleSelect?.(row.id)}
aria-label={t('select_row')}
className={cn(
'absolute -left-5 top-1/2 -translate-y-1/2 transition-opacity duration-150 md:-left-6',
isSelected
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',
)}
/>
)}
</td>
<td className={cn(TD_CLASS, '!pl-0 whitespace-nowrap tabular-nums text-muted-foreground')}>
{formatDate(row.transaktionsdatum)}
</td>
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
<span className="row-collapsible 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>
{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>
)}
{/* What "Bokför" will do: the deterministic rule match, muted so it
reads as information, not state. Suppressed on likely duplicates
where linking (not booking) is the recommended action. */}
{bookingSuggestion && !matchSuggestion && (
<span className="hidden shrink-0 whitespace-nowrap text-xs text-muted-foreground md:inline">
{t('suggestion_line', {
account: bookingSuggestion.account_name
? `${bookingSuggestion.account} ${bookingSuggestion.account_name}`
: bookingSuggestion.account,
})}
</span>
)}
</span>
</td>
<td
className={cn(
TD_CLASS,
'whitespace-nowrap text-right tabular-nums rr-mask',
isIncome && 'text-success',
)}
>
{isIncome ? '+' : ''}
{formatCurrency(amount)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right !pr-0 py-[9px]')}>
<span className="row-collapsible inline-flex items-center justify-end gap-3">
{matchSuggestion ? (
<>
{/* 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>
</>
)}
</span>
</td>
</tr>
)
}