@php // Navigation is a flat list of items. Each item is either: // • 'link' , a single top-level destination, or // • 'group', a collapsible dropdown holding related children. // Every link (top-level or child) carries a `can` ability (or an array of // any-of abilities, or null for "always visible"). Links the current user // lacks permission for are filtered out below, so restricted areas are // hidden from the menu entirely rather than 403'ing when clicked. A group // whose children all get filtered away is dropped too. // Capital & Equity and Funding now share one entry. Funding folds in as a tab // but keeps its own permission (sponsorships.*), so land on whichever page the // viewer can actually open, a Funding-only user must not hit the capital.read // gate on the Capital page. $cuUser = Auth::user(); $cuTeam = $cuUser?->currentTeam; $cuCan = fn ($p) => (bool) ($cuTeam && $cuUser->hasTeamPermission($cuTeam, $p)); $capitalRoute = $cuCan('capital.read') ? 'capital.index' : 'funding.index'; $items = [ // High-traffic destinations stay as flat top-level links. ['type' => 'link', 'route' => 'dashboard', 'pattern' => 'dashboard', 'label' => __('Dashboard'), 'icon' => 'squares-2x2', 'can' => null], // Everything else is grouped into collapsible dropdowns. ['type' => 'group', 'label' => __('Catalog'), 'icon' => 'cube', 'children' => [ ['route' => 'products.index', 'pattern' => 'product*', 'label' => __('Products'), 'icon' => 'cube', 'can' => 'products.read'], ['route' => 'costing.index', 'pattern' => 'costing.*', 'label' => __('Costing'), 'icon' => 'calculator', 'can' => 'costing.read'], // Inventory is one workspace: Materials, Reorder, Locations, // Transfers and Material Categories are tabs within it (see // ). ['route' => 'inventory.index', 'pattern' => 'inventory.*', 'label' => __('Inventory'), 'icon' => 'archive-box', 'can' => 'inventory.read'], // Procurement: the supplier book. Purchase Orders is a tab on the // Suppliers page (they flow on into Supplier Bills / AP). ['route' => 'purchasing.index', 'pattern' => 'purchasing.*', 'label' => __('Suppliers'), 'icon' => 'building-office', 'can' => 'purchasing.read'], ]], // Customers opens the CRM's Organisations list (the customer record is // the organisation); Contacts and Opportunities sit beside it as tabs // (). Delivery Notes lives as a tab inside Invoices // (), so the Sales menu stays lean. ['type' => 'group', 'label' => __('Sales'), 'icon' => 'document-currency-dollar', 'children' => [ ['route' => 'crm.organisations.index', 'pattern' => ['people.*', 'customers.*', 'crm.organisations.*', 'crm.contacts.*'], 'label' => __('Customers'), 'icon' => 'users', 'can' => 'customers.read'], ['route' => 'quotes.index', 'pattern' => 'quotes.*', 'label' => __('Quotes'), 'icon' => 'document-text', 'can' => 'quotes.read'], ['route' => 'invoices.index', 'pattern' => ['invoices.*', 'delivery-notes.*'], 'label' => __('Invoices'), 'icon' => 'document-currency-dollar', 'can' => 'invoices.read'], ['route' => 'letters.index', 'pattern' => 'letters.*', 'label' => __('Letterhead'), 'icon' => 'document-text', 'can' => 'letters.read'], ]], // The CRM is a workspace of its own, not one more dropdown. Outside it // this is a single row that opens the CRM; inside it (any crm.* route) // the row becomes the main menu, its sections drawn one under another // with the current section open, and the BillPro groups below fold // shut. Its entries come from App\Support\CrmNav, which lists every // CRM page and leaves out any still a placeholder, so a page appears // the moment its screen ships. With nothing shipped the row is dropped. ['type' => 'crm', 'label' => __('CRM'), 'icon' => 'flag', 'sections' => \App\Support\CrmNav::menuSections()], ['type' => 'group', 'label' => __('Jobs'), 'icon' => 'briefcase', 'children' => [ ['route' => 'jobs.index', 'pattern' => 'jobs.*', 'label' => __('All Jobs'), 'icon' => 'briefcase', 'can' => 'jobs.read'], ['route' => 'service-requests.index', 'pattern' => 'service-requests.*', 'label' => __('Requests'), 'icon' => 'qr-code', 'can' => 'jobs.read'], ['route' => 'job-categories.index', 'pattern' => 'job-categories.*', 'label' => __('Job Categories'), 'icon' => 'tag', 'can' => 'jobs.read'], ['route' => 'partners.index', 'pattern' => 'partners.*', 'label' => __('Partners'), 'icon' => 'building-office', 'can' => 'jobs.read'], ['route' => 'checklists.index', 'pattern' => 'checklists.*', 'label' => __('Quality Checklists'), 'icon' => 'shield-check', 'can' => 'settings.manage'], ['route' => 'showcase.index', 'pattern' => 'showcase.*', 'label' => __('Showcase'), 'icon' => 'camera', 'can' => 'jobs.read'], ]], // Income and Expenses are the two sides of cash flow: they share one // "Cashflow" entry and are toggled by the segmented control at the top of // each page (see ). Funding likewise folds into Capital // & Equity as a tab (), so its sidebar row is gone and the // Capital entry stays active on funding.* too. Marketing Fund is a report, // so it now lives under Insights. ['type' => 'group', 'label' => __('Finance'), 'icon' => 'banknotes', 'children' => [ ['route' => 'income.index', 'pattern' => ['income.*', 'expenses.*', 'recurring-bills.*'], 'label' => __('Cashflow'), 'icon' => 'arrows-right-left', 'can' => ['income.read', 'expenses.read']], ['route' => 'treasury.index', 'pattern' => 'treasury.index', 'label' => __('Treasury'), 'icon' => 'banknotes', 'can' => 'treasury.read'], ['route' => 'treasury.cash_up', 'pattern' => 'treasury.cash_up', 'label' => __('Cash-up'), 'icon' => 'shield-check', 'can' => 'treasury.reconcile'], ['route' => 'treasury.reconciliation', 'pattern' => 'treasury.reconciliation', 'label' => __('Reconciliation'), 'icon' => 'scale', 'can' => 'treasury.read'], ['route' => $capitalRoute, 'pattern' => ['capital.*', 'funding.*'], 'label' => __('Capital & Equity'), 'icon' => 'scale-balance', 'can' => ['capital.read', 'sponsorships.read']], ['route' => 'assets.index', 'pattern' => 'assets.*', 'label' => __('Assets'), 'icon' => 'wrench-screwdriver', 'can' => 'assets.read'], ]], ['type' => 'group', 'label' => __('People'), 'icon' => 'users', 'children' => [ // Self-service first, and ungated: everyone owns their own record, // and without this row a member with no HR ability has no People // group at all, only the login nag telling them to fill it in. ['route' => 'my-details', 'pattern' => 'my-details', 'label' => __('My Details'), 'icon' => 'user-circle', 'can' => null], ['route' => 'employees.index', 'pattern' => 'employees.*', 'label' => __('Employees'), 'icon' => 'identification', 'can' => ['hr.read', 'hr.manage', 'payroll.process']], ['route' => 'attendance.index', 'pattern' => 'attendance.*', 'label' => __('Attendance'), 'icon' => 'clock', 'can' => 'attendance.read'], ['route' => 'hr.index', 'pattern' => 'hr.*', 'label' => __('HR Desk'), 'icon' => 'briefcase', 'can' => ['hr.manage', 'settings.manage']], ['route' => 'payroll.index', 'pattern' => 'payroll.*', 'label' => __('Payroll & Commissions'), 'icon' => 'banknotes', 'can' => ['commissions.read', 'payroll.read']], ['route' => 'loans.index', 'pattern' => 'loans.*', 'label' => __('Loans'), 'icon' => 'credit-card', 'can' => ['loans.read', 'loans.apply']], ]], ['type' => 'group', 'label' => __('Insights'), 'icon' => 'arrow-trending-up', 'children' => [ ['route' => 'reports.index', 'pattern' => 'reports.index', 'label' => __('Reports'), 'icon' => 'arrow-trending-up', 'can' => 'reports.view'], ['route' => 'reports.profitability', 'pattern' => 'reports.profitability', 'label' => __('Profitability'), 'icon' => 'scale-balance', 'can' => 'reports.view'], ['route' => 'reports.balance_sheet', 'pattern' => 'reports.balance_sheet', 'label' => __('Balance Sheet'), 'icon' => 'table-cells', 'can' => 'reports.view'], ['route' => 'reports.break_even', 'pattern' => 'reports.break_even', 'label' => __('Break-even'), 'icon' => 'chart-bar', 'can' => 'reports.view'], ['route' => 'reports.aging', 'pattern' => 'reports.aging', 'label' => __('Aged AR / AP'), 'icon' => 'clock', 'can' => 'reports.view'], ['route' => 'reports.budget', 'pattern' => 'reports.budget', 'label' => __('Budget vs Actual'), 'icon' => 'scale-balance', 'can' => 'reports.view'], // Month-end cycle (FRS §5/§6/§7), listed in the order the work is // actually done: read the pack, close the month, and only then // correct one that is already closed. Adjustments is the sole legal // route back into a locked period, which is why it sits last rather // than beside the other reports. ['route' => 'month-end.index', 'pattern' => 'month-end.*', 'label' => __('Month-End Pack'), 'icon' => 'calculator', 'can' => 'close.read'], ['route' => 'close.index', 'pattern' => 'close.*', 'label' => __('Close Month'), 'icon' => 'lock-closed', 'can' => 'close.manage'], ['route' => 'reports.adjustments', 'pattern' => 'reports.adjustments', 'label' => __('Adjustments'), 'icon' => 'pencil-square', 'can' => 'close.read'], // Marketing Fund is a read-only report on the virtual marketing budget, // so it belongs with the other reports rather than under Finance. ['route' => 'marketing.index', 'pattern' => 'marketing.*', 'label' => __('Marketing Fund'), 'icon' => 'megaphone', 'can' => 'marketing.read'], ['route' => 'targets.index', 'pattern' => 'targets.*', 'label' => __('Targets'), 'icon' => 'flag', 'can' => 'targets.read'], ['route' => 'budgeting.index', 'pattern' => 'budgeting.*', 'label' => __('Budgeting'), 'icon' => 'table-cells', 'can' => 'budgets.read'], ]], ['type' => 'group', 'label' => __('Configuration'), 'icon' => 'cog-6-tooth', 'children' => [ // Ungated on purpose, and the only row in this group that is. Every // other item here configures the WORKSPACE and needs a permission; // this one configures which of YOUR devices buzzes, which is nobody // else's business. Without it a member with no admin rights has no // Configuration group at all, and no way to reach the page that // turns their phone on. ['route' => 'notifications.index', 'pattern' => 'notifications.*', 'label' => __('Notifications & App'), 'icon' => 'bell', 'can' => null], ['route' => 'imports.index', 'pattern' => 'imports.*', 'label' => __('Data Import'), 'icon' => 'arrow-up-tray', 'can' => 'invoices.create'], // Same permission as the import: one document on screen and the // whole ledger in a file that leaves the building are not the // same act, so plain read access is not enough to take it out. ['route' => 'exports.index', 'pattern' => 'exports.*', 'label' => __('Data Export'), 'icon' => 'arrow-down-tray', 'can' => 'invoices.create'], ['route' => 'currencies.index', 'pattern' => 'currencies.*', 'label' => __('Currencies'), 'icon' => 'currency-dollar', 'can' => 'settings.manage'], ['route' => 'settings.index', 'pattern' => 'settings.*', 'label' => __('Settings'), 'icon' => 'cog-6-tooth', 'can' => 'settings.manage'], ['route' => 'activity.index', 'pattern' => 'activity.*', 'label' => __('Activity'), 'icon' => 'clock', 'can' => ['reports.view', 'settings.manage']], ]], ]; // A household-enabled workspace is household-ONLY: replace the entire // business menu with a focused household menu so none of the business // modules (customers, invoices, jobs…) show up here. Configuration stays so // the household can still manage currencies, base settings and team members. if (Auth::user()?->currentTeam?->settings?->household_enabled) { $items = [ ['type' => 'link', 'route' => 'household.dashboard', 'pattern' => 'household.dashboard', 'label' => __('Overview'), 'icon' => 'home', 'can' => 'household.read'], ['type' => 'group', 'label' => __('Household'), 'icon' => 'home', 'children' => [ ['route' => 'household.income.index', 'pattern' => 'household.income.*', 'label' => __('Income'), 'icon' => 'currency-dollar', 'can' => 'household.read'], ['route' => 'household.loans.index', 'pattern' => 'household.loans.*', 'label' => __('Loans'), 'icon' => 'credit-card', 'can' => 'household.read'], ['route' => 'household.expenses.index', 'pattern' => 'household.expenses.*', 'label' => __('Expenses'), 'icon' => 'receipt-percent', 'can' => 'household.read'], ['route' => 'household.accounts.index', 'pattern' => 'household.accounts.*', 'label' => __('Accounts'), 'icon' => 'banknotes', 'can' => 'household.read'], ['route' => 'household.members.index', 'pattern' => 'household.members.*', 'label' => __('Members'), 'icon' => 'users', 'can' => 'household.read'], ['route' => 'household.reports.index', 'pattern' => 'household.reports.*', 'label' => __('Insights'), 'icon' => 'chart-bar', 'can' => 'household.read'], ]], ['type' => 'group', 'label' => __('Configuration'), 'icon' => 'cog-6-tooth', 'children' => [ ['route' => 'notifications.index', 'pattern' => 'notifications.*', 'label' => __('Notifications & App'), 'icon' => 'bell', 'can' => null], ['route' => 'currencies.index', 'pattern' => 'currencies.*', 'label' => __('Currencies'), 'icon' => 'currency-dollar', 'can' => 'settings.manage'], ['route' => 'settings.index', 'pattern' => 'settings.*', 'label' => __('Settings'), 'icon' => 'cog-6-tooth', 'can' => 'settings.manage'], ['route' => 'activity.index', 'pattern' => 'activity.*', 'label' => __('Activity'), 'icon' => 'clock', 'can' => ['reports.view', 'settings.manage']], ]], ]; } // Float punch-in, only while this person still owes a declaration on the // open workspace cut-over. Not permission-gated (holding cash is not a // role) and self-removing: once they have declared, the row goes away. if ($cuUser && app(\App\Services\CutoverService::class)->outstandingFor($cuUser)->isNotEmpty()) { $items[] = ['type' => 'link', 'route' => 'treasury.punch_in', 'pattern' => 'treasury.punch_in', 'label' => __('Float punch-in'), 'icon' => 'wallet', 'can' => null]; } // The directors' side of the same window: who is holding it up, and who is // asking to change a figure already agreed. Shown while the punch-in is open // and only to people who can sign an opening balance off. if ($cuUser && $cuUser->currentTeam && app(\App\Services\CutoverService::class)->current($cuUser->currentTeam->id) && \Illuminate\Support\Facades\Gate::allows('cutOver', \App\Models\FundAccount::class)) { $items[] = ['type' => 'link', 'route' => 'treasury.confirmations', 'pattern' => 'treasury.confirmations', 'label' => __('Float confirmations'), 'icon' => 'shield-check', 'can' => null]; } // The glossary sits at the bottom of every menu, business or household, and // is never permission-gated: the people who most need a term explained are // the ones with the least access to the page it belongs to. $items[] = ['type' => 'link', 'route' => 'knowledge.index', 'pattern' => 'knowledge.*', 'label' => __('Knowledge Base'), 'icon' => 'academic-cap', 'can' => null]; if (Auth::user()?->isSuperAdmin()) { $items[] = ['type' => 'group', 'label' => __('Administration'), 'icon' => 'shield-check', 'children' => [ ['route' => 'admin.companies.index', 'pattern' => 'admin.companies.*', 'label' => __('Companies'), 'icon' => 'building-office', 'can' => null], ['route' => 'admin.users.index', 'pattern' => 'admin.users.*', 'label' => __('Users'), 'icon' => 'shield-check', 'can' => null], ['route' => 'admin.archive.index', 'pattern' => 'admin.archive.*', 'label' => __('Archive'), 'icon' => 'archive-box', 'can' => null], ['route' => 'admin.errors.index', 'pattern' => 'admin.errors.*', 'label' => __('Fault log'), 'icon' => 'exclamation-triangle', 'can' => null], ]]; } // Hide links the current user lacks permission for, then drop any group left // with no visible children. Owners and super admins pass every check // (hasTeamPermission() returns true for owners; the global Gate::before // short-circuits for super admins). $navUser = Auth::user(); $navAllows = function ($can) use ($navUser) { if ($can === null) { return true; } if (! $navUser || ! $navUser->currentTeam) { return false; } // hasTeamPermission() only short-circuits for the company's OWNER, so a // super admin who is a plain member would otherwise lose menu entries // their Gate::before access still lets them open. if ($navUser->isSuperAdmin()) { return true; } foreach ((array) $can as $perm) { if ($navUser->hasTeamPermission($navUser->currentTeam, $perm)) { return true; } } return false; }; $items = collect($items) ->map(function ($item) use ($navAllows, $navUser) { if ($item['type'] === 'group') { $item['children'] = array_values(array_filter($item['children'], fn ($c) => $navAllows($c['can'] ?? null))); } if ($item['type'] === 'crm') { // The CRM workspace is in a pilot. The customer book stays in // Sales; this block is the rest of the CRM. if (! \App\Support\CrmPilot::allows($navUser)) { $item['sections'] = []; return $item; } $item['sections'] = collect($item['sections']) ->map(function ($section) use ($navAllows) { $section['items'] = array_values(array_filter($section['items'], fn ($c) => $navAllows($c['can'] ?? null))); return $section; }) ->filter(fn ($section) => count($section['items']) > 0) ->values() ->all(); } return $item; }) ->filter(fn ($item) => match ($item['type']) { 'group' => count($item['children']) > 0, 'crm' => count($item['sections']) > 0, default => $navAllows($item['can'] ?? null), }) ->values() ->all(); // Inside the CRM its block leads the menu and owns the active state; the // BillPro groups below render closed, even the Sales group whose // Customers row points at a CRM page. $inCrm = request()->routeIs('crm.*') && collect($items)->contains(fn ($item) => $item['type'] === 'crm'); if ($inCrm) { $items = collect($items)->sortBy(fn ($item) => $item['type'] === 'crm' ? 0 : 1, SORT_NUMERIC, false)->values()->all(); } $crmActiveSection = 'Home'; foreach ($items as $crmItem) { if ($crmItem['type'] !== 'crm') { continue; } foreach ($crmItem['sections'] as $section) { if (collect($section['items'])->contains(fn ($c) => request()->routeIs($c['pattern']))) { $crmActiveSection = $section['key']; break; } } } // Outside the CRM, "is this row current" is the route match; inside it only // the CRM block may light up. $rowActive = fn ($pattern) => ! $inCrm && request()->routeIs($pattern); @endphp
Semita BillPro {{-- On a phone this panel is a drawer, so it needs a way out that is not "guess that the dimmed strip on the right is tappable". --}}
@php // Team-mates seen in the last few minutes (isOnline()), suspended accounts // excluded. The signed-in user is listed too, always first, since they are // online by definition. Rendered as one wrapping line — first names, or the // photo where someone has uploaded one — so a full team fits the sidebar // without pushing the version link off-screen. $presenceTeam = Auth::user()?->currentTeam; $onlineUsers = $presenceTeam ? $presenceTeam->allUsers() ->filter(fn ($u) => $u->id === Auth::id() || ($u->isOnline() && ! $u->isSuspended())) ->sortByDesc(fn ($u) => $u->id === Auth::id() ? '9999-12-31' : (string) $u->last_seen_at) ->values() : collect(); @endphp

{{ __('Online now') }} {{ $onlineUsers->count() }}

@if ($onlineUsers->isNotEmpty())
@foreach ($onlineUsers as $onlineUser) @php $isMe = $onlineUser->id === Auth::id(); $firstName = \Illuminate\Support\Str::of($onlineUser->name)->trim()->explode(' ')->first(); $tooltip = $onlineUser->name.($isMe ? ' ('.__('you').')' : ''); @endphp @if ($onlineUser->hasProfilePhoto()) {{ $tooltip }} @else {{ $firstName }}@unless ($loop->last),@endunless @endif @endforeach
@else

{{ __('No one online') }}

@endif
@php $sidebarReleases = app(\App\Services\ReleaseService::class); $sidebarVersion = $sidebarReleases->currentVersion(); $sidebarUpdate = Auth::user() ? $sidebarReleases->pendingFor(Auth::user()) : null; @endphp @if ($sidebarVersion) @endif {{-- The signed-in user is in the "Online now" list above; account actions live in the top-bar avatar menu, so no footer block here. --}} {{-- Clears the phone home indicator so the last row is still tappable. --}}