Restructure company nav: 8 primary tabs + HR/Ops/Admin dropdowns with active highlight
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,58 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
let { data, children } = $props();
|
||||
let { data, children }: { data: LayoutData; children: Snippet } = $props();
|
||||
|
||||
const tabs = $derived([
|
||||
{ href: `/companies/${data.company.id}`, label: 'Overview' },
|
||||
{ href: `/companies/${data.company.id}/links`, label: 'Links' },
|
||||
{ href: `/companies/${data.company.id}/projects`, label: 'Projects' },
|
||||
{ href: `/companies/${data.company.id}/expenses`, label: 'Expenses' },
|
||||
{ href: `/companies/${data.company.id}/budget`, label: 'Budget' },
|
||||
{ href: `/companies/${data.company.id}/categories`, label: 'Categories' },
|
||||
{ href: `/companies/${data.company.id}/reports`, label: 'Reports' },
|
||||
...(data.companyRoles.some(r => r === 'admin' || r === 'manager' || r === 'hr')
|
||||
type MenuKey = 'hr' | 'ops' | 'admin';
|
||||
let openMenu = $state<MenuKey | null>(null);
|
||||
|
||||
afterNavigate(() => {
|
||||
openMenu = null;
|
||||
});
|
||||
|
||||
const baseUrl = $derived(`/companies/${data.company.id}`);
|
||||
const currentPath = $derived($page.url.pathname);
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
if (href === baseUrl) return currentPath === baseUrl;
|
||||
return currentPath === href || currentPath.startsWith(href + '/');
|
||||
}
|
||||
|
||||
function has(roles: string[]): boolean {
|
||||
return data.companyRoles.some((r) => roles.includes(r));
|
||||
}
|
||||
|
||||
const primaryTabs = $derived(
|
||||
[
|
||||
{ href: baseUrl, label: 'Overview', show: true },
|
||||
{ href: `${baseUrl}/accounts`, label: 'Accounts', show: has(['admin', 'manager', 'accountant']) },
|
||||
{ href: `${baseUrl}/projects`, label: 'Projects', show: true },
|
||||
{ href: `${baseUrl}/expenses`, label: 'Expenses', show: true },
|
||||
{ href: `${baseUrl}/bills`, label: 'Bills', show: has(['admin', 'manager', 'accountant']) },
|
||||
{ href: `${baseUrl}/invoices`, label: 'Invoices', show: has(['admin', 'manager']) },
|
||||
{ href: `${baseUrl}/budget`, label: 'Budget', show: true },
|
||||
{ href: `${baseUrl}/reports`, label: 'Reports', show: true }
|
||||
].filter((t) => t.show)
|
||||
);
|
||||
|
||||
const hrItems = $derived(
|
||||
has(['admin', 'manager', 'hr'])
|
||||
? [
|
||||
{ href: `/companies/${data.company.id}/employees`, label: 'Employees' },
|
||||
{ href: `/companies/${data.company.id}/hr/leave-requests`, label: 'Leave' },
|
||||
{ href: `/companies/${data.company.id}/hr/payroll`, label: 'Payroll' },
|
||||
{ href: `/companies/${data.company.id}/hr/holidays`, label: 'Holidays' }
|
||||
{ href: `${baseUrl}/employees`, label: 'Employees' },
|
||||
{ href: `${baseUrl}/hr/leave-requests`, label: 'Leave' },
|
||||
{ href: `${baseUrl}/hr/payroll`, label: 'Payroll' },
|
||||
{ href: `${baseUrl}/hr/holidays`, label: 'Holidays' }
|
||||
]
|
||||
: []),
|
||||
...(data.companyRoles.some(r => r === 'admin' || r === 'manager')
|
||||
? [
|
||||
{ href: `/companies/${data.company.id}/parties`, label: 'Contacts' },
|
||||
{ href: `/companies/${data.company.id}/invoices`, label: 'Invoices' }
|
||||
]
|
||||
: []),
|
||||
...(data.companyRoles.some(r => r === 'admin' || r === 'manager' || r === 'user' || r === 'hr')
|
||||
? [{ href: `/companies/${data.company.id}/packages`, label: 'Packages' }]
|
||||
: []),
|
||||
...(data.companyRoles.includes('admin')
|
||||
? [
|
||||
{ href: `/companies/${data.company.id}/integrations`, label: 'Integrations' }
|
||||
]
|
||||
: []),
|
||||
...(data.companyRoles.some((r) => r === 'admin' || r === 'manager' || r === 'accountant')
|
||||
? [
|
||||
{ href: `/companies/${data.company.id}/accounts`, label: 'Accounts' },
|
||||
{ href: `/companies/${data.company.id}/bills`, label: 'Bills' },
|
||||
{ href: `/companies/${data.company.id}/profile`, label: 'Profile' },
|
||||
{ href: `/companies/${data.company.id}/documents`, label: 'Documents' }
|
||||
]
|
||||
: []),
|
||||
...(data.companyRoles.includes('admin') || data.companyRoles.includes('accountant')
|
||||
? [{ href: `/companies/${data.company.id}/export`, label: 'Export' }]
|
||||
: []),
|
||||
...(data.companyRoles.includes('admin') || data.companyRoles.includes('manager')
|
||||
? [
|
||||
{ href: `/companies/${data.company.id}/import`, label: 'Import' },
|
||||
{ href: `/companies/${data.company.id}/settings`, label: 'Settings' }
|
||||
]
|
||||
: [])
|
||||
]);
|
||||
: []
|
||||
);
|
||||
|
||||
const opsItems = $derived(
|
||||
[
|
||||
{ href: `${baseUrl}/parties`, label: 'Contacts', show: has(['admin', 'manager']) },
|
||||
{ href: `${baseUrl}/categories`, label: 'Categories', show: true },
|
||||
{ href: `${baseUrl}/packages`, label: 'Packages', show: has(['admin', 'manager', 'user', 'hr']) },
|
||||
{ href: `${baseUrl}/links`, label: 'Links', show: true },
|
||||
{ href: `${baseUrl}/documents`, label: 'Documents', show: has(['admin', 'manager', 'accountant']) }
|
||||
].filter((t) => t.show)
|
||||
);
|
||||
|
||||
const adminItems = $derived(
|
||||
[
|
||||
{ href: `${baseUrl}/integrations`, label: 'Integrations', show: has(['admin']) },
|
||||
{ href: `${baseUrl}/import`, label: 'Import', show: has(['admin', 'manager']) },
|
||||
{ href: `${baseUrl}/export`, label: 'Export', show: has(['admin', 'accountant']) },
|
||||
{ href: `${baseUrl}/profile`, label: 'Profile', show: has(['admin', 'manager', 'accountant']) },
|
||||
{ href: `${baseUrl}/settings`, label: 'Settings', show: has(['admin', 'manager']) }
|
||||
].filter((t) => t.show)
|
||||
);
|
||||
|
||||
function menuActive(items: Array<{ href: string }>): boolean {
|
||||
return items.some((i) => isActive(i.href));
|
||||
}
|
||||
|
||||
function toggleMenu(menu: MenuKey) {
|
||||
openMenu = openMenu === menu ? null : menu;
|
||||
}
|
||||
|
||||
function handleWindowClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('[data-nav-dropdown]')) return;
|
||||
openMenu = null;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') openMenu = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} onkeydown={handleKeydown} />
|
||||
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{data.company.name}</h1>
|
||||
@@ -61,16 +98,82 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<nav class="mb-6 flex gap-1 overflow-x-auto border-b border-gray-200 dark:border-gray-700">
|
||||
{#each tabs as tab}
|
||||
<nav
|
||||
class="mb-6 flex flex-wrap items-center gap-1 border-b border-gray-200 dark:border-gray-700"
|
||||
aria-label="Company navigation"
|
||||
>
|
||||
{#each primaryTabs as tab (tab.href)}
|
||||
{@const active = isActive(tab.href)}
|
||||
<a
|
||||
href={tab.href}
|
||||
class="whitespace-nowrap border-b-2 px-4 py-2 text-sm font-medium transition-colors border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
class="whitespace-nowrap border-b-2 px-4 py-2 text-sm font-medium transition-colors {active
|
||||
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300'}"
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
{tab.label}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
{#snippet dropdown(key: MenuKey, label: string, items: Array<{ href: string; label: string }>)}
|
||||
{#if items.length > 0}
|
||||
{@const active = menuActive(items)}
|
||||
{@const open = openMenu === key}
|
||||
<div class="relative" data-nav-dropdown>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleMenu(key)}
|
||||
class="flex items-center gap-1 whitespace-nowrap border-b-2 px-4 py-2 text-sm font-medium transition-colors {active
|
||||
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
||||
: open
|
||||
? 'border-gray-300 text-gray-700 dark:border-gray-600 dark:text-gray-300'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300'}"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
>
|
||||
{label}
|
||||
<svg
|
||||
class="h-3 w-3 transition-transform {open ? 'rotate-180' : ''}"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M3 4.5L6 7.5L9 4.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if open}
|
||||
<div
|
||||
role="menu"
|
||||
class="absolute left-0 top-full z-20 mt-1 min-w-[180px] rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
{#each items as item (item.href)}
|
||||
{@const itemActive = isActive(item.href)}
|
||||
<a
|
||||
href={item.href}
|
||||
role="menuitem"
|
||||
class="block px-4 py-2 text-sm transition-colors {itemActive
|
||||
? 'bg-blue-50 font-medium text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700/60'}"
|
||||
aria-current={itemActive ? 'page' : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{@render dropdown('hr', 'HR', hrItems)}
|
||||
{@render dropdown('ops', 'Ops', opsItems)}
|
||||
{@render dropdown('admin', 'Admin', adminItems)}
|
||||
</nav>
|
||||
|
||||
{@render children()}
|
||||
|
||||
Reference in New Issue
Block a user