Procházet zdrojové kódy

Give the sidebar a workspace header, live work counts and a calmer nav (#321)

* Give the sidebar a workspace header, live work counts and a calmer nav

* Explain the sidebar counts on hover and centre the collapsed new-order button

* Drop the sidebar separator that gave the panel a horizontal scrollbar

* Stop the page sliding when a dialog locks scrolling
Bernt Christian Egeland před 4 týdny
rodič
revize
5cd439f42e

+ 13 - 1
messages/de/navigation.json

@@ -118,6 +118,18 @@
     "auditLog": "Aktivitätsprotokoll",
     "aiAssistant": "KI-Assistent",
     "tireHotel": "Reifenhotel",
-    "whatsapp": "WhatsApp"
+    "whatsapp": "WhatsApp",
+    "newWorkOrder": "Neuer Arbeitsauftrag",
+    "installApp": "App installieren",
+    "roles": {
+      "owner": "Inhaber",
+      "admin": "Administrator",
+      "member": "Mitglied"
+    },
+    "badges": {
+      "workOrders": "{count} offene Arbeitsaufträge",
+      "inspections": "{count} laufende Inspektionen",
+      "reminders": "{count} fällige Erinnerungen"
+    }
   }
 }

+ 13 - 1
messages/en/navigation.json

@@ -118,6 +118,18 @@
     "auditLog": "Audit Log",
     "aiAssistant": "AI Assistant",
     "tireHotel": "Tire hotel",
-    "whatsapp": "WhatsApp"
+    "whatsapp": "WhatsApp",
+    "newWorkOrder": "New work order",
+    "installApp": "Install app",
+    "roles": {
+      "owner": "Owner",
+      "admin": "Admin",
+      "member": "Member"
+    },
+    "badges": {
+      "workOrders": "{count} open work orders",
+      "inspections": "{count} inspections in progress",
+      "reminders": "{count} reminders due"
+    }
   }
 }

+ 13 - 1
messages/es/navigation.json

@@ -118,6 +118,18 @@
     "auditLog": "Registro de auditoría",
     "aiAssistant": "Asistente IA",
     "tireHotel": "Hotel de neumáticos",
-    "whatsapp": "WhatsApp"
+    "whatsapp": "WhatsApp",
+    "newWorkOrder": "Nueva orden de trabajo",
+    "installApp": "Instalar la app",
+    "roles": {
+      "owner": "Propietario",
+      "admin": "Administrador",
+      "member": "Miembro"
+    },
+    "badges": {
+      "workOrders": "{count} órdenes de trabajo abiertas",
+      "inspections": "{count} inspecciones en curso",
+      "reminders": "{count} recordatorios vencidos"
+    }
   }
 }

+ 13 - 1
messages/fr/navigation.json

@@ -118,6 +118,18 @@
     "auditLog": "Journal d'audit",
     "aiAssistant": "Assistant IA",
     "tireHotel": "Hôtel à pneus",
-    "whatsapp": "WhatsApp"
+    "whatsapp": "WhatsApp",
+    "newWorkOrder": "Nouvel ordre de travail",
+    "installApp": "Installer l'application",
+    "roles": {
+      "owner": "Propriétaire",
+      "admin": "Administrateur",
+      "member": "Membre"
+    },
+    "badges": {
+      "workOrders": "{count} ordres de travail ouverts",
+      "inspections": "{count} inspections en cours",
+      "reminders": "{count} rappels à échéance"
+    }
   }
 }

+ 13 - 1
messages/it/navigation.json

@@ -118,6 +118,18 @@
     "auditLog": "Registro attività",
     "aiAssistant": "Assistente IA",
     "tireHotel": "Hotel pneumatici",
-    "whatsapp": "WhatsApp"
+    "whatsapp": "WhatsApp",
+    "newWorkOrder": "Nuovo ordine di lavoro",
+    "installApp": "Installa l'app",
+    "roles": {
+      "owner": "Proprietario",
+      "admin": "Amministratore",
+      "member": "Membro"
+    },
+    "badges": {
+      "workOrders": "{count} ordini di lavoro aperti",
+      "inspections": "{count} ispezioni in corso",
+      "reminders": "{count} promemoria in scadenza"
+    }
   }
 }

+ 13 - 1
messages/lt/navigation.json

@@ -118,6 +118,18 @@
     "auditLog": "Audito žurnalas",
     "aiAssistant": "DI asistentas",
     "tireHotel": "Padangų viešbutis",
-    "whatsapp": "„WhatsApp“"
+    "whatsapp": "„WhatsApp“",
+    "newWorkOrder": "Naujas darbo užsakymas",
+    "installApp": "Įdiegti programėlę",
+    "roles": {
+      "owner": "Savininkas",
+      "admin": "Administratorius",
+      "member": "Narys"
+    },
+    "badges": {
+      "workOrders": "{count} atviri darbo užsakymai",
+      "inspections": "{count} vykdomos apžiūros",
+      "reminders": "{count} priminimai, kurių terminas suėjo"
+    }
   }
 }

+ 13 - 1
messages/nb/navigation.json

@@ -118,6 +118,18 @@
     "auditLog": "Aktivitetslogg",
     "aiAssistant": "AI-assistent",
     "tireHotel": "Dekkhotell",
-    "whatsapp": "WhatsApp"
+    "whatsapp": "WhatsApp",
+    "newWorkOrder": "Ny arbeidsordre",
+    "installApp": "Installer appen",
+    "roles": {
+      "owner": "Eier",
+      "admin": "Administrator",
+      "member": "Medlem"
+    },
+    "badges": {
+      "workOrders": "{count} åpne arbeidsordrer",
+      "inspections": "{count} pågående inspeksjoner",
+      "reminders": "{count} forfalte påminnelser"
+    }
   }
 }

+ 13 - 1
messages/nl/navigation.json

@@ -118,6 +118,18 @@
     "auditLog": "Activiteitenlog",
     "aiAssistant": "AI-assistent",
     "tireHotel": "Bandenhotel",
-    "whatsapp": "WhatsApp"
+    "whatsapp": "WhatsApp",
+    "newWorkOrder": "Nieuwe werkorder",
+    "installApp": "App installeren",
+    "roles": {
+      "owner": "Eigenaar",
+      "admin": "Beheerder",
+      "member": "Lid"
+    },
+    "badges": {
+      "workOrders": "{count} open werkorders",
+      "inspections": "{count} lopende inspecties",
+      "reminders": "{count} herinneringen vervallen"
+    }
   }
 }

+ 13 - 1
messages/pl/navigation.json

@@ -118,6 +118,18 @@
     "auditLog": "Dziennik audytu",
     "aiAssistant": "Asystent AI",
     "tireHotel": "Hotel opon",
-    "whatsapp": "WhatsApp"
+    "whatsapp": "WhatsApp",
+    "newWorkOrder": "Nowe zlecenie",
+    "installApp": "Zainstaluj aplikację",
+    "roles": {
+      "owner": "Właściciel",
+      "admin": "Administrator",
+      "member": "Członek"
+    },
+    "badges": {
+      "workOrders": "{count} otwarte zlecenia",
+      "inspections": "{count} przeglądy w toku",
+      "reminders": "{count} zaległe przypomnienia"
+    }
   }
 }

+ 13 - 1
messages/pt-BR/navigation.json

@@ -118,6 +118,18 @@
     "auditLog": "Registro de auditoria",
     "aiAssistant": "Assistente IA",
     "tireHotel": "Hotel de pneus",
-    "whatsapp": "WhatsApp"
+    "whatsapp": "WhatsApp",
+    "newWorkOrder": "Nova ordem de serviço",
+    "installApp": "Instalar o app",
+    "roles": {
+      "owner": "Proprietário",
+      "admin": "Administrador",
+      "member": "Membro"
+    },
+    "badges": {
+      "workOrders": "{count} ordens de serviço abertas",
+      "inspections": "{count} inspeções em andamento",
+      "reminders": "{count} lembretes vencidos"
+    }
   }
 }

+ 13 - 1
messages/ru/navigation.json

@@ -118,6 +118,18 @@
     "auditLog": "Журнал аудита",
     "aiAssistant": "ИИ-ассистент",
     "tireHotel": "Шинный отель",
-    "whatsapp": "WhatsApp"
+    "whatsapp": "WhatsApp",
+    "newWorkOrder": "Новый заказ-наряд",
+    "installApp": "Установить приложение",
+    "roles": {
+      "owner": "Владелец",
+      "admin": "Администратор",
+      "member": "Участник"
+    },
+    "badges": {
+      "workOrders": "{count} открытых заказ-нарядов",
+      "inspections": "{count} проверок в работе",
+      "reminders": "{count} напоминаний просрочено"
+    }
   }
 }

+ 13 - 1
messages/tr/navigation.json

@@ -118,6 +118,18 @@
     "auditLog": "Denetim günlüğü",
     "aiAssistant": "AI Asistanı",
     "tireHotel": "Lastik oteli",
-    "whatsapp": "WhatsApp"
+    "whatsapp": "WhatsApp",
+    "newWorkOrder": "Yeni iş emri",
+    "installApp": "Uygulamayı yükle",
+    "roles": {
+      "owner": "Sahip",
+      "admin": "Yönetici",
+      "member": "Üye"
+    },
+    "badges": {
+      "workOrders": "{count} açık iş emri",
+      "inspections": "{count} devam eden muayene",
+      "reminders": "{count} vadesi gelen hatırlatma"
+    }
   }
 }

+ 16 - 15
src/__tests__/components/fullscreen-toggle.test.tsx

@@ -6,6 +6,10 @@
  * because hijacking the first click on a browser tab is hostile. And the
  * preference has to be written on the way out as well as the way in, or
  * turning fullscreen off is undone by the next launch.
+ *
+ * The two halves are separate components: the launcher is always mounted so
+ * it can catch the first gesture, while the menu item only exists once the
+ * account menu is open.
  */
 
 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
@@ -14,18 +18,15 @@ import { render, screen, act } from '@testing-library/react'
 let installed = false
 vi.mock('@/components/pwa-install-prompt', () => ({
   useInstallPrompt: () => ({ installed }),
-  SidebarInstallButton: () => null,
+  InstallMenuItem: () => null,
 }))
 
 vi.mock('next-intl', () => ({
   useTranslations: () => (key: string) => key,
 }))
 
-vi.mock('@/components/ui/sidebar', () => ({
-  SidebarGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
-  SidebarMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
-  SidebarMenuItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
-  SidebarMenuButton: ({
+vi.mock('@/components/ui/dropdown-menu', () => ({
+  DropdownMenuItem: ({
     children,
     onClick,
   }: {
@@ -38,7 +39,7 @@ vi.mock('@/components/ui/sidebar', () => ({
   ),
 }))
 
-const { FullscreenToggle } = await import('@/components/fullscreen-toggle')
+const { FullscreenLauncher, FullscreenMenuItem } = await import('@/components/fullscreen-toggle')
 
 const requestFullscreen = vi.fn().mockResolvedValue(undefined)
 const exitFullscreen = vi.fn().mockResolvedValue(undefined)
@@ -69,7 +70,7 @@ describe('the remembered preference', () => {
   it('is spent on the first gesture inside the installed app', () => {
     installed = true
     localStorage.setItem('app-fullscreen', 'true')
-    render(<FullscreenToggle />)
+    render(<FullscreenLauncher />)
 
     expect(requestFullscreen).not.toHaveBeenCalled()
     act(() => {
@@ -83,7 +84,7 @@ describe('the remembered preference', () => {
     // is the difference between a feature and a hijack.
     installed = false
     localStorage.setItem('app-fullscreen', 'true')
-    render(<FullscreenToggle />)
+    render(<FullscreenLauncher />)
 
     act(() => {
       document.dispatchEvent(new Event('pointerdown'))
@@ -93,7 +94,7 @@ describe('the remembered preference', () => {
 
   it('is not spent when nobody asked for it', () => {
     installed = true
-    render(<FullscreenToggle />)
+    render(<FullscreenLauncher />)
 
     act(() => {
       document.dispatchEvent(new Event('pointerdown'))
@@ -104,7 +105,7 @@ describe('the remembered preference', () => {
   it('is spent once, not on every click', () => {
     installed = true
     localStorage.setItem('app-fullscreen', 'true')
-    render(<FullscreenToggle />)
+    render(<FullscreenLauncher />)
 
     act(() => {
       document.dispatchEvent(new Event('pointerdown'))
@@ -114,9 +115,9 @@ describe('the remembered preference', () => {
   })
 })
 
-describe('the button', () => {
+describe('the menu item', () => {
   it('records the choice when turning fullscreen on', () => {
-    render(<FullscreenToggle />)
+    render(<FullscreenMenuItem />)
     act(() => {
       screen.getByTestId('toggle').click()
     })
@@ -127,7 +128,7 @@ describe('the button', () => {
   it('records the choice when turning it off, so the next launch respects it', () => {
     localStorage.setItem('app-fullscreen', 'true')
     setFullscreen(true)
-    render(<FullscreenToggle />)
+    render(<FullscreenMenuItem />)
 
     act(() => {
       screen.getByTestId('toggle').click()
@@ -139,7 +140,7 @@ describe('the button', () => {
   it('is absent where the browser has no fullscreen to give', () => {
     // iOS Safari on the phone, where requesting it throws rather than refuses.
     Object.defineProperty(document, 'fullscreenEnabled', { value: false, configurable: true })
-    render(<FullscreenToggle />)
+    render(<FullscreenMenuItem />)
     expect(screen.queryByTestId('toggle')).toBeNull()
   })
 })

+ 38 - 0
src/app/(authenticated)/layout.tsx

@@ -32,6 +32,8 @@ import { findLookupConnection } from '@/features/integrations/Lib/vehicle-lookup
 import { getManifest } from '@/integrations/registry'
 import { PlateLookupProvider } from '@/components/plate-lookup-context'
 import { PlateLookupCommand } from '@/features/vehicles/Components/PlateLookupCommand'
+import { OPEN_SERVICE_STATUSES } from '@/lib/service-record'
+import { addZonedDays, safeTimeZone, startOfZonedDay } from '@/lib/timezone'
 
 export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
   const data = await getLayoutData()
@@ -122,6 +124,41 @@ export default async function DashboardLayout({ children }: { children: React.Re
     return <NoAccess organizationName={org?.name ?? ''} />
   }
 
+  // Live work for the sidebar pills. Each is a count of things still in hand,
+  // never a running total, so the numbers stay small enough to mean
+  // something. Only the screens this role can see are counted at all.
+  const timeZone = safeTimeZone(data.timezone)
+  const endOfToday = startOfZonedDay(addZonedDays(new Date(), 1, timeZone), timeZone)
+  const [openWorkOrders, activeInspections, dueReminders] = await Promise.all([
+    visibleSubjects.includes(PermissionSubject.WORK_ORDERS)
+      ? db.serviceRecord.count({
+          where: {
+            organizationId: data.organizationId,
+            status: { in: [...OPEN_SERVICE_STATUSES] },
+          },
+        })
+      : 0,
+    visibleSubjects.includes(PermissionSubject.INSPECTIONS)
+      ? db.inspection.count({
+          where: { organizationId: data.organizationId, status: 'in_progress' },
+        })
+      : 0,
+    visibleSubjects.includes(PermissionSubject.VEHICLES)
+      ? db.reminder.count({
+          where: {
+            organizationId: data.organizationId,
+            isCompleted: false,
+            dueDate: { lt: endOfToday },
+          },
+        })
+      : 0,
+  ])
+  const sidebarCounts = {
+    workOrders: openWorkOrders,
+    inspections: activeInspections,
+    reminders: dueReminders,
+  }
+
   // Product announcements join the same queue as the hints a setting flip
   // raises, so only ever one card shows. They are worked out per request
   // rather than stored, because who may be told depends on the account
@@ -232,6 +269,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
                         visibleSubjects={visibleSubjects}
                         announcement={announcements[0] ?? null}
                         isAdminOrOwner={isOwnerOrAdmin}
+                        counts={sidebarCounts}
                       />
                       <SidebarInset>
                         {/* A flex column with a real height, so the `flex-1` every

+ 13 - 1
src/app/(authenticated)/work-orders/work-orders-client.tsx

@@ -5,7 +5,7 @@ import { interactiveRow } from '@/lib/interactive-row'
 import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
 import { useDebouncedSearch } from '@/hooks/use-debounced-search'
 
-import { useState, useCallback, useTransition } from 'react'
+import { useState, useCallback, useEffect, useTransition } from 'react'
 import { useRouter, usePathname, useSearchParams } from 'next/navigation'
 import { toast } from 'sonner'
 import { useFormatDate } from '@/lib/use-format-date'
@@ -198,6 +198,18 @@ export function WorkOrdersClient({
     [router, pathname, searchParams]
   )
 
+  // The sidebar's "New work order" lands here with ?new=1. Open the picker
+  // and drop the flag from the address, so a reload or a back-step does not
+  // open it again.
+  useEffect(() => {
+    if (searchParams.get('new') !== '1') return
+    setShowPicker(true)
+    const rest = new URLSearchParams(searchParams.toString())
+    rest.delete('new')
+    const query = rest.toString()
+    router.replace(query ? `${pathname}?${query}` : pathname)
+  }, [searchParams, pathname, router])
+
   const handleSort = useCallback(
     (column: string) => {
       const newOrder = sortBy === column && sortOrder === 'asc' ? 'desc' : 'asc'

+ 10 - 0
src/app/globals.css

@@ -328,6 +328,16 @@
   }
 }
 
+/* Dialogs lock body scroll through react-remove-scroll, which then pushes
+   the body in by one scrollbar width to stop the page jumping when the bar
+   goes. The gutter above already keeps that space, so the push is the jump:
+   the whole page slid left every time the search or plate lookup opened.
+   Outranks the library's injected !important rule by specificity. */
+html body[data-scroll-locked] {
+  margin-right: 0 !important;
+  padding-right: 0 !important;
+}
+
 /* Keyboard-focusable rows (see src/lib/interactive-row.ts). Inset outline so
    the ring survives overflow-hidden card and table corners. */
 [data-row-interactive] {

+ 154 - 56
src/components/app-sidebar.tsx

@@ -29,12 +29,14 @@ import {
   SidebarGroupLabel,
   SidebarHeader,
   SidebarMenu,
+  SidebarMenuBadge,
   SidebarMenuButton,
   SidebarMenuItem,
   useSidebar,
 } from '@/components/ui/sidebar'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
 import { Button } from '@/components/ui/button'
 import {
   BarChart3,
@@ -79,14 +81,43 @@ import {
   NotificationBell,
   NotificationPanel,
 } from '@/features/notifications/Components/NotificationPanel'
-import { SidebarInstallButton } from '@/components/pwa-install-prompt'
-import { FullscreenToggle } from '@/components/fullscreen-toggle'
+import { InstallMenuItem } from '@/components/pwa-install-prompt'
+import { FullscreenLauncher, FullscreenMenuItem } from '@/components/fullscreen-toggle'
 import { FeatureHint } from '@/components/feature-hint'
 import { ANNOUNCEMENTS } from '@/features/settings/Lib/featureHints'
 import { cn } from '@/lib/utils'
 
 type OrgInfo = { id: string; name: string; role: string }
 
+/**
+ * Live work, counted on the server for the rows that carry a pill.
+ *
+ * These are things still in hand, never running totals: a total would only
+ * ever grow, and a four-digit pill says nothing. Work orders count the ones
+ * not yet finished, inspections the ones still being walked, reminders the
+ * ones past their date.
+ */
+export type SidebarCounts = {
+  workOrders: number
+  inspections: number
+  reminders: number
+}
+
+type NavItem = {
+  titleKey: string
+  url: string
+  icon: React.ComponentType<{ className?: string }>
+  subject: string
+  /** Set to point a one-time note at this link the first time it appears. */
+  hint?: string
+  /** A pill on the row, with the translation key that reads it out. */
+  count?: number
+  countKey?: string
+}
+
+/** Roles the sidebar has a word for; custom roles fall back to the raw name. */
+const NAMED_ROLES = new Set(['owner', 'admin', 'member'])
+
 export function AppSidebar({
   companyLogo,
   organizations = [],
@@ -97,6 +128,7 @@ export function AppSidebar({
   isAdminOrOwner = false,
   visibleSubjects,
   announcement = null,
+  counts,
   ...props
 }: React.ComponentProps<typeof Sidebar> & {
   companyLogo?: string
@@ -109,6 +141,7 @@ export function AppSidebar({
   visibleSubjects?: string[]
   /** The one product announcement to show, worked out on the server. */
   announcement?: string | null
+  counts?: SidebarCounts
 }) {
   const pathname = usePathname()
   const router = useRouter()
@@ -128,7 +161,7 @@ export function AppSidebar({
 
   const canAccess = (subject: string) => !visibleSubjects || visibleSubjects.includes(subject)
 
-  const clientItems = [
+  const clientItems: NavItem[] = [
     {
       titleKey: 'sidebar.customers' as const,
       url: '/customers',
@@ -143,25 +176,36 @@ export function AppSidebar({
     },
   ].filter((item) => canAccess(item.subject))
 
-  const workshopItems = [
+  const workshopItems: NavItem[] = [
     {
       titleKey: isMarine ? ('sidebar.vessels' as const) : ('sidebar.vehicles' as const),
       url: '/vehicles',
       icon: isMarine ? Ship : Car,
       subject: 'vehicles',
     },
-    { titleKey: 'sidebar.reminders' as const, url: '/reminders', icon: Bell, subject: 'vehicles' },
+    {
+      titleKey: 'sidebar.reminders' as const,
+      url: '/reminders',
+      icon: Bell,
+      subject: 'vehicles',
+      count: counts?.reminders,
+      countKey: 'sidebar.badges.reminders',
+    },
     {
       titleKey: 'sidebar.workOrders' as const,
       url: '/work-orders',
       icon: ClipboardList,
       subject: 'work_orders',
+      count: counts?.workOrders,
+      countKey: 'sidebar.badges.workOrders',
     },
     {
       titleKey: 'sidebar.inspections' as const,
       url: '/inspections',
       icon: ClipboardCheck,
       subject: 'inspections',
+      count: counts?.inspections,
+      countKey: 'sidebar.badges.inspections',
     },
     {
       titleKey: 'sidebar.calendar' as const,
@@ -191,7 +235,7 @@ export function AppSidebar({
       : []),
   ].filter((item) => canAccess(item.subject))
 
-  const businessItems = [
+  const businessItems: NavItem[] = [
     { titleKey: 'sidebar.quotes' as const, url: '/quotes', icon: FileText, subject: 'quotes' },
     { titleKey: 'sidebar.billing' as const, url: '/billing', icon: Receipt, subject: 'billing' },
     {
@@ -242,7 +286,7 @@ export function AppSidebar({
           tooltip={t('sidebar.settings')}
           className={cn(highlighted && 'ring-2 ring-primary ring-offset-1 ring-offset-sidebar')}
         >
-          <Link href="/settings" className="font-medium" onClick={closeMobileSidebar}>
+          <Link href="/settings" onClick={closeMobileSidebar}>
             <Settings className="size-4" />
             {t('sidebar.settings')}
           </Link>
@@ -273,17 +317,10 @@ export function AppSidebar({
     )
   }
 
-  const renderNavGroup = (
-    items: {
-      titleKey: string
-      url: string
-      icon: React.ComponentType<{ className?: string }>
-      /** Set to point a one-time note at this link the first time it appears. */
-      hint?: string
-    }[]
-  ) =>
+  const renderNavGroup = (items: NavItem[]) =>
     items.map((item) => {
       const isActive = pathname === item.url || (item.url !== '/' && pathname.startsWith(item.url))
+      const count = item.count ?? 0
       const row = (highlighted: boolean) => (
         <SidebarMenuItem key={item.titleKey}>
           <SidebarMenuButton
@@ -292,13 +329,34 @@ export function AppSidebar({
             tooltip={t(item.titleKey)}
             // Marked while the card is up, so it is obvious which of a dozen
             // links the card is talking about.
-            className={cn(highlighted && 'ring-2 ring-primary ring-offset-1 ring-offset-sidebar')}
+            className={cn(
+              count > 0 && 'pr-10',
+              highlighted && 'ring-2 ring-primary ring-offset-1 ring-offset-sidebar'
+            )}
           >
-            <Link href={item.url} className="font-medium" onClick={closeMobileSidebar}>
+            <Link href={item.url} onClick={closeMobileSidebar}>
               <item.icon className="size-4" />
               {t(item.titleKey)}
             </Link>
           </SidebarMenuButton>
+          {count > 0 && item.countKey && (
+            <Tooltip>
+              <TooltipTrigger asChild>
+                {/* The pill sits over the link, so it takes the pointer
+                    itself and forwards a click to where the row goes. */}
+                <SidebarMenuBadge
+                  className="pointer-events-auto cursor-pointer"
+                  onClick={() => {
+                    closeMobileSidebar()
+                    router.push(item.url)
+                  }}
+                >
+                  {count > 99 ? '99+' : count}
+                </SidebarMenuBadge>
+              </TooltipTrigger>
+              <TooltipContent side="right">{t(item.countKey, { count })}</TooltipContent>
+            </Tooltip>
+          )}
         </SidebarMenuItem>
       )
 
@@ -319,6 +377,11 @@ export function AppSidebar({
     })
 
   const activeOrg = organizations.find((o) => o.id === activeOrgId) || organizations[0]
+  const roleLabel = activeOrg?.role
+    ? NAMED_ROLES.has(activeOrg.role)
+      ? t(`sidebar.roles.${activeOrg.role}`)
+      : activeOrg.role
+    : null
 
   const handleSwitchOrg = async (orgId: string) => {
     await switchOrganization(orgId)
@@ -357,29 +420,37 @@ export function AppSidebar({
 
   return (
     <Sidebar variant="floating" collapsible="icon" {...props}>
-      <SidebarHeader>
+      <FullscreenLauncher />
+      <SidebarHeader className="gap-2 p-2 pb-1">
         <SidebarMenu>
-          <SidebarMenuItem className="flex items-center gap-1 group-data-[collapsible=icon]:flex-col">
+          <SidebarMenuItem>
             <DropdownMenu>
               <DropdownMenuTrigger asChild>
                 <SidebarMenuButton
                   size="lg"
                   className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
                 >
-                  <div className="flex aspect-square size-12 shrink-0 items-center justify-center overflow-hidden rounded-lg group-data-[collapsible=icon]:size-8">
+                  {/* The mark sits in its own bordered tile, so a logo of any
+                      shape or colour reads as one deliberate object. */}
+                  <div className="flex aspect-square size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-sidebar-border bg-card p-1 group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:p-0.5">
                     <Image
                       src={companyLogo || '/torqvoice_app_logo.png'}
                       alt={activeOrg?.name ?? 'Company'}
-                      width={38}
-                      height={38}
+                      width={32}
+                      height={32}
                       unoptimized
                       className="h-auto max-h-full w-auto max-w-full object-contain"
                     />
                   </div>
-                  <div className="flex flex-col gap-0.5 leading-none group-data-[collapsible=icon]:hidden">
-                    <span className="font-semibold">
+                  <div className="grid flex-1 text-left leading-tight group-data-[collapsible=icon]:hidden">
+                    <span className="truncate font-semibold">
                       {activeOrg?.name ?? t('sidebar.noOrganization')}
                     </span>
+                    {roleLabel && (
+                      <span className="truncate text-xs text-sidebar-foreground/60">
+                        {roleLabel}
+                      </span>
+                    )}
                   </div>
                   <ChevronsUpDown className="ml-auto size-4 group-data-[collapsible=icon]:hidden" />
                 </SidebarMenuButton>
@@ -411,22 +482,39 @@ export function AppSidebar({
                 </DropdownMenuItem>
               </DropdownMenuContent>
             </DropdownMenu>
-            {isAdminOrOwner && <NotificationBell />}
           </SidebarMenuItem>
+          {/* The one filled button in the sidebar: the action a workshop
+              reaches for most, kept where it is never scrolled away. */}
+          {canAccess('work_orders') && (
+            <SidebarMenuItem>
+              <SidebarMenuButton
+                asChild
+                tooltip={t('sidebar.newWorkOrder')}
+                className="justify-center bg-sidebar-primary font-medium text-sidebar-primary-foreground shadow-xs hover:bg-sidebar-primary/90 hover:text-sidebar-primary-foreground active:bg-sidebar-primary/90 active:text-sidebar-primary-foreground [&>svg]:text-sidebar-primary-foreground hover:[&>svg]:text-sidebar-primary-foreground"
+              >
+                <Link href="/work-orders?new=1" onClick={closeMobileSidebar}>
+                  <Plus className="size-4" />
+                  <span className="group-data-[collapsible=icon]:hidden">
+                    {t('sidebar.newWorkOrder')}
+                  </span>
+                </Link>
+              </SidebarMenuButton>
+            </SidebarMenuItem>
+          )}
         </SidebarMenu>
       </SidebarHeader>
-      <SidebarContent>
+      <SidebarContent className="gap-0">
         {/* Dashboard */}
         {canAccess('dashboard') && (
-          <SidebarGroup>
-            <SidebarMenu className="gap-2">
+          <SidebarGroup className="pb-0">
+            <SidebarMenu className="gap-1">
               <SidebarMenuItem>
                 <SidebarMenuButton
                   asChild
                   isActive={dashboardActive}
                   tooltip={t('sidebar.dashboard')}
                 >
-                  <Link href="/" className="font-medium" onClick={closeMobileSidebar}>
+                  <Link href="/" onClick={closeMobileSidebar}>
                     <LayoutDashboard className="size-4" />
                     {t('sidebar.dashboard')}
                   </Link>
@@ -440,7 +528,7 @@ export function AppSidebar({
         {clientItems.length > 0 && (
           <SidebarGroup>
             <SidebarGroupLabel>{t('sidebar.clients')}</SidebarGroupLabel>
-            <SidebarMenu className="gap-2">{renderNavGroup(clientItems)}</SidebarMenu>
+            <SidebarMenu className="gap-1">{renderNavGroup(clientItems)}</SidebarMenu>
           </SidebarGroup>
         )}
 
@@ -448,7 +536,7 @@ export function AppSidebar({
         {workshopItems.length > 0 && (
           <SidebarGroup>
             <SidebarGroupLabel>{t('sidebar.workshop')}</SidebarGroupLabel>
-            <SidebarMenu className="gap-2">{renderNavGroup(workshopItems)}</SidebarMenu>
+            <SidebarMenu className="gap-1">{renderNavGroup(workshopItems)}</SidebarMenu>
           </SidebarGroup>
         )}
 
@@ -456,35 +544,41 @@ export function AppSidebar({
         {businessItems.length > 0 && (
           <SidebarGroup>
             <SidebarGroupLabel>{t('sidebar.business')}</SidebarGroupLabel>
-            <SidebarMenu className="gap-2">{renderNavGroup(businessItems)}</SidebarMenu>
+            <SidebarMenu className="gap-1">{renderNavGroup(businessItems)}</SidebarMenu>
           </SidebarGroup>
         )}
 
-        {/* Settings */}
+        {/* Settings, set apart from the page groups above it */}
         {canAccess('settings') && (
-          <SidebarGroup>
-            <SidebarMenu className="gap-2">
-              {/* Product announcements hang off Settings: everything they
-                  point at so far lives behind it, and it is the one link on
-                  the screen that is never scrolled away or filtered out by a
-                  role. Which one is worth showing was decided on the server,
-                  so this only says where the card goes. */}
-              {settingsRow()}
-            </SidebarMenu>
-          </SidebarGroup>
+          <>
+            {/* A plain hairline rather than SidebarSeparator: that one is
+                full width with side margins on top, which is wider than the
+                panel and gives the content a horizontal scrollbar. */}
+            <div aria-hidden className="mx-4 mt-2 h-px shrink-0 bg-sidebar-border" />
+            <SidebarGroup>
+              <SidebarMenu className="gap-1">
+                {/* Product announcements hang off Settings: everything they
+                    point at so far lives behind it, and it is the one link on
+                    the screen that is never scrolled away or filtered out by a
+                    role. Which one is worth showing was decided on the server,
+                    so this only says where the card goes. */}
+                {settingsRow()}
+              </SidebarMenu>
+            </SidebarGroup>
+          </>
         )}
 
         {isSuperAdmin && (
           <SidebarGroup>
             <SidebarGroupLabel>{t('sidebar.superAdmin')}</SidebarGroupLabel>
-            <SidebarMenu className="gap-2">
+            <SidebarMenu className="gap-1">
               <SidebarMenuItem>
                 <SidebarMenuButton
                   asChild
                   isActive={pathname.startsWith('/admin')}
                   tooltip={t('sidebar.adminPanel')}
                 >
-                  <Link href="/admin" className="font-medium" onClick={closeMobileSidebar}>
+                  <Link href="/admin" onClick={closeMobileSidebar}>
                     <ShieldCheck className="size-4" />
                     {t('sidebar.adminPanel')}
                   </Link>
@@ -493,26 +587,24 @@ export function AppSidebar({
             </SidebarMenu>
           </SidebarGroup>
         )}
-        <SidebarInstallButton />
-        <FullscreenToggle />
       </SidebarContent>
-      <SidebarFooter>
+      <SidebarFooter className="border-t border-sidebar-border p-2">
         <SidebarMenu>
-          <SidebarMenuItem>
+          <SidebarMenuItem className="flex items-center gap-1 group-data-[collapsible=icon]:flex-col">
             <DropdownMenu>
               <DropdownMenuTrigger asChild>
                 <SidebarMenuButton
                   size="lg"
                   className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
                 >
-                  <Avatar className="size-8 rounded-lg">
-                    <AvatarFallback className="rounded-lg bg-sidebar-primary/10 text-xs font-semibold text-sidebar-primary">
+                  <Avatar className="size-8 rounded-full">
+                    <AvatarFallback className="rounded-full bg-sidebar-primary/15 text-xs font-semibold text-sidebar-foreground">
                       {initials}
                     </AvatarFallback>
                   </Avatar>
                   <div className="grid flex-1 text-left text-sm leading-tight">
-                    <span className="truncate font-semibold">{session?.user?.name}</span>
-                    <span className="truncate text-xs text-muted-foreground">
+                    <span className="truncate font-medium">{session?.user?.name}</span>
+                    <span className="truncate text-xs text-sidebar-foreground/60">
                       {session?.user?.email}
                     </span>
                   </div>
@@ -521,8 +613,8 @@ export function AppSidebar({
               </DropdownMenuTrigger>
               <DropdownMenuContent
                 className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
-                side="bottom"
-                align="end"
+                side="top"
+                align="start"
                 sideOffset={4}
               >
                 {canAccess('settings') && (
@@ -585,6 +677,11 @@ export function AppSidebar({
                     </DropdownMenuRadioGroup>
                   </DropdownMenuSubContent>
                 </DropdownMenuSub>
+                {/* Device conveniences, out of the navigation where they
+                    used to pass for pages. Each renders nothing where it
+                    does not apply. */}
+                <InstallMenuItem />
+                <FullscreenMenuItem />
                 <DropdownMenuSeparator />
                 <DropdownMenuItem onClick={handleSignOut}>
                   <LogOut className="mr-2 size-4" />
@@ -592,6 +689,7 @@ export function AppSidebar({
                 </DropdownMenuItem>
               </DropdownMenuContent>
             </DropdownMenu>
+            {isAdminOrOwner && <NotificationBell />}
           </SidebarMenuItem>
         </SidebarMenu>
       </SidebarFooter>

+ 35 - 33
src/components/fullscreen-toggle.tsx

@@ -3,12 +3,7 @@
 import { useCallback, useEffect, useState } from 'react'
 import { useTranslations } from 'next-intl'
 import { Maximize, Minimize } from 'lucide-react'
-import {
-  SidebarGroup,
-  SidebarMenu,
-  SidebarMenuButton,
-  SidebarMenuItem,
-} from '@/components/ui/sidebar'
+import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
 import { useInstallPrompt } from '@/components/pwa-install-prompt'
 
 /**
@@ -41,6 +36,22 @@ function prefersFullscreen(): boolean {
   }
 }
 
+/** Whether the browser offers fullscreen at all, and whether it is on now. */
+function useFullscreenState() {
+  const [supported, setSupported] = useState(false)
+  const [active, setActive] = useState(false)
+
+  useEffect(() => {
+    setSupported(typeof document !== 'undefined' && document.fullscreenEnabled)
+    const sync = () => setActive(document.fullscreenElement !== null)
+    sync()
+    document.addEventListener('fullscreenchange', sync)
+    return () => document.removeEventListener('fullscreenchange', sync)
+  }, [])
+
+  return { supported, active }
+}
+
 /**
  * Fullscreen for the installed app.
  *
@@ -57,20 +68,14 @@ function prefersFullscreen(): boolean {
  *
  * Only ever automatic for the installed app. Doing this to a browser tab would
  * hijack the first click on a page somebody opened alongside others.
+ *
+ * Renders nothing. It lives apart from the menu item because that item sits
+ * in a dropdown that is not mounted until somebody opens it, and the first
+ * gesture after launch has to be caught before then.
  */
-export function FullscreenToggle() {
-  const t = useTranslations('common.shared')
+export function FullscreenLauncher() {
   const { installed } = useInstallPrompt()
-  const [supported, setSupported] = useState(false)
-  const [active, setActive] = useState(false)
-
-  useEffect(() => {
-    setSupported(typeof document !== 'undefined' && document.fullscreenEnabled)
-    const sync = () => setActive(document.fullscreenElement !== null)
-    sync()
-    document.addEventListener('fullscreenchange', sync)
-    return () => document.removeEventListener('fullscreenchange', sync)
-  }, [])
+  const { supported } = useFullscreenState()
 
   // Spend the remembered preference on the first click or keypress. Listening
   // once, in capture, so the gesture still reaches whatever was clicked.
@@ -90,6 +95,14 @@ export function FullscreenToggle() {
     }
   }, [installed, supported])
 
+  return null
+}
+
+/** The fullscreen switch, as an entry in the account menu. */
+export function FullscreenMenuItem() {
+  const t = useTranslations('common.shared')
+  const { supported, active } = useFullscreenState()
+
   const toggle = useCallback(() => {
     if (document.fullscreenElement) {
       // Turning it off is also a decision: the next launch should respect it.
@@ -112,20 +125,9 @@ export function FullscreenToggle() {
   if (!supported) return null
 
   return (
-    <SidebarGroup>
-      <SidebarMenu>
-        <SidebarMenuItem>
-          <SidebarMenuButton
-            onClick={toggle}
-            tooltip={active ? t('exitFullscreen') : t('enterFullscreen')}
-          >
-            {active ? <Minimize className="size-4" /> : <Maximize className="size-4" />}
-            <span className="font-medium">
-              {active ? t('exitFullscreen') : t('enterFullscreen')}
-            </span>
-          </SidebarMenuButton>
-        </SidebarMenuItem>
-      </SidebarMenu>
-    </SidebarGroup>
+    <DropdownMenuItem onClick={toggle}>
+      {active ? <Minimize className="mr-2 size-4" /> : <Maximize className="mr-2 size-4" />}
+      {active ? t('exitFullscreen') : t('enterFullscreen')}
+    </DropdownMenuItem>
   )
 }

+ 10 - 20
src/components/pwa-install-prompt.tsx

@@ -4,12 +4,7 @@ import { useCallback, useEffect, useReducer, useState } from 'react'
 import Image from 'next/image'
 import { Download, X } from 'lucide-react'
 import { Button } from '@/components/ui/button'
-import {
-  SidebarGroup,
-  SidebarMenu,
-  SidebarMenuButton,
-  SidebarMenuItem,
-} from '@/components/ui/sidebar'
+import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
 
 import { useTranslations } from 'next-intl'
 interface BeforeInstallPromptEvent extends Event {
@@ -176,25 +171,20 @@ export function InstallBanner() {
   )
 }
 
-export function SidebarInstallButton() {
+/** The install offer, as an entry in the account menu. */
+export function InstallMenuItem() {
   const t = useTranslations('common.shared')
   const { canInstall, dismissed, isIOS, install, dismiss } = useInstallPrompt()
 
   if (!canInstall || dismissed) return null
 
   return (
-    <SidebarGroup>
-      <SidebarMenu>
-        <SidebarMenuItem>
-          <SidebarMenuButton
-            onClick={isIOS ? dismiss : install}
-            tooltip={isIOS ? t('iosInstallHint') : t('installApp')}
-          >
-            <Download className="size-4" />
-            <span className="font-medium">{t('installApp')}</span>
-          </SidebarMenuButton>
-        </SidebarMenuItem>
-      </SidebarMenu>
-    </SidebarGroup>
+    <DropdownMenuItem
+      onClick={isIOS ? dismiss : install}
+      title={isIOS ? t('iosInstallHint') : undefined}
+    >
+      <Download className="mr-2 size-4" />
+      {t('installApp')}
+    </DropdownMenuItem>
   )
 }

+ 14 - 10
src/components/ui/sidebar.tsx

@@ -236,7 +236,7 @@ function Sidebar({
         <div
           data-sidebar="sidebar"
           data-slot="sidebar-inner"
-          className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
+          className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-xl group-data-[variant=floating]:border"
         >
           {children}
         </div>
@@ -387,8 +387,10 @@ function SidebarGroupLabel({
       data-slot="sidebar-group-label"
       data-sidebar="group-label"
       className={cn(
-        'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
-        'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
+        // A section heading, not a link: small, spaced capitals in a colour
+        // quiet enough that the rows under it read first.
+        'text-sidebar-foreground/50 ring-sidebar-ring flex h-7 shrink-0 items-center rounded-md px-2 text-[11px] font-medium tracking-[0.08em] uppercase outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
+        'group-data-[collapsible=icon]:-mt-7 group-data-[collapsible=icon]:opacity-0',
         className
       )}
       {...props}
@@ -453,11 +455,12 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
 }
 
 const sidebarMenuButtonVariants = cva(
-  // The active item is marked three ways: the accent fill, a primary bar down
-  // its leading edge, and a primary icon. The label keeps the high-contrast
-  // accent foreground — several themes set --sidebar-primary to a light amber
-  // that is unreadable as body text on a light sidebar.
-  'peer/menu-button relative flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding,background-color,color] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[active=true]:[&>svg]:text-sidebar-primary data-[active=true]:before:absolute data-[active=true]:before:left-0 data-[active=true]:before:top-1/2 data-[active=true]:before:h-[60%] data-[active=true]:before:w-[3px] data-[active=true]:before:-translate-y-1/2 data-[active=true]:before:rounded-r-full data-[active=true]:before:bg-sidebar-primary group-data-[collapsible=icon]:before:hidden data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
+  // Rest rows are regular weight with the icon toned down, so the one active
+  // row (medium weight, a soft primary tint, a primary icon) is the only thing
+  // that stands out. The label keeps the high-contrast foreground rather than
+  // going primary: several themes set --sidebar-primary to a light amber that
+  // is unreadable as body text on a light sidebar.
+  'peer/menu-button relative flex w-full items-center gap-2.5 overflow-hidden rounded-lg px-2.5 py-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding,background-color,color] duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>svg]:text-sidebar-foreground/60 [&>svg]:transition-colors [&>svg]:duration-150 hover:[&>svg]:text-sidebar-accent-foreground data-[active=true]:bg-sidebar-primary/12 data-[active=true]:font-medium data-[active=true]:text-sidebar-foreground data-[active=true]:[&>svg]:text-sidebar-primary data-[active=true]:hover:bg-sidebar-primary/16 data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
   {
     variants: {
       variant: {
@@ -566,8 +569,9 @@ function SidebarMenuBadge({ className, ...props }: React.ComponentProps<'div'>)
       data-slot="sidebar-menu-badge"
       data-sidebar="menu-badge"
       className={cn(
-        'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',
-        'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
+        // A quiet pill that takes the primary tint only on the active row.
+        'text-sidebar-foreground/70 bg-sidebar-foreground/8 pointer-events-none absolute right-2 flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-[11px] font-medium tabular-nums select-none',
+        'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:bg-sidebar-primary/15 peer-data-[active=true]/menu-button:text-sidebar-foreground',
         'peer-data-[size=sm]/menu-button:top-1',
         'peer-data-[size=default]/menu-button:top-1.5',
         'peer-data-[size=lg]/menu-button:top-2.5',