From 45d7f1be4e915e2129899ff3e5b6d57650f82d62 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:48:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(mileage):=20surface=20K=C3=B6rjournal=20in?= =?UTF-8?q?=20the=20nav=20behind=20a=20settings=20toggle=20(#1540)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- app/(dashboard)/layout.tsx | 15 +++ app/(dashboard)/request-context.ts | 2 +- app/api/settings/__tests__/route.test.ts | 19 ++++ components/dashboard/DashboardNav.tsx | 20 +++- components/settings/MileageToggle.tsx | 98 +++++++++++++++++++ .../sections/BookkeepingSettingsContent.tsx | 2 + lib/api/schemas.ts | 3 + messages/en.json | 7 +- messages/sv.json | 7 +- ...93500_company_settings_mileage_enabled.sql | 19 ++++ tests/helpers.ts | 1 + types/index.ts | 4 + 12 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 components/settings/MileageToggle.tsx create mode 100644 supabase/migrations/20260812193500_company_settings_mileage_enabled.sql diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index d8bc6d7c..b9594325 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -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} diff --git a/app/(dashboard)/request-context.ts b/app/(dashboard)/request-context.ts index 63e6d770..2d3cfd5c 100644 --- a/app/(dashboard)/request-context.ts +++ b/app/(dashboard)/request-context.ts @@ -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() }) diff --git a/app/api/settings/__tests__/route.test.ts b/app/api/settings/__tests__/route.test.ts index 83362071..e4fd00ef 100644 --- a/app/api/settings/__tests__/route.test.ts +++ b/app/api/settings/__tests__/route.test.ts @@ -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([ diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index 7a3d5a02..6cee24e8 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -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, 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. diff --git a/components/settings/MileageToggle.tsx b/components/settings/MileageToggle.tsx new file mode 100644 index 00000000..c0aa7afd --- /dev/null +++ b/components/settings/MileageToggle.tsx @@ -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 ( + + void handleChange(next)} + disabled={locked} + /> + + {enabled && ( + + + + {t('settings_open_page')} + + + )} + + ) +} diff --git a/components/settings/sections/BookkeepingSettingsContent.tsx b/components/settings/sections/BookkeepingSettingsContent.tsx index a6f89cb2..0b522812 100644 --- a/components/settings/sections/BookkeepingSettingsContent.tsx +++ b/components/settings/sections/BookkeepingSettingsContent.tsx @@ -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() { + diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 3fc9b805..4d7e73c1 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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 diff --git a/messages/en.json b/messages/en.json index b481805a..e17f90e6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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" } } diff --git a/messages/sv.json b/messages/sv.json index c6526651..f4157fb7 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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" } } diff --git a/supabase/migrations/20260812193500_company_settings_mileage_enabled.sql b/supabase/migrations/20260812193500_company_settings_mileage_enabled.sql new file mode 100644 index 00000000..00e0e8e0 --- /dev/null +++ b/supabase/migrations/20260812193500_company_settings_mileage_enabled.sql @@ -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'; diff --git a/tests/helpers.ts b/tests/helpers.ts index 39c13bbd..43af5163 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -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, diff --git a/types/index.ts b/types/index.ts index 75daa84a..1a576a16 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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.