* 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>
183 lines
6.5 KiB
TypeScript
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>
|
|
)
|
|
}
|