From 8a9162b94871ad404b4e4f22eb5170cc3919c3f6 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:37:34 +0200 Subject: [PATCH] polish(ui): loading feedback on sync/refresh buttons system-wide (#1156) The "Synka bank nu" row in the transactions Importera split button fired syncAll() with zero visual feedback. SplitButton now takes busy/busyLabel per option: the primary face and the menu row swap to a spinning Loader2, show the busy label and go inert until the action resolves. useBankSync holds isBusy across the whole syncAll loop so the spinner does not flicker between per-connection syncs. Sweep of the rest of the system for async buttons missing the same feedback (convention: disabled + Loader2 animate-spin + label swap): - bokslut DigitalInlamning "Uppdatera status" (Bolagsverket poll): had no feedback at all; now disabled + spinner + "Uppdaterar ..." while polling - Stripe settings "Synka nu": had disabled + label swap but a static icon - Skatteverket "Verifiera": had disabled + label swap but no spinner - AgentMemoryPanel "Dolj"/"Aterstall" row actions: static icons on async patch; now swap to spinner for the busy row Checked and intentionally unchanged: Arcim migration "Synka igen" and "Ateranslut" (the whole step flips to a spinner view synchronously on click), skattekonto "Forsok igen" (page flips to loading view), AgentChat "Generera om" (streaming indicator is the feedback). Co-authored-by: Claude Fable 5 --- components/bokslut/DigitalInlamning.tsx | 19 ++++++++++-- components/settings/AgentMemoryPanel.tsx | 12 ++++++-- .../settings/SkatteverketConnectPanel.tsx | 3 +- components/transactions/BankSyncNowButton.tsx | 26 ++++++++++------ .../transactions/TransactionStatusBar.tsx | 4 ++- components/ui/split-button.tsx | 30 ++++++++++++++----- .../stripe/components/StripeSettingsPanel.tsx | 8 +++-- 7 files changed, 77 insertions(+), 25 deletions(-) diff --git a/components/bokslut/DigitalInlamning.tsx b/components/bokslut/DigitalInlamning.tsx index 37d76a9e..0014fe02 100644 --- a/components/bokslut/DigitalInlamning.tsx +++ b/components/bokslut/DigitalInlamning.tsx @@ -182,6 +182,7 @@ export function DigitalInlamning({ periodId }: { periodId: string }) { const [submissions, setSubmissions] = useState([]) const [loadingSubmissions, setLoadingSubmissions] = useState(false) const [submissionsError, setSubmissionsError] = useState(null) + const [pollingEvents, setPollingEvents] = useState(false) useEffect(() => { const signer = versions.find((version) => version.id === selectedVersionId) @@ -384,6 +385,7 @@ export function DigitalInlamning({ periodId }: { periodId: string }) { } const handlePollEvents = async () => { + setPollingEvents(true) try { const res = await fetch('/api/extensions/ext/bolagsverket/poll-events', { method: 'POST', @@ -399,6 +401,8 @@ export function DigitalInlamning({ periodId }: { periodId: string }) { toast({ title: 'Status uppdaterad från Bolagsverket' }) } catch { toast({ title: 'Kunde inte hämta händelser', variant: 'destructive' }) + } finally { + setPollingEvents(false) } } @@ -794,8 +798,19 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
-
{submissionsError &&

{submissionsError}

} diff --git a/components/settings/AgentMemoryPanel.tsx b/components/settings/AgentMemoryPanel.tsx index 42bdba4b..dba4b522 100644 --- a/components/settings/AgentMemoryPanel.tsx +++ b/components/settings/AgentMemoryPanel.tsx @@ -371,7 +371,11 @@ export function AgentMemoryPanel() { onClick={() => patch(row.id, { is_active: false })} disabled={isBusy} > - + {isBusy ? ( + + ) : ( + + )} Dölj @@ -382,7 +386,11 @@ export function AgentMemoryPanel() { onClick={() => patch(row.id, { is_active: true })} disabled={isBusy} > - + {isBusy ? ( + + ) : ( + + )} Återställ )} diff --git a/components/settings/SkatteverketConnectPanel.tsx b/components/settings/SkatteverketConnectPanel.tsx index 6dabb6a7..76ad9ca4 100644 --- a/components/settings/SkatteverketConnectPanel.tsx +++ b/components/settings/SkatteverketConnectPanel.tsx @@ -10,7 +10,7 @@ import { useCapability } from '@/contexts/CompanyContext' import { isAllowedSkvPopupOrigin } from '@/lib/skatteverket/popup-origin' import { CAPABILITY } from '@/lib/entitlements/keys' import { UpgradeNote } from '@/components/billing/UpgradeNote' -import { CheckCircle2, ExternalLink, ShieldOff, FlaskConical, ShieldAlert } from 'lucide-react' +import { CheckCircle2, ExternalLink, Loader2, ShieldOff, FlaskConical, ShieldAlert } from 'lucide-react' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' type Environment = 'test' | 'prod' @@ -552,6 +552,7 @@ function SkatteverketSystemConnectionCard() { )} diff --git a/components/transactions/BankSyncNowButton.tsx b/components/transactions/BankSyncNowButton.tsx index 28d0a0c3..a6a2c177 100644 --- a/components/transactions/BankSyncNowButton.tsx +++ b/components/transactions/BankSyncNowButton.tsx @@ -46,6 +46,9 @@ export function useBankSync() { const hasBankSync = useCapability(CAPABILITY.bank_sync) const [connections, setConnections] = useState(null) const [busyId, setBusyId] = useState(null) + // Holds isBusy true across the whole syncAll loop so the spinner doesn't + // flicker off between per-connection syncs. + const [syncingAll, setSyncingAll] = useState(false) useEffect(() => { if (!company?.id) return @@ -156,14 +159,19 @@ export function useBankSync() { // active connection in turn; with only dead connections it jumps straight // to re-authorizing the first one (a retry can't revive a closed session). async function syncAll() { - const conns = connections ?? [] - const active = conns.filter((c) => c.status === 'active') - if (active.length === 0) { - if (conns[0]) await reconnect(conns[0]) - return - } - for (const conn of active) { - await syncConnection(conn) + setSyncingAll(true) + try { + const conns = connections ?? [] + const active = conns.filter((c) => c.status === 'active') + if (active.length === 0) { + if (conns[0]) await reconnect(conns[0]) + return + } + for (const conn of active) { + await syncConnection(conn) + } + } finally { + setSyncingAll(false) } } @@ -177,7 +185,7 @@ export function useBankSync() { return { connections, busyId, - isBusy: busyId !== null, + isBusy: busyId !== null || syncingAll, hasBankSync, reconnect, syncConnection, diff --git a/components/transactions/TransactionStatusBar.tsx b/components/transactions/TransactionStatusBar.tsx index 63bfd86a..4b6c7482 100644 --- a/components/transactions/TransactionStatusBar.tsx +++ b/components/transactions/TransactionStatusBar.tsx @@ -26,7 +26,7 @@ export default function TransactionStatusBar({ const t = useTranslations('transactions') const router = useRouter() const { uiState, loaded } = useUiState() - const { connections, hasBankSync, syncAll, lastSyncedAt } = useBankSync() + const { connections, hasBankSync, syncAll, lastSyncedAt, isBusy } = useBankSync() const formatAge = useAgeFormatter() // "Synka bank nu" (concept: first menu row) only renders once a bank is @@ -41,6 +41,8 @@ export default function TransactionStatusBar({ key: 'synka', label: t('create_synka'), icon: RefreshCw, + busy: isBusy, + busyLabel: t('bank_sync_button_syncing'), description: lastSyncedAt ? t('create_synka_desc_last', { age: formatAge(lastSyncedAt) }) : t('create_synka_desc'), diff --git a/components/ui/split-button.tsx b/components/ui/split-button.tsx index fb070f05..ec7d157c 100644 --- a/components/ui/split-button.tsx +++ b/components/ui/split-button.tsx @@ -6,7 +6,7 @@ import { useTranslations } from 'next-intl' import { cn } from '@/lib/utils' import { Button, type ButtonProps } from '@/components/ui/button' import { rememberCreateMode } from '@/lib/ui-state/client' -import { Check, ChevronDown, type LucideIcon } from 'lucide-react' +import { Check, ChevronDown, Loader2, type LucideIcon } from 'lucide-react' export interface SplitButtonOption { key: string @@ -18,6 +18,11 @@ export interface SplitButtonOption { * as the tooltip instead of silently no-opping. */ disabled?: boolean disabledTitle?: string + /** In-flight async action (e.g. bank sync): spinner replaces the icon and + * the option is inert until it resolves. */ + busy?: boolean + /** Label shown on the primary face while busy (e.g. "Synkar…"). */ + busyLabel?: string onSelect: () => void } @@ -102,7 +107,7 @@ export function SplitButton({ if (!active) return null const runOption = (option: SplitButtonOption) => { - if (option.disabled) return + if (option.disabled || option.busy) return setActiveKey(option.key) if (persistKey) rememberCreateMode(persistKey, option.key) option.onSelect() @@ -113,12 +118,17 @@ export function SplitButton({