feat(reports,settings): report library + focused report routes, settings modal (#629)

* feat(reports,settings): report library + focused report routes, settings modal

Reports
- Replace the monolithic /reports tab-switcher with a calm, grouped report
  library landing (ReportLibrary + RecentReportsShelf) driven by a new
  lib/reports/catalog.ts.
- Each report opens a focused /reports/[slug] route (FocusedReport) with a
  shared fiscal-year selector, optional date-range, and URL-based account
  drill-down into the general ledger.
- Extract every report view into components/reports/views, add a reusable
  ReportExportMenu, and remove the old ReportsNav.

Settings
- Add an intercepting @settingsModal parallel route so in-app navigation to
  /settings opens as a modal over the current page; hard loads still resolve
  to the full page.
- Share one SettingsShell (rail + content) between page and modal, extract each
  section into components/settings/sections/*Content, add a settings hotkey and
  command-palette entry, and remove the old SettingsSidebar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reports,settings): remove dead salary-journal entry, fix border token

Addresses PR review feedback (#629):

- Remove the unreachable `salary-journal` report from the catalog. It had
  needsEmployees + no route + no FocusedView handler and `hasEmployees` was
  never plumbed through, so it never appeared in the library and a direct
  /reports/salary-journal URL rendered a blank frame. The report was never on
  the old page and has no view component; the API + generator stay in place for
  a proper follow-up. Drops its two now-unused i18n keys.

- Replace opacity-suffixed `border-border/8` section dividers with full-opacity
  `border-border` across the extracted settings section components, per the
  design system (no opacity-suffixed border tokens on surfaces).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: re-trigger checks (pg-real hit a Docker Hub registry timeout)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-02 14:50:50 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent f6ee0c2a82
commit ff01640f60
44 changed files with 4755 additions and 3974 deletions
@@ -0,0 +1,16 @@
import { SettingsModal } from '@/components/settings/SettingsModal'
// Intercepting route: catches in-app soft navigations to /settings and
// /settings/<section> and renders them as a modal in the `@settings` slot,
// leaving the page the user came from mounted in the background `children` slot.
// Hard loads / refreshes / pasted deep-links bypass interception and resolve to
// the real full-page settings route instead. The optional catch-all captures
// the bare /settings path (section === undefined → default in SettingsModal).
export default async function InterceptedSettingsModal({
params,
}: {
params: Promise<{ section?: string[] }>
}) {
const { section } = await params
return <SettingsModal sectionId={section?.[0]} />
}
@@ -0,0 +1,7 @@
// Parallel-slot fallback. Next.js renders this for the `@settings` slot on
// every route where the intercepting route below does NOT match (i.e. every
// page except an in-app soft navigation to /settings/*, and every hard load).
// Returning null means the slot contributes nothing in those cases.
export default function SettingsSlotDefault() {
return null
}
+11
View File
@@ -8,6 +8,7 @@ import { RecaptIdentify } from '@/components/RecaptIdentify'
import { AgentSheetProvider } from '@/components/agent/AgentSheetProvider'
import AgentTrigger from '@/components/agent/AgentTrigger'
import CommandPalette from '@/components/common/CommandPalette'
import { SettingsHotkey } from '@/components/settings/SettingsHotkey'
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
import { getExtensionNavItems } from '@/lib/extensions/sectors'
import { CompanyProvider } from '@/contexts/CompanyContext'
@@ -25,8 +26,12 @@ const NO_COMPANY_ALLOWED_PATHS = ['/settings/account']
export default async function DashboardLayout({
children,
settingsModal,
}: {
children: React.ReactNode
// `@settingsModal` parallel slot — renders the routed settings modal over the
// current page on in-app navigation to /settings/*; null otherwise.
settingsModal: React.ReactNode
}) {
const supabase = await createClient()
@@ -109,6 +114,8 @@ export default async function DashboardLayout({
{children}
</div>
</main>
{settingsModal}
<SettingsHotkey />
</div>
</AgentSheetProvider>
</CompanyProvider>
@@ -159,6 +166,8 @@ export default async function DashboardLayout({
{children}
</div>
</main>
{settingsModal}
<SettingsHotkey />
</div>
</AgentSheetProvider>
</CompanyProvider>
@@ -278,6 +287,8 @@ export default async function DashboardLayout({
</main>
<AgentTrigger />
<CommandPalette />
<SettingsHotkey />
{settingsModal}
</div>
{!isSandbox && (
<RecaptIdentify
+20
View File
@@ -0,0 +1,20 @@
import { notFound, redirect } from 'next/navigation'
import { getReport } from '@/lib/reports/catalog'
import { FocusedReport } from '@/components/reports/FocusedReport'
/**
* Focused single-report route. Unknown slugs 404; reports that own a dedicated
* route (cash flow, annual report, KPI, SIE) redirect there. Everything else
* renders inside the shared focused-report shell.
*/
export default async function ReportSlugPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const report = getReport(slug)
if (!report) notFound()
if (report.route) redirect(report.route)
return <FocusedReport slug={slug} />
}
File diff suppressed because it is too large Load Diff
+2 -192
View File
@@ -1,195 +1,5 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useLocale, useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Sun, Moon, Monitor, LogOut, Languages, ExternalLink } from 'lucide-react'
import { useTheme } from 'next-themes'
import { createClient } from '@/lib/supabase/client'
import { SecuritySettings } from '@/components/settings/SecuritySettings'
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
import { AccountDangerZone } from '@/components/settings/AccountDangerZone'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { useSettings } from '@/components/settings/useSettings'
import { clearRecaptIdentity } from '@/lib/recapt'
import { useToast } from '@/components/ui/use-toast'
import { SUPPORTED_LOCALES, type Locale } from '@/i18n/config'
import { AccountSettingsContent } from '@/components/settings/sections/AccountSettingsContent'
export default function AccountSettingsPage() {
const router = useRouter()
const supabase = createClient()
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
const hasCalendarExtension = ENABLED_EXTENSION_IDS.has('calendar')
const { settings } = useSettings()
const { toast } = useToast()
const activeLocale = useLocale() as Locale
const tCommon = useTranslations('common')
const tSettings = useTranslations('settings')
const [savingLocale, setSavingLocale] = useState(false)
useEffect(() => { setMounted(true) }, [])
async function handleLogout() {
clearRecaptIdentity()
await supabase.auth.signOut()
router.push('/login')
}
async function handleLocaleChange(next: Locale) {
if (next === activeLocale || savingLocale) return
setSavingLocale(true)
try {
const res = await fetch('/api/user/locale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locale: next }),
})
if (!res.ok) throw new Error('Could not save')
toast({ title: tSettings('language_saved') })
router.refresh()
} catch {
toast({
title: tSettings('language_save_failed'),
variant: 'destructive',
})
} finally {
setSavingLocale(false)
}
}
const localeLabels: Record<Locale, string> = {
sv: tCommon('language_swedish'),
en: tCommon('language_english'),
}
return (
<div className="space-y-8">
{/* Appearance */}
<section className="space-y-4">
<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-2.5 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/8 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-2.5 text-sm font-medium transition-colors disabled:opacity-50 ${
activeLocale === value
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/40'
}`}
>
<Languages className="h-4 w-4 text-muted-foreground" />
{localeLabels[value]}
</button>
))}
</div>
</section>
{/* Security */}
<div className="border-t border-border/8 pt-8">
<SecuritySettings />
</div>
{/* Calendar feed */}
{hasCalendarExtension && (
<div className="border-t border-border/8 pt-8">
<CalendarFeedSettings />
</div>
)}
{/* Logout */}
<section className="border-t border-border/8 pt-8">
<Card>
<CardHeader>
<CardTitle>{tCommon('account_settings')}</CardTitle>
</CardHeader>
<CardContent>
<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>
</CardContent>
</Card>
</section>
{/* Privacy & agreements — surface the otherwise-unlinked DPA + privacy policy */}
<section className="border-t border-border/8 pt-8">
<Card>
<CardHeader>
<CardTitle>{tSettings('legal_title')}</CardTitle>
</CardHeader>
<CardContent 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>
</CardContent>
</Card>
</section>
{/* Delete account — only for non-sandbox */}
{!settings?.is_sandbox && <AccountDangerZone />}
</div>
)
return <AccountSettingsContent />
}
+2 -10
View File
@@ -1,13 +1,5 @@
'use client'
import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel'
import { OAuthClientsPanel } from '@/components/settings/OAuthClientsPanel'
import { ApiSettingsContent } from '@/components/settings/sections/ApiSettingsContent'
export default function ApiSettingsPage() {
return (
<div className="space-y-8">
<ApiKeysPanel />
<OAuthClientsPanel />
</div>
)
return <ApiSettingsContent />
}
+2 -40
View File
@@ -1,43 +1,5 @@
'use client'
import { useSearchParams, useRouter } from 'next/navigation'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { AgentMemoryPanel } from '@/components/settings/AgentMemoryPanel'
import { AgentSkillsPanel } from '@/components/settings/AgentSkillsPanel'
// "Assistenten" — what the assistant remembers about this company (Minne,
// editable) and the domain knowledge it ships with (Kompetens, read-only).
// A toggle keeps both one click away instead of stacked, so the competence
// view isn't buried below the memory list.
type View = 'memory' | 'skills'
import { AssistantSettingsContent } from '@/components/settings/sections/AssistantSettingsContent'
export default function AssistantSettingsPage() {
const searchParams = useSearchParams()
const router = useRouter()
const view: View = searchParams.get('view') === 'skills' ? 'skills' : 'memory'
function setView(next: string) {
// 'memory' is the default — keep its URL clean (no query string).
router.replace(next === 'skills' ? '/settings/assistant?view=skills' : '/settings/assistant', {
scroll: false,
})
}
return (
<Tabs value={view} onValueChange={setView} className="space-y-6">
<TabsList>
<TabsTrigger value="memory">Minne</TabsTrigger>
<TabsTrigger value="skills">Kompetens</TabsTrigger>
</TabsList>
{/* Radix unmounts the inactive panel, so each panel's data is fetched
lazily the first time its tab is opened. */}
<TabsContent value="memory">
<AgentMemoryPanel />
</TabsContent>
<TabsContent value="skills">
<AgentSkillsPanel />
</TabsContent>
</Tabs>
)
return <AssistantSettingsContent />
}
+2 -168
View File
@@ -1,171 +1,5 @@
'use client'
import { useState, useEffect, useRef } 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 { useToast } from '@/components/ui/use-toast'
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'
const BankingPanel = getSettingsPanel('enable-banking')
import { BankingSettingsContent } from '@/components/settings/sections/BankingSettingsContent'
export default function BankingSettingsPage() {
const t = useTranslations('settings_banking')
const searchParams = useSearchParams()
const router = useRouter()
const { toast } = useToast()
const [bankConnectionError, setBankConnectionError] = useState<string | null>(null)
const [failedBankName, setFailedBankName] = useState<string | null>(null)
const [isAccessDenied, setIsAccessDenied] = useState(false)
const syncInitiatedRef = useRef(false)
const abortControllerRef = useRef<AbortController | null>(null)
const unmountedRef = useRef(false)
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
useEffect(() => {
return () => {
unmountedRef.current = true
if (abortControllerRef.current) abortControllerRef.current.abort()
}
}, [])
useEffect(() => {
const bankConnected = searchParams.get('bank_connected')
const bankError = searchParams.get('bank_error')
if (bankConnected === 'true' && !syncInitiatedRef.current) {
syncInitiatedRef.current = true
const connectionId = searchParams.get('connection_id')
router.replace('/settings/banking')
if (connectionId) {
toast({
title: t('sync_start_title'),
description: t('sync_start_description'),
})
const controller = new AbortController()
abortControllerRef.current = controller
const syncTimeout = setTimeout(() => controller.abort(), 120_000)
;(async () => {
try {
const res = await fetch('/api/extensions/ext/enable-banking/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: connectionId, days_back: 120 }),
signal: controller.signal,
})
clearTimeout(syncTimeout)
const data = await res.json()
if (res.ok) {
if (!unmountedRef.current) {
toast({
title: t('sync_success_title'),
description: t('sync_success_description', { count: data.imported ?? 0 }),
})
}
} else {
throw new Error(data.error || 'Sync failed')
}
} catch (err) {
clearTimeout(syncTimeout)
if (unmountedRef.current) return
if (controller.signal.aborted) {
toast({
title: t('sync_timeout_title'),
description: t('sync_timeout_description'),
})
} else {
toast({
title: t('sync_failed_title'),
description: err instanceof Error ? err.message : t('sync_failed_default'),
variant: 'destructive',
})
}
}
})()
} else {
toast({
title: t('sync_success_title'),
description: t('sync_success_no_id_description'),
})
}
}
if (bankError) {
let errorMsg: string
try { errorMsg = decodeURIComponent(bankError) } catch { errorMsg = bankError }
const bankName = searchParams.get('bank_name')
const errorCode = searchParams.get('bank_error_code')
toast({
title: t('connect_failed_title'),
description: errorMsg,
variant: 'destructive',
})
setBankConnectionError(errorMsg)
if (bankName) setFailedBankName(bankName)
if (errorCode === 'access_denied') setIsAccessDenied(true)
router.replace('/settings/banking')
}
}, [searchParams, router, toast, t])
return (
<div className="space-y-8">
{bankConnectionError && (
<div className="flex items-start gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div className="flex-1">
<p className="text-sm font-medium text-destructive">{bankConnectionError}</p>
{isAccessDenied && failedBankName && (
<p className="mt-1 text-sm text-muted-foreground">
{t('access_denied_hint', { bankName: failedBankName })}
</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>
</div>
<button
onClick={() => {
setBankConnectionError(null)
setFailedBankName(null)
setIsAccessDenied(false)
}}
className="shrink-0 rounded-md p-1 text-muted-foreground hover:text-foreground"
aria-label={t('dismiss_aria')}
>
<span className="text-lg leading-none">&times;</span>
</button>
</div>
)}
{hasBankingExtension && BankingPanel ? (
<>
<BankSyncStatusChip />
<BankingPanel />
</>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<CreditCard className="h-10 w-10 text-muted-foreground/40 mb-4" />
<p className="font-medium mb-1">{t('not_enabled_title')}</p>
<p className="text-sm text-muted-foreground mb-4 max-w-md">
{t('not_enabled_description')}
</p>
<Button variant="outline" asChild>
<Link href="/extensions">
<ExternalLink className="mr-2 h-4 w-4" />
{t('go_to_extensions')}
</Link>
</Button>
</CardContent>
</Card>
)}
</div>
)
return <BankingSettingsContent />
}
+2 -161
View File
@@ -1,164 +1,5 @@
'use client'
import Link from 'next/link'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { PeriodLockingSettings } from '@/components/settings/PeriodLockingSettings'
import { VoucherSeriesManager } from '@/components/settings/VoucherSeriesManager'
import { VoucherSeriesPerSourceTypeForm } from '@/components/settings/VoucherSeriesPerSourceTypeForm'
import { PeriodiseringAutoDetectToggle } from '@/components/settings/PeriodiseringAutoDetectToggle'
import { AccountingFrameworkForm } from '@/components/settings/AccountingFrameworkForm'
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'
const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
import { BookkeepingSettingsContent } from '@/components/settings/sections/BookkeepingSettingsContent'
export default function BookkeepingSettingsPage() {
const t = useTranslations('settings_bookkeeping')
const { settings, isLoading, updateSettings } = useSettings()
const { company } = useCompany()
// Local mirror of the company-level accounting_framework so the K2/K3
// selector can reflect its own saves without waiting for the layout to
// re-render through the server. Falls back to k2 (matches the column
// default) until the company row is loaded.
const [framework, setFramework] = useState<AccountingFramework>(
company?.accounting_framework ?? 'k2',
)
if (isLoading || !settings) return <SettingsLoadingSkeleton />
function handleSave(formData: FormData) {
const autoLockValue = formData.get('auto_lock_period_days') as string
const lockedThrough = (formData.get('bookkeeping_locked_through') as string) || null
const accountingMethod = (formData.get('accounting_method') as string) || 'accrual'
const defaultVoucherSeries = (formData.get('default_voucher_series') as string) || 'A'
const updates: Record<string, unknown> = {
bookkeeping_locked_through: lockedThrough,
auto_lock_period_days: autoLockValue === 'none' ? null : parseInt(autoLockValue),
accounting_method: accountingMethod,
default_voucher_series: defaultVoucherSeries,
}
return {
updates,
onSuccess: (data: Record<string, unknown>) => {
updateSettings(data as Partial<CompanySettings>)
},
}
}
// K2/K3 selector is only meaningful for AB. EF stays on EF rules and never
// picks a framework. Use the company row (source of truth) since
// company_settings.entity_type can be stale on legacy data.
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
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>
</section>
{/* Default voucher series */}
<div className="border-t border-border/8 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/8 pt-8">
<PeriodLockingSettings settings={settings} />
</div>
</SettingsFormWrapper>
{/* Voucher series — per-source-type mapping */}
<div className="border-t border-border/8 pt-8">
<VoucherSeriesPerSourceTypeForm
settings={settings}
onSettingsUpdated={updateSettings}
/>
</div>
{/* Voucher series — read-only display */}
<div className="border-t border-border/8 pt-8">
<VoucherSeriesManager defaultSeries={settings.default_voucher_series || 'A'} />
</div>
{/* Periodisering auto-detect toggle */}
<div className="border-t border-border/8 pt-8">
<PeriodiseringAutoDetectToggle />
</div>
{/* Cross-links */}
<div className="border-t border-border/8 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">
<Link
href="/bookkeeping"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
{t('related_fiscal_year')}
</Link>
<Link
href="/bookkeeping"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
{t('related_chart_of_accounts')}
</Link>
</div>
</div>
</div>
)
return <BookkeepingSettingsContent />
}
+2 -66
View File
@@ -1,69 +1,5 @@
'use client'
import { useRouter } from 'next/navigation'
import { CompanyDangerZone } from '@/components/settings/CompanyDangerZone'
import { CompanyInfoForm } from '@/components/settings/CompanyInfoForm'
import { CompanyMembersSection } from '@/components/settings/CompanyMembersSection'
import { CompanyProfileSection } from '@/components/settings/CompanyProfileSection'
import { FiscalPeriodEditor } from '@/components/settings/FiscalPeriodEditor'
import { LogoUpload } from '@/components/settings/LogoUpload'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { useSettings } from '@/components/settings/useSettings'
import type { CompanySettings } from '@/types'
import { CompanySettingsContent } from '@/components/settings/sections/CompanySettingsContent'
export default function CompanySettingsPage() {
const router = useRouter()
const { settings, isLoading, updateSettings } = useSettings()
if (isLoading || !settings) return <SettingsLoadingSkeleton />
function handleSave(formData: FormData) {
const updates: Record<string, unknown> = {
...(formData.has('company_name') && { company_name: formData.get('company_name') as string }),
...(formData.has('org_number') && { org_number: formData.get('org_number') as string }),
address_line1: formData.get('address_line1') as string,
postal_code: formData.get('postal_code') as string,
city: formData.get('city') as string,
phone: (formData.get('phone') as string) || '',
email: (formData.get('email') as string) || '',
website: (formData.get('website') as string) || '',
}
return {
updates,
onSuccess: (data: Record<string, unknown>) => {
updateSettings(data as Partial<CompanySettings>)
// Refresh server components so the company switcher and DashboardNav
// pick up the new company_name (rendered from server in the dashboard layout).
if ('company_name' in updates) {
router.refresh()
}
},
}
}
return (
<div className="space-y-8">
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
<CompanyInfoForm settings={settings} />
</SettingsFormWrapper>
<div className="border-t border-border/8 pt-8">
<LogoUpload
logoUrl={settings.logo_url}
onUpdate={(url) => updateSettings({ logo_url: url })}
/>
</div>
<div className="border-t border-border/8 pt-8">
<CompanyMembersSection />
</div>
<FiscalPeriodEditor />
<CompanyProfileSection />
<CompanyDangerZone />
</div>
)
return <CompanySettingsContent />
}
+2 -68
View File
@@ -1,71 +1,5 @@
'use client'
import { useTranslations } from 'next-intl'
import { BankDetailsForm, validateBankFields } from '@/components/settings/BankDetailsForm'
import { InvoiceSettingsForm } from '@/components/settings/InvoiceSettingsForm'
import { InvoicePreviewCard } from '@/components/settings/InvoicePreviewCard'
import { PdfPrintSettings } from '@/components/settings/PdfPrintSettings'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { useSettings } from '@/components/settings/useSettings'
import { useToast } from '@/components/ui/use-toast'
import { normaliseSwish } from '@/lib/payments/swish'
import type { CompanySettings } from '@/types'
import { InvoicingSettingsContent } from '@/components/settings/sections/InvoicingSettingsContent'
export default function InvoicingSettingsPage() {
const t = useTranslations('settings_invoicing')
const { settings, isLoading, updateSettings } = useSettings()
const { toast } = useToast()
if (isLoading || !settings) return <SettingsLoadingSkeleton />
function handleSave(formData: FormData) {
const bankErrors = validateBankFields(formData)
if (bankErrors.length > 0) {
toast({
title: t('bank_validation_title'),
description: bankErrors.map(e => e.message).join(', '),
variant: 'destructive',
})
return {}
}
const updates: Record<string, unknown> = {
bank_name: formData.get('bank_name') as string,
clearing_number: formData.get('clearing_number') as string,
account_number: formData.get('account_number') as string,
bankgiro: (formData.get('bankgiro') as string) || null,
swish: normaliseSwish(formData.get('swish') as string) || null,
invoice_prefix: (formData.get('invoice_prefix') as string) || null,
next_invoice_number: parseInt(formData.get('next_invoice_number') as string) || 1,
invoice_default_days: parseInt(formData.get('invoice_default_days') as string) || 30,
invoice_default_notes: (formData.get('invoice_default_notes') as string) || null,
}
return {
updates,
onSuccess: (data: Record<string, unknown>) => {
updateSettings(data as Partial<CompanySettings>)
},
}
}
return (
<div className="space-y-8">
<div className="flex justify-end">
<InvoicePreviewCard settings={settings} />
</div>
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
<BankDetailsForm settings={settings} />
<div className="border-t border-border/8 pt-8">
<InvoiceSettingsForm settings={settings} />
</div>
</SettingsFormWrapper>
{/* PDF settings — saves individually via toggle switches */}
<div className="border-t border-border/8 pt-8">
<PdfPrintSettings settings={settings} onUpdate={updateSettings} />
</div>
</div>
)
return <InvoicingSettingsContent />
}
+7 -31
View File
@@ -1,10 +1,9 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { SettingsNav } from '@/components/settings/SettingsSidebar'
import { useCompany } from '@/contexts/CompanyContext'
import { createClient } from '@/lib/supabase/client'
import { useTranslations } from 'next-intl'
import { SettingsShell } from '@/components/settings/SettingsShell'
const TAB_TO_ROUTE: Record<string, string> = {
company: '/settings/company',
@@ -23,22 +22,7 @@ const TAB_TO_ROUTE: Record<string, string> = {
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
const searchParams = useSearchParams()
const router = useRouter()
const { company } = useCompany()
const [isSandbox, setIsSandbox] = useState(false)
// Fetch sandbox status
useEffect(() => {
if (!company?.id) return
const supabase = createClient()
supabase
.from('company_settings')
.select('is_sandbox')
.eq('company_id', company.id)
.single()
.then(({ data }) => {
if (data?.is_sandbox) setIsSandbox(true)
})
}, [company?.id])
const t = useTranslations('settings_nav')
// Handle legacy ?tab= URLs
useEffect(() => {
@@ -49,17 +33,9 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
}, [searchParams, router])
return (
<div className="space-y-6">
<div>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Inställningar</h1>
<p className="text-muted-foreground text-sm mt-1">
Hantera ditt företag och konto
</p>
</div>
<SettingsNav isSandbox={isSandbox} />
<div>{children}</div>
<div className="space-y-8">
<h1 className="font-display text-2xl tracking-tight md:text-3xl">{t('aria_label')}</h1>
<SettingsShell variant="page">{children}</SettingsShell>
</div>
)
}
+2 -84
View File
@@ -1,87 +1,5 @@
'use client'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { PageHeader } from '@/components/ui/page-header'
import { TaxTableStatus } from '@/components/salary/TaxTableStatus'
import { SalarySettingsContent } from '@/components/settings/sections/SalarySettingsContent'
export default function SalarySettingsPage() {
const t = useTranslations('settings_salary')
return (
<div className="space-y-8">
<PageHeader title={t('title')} />
<Card>
<CardHeader>
<CardTitle className="text-base">{t('accounting_heading')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t('voucher_series_label')}</label>
<select className="flex h-9 w-full max-w-xs rounded-md border border-input bg-transparent px-3 py-1 text-sm" defaultValue="A">
<option value="A">{t('voucher_series_a')}</option>
<option value="L">{t('voucher_series_l')}</option>
</select>
<p className="text-xs text-muted-foreground">
{t('voucher_series_help')}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('tax_tables_heading')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<TaxTableStatus />
<p className="text-xs text-muted-foreground">
{t('tax_tables_help')}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('vacation_heading')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t('vacation_rule_label')}</label>
<select className="flex h-9 w-full max-w-xs rounded-md border border-input bg-transparent px-3 py-1 text-sm" defaultValue="procentregeln">
<option value="procentregeln">{t('vacation_rule_percentage')}</option>
<option value="sammaloneregeln">{t('vacation_rule_same_pay')}</option>
<option value="none">{t('vacation_rule_none')}</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('vacation_supplement_label')}</label>
<select className="flex h-9 w-full max-w-xs rounded-md border border-input bg-transparent px-3 py-1 text-sm" defaultValue="0.0043">
<option value="0.0043">{t('vacation_supplement_min')}</option>
<option value="0.008">{t('vacation_supplement_cba')}</option>
</select>
<p className="text-xs text-muted-foreground">
{t('vacation_supplement_help')}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('info_heading')}</CardTitle>
</CardHeader>
<CardContent>
<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>
</CardContent>
</Card>
</div>
)
return <SalarySettingsContent />
}
+2 -99
View File
@@ -1,102 +1,5 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useSearchParams, useRouter } from 'next/navigation'
import { TaxSettingsForm } from '@/components/settings/TaxSettingsForm'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { SkatteverketConnectPanel } from '@/components/settings/SkatteverketConnectPanel'
import { useSettings } from '@/components/settings/useSettings'
import { useToast } from '@/components/ui/use-toast'
import { useCompany } from '@/contexts/CompanyContext'
import { createClient } from '@/lib/supabase/client'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import type { CompanySettings } from '@/types'
import { TaxSettingsContent } from '@/components/settings/sections/TaxSettingsContent'
export default function TaxSettingsPage() {
const { settings, isLoading, updateSettings } = useSettings()
const { company } = useCompany()
const t = useTranslations('settings_skatteverket')
const searchParams = useSearchParams()
const router = useRouter()
const { toast } = useToast()
const [isSandbox, setIsSandbox] = useState(false)
const hasSkatteverketExtension = ENABLED_EXTENSION_IDS.has('skatteverket')
// Sandbox companies don't connect to the real Skatteverket — hide the panel,
// matching the old Skatteverket tab's visibility gate.
useEffect(() => {
if (!company?.id) return
const supabase = createClient()
supabase
.from('company_settings')
.select('is_sandbox')
.eq('company_id', company.id)
.single()
.then(({ data }) => {
if (data?.is_sandbox) setIsSandbox(true)
})
}, [company?.id])
// Skatteverket OAuth callback — the connect flow returns to /settings/tax with
// a status query param (returnTo set in SkatteverketConnectPanel).
useEffect(() => {
const connected = searchParams.get('skv_connected')
const error = searchParams.get('skv_error')
if (connected === 'true') {
toast({ title: t('connected_title'), description: t('connected_description') })
router.replace('/settings/tax')
} else if (error) {
let msg: string
try {
msg = decodeURIComponent(error)
} catch {
msg = error
}
toast({ title: t('connect_failed_title'), description: msg, variant: 'destructive' })
router.replace('/settings/tax')
}
}, [searchParams, router, toast, t])
if (isLoading || !settings) return <SettingsLoadingSkeleton />
function handleSave(formData: FormData) {
const vatRegistered = formData.get('vat_registered') === 'true'
const updates: Record<string, unknown> = {
f_skatt: formData.get('f_skatt') === 'true',
vat_registered: vatRegistered,
vat_number: vatRegistered ? ((formData.get('vat_number') as string) || null) : null,
moms_period: vatRegistered ? ((formData.get('moms_period') as string) || null) : null,
periodisk_sammanstallning_period:
(formData.get('periodisk_sammanstallning_period') as string) || 'monthly',
tax_contact_name: (formData.get('tax_contact_name') as string) || null,
tax_contact_phone: (formData.get('tax_contact_phone') as string) || null,
tax_contact_email: (formData.get('tax_contact_email') as string) || null,
fiscal_year_start_month: parseInt(formData.get('fiscal_year_start_month') as string) || 1,
pays_salaries: formData.get('pays_salaries') === 'true',
preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null,
}
return {
updates,
onSuccess: (data: Record<string, unknown>) => {
updateSettings(data as Partial<CompanySettings>)
},
}
}
const showSkatteverket = hasSkatteverketExtension && !isSandbox
return (
<div className="space-y-8">
<SettingsFormWrapper onSave={handleSave} className="space-y-0">
<TaxSettingsForm settings={settings} />
</SettingsFormWrapper>
{showSkatteverket && <SkatteverketConnectPanel />}
</div>
)
return <TaxSettingsContent />
}
+2 -10
View File
@@ -1,13 +1,5 @@
'use client'
import { BookingTemplatesPanel } from '@/components/settings/BookingTemplatesPanel'
import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel'
import { TemplatesSettingsContent } from '@/components/settings/sections/TemplatesSettingsContent'
export default function TemplatesSettingsPage() {
return (
<div className="space-y-8">
<BookingTemplatesPanel />
<CounterpartyTemplatesPanel />
</div>
)
return <TemplatesSettingsContent />
}