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
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 />
}
+6
View File
@@ -49,6 +49,12 @@ const PAGE_ENTRIES: Entry[] = [
{ id: 'bokföring', label: 'Bokföring', icon: BookOpen, href: '/bookkeeping', keywords: 'verifikat journal ledger' },
{ id: 'anläggningstillgångar', label: 'Anläggningstillgångar', icon: Package, href: '/assets', keywords: 'tillgångar assets' },
{ id: 'rapporter', label: 'Rapporter', icon: BarChart3, href: '/reports' },
{ id: 'rapport-resultatrapport', label: 'Visa rapport: Resultatrapport', icon: BarChart3, href: '/reports/resultatrapport', keywords: 'rapport resultat intäkter kostnader' },
{ id: 'rapport-balansrapport', label: 'Visa rapport: Balansrapport', icon: BarChart3, href: '/reports/balansrapport', keywords: 'rapport balans tillgångar skulder' },
{ id: 'rapport-saldobalans', label: 'Visa rapport: Saldobalans', icon: BarChart3, href: '/reports/trial-balance', keywords: 'rapport saldobalans trial balance' },
{ id: 'rapport-moms', label: 'Visa rapport: Momsdeklaration', icon: BarChart3, href: '/reports/vat-declaration', keywords: 'rapport moms vat deklaration' },
{ id: 'rapport-huvudbok', label: 'Visa rapport: Huvudbok', icon: BookOpen, href: '/reports/huvudbok', keywords: 'rapport huvudbok ledger konto' },
{ id: 'rapport-kundreskontra', label: 'Visa rapport: Kundreskontra', icon: Users, href: '/reports/kundreskontra', keywords: 'rapport kundreskontra ar kundfordringar' },
{ id: 'importera', label: 'Importera', icon: Upload, href: '/import' },
{ id: 'granskning', label: 'Granskning', icon: ClipboardCheck, href: '/pending', keywords: 'pending review' },
{ id: 'löner', label: 'Löner', icon: HandCoins, href: '/salary' },
+193
View File
@@ -0,0 +1,193 @@
'use client'
import { Suspense, useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { ChevronLeft } from 'lucide-react'
import { PageHeader } from '@/components/ui/page-header'
import { EmptyState } from '@/components/ui/empty-state'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { useCompany } from '@/contexts/CompanyContext'
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
import { ReportDateRange, type DateRangeValue } from '@/components/common/ReportDateRange'
import { DATE_RANGE_SLUGS, getReport } from '@/lib/reports/catalog'
import { NEDeclarationView } from '@/components/reports/NEDeclarationView'
import { PeriodiskSammanstallningView } from '@/components/reports/PeriodiskSammanstallningView'
import { INK2DeclarationView } from '@/components/reports/INK2DeclarationView'
import { BankReconciliationView } from '@/components/reports/BankReconciliationView'
import {
TrialBalanceView,
IncomeStatementView,
BalanceSheetView,
ResultatrapportView,
BalansrapportView,
VatDeclarationView,
SupplierLedgerView,
GeneralLedgerView,
JournalRegisterView,
ARLedgerView,
} from '@/components/reports/views'
/**
* The focused single-report experience at /reports/[slug]. Carries one report:
* a back link to the library, the shared fiscal-year selector (restored from
* localStorage so it matches the year picked on the landing), the report's
* optional date-range control, and the report body. Drilling into an account
* navigates to /reports/huvudbok?account=… — drill state lives in the URL.
*/
function FocusedReportInner({ slug }: { slug: string }) {
const router = useRouter()
const searchParams = useSearchParams()
const { company } = useCompany()
const t = useTranslations('reports')
const [selectedPeriod, setSelectedPeriod] = useState('')
const [selectedPeriodBounds, setSelectedPeriodBounds] = useState<{ start: string; end: string } | null>(null)
const [dateRange, setDateRange] = useState<DateRangeValue>({})
const [isReady, setIsReady] = useState(false)
const report = getReport(slug)
// Calendar (VAT family) and param-less reports don't need a fiscal period.
const isPeriodless = report?.params === 'calendar' || report?.params === 'none'
const reportName = report ? t(report.labelKey) : slug
const accountFilter = searchParams.get('account')
const isEnskildFirma = company?.entity_type === 'enskild_firma'
const isAktiebolag = company?.entity_type === 'aktiebolag'
// Drilling from a report into the general ledger is a route change, so the
// account lands in the URL and the browser back button returns to the report.
const navigateToAccount = (accountNumber: string) => {
router.push(`/reports/huvudbok?account=${encodeURIComponent(accountNumber)}`)
}
return (
<div className="space-y-8">
<Link
href="/reports"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="h-4 w-4" />
{t('back_to_library')}
</Link>
<PageHeader
title={reportName}
action={
<FiscalYearSelector
value={selectedPeriod || null}
onChange={(id, period) => {
setSelectedPeriod(id || '')
setSelectedPeriodBounds(
period ? { start: period.period_start, end: period.period_end } : null,
)
setDateRange({})
}}
includeAllOption={false}
hideFuturePeriods
onReady={() => setIsReady(true)}
/>
}
/>
{DATE_RANGE_SLUGS.has(slug) && selectedPeriodBounds && (
<ReportDateRange
periodStart={selectedPeriodBounds.start}
periodEnd={selectedPeriodBounds.end}
value={dateRange}
onChange={setDateRange}
/>
)}
{!isReady && !isPeriodless ? (
<Card>
<CardContent className="p-6 space-y-4">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-64" />
</CardContent>
</Card>
) : isPeriodless || selectedPeriod ? (
<FocusedView
slug={slug}
periodId={selectedPeriod}
periodBounds={selectedPeriodBounds}
dateRange={dateRange}
accountFilter={accountFilter}
isEnskildFirma={isEnskildFirma}
isAktiebolag={isAktiebolag}
onNavigateToAccount={navigateToAccount}
/>
) : (
<EmptyState
title="Inget räkenskapsår valt"
description="Skapa ett räkenskapsår för att kunna se rapporter."
actionLabel="Gå till inställningar"
actionHref="/settings"
/>
)}
</div>
)
}
function FocusedView({
slug,
periodId,
periodBounds,
dateRange,
accountFilter,
isEnskildFirma,
isAktiebolag,
onNavigateToAccount,
}: {
slug: string
periodId: string
periodBounds: { start: string; end: string } | null
dateRange: DateRangeValue
accountFilter: string | null
isEnskildFirma: boolean
isAktiebolag: boolean
onNavigateToAccount: (account: string) => void
}) {
switch (slug) {
case 'resultatrapport':
return <ResultatrapportView periodId={periodId} dateRange={dateRange} onNavigateToAccount={onNavigateToAccount} />
case 'balansrapport':
return <BalansrapportView periodId={periodId} dateRange={dateRange} onNavigateToAccount={onNavigateToAccount} />
case 'trial-balance':
return <TrialBalanceView periodId={periodId} onNavigateToAccount={onNavigateToAccount} />
case 'income-statement':
return <IncomeStatementView periodId={periodId} dateRange={dateRange} onNavigateToAccount={onNavigateToAccount} />
case 'balance-sheet':
return <BalanceSheetView periodId={periodId} dateRange={dateRange} onNavigateToAccount={onNavigateToAccount} />
case 'vat-declaration':
return <VatDeclarationView fiscalPeriodId={periodId} fiscalPeriodBounds={periodBounds} />
case 'periodisk-sammanstallning':
return <PeriodiskSammanstallningView />
case 'ne-declaration':
return isEnskildFirma ? <NEDeclarationView periodId={periodId} /> : null
case 'ink2-declaration':
return isAktiebolag ? <INK2DeclarationView periodId={periodId} /> : null
case 'huvudbok':
return <GeneralLedgerView periodId={periodId} initialAccountFilter={accountFilter} />
case 'grundbok':
return <JournalRegisterView periodId={periodId} />
case 'kundreskontra':
return <ARLedgerView periodId={periodId} />
case 'supplier-ledger':
return <SupplierLedgerView periodId={periodId} />
case 'bank-reconciliation':
return <BankReconciliationView />
default:
return null
}
}
export function FocusedReport({ slug }: { slug: string }) {
return (
<Suspense fallback={<div className="space-y-8" />}>
<FocusedReportInner slug={slug} />
</Suspense>
)
}
+52
View File
@@ -0,0 +1,52 @@
'use client'
import { useTranslations } from 'next-intl'
import { getReport } from '@/lib/reports/catalog'
import type { EntityType } from '@/types'
/**
* "Senast öppnade" — compact beige chips for the reports the user opened most
* recently. One tap reopens a report straight from the library landing.
* Renders nothing when there is no history (first visit).
*/
export function RecentReportsShelf({
slugs,
entityType,
hasEmployees,
onOpen,
}: {
slugs: string[]
entityType?: EntityType
hasEmployees?: boolean
onOpen: (slug: string) => void
}) {
const t = useTranslations('reports')
const items = slugs
.map((slug) => getReport(slug))
.filter((r): r is NonNullable<typeof r> => !!r)
.filter((r) => !r.entityType || r.entityType === entityType)
.filter((r) => !r.needsEmployees || hasEmployees)
if (items.length === 0) return null
return (
<div className="space-y-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('recent_heading')}
</h2>
<div className="flex flex-wrap gap-2">
{items.map((item) => (
<button
key={item.slug}
type="button"
onClick={() => onOpen(item.slug)}
className="inline-flex items-center rounded-md bg-secondary px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-secondary/70"
>
{t(item.labelKey)}
</button>
))}
</div>
</div>
)
}
+65
View File
@@ -0,0 +1,65 @@
'use client'
import { Download, FileSpreadsheet, FileText } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import type { ReportExportFormat } from '@/lib/reports/catalog'
export interface ReportExportItem {
format: ReportExportFormat
href: string
}
/**
* The single "Exportera" affordance for a report. Replaces the scattered
* per-format download buttons that used to float inside report card bodies.
* `children` lets a report append a sibling action (e.g. the VAT review agent).
*/
export function ReportExportMenu({
items,
children,
}: {
items?: ReportExportItem[]
children?: React.ReactNode
}) {
const t = useTranslations('reports')
const hasItems = !!items && items.length > 0
if (!hasItems && !children) return null
return (
<div className="flex items-center justify-end gap-2">
{hasItems && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Download className="h-4 w-4 mr-2" />
{t('export')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{items!.map((item) => (
<DropdownMenuItem
key={item.href}
onSelect={() => window.open(item.href, '_blank')}
>
{item.format === 'pdf' ? (
<FileText className="h-4 w-4 mr-2" />
) : (
<FileSpreadsheet className="h-4 w-4 mr-2" />
)}
{item.format === 'pdf' ? t('download_pdf') : t('download_excel')}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{children}
</div>
)
}
+69
View File
@@ -0,0 +1,69 @@
'use client'
import { useTranslations } from 'next-intl'
import { ChevronRight } from 'lucide-react'
import {
DataList,
DataListMeta,
DataListPrimary,
DataListRow,
} from '@/components/ui/data-list'
import { Badge } from '@/components/ui/badge'
import { getLibrarySections, type ReportDescriptor } from '@/lib/reports/catalog'
import type { EntityType } from '@/types'
/**
* The report library — the calm landing for /reports. Reports grouped by
* accounting taxonomy, each section a single DataList. One report = one row =
* one destination; no data preview, so the landing stays a fast index.
*/
export function ReportLibrary({
entityType,
hasEmployees,
onOpen,
}: {
entityType?: EntityType
hasEmployees?: boolean
onOpen: (slug: string) => void
}) {
const t = useTranslations('reports')
const sections = getLibrarySections(entityType, hasEmployees)
return (
<div className="space-y-8">
{sections.map((section) => (
<div key={section.category} className="space-y-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t(section.labelKey)}
</h2>
<DataList>
{section.items.map((item) => (
<DataListRow
key={item.slug}
onClick={() => onOpen(item.slug)}
trailing={
<>
<EntityBadge item={item} />
{item.params === 'calendar' && (
<Badge variant="secondary">{t('calendar_badge')}</Badge>
)}
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</>
}
>
<DataListPrimary>{t(item.labelKey)}</DataListPrimary>
<DataListMeta>{t(item.descKey)}</DataListMeta>
</DataListRow>
))}
</DataList>
</div>
))}
</div>
)
}
function EntityBadge({ item }: { item: ReportDescriptor }) {
if (item.entityType === 'enskild_firma') return <Badge variant="outline">EF</Badge>
if (item.entityType === 'aktiebolag') return <Badge variant="outline">AB</Badge>
return null
}
-148
View File
@@ -1,148 +0,0 @@
'use client'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { EntityType } from '@/types'
interface ReportItem {
value: string
labelKey: string
entityType?: EntityType
}
interface ReportCategory {
labelKey: string
items: ReportItem[]
}
const CATEGORIES: ReportCategory[] = [
{
labelKey: 'group_interim',
items: [
{ value: 'resultatrapport', labelKey: 'name_resultatrapport' },
{ value: 'balansrapport', labelKey: 'name_balansrapport' },
{ value: 'trial-balance', labelKey: 'name_trial_balance' },
],
},
{
labelKey: 'group_year_end',
items: [
{ value: 'income-statement', labelKey: 'name_income_statement' },
{ value: 'balance-sheet', labelKey: 'name_balance_sheet' },
{ value: 'kassaflodesanalys', labelKey: 'name_kassaflodesanalys' },
{ value: 'arsredovisning', labelKey: 'name_arsredovisning', entityType: 'aktiebolag' },
],
},
{
labelKey: 'group_tax_vat',
items: [
{ value: 'vat-declaration', labelKey: 'name_vat_declaration' },
{ value: 'periodisk-sammanstallning', labelKey: 'name_periodisk_sammanstallning' },
{ value: 'ne-declaration', labelKey: 'name_ne_declaration', entityType: 'enskild_firma' },
{ value: 'ink2-declaration', labelKey: 'name_ink2_declaration', entityType: 'aktiebolag' },
],
},
{
labelKey: 'group_ledgers',
items: [
{ value: 'huvudbok', labelKey: 'name_huvudbok' },
{ value: 'grundbok', labelKey: 'name_grundbok' },
{ value: 'kundreskontra', labelKey: 'name_kundreskontra' },
{ value: 'supplier-ledger', labelKey: 'name_supplier_ledger' },
],
},
{
labelKey: 'group_reconciliation',
items: [
{ value: 'bank-reconciliation', labelKey: 'name_bank_reconciliation' },
],
},
]
interface ReportsNavProps {
active: string
onChange: (value: string) => void
entityType?: EntityType
}
export function ReportsNav({ active, onChange, entityType }: ReportsNavProps) {
const t = useTranslations('reports')
const filtered = CATEGORIES
.map(cat => ({
...cat,
items: cat.items.filter(item => !item.entityType || item.entityType === entityType),
}))
.filter(cat => cat.items.length > 0)
return (
<>
{/* Mobile: grouped select */}
<div className="sm:hidden">
<Select value={active} onValueChange={onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{filtered.map(cat => (
<SelectGroup key={cat.labelKey}>
<SelectLabel>{t(cat.labelKey)}</SelectLabel>
{cat.items.map(item => (
<SelectItem key={item.value} value={item.value}>
{t(item.labelKey)}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</div>
{/* Desktop: vertical left rail */}
<nav
className="hidden sm:block w-56 flex-shrink-0 sticky top-8 self-start"
aria-label={t('categories_aria')}
>
<ul className="space-y-6">
{filtered.map(cat => (
<li key={cat.labelKey}>
<p className="text-[11px] font-semibold text-muted-foreground/80 uppercase tracking-[0.08em] mb-2 px-3">
{t(cat.labelKey)}
</p>
<ul className="space-y-px">
{cat.items.map(item => {
const isActive = active === item.value
return (
<li key={item.value}>
<button
type="button"
onClick={() => onChange(item.value)}
aria-current={isActive ? 'page' : undefined}
className={cn(
'w-full text-left px-3 py-1.5 rounded-md text-[13px] transition-colors',
isActive
? 'bg-primary/10 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)}
>
{t(item.labelKey)}
</button>
</li>
)
})}
</ul>
</li>
))}
</ul>
</nav>
</>
)
}
+57
View File
@@ -0,0 +1,57 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
/**
* Tracks the last few report slugs the user opened, per company, in
* localStorage. Mirrors the `gnubok:<key>:<companyId>` convention used by
* FiscalYearSelector (STORAGE_KEY_PREFIX). Powers the "Senast öppnade" shelf
* so returning users skip the library hop.
*/
const STORAGE_KEY_PREFIX = 'gnubok:report-recents:'
const MAX_RECENTS = 4
export function useRecentReports(companyId: string | null | undefined) {
const [recents, setRecents] = useState<string[]>([])
useEffect(() => {
let cancelled = false
// Deferred to a microtask so the read isn't a synchronous setState in the
// effect body (and so the first server/client render agree on an empty
// shelf, avoiding a hydration mismatch).
Promise.resolve().then(() => {
if (cancelled) return
if (!companyId) {
setRecents([])
return
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY_PREFIX + companyId)
setRecents(raw ? (JSON.parse(raw) as string[]) : [])
} catch {
setRecents([])
}
})
return () => {
cancelled = true
}
}, [companyId])
const pushRecent = useCallback(
(slug: string) => {
if (!companyId) return
setRecents((prev) => {
const next = [slug, ...prev.filter((s) => s !== slug)].slice(0, MAX_RECENTS)
try {
window.localStorage.setItem(STORAGE_KEY_PREFIX + companyId, JSON.stringify(next))
} catch {
/* localStorage unavailable — keep in-memory only */
}
return next
})
},
[companyId],
)
return { recents, pushRecent }
}
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
/**
* Global ⌘, / Ctrl+, shortcut to open settings (mirrors CommandPalette's ⌘K).
* Navigates to /settings, which the intercepting route turns into the modal.
*/
export function SettingsHotkey() {
const router = useRouter()
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === ',') {
e.preventDefault()
router.push('/settings')
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [router])
return null
}
+56
View File
@@ -0,0 +1,56 @@
'use client'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { useCompany } from '@/contexts/CompanyContext'
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog'
import { SETTINGS_SECTIONS } from './sections'
import { SettingsShell } from './SettingsShell'
/**
* The settings popup. Rendered only by the intercepting route
* (`@settings/(.)settings/[[...section]]`) on in-app soft navigation, so its
* mere presence means "open". Closing pops the history entry that opened it,
* returning the user to the page they came from (which stayed mounted in the
* `children` slot behind the scrim). On hard load / refresh / deep-link the
* interceptor doesn't fire and the real full-page settings render instead.
*/
export function SettingsModal({ sectionId }: { sectionId?: string }) {
const router = useRouter()
const { company } = useCompany()
const t = useTranslations('settings_modal')
// Bare /settings (or an unknown section) defaults to company, or to account
// when there is no active company (the no-company escape hatch).
const resolved =
sectionId && SETTINGS_SECTIONS[sectionId]
? sectionId
: company
? 'company'
: 'account'
function onOpenChange(open: boolean) {
if (!open) router.back()
}
return (
<Dialog open onOpenChange={onOpenChange}>
<DialogContent
className="flex h-[100dvh] max-h-[100dvh] max-w-none flex-col gap-0 overflow-hidden rounded-none p-0 md:h-auto md:max-h-[85dvh] md:max-w-4xl md:rounded-lg"
>
<div className="flex shrink-0 items-center border-b border-border px-6 py-4">
<DialogTitle className="font-display text-lg tracking-tight">
{t('title')}
</DialogTitle>
</div>
<DialogDescription className="sr-only">{t('description')}</DialogDescription>
<SettingsShell variant="modal" activeSection={resolved} />
</DialogContent>
</Dialog>
)
}
+113
View File
@@ -0,0 +1,113 @@
'use client'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useSettingsNavItems } from './useSettingsNavItems'
interface SettingsRailProps {
/** Layout context: 'page' navigates with push (real route), 'modal' replaces
* the URL so section-switching keeps a single back-stack entry. */
variant: 'page' | 'modal'
/** 'rail' = grouped vertical list (desktop); 'select' = grouped dropdown (mobile). */
display: 'rail' | 'select'
/** Explicit active section id. Falls back to the current pathname when omitted
* (used by the page variant where the URL is the source of truth). */
activeId?: string
}
export function SettingsRail({ variant, display, activeId }: SettingsRailProps) {
const router = useRouter()
const pathname = usePathname()
const t = useTranslations('settings_nav')
const { items, groups } = useSettingsNavItems()
const resolvedActiveId =
activeId ??
items.find((i) => pathname.startsWith(i.href))?.id ??
items[0]?.id
function navigate(href: string) {
if (variant === 'modal') router.replace(href)
else router.push(href)
}
if (display === 'select') {
const activeHref =
items.find((i) => i.id === resolvedActiveId)?.href ?? items[0]?.href
return (
<Select value={activeHref} onValueChange={navigate}>
<SelectTrigger className="w-full" aria-label={t('aria_label')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{groups.map((g) => (
<SelectGroup key={g.key}>
<SelectLabel>{g.label}</SelectLabel>
{g.items.map((i) => (
<SelectItem key={i.id} value={i.href}>
{i.label}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
)
}
return (
<nav aria-label={t('aria_label')} className="space-y-5">
{groups.map((g) => (
<div key={g.key} className="space-y-1">
<p className="px-3 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{g.label}
</p>
<ul className="space-y-0.5">
{g.items.map((i) => {
const isActive = i.id === resolvedActiveId
const rowClass = cn(
'flex min-h-10 items-center rounded-lg px-3 py-2 text-sm transition-colors duration-150',
isActive
? 'bg-secondary font-medium text-foreground'
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
)
return (
<li key={i.id}>
{variant === 'modal' ? (
<button
type="button"
onClick={() => navigate(i.href)}
aria-current={isActive ? 'page' : undefined}
className={cn(rowClass, 'w-full text-left')}
>
{i.label}
</button>
) : (
<Link
href={i.href}
aria-current={isActive ? 'page' : undefined}
className={rowClass}
>
{i.label}
</Link>
)}
</li>
)
})}
</ul>
</div>
))}
</nav>
)
}
+55
View File
@@ -0,0 +1,55 @@
'use client'
import { Suspense } from 'react'
import { SettingsRail } from './SettingsRail'
import { SettingsLoadingSkeleton } from './SettingsLoadingSkeleton'
import { SETTINGS_SECTIONS } from './sections'
interface SettingsShellProps {
variant: 'page' | 'modal'
/** Resolved section id. Required for the modal (drives which content renders);
* optional for the page where `{children}` is the route's own content. */
activeSection?: string
children?: React.ReactNode
}
/**
* Shared two-pane settings layout (category rail + content). The full-page
* route renders it via `settings/layout.tsx` (content = the route's children);
* the routed modal renders it inside a Dialog (content resolved from the
* section map). Keeping one shell means page and modal stay visually identical.
*/
export function SettingsShell({ variant, activeSection, children }: SettingsShellProps) {
if (variant === 'modal') {
const Section = activeSection ? SETTINGS_SECTIONS[activeSection] : undefined
return (
<div className="flex min-h-0 flex-1">
<aside className="hidden w-56 shrink-0 overflow-y-auto border-r border-border p-3 md:block">
<SettingsRail variant="modal" display="rail" activeId={activeSection} />
</aside>
<div className="min-h-0 flex-1 overflow-y-auto p-6">
<div className="mb-6 md:hidden">
<SettingsRail variant="modal" display="select" activeId={activeSection} />
</div>
<Suspense fallback={<SettingsLoadingSkeleton />}>
{Section ? <Section /> : null}
</Suspense>
</div>
</div>
)
}
return (
<div className="grid gap-8 md:grid-cols-[220px_1fr]">
<aside className="md:sticky md:top-8 md:self-start">
<div className="mb-4 md:hidden">
<SettingsRail variant="page" display="select" />
</div>
<div className="hidden md:block">
<SettingsRail variant="page" display="rail" />
</div>
</aside>
<div className="min-w-0">{children}</div>
</div>
)
}
-95
View File
@@ -1,95 +0,0 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useRouter } from 'next/navigation'
import { useCompany } from '@/contexts/CompanyContext'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
interface NavItem {
href: string
label: string
show: boolean
}
export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
const pathname = usePathname()
const router = useRouter()
const { company } = useCompany()
const { identity } = useAgentSheet()
const t = useTranslations('settings_nav')
const hasCompany = !!company
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
const hasMcpExtension = ENABLED_EXTENSION_IDS.has('mcp-server')
const items: NavItem[] = [
// Företagsprofil (TIC-snapshot) lives under Företag; Skatteverket under Skatt;
// assistentens minne + kunskap under Assistenten; säkerhetsbackup under Importera/Exportera.
{ href: '/settings/company', label: t('company'), show: hasCompany },
{ href: '/settings/invoicing', label: t('invoicing'), show: hasCompany },
{ href: '/settings/bookkeeping', label: t('bookkeeping'), show: hasCompany },
{ href: '/settings/tax', label: t('tax'), show: hasCompany },
{ href: '/settings/team', label: t('team'), show: false },
{ href: '/settings/banking', label: t('banking'), show: hasCompany && !isSandbox && hasBankingExtension },
{ href: '/settings/salary', label: t('salary'), show: hasCompany && company?.entity_type === 'aktiebolag' },
{ href: '/settings/templates', label: t('templates'), show: hasCompany },
{ href: '/settings/assistant', label: t('assistant'), show: hasCompany && identity.isVerified },
{ href: '/settings/account', label: t('account'), show: true },
{ href: '/settings/api', label: t('api'), show: hasCompany && hasMcpExtension },
].filter(item => item.show)
const activeHref = items.find(item => pathname.startsWith(item.href))?.href || items[0]?.href
return (
<>
{/* Mobile: select dropdown */}
<div className="sm:hidden">
<Select value={activeHref} onValueChange={(v) => router.push(v)}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{items.map(item => (
<SelectItem key={item.href} value={item.href}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Desktop: horizontal tabs with bottom border */}
<nav
className="hidden sm:block overflow-x-auto scrollbar-none border-b border-border"
aria-label={t('aria_label')}
>
<ul className="flex gap-0 -mb-px">
{items.map(item => {
const isActive = pathname.startsWith(item.href)
return (
<li key={item.href}>
<Link
href={item.href}
className={`block whitespace-nowrap px-3 py-2 text-sm transition-colors border-b-2 ${
isActive
? 'font-medium text-foreground border-foreground'
: 'text-muted-foreground border-transparent hover:text-foreground hover:border-border'
}`}
>
{item.label}
</Link>
</li>
)
})}
</ul>
</nav>
</>
)
}
// Keep old name as alias for backward compat during transition
export const SettingsSidebar = SettingsNav
@@ -0,0 +1,195 @@
'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'
export function AccountSettingsContent() {
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 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 pt-8">
<SecuritySettings />
</div>
{/* Calendar feed */}
{hasCalendarExtension && (
<div className="border-t border-border pt-8">
<CalendarFeedSettings />
</div>
)}
{/* Logout */}
<section className="border-t border-border 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 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>
)
}
@@ -0,0 +1,13 @@
'use client'
import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel'
import { OAuthClientsPanel } from '@/components/settings/OAuthClientsPanel'
export function ApiSettingsContent() {
return (
<div className="space-y-8">
<ApiKeysPanel />
<OAuthClientsPanel />
</div>
)
}
@@ -0,0 +1,43 @@
'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'
export function AssistantSettingsContent() {
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>
)
}
@@ -0,0 +1,171 @@
'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')
export function BankingSettingsContent() {
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>
)
}
@@ -0,0 +1,164 @@
'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('')
export function BookkeepingSettingsContent() {
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 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>
</SettingsFormWrapper>
{/* Voucher series — per-source-type mapping */}
<div className="border-t border-border pt-8">
<VoucherSeriesPerSourceTypeForm
settings={settings}
onSettingsUpdated={updateSettings}
/>
</div>
{/* Voucher series — read-only display */}
<div className="border-t border-border pt-8">
<VoucherSeriesManager defaultSeries={settings.default_voucher_series || 'A'} />
</div>
{/* Periodisering auto-detect toggle */}
<div className="border-t border-border pt-8">
<PeriodiseringAutoDetectToggle />
</div>
{/* 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">
<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>
)
}
@@ -0,0 +1,69 @@
'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'
export function CompanySettingsContent() {
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 pt-8">
<LogoUpload
logoUrl={settings.logo_url}
onUpdate={(url) => updateSettings({ logo_url: url })}
/>
</div>
<div className="border-t border-border pt-8">
<CompanyMembersSection />
</div>
<FiscalPeriodEditor />
<CompanyProfileSection />
<CompanyDangerZone />
</div>
)
}
@@ -0,0 +1,71 @@
'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'
export function InvoicingSettingsContent() {
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 pt-8">
<InvoiceSettingsForm settings={settings} />
</div>
</SettingsFormWrapper>
{/* PDF settings — saves individually via toggle switches */}
<div className="border-t border-border pt-8">
<PdfPrintSettings settings={settings} onUpdate={updateSettings} />
</div>
</div>
)
}
@@ -0,0 +1,87 @@
'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'
export function SalarySettingsContent() {
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>
)
}
@@ -0,0 +1,102 @@
'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'
export function TaxSettingsContent() {
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>
)
}
@@ -0,0 +1,13 @@
'use client'
import { BookingTemplatesPanel } from '@/components/settings/BookingTemplatesPanel'
import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel'
export function TemplatesSettingsContent() {
return (
<div className="space-y-8">
<BookingTemplatesPanel />
<CounterpartyTemplatesPanel />
</div>
)
}
+33
View File
@@ -0,0 +1,33 @@
import type { ComponentType } from 'react'
import { AccountSettingsContent } from './AccountSettingsContent'
import { CompanySettingsContent } from './CompanySettingsContent'
import { BookkeepingSettingsContent } from './BookkeepingSettingsContent'
import { TaxSettingsContent } from './TaxSettingsContent'
import { SalarySettingsContent } from './SalarySettingsContent'
import { InvoicingSettingsContent } from './InvoicingSettingsContent'
import { TemplatesSettingsContent } from './TemplatesSettingsContent'
import { BankingSettingsContent } from './BankingSettingsContent'
import { AssistantSettingsContent } from './AssistantSettingsContent'
import { ApiSettingsContent } from './ApiSettingsContent'
/**
* Single source of truth mapping a settings section id to the component that
* renders its content. Both the per-section route (`settings/<section>/page.tsx`,
* a thin wrapper) and the routed settings modal (`SettingsModal` → `SettingsShell`)
* resolve content through this map, so there is exactly one place a section's
* composition lives.
*/
export const SETTINGS_SECTIONS: Record<string, ComponentType> = {
account: AccountSettingsContent,
company: CompanySettingsContent,
bookkeeping: BookkeepingSettingsContent,
tax: TaxSettingsContent,
salary: SalarySettingsContent,
invoicing: InvoicingSettingsContent,
templates: TemplatesSettingsContent,
banking: BankingSettingsContent,
assistant: AssistantSettingsContent,
api: ApiSettingsContent,
}
export type SettingsSectionId = keyof typeof SETTINGS_SECTIONS
@@ -0,0 +1,80 @@
'use client'
import { useTranslations } from 'next-intl'
import { useCompany } from '@/contexts/CompanyContext'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
export type SettingsGroupKey = 'account' | 'company' | 'accounting' | 'sales' | 'tools'
export interface SettingsNavItem {
id: string
href: string
label: string
group: SettingsGroupKey
}
export interface SettingsNavGroup {
key: SettingsGroupKey
label: string
items: SettingsNavItem[]
}
// Rail group order — personal first (Konto), then company-scoped buckets.
const GROUP_ORDER: SettingsGroupKey[] = ['account', 'company', 'accounting', 'sales', 'tools']
/**
* Single source of truth for the settings sections, their conditional
* visibility, and their grouping. Consumed by both the full-page rail and the
* routed settings modal so the two can never drift on which sections show for
* AB vs EF, sandbox, identity-verified, or enabled extensions.
*
* Visibility is derived from client context (no extra fetch): `isSandbox`
* comes from CompanyContext, identity from the agent sheet, and extension
* availability from the generated enabled-extensions set.
*/
export function useSettingsNavItems(): { items: SettingsNavItem[]; groups: SettingsNavGroup[] } {
const { company, isSandbox } = useCompany()
const { identity } = useAgentSheet()
const t = useTranslations('settings_nav')
const hasCompany = !!company
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
const hasMcpExtension = ENABLED_EXTENSION_IDS.has('mcp-server')
// Företagsprofil (TIC-snapshot) lives under Företag; Skatteverket under Skatt;
// assistentens minne + kunskap under Assistenten; säkerhetsbackup under
// Importera/Exportera. Team stays hidden (show:false) until enabled.
const defs: Array<SettingsNavItem & { show: boolean }> = [
{ id: 'account', href: '/settings/account', label: t('account'), group: 'account', show: true },
{ id: 'company', href: '/settings/company', label: t('company'), group: 'company', show: hasCompany },
{ id: 'bookkeeping', href: '/settings/bookkeeping', label: t('bookkeeping'), group: 'accounting', show: hasCompany },
{ id: 'tax', href: '/settings/tax', label: t('tax'), group: 'accounting', show: hasCompany },
{ id: 'salary', href: '/settings/salary', label: t('salary'), group: 'accounting', show: hasCompany && company?.entity_type === 'aktiebolag' },
{ id: 'invoicing', href: '/settings/invoicing', label: t('invoicing'), group: 'sales', show: hasCompany },
{ id: 'templates', href: '/settings/templates', label: t('templates'), group: 'sales', show: hasCompany },
{ id: 'banking', href: '/settings/banking', label: t('banking'), group: 'tools', show: hasCompany && !isSandbox && hasBankingExtension },
{ id: 'assistant', href: '/settings/assistant', label: t('assistant'), group: 'tools', show: hasCompany && identity.isVerified },
{ id: 'api', href: '/settings/api', label: t('api'), group: 'tools', show: hasCompany && hasMcpExtension },
]
const items: SettingsNavItem[] = defs
.filter((d) => d.show)
.map(({ show: _show, ...item }) => item)
const groupLabels: Record<SettingsGroupKey, string> = {
account: t('group_account'),
company: t('group_company'),
accounting: t('group_accounting'),
sales: t('group_sales'),
tools: t('group_tools'),
}
const groups: SettingsNavGroup[] = GROUP_ORDER.map((key) => ({
key,
label: groupLabels[key],
items: items.filter((i) => i.group === key),
})).filter((g) => g.items.length > 0)
return { items, groups }
}
+302
View File
@@ -0,0 +1,302 @@
import type { EntityType } from '@/types'
/**
* Single source of truth for the reports surface.
*
* One descriptor per report drives every entry point: the report-library
* landing (`ReportLibrary`), the "Senast öppnade" recent shelf, the focused
* report route (`/reports/[slug]` via `FocusedReport`), and the command-palette
* "Visa rapport" jumps. Adding a report = adding one row here.
*
* `labelKey` / `descKey` resolve against the `reports` i18n namespace. The
* category labels reuse the existing `group_*` keys so statutory terminology is
* never re-translated.
*/
export type ReportCategory =
| 'interim'
| 'year_end'
| 'tax_vat'
| 'ledgers'
| 'reconciliation'
| 'payroll'
| 'export'
/**
* How the report is parameterised:
* - `fiscal-range`: fiscal period + an optional date sub-range (ReportDateRange)
* - `fiscal`: fiscal period only
* - `calendar`: calendar year + monthly/quarterly/yearly period (VAT family) —
* the deliberate exception to "pick the fiscal year once"
* - `none`: no period parameter
*/
export type ReportParams = 'fiscal-range' | 'fiscal' | 'calendar' | 'none'
export type ReportExportFormat = 'pdf' | 'xlsx'
export interface ReportDescriptor {
/** URL slug at /reports/[slug]; also the legacy activeTab id. */
slug: string
/** i18n key in the `reports` namespace for the display name. */
labelKey: string
/** i18n key in the `reports` namespace for the one-line description. */
descKey: string
category: ReportCategory
/** When set, the report only appears for this entity type. */
entityType?: EntityType
/** When true, only shown if the company has employees. */
needsEmployees?: boolean
params: ReportParams
/** On-page export formats handled by the focused view's export menu. */
exports?: ReportExportFormat[]
/**
* External destination. When set, the library/nav links straight here instead
* of /reports/[slug] (e.g. reports that own their own route, or live elsewhere).
*/
route?: string
/**
* Hidden from the legacy desktop rail; surfaced only on the library landing.
* Used for reports that were never in the nav (KPI, payroll, archive…).
*/
libraryOnly?: boolean
}
/** Categories shown in the legacy desktop rail, in order. */
export const NAV_CATEGORIES: ReportCategory[] = [
'interim',
'year_end',
'tax_vat',
'ledgers',
'reconciliation',
]
/** All categories shown on the library landing, in order. */
export const LIBRARY_CATEGORIES: ReportCategory[] = [
'interim',
'year_end',
'tax_vat',
'ledgers',
'reconciliation',
'payroll',
'export',
]
/** Maps a category to its existing `group_*` i18n label key. */
export const CATEGORY_LABEL_KEY: Record<ReportCategory, string> = {
interim: 'group_interim',
year_end: 'group_year_end',
tax_vat: 'group_tax_vat',
ledgers: 'group_ledgers',
reconciliation: 'group_reconciliation',
payroll: 'group_payroll',
export: 'group_export',
}
export const REPORT_CATALOG: ReportDescriptor[] = [
// --- Löpande (interim) ---
{
slug: 'resultatrapport',
labelKey: 'name_resultatrapport',
descKey: 'desc_resultatrapport',
category: 'interim',
params: 'fiscal-range',
exports: ['pdf', 'xlsx'],
},
{
slug: 'balansrapport',
labelKey: 'name_balansrapport',
descKey: 'desc_balansrapport',
category: 'interim',
params: 'fiscal-range',
exports: ['pdf', 'xlsx'],
},
{
slug: 'trial-balance',
labelKey: 'name_trial_balance',
descKey: 'desc_trial_balance',
category: 'interim',
params: 'fiscal',
exports: ['xlsx'],
},
{
slug: 'kpi',
labelKey: 'name_kpi',
descKey: 'desc_kpi',
category: 'interim',
params: 'fiscal',
route: '/kpi',
libraryOnly: true,
},
// --- Bokslut (year-end) ---
{
slug: 'income-statement',
labelKey: 'name_income_statement',
descKey: 'desc_income_statement',
category: 'year_end',
params: 'fiscal-range',
exports: ['pdf', 'xlsx'],
},
{
slug: 'balance-sheet',
labelKey: 'name_balance_sheet',
descKey: 'desc_balance_sheet',
category: 'year_end',
params: 'fiscal-range',
exports: ['pdf', 'xlsx'],
},
{
slug: 'kassaflodesanalys',
labelKey: 'name_kassaflodesanalys',
descKey: 'desc_kassaflodesanalys',
category: 'year_end',
params: 'fiscal',
route: '/reports/kassaflodesanalys',
},
{
slug: 'arsredovisning',
labelKey: 'name_arsredovisning',
descKey: 'desc_arsredovisning',
category: 'year_end',
entityType: 'aktiebolag',
params: 'fiscal',
route: '/bookkeeping/year-end/arsredovisning',
},
// --- Skatt & moms (tax & VAT) ---
{
slug: 'vat-declaration',
labelKey: 'name_vat_declaration',
descKey: 'desc_vat_declaration',
category: 'tax_vat',
params: 'calendar',
exports: ['xlsx'],
},
{
slug: 'periodisk-sammanstallning',
labelKey: 'name_periodisk_sammanstallning',
descKey: 'desc_periodisk_sammanstallning',
category: 'tax_vat',
params: 'calendar',
},
{
slug: 'ne-declaration',
labelKey: 'name_ne_declaration',
descKey: 'desc_ne_declaration',
category: 'tax_vat',
entityType: 'enskild_firma',
params: 'fiscal',
},
{
slug: 'ink2-declaration',
labelKey: 'name_ink2_declaration',
descKey: 'desc_ink2_declaration',
category: 'tax_vat',
entityType: 'aktiebolag',
params: 'fiscal',
},
// --- Huvudböcker (ledgers) ---
{
slug: 'huvudbok',
labelKey: 'name_huvudbok',
descKey: 'desc_huvudbok',
category: 'ledgers',
params: 'fiscal',
exports: ['xlsx'],
},
{
slug: 'grundbok',
labelKey: 'name_grundbok',
descKey: 'desc_grundbok',
category: 'ledgers',
params: 'fiscal',
exports: ['xlsx'],
},
{
slug: 'kundreskontra',
labelKey: 'name_kundreskontra',
descKey: 'desc_kundreskontra',
category: 'ledgers',
params: 'fiscal',
exports: ['xlsx'],
},
{
slug: 'supplier-ledger',
labelKey: 'name_supplier_ledger',
descKey: 'desc_supplier_ledger',
category: 'ledgers',
params: 'fiscal',
exports: ['xlsx'],
},
// --- Avstämning (reconciliation) ---
{
slug: 'bank-reconciliation',
labelKey: 'name_bank_reconciliation',
descKey: 'desc_bank_reconciliation',
category: 'reconciliation',
params: 'none',
},
// --- Export & arkiv — library-only ---
{
slug: 'sie-export',
labelKey: 'name_sie_export',
descKey: 'desc_sie_export',
category: 'export',
params: 'fiscal',
route: '/import?view=export#sie-export',
libraryOnly: true,
},
]
/** Reports that take a fiscal period + optional date sub-range. */
export const DATE_RANGE_SLUGS: ReadonlySet<string> = new Set(
REPORT_CATALOG.filter((r) => r.params === 'fiscal-range').map((r) => r.slug),
)
export function getReport(slug: string): ReportDescriptor | undefined {
return REPORT_CATALOG.find((r) => r.slug === slug)
}
function isVisible(
r: ReportDescriptor,
entityType?: EntityType,
hasEmployees?: boolean,
): boolean {
if (r.entityType && r.entityType !== entityType) return false
if (r.needsEmployees && !hasEmployees) return false
return true
}
export interface ReportSection {
category: ReportCategory
labelKey: string
items: ReportDescriptor[]
}
/** Grouped reports for the legacy desktop rail (excludes library-only items). */
export function getNavSections(entityType?: EntityType): ReportSection[] {
return NAV_CATEGORIES.map((category) => ({
category,
labelKey: CATEGORY_LABEL_KEY[category],
items: REPORT_CATALOG.filter(
(r) => r.category === category && !r.libraryOnly && isVisible(r, entityType),
),
})).filter((s) => s.items.length > 0)
}
/** Grouped reports for the library landing (includes everything visible). */
export function getLibrarySections(
entityType?: EntityType,
hasEmployees?: boolean,
): ReportSection[] {
return LIBRARY_CATEGORIES.map((category) => ({
category,
labelKey: CATEGORY_LABEL_KEY[category],
items: REPORT_CATALOG.filter(
(r) => r.category === category && isVisible(r, entityType, hasEmployees),
),
})).filter((s) => s.items.length > 0)
}
+38 -1
View File
@@ -176,7 +176,16 @@
"assistant": "Assistant",
"backup": "Backup",
"account": "Account",
"api": "API"
"api": "API",
"group_account": "Account",
"group_company": "Company",
"group_accounting": "Accounting & tax",
"group_sales": "Sales",
"group_tools": "Tools & integrations"
},
"settings_modal": {
"title": "Settings",
"description": "Manage your company and account"
},
"settings": {
"section_appearance": "Appearance",
@@ -3628,6 +3637,34 @@
"sie_moved_hint": "SIE export now lives under Import/Export.",
"sie_moved_link": "Open SIE export",
"download_pdf": "Download PDF",
"download_excel": "Download Excel",
"export": "Export",
"recent_heading": "Recently opened",
"back_to_library": "Reports",
"switch_report": "Switch report",
"calendar_badge": "Calendar",
"group_payroll": "Payroll",
"group_export": "Export & archive",
"name_kpi": "Key figures",
"name_sie_export": "SIE export",
"desc_resultatrapport": "Revenue less costs for the period",
"desc_balansrapport": "Assets, liabilities and equity by account",
"desc_trial_balance": "All accounts with opening and closing balances",
"desc_kpi": "Margin, liquidity and other key figures",
"desc_income_statement": "Profit or loss in the statutory layout",
"desc_balance_sheet": "Financial position at the end of the period",
"desc_kassaflodesanalys": "Change in liquidity during the year",
"desc_arsredovisning": "Directors' report, notes and signatures",
"desc_vat_declaration": "Basis for the VAT return (boxes)",
"desc_periodisk_sammanstallning": "EU sales of goods and services",
"desc_ne_declaration": "NE appendix for sole traders",
"desc_ink2_declaration": "Income tax return 2 for limited companies",
"desc_huvudbok": "All transactions grouped by account",
"desc_grundbok": "Vouchers in registration order",
"desc_kundreskontra": "Outstanding receivables with aging",
"desc_supplier_ledger": "Outstanding payables with aging",
"desc_bank_reconciliation": "Reconcile bank transactions against the books",
"desc_sie_export": "Export the books as a SIE file",
"categories_aria": "Report categories",
"group_interim": "Interim",
"group_year_end": "Year-end",
+38 -1
View File
@@ -176,7 +176,16 @@
"assistant": "Assistenten",
"backup": "Säkerhetsbackup",
"account": "Konto",
"api": "API"
"api": "API",
"group_account": "Konto",
"group_company": "Företag",
"group_accounting": "Bokföring & skatt",
"group_sales": "Försäljning",
"group_tools": "Verktyg & integrationer"
},
"settings_modal": {
"title": "Inställningar",
"description": "Hantera ditt företag och konto"
},
"settings": {
"section_appearance": "Utseende",
@@ -3628,6 +3637,34 @@
"sie_moved_hint": "SIE-export finns nu under Importera/Exportera.",
"sie_moved_link": "Öppna SIE-export",
"download_pdf": "Ladda ner PDF",
"download_excel": "Ladda ner Excel",
"export": "Exportera",
"recent_heading": "Senast öppnade",
"back_to_library": "Rapporter",
"switch_report": "Byt rapport",
"calendar_badge": "Kalender",
"group_payroll": "Lön",
"group_export": "Export & arkiv",
"name_kpi": "Nyckeltal",
"name_sie_export": "SIE-export",
"desc_resultatrapport": "Intäkter minus kostnader för perioden",
"desc_balansrapport": "Tillgångar, skulder och eget kapital per konto",
"desc_trial_balance": "Alla konton med ingående och utgående saldo",
"desc_kpi": "Marginal, likviditet och andra nyckeltal",
"desc_income_statement": "Årets resultat enligt uppställningsform",
"desc_balance_sheet": "Ekonomisk ställning vid periodens slut",
"desc_kassaflodesanalys": "Likviditetens förändring under året",
"desc_arsredovisning": "Förvaltningsberättelse, noter och underskrifter",
"desc_vat_declaration": "Underlag till momsdeklarationen (rutor)",
"desc_periodisk_sammanstallning": "EU-försäljning av varor och tjänster",
"desc_ne_declaration": "NE-bilaga för enskild firma",
"desc_ink2_declaration": "Inkomstdeklaration 2 för aktiebolag",
"desc_huvudbok": "Alla transaktioner grupperade per konto",
"desc_grundbok": "Verifikationer i registreringsordning",
"desc_kundreskontra": "Utestående kundfordringar med åldersfördelning",
"desc_supplier_ledger": "Utestående leverantörsskulder med åldersfördelning",
"desc_bank_reconciliation": "Stäm av banktransaktioner mot bokföringen",
"desc_sie_export": "Exportera bokföringen som SIE-fil",
"categories_aria": "Rapportkategorier",
"group_interim": "Löpande",
"group_year_end": "Bokslut",