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:
2026-04-16 15:59:25 +07:00
parent 03526ff3b9
commit f51e156539
@@ -1,58 +1,95 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores';
import { afterNavigate } from '$app/navigation';
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types'; import type { LayoutData } from './$types';
let { data, children } = $props(); let { data, children }: { data: LayoutData; children: Snippet } = $props();
const tabs = $derived([ type MenuKey = 'hr' | 'ops' | 'admin';
{ href: `/companies/${data.company.id}`, label: 'Overview' }, let openMenu = $state<MenuKey | null>(null);
{ href: `/companies/${data.company.id}/links`, label: 'Links' },
{ href: `/companies/${data.company.id}/projects`, label: 'Projects' }, afterNavigate(() => {
{ href: `/companies/${data.company.id}/expenses`, label: 'Expenses' }, openMenu = null;
{ href: `/companies/${data.company.id}/budget`, label: 'Budget' }, });
{ href: `/companies/${data.company.id}/categories`, label: 'Categories' },
{ href: `/companies/${data.company.id}/reports`, label: 'Reports' }, const baseUrl = $derived(`/companies/${data.company.id}`);
...(data.companyRoles.some(r => r === 'admin' || r === 'manager' || r === 'hr') 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: `${baseUrl}/employees`, label: 'Employees' },
{ href: `/companies/${data.company.id}/hr/leave-requests`, label: 'Leave' }, { href: `${baseUrl}/hr/leave-requests`, label: 'Leave' },
{ href: `/companies/${data.company.id}/hr/payroll`, label: 'Payroll' }, { href: `${baseUrl}/hr/payroll`, label: 'Payroll' },
{ href: `/companies/${data.company.id}/hr/holidays`, label: 'Holidays' } { href: `${baseUrl}/hr/holidays`, label: 'Holidays' }
] ]
: []), : []
...(data.companyRoles.some(r => r === 'admin' || r === 'manager') );
? [
{ href: `/companies/${data.company.id}/parties`, label: 'Contacts' }, const opsItems = $derived(
{ href: `/companies/${data.company.id}/invoices`, label: 'Invoices' } [
] { href: `${baseUrl}/parties`, label: 'Contacts', show: has(['admin', 'manager']) },
: []), { href: `${baseUrl}/categories`, label: 'Categories', show: true },
...(data.companyRoles.some(r => r === 'admin' || r === 'manager' || r === 'user' || r === 'hr') { href: `${baseUrl}/packages`, label: 'Packages', show: has(['admin', 'manager', 'user', 'hr']) },
? [{ href: `/companies/${data.company.id}/packages`, label: 'Packages' }] { href: `${baseUrl}/links`, label: 'Links', show: true },
: []), { href: `${baseUrl}/documents`, label: 'Documents', show: has(['admin', 'manager', 'accountant']) }
...(data.companyRoles.includes('admin') ].filter((t) => t.show)
? [ );
{ href: `/companies/${data.company.id}/integrations`, label: 'Integrations' }
] const adminItems = $derived(
: []), [
...(data.companyRoles.some((r) => r === 'admin' || r === 'manager' || r === 'accountant') { href: `${baseUrl}/integrations`, label: 'Integrations', show: has(['admin']) },
? [ { href: `${baseUrl}/import`, label: 'Import', show: has(['admin', 'manager']) },
{ href: `/companies/${data.company.id}/accounts`, label: 'Accounts' }, { href: `${baseUrl}/export`, label: 'Export', show: has(['admin', 'accountant']) },
{ href: `/companies/${data.company.id}/bills`, label: 'Bills' }, { href: `${baseUrl}/profile`, label: 'Profile', show: has(['admin', 'manager', 'accountant']) },
{ href: `/companies/${data.company.id}/profile`, label: 'Profile' }, { href: `${baseUrl}/settings`, label: 'Settings', show: has(['admin', 'manager']) }
{ href: `/companies/${data.company.id}/documents`, label: 'Documents' } ].filter((t) => t.show)
] );
: []),
...(data.companyRoles.includes('admin') || data.companyRoles.includes('accountant') function menuActive(items: Array<{ href: string }>): boolean {
? [{ href: `/companies/${data.company.id}/export`, label: 'Export' }] return items.some((i) => isActive(i.href));
: []), }
...(data.companyRoles.includes('admin') || data.companyRoles.includes('manager')
? [ function toggleMenu(menu: MenuKey) {
{ href: `/companies/${data.company.id}/import`, label: 'Import' }, openMenu = openMenu === menu ? null : menu;
{ href: `/companies/${data.company.id}/settings`, label: 'Settings' } }
]
: []) 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> </script>
<svelte:window onclick={handleWindowClick} onkeydown={handleKeydown} />
<div> <div>
<div class="mb-6"> <div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{data.company.name}</h1> <h1 class="text-2xl font-bold text-gray-900 dark:text-white">{data.company.name}</h1>
@@ -61,16 +98,82 @@
{/if} {/if}
</div> </div>
<!-- Tabs --> <nav
<nav class="mb-6 flex gap-1 overflow-x-auto border-b border-gray-200 dark:border-gray-700"> class="mb-6 flex flex-wrap items-center gap-1 border-b border-gray-200 dark:border-gray-700"
{#each tabs as tab} aria-label="Company navigation"
>
{#each primaryTabs as tab (tab.href)}
{@const active = isActive(tab.href)}
<a <a
href={tab.href} 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} {tab.label}
</a> </a>
{/each} {/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> </nav>
{@render children()} {@render children()}