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 { AgentSheetProvider } from '@/components/agent/AgentSheetProvider'
|
||||||
import AgentTrigger from '@/components/agent/AgentTrigger'
|
import AgentTrigger from '@/components/agent/AgentTrigger'
|
||||||
import CommandPalette from '@/components/common/CommandPalette'
|
import CommandPalette from '@/components/common/CommandPalette'
|
||||||
|
import { SettingsHotkey } from '@/components/settings/SettingsHotkey'
|
||||||
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
|
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
|
||||||
import { getExtensionNavItems } from '@/lib/extensions/sectors'
|
import { getExtensionNavItems } from '@/lib/extensions/sectors'
|
||||||
import { CompanyProvider } from '@/contexts/CompanyContext'
|
import { CompanyProvider } from '@/contexts/CompanyContext'
|
||||||
@@ -25,8 +26,12 @@ const NO_COMPANY_ALLOWED_PATHS = ['/settings/account']
|
|||||||
|
|
||||||
export default async function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
|
settingsModal,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
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()
|
const supabase = await createClient()
|
||||||
|
|
||||||
@@ -109,6 +114,8 @@ export default async function DashboardLayout({
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
{settingsModal}
|
||||||
|
<SettingsHotkey />
|
||||||
</div>
|
</div>
|
||||||
</AgentSheetProvider>
|
</AgentSheetProvider>
|
||||||
</CompanyProvider>
|
</CompanyProvider>
|
||||||
@@ -159,6 +166,8 @@ export default async function DashboardLayout({
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
{settingsModal}
|
||||||
|
<SettingsHotkey />
|
||||||
</div>
|
</div>
|
||||||
</AgentSheetProvider>
|
</AgentSheetProvider>
|
||||||
</CompanyProvider>
|
</CompanyProvider>
|
||||||
@@ -278,6 +287,8 @@ export default async function DashboardLayout({
|
|||||||
</main>
|
</main>
|
||||||
<AgentTrigger />
|
<AgentTrigger />
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
|
<SettingsHotkey />
|
||||||
|
{settingsModal}
|
||||||
</div>
|
</div>
|
||||||
{!isSandbox && (
|
{!isSandbox && (
|
||||||
<RecaptIdentify
|
<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 { AccountSettingsContent } from '@/components/settings/sections/AccountSettingsContent'
|
||||||
|
|
||||||
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 default function AccountSettingsPage() {
|
export default function AccountSettingsPage() {
|
||||||
const router = useRouter()
|
return <AccountSettingsContent />
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
'use client'
|
import { ApiSettingsContent } from '@/components/settings/sections/ApiSettingsContent'
|
||||||
|
|
||||||
import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel'
|
|
||||||
import { OAuthClientsPanel } from '@/components/settings/OAuthClientsPanel'
|
|
||||||
|
|
||||||
export default function ApiSettingsPage() {
|
export default function ApiSettingsPage() {
|
||||||
return (
|
return <ApiSettingsContent />
|
||||||
<div className="space-y-8">
|
|
||||||
<ApiKeysPanel />
|
|
||||||
<OAuthClientsPanel />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,5 @@
|
|||||||
'use client'
|
import { AssistantSettingsContent } from '@/components/settings/sections/AssistantSettingsContent'
|
||||||
|
|
||||||
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 default function AssistantSettingsPage() {
|
export default function AssistantSettingsPage() {
|
||||||
const searchParams = useSearchParams()
|
return <AssistantSettingsContent />
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,171 +1,5 @@
|
|||||||
'use client'
|
import { BankingSettingsContent } from '@/components/settings/sections/BankingSettingsContent'
|
||||||
|
|
||||||
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 default function BankingSettingsPage() {
|
export default function BankingSettingsPage() {
|
||||||
const t = useTranslations('settings_banking')
|
return <BankingSettingsContent />
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,164 +1,5 @@
|
|||||||
'use client'
|
import { BookkeepingSettingsContent } from '@/components/settings/sections/BookkeepingSettingsContent'
|
||||||
|
|
||||||
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 default function BookkeepingSettingsPage() {
|
export default function BookkeepingSettingsPage() {
|
||||||
const t = useTranslations('settings_bookkeeping')
|
return <BookkeepingSettingsContent />
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,5 @@
|
|||||||
'use client'
|
import { CompanySettingsContent } from '@/components/settings/sections/CompanySettingsContent'
|
||||||
|
|
||||||
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 default function CompanySettingsPage() {
|
export default function CompanySettingsPage() {
|
||||||
const router = useRouter()
|
return <CompanySettingsContent />
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +1,5 @@
|
|||||||
'use client'
|
import { InvoicingSettingsContent } from '@/components/settings/sections/InvoicingSettingsContent'
|
||||||
|
|
||||||
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 default function InvoicingSettingsPage() {
|
export default function InvoicingSettingsPage() {
|
||||||
const t = useTranslations('settings_invoicing')
|
return <InvoicingSettingsContent />
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useSearchParams, useRouter } from 'next/navigation'
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
import { SettingsNav } from '@/components/settings/SettingsSidebar'
|
import { useTranslations } from 'next-intl'
|
||||||
import { useCompany } from '@/contexts/CompanyContext'
|
import { SettingsShell } from '@/components/settings/SettingsShell'
|
||||||
import { createClient } from '@/lib/supabase/client'
|
|
||||||
|
|
||||||
const TAB_TO_ROUTE: Record<string, string> = {
|
const TAB_TO_ROUTE: Record<string, string> = {
|
||||||
company: '/settings/company',
|
company: '/settings/company',
|
||||||
@@ -23,22 +22,7 @@ const TAB_TO_ROUTE: Record<string, string> = {
|
|||||||
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { company } = useCompany()
|
const t = useTranslations('settings_nav')
|
||||||
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])
|
|
||||||
|
|
||||||
// Handle legacy ?tab= URLs
|
// Handle legacy ?tab= URLs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -49,17 +33,9 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
|
|||||||
}, [searchParams, router])
|
}, [searchParams, router])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div>
|
<h1 className="font-display text-2xl tracking-tight md:text-3xl">{t('aria_label')}</h1>
|
||||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Inställningar</h1>
|
<SettingsShell variant="page">{children}</SettingsShell>
|
||||||
<p className="text-muted-foreground text-sm mt-1">
|
|
||||||
Hantera ditt företag och konto
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SettingsNav isSandbox={isSandbox} />
|
|
||||||
|
|
||||||
<div>{children}</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +1,5 @@
|
|||||||
'use client'
|
import { SalarySettingsContent } from '@/components/settings/sections/SalarySettingsContent'
|
||||||
|
|
||||||
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 default function SalarySettingsPage() {
|
export default function SalarySettingsPage() {
|
||||||
const t = useTranslations('settings_salary')
|
return <SalarySettingsContent />
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,102 +1,5 @@
|
|||||||
'use client'
|
import { TaxSettingsContent } from '@/components/settings/sections/TaxSettingsContent'
|
||||||
|
|
||||||
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 default function TaxSettingsPage() {
|
export default function TaxSettingsPage() {
|
||||||
const { settings, isLoading, updateSettings } = useSettings()
|
return <TaxSettingsContent />
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
'use client'
|
import { TemplatesSettingsContent } from '@/components/settings/sections/TemplatesSettingsContent'
|
||||||
|
|
||||||
import { BookingTemplatesPanel } from '@/components/settings/BookingTemplatesPanel'
|
|
||||||
import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel'
|
|
||||||
|
|
||||||
export default function TemplatesSettingsPage() {
|
export default function TemplatesSettingsPage() {
|
||||||
return (
|
return <TemplatesSettingsContent />
|
||||||
<div className="space-y-8">
|
|
||||||
<BookingTemplatesPanel />
|
|
||||||
<CounterpartyTemplatesPanel />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ const PAGE_ENTRIES: Entry[] = [
|
|||||||
{ id: 'bokföring', label: 'Bokföring', icon: BookOpen, href: '/bookkeeping', keywords: 'verifikat journal ledger' },
|
{ 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: 'anläggningstillgångar', label: 'Anläggningstillgångar', icon: Package, href: '/assets', keywords: 'tillgångar assets' },
|
||||||
{ id: 'rapporter', label: 'Rapporter', icon: BarChart3, href: '/reports' },
|
{ 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: 'importera', label: 'Importera', icon: Upload, href: '/import' },
|
||||||
{ id: 'granskning', label: 'Granskning', icon: ClipboardCheck, href: '/pending', keywords: 'pending review' },
|
{ id: 'granskning', label: 'Granskning', icon: ClipboardCheck, href: '/pending', keywords: 'pending review' },
|
||||||
{ id: 'löner', label: 'Löner', icon: HandCoins, href: '/salary' },
|
{ 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",
|
"assistant": "Assistant",
|
||||||
"backup": "Backup",
|
"backup": "Backup",
|
||||||
"account": "Account",
|
"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": {
|
"settings": {
|
||||||
"section_appearance": "Appearance",
|
"section_appearance": "Appearance",
|
||||||
@@ -3628,6 +3637,34 @@
|
|||||||
"sie_moved_hint": "SIE export now lives under Import/Export.",
|
"sie_moved_hint": "SIE export now lives under Import/Export.",
|
||||||
"sie_moved_link": "Open SIE export",
|
"sie_moved_link": "Open SIE export",
|
||||||
"download_pdf": "Download PDF",
|
"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",
|
"categories_aria": "Report categories",
|
||||||
"group_interim": "Interim",
|
"group_interim": "Interim",
|
||||||
"group_year_end": "Year-end",
|
"group_year_end": "Year-end",
|
||||||
|
|||||||
+38
-1
@@ -176,7 +176,16 @@
|
|||||||
"assistant": "Assistenten",
|
"assistant": "Assistenten",
|
||||||
"backup": "Säkerhetsbackup",
|
"backup": "Säkerhetsbackup",
|
||||||
"account": "Konto",
|
"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": {
|
"settings": {
|
||||||
"section_appearance": "Utseende",
|
"section_appearance": "Utseende",
|
||||||
@@ -3628,6 +3637,34 @@
|
|||||||
"sie_moved_hint": "SIE-export finns nu under Importera/Exportera.",
|
"sie_moved_hint": "SIE-export finns nu under Importera/Exportera.",
|
||||||
"sie_moved_link": "Öppna SIE-export",
|
"sie_moved_link": "Öppna SIE-export",
|
||||||
"download_pdf": "Ladda ner PDF",
|
"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",
|
"categories_aria": "Rapportkategorier",
|
||||||
"group_interim": "Löpande",
|
"group_interim": "Löpande",
|
||||||
"group_year_end": "Bokslut",
|
"group_year_end": "Bokslut",
|
||||||
|
|||||||
Reference in New Issue
Block a user