From c1630068b35f6f458142c06628743fa127003873 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:04:34 +0100 Subject: [PATCH] Feature/quick review scroll (#110) * fix: allow scrolling in QuickReviewDialog when content overflows viewport The dialog had no max-height or overflow handling, so tall content (e.g. extern representation with warnings + journal preview) was clipped with no way to scroll. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add detailed error logging to Enable Banking integration Adds structured console logging with full context (status codes, response bodies, connection IDs, account UIDs, stack traces) to all Enable Banking operations: connect, callback, sync, disconnect, and the API client retry/error paths. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add detailed error logging to login and register flows Co-Authored-By: Claude Opus 4.6 (1M context) * feat: implement KPI settings management with preferences and dynamic KPI display --------- Co-authored-by: Claude Opus 4.6 (1M context) --- app/(auth)/login/page.tsx | 65 ++++- app/(auth)/register/page.tsx | 36 ++- app/(dashboard)/kpi/page.tsx | 95 +++++--- .../enable-banking/callback/route.ts | 42 +++- .../enable-banking/sync/cron/route.ts | 18 +- app/api/kpi/preferences/route.ts | 81 +++++++ app/api/reports/kpi/route.ts | 89 +++++-- components/kpi/KPIHeroCards.tsx | 226 +++++++++++++----- components/kpi/KPISettingsDialog.tsx | 215 +++++++++++++++++ .../components/BankingSettingsPanel.tsx | 53 ++++ extensions/general/enable-banking/index.ts | 37 ++- .../general/enable-banking/lib/api-client.ts | 106 ++++++-- extensions/general/enable-banking/lib/sync.ts | 23 ++ lib/reports/kpi-definitions.ts | 132 ++++++++++ types/index.ts | 9 + 15 files changed, 1097 insertions(+), 130 deletions(-) create mode 100644 app/api/kpi/preferences/route.ts create mode 100644 components/kpi/KPISettingsDialog.tsx create mode 100644 lib/reports/kpi-definitions.ts diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index c24b33ba..f61eef60 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -31,12 +31,27 @@ export default function LoginPage() { const passwordValue = (formData.get('password') as string) || password try { - const { error } = await supabase.auth.signInWithPassword({ + console.log('[login] attempting signInWithPassword', { + email: emailValue, + hasPassword: !!passwordValue, + supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL, + }) + + const { data, error } = await supabase.auth.signInWithPassword({ email: emailValue, password: passwordValue, }) if (error) { + console.error('[login] signInWithPassword error', { + message: error.message, + code: error.code, + status: error.status, + name: error.name, + stack: error.stack, + cause: error.cause, + fullError: JSON.stringify(error, Object.getOwnPropertyNames(error)), + }) toast({ title: 'Inloggning misslyckades', description: error.message === 'Invalid login credentials' @@ -47,8 +62,25 @@ export default function LoginPage() { return } + console.log('[login] signInWithPassword success', { + userId: data.user?.id, + email: data.user?.email, + hasSession: !!data.session, + provider: data.user?.app_metadata?.provider, + }) + // Check MFA status - const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel() + const { data: aal, error: mfaError } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel() + if (mfaError) { + console.error('[login] MFA check error', { + message: mfaError.message, + code: mfaError.code, + status: mfaError.status, + fullError: JSON.stringify(mfaError, Object.getOwnPropertyNames(mfaError)), + }) + } + console.log('[login] MFA status', { currentLevel: aal?.currentLevel, nextLevel: aal?.nextLevel }) + if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') { router.push('/mfa/verify') return @@ -57,6 +89,13 @@ export default function LoginPage() { router.push('/') router.refresh() } catch (error) { + console.error('[login] unexpected exception', { + error, + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + type: typeof error, + constructor: error?.constructor?.name, + }) toast({ title: 'Inloggning misslyckades', description: getErrorMessage(error, { context: 'auth' }), @@ -75,11 +114,25 @@ export default function LoginPage() { const emailValue = (formData.get('email') as string) || email try { + console.log('[login] attempting resetPasswordForEmail', { + email: emailValue, + redirectTo: `${window.location.origin}/auth/callback?next=/reset-password`, + }) + const { error } = await supabase.auth.resetPasswordForEmail(emailValue, { redirectTo: `${window.location.origin}/auth/callback?next=/reset-password`, }) if (error) { + console.error('[login] resetPasswordForEmail error', { + message: error.message, + code: error.code, + status: error.status, + name: error.name, + stack: error.stack, + cause: error.cause, + fullError: JSON.stringify(error, Object.getOwnPropertyNames(error)), + }) toast({ title: 'Kunde inte skicka återställningslänk', description: getErrorMessage(error, { context: 'auth' }), @@ -88,6 +141,7 @@ export default function LoginPage() { return } + console.log('[login] resetPasswordForEmail success', { email: emailValue }) setEmail(emailValue) setIsEmailSent(true) toast({ @@ -95,6 +149,13 @@ export default function LoginPage() { description: 'Kolla din inkorg för att återställa lösenordet.', }) } catch (error) { + console.error('[login] resetPasswordForEmail unexpected exception', { + error, + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + type: typeof error, + constructor: error?.constructor?.name, + }) toast({ title: 'Kunde inte skicka återställningslänk', description: getErrorMessage(error, { context: 'auth' }), diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 82d41ed3..5da73a57 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -58,7 +58,15 @@ export default function RegisterPage() { } try { - const { error } = await supabase.auth.signUp({ + console.log('[register] attempting signUp', { + email: emailValue, + hasPassword: !!passwordValue, + passwordLength: passwordValue.length, + redirectTo: `${window.location.origin}/auth/callback`, + supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL, + }) + + const { data, error } = await supabase.auth.signUp({ email: emailValue, password: passwordValue, options: { @@ -67,6 +75,15 @@ export default function RegisterPage() { }) if (error) { + console.error('[register] signUp error', { + message: error.message, + code: error.code, + status: error.status, + name: error.name, + stack: error.stack, + cause: error.cause, + fullError: JSON.stringify(error, Object.getOwnPropertyNames(error)), + }) toast({ title: 'Registrering misslyckades', description: getErrorMessage(error, { context: 'auth' }), @@ -75,9 +92,26 @@ export default function RegisterPage() { return } + console.log('[register] signUp response', { + userId: data.user?.id, + email: data.user?.email, + isAnonymous: data.user?.is_anonymous, + identities: data.user?.identities?.length, + hasSession: !!data.session, + confirmationSentAt: data.user?.confirmation_sent_at, + provider: data.user?.app_metadata?.provider, + }) + setEmail(emailValue) setIsRegistered(true) } catch (error) { + console.error('[register] unexpected exception', { + error, + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + type: typeof error, + constructor: error?.constructor?.name, + }) toast({ title: 'Registrering misslyckades', description: getErrorMessage(error, { context: 'auth' }), diff --git a/app/(dashboard)/kpi/page.tsx b/app/(dashboard)/kpi/page.tsx index 4f9baf50..f692f2cd 100644 --- a/app/(dashboard)/kpi/page.tsx +++ b/app/(dashboard)/kpi/page.tsx @@ -1,59 +1,95 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { Label } from '@/components/ui/label' import { Card, CardContent } from '@/components/ui/card' import { KPIHeroCards } from '@/components/kpi/KPIHeroCards' import { KPITrendChart } from '@/components/kpi/KPITrendChart' -import type { FiscalPeriod, KPIReport } from '@/types' +import { KPISettingsDialog } from '@/components/kpi/KPISettingsDialog' +import { getDefaultPreferences } from '@/lib/reports/kpi-definitions' +import type { FiscalPeriod, KPIReport, KPIPreferences } from '@/types' export default function KpiPage() { const [periods, setPeriods] = useState([]) const [selectedPeriod, setSelectedPeriod] = useState('') const [report, setReport] = useState(null) + const [preferences, setPreferences] = useState(getDefaultPreferences()) const [isLoadingInit, setIsLoadingInit] = useState(true) const [isLoadingReport, setIsLoadingReport] = useState(false) + const [isSavingPrefs, setIsSavingPrefs] = useState(false) const [error, setError] = useState(null) useEffect(() => { - async function fetchPeriods() { + async function init() { try { - const res = await fetch('/api/bookkeeping/fiscal-periods') - const { data } = await res.json() - setPeriods(data || []) - if (data && data.length > 0) { - setSelectedPeriod(data[0].id) + const [periodsRes, prefsRes] = await Promise.all([ + fetch('/api/bookkeeping/fiscal-periods'), + fetch('/api/kpi/preferences'), + ]) + const { data: periodsData } = await periodsRes.json() + const { data: prefsData } = await prefsRes.json() + + setPeriods(periodsData || []) + if (prefsData) setPreferences(prefsData) + if (periodsData && periodsData.length > 0) { + setSelectedPeriod(periodsData[0].id) } } catch { - setError('Kunde inte hämta räkenskapsår') + setError('Kunde inte hämta data') } finally { setIsLoadingInit(false) } } - fetchPeriods() + init() + }, []) + + const fetchReport = useCallback(async (periodId: string) => { + setIsLoadingReport(true) + setError(null) + try { + const res = await fetch(`/api/reports/kpi?period_id=${periodId}`) + if (!res.ok) throw new Error('Kunde inte hämta nyckeltal') + const { data } = await res.json() + setReport(data) + } catch { + setError('Kunde inte hämta nyckeltal') + } finally { + setIsLoadingReport(false) + } }, []) useEffect(() => { if (!selectedPeriod) return let cancelled = false - async function fetchReport() { - setIsLoadingReport(true) - setError(null) - try { - const res = await fetch(`/api/reports/kpi?period_id=${selectedPeriod}`) - if (!res.ok) throw new Error('Kunde inte hämta nyckeltal') - const { data } = await res.json() - if (!cancelled) setReport(data) - } catch { - if (!cancelled) setError('Kunde inte hämta nyckeltal') - } finally { - if (!cancelled) setIsLoadingReport(false) - } - } - fetchReport() + fetchReport(selectedPeriod).then(() => { + if (cancelled) setReport(null) + }) return () => { cancelled = true } - }, [selectedPeriod]) + }, [selectedPeriod, fetchReport]) + + async function handleSavePreferences(prefs: KPIPreferences) { + setIsSavingPrefs(true) + try { + const res = await fetch('/api/kpi/preferences', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(prefs), + }) + if (!res.ok) throw new Error() + const { data } = await res.json() + setPreferences(data) + + // Re-fetch report if account overrides changed (calculations may differ) + if (selectedPeriod) { + await fetchReport(selectedPeriod) + } + } catch { + // Silently fail — user can retry + } finally { + setIsSavingPrefs(false) + } + } if (isLoadingInit) { return ( @@ -74,6 +110,11 @@ export default function KpiPage() {

Nyckeltal

Översikt av företagets ekonomiska hälsa

+ {/* Period selector */} @@ -106,7 +147,7 @@ export default function KpiPage() { {!isLoadingReport && !error && report && ( <> - + {report.months.length > 0 && } )} diff --git a/app/api/extensions/enable-banking/callback/route.ts b/app/api/extensions/enable-banking/callback/route.ts index 2dea897b..aada6ad4 100644 --- a/app/api/extensions/enable-banking/callback/route.ts +++ b/app/api/extensions/enable-banking/callback/route.ts @@ -81,7 +81,11 @@ export async function GET(request: Request) { .single() if (findError || !pendingConnection) { - console.error('No pending connection for oauth_state:', findError) + console.error('[enable-banking] No pending connection for oauth_state', { + findError: findError ? { message: findError.message, code: findError.code, details: findError.details } : null, + state, + hasCode: !!code, + }) return NextResponse.redirect( `${baseUrl}/settings?bank_error=${encodeURIComponent('invalid_state')}` ) @@ -89,10 +93,23 @@ export async function GET(request: Request) { const userId = pendingConnection.user_id + console.log('[enable-banking] Exchanging code for session', { + connectionId: pendingConnection.id, + userId, + codeLength: code.length, + }) + const sessionData = await createSession(code) const { session_id, accounts, access } = sessionData const consentExpiresAt = access.valid_until + console.log('[enable-banking] Session created successfully', { + connectionId: pendingConnection.id, + sessionId: session_id, + accountCount: accounts.length, + consentExpiresAt, + }) + const accountsWithBalances: StoredAccount[] = await Promise.all( accounts.map(async (account: AccountInfo) => { try { @@ -130,7 +147,12 @@ export async function GET(request: Request) { .eq('id', pendingConnection.id) if (updateError) { - throw new Error('Failed to update connection') + console.error('[enable-banking] Failed to update connection after session creation', { + connectionId: pendingConnection.id, + updateError: { message: updateError.message, code: updateError.code, details: updateError.details }, + sessionId: session_id, + }) + throw new Error(`Failed to update connection: ${updateError.message}`) } const connectionId = pendingConnection.id @@ -147,16 +169,24 @@ export async function GET(request: Request) { return NextResponse.redirect(`${baseUrl}${redirectTarget}`) } catch (error) { - console.error('Bank callback error:', error) + console.error('[enable-banking] Callback error', { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + name: error instanceof Error ? error.name : undefined, + state, + hasCode: !!code, + }) try { await supabase .from('bank_connections') - .update({ status: 'error', oauth_state: null }) + .update({ status: 'error', error_message: error instanceof Error ? error.message : 'Connection failed', oauth_state: null }) .eq('oauth_state', state) .eq('status', 'pending') - } catch { - // Ignore cleanup errors + } catch (cleanupError) { + console.error('[enable-banking] Callback cleanup failed', { + cleanupError: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + }) } return NextResponse.redirect( diff --git a/app/api/extensions/enable-banking/sync/cron/route.ts b/app/api/extensions/enable-banking/sync/cron/route.ts index 830ee6e5..7a1cd914 100644 --- a/app/api/extensions/enable-banking/sync/cron/route.ts +++ b/app/api/extensions/enable-banking/sync/cron/route.ts @@ -64,7 +64,11 @@ export async function GET(request: Request) { .limit(50) if (connError) { - console.error('Failed to fetch bank connections:', connError) + console.error('[bank-sync-cron] Failed to fetch bank connections', { + message: connError.message, + code: connError.code, + details: connError.details, + }) return NextResponse.json({ error: 'Failed to fetch connections' }, { status: 500 }) } @@ -174,7 +178,17 @@ export async function GET(request: Request) { }) } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error' - console.error(`Sync failed for connection ${connection.id}:`, error) + console.error('[bank-sync-cron] Sync failed for connection', { + connectionId: connection.id, + userId: connection.user_id, + bankName: connection.bank_name, + sessionId: connection.session_id, + consentExpires: connection.consent_expires, + lastSyncedAt: connection.last_synced_at, + message, + stack: error instanceof Error ? error.stack : undefined, + name: error instanceof Error ? error.name : undefined, + }) // Persist error status on sync failure await supabase diff --git a/app/api/kpi/preferences/route.ts b/app/api/kpi/preferences/route.ts new file mode 100644 index 00000000..624f8596 --- /dev/null +++ b/app/api/kpi/preferences/route.ts @@ -0,0 +1,81 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { mergeWithDefaults } from '@/lib/reports/kpi-definitions' +import type { KPIPreferences } from '@/types' + +const EXTENSION_ID = 'core/kpi' +const KEY = 'preferences' + +export async function GET() { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { data } = await supabase + .from('extension_data') + .select('value') + .eq('user_id', user.id) + .eq('extension_id', EXTENSION_ID) + .eq('key', KEY) + .single() + + const preferences = mergeWithDefaults((data?.value as Partial) ?? {}) + return NextResponse.json({ data: preferences }) +} + +export async function PUT(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + } + + const prefs = body as Partial + + // Validate account overrides — must be 4-digit numeric strings + if (prefs.accountOverrides) { + for (const [kpiId, accounts] of Object.entries(prefs.accountOverrides)) { + if (!Array.isArray(accounts)) { + return NextResponse.json( + { error: `accountOverrides.${kpiId} must be an array` }, + { status: 400 } + ) + } + for (const acc of accounts) { + if (typeof acc !== 'string' || !/^\d{4}$/.test(acc)) { + return NextResponse.json( + { error: `Invalid account number "${acc}" in ${kpiId} — must be 4 digits` }, + { status: 400 } + ) + } + } + } + } + + const merged = mergeWithDefaults(prefs) + + const { data, error } = await supabase + .from('extension_data') + .upsert( + { + user_id: user.id, + extension_id: EXTENSION_ID, + key: KEY, + value: merged, + }, + { onConflict: 'user_id,extension_id,key' } + ) + .select() + .single() + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data: data.value }) +} diff --git a/app/api/reports/kpi/route.ts b/app/api/reports/kpi/route.ts index 21bb981d..3ce36dee 100644 --- a/app/api/reports/kpi/route.ts +++ b/app/api/reports/kpi/route.ts @@ -4,8 +4,14 @@ import { generateIncomeStatement } from '@/lib/reports/income-statement' import { generateTrialBalance } from '@/lib/reports/trial-balance' import { generateARLedger } from '@/lib/reports/ar-ledger' import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown' -import { calculateCashPosition } from '@/lib/reports/kpi' -import type { KPIReport } from '@/types' +import { + calculateCashPosition, + calculateGrossMargin, + calculateExpenseRatio, + calculateAvgPaymentDays, +} from '@/lib/reports/kpi' +import { mergeWithDefaults } from '@/lib/reports/kpi-definitions' +import type { KPIReport, KPIPreferences } from '@/types' export async function GET(request: Request) { const supabase = await createClient() @@ -29,32 +35,87 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 }) } - const [incomeStatement, trialBalanceResult, arLedger, monthlyBreakdown] = + // Load user preferences for account overrides + const { data: prefsData } = await supabase + .from('extension_data') + .select('value') + .eq('user_id', user.id) + .eq('extension_id', 'core/kpi') + .eq('key', 'preferences') + .single() + + const preferences = mergeWithDefaults( + (prefsData?.value as Partial) ?? {} + ) + + const [incomeStatement, trialBalanceResult, arLedger, monthlyBreakdown, paidInvoicesResult] = await Promise.all([ generateIncomeStatement(supabase, user.id, periodId), generateTrialBalance(supabase, user.id, periodId), generateARLedger(supabase, user.id), generateMonthlyBreakdown(supabase, user.id, periodId), + supabase + .from('invoices') + .select('invoice_date, paid_at') + .eq('user_id', user.id) + .eq('status', 'paid') + .not('paid_at', 'is', null), ]) - // VAT liability from trial balance (output VAT - input VAT) - const vatOutputAccounts = ['2611', '2621', '2631'] - const vatInputAccounts = ['2641', '2645'] - const outputVat = trialBalanceResult.rows - .filter((r) => vatOutputAccounts.includes(r.account_number)) - .reduce((sum, r) => sum + (r.closing_credit - r.closing_debit), 0) - const inputVat = trialBalanceResult.rows - .filter((r) => vatInputAccounts.includes(r.account_number)) - .reduce((sum, r) => sum + (r.closing_debit - r.closing_credit), 0) + // Cash position — use account overrides if set + const cashOverrides = preferences.accountOverrides['cashPosition'] + let cashPosition: number + if (cashOverrides && cashOverrides.length > 0) { + const cashRows = trialBalanceResult.rows.filter((r) => + cashOverrides.includes(r.account_number) + ) + cashPosition = Math.round( + cashRows.reduce((sum, r) => sum + (r.closing_debit - r.closing_credit), 0) * 100 + ) / 100 + } else { + cashPosition = calculateCashPosition(trialBalanceResult.rows) + } + + // VAT liability — use account overrides if set + const vatOverrides = preferences.accountOverrides['vatLiability'] + let vatLiability: number + if (vatOverrides && vatOverrides.length > 0) { + const outputVat = trialBalanceResult.rows + .filter((r) => vatOverrides.includes(r.account_number) && r.account_number.startsWith('26') && !r.account_number.startsWith('264')) + .reduce((sum, r) => sum + (r.closing_credit - r.closing_debit), 0) + const inputVat = trialBalanceResult.rows + .filter((r) => vatOverrides.includes(r.account_number) && r.account_number.startsWith('264')) + .reduce((sum, r) => sum + (r.closing_debit - r.closing_credit), 0) + vatLiability = Math.round((outputVat - inputVat) * 100) / 100 + } else { + const vatOutputAccounts = ['2611', '2621', '2631'] + const vatInputAccounts = ['2641', '2645'] + const outputVat = trialBalanceResult.rows + .filter((r) => vatOutputAccounts.includes(r.account_number)) + .reduce((sum, r) => sum + (r.closing_credit - r.closing_debit), 0) + const inputVat = trialBalanceResult.rows + .filter((r) => vatInputAccounts.includes(r.account_number)) + .reduce((sum, r) => sum + (r.closing_debit - r.closing_credit), 0) + vatLiability = Math.round((outputVat - inputVat) * 100) / 100 + } + + // Avg payment days from paid invoices + const paidInvoices = (paidInvoicesResult.data ?? []).map((inv) => ({ + invoice_date: inv.invoice_date as string, + paid_at: inv.paid_at as string, + })) const report: KPIReport = { netResult: incomeStatement.net_result, - cashPosition: calculateCashPosition(trialBalanceResult.rows), + cashPosition, outstandingReceivables: arLedger.total_outstanding, overdueReceivables: arLedger.total_overdue, - vatLiability: Math.round((outputVat - inputVat) * 100) / 100, + vatLiability, totalRevenue: incomeStatement.total_revenue, totalExpenses: incomeStatement.total_expenses, + grossMargin: calculateGrossMargin(incomeStatement), + expenseRatio: calculateExpenseRatio(incomeStatement), + avgPaymentDays: calculateAvgPaymentDays(paidInvoices), periodComplete: period.is_closed, months: monthlyBreakdown.months, period: { start: period.period_start, end: period.period_end }, diff --git a/components/kpi/KPIHeroCards.tsx b/components/kpi/KPIHeroCards.tsx index 56853657..ad4d90e2 100644 --- a/components/kpi/KPIHeroCards.tsx +++ b/components/kpi/KPIHeroCards.tsx @@ -1,73 +1,183 @@ 'use client' +import { useState } from 'react' +import { Info, X } from 'lucide-react' import { Card, CardContent } from '@/components/ui/card' import { formatCurrency } from '@/lib/utils' -import type { KPIReport } from '@/types' +import { KPI_DEFINITIONS, getDefaultPreferences } from '@/lib/reports/kpi-definitions' +import type { KPIReport, KPIPreferences } from '@/types' interface KPIHeroCardsProps { report: KPIReport + preferences?: KPIPreferences } -export function KPIHeroCards({ report }: KPIHeroCardsProps) { +function getKPIValue( + report: KPIReport, + id: string +): { value: number | null; subtitle: string } { + switch (id) { + case 'netResult': + return { value: report.netResult, subtitle: 'netto' } + case 'cashPosition': + return { value: report.cashPosition, subtitle: 'likvida medel' } + case 'outstandingReceivables': + return { + value: report.outstandingReceivables, + subtitle: + report.overdueReceivables > 0 + ? `varav förfallet: ${formatCurrency(report.overdueReceivables)}` + : 'utestående', + } + case 'vatLiability': + return { + value: report.vatLiability, + subtitle: + report.vatLiability > 0 + ? 'att betala' + : report.vatLiability < 0 + ? 'att återfå' + : 'jämnt', + } + case 'grossMargin': + return { value: report.grossMargin, subtitle: 'av intäkter' } + case 'expenseRatio': + return { value: report.expenseRatio, subtitle: 'av intäkter' } + case 'avgPaymentDays': + return { value: report.avgPaymentDays, subtitle: 'snitt' } + default: + return { value: null, subtitle: '' } + } +} + +function formatKPIValue(value: number | null, format: string, id: string): string { + if (value === null) return '—' + if (format === 'currency') { + if (id === 'vatLiability') return formatCurrency(Math.abs(value)) + return formatCurrency(value) + } + if (format === 'percentage') return `${value}%` + if (format === 'days') return `${value} dagar` + return String(value) +} + +function getValueColor( + value: number | null, + colorLogic: string +): string { + if (value === null) return 'text-muted-foreground' + if (colorLogic === 'neutral') return '' + if (colorLogic === 'positive-good') { + return value >= 0 + ? 'text-[hsl(var(--chart-1))]' + : 'text-[hsl(var(--chart-2))]' + } + // negative-good (e.g. VAT: negative = refund = good, expense ratio: lower = better) + if (colorLogic === 'negative-good') { + return value <= 0 + ? 'text-[hsl(var(--chart-1))]' + : 'text-[hsl(var(--chart-2))]' + } + return '' +} + +export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) { + const prefs = preferences ?? getDefaultPreferences() + const [infoOpen, setInfoOpen] = useState(null) + + // Build ordered, visible list + const visibleDefs = prefs.kpiOrder + .map((id) => KPI_DEFINITIONS.find((d) => d.id === id)) + .filter((d) => d && prefs.visibleKpis.includes(d.id)) as typeof KPI_DEFINITIONS + + if (visibleDefs.length === 0) { + return ( + + + Inga nyckeltal valda. Klicka på "Anpassa" för att välja vilka som ska visas. + + + ) + } + + // Responsive grid: 2 cols on mobile, up to 4 on desktop + const gridCols = + visibleDefs.length <= 2 + ? 'grid-cols-2' + : visibleDefs.length === 3 + ? 'grid-cols-2 md:grid-cols-3' + : 'grid-cols-2 md:grid-cols-4' + return ( -
- {/* Net result */} - - -

Resultat

-

= 0 ? 'text-[hsl(var(--chart-1))]' : 'text-[hsl(var(--chart-2))]' - }`}> - {formatCurrency(report.netResult)} -

-

netto

-
-
+
+ {visibleDefs.map((def) => { + const { value, subtitle } = getKPIValue(report, def.id) + const formatted = formatKPIValue(value, def.format, def.id) + const color = getValueColor(value, def.colorLogic) + const showInfo = infoOpen === def.id + const hasOverride = + prefs.accountOverrides[def.id] && + prefs.accountOverrides[def.id].length > 0 - {/* Cash position */} - - -

Kassa

-

0 ? 'text-[hsl(var(--chart-1))]' : 'text-[hsl(var(--chart-2))]' - }`}> - {formatCurrency(report.cashPosition)} -

-

likvida medel

-
-
+ return ( + + +
+

+ {def.label} +

+ +
- {/* Outstanding receivables */} - - -

Kundfordringar

-

- {formatCurrency(report.outstandingReceivables)} -

- {report.overdueReceivables > 0 ? ( -

- varav förfallet: {formatCurrency(report.overdueReceivables)} -

- ) : ( -

utestående

- )} -
-
- - {/* VAT liability */} - - -

Moms

-

0 ? 'text-[hsl(var(--chart-2))]' : 'text-[hsl(var(--chart-1))]' - }`}> - {formatCurrency(Math.abs(report.vatLiability))} -

-

- {report.vatLiability > 0 ? 'att betala' : report.vatLiability < 0 ? 'att återfå' : 'jämnt'} -

-
-
+ {showInfo ? ( +
+

{def.description}

+
+ Formel: + {def.formula} +
+
+ Konton: + {def.accountDescription} +
+ {hasOverride && ( +
+ Anpassade konton: + + {prefs.accountOverrides[def.id].join(', ')} + +
+ )} +
+ ) : ( + <> +

+ {formatted} +

+

+ {subtitle} +

+ + )} +
+
+ ) + })}
) } diff --git a/components/kpi/KPISettingsDialog.tsx b/components/kpi/KPISettingsDialog.tsx new file mode 100644 index 00000000..53542903 --- /dev/null +++ b/components/kpi/KPISettingsDialog.tsx @@ -0,0 +1,215 @@ +'use client' + +import { useState } from 'react' +import { Settings2, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogTrigger, + DialogClose, +} from '@/components/ui/dialog' +import { Switch } from '@/components/ui/switch' +import { KPI_DEFINITIONS, getDefaultPreferences } from '@/lib/reports/kpi-definitions' +import type { KPIPreferences } from '@/types' + +interface KPISettingsDialogProps { + preferences: KPIPreferences + onSave: (prefs: KPIPreferences) => void + saving: boolean +} + +export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDialogProps) { + const [draft, setDraft] = useState(preferences) + const [expandedKpi, setExpandedKpi] = useState(null) + const [open, setOpen] = useState(false) + + function handleOpen(isOpen: boolean) { + if (isOpen) setDraft(preferences) + setOpen(isOpen) + } + + function toggleKpi(id: string) { + setDraft((prev) => { + const visible = prev.visibleKpis.includes(id) + ? prev.visibleKpis.filter((k) => k !== id) + : [...prev.visibleKpis, id] + return { ...prev, visibleKpis: visible } + }) + } + + function setAccountOverride(kpiId: string, value: string) { + const accounts = value + .split(',') + .map((s) => s.trim()) + .filter((s) => /^\d{4}$/.test(s)) + + setDraft((prev) => ({ + ...prev, + accountOverrides: { + ...prev.accountOverrides, + [kpiId]: accounts, + }, + })) + } + + function clearAccountOverride(kpiId: string) { + setDraft((prev) => { + const overrides = { ...prev.accountOverrides } + delete overrides[kpiId] + return { ...prev, accountOverrides: overrides } + }) + } + + function handleReset() { + setDraft(getDefaultPreferences()) + } + + function handleSave() { + onSave(draft) + setOpen(false) + } + + return ( + + + + + + + Anpassa nyckeltal + + Välj vilka nyckeltal som visas och justera beräkningarna. + + + +
+ {KPI_DEFINITIONS.map((def) => { + const isVisible = draft.visibleKpis.includes(def.id) + const isExpanded = expandedKpi === def.id + const hasOverride = + def.customizableAccounts && + draft.accountOverrides[def.id] && + draft.accountOverrides[def.id].length > 0 + const overrideValue = + draft.accountOverrides[def.id]?.join(', ') ?? '' + + return ( +
+
+ + toggleKpi(def.id)} + /> +
+ + {isExpanded && ( +
+

{def.description}

+
+

+ Formel +

+

+ {def.formula} +

+
+
+

+ Konton +

+

{def.accountDescription}

+
+ + {def.customizableAccounts && ( +
+ + + setAccountOverride(def.id, e.target.value) + } + placeholder={def.defaultAccounts.join(', ')} + className="w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-xs font-mono tabular-nums placeholder:text-muted-foreground/50" + /> +

+ Ange kontonummer separerade med komma (t.ex.{' '} + {def.defaultAccounts.slice(0, 3).join(', ')}) +

+ {hasOverride && ( + + )} +
+ )} +
+ )} +
+ ) + })} +
+ +
+ +
+ + + + +
+
+
+
+ ) +} diff --git a/extensions/general/enable-banking/components/BankingSettingsPanel.tsx b/extensions/general/enable-banking/components/BankingSettingsPanel.tsx index 0f9367d2..507f7e48 100644 --- a/extensions/general/enable-banking/components/BankingSettingsPanel.tsx +++ b/extensions/general/enable-banking/components/BankingSettingsPanel.tsx @@ -53,6 +53,11 @@ export default function BankingSettingsPanel() { setConnectingBankName(bank.name) try { + console.log('[enable-banking] Initiating bank connection', { + bankName: bank.name, + bankCountry: bank.country, + }) + const response = await fetch('/api/extensions/ext/enable-banking/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -62,11 +67,26 @@ export default function BankingSettingsPanel() { const data = await response.json() if (!response.ok) { + console.error('[enable-banking] Connect request failed', { + status: response.status, + statusText: response.statusText, + error: data.error, + bankName: bank.name, + }) throw new Error(data.error) } + console.log('[enable-banking] Redirecting to bank authorization', { + connectionId: data.connection_id, + hasAuthUrl: !!data.authorization_url, + }) window.location.href = data.authorization_url } catch (error) { + console.error('[enable-banking] Connect flow failed', { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + bankName: bank.name, + }) toast({ title: 'Fel', description: error instanceof Error ? error.message : 'Kunde inte ansluta bank', @@ -82,6 +102,8 @@ export default function BankingSettingsPanel() { setSyncingConnectionId(connectionId) try { + console.log('[enable-banking] Starting sync', { connectionId }) + const response = await fetch('/api/extensions/ext/enable-banking/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -91,9 +113,21 @@ export default function BankingSettingsPanel() { const data = await response.json() if (!response.ok) { + console.error('[enable-banking] Sync request failed', { + status: response.status, + statusText: response.statusText, + error: data.error, + connectionId, + }) throw new Error(data.error) } + console.log('[enable-banking] Sync completed', { + connectionId, + imported: data.imported, + duplicates: data.duplicates, + }) + toast({ title: 'Synkronisering klar', description: `${data.imported} nya transaktioner importerade`, @@ -102,6 +136,11 @@ export default function BankingSettingsPanel() { setShowCsvFallback(false) fetchConnections() } catch (error) { + console.error('[enable-banking] Sync flow failed', { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + connectionId, + }) toast({ title: 'Fel', description: error instanceof Error ? error.message : 'Synkronisering misslyckades', @@ -123,6 +162,8 @@ export default function BankingSettingsPanel() { if (!ok) return try { + console.log('[enable-banking] Disconnecting bank', { connectionId }) + const response = await fetch('/api/extensions/ext/enable-banking/disconnect', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, @@ -131,15 +172,27 @@ export default function BankingSettingsPanel() { if (!response.ok) { const data = await response.json() + console.error('[enable-banking] Disconnect request failed', { + status: response.status, + statusText: response.statusText, + error: data.error, + connectionId, + }) throw new Error(data.error || 'Disconnect failed') } + console.log('[enable-banking] Bank disconnected', { connectionId }) toast({ title: 'Bank bortkopplad', description: 'Bankanslutningen och PSD2-samtycket har återkallats', }) fetchConnections() } catch (error) { + console.error('[enable-banking] Disconnect flow failed', { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + connectionId, + }) toast({ title: 'Fel', description: error instanceof Error ? error.message : 'Kunde inte koppla bort bank', diff --git a/extensions/general/enable-banking/index.ts b/extensions/general/enable-banking/index.ts index c8f4470d..f84c2c20 100644 --- a/extensions/general/enable-banking/index.ts +++ b/extensions/general/enable-banking/index.ts @@ -148,8 +148,14 @@ export const enableBankingExtension: Extension = { .single() if (error) { - log.error('Database error:', error) - throw new Error('Failed to store connection') + log.error('[enable-banking] Database error storing connection', { + errorMessage: error.message, + errorCode: error.code, + errorDetails: error.details, + user_id: user.id, + bank: aspsp_name, + }) + throw new Error(`Failed to store connection: ${error.message}`) } return NextResponse.json({ @@ -157,7 +163,14 @@ export const enableBankingExtension: Extension = { authorization_url: url, }) } catch (error) { - log.error('Bank connection error:', error) + log.error('[enable-banking] Connect handler error', { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + name: error instanceof Error ? error.name : undefined, + user_id: user.id, + aspsp_name, + aspsp_country, + }) return NextResponse.json( { error: error instanceof Error ? error.message : 'Connection failed' }, { status: 500 } @@ -256,7 +269,15 @@ export const enableBankingExtension: Extension = { last_synced_at: syncedAt, }) } catch (error) { - log.error('Sync error:', error) + log.error('[enable-banking] Sync handler error', { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + name: error instanceof Error ? error.name : undefined, + user_id: user.id, + connection_id, + connectionStatus: connection.status, + bankName: connection.bank_name, + }) return NextResponse.json( { error: error instanceof Error ? error.message : 'Sync failed' }, { status: 500 } @@ -298,8 +319,12 @@ export const enableBankingExtension: Extension = { try { await deleteSession(connection.session_id) } catch (error) { - // Consent may already be expired — log and continue - log.error('Failed to revoke PSD2 session (may be expired):', error) + log.error('[enable-banking] Failed to revoke PSD2 session (may be expired)', { + message: error instanceof Error ? error.message : String(error), + sessionId: connection.session_id, + connectionId: connection_id, + connectionStatus: connection.status, + }) } } diff --git a/extensions/general/enable-banking/lib/api-client.ts b/extensions/general/enable-banking/lib/api-client.ts index a35a155b..aab99315 100644 --- a/extensions/general/enable-banking/lib/api-client.ts +++ b/extensions/general/enable-banking/lib/api-client.ts @@ -187,6 +187,10 @@ async function authenticatedFetchWithRetry( try { const response = await authenticatedFetch(endpoint, options) if (attempt < MAX_RETRIES && [429, 502, 503, 504].includes(response.status)) { + console.warn(`[enable-banking] Retrying ${endpoint} (attempt ${attempt + 1}/${MAX_RETRIES})`, { + status: response.status, + statusText: response.statusText, + }) await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS * (attempt + 1))) continue } @@ -194,9 +198,16 @@ async function authenticatedFetchWithRetry( } catch (error: unknown) { const isAbort = error instanceof Error && error.name === 'AbortError' if (attempt < MAX_RETRIES && isAbort) { + console.warn(`[enable-banking] Request timeout, retrying ${endpoint} (attempt ${attempt + 1}/${MAX_RETRIES})`) await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS * (attempt + 1))) continue } + console.error(`[enable-banking] Request failed for ${endpoint}`, { + attempt, + error: error instanceof Error ? error.message : String(error), + name: error instanceof Error ? error.name : undefined, + isTimeout: isAbort, + }) throw error } } @@ -220,9 +231,17 @@ export async function getASPSPs(country: string = 'SE'): Promise { const response = await authenticatedFetchWithRetry(`/aspsps?${params.toString()}`) if (!response.ok) { - const error = await response.text() - console.error('Failed to fetch ASPSPs:', error) - throw new Error('Failed to fetch banks') + const body = await response.text() + console.error('[enable-banking] getASPSPs failed', { + status: response.status, + statusText: response.statusText, + body, + country, + psuType, + sandbox: isSandbox, + apiUrl: ENABLE_BANKING_API_URL, + }) + throw new Error(`Failed to fetch banks (${response.status})`) } const data = await response.json() @@ -294,9 +313,19 @@ export async function startAuthorization( }) if (!response.ok) { - const error = await response.text() - console.error('Failed to start authorization:', error) - throw new Error('Failed to start bank connection') + const body = await response.text() + console.error('[enable-banking] startAuthorization failed', { + status: response.status, + statusText: response.statusText, + body, + aspspName, + aspspCountry, + psuType, + redirectUrl, + apiUrl: ENABLE_BANKING_API_URL, + requestBody: JSON.stringify(requestBody), + }) + throw new Error(`Failed to start bank connection (${response.status}): ${body}`) } return response.json() @@ -314,9 +343,16 @@ export async function createSession(code: string): Promise { }) if (!response.ok) { - const error = await response.text() - console.error('Failed to create session:', error) - throw new Error('Failed to create bank session') + const body = await response.text() + console.error('[enable-banking] createSession failed', { + status: response.status, + statusText: response.statusText, + body, + hasCode: !!code, + codeLength: code?.length, + apiUrl: ENABLE_BANKING_API_URL, + }) + throw new Error(`Failed to create bank session (${response.status}): ${body}`) } return response.json() @@ -331,7 +367,14 @@ export async function getSession(sessionId: string): Promise { const response = await authenticatedFetchWithRetry(`/sessions/${sessionId}`) if (!response.ok) { - throw new Error('Failed to get session') + const body = await response.text() + console.error('[enable-banking] getSession failed', { + status: response.status, + statusText: response.statusText, + body, + sessionId, + }) + throw new Error(`Failed to get session (${response.status}): ${body}`) } return response.json() @@ -348,7 +391,14 @@ export async function deleteSession(sessionId: string): Promise { }) if (!response.ok) { - throw new Error('Failed to revoke session') + const body = await response.text() + console.error('[enable-banking] deleteSession failed', { + status: response.status, + statusText: response.statusText, + body, + sessionId, + }) + throw new Error(`Failed to revoke session (${response.status}): ${body}`) } } @@ -361,7 +411,14 @@ export async function getAccountBalances(accountUid: string): Promise const response = await authenticatedFetchWithRetry(`/accounts/${accountUid}/balances`) if (!response.ok) { - throw new Error('Failed to get account balances') + const body = await response.text() + console.error('[enable-banking] getAccountBalances failed', { + status: response.status, + statusText: response.statusText, + body, + accountUid, + }) + throw new Error(`Failed to get account balances (${response.status}): ${body}`) } const data: BalanceResponse = await response.json() @@ -417,7 +474,17 @@ export async function getAccountTransactions( const response = await authenticatedFetchWithRetry(endpoint) if (!response.ok) { - throw new Error('Failed to get transactions') + const body = await response.text() + console.error('[enable-banking] getAccountTransactions failed', { + status: response.status, + statusText: response.statusText, + body, + accountUid, + dateFrom, + dateTo, + hasContinuationKey: !!continuationKey, + }) + throw new Error(`Failed to get transactions (${response.status}): ${body}`) } return response.json() @@ -482,7 +549,18 @@ export async function getAllTransactionsWithRaw( const response = await authenticatedFetchWithRetry(endpoint) if (!response.ok) { - throw new Error('Failed to get transactions') + const body = await response.text() + console.error('[enable-banking] getAllTransactionsWithRaw failed', { + status: response.status, + statusText: response.statusText, + body, + accountUid, + dateFrom, + dateTo, + page, + hasContinuationKey: !!continuationKey, + }) + throw new Error(`Failed to get transactions (${response.status}): ${body}`) } const rawText = await response.text() diff --git a/extensions/general/enable-banking/lib/sync.ts b/extensions/general/enable-banking/lib/sync.ts index 999ec5a0..a3c2524c 100644 --- a/extensions/general/enable-banking/lib/sync.ts +++ b/extensions/general/enable-banking/lib/sync.ts @@ -38,12 +38,27 @@ export async function syncAccountTransactions( toDate: string, ingest: IngestFn = defaultIngest ): Promise { + console.log('[enable-banking] syncAccountTransactions starting', { + connectionId, + accountUid: account.uid, + accountIban: account.iban, + fromDate, + toDate, + }) + const { transactions, rawPages } = await getAllTransactionsWithRaw( account.uid, fromDate, toDate, ) + console.log('[enable-banking] Fetched transactions from API', { + connectionId, + accountUid: account.uid, + transactionCount: transactions.length, + rawPageCount: rawPages.length, + }) + const bankTransactions = transactions.map(tx => convertTransaction(tx, account.currency)) // Convert Enable Banking format to generic RawTransaction @@ -62,6 +77,14 @@ export async function syncAccountTransactions( const ingestResult = await ingest(supabase, userId, rawTransactions) + console.log('[enable-banking] Ingest result', { + connectionId, + accountUid: account.uid, + imported: ingestResult.imported, + duplicates: ingestResult.duplicates, + errors: ingestResult.errors, + }) + // Archive raw PSD2 API responses as räkenskapsinformation (BFL 7 kap) for (let i = 0; i < rawPages.length; i++) { try { diff --git a/lib/reports/kpi-definitions.ts b/lib/reports/kpi-definitions.ts new file mode 100644 index 00000000..52b2f6b8 --- /dev/null +++ b/lib/reports/kpi-definitions.ts @@ -0,0 +1,132 @@ +import type { KPIPreferences } from '@/types' + +export interface KPIDefinition { + id: string + label: string + subtitle: string + description: string + formula: string + defaultAccounts: string[] + accountDescription: string + customizableAccounts: boolean + defaultVisible: boolean + format: 'currency' | 'percentage' | 'days' + colorLogic: 'positive-good' | 'negative-good' | 'neutral' +} + +export const KPI_DEFINITIONS: KPIDefinition[] = [ + { + id: 'netResult', + label: 'Resultat', + subtitle: 'netto', + description: 'Nettoresultat för perioden (intäkter minus kostnader)', + formula: 'Totala intäkter − Totala kostnader ± Finansiella poster', + defaultAccounts: [], + accountDescription: 'Intäkter (klass 3), Kostnader (klass 4–7), Finansiellt (klass 8)', + customizableAccounts: false, + defaultVisible: true, + format: 'currency', + colorLogic: 'positive-good', + }, + { + id: 'cashPosition', + label: 'Kassa', + subtitle: 'likvida medel', + description: 'Totala likvida medel (bank- och kassakonton)', + formula: 'Summa utgående saldon för valda 19xx-konton', + defaultAccounts: ['1910', '1920', '1930', '1940', '1950', '1960', '1970', '1980'], + accountDescription: 'Bank- och kassakonton (19xx)', + customizableAccounts: true, + defaultVisible: true, + format: 'currency', + colorLogic: 'positive-good', + }, + { + id: 'outstandingReceivables', + label: 'Kundfordringar', + subtitle: 'utestående', + description: 'Utestående kundfordringar', + formula: 'Summa obetalda kundfakturor', + defaultAccounts: ['1510'], + accountDescription: 'Kundfordringar (1510)', + customizableAccounts: false, + defaultVisible: true, + format: 'currency', + colorLogic: 'neutral', + }, + { + id: 'vatLiability', + label: 'Moms', + subtitle: '', + description: 'Momsskuld eller momsfordran för perioden', + formula: 'Utgående moms (2611 + 2621 + 2631) − Ingående moms (2641 + 2645)', + defaultAccounts: ['2611', '2621', '2631', '2641', '2645'], + accountDescription: 'Utgående moms (2611, 2621, 2631), Ingående moms (2641, 2645)', + customizableAccounts: true, + defaultVisible: true, + format: 'currency', + colorLogic: 'negative-good', + }, + { + id: 'grossMargin', + label: 'Bruttomarginal', + subtitle: 'av intäkter', + description: 'Andel av intäkterna som blir kvar efter varuinköp', + formula: '(Intäkter − Varukostnad klass 4) ÷ Intäkter × 100', + defaultAccounts: [], + accountDescription: 'Intäkter (klass 3), Varor och material (klass 4)', + customizableAccounts: false, + defaultVisible: false, + format: 'percentage', + colorLogic: 'positive-good', + }, + { + id: 'expenseRatio', + label: 'Kostnadsandel', + subtitle: 'av intäkter', + description: 'Andel av intäkterna som går till kostnader', + formula: 'Totala kostnader ÷ Totala intäkter × 100', + defaultAccounts: [], + accountDescription: 'Intäkter (klass 3), Kostnader (klass 4–7)', + customizableAccounts: false, + defaultVisible: false, + format: 'percentage', + colorLogic: 'negative-good', + }, + { + id: 'avgPaymentDays', + label: 'Betalningstid', + subtitle: 'snitt', + description: 'Genomsnittligt antal dagar till kundbetalning', + formula: 'Summa betaldagar ÷ Antal betalda fakturor (minst 5 krävs)', + defaultAccounts: [], + accountDescription: 'Beräknas från betalda kundfakturor, inte konton', + customizableAccounts: false, + defaultVisible: false, + format: 'days', + colorLogic: 'negative-good', + }, +] + +export const ALL_KPI_IDS = KPI_DEFINITIONS.map((d) => d.id) + +export function getKPIDefinition(id: string): KPIDefinition | undefined { + return KPI_DEFINITIONS.find((d) => d.id === id) +} + +export function getDefaultPreferences(): KPIPreferences { + return { + visibleKpis: KPI_DEFINITIONS.filter((d) => d.defaultVisible).map((d) => d.id), + kpiOrder: ALL_KPI_IDS, + accountOverrides: {}, + } +} + +export function mergeWithDefaults(prefs: Partial): KPIPreferences { + const defaults = getDefaultPreferences() + return { + visibleKpis: prefs.visibleKpis ?? defaults.visibleKpis, + kpiOrder: prefs.kpiOrder ?? defaults.kpiOrder, + accountOverrides: prefs.accountOverrides ?? defaults.accountOverrides, + } +} diff --git a/types/index.ts b/types/index.ts index 03cb570a..942b25e4 100644 --- a/types/index.ts +++ b/types/index.ts @@ -2027,7 +2027,16 @@ export interface KPIReport { vatLiability: number // SEK, ruta 49 (positive = owe, negative = refund) totalRevenue: number // SEK totalExpenses: number // SEK + grossMargin: number | null // percentage, null if no revenue + expenseRatio: number | null // percentage, null if no revenue + avgPaymentDays: number | null // days, null if fewer than 5 paid invoices periodComplete: boolean // whether selected period is closed/complete months: { label: string; income: number; expenses: number; net: number }[] period: { start: string; end: string } } + +export interface KPIPreferences { + visibleKpis: string[] + kpiOrder: string[] + accountOverrides: Record +}