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:
co-authored by
Claude Opus 4.8
parent
f6ee0c2a82
commit
ff01640f60
@@ -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 />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user