Delete features (#218)
* Implement company and account deletion features - Add event types for company and account deletion to CoreEvent. - Enhance Supabase middleware to handle company context resolution and cookie management for archived companies. - Create API routes for deleting accounts and companies, including necessary validations and event emissions. - Implement tests for account and company deletion endpoints to ensure proper functionality and error handling. - Add retention notice component to inform users about bookkeeping data retention during destructive actions. - Create database migrations to support soft deletion of companies and anonymization of user accounts, ensuring compliance with retention laws. * feat: enhance account deletion process and update user notifications * Add service client for onboarding completion check and update escape hatch visibility * Enhance invite flow and email handling for company members * Refactor company context and RLS policies for active company isolation - Update `switchCompany` to remove unnecessary revalidation as client handles navigation. - Revise `getActiveCompanyId` to prioritize `user_preferences` and validate against non-archived memberships. - Modify `setActiveCompany` to ensure `user_preferences` is the authoritative source while maintaining cookie compatibility. - Enhance middleware to resolve active company using `user_preferences` and fallback to first non-archived membership. - Introduce new API route `/api/company/current` to fetch the active company ID for cross-tab synchronization. - Implement `CompanyTabSync` component for real-time active company enforcement across tabs. - Create migration for RLS policies to enforce single-active-company isolation using `current_active_company_id()`. * feat: implement viewer role enforcement for write permissions - Added `useCanWrite` hook to determine if the current user has write permissions based on their role in the active company. - Updated various components (JournalEntryForm, CustomerForm, DeadlineForm, etc.) to disable write actions and show a lock icon with a tooltip for users without write permissions. - Introduced `requireWritePermission` function to enforce write permissions at the API level, returning a 403 response for viewers. - Created tests to verify the behavior of the viewer role and write permissions. - Added database migration to enforce read-only access for viewers at the database level.
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useTransition, useCallback } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
@@ -11,9 +10,8 @@ import { Check, ChevronsUpDown, Plus, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function CompanySwitcher() {
|
||||
const { company, companies } = useCompany()
|
||||
const router = useRouter()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0 })
|
||||
@@ -73,27 +71,52 @@ export default function CompanySwitcher() {
|
||||
return () => document.removeEventListener('keydown', handleKey)
|
||||
}, [open])
|
||||
|
||||
const handleSwitch = (companyId: string) => {
|
||||
const handleSwitch = async (companyId: string) => {
|
||||
if (company && companyId === company.id) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
startTransition(async () => {
|
||||
const result = await switchCompany(companyId)
|
||||
if (!result.error) {
|
||||
setOpen(false)
|
||||
router.refresh()
|
||||
setIsPending(true)
|
||||
const result = await switchCompany(companyId)
|
||||
if (result.error) {
|
||||
setIsPending(false)
|
||||
return
|
||||
}
|
||||
// Notify every other open tab of the same user so they hard-reload
|
||||
// onto the new company. BroadcastChannel is best-effort — if the
|
||||
// browser doesn't support it (very old) we still hard-reload
|
||||
// ourselves, and other tabs will self-correct via the visibilitychange
|
||||
// / pageshow listeners in CompanyTabSync on their next focus event.
|
||||
if (typeof BroadcastChannel !== 'undefined') {
|
||||
try {
|
||||
const channel = new BroadcastChannel('gnubok-company-switch')
|
||||
channel.postMessage({ companyId })
|
||||
channel.close()
|
||||
} catch {
|
||||
// Ignore — hard reload still happens below
|
||||
}
|
||||
})
|
||||
}
|
||||
// Hard navigation — tears down React state, router cache, in-flight
|
||||
// fetches, blob URLs, etc. This is the whole point: nothing from the
|
||||
// previous company can survive the switch.
|
||||
window.location.assign('/')
|
||||
}
|
||||
|
||||
// Always allow opening the dropdown (to show "Lägg till företag")
|
||||
const hasMultiple = companies.length > 1
|
||||
const canOpen = companies.length > 0
|
||||
|
||||
// No companies yet — hide the switcher entirely
|
||||
// No companies yet — show a direct "Lägg till företag" link instead of
|
||||
// the switcher so the user can still create one.
|
||||
if (!company && companies.length === 0) {
|
||||
return null
|
||||
return (
|
||||
<Link
|
||||
href="/onboarding"
|
||||
className="flex items-center gap-2 w-full text-left rounded-lg border border-dashed border-border/60 hover:border-foreground/30 hover:bg-muted/40 -mx-1 px-2 py-1.5 transition-all duration-150"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-[13px] text-muted-foreground truncate">Lägg till företag</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
|
||||
/**
|
||||
* CompanyTabSync — cross-tab active company enforcement.
|
||||
*
|
||||
* Mounted once inside the dashboard layout (via CompanyProvider), this
|
||||
* component guarantees that every open tab of the same user always shows
|
||||
* the same active company. It has three layers:
|
||||
*
|
||||
* 1. BroadcastChannel('gnubok-company-switch')
|
||||
* When the user switches company in one tab, every other live tab
|
||||
* receives the message and hard-reloads if its current company differs
|
||||
* from the broadcasted one.
|
||||
*
|
||||
* 2. visibilitychange
|
||||
* Catches tabs that were hidden/minimized during a switch. On focus,
|
||||
* the tab checks /api/company/current and reloads on mismatch — before
|
||||
* any pixel of stale data is painted to the user.
|
||||
*
|
||||
* 3. pageshow with event.persisted === true
|
||||
* Catches tabs restored from the browser's bfcache (back/forward
|
||||
* navigation). bfcache literally freezes the DOM and JS state, so
|
||||
* neither BroadcastChannel nor visibilitychange fires. pageshow is the
|
||||
* only reliable signal and is guaranteed to fire on bfcache restore.
|
||||
*
|
||||
* All three layers converge on the same action: window.location.assign('/')
|
||||
* — a hard navigation that wipes React state, the router cache, in-flight
|
||||
* requests, blob URLs, and every other in-tab leak vector.
|
||||
*
|
||||
* No-op when the user has no active company (renders nothing, attaches no
|
||||
* listeners).
|
||||
*/
|
||||
export default function CompanyTabSync() {
|
||||
const { company } = useCompany()
|
||||
const currentCompanyId = company?.id ?? null
|
||||
|
||||
useEffect(() => {
|
||||
const hardReload = () => {
|
||||
window.location.assign('/')
|
||||
}
|
||||
|
||||
// Layer 1: BroadcastChannel — live cross-tab sync
|
||||
let channel: BroadcastChannel | null = null
|
||||
if (typeof BroadcastChannel !== 'undefined') {
|
||||
channel = new BroadcastChannel('gnubok-company-switch')
|
||||
channel.onmessage = (event: MessageEvent<{ companyId: string | null }>) => {
|
||||
const incomingId = event.data?.companyId ?? null
|
||||
if (incomingId !== currentCompanyId) {
|
||||
hardReload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 2: visibilitychange — on focus, verify against server
|
||||
const checkServer = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/company/current', {
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
if (!res.ok) return
|
||||
const data = (await res.json()) as { companyId: string | null }
|
||||
if (data.companyId !== currentCompanyId) {
|
||||
hardReload()
|
||||
}
|
||||
} catch {
|
||||
// Network error / offline — do nothing (don't accidentally reload-loop)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void checkServer()
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
// Layer 3: pageshow (persisted === true) — bfcache restore
|
||||
const handlePageShow = (event: PageTransitionEvent) => {
|
||||
if (event.persisted) {
|
||||
void checkServer()
|
||||
}
|
||||
}
|
||||
window.addEventListener('pageshow', handlePageShow)
|
||||
|
||||
return () => {
|
||||
channel?.close()
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
window.removeEventListener('pageshow', handlePageShow)
|
||||
}
|
||||
}, [currentCompanyId])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||
import { SupportLink } from '@/components/ui/support-link'
|
||||
import CompanySwitcher from '@/components/dashboard/CompanySwitcher'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
interface ExtensionNavItem {
|
||||
@@ -87,13 +88,23 @@ const groupLabels: Record<string, string> = {
|
||||
övrigt: 'Övrigt',
|
||||
}
|
||||
|
||||
export default function DashboardNav({ companyName, entityType, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [] }: DashboardNavProps) {
|
||||
export default function DashboardNav({ companyName: _companyName, entityType, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [] }: DashboardNavProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const supabase = createClient()
|
||||
const { company } = useCompany()
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||
const [isClosing, setIsClosing] = useState(false)
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// When the user has no active company (e.g. just archived their last
|
||||
// one), every company-scoped route is unreachable. We keep them visible
|
||||
// so the sidebar doesn't collapse, but render them as disabled.
|
||||
// Only /settings remains navigable — from there the user can either
|
||||
// create a new company or delete their account.
|
||||
const hasCompany = !!company
|
||||
const ALWAYS_ENABLED = new Set(['/settings'])
|
||||
const isItemEnabled = (href: string) => hasCompany || ALWAYS_ENABLED.has(href)
|
||||
// Auto-expand Övrigt when the user is on one of its pages, or when manually toggled
|
||||
const isOnOvrigtPage = ['/help', '/settings', '/e/'].some(p => pathname.startsWith(p))
|
||||
const [manualOvrigtExpanded, setManualOvrigtExpanded] = useState(false)
|
||||
@@ -175,23 +186,40 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
|
||||
{mainItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.href)
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
|
||||
active
|
||||
? 'bg-primary/12 text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
const enabled = isItemEnabled(item.href)
|
||||
const content = (
|
||||
<>
|
||||
<Icon className={cn(
|
||||
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
|
||||
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
|
||||
)} />
|
||||
{item.label}
|
||||
</>
|
||||
)
|
||||
const baseClass = cn(
|
||||
'group flex items-center px-3 py-[7px] text-[13px] rounded-lg',
|
||||
enabled
|
||||
? cn(
|
||||
'transition-colors duration-150',
|
||||
active
|
||||
? 'bg-primary/12 text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
||||
)
|
||||
: 'text-muted-foreground/40 cursor-not-allowed'
|
||||
)
|
||||
return enabled ? (
|
||||
<Link key={item.href} href={item.href} className={baseClass}>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
key={item.href}
|
||||
className={baseClass}
|
||||
aria-disabled="true"
|
||||
title="Lägg till ett företag för att aktivera"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -207,22 +235,14 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.href)
|
||||
const enabled = isItemEnabled(item.href)
|
||||
const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0
|
||||
? uncategorizedTransactionCount
|
||||
: item.href === '/pending' && pendingOperationsCount > 0
|
||||
? pendingOperationsCount
|
||||
: null
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
|
||||
active
|
||||
? 'bg-primary/12 text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
const content = (
|
||||
<>
|
||||
<Icon className={cn(
|
||||
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
|
||||
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
|
||||
@@ -233,7 +253,32 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
const baseClass = cn(
|
||||
'group flex items-center px-3 py-[7px] text-[13px] rounded-lg',
|
||||
enabled
|
||||
? cn(
|
||||
'transition-colors duration-150',
|
||||
active
|
||||
? 'bg-primary/12 text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
||||
)
|
||||
: 'text-muted-foreground/40 cursor-not-allowed'
|
||||
)
|
||||
return enabled ? (
|
||||
<Link key={item.href} href={item.href} className={baseClass}>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
key={item.href}
|
||||
className={baseClass}
|
||||
aria-disabled="true"
|
||||
title="Lägg till ett företag för att aktivera"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -257,45 +302,80 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
|
||||
{extensionNavItems.map((item) => {
|
||||
const Icon = resolveIcon(item.icon)
|
||||
const active = isActive(item.href)
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
|
||||
active
|
||||
? 'bg-primary/12 text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
// Extension nav items are always company-scoped.
|
||||
const enabled = hasCompany
|
||||
const content = (
|
||||
<>
|
||||
<Icon className={cn(
|
||||
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
|
||||
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
|
||||
)} />
|
||||
{item.label}
|
||||
</>
|
||||
)
|
||||
const baseClass = cn(
|
||||
'group flex items-center px-3 py-[7px] text-[13px] rounded-lg',
|
||||
enabled
|
||||
? cn(
|
||||
'transition-colors duration-150',
|
||||
active
|
||||
? 'bg-primary/12 text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
||||
)
|
||||
: 'text-muted-foreground/40 cursor-not-allowed'
|
||||
)
|
||||
return enabled ? (
|
||||
<Link key={item.href} href={item.href} className={baseClass}>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
key={item.href}
|
||||
className={baseClass}
|
||||
aria-disabled="true"
|
||||
title="Lägg till ett företag för att aktivera"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{övrigtItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.href)
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
|
||||
active
|
||||
? 'bg-primary/12 text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
const enabled = isItemEnabled(item.href)
|
||||
const content = (
|
||||
<>
|
||||
<Icon className={cn(
|
||||
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
|
||||
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
|
||||
)} />
|
||||
{item.label}
|
||||
</>
|
||||
)
|
||||
const baseClass = cn(
|
||||
'group flex items-center px-3 py-[7px] text-[13px] rounded-lg',
|
||||
enabled
|
||||
? cn(
|
||||
'transition-colors duration-150',
|
||||
active
|
||||
? 'bg-primary/12 text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
||||
)
|
||||
: 'text-muted-foreground/40 cursor-not-allowed'
|
||||
)
|
||||
return enabled ? (
|
||||
<Link key={item.href} href={item.href} className={baseClass}>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
key={item.href}
|
||||
className={baseClass}
|
||||
aria-disabled="true"
|
||||
title="Lägg till ett företag för att aktivera"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -327,21 +407,13 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
|
||||
{mobileNavItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.href)
|
||||
const enabled = isItemEnabled(item.href)
|
||||
const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0
|
||||
? uncategorizedTransactionCount
|
||||
: null
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center flex-1 h-full text-xs transition-colors duration-200',
|
||||
active
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
const content = (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Icon className={cn(
|
||||
"h-5 w-5 mb-1",
|
||||
@@ -357,7 +429,26 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
|
||||
"truncate",
|
||||
active && "font-medium"
|
||||
)}>{item.label}</span>
|
||||
</>
|
||||
)
|
||||
const baseClass = cn(
|
||||
'relative flex flex-col items-center justify-center flex-1 h-full text-xs',
|
||||
enabled
|
||||
? cn(
|
||||
'transition-colors duration-200',
|
||||
active ? 'text-primary' : 'text-muted-foreground'
|
||||
)
|
||||
: 'text-muted-foreground/40'
|
||||
)
|
||||
|
||||
return enabled ? (
|
||||
<Link key={item.href} href={item.href} className={baseClass}>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={item.href} className={baseClass} aria-disabled="true">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Menu button */}
|
||||
@@ -424,21 +515,37 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
|
||||
{mainItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.href)
|
||||
return (
|
||||
const enabled = isItemEnabled(item.href)
|
||||
const content = (
|
||||
<>
|
||||
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
|
||||
<span className="text-sm">{item.label}</span>
|
||||
</>
|
||||
)
|
||||
const baseClass = cn(
|
||||
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg',
|
||||
enabled
|
||||
? cn(
|
||||
'transition-colors',
|
||||
active
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-foreground active:bg-muted/60'
|
||||
)
|
||||
: 'text-muted-foreground/40'
|
||||
)
|
||||
return enabled ? (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={closeMobileMenu}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg transition-colors',
|
||||
active
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-foreground active:bg-muted/60'
|
||||
)}
|
||||
className={baseClass}
|
||||
>
|
||||
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
|
||||
<span className="text-sm">{item.label}</span>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={item.href} className={baseClass} aria-disabled="true">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -454,23 +561,14 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.href)
|
||||
const enabled = isItemEnabled(item.href)
|
||||
const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0
|
||||
? uncategorizedTransactionCount
|
||||
: item.href === '/pending' && pendingOperationsCount > 0
|
||||
? pendingOperationsCount
|
||||
: null
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={closeMobileMenu}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg transition-colors',
|
||||
active
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-foreground active:bg-muted/60'
|
||||
)}
|
||||
>
|
||||
const content = (
|
||||
<>
|
||||
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
|
||||
<span className="text-sm flex-1">{item.label}</span>
|
||||
{badge !== null && (
|
||||
@@ -478,7 +576,32 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
const baseClass = cn(
|
||||
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg',
|
||||
enabled
|
||||
? cn(
|
||||
'transition-colors',
|
||||
active
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-foreground active:bg-muted/60'
|
||||
)
|
||||
: 'text-muted-foreground/40'
|
||||
)
|
||||
return enabled ? (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={closeMobileMenu}
|
||||
className={baseClass}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={item.href} className={baseClass} aria-disabled="true">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -496,41 +619,73 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
|
||||
{extensionNavItems.map((item) => {
|
||||
const Icon = resolveIcon(item.icon)
|
||||
const active = isActive(item.href)
|
||||
return (
|
||||
const enabled = hasCompany
|
||||
const content = (
|
||||
<>
|
||||
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
|
||||
<span className="text-sm">{item.label}</span>
|
||||
</>
|
||||
)
|
||||
const baseClass = cn(
|
||||
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg',
|
||||
enabled
|
||||
? cn(
|
||||
'transition-colors',
|
||||
active
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-foreground active:bg-muted/60'
|
||||
)
|
||||
: 'text-muted-foreground/40'
|
||||
)
|
||||
return enabled ? (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={closeMobileMenu}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg transition-colors',
|
||||
active
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-foreground active:bg-muted/60'
|
||||
)}
|
||||
className={baseClass}
|
||||
>
|
||||
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
|
||||
<span className="text-sm">{item.label}</span>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={item.href} className={baseClass} aria-disabled="true">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{övrigtItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.href)
|
||||
return (
|
||||
const enabled = isItemEnabled(item.href)
|
||||
const content = (
|
||||
<>
|
||||
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
|
||||
<span className="text-sm">{item.label}</span>
|
||||
</>
|
||||
)
|
||||
const baseClass = cn(
|
||||
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg',
|
||||
enabled
|
||||
? cn(
|
||||
'transition-colors',
|
||||
active
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-foreground active:bg-muted/60'
|
||||
)
|
||||
: 'text-muted-foreground/40'
|
||||
)
|
||||
return enabled ? (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={closeMobileMenu}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg transition-colors',
|
||||
active
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-foreground active:bg-muted/60'
|
||||
)}
|
||||
className={baseClass}
|
||||
>
|
||||
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
|
||||
<span className="text-sm">{item.label}</span>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={item.href} className={baseClass} aria-disabled="true">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user