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>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
4dbd19aeb0
commit
c897a906df
@@ -1019,4 +1019,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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 <tr> 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).
|
||||
|
||||
@@ -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. */}
|
||||
<JournalEntryList
|
||||
key={refreshKey}
|
||||
refreshToken={refreshKey}
|
||||
pristineSlot={
|
||||
<div className="animate-fade-in space-y-4">
|
||||
<StartCard
|
||||
|
||||
@@ -280,7 +280,11 @@ export default function InvoicesPage() {
|
||||
|
||||
async function fetchInvoices() {
|
||||
if (!company) return
|
||||
setIsLoading(true)
|
||||
// Skeleton takeover only while nothing is on screen: refetches after an
|
||||
// action (bulk Bokför) reconcile BEHIND the rendered table. Collapsing
|
||||
// hundreds of rows to 3 skeleton stubs and replaying the stagger-enter
|
||||
// entrance for a row-scoped action was the "booking feels glitchy" jump.
|
||||
if (invoices.length === 0) setIsLoading(true)
|
||||
const [invoicesResult, settingsResult] = await Promise.allSettled([
|
||||
fetchAllRows<Invoice>(
|
||||
({ from, to }) =>
|
||||
|
||||
@@ -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<ViewTab | null>(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 && (
|
||||
<Loader2
|
||||
className="h-3.5 w-3.5 animate-spin text-muted-foreground"
|
||||
aria-label={t('refreshing')}
|
||||
/>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
<ContextPicker
|
||||
value={sourceFilter}
|
||||
|
||||
@@ -117,11 +117,27 @@ export default function SupplierInvoicesPage() {
|
||||
const openNewInvoice = () => 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
|
||||
|
||||
@@ -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<string | null>(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<string | null>(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. */}
|
||||
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||||
{/* Quiet cue that a scope change is reconciling behind the rendered
|
||||
list (the list itself never swaps to a skeleton for it). */}
|
||||
{isScopeRefreshing && (
|
||||
<Loader2
|
||||
className="h-3.5 w-3.5 animate-spin text-muted-foreground"
|
||||
aria-label={t('refreshing')}
|
||||
/>
|
||||
)}
|
||||
<FyPicker
|
||||
value={fyPeriodId}
|
||||
onChange={handlePeriodChange}
|
||||
onReady={handleFyReady}
|
||||
storageKeyPrefix={PERIOD_FILTER_STORAGE_PREFIX}
|
||||
/>
|
||||
{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() {
|
||||
<TransactionHistoryList
|
||||
transactions={historyTransactions}
|
||||
skvRows={skvRowsInScope}
|
||||
exitingIds={exitingIds}
|
||||
searchTerm={searchTerm}
|
||||
sourceFilter={sourceFilter}
|
||||
jeUnderlagStatus={jeUnderlagStatus}
|
||||
|
||||
@@ -616,6 +616,57 @@ summary,
|
||||
.stagger-enter > *: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);
|
||||
|
||||
@@ -191,7 +191,20 @@ const PAGE_SIZE_VALUES = new Set<PageSizeChoice>(['20', '50', '100', 'all'])
|
||||
// Sentinel limit sent for "Alla". The route clamps this to its own MAX_LIMIT.
|
||||
const ALL_PAGE_SIZE = 100000
|
||||
|
||||
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 }))
|
||||
}, [])
|
||||
|
||||
@@ -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)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<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" />
|
||||
@@ -121,7 +130,7 @@ export default function SkattekontoInboxCard({
|
||||
{formatCurrency(amount)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right !pr-0 py-[9px]')}>
|
||||
<span className="inline-flex items-center justify-end gap-3">
|
||||
<span className="row-collapsible inline-flex items-center justify-end gap-3">
|
||||
{matchSuggestion ? (
|
||||
<>
|
||||
{/* Likely duplicate: linking beats re-booking, so it leads. */}
|
||||
|
||||
@@ -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<string>
|
||||
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({
|
||||
<BankHistoryRow
|
||||
key={`bank-${item.data.id}`}
|
||||
transaction={item.data}
|
||||
isExiting={exitingIds?.has(item.data.id) ?? false}
|
||||
jeUnderlagStatus={jeUnderlagStatus}
|
||||
onOpenMatchDialog={onOpenMatchDialog}
|
||||
onOpenCategoryDialog={onOpenCategoryDialog}
|
||||
@@ -228,6 +233,7 @@ export default function TransactionHistoryList({
|
||||
|
||||
function BankHistoryRow({
|
||||
transaction,
|
||||
isExiting = false,
|
||||
jeUnderlagStatus,
|
||||
onOpenMatchDialog,
|
||||
onOpenCategoryDialog,
|
||||
@@ -236,6 +242,7 @@ function BankHistoryRow({
|
||||
onDelete,
|
||||
}: {
|
||||
transaction: TransactionWithInvoice
|
||||
isExiting?: boolean
|
||||
jeUnderlagStatus?: Record<string, JeUnderlagStatus>
|
||||
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
|
||||
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
|
||||
@@ -282,14 +289,21 @@ function BankHistoryRow({
|
||||
return (
|
||||
<tr
|
||||
data-tx-id={transaction.id}
|
||||
className="group transition-colors duration-150 hover:bg-secondary/35"
|
||||
className={cn(
|
||||
'group transition-colors duration-150 hover:bg-secondary/35',
|
||||
isExiting && 'row-exit',
|
||||
)}
|
||||
// .row-exit only blocks pointer input; `inert` also drops keyboard
|
||||
// focus and activation (booking, delete, the ⋯ menu) during the 350ms
|
||||
// removal window.
|
||||
inert={isExiting || undefined}
|
||||
>
|
||||
<td className={cn(TD_CLASS, 'w-0 !p-0')} aria-hidden="true"></td>
|
||||
<td className={cn(TD_CLASS, '!pl-0 whitespace-nowrap tabular-nums text-muted-foreground')}>
|
||||
{formatDate(transaction.date)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="row-collapsible flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">{transaction.description}</span>
|
||||
<TransactionAttachmentIndicator
|
||||
documentId={transaction.document_id}
|
||||
@@ -337,7 +351,7 @@ function BankHistoryRow({
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right !pr-0 py-[9px]')}>
|
||||
<span className="inline-flex items-center justify-end gap-2">
|
||||
<span className="row-collapsible inline-flex items-center justify-end gap-2">
|
||||
{isBooked ? (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
|
||||
@@ -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 <tr> 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)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="row-collapsible flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">{transaction.description}</span>
|
||||
<TransactionAttachmentIndicator documentId={attachedDocumentId} />
|
||||
{transaction.title_edited_at && (
|
||||
@@ -325,7 +337,7 @@ export default function TransactionInboxCard({
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'relative whitespace-nowrap text-right !pr-0 py-[9px]')}>
|
||||
<span className="inline-flex items-center justify-end gap-2">
|
||||
<span className="row-collapsible inline-flex items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
||||
@@ -75,11 +75,13 @@ describe('transactions page booking feedback', () => {
|
||||
|
||||
it('decrements the unbooked count on every path that removes a row', () => {
|
||||
// finishBooking, handleTransactionBooked (manual booking dialog / voucher
|
||||
// match), the three other single-row exits already on the page, and the
|
||||
// duplicate-dialog "Ignorera transaktionen" tail.
|
||||
// match), the three other single-row exits already on the page, the
|
||||
// duplicate-dialog "Ignorera transaktionen" tail, and
|
||||
// handleDeleteTransaction (deleting a pending row must not leave the
|
||||
// inbox badge stale: the realtime echo is not guaranteed for DELETE).
|
||||
expect(
|
||||
PAGE_SRC.match(/setTotalUncategorizedCount\(\(prev\) => Math\.max\(0, \(prev \?\? 1\) - 1\)\)/g) ?? [],
|
||||
).toHaveLength(6)
|
||||
).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('ships the undo strings it renders in both locales', () => {
|
||||
|
||||
@@ -609,6 +609,7 @@
|
||||
"pending": {
|
||||
"title": "Review",
|
||||
"subtitle": "Operations waiting for approval",
|
||||
"refreshing": "Refreshing…",
|
||||
"tab_pending": "Waiting",
|
||||
"tab_committed": "Approved",
|
||||
"tab_rejected": "Rejected",
|
||||
@@ -849,6 +850,8 @@
|
||||
},
|
||||
"supplier_invoices": {
|
||||
"title": "Supplier invoices",
|
||||
"load_failed_title": "Could not load supplier invoices",
|
||||
"load_failed_description": "Check your connection and try again.",
|
||||
"register_invoice": "Register invoice",
|
||||
"viewer_disabled_tooltip": "You only have viewer access in this company",
|
||||
"tab_all": "All",
|
||||
@@ -5435,6 +5438,7 @@
|
||||
"dialog_duplicate_cancel": "Cancel",
|
||||
"load_failed_title": "Could not load transactions",
|
||||
"load_failed_description": "Check your connection and try again.",
|
||||
"refreshing": "Refreshing…",
|
||||
"undone_title": "Undone",
|
||||
"undone_description": "Categorization was undone",
|
||||
"undo_failed_title": "Could not undo",
|
||||
|
||||
@@ -609,6 +609,7 @@
|
||||
"pending": {
|
||||
"title": "Granskning",
|
||||
"subtitle": "Operationer som väntar på godkännande",
|
||||
"refreshing": "Uppdaterar…",
|
||||
"tab_pending": "Väntar",
|
||||
"tab_committed": "Godkända",
|
||||
"tab_rejected": "Avvisade",
|
||||
@@ -849,6 +850,8 @@
|
||||
},
|
||||
"supplier_invoices": {
|
||||
"title": "Leverantörsfakturor",
|
||||
"load_failed_title": "Kunde inte ladda leverantörsfakturor",
|
||||
"load_failed_description": "Kontrollera din anslutning och försök igen.",
|
||||
"register_invoice": "Registrera faktura",
|
||||
"viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
|
||||
"tab_all": "Alla",
|
||||
@@ -5435,6 +5438,7 @@
|
||||
"dialog_duplicate_cancel": "Avbryt",
|
||||
"load_failed_title": "Kunde inte ladda transaktioner",
|
||||
"load_failed_description": "Kontrollera din anslutning och försök igen.",
|
||||
"refreshing": "Uppdaterar…",
|
||||
"undone_title": "Ångrad",
|
||||
"undone_description": "Kategorisering har ångrats",
|
||||
"undo_failed_title": "Kunde inte ångra",
|
||||
|
||||
Reference in New Issue
Block a user