feat(settings): Fönster redesign - flat rows, ? help, dirty save bar (#1193)

* feat(settings): Fönster redesign - flat rows, help behind ?, dirty save bar

Founder-approved concept (2026-07-25) applied to the whole settings
surface, modal and full-page variants alike:

- New primitives in components/settings/SettingsRows.tsx: section header
  (serif title + one-line intro), eyebrow groups, hairline label/control
  rows, flat inputs/selects/textareas, segmented control, animated
  reveal for gated settings, danger zone.
- Every static explanation paragraph moved behind a "?" popover
  (HelpPopover) at row or group level; dynamic status stays visible.
- Modal chrome: company kicker over serif title, fixed 920x680 window.
- SettingsFormWrapper: save is a sticky bar that appears only when the
  form is dirty; collapses to zero height when clean.
- All 11 sections converted (Konto, Abonnemang, Företag, Bokföring,
  Skatt, Löner, Fakturering, Mallar, Bank incl. Enable Banking-panel,
  Assistenten, API) with handlers, validation, role/entitlement/sandbox
  gates and i18n keys preserved; checkboxes became switches, cards
  dissolved into groups.
- Fix: Escape with an open help popover closed the whole settings
  modal; it now closes the popover first.
- New i18n keys: settings_intro.*, group labels, wrapper_unsaved
  (sv+en).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): founder feedback round 1 on the Fönster redesign

- Abonnemang paying state: status and manage split into two rows so the
  row no longer wraps awkwardly; the included-features list now shows
  for paying companies too.
- Logos where the counterpart has one: BankID mark on the security row
  and on the Koppla BankID button, Skatteverket mark on the connection
  rows.
- Buttons are unmistakably buttons: 27 text-labeled row actions went
  from ghost to outline pills; icon-only actions stay quiet.
- The agent-knowledge view (Regler & profil: Dina regler, Momsprofil,
  Konventioner) converted to the flat row language; it was the last
  old-style surface inside settings. Descriptions moved behind "?",
  rules render as hairline rows, the per-row "Regel" chip demoted to
  muted text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): address review-bot findings on the Fönster redesign

- SettingsFormWrapper marks the form dirty on switch clicks too: Radix
  Switch is a button and fires no input event, so switch-only changes
  (f-skatt, KU, ROT/RUT, OSS...) never revealed the save bar.
- i18n: the migrated hardcoded strings got keys in both locales
  (fiscal-period start date/range/months, security set-password trio);
  dates in ApiKeysPanel/OAuthClientsPanel/CalendarFeedSettings now pass
  the active locale to formatDateLong.
- A11y: member remove/revoke buttons and the invite role select got
  correct accessible names; BankNameCombobox accepts aria-label wired
  from its row; the pinned-fact icon exposes role img.
- BankIdSettings: explicit Avbryt under the QR block so a cancelled
  BankID flow cannot strand isLinking.
- VoucherSeriesManager: clear the skeleton when no company is resolved.

Verified end to end in sandbox: switch-only dirty bar, PUT /api/settings
200 for text and switch saves, persistence across hard reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-25 23:55:08 +02:00
committed by GitHub
parent d54b43f80f
commit 6d9846b1e7
30 changed files with 400 additions and 367 deletions
@@ -3,15 +3,17 @@
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { Pin, ArrowUpRight } from 'lucide-react'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { SettingsGroup, SettingsRow } from '@/components/settings/SettingsRows'
import type { AgentCompetence, AtomTier, FactKind, FactSource } from '@/lib/agent-context/agent-competence'
/**
* Read-only views of the agent's competence (domain-knowledge atoms) and top
* learned facts, for the "Vad din agent vet" overview. Each is a standalone
* Card so it can sit in its own tab. Full editable management lives in
* /settings/assistant; each links there.
* learned facts, for the "Vad din agent vet" overview. Each renders as a flat
* settings group (Fönster language) with its description behind the group
* "?" help. Full editable management lives in /settings/assistant; each
* links there.
*/
const TIER_ORDER: AtomTier[] = ['horizontal', 'vertical', 'modifier']
@@ -24,50 +26,43 @@ export function CompetenceCard({ competence }: { competence: AgentCompetence })
tier === 'horizontal' ? t('tier_horizontal') : tier === 'vertical' ? t('tier_vertical') : t('tier_modifier')
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('comp_title')}</CardTitle>
<CardDescription>{t('comp_desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-0">
{atoms.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('comp_empty')}</p>
) : (
<>
{TIER_ORDER.map((tier) => {
const items = atoms.filter((a) => a.tier === tier)
if (items.length === 0) return null
return (
<div key={tier} className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{tierLabel(tier)}
</h3>
<div className="flex flex-wrap gap-2">
{items.map((a) => (
<Badge
key={a.id}
variant={a.active ? 'secondary' : 'outline'}
className={a.active ? '' : 'text-muted-foreground'}
title={a.description}
>
{a.title}
{!a.active && tier !== 'horizontal' && (
<span className="ml-1.5 opacity-70">· {t('badge_dormant')}</span>
)}
</Badge>
))}
</div>
<SettingsGroup label={t('comp_title')} help={t('comp_desc')}>
{atoms.length === 0 ? (
<p className="px-1 py-3 text-sm text-muted-foreground">{t('comp_empty')}</p>
) : (
<>
{/* One row per tier: micro-label left, atom chips right. Dormant
atoms are the exception the outline chip variant marks. */}
{TIER_ORDER.map((tier) => {
const items = atoms.filter((a) => a.tier === tier)
if (items.length === 0) return null
return (
<SettingsRow key={tier} label={tierLabel(tier)} align="baseline">
<div className="flex flex-wrap gap-2">
{items.map((a) => (
<Badge
key={a.id}
variant={a.active ? 'secondary' : 'outline'}
className={a.active ? '' : 'text-muted-foreground'}
title={a.description}
>
{a.title}
{!a.active && tier !== 'horizontal' && (
<span className="ml-1.5 opacity-70">· {t('badge_dormant')}</span>
)}
</Badge>
))}
</div>
)
})}
<div className="flex items-center justify-between pt-1 text-xs text-muted-foreground">
<span className="tabular-nums">{t('comp_count', { total: atoms.length, active: activeAtoms })}</span>
<ManageLink href="/settings/assistant?view=skills" label={t('comp_manage')} />
</div>
</>
)}
</CardContent>
</Card>
</SettingsRow>
)
})}
<div className="flex items-center justify-between gap-4 px-1 pt-3 text-xs text-muted-foreground">
<span className="tabular-nums">{t('comp_count', { total: atoms.length, active: activeAtoms })}</span>
<ManageLink href="/settings/assistant?view=skills" label={t('comp_manage')} />
</div>
</>
)}
</SettingsGroup>
)
}
@@ -80,51 +75,49 @@ export function FactsCard({ competence }: { competence: AgentCompetence }) {
s === 'composer' ? t('source_composer') : s === 'user_taught' ? t('source_user_taught') : s === 'agent_learned' ? t('source_agent_learned') : t('source_derived')
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('facts_title')}</CardTitle>
<CardDescription>{t('facts_desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3 pt-0">
{facts.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('facts_empty')}</p>
) : (
<>
<ul className="space-y-3">
{facts.map((f) => (
<li key={f.id} className="flex items-start gap-2">
{f.is_pinned ? (
<Pin className="mt-1 h-3.5 w-3.5 shrink-0 fill-current text-muted-foreground" aria-label={t('facts_pinned')} />
) : (
<span className="mt-1 h-3.5 w-3.5 shrink-0" aria-hidden />
)}
<div className="min-w-0 space-y-0.5">
<p className="text-sm text-foreground">{f.content}</p>
<p className="text-[11px] text-muted-foreground">
{kindLabel(f.kind)} · {sourceLabel(f.source)}
</p>
</div>
</li>
))}
</ul>
<div className="flex items-center justify-between pt-1 text-xs text-muted-foreground">
<span className="tabular-nums">
{factsActiveTotal > facts.length ? t('facts_more', { n: factsActiveTotal - facts.length }) : ''}
</span>
<ManageLink href="/settings/assistant?view=memory" label={t('facts_manage')} />
</div>
</>
)}
</CardContent>
</Card>
<SettingsGroup label={t('facts_title')} help={t('facts_desc')}>
{facts.length === 0 ? (
<p className="px-1 py-3 text-sm text-muted-foreground">{t('facts_empty')}</p>
) : (
<>
{/* Flat hairline rows: fact content with its kind/source flowing
inline as muted text; the pin marks the exception. */}
<ul>
{facts.map((f) => (
<li key={f.id} className="flex items-start gap-2 border-b border-border px-1 py-3">
{f.is_pinned ? (
<Pin role="img" className="mt-1 h-3.5 w-3.5 shrink-0 fill-current text-muted-foreground" aria-label={t('facts_pinned')} />
) : (
<span className="mt-1 h-3.5 w-3.5 shrink-0" aria-hidden />
)}
<div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 gap-y-1">
<span className="text-sm text-foreground">{f.content}</span>
<span className="text-[11px] text-muted-foreground">
{kindLabel(f.kind)} · {sourceLabel(f.source)}
</span>
</div>
</li>
))}
</ul>
<div className="flex items-center justify-between gap-4 px-1 pt-3 text-xs text-muted-foreground">
<span className="tabular-nums">
{factsActiveTotal > facts.length ? t('facts_more', { n: factsActiveTotal - facts.length }) : ''}
</span>
<ManageLink href="/settings/assistant?view=memory" label={t('facts_manage')} />
</div>
</>
)}
</SettingsGroup>
)
}
function ManageLink({ href, label }: { href: string; label: string }) {
return (
<Link href={href} className="inline-flex items-center gap-1 text-foreground underline underline-offset-2 hover:text-muted-foreground">
{label}
<ArrowUpRight className="h-3 w-3" />
</Link>
<Button asChild variant="outline" size="sm">
<Link href={href}>
{label}
<ArrowUpRight className="ml-1 h-3 w-3" />
</Link>
</Button>
)
}
+85 -151
View File
@@ -3,21 +3,10 @@
import { useTranslations } from 'next-intl'
import { Brain } from 'lucide-react'
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from '@/components/ui/table'
SettingsGroup,
SettingsRow,
SettingsSectionHeader,
} from '@/components/settings/SettingsRows'
import { AccountNumber } from '@/components/ui/account-number'
import { EmptyState } from '@/components/ui/empty-state'
import { formatDateLong } from '@/lib/utils'
@@ -74,18 +63,14 @@ export function AgentKnowledgeView({
// already remember facts: show those rather than a dead end.
return (
<div className="space-y-8">
<Card>
<CardContent className="p-0">
<EmptyState
icon={Brain}
title={t('empty_title')}
description={t('empty_description')}
actionLabel={t('empty_action')}
actionHref="/transactions"
/>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
<EmptyState
icon={Brain}
title={t('empty_title')}
description={t('empty_description')}
actionLabel={t('empty_action')}
actionHref="/transactions"
/>
<div>
<CompetenceCard competence={competence} />
<FactsCard competence={competence} />
</div>
@@ -109,126 +94,84 @@ export function AgentKnowledgeView({
? t('period_yearly')
: (vat_profile.moms_period ?? t('unknown'))
// "Regler & profil": user-authored rules + observed VAT + conventions.
// Rendered inline below the graph; Minne and Kompetens have their own
// top-level tabs, so nesting a second tab row here would just duplicate them.
const configContent = (
<>
{explicit_rules.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('rules_title')}</CardTitle>
<CardDescription>{t('rules_description')}</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('col_rule')}</TableHead>
<TableHead>{t('col_match')}</TableHead>
<TableHead>{t('col_account')}</TableHead>
<TableHead>{t('col_vat')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{explicit_rules.map((r, i) => (
<TableRow key={r.rule_name + r.match + i}>
<TableCell>
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{r.rule_name}</span>
<Badge variant="default">{t('src_rule')}</Badge>
</div>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{r.match}</TableCell>
<TableCell>
{r.account_number
? <AccountNumber number={r.account_number} showName size="sm" />
: <span className="text-muted-foreground">-</span>}
</TableCell>
<TableCell className="text-muted-foreground">{vatLabel(r.vat_treatment) ?? '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('vat_title')}</CardTitle>
<CardDescription>{t('vat_description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 pt-0">
<Row label={t('vat_registered_label')}>
<Badge variant={vat_profile.registered ? 'success' : 'outline'}>
{vat_profile.registered ? t('yes') : t('no')}
</Badge>
</Row>
<Row label={t('vat_period_label')}>
<span className="text-sm">{periodLabel}</span>
</Row>
<Row label={t('vat_treatments_label')}>
{vat_profile.treatments_used_12m.length === 0 ? (
<span className="text-sm text-muted-foreground">{t('vat_no_treatments')}</span>
) : (
<div className="flex flex-wrap justify-end gap-2">
{vat_profile.treatments_used_12m.map((code) => (
<Badge key={code} variant="outline">{vatLabel(code)}</Badge>
))}
</div>
)}
</Row>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('conv_title')}</CardTitle>
<CardDescription>{t('conv_description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 pt-0">
<Row label={t('conv_method_label')}>
<span className="text-sm">{methodLabel}</span>
</Row>
<Row label={t('conv_series_label')}>
{conventions.voucher_series_in_use.length === 0 ? (
<span className="text-sm text-muted-foreground">-</span>
) : (
<div className="flex flex-wrap justify-end gap-2">
{conventions.voucher_series_in_use.map((s) => (
<Badge key={s} variant="secondary" className="font-mono">{s}</Badge>
))}
</div>
)}
</Row>
<Row label={t('conv_salary_label')}>
<Badge variant={conventions.salary_run_active ? 'success' : 'outline'}>
{conventions.salary_run_active ? t('yes') : t('no')}
</Badge>
</Row>
{conventions.typical_booking_lag_days !== null && (
<Row label={t('conv_lag_label')}>
<span className="text-sm tabular-nums">{t('meta_lag_value', { days: conventions.typical_booking_lag_days })}</span>
</Row>
)}
</CardContent>
</Card>
</div>
</>
)
return (
<div className="space-y-8">
{/* The cinematic hero: a self-contained dark panel with its own header */}
<LedgerGraph deep={deep} companyName={companyName} />
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('tab_config')}
</h2>
{configContent}
{/* "Regler & profil": user-authored rules + observed VAT + conventions,
in the flat settings language (groups under eyebrow labels, static
descriptions behind the group "?"). Minne and Kompetens have their
own top-level views, so no second tab row here. */}
<section>
<SettingsSectionHeader title={t('tab_config')} />
{explicit_rules.length > 0 && (
<SettingsGroup label={t('rules_title')} help={t('rules_description')}>
{explicit_rules.map((r, i) => {
const vat = vatLabel(r.vat_treatment)
return (
<div
key={r.rule_name + r.match + i}
className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-b border-border px-1 py-3"
>
<span className="text-sm">{r.rule_name}</span>
<span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground">
{r.match}
</span>
<span className="flex shrink-0 flex-wrap items-baseline gap-x-2 text-xs text-muted-foreground">
{r.account_number ? (
<AccountNumber number={r.account_number} showName size="sm" />
) : (
<span>-</span>
)}
{vat && <span>· {vat}</span>}
<span>· {t('src_rule')}</span>
</span>
</div>
)
})}
</SettingsGroup>
)}
<SettingsGroup label={t('vat_title')} help={t('vat_description')}>
<SettingsRow label={t('vat_registered_label')}>
<span>{vat_profile.registered ? t('yes') : t('no')}</span>
</SettingsRow>
<SettingsRow label={t('vat_period_label')}>
<span>{periodLabel}</span>
</SettingsRow>
<SettingsRow label={t('vat_treatments_label')}>
{vat_profile.treatments_used_12m.length === 0 ? (
<span className="text-muted-foreground">{t('vat_no_treatments')}</span>
) : (
<span>{vat_profile.treatments_used_12m.map((code) => vatLabel(code)).join(' · ')}</span>
)}
</SettingsRow>
</SettingsGroup>
<SettingsGroup label={t('conv_title')} help={t('conv_description')}>
<SettingsRow label={t('conv_method_label')}>
<span>{methodLabel}</span>
</SettingsRow>
<SettingsRow label={t('conv_series_label')}>
{conventions.voucher_series_in_use.length === 0 ? (
<span className="text-muted-foreground">-</span>
) : (
<span className="font-mono text-xs">{conventions.voucher_series_in_use.join(', ')}</span>
)}
</SettingsRow>
<SettingsRow label={t('conv_salary_label')}>
<span>{conventions.salary_run_active ? t('yes') : t('no')}</span>
</SettingsRow>
{conventions.typical_booking_lag_days !== null && (
<SettingsRow label={t('conv_lag_label')}>
<span className="tabular-nums">
{t('meta_lag_value', { days: conventions.typical_booking_lag_days })}
</span>
</SettingsRow>
)}
</SettingsGroup>
</section>
<p className="text-right text-xs text-muted-foreground">
@@ -237,12 +180,3 @@ export function AgentKnowledgeView({
</div>
)
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4">
<span className="text-sm text-muted-foreground">{label}</span>
<div>{children}</div>
</div>
)
}
+1 -1
View File
@@ -82,7 +82,7 @@ export function TaxTableStatus({ year, compact = false }: Props) {
<span className="min-w-0 text-xs text-muted-foreground">{label}</span>
<Button
type="button"
variant="ghost"
variant="outline"
size="sm"
onClick={check}
disabled={loading}
+2 -2
View File
@@ -122,7 +122,7 @@ export function AccountDangerZone() {
>
<span className="text-sm">{b.name}</span>
<SettingsRowEnd>
<Button variant="ghost" size="sm" asChild>
<Button variant="outline" size="sm" asChild>
<Link href="/settings/company">{t('blockers_manage')}</Link>
</Button>
</SettingsRowEnd>
@@ -138,7 +138,7 @@ export function AccountDangerZone() {
>
<SettingsRowNote>{tRetention('account_title')}</SettingsRowNote>
<SettingsRowEnd>
<Button variant="ghost" size="sm" asChild>
<Button variant="outline" size="sm" asChild>
<Link href="/reports?type=sie">
<ExternalLink className="mr-2 h-3.5 w-3.5" />
{t('export_sie')}
+6 -6
View File
@@ -191,7 +191,7 @@ export function AgentMemoryPanel() {
/>
{canWrite && (
<Button
variant="ghost"
variant="outline"
size="sm"
onClick={() => setShowAdd((v) => !v)}
disabled={adding}
@@ -228,7 +228,7 @@ export function AgentMemoryPanel() {
</SettingsSelect>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => { setShowAdd(false); setNewContent('') }}>
<Button variant="outline" size="sm" onClick={() => { setShowAdd(false); setNewContent('') }}>
Avbryt
</Button>
<Button size="sm" onClick={addMemory} disabled={adding || newContent.trim().length < 2}>
@@ -347,7 +347,7 @@ export function AgentMemoryPanel() {
{isEditing ? (
<>
<Button
variant="ghost"
variant="outline"
size="sm"
onClick={() => setEditingId(null)}
disabled={isBusy}
@@ -366,7 +366,7 @@ export function AgentMemoryPanel() {
) : row.is_active ? (
<>
<Button
variant="ghost"
variant="outline"
size="sm"
onClick={() => startEdit(row)}
disabled={isBusy}
@@ -375,7 +375,7 @@ export function AgentMemoryPanel() {
Redigera
</Button>
<Button
variant="ghost"
variant="outline"
size="sm"
onClick={() => patch(row.id, { is_active: false })}
disabled={isBusy}
@@ -390,7 +390,7 @@ export function AgentMemoryPanel() {
</>
) : (
<Button
variant="ghost"
variant="outline"
size="sm"
onClick={() => patch(row.id, { is_active: true })}
disabled={isBusy}
+6 -5
View File
@@ -1,6 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useLocale, useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -187,7 +187,7 @@ function CopyBlock({ text, copyAriaLabel }: { text: string; copyAriaLabel: strin
{text}
</pre>
<Button
variant="ghost"
variant="outline"
size="sm"
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={handleCopy}
@@ -249,6 +249,7 @@ function ScopeCard({
export function ApiKeysPanel() {
const t = useTranslations('settings_api_keys')
const locale = useLocale()
const { toast } = useToast()
const { dialogProps: revokeDialogProps, confirm: confirmRevoke } = useDestructiveConfirm()
const { dialogProps: sodDialogProps, confirm: confirmSod } = useDestructiveConfirm()
@@ -439,10 +440,10 @@ export function ApiKeysPanel() {
<span className="font-mono">{key.key_prefix}...</span>
</span>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{t('created')} {formatDateLong(key.created_at)}
{t('created')} {formatDateLong(key.created_at, locale)}
{' · '}
{key.last_used_at
? t('used_on', { date: formatDateLong(key.last_used_at) })
? t('used_on', { date: formatDateLong(key.last_used_at, locale) })
: t('never_used')}
</span>
</div>
@@ -685,7 +686,7 @@ export function ApiKeysPanel() {
{newKeyValue}
</code>
<Button
variant="ghost"
variant="outline"
size="sm"
className="absolute right-2 top-2"
onClick={handleCopy}
+30 -4
View File
@@ -1,5 +1,6 @@
'use client'
import Image from 'next/image'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
@@ -91,7 +92,19 @@ export function BankIdSettings() {
return (
<>
<SettingsRow
label={t('title')}
label={
<span className="inline-flex items-center gap-2">
<Image
src="/logos/bankid-seeklogo.svg"
alt=""
aria-hidden="true"
width={16}
height={16}
className="dark:invert"
/>
{t('title')}
</span>
}
help={identity ? t('linked_description') : t('not_linked_description')}
borderless={isLinking}
>
@@ -105,7 +118,7 @@ export function BankIdSettings() {
</SettingsRowNote>
<SettingsRowEnd>
<Button
variant="ghost"
variant="outline"
size="sm"
onClick={handleUnlink}
disabled={isUnlinking}
@@ -121,7 +134,15 @@ export function BankIdSettings() {
<SettingsRowNote>{t('link_bankid_description')}</SettingsRowNote>
) : (
<SettingsRowEnd>
<Button variant="ghost" size="sm" onClick={() => setIsLinking(true)}>
<Button variant="outline" size="sm" onClick={() => setIsLinking(true)}>
<Image
src="/logos/bankid-seeklogo.svg"
alt=""
aria-hidden="true"
width={16}
height={16}
className="mr-2 dark:invert"
/>
{t('link_button')}
</Button>
</SettingsRowEnd>
@@ -131,8 +152,13 @@ export function BankIdSettings() {
{/* QR flow: an expanding block below the row. Mounted only while
linking so the BankID session starts exactly when requested. */}
{isLinking && (
<div className="flex flex-col items-center border-b border-border px-1 py-4">
<div className="flex flex-col items-center gap-3 border-b border-border px-1 py-4">
<BankIdAuth mode="link" onComplete={handleLinkComplete} />
{/* BankIdAuth's own Avbryt only resets its internal session; give
the row an exit so isLinking can't get stuck. */}
<Button variant="outline" size="sm" onClick={() => setIsLinking(false)}>
{t('cancel_linking')}
</Button>
</div>
)}
</>
+3 -1
View File
@@ -28,9 +28,10 @@ interface BankNameComboboxProps {
value?: string
onChange?: (value: string) => void
enableBankingEnabled?: boolean
'aria-label'?: string
}
export function BankNameCombobox({ defaultValue = '', value: controlledValue, onChange, enableBankingEnabled = false }: BankNameComboboxProps) {
export function BankNameCombobox({ defaultValue = '', value: controlledValue, onChange, enableBankingEnabled = false, 'aria-label': ariaLabel }: BankNameComboboxProps) {
const isControlled = controlledValue !== undefined
const [internalValue, setInternalValue] = useState(defaultValue)
const value = isControlled ? controlledValue : internalValue
@@ -131,6 +132,7 @@ export function BankNameCombobox({ defaultValue = '', value: controlledValue, on
<div ref={containerRef} className="relative">
<input type="hidden" name="bank_name" value={value} />
<Input
aria-label={ariaLabel}
ref={inputRef}
type="text"
placeholder="t.ex. Nordea"
@@ -139,7 +139,7 @@ export function BookingTemplatesPanel() {
{canWrite && (
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
variant="outline"
size="sm"
onClick={handleExport}
className="text-muted-foreground hover:text-foreground"
@@ -148,7 +148,7 @@ export function BookingTemplatesPanel() {
{t('export')}
</Button>
<Button
variant="ghost"
variant="outline"
size="sm"
onClick={() => importRef.current?.click()}
className="text-muted-foreground hover:text-foreground"
+7 -10
View File
@@ -1,6 +1,7 @@
'use client'
import { useTranslations } from 'next-intl'
import { useLocale, useTranslations } from 'next-intl'
import { formatDateLong } from '@/lib/utils'
import { useState, useEffect } from 'react'
import { Switch } from '@/components/ui/switch'
import { Button } from '@/components/ui/button'
@@ -23,6 +24,7 @@ interface CalendarFeedWithUrls extends CalendarFeed {
export function CalendarFeedSettings() {
const t = useTranslations('settings_calendar_feed')
const locale = useLocale()
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(true)
@@ -176,7 +178,7 @@ export function CalendarFeedSettings() {
<SettingsGroup label={t('title')} help={t('description')}>
<SettingsRow label={t('activate_sync')} help={t('empty_intro')}>
<SettingsRowEnd>
<Button variant="ghost" size="sm" onClick={createFeed} disabled={isSaving}>
<Button variant="outline" size="sm" onClick={createFeed} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
@@ -221,7 +223,7 @@ export function CalendarFeedSettings() {
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
<SettingsRowEnd>
<Button variant="ghost" size="sm" onClick={openWebcal}>
<Button variant="outline" size="sm" onClick={openWebcal}>
<Calendar className="mr-2 h-3.5 w-3.5" />
{t('add_to_apple_calendar')}
</Button>
@@ -233,19 +235,14 @@ export function CalendarFeedSettings() {
{feed.last_accessed_at && (
<SettingsRowNote className="tabular-nums">
{t('last_fetched')}{' '}
{new Date(feed.last_accessed_at).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})}
{formatDateLong(feed.last_accessed_at, locale)}
{' · '}
{t('times_count', { count: feed.access_count })}
</SettingsRowNote>
)}
<SettingsRowEnd>
<Button
variant="ghost"
variant="outline"
size="sm"
onClick={regenerateToken}
disabled={isRegenerating}
@@ -188,7 +188,7 @@ export function CompanyMembersSection() {
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
aria-label={t('members_removed')}
aria-label={t('members_remove_aria')}
onClick={() => handleRemoveMember(member.id)}
disabled={removingId === member.id}
>
@@ -225,7 +225,7 @@ export function CompanyMembersSection() {
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
aria-label={t('members_invite_revoked')}
aria-label={t('members_revoke_aria')}
onClick={() => handleRevokeInvite(inv.id)}
disabled={revokingId === inv.id}
>
@@ -258,7 +258,7 @@ export function CompanyMembersSection() {
<SettingsSelect
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value)}
aria-label={t('members_invite_email_label')}
aria-label={t('members_role_label')}
>
<option value="viewer">{t('members_role_viewer')}</option>
<option value="member">{t('members_role_member')}</option>
@@ -216,7 +216,7 @@ export function CounterpartyTemplatesPanel() {
{/* Delete */}
<div className="flex justify-end pt-1">
<Button
variant="ghost"
variant="outline"
size="sm"
onClick={() => handleDelete(tt.id)}
disabled={deletingId === tt.id}
+6 -6
View File
@@ -197,9 +197,9 @@ export function FiscalPeriodEditor() {
</p>
<SettingsRow
label="Startdatum"
label={t('fp_start_date_label')}
htmlFor="fiscal-period-start"
help="Första räkenskapsåret kan börja valfri dag."
help={t('fp_start_date_help')}
align="baseline"
>
<SettingsInput
@@ -230,9 +230,9 @@ export function FiscalPeriodEditor() {
<p className="px-1 pt-3 text-xs text-muted-foreground">
{t('fp_summary_title')}:{' '}
<span className="tabular-nums">
{formatSwedishDate(startDate)} till {formatSwedishDate(endDate)}
{t('fp_range', { start: formatSwedishDate(startDate), end: formatSwedishDate(endDate) })}
</span>
{validation.months !== null && <> · {validation.months} månader</>}
{validation.months !== null && <> · {t('fp_months', { count: validation.months })}</>}
</p>
)}
{validation.error && (
@@ -242,7 +242,7 @@ export function FiscalPeriodEditor() {
<div className="flex justify-end gap-2 px-1 pt-3">
<Button
type="button"
variant="ghost"
variant="outline"
size="sm"
onClick={handleReset}
disabled={!isDirty || isSaving}
@@ -311,7 +311,7 @@ function BlockedRow({
>
<Lock aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="tabular-nums">
{formatSwedishDate(period.period_start)} till {formatSwedishDate(period.period_end)}
{t('fp_range', { start: formatSwedishDate(period.period_start), end: formatSwedishDate(period.period_end) })}
</span>
<SettingsRowNote>
{(isCalendarYear(period) ? t('fp_blocked_calendar_year') : t('fp_blocked_broken_year')).trim()}
+3 -3
View File
@@ -146,7 +146,7 @@ export function FiscalYearsManager() {
)}
{canManage && status === 'open' && (
<Button
variant="ghost"
variant="outline"
size="sm"
className="text-muted-foreground hover:text-foreground"
disabled={isMutating}
@@ -164,7 +164,7 @@ export function FiscalYearsManager() {
)}
{canManage && status === 'locked' && (
<Button
variant="ghost"
variant="outline"
size="sm"
className="text-muted-foreground hover:text-foreground"
disabled={isMutating}
@@ -189,7 +189,7 @@ export function FiscalYearsManager() {
{/* Trailing quiet action: create the next fiscal year. */}
<div className="px-1 pt-3">
<Button
variant="ghost"
variant="outline"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => setDialogOpen(true)}
+1 -1
View File
@@ -83,7 +83,7 @@ export function InstallAppSection() {
<SettingsRow label={t('install_app_title')} help={t('install_app_description')}>
{installPrompt ? (
<SettingsRowEnd>
<Button variant="ghost" size="sm" onClick={handleInstall}>
<Button variant="outline" size="sm" onClick={handleInstall}>
<MonitorDown className="mr-2 h-3.5 w-3.5" />
{t('install_app_button')}
</Button>
@@ -351,6 +351,7 @@ export function InvoicePaymentAccountsSettings({
{/* Typeahead combobox stays boxed on purpose: it is a picker, not a field. */}
<div className="min-w-0 flex-1 sm:max-w-64">
<BankNameCombobox
aria-label={t('bank_label')}
value={value(activeAccount, 'bank_name')}
onChange={(next) => updateField('bank_name', next)}
enableBankingEnabled={hasBankingExtension}
+1 -1
View File
@@ -135,7 +135,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
{t('logo_change')}
</Button>
<Button
variant="ghost"
variant="outline"
size="sm"
onClick={handleDelete}
disabled={isDeleting}
+3 -2
View File
@@ -1,6 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useLocale, useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -31,6 +31,7 @@ interface OAuthClient {
export function OAuthClientsPanel() {
const t = useTranslations('settings_oauth_clients')
const locale = useLocale()
const { toast } = useToast()
const { dialogProps: revokeDialogProps, confirm: confirmRevoke } = useDestructiveConfirm()
@@ -152,7 +153,7 @@ export function OAuthClientsPanel() {
{c.redirect_uri}
</code>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{t('registered_on')} {formatDateLong(c.created_at)}
{t('registered_on')} {formatDateLong(c.created_at, locale)}
</span>
</div>
<Button
+6 -6
View File
@@ -177,8 +177,8 @@ export function SecuritySettings() {
{/* BankID-only users with no password: set-password row before the rest */}
{hasPassword === false && (
<SettingsRow
label="Sätt ett lösenord"
help="Du loggade in med BankID och har inget lösenord ännu. Sätt ett lösenord för att kunna aktivera 2FA eller logga in när BankID inte är tillgängligt."
label={t('set_password_title')}
help={t('set_password_description')}
>
<SettingsRowEnd>
<Button
@@ -187,7 +187,7 @@ export function SecuritySettings() {
router.push('/account/set-password?returnTo=/settings/account')
}
>
Sätt lösenord
{t('set_password_button')}
</Button>
</SettingsRowEnd>
</SettingsRow>
@@ -276,7 +276,7 @@ export function SecuritySettings() {
<SettingsRowNote>{t('mfa_required_note')}</SettingsRowNote>
) : (
<Button
variant="ghost"
variant="outline"
size="sm"
onClick={handleUnenrollMfa}
disabled={isUnenrolling}
@@ -302,7 +302,7 @@ export function SecuritySettings() {
<SettingsRowEnd>
{hasPassword === false ? (
<Button
variant="ghost"
variant="outline"
size="sm"
onClick={() =>
router.push('/account/set-password?returnTo=/mfa/enroll')
@@ -312,7 +312,7 @@ export function SecuritySettings() {
</Button>
) : (
<Button
variant="ghost"
variant="outline"
size="sm"
onClick={() =>
router.push(`/mfa/enroll?returnTo=${encodeURIComponent('/settings/account')}`)
@@ -105,6 +105,13 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
<form
onSubmit={handleSubmit}
onInput={() => setDirty(true)}
// Radix Switch renders a button, so toggling it fires no form input
// event; catch those clicks too or switch-only changes never reveal
// the save bar.
onClickCapture={(e) => {
const el = e.target as HTMLElement
if (el.closest('[role="switch"]:not([disabled])')) setDirty(true)
}}
className={className}
>
{children}
@@ -1,6 +1,7 @@
'use client'
import { useTranslations } from 'next-intl'
import Image from 'next/image'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -275,7 +276,12 @@ function SkatteverketPersonalConnectionCard() {
if (loading) {
return (
<SettingsGroup>
<SettingsRow label={t('title')} help={connectHelp} borderless>
<SettingsRow label={
<span className="inline-flex items-center gap-2">
<Image src="/logos/skatteverket.svg" alt="" aria-hidden="true" width={14} height={14} className="dark:invert" />
{t('title')}
</span>
} help={connectHelp} borderless>
<SettingsRowNote>{t('loading_status')}</SettingsRowNote>
</SettingsRow>
</SettingsGroup>
@@ -286,7 +292,12 @@ function SkatteverketPersonalConnectionCard() {
return (
<SettingsGroup>
{status?.disabled && <WarningLine>{t('disabled_message')}</WarningLine>}
<SettingsRow label={t('title')} help={connectHelp} borderless={!hasSkatteverket}>
<SettingsRow label={
<span className="inline-flex items-center gap-2">
<Image src="/logos/skatteverket.svg" alt="" aria-hidden="true" width={14} height={14} className="dark:invert" />
{t('title')}
</span>
} help={connectHelp} borderless={!hasSkatteverket}>
<EnvironmentBadge environment={status?.environment} disabled={status?.disabled} />
<SettingsRowEnd>
<Button
@@ -328,7 +339,12 @@ function SkatteverketPersonalConnectionCard() {
</WarningLine>
)}
<SettingsRow label={t('title')} help={connectHelp}>
<SettingsRow label={
<span className="inline-flex items-center gap-2">
<Image src="/logos/skatteverket.svg" alt="" aria-hidden="true" width={14} height={14} className="dark:invert" />
{t('title')}
</span>
} help={connectHelp}>
{status.expired ? (
<Badge variant="warning">{t('expired')}</Badge>
) : (
@@ -198,7 +198,7 @@ export function TaxAssessmentNoticesPanel() {
{saving ? t('saving') : t(editingId ? 'update_action' : 'save_action')}
</Button>
{editingId && (
<Button type="button" variant="ghost" size="sm" onClick={resetForm} disabled={saving}>
<Button type="button" variant="outline" size="sm" onClick={resetForm} disabled={saving}>
{t('cancel')}
</Button>
)}
@@ -221,11 +221,11 @@ export function TaxAssessmentNoticesPanel() {
</SettingsRowNote>
</div>
<div className="flex shrink-0 gap-2">
<Button type="button" variant="ghost" size="sm" onClick={() => editNotice(notice)} disabled={saving}>
<Button type="button" variant="outline" size="sm" onClick={() => editNotice(notice)} disabled={saving}>
<Pencil className="mr-2 h-4 w-4" />
{t('edit')}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => void archiveNotice(notice)} disabled={saving}>
<Button type="button" variant="outline" size="sm" onClick={() => void archiveNotice(notice)} disabled={saving}>
<Trash2 className="mr-2 h-4 w-4" />
{t('archive')}
</Button>
+1 -1
View File
@@ -251,7 +251,7 @@ export function TemplateForm({
/>
<Button
type="button"
variant="ghost"
variant="outline"
size="sm"
onClick={() => removeLine(i)}
disabled={lines.length <= 2}
+1 -1
View File
@@ -25,7 +25,7 @@ export function VoucherSeriesManager({ defaultSeries }: VoucherSeriesManagerProp
const [isLoading, setIsLoading] = useState(true)
const fetchSeries = useCallback(async () => {
if (!company?.id) return
if (!company?.id) { setIsLoading(false); return }
const supabase = createClient()
const { data } = await supabase
.from('voucher_sequences')
@@ -225,7 +225,7 @@ export function AccountSettingsContent() {
<SettingsGroup label={tSettings('legal_title')}>
<SettingsRow label={tSettings('legal_privacy')}>
<SettingsRowEnd>
<Button variant="ghost" size="sm" asChild>
<Button variant="outline" size="sm" asChild>
<Link href="/privacy" target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-3.5 w-3.5" />
{tCommon('open')}
@@ -235,7 +235,7 @@ export function AccountSettingsContent() {
</SettingsRow>
<SettingsRow label={tSettings('legal_dpa')}>
<SettingsRowEnd>
<Button variant="ghost" size="sm" asChild>
<Button variant="outline" size="sm" asChild>
<Link href="/dpa" target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-3.5 w-3.5" />
{tCommon('open')}
@@ -249,7 +249,7 @@ export function AccountSettingsContent() {
<SettingsGroup>
<SettingsRow label={tCommon('logout')} help={tCommon('logout_description')}>
<SettingsRowEnd>
<Button variant="ghost" size="sm" onClick={handleLogout}>
<Button variant="outline" size="sm" onClick={handleLogout}>
<LogOut className="mr-2 h-3.5 w-3.5" />
{tCommon('logout')}
</Button>
@@ -153,14 +153,27 @@ export function BillingSettingsContent() {
<div>
{header}
<SettingsGroup label="Ditt abonnemang">
<SettingsRow label="Status" borderless>
<span>Aktivt</span>
<SettingsRow label="Status">
<span className="font-medium">Aktivt</span>
<SettingsRowNote>Du kan hantera eller avsluta det när som helst.</SettingsRowNote>
</SettingsRow>
<SettingsRow label="Hantera" borderless>
<SettingsRowNote>Byt kort, ändra plan eller säg upp via Stripes kundportal.</SettingsRowNote>
<SettingsRowEnd>
<BillingActions isPaying configured={view.configured} />
</SettingsRowEnd>
</SettingsRow>
</SettingsGroup>
<SettingsGroup label="I abonnemanget">
<ul className="space-y-2 px-1 pt-3">
{INCLUDED.map((item) => (
<li key={item} className="flex items-start gap-2 text-sm">
<Check aria-hidden="true" className="mt-1 h-3.5 w-3.5 shrink-0 text-foreground" />
<span>{item}</span>
</li>
))}
</ul>
</SettingsGroup>
</div>
)
}
@@ -110,7 +110,7 @@ export function BankConnectionStatus({
{(isConnectionExpired || isConnectionError) && onReconnect && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-1 text-muted-foreground hover:text-foreground">
<Button variant="outline" size="sm" className="gap-1 text-muted-foreground hover:text-foreground">
Förnya anslutning
<ChevronDown className="h-3.5 w-3.5" />
</Button>
@@ -135,7 +135,7 @@ export function BankConnectionStatus({
)}
{isConnectionError && (
<Button
variant="ghost"
variant="outline"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => onSync(connection.id)}
@@ -147,7 +147,7 @@ export function BankConnectionStatus({
)}
{connection.status === 'active' && (
<Button
variant="ghost"
variant="outline"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => onSync(connection.id)}
@@ -159,7 +159,7 @@ export function BankConnectionStatus({
)}
{onManageAccounts && (
<Button
variant="ghost"
variant="outline"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => onManageAccounts(connection.id)}
@@ -168,7 +168,7 @@ export function BankConnectionStatus({
</Button>
)}
<Button
variant="ghost"
variant="outline"
size="sm"
className="text-muted-foreground hover:text-destructive"
onClick={() => onDisconnect(connection.id)}
@@ -440,7 +440,7 @@ export default function BankingSettingsPanel() {
<Button variant="outline" size="sm" onClick={() => fetchConnections()}>
Försök igen
</Button>
<Button variant="ghost" size="sm" asChild>
<Button variant="outline" size="sm" asChild>
<Link href="/import?mode=bank">Importera bankfil istället</Link>
</Button>
</div>
@@ -514,7 +514,7 @@ export default function BankingSettingsPanel() {
Välj konton
</Button>
<Button
variant="ghost"
variant="outline"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => handleDisconnectBank(connection.id)}
+48 -27
View File
@@ -20,8 +20,6 @@
"load_more": "Load more",
"retry": "Try again",
"load_error": "Could not load data",
"popup_blocked_title": "The browser blocked the tab",
"popup_blocked_description": "Allow pop-ups for Accounted in your browser and try again.",
"confirm": "Confirm",
"yes": "Yes",
"no": "No",
@@ -66,7 +64,9 @@
"matched": "Matched",
"unmatched": "Unmatched"
},
"more_options": "More options"
"more_options": "More options",
"popup_blocked_title": "The browser blocked the tab",
"popup_blocked_description": "Allow pop-ups for Accounted in your browser and try again."
},
"nav": {
"dashboard": "Overview",
@@ -369,9 +369,16 @@
"sync_now": "Sync now",
"syncing": "Syncing…",
"sync_done_title": "Sync complete",
"sync_done_feed": "{fetched} transaction(s) fetched: {imported} new in the inbox, {linked} linked to vouchers.",
"sync_done_empty": "Stripe returned no transactions for the period. If you expected transactions, check that the right account is connected.",
"sync_done_description": "{settled} payment(s) booked, {review} need review.",
"sync_failed_title": "Sync failed",
"needs_review_title": "Needs review",
"needs_review_hint": "Payments that could not be matched automatically. Handle them manually via the invoice or in Stripe.",
"reason_invoice_not_found": "Payment without a matching invoice",
"reason_invoice_already_paid": "The invoice is already marked as paid",
"reason_amount_mismatch": "The amount does not match the invoice remaining balance",
"reason_currency_mismatch": "The currency does not match the invoice",
"reason_non_sek_invoice": "Foreign-currency invoice (book manually)",
"reason_unknown": "Unknown reason",
"transaction_sync_title": "Transactions from Stripe",
"transaction_sync_description": "Import all Stripe transactions (payments, fees, refunds and payouts) into the transactions inbox every night, like a bank feed for your Stripe balance. You book the rows from the inbox as usual.",
"transaction_sync_backfill_note": "The first sync fetches up to 90 days of history, but never before the bookkeeping lock date.",
@@ -379,7 +386,10 @@
"transaction_sync_never_synced": "Not synced yet",
"transaction_sync_enabled_toast": "Transaction sync enabled. History is fetched on the next sync.",
"transaction_sync_disabled_toast": "Transaction sync disabled.",
"transaction_sync_toggle_failed": "Could not save the setting. Please try again."
"transaction_sync_toggle_failed": "Could not save the setting. Please try again.",
"sync_done_transactions": "{imported} transaction(s) imported, {linked} linked to vouchers.",
"sync_done_feed": "{fetched} transaction(s) fetched: {imported} new in the inbox, {linked} linked to vouchers.",
"sync_done_empty": "Stripe returned no transactions for the period. If you expected transactions, check that the right account is connected."
},
"settings_modal": {
"title": "Settings",
@@ -1374,6 +1384,13 @@
"joined_generic": "You are now a member."
},
"settings_company": {
"members_remove_aria": "Remove member",
"members_revoke_aria": "Revoke invitation",
"members_role_label": "Role",
"fp_start_date_label": "Start date",
"fp_start_date_help": "The first fiscal year can start on any day.",
"fp_range": "{start} to {end}",
"fp_months": "{count} months",
"company_info_heading": "Company details",
"share_capital_heading": "Share capital",
"aktiekapital_label": "Share capital (SEK)",
@@ -2239,6 +2256,7 @@
"env_prod": "Production"
},
"settings_bankid": {
"cancel_linking": "Cancel",
"title": "BankID",
"toast_already_linked": "This BankID is already linked to another account.",
"toast_link_failed": "Could not link BankID.",
@@ -2256,6 +2274,9 @@
"link_button": "Link BankID"
},
"settings_security": {
"set_password_title": "Set a password",
"set_password_description": "You signed in with BankID and have no password yet. Set one to enable 2FA or to sign in when BankID is unavailable.",
"set_password_button": "Set password",
"group_security": "Security",
"toast_weak_password_title": "Password is too weak",
"toast_weak_password_description": "The password must be at least 8 characters and include uppercase, lowercase, digits and a special character.",
@@ -2893,8 +2914,6 @@
"preview_pdf": "Preview PDF",
"preview_pdf_generating": "Generating...",
"preview_pdf_failed": "Could not generate PDF",
"review_customer_missing_title": "Customer details could not be loaded",
"review_customer_missing_description": "Reload the page and try again. Contact support if the problem persists.",
"create_invoice_failed_title": "Could not create invoice",
"doc_created_title": "{docLabel} created",
"doc_created_description": "{docLabel} {number} has been created",
@@ -2936,7 +2955,9 @@
"deduction_cap_check": "The customer needs to check their remaining allowance themselves.",
"deduction_summary_label": "Tax reduction ROT/RUT",
"to_pay_label": "Amount to pay",
"total_incl_vat_label": "Total incl. VAT"
"total_incl_vat_label": "Total incl. VAT",
"review_customer_missing_title": "Customer details could not be loaded",
"review_customer_missing_description": "Reload the page and try again. Contact support if the problem persists."
},
"invoice_review": {
"assigned_number_prefix": "Will be assigned invoice number",
@@ -3028,21 +3049,6 @@
"delivery_status_sent": "Sent",
"delivery_status_failed": "Failed",
"delivery_status_marked_sent": "Manual",
"delivery_status_delivered": "Delivered",
"delivery_status_delayed": "Delayed",
"delivery_status_complained": "Marked as spam",
"delivery_status_bounced": "Bounced",
"delivery_status_suppressed": "Blocked",
"delivery_status_explanation_sent": "The email provider accepted the message. No word yet on whether the recipient's server took it.",
"delivery_status_explanation_delivered": "The recipient's server accepted the message. If the customer still cannot find it, ask them to check their spam folder. Quarantine on the recipient's side is never visible to the sender.",
"delivery_status_explanation_delayed": "The recipient's server has not accepted the message yet, but delivery is still being retried. If nothing changes within a few hours, contact the customer.",
"delivery_status_explanation_complained": "The recipient marked the message as spam. Further sends to this address may be blocked.",
"delivery_status_explanation_bounced": "The recipient's server rejected the message. The invoice did not arrive.",
"delivery_status_explanation_failed": "The message could not be sent. The invoice did not arrive.",
"delivery_status_explanation_suppressed": "The email provider has blocked this address after an earlier bounce or spam complaint, so the message was never sent.",
"delivery_status_whole_send_note": "This applies to the whole send, not to individual recipients.",
"delivery_provider_status_label": "Delivery status",
"delivery_provider_reason_label": "Reason from the recipient",
"delivery_manual_unknown_details": "The invoice was delivered outside Accounted, so its recipients, message, and delivered file are unknown.",
"delivery_to_label": "To",
"delivery_cc_label": "Cc",
@@ -3189,7 +3195,22 @@
"cancelled_draft": "The draft has been cancelled.",
"cancel_failed_title": "Could not cancel the invoice",
"paid_toast_title": "Paid",
"paid_toast_description": "Invoice {number} has been marked as paid and posted"
"paid_toast_description": "Invoice {number} has been marked as paid and posted",
"delivery_status_delivered": "Delivered",
"delivery_status_delayed": "Delayed",
"delivery_status_complained": "Marked as spam",
"delivery_status_bounced": "Bounced",
"delivery_status_suppressed": "Blocked",
"delivery_status_explanation_sent": "The email provider accepted the message. No word yet on whether the recipient's server took it.",
"delivery_status_explanation_delivered": "The recipient's server accepted the message. If the customer still cannot find it, ask them to check their spam folder. Quarantine on the recipient's side is never visible to the sender.",
"delivery_status_explanation_delayed": "The recipient's server has not accepted the message yet, but delivery is still being retried. If nothing changes within a few hours, contact the customer.",
"delivery_status_explanation_complained": "The recipient marked the message as spam. Further sends to this address may be blocked.",
"delivery_status_explanation_bounced": "The recipient's server rejected the message. The invoice did not arrive.",
"delivery_status_explanation_failed": "The message could not be sent. The invoice did not arrive.",
"delivery_status_explanation_suppressed": "The email provider has blocked this address after an earlier bounce or spam complaint, so the message was never sent.",
"delivery_status_whole_send_note": "This applies to the whole send, not to individual recipients.",
"delivery_provider_status_label": "Delivery status",
"delivery_provider_reason_label": "Reason from the recipient"
},
"invoice_credit": {
"back": "Back",
@@ -3954,7 +3975,6 @@
"remove_blocked_cancel_cta": "Close",
"replace_uploading": "Replacing...",
"remove_failed": "Could not remove the document.",
"download_failed": "Could not open the document",
"replace_failed": "Could not upload new version.",
"choose_from_inbox": "Choose from inbox",
"picker_title": "Choose a document from the inbox",
@@ -3970,7 +3990,8 @@
"picker_preview": "Preview",
"picker_attach": "Attach document",
"picker_close": "Close",
"picker_preview_unavailable": "Preview could not be displayed."
"picker_preview_unavailable": "Preview could not be displayed.",
"download_failed": "Could not open the document"
},
"journal_status": {
"status_draft": "Draft",
+48 -27
View File
@@ -20,8 +20,6 @@
"load_more": "Ladda fler",
"retry": "Försök igen",
"load_error": "Kunde inte ladda data",
"popup_blocked_title": "Webbläsaren blockerade fliken",
"popup_blocked_description": "Tillåt popupfönster för Accounted i webbläsaren och försök igen.",
"confirm": "Bekräfta",
"yes": "Ja",
"no": "Nej",
@@ -66,7 +64,9 @@
"matched": "Matchad",
"unmatched": "Omatchad"
},
"more_options": "Fler alternativ"
"more_options": "Fler alternativ",
"popup_blocked_title": "Webbläsaren blockerade fliken",
"popup_blocked_description": "Tillåt popupfönster för Accounted i webbläsaren och försök igen."
},
"nav": {
"dashboard": "Översikt",
@@ -369,9 +369,16 @@
"sync_now": "Synka nu",
"syncing": "Synkar…",
"sync_done_title": "Synkronisering klar",
"sync_done_feed": "{fetched} transaktion(er) hämtade: {imported} nya i inkorgen, {linked} länkade till verifikat.",
"sync_done_empty": "Stripe returnerade inga transaktioner för perioden. Kontrollera att rätt konto är anslutet om du väntade dig transaktioner.",
"sync_done_description": "{settled} betalning(ar) bokförda, {review} kräver granskning.",
"sync_failed_title": "Synkroniseringen misslyckades",
"needs_review_title": "Kräver granskning",
"needs_review_hint": "Betalningar som inte kunde prickas av automatiskt. Hantera dem manuellt via fakturan eller i Stripe.",
"reason_invoice_not_found": "Betalning utan matchande faktura",
"reason_invoice_already_paid": "Fakturan är redan markerad som betald",
"reason_amount_mismatch": "Beloppet stämmer inte med fakturans restbelopp",
"reason_currency_mismatch": "Valutan stämmer inte med fakturan",
"reason_non_sek_invoice": "Faktura i utländsk valuta (bokförs manuellt)",
"reason_unknown": "Okänd orsak",
"transaction_sync_title": "Transaktioner från Stripe",
"transaction_sync_description": "Hämta alla Stripe-transaktioner (betalningar, avgifter, återbetalningar och utbetalningar) till transaktionsinkorgen varje natt, som ett bankflöde för ditt Stripe-saldo. Du bokför raderna som vanligt från inkorgen.",
"transaction_sync_backfill_note": "Vid första synkningen hämtas upp till 90 dagars historik, dock inte före bokföringslåset.",
@@ -379,7 +386,10 @@
"transaction_sync_never_synced": "Inte synkad ännu",
"transaction_sync_enabled_toast": "Transaktionssynk aktiverad. Historiken hämtas vid nästa synkning.",
"transaction_sync_disabled_toast": "Transaktionssynk avaktiverad.",
"transaction_sync_toggle_failed": "Kunde inte spara inställningen. Försök igen."
"transaction_sync_toggle_failed": "Kunde inte spara inställningen. Försök igen.",
"sync_done_transactions": "{imported} transaktion(er) importerade, {linked} länkade till verifikat.",
"sync_done_feed": "{fetched} transaktion(er) hämtade: {imported} nya i inkorgen, {linked} länkade till verifikat.",
"sync_done_empty": "Stripe returnerade inga transaktioner för perioden. Kontrollera att rätt konto är anslutet om du väntade dig transaktioner."
},
"settings_modal": {
"title": "Inställningar",
@@ -1374,6 +1384,13 @@
"joined_generic": "Du är nu medlem."
},
"settings_company": {
"members_remove_aria": "Ta bort medlem",
"members_revoke_aria": "Återkalla inbjudan",
"members_role_label": "Roll",
"fp_start_date_label": "Startdatum",
"fp_start_date_help": "Första räkenskapsåret kan börja valfri dag.",
"fp_range": "{start} till {end}",
"fp_months": "{count} månader",
"company_info_heading": "Företagsuppgifter",
"share_capital_heading": "Aktiekapital",
"aktiekapital_label": "Aktiekapital (kr)",
@@ -2239,6 +2256,7 @@
"env_prod": "Produktion"
},
"settings_bankid": {
"cancel_linking": "Avbryt",
"title": "BankID",
"toast_already_linked": "Detta BankID är redan kopplat till ett annat konto.",
"toast_link_failed": "Kunde inte koppla BankID.",
@@ -2256,6 +2274,9 @@
"link_button": "Koppla BankID"
},
"settings_security": {
"set_password_title": "Sätt ett lösenord",
"set_password_description": "Du loggade in med BankID och har inget lösenord ännu. Sätt ett lösenord för att kunna aktivera 2FA eller logga in när BankID inte är tillgängligt.",
"set_password_button": "Sätt lösenord",
"group_security": "Säkerhet",
"toast_weak_password_title": "Lösenordet är för svagt",
"toast_weak_password_description": "Lösenordet måste vara minst 8 tecken och innehålla versaler, gemener, siffror och specialtecken.",
@@ -2893,8 +2914,6 @@
"preview_pdf": "Förhandsgranska PDF",
"preview_pdf_generating": "Genererar...",
"preview_pdf_failed": "Kunde inte generera PDF",
"review_customer_missing_title": "Kunduppgifterna kunde inte laddas",
"review_customer_missing_description": "Ladda om sidan och försök igen. Kontakta support om det inte hjälper.",
"create_invoice_failed_title": "Kunde inte skapa faktura",
"doc_created_title": "{docLabel} skapad",
"doc_created_description": "{docLabel} {number} har skapats",
@@ -2936,7 +2955,9 @@
"deduction_cap_check": "Kunden behöver kontrollera sitt återstående utrymme själv.",
"deduction_summary_label": "Skattereduktion ROT/RUT",
"to_pay_label": "Att betala",
"total_incl_vat_label": "Totalt inkl. moms"
"total_incl_vat_label": "Totalt inkl. moms",
"review_customer_missing_title": "Kunduppgifterna kunde inte laddas",
"review_customer_missing_description": "Ladda om sidan och försök igen. Kontakta support om det inte hjälper."
},
"invoice_review": {
"assigned_number_prefix": "Tilldelas fakturanummer",
@@ -3028,21 +3049,6 @@
"delivery_status_sent": "Skickad",
"delivery_status_failed": "Misslyckad",
"delivery_status_marked_sent": "Manuell",
"delivery_status_delivered": "Levererad",
"delivery_status_delayed": "Fördröjd",
"delivery_status_complained": "Spam-anmäld",
"delivery_status_bounced": "Studsade",
"delivery_status_suppressed": "Blockerad",
"delivery_status_explanation_sent": "Mailet är accepterat av e-posttjänsten. Besked om att mottagarens server tagit emot det har inte kommit in ännu.",
"delivery_status_explanation_delivered": "Mottagarens server tog emot mailet. Hittar kunden det ändå inte: be dem titta i skräpposten. Karantän hos mottagarens IT-avdelning syns aldrig för avsändaren.",
"delivery_status_explanation_delayed": "Mottagarens server har inte tagit emot mailet ännu, men nya försök pågår. Kommer inget besked inom några timmar: hör av dig till kunden.",
"delivery_status_explanation_complained": "Mottagaren markerade mailet som skräppost. Fortsatta utskick till adressen kan komma att blockeras.",
"delivery_status_explanation_bounced": "Mottagarens server avvisade mailet. Fakturan kom inte fram.",
"delivery_status_explanation_failed": "Mailet kunde inte skickas ut. Fakturan kom inte fram.",
"delivery_status_explanation_suppressed": "Adressen är spärrad hos e-posttjänsten efter tidigare studs eller spam-anmälan, så mailet skickades aldrig.",
"delivery_status_whole_send_note": "Beskedet gäller hela utskicket, inte enskilda mottagare.",
"delivery_provider_status_label": "Leveransstatus",
"delivery_provider_reason_label": "Besked från mottagaren",
"delivery_manual_unknown_details": "Utskicket gjordes utanför Accounted. Mottagare, meddelande och den levererade filen är därför inte kända.",
"delivery_to_label": "Till",
"delivery_cc_label": "Kopia",
@@ -3189,7 +3195,22 @@
"cancelled_draft": "Utkastet har makulerats.",
"cancel_failed_title": "Kunde inte makulera fakturan",
"paid_toast_title": "Betald",
"paid_toast_description": "Faktura {number} har markerats som betald och bokförts"
"paid_toast_description": "Faktura {number} har markerats som betald och bokförts",
"delivery_status_delivered": "Levererad",
"delivery_status_delayed": "Fördröjd",
"delivery_status_complained": "Spam-anmäld",
"delivery_status_bounced": "Studsade",
"delivery_status_suppressed": "Blockerad",
"delivery_status_explanation_sent": "Mailet är accepterat av e-posttjänsten. Besked om att mottagarens server tagit emot det har inte kommit in ännu.",
"delivery_status_explanation_delivered": "Mottagarens server tog emot mailet. Hittar kunden det ändå inte: be dem titta i skräpposten. Karantän hos mottagarens IT-avdelning syns aldrig för avsändaren.",
"delivery_status_explanation_delayed": "Mottagarens server har inte tagit emot mailet ännu, men nya försök pågår. Kommer inget besked inom några timmar: hör av dig till kunden.",
"delivery_status_explanation_complained": "Mottagaren markerade mailet som skräppost. Fortsatta utskick till adressen kan komma att blockeras.",
"delivery_status_explanation_bounced": "Mottagarens server avvisade mailet. Fakturan kom inte fram.",
"delivery_status_explanation_failed": "Mailet kunde inte skickas ut. Fakturan kom inte fram.",
"delivery_status_explanation_suppressed": "Adressen är spärrad hos e-posttjänsten efter tidigare studs eller spam-anmälan, så mailet skickades aldrig.",
"delivery_status_whole_send_note": "Beskedet gäller hela utskicket, inte enskilda mottagare.",
"delivery_provider_status_label": "Leveransstatus",
"delivery_provider_reason_label": "Besked från mottagaren"
},
"invoice_credit": {
"back": "Tillbaka",
@@ -3954,7 +3975,6 @@
"remove_blocked_cancel_cta": "Stäng",
"replace_uploading": "Ersätter...",
"remove_failed": "Kunde inte ta bort underlaget.",
"download_failed": "Kunde inte öppna dokumentet",
"replace_failed": "Kunde inte ladda upp ny version.",
"choose_from_inbox": "Välj från inkorgen",
"picker_title": "Välj underlag från inkorgen",
@@ -3970,7 +3990,8 @@
"picker_preview": "Förhandsgranska",
"picker_attach": "Koppla underlag",
"picker_close": "Stäng",
"picker_preview_unavailable": "Förhandsgranskning kunde inte visas."
"picker_preview_unavailable": "Förhandsgranskning kunde inte visas.",
"download_failed": "Kunde inte öppna dokumentet"
},
"journal_status": {
"status_draft": "Utkast",