users menu listing

This commit is contained in:
Mark Gregory
2026-08-26 08:01:49 +01:00
parent 6f8d0c7abf
commit 7dc31371f6
29 changed files with 779 additions and 669 deletions
@@ -1,3 +1,3 @@
<x-layouts::app :title="__('Helpdesk Dashboard')">
<x-layouts::app :title="__('Dashboard')">
<livewire:helpdesk.dashboard-widget />
</x-layouts::app>
@@ -1,5 +1,3 @@
<x-layouts::app.sidebar :title="$title ?? null">
<flux:main>
{{ $slot }}
</flux:main>
{{ $slot }}
</x-layouts::app.sidebar>
@@ -1,228 +1,82 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
@include('partials.head')
</head>
<body class="min-h-screen bg-white dark:bg-zinc-800">
@php
$helpdeskSettingsRoute = Route::has('helpdesk.settings')
? 'helpdesk.settings'
: (Route::has('helpdesk.settings.index')
? 'helpdesk.settings.index'
: null);
@endphp
<flux:sidebar
sticky
collapsible="mobile"
class="hd-sidebar border-e border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900"
>
<flux:sidebar.header>
<x-app-logo
:sidebar="true"
href="{{ route('dashboard') }}"
wire:navigate
/>
<flux:sidebar.collapse class="lg:hidden" />
</flux:sidebar.header>
<flux:sidebar.nav>
<flux:sidebar.group :heading="__('Platform')" class="grid">
<flux:sidebar.item
icon="home"
:href="route('dashboard')"
:current="request()->routeIs('dashboard')"
wire:navigate
>
{{ __('Dashboard') }}
</flux:sidebar.item>
</flux:sidebar.group>
<flux:sidebar.group :heading="__('Helpdesk')" class="grid">
@if (Route::has('helpdesk.tickets.index'))
<flux:sidebar.item
icon="inbox"
:href="route('helpdesk.tickets.index')"
:current="request()->routeIs('helpdesk.tickets.*')"
wire:navigate
>
{{ __('Tickets') }}
</flux:sidebar.item>
@endif
@if (Route::has('helpdesk.kanban'))
<flux:sidebar.item
icon="squares-2x2"
:href="route('helpdesk.kanban')"
:current="request()->routeIs('helpdesk.kanban')"
wire:navigate
>
{{ __('Kanban') }}
</flux:sidebar.item>
@endif
@if ($helpdeskSettingsRoute)
<flux:sidebar.item
icon="cog"
:href="route($helpdeskSettingsRoute)"
:current="request()->routeIs('helpdesk.settings') || request()->routeIs('helpdesk.settings.index')"
wire:navigate
>
{{ __('Settings') }}
</flux:sidebar.item>
@endif
</flux:sidebar.group>
@if (Route::has('documentation.index') || Route::has('documentation.documents.index'))
<flux:sidebar.group :heading="__('Documentation')" class="grid">
@if (Route::has('documentation.documents.index'))
<flux:sidebar.item
icon="book-open"
:href="route('documentation.documents.index')"
:current="request()->routeIs('documentation.documents.*') || request()->routeIs('documentation.index')"
wire:navigate
>
{{ __('Documents') }}
</flux:sidebar.item>
@elseif (Route::has('documentation.index'))
<flux:sidebar.item
icon="book-open"
:href="route('documentation.index')"
:current="request()->routeIs('documentation.*')"
wire:navigate
>
{{ __('Documents') }}
</flux:sidebar.item>
@endif
@if (Route::has('documentation.categories.index'))
<flux:sidebar.item
icon="folder"
:href="route('documentation.categories.index')"
:current="request()->routeIs('documentation.categories.*')"
wire:navigate
>
{{ __('Categories') }}
</flux:sidebar.item>
@endif
</flux:sidebar.group>
@endif
</flux:sidebar.nav>
<flux:spacer />
<x-desktop-user-menu
class="hidden lg:block"
:name="auth()->user()->name"
/>
</flux:sidebar>
<!-- Mobile User Menu -->
<flux:header class="lg:hidden">
<flux:sidebar.toggle
class="lg:hidden"
icon="bars-2"
inset="left"
/>
<flux:spacer />
<flux:dropdown position="top" align="end">
<flux:profile
:initials="auth()->user()->initials()"
icon-trailing="chevron-down"
/>
<head>@include('partials.head')</head>
<body class="hd-app-body">
@php
$user = auth()->user();
$isAdmin = (method_exists($user, 'isHelpdeskAdmin') && $user->isHelpdeskAdmin()) || (method_exists($user, 'isAdmin') && $user->isAdmin()) || (bool) ($user->helpdesk_admin ?? false);
$roleLabel = $isAdmin ? 'Administrator' : ((method_exists($user, 'isHelpdeskAgent') && $user->isHelpdeskAgent()) || (bool) ($user->helpdesk_agent ?? false) ? 'Agent' : 'User');
@endphp
<div class="hd-app-shell" data-hd-shell>
<header class="hd-topbar">
<div class="hd-topbar-brand">
<button class="hd-icon-button hd-mobile-menu-button" type="button" data-hd-sidebar-toggle aria-label="Toggle navigation"><i class="fa-solid fa-bars"></i></button>
<a href="{{ route('dashboard') }}" wire:navigate class="hd-brand-name">{{ config('app.name', 'Laravel') }}</a>
</div>
<div class="hd-topbar-main">
@if(Route::has('helpdesk.tickets.index'))
<form method="GET" action="{{ route('helpdesk.tickets.index') }}" class="hd-top-search">
<i class="fa-solid fa-magnifying-glass"></i><input name="search" placeholder="Search tickets" aria-label="Search tickets">
</form>
@endif
<div class="hd-topbar-spacer"></div>
<flux:dropdown position="bottom" align="end">
<button class="hd-account-button" type="button">
<span class="hd-user-avatar">{{ $user->initials() }}</span>
<span class="hd-account-copy"><strong>{{ $user->name }}</strong><small>{{ $roleLabel }}</small></span>
<i class="fa-solid fa-chevron-down text-xs"></i>
</button>
<flux:menu>
<flux:menu.radio.group>
<div class="p-0 text-sm font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
<flux:avatar
:name="auth()->user()->name"
:initials="auth()->user()->initials()"
/>
<div class="grid flex-1 text-start text-sm leading-tight">
<flux:heading class="truncate">
{{ auth()->user()->name }}
</flux:heading>
<flux:text class="truncate">
{{ auth()->user()->email }}
</flux:text>
</div>
</div>
</div>
</flux:menu.radio.group>
@if(Route::has('profile.edit'))<flux:menu.item :href="route('profile.edit')" icon="user-circle" wire:navigate>Profile</flux:menu.item>@endif
@if(Route::has('helpdesk.settings'))<flux:menu.item :href="route('helpdesk.settings')" icon="cog" wire:navigate>Helpdesk Settings</flux:menu.item>@endif
@if($isAdmin)<flux:menu.item :href="url('/admin/users')" icon="user-circle" wire:navigate>User Management</flux:menu.item>@endif
<flux:menu.separator />
<flux:menu.radio.group>
@if (Route::has('documentation.documents.index') || Route::has('documentation.index'))
<flux:menu.item
:href="Route::has('documentation.documents.index') ? route('documentation.documents.index') : route('documentation.index')"
icon="book-open"
wire:navigate
>
{{ __('Documentation') }}
</flux:menu.item>
@endif
@if ($helpdeskSettingsRoute)
<flux:menu.item
:href="route($helpdeskSettingsRoute)"
icon="cog"
wire:navigate
>
{{ __('Helpdesk Settings') }}
</flux:menu.item>
@endif
@if (Route::has('profile.edit'))
<flux:menu.item
:href="route('profile.edit')"
icon="user-circle"
wire:navigate
>
{{ __('Profile') }}
</flux:menu.item>
@endif
</flux:menu.radio.group>
<flux:menu.separator />
<form
method="POST"
action="{{ route('logout') }}"
class="w-full"
>
@csrf
<flux:menu.item
as="button"
type="submit"
icon="arrow-right-start-on-rectangle"
class="w-full cursor-pointer"
data-test="logout-button"
>
{{ __('Log out') }}
</flux:menu.item>
</form>
<form method="POST" action="{{ route('logout') }}">@csrf<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle">Log out</flux:menu.item></form>
</flux:menu>
</flux:dropdown>
</flux:header>
</div>
</header>
{{ $slot }}
<aside class="hd-shell-sidebar" data-hd-sidebar>
<nav class="hd-shell-nav" aria-label="Primary navigation">
<div class="hd-nav-section"><div class="hd-nav-heading">Platform</div>
<a class="hd-nav-link {{ request()->routeIs('dashboard') ? 'is-active' : '' }}" href="{{ route('dashboard') }}" wire:navigate>
<i class="fa-solid fa-house"></i><span>Dashboard</span>
</a>
@persist('toast')
<flux:toast.group>
<flux:toast />
</flux:toast.group>
@endpersist
@if($isAdmin)
<a class="hd-nav-link {{ request()->is('admin/users') || request()->is('admin/users/create') || request()->is('admin/users/*/edit') ? 'is-active' : '' }}"
href="{{ url('/admin/users') }}" wire:navigate>
<i class="fa-solid fa-users"></i><span>Users</span>
</a>
@fluxScripts
</body>
<a class="hd-nav-link {{ request()->is('admin/users/roles') ? 'is-active' : '' }}"
href="{{ url('/admin/users/roles') }}" wire:navigate>
<i class="fa-solid fa-shield-halved"></i><span>Roles & Permissions</span>
</a>
@endif
</div>
<div class="hd-nav-section"><div class="hd-nav-heading">Helpdesk</div>
@if(Route::has('helpdesk.tickets.index'))<a class="hd-nav-link {{ request()->routeIs('helpdesk.tickets.*') ? 'is-active' : '' }}" href="{{ route('helpdesk.tickets.index') }}" wire:navigate><i class="fa-solid fa-ticket"></i><span>Tickets</span></a>@endif
@if(Route::has('helpdesk.kanban'))<a class="hd-nav-link {{ request()->routeIs('helpdesk.kanban') ? 'is-active' : '' }}" href="{{ route('helpdesk.kanban') }}" wire:navigate><i class="fa-solid fa-table-columns"></i><span>Kanban</span></a>@endif
@if(Route::has('helpdesk.settings'))<a class="hd-nav-link {{ request()->routeIs('helpdesk.settings*') ? 'is-active' : '' }}" href="{{ route('helpdesk.settings') }}" wire:navigate><i class="fa-solid fa-gear"></i><span>Settings</span></a>@endif
</div>
@if(Route::has('documentation.index') || Route::has('documentation.documents.index'))
<div class="hd-nav-section"><div class="hd-nav-heading">Documentation</div>
<a class="hd-nav-link {{ request()->routeIs('documentation.documents.*') || request()->routeIs('documentation.index') ? 'is-active' : '' }}" href="{{ Route::has('documentation.documents.index') ? route('documentation.documents.index') : route('documentation.index') }}" wire:navigate><i class="fa-regular fa-file-lines"></i><span>Documents</span></a>
@if(Route::has('documentation.categories.index'))<a class="hd-nav-link {{ request()->routeIs('documentation.categories.*') ? 'is-active' : '' }}" href="{{ route('documentation.categories.index') }}" wire:navigate><i class="fa-regular fa-folder"></i><span>Categories</span></a>@endif
</div>
@endif
</nav>
<div class="hd-sidebar-resizer" data-hd-sidebar-resizer title="Drag to resize sidebar"><span></span></div>
</aside>
<button type="button" class="hd-sidebar-backdrop" data-hd-sidebar-toggle aria-label="Close navigation"></button>
<main class="hd-shell-main">{{ $slot }}</main>
</div>
@persist('toast')<flux:toast.group><flux:toast /></flux:toast.group>@endpersist
@fluxScripts
</body>
</html>
@@ -1,196 +1,52 @@
<div class="space-y-5" dusk="helpdesk-dashboard-widget">
<span class="sr-only">Total tickets Open tickets Closed tickets</span>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-2xl border border-blue-400/20 bg-gradient-to-br from-slate-950 to-blue-950/40 p-4 shadow-sm ring-1 ring-white/5">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-xs font-black uppercase tracking-[0.18em] text-blue-200/80">Total tickets</div>
<div class="mt-2 text-3xl font-black text-white">{{ $totalCount }}</div>
</div>
<div class="flex h-11 w-11 items-center justify-center rounded-xl bg-blue-500/20 text-blue-200 ring-1 ring-blue-300/20">
<i class="fa-solid fa-ticket" aria-hidden="true"></i>
</div>
</div>
<div class="mt-4 h-1.5 rounded-full bg-slate-800">
<div class="h-1.5 rounded-full bg-blue-400" style="width: 100%"></div>
</div>
</div>
@php
$trendMax = max(1, (int) $trend->max(fn($row) => max($row['created'], $row['resolved'])));
$pointCount = max(1, $trend->count() - 1);
$createdPoints = $trend->map(fn($row, $i) => ($i * (100 / $pointCount)).','. (88 - (($row['created'] / $trendMax) * 68)))->implode(' ');
$resolvedPoints = $trend->map(fn($row, $i) => ($i * (100 / $pointCount)).','. (88 - (($row['resolved'] / $trendMax) * 68)))->implode(' ');
$palette = ['#ef4444', '#f59e0b', '#3b82f6', '#8b5cf6'];
$priorityTotal = max(1, $priorityBreakdown->sum('count'));
$offset = 0;
$segments = [];
foreach($priorityBreakdown as $index => $priority) {
$percent = ($priority['count'] / $priorityTotal) * 100;
$segments[] = $palette[$index % count($palette)].' '.$offset.'% '.($offset + $percent).'%';
$offset += $percent;
}
@endphp
<div class="hd-dashboard space-y-5" dusk="helpdesk-dashboard-widget">
<section class="hd-dashboard-heading">
<div><h1>Dashboard</h1><p>Overview of your helpdesk system</p></div>
<label class="hd-range-select"><i class="fa-regular fa-calendar"></i><select wire:model.live="days"><option value="7">Last 7 days</option><option value="14">Last 14 days</option><option value="30">Last 30 days</option></select></label>
</section>
<div class="rounded-2xl border border-blue-400/20 bg-gradient-to-br from-slate-950 to-blue-950/40 p-4 shadow-sm ring-1 ring-white/5">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-xs font-black uppercase tracking-[0.18em] text-blue-200/80">Open tickets</div>
<div class="mt-2 text-3xl font-black text-blue-200">{{ $openCount }}</div>
</div>
<div class="flex h-11 w-11 items-center justify-center rounded-xl bg-blue-500/20 text-blue-200 ring-1 ring-blue-300/20">
<i class="fa-solid fa-inbox" aria-hidden="true"></i>
</div>
</div>
<div class="mt-4 h-1.5 rounded-full bg-slate-800">
<div class="h-1.5 rounded-full bg-blue-400" style="width: {{ $openPercent }}%"></div>
</div>
</div>
<div class="rounded-2xl border border-emerald-400/20 bg-gradient-to-br from-slate-950 to-emerald-950/30 p-4 shadow-sm ring-1 ring-white/5">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-xs font-black uppercase tracking-[0.18em] text-emerald-200/80">Closed tickets</div>
<div class="mt-2 text-3xl font-black text-emerald-200">{{ $closedCount }}</div>
</div>
<div class="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-500/20 text-emerald-200 ring-1 ring-emerald-300/20">
<i class="fa-solid fa-circle-check" aria-hidden="true"></i>
</div>
</div>
<div class="mt-4 h-1.5 rounded-full bg-slate-800">
<div class="h-1.5 rounded-full bg-emerald-400" style="width: {{ $closedPercent }}%"></div>
</div>
</div>
<div class="rounded-2xl border border-rose-400/20 bg-gradient-to-br from-slate-950 to-rose-950/25 p-4 shadow-sm ring-1 ring-white/5">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-xs font-black uppercase tracking-[0.18em] text-rose-200/80">High priority</div>
<div class="mt-2 text-3xl font-black text-rose-200">{{ $criticalCount }}</div>
</div>
<div class="flex h-11 w-11 items-center justify-center rounded-xl bg-rose-500/20 text-rose-200 ring-1 ring-rose-300/20">
<i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
</div>
</div>
<div class="mt-4 text-xs font-semibold text-slate-400">Open urgent/high/critical workload.</div>
</div>
<div class="hd-metric-grid">
<article class="hd-metric-card hd-metric-blue"><span class="hd-metric-icon"><i class="fa-regular fa-clipboard"></i></span><div><small>Total Tickets</small><strong>{{ $totalCount }}</strong><em>All visible tickets</em></div></article>
<article class="hd-metric-card hd-metric-green"><span class="hd-metric-icon"><i class="fa-regular fa-circle-check"></i></span><div><small>Resolved</small><strong>{{ $closedCount }}</strong><em>Completed workload</em></div></article>
<article class="hd-metric-card hd-metric-amber"><span class="hd-metric-icon"><i class="fa-regular fa-clock"></i></span><div><small>In Progress</small><strong>{{ $inProgressCount }}</strong><em>Actively being worked</em></div></article>
<article class="hd-metric-card hd-metric-red"><span class="hd-metric-icon"><i class="fa-solid fa-circle-exclamation"></i></span><div><small>Open</small><strong>{{ $openCount }}</strong><em>Awaiting resolution</em></div></article>
</div>
<div class="grid gap-5 xl:grid-cols-[minmax(0,2fr)_minmax(22rem,1fr)]">
<div class="rounded-2xl border border-zinc-700 bg-gradient-to-br from-slate-950 via-slate-950 to-blue-950/20 shadow-sm ring-1 ring-white/5">
<div class="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div class="flex items-center gap-3">
<span class="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-500/15 text-blue-200 ring-1 ring-blue-300/20">
<i class="fa-solid fa-list-check" aria-hidden="true"></i>
</span>
<div>
<h2 class="text-lg font-black text-white">Recent tickets</h2>
<p class="text-sm text-slate-400">Latest support activity.</p>
</div>
</div>
<a href="{{ route('helpdesk.tickets.index') }}" wire:navigate class="hd-btn hd-btn-sm hd-btn-blue">View all tickets</a>
</div>
{{-- Recent Tickets --}}
<div class="divide-y divide-white/10">
@forelse ($recentTickets as $ticket)
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate
class="flex flex-nowrap items-center gap-4 px-5 py-4 transition hover:bg-blue-500/10">
<div class="flex w-36 shrink-0 items-center gap-3 text-sm font-semibold text-slate-300">
<span class="h-2 w-2 shrink-0 rounded-full bg-blue-400"></span>
<span class="whitespace-nowrap">{{ $ticket->reference }}</span>
</div>
<div class="hd-dashboard-grid">
<section class="hd-dashboard-card hd-chart-card">
<header><h2>Tickets Over Time</h2><span>{{ $days }} days</span></header>
<div class="hd-chart-legend"><span><b class="bg-blue-500"></b>Created</span><span><b class="bg-emerald-500"></b>Resolved</span></div>
<div class="hd-line-chart"><svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-label="Ticket trend chart"><g class="hd-chart-grid-lines"><line x1="0" y1="20" x2="100" y2="20"/><line x1="0" y1="42" x2="100" y2="42"/><line x1="0" y1="64" x2="100" y2="64"/><line x1="0" y1="88" x2="100" y2="88"/></g><polyline class="hd-chart-line hd-chart-line-blue" points="{{ $createdPoints }}"/><polyline class="hd-chart-line hd-chart-line-green" points="{{ $resolvedPoints }}"/></svg></div>
<div class="hd-chart-labels">@foreach($trend as $row)<span>{{ $row['label'] }}</span>@endforeach</div>
</section>
<div class="min-w-0 flex-1">
<div class="truncate font-black text-white">{{ $ticket->subject }}</div>
<div class="mt-1 truncate text-xs text-slate-400">
{{ optional($ticket->last_activity_at)->format('d M Y H:i') ?? 'No activity yet' }}
</div>
</div>
<section class="hd-dashboard-card hd-priority-card">
<header><h2>Tickets by Priority</h2></header>
<div class="hd-priority-body"><div class="hd-donut" style="background: conic-gradient({{ implode(', ', $segments) ?: '#334155 0 100%' }})"><span>{{ $priorityTotal }}</span></div><div class="hd-priority-legend">@foreach($priorityBreakdown as $i => $priority)<div><span><b style="background: {{ $palette[$i % count($palette)] }}"></b>{{ $priority['name'] }}</span><strong>{{ $priority['count'] }} ({{ round(($priority['count']/$priorityTotal)*100, 1) }}%)</strong></div>@endforeach</div></div>
</section>
<div class="w-28 shrink-0">
<x-helpdesk.colour-badge :color="$ticket->status?->color ?? 'zinc'">
{{ $ticket->status?->name ?? 'No status' }}
</x-helpdesk.colour-badge>
</div>
<section class="hd-dashboard-card hd-latest-card">
<header><h2>Latest Tickets</h2><a href="{{ route('helpdesk.tickets.index') }}" wire:navigate>View all</a></header>
<div class="hd-latest-list">@forelse($recentTickets as $ticket)<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate><span class="hd-priority-mini">{{ $ticket->priority?->name ?? 'Normal' }}</span><div><strong>{{ $ticket->subject }}</strong><small>#{{ $ticket->reference }} · {{ $ticket->user?->name ?? 'Unknown' }}</small></div><time>{{ optional($ticket->last_activity_at)->diffForHumans() }}</time><span class="hd-status-mini">{{ $ticket->status?->name ?? 'Open' }}</span></a>@empty<div class="hd-empty">No tickets yet.</div>@endforelse</div>
</section>
<div class="w-28 shrink-0">
<x-helpdesk.colour-badge :color="$ticket->priority?->color ?? 'zinc'">
{{ $ticket->priority?->name ?? 'No priority' }}
</x-helpdesk.colour-badge>
</div>
<div class="w-28 shrink-0 whitespace-nowrap text-right text-sm font-semibold text-slate-400">
{{ optional($ticket->last_activity_at)->diffForHumans() ?? 'No activity' }}
</div>
</a>
@empty
<div class="px-5 py-8 text-center text-sm text-slate-400">No helpdesk tickets yet.</div>
@endforelse
</div>
{{-- Recent Tickets End--}}
</div>
<div class="space-y-5">
<div class="rounded-2xl border border-zinc-700 bg-gradient-to-br from-slate-950 to-slate-900 p-5 shadow-sm ring-1 ring-white/5">
<div class="mb-5 flex items-center justify-between">
<div>
<h2 class="text-lg font-black text-white">SLA overview</h2>
<p class="text-sm text-slate-400">Closure rate and open workload.</p>
</div>
<span class="rounded-xl bg-emerald-500/15 px-3 py-1.5 text-sm font-black text-emerald-200 ring-1 ring-emerald-300/20">{{ $closedPercent }}% closed</span>
</div>
<div class="grid gap-5 sm:grid-cols-[9rem_minmax(0,1fr)] sm:items-center">
<div class="relative mx-auto flex h-32 w-32 items-center justify-center rounded-full bg-slate-900 ring-8 ring-emerald-400/20">
<div class="absolute inset-2 rounded-full border-8 border-emerald-400" style="clip-path: polygon(0 0, {{ max($closedPercent, 8) }}% 0, {{ max($closedPercent, 8) }}% 100%, 0 100%);"></div>
<div class="relative text-center">
<div class="text-3xl font-black text-white">{{ $closedPercent }}%</div>
<div class="text-xs font-bold text-slate-400">closed</div>
</div>
</div>
<div class="space-y-4">
<div>
<div class="mb-2 flex justify-between text-sm font-semibold text-slate-300"><span>Closed</span><span>{{ $closedPercent }}%</span></div>
<div class="h-2 rounded-full bg-slate-800"><div class="h-2 rounded-full bg-emerald-400" style="width: {{ $closedPercent }}%"></div></div>
</div>
<div>
<div class="mb-2 flex justify-between text-sm font-semibold text-slate-300"><span>Open</span><span>{{ $openPercent }}%</span></div>
<div class="h-2 rounded-full bg-slate-800"><div class="h-2 rounded-full bg-blue-400" style="width: {{ $openPercent }}%"></div></div>
</div>
</div>
</div>
</div>
<details
class="group rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
dusk="recent-commits-card">
<summary
class="-mx-4 -mt-4 mb-4 flex cursor-pointer list-none items-center justify-between gap-3 rounded-t-xl bg-violet-700 px-4 py-4 text-white marker:hidden">
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-violet-950 text-white ring-1 ring-violet-300/60 shadow-lg">
<i class="fa-solid fa-code-branch text-lg" aria-hidden="true"></i>
</span>
<div class="min-w-0">
<h2 class="text-lg font-black uppercase tracking-wide">Recent commits</h2>
<p class="text-xs font-semibold text-violet-100">Latest linked Git activity</p>
</div>
</div>
<span
class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white/15 text-white ring-1 ring-white/20">
<i class="fa-solid fa-chevron-right text-xs transition group-open:rotate-90" aria-hidden="true"></i>
</span>
</summary>
<div class="mb-4 flex justify-end">
<a href="{{ route('helpdesk.settings') }}" wire:navigate class="hd-btn hd-btn-sm hd-btn-violet">
Git setup
</a>
</div>
<div class="space-y-2">
@forelse ($recentCommits as $commit)
<div class="rounded-xl border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-950">
<div class="font-mono text-xs text-zinc-500 dark:text-zinc-400">
{{ $commit->short_hash }} · {{ $commit->repository?->name }}
</div>
<div class="mt-1 truncate text-sm font-semibold text-zinc-900 dark:text-zinc-100">
{{ $commit->message }}
</div>
</div>
@empty
<div class="rounded-xl border border-dashed border-zinc-300 p-4 text-center text-sm text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
No commits tracked yet.
</div>
@endforelse
</div>
</details>
</div>
<section class="hd-dashboard-card hd-requester-card">
<header><h2>Top Requesters</h2></header>
<div class="hd-requester-list">@forelse($topRequesters as $row)<div><span class="hd-user-avatar">{{ $row['user']->initials() }}</span><strong>{{ $row['user']->name }}</strong><em>{{ $row['count'] }} tickets</em></div>@empty<div class="hd-empty">No requester data yet.</div>@endforelse</div>
</section>
</div>
</div>
@@ -107,10 +107,15 @@
wire:key="kanban-dropzone-{{ $board?->id ?? 0 }}-{{ $status->id }}-{{ $refreshNonce }}">
@forelse ($tickets as $ticket)
<article
class="group cursor-grab rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md active:cursor-grabbing dark:border-zinc-800 dark:bg-zinc-900"
draggable="true" data-helpdesk-kanban-ticket data-ticket-id="{{ $ticket->id }}"
class="group rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md active:cursor-grabbing dark:border-zinc-800 dark:bg-zinc-900"
draggable="false" data-helpdesk-kanban-ticket data-ticket-id="{{ $ticket->id }}"
wire:key="kanban-ticket-{{ $ticket->id }}-{{ $ticket->status_id }}-{{ $ticket->board_id }}-{{ optional($ticket->updated_at)->timestamp }}"
dusk="kanban-ticket-{{ $ticket->id }}">
<div class="mb-2 flex justify-end">
<span class="hd-kanban-drag-handle" draggable="true" data-kanban-drag-handle title="Drag ticket" aria-label="Drag {{ $ticket->reference }}">
<i class="fa-solid fa-grip-vertical"></i>
</span>
</div>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div
@@ -248,126 +253,93 @@
@script
<script>
const initialiseHelpdeskKanban = function() {
let draggedTicketId = null;
let draggedCard = null;
(() => {
const componentRoot = $wire.$el;
if (!componentRoot || componentRoot.dataset.kanbanDndBound === '1') return;
componentRoot.dataset.kanbanDndBound = '1';
const updateColumnCounts = function() {
document.querySelectorAll('[data-helpdesk-kanban-count]').forEach(function(counter) {
const statusId = counter.dataset.statusId;
const column = document.querySelector('[data-helpdesk-kanban-column][data-status-id="' +
statusId + '"]');
let drag = null;
if (!column) {
return;
}
const columnFor = (element) => element?.closest?.('[data-helpdesk-kanban-column]');
const cardFor = (element) => element?.closest?.('[data-helpdesk-kanban-ticket]');
counter.textContent = column.querySelectorAll('[data-helpdesk-kanban-ticket]').length
.toString();
const updateCounts = () => {
componentRoot.querySelectorAll('[data-helpdesk-kanban-count]').forEach(counter => {
const column = componentRoot.querySelector(`[data-helpdesk-kanban-column][data-status-id="${counter.dataset.statusId}"]`);
if (column) counter.textContent = String(column.querySelectorAll('[data-helpdesk-kanban-ticket]').length);
});
};
const forceLivewireRefresh = function() {
if (typeof $wire !== 'undefined' && typeof $wire.$refresh === 'function') {
return Promise.resolve($wire.$refresh());
}
componentRoot.addEventListener('dragstart', (event) => {
const handle = event.target.closest('[data-kanban-drag-handle]');
if (!handle) { event.preventDefault(); return; }
const card = cardFor(handle);
const column = columnFor(card);
if (!card || !column) { event.preventDefault(); return; }
if (typeof Livewire !== 'undefined' && typeof Livewire.find === 'function') {
const root = document.querySelector('[wire\\:id]');
const component = root ? Livewire.find(root.getAttribute('wire:id')) : null;
if (component && typeof component.$refresh === 'function') {
return Promise.resolve(component.$refresh());
}
}
window.location.reload();
return Promise.resolve();
};
document.querySelectorAll('[data-helpdesk-kanban-ticket]').forEach(function(card) {
if (card.dataset.nativeDragReady === '1') return;
card.dataset.nativeDragReady = '1';
card.addEventListener('dragstart', function(event) {
draggedTicketId = card.dataset.ticketId;
draggedCard = card;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', draggedTicketId);
event.dataTransfer.setData('application/x-helpdesk-ticket-id', draggedTicketId);
card.classList.add('opacity-60');
});
card.addEventListener('dragend', function() {
card.classList.remove('opacity-60');
document.querySelectorAll('[data-helpdesk-kanban-column]').forEach(function(
column) {
column.dataset.dragOver = 'false';
column.dataset.refreshing = 'false';
});
});
drag = {
card,
ticketId: Number(card.dataset.ticketId),
fromStatusId: Number(column.dataset.statusId),
originalParent: card.parentElement,
originalNext: card.nextElementSibling,
};
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(drag.ticketId));
requestAnimationFrame(() => card.classList.add('hd-is-dragging'));
});
document.querySelectorAll('[data-helpdesk-kanban-column]').forEach(function(column) {
if (column.dataset.nativeDropReady === '1') return;
column.dataset.nativeDropReady = '1';
column.addEventListener('dragover', function(event) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
column.dataset.dragOver = 'true';
});
column.addEventListener('dragleave', function(event) {
if (!column.contains(event.relatedTarget)) {
column.dataset.dragOver = 'false';
}
});
column.addEventListener('drop', function(event) {
event.preventDefault();
column.dataset.dragOver = 'false';
const ticketId = event.dataTransfer.getData('application/x-helpdesk-ticket-id') ||
event.dataTransfer.getData('text/plain') || draggedTicketId;
const statusId = column.dataset.statusId;
const card = draggedCard || document.querySelector(
'[data-helpdesk-kanban-ticket][data-ticket-id="' + ticketId + '"]');
if (!ticketId || !statusId || !card) return;
column.dataset.refreshing = 'true';
column.appendChild(card);
updateColumnCounts();
Promise.resolve($wire.moveTicket(ticketId, statusId))
.then(forceLivewireRefresh)
.finally(function() {
column.dataset.refreshing = 'false';
draggedTicketId = null;
draggedCard = null;
updateColumnCounts();
setTimeout(initialiseHelpdeskKanban, 50);
});
});
componentRoot.addEventListener('dragover', (event) => {
const column = columnFor(event.target);
if (!drag || !column) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
componentRoot.querySelectorAll('[data-helpdesk-kanban-column]').forEach(el => el.dataset.dragOver = el === column ? 'true' : 'false');
});
updateColumnCounts();
};
componentRoot.addEventListener('drop', async (event) => {
const column = columnFor(event.target);
if (!drag || !column) return;
event.preventDefault();
initialiseHelpdeskKanban();
document.addEventListener('livewire:navigated', initialiseHelpdeskKanban);
document.addEventListener('livewire:updated', initialiseHelpdeskKanban);
document.addEventListener('helpdesk-kanban-moved', function() {
setTimeout(function() {
initialiseHelpdeskKanban();
const targetStatusId = Number(column.dataset.statusId);
const state = drag;
drag = null;
componentRoot.querySelectorAll('[data-helpdesk-kanban-column]').forEach(el => el.dataset.dragOver = 'false');
if (typeof $wire !== 'undefined' && typeof $wire.$refresh === 'function') {
$wire.$refresh();
if (targetStatusId === state.fromStatusId) {
state.card.classList.remove('hd-is-dragging');
return;
}
}, 50);
});
column.appendChild(state.card);
state.card.classList.remove('hd-is-dragging');
state.card.classList.add('hd-is-saving');
updateCounts();
try {
await $wire.moveTicket(state.ticketId, targetStatusId);
await $wire.$refresh();
} catch (error) {
if (state.originalNext && state.originalNext.parentElement === state.originalParent) {
state.originalParent.insertBefore(state.card, state.originalNext);
} else {
state.originalParent.appendChild(state.card);
}
state.card.classList.remove('hd-is-saving');
updateCounts();
console.error('Helpdesk Kanban move failed', error);
}
});
componentRoot.addEventListener('dragend', () => {
if (drag?.card) drag.card.classList.remove('hd-is-dragging');
drag = null;
componentRoot.querySelectorAll('[data-helpdesk-kanban-column]').forEach(el => el.dataset.dragOver = 'false');
});
updateCounts();
})();
</script>
@endscript
</div>
@@ -0,0 +1,20 @@
<x-layouts::app :title="__('Create User')">
<div class="hd-page-shell space-y-5">
<section class="hd-page-hero"><div><div class="hd-eyebrow">User administration</div><h1>Create user</h1><p>Create a Helpdesk account and assign its access level.</p></div><a class="hd-action" href="{{ route('user-management.index') }}">Back</a></section>
@if($errors->any())<div class="hd-alert hd-alert-danger"><ul>@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach</ul></div>@endif
<form method="POST" action="{{ route('user-management.store') }}" class="hd-panel p-6 space-y-6">@csrf
<div class="hd-form-grid">
<label class="hd-field"><span>Name</span><input name="name" value="{{ old('name', $user->name ?? '') }}" required></label>
<label class="hd-field"><span>Email</span><input type="email" name="email" value="{{ old('email', $user->email ?? '') }}" required></label>
<label class="hd-field"><span>Password {{ isset($user) ? '(leave blank to keep current)' : '' }}</span><input type="password" name="password" {{ isset($user) ? '' : 'required' }}></label>
<label class="hd-field"><span>Confirm password</span><input type="password" name="password_confirmation" {{ isset($user) ? '' : 'required' }}></label>
</div>
<div class="hd-role-options">
<label><input type="checkbox" name="helpdesk_agent" value="1" @checked(old('helpdesk_agent', $user->helpdesk_agent ?? false))> <span><strong>Helpdesk agent</strong><small>Can manage tickets and workflow.</small></span></label>
<label><input type="checkbox" name="helpdesk_admin" value="1" @checked(old('helpdesk_admin', $user->helpdesk_admin ?? false))> <span><strong>Administrator</strong><small>Full Helpdesk, settings and user administration.</small></span></label>
</div>
<div class="flex justify-end"><button class="hd-action hd-action-green" type="submit"><i class="fa-solid fa-floppy-disk"></i> Create user</button></div></form>
</div>
</x-layouts::app>
@@ -0,0 +1,22 @@
<x-layouts::app :title="__('Edit User')">
<div class="hd-page-shell space-y-5">
<section class="hd-page-hero"><div><div class="hd-eyebrow">User administration</div><h1>Edit {{ $user->name }}</h1><p>Update identity and Helpdesk access.</p></div><a class="hd-action" href="{{ route('user-management.index') }}">Back</a></section>
@if(session('status'))<div class="hd-alert hd-alert-success">{{ session('status') }}</div>@endif
@if($errors->any())<div class="hd-alert hd-alert-danger"><ul>@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach</ul></div>@endif
<form method="POST" action="{{ route('user-management.update', $user) }}" class="hd-panel p-6 space-y-6">@csrf @method('PUT')
<div class="hd-form-grid">
<label class="hd-field"><span>Name</span><input name="name" value="{{ old('name', $user->name ?? '') }}" required></label>
<label class="hd-field"><span>Email</span><input type="email" name="email" value="{{ old('email', $user->email ?? '') }}" required></label>
<label class="hd-field"><span>Password {{ isset($user) ? '(leave blank to keep current)' : '' }}</span><input type="password" name="password" {{ isset($user) ? '' : 'required' }}></label>
<label class="hd-field"><span>Confirm password</span><input type="password" name="password_confirmation" {{ isset($user) ? '' : 'required' }}></label>
</div>
<div class="hd-role-options">
<label><input type="checkbox" name="helpdesk_agent" value="1" @checked(old('helpdesk_agent', $user->helpdesk_agent ?? false))> <span><strong>Helpdesk agent</strong><small>Can manage tickets and workflow.</small></span></label>
<label><input type="checkbox" name="helpdesk_admin" value="1" @checked(old('helpdesk_admin', $user->helpdesk_admin ?? false))> <span><strong>Administrator</strong><small>Full Helpdesk, settings and user administration.</small></span></label>
</div>
<div class="flex flex-wrap justify-between gap-3"><div></div><button class="hd-action hd-action-blue" type="submit"><i class="fa-solid fa-floppy-disk"></i> Save changes</button></div></form>
@if(auth()->id() !== $user->id)<form method="POST" action="{{ route('user-management.destroy', $user) }}" onsubmit="return confirm('Delete this user?')">@csrf @method('DELETE')<button class="hd-action hd-action-red" type="submit">Delete user</button></form>@endif
</div>
</x-layouts::app>
@@ -0,0 +1,61 @@
<x-layouts::app :title="__('Users')">
<div class="hd-page-shell space-y-5">
<section class="hd-page-hero">
<div>
<div class="hd-eyebrow">Administration</div>
<h1>Users</h1>
<p>Manage Helpdesk users, administrators and agents.</p>
</div>
<a href="{{ route('user-management.create') }}" class="hd-action hd-action-green">
<i class="fa-solid fa-user-plus"></i> New user
</a>
</section>
@if (session('status'))
<div class="hd-alert hd-alert-success">{{ session('status') }}</div>
@endif
<section class="hd-panel">
<form method="GET" class="hd-toolbar">
<div class="hd-search-field">
<i class="fa-solid fa-magnifying-glass"></i>
<input name="search" value="{{ $search }}" placeholder="Search users by name or email">
</div>
<button class="hd-action hd-action-blue" type="submit">Search</button>
</form>
<div class="hd-table-wrap">
<table class="hd-table">
<thead><tr><th>User</th><th>Email</th><th>Role</th><th>Verified</th><th class="text-right">Actions</th></tr></thead>
<tbody>
@forelse($users as $user)
<tr>
<td>
<div class="flex items-center gap-3">
<span class="hd-user-avatar">{{ $user->initials() }}</span>
<span class="font-semibold">{{ $user->name }}</span>
</div>
</td>
<td>{{ $user->email }}</td>
<td>
@if($user->helpdesk_admin)
<span class="hd-role-pill hd-role-admin">Administrator</span>
@elseif($user->helpdesk_agent)
<span class="hd-role-pill hd-role-agent">Agent</span>
@else
<span class="hd-role-pill">User</span>
@endif
</td>
<td>{{ $user->email_verified_at ? 'Yes' : 'No' }}</td>
<td class="text-right"><a class="hd-action hd-action-blue hd-action-sm" href="{{ route('user-management.edit', $user) }}">Edit</a></td>
</tr>
@empty
<tr><td colspan="5" class="text-center text-zinc-500">No users found.</td></tr>
@endforelse
</tbody>
</table>
</div>
<div class="p-4">{{ $users->links() }}</div>
</section>
</div>
</x-layouts::app>
@@ -0,0 +1,10 @@
<x-layouts::app :title="__('Roles & Permissions')">
<div class="hd-page-shell space-y-5">
<section class="hd-page-hero"><div><div class="hd-eyebrow">Administration</div><h1>Roles & permissions</h1><p>Helpdesk uses three clear access levels.</p></div></section>
<div class="grid gap-4 lg:grid-cols-3">
<article class="hd-role-card hd-role-card-admin"><i class="fa-solid fa-shield-halved"></i><h2>Administrator</h2><strong>{{ $counts['admins'] }}</strong><p>Full tickets, settings, documentation and user management access.</p></article>
<article class="hd-role-card hd-role-card-agent"><i class="fa-solid fa-headset"></i><h2>Agent</h2><strong>{{ $counts['agents'] }}</strong><p>Can work tickets, Kanban workflow and support operations.</p></article>
<article class="hd-role-card"><i class="fa-solid fa-user"></i><h2>User</h2><strong>{{ $counts['users'] }}</strong><p>Standard authenticated requester access.</p></article>
</div>
</div>
</x-layouts::app>
@@ -1,25 +1,11 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>
{{ filled($title ?? null) ? $title.' - '.config('app.name', 'Laravel') : config('app.name', 'Laravel') }}
</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<title>{{ filled($title ?? null) ? $title.' - '.config('app.name', 'Laravel') : config('app.name', 'Laravel') }}</title>
<link rel="icon" href="/favicon.ico" sizes="any"><link rel="icon" href="/favicon.svg" type="image/svg+xml"><link rel="apple-touch-icon" href="/apple-touch-icon.png">
@fonts
@vite('resources/themes/backend/assets/css/app.css')
{{-- Helpdesk owns additional Select2/Flatpickr/Tailwind assets in its module Vite build. --}}
@if (file_exists(public_path('build/helpdesk/manifest.json')))
@vite([
'Resources/assets/css/app.css',
'Resources/assets/js/app.js',
], 'build/helpdesk')
@endif
@vite([
'resources/themes/backend/assets/css/app.css',
'resources/themes/backend/assets/js/passkeys.js',
'resources/themes/backend/assets/js/layout.js',
])
@fluxAppearance