Initial Ladill Queue release — enterprise QMS standalone app.
Deploy Ladill Queue / deploy (push) Successful in 56s

Phases 1–6: tickets, counters, displays, appointments, workflows, rules, analytics, reports, feedback, admin, device API, and Gitea deploy workflow for queue.ladill.com.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-29 20:19:52 +00:00
co-authored by Cursor
commit cca98eefd2
297 changed files with 27263 additions and 0 deletions
@@ -0,0 +1,15 @@
@props(['compact' => false])
<button type="button"
@click="window.dispatchEvent(new CustomEvent('afia-open'))"
@class([
'inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-indigo-700 via-blue-600 to-emerald-400 text-sm font-semibold text-white shadow-sm transition hover:opacity-95',
'h-9 w-9 px-0 lg:h-auto lg:w-auto lg:px-4 lg:py-2' => $compact,
'px-3 py-2 lg:px-4' => ! $compact,
])
aria-label="Open Afia AI assistant">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2.25c.414 0 .75.336.75.75a5.25 5.25 0 0 0 5.25 5.25.75.75 0 0 1 0 1.5A5.25 5.25 0 0 0 12.75 15a.75.75 0 0 1-1.5 0A5.25 5.25 0 0 0 6 9.75a.75.75 0 0 1 0-1.5A5.25 5.25 0 0 0 11.25 3a.75.75 0 0 1 .75-.75Zm6.75 11.25a.75.75 0 0 1 .75.75A2.25 2.25 0 0 0 21.75 16.5a.75.75 0 0 1 0 1.5A2.25 2.25 0 0 0 19.5 20.25a.75.75 0 0 1-1.5 0A2.25 2.25 0 0 0 15.75 18a.75.75 0 0 1 0-1.5A2.25 2.25 0 0 0 18 14.25a.75.75 0 0 1 .75-.75ZM5.25 14.25a.75.75 0 0 1 .75.75 3 3 0 0 0 3 3 .75.75 0 0 1 0 1.5 3 3 0 0 0-3 3 .75.75 0 0 1-1.5 0 3 3 0 0 0-3-3 .75.75 0 0 1 0-1.5 3 3 0 0 0 3-3 .75.75 0 0 1 .75-.75Z"/>
</svg>
<span @class(['sr-only lg:not-sr-only lg:inline' => $compact, 'hidden sm:inline' => ! $compact])>AI</span>
</button>
+106
View File
@@ -0,0 +1,106 @@
@php
$afiaGreeting = "Hi, I'm Afia 👋 Ask me about visitor check-in, kiosks, hosts, badges, devices, or setting up your reception desk…";
$afiaSuggestions = [
'How do I set up a visitor kiosk?',
'How do I check a visitor in?',
'Where do I add reception desks?',
'How do hosts approve visits?',
];
@endphp
{{-- Afia Ladill AI assistant slide-over. Opened via $dispatch('afia-open'). --}}
<div x-data="afia({
chatUrl: '{{ route('frontdesk.ai.chat') }}',
csrf: '{{ csrf_token() }}',
greeting: @js($afiaGreeting),
suggestions: @js($afiaSuggestions),
})" @afia-open.window="open = true; $nextTick(() => $refs.input && $refs.input.focus())">
<div x-show="open" x-cloak @click="close()" class="fixed inset-0 z-50 bg-slate-900/20"></div>
<section x-show="open" x-cloak
x-transition:enter="transition transform ease-out duration-200"
x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0"
x-transition:leave="transition transform ease-in duration-150"
x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
class="fixed inset-y-0 right-0 z-50 flex w-full max-w-md flex-col bg-white shadow-2xl">
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3.5">
<div class="flex items-center gap-2.5">
<span class="relative flex h-8 w-8 shrink-0 items-center justify-center">
<svg viewBox="0 0 36 36" class="h-8 w-8" aria-hidden="true">
<defs>
<radialGradient id="afia-orb" cx="40%" cy="35%" r="60%">
<stop offset="0%" stop-color="#a5b4fc"/>
<stop offset="50%" stop-color="#6366f1"/>
<stop offset="100%" stop-color="#3730a3"/>
</radialGradient>
</defs>
<circle cx="18" cy="18" r="14" fill="url(#afia-orb)">
<animate attributeName="r" values="14;14.8;14" dur="3s" repeatCount="indefinite"/>
</circle>
<circle cx="18" cy="18" r="11" fill="none" stroke="#c7d2fe" stroke-width=".6" opacity=".5">
<animate attributeName="r" values="11;12.5;11" dur="4s" repeatCount="indefinite"/>
<animate attributeName="opacity" values=".5;.15;.5" dur="4s" repeatCount="indefinite"/>
</circle>
<circle r="1.2" fill="#c7d2fe" opacity=".8">
<animateMotion dur="3s" repeatCount="indefinite" path="M18,8 A10,10 0 1,1 17.99,8" rotate="auto"/>
</circle>
</svg>
<span class="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-emerald-400"></span>
</span>
<div class="leading-tight">
<p class="text-sm font-semibold text-slate-900">Afia</p>
<p class="text-[11px] text-slate-400">Frontdesk assistant</p>
</div>
</div>
<div class="flex items-center gap-1">
<a href="{{ ladill_account_url('ai') }}"
class="rounded-lg px-2 py-1 text-[11px] font-medium text-slate-500 hover:bg-slate-100 hover:text-slate-700"
title="AI usage history on your Ladill account">History</a>
<button @click="close()" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto px-4 py-4" x-ref="scroll">
<div class="space-y-3">
<template x-for="(m, i) in messages" :key="i">
<div class="flex" :class="m.role === 'user' ? 'justify-end' : 'justify-start'">
<div class="max-w-[85%] whitespace-pre-line rounded-2xl px-3.5 py-2.5 text-[13px] leading-relaxed"
:class="m.role === 'user' ? 'bg-indigo-600 text-white rounded-br-md' : 'bg-slate-100 text-slate-700 rounded-bl-md'"
x-html="m.text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\*\*([^*]+?)\*\*/g,'<strong>$1</strong>')"></div>
</div>
</template>
<div x-show="loading" class="flex justify-start">
<div class="rounded-2xl rounded-bl-md bg-slate-100 px-4 py-3">
<div class="flex gap-1">
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay:0ms"></span>
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay:150ms"></span>
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay:300ms"></span>
</div>
</div>
</div>
</div>
<div x-show="messages.length <= 1 && suggestions.length" class="mt-4 space-y-2">
<template x-for="s in suggestions" :key="s">
<button @click="useSuggestion(s)" :disabled="loading"
class="flex w-full items-center gap-2 rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-left text-[13px] font-medium text-slate-700 transition hover:border-indigo-200 hover:bg-indigo-50 disabled:opacity-50">
<span class="text-indigo-400"></span><span x-text="s"></span>
</button>
</template>
</div>
</div>
<div class="border-t border-slate-100 px-4 pb-4 pt-3">
<form @submit.prevent="send()" class="flex items-end gap-2">
<input type="text" x-ref="input" x-model="input" :disabled="loading" placeholder="Message Afia…"
class="flex-1 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm text-slate-700 placeholder-slate-400 focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
<button type="submit" :disabled="loading || !input.trim()"
class="btn-fab shrink-0 disabled:opacity-40">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18"/></svg>
</button>
</form>
<p class="mt-2 text-center text-[10px] text-slate-400">Afia can make mistakes verify important details.</p>
</div>
</section>
</div>
@@ -0,0 +1,43 @@
@php
// Branded boot splash (Ladill Mail style). Self-icon from this app's subdomain.
$sub = strtolower((string) ((explode('.', (string) (parse_url((string) config('app.url'), PHP_URL_HOST) ?: '')))[0] ?? ''));
$icon = $sub !== '' && is_file(public_path("images/launcher-icons/{$sub}.svg")) ? "images/launcher-icons/{$sub}.svg" : null;
$label = (string) config('app.name', 'Ladill');
@endphp
<div id="ladill-boot" role="status" aria-live="polite">
<div class="lb-wrap">
@if ($icon)
<img class="lb-logo" src="{{ asset($icon) }}?v={{ @filemtime(public_path($icon)) ?: '1' }}" alt="">
@endif
<div class="lb-bar"><span></span></div>
<div class="lb-title">Loading {{ $label }}</div>
</div>
</div>
<style>
#ladill-boot{position:fixed;inset:0;z-index:99999;background:#f8fafc;display:flex;align-items:center;justify-content:center;transition:opacity .35s ease;font-family:'Figtree',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
#ladill-boot .lb-wrap{display:flex;flex-direction:column;align-items:center;gap:24px;width:260px}
#ladill-boot .lb-logo{width:64px;height:64px;animation:lb-pulse 1.6s ease-in-out infinite}
#ladill-boot .lb-bar{width:100%;height:4px;background:#e2e8f0;border-radius:999px;overflow:hidden}
#ladill-boot .lb-bar>span{display:block;height:100%;width:8%;border-radius:999px;background:linear-gradient(90deg,#4f46e5,#7c3aed);animation:lb-fill 1.9s cubic-bezier(.4,0,.2,1) forwards}
#ladill-boot .lb-title{font-size:14px;color:#64748b;font-weight:500}
@keyframes lb-fill{0%{width:8%}55%{width:68%}100%{width:93%}}
@keyframes lb-pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(.93);opacity:.82}}
</style>
<script>
(function(){
var el=document.getElementById('ladill-boot');
if(!el)return;
// Show once per browser session; suppress instantly on later navigations (no flash).
try{
if(sessionStorage.getItem('lb_booted')){el.parentNode.removeChild(el);return;}
sessionStorage.setItem('lb_booted','1');
}catch(e){}
var start=Date.now(),MIN=550;
function done(){
var wait=Math.max(0,MIN-(Date.now()-start));
setTimeout(function(){if(!el)return;el.style.opacity='0';setTimeout(function(){if(el&&el.parentNode)el.parentNode.removeChild(el);},400);},wait);
}
if(document.readyState==='complete')done();else window.addEventListener('load',done);
setTimeout(done,8000); // safety net
})();
</script>
@@ -0,0 +1,8 @@
@php
$svgVer = @filemtime(public_path('favicon.svg')) ?: '1';
$icoVer = @filemtime(public_path('favicon.ico')) ?: '1';
@endphp
<link rel="icon" type="image/svg+xml" href="{{ asset('favicon.svg') }}?v={{ $svgVer }}">
<link rel="alternate icon" href="{{ asset('favicon.ico') }}?v={{ $icoVer }}" sizes="any">
<link rel="shortcut icon" href="{{ asset('favicon.svg') }}?v={{ $svgVer }}">
<link rel="apple-touch-icon" href="{{ asset('favicon.svg') }}?v={{ $svgVer }}">
+34
View File
@@ -0,0 +1,34 @@
@if (session('success'))
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 4000)" x-transition class="mb-4">
<div class="rounded-lg bg-green-50 border border-green-200 p-4">
<p class="text-sm text-green-800">{{ session('success') }}</p>
</div>
</div>
@endif
@if (session('error'))
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 6000)" x-transition class="mb-4">
<div class="rounded-lg bg-red-50 border border-red-200 p-4">
<p class="text-sm text-red-800">{{ session('error') }}</p>
</div>
</div>
@endif
@if (session('warning'))
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 5000)" x-transition class="mb-4">
<div class="rounded-lg bg-yellow-50 border border-yellow-200 p-4">
<p class="text-sm text-yellow-800">{{ session('warning') }}</p>
</div>
</div>
@endif
@if ($errors->any())
<div class="mb-4 rounded-lg bg-red-50 border border-red-200 p-4">
<p class="text-sm font-medium text-red-800">Please fix the following:</p>
<ul class="mt-1 list-disc pl-5 text-sm text-red-700">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
@@ -0,0 +1,84 @@
@php
// Shared Ladill app launcher — IDENTICAL across every app/service.
// Driven by config/ladill_launcher.php (fully-extracted apps only) + icons
// from public/images/launcher-icons/. Omits the current app (matched by
// APP_URL host). To replicate elsewhere, copy this file + the config +
// the launcher-icons folder verbatim.
$sidebar = (bool) ($sidebar ?? false);
$selfHost = parse_url((string) config('app.url'), PHP_URL_HOST);
$launcherApps = array_values(array_filter(
config('ladill_launcher.apps', []),
fn (array $app) => parse_url($app['url'], PHP_URL_HOST) !== $selfHost
));
$launcherOverflows = count($launcherApps) > 15;
@endphp
@if (! empty($launcherApps))
<div class="relative" x-data="{
appsOpen: false,
showScrollMore: {{ $launcherOverflows ? 'true' : 'false' }},
checkScrollEnd() {
const el = this.$refs.appsScroller;
if (! el) return;
this.showScrollMore = el.scrollTop + el.clientHeight < el.scrollHeight - 4;
},
toggleApps() {
this.appsOpen = ! this.appsOpen;
if (this.appsOpen) {
this.$nextTick(() => this.checkScrollEnd());
} else {
this.showScrollMore = {{ $launcherOverflows ? 'true' : 'false' }};
}
},
}" @click.outside="appsOpen = false" @keydown.escape.window="appsOpen = false">
<button type="button" @click="toggleApps()"
@class([
'inline-flex items-center justify-center rounded-xl border border-slate-200 text-slate-600 transition hover:bg-slate-50',
'p-2' => ! $sidebar,
'w-full gap-3 px-3 py-2 text-[13px] hover:text-slate-900' => $sidebar,
])
:class="appsOpen ? 'bg-slate-50 text-slate-900' : ''" aria-label="All apps">
<svg @class(['shrink-0', 'h-5 w-5' => ! $sidebar, 'h-[18px] w-[18px]' => $sidebar]) viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4.5 0.5H1.5C0.947715 0.5 0.5 0.947715 0.5 1.5V4.5C0.5 5.05228 0.947715 5.5 1.5 5.5H4.5C5.05228 5.5 5.5 5.05228 5.5 4.5V1.5C5.5 0.947715 5.05228 0.5 4.5 0.5Z"/>
<path d="M12.5 0.5H9.5C8.94772 0.5 8.5 0.947715 8.5 1.5V4.5C8.5 5.05228 8.94772 5.5 9.5 5.5H12.5C13.0523 5.5 13.5 5.05228 13.5 4.5V1.5C13.5 0.947715 13.0523 0.5 12.5 0.5Z"/>
<path d="M4.5 8.5H1.5C0.947715 8.5 0.5 8.94772 0.5 9.5V12.5C0.5 13.0523 0.947715 13.5 1.5 13.5H4.5C5.05228 13.5 5.5 13.0523 5.5 12.5V9.5C5.5 8.94772 5.05228 8.5 4.5 8.5Z"/>
<path d="M12.5 8.5H9.5C8.94772 8.5 8.5 8.94772 8.5 9.5V12.5C8.5 13.0523 8.94772 13.5 9.5 13.5H12.5C13.0523 13.5 13.5 13.0523 13.5 12.5V9.5C13.5 8.94772 13.0523 8.5 12.5 8.5Z"/>
</svg>
@if ($sidebar)
<span class="font-medium">All Ladill apps</span>
@endif
</button>
<div x-show="appsOpen" x-cloak x-transition.origin.top.right
@class([
'absolute z-50 w-72 rounded-2xl border border-slate-200 bg-white p-3 shadow-xl',
'right-0 mt-2' => ! $sidebar,
'bottom-full left-0 mb-2' => $sidebar,
])>
<p class="px-1 pb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">All apps</p>
<div @class(['ladill-launcher-apps-wrap' => $launcherOverflows])>
<div
class="ladill-launcher-apps"
@if ($launcherOverflows) x-ref="appsScroller" @scroll="checkScrollEnd()" @endif
>
<div class="grid grid-cols-3 gap-1">
@foreach ($launcherApps as $app)
<a href="{{ $app['url'] }}"
class="flex min-h-[5.25rem] flex-col items-center justify-center gap-1.5 rounded-xl px-2 py-3 text-center transition hover:bg-indigo-50">
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-100">
<img src="{{ asset('images/launcher-icons/'.$app['icon']) }}?v={{ @filemtime(public_path('images/launcher-icons/'.$app['icon'])) ?: '1' }}" alt="" class="h-5 w-5 object-contain" width="20" height="20">
</span>
<span class="text-[11px] font-medium leading-tight text-slate-700">{{ $app['name'] }}</span>
</a>
@endforeach
</div>
</div>
@if ($launcherOverflows)
<div class="ladill-launcher-scroll-more" x-show="showScrollMore" x-cloak aria-hidden="true">
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd"/>
</svg>
</div>
@endif
</div>
</div>
</div>
@endif
@@ -0,0 +1,130 @@
@php
$showSearch = isset($searchUrl) && $searchUrl !== null && $searchUrl !== '#';
$gridCols = match (true) {
! empty($centerCompose) => $showSearch ? 'grid-cols-5' : 'grid-cols-4',
default => $showSearch ? 'grid-cols-4' : 'grid-cols-3',
};
$avatarUrl = $avatarUrl ?? null;
$initials = $initials ?? 'U';
$notificationsUrl = $notificationsUrl ?? '#';
$unreadUrl = $unreadUrl ?? null;
$profileUrl = $profileUrl ?? '#';
$profileName = trim((string) ($profileName ?? ''));
$profileSubtitle = trim((string) ($profileSubtitle ?? ''));
$profileMenuItems = $profileMenuItems ?? [];
if ($profileMenuItems === [] && $profileUrl !== '#') {
$profileMenuItems = [['type' => 'link', 'label' => 'Profile', 'href' => $profileUrl]];
}
$navActive = fn (bool $active) => $active ? 'text-indigo-600' : 'text-slate-600';
@endphp
<div x-data="{ profileOpen: false }" class="lg:hidden">
<nav class="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200 bg-white"
style="padding-bottom: env(safe-area-inset-bottom, 0px)">
<div class="grid h-16 {{ $gridCols }}">
<a href="{{ $homeUrl }}"
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($homeActive ?? false) }}">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/></svg>
<span class="text-[10px] font-medium">Home</span>
</a>
@if ($showSearch)
<a href="{{ $searchUrl }}"
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($searchActive ?? false) }}">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
<span class="text-[10px] font-medium">Search</span>
</a>
@endif
@if (!empty($centerCompose))
<div class="flex items-center justify-center">
<button type="button"
@click="$dispatch('compose-open')"
class="btn-fab -translate-y-1 ring-4 ring-white shadow-md"
aria-label="Compose">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Z"/></svg>
</button>
</div>
@endif
<a href="{{ $notificationsUrl }}"
@if ($unreadUrl)
x-data="{
unread: 0,
init() {
fetch({{ \Illuminate\Support\Js::from($unreadUrl) }}, {
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
}).then(r => r.json()).then(d => this.unread = d.unread_count || 0).catch(() => {});
},
}"
@endif
class="relative flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($notificationsActive ?? false) }}">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
@if ($unreadUrl)
<span x-show="unread > 0" x-cloak x-text="unread > 9 ? '9+' : unread"
class="absolute right-3 top-1.5 inline-flex min-w-4 items-center justify-center rounded-full bg-rose-500 px-1 py-0.5 text-[9px] font-semibold text-white"></span>
@endif
<span class="text-[10px] font-medium">Notifications</span>
</a>
<button type="button"
@click="profileOpen = true"
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($profileActive ?? false) }}"
:class="profileOpen ? 'text-indigo-600' : ''"
aria-label="Open profile menu">
@if ($avatarUrl)
<img src="{{ $avatarUrl }}" alt="" class="h-7 w-7 rounded-full object-cover ring-1 ring-slate-200">
@else
<span class="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-900 text-[10px] font-semibold text-white">{{ $initials }}</span>
@endif
<span class="text-[10px] font-medium">Profile</span>
</button>
</div>
</nav>
{{-- Profile menu bottom sheet (matches desktop avatar dropdown) --}}
<div x-show="profileOpen" x-cloak class="fixed inset-0 z-50" @keydown.escape.window="profileOpen = false">
<div x-show="profileOpen" x-transition.opacity.duration.200ms
@click="profileOpen = false"
class="absolute inset-0 bg-slate-900/40 backdrop-blur-[1px]"></div>
<div x-show="profileOpen"
x-effect="profileOpen && window.dispatchEvent(new CustomEvent('wallet-balance-refresh'))"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="translate-y-full"
x-transition:enter-end="translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="translate-y-0"
x-transition:leave-end="translate-y-full"
class="absolute inset-x-0 bottom-0 rounded-t-2xl border-t border-slate-200 bg-white shadow-2xl"
style="padding-bottom: env(safe-area-inset-bottom, 0px)"
@click.outside="profileOpen = false">
<div class="flex justify-center pt-3">
<span class="h-1 w-10 rounded-full bg-slate-200" aria-hidden="true"></span>
</div>
@if ($profileName !== '' || $profileSubtitle !== '')
<div class="flex items-center gap-3 border-b border-slate-100 px-5 py-4">
@if ($avatarUrl)
<img src="{{ $avatarUrl }}" alt="" class="h-12 w-12 rounded-full object-cover ring-1 ring-slate-200">
@else
<span class="inline-flex h-12 w-12 items-center justify-center rounded-full bg-slate-900 text-sm font-semibold text-white">{{ $initials }}</span>
@endif
<div class="min-w-0">
@if ($profileName !== '')
<p class="truncate text-sm font-semibold text-slate-900">{{ $profileName }}</p>
@endif
@if ($profileSubtitle !== '')
<p class="truncate text-xs text-slate-500">{{ $profileSubtitle }}</p>
@endif
</div>
</div>
@endif
@include('partials.user-profile-menu', [
'items' => $profileMenuItems,
'variant' => 'sheet',
'onNavigate' => 'profileOpen = false',
])
</div>
</div>
</div>
@@ -0,0 +1,53 @@
@php
$variant = $variant ?? 'primary';
$type = $type ?? 'submit';
$tag = ! empty($href) ? 'a' : 'button';
$ariaLabel = $ariaLabel ?? $label ?? '';
$desktopLabel = $desktopLabel ?? $label ?? '';
$showDesktopIcon = $showDesktopIcon ?? ($variant === 'primary');
$mobileClass = match ($variant) {
'primary' => 'btn-fab h-10 w-10 lg:hidden',
'outline' => 'inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 shadow-sm transition hover:border-slate-300 hover:bg-slate-50 lg:hidden',
'dark' => 'inline-flex h-10 w-10 items-center justify-center rounded-full bg-gray-900 text-white shadow-sm transition hover:bg-gray-800 lg:hidden',
'indigo' => 'inline-flex h-10 w-10 items-center justify-center rounded-full bg-indigo-600 text-white shadow-sm transition hover:bg-indigo-700 lg:hidden',
default => 'btn-fab h-10 w-10 lg:hidden',
};
$desktopClass = match ($variant) {
'primary' => 'btn-primary hidden items-center lg:inline-flex',
'outline' => 'hidden items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 lg:inline-flex',
'dark' => 'hidden items-center gap-1.5 rounded-lg bg-gray-900 px-3.5 py-2 text-sm font-medium text-white transition hover:bg-gray-800 lg:inline-flex',
'indigo' => 'hidden items-center justify-center rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-700 lg:inline-flex',
default => 'btn-primary hidden items-center lg:inline-flex',
};
$extraClass = $class ?? '';
@endphp
<{{ $tag }}
@if ($tag === 'a') href="{{ $href }}" @endif
@if ($tag === 'button') type="{{ $type }}" @endif
aria-label="{{ $ariaLabel }}"
title="{{ $ariaLabel }}"
@if (! empty($attributes)) {!! $attributes !!} @endif
class="{{ trim($mobileClass.' '.$extraClass) }}"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
</svg>
</{{ $tag }}>
<{{ $tag }}
@if ($tag === 'a') href="{{ $href }}" @endif
@if ($tag === 'button') type="{{ $type }}" @endif
@if (! empty($attributes)) {!! $attributes !!} @endif
class="{{ trim($desktopClass.' '.$extraClass) }}"
>
@if ($showDesktopIcon)
<svg class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
</svg>
@endif
{{ $desktopLabel }}
</{{ $tag }}>
@@ -0,0 +1,27 @@
@php
$icon = $icon ?? 'arrow';
$desktopClass = $desktopClass ?? 'text-xs font-medium text-indigo-600 hover:text-indigo-700';
$label = $label ?? '';
@endphp
<a href="{{ $href }}"
aria-label="{{ $label }}"
title="{{ $label }}"
class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-indigo-50 text-indigo-600 ring-1 ring-indigo-100 transition hover:bg-indigo-100 lg:h-auto lg:w-auto lg:rounded-none lg:bg-transparent lg:ring-0 lg:hover:bg-transparent">
<span class="lg:hidden">
@if ($icon === 'shield')
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"/>
</svg>
@elseif ($icon === 'calendar')
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5"/>
</svg>
@else
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"/>
</svg>
@endif
</span>
<span class="hidden lg:inline {{ $desktopClass }}">{{ $label }}</span>
</a>
@@ -0,0 +1,6 @@
@php
$title = $mobileTopbarTitle ?? 'Ladill';
@endphp
<div class="min-w-0 flex-1 lg:hidden">
<h1 class="truncate text-base font-semibold text-slate-900">{{ $title }}</h1>
</div>
@@ -0,0 +1,97 @@
{{-- In-app notification bell + dropdown (scoped to this app). --}}
<div class="relative hidden lg:block"
x-data="notificationDropdown({
unreadUrl: {{ \Illuminate\Support\Js::from(route('notifications.unread')) }},
markReadUrl: {{ \Illuminate\Support\Js::from(route('notifications.mark-read', ['id' => '__ID__'])) }},
markAllReadUrl: {{ \Illuminate\Support\Js::from(route('notifications.mark-all-read')) }},
indexUrl: {{ \Illuminate\Support\Js::from(route('notifications.index')) }},
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
})"
@click.outside="open = false">
<button type="button"
@click="toggle()"
class="relative inline-flex items-center justify-center rounded-full border border-slate-200 p-2.5 text-slate-600 transition hover:bg-slate-50"
aria-label="Notifications">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.7" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/>
</svg>
<span x-show="unreadCount > 0"
x-cloak
x-text="unreadCount > 9 ? '9+' : unreadCount"
class="absolute -right-1 -top-1 inline-flex min-w-5 items-center justify-center rounded-full bg-rose-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"></span>
</button>
<div x-show="open"
x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 z-50 mt-2 w-80 origin-top-right rounded-xl border border-slate-200 bg-white shadow-lg">
<div class="flex items-center justify-between border-b border-slate-100 px-4 py-3">
<h3 class="text-sm font-semibold text-slate-900">Notifications</h3>
<button type="button"
x-show="unreadCount > 0"
@click="markAllRead()"
class="text-xs font-medium text-indigo-600 hover:text-indigo-700">
Mark all read
</button>
</div>
<div class="max-h-80 overflow-y-auto">
<template x-if="loading">
<div class="px-4 py-6 text-center">
<svg class="mx-auto h-5 w-5 animate-spin text-slate-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
</template>
<template x-if="!loading && notifications.length === 0">
<div class="px-4 py-8 text-center">
<svg class="mx-auto h-10 w-10 text-slate-300" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/>
</svg>
<p class="mt-2 text-sm text-slate-500">No notifications yet</p>
</div>
</template>
<template x-if="!loading && notifications.length > 0">
<div class="divide-y divide-slate-100">
<template x-for="notification in notifications" :key="notification.id">
<a :href="notification.url || '#'"
@click="markRead(notification.id)"
class="flex items-start gap-3 px-4 py-3 transition hover:bg-slate-50">
<span class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
:class="getIconBg(notification.icon)">
<template x-if="notification.icon === 'success'">
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg>
</template>
<template x-if="notification.icon === 'task'">
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg>
</template>
<template x-if="!['success','task'].includes(notification.icon)">
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
</template>
</span>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-slate-900 truncate" x-text="notification.title"></p>
<p class="text-xs text-slate-500 line-clamp-2" x-text="notification.message"></p>
<p class="mt-1 text-[11px] text-slate-400" x-text="notification.created_at"></p>
</div>
</a>
</template>
</div>
</template>
</div>
<div class="border-t border-slate-100 px-4 py-2.5">
<a :href="indexUrl" class="block text-center text-xs font-medium text-slate-600 hover:text-slate-900">
View all notifications
</a>
</div>
</div>
</div>
@@ -0,0 +1,85 @@
<div class="flex h-full flex-col bg-white border-r border-slate-200 text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
<div class="flex h-16 shrink-0 items-center border-b border-slate-200 px-6 dark:border-slate-700">
<a href="{{ route('qms.dashboard') }}" class="flex items-center">
<img src="{{ asset('images/logo/ladillqueue-logo.svg') }}?v={{ @filemtime(public_path('images/logo/ladillqueue-logo.svg')) ?: '1' }}" alt="Ladill Queue" class="h-6 w-auto">
</a>
</div>
@php
$member = auth()->user() ? app(\App\Services\Qms\OrganizationResolver::class)->memberFor(auth()->user()) : null;
$permissions = app(\App\Services\Qms\QmsPermissions::class);
$nav = [
['name' => 'Dashboard', 'route' => route('qms.dashboard'), 'active' => request()->routeIs('qms.dashboard'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12 11.2 3.05c.44-.44 1.15-.44 1.59 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75H9.75v-6a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v6h4.5a.75.75 0 0 0 .75-.75V9.75" />'],
['name' => 'Live board', 'route' => route('qms.board.index'), 'active' => request()->routeIs('qms.board.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />'],
];
if ($permissions->can($member, 'queues.view')) {
$nav[] = ['name' => 'Queues', 'route' => route('qms.queues.index'), 'active' => request()->routeIs('qms.queues.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.25 4.5h13.5a.75.75 0 0 1 .75.75v15.75a.75.75 0 0 1-.75.75H5.25a.75.75 0 0 1-.75-.75V5.25a.75.75 0 0 1 .75-.75Z" />'];
}
if ($permissions->can($member, 'tickets.view')) {
$nav[] = ['name' => 'Tickets', 'route' => route('qms.tickets.index'), 'active' => request()->routeIs('qms.tickets.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 0 1 0 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 0 1 0-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375Z" />'];
}
if ($permissions->can($member, 'counters.view')) {
$nav[] = ['name' => 'Counters', 'route' => route('qms.counters.index'), 'active' => request()->routeIs('qms.counters.*') || request()->routeIs('qms.console.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m-1.5 3h1.5m3-9H15m-1.5 3H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21" />'];
}
if ($permissions->can($member, 'displays.view')) {
$nav[] = ['name' => 'Displays', 'route' => route('qms.displays.index'), 'active' => request()->routeIs('qms.displays.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M6 20.25h12m-7.5-3v3m3-3v3m-10.125-3H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" />'];
$nav[] = ['name' => 'Devices', 'route' => route('qms.devices.index'), 'active' => request()->routeIs('qms.devices.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />'];
}
if ($permissions->can($member, 'appointments.view')) {
$nav[] = ['name' => 'Appointments', 'route' => route('qms.appointments.index'), 'active' => request()->routeIs('qms.appointments.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />'];
}
if ($permissions->can($member, 'workflows.view')) {
$nav[] = ['name' => 'Workflows', 'route' => route('qms.workflows.index'), 'active' => request()->routeIs('qms.workflows.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12" />'];
}
if ($permissions->can($member, 'reports.view')) {
$nav[] = ['name' => 'Analytics', 'route' => route('qms.analytics.index'), 'active' => request()->routeIs('qms.analytics.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />'];
$nav[] = ['name' => 'Reports', 'route' => route('qms.reports.index'), 'active' => request()->routeIs('qms.reports.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />'];
}
if ($permissions->can($member, 'feedback.view')) {
$nav[] = ['name' => 'Feedback', 'route' => route('qms.feedback.admin'), 'active' => request()->routeIs('qms.feedback.admin'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.184-4.183a1.14 1.14 0 0 1 .778-.332 48.294 48.294 0 0 0 5.83-.498c1.585-.233 2.708-1.626 2.708-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />'];
}
$adminItems = [];
if ($permissions->can($member, 'admin.branches.view')) {
$adminItems[] = ['name' => 'Branches', 'route' => route('qms.branches.index'), 'active' => request()->routeIs('qms.branches.*')];
}
if ($permissions->can($member, 'admin.departments.view')) {
$adminItems[] = ['name' => 'Departments', 'route' => route('qms.departments.index'), 'active' => request()->routeIs('qms.departments.*')];
}
if ($permissions->can($member, 'admin.members.view')) {
$adminItems[] = ['name' => 'Team', 'route' => route('qms.members.index'), 'active' => request()->routeIs('qms.members.*')];
}
if ($permissions->can($member, 'audit.view')) {
$adminItems[] = ['name' => 'Audit log', 'route' => route('qms.audit.index'), 'active' => request()->routeIs('qms.audit.*')];
}
if ($permissions->can($member, 'settings.view')) {
$adminItems[] = ['name' => 'Settings', 'route' => route('qms.settings.edit'), 'active' => request()->routeIs('qms.settings.*')];
}
@endphp
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
@foreach ($nav as $item)
<a href="{{ $item['route'] }}" class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $item['active'] ? 'bg-indigo-50 text-indigo-700 font-semibold dark:bg-indigo-950 dark:text-indigo-300' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800' }}">
<svg class="h-[18px] w-[18px] shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">{!! $item['icon'] !!}</svg>
<span>{{ $item['name'] }}</span>
</a>
@endforeach
@if (!empty($adminItems))
<div class="pt-4 pb-1 px-3 text-[10px] font-semibold uppercase tracking-wider text-slate-400">Administration</div>
@foreach ($adminItems as $item)
<a href="{{ $item['route'] }}" class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $item['active'] ? 'bg-indigo-50 text-indigo-700 font-semibold dark:bg-indigo-950 dark:text-indigo-300' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800' }}">
<span>{{ $item['name'] }}</span>
</a>
@endforeach
@endif
</nav>
</div>
@@ -0,0 +1,24 @@
{{-- Account switcher (desktop) only when the user belongs to more than one account. --}}
@if (isset($accessibleAccounts) && $accessibleAccounts->count() > 1)
<div x-data="{ accountSwitcherOpen: false }" @click.outside="accountSwitcherOpen = false" class="relative hidden lg:block">
<button type="button" @click="accountSwitcherOpen = !accountSwitcherOpen"
class="inline-flex items-center gap-1.5 rounded-full border border-slate-200 px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-50">
<span class="max-w-[140px] truncate">{{ $actingAccount->name ?? $actingAccount->email }}</span>
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19 9-7 7-7-7"/></svg>
</button>
<div x-show="accountSwitcherOpen" x-cloak x-transition
class="absolute right-0 z-30 mt-2 w-60 rounded-xl border border-slate-200 bg-white p-1 shadow-lg">
<p class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">Switch account</p>
@foreach ($accessibleAccounts as $acctOption)
<form method="POST" action="{{ route('account.switch') }}">
@csrf
<input type="hidden" name="account" value="{{ $acctOption->id }}">
<button type="submit" class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-50 {{ $acctOption->id === $actingAccount->id ? 'font-semibold text-indigo-700' : 'text-slate-700' }}">
<span class="truncate">{{ $acctOption->id === auth()->id() ? 'My account' : ($acctOption->name ?? $acctOption->email) }}</span>
@if ($acctOption->id === $actingAccount->id)<span class="text-indigo-600"></span>@endif
</button>
</form>
@endforeach
</div>
</div>
@endif
@@ -0,0 +1,45 @@
{{--
Top-right header widgets:
Mobile Afia + launcher only (profile/notifications live in bottom nav).
Desktop Afia notifications launcher divider avatar dropdown.
--}}
@php
$topbarUser = $user ?? auth()->user();
$initials = collect(explode(' ', trim((string) $topbarUser?->name)))
->filter()->take(2)->map(fn ($p) => strtoupper(substr($p, 0, 1)))->implode('');
$avatarUrl = $topbarUser && method_exists($topbarUser, 'avatarUrl')
? $topbarUser->avatarUrl()
: ($topbarUser?->avatar_url ?? null);
$showUserHeader = $showUser ?? true;
@endphp
@includeIf('partials.afia-button', ['compact' => true])
@includeIf('partials.notification-dropdown')
@include('partials.launcher')
@includeIf('partials.topbar-widgets-mid')
<div class="hidden h-8 w-px bg-slate-200 lg:block"></div>
<div class="relative hidden lg:block" x-data="{ profileOpen: false }" @click.outside="profileOpen = false" @keydown.escape.window="profileOpen = false">
<button type="button" @click="profileOpen = !profileOpen"
class="inline-flex items-center gap-2 rounded-full border border-slate-200 px-2 py-1.5 text-slate-700 transition hover:bg-slate-50">
@if ($avatarUrl)
<img src="{{ $avatarUrl }}" alt="Avatar" class="h-8 w-8 rounded-full object-cover ring-1 ring-slate-200">
@else
<span class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">{{ $initials !== '' ? $initials : 'U' }}</span>
@endif
<svg class="h-4 w-4 text-slate-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19 9-7 7-7-7"/></svg>
</button>
<div x-show="profileOpen" x-cloak x-transition
class="absolute right-0 z-50 mt-2 w-64 rounded-xl border border-slate-200 bg-white shadow-lg">
@include('partials.user-profile-menu', [
'items' => \App\Support\UserProfileMenu::items($topbarUser),
'user' => $topbarUser,
'showUser' => $showUserHeader,
])
</div>
</div>
+33
View File
@@ -0,0 +1,33 @@
@php
$user = auth()->user();
$initials = collect(explode(' ', trim((string) $user?->name)))
->filter()->take(2)->map(fn ($p) => strtoupper(substr($p, 0, 1)))->implode('');
@endphp
<header class="flex items-center justify-between h-16 border-b border-slate-200 bg-white px-4 sm:px-6">
<div class="flex flex-1 items-center gap-3 min-w-0">
<button @click="sidebarOpen = !sidebarOpen" class="lg:hidden shrink-0 text-slate-500 hover:text-slate-700" aria-label="Menu">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
{{-- Mobile: app name --}}
@include('partials.mobile-topbar-title')
{{-- Desktop: search (visitors) --}}
<form method="GET" action="{{ route('frontdesk.visitors.index') }}"
class="relative hidden w-full max-w-md lg:block"
x-data
@keydown.window="if ($event.key === '/' && !['INPUT','TEXTAREA','SELECT'].includes($event.target.tagName)) { $event.preventDefault(); $refs.search.focus(); }">
<svg class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
<input x-ref="search" type="search" name="q" value="{{ request('q') }}"
placeholder="Search visitors…"
class="w-full rounded-xl border border-slate-200 bg-slate-50 py-2 pl-9 pr-10 text-sm text-slate-700 placeholder-slate-400 transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100 focus:outline-none">
<kbd class="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 rounded-md border border-slate-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-slate-400 sm:inline-block">/</kbd>
</form>
</div>
<div class="flex items-center gap-3">
@includeIf('partials.topbar-widgets-prepend')
@include('partials.topbar-desktop-widgets', ['user' => $user, 'showUser' => true])
@includeIf('partials.topbar-widgets-append')
</div>
</header>
@@ -0,0 +1,73 @@
@props([
'items' => [],
'user' => null,
'showUser' => false,
'variant' => 'dropdown',
'onNavigate' => null,
])
@php
$linkClass = match ($variant) {
'dark' => 'block rounded-md px-3 py-2 text-sm text-slate-200 hover:bg-slate-800',
'sheet' => 'block rounded-xl px-4 py-3 text-sm font-medium text-slate-700 transition hover:bg-slate-100',
default => 'block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100',
};
$logoutClass = match ($variant) {
'dark' => 'w-full rounded-md px-3 py-2 text-left text-sm text-rose-300 hover:bg-rose-950/40',
'sheet' => 'w-full rounded-xl px-4 py-3 text-left text-sm font-medium text-rose-600 transition hover:bg-rose-50',
default => 'w-full rounded-lg px-3 py-2 text-left text-sm text-rose-600 hover:bg-rose-50',
};
$logoutWrapperClass = match ($variant) {
'sheet' => 'border-t border-slate-100 pt-2',
'dark' => '',
default => '',
};
$dividerClass = match ($variant) {
'dark' => 'my-1 border-t border-slate-800',
default => 'my-1 border-t border-slate-100',
};
$containerClass = match ($variant) {
'dark' => 'mt-2 rounded-lg border border-slate-800 bg-slate-900 p-1',
'sheet' => 'space-y-1 p-2 pb-4',
default => 'p-1',
};
@endphp
<div {{ $attributes->merge(['class' => $containerClass]) }}>
@if ($showUser && $user)
<div class="px-3 py-2">
<p class="truncate text-sm font-semibold text-slate-900">{{ $user->name ?? 'Your account' }}</p>
<p class="truncate text-xs text-slate-400">{{ $user->email }}</p>
</div>
<div class="{{ $dividerClass }}"></div>
@endif
@foreach ($items as $item)
@if (($item['type'] ?? 'link') === 'link')
<a href="{{ $item['href'] }}"
class="{{ $linkClass }}"
@if ($onNavigate) @click="{{ $onNavigate }}" @endif>
{{ $item['label'] }}
</a>
@elseif (($item['type'] ?? '') === 'wallet')
@includeIf('partials.wallet-widget', [
'onNavigate' => $onNavigate,
'class' => $variant === 'sheet' ? 'mx-2' : 'mx-1',
])
@elseif (($item['type'] ?? '') === 'logout')
@if ($variant !== 'sheet')
<div class="{{ $dividerClass }}"></div>
@endif
<form method="POST" action="{{ $item['action'] }}" class="{{ $logoutWrapperClass }}">
@csrf
<button type="submit" class="{{ $logoutClass }}">
{{ $item['label'] }}
</button>
</form>
@endif
@endforeach
</div>
@@ -0,0 +1,24 @@
@props(['openOnLoad' => false, 'suggested' => 10, 'min' => 1, 'returnUrl' => null])
<x-modal name="wallet-topup" :show="$openOnLoad" maxWidth="md">
<div class="border-b border-slate-100 px-5 py-4 pr-12 sm:px-6 sm:pr-14">
<h2 class="text-lg font-semibold text-slate-900">Top up your wallet</h2>
<p class="mt-1 text-sm text-slate-500">Add funds to your Ladill wallet to continue.</p>
</div>
<form action="{{ route('frontdesk.wallet') }}" method="GET" class="p-5 sm:p-6">
@csrf
<input type="hidden" name="return_url" value="{{ $returnUrl ?? url()->current() }}">
<label for="topup_amount" class="text-sm font-medium text-slate-700">Amount (GHS)</label>
<input type="number" id="topup_amount" name="amount" min="{{ $min }}" max="5000" step="0.01" value="{{ $suggested }}" required
class="mt-1.5 block w-full rounded-xl border border-slate-200 px-3 py-2.5 text-sm focus:border-indigo-400 focus:ring-1 focus:ring-indigo-400">
<p class="mt-1.5 text-xs text-slate-500">You'll be taken to Paystack to pay securely; funds land in your Ladill wallet.</p>
<div class="mt-6 flex flex-col gap-2 sm:flex-row sm:justify-end">
<button type="button" @click="$dispatch('close-modal', 'wallet-topup')"
class="hidden rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 sm:inline-flex">Cancel</button>
<button type="submit" class="btn-primary w-full sm:w-auto">Add funds</button>
</div>
</form>
</x-modal>
@@ -0,0 +1,30 @@
{{-- Wallet balance peek (links to the account wallet on account.ladill.com). --}}
@php
$onNavigate = $onNavigate ?? null;
$class = trim((string) ($class ?? 'mx-1'));
$balanceRoute = (string) config('billing.wallet_balance_route', 'wallet.balance');
$balanceUrl = \Illuminate\Support\Facades\Route::has($balanceRoute) ? route($balanceRoute) : null;
$walletUrl = \Illuminate\Support\Facades\Route::has('user.wallet.index')
? route('user.wallet.index')
: (function_exists('ladill_account_url') ? ladill_account_url('/wallet') : '#');
@endphp
@if ($balanceUrl)
<a href="{{ $walletUrl }}"
x-data="walletWidget({ url: {{ \Illuminate\Support\Js::from($balanceUrl) }} })" x-init="load()"
@wallet-balance-refresh.window="load()"
@if ($onNavigate) @click="{{ $onNavigate }}" @endif
class="{{ $class }} flex items-center justify-between gap-3 rounded-xl border border-slate-100 bg-gradient-to-r from-indigo-50 to-slate-50 px-3 py-2.5 transition hover:border-indigo-200 hover:from-indigo-100/70">
<span class="flex items-center gap-2.5 min-w-0">
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-indigo-100 text-indigo-600">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.6" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 1 3 7.5m18 4.5v3.75A2.25 2.25 0 0 1 18.75 18H5.25A2.25 2.25 0 0 1 3 15.75V7.5m18 4.5h-3.75a1.5 1.5 0 0 0 0 3H21M3 7.5A2.25 2.25 0 0 1 5.25 5.25h11.25A2.25 2.25 0 0 1 18.75 7.5"/>
</svg>
</span>
<span class="min-w-0">
<span class="block text-[11px] font-medium text-slate-500">Wallet balance</span>
<span class="block truncate text-sm font-semibold text-slate-900" x-text="display"></span>
</span>
</span>
<svg class="h-4 w-4 shrink-0 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m9 5 7 7-7 7"/></svg>
</a>
@endif