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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
bb551d1d59
commit
8a9162b948
@@ -182,6 +182,7 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
|
||||
const [submissions, setSubmissions] = useState<SubmissionRow[]>([])
|
||||
const [loadingSubmissions, setLoadingSubmissions] = useState(false)
|
||||
const [submissionsError, setSubmissionsError] = useState<string | null>(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 }) {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<div className="flex justify-end">
|
||||
<Button className="min-h-11" variant="outline" size="sm" onClick={() => void handlePollEvents()}>
|
||||
<RefreshCcw className="mr-2 h-4 w-4" /> Uppdatera status
|
||||
<Button
|
||||
className="min-h-11"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pollingEvents}
|
||||
onClick={() => void handlePollEvents()}
|
||||
>
|
||||
{pollingEvents ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{pollingEvents ? 'Uppdaterar …' : 'Uppdatera status'}
|
||||
</Button>
|
||||
</div>
|
||||
{submissionsError && <p className="text-xs text-destructive">{submissionsError}</p>}
|
||||
|
||||
@@ -371,7 +371,11 @@ export function AgentMemoryPanel() {
|
||||
onClick={() => patch(row.id, { is_active: false })}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
{isBusy ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
Dölj
|
||||
</Button>
|
||||
</>
|
||||
@@ -382,7 +386,11 @@ export function AgentMemoryPanel() {
|
||||
onClick={() => patch(row.id, { is_active: true })}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<RotateCcw className="mr-1 h-3.5 w-3.5" />
|
||||
{isBusy ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
Återställ
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -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() {
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={verify} disabled={verifying}>
|
||||
{verifying && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{verifying ? t('system_verifying') : t('system_verify')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,9 @@ export function useBankSync() {
|
||||
const hasBankSync = useCapability(CAPABILITY.bank_sync)
|
||||
const [connections, setConnections] = useState<BankConn[] | null>(null)
|
||||
const [busyId, setBusyId] = useState<string | null>(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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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({
|
||||
<Button
|
||||
variant={variant}
|
||||
className="rounded-r-none"
|
||||
disabled={active.disabled}
|
||||
disabled={active.disabled || active.busy}
|
||||
aria-busy={active.busy || undefined}
|
||||
title={active.disabled ? active.disabledTitle : undefined}
|
||||
onClick={() => runOption(active)}
|
||||
>
|
||||
{active.icon && <active.icon className="mr-1.5 h-4 w-4" />}
|
||||
{active.label}
|
||||
{active.busy ? (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
active.icon && <active.icon className="mr-1.5 h-4 w-4" />
|
||||
)}
|
||||
{active.busy ? (active.busyLabel ?? active.label) : active.label}
|
||||
</Button>
|
||||
<Button
|
||||
ref={caretRef}
|
||||
@@ -151,7 +161,7 @@ export function SplitButton({
|
||||
key={option.key}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={option.disabled}
|
||||
disabled={option.disabled || option.busy}
|
||||
title={option.disabled ? option.disabledTitle : undefined}
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
@@ -164,8 +174,12 @@ export function SplitButton({
|
||||
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{option.icon && (
|
||||
<option.icon className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
{option.busy ? (
|
||||
<Loader2 className="mt-0.5 h-4 w-4 flex-shrink-0 animate-spin" />
|
||||
) : (
|
||||
option.icon && (
|
||||
<option.icon className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
)
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[13px] text-foreground">{option.label}</span>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useFormat } from '@/lib/hooks/use-format'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { CreditCard, Link2, RefreshCw, Unlink } from 'lucide-react'
|
||||
import { CreditCard, Link2, Loader2, RefreshCw, Unlink } from 'lucide-react'
|
||||
import type { StripeReviewEvent, StripeStatusResponse } from '../types'
|
||||
|
||||
type ConnectionInfo = NonNullable<StripeStatusResponse['connection']>
|
||||
@@ -302,7 +302,11 @@ export default function StripeSettingsPanel() {
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleSyncNow} disabled={syncing}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{syncing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{syncing ? t('syncing') : t('sync_now')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setConfirmDisconnect(true)}>
|
||||
|
||||
Reference in New Issue
Block a user