feat(mileage): surface Körjournal in the nav behind a settings toggle (#1540)

* feat(mileage): surface Körjournal in the nav behind a settings toggle

The /mileage page shipped hidden: the route works but no nav row points at
it. Add company_settings.mileage_enabled (mirroring dimensions_enabled) with
a switch in Fönster -> Bokföring, and show the Arbeta nav row when the toggle
is on OR the company already has mileage_trips rows, the same hybrid gate as
webshop orders, so trips created via API/MCP can never become invisible
underlag. UI visibility only, never load-bearing for correctness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(migrations): move mileage_enabled migration after already-applied 20260812153208

origin/main merged in 20260812153208 which prod has already applied; a new
file sorting before it risks an out-of-order db push abort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-12 21:48:01 +02:00
committed by GitHub
parent 67febd5097
commit 45d7f1be4e
12 changed files with 191 additions and 6 deletions
+15
View File
@@ -152,6 +152,7 @@ export default async function DashboardLayout({
{ data: allSettingsNames },
{ data: userPrefs },
hasWebshop,
hasMileageTrips,
] = await Promise.all([
supabase.from('companies').select('*').eq('id', companyId).single(),
supabase.from('company_members').select('role').eq('company_id', companyId).eq('user_id', user.id).single(),
@@ -195,6 +196,16 @@ export default async function DashboardLayout({
([woo, orders]) =>
(woo.data?.length ?? 0) > 0 || (orders.data?.length ?? 0) > 0,
),
// Whether the company already has mileage trips: OR-ed with the
// mileage_enabled settings toggle below so trips created via API/MCP can
// never be invisible underlag even if nobody flipped the toggle. Indexed
// limit-1 select, same accepted first-paint cost as the webshop gate.
supabase
.from('mileage_trips')
.select('id')
.eq('company_id', companyId)
.limit(1)
.then((trips) => (trips.data?.length ?? 0) > 0),
])
// company_id -> current display name for every company the user belongs to.
@@ -267,6 +278,9 @@ export default async function DashboardLayout({
// mechanism as paysSalaries: UI gate only, never load-bearing for
// correctness (dimensions plan §2).
const dimensionsEnabled = settings?.dimensions_enabled ?? false
// Körjournal visibility: the settings toggle is the normal way in, existing
// trips force the row on so already-created data stays reachable.
const hasMileage = (settings?.mileage_enabled ?? false) || hasMileageTrips
const companyWithName = {
...companyRow,
name: displayName,
@@ -336,6 +350,7 @@ export default async function DashboardLayout({
paysSalaries={paysSalaries}
dimensionsEnabled={dimensionsEnabled}
hasWebshop={hasWebshop}
hasMileage={hasMileage}
isSandbox={isSandbox}
extensionNavItems={getExtensionNavItems()}
userName={userProfile?.full_name ?? null}
+1 -1
View File
@@ -37,7 +37,7 @@ export const getDashboardSettings = cache(async () => {
return supabase
.from('company_settings')
.select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox, dimensions_enabled, ore_rounding, initial_setup_path, initial_setup_completed_at, initial_setup_dismissed_at, vat_registered, moms_period')
.select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox, dimensions_enabled, mileage_enabled, ore_rounding, initial_setup_path, initial_setup_completed_at, initial_setup_dismissed_at, vat_registered, moms_period')
.eq('company_id', companyId)
.maybeSingle()
})
+19
View File
@@ -97,6 +97,25 @@ describe('PUT /api/settings', () => {
expect(deadlineMocks.regenerate).not.toHaveBeenCalled()
})
it('accepts the mileage_enabled visibility toggle', async () => {
enqueueMany([
{ data: { entity_type: 'enskild_firma', onboarding_complete: true } }, // fetch oldSettings
{ data: { id: 's1', mileage_enabled: true } }, // update ... returning
{ data: null, count: 5 }, // deadlines count (has some -> no regen)
])
const request = createMockRequest('/api/settings', {
method: 'PUT',
body: { mileage_enabled: true },
})
const response = await PUT(request, { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ data: { mileage_enabled: boolean } }>(response)
expect(status).toBe(200)
expect(body.data.mileage_enabled).toBe(true)
expect(deadlineMocks.regenerate).not.toHaveBeenCalled()
})
it('round-trips share capital fields and clears them with null', async () => {
const updates = { aktiekapital: 25000, antal_aktier: 500 }
enqueueMany([
+17 -3
View File
@@ -46,6 +46,7 @@ import {
Library,
BookCheck,
ShoppingCart,
Car,
} from 'lucide-react'
import { getBranding } from '@/lib/branding/service'
import { ENABLED_EXTENSION_IDS as _ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
@@ -86,6 +87,10 @@ interface DashboardNavProps {
// connection, or existing webshop_orders rows). Drives visibility of the
// Order row: same mechanism as paysSalaries, fetched by the layout.
hasWebshop?: boolean
// Whether the Körjournal row shows: the company_settings.mileage_enabled
// toggle OR existing mileage_trips rows (trips created via API/MCP must
// stay reachable). Computed by the dashboard layout.
hasMileage?: boolean
isSandbox?: boolean
extensionNavItems?: ExtensionNavItem[]
// Signed-in user's full name + email: drives the bottom-left account
@@ -172,6 +177,10 @@ interface NavItem {
// WooCommerce/Shopify connection or already-imported order rows.
// UI-visibility gate only; the page and APIs work regardless.
requiresWebshop?: boolean
// Körjournal surfaces: visible only when the company has opted in via the
// bookkeeping settings toggle (company_settings.mileage_enabled) or already
// has trips. UI-visibility gate only; the page and APIs work regardless.
requiresMileage?: boolean
// Paywall surfaces: hidden unless the active company holds this paid
// capability. Cosmetic only, the page and API gates are the real
// enforcement; this just keeps the sidebar honest for non-payers.
@@ -208,8 +217,10 @@ const navItems: NavItem[] = [
{ href: '/orders', labelKey: 'sales_orders', icon: ShoppingCart, group: 'arbeta', requiresWebshop: true },
{ href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'arbeta' },
{ href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'arbeta', employerOnly: true },
// Körjournal is deliberately hidden from the nav; the /mileage route stays live.
// { href: '/mileage', labelKey: 'mileage', icon: Car, group: 'arbeta' },
// Körjournal: hidden by default (most companies have no car); shows when
// the settings toggle is on or trips already exist (hybrid gate, same
// "data stays reachable" reasoning as the Order row above).
{ href: '/mileage', labelKey: 'mileage', icon: Car, group: 'arbeta', requiresMileage: true },
// Analys: read the numbers.
{ href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'analys' },
{ href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'analys' },
@@ -283,7 +294,7 @@ const groupLabelKey: Record<Exclude<GroupKey, 'top'>, string> = {
skatt: 'group_tax',
}
export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, dimensionsEnabled = false, hasWebshop = false, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null, initialUiState }: DashboardNavProps) {
export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, dimensionsEnabled = false, hasWebshop = false, hasMileage = false, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null, initialUiState }: DashboardNavProps) {
const pathname = usePathname()
const router = useRouter()
const supabase = useRealtimeSupabase()
@@ -503,6 +514,9 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
// Webshop surfaces are hidden until a store is connected (or order rows
// already exist from a since-disconnected store).
if (item.requiresWebshop && !hasWebshop) return false
// Körjournal is hidden until the company opts in via the bookkeeping
// settings toggle (or trips already exist, e.g. created via MCP).
if (item.requiresMileage && !hasMileage) return false
// Paywalled surfaces (e.g. the AI-only Dokumentinkorg) are hidden unless
// the active company holds the capability. The page + API gates enforce
// the paywall; this keeps the sidebar from advertising a dead workspace.
+98
View File
@@ -0,0 +1,98 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { useLocale, useTranslations } from 'next-intl'
import { ExternalLink } from 'lucide-react'
import { Switch } from '@/components/ui/switch'
import { useToast } from '@/components/ui/use-toast'
import {
SettingsRow,
SettingsRowEnd,
} from '@/components/settings/SettingsRows'
import { useSettings } from '@/components/settings/useSettings'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { cn } from '@/lib/utils'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
/**
* Company-level toggle for the Körjournal (mileage log). Persists
* company_settings.mileage_enabled through the standard settings PUT: the
* flag gates UI visibility only (the nav row), never correctness. Trips
* created via API/MCP work regardless, and the nav row also shows whenever
* mileage_trips rows exist (hybrid gate computed by the dashboard layout),
* so turning this off never hides existing underlag.
*/
export function MileageToggle() {
const t = useTranslations('mileage')
const errorLocale = useLocale() as ErrorLocale
const { settings, updateSettings } = useSettings()
const { canWrite } = useCanWrite()
const { toast } = useToast()
const [isSaving, setIsSaving] = useState(false)
const enabled = settings?.mileage_enabled ?? false
async function handleChange(next: boolean) {
setIsSaving(true)
try {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mileage_enabled: next }),
})
const json = await res.json().catch(() => null)
if (!res.ok) {
toast({
title: t('settings_save_failed_title'),
description: getErrorMessage(json, { locale: errorLocale }),
variant: 'destructive',
})
return
}
updateSettings({ mileage_enabled: next })
} catch (err) {
// A rejected fetch (offline, DNS failure, aborted request) never reaches
// the !res.ok arm above, and the switch is controlled by the settings
// context, so it simply stays where it was: without this toast the click
// looks like a dead control rather than a save that did not happen.
toast({
title: t('settings_save_failed_title'),
description: getErrorMessage(err, { locale: errorLocale }),
variant: 'destructive',
})
} finally {
setIsSaving(false)
}
}
const locked = isSaving || !canWrite
return (
<SettingsRow label={t('settings_heading')} help={t('settings_toggle_help')}>
<Switch
id="mileage-enabled"
checked={enabled}
onCheckedChange={(next) => void handleChange(next)}
disabled={locked}
/>
<label
htmlFor="mileage-enabled"
className={cn('text-sm', locked ? 'text-muted-foreground' : 'cursor-pointer')}
>
{t('settings_toggle_label')}
</label>
{enabled && (
<SettingsRowEnd>
<Link
href="/mileage"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
{t('settings_open_page')}
</Link>
</SettingsRowEnd>
)}
</SettingsRow>
)
}
@@ -13,6 +13,7 @@ import { VoucherSeriesPerSourceTypeForm } from '@/components/settings/VoucherSer
import { applyDefaultSeriesToMap } from '@/lib/bookkeeping/voucher-series-resolver'
import { PeriodiseringAutoDetectToggle } from '@/components/settings/PeriodiseringAutoDetectToggle'
import { DimensionsToggle } from '@/components/settings/DimensionsToggle'
import { MileageToggle } from '@/components/settings/MileageToggle'
import { AccountingFrameworkForm } from '@/components/settings/AccountingFrameworkForm'
import {
SettingsGroup,
@@ -174,6 +175,7 @@ export function BookkeepingSettingsContent() {
<SettingsGroup label={t('group_automation')}>
<PeriodiseringAutoDetectToggle />
<DimensionsToggle />
<MileageToggle />
</SettingsGroup>
<SettingsGroup>
+3
View File
@@ -1975,6 +1975,9 @@ export const UpdateSettingsSchema = z.object({
// Dimensions (kostnadsställe/projekt): UI-visibility toggle only, never
// load-bearing for correctness (dev_docs/dimensions_implementation_plan.md §2).
dimensions_enabled: z.boolean().optional(),
// Körjournal (mileage log): UI-visibility toggle only, never load-bearing
// for correctness (trips created via API/MCP work regardless).
mileage_enabled: z.boolean().optional(),
// Salary payment file
preferred_payment_format: z.enum(['bg_lb', 'pain001']).optional(),
// Salary settings (migration 20260703190000). Day of month salaries are
+6 -1
View File
@@ -7032,6 +7032,11 @@
"booking": "Booking...",
"book_error": "The mileage allowance could not be booked",
"booked_title": "Voucher {voucher} booked",
"booked_description": "{count} trips, total {amount}"
"booked_description": "{count} trips, total {amount}",
"settings_heading": "Driving log",
"settings_toggle_label": "Enable the driving log",
"settings_toggle_help": "Shows the driving log in the menu. There you log business trips and book tax-free mileage allowance at the statutory rate.",
"settings_save_failed_title": "Could not save the setting",
"settings_open_page": "Open the driving log"
}
}
+6 -1
View File
@@ -7032,6 +7032,11 @@
"booking": "Bokför...",
"book_error": "Milersättningen kunde inte bokföras",
"booked_title": "Verifikat {voucher} bokfört",
"booked_description": "{count} resor, totalt {amount}"
"booked_description": "{count} resor, totalt {amount}",
"settings_heading": "Körjournal",
"settings_toggle_label": "Aktivera körjournal",
"settings_toggle_help": "Visar körjournalen i menyn. Där loggar du tjänsteresor och bokför milersättning skattefritt enligt schablon.",
"settings_save_failed_title": "Kunde inte spara inställningen",
"settings_open_page": "Öppna körjournalen"
}
}
@@ -0,0 +1,19 @@
-- Körjournal (mileage log) sidebar visibility toggle.
--
-- Adds the per-company UI toggle that surfaces the Körjournal nav row and the
-- /mileage page entry point. UI-visibility only, NEVER load-bearing for
-- correctness: trips written via API/MCP are validated and bookable regardless
-- of this flag, and the nav row also shows whenever the company already has
-- mileage_trips rows (same hybrid gate as webshop orders), so agent-created
-- trips can never become invisible underlag. Mirrors dimensions_enabled
-- (20260702100000).
--
-- pg-test: skip (plain column addition, no trigger/RPC/RLS)
ALTER TABLE public.company_settings
ADD COLUMN mileage_enabled boolean NOT NULL DEFAULT false;
COMMENT ON COLUMN public.company_settings.mileage_enabled IS
'UI-visibility toggle for the Körjournal (mileage log) nav row. Never load-bearing for correctness: trips created via API/MCP work regardless, and the nav row also shows when mileage_trips rows exist.';
NOTIFY pgrst, 'reload schema';
+1
View File
@@ -622,6 +622,7 @@ export function makeCompanySettings(
reminder_fee_amount: 60,
reminder_interest_rate_override: null,
dimensions_enabled: false,
mileage_enabled: false,
preferred_payment_format: 'pain001',
salary_pay_day: 25,
salary_default_bank: null,
+4
View File
@@ -442,6 +442,10 @@ export interface CompanySettings {
// load-bearing for correctness. Free tier (founder decision 2026-07-02).
dimensions_enabled: boolean
// Körjournal (mileage log): UI-visibility toggle only, never load-bearing
// for correctness. The nav row also shows when mileage_trips rows exist.
mileage_enabled: boolean
// Salary payments (migration 20260508120000 + 20260703190000).
// preferred_payment_format defaults to 'pain001' — Bankgirot Lön is
// retired by the banks during 2026.