feat(sandbox): seed payroll, articles and a year of ledger history; calm the connect CTAs (#1437)
* feat(sandbox): seed payroll, articles and a year of ledger history; calm the connect CTAs The sandbox showed neither Löner nor a usable set of reports, and the "connect X" surfaces were oversized boxed cards. Sandbox seed: - pays_salaries + employer_registered, so Löner and Anställda appear at all (an enskild firma is not an employer by default). Both seeded employees are employment_type 'employee': an EF may employ staff, just not its own owner. - Two employees, one booked and one open lönekörning, and the three verifikat the booked run must have posted (7210/2710/1930, 7510/2731, 7290+7519/ 2920+2940). Skatteavdrag comes from the real Skatteverket 2026 tables. - Year-to-date ledger history, January through last month, with the quarterly momsredovisning cleared to 2650 and paid on the SFL deadline. Without the settlement the demo collected VAT all year and never remitted it, which left an implausible bank balance and 155 813 kr of moms "att betala". - The history is exempted through journal_entry_no_doc_required, the same way the SIE-import opt-in treats imported books: its kvitton live in the previous system, and unflagged it put 39 "verifikat utan underlag" on the home screen. - Artikelregister, and the BAS accounts the K1 chart omits for an enskild firma. - History is numbered before the invoice and payroll vouchers so the series runs forwards through the year, and its writes are batched. Connect CTAs: - Bank picker: a two-column grid of 95px bordered logo cards becomes flat hairline rows, Lucide icons, and a quiet inline connecting state. - Cloud backup: each provider collapses to one row; the BFL note is shown once for the section and names only configured destinations. - Hem first-run: only the active step argues its case, but every not-done step keeps a reachable action. The Skatteverket nudge becomes one quiet sentence. Mobile assistant FAB: a fresh open is desktop-only, since the bottom nav already has an Assistent tab. A collapsed session keeps its handle everywhere except /chat, which is itself the way back to the conversation. Also closes a real hole: /api/salary/runs/[id]/payslips/send had no sandbox guard, and a seeded booked run put "Skicka lönebesked" one click from an anonymous visitor with live Resend behind it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sandbox): check the two unchecked Supabase errors and tighten review nits CodeRabbit review on #1437. Major: two calls discarded their error and continued with null data. A failed chart_of_accounts re-select would have written account_id: null onto every ledger-history and salary voucher line, and a failed next_voucher_number would have inserted a posted verifikat with no number, which is a hole in the verifikationsserie (BFNAR 2013:2). Both now throw, and a null voucher number is rejected explicitly. Minor: the A-004 note claimed a 10 % markup on numbers that are 11.1 %; the salary breakdown test's name said the opposite of its assertions after the switch to the real tax table; the ledger-history doc still said 4 to 6 verifikat per month before the quarterly momsredovisning added a seventh in March, May and June. Bank picker: the spinner is aria-hidden, so loading and connecting had no text equivalent and a failed bank fetch was never announced. Added role="status" with an sr-only label, and role="alert" on the error line. Declined: confirm-before-disconnect on the cloud-backup row. Disconnect was unconfirmed before this PR too, so adding a dialog is a behaviour change beyond the redesign rather than a fix to it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
Jakob Wennberg
parent
43386b4852
commit
d41ef2a909
@@ -5,21 +5,19 @@ import { useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import {
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
SettingsSelect,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
DestructiveConfirmDialog,
|
||||
useDestructiveConfirm,
|
||||
} from '@/components/ui/destructive-confirm-dialog'
|
||||
import {
|
||||
AlertTriangle,
|
||||
Box,
|
||||
Cloud,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Unplug,
|
||||
} from 'lucide-react'
|
||||
import { Box, Cloud, ExternalLink, Loader2, RefreshCw, Unplug } from 'lucide-react'
|
||||
import type {
|
||||
CloudBackupStatus,
|
||||
CloudLastSync,
|
||||
@@ -117,54 +115,78 @@ export default function CloudBackupCard() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<p className="text-sm text-muted-foreground">{t('ext_cloud_backup_loading')}</p>
|
||||
</div>
|
||||
<p className="px-1 py-3 text-xs text-muted-foreground">
|
||||
{t('ext_cloud_backup_loading')}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const providers = status?.providers ?? []
|
||||
// Configured too: reconnecting a destination whose credentials are gone
|
||||
// would only bounce off `/connect`, so that row keeps its quiet
|
||||
// "not configured" line instead.
|
||||
const reauthProviders = providers.filter((p) => p.configured && p.needs_reauth)
|
||||
|
||||
// `/status` returns a row for every provider the build knows about, whether
|
||||
// or not this deployment has credentials for it, so the note has to filter:
|
||||
// naming Dropbox where Dropbox is not configured would promise a
|
||||
// destination that does not exist. Configured (not connected) is the right
|
||||
// cut, because the note is the compliance framing for the decision to
|
||||
// connect and has to read before anything is connected.
|
||||
const destinations = providers
|
||||
.filter((p) => p.configured)
|
||||
.map((p) => PROVIDER_META[p.provider]?.label ?? p.provider)
|
||||
.join(' / ')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{(status?.providers ?? []).map((providerStatus) => (
|
||||
<ProviderRow
|
||||
key={providerStatus.provider}
|
||||
status={providerStatus}
|
||||
onChanged={loadStatus}
|
||||
/>
|
||||
))}
|
||||
<div>
|
||||
{reauthProviders.length > 0 && <ReauthAttn providers={reauthProviders} />}
|
||||
|
||||
<div>
|
||||
{providers.map((providerStatus) => (
|
||||
<ProviderRow
|
||||
key={providerStatus.provider}
|
||||
status={providerStatus}
|
||||
onChanged={loadStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
The 7-year BFL note is compliance context, so it stays visible rather
|
||||
than hiding behind hover: it is shown once for the whole section
|
||||
instead of once per destination, because the statement is identical
|
||||
for every provider and repeating it was what made the panel bulky.
|
||||
*/}
|
||||
{destinations.length > 0 && (
|
||||
<p className="mt-4 px-1 text-xs leading-5 text-muted-foreground">
|
||||
{t('ext_cloud_backup_legal_note', { provider: destinations })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProviderRowProps {
|
||||
status: CloudProviderStatus
|
||||
onChanged: () => Promise<void> | void
|
||||
}
|
||||
|
||||
/**
|
||||
* One destination: identity on the left, its own connection state, schedule
|
||||
* and actions on the right. Every request carries `?provider=`, so the two
|
||||
* rows never touch each other's records.
|
||||
* Start the OAuth handshake for one destination. Shared by the connect button
|
||||
* on a row and by the section-level reconnect line, so both take the exact
|
||||
* same path.
|
||||
*/
|
||||
function ProviderRow({ status, onChanged }: ProviderRowProps) {
|
||||
function useConnectProvider(providerId: CloudProviderId, provider: string) {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('extensions')
|
||||
const { dialogProps, confirm } = useDestructiveConfirm()
|
||||
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const [isDisconnecting, setIsDisconnecting] = useState(false)
|
||||
|
||||
const providerId = status.provider
|
||||
const meta = PROVIDER_META[providerId]
|
||||
const provider = meta?.label ?? providerId
|
||||
const Icon = meta?.icon ?? Cloud
|
||||
const qs = `?provider=${encodeURIComponent(providerId)}`
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
const connect = useCallback(async () => {
|
||||
// The reconnect affordance is a plain inline link (no disabled state), so
|
||||
// the guard lives here instead of on a button.
|
||||
if (isConnecting) return
|
||||
setIsConnecting(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/connect${qs}`, { method: 'POST' })
|
||||
const res = await fetch(
|
||||
`${API_BASE}/connect?provider=${encodeURIComponent(providerId)}`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || t('ext_cloud_backup_connect_start_failed'))
|
||||
@@ -179,7 +201,67 @@ function ProviderRow({ status, onChanged }: ProviderRowProps) {
|
||||
})
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}, [provider, qs, t, toast])
|
||||
}, [isConnecting, provider, providerId, t, toast])
|
||||
|
||||
return { isConnecting, connect }
|
||||
}
|
||||
|
||||
/**
|
||||
* Attention is one ochre sentence, max one per section (convention 6), so the
|
||||
* reconnect notice lives here and not on each row: both destinations can lose
|
||||
* their token at once, and two stacked banners is exactly the shape the
|
||||
* convention forbids. The sentence names every affected destination; the
|
||||
* action reconnects the first, since one redirect can only hand off to one
|
||||
* provider. Once it is back the line returns for the next one.
|
||||
*/
|
||||
function ReauthAttn({ providers }: { providers: CloudProviderStatus[] }) {
|
||||
const t = useTranslations('extensions')
|
||||
const labels = providers.map((p) => PROVIDER_META[p.provider]?.label ?? p.provider)
|
||||
const { isConnecting, connect } = useConnectProvider(providers[0].provider, labels[0])
|
||||
|
||||
return (
|
||||
<AttnLine
|
||||
className="pb-3"
|
||||
action={{
|
||||
label: isConnecting
|
||||
? t('ext_cloud_backup_redirecting')
|
||||
: t('ext_cloud_backup_reauth_action', { provider: labels[0] }),
|
||||
onClick: connect,
|
||||
}}
|
||||
>
|
||||
{t('ext_cloud_backup_reauth_description', { provider: labels.join(' / ') })}
|
||||
</AttnLine>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProviderRowProps {
|
||||
status: CloudProviderStatus
|
||||
onChanged: () => Promise<void> | void
|
||||
}
|
||||
|
||||
/**
|
||||
* One destination as a single hairline row: brand mark and name on the left,
|
||||
* the connect action on the right. Connected destinations unfold their detail
|
||||
* (last sync, schedule) as indented settings rows below the same header row.
|
||||
* Every request carries `?provider=`, so the two rows never touch each
|
||||
* other's records. The reconnect notice is deliberately not here: it belongs
|
||||
* to the section (see `ReauthAttn`), one ochre sentence for all destinations.
|
||||
*/
|
||||
function ProviderRow({ status, onChanged }: ProviderRowProps) {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('extensions')
|
||||
const { dialogProps, confirm } = useDestructiveConfirm()
|
||||
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const [isDisconnecting, setIsDisconnecting] = useState(false)
|
||||
|
||||
const providerId = status.provider
|
||||
const meta = PROVIDER_META[providerId]
|
||||
const provider = meta?.label ?? providerId
|
||||
const Icon = meta?.icon ?? Cloud
|
||||
const qs = `?provider=${encodeURIComponent(providerId)}`
|
||||
|
||||
const { isConnecting, connect: handleConnect } = useConnectProvider(providerId, provider)
|
||||
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
setIsDisconnecting(true)
|
||||
@@ -296,102 +378,58 @@ function ProviderRow({ status, onChanged }: ProviderRowProps) {
|
||||
if (ok) await syncOnce(true)
|
||||
}, [confirm, syncOnce, t])
|
||||
|
||||
// The scope copy ("only files the app creates" / "its own app folder") is
|
||||
// what earns the OAuth grant, so it stays visible in the state where the
|
||||
// user decides: it replaces the tagline, which only paraphrased the row
|
||||
// title. Connected rows swap it for the account the backup lands in.
|
||||
const scopeKey =
|
||||
providerId === 'dropbox'
|
||||
? 'ext_cloud_backup_connect_description_dropbox'
|
||||
: 'ext_cloud_backup_connect_description_google'
|
||||
|
||||
const supportingLine = status.connected
|
||||
? status.account_email
|
||||
: status.configured
|
||||
? t(scopeKey)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<div className="border-b border-border">
|
||||
<DestructiveConfirmDialog {...dialogProps} />
|
||||
<div className="grid gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)]">
|
||||
{/* Identity */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
|
||||
<Icon className="h-[18px] w-[18px] text-foreground/60" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-[15px] font-semibold leading-tight">{provider}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
||||
{t('ext_cloud_backup_card_tagline', { provider })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2 leading-relaxed">
|
||||
{t('ext_cloud_backup_legal_note', { provider })}
|
||||
</p>
|
||||
|
||||
{/* Header row: identity left, action right, in every state. */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-1 py-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium">{provider}</span>
|
||||
{supportingLine && (
|
||||
<p
|
||||
className={cn(
|
||||
'mt-1 text-xs leading-5 text-muted-foreground',
|
||||
// Only the account address is a single unbreakable token
|
||||
// worth clipping; the scope sentence has to wrap, or it is
|
||||
// cut off on a 390px screen where the row does not wrap.
|
||||
status.connected && 'truncate'
|
||||
)}
|
||||
>
|
||||
{supportingLine}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div>
|
||||
{!status.configured ? (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t('ext_cloud_backup_not_configured', { provider })}
|
||||
</p>
|
||||
) : status.connected ? (
|
||||
<>
|
||||
{status.needs_reauth && (
|
||||
<div className="mb-6 flex items-start gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t('ext_cloud_backup_reauth_title', { provider })}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
|
||||
{t('ext_cloud_backup_reauth_description', { provider })}
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting}
|
||||
className="mt-3 w-full sm:w-auto"
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('ext_cloud_backup_redirecting')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
{t('ext_cloud_backup_reauth_action', { provider })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<dt className="shrink-0 text-muted-foreground">
|
||||
{t('ext_cloud_backup_account_label')}
|
||||
</dt>
|
||||
<dd className="min-w-0 truncate font-medium">{status.account_email}</dd>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<dt className="shrink-0 text-muted-foreground">
|
||||
{t('ext_cloud_backup_last_sync_label')}
|
||||
</dt>
|
||||
<dd className="min-w-0 text-right">
|
||||
{status.last_sync ? (
|
||||
<LastSyncSummary
|
||||
lastSync={status.last_sync}
|
||||
providerId={providerId}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{t('ext_cloud_backup_never')}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-border">
|
||||
<ScheduleSection
|
||||
providerId={providerId}
|
||||
provider={provider}
|
||||
schedule={status.schedule}
|
||||
needsReauth={status.needs_reauth}
|
||||
onUpdated={onChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-border flex flex-col gap-2 sm:flex-row sm:justify-between">
|
||||
<Button onClick={handleSync} disabled={isSyncing} className="w-full sm:w-auto">
|
||||
{!status.configured ? (
|
||||
// Not configured is a state, not an action: one quiet line where the
|
||||
// button would sit, free to wrap on narrow screens.
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('ext_cloud_backup_not_configured', { provider })}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{status.connected ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleSync} disabled={isSyncing}>
|
||||
{isSyncing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
@@ -404,12 +442,7 @@ function ProviderRow({ status, onChanged }: ProviderRowProps) {
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDisconnect}
|
||||
disabled={isDisconnecting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Button variant="ghost" onClick={handleDisconnect} disabled={isDisconnecting}>
|
||||
{isDisconnecting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
@@ -422,36 +455,54 @@ function ProviderRow({ status, onChanged }: ProviderRowProps) {
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t(
|
||||
providerId === 'dropbox'
|
||||
? 'ext_cloud_backup_connect_description_dropbox'
|
||||
: 'ext_cloud_backup_connect_description_google'
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={handleConnect} disabled={isConnecting}>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('ext_cloud_backup_redirecting')}
|
||||
</>
|
||||
) : (
|
||||
t('ext_cloud_backup_connect', { provider })
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button onClick={handleConnect} disabled={isConnecting} className="w-full sm:w-auto">
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('ext_cloud_backup_redirecting')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
{t('ext_cloud_backup_connect', { provider })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Both gates, not just `connected`: a deployment that loses its
|
||||
credentials while an account is still stored shows the "not
|
||||
configured" state alone, exactly as the header row does, instead of
|
||||
offering controls next to a line saying the destination is unavailable.
|
||||
`role="group"` ties these controls to the destination they belong to:
|
||||
with two rows open, "Synka nu" is otherwise unattributable.
|
||||
*/}
|
||||
{status.configured && status.connected && (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={provider}
|
||||
className="ml-3 border-l border-border pb-3 pl-4"
|
||||
>
|
||||
<SettingsRow label={t('ext_cloud_backup_last_sync_label')} align="baseline">
|
||||
{status.last_sync ? (
|
||||
<LastSyncSummary lastSync={status.last_sync} providerId={providerId} />
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t('ext_cloud_backup_never')}</span>
|
||||
)}
|
||||
</SettingsRow>
|
||||
|
||||
<ScheduleSection
|
||||
providerId={providerId}
|
||||
provider={provider}
|
||||
schedule={status.schedule}
|
||||
needsReauth={status.needs_reauth}
|
||||
onUpdated={onChanged}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -487,7 +538,7 @@ function LastSyncSummary({
|
||||
const verified = files ? files.every((f) => f.sha256) : Boolean(lastSync.sha256)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-w-0">
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
@@ -508,7 +559,7 @@ function LastSyncSummary({
|
||||
{t('ext_cloud_backup_last_sync_no_documents')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -557,8 +608,7 @@ function ScheduleSection({
|
||||
const [localHour, setLocalHour] = useState(scheduleHour(schedule))
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// Each provider renders its own controls, so the ids must not collide.
|
||||
const toggleId = `auto-sync-toggle-${providerId}`
|
||||
// Each provider renders its own controls, so the id must not collide.
|
||||
const hourId = `auto-sync-hour-${providerId}`
|
||||
|
||||
useEffect(() => {
|
||||
@@ -618,48 +668,43 @@ function ScheduleSection({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Label htmlFor={toggleId} className="text-sm font-medium">
|
||||
{t('ext_cloud_backup_auto_sync_title')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t('ext_cloud_backup_auto_sync_description', { provider })}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={toggleId}
|
||||
checked={enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<SettingsRow
|
||||
label={t('ext_cloud_backup_auto_sync_title')}
|
||||
help={t('ext_cloud_backup_auto_sync_description', { provider })}
|
||||
borderless={!enabled}
|
||||
>
|
||||
<SettingsRowEnd>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={isSaving}
|
||||
aria-label={t('ext_cloud_backup_auto_sync_title')}
|
||||
/>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
|
||||
{enabled && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={hourId} className="text-xs text-muted-foreground">
|
||||
{t('ext_cloud_backup_time_label')}
|
||||
</Label>
|
||||
<select
|
||||
<SettingsRow label={t('ext_cloud_backup_time_label')} htmlFor={hourId} borderless>
|
||||
<SettingsSelect
|
||||
id={hourId}
|
||||
value={localHour}
|
||||
onChange={handleHourChange}
|
||||
disabled={isSaving}
|
||||
className="rounded-md border border-input bg-background px-2 py-1 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="tabular-nums"
|
||||
>
|
||||
{Array.from({ length: 24 }, (_, h) => (
|
||||
<option key={h} value={h}>
|
||||
{h.toString().padStart(2, '0')}:00
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingsSelect>
|
||||
{isSaving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
)}
|
||||
|
||||
{schedule?.last_auto_sync_at && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="px-1 text-xs text-muted-foreground">
|
||||
{t('ext_cloud_backup_last_auto_sync')} {formatDateTime(schedule.last_auto_sync_at)}{' '}
|
||||
{schedule.last_auto_sync_status === 'success' ? (
|
||||
<span className="text-success">· {t('ext_cloud_backup_auto_sync_success')}</span>
|
||||
@@ -675,7 +720,7 @@ function ScheduleSection({
|
||||
) : null}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { ChevronRight, Landmark, Loader2, Search } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface Bank {
|
||||
@@ -27,7 +28,52 @@ interface BankSelectorProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
function BankCard({ bank, isConnecting, connectingBankName, onConnect }: {
|
||||
/**
|
||||
* Eyebrow above a list section: same micro-label and same px-1 inset as the
|
||||
* SettingsGroup eyebrow, so the two sit on one left edge.
|
||||
*/
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="px-1 pb-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{children}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 32px leading mark for a row. Real bank logos are drawn for a white
|
||||
* background, so they sit on a white chip (same treatment as LogoMark in
|
||||
* components/onboarding/NewUserChecklist.tsx); the no-logo fallback uses the
|
||||
* neutral surface instead, which keeps the icon readable in dark mode.
|
||||
* While connecting, the mark is replaced by a bare spinner: quiet inline
|
||||
* state, no tint, no box.
|
||||
*/
|
||||
function BankMark({ bank, connecting }: { bank: Bank; connecting: boolean }) {
|
||||
if (connecting) {
|
||||
return (
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" aria-hidden="true" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (bank.logo) {
|
||||
return (
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-white">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={bank.logo} alt="" className="h-6 w-6 object-contain" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border bg-secondary">
|
||||
<Landmark className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function BankRow({ bank, isConnecting, connectingBankName, onConnect }: {
|
||||
bank: Bank
|
||||
isConnecting: boolean
|
||||
connectingBankName: string | null
|
||||
@@ -37,54 +83,34 @@ function BankCard({ bank, isConnecting, connectingBankName, onConnect }: {
|
||||
|
||||
return (
|
||||
<button
|
||||
key={bank.name}
|
||||
type="button"
|
||||
disabled={isConnecting}
|
||||
onClick={() => onConnect(bank)}
|
||||
className={cn(
|
||||
'group flex items-center gap-3 p-4 border border-border rounded-lg bg-card text-left transition-all',
|
||||
'hover:border-primary hover:bg-muted',
|
||||
connecting && 'border-primary bg-muted',
|
||||
isConnecting && !connecting && 'opacity-50 cursor-not-allowed',
|
||||
// 32px mark + py-2 keeps the row at 48px: comfortably past the 44px
|
||||
// touch target without turning back into a card.
|
||||
'flex w-full items-center gap-3 px-1 py-2 text-left transition-colors duration-150',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',
|
||||
!isConnecting && 'hover:bg-secondary/60',
|
||||
isConnecting && 'cursor-not-allowed',
|
||||
isConnecting && !connecting && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-lg border border-border bg-white dark:bg-gray-300 flex items-center justify-center overflow-hidden">
|
||||
{connecting ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
) : bank.logo ? (
|
||||
<img
|
||||
src={bank.logo}
|
||||
alt={bank.name}
|
||||
className="w-12 h-12 object-contain p-1"
|
||||
/>
|
||||
) : (
|
||||
<svg
|
||||
className="h-6 w-6 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||
</svg>
|
||||
<BankMark bank={bank} connecting={connecting} />
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate text-sm',
|
||||
connecting ? 'text-muted-foreground' : 'text-foreground',
|
||||
)}
|
||||
</div>
|
||||
<span className="flex-1 font-medium text-foreground group-hover:text-primary truncate">
|
||||
>
|
||||
{bank.name}
|
||||
</span>
|
||||
<svg
|
||||
className="flex-shrink-0 h-5 w-5 text-muted-foreground group-hover:text-primary transition-colors"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{/* No per-row "Ansluter..." text: the spinner mark carries the row's
|
||||
share of the state, the wording lives once in the line below the
|
||||
list. */}
|
||||
{!connecting && (
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -151,46 +177,42 @@ export function BankSelector({
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
placeholder="Sök efter din bank..."
|
||||
aria-label="Sök efter din bank"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-3 py-3 border border-border rounded-lg bg-background text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-all"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{/* Loading state. The spinner is decorative, so the state needs a text
|
||||
equivalent: without it a screen-reader user gets silence between
|
||||
submitting and the list appearing. */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<div role="status" className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" aria-hidden="true" />
|
||||
<span className="sr-only">Laddar banker...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{/* Error state: one quiet line, not a tinted panel. role="alert" so the
|
||||
failure is announced rather than only redrawn. */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-4">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
<p role="alert" className="px-1 text-[12.5px] leading-relaxed text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Bank grid */}
|
||||
{/* Bank list */}
|
||||
{!isLoading && !error && (
|
||||
<>
|
||||
{filteredBanks.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="px-1 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{searchQuery
|
||||
? `Inga banker matchar "${searchQuery}"`
|
||||
@@ -203,36 +225,46 @@ export function BankSelector({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[400px] overflow-y-auto space-y-4">
|
||||
// -mx-1 cancels the px-1 the settings panel wraps this in, so the
|
||||
// hairlines and the hover band run the full width of the settings
|
||||
// content, level with the SettingsRow hairlines above; the rows'
|
||||
// own px-1 then puts the bank mark on the same left edge as the
|
||||
// settings labels.
|
||||
// max-h-96 is exactly eight 48px rows: a scroll box with a reason.
|
||||
<div className="-mx-1 max-h-96 space-y-6 overflow-y-auto overscroll-contain">
|
||||
{popularBanks.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">Populära banker</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<section>
|
||||
<SectionLabel>Populära banker</SectionLabel>
|
||||
<div className="divide-y divide-border border-t border-border">
|
||||
{popularBanks.map((bank) => (
|
||||
<BankCard key={bank.name} bank={bank} isConnecting={isConnecting} connectingBankName={connectingBankName} onConnect={onConnect} />
|
||||
<BankRow key={bank.name} bank={bank} isConnecting={isConnecting} connectingBankName={connectingBankName} onConnect={onConnect} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{popularBanks.length > 0 && otherBanks.length > 0 && (
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">Alla banker</h3>
|
||||
{otherBanks.length > 0 && (
|
||||
<section>
|
||||
{popularBanks.length > 0 && <SectionLabel>Alla banker</SectionLabel>}
|
||||
<div className="divide-y divide-border border-t border-border">
|
||||
{otherBanks.map((bank) => (
|
||||
<BankRow key={bank.name} bank={bank} isConnecting={isConnecting} connectingBankName={connectingBankName} onConnect={onConnect} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{otherBanks.map((bank) => (
|
||||
<BankCard key={bank.name} bank={bank} isConnecting={isConnecting} connectingBankName={connectingBankName} onConnect={onConnect} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Connecting overlay */}
|
||||
{/* The single worded home of the connecting state: one muted line rather
|
||||
than a tinted bordered panel, and it names the bank so the state is
|
||||
still readable when the chosen row has scrolled out of view. */}
|
||||
{isConnecting && connectingBankName && (
|
||||
<div className="flex items-center justify-center gap-2 p-3 rounded-lg bg-primary/10 border border-primary/30">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<span className="text-sm font-medium text-foreground">Ansluter till {connectingBankName}...</span>
|
||||
</div>
|
||||
<p role="status" className="flex items-center gap-2 px-1 text-[12.5px] leading-relaxed text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden="true" />
|
||||
Ansluter till {connectingBankName}...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user