diff --git a/app/(dashboard)/@settingsModal/(.)settings/[[...section]]/page.tsx b/app/(dashboard)/@settingsModal/(.)settings/[[...section]]/page.tsx new file mode 100644 index 00000000..7e9a044d --- /dev/null +++ b/app/(dashboard)/@settingsModal/(.)settings/[[...section]]/page.tsx @@ -0,0 +1,16 @@ +import { SettingsModal } from '@/components/settings/SettingsModal' + +// Intercepting route: catches in-app soft navigations to /settings and +// /settings/
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 +} diff --git a/app/(dashboard)/@settingsModal/default.tsx b/app/(dashboard)/@settingsModal/default.tsx new file mode 100644 index 00000000..be31c618 --- /dev/null +++ b/app/(dashboard)/@settingsModal/default.tsx @@ -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 +} diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index ebbf7a52..fbb7606a 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -8,6 +8,7 @@ import { RecaptIdentify } from '@/components/RecaptIdentify' import { AgentSheetProvider } from '@/components/agent/AgentSheetProvider' import AgentTrigger from '@/components/agent/AgentTrigger' import CommandPalette from '@/components/common/CommandPalette' +import { SettingsHotkey } from '@/components/settings/SettingsHotkey' import { SandboxBanner } from '@/components/dashboard/SandboxBanner' import { getExtensionNavItems } from '@/lib/extensions/sectors' import { CompanyProvider } from '@/contexts/CompanyContext' @@ -25,8 +26,12 @@ const NO_COMPANY_ALLOWED_PATHS = ['/settings/account'] export default async function DashboardLayout({ children, + settingsModal, }: { children: React.ReactNode + // `@settingsModal` parallel slot — renders the routed settings modal over the + // current page on in-app navigation to /settings/*; null otherwise. + settingsModal: React.ReactNode }) { const supabase = await createClient() @@ -109,6 +114,8 @@ export default async function DashboardLayout({ {children} + {settingsModal} + @@ -159,6 +166,8 @@ export default async function DashboardLayout({ {children} + {settingsModal} + @@ -278,6 +287,8 @@ export default async function DashboardLayout({ + + {settingsModal} {!isSandbox && ( +}) { + const { slug } = await params + const report = getReport(slug) + if (!report) notFound() + if (report.route) redirect(report.route) + return +} diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index 19baa941..3d5f94f8 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -1,191 +1,67 @@ 'use client' -import React, { useState, useEffect, useCallback, useRef } from 'react' -import Link from 'next/link' +import { useState } from 'react' import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Skeleton } from "@/components/ui/skeleton" -import { Button } from '@/components/ui/button' -import { Label } from '@/components/ui/label' -import { Badge } from '@/components/ui/badge' -import { Download, FileSpreadsheet, AlertCircle, ChevronDown, ChevronRight, ArrowRight } from 'lucide-react' -import AgentSparkleButton from '@/components/agent/AgentSparkleButton' -import { formatDate } from '@/lib/utils' -import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' -import { AccountNumber } from '@/components/ui/account-number' +import { Skeleton } from '@/components/ui/skeleton' +import { Card, CardContent } from '@/components/ui/card' +import { PageHeader } from '@/components/ui/page-header' +import { EmptyState } from '@/components/ui/empty-state' import { useCompany } from '@/contexts/CompanyContext' -import { useSettings } from '@/components/settings/useSettings' import { FiscalYearSelector } from '@/components/common/FiscalYearSelector' -import { ReportDateRange, type DateRangeValue } from '@/components/common/ReportDateRange' -import { ReportsNav } from '@/components/reports/ReportsNav' -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 { TrialBalanceChart } from '@/components/reports/TrialBalanceChart' -import { VatCompositionChart } from '@/components/reports/VatCompositionChart' -import { SkatteverketPanel } from '@/components/reports/SkatteverketPanel' -import { IncomeExpenseChart } from '@/components/reports/IncomeExpenseChart' -import { useReportRowExpansion } from '@/components/reports/ReportRowExpansion' -import type { - ReportSourceLine, - ReportSourceFetcher, -} from '@/lib/reports/source-lines' -import type { MonthlyDataPoint } from '@/components/reports/IncomeExpenseChart' -import type { - TrialBalanceRow, - IncomeStatementReport, - BalanceSheetReport, - ResultatrapportReport, - BalansrapportReport, - VatDeclaration, - VatPeriodType, -} from '@/types' - -function formatAmount(amount: number): string { - return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) -} +import { ReportLibrary } from '@/components/reports/ReportLibrary' +import { RecentReportsShelf } from '@/components/reports/RecentReportsShelf' +import { useRecentReports } from '@/components/reports/useRecentReports' +import { getReport } from '@/lib/reports/catalog' /** - * Build a report API query string with the period and optional date range. - * Omitting from/to lets the API fall back to full-period behaviour, which - * keeps URLs (and the matching caches) identical for the "Hela året" preset. + * Reports library landing. A calm, grouped index of every report — selecting + * one opens the focused /reports/[slug] route. The fiscal year picked here + * persists (FiscalYearSelector localStorage) and is restored on the focused + * page, so the choice carries across without URL plumbing. */ -function reportQuery(periodId: string, range?: DateRangeValue): string { - const params = new URLSearchParams({ period_id: periodId }) - if (range?.fromDate) params.set('from_date', range.fromDate) - if (range?.toDate) params.set('to_date', range.toDate) - return params.toString() -} - -// Breadcrumb trail for drill-down navigation -interface DrillDownStep { - tab: string - label: string - accountNumber?: string -} - -const TAB_LABEL_KEYS: Record = { - 'resultatrapport': 'name_resultatrapport', - 'balansrapport': 'name_balansrapport', - 'trial-balance': 'name_trial_balance', - 'income-statement': 'name_income_statement', - 'balance-sheet': 'name_balance_sheet', - 'huvudbok': 'name_huvudbok', -} - -const DATE_RANGE_TABS = new Set([ - 'resultatrapport', - 'balansrapport', - 'income-statement', - 'balance-sheet', -]) - export default function ReportsPage() { const router = useRouter() const [selectedPeriod, setSelectedPeriod] = useState('') - const [selectedPeriodBounds, setSelectedPeriodBounds] = useState<{ start: string; end: string } | null>(null) - const [dateRange, setDateRange] = useState({}) - const [activeTab, setActiveTab] = useState('resultatrapport') const [isLoadingInit, setIsLoadingInit] = useState(true) const { company } = useCompany() const t = useTranslations('reports') + const { recents, pushRecent } = useRecentReports(company?.id) - // Drill-down state: when navigating from a report to the GL for a specific account - const [glAccountFilter, setGlAccountFilter] = useState(null) - const [drillDownTrail, setDrillDownTrail] = useState([]) - - const navigateToAccount = useCallback((accountNumber: string) => { - setDrillDownTrail((prev) => [ - ...prev, - { tab: activeTab, label: TAB_LABEL_KEYS[activeTab] ? t(TAB_LABEL_KEYS[activeTab]) : activeTab }, - ]) - setGlAccountFilter(accountNumber) - setActiveTab('huvudbok') - }, [activeTab, t]) - - const handleTabChange = useCallback((tab: string) => { - // Kassaflödesanalys lives on its own route; route there instead of swapping tabs. - if (tab === 'kassaflodesanalys') { - router.push('/reports/kassaflodesanalys') + // Open a report. Route-owning reports (cash flow, annual report, KPI, SIE) + // navigate to their own page; the rest open the focused /reports/[slug] route. + const openReport = (slug: string) => { + const report = getReport(slug) + if (report?.route) { + const href = + slug === 'arsredovisning' && selectedPeriod + ? `${report.route}?period=${selectedPeriod}` + : report.route + router.push(href) return } - // Årsredovisning is an editable document (narrative + signatures) and lives - // on its own route under the year-end flow. Forward the active period so - // the page opens directly on the right fiscal year. - if (tab === 'arsredovisning') { - router.push( - selectedPeriod - ? `/bookkeeping/year-end/arsredovisning?period=${selectedPeriod}` - : '/bookkeeping/year-end/arsredovisning', - ) - return - } - // Manual tab change clears drill-down state - setActiveTab(tab) - setGlAccountFilter(null) - setDrillDownTrail([]) - }, [router, selectedPeriod]) - - const navigateBack = useCallback((stepIndex: number) => { - const step = drillDownTrail[stepIndex] - setActiveTab(step.tab) - setGlAccountFilter(null) - setDrillDownTrail(drillDownTrail.slice(0, stepIndex)) - }, [drillDownTrail]) - - // Period list is loaded by FiscalYearSelector; isLoadingInit flips to false - // via its onReady callback once the initial fetch completes. - - const isEnskildFirma = company?.entity_type === 'enskild_firma' - const isAktiebolag = company?.entity_type === 'aktiebolag' + pushRecent(slug) + router.push(`/reports/${slug}`) + } return (
-
-

{t('title')}

-
- -
- { - setSelectedPeriod(id || '') - setSelectedPeriodBounds( - period ? { start: period.period_start, end: period.period_end } : null, - ) - // Reset the range so the new period's stored preset re-resolves - // against the new bounds (avoids stale dates from the prior year). - setDateRange({}) - }} - includeAllOption={false} - hideFuturePeriods - onReady={() => setIsLoadingInit(false)} - /> - {DATE_RANGE_TABS.has(activeTab) && selectedPeriodBounds && ( - setSelectedPeriod(id || '')} + includeAllOption={false} + hideFuturePeriods + onReady={() => setIsLoadingInit(false)} /> - )} -
+ } + /> + {isLoadingInit ? (
-
- {[1, 2, 3, 4].map((i) => ( -
- -
- - - -
-
- ))} -
+ @@ -193,2643 +69,25 @@ export default function ReportsPage() {
- ) : ( - <> - - {selectedPeriod ? ( - <> - {/* Drill-down breadcrumb */} - {drillDownTrail.length > 0 && ( - - )} - -
- -
- {activeTab === 'resultatrapport' && ( - - )} - {activeTab === 'balansrapport' && ( - - )} - {activeTab === 'trial-balance' && ( - - )} - {activeTab === 'income-statement' && ( - - )} - {activeTab === 'balance-sheet' && ( - - )} - {activeTab === 'vat-declaration' && ( - - )} - {activeTab === 'periodisk-sammanstallning' && } - {isEnskildFirma && activeTab === 'ne-declaration' && ( - - )} - {isAktiebolag && activeTab === 'ink2-declaration' && ( - - )} - {activeTab === 'huvudbok' && ( - - )} - {activeTab === 'grundbok' && } - {activeTab === 'kundreskontra' && } - {activeTab === 'supplier-ledger' && } - {activeTab === 'bank-reconciliation' && } -
-
- - ) : ( - - - Inget räkenskapsår valt. Skapa ett räkenskapsår under Inställningar. - - - )} - - )} -
- ) -} - -function TrialBalanceView({ periodId, onNavigateToAccount }: { periodId: string; onNavigateToAccount: (account: string) => void }) { - const [data, setData] = useState<{ - rows: TrialBalanceRow[] - totalDebit: number - totalCredit: number - isBalanced: boolean - } | null>(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [viewMode, setViewMode] = useState<'simplified' | 'detailed'>('simplified') - - useEffect(() => { - setLoading(true) - setError(null) - fetch(`/api/reports/trial-balance?period_id=${periodId}`) - .then((res) => res.json()) - .then((result) => { - if (result.error) { - setError(result.error) - } else { - setData(result.data) - } - setLoading(false) - }) - .catch(() => { - setError('Kunde inte hämta saldobalans') - setLoading(false) - }) - }, [periodId]) - - if (loading) { - return ( - - - Laddar saldobalans... - - - ) - } - - if (error) { - return ( - - - - {error} - - - ) - } - - if (!data || data.rows.length === 0) { - return ( - - - Inga bokförda verifikationer i denna period. - - - ) - } - - function getNetBalance(row: TrialBalanceRow, type: 'opening' | 'period' | 'closing'): number { - let debit: number, credit: number - if (type === 'opening') { - debit = row.opening_debit; credit = row.opening_credit - } else if (type === 'period') { - debit = row.period_debit; credit = row.period_credit - } else { - debit = row.closing_debit; credit = row.closing_credit - } - // Credit-normal accounts (liabilities/equity class 2, revenue class 3): positive when credit > debit - // Debit-normal accounts (assets class 1, expenses class 4-9): positive when debit > credit - const creditNormal = row.account_class === 2 || row.account_class === 3 - return Math.round((creditNormal ? credit - debit : debit - credit) * 100) / 100 - } - - function formatSigned(amount: number): string { - if (amount === 0) return '' - return amount < 0 - ? `−${formatAmount(Math.abs(amount))}` - : formatAmount(amount) - } - - return ( -
-
- -
- - - -
- Saldobalans -
-
- - -
- {data.isBalanced ? ( - Balanserad - ) : ( - Ej balanserad - )} -
-
-
- -
- {viewMode === 'simplified' ? ( - - - - - - - - - - - - - {data.rows.map((row) => ( - - ))} - -
KontoNamnIngående saldoFörändringUtgående saldo
- ) : ( - - - - - - - - - - - - - - {data.rows.map((row) => ( - - ))} - - - - - - - - - - - -
KontoNamnPeriod debetPeriod kreditSaldo debetSaldo kredit
Summa - {formatAmount(data.rows.reduce((s, r) => s + r.period_debit, 0))} - - {formatAmount(data.rows.reduce((s, r) => s + r.period_credit, 0))} - - {formatAmount(data.totalDebit)} - - {formatAmount(data.totalCredit)} -
- )} -
-
-
-
- ) -} - -// Lazy fetcher for a TB account's source lines. Memoised at the row level so -// repeated toggling never refetches. -function makeTrialBalanceFetcher(accountNumber: string, periodId: string): ReportSourceFetcher { - return async () => { - const res = await fetch( - `/api/reports/trial-balance/account/${encodeURIComponent(accountNumber)}/sources?fiscal_period_id=${encodeURIComponent(periodId)}` - ) - const json = await res.json() - if (!res.ok) throw new Error(json.error || 'Kunde inte hämta verifikat') - const lines: ReportSourceLine[] = json.data?.lines || [] - return { lines, next_cursor: json.data?.next_cursor ?? null } - } -} - -function TrialBalanceSimplifiedRow({ - row, - periodId, - onNavigateToAccount, - getNetBalance, - formatSigned, -}: { - row: TrialBalanceRow - periodId: string - onNavigateToAccount: (account: string) => void - getNetBalance: (row: TrialBalanceRow, type: 'opening' | 'period' | 'closing') => number - formatSigned: (amount: number) => string -}) { - const fetcher = React.useMemo( - () => makeTrialBalanceFetcher(row.account_number, periodId), - [row.account_number, periodId] - ) - const { Toggle, Panel } = useReportRowExpansion(fetcher, `tb-${row.account_number}`) - - const ob = getNetBalance(row, 'opening') - const ch = getNetBalance(row, 'period') - const cb = getNetBalance(row, 'closing') - - return ( - <> - - e.stopPropagation()}> - - - onNavigateToAccount(row.account_number)} - > - - - onNavigateToAccount(row.account_number)} - > - {row.account_name} - - - {formatSigned(ob)} - - - {formatSigned(ch)} - - - {formatSigned(cb)} - - - - - ) -} - -function TrialBalanceDetailedRow({ - row, - periodId, - onNavigateToAccount, -}: { - row: TrialBalanceRow - periodId: string - onNavigateToAccount: (account: string) => void -}) { - const fetcher = React.useMemo( - () => makeTrialBalanceFetcher(row.account_number, periodId), - [row.account_number, periodId] - ) - const { Toggle, Panel } = useReportRowExpansion(fetcher, `tb-det-${row.account_number}`) - - return ( - <> - - e.stopPropagation()}> - - - onNavigateToAccount(row.account_number)} - > - - - onNavigateToAccount(row.account_number)} - > - {row.account_name} - - - {row.period_debit > 0 ? formatAmount(row.period_debit) : ''} - - - {row.period_credit > 0 ? formatAmount(row.period_credit) : ''} - - - {row.closing_debit > 0 ? formatAmount(row.closing_debit) : ''} - - - {row.closing_credit > 0 ? formatAmount(row.closing_credit) : ''} - - - - - ) -} -function IncomeStatementView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) { - const t = useTranslations('reports') - const [data, setData] = useState(null) - const [monthlyData, setMonthlyData] = useState([]) - const [monthlyLoading, setMonthlyLoading] = useState(false) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const reportQs = reportQuery(periodId, dateRange) - - useEffect(() => { - setLoading(true) - setError(null) - setMonthlyLoading(true) - - fetch(`/api/reports/income-statement?${reportQs}`) - .then((res) => res.json()) - .then((result) => { - if (result.error) { - setError(result.error) - } else { - setData(result.data) - } - setLoading(false) - }) - .catch(() => { - setError('Kunde inte hämta resultaträkning') - setLoading(false) - }) - - // Monthly breakdown is full-period by design (it IS the per-month view), - // so the date range only affects the headline numbers above the chart. - fetch(`/api/reports/monthly-breakdown?period_id=${periodId}`) - .then((res) => res.json()) - .then((result) => { - if (result.data?.months) { - setMonthlyData(result.data.months) - } - setMonthlyLoading(false) - }) - .catch(() => { - setMonthlyLoading(false) - }) - }, [periodId, reportQs]) - - if (loading) { - return ( - - - Laddar resultaträkning... - - - ) - } - - if (error) { - return ( - - - - {error} - - - ) - } - - if (!data) { - return ( - - - Ingen data för denna period. - - - ) - } - - return ( -
-
- - -
- - {!monthlyLoading && monthlyData.length > 0 && ( - - )} - - {/* Revenue */} - - - Rörelseintäkter - - - -
- Summa rörelseintäkter - {formatAmount(data.total_revenue)} kr -
-
-
- - {/* Expenses */} - - - Rörelsekostnader - - - -
- Summa rörelsekostnader - -{formatAmount(data.total_expenses)} kr -
-
-
- - {/* Operating result */} - - -
- Rörelseresultat - = 0 ? 'text-success' : 'text-destructive'}> - {formatAmount(data.total_revenue - data.total_expenses)} kr - -
-
-
- - {/* Financial items */} - {data.financial_sections.length > 0 && ( - - - Finansiella poster - - - -
- Summa finansiella poster - {formatAmount(data.total_financial)} kr -
-
-
- )} - - {/* Net result */} - - -
- Årets resultat - = 0 ? 'text-success' : 'text-destructive'}> - {formatAmount(data.net_result)} kr - -
-
-
-
- ) -} - -function BalanceSheetView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) { - const t = useTranslations('reports') - const [data, setData] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const reportQs = reportQuery(periodId, dateRange) - - useEffect(() => { - setLoading(true) - setError(null) - fetch(`/api/reports/balance-sheet?${reportQs}`) - .then((res) => res.json()) - .then((result) => { - if (result.error) { - setError(result.error) - } else { - setData(result.data) - } - setLoading(false) - }) - .catch(() => { - setError('Kunde inte hämta balansräkning') - setLoading(false) - }) - }, [periodId, reportQs]) - - if (loading) { - return ( - - - Laddar balansräkning... - - - ) - } - - if (error) { - return ( - - - - {error} - - - ) - } - - if (!data) { - return ( - - - Ingen data för denna period. - - - ) - } - - const isBalanced = Math.abs(data.total_assets - data.total_equity_liabilities) < 0.01 - - return ( -
-
- - -
- - {/* Assets */} - - - Tillgångar - - - -
- Summa tillgångar - {formatAmount(data.total_assets)} kr -
-
-
- - {/* Equity and liabilities */} - - - Eget kapital och skulder - - - -
- Summa eget kapital och skulder - {formatAmount(data.total_equity_liabilities)} kr -
-
-
- - {/* Balance check */} - - -
- Balanscheck - {isBalanced ? ( - - Balanserar - - ) : ( -
- - Balanserar ej - -

- Differens: {formatAmount(Math.abs(data.total_assets - data.total_equity_liabilities))} kr -

-
- )} -
-
-
-
- ) -} - -function ResultatrapportView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) { - const t = useTranslations('reports') - const [data, setData] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const reportQs = reportQuery(periodId, dateRange) - - useEffect(() => { - setLoading(true) - setError(null) - fetch(`/api/reports/resultatrapport?${reportQs}`) - .then((res) => res.json()) - .then((result) => { - if (result.error) { - setError(result.error) - } else { - setData(result.data) - } - setLoading(false) - }) - .catch(() => { - setError('Kunde inte hämta resultatrapport') - setLoading(false) - }) - }, [periodId, reportQs]) - - if (loading) { - return ( - - - Laddar resultatrapport... - - - ) - } - - if (error) { - return ( - - - - {error} - - - ) - } - - if (!data || data.groups.length === 0) { - return ( - - - Inga bokförda intäkter eller kostnader i denna period. - - - ) - } - - const hasPrior = data.prior_period !== null - const colCount = 4 - - return ( -
-
- - -
- - - -
- - - - - - - - - - - {data.groups.map((group) => ( - - - - - {group.rows.map((row) => ( - onNavigateToAccount(row.account_number)} - > - - - - - - ))} - - - - - - - ))} - -
KontoKontonamnInnevarandeFöregående
- {group.class_label} -
- - {row.account_name}{formatAmount(row.current_period)} - {hasPrior ? formatAmount(row.prior_period) : '—'} -
- Summa - {formatAmount(group.subtotal_current)} - {hasPrior ? formatAmount(group.subtotal_prior) : '—'} -
-
-
-
- - - -
- Beräknat resultat - = 0 ? 'text-success' : 'text-destructive'}`}> - {formatAmount(data.net_result_current)} kr - - - {hasPrior ? `${formatAmount(data.net_result_prior)} kr` : '—'} - -
-
-
-
- ) -} - -function BalansrapportView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) { - const t = useTranslations('reports') - const [data, setData] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const reportQs = reportQuery(periodId, dateRange) - - useEffect(() => { - setLoading(true) - setError(null) - fetch(`/api/reports/balansrapport?${reportQs}`) - .then((res) => res.json()) - .then((result) => { - if (result.error) { - setError(result.error) - } else { - setData(result.data) - } - setLoading(false) - }) - .catch(() => { - setError('Kunde inte hämta balansrapport') - setLoading(false) - }) - }, [periodId, reportQs]) - - if (loading) { - return ( - - - Laddar balansrapport... - - - ) - } - - if (error) { - return ( - - - - {error} - - - ) - } - - if (!data || data.groups.length === 0) { - return ( - - - Inga balansposter i denna period. - - - ) - } - - return ( -
-
- - -
- - - -
- - - - - - - - - - - - {data.groups.map((group) => ( - - - - - {group.rows.map((row) => ( - onNavigateToAccount(row.account_number)} - > - - - - - - - ))} - - - - - - - - ))} - -
KontoKontonamnIngående balansFörändringUtgående balans
- {group.class_label} -
- - {row.account_name}{formatAmount(row.ib)}{formatAmount(row.period_change)}{formatAmount(row.ub)}
- Summa - {formatAmount(group.subtotal_ib)} - {formatAmount(group.subtotal_ub - group.subtotal_ib)} - {formatAmount(group.subtotal_ub)}
-
-
-
- - - -
- Summa tillgångar - {formatAmount(data.total_assets_ub)} kr -
-
- Summa eget kapital, reserver, avsättningar och skulder - {formatAmount(data.total_equity_liabilities_ub)} kr -
-
- Beräknat resultat (ej bokslutsjusterat) - {formatAmount(data.beraknat_resultat)} kr -
-
- Balanscheck - {data.is_balanced ? ( - - Balanserar - - ) : ( - - Balanserar ej - - )} -
-
-
-
- ) -} - -function ReportSectionTable({ - sections, - negate, - onNavigateToAccount, -}: { - sections: { title: string; rows: { account_number: string; account_name: string; amount: number }[]; subtotal: number }[] - negate?: boolean - onNavigateToAccount?: (account: string) => void -}) { - if (sections.length === 0) { - return

Inga poster.

- } - - return ( -
- {sections.map((section) => ( -
-

{section.title}

-
- - {section.rows.map((row) => ( - onNavigateToAccount(row.account_number) : undefined} - > - - - - - ))} - -
{row.account_name} - {negate ? `-${formatAmount(row.amount)}` : formatAmount(row.amount)} kr -
-
- {section.title} - - {negate ? `-${formatAmount(section.subtotal)}` : formatAmount(section.subtotal)} kr - -
-
- ))} -
- ) -} - -// Carries the selected fiscal period into the ruta drill-down rows so their -// source-verifikat query matches the report's period. Only set for yearly -// (räkenskapsår); undefined for monthly/quarterly (calendar periods). -const VatDrillContext = React.createContext<{ fiscalPeriodId?: string }>({}) - -function VatDeclarationView({ - fiscalPeriodId, - fiscalPeriodBounds, -}: { - fiscalPeriodId: string - fiscalPeriodBounds: { start: string; end: string } | null -}) { - const currentYear = new Date().getFullYear() - const currentMonth = new Date().getMonth() + 1 - const currentQuarter = Math.ceil(currentMonth / 3) - - const [periodType, setPeriodType] = useState('quarterly') - const [year, setYear] = useState(currentYear) - const [period, setPeriod] = useState(currentQuarter) - const [data, setData] = useState(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - - // Default the periodicity to the company's configured VAT reporting period - // (moms_period in Inställningar) so the picker mirrors the setting instead of - // always starting on quarterly. Applied once per company the first time its - // settings load; a later manual change to the picker is preserved, and a - // company switch re-applies the new company's setting. `useSettings` only - // refetches when the active company changes, so this never clobbers a manual - // selection mid-session. - const { settings } = useSettings() - const appliedForCompany = useRef(null) - useEffect(() => { - const momsPeriod = settings?.moms_period - const companyId = settings?.company_id - if (!momsPeriod || !companyId) return - if (appliedForCompany.current === companyId) return - appliedForCompany.current = companyId - setPeriodType(momsPeriod) - // `period` is reset to a sensible value by the periodType effect below. - }, [settings]) - - // Generate year options (last 5 years) - const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i) - - // Generate period options based on type - const getPeriodOptions = () => { - switch (periodType) { - case 'monthly': - return [ - { value: 1, label: 'Januari' }, - { value: 2, label: 'Februari' }, - { value: 3, label: 'Mars' }, - { value: 4, label: 'April' }, - { value: 5, label: 'Maj' }, - { value: 6, label: 'Juni' }, - { value: 7, label: 'Juli' }, - { value: 8, label: 'Augusti' }, - { value: 9, label: 'September' }, - { value: 10, label: 'Oktober' }, - { value: 11, label: 'November' }, - { value: 12, label: 'December' }, - ] - case 'quarterly': - return [ - { value: 1, label: 'Kvartal 1 (jan-mar)' }, - { value: 2, label: 'Kvartal 2 (apr-jun)' }, - { value: 3, label: 'Kvartal 3 (jul-sep)' }, - { value: 4, label: 'Kvartal 4 (okt-dec)' }, - ] - case 'yearly': - return [{ value: 1, label: 'Helår' }] - default: - return [] - } - } - - // Reset period when type changes - useEffect(() => { - if (periodType === 'monthly') { - setPeriod(currentMonth) - } else if (periodType === 'quarterly') { - setPeriod(currentQuarter) - } else { - setPeriod(1) - } - }, [periodType, currentMonth, currentQuarter]) - - // Annual VAT (helårsmoms) is reported per räkenskapsår, not per calendar year. - // For yearly we pass the selected fiscal period so the API uses its actual - // bounds (handles extended/shortened years); monthly/quarterly stay calendar. - const isYearly = periodType === 'yearly' - const vatQueryString = () => { - const params = new URLSearchParams({ - periodType, - year: String(year), - period: String(period), - }) - if (isYearly && fiscalPeriodId) params.set('fiscal_period_id', fiscalPeriodId) - return params.toString() - } - - const fetchDeclaration = async () => { - setLoading(true) - setError(null) - try { - const res = await fetch( - `/api/reports/vat-declaration?${vatQueryString()}` - ) - const result = await res.json() - if (result.error) { - setError(result.error) - } else { - setData(result.data) - } - } catch { - setError('Kunde inte hämta momsdeklaration') - } finally { - setLoading(false) - } - } - - return ( - -
-
- - -
- {/* Period selection */} - - - Välj period - - -
-
- - -
- {isYearly ? ( - // Annual VAT covers the selected räkenskapsår — driven by the - // fiscal-year picker at the top of the page, not a calendar year. -
- -
- {fiscalPeriodBounds - ? `${formatDate(fiscalPeriodBounds.start)} – ${formatDate(fiscalPeriodBounds.end)}` - : '—'} -
-
- ) : ( - <> -
- - -
-
- - -
- - )} - -
-
-
- - {error && ( - - - - {error} - - - )} - - {data && ( - <> - - - {/* Summary */} - - -
- Momsdeklaration - {data.period.start} till {data.period.end} - 0 - ? 'warning' - : data.rutor.ruta49 < 0 - ? 'success' - : 'secondary' - } - > - {data.rutor.ruta49 > 0 - ? `Att betala: ${formatAmount(data.rutor.ruta49)} kr` - : data.rutor.ruta49 < 0 - ? `Att återfå: ${formatAmount(Math.abs(data.rutor.ruta49))} kr` - : 'Ingen moms'} - -
-
- -
- Baserat på {data.invoiceCount} fakturor och {data.transactionCount} transaktioner -
- -
- {/* Utgående moms */} -
-

Utgående moms (försäljning)

-
- - {data.rutor.ruta05 > 0 && ( - - )} - - - - - - - - - - - - -
Summa utgående - {formatAmount( - data.rutor.ruta10 + data.rutor.ruta11 + data.rutor.ruta12 + - data.rutor.ruta30 + data.rutor.ruta31 + data.rutor.ruta32 - )} kr -
- - {/* Omvänd skattskyldighet (inköp) */} - {(data.rutor.ruta20 > 0 || data.rutor.ruta21 > 0 || data.rutor.ruta22 > 0 || data.rutor.ruta23 > 0 || data.rutor.ruta24 > 0 || - data.rutor.ruta30 > 0 || data.rutor.ruta31 > 0 || data.rutor.ruta32 > 0) && ( - <> -

Omvänd skattskyldighet (inköp)

-
- - - - - - - - - - -
- - )} -
- - {/* Ingående moms */} -
-

Ingående moms (avdragsgill)

-
- - - {data.breakdown.transactions.ruta48 > 0 && ( - - - - - )} - {data.breakdown.receipts.ruta48 > 0 && ( - - - - - )} - - - - - - - -
- från transaktioner - {formatAmount(data.breakdown.transactions.ruta48)} kr -
- från kvitton - {formatAmount(data.breakdown.receipts.ruta48)} kr -
Summa ingående{formatAmount(data.rutor.ruta48)} kr
-
-
- - {/* Net result */} -
-
-
- 49 - - {data.rutor.ruta49 >= 0 ? 'Moms att betala' : 'Moms att återfå'} - -
- 0 - ? 'text-orange-600' - : data.rutor.ruta49 < 0 - ? 'text-success' - : '' - }`} - > - {formatAmount(Math.abs(data.rutor.ruta49))} kr - -
-
-
-
- - )} - - {/* Skatteverket integration panel */} - - - {!data && !loading && !error && ( - - - Välj period och klicka "Hämta" för att se momsdeklaration. - - - )} -
-
- ) -} - -function makeVatFetcher( - ruta: string, - periodType: VatPeriodType, - year: number, - period: number, - fiscalPeriodId?: string, -): ReportSourceFetcher { - return async () => { - const params = new URLSearchParams({ - periodType, - year: String(year), - period: String(period), - }) - // Yearly drill-down resolves against the räkenskapsår, matching the report. - if (periodType === 'yearly' && fiscalPeriodId) { - params.set('fiscal_period_id', fiscalPeriodId) - } - const res = await fetch( - `/api/reports/vat-declaration/ruta/${encodeURIComponent(ruta)}/sources?${params.toString()}` - ) - const json = await res.json() - if (!res.ok) throw new Error(json.error || 'Kunde inte hämta verifikat') - const lines: ReportSourceLine[] = json.data?.lines || [] - return { lines, next_cursor: json.data?.next_cursor ?? null } - } -} - -function VatRutaRow({ - ruta, - label, - amount, - baseAmount, - noVat, - periodType, - year, - period, -}: { - ruta: string - label: string - amount: number - baseAmount: number - noVat?: boolean - periodType?: VatPeriodType - year?: number - period?: number -}) { - const { fiscalPeriodId } = React.useContext(VatDrillContext) - const canDrill = periodType !== undefined && year !== undefined && period !== undefined - const fetcher = React.useMemo( - () => (canDrill ? makeVatFetcher(ruta, periodType!, year!, period!, fiscalPeriodId) : null), - [canDrill, ruta, periodType, year, period, fiscalPeriodId] - ) - // Hooks must be called unconditionally — provide a noop fetcher when drill - // is disabled. The early-return for zero rows lives below the hooks. - const expansion = useReportRowExpansion( - fetcher ?? (async () => ({ lines: [], next_cursor: null })), - `vat-${ruta}` - ) - - // Don't show rows with zero values - if (baseAmount === 0 && amount === 0) return null - - return ( - <> - - - {canDrill && ( - - - - )} - {ruta} - {label} - - {noVat ? `${formatAmount(baseAmount)} kr` : `${formatAmount(amount)} kr`} - - {!noVat && baseAmount > 0 && ( - - Underlag - {formatAmount(baseAmount)} kr - - )} - {canDrill && } - - ) -} - -interface SupplierLedgerData { - ledger: { - entries: { - supplier_id: string - supplier_name: string - current: number - days_1_30: number - days_31_60: number - days_61_90: number - days_90_plus: number - total_outstanding: number - }[] - total_outstanding: number - total_current: number - total_overdue: number - unpaid_count: number - unconverted_fx_count: number - } - reconciliation: { - supplier_ledger_total: number - account_2440_balance: number - difference: number - is_reconciled: boolean - unconverted_fx_count: number - } | null -} - -function SupplierLedgerView({ periodId }: { periodId: string }) { - const [data, setData] = useState(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - - const fetchData = async () => { - setLoading(true) - setError(null) - try { - const res = await fetch(`/api/reports/supplier-ledger?period_id=${periodId}`) - const result = await res.json() - if (result.error) { - setError(result.error) - } else { - setData(result.data) - } - } catch { - setError('Kunde inte hämta leverantörsreskontra') - } finally { - setLoading(false) - } - } - - useEffect(() => { - if (periodId) fetchData() - }, [periodId]) - - if (loading) { - return ( - - - Laddar leverantörsreskontra... - - - ) - } - - if (error) { - return ( - - - - {error} - - - ) - } - - if (!data || !data.ledger) { - return ( - - - Ingen data tillgänglig. - - - ) - } - - const { ledger, reconciliation } = data - - return ( -
-
- -
- {/* Summary cards */} -
- - - Totalt utestående - - -

{formatAmount(ledger.total_outstanding)} kr

-

{ledger.unpaid_count} fakturor

- {ledger.unconverted_fx_count > 0 && ( -

- {ledger.unconverted_fx_count} faktura i utländsk valuta utan växelkurs är inte med i totalen. -

- )} -
-
- - - Ej förfallet - - -

{formatAmount(ledger.total_current)} kr

-
-
- - - Förfallet - - -

{formatAmount(ledger.total_overdue)} kr

-
-
-
- - {/* Aging table */} - {ledger.entries.length > 0 && ( - - - Ålderfördelning per leverantör - - -
- - - - - - - - - - - - - - {ledger.entries.map((entry) => ( - - ))} - - - - - - - - - - - - - -
LeverantörEj förfallet1-30 dagar31-60 dagar61-90 dagar90+ dagarTotalt
Summa{formatAmount(ledger.entries.reduce((s, e) => s + e.current, 0))}{formatAmount(ledger.entries.reduce((s, e) => s + e.days_1_30, 0))}{formatAmount(ledger.entries.reduce((s, e) => s + e.days_31_60, 0))}{formatAmount(ledger.entries.reduce((s, e) => s + e.days_61_90, 0))}{formatAmount(ledger.entries.reduce((s, e) => s + e.days_90_plus, 0))}{formatAmount(ledger.total_outstanding)}
-
-
- )} - - {/* Reconciliation */} - {reconciliation && ( - - - Avstämning mot - - -
-
- Leverantörsreskontra (summa utestående) - {formatAmount(reconciliation.supplier_ledger_total)} kr -
-
- saldo (huvudbok) - {formatAmount(reconciliation.account_2440_balance)} kr -
-
- Differens - - {formatAmount(reconciliation.difference)} kr - -
-
- {reconciliation.is_reconciled ? ( - Avstämd - ) : ( - Ej avstämd - kontrollera bokföring - )} - {reconciliation.unconverted_fx_count > 0 && ( -

- {reconciliation.unconverted_fx_count} leverantörsfaktura i utländsk valuta saknar växelkurs — differensen kan bero på saknade kursuppgifter snarare än felbokning. -

- )} -
-
-
-
- )} -
- ) -} - -function makeSupplierFetcher(supplierId: string): ReportSourceFetcher { - return async () => { - const res = await fetch( - `/api/reports/supplier-ledger/supplier/${encodeURIComponent(supplierId)}/invoices` - ) - const json = await res.json() - if (!res.ok) throw new Error(json.error || 'Kunde inte hämta leverantörsfakturor') - const lines: ReportSourceLine[] = json.data?.lines || [] - return { lines, next_cursor: json.data?.next_cursor ?? null } - } -} - -function SupplierLedgerRow({ - entry, -}: { - entry: { - supplier_id: string - supplier_name: string - current: number - days_1_30: number - days_31_60: number - days_61_90: number - days_90_plus: number - total_outstanding: number - } -}) { - const fetcher = React.useMemo( - () => makeSupplierFetcher(entry.supplier_id), - [entry.supplier_id] - ) - const { Toggle, Panel } = useReportRowExpansion(fetcher, `sup-${entry.supplier_id}`) - return ( - <> - - - {entry.supplier_name} - {entry.current > 0 ? formatAmount(entry.current) : ''} - {entry.days_1_30 > 0 ? formatAmount(entry.days_1_30) : ''} - {entry.days_31_60 > 0 ? formatAmount(entry.days_31_60) : ''} - {entry.days_61_90 > 0 ? formatAmount(entry.days_61_90) : ''} - {entry.days_90_plus > 0 ? formatAmount(entry.days_90_plus) : ''} - {formatAmount(entry.total_outstanding)} - - - - ) -} - -// --- General Ledger (Huvudbok) --- - -interface GeneralLedgerData { - accounts: { - account_number: string - account_name: string - opening_balance: number - lines: { - date: string - voucher_series: string - voucher_number: number - journal_entry_id: string - description: string - source_type: string - debit: number - credit: number - balance: number - }[] - closing_balance: number - total_debit: number - total_credit: number - }[] - period: { start: string; end: string } -} - -function GeneralLedgerView({ periodId, initialAccountFilter }: { periodId: string; initialAccountFilter: string | null }) { - const [data, setData] = useState(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - const [accountFrom, setAccountFrom] = useState('') - const [accountTo, setAccountTo] = useState('') - - const fetchData = useCallback(async (fromOverride?: string, toOverride?: string) => { - const from = fromOverride ?? accountFrom - const to = toOverride ?? accountTo - setLoading(true) - setError(null) - try { - const params = new URLSearchParams({ period_id: periodId }) - if (from) params.set('account_from', from) - if (to) params.set('account_to', to) - const res = await fetch(`/api/reports/general-ledger?${params}`) - const result = await res.json() - if (result.error) { - setError(result.error) - } else { - setData(result.data) - } - } catch { - setError('Kunde inte hämta huvudbok') - } finally { - setLoading(false) - } - }, [periodId, accountFrom, accountTo]) - - // When initialAccountFilter changes (drill-down from another report), apply it - useEffect(() => { - if (initialAccountFilter) { - setAccountFrom(initialAccountFilter) - setAccountTo(initialAccountFilter) - fetchData(initialAccountFilter, initialAccountFilter) - } else { - fetchData() - } - }, [periodId, initialAccountFilter]) - - if (loading) { - return ( - - - Laddar huvudbok... - - - ) - } - - if (error) { - return ( - - - - {error} - - - ) - } - - if (!data || data.accounts.length === 0) { - return ( - - - Inga bokförda verifikationer i denna period. - - - ) - } - - return ( -
-
- -
- {/* Account range filter */} - - -
-
- - setAccountFrom(e.target.value)} - placeholder="t.ex. 1510" - className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm" - /> -
-
- - setAccountTo(e.target.value)} - placeholder="t.ex. 1519" - className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm" - /> -
- -
-
-
- - {data.period.start && ( -

- Period: {data.period.start} — {data.period.end} | {data.accounts.length} konton -

- )} - - {data.accounts.map((account) => ( - - -
- - - - - IB: {formatAmount(account.opening_balance)} kr - -
-
- -
- - - - - - - - - - - - {account.lines.map((line, i) => ( - - - - - - - - - ))} - - - - - - - - - -
Ver.nrDatumBeskrivningDebetKreditSaldo
- - {formatVoucher(line)} - - {line.date}{line.description} - {line.debit > 0 ? formatAmount(line.debit) : ''} - - {line.credit > 0 ? formatAmount(line.credit) : ''} - {formatAmount(line.balance)}
Summa / Utgående balans{formatAmount(account.total_debit)}{formatAmount(account.total_credit)}{formatAmount(account.closing_balance)}
-
-
- ))} -
- ) -} - -// --- Journal Register (Grundbok) --- - -interface JournalRegisterData { - entries: { - voucher_series: string - voucher_number: number - date: string - description: string - source_type: string - status: string - lines: { - account_number: string - account_name: string - debit: number - credit: number - }[] - total_debit: number - total_credit: number - }[] - total_entries: number - total_debit: number - total_credit: number - period: { start: string; end: string } -} - -function JournalRegisterView({ periodId }: { periodId: string }) { - const [data, setData] = useState(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - const [expandedEntries, setExpandedEntries] = useState>(new Set()) - - const fetchData = async () => { - setLoading(true) - setError(null) - setExpandedEntries(new Set()) - try { - const res = await fetch(`/api/reports/journal-register?period_id=${periodId}`) - const result = await res.json() - if (result.error) { - setError(result.error) - } else { - setData(result.data) - } - } catch { - setError('Kunde inte hämta grundbok') - } finally { - setLoading(false) - } - } - - useEffect(() => { - if (periodId) fetchData() - }, [periodId]) - - const toggleEntry = (index: number) => { - setExpandedEntries((prev) => { - const next = new Set(prev) - if (next.has(index)) { - next.delete(index) - } else { - next.add(index) - } - return next - }) - } - - if (loading) { - return ( - - - Laddar grundbok... - - - ) - } - - if (error) { - return ( - - - - {error} - - - ) - } - - if (!data || data.entries.length === 0) { - return ( - - - Inga bokförda verifikationer i denna period. - - - ) - } - - return ( -
-
- -
- {data.period.start && ( -

- Period: {data.period.start} — {data.period.end} | {data.total_entries} verifikationer -

- )} - - - - Grundbok (registreringsordning) - - -
- - - - - - - - - - - - - {data.entries.map((entry, index) => { - const isExpanded = expandedEntries.has(index) - const isReversed = entry.status === 'reversed' - - return ( - - toggleEntry(index)} - > - - - - - - - - - {isExpanded && entry.lines.map((line, lineIndex) => ( - - - - - - - - - - ))} - - ) - })} - - - - - - - - -
Ver.nrDatumBeskrivningTypDebetKredit
- {isExpanded ? ( - - ) : ( - - )} - - {formatVoucher(entry)} - {entry.date} - {entry.description} - {isReversed && ( - Makulerad - )} - {entry.source_type}{formatAmount(entry.total_debit)}{formatAmount(entry.total_credit)}
{line.account_name} - {line.debit > 0 ? formatAmount(line.debit) : ''} - - {line.credit > 0 ? formatAmount(line.credit) : ''} -
Summa{formatAmount(data.total_debit)}{formatAmount(data.total_credit)}
-
-
-
- ) -} - -// --- AR Ledger (Kundreskontra) --- - -interface ARLedgerData { - ledger: { - entries: { - customer_id: string - customer_name: string - invoices: { - invoice_id: string - invoice_number: string - invoice_date: string - due_date: string - total: number - paid_amount: number - outstanding: number - outstanding_sek: number | null - days_overdue: number - currency: string - }[] - current: number - days_1_30: number - days_31_60: number - days_61_90: number - days_90_plus: number - total_outstanding: number - }[] - total_outstanding: number - total_current: number - total_overdue: number - unpaid_count: number - unconverted_fx_count: number - } - reconciliation: { - ar_ledger_total: number - account_1510_balance: number - difference: number - is_reconciled: boolean - unconverted_fx_count: number - } | null -} - -// Inner expansion row component for AR ledger. -// Fetches per-customer invoices (with journal_entry_id) and renders each as a -// link to /bookkeeping/[id] when posted, /invoices/[id] when still draft. -function ARCustomerInvoiceRows({ - customerId, - invoices, -}: { - customerId: string - invoices: { - invoice_id: string - invoice_number: string - invoice_date: string - due_date: string - total: number - paid_amount: number - outstanding: number - outstanding_sek: number | null - days_overdue: number - currency: string - }[] -}) { - // ARCustomerInvoiceRows is mounted lazily — only when a customer is - // expanded, so initial state matches "still loading" and resets on - // unmount. No synchronous setState in the effect is needed. - const [enriched, setEnriched] = useState>({}) - const [loaded, setLoaded] = useState(false) - - useEffect(() => { - let cancelled = false - fetch(`/api/reports/ar-ledger/customer/${encodeURIComponent(customerId)}/invoices`) - .then((r) => r.json()) - .then((json) => { - if (cancelled) return - const map: typeof enriched = {} - for (const line of json.data?.lines || []) { - if (line.invoice_id && line.journal_entry_id) { - map[line.invoice_id] = { - journal_entry_id: line.journal_entry_id, - voucher_series: line.voucher_series, - voucher_number: line.voucher_number, - } - } - } - setEnriched(map) - }) - .catch(() => { /* fail silently; rows still render without verifikat link */ }) - .finally(() => { if (!cancelled) setLoaded(true) }) - return () => { cancelled = true } - }, [customerId]) - const loading = !loaded - - return ( - <> - {invoices.map((inv) => { - const entry = enriched[inv.invoice_id] - const targetHref = entry?.journal_entry_id - ? `/bookkeeping/${entry.journal_entry_id}` - : `/invoices/${inv.invoice_id}` - return ( - - - - - {inv.invoice_number || '(utkast)'} - - {entry && ( - - {formatVoucher(entry)} - - )} - {formatDate(inv.invoice_date)} - förfaller {formatDate(inv.due_date)} - - - {inv.days_overdue > 0 ? `${inv.days_overdue} dagar förfallen` : 'Ej förfallen'} - - - {inv.paid_amount > 0 ? `Betalt: ${formatAmount(inv.paid_amount)}` : ''} - - - - {formatAmount(inv.outstanding)} {inv.currency} - - - ) - })} - {loading && ( - - - Letar verifikat… - - )} - - ) -} - -function ARLedgerView({ periodId }: { periodId: string }) { - const [data, setData] = useState(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - const [expandedCustomers, setExpandedCustomers] = useState>(new Set()) - - const fetchData = async () => { - setLoading(true) - setError(null) - try { - const res = await fetch(`/api/reports/ar-ledger?period_id=${periodId}`) - const result = await res.json() - if (result.error) { - setError(result.error) - } else { - setData(result.data) - } - } catch { - setError('Kunde inte hämta kundreskontra') - } finally { - setLoading(false) - } - } - - useEffect(() => { - if (periodId) fetchData() - }, [periodId]) - - const toggleCustomer = (customerId: string) => { - setExpandedCustomers((prev) => { - const next = new Set(prev) - if (next.has(customerId)) { - next.delete(customerId) - } else { - next.add(customerId) - } - return next - }) - } - - if (loading) { - return ( - - - Laddar kundreskontra... - - - ) - } - - if (error) { - return ( - - - - {error} - - - ) - } - - if (!data || !data.ledger) { - return ( - - - Ingen data tillgänglig. - - - ) - } - - const { ledger, reconciliation } = data - - return ( -
-
- -
- {/* Summary cards */} -
- - - Totalt utestående - - -

{formatAmount(ledger.total_outstanding)} kr

-

{ledger.unpaid_count} fakturor

- {ledger.unconverted_fx_count > 0 && ( -

- {ledger.unconverted_fx_count} faktura i utländsk valuta utan växelkurs är inte med i totalen. -

- )} -
-
- - - Ej förfallet - - -

{formatAmount(ledger.total_current)} kr

-
-
- - - Förfallet - - -

{formatAmount(ledger.total_overdue)} kr

-
-
-
- - {/* Aging table with expandable invoice details */} - {ledger.entries.length > 0 && ( - - - Ålderfördelning per kund - - -
- - - - - - - - - - - - - - {ledger.entries.map((entry) => { - const isExpanded = expandedCustomers.has(entry.customer_id) - return ( - - toggleCustomer(entry.customer_id)} - > - - - - - - - - - - {isExpanded && ( - - )} - - ) - })} - - - - - - - - - - - - - -
KundEj förfallet1-30 dagar31-60 dagar61-90 dagar90+ dagarTotalt
- {isExpanded ? ( - - ) : ( - - )} - {entry.customer_name}{entry.current > 0 ? formatAmount(entry.current) : ''}{entry.days_1_30 > 0 ? formatAmount(entry.days_1_30) : ''}{entry.days_31_60 > 0 ? formatAmount(entry.days_31_60) : ''}{entry.days_61_90 > 0 ? formatAmount(entry.days_61_90) : ''}{entry.days_90_plus > 0 ? formatAmount(entry.days_90_plus) : ''}{formatAmount(entry.total_outstanding)}
Summa{formatAmount(ledger.entries.reduce((s, e) => s + e.current, 0))}{formatAmount(ledger.entries.reduce((s, e) => s + e.days_1_30, 0))}{formatAmount(ledger.entries.reduce((s, e) => s + e.days_31_60, 0))}{formatAmount(ledger.entries.reduce((s, e) => s + e.days_61_90, 0))}{formatAmount(ledger.entries.reduce((s, e) => s + e.days_90_plus, 0))}{formatAmount(ledger.total_outstanding)}
-
-
- )} - - {/* Reconciliation */} - {reconciliation && ( - - - Avstämning mot - - -
-
- Kundreskontra (summa utestående) - {formatAmount(reconciliation.ar_ledger_total)} kr -
-
- Kundfordringar ( + ) saldo - {formatAmount(reconciliation.account_1510_balance)} kr -
-
- Differens - - {formatAmount(reconciliation.difference)} kr - -
-
- {reconciliation.is_reconciled ? ( - Avstämd - ) : ( - Ej avstämd - kontrollera bokföring - )} - {reconciliation.unconverted_fx_count > 0 && ( -

- {reconciliation.unconverted_fx_count} kundfaktura i utländsk valuta saknar växelkurs — differensen kan bero på saknade kursuppgifter snarare än felbokning. -

- )} -
-
-
-
+ ) : ( +
+ + +
)}
) diff --git a/app/(dashboard)/settings/account/page.tsx b/app/(dashboard)/settings/account/page.tsx index 3dfa760e..69fc1d18 100644 --- a/app/(dashboard)/settings/account/page.tsx +++ b/app/(dashboard)/settings/account/page.tsx @@ -1,195 +1,5 @@ -'use client' - -import { useState, useEffect } from 'react' -import { useRouter } from 'next/navigation' -import Link from 'next/link' -import { useLocale, useTranslations } from 'next-intl' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { Sun, Moon, Monitor, LogOut, Languages, ExternalLink } from 'lucide-react' -import { useTheme } from 'next-themes' -import { createClient } from '@/lib/supabase/client' -import { SecuritySettings } from '@/components/settings/SecuritySettings' -import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings' -import { AccountDangerZone } from '@/components/settings/AccountDangerZone' -import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' -import { useSettings } from '@/components/settings/useSettings' -import { clearRecaptIdentity } from '@/lib/recapt' -import { useToast } from '@/components/ui/use-toast' -import { SUPPORTED_LOCALES, type Locale } from '@/i18n/config' +import { AccountSettingsContent } from '@/components/settings/sections/AccountSettingsContent' export default function AccountSettingsPage() { - const router = useRouter() - const supabase = createClient() - const { theme, setTheme } = useTheme() - const [mounted, setMounted] = useState(false) - const hasCalendarExtension = ENABLED_EXTENSION_IDS.has('calendar') - const { settings } = useSettings() - const { toast } = useToast() - const activeLocale = useLocale() as Locale - const tCommon = useTranslations('common') - const tSettings = useTranslations('settings') - const [savingLocale, setSavingLocale] = useState(false) - - useEffect(() => { setMounted(true) }, []) - - async function handleLogout() { - clearRecaptIdentity() - await supabase.auth.signOut() - router.push('/login') - } - - async function handleLocaleChange(next: Locale) { - if (next === activeLocale || savingLocale) return - setSavingLocale(true) - try { - const res = await fetch('/api/user/locale', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ locale: next }), - }) - if (!res.ok) throw new Error('Could not save') - toast({ title: tSettings('language_saved') }) - router.refresh() - } catch { - toast({ - title: tSettings('language_save_failed'), - variant: 'destructive', - }) - } finally { - setSavingLocale(false) - } - } - - const localeLabels: Record = { - sv: tCommon('language_swedish'), - en: tCommon('language_english'), - } - - return ( -
- {/* Appearance */} -
-

- {tSettings('section_appearance')} -

- {mounted && ( -
- {([ - { 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 }) => ( - - ))} -
- )} -
- - {/* Language */} -
-

- {tSettings('section_language')} -

-

- {tSettings('language_description')} -

-
- {SUPPORTED_LOCALES.map((value) => ( - - ))} -
-
- - {/* Security */} -
- -
- - {/* Calendar feed */} - {hasCalendarExtension && ( -
- -
- )} - - {/* Logout */} -
- - - {tCommon('account_settings')} - - -
-
-

{tCommon('logout')}

-

{tCommon('logout_description')}

-
- -
-
-
-
- - {/* Privacy & agreements — surface the otherwise-unlinked DPA + privacy policy */} -
- - - {tSettings('legal_title')} - - - - {tSettings('legal_privacy')} - - - - {tSettings('legal_dpa')} - - - - -
- - {/* Delete account — only for non-sandbox */} - {!settings?.is_sandbox && } -
- ) + return } diff --git a/app/(dashboard)/settings/api/page.tsx b/app/(dashboard)/settings/api/page.tsx index 9df84ae3..34783b58 100644 --- a/app/(dashboard)/settings/api/page.tsx +++ b/app/(dashboard)/settings/api/page.tsx @@ -1,13 +1,5 @@ -'use client' - -import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel' -import { OAuthClientsPanel } from '@/components/settings/OAuthClientsPanel' +import { ApiSettingsContent } from '@/components/settings/sections/ApiSettingsContent' export default function ApiSettingsPage() { - return ( -
- - -
- ) + return } diff --git a/app/(dashboard)/settings/assistant/page.tsx b/app/(dashboard)/settings/assistant/page.tsx index 0b6e0cb5..ffee82ee 100644 --- a/app/(dashboard)/settings/assistant/page.tsx +++ b/app/(dashboard)/settings/assistant/page.tsx @@ -1,43 +1,5 @@ -'use client' - -import { useSearchParams, useRouter } from 'next/navigation' -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' -import { AgentMemoryPanel } from '@/components/settings/AgentMemoryPanel' -import { AgentSkillsPanel } from '@/components/settings/AgentSkillsPanel' - -// "Assistenten" — what the assistant remembers about this company (Minne, -// editable) and the domain knowledge it ships with (Kompetens, read-only). -// A toggle keeps both one click away instead of stacked, so the competence -// view isn't buried below the memory list. -type View = 'memory' | 'skills' +import { AssistantSettingsContent } from '@/components/settings/sections/AssistantSettingsContent' export default function AssistantSettingsPage() { - const searchParams = useSearchParams() - const router = useRouter() - const view: View = searchParams.get('view') === 'skills' ? 'skills' : 'memory' - - function setView(next: string) { - // 'memory' is the default — keep its URL clean (no query string). - router.replace(next === 'skills' ? '/settings/assistant?view=skills' : '/settings/assistant', { - scroll: false, - }) - } - - return ( - - - Minne - Kompetens - - - {/* Radix unmounts the inactive panel, so each panel's data is fetched - lazily the first time its tab is opened. */} - - - - - - - - ) + return } diff --git a/app/(dashboard)/settings/banking/page.tsx b/app/(dashboard)/settings/banking/page.tsx index 54b69561..da2660c2 100644 --- a/app/(dashboard)/settings/banking/page.tsx +++ b/app/(dashboard)/settings/banking/page.tsx @@ -1,171 +1,5 @@ -'use client' - -import { useState, useEffect, useRef } from 'react' -import { useTranslations } from 'next-intl' -import { useSearchParams, useRouter } from 'next/navigation' -import Link from 'next/link' -import { Card, CardContent } from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { useToast } from '@/components/ui/use-toast' -import { AlertTriangle, CreditCard, ExternalLink } from 'lucide-react' -import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry' -import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' -import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip' - -const BankingPanel = getSettingsPanel('enable-banking') +import { BankingSettingsContent } from '@/components/settings/sections/BankingSettingsContent' export default function BankingSettingsPage() { - const t = useTranslations('settings_banking') - const searchParams = useSearchParams() - const router = useRouter() - const { toast } = useToast() - const [bankConnectionError, setBankConnectionError] = useState(null) - const [failedBankName, setFailedBankName] = useState(null) - const [isAccessDenied, setIsAccessDenied] = useState(false) - const syncInitiatedRef = useRef(false) - const abortControllerRef = useRef(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 ( -
- {bankConnectionError && ( -
- -
-

{bankConnectionError}

- {isAccessDenied && failedBankName && ( -

- {t('access_denied_hint', { bankName: failedBankName })} -

- )} -

- {t('import_fallback_text')}{t('import_fallback_link')}{t('import_fallback_suffix')} -

-
- -
- )} - - {hasBankingExtension && BankingPanel ? ( - <> - - - - ) : ( - - - -

{t('not_enabled_title')}

-

- {t('not_enabled_description')} -

- -
-
- )} -
- ) + return } diff --git a/app/(dashboard)/settings/bookkeeping/page.tsx b/app/(dashboard)/settings/bookkeeping/page.tsx index 41cd37a9..d3db6f24 100644 --- a/app/(dashboard)/settings/bookkeeping/page.tsx +++ b/app/(dashboard)/settings/bookkeeping/page.tsx @@ -1,164 +1,5 @@ -'use client' - -import Link from 'next/link' -import { useState } from 'react' -import { useTranslations } from 'next-intl' -import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper' -import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton' -import { PeriodLockingSettings } from '@/components/settings/PeriodLockingSettings' -import { VoucherSeriesManager } from '@/components/settings/VoucherSeriesManager' -import { VoucherSeriesPerSourceTypeForm } from '@/components/settings/VoucherSeriesPerSourceTypeForm' -import { PeriodiseringAutoDetectToggle } from '@/components/settings/PeriodiseringAutoDetectToggle' -import { AccountingFrameworkForm } from '@/components/settings/AccountingFrameworkForm' -import { useSettings } from '@/components/settings/useSettings' -import { useCompany } from '@/contexts/CompanyContext' -import { Label } from '@/components/ui/label' -import { ExternalLink } from 'lucide-react' -import type { AccountingFramework, CompanySettings } from '@/types' - -const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') +import { BookkeepingSettingsContent } from '@/components/settings/sections/BookkeepingSettingsContent' export default function BookkeepingSettingsPage() { - const t = useTranslations('settings_bookkeeping') - const { settings, isLoading, updateSettings } = useSettings() - const { company } = useCompany() - // Local mirror of the company-level accounting_framework so the K2/K3 - // selector can reflect its own saves without waiting for the layout to - // re-render through the server. Falls back to k2 (matches the column - // default) until the company row is loaded. - const [framework, setFramework] = useState( - company?.accounting_framework ?? 'k2', - ) - - if (isLoading || !settings) return - - 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 = { - 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) => { - updateSettings(data as Partial) - }, - } - } - - // 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 ( -
- {isAktiebolag && ( - setFramework(next)} - /> - )} - - {/* Accounting method */} -
-

- {t('method_heading')} -

-
- - -

- {t('method_help')} -

-
-
- - {/* Default voucher series */} -
-
-

- {t('series_heading')} -

-
- - -

- {t('series_help')} -

-
-
-
- - {/* Period locking */} -
- -
-
- - {/* Voucher series — per-source-type mapping */} -
- -
- - {/* Voucher series — read-only display */} -
- -
- - {/* Periodisering auto-detect toggle */} -
- -
- - {/* Cross-links */} -
-

- {t('related_heading')} -

-
- - - {t('related_fiscal_year')} - - - - {t('related_chart_of_accounts')} - -
-
-
- ) + return } diff --git a/app/(dashboard)/settings/company/page.tsx b/app/(dashboard)/settings/company/page.tsx index d2f00364..daa01ddd 100644 --- a/app/(dashboard)/settings/company/page.tsx +++ b/app/(dashboard)/settings/company/page.tsx @@ -1,69 +1,5 @@ -'use client' - -import { useRouter } from 'next/navigation' -import { CompanyDangerZone } from '@/components/settings/CompanyDangerZone' -import { CompanyInfoForm } from '@/components/settings/CompanyInfoForm' -import { CompanyMembersSection } from '@/components/settings/CompanyMembersSection' -import { CompanyProfileSection } from '@/components/settings/CompanyProfileSection' -import { FiscalPeriodEditor } from '@/components/settings/FiscalPeriodEditor' -import { LogoUpload } from '@/components/settings/LogoUpload' -import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper' -import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton' -import { useSettings } from '@/components/settings/useSettings' -import type { CompanySettings } from '@/types' +import { CompanySettingsContent } from '@/components/settings/sections/CompanySettingsContent' export default function CompanySettingsPage() { - const router = useRouter() - const { settings, isLoading, updateSettings } = useSettings() - - if (isLoading || !settings) return - - function handleSave(formData: FormData) { - const updates: Record = { - ...(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) => { - updateSettings(data as Partial) - // 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 ( -
- - - - -
- updateSettings({ logo_url: url })} - /> -
- -
- -
- - - - - - -
- ) + return } diff --git a/app/(dashboard)/settings/invoicing/page.tsx b/app/(dashboard)/settings/invoicing/page.tsx index 425b9204..3f576fb5 100644 --- a/app/(dashboard)/settings/invoicing/page.tsx +++ b/app/(dashboard)/settings/invoicing/page.tsx @@ -1,71 +1,5 @@ -'use client' - -import { useTranslations } from 'next-intl' -import { BankDetailsForm, validateBankFields } from '@/components/settings/BankDetailsForm' -import { InvoiceSettingsForm } from '@/components/settings/InvoiceSettingsForm' -import { InvoicePreviewCard } from '@/components/settings/InvoicePreviewCard' -import { PdfPrintSettings } from '@/components/settings/PdfPrintSettings' -import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper' -import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton' -import { useSettings } from '@/components/settings/useSettings' -import { useToast } from '@/components/ui/use-toast' -import { normaliseSwish } from '@/lib/payments/swish' -import type { CompanySettings } from '@/types' +import { InvoicingSettingsContent } from '@/components/settings/sections/InvoicingSettingsContent' export default function InvoicingSettingsPage() { - const t = useTranslations('settings_invoicing') - const { settings, isLoading, updateSettings } = useSettings() - const { toast } = useToast() - - if (isLoading || !settings) return - - 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 = { - 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) => { - updateSettings(data as Partial) - }, - } - } - - return ( -
-
- -
- - - -
- -
-
- - {/* PDF settings — saves individually via toggle switches */} -
- -
-
- ) + return } diff --git a/app/(dashboard)/settings/layout.tsx b/app/(dashboard)/settings/layout.tsx index b7ce519a..17fcd865 100644 --- a/app/(dashboard)/settings/layout.tsx +++ b/app/(dashboard)/settings/layout.tsx @@ -1,10 +1,9 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect } from 'react' import { useSearchParams, useRouter } from 'next/navigation' -import { SettingsNav } from '@/components/settings/SettingsSidebar' -import { useCompany } from '@/contexts/CompanyContext' -import { createClient } from '@/lib/supabase/client' +import { useTranslations } from 'next-intl' +import { SettingsShell } from '@/components/settings/SettingsShell' const TAB_TO_ROUTE: Record = { company: '/settings/company', @@ -23,22 +22,7 @@ const TAB_TO_ROUTE: Record = { export default function SettingsLayout({ children }: { children: React.ReactNode }) { const searchParams = useSearchParams() const router = useRouter() - const { company } = useCompany() - const [isSandbox, setIsSandbox] = useState(false) - - // Fetch sandbox status - useEffect(() => { - if (!company?.id) return - const supabase = createClient() - supabase - .from('company_settings') - .select('is_sandbox') - .eq('company_id', company.id) - .single() - .then(({ data }) => { - if (data?.is_sandbox) setIsSandbox(true) - }) - }, [company?.id]) + const t = useTranslations('settings_nav') // Handle legacy ?tab= URLs useEffect(() => { @@ -49,17 +33,9 @@ export default function SettingsLayout({ children }: { children: React.ReactNode }, [searchParams, router]) return ( -
-
-

Inställningar

-

- Hantera ditt företag och konto -

-
- - - -
{children}
+
+

{t('aria_label')}

+ {children}
) } diff --git a/app/(dashboard)/settings/salary/page.tsx b/app/(dashboard)/settings/salary/page.tsx index aa622854..ace2c779 100644 --- a/app/(dashboard)/settings/salary/page.tsx +++ b/app/(dashboard)/settings/salary/page.tsx @@ -1,87 +1,5 @@ -'use client' - -import { useTranslations } from 'next-intl' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { PageHeader } from '@/components/ui/page-header' -import { TaxTableStatus } from '@/components/salary/TaxTableStatus' +import { SalarySettingsContent } from '@/components/settings/sections/SalarySettingsContent' export default function SalarySettingsPage() { - const t = useTranslations('settings_salary') - return ( -
- - - - - {t('accounting_heading')} - - -
- - -

- {t('voucher_series_help')} -

-
-
-
- - - - {t('tax_tables_heading')} - - - -

- {t('tax_tables_help')} -

-
-
- - - - {t('vacation_heading')} - - -
- - -
-
- - -

- {t('vacation_supplement_help')} -

-
-
-
- - - - {t('info_heading')} - - -
-

{t('info_payroll_scope')}

-

- {t.rich('info_current_year', { - strong: (chunks) => {chunks}, - })} -

-
-
-
-
- ) + return } diff --git a/app/(dashboard)/settings/tax/page.tsx b/app/(dashboard)/settings/tax/page.tsx index 6e71bad8..fe394f26 100644 --- a/app/(dashboard)/settings/tax/page.tsx +++ b/app/(dashboard)/settings/tax/page.tsx @@ -1,102 +1,5 @@ -'use client' - -import { useEffect, useState } from 'react' -import { useTranslations } from 'next-intl' -import { useSearchParams, useRouter } from 'next/navigation' -import { TaxSettingsForm } from '@/components/settings/TaxSettingsForm' -import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper' -import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton' -import { SkatteverketConnectPanel } from '@/components/settings/SkatteverketConnectPanel' -import { useSettings } from '@/components/settings/useSettings' -import { useToast } from '@/components/ui/use-toast' -import { useCompany } from '@/contexts/CompanyContext' -import { createClient } from '@/lib/supabase/client' -import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' -import type { CompanySettings } from '@/types' +import { TaxSettingsContent } from '@/components/settings/sections/TaxSettingsContent' export default function TaxSettingsPage() { - const { settings, isLoading, updateSettings } = useSettings() - const { company } = useCompany() - const t = useTranslations('settings_skatteverket') - const searchParams = useSearchParams() - const router = useRouter() - const { toast } = useToast() - - const [isSandbox, setIsSandbox] = useState(false) - - const hasSkatteverketExtension = ENABLED_EXTENSION_IDS.has('skatteverket') - - // Sandbox companies don't connect to the real Skatteverket — hide the panel, - // matching the old Skatteverket tab's visibility gate. - useEffect(() => { - if (!company?.id) return - const supabase = createClient() - supabase - .from('company_settings') - .select('is_sandbox') - .eq('company_id', company.id) - .single() - .then(({ data }) => { - if (data?.is_sandbox) setIsSandbox(true) - }) - }, [company?.id]) - - // Skatteverket OAuth callback — the connect flow returns to /settings/tax with - // a status query param (returnTo set in SkatteverketConnectPanel). - useEffect(() => { - const connected = searchParams.get('skv_connected') - const error = searchParams.get('skv_error') - if (connected === 'true') { - toast({ title: t('connected_title'), description: t('connected_description') }) - router.replace('/settings/tax') - } else if (error) { - let msg: string - try { - msg = decodeURIComponent(error) - } catch { - msg = error - } - toast({ title: t('connect_failed_title'), description: msg, variant: 'destructive' }) - router.replace('/settings/tax') - } - }, [searchParams, router, toast, t]) - - if (isLoading || !settings) return - - function handleSave(formData: FormData) { - const vatRegistered = formData.get('vat_registered') === 'true' - - const updates: Record = { - 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) => { - updateSettings(data as Partial) - }, - } - } - - const showSkatteverket = hasSkatteverketExtension && !isSandbox - - return ( -
- - - - - {showSkatteverket && } -
- ) + return } diff --git a/app/(dashboard)/settings/templates/page.tsx b/app/(dashboard)/settings/templates/page.tsx index c80a8b6d..cbce09b5 100644 --- a/app/(dashboard)/settings/templates/page.tsx +++ b/app/(dashboard)/settings/templates/page.tsx @@ -1,13 +1,5 @@ -'use client' - -import { BookingTemplatesPanel } from '@/components/settings/BookingTemplatesPanel' -import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel' +import { TemplatesSettingsContent } from '@/components/settings/sections/TemplatesSettingsContent' export default function TemplatesSettingsPage() { - return ( -
- - -
- ) + return } diff --git a/components/common/CommandPalette.tsx b/components/common/CommandPalette.tsx index 6c887912..e9fe751e 100644 --- a/components/common/CommandPalette.tsx +++ b/components/common/CommandPalette.tsx @@ -49,6 +49,12 @@ const PAGE_ENTRIES: Entry[] = [ { id: 'bokföring', label: 'Bokföring', icon: BookOpen, href: '/bookkeeping', keywords: 'verifikat journal ledger' }, { id: 'anläggningstillgångar', label: 'Anläggningstillgångar', icon: Package, href: '/assets', keywords: 'tillgångar assets' }, { id: 'rapporter', label: 'Rapporter', icon: BarChart3, href: '/reports' }, + { id: 'rapport-resultatrapport', label: 'Visa rapport: Resultatrapport', icon: BarChart3, href: '/reports/resultatrapport', keywords: 'rapport resultat intäkter kostnader' }, + { id: 'rapport-balansrapport', label: 'Visa rapport: Balansrapport', icon: BarChart3, href: '/reports/balansrapport', keywords: 'rapport balans tillgångar skulder' }, + { id: 'rapport-saldobalans', label: 'Visa rapport: Saldobalans', icon: BarChart3, href: '/reports/trial-balance', keywords: 'rapport saldobalans trial balance' }, + { id: 'rapport-moms', label: 'Visa rapport: Momsdeklaration', icon: BarChart3, href: '/reports/vat-declaration', keywords: 'rapport moms vat deklaration' }, + { id: 'rapport-huvudbok', label: 'Visa rapport: Huvudbok', icon: BookOpen, href: '/reports/huvudbok', keywords: 'rapport huvudbok ledger konto' }, + { id: 'rapport-kundreskontra', label: 'Visa rapport: Kundreskontra', icon: Users, href: '/reports/kundreskontra', keywords: 'rapport kundreskontra ar kundfordringar' }, { id: 'importera', label: 'Importera', icon: Upload, href: '/import' }, { id: 'granskning', label: 'Granskning', icon: ClipboardCheck, href: '/pending', keywords: 'pending review' }, { id: 'löner', label: 'Löner', icon: HandCoins, href: '/salary' }, diff --git a/components/reports/FocusedReport.tsx b/components/reports/FocusedReport.tsx new file mode 100644 index 00000000..9cfaec15 --- /dev/null +++ b/components/reports/FocusedReport.tsx @@ -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({}) + 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 ( +
+ + + {t('back_to_library')} + + + { + 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 && ( + + )} + + {!isReady && !isPeriodless ? ( + + + + + + + ) : isPeriodless || selectedPeriod ? ( + + ) : ( + + )} +
+ ) +} + +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 + case 'balansrapport': + return + case 'trial-balance': + return + case 'income-statement': + return + case 'balance-sheet': + return + case 'vat-declaration': + return + case 'periodisk-sammanstallning': + return + case 'ne-declaration': + return isEnskildFirma ? : null + case 'ink2-declaration': + return isAktiebolag ? : null + case 'huvudbok': + return + case 'grundbok': + return + case 'kundreskontra': + return + case 'supplier-ledger': + return + case 'bank-reconciliation': + return + default: + return null + } +} + +export function FocusedReport({ slug }: { slug: string }) { + return ( + }> + + + ) +} diff --git a/components/reports/RecentReportsShelf.tsx b/components/reports/RecentReportsShelf.tsx new file mode 100644 index 00000000..9ebbb58d --- /dev/null +++ b/components/reports/RecentReportsShelf.tsx @@ -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 => !!r) + .filter((r) => !r.entityType || r.entityType === entityType) + .filter((r) => !r.needsEmployees || hasEmployees) + + if (items.length === 0) return null + + return ( +
+

+ {t('recent_heading')} +

+
+ {items.map((item) => ( + + ))} +
+
+ ) +} diff --git a/components/reports/ReportExportMenu.tsx b/components/reports/ReportExportMenu.tsx new file mode 100644 index 00000000..6c7f3c8d --- /dev/null +++ b/components/reports/ReportExportMenu.tsx @@ -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 ( +
+ {hasItems && ( + + + + + + {items!.map((item) => ( + window.open(item.href, '_blank')} + > + {item.format === 'pdf' ? ( + + ) : ( + + )} + {item.format === 'pdf' ? t('download_pdf') : t('download_excel')} + + ))} + + + )} + {children} +
+ ) +} diff --git a/components/reports/ReportLibrary.tsx b/components/reports/ReportLibrary.tsx new file mode 100644 index 00000000..71c42d1b --- /dev/null +++ b/components/reports/ReportLibrary.tsx @@ -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 ( +
+ {sections.map((section) => ( +
+

+ {t(section.labelKey)} +

+ + {section.items.map((item) => ( + onOpen(item.slug)} + trailing={ + <> + + {item.params === 'calendar' && ( + {t('calendar_badge')} + )} + + + } + > + {t(item.labelKey)} + {t(item.descKey)} + + ))} + +
+ ))} +
+ ) +} + +function EntityBadge({ item }: { item: ReportDescriptor }) { + if (item.entityType === 'enskild_firma') return EF + if (item.entityType === 'aktiebolag') return AB + return null +} diff --git a/components/reports/ReportsNav.tsx b/components/reports/ReportsNav.tsx deleted file mode 100644 index c20e99ae..00000000 --- a/components/reports/ReportsNav.tsx +++ /dev/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 */} -
- -
- - {/* Desktop: vertical left rail */} - - - ) -} diff --git a/components/reports/useRecentReports.ts b/components/reports/useRecentReports.ts new file mode 100644 index 00000000..efc9242d --- /dev/null +++ b/components/reports/useRecentReports.ts @@ -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::` 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([]) + + 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 } +} diff --git a/components/reports/views/index.tsx b/components/reports/views/index.tsx new file mode 100644 index 00000000..5e959ea4 --- /dev/null +++ b/components/reports/views/index.tsx @@ -0,0 +1,2506 @@ +'use client' + +// Report view components, extracted verbatim from app/(dashboard)/reports/page.tsx. +// Rendered by the focused /reports/[slug] route (see components/reports/FocusedReport.tsx). +// The regulated table/figure rendering is unchanged from the original monolith. + +import React, { useState, useEffect, useRef, useCallback } from 'react' +import Link from 'next/link' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { AlertCircle, ChevronDown, ChevronRight } from 'lucide-react' +import AgentSparkleButton from '@/components/agent/AgentSparkleButton' +import { formatDate } from '@/lib/utils' +import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' +import { AccountNumber } from '@/components/ui/account-number' +import { ReportExportMenu } from '@/components/reports/ReportExportMenu' +import { useSettings } from '@/components/settings/useSettings' +import { TrialBalanceChart } from '@/components/reports/TrialBalanceChart' +import { VatCompositionChart } from '@/components/reports/VatCompositionChart' +import { SkatteverketPanel } from '@/components/reports/SkatteverketPanel' +import { IncomeExpenseChart } from '@/components/reports/IncomeExpenseChart' +import { useReportRowExpansion } from '@/components/reports/ReportRowExpansion' +import type { + ReportSourceLine, + ReportSourceFetcher, +} from '@/lib/reports/source-lines' +import type { MonthlyDataPoint } from '@/components/reports/IncomeExpenseChart' +import type { DateRangeValue } from '@/components/common/ReportDateRange' +import type { + TrialBalanceRow, + IncomeStatementReport, + BalanceSheetReport, + ResultatrapportReport, + BalansrapportReport, + VatDeclaration, + VatPeriodType, +} from '@/types' + +function formatAmount(amount: number): string { + return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +} + +function reportQuery(periodId: string, range?: DateRangeValue): string { + const params = new URLSearchParams({ period_id: periodId }) + if (range?.fromDate) params.set('from_date', range.fromDate) + if (range?.toDate) params.set('to_date', range.toDate) + return params.toString() +} + +export function TrialBalanceView({ periodId, onNavigateToAccount }: { periodId: string; onNavigateToAccount: (account: string) => void }) { + const [data, setData] = useState<{ + rows: TrialBalanceRow[] + totalDebit: number + totalCredit: number + isBalanced: boolean + } | null>(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [viewMode, setViewMode] = useState<'simplified' | 'detailed'>('simplified') + + useEffect(() => { + setLoading(true) + setError(null) + fetch(`/api/reports/trial-balance?period_id=${periodId}`) + .then((res) => res.json()) + .then((result) => { + if (result.error) { + setError(result.error) + } else { + setData(result.data) + } + setLoading(false) + }) + .catch(() => { + setError('Kunde inte hämta saldobalans') + setLoading(false) + }) + }, [periodId]) + + if (loading) { + return ( + + + Laddar saldobalans... + + + ) + } + + if (error) { + return ( + + + + {error} + + + ) + } + + if (!data || data.rows.length === 0) { + return ( + + + Inga bokförda verifikationer i denna period. + + + ) + } + + function getNetBalance(row: TrialBalanceRow, type: 'opening' | 'period' | 'closing'): number { + let debit: number, credit: number + if (type === 'opening') { + debit = row.opening_debit; credit = row.opening_credit + } else if (type === 'period') { + debit = row.period_debit; credit = row.period_credit + } else { + debit = row.closing_debit; credit = row.closing_credit + } + // Credit-normal accounts (liabilities/equity class 2, revenue class 3): positive when credit > debit + // Debit-normal accounts (assets class 1, expenses class 4-9): positive when debit > credit + const creditNormal = row.account_class === 2 || row.account_class === 3 + return Math.round((creditNormal ? credit - debit : debit - credit) * 100) / 100 + } + + function formatSigned(amount: number): string { + if (amount === 0) return '' + return amount < 0 + ? `−${formatAmount(Math.abs(amount))}` + : formatAmount(amount) + } + + return ( +
+ + + + +
+ Saldobalans +
+
+ + +
+ {data.isBalanced ? ( + Balanserad + ) : ( + Ej balanserad + )} +
+
+
+ +
+ {viewMode === 'simplified' ? ( + + + + + + + + + + + + + {data.rows.map((row) => ( + + ))} + +
KontoNamnIngående saldoFörändringUtgående saldo
+ ) : ( + + + + + + + + + + + + + + {data.rows.map((row) => ( + + ))} + + + + + + + + + + + +
KontoNamnPeriod debetPeriod kreditSaldo debetSaldo kredit
Summa + {formatAmount(data.rows.reduce((s, r) => s + r.period_debit, 0))} + + {formatAmount(data.rows.reduce((s, r) => s + r.period_credit, 0))} + + {formatAmount(data.totalDebit)} + + {formatAmount(data.totalCredit)} +
+ )} +
+
+
+
+ ) +} + +// Lazy fetcher for a TB account's source lines. Memoised at the row level so +// repeated toggling never refetches. +function makeTrialBalanceFetcher(accountNumber: string, periodId: string): ReportSourceFetcher { + return async () => { + const res = await fetch( + `/api/reports/trial-balance/account/${encodeURIComponent(accountNumber)}/sources?fiscal_period_id=${encodeURIComponent(periodId)}` + ) + const json = await res.json() + if (!res.ok) throw new Error(json.error || 'Kunde inte hämta verifikat') + const lines: ReportSourceLine[] = json.data?.lines || [] + return { lines, next_cursor: json.data?.next_cursor ?? null } + } +} + +function TrialBalanceSimplifiedRow({ + row, + periodId, + onNavigateToAccount, + getNetBalance, + formatSigned, +}: { + row: TrialBalanceRow + periodId: string + onNavigateToAccount: (account: string) => void + getNetBalance: (row: TrialBalanceRow, type: 'opening' | 'period' | 'closing') => number + formatSigned: (amount: number) => string +}) { + const fetcher = React.useMemo( + () => makeTrialBalanceFetcher(row.account_number, periodId), + [row.account_number, periodId] + ) + const { Toggle, Panel } = useReportRowExpansion(fetcher, `tb-${row.account_number}`) + + const ob = getNetBalance(row, 'opening') + const ch = getNetBalance(row, 'period') + const cb = getNetBalance(row, 'closing') + + return ( + <> + + e.stopPropagation()}> + + + onNavigateToAccount(row.account_number)} + > + + + onNavigateToAccount(row.account_number)} + > + {row.account_name} + + + {formatSigned(ob)} + + + {formatSigned(ch)} + + + {formatSigned(cb)} + + + + + ) +} + +function TrialBalanceDetailedRow({ + row, + periodId, + onNavigateToAccount, +}: { + row: TrialBalanceRow + periodId: string + onNavigateToAccount: (account: string) => void +}) { + const fetcher = React.useMemo( + () => makeTrialBalanceFetcher(row.account_number, periodId), + [row.account_number, periodId] + ) + const { Toggle, Panel } = useReportRowExpansion(fetcher, `tb-det-${row.account_number}`) + + return ( + <> + + e.stopPropagation()}> + + + onNavigateToAccount(row.account_number)} + > + + + onNavigateToAccount(row.account_number)} + > + {row.account_name} + + + {row.period_debit > 0 ? formatAmount(row.period_debit) : ''} + + + {row.period_credit > 0 ? formatAmount(row.period_credit) : ''} + + + {row.closing_debit > 0 ? formatAmount(row.closing_debit) : ''} + + + {row.closing_credit > 0 ? formatAmount(row.closing_credit) : ''} + + + + + ) +} +export function IncomeStatementView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) { + const [data, setData] = useState(null) + const [monthlyData, setMonthlyData] = useState([]) + const [monthlyLoading, setMonthlyLoading] = useState(false) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const reportQs = reportQuery(periodId, dateRange) + + useEffect(() => { + setLoading(true) + setError(null) + setMonthlyLoading(true) + + fetch(`/api/reports/income-statement?${reportQs}`) + .then((res) => res.json()) + .then((result) => { + if (result.error) { + setError(result.error) + } else { + setData(result.data) + } + setLoading(false) + }) + .catch(() => { + setError('Kunde inte hämta resultaträkning') + setLoading(false) + }) + + // Monthly breakdown is full-period by design (it IS the per-month view), + // so the date range only affects the headline numbers above the chart. + fetch(`/api/reports/monthly-breakdown?period_id=${periodId}`) + .then((res) => res.json()) + .then((result) => { + if (result.data?.months) { + setMonthlyData(result.data.months) + } + setMonthlyLoading(false) + }) + .catch(() => { + setMonthlyLoading(false) + }) + }, [periodId, reportQs]) + + if (loading) { + return ( + + + Laddar resultaträkning... + + + ) + } + + if (error) { + return ( + + + + {error} + + + ) + } + + if (!data) { + return ( + + + Ingen data för denna period. + + + ) + } + + return ( +
+ + + {!monthlyLoading && monthlyData.length > 0 && ( + + )} + + {/* Revenue */} + + + Rörelseintäkter + + + +
+ Summa rörelseintäkter + {formatAmount(data.total_revenue)} kr +
+
+
+ + {/* Expenses */} + + + Rörelsekostnader + + + +
+ Summa rörelsekostnader + -{formatAmount(data.total_expenses)} kr +
+
+
+ + {/* Operating result */} + + +
+ Rörelseresultat + = 0 ? 'text-success' : 'text-destructive'}> + {formatAmount(data.total_revenue - data.total_expenses)} kr + +
+
+
+ + {/* Financial items */} + {data.financial_sections.length > 0 && ( + + + Finansiella poster + + + +
+ Summa finansiella poster + {formatAmount(data.total_financial)} kr +
+
+
+ )} + + {/* Net result */} + + +
+ Årets resultat + = 0 ? 'text-success' : 'text-destructive'}> + {formatAmount(data.net_result)} kr + +
+
+
+
+ ) +} + +export function BalanceSheetView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const reportQs = reportQuery(periodId, dateRange) + + useEffect(() => { + setLoading(true) + setError(null) + fetch(`/api/reports/balance-sheet?${reportQs}`) + .then((res) => res.json()) + .then((result) => { + if (result.error) { + setError(result.error) + } else { + setData(result.data) + } + setLoading(false) + }) + .catch(() => { + setError('Kunde inte hämta balansräkning') + setLoading(false) + }) + }, [periodId, reportQs]) + + if (loading) { + return ( + + + Laddar balansräkning... + + + ) + } + + if (error) { + return ( + + + + {error} + + + ) + } + + if (!data) { + return ( + + + Ingen data för denna period. + + + ) + } + + const isBalanced = Math.abs(data.total_assets - data.total_equity_liabilities) < 0.01 + + return ( +
+ + + {/* Assets */} + + + Tillgångar + + + +
+ Summa tillgångar + {formatAmount(data.total_assets)} kr +
+
+
+ + {/* Equity and liabilities */} + + + Eget kapital och skulder + + + +
+ Summa eget kapital och skulder + {formatAmount(data.total_equity_liabilities)} kr +
+
+
+ + {/* Balance check */} + + +
+ Balanscheck + {isBalanced ? ( + + Balanserar + + ) : ( +
+ + Balanserar ej + +

+ Differens: {formatAmount(Math.abs(data.total_assets - data.total_equity_liabilities))} kr +

+
+ )} +
+
+
+
+ ) +} + +export function ResultatrapportView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const reportQs = reportQuery(periodId, dateRange) + + useEffect(() => { + setLoading(true) + setError(null) + fetch(`/api/reports/resultatrapport?${reportQs}`) + .then((res) => res.json()) + .then((result) => { + if (result.error) { + setError(result.error) + } else { + setData(result.data) + } + setLoading(false) + }) + .catch(() => { + setError('Kunde inte hämta resultatrapport') + setLoading(false) + }) + }, [periodId, reportQs]) + + if (loading) { + return ( + + + Laddar resultatrapport... + + + ) + } + + if (error) { + return ( + + + + {error} + + + ) + } + + if (!data || data.groups.length === 0) { + return ( + + + Inga bokförda intäkter eller kostnader i denna period. + + + ) + } + + const hasPrior = data.prior_period !== null + const colCount = 4 + + return ( +
+ + + + +
+ + + + + + + + + + + {data.groups.map((group) => ( + + + + + {group.rows.map((row) => ( + onNavigateToAccount(row.account_number)} + > + + + + + + ))} + + + + + + + ))} + +
KontoKontonamnInnevarandeFöregående
+ {group.class_label} +
+ + {row.account_name}{formatAmount(row.current_period)} + {hasPrior ? formatAmount(row.prior_period) : '—'} +
+ Summa + {formatAmount(group.subtotal_current)} + {hasPrior ? formatAmount(group.subtotal_prior) : '—'} +
+
+
+
+ + + +
+ Beräknat resultat + = 0 ? 'text-success' : 'text-destructive'}`}> + {formatAmount(data.net_result_current)} kr + + + {hasPrior ? `${formatAmount(data.net_result_prior)} kr` : '—'} + +
+
+
+
+ ) +} + +export function BalansrapportView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const reportQs = reportQuery(periodId, dateRange) + + useEffect(() => { + setLoading(true) + setError(null) + fetch(`/api/reports/balansrapport?${reportQs}`) + .then((res) => res.json()) + .then((result) => { + if (result.error) { + setError(result.error) + } else { + setData(result.data) + } + setLoading(false) + }) + .catch(() => { + setError('Kunde inte hämta balansrapport') + setLoading(false) + }) + }, [periodId, reportQs]) + + if (loading) { + return ( + + + Laddar balansrapport... + + + ) + } + + if (error) { + return ( + + + + {error} + + + ) + } + + if (!data || data.groups.length === 0) { + return ( + + + Inga balansposter i denna period. + + + ) + } + + return ( +
+ + + + +
+ + + + + + + + + + + + {data.groups.map((group) => ( + + + + + {group.rows.map((row) => ( + onNavigateToAccount(row.account_number)} + > + + + + + + + ))} + + + + + + + + ))} + +
KontoKontonamnIngående balansFörändringUtgående balans
+ {group.class_label} +
+ + {row.account_name}{formatAmount(row.ib)}{formatAmount(row.period_change)}{formatAmount(row.ub)}
+ Summa + {formatAmount(group.subtotal_ib)} + {formatAmount(group.subtotal_ub - group.subtotal_ib)} + {formatAmount(group.subtotal_ub)}
+
+
+
+ + + +
+ Summa tillgångar + {formatAmount(data.total_assets_ub)} kr +
+
+ Summa eget kapital, reserver, avsättningar och skulder + {formatAmount(data.total_equity_liabilities_ub)} kr +
+
+ Beräknat resultat (ej bokslutsjusterat) + {formatAmount(data.beraknat_resultat)} kr +
+
+ Balanscheck + {data.is_balanced ? ( + + Balanserar + + ) : ( + + Balanserar ej + + )} +
+
+
+
+ ) +} + +function ReportSectionTable({ + sections, + negate, + onNavigateToAccount, +}: { + sections: { title: string; rows: { account_number: string; account_name: string; amount: number }[]; subtotal: number }[] + negate?: boolean + onNavigateToAccount?: (account: string) => void +}) { + if (sections.length === 0) { + return

Inga poster.

+ } + + return ( +
+ {sections.map((section) => ( +
+

{section.title}

+
+ + {section.rows.map((row) => ( + onNavigateToAccount(row.account_number) : undefined} + > + + + + + ))} + +
{row.account_name} + {negate ? `-${formatAmount(row.amount)}` : formatAmount(row.amount)} kr +
+
+ {section.title} + + {negate ? `-${formatAmount(section.subtotal)}` : formatAmount(section.subtotal)} kr + +
+
+ ))} +
+ ) +} + +// Carries the selected fiscal period into the ruta drill-down rows so their +// source-verifikat query matches the report's period. Only set for yearly +// (räkenskapsår); undefined for monthly/quarterly (calendar periods). +const VatDrillContext = React.createContext<{ fiscalPeriodId?: string }>({}) + +export function VatDeclarationView({ + fiscalPeriodId, + fiscalPeriodBounds, +}: { + fiscalPeriodId?: string + fiscalPeriodBounds?: { start: string; end: string } | null +} = {}) { + const currentYear = new Date().getFullYear() + const currentMonth = new Date().getMonth() + 1 + const currentQuarter = Math.ceil(currentMonth / 3) + + const [periodType, setPeriodType] = useState('quarterly') + const [year, setYear] = useState(currentYear) + const [period, setPeriod] = useState(currentQuarter) + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + // Default the periodicity to the company's configured VAT reporting period + // (moms_period in Inställningar) so the picker mirrors the setting instead of + // always starting on quarterly. Applied once per company the first time its + // settings load; a later manual change to the picker is preserved, and a + // company switch re-applies the new company's setting. `useSettings` only + // refetches when the active company changes, so this never clobbers a manual + // selection mid-session. + const { settings } = useSettings() + const appliedForCompany = useRef(null) + useEffect(() => { + const momsPeriod = settings?.moms_period + const companyId = settings?.company_id + if (!momsPeriod || !companyId) return + if (appliedForCompany.current === companyId) return + appliedForCompany.current = companyId + setPeriodType(momsPeriod) + // `period` is reset to a sensible value by the periodType effect below. + }, [settings]) + + // Generate year options (last 5 years) + const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i) + + // Generate period options based on type + const getPeriodOptions = () => { + switch (periodType) { + case 'monthly': + return [ + { value: 1, label: 'Januari' }, + { value: 2, label: 'Februari' }, + { value: 3, label: 'Mars' }, + { value: 4, label: 'April' }, + { value: 5, label: 'Maj' }, + { value: 6, label: 'Juni' }, + { value: 7, label: 'Juli' }, + { value: 8, label: 'Augusti' }, + { value: 9, label: 'September' }, + { value: 10, label: 'Oktober' }, + { value: 11, label: 'November' }, + { value: 12, label: 'December' }, + ] + case 'quarterly': + return [ + { value: 1, label: 'Kvartal 1 (jan-mar)' }, + { value: 2, label: 'Kvartal 2 (apr-jun)' }, + { value: 3, label: 'Kvartal 3 (jul-sep)' }, + { value: 4, label: 'Kvartal 4 (okt-dec)' }, + ] + case 'yearly': + return [{ value: 1, label: 'Helår' }] + default: + return [] + } + } + + // Reset period when type changes + useEffect(() => { + if (periodType === 'monthly') { + setPeriod(currentMonth) + } else if (periodType === 'quarterly') { + setPeriod(currentQuarter) + } else { + setPeriod(1) + } + }, [periodType, currentMonth, currentQuarter]) + + // Annual VAT (helårsmoms) is reported per räkenskapsår, not per calendar year. + // For yearly we pass the selected fiscal period so the API uses its actual + // bounds (handles extended/shortened years); monthly/quarterly stay calendar. + const isYearly = periodType === 'yearly' + const vatQueryString = () => { + const params = new URLSearchParams({ + periodType, + year: String(year), + period: String(period), + }) + if (isYearly && fiscalPeriodId) params.set('fiscal_period_id', fiscalPeriodId) + return params.toString() + } + + const fetchDeclaration = async () => { + setLoading(true) + setError(null) + try { + const res = await fetch( + `/api/reports/vat-declaration?${vatQueryString()}` + ) + const result = await res.json() + if (result.error) { + setError(result.error) + } else { + setData(result.data) + } + } catch { + setError('Kunde inte hämta momsdeklaration') + } finally { + setLoading(false) + } + } + + return ( + +
+ + + + {/* Period selection */} + + + Välj period + + +
+
+ + +
+ {isYearly ? ( + // Annual VAT covers the selected räkenskapsår — driven by the + // fiscal-year picker on the report page, not a calendar year. +
+ +
+ {fiscalPeriodBounds + ? `${formatDate(fiscalPeriodBounds.start)} – ${formatDate(fiscalPeriodBounds.end)}` + : '—'} +
+
+ ) : ( + <> +
+ + +
+
+ + +
+ + )} + +
+
+
+ + {error && ( + + + + {error} + + + )} + + {data && ( + <> + + + {/* Summary */} + + +
+ Momsdeklaration - {data.period.start} till {data.period.end} + 0 + ? 'warning' + : data.rutor.ruta49 < 0 + ? 'success' + : 'secondary' + } + > + {data.rutor.ruta49 > 0 + ? `Att betala: ${formatAmount(data.rutor.ruta49)} kr` + : data.rutor.ruta49 < 0 + ? `Att återfå: ${formatAmount(Math.abs(data.rutor.ruta49))} kr` + : 'Ingen moms'} + +
+
+ +
+ Baserat på {data.invoiceCount} fakturor och {data.transactionCount} transaktioner +
+ +
+ {/* Utgående moms */} +
+

Utgående moms (försäljning)

+
+ + {data.rutor.ruta05 > 0 && ( + + )} + + + + + + + + + + + + +
Summa utgående + {formatAmount( + data.rutor.ruta10 + data.rutor.ruta11 + data.rutor.ruta12 + + data.rutor.ruta30 + data.rutor.ruta31 + data.rutor.ruta32 + )} kr +
+ + {/* Omvänd skattskyldighet (inköp) */} + {(data.rutor.ruta20 > 0 || data.rutor.ruta21 > 0 || data.rutor.ruta22 > 0 || data.rutor.ruta23 > 0 || data.rutor.ruta24 > 0 || + data.rutor.ruta30 > 0 || data.rutor.ruta31 > 0 || data.rutor.ruta32 > 0) && ( + <> +

Omvänd skattskyldighet (inköp)

+
+ + + + + + + + + + +
+ + )} +
+ + {/* Ingående moms */} +
+

Ingående moms (avdragsgill)

+
+ + + {data.breakdown.transactions.ruta48 > 0 && ( + + + + + )} + {data.breakdown.receipts.ruta48 > 0 && ( + + + + + )} + + + + + + + +
- från transaktioner + {formatAmount(data.breakdown.transactions.ruta48)} kr +
- från kvitton + {formatAmount(data.breakdown.receipts.ruta48)} kr +
Summa ingående{formatAmount(data.rutor.ruta48)} kr
+
+
+ + {/* Net result */} +
+
+
+ 49 + + {data.rutor.ruta49 >= 0 ? 'Moms att betala' : 'Moms att återfå'} + +
+ 0 + ? 'text-warning' + : data.rutor.ruta49 < 0 + ? 'text-success' + : '' + }`} + > + {formatAmount(Math.abs(data.rutor.ruta49))} kr + +
+
+
+
+ + )} + + {/* Skatteverket integration panel */} + + + {!data && !loading && !error && ( + + + Välj period och klicka "Hämta" för att se momsdeklaration. + + + )} +
+
+ ) +} + +function makeVatFetcher( + ruta: string, + periodType: VatPeriodType, + year: number, + period: number, + fiscalPeriodId?: string, +): ReportSourceFetcher { + return async () => { + const params = new URLSearchParams({ + periodType, + year: String(year), + period: String(period), + }) + // Yearly drill-down resolves against the räkenskapsår, matching the report. + if (periodType === 'yearly' && fiscalPeriodId) { + params.set('fiscal_period_id', fiscalPeriodId) + } + const res = await fetch( + `/api/reports/vat-declaration/ruta/${encodeURIComponent(ruta)}/sources?${params.toString()}` + ) + const json = await res.json() + if (!res.ok) throw new Error(json.error || 'Kunde inte hämta verifikat') + const lines: ReportSourceLine[] = json.data?.lines || [] + return { lines, next_cursor: json.data?.next_cursor ?? null } + } +} + +function VatRutaRow({ + ruta, + label, + amount, + baseAmount, + noVat, + periodType, + year, + period, +}: { + ruta: string + label: string + amount: number + baseAmount: number + noVat?: boolean + periodType?: VatPeriodType + year?: number + period?: number +}) { + const { fiscalPeriodId } = React.useContext(VatDrillContext) + const canDrill = periodType !== undefined && year !== undefined && period !== undefined + const fetcher = React.useMemo( + () => (canDrill ? makeVatFetcher(ruta, periodType!, year!, period!, fiscalPeriodId) : null), + [canDrill, ruta, periodType, year, period, fiscalPeriodId] + ) + // Hooks must be called unconditionally — provide a noop fetcher when drill + // is disabled. The early-return for zero rows lives below the hooks. + const expansion = useReportRowExpansion( + fetcher ?? (async () => ({ lines: [], next_cursor: null })), + `vat-${ruta}` + ) + + // Don't show rows with zero values + if (baseAmount === 0 && amount === 0) return null + + return ( + <> + + + {canDrill && ( + + + + )} + {ruta} + {label} + + {noVat ? `${formatAmount(baseAmount)} kr` : `${formatAmount(amount)} kr`} + + {!noVat && baseAmount > 0 && ( + + Underlag + {formatAmount(baseAmount)} kr + + )} + {canDrill && } + + ) +} + +interface SupplierLedgerData { + ledger: { + entries: { + supplier_id: string + supplier_name: string + current: number + days_1_30: number + days_31_60: number + days_61_90: number + days_90_plus: number + total_outstanding: number + }[] + total_outstanding: number + total_current: number + total_overdue: number + unpaid_count: number + unconverted_fx_count: number + } + reconciliation: { + supplier_ledger_total: number + account_2440_balance: number + difference: number + is_reconciled: boolean + unconverted_fx_count: number + } | null +} + +export function SupplierLedgerView({ periodId }: { periodId: string }) { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const fetchData = async () => { + setLoading(true) + setError(null) + try { + const res = await fetch(`/api/reports/supplier-ledger?period_id=${periodId}`) + const result = await res.json() + if (result.error) { + setError(result.error) + } else { + setData(result.data) + } + } catch { + setError('Kunde inte hämta leverantörsreskontra') + } finally { + setLoading(false) + } + } + + useEffect(() => { + if (periodId) fetchData() + }, [periodId]) + + if (loading) { + return ( + + + Laddar leverantörsreskontra... + + + ) + } + + if (error) { + return ( + + + + {error} + + + ) + } + + if (!data || !data.ledger) { + return ( + + + Ingen data tillgänglig. + + + ) + } + + const { ledger, reconciliation } = data + + return ( +
+ + {/* Summary cards */} +
+ + + Totalt utestående + + +

{formatAmount(ledger.total_outstanding)} kr

+

{ledger.unpaid_count} fakturor

+ {ledger.unconverted_fx_count > 0 && ( +

+ {ledger.unconverted_fx_count} faktura i utländsk valuta utan växelkurs är inte med i totalen. +

+ )} +
+
+ + + Ej förfallet + + +

{formatAmount(ledger.total_current)} kr

+
+
+ + + Förfallet + + +

{formatAmount(ledger.total_overdue)} kr

+
+
+
+ + {/* Aging table */} + {ledger.entries.length > 0 && ( + + + Ålderfördelning per leverantör + + +
+ + + + + + + + + + + + + + {ledger.entries.map((entry) => ( + + ))} + + + + + + + + + + + + + +
LeverantörEj förfallet1-30 dagar31-60 dagar61-90 dagar90+ dagarTotalt
Summa{formatAmount(ledger.entries.reduce((s, e) => s + e.current, 0))}{formatAmount(ledger.entries.reduce((s, e) => s + e.days_1_30, 0))}{formatAmount(ledger.entries.reduce((s, e) => s + e.days_31_60, 0))}{formatAmount(ledger.entries.reduce((s, e) => s + e.days_61_90, 0))}{formatAmount(ledger.entries.reduce((s, e) => s + e.days_90_plus, 0))}{formatAmount(ledger.total_outstanding)}
+
+
+ )} + + {/* Reconciliation */} + {reconciliation && ( + + + Avstämning mot + + +
+
+ Leverantörsreskontra (summa utestående) + {formatAmount(reconciliation.supplier_ledger_total)} kr +
+
+ saldo (huvudbok) + {formatAmount(reconciliation.account_2440_balance)} kr +
+
+ Differens + + {formatAmount(reconciliation.difference)} kr + +
+
+ {reconciliation.is_reconciled ? ( + Avstämd + ) : ( + Ej avstämd - kontrollera bokföring + )} + {reconciliation.unconverted_fx_count > 0 && ( +

+ {reconciliation.unconverted_fx_count} leverantörsfaktura i utländsk valuta saknar växelkurs — differensen kan bero på saknade kursuppgifter snarare än felbokning. +

+ )} +
+
+
+
+ )} +
+ ) +} + +function makeSupplierFetcher(supplierId: string): ReportSourceFetcher { + return async () => { + const res = await fetch( + `/api/reports/supplier-ledger/supplier/${encodeURIComponent(supplierId)}/invoices` + ) + const json = await res.json() + if (!res.ok) throw new Error(json.error || 'Kunde inte hämta leverantörsfakturor') + const lines: ReportSourceLine[] = json.data?.lines || [] + return { lines, next_cursor: json.data?.next_cursor ?? null } + } +} + +function SupplierLedgerRow({ + entry, +}: { + entry: { + supplier_id: string + supplier_name: string + current: number + days_1_30: number + days_31_60: number + days_61_90: number + days_90_plus: number + total_outstanding: number + } +}) { + const fetcher = React.useMemo( + () => makeSupplierFetcher(entry.supplier_id), + [entry.supplier_id] + ) + const { Toggle, Panel } = useReportRowExpansion(fetcher, `sup-${entry.supplier_id}`) + return ( + <> + + + {entry.supplier_name} + {entry.current > 0 ? formatAmount(entry.current) : ''} + {entry.days_1_30 > 0 ? formatAmount(entry.days_1_30) : ''} + {entry.days_31_60 > 0 ? formatAmount(entry.days_31_60) : ''} + {entry.days_61_90 > 0 ? formatAmount(entry.days_61_90) : ''} + {entry.days_90_plus > 0 ? formatAmount(entry.days_90_plus) : ''} + {formatAmount(entry.total_outstanding)} + + + + ) +} + +// --- General Ledger (Huvudbok) --- + +interface GeneralLedgerData { + accounts: { + account_number: string + account_name: string + opening_balance: number + lines: { + date: string + voucher_series: string + voucher_number: number + journal_entry_id: string + description: string + source_type: string + debit: number + credit: number + balance: number + }[] + closing_balance: number + total_debit: number + total_credit: number + }[] + period: { start: string; end: string } +} + +export function GeneralLedgerView({ periodId, initialAccountFilter }: { periodId: string; initialAccountFilter: string | null }) { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [accountFrom, setAccountFrom] = useState('') + const [accountTo, setAccountTo] = useState('') + + const fetchData = useCallback(async (fromOverride?: string, toOverride?: string) => { + const from = fromOverride ?? accountFrom + const to = toOverride ?? accountTo + setLoading(true) + setError(null) + try { + const params = new URLSearchParams({ period_id: periodId }) + if (from) params.set('account_from', from) + if (to) params.set('account_to', to) + const res = await fetch(`/api/reports/general-ledger?${params}`) + const result = await res.json() + if (result.error) { + setError(result.error) + } else { + setData(result.data) + } + } catch { + setError('Kunde inte hämta huvudbok') + } finally { + setLoading(false) + } + }, [periodId, accountFrom, accountTo]) + + // When initialAccountFilter changes (drill-down from another report), apply it + useEffect(() => { + if (initialAccountFilter) { + setAccountFrom(initialAccountFilter) + setAccountTo(initialAccountFilter) + fetchData(initialAccountFilter, initialAccountFilter) + } else { + fetchData() + } + }, [periodId, initialAccountFilter]) + + if (loading) { + return ( + + + Laddar huvudbok... + + + ) + } + + if (error) { + return ( + + + + {error} + + + ) + } + + if (!data || data.accounts.length === 0) { + return ( + + + Inga bokförda verifikationer i denna period. + + + ) + } + + return ( +
+ + {/* Account range filter */} + + +
+
+ + setAccountFrom(e.target.value)} + placeholder="t.ex. 1510" + className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm" + /> +
+
+ + setAccountTo(e.target.value)} + placeholder="t.ex. 1519" + className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm" + /> +
+ +
+
+
+ + {data.period.start && ( +

+ Period: {data.period.start} — {data.period.end} | {data.accounts.length} konton +

+ )} + + {data.accounts.map((account) => ( + + +
+ + + + + IB: {formatAmount(account.opening_balance)} kr + +
+
+ +
+ + + + + + + + + + + + {account.lines.map((line, i) => ( + + + + + + + + + ))} + + + + + + + + + +
Ver.nrDatumBeskrivningDebetKreditSaldo
+ + {formatVoucher(line)} + + {line.date}{line.description} + {line.debit > 0 ? formatAmount(line.debit) : ''} + + {line.credit > 0 ? formatAmount(line.credit) : ''} + {formatAmount(line.balance)}
Summa / Utgående balans{formatAmount(account.total_debit)}{formatAmount(account.total_credit)}{formatAmount(account.closing_balance)}
+
+
+ ))} +
+ ) +} + +// --- Journal Register (Grundbok) --- + +interface JournalRegisterData { + entries: { + voucher_series: string + voucher_number: number + date: string + description: string + source_type: string + status: string + lines: { + account_number: string + account_name: string + debit: number + credit: number + }[] + total_debit: number + total_credit: number + }[] + total_entries: number + total_debit: number + total_credit: number + period: { start: string; end: string } +} + +export function JournalRegisterView({ periodId }: { periodId: string }) { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [expandedEntries, setExpandedEntries] = useState>(new Set()) + + const fetchData = async () => { + setLoading(true) + setError(null) + setExpandedEntries(new Set()) + try { + const res = await fetch(`/api/reports/journal-register?period_id=${periodId}`) + const result = await res.json() + if (result.error) { + setError(result.error) + } else { + setData(result.data) + } + } catch { + setError('Kunde inte hämta grundbok') + } finally { + setLoading(false) + } + } + + useEffect(() => { + if (periodId) fetchData() + }, [periodId]) + + const toggleEntry = (index: number) => { + setExpandedEntries((prev) => { + const next = new Set(prev) + if (next.has(index)) { + next.delete(index) + } else { + next.add(index) + } + return next + }) + } + + if (loading) { + return ( + + + Laddar grundbok... + + + ) + } + + if (error) { + return ( + + + + {error} + + + ) + } + + if (!data || data.entries.length === 0) { + return ( + + + Inga bokförda verifikationer i denna period. + + + ) + } + + return ( +
+ + {data.period.start && ( +

+ Period: {data.period.start} — {data.period.end} | {data.total_entries} verifikationer +

+ )} + + + + Grundbok (registreringsordning) + + +
+ + + + + + + + + + + + + {data.entries.map((entry, index) => { + const isExpanded = expandedEntries.has(index) + const isReversed = entry.status === 'reversed' + + return ( + + toggleEntry(index)} + > + + + + + + + + + {isExpanded && entry.lines.map((line, lineIndex) => ( + + + + + + + + + + ))} + + ) + })} + + + + + + + + +
Ver.nrDatumBeskrivningTypDebetKredit
+ {isExpanded ? ( + + ) : ( + + )} + + {formatVoucher(entry)} + {entry.date} + {entry.description} + {isReversed && ( + Makulerad + )} + {entry.source_type}{formatAmount(entry.total_debit)}{formatAmount(entry.total_credit)}
{line.account_name} + {line.debit > 0 ? formatAmount(line.debit) : ''} + + {line.credit > 0 ? formatAmount(line.credit) : ''} +
Summa{formatAmount(data.total_debit)}{formatAmount(data.total_credit)}
+
+
+
+ ) +} + +// --- AR Ledger (Kundreskontra) --- + +interface ARLedgerData { + ledger: { + entries: { + customer_id: string + customer_name: string + invoices: { + invoice_id: string + invoice_number: string + invoice_date: string + due_date: string + total: number + paid_amount: number + outstanding: number + outstanding_sek: number | null + days_overdue: number + currency: string + }[] + current: number + days_1_30: number + days_31_60: number + days_61_90: number + days_90_plus: number + total_outstanding: number + }[] + total_outstanding: number + total_current: number + total_overdue: number + unpaid_count: number + unconverted_fx_count: number + } + reconciliation: { + ar_ledger_total: number + account_1510_balance: number + difference: number + is_reconciled: boolean + unconverted_fx_count: number + } | null +} + +// Inner expansion row component for AR ledger. +// Fetches per-customer invoices (with journal_entry_id) and renders each as a +// link to /bookkeeping/[id] when posted, /invoices/[id] when still draft. +function ARCustomerInvoiceRows({ + customerId, + invoices, +}: { + customerId: string + invoices: { + invoice_id: string + invoice_number: string + invoice_date: string + due_date: string + total: number + paid_amount: number + outstanding: number + outstanding_sek: number | null + days_overdue: number + currency: string + }[] +}) { + // ARCustomerInvoiceRows is mounted lazily — only when a customer is + // expanded, so initial state matches "still loading" and resets on + // unmount. No synchronous setState in the effect is needed. + const [enriched, setEnriched] = useState>({}) + const [loaded, setLoaded] = useState(false) + + useEffect(() => { + let cancelled = false + fetch(`/api/reports/ar-ledger/customer/${encodeURIComponent(customerId)}/invoices`) + .then((r) => r.json()) + .then((json) => { + if (cancelled) return + const map: typeof enriched = {} + for (const line of json.data?.lines || []) { + if (line.invoice_id && line.journal_entry_id) { + map[line.invoice_id] = { + journal_entry_id: line.journal_entry_id, + voucher_series: line.voucher_series, + voucher_number: line.voucher_number, + } + } + } + setEnriched(map) + }) + .catch(() => { /* fail silently; rows still render without verifikat link */ }) + .finally(() => { if (!cancelled) setLoaded(true) }) + return () => { cancelled = true } + }, [customerId]) + const loading = !loaded + + return ( + <> + {invoices.map((inv) => { + const entry = enriched[inv.invoice_id] + const targetHref = entry?.journal_entry_id + ? `/bookkeeping/${entry.journal_entry_id}` + : `/invoices/${inv.invoice_id}` + return ( + + + + + {inv.invoice_number || '(utkast)'} + + {entry && ( + + {formatVoucher(entry)} + + )} + {formatDate(inv.invoice_date)} + förfaller {formatDate(inv.due_date)} + + + {inv.days_overdue > 0 ? `${inv.days_overdue} dagar förfallen` : 'Ej förfallen'} + + + {inv.paid_amount > 0 ? `Betalt: ${formatAmount(inv.paid_amount)}` : ''} + + + + {formatAmount(inv.outstanding)} {inv.currency} + + + ) + })} + {loading && ( + + + Letar verifikat… + + )} + + ) +} + +export function ARLedgerView({ periodId }: { periodId: string }) { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [expandedCustomers, setExpandedCustomers] = useState>(new Set()) + + const fetchData = async () => { + setLoading(true) + setError(null) + try { + const res = await fetch(`/api/reports/ar-ledger?period_id=${periodId}`) + const result = await res.json() + if (result.error) { + setError(result.error) + } else { + setData(result.data) + } + } catch { + setError('Kunde inte hämta kundreskontra') + } finally { + setLoading(false) + } + } + + useEffect(() => { + if (periodId) fetchData() + }, [periodId]) + + const toggleCustomer = (customerId: string) => { + setExpandedCustomers((prev) => { + const next = new Set(prev) + if (next.has(customerId)) { + next.delete(customerId) + } else { + next.add(customerId) + } + return next + }) + } + + if (loading) { + return ( + + + Laddar kundreskontra... + + + ) + } + + if (error) { + return ( + + + + {error} + + + ) + } + + if (!data || !data.ledger) { + return ( + + + Ingen data tillgänglig. + + + ) + } + + const { ledger, reconciliation } = data + + return ( +
+ + {/* Summary cards */} +
+ + + Totalt utestående + + +

{formatAmount(ledger.total_outstanding)} kr

+

{ledger.unpaid_count} fakturor

+ {ledger.unconverted_fx_count > 0 && ( +

+ {ledger.unconverted_fx_count} faktura i utländsk valuta utan växelkurs är inte med i totalen. +

+ )} +
+
+ + + Ej förfallet + + +

{formatAmount(ledger.total_current)} kr

+
+
+ + + Förfallet + + +

{formatAmount(ledger.total_overdue)} kr

+
+
+
+ + {/* Aging table with expandable invoice details */} + {ledger.entries.length > 0 && ( + + + Ålderfördelning per kund + + +
+ + + + + + + + + + + + + + {ledger.entries.map((entry) => { + const isExpanded = expandedCustomers.has(entry.customer_id) + return ( + + toggleCustomer(entry.customer_id)} + > + + + + + + + + + + {isExpanded && ( + + )} + + ) + })} + + + + + + + + + + + + + +
KundEj förfallet1-30 dagar31-60 dagar61-90 dagar90+ dagarTotalt
+ {isExpanded ? ( + + ) : ( + + )} + {entry.customer_name}{entry.current > 0 ? formatAmount(entry.current) : ''}{entry.days_1_30 > 0 ? formatAmount(entry.days_1_30) : ''}{entry.days_31_60 > 0 ? formatAmount(entry.days_31_60) : ''}{entry.days_61_90 > 0 ? formatAmount(entry.days_61_90) : ''}{entry.days_90_plus > 0 ? formatAmount(entry.days_90_plus) : ''}{formatAmount(entry.total_outstanding)}
Summa{formatAmount(ledger.entries.reduce((s, e) => s + e.current, 0))}{formatAmount(ledger.entries.reduce((s, e) => s + e.days_1_30, 0))}{formatAmount(ledger.entries.reduce((s, e) => s + e.days_31_60, 0))}{formatAmount(ledger.entries.reduce((s, e) => s + e.days_61_90, 0))}{formatAmount(ledger.entries.reduce((s, e) => s + e.days_90_plus, 0))}{formatAmount(ledger.total_outstanding)}
+
+
+ )} + + {/* Reconciliation */} + {reconciliation && ( + + + Avstämning mot + + +
+
+ Kundreskontra (summa utestående) + {formatAmount(reconciliation.ar_ledger_total)} kr +
+
+ Kundfordringar ( + ) saldo + {formatAmount(reconciliation.account_1510_balance)} kr +
+
+ Differens + + {formatAmount(reconciliation.difference)} kr + +
+
+ {reconciliation.is_reconciled ? ( + Avstämd + ) : ( + Ej avstämd - kontrollera bokföring + )} + {reconciliation.unconverted_fx_count > 0 && ( +

+ {reconciliation.unconverted_fx_count} kundfaktura i utländsk valuta saknar växelkurs — differensen kan bero på saknade kursuppgifter snarare än felbokning. +

+ )} +
+
+
+
+ )} +
+ ) +} diff --git a/components/settings/SettingsHotkey.tsx b/components/settings/SettingsHotkey.tsx new file mode 100644 index 00000000..d6545f03 --- /dev/null +++ b/components/settings/SettingsHotkey.tsx @@ -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 +} diff --git a/components/settings/SettingsModal.tsx b/components/settings/SettingsModal.tsx new file mode 100644 index 00000000..f9fa078d --- /dev/null +++ b/components/settings/SettingsModal.tsx @@ -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 ( + + +
+ + {t('title')} + +
+ {t('description')} + +
+
+ ) +} diff --git a/components/settings/SettingsRail.tsx b/components/settings/SettingsRail.tsx new file mode 100644 index 00000000..c4cd5db8 --- /dev/null +++ b/components/settings/SettingsRail.tsx @@ -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 ( + + ) + } + + return ( + + ) +} diff --git a/components/settings/SettingsShell.tsx b/components/settings/SettingsShell.tsx new file mode 100644 index 00000000..26f16ccc --- /dev/null +++ b/components/settings/SettingsShell.tsx @@ -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 ( +
+ +
+
+ +
+ }> + {Section ?
: null} + +
+
+ ) + } + + return ( +
+ +
{children}
+
+ ) +} diff --git a/components/settings/SettingsSidebar.tsx b/components/settings/SettingsSidebar.tsx deleted file mode 100644 index bd74a6dd..00000000 --- a/components/settings/SettingsSidebar.tsx +++ /dev/null @@ -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 */} -
- -
- - {/* Desktop: horizontal tabs with bottom border */} - - - ) -} - -// Keep old name as alias for backward compat during transition -export const SettingsSidebar = SettingsNav diff --git a/components/settings/sections/AccountSettingsContent.tsx b/components/settings/sections/AccountSettingsContent.tsx new file mode 100644 index 00000000..ae7f6c3a --- /dev/null +++ b/components/settings/sections/AccountSettingsContent.tsx @@ -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 = { + sv: tCommon('language_swedish'), + en: tCommon('language_english'), + } + + return ( +
+ {/* Appearance */} +
+

+ {tSettings('section_appearance')} +

+ {mounted && ( +
+ {([ + { 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 }) => ( + + ))} +
+ )} +
+ + {/* Language */} +
+

+ {tSettings('section_language')} +

+

+ {tSettings('language_description')} +

+
+ {SUPPORTED_LOCALES.map((value) => ( + + ))} +
+
+ + {/* Security */} +
+ +
+ + {/* Calendar feed */} + {hasCalendarExtension && ( +
+ +
+ )} + + {/* Logout */} +
+ + + {tCommon('account_settings')} + + +
+
+

{tCommon('logout')}

+

{tCommon('logout_description')}

+
+ +
+
+
+
+ + {/* Privacy & agreements — surface the otherwise-unlinked DPA + privacy policy */} +
+ + + {tSettings('legal_title')} + + + + {tSettings('legal_privacy')} + + + + {tSettings('legal_dpa')} + + + + +
+ + {/* Delete account — only for non-sandbox */} + {!settings?.is_sandbox && } +
+ ) +} diff --git a/components/settings/sections/ApiSettingsContent.tsx b/components/settings/sections/ApiSettingsContent.tsx new file mode 100644 index 00000000..16197e4e --- /dev/null +++ b/components/settings/sections/ApiSettingsContent.tsx @@ -0,0 +1,13 @@ +'use client' + +import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel' +import { OAuthClientsPanel } from '@/components/settings/OAuthClientsPanel' + +export function ApiSettingsContent() { + return ( +
+ + +
+ ) +} diff --git a/components/settings/sections/AssistantSettingsContent.tsx b/components/settings/sections/AssistantSettingsContent.tsx new file mode 100644 index 00000000..bc54d288 --- /dev/null +++ b/components/settings/sections/AssistantSettingsContent.tsx @@ -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 ( + + + Minne + Kompetens + + + {/* Radix unmounts the inactive panel, so each panel's data is fetched + lazily the first time its tab is opened. */} + + + + + + + + ) +} diff --git a/components/settings/sections/BankingSettingsContent.tsx b/components/settings/sections/BankingSettingsContent.tsx new file mode 100644 index 00000000..a1c39f1b --- /dev/null +++ b/components/settings/sections/BankingSettingsContent.tsx @@ -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(null) + const [failedBankName, setFailedBankName] = useState(null) + const [isAccessDenied, setIsAccessDenied] = useState(false) + const syncInitiatedRef = useRef(false) + const abortControllerRef = useRef(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 ( +
+ {bankConnectionError && ( +
+ +
+

{bankConnectionError}

+ {isAccessDenied && failedBankName && ( +

+ {t('access_denied_hint', { bankName: failedBankName })} +

+ )} +

+ {t('import_fallback_text')}{t('import_fallback_link')}{t('import_fallback_suffix')} +

+
+ +
+ )} + + {hasBankingExtension && BankingPanel ? ( + <> + + + + ) : ( + + + +

{t('not_enabled_title')}

+

+ {t('not_enabled_description')} +

+ +
+
+ )} +
+ ) +} diff --git a/components/settings/sections/BookkeepingSettingsContent.tsx b/components/settings/sections/BookkeepingSettingsContent.tsx new file mode 100644 index 00000000..d565a502 --- /dev/null +++ b/components/settings/sections/BookkeepingSettingsContent.tsx @@ -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( + company?.accounting_framework ?? 'k2', + ) + + if (isLoading || !settings) return + + 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 = { + 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) => { + updateSettings(data as Partial) + }, + } + } + + // 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 ( +
+ {isAktiebolag && ( + setFramework(next)} + /> + )} + + {/* Accounting method */} +
+

+ {t('method_heading')} +

+
+ + +

+ {t('method_help')} +

+
+
+ + {/* Default voucher series */} +
+
+

+ {t('series_heading')} +

+
+ + +

+ {t('series_help')} +

+
+
+
+ + {/* Period locking */} +
+ +
+
+ + {/* Voucher series — per-source-type mapping */} +
+ +
+ + {/* Voucher series — read-only display */} +
+ +
+ + {/* Periodisering auto-detect toggle */} +
+ +
+ + {/* Cross-links */} +
+

+ {t('related_heading')} +

+
+ + + {t('related_fiscal_year')} + + + + {t('related_chart_of_accounts')} + +
+
+
+ ) +} diff --git a/components/settings/sections/CompanySettingsContent.tsx b/components/settings/sections/CompanySettingsContent.tsx new file mode 100644 index 00000000..99694a22 --- /dev/null +++ b/components/settings/sections/CompanySettingsContent.tsx @@ -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 + + function handleSave(formData: FormData) { + const updates: Record = { + ...(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) => { + updateSettings(data as Partial) + // 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 ( +
+ + + + +
+ updateSettings({ logo_url: url })} + /> +
+ +
+ +
+ + + + + + +
+ ) +} diff --git a/components/settings/sections/InvoicingSettingsContent.tsx b/components/settings/sections/InvoicingSettingsContent.tsx new file mode 100644 index 00000000..826a1370 --- /dev/null +++ b/components/settings/sections/InvoicingSettingsContent.tsx @@ -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 + + 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 = { + 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) => { + updateSettings(data as Partial) + }, + } + } + + return ( +
+
+ +
+ + + +
+ +
+
+ + {/* PDF settings — saves individually via toggle switches */} +
+ +
+
+ ) +} diff --git a/components/settings/sections/SalarySettingsContent.tsx b/components/settings/sections/SalarySettingsContent.tsx new file mode 100644 index 00000000..a4e1dcf9 --- /dev/null +++ b/components/settings/sections/SalarySettingsContent.tsx @@ -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 ( +
+ + + + + {t('accounting_heading')} + + +
+ + +

+ {t('voucher_series_help')} +

+
+
+
+ + + + {t('tax_tables_heading')} + + + +

+ {t('tax_tables_help')} +

+
+
+ + + + {t('vacation_heading')} + + +
+ + +
+
+ + +

+ {t('vacation_supplement_help')} +

+
+
+
+ + + + {t('info_heading')} + + +
+

{t('info_payroll_scope')}

+

+ {t.rich('info_current_year', { + strong: (chunks) => {chunks}, + })} +

+
+
+
+
+ ) +} diff --git a/components/settings/sections/TaxSettingsContent.tsx b/components/settings/sections/TaxSettingsContent.tsx new file mode 100644 index 00000000..dad0b855 --- /dev/null +++ b/components/settings/sections/TaxSettingsContent.tsx @@ -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 + + function handleSave(formData: FormData) { + const vatRegistered = formData.get('vat_registered') === 'true' + + const updates: Record = { + 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) => { + updateSettings(data as Partial) + }, + } + } + + const showSkatteverket = hasSkatteverketExtension && !isSandbox + + return ( +
+ + + + + {showSkatteverket && } +
+ ) +} diff --git a/components/settings/sections/TemplatesSettingsContent.tsx b/components/settings/sections/TemplatesSettingsContent.tsx new file mode 100644 index 00000000..79f85cfe --- /dev/null +++ b/components/settings/sections/TemplatesSettingsContent.tsx @@ -0,0 +1,13 @@ +'use client' + +import { BookingTemplatesPanel } from '@/components/settings/BookingTemplatesPanel' +import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel' + +export function TemplatesSettingsContent() { + return ( +
+ + +
+ ) +} diff --git a/components/settings/sections/index.ts b/components/settings/sections/index.ts new file mode 100644 index 00000000..c8a17385 --- /dev/null +++ b/components/settings/sections/index.ts @@ -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/
/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 = { + 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 diff --git a/components/settings/useSettingsNavItems.ts b/components/settings/useSettingsNavItems.ts new file mode 100644 index 00000000..dcdb417a --- /dev/null +++ b/components/settings/useSettingsNavItems.ts @@ -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 = [ + { 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 = { + 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 } +} diff --git a/lib/reports/catalog.ts b/lib/reports/catalog.ts new file mode 100644 index 00000000..53c07c92 --- /dev/null +++ b/lib/reports/catalog.ts @@ -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 = { + 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 = 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) +} diff --git a/messages/en.json b/messages/en.json index 8a9df048..beedea3c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -176,7 +176,16 @@ "assistant": "Assistant", "backup": "Backup", "account": "Account", - "api": "API" + "api": "API", + "group_account": "Account", + "group_company": "Company", + "group_accounting": "Accounting & tax", + "group_sales": "Sales", + "group_tools": "Tools & integrations" + }, + "settings_modal": { + "title": "Settings", + "description": "Manage your company and account" }, "settings": { "section_appearance": "Appearance", @@ -3628,6 +3637,34 @@ "sie_moved_hint": "SIE export now lives under Import/Export.", "sie_moved_link": "Open SIE export", "download_pdf": "Download PDF", + "download_excel": "Download Excel", + "export": "Export", + "recent_heading": "Recently opened", + "back_to_library": "Reports", + "switch_report": "Switch report", + "calendar_badge": "Calendar", + "group_payroll": "Payroll", + "group_export": "Export & archive", + "name_kpi": "Key figures", + "name_sie_export": "SIE export", + "desc_resultatrapport": "Revenue less costs for the period", + "desc_balansrapport": "Assets, liabilities and equity by account", + "desc_trial_balance": "All accounts with opening and closing balances", + "desc_kpi": "Margin, liquidity and other key figures", + "desc_income_statement": "Profit or loss in the statutory layout", + "desc_balance_sheet": "Financial position at the end of the period", + "desc_kassaflodesanalys": "Change in liquidity during the year", + "desc_arsredovisning": "Directors' report, notes and signatures", + "desc_vat_declaration": "Basis for the VAT return (boxes)", + "desc_periodisk_sammanstallning": "EU sales of goods and services", + "desc_ne_declaration": "NE appendix for sole traders", + "desc_ink2_declaration": "Income tax return 2 for limited companies", + "desc_huvudbok": "All transactions grouped by account", + "desc_grundbok": "Vouchers in registration order", + "desc_kundreskontra": "Outstanding receivables with aging", + "desc_supplier_ledger": "Outstanding payables with aging", + "desc_bank_reconciliation": "Reconcile bank transactions against the books", + "desc_sie_export": "Export the books as a SIE file", "categories_aria": "Report categories", "group_interim": "Interim", "group_year_end": "Year-end", diff --git a/messages/sv.json b/messages/sv.json index bbfca033..a2820909 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -176,7 +176,16 @@ "assistant": "Assistenten", "backup": "Säkerhetsbackup", "account": "Konto", - "api": "API" + "api": "API", + "group_account": "Konto", + "group_company": "Företag", + "group_accounting": "Bokföring & skatt", + "group_sales": "Försäljning", + "group_tools": "Verktyg & integrationer" + }, + "settings_modal": { + "title": "Inställningar", + "description": "Hantera ditt företag och konto" }, "settings": { "section_appearance": "Utseende", @@ -3628,6 +3637,34 @@ "sie_moved_hint": "SIE-export finns nu under Importera/Exportera.", "sie_moved_link": "Öppna SIE-export", "download_pdf": "Ladda ner PDF", + "download_excel": "Ladda ner Excel", + "export": "Exportera", + "recent_heading": "Senast öppnade", + "back_to_library": "Rapporter", + "switch_report": "Byt rapport", + "calendar_badge": "Kalender", + "group_payroll": "Lön", + "group_export": "Export & arkiv", + "name_kpi": "Nyckeltal", + "name_sie_export": "SIE-export", + "desc_resultatrapport": "Intäkter minus kostnader för perioden", + "desc_balansrapport": "Tillgångar, skulder och eget kapital per konto", + "desc_trial_balance": "Alla konton med ingående och utgående saldo", + "desc_kpi": "Marginal, likviditet och andra nyckeltal", + "desc_income_statement": "Årets resultat enligt uppställningsform", + "desc_balance_sheet": "Ekonomisk ställning vid periodens slut", + "desc_kassaflodesanalys": "Likviditetens förändring under året", + "desc_arsredovisning": "Förvaltningsberättelse, noter och underskrifter", + "desc_vat_declaration": "Underlag till momsdeklarationen (rutor)", + "desc_periodisk_sammanstallning": "EU-försäljning av varor och tjänster", + "desc_ne_declaration": "NE-bilaga för enskild firma", + "desc_ink2_declaration": "Inkomstdeklaration 2 för aktiebolag", + "desc_huvudbok": "Alla transaktioner grupperade per konto", + "desc_grundbok": "Verifikationer i registreringsordning", + "desc_kundreskontra": "Utestående kundfordringar med åldersfördelning", + "desc_supplier_ledger": "Utestående leverantörsskulder med åldersfördelning", + "desc_bank_reconciliation": "Stäm av banktransaktioner mot bokföringen", + "desc_sie_export": "Exportera bokföringen som SIE-fil", "categories_aria": "Rapportkategorier", "group_interim": "Löpande", "group_year_end": "Bokslut",