Bug/resend and invoices (#1192)
* fix(invoices): anchor the PDF logo to the top-left of its header cell The logo box is always the full 240x80pt reserved area (any larger logo is clamped to exactly that), so objectFit: 'contain' placed the image inside it with the default 50% 50% centering. A wide banner logo fills the width and lands on the left margin, but a near-square logo scaled down to the 80pt height cap is only ~117pt wide and got pushed ~60pt in from the margin, which reads as a misaligned logo and forced companies to reshape their artwork. Anchor the image top-left so every aspect ratio starts at the margin. Covered by a test that renders the real PDF and reads the image placement matrix out of the content stream, for both a wide and a near-square logo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(invoices): show the real delivery outcome in the send history "Skickad" only meant the email provider accepted the message, so a bounced invoice looked identical to one that arrived. Resend reports the outcome asynchronously; that report now lands on the delivery row and drives the history: green is reserved for a confirmed delivery, bounce/blocked reads red, delayed and spam-marked read amber, and an accepted-but-unconfirmed send is neutral instead of falsely green. The report arrives on a signed webhook and may only touch the three new provider status columns of an already sent, unredacted row: the WORM trigger proves nothing else changed, and a lower ranked or older report can never downgrade an observed failure. The provider reason text can quote the failing address, so it is masked on read and cleared by the daily PII redaction job. Timestamps also formatted in Europe/Stockholm instead of falling back to the runtime zone, which rendered a 14:05 send as 12:05 on Vercel. Delivery reports are per message, never per recipient: Resend sends one event for the whole message, so splitting a send per recipient would be the only way to get finer granularity, at the cost of CC. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(stripe): make the integration feed-only Stripe sync now only imports balance transactions into the transactions inbox, like any bank feed; nothing auto-books. The event/settlement sync (lib/sync.ts, lib/payouts.ts) stays in the repo but is no longer wired to any route or cron: the 15-min sync cron is removed from vercel.json. Payment links on invoice send are unchanged; their payments arrive as feed rows and are matched manually. - /sync runs only syncStripeBalanceTransactions; response is { success, transactions } - connecting via OAuth enables the nightly feed by default (toggle stays as opt-out) - panel: needs-review section and plumbing removed, copy rewritten to transactions-first (sv + en), toast reports fetched/imported/linked and calls out an empty result instead of silent all-zeros Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): return the article currency from the v1 article list The dashboard, importer, export and MCP article surfaces all learned to carry a non-SEK article price (#1166, #1183, #1184), but the v1 projection still omitted currency. An API or agent caller therefore read price_excl_vat with nothing marking it as EUR and would copy the number straight onto a SEK invoice line, at a nine-to-one error. Adds currency to the projection, the response shape and the example, plus a pitfall stating the price is not always SEK and that this endpoint does no FX conversion. Additive field only; no migration (articles.currency already exists). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): replace the settings modal with a routed panel sheet Settings now renders as a sheet that fills the main panel, sliding up over the page the user came from and back down on close, with the sidebar and frame left visible and usable. Behind it sits one shared master-detail surface: underline search across every section and subsection, the grouped section rail, and the active section as a direct-editing accordion. All 11 sections are decomposed into subsections, and the legacy *SettingsContent components compose the same pieces so the stacked and accordion layouts cannot drift. The sheet is the only presentation, on every entry path. The intercepting route handles in-app navigation and closes by popping the history entry, landing back on the page underneath. @settingsModal/default.tsx handles cold loads (refresh, deep link, new tab), where interception never fires; nothing is mounted underneath there, so it closes to the dashboard. Both branch on one shared predicate, isSheetSection, together with the settings layout, which must render nothing for those sections or the surface would stack twice behind the sheet and run every section's fetches twice. Closing is deliberate rather than incidental: the X, Esc, or navigating away. The dialog is non-modal so the sidebar's account popover and company switcher keep working with settings up, and an outside click no longer dismisses it. Sections land fully collapsed, and the scroll position of the page behind survives opening and closing the sheet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: enhance article management and settings UI - Add PATCH test for toggling article active state without other fields. - Remove unused MessageCircle icon from DashboardContent. - Refactor AccountingFrameworkForm to use SettingsFieldRow for better help text display. - Update CompanyInfoForm, DimensionsToggle, and various settings forms to replace description with help text. - Remove redundant headings and intros in several settings components to streamline UI. - Improve help text for various settings in English and Swedish translations. - Update structured error messages for better clarity on article deletion. * refactor(ArticleDetailPage): remove unused imports and duplicate state variable * fix(settings): own deep-linked settings routes by route list, not nav visibility Review fixes from the settings panel sheet work: * isSheetSection reads the full settings route list so a hidden-but-deep-linked section (assistant before BankID, banking in sandbox, api without MCP) is claimed by the sheet instead of rendering the legacy shell around an empty panel * keep 503 on the Resend delivery webhook when the signing secret is unset, with a test pinning the behaviour * stripe callback route test coverage Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: update salary, tax, and templates settings components - Refactored SalarySettingsContent to use a form wrapper and improved payment settings UI. - Enhanced TaxSettingsContent with new signals for EU sales, KU obligations, and ROT/RUT deductions. - Updated TemplatesSettingsContent to remove legacy comments and improve readability. - Simplified navigation items by removing unnecessary constants and directly using hrefs. - Cleaned up translation files by removing deprecated keys and adding new descriptions for clarity. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f07a34c51b
commit
d54b43f80f
@@ -5,15 +5,21 @@ import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Sun, Moon, Monitor, LogOut, Languages, ExternalLink } from 'lucide-react'
|
||||
import { Sun, Moon, Monitor, LogOut, ExternalLink } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { SecuritySettings } from '@/components/settings/SecuritySettings'
|
||||
import { InstallAppSection } from '@/components/settings/InstallAppSection'
|
||||
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
|
||||
import { AccountDangerZone } from '@/components/settings/AccountDangerZone'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsInput,
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
SettingsSectionHeader,
|
||||
SettingsSeg,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import { clearRecaptIdentity } from '@/lib/recapt'
|
||||
@@ -31,6 +37,8 @@ export function AccountSettingsContent() {
|
||||
const activeLocale = useLocale() as Locale
|
||||
const tCommon = useTranslations('common')
|
||||
const tSettings = useTranslations('settings')
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
const [savingLocale, setSavingLocale] = useState(false)
|
||||
const [fullName, setFullName] = useState('')
|
||||
const [initialName, setInitialName] = useState('')
|
||||
@@ -115,155 +123,139 @@ export function AccountSettingsContent() {
|
||||
en: tCommon('language_english'),
|
||||
}
|
||||
|
||||
const nameUnchanged = !fullName.trim() || fullName.trim() === initialName
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Name */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{tSettings('section_name')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
{tSettings('name_description')}
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="flex-1 space-y-2 sm:max-w-sm">
|
||||
<Label htmlFor="full_name">{tSettings('name_label')}</Label>
|
||||
<Input
|
||||
id="full_name"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
placeholder={tSettings('name_placeholder')}
|
||||
disabled={nameLoading || savingName}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSaveName}
|
||||
disabled={
|
||||
nameLoading || savingName || !fullName.trim() || fullName.trim() === initialName
|
||||
}
|
||||
>
|
||||
{savingName ? tCommon('saving') : tCommon('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<div>
|
||||
<SettingsSectionHeader title={tNav('account')} intro={tIntro('account')} />
|
||||
|
||||
{/* Appearance */}
|
||||
<section className="space-y-4 border-t border-border pt-8">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{tSettings('section_appearance')}
|
||||
</h2>
|
||||
{mounted && (
|
||||
<div className="flex gap-3">
|
||||
{([
|
||||
{ value: 'light', labelKey: 'theme_light', icon: Sun },
|
||||
{ value: 'dark', labelKey: 'theme_dark', icon: Moon },
|
||||
{ value: 'system', labelKey: 'theme_system', icon: Monitor },
|
||||
] as const).map(({ value, labelKey, icon: Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setTheme(value)}
|
||||
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-3 text-sm font-medium transition-colors ${
|
||||
theme === value
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/40'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
{tCommon(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Language */}
|
||||
<section className="space-y-4 border-t border-border pt-8">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{tSettings('section_language')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
{tSettings('language_description')}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
{SUPPORTED_LOCALES.map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => handleLocaleChange(value)}
|
||||
disabled={savingLocale}
|
||||
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-3 text-sm font-medium transition-colors disabled:opacity-50 ${
|
||||
activeLocale === value
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/40'
|
||||
}`}
|
||||
{/* Profile: name, appearance, language, install-as-app */}
|
||||
<SettingsGroup label={tSettings('group_profile')}>
|
||||
<SettingsRow
|
||||
label={tSettings('name_label')}
|
||||
htmlFor="full_name"
|
||||
help={tSettings('name_description')}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="full_name"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
placeholder={tSettings('name_placeholder')}
|
||||
disabled={nameLoading || savingName}
|
||||
maxLength={100}
|
||||
/>
|
||||
<SettingsRowEnd>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveName}
|
||||
disabled={nameLoading || savingName || nameUnchanged}
|
||||
>
|
||||
<Languages className="h-4 w-4 text-muted-foreground" />
|
||||
{localeLabels[value]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{savingName ? tCommon('saving') : tCommon('save')}
|
||||
</Button>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
|
||||
{/* Install as app: renders nothing when already running installed */}
|
||||
<InstallAppSection />
|
||||
<SettingsRow label={tSettings('section_appearance')}>
|
||||
{mounted && (
|
||||
<SettingsSeg
|
||||
value={theme ?? 'system'}
|
||||
onChange={setTheme}
|
||||
aria-label={tSettings('section_appearance')}
|
||||
options={[
|
||||
{
|
||||
value: 'light',
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Sun className="h-3.5 w-3.5" />
|
||||
{tCommon('theme_light')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Moon className="h-3.5 w-3.5" />
|
||||
{tCommon('theme_dark')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'system',
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Monitor className="h-3.5 w-3.5" />
|
||||
{tCommon('theme_system')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</SettingsRow>
|
||||
|
||||
{/* Security */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<SecuritySettings />
|
||||
</div>
|
||||
<SettingsRow
|
||||
label={tSettings('section_language')}
|
||||
help={tSettings('language_description')}
|
||||
>
|
||||
<SettingsSeg
|
||||
value={activeLocale}
|
||||
onChange={(next) => void handleLocaleChange(next)}
|
||||
disabled={savingLocale}
|
||||
aria-label={tSettings('section_language')}
|
||||
options={SUPPORTED_LOCALES.map((value) => ({
|
||||
value,
|
||||
label: localeLabels[value],
|
||||
}))}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
{/* Calendar feed */}
|
||||
{hasCalendarExtension && (
|
||||
<div className="border-t border-border pt-8">
|
||||
<CalendarFeedSettings />
|
||||
</div>
|
||||
)}
|
||||
{/* Install as app: renders nothing when already running installed */}
|
||||
<InstallAppSection />
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Logout */}
|
||||
<section className="space-y-4 border-t border-border pt-8">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{tCommon('account_settings')}
|
||||
</h2>
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">{tCommon('logout')}</p>
|
||||
<p className="text-sm text-muted-foreground">{tCommon('logout_description')}</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{tCommon('logout')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
{/* Security: BankID, password, 2FA (renders its own group) */}
|
||||
<SecuritySettings />
|
||||
|
||||
{/* Calendar feed (extension-gated) */}
|
||||
{hasCalendarExtension && <CalendarFeedSettings />}
|
||||
|
||||
{/* Privacy & agreements: surface the otherwise-unlinked DPA + privacy policy */}
|
||||
<section className="space-y-4 border-t border-border pt-8">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{tSettings('legal_title')}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
href="/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between rounded-lg border p-4 transition-colors hover:bg-secondary/60"
|
||||
>
|
||||
<span className="font-medium">{tSettings('legal_privacy')}</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/dpa"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between rounded-lg border p-4 transition-colors hover:bg-secondary/60"
|
||||
>
|
||||
<span className="font-medium">{tSettings('legal_dpa')}</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
<SettingsGroup label={tSettings('legal_title')}>
|
||||
<SettingsRow label={tSettings('legal_privacy')}>
|
||||
<SettingsRowEnd>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/privacy" target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="mr-2 h-3.5 w-3.5" />
|
||||
{tCommon('open')}
|
||||
</Link>
|
||||
</Button>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={tSettings('legal_dpa')}>
|
||||
<SettingsRowEnd>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/dpa" target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="mr-2 h-3.5 w-3.5" />
|
||||
{tCommon('open')}
|
||||
</Link>
|
||||
</Button>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Sign out */}
|
||||
<SettingsGroup>
|
||||
<SettingsRow label={tCommon('logout')} help={tCommon('logout_description')}>
|
||||
<SettingsRowEnd>
|
||||
<Button variant="ghost" size="sm" onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-3.5 w-3.5" />
|
||||
{tCommon('logout')}
|
||||
</Button>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Delete account: only for non-sandbox */}
|
||||
{!settings?.is_sandbox && <AccountDangerZone />}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel'
|
||||
import { OAuthClientsPanel } from '@/components/settings/OAuthClientsPanel'
|
||||
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
|
||||
|
||||
export function ApiSettingsContent() {
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<SettingsSectionHeader title={tNav('api')} intro={tIntro('api')} />
|
||||
<ApiKeysPanel />
|
||||
<OAuthClientsPanel />
|
||||
</div>
|
||||
|
||||
@@ -3,18 +3,23 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { AgentMemoryPanel } from '@/components/settings/AgentMemoryPanel'
|
||||
import { AgentSkillsPanel } from '@/components/settings/AgentSkillsPanel'
|
||||
import { AgentKnowledgePanel } from '@/components/agent-knowledge/AgentKnowledgePanel'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
SettingsSectionHeader,
|
||||
SettingsSeg,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
|
||||
// "Assistenten": the ledger profile the agent reads before booking (Kunskap =
|
||||
// "Vad din agent vet", opens on the konteringskarta and is the default view),
|
||||
// what the assistant remembers about this company (Minne, editable), and the
|
||||
// domain knowledge it ships with (Kompetens, read-only). Tabs keep all three
|
||||
// one click away instead of stacked.
|
||||
// domain knowledge it ships with (Kompetens, read-only). The segmented control
|
||||
// keeps all three one click away instead of stacked.
|
||||
type View = 'knowledge' | 'memory' | 'skills'
|
||||
|
||||
const VIEW_ROUTE: Record<View, string> = {
|
||||
@@ -23,40 +28,43 @@ const VIEW_ROUTE: Record<View, string> = {
|
||||
skills: '/settings/assistant?view=skills',
|
||||
}
|
||||
|
||||
const VIEW_OPTIONS: Array<{ value: View; label: string }> = [
|
||||
{ value: 'knowledge', label: 'Kunskap' },
|
||||
{ value: 'memory', label: 'Minne' },
|
||||
{ value: 'skills', label: 'Kompetens' },
|
||||
]
|
||||
|
||||
export function AssistantSettingsContent() {
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const raw = searchParams.get('view')
|
||||
const view: View = raw === 'skills' ? 'skills' : raw === 'memory' ? 'memory' : 'knowledge'
|
||||
|
||||
function setView(next: string) {
|
||||
function setView(next: View) {
|
||||
// 'knowledge' is the default: keep its URL clean (no query string).
|
||||
router.replace(VIEW_ROUTE[next as View] ?? VIEW_ROUTE.knowledge, { scroll: false })
|
||||
router.replace(VIEW_ROUTE[next] ?? VIEW_ROUTE.knowledge, { scroll: false })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Tabs value={view} onValueChange={setView} className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="knowledge">Kunskap</TabsTrigger>
|
||||
<TabsTrigger value="memory">Minne</TabsTrigger>
|
||||
<TabsTrigger value="skills">Kompetens</TabsTrigger>
|
||||
</TabsList>
|
||||
<div>
|
||||
<SettingsSectionHeader title={tNav('assistant')} intro={tIntro('assistant')} />
|
||||
|
||||
{/* Radix unmounts the inactive panel, so each panel's data is fetched
|
||||
lazily the first time its tab is opened. */}
|
||||
<TabsContent value="knowledge">
|
||||
<AgentKnowledgePanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="memory">
|
||||
<AgentMemoryPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="skills">
|
||||
<AgentSkillsPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className="mt-6">
|
||||
<SettingsSeg value={view} onChange={setView} options={VIEW_OPTIONS} aria-label="Välj vy" />
|
||||
</div>
|
||||
|
||||
<FabVisibilityCard />
|
||||
{/* Only the active view mounts, so each panel's data is fetched lazily
|
||||
the first time its view is opened (same behavior as when Radix Tabs
|
||||
unmounted the inactive panels). */}
|
||||
<div className="mt-6">
|
||||
{view === 'knowledge' && <AgentKnowledgePanel />}
|
||||
{view === 'memory' && <AgentMemoryPanel />}
|
||||
{view === 'skills' && <AgentSkillsPanel />}
|
||||
</div>
|
||||
|
||||
<FabVisibilityRow />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -65,7 +73,7 @@ export function AssistantSettingsContent() {
|
||||
// lives on user_preferences (server-rendered into the dashboard layout), so
|
||||
// a successful save triggers router.refresh() to make the button react
|
||||
// immediately instead of on next navigation.
|
||||
function FabVisibilityCard() {
|
||||
function FabVisibilityRow() {
|
||||
const t = useTranslations('settings_assistant')
|
||||
const router = useRouter()
|
||||
// null = not yet loaded (switch disabled meanwhile)
|
||||
@@ -108,19 +116,17 @@ function FabVisibilityCard() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6 flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{t('fab_title')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('fab_description')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={hideFab === null ? true : !hideFab}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={hideFab === null || saving}
|
||||
aria-label={t('fab_title')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SettingsGroup>
|
||||
<SettingsRow label={t('fab_title')} help={t('fab_description')}>
|
||||
<SettingsRowEnd>
|
||||
<Switch
|
||||
checked={hideFab === null ? true : !hideFab}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={hideFab === null || saving}
|
||||
aria-label={t('fab_title')}
|
||||
/>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
@@ -12,11 +11,14 @@ import { AlertTriangle, CreditCard, ExternalLink } from 'lucide-react'
|
||||
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
|
||||
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
|
||||
|
||||
const BankingPanel = getSettingsPanel('enable-banking')
|
||||
|
||||
export function BankingSettingsContent() {
|
||||
const t = useTranslations('settings_banking')
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
@@ -74,32 +76,36 @@ export function BankingSettingsContent() {
|
||||
}, [searchParams, router, toast, t])
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<SettingsSectionHeader title={tNav('banking')} intro={tIntro('banking')} />
|
||||
|
||||
{/* OAuth bounce-back failure: a live warning, so it stays visible in the
|
||||
page flow, as compact warning-tone lines instead of a bordered box. */}
|
||||
{bankConnectionError && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-destructive p-4">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-destructive">{bankConnectionError}</p>
|
||||
<div role="alert" className="mt-6 flex items-start gap-2 px-1">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-attn" />
|
||||
<div className="min-w-0 flex-1 space-y-1 text-[12.5px] leading-relaxed">
|
||||
<p className="text-attn">{bankConnectionError}</p>
|
||||
{isAccessDenied && failedBankName && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground">
|
||||
{t('access_denied_hint', { bankName: failedBankName })}
|
||||
</p>
|
||||
)}
|
||||
{showHbPoaHint && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground">
|
||||
{t('hb_business_poa_hint')}{' '}
|
||||
<a
|
||||
href="https://tilisy.enablebanking.com/guides/SE/Handelsbanken/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
{t('hb_business_poa_link')}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('import_fallback_text')}<Link href="/import?mode=bank" className="underline hover:text-foreground">{t('import_fallback_link')}</Link>{t('import_fallback_suffix')}
|
||||
<p className="text-muted-foreground">
|
||||
{t('import_fallback_text')}<Link href="/import?mode=bank" className="underline underline-offset-2 hover:text-foreground">{t('import_fallback_link')}</Link>{t('import_fallback_suffix')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -109,7 +115,7 @@ export function BankingSettingsContent() {
|
||||
setIsAccessDenied(false)
|
||||
setShowHbPoaHint(false)
|
||||
}}
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground hover:text-foreground"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
||||
aria-label={t('dismiss_aria')}
|
||||
>
|
||||
<span className="text-lg leading-none">×</span>
|
||||
@@ -119,26 +125,28 @@ export function BankingSettingsContent() {
|
||||
|
||||
{hasBankingExtension && BankingPanel ? (
|
||||
<>
|
||||
<BankSyncStatusChip />
|
||||
{/* The chip renders null when there are no connections; empty:hidden
|
||||
keeps its margin from leaving a stray gap in that case. */}
|
||||
<div className="mt-6 empty:hidden">
|
||||
<BankSyncStatusChip />
|
||||
</div>
|
||||
<BankingPanel />
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<EmptyState
|
||||
icon={CreditCard}
|
||||
title={t('not_enabled_title')}
|
||||
description={t('not_enabled_description')}
|
||||
>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/extensions">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
{t('go_to_extensions')}
|
||||
</Link>
|
||||
</Button>
|
||||
</EmptyState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="pt-8">
|
||||
<EmptyState
|
||||
icon={CreditCard}
|
||||
title={t('not_enabled_title')}
|
||||
description={t('not_enabled_description')}
|
||||
>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/extensions">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
{t('go_to_extensions')}
|
||||
</Link>
|
||||
</Button>
|
||||
</EmptyState>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, Clock, Minus } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Check } from 'lucide-react'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
SettingsRowNote,
|
||||
SettingsSectionHeader,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { formatDateLong } from '@/lib/utils'
|
||||
import { BillingActions } from '@/components/settings/BillingActions'
|
||||
|
||||
@@ -16,15 +23,14 @@ const INCLUDED = [
|
||||
'E-postutskick av fakturor, påminnelser och lönebesked',
|
||||
]
|
||||
|
||||
// Free vs paid, shown as a comparison table: what the paid tier adds reads
|
||||
// strongest next to what stays free forever (freeze-and-retain, nothing is
|
||||
// taken away). Free rows mirror the old ALWAYS_FREE copy.
|
||||
const FEATURE_MATRIX: { label: string; free: boolean }[] = [
|
||||
{ label: 'Bokföring och rapporter', free: true },
|
||||
{ label: 'Fakturering', free: true },
|
||||
{ label: 'SIE-export', free: true },
|
||||
{ label: 'Org.nr-uppslag och momsnummerkontroll', free: true },
|
||||
...INCLUDED.map((label) => ({ label, free: false })),
|
||||
// What stays free forever (freeze-and-retain, nothing is taken away). Shown
|
||||
// as the second column of the flat feature list so the paid tier reads
|
||||
// strongest right next to it. Mirrors the old ALWAYS_FREE copy.
|
||||
const ALWAYS_FREE = [
|
||||
'Bokföring och rapporter',
|
||||
'Fakturering',
|
||||
'SIE-export',
|
||||
'Org.nr-uppslag och momsnummerkontroll',
|
||||
]
|
||||
|
||||
// Mirrors the checkout route's deferred-first-charge condition (Stripe's 48h
|
||||
@@ -42,12 +48,30 @@ interface BillingView {
|
||||
isDemo: boolean
|
||||
}
|
||||
|
||||
function FeatureList({ heading, items }: { heading: string; items: string[] }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">{heading}</p>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{items.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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → Abonnemang. Rendered both as the full page (thin wrapper) and
|
||||
* inside the settings modal (via SETTINGS_SECTIONS), so it's a client component
|
||||
* that reads its state from GET /api/billing/status.
|
||||
*/
|
||||
export function BillingSettingsContent() {
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
const [view, setView] = useState<BillingView | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -80,11 +104,16 @@ export function BillingSettingsContent() {
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
|
||||
const header = <SettingsSectionHeader title={tNav('billing')} intro={tIntro('billing')} />
|
||||
|
||||
if (!view) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<div>
|
||||
{header}
|
||||
<div className="mt-6 space-y-4">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -93,42 +122,46 @@ export function BillingSettingsContent() {
|
||||
// them to creating a real account instead of a pay button that would 403.
|
||||
if (view.isDemo) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Abonnemang</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Du provkör Accounted i en demo. Skapa ett riktigt konto för att aktivera
|
||||
abonnemang, AI-assistent, bankkoppling och inlämning till Skatteverket.
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
<div>
|
||||
{header}
|
||||
<SettingsGroup label="Ditt abonnemang">
|
||||
<SettingsRow label="Status" borderless>
|
||||
<span>Demo</span>
|
||||
<SettingsRowNote>
|
||||
Du provkör Accounted i en demo. Skapa ett riktigt konto för att aktivera
|
||||
abonnemang, AI-assistent, bankkoppling och inlämning till Skatteverket.
|
||||
</SettingsRowNote>
|
||||
</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 className="h-4 w-4 mt-1 shrink-0 text-foreground" />
|
||||
<Check aria-hidden="true" className="mt-1 h-3.5 w-3.5 shrink-0 text-foreground" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Paying company → manage view.
|
||||
if (view.isPaying) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Abonnemang</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ditt abonnemang är aktivt. Du kan hantera eller avsluta det när som helst.
|
||||
</p>
|
||||
<BillingActions isPaying configured={view.configured} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div>
|
||||
{header}
|
||||
<SettingsGroup label="Ditt abonnemang">
|
||||
<SettingsRow label="Status" borderless>
|
||||
<span>Aktivt</span>
|
||||
<SettingsRowNote>Du kan hantera eller avsluta det när som helst.</SettingsRowNote>
|
||||
<SettingsRowEnd>
|
||||
<BillingActions isPaying configured={view.configured} />
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -136,18 +169,18 @@ export function BillingSettingsContent() {
|
||||
// confirm instead of re-showing the sell pitch to someone who already paid.
|
||||
if (view.paidJustNow) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Abonnemang</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="flex items-start gap-2 text-sm">
|
||||
<Check className="h-4 w-4 mt-0.5 shrink-0 text-foreground" />
|
||||
<span>Klart! Ditt abonnemang är aktiverat och alla funktioner låses upp inom någon minut.</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Ladda om sidan om du inte ser ändringen.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div>
|
||||
{header}
|
||||
<SettingsGroup label="Ditt abonnemang">
|
||||
<SettingsRow label="Status" borderless>
|
||||
<span className="flex items-start gap-2">
|
||||
<Check aria-hidden="true" className="mt-1 h-3.5 w-3.5 shrink-0 text-foreground" />
|
||||
<span>Klart! Ditt abonnemang är aktiverat och alla funktioner låses upp inom någon minut.</span>
|
||||
</span>
|
||||
<SettingsRowNote>Ladda om sidan om du inte ser ändringen.</SettingsRowNote>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -156,84 +189,60 @@ export function BillingSettingsContent() {
|
||||
const deferredTo = view.chargeDeferred ? trialEndsAt : null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
{header}
|
||||
|
||||
{/* Consequential trial countdown: one attn-tone sentence, not a banner. */}
|
||||
{daysLeft !== null && (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-border bg-secondary px-4 py-3 text-sm">
|
||||
<Clock className="h-4 w-4 shrink-0 text-foreground" />
|
||||
<span>
|
||||
{daysLeft > 0
|
||||
? `Din provperiod löper ut om ${daysLeft} ${daysLeft === 1 ? 'dag' : 'dagar'}${
|
||||
trialEndsAt ? ` (${formatDateLong(trialEndsAt)})` : ''
|
||||
}. ${
|
||||
deferredTo
|
||||
? 'Lägg till ditt kort nu: inget dras förrän provperioden är slut.'
|
||||
: 'Lägg till betalning nu så fortsätter allt utan avbrott.'
|
||||
}`
|
||||
: 'Din provperiod har löpt ut. Aktivera abonnemanget för att få tillbaka AI, bankkoppling och inlämning.'}
|
||||
</span>
|
||||
</div>
|
||||
<AttnLine className="mt-3">
|
||||
{daysLeft > 0
|
||||
? `Din provperiod löper ut om ${daysLeft} ${daysLeft === 1 ? 'dag' : 'dagar'}${
|
||||
trialEndsAt ? ` (${formatDateLong(trialEndsAt)})` : ''
|
||||
}. ${
|
||||
deferredTo
|
||||
? 'Lägg till ditt kort nu: inget dras förrän provperioden är slut.'
|
||||
: 'Lägg till betalning nu så fortsätter allt utan avbrott.'
|
||||
}`
|
||||
: 'Din provperiod har löpt ut. Aktivera abonnemanget för att få tillbaka AI, bankkoppling och inlämning.'}
|
||||
</AttnLine>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Allt du behöver för att sköta bokföringen själv</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<SettingsGroup
|
||||
label="Allt du behöver för att sköta bokföringen själv"
|
||||
help="Ingen bindningstid · Avsluta när du vill · Säker betalning via Stripe"
|
||||
>
|
||||
<div className="px-1 pt-3">
|
||||
<BillingActions isPaying={false} configured={view.configured} firstChargeAt={deferredTo} />
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
{deferredTo && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">Så funkar det</h3>
|
||||
<ol className="space-y-2 text-sm">
|
||||
<li className="flex gap-3">
|
||||
<span className="w-28 shrink-0 text-muted-foreground">Idag</span>
|
||||
<span>Du lägger till ditt kort. Inget dras nu.</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="w-28 shrink-0 text-muted-foreground tabular-nums">{formatDateLong(deferredTo)}</span>
|
||||
<span>Provperioden slutar och den första debiteringen sker.</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="w-28 shrink-0 text-muted-foreground">När som helst</span>
|
||||
<span>Avsluta direkt via Stripe. Före {formatDateLong(deferredTo)} kostar det ingenting.</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
{deferredTo && (
|
||||
<SettingsGroup label="Så funkar det">
|
||||
<SettingsRow label="Idag">
|
||||
<span>Du lägger till ditt kort. Inget dras nu.</span>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={<span className="tabular-nums">{formatDateLong(deferredTo)}</span>}>
|
||||
<span>Provperioden slutar och den första debiteringen sker.</span>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="När som helst" borderless>
|
||||
<span>
|
||||
Avsluta direkt via Stripe. Före {formatDateLong(deferredTo)} kostar det ingenting.
|
||||
</span>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
)}
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-full">Funktion</TableHead>
|
||||
<TableHead className="text-center">Gratis</TableHead>
|
||||
<TableHead className="text-center">Abonnemang</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{FEATURE_MATRIX.map((f) => (
|
||||
<TableRow key={f.label}>
|
||||
<TableCell className="text-sm">{f.label}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{f.free ? (
|
||||
<Check role="img" aria-label="Ingår" className="h-4 w-4 mx-auto text-foreground" />
|
||||
) : (
|
||||
<Minus role="img" aria-label="Ingår inte" className="h-4 w-4 mx-auto text-muted-foreground/50" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Check role="img" aria-label="Ingår" className="h-4 w-4 mx-auto text-foreground" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Utan abonnemang behåller du bokföringen, fakturorna, rapporterna och all din data utan kostnad. Ingenting
|
||||
raderas: räkenskapsinformation bevaras i sju år enligt bokföringslagen, oavsett abonnemang.
|
||||
</p>
|
||||
<SettingsGroup
|
||||
label="Funktioner"
|
||||
// The 7-year retention reassurance moved behind the "?": long legal
|
||||
// copy stays out of the page flow.
|
||||
help="Utan abonnemang behåller du bokföringen, fakturorna, rapporterna och all din data utan kostnad. Ingenting raderas: räkenskapsinformation bevaras i sju år enligt bokföringslagen, oavsett abonnemang."
|
||||
>
|
||||
<div className="grid gap-6 px-1 pt-3 sm:grid-cols-2">
|
||||
<FeatureList heading="I abonnemanget" items={INCLUDED} />
|
||||
<FeatureList heading="Alltid gratis" items={ALWAYS_FREE} />
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,9 +14,14 @@ import { applyDefaultSeriesToMap } from '@/lib/bookkeeping/voucher-series-resolv
|
||||
import { PeriodiseringAutoDetectToggle } from '@/components/settings/PeriodiseringAutoDetectToggle'
|
||||
import { DimensionsToggle } from '@/components/settings/DimensionsToggle'
|
||||
import { AccountingFrameworkForm } from '@/components/settings/AccountingFrameworkForm'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsSectionHeader,
|
||||
SettingsSelect,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import type { AccountingFramework, CompanySettings } from '@/types'
|
||||
|
||||
@@ -24,6 +29,8 @@ const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
|
||||
|
||||
export function BookkeepingSettingsContent() {
|
||||
const t = useTranslations('settings_bookkeeping')
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
const { settings, isLoading, updateSettings, refetch } = useSettings()
|
||||
const { company } = useCompany()
|
||||
// Local mirror of the company-level accounting_framework so the K2/K3
|
||||
@@ -88,127 +95,98 @@ export function BookkeepingSettingsContent() {
|
||||
const isAktiebolag = company?.entity_type === 'aktiebolag'
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{isAktiebolag && (
|
||||
<AccountingFrameworkForm
|
||||
current={framework}
|
||||
onSaved={(next) => setFramework(next)}
|
||||
/>
|
||||
)}
|
||||
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
|
||||
{/* Accounting method */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('method_heading')}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accounting_method">{t('method_label')}</Label>
|
||||
<select
|
||||
<div>
|
||||
<SettingsSectionHeader title={tNav('bookkeeping')} intro={tIntro('bookkeeping')} />
|
||||
|
||||
<SettingsFormWrapper onSave={handleSave}>
|
||||
{/* Grunder: framework (AB only), method, deferred booking, default
|
||||
series. The framework row saves through its own PATCH and opts out
|
||||
of this wrapper's dirty tracking; the rest read via FormData. */}
|
||||
<SettingsGroup label={t('group_basics')}>
|
||||
{isAktiebolag && (
|
||||
<AccountingFrameworkForm
|
||||
current={framework}
|
||||
onSaved={(next) => setFramework(next)}
|
||||
/>
|
||||
)}
|
||||
<SettingsRow
|
||||
label={t('method_label')}
|
||||
htmlFor="accounting_method"
|
||||
help={t('method_help')}
|
||||
>
|
||||
<SettingsSelect
|
||||
id="accounting_method"
|
||||
name="accounting_method"
|
||||
defaultValue={settings.accounting_method || 'accrual'}
|
||||
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="accrual">{t('method_accrual')}</option>
|
||||
<option value="cash">{t('method_cash')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('method_help')}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSelect>
|
||||
</SettingsRow>
|
||||
{/* #967: register/send without booking; ekonomi books in a separate
|
||||
explicit step. Only meaningful under faktureringsmetoden. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="defer_invoice_booking">{t('defer_booking_label')}</Label>
|
||||
<select
|
||||
<SettingsRow
|
||||
label={t('defer_booking_label')}
|
||||
htmlFor="defer_invoice_booking"
|
||||
help={t('defer_booking_help')}
|
||||
>
|
||||
<SettingsSelect
|
||||
id="defer_invoice_booking"
|
||||
name="defer_invoice_booking"
|
||||
defaultValue={settings.defer_invoice_booking ? 'true' : 'false'}
|
||||
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="false">{t('defer_booking_off')}</option>
|
||||
<option value="true">{t('defer_booking_on')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('defer_booking_help')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsSelect>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('series_label')}
|
||||
htmlFor="default_voucher_series"
|
||||
help={t('series_help')}
|
||||
>
|
||||
<SettingsSelect
|
||||
id="default_voucher_series"
|
||||
name="default_voucher_series"
|
||||
defaultValue={settings.default_voucher_series || 'A'}
|
||||
className="font-mono"
|
||||
>
|
||||
{SERIES_OPTIONS.map((letter) => (
|
||||
<option key={letter} value={letter}>
|
||||
{letter}
|
||||
</option>
|
||||
))}
|
||||
</SettingsSelect>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Default voucher series */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('series_heading')}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_voucher_series">{t('series_label')}</Label>
|
||||
<select
|
||||
id="default_voucher_series"
|
||||
name="default_voucher_series"
|
||||
defaultValue={settings.default_voucher_series || 'A'}
|
||||
className="flex h-10 w-16 rounded-md border border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{SERIES_OPTIONS.map((letter) => (
|
||||
<option key={letter} value={letter}>{letter}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('series_help')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Period locking */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<PeriodLockingSettings settings={settings} />
|
||||
</div>
|
||||
<PeriodLockingSettings settings={settings} />
|
||||
</SettingsFormWrapper>
|
||||
|
||||
{/* Fiscal years */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<FiscalYearsManager />
|
||||
</div>
|
||||
<FiscalYearsManager />
|
||||
|
||||
{/* Voucher series: per-source-type mapping */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<VoucherSeriesPerSourceTypeForm
|
||||
settings={settings}
|
||||
onSettingsUpdated={updateSettings}
|
||||
/>
|
||||
</div>
|
||||
<VoucherSeriesPerSourceTypeForm
|
||||
settings={settings}
|
||||
onSettingsUpdated={updateSettings}
|
||||
/>
|
||||
|
||||
{/* Voucher series: read-only display */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<VoucherSeriesManager defaultSeries={settings.default_voucher_series || 'A'} />
|
||||
</div>
|
||||
<VoucherSeriesManager defaultSeries={settings.default_voucher_series || 'A'} />
|
||||
|
||||
{/* Periodisering auto-detect toggle */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<SettingsGroup label={t('group_automation')}>
|
||||
<PeriodiseringAutoDetectToggle />
|
||||
</div>
|
||||
|
||||
{/* Kostnadsställen & projekt (dimensions) toggle */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<DimensionsToggle />
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Cross-links */}
|
||||
<div className="border-t border-border pt-8 space-y-3">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('related_heading')}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
<SettingsGroup>
|
||||
<SettingsRow label={t('related_heading')} borderless>
|
||||
<Link
|
||||
href="/bookkeeping?tab=accounts"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t('related_chart_of_accounts')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { CompanyDangerZone } from '@/components/settings/CompanyDangerZone'
|
||||
import { CompanyInfoForm } from '@/components/settings/CompanyInfoForm'
|
||||
import { CompanyMembersSection } from '@/components/settings/CompanyMembersSection'
|
||||
@@ -10,12 +11,15 @@ import { LogoUpload } from '@/components/settings/LogoUpload'
|
||||
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
|
||||
import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
|
||||
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
|
||||
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
|
||||
import { ShareCapitalForm } from '@/components/settings/ShareCapitalForm'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
export function CompanySettingsContent() {
|
||||
const router = useRouter()
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
const { settings, isLoading, updateSettings, refetch } = useSettings()
|
||||
|
||||
if (isLoading) return <SettingsLoadingSkeleton />
|
||||
@@ -56,8 +60,10 @@ export function CompanySettingsContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
|
||||
<div>
|
||||
<SettingsSectionHeader title={tNav('company')} intro={tIntro('company')} />
|
||||
|
||||
<SettingsFormWrapper onSave={handleSave}>
|
||||
<CompanyInfoForm settings={settings} />
|
||||
{settings.entity_type === 'aktiebolag' && (
|
||||
<ShareCapitalForm
|
||||
@@ -66,16 +72,12 @@ export function CompanySettingsContent() {
|
||||
)}
|
||||
</SettingsFormWrapper>
|
||||
|
||||
<div className="border-t border-border pt-8">
|
||||
<LogoUpload
|
||||
logoUrl={settings.logo_url}
|
||||
onUpdate={(url) => updateSettings({ logo_url: url })}
|
||||
/>
|
||||
</div>
|
||||
<LogoUpload
|
||||
logoUrl={settings.logo_url}
|
||||
onUpdate={(url) => updateSettings({ logo_url: url })}
|
||||
/>
|
||||
|
||||
<div className="border-t border-border pt-8">
|
||||
<CompanyMembersSection />
|
||||
</div>
|
||||
<CompanyMembersSection />
|
||||
|
||||
<FiscalPeriodEditor />
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { InvoiceSettingsForm } from '@/components/settings/InvoiceSettingsForm'
|
||||
import { InvoicePaymentLinkSettings } from '@/components/settings/InvoicePaymentLinkSettings'
|
||||
import { InvoicePaymentAccountsSettings } from '@/components/settings/InvoicePaymentAccountsSettings'
|
||||
@@ -10,10 +11,13 @@ import { PdfPrintSettings } from '@/components/settings/PdfPrintSettings'
|
||||
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
|
||||
import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
|
||||
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
|
||||
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
export function InvoicingSettingsContent() {
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
const { settings, isLoading, updateSettings, refetch } = useSettings()
|
||||
|
||||
if (isLoading) return <SettingsLoadingSkeleton />
|
||||
@@ -43,36 +47,31 @@ export function InvoicingSettingsContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-end">
|
||||
<InvoicePreviewCard settings={settings} />
|
||||
</div>
|
||||
<div>
|
||||
<SettingsSectionHeader
|
||||
title={tNav('invoicing')}
|
||||
intro={tIntro('invoicing')}
|
||||
action={<InvoicePreviewCard settings={settings} />}
|
||||
/>
|
||||
|
||||
{/* Owner/admin only: the component gates itself on role. */}
|
||||
<InvoicePaymentAccountsSettings settings={settings} onUpdate={updateSettings} />
|
||||
|
||||
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
|
||||
<SettingsFormWrapper onSave={handleSave}>
|
||||
<InvoiceSettingsForm settings={settings} />
|
||||
</SettingsFormWrapper>
|
||||
|
||||
{/* Payment link opt-in: saves individually via toggle switch */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<InvoicePaymentLinkSettings settings={settings} onUpdate={updateSettings} />
|
||||
</div>
|
||||
<InvoicePaymentLinkSettings settings={settings} onUpdate={updateSettings} />
|
||||
|
||||
{/* PDF settings: saves individually via toggle switches */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<PdfPrintSettings settings={settings} onUpdate={updateSettings} />
|
||||
</div>
|
||||
<PdfPrintSettings settings={settings} onUpdate={updateSettings} />
|
||||
|
||||
{/* Fixed invoice email recipients: explicit save */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<InvoiceEmailRecipientsSettings settings={settings} onUpdate={updateSettings} />
|
||||
</div>
|
||||
{/* Fixed invoice email recipients: explicit save (owner/admin only) */}
|
||||
<InvoiceEmailRecipientsSettings settings={settings} onUpdate={updateSettings} />
|
||||
|
||||
{/* Invoice email texts: autosaves on blur */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<InvoiceEmailTextsSettings settings={settings} onUpdate={updateSettings} />
|
||||
</div>
|
||||
<InvoiceEmailTextsSettings settings={settings} onUpdate={updateSettings} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,15 +3,19 @@
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
|
||||
import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
|
||||
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsInput,
|
||||
SettingsRow,
|
||||
SettingsSectionHeader,
|
||||
SettingsSelect,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { TaxTableStatus } from '@/components/salary/TaxTableStatus'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
|
||||
@@ -24,11 +28,11 @@ const BANK_LABEL: Record<(typeof BANK_OPTIONS)[number], string> = {
|
||||
nordea: 'Nordea',
|
||||
}
|
||||
|
||||
const selectClassName =
|
||||
'flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
|
||||
|
||||
export function SalarySettingsContent() {
|
||||
const t = useTranslations('settings_salary')
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
const tSalary = useTranslations('salary')
|
||||
const { settings, isLoading, updateSettings, refetch } = useSettings()
|
||||
// Controlled so the LB sunset note reacts to the selection before save.
|
||||
const [format, setFormat] = useState<'bg_lb' | 'pain001' | null>(null)
|
||||
@@ -54,7 +58,7 @@ export function SalarySettingsContent() {
|
||||
|
||||
// The booking engine resolves the series from the per-source-type map;
|
||||
// salary entries pass run.voucher_series explicitly, seeded from this
|
||||
// entry at run creation. Merge — never replace — the map so other
|
||||
// entry at run creation. Merge, never replace, the map so other
|
||||
// source-type overrides survive.
|
||||
if (series !== currentSeries) {
|
||||
updates.default_voucher_series_per_source_type = {
|
||||
@@ -72,135 +76,108 @@ export function SalarySettingsContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
|
||||
{/* Payment */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('payments_heading')}
|
||||
</h2>
|
||||
<div>
|
||||
<SettingsSectionHeader title={tNav('salary')} intro={tIntro('salary')} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="salary_pay_day">{t('pay_day_label')}</Label>
|
||||
<Input
|
||||
<SettingsFormWrapper onSave={handleSave}>
|
||||
<SettingsGroup label={t('payments_heading')} help={t('info_payroll_scope')}>
|
||||
<SettingsRow
|
||||
label={t('pay_day_label')}
|
||||
htmlFor="salary_pay_day"
|
||||
help={t('pay_day_help')}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="salary_pay_day"
|
||||
name="salary_pay_day"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={28}
|
||||
defaultValue={settings.salary_pay_day ?? 25}
|
||||
className="w-24 tabular-nums"
|
||||
className="max-w-24 flex-none tabular-nums"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('pay_day_help')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="preferred_payment_format">{t('format_label')}</Label>
|
||||
<select
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('format_label')}
|
||||
htmlFor="preferred_payment_format"
|
||||
help={t('format_help')}
|
||||
borderless={effectiveFormat === 'bg_lb'}
|
||||
>
|
||||
<SettingsSelect
|
||||
id="preferred_payment_format"
|
||||
name="preferred_payment_format"
|
||||
value={effectiveFormat}
|
||||
onChange={(e) => setFormat(e.target.value as 'bg_lb' | 'pain001')}
|
||||
className={selectClassName}
|
||||
>
|
||||
<option value="pain001">{t('format_pain001')}</option>
|
||||
<option value="bg_lb">{t('format_bg_lb')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">{t('format_help')}</p>
|
||||
{effectiveFormat === 'bg_lb' && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-border p-3 text-xs max-w-xl">
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span className="text-muted-foreground">
|
||||
{t('sunset_warning')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="salary_default_bank">{t('bank_label')}</Label>
|
||||
<select
|
||||
</SettingsSelect>
|
||||
</SettingsRow>
|
||||
{effectiveFormat === 'bg_lb' && (
|
||||
<p className="border-b border-border px-1 pb-3 text-[12.5px] leading-relaxed text-attn">
|
||||
{t('sunset_warning')}
|
||||
</p>
|
||||
)}
|
||||
<SettingsRow label={t('bank_label')} htmlFor="salary_default_bank" help={t('bank_help')}>
|
||||
<SettingsSelect
|
||||
id="salary_default_bank"
|
||||
name="salary_default_bank"
|
||||
defaultValue={settings.salary_default_bank ?? 'none'}
|
||||
className={selectClassName}
|
||||
>
|
||||
<option value="none">{t('bank_none')}</option>
|
||||
{BANK_OPTIONS.map((key) => (
|
||||
<option key={key} value={key}>{BANK_LABEL[key]}</option>
|
||||
))}
|
||||
<option value="other">{t('bank_other')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">{t('bank_help')}</p>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsSelect>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Accounting */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('accounting_heading')}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="salary_voucher_series">{t('voucher_series_label')}</Label>
|
||||
<select
|
||||
id="salary_voucher_series"
|
||||
name="salary_voucher_series"
|
||||
defaultValue={currentSeries}
|
||||
className="flex h-10 w-16 rounded-md border border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{SERIES_OPTIONS.map((letter) => (
|
||||
<option key={letter} value={letter}>{letter}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">{t('voucher_series_help')}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<SettingsGroup label={t('accounting_heading')}>
|
||||
<SettingsRow
|
||||
label={t('voucher_series_label')}
|
||||
htmlFor="salary_voucher_series"
|
||||
help={t('voucher_series_help')}
|
||||
>
|
||||
<SettingsSelect
|
||||
id="salary_voucher_series"
|
||||
name="salary_voucher_series"
|
||||
defaultValue={currentSeries}
|
||||
className="font-mono"
|
||||
>
|
||||
{SERIES_OPTIONS.map((letter) => (
|
||||
<option key={letter} value={letter}>{letter}</option>
|
||||
))}
|
||||
</SettingsSelect>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</SettingsFormWrapper>
|
||||
|
||||
{/* Tax tables (read-only status) */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('tax_tables_heading')}
|
||||
</h2>
|
||||
{/* Tax tables: automatic, read-only status. Lives outside the form so
|
||||
the recheck action never interacts with the save flow. */}
|
||||
<SettingsGroup
|
||||
label={t('tax_tables_heading')}
|
||||
help={t.rich('info_current_year', {
|
||||
strong: (chunks) => <strong>{chunks}</strong>,
|
||||
})}
|
||||
>
|
||||
<SettingsRow label={tSalary('th_status')} help={t('tax_tables_help')}>
|
||||
<TaxTableStatus />
|
||||
<p className="text-xs text-muted-foreground">{t('tax_tables_help')}</p>
|
||||
</section>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Vacation (informational — the rule is per-employee) */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('vacation_heading')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('vacation_info')}{' '}
|
||||
<Link href="/salary/employees" className="underline underline-offset-2 hover:text-foreground">
|
||||
{t('vacation_info_link')}
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('info_heading')}
|
||||
</h2>
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
<p>{t('info_payroll_scope')}</p>
|
||||
<p>
|
||||
{t.rich('info_current_year', {
|
||||
strong: (chunks) => <strong>{chunks}</strong>,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{/* Vacation is configured per employee; this row only points there. */}
|
||||
<SettingsGroup label={t('vacation_heading')}>
|
||||
<SettingsRow label={t('vacation_rule_label')} help={t('vacation_info')}>
|
||||
<Link
|
||||
href="/salary/employees"
|
||||
className="text-sm text-muted-foreground underline underline-offset-2 transition-colors duration-150 hover:text-foreground"
|
||||
>
|
||||
{t('vacation_info_link')}
|
||||
</Link>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TaxAssessmentNoticesPanel } from '@/components/settings/TaxAssessmentNo
|
||||
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
|
||||
import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
|
||||
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
|
||||
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
|
||||
import { SkatteverketConnectPanel } from '@/components/settings/SkatteverketConnectPanel'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
@@ -17,6 +18,8 @@ import type { CompanySettings } from '@/types'
|
||||
export function TaxSettingsContent() {
|
||||
const { settings, isLoading, updateSettings, refetch } = useSettings()
|
||||
const t = useTranslations('settings_skatteverket')
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
@@ -125,8 +128,8 @@ export function TaxSettingsContent() {
|
||||
fiscal_year_start_month: parseInt(formData.get('fiscal_year_start_month') as string) || 1,
|
||||
pays_salaries: paysSalaries,
|
||||
employer_registered: employerRegistered,
|
||||
// The seasonal checkbox is unmounted when not registered; absence
|
||||
// means false rather than "keep stored value".
|
||||
// The seasonal switch stays mounted inside its reveal, but the
|
||||
// employer_registered gate still forces false when not registered.
|
||||
employer_seasonal: employerRegistered && formData.get('employer_seasonal') === 'true',
|
||||
preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null,
|
||||
kontrolluppgifter_enabled: formData.get('kontrolluppgifter_enabled') === 'true',
|
||||
@@ -152,9 +155,11 @@ export function TaxSettingsContent() {
|
||||
const showSkatteverket = hasSkatteverketExtension && !settings.is_sandbox
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<SettingsSectionHeader title={tNav('tax')} intro={tIntro('tax')} />
|
||||
|
||||
{/* Connection panel first: the skattekonto and momsdeklaration pages
|
||||
send users here specifically to (re)connect — below the long tax
|
||||
send users here specifically to (re)connect; below the long tax
|
||||
form it sat out of view. */}
|
||||
{showSkatteverket && <SkatteverketConnectPanel />}
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { BookingTemplatesPanel } from '@/components/settings/BookingTemplatesPanel'
|
||||
import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel'
|
||||
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
|
||||
|
||||
export function TemplatesSettingsContent() {
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<SettingsSectionHeader title={tNav('templates')} intro={tIntro('templates')} />
|
||||
<BookingTemplatesPanel />
|
||||
<CounterpartyTemplatesPanel />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user