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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
+58
-2800
File diff suppressed because it is too large
Load Diff
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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">×</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 />
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">×</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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user