diff --git a/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php b/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php index 3b53922..fbcb9b1 100644 --- a/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php +++ b/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php @@ -4,49 +4,60 @@ declare(strict_types=1); namespace Modules\Helpdesk\Livewire; +use App\Models\User; +use Carbon\CarbonPeriod; use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Auth; use Livewire\Component; -use Modules\Helpdesk\Models\GitCommit; use Modules\Helpdesk\Models\Ticket; final class HelpdeskDashboardWidget extends Component { + public int $days = 7; + + public function updatedDays(): void + { + if (! in_array($this->days, [7, 14, 30], true)) $this->days = 7; + } + public function render(): View { $user = Auth::user(); - $base = Ticket::query(); - - if ($user->helpdesk_admin ?? false) { - // no filter - } elseif ($user->helpdesk_agent ?? false) { - $base->where(function ($q) use ($user) { - $q->where('agent_id', $user->id) - ->orWhere('user_id', $user->id); - }); - } else { - $base->where('user_id', $user->id); - } + $base = Ticket::query()->visibleTo($user); $totalCount = (clone $base)->count(); $openCount = (clone $base)->whereNull('completed_at')->count(); $closedCount = (clone $base)->whereNotNull('completed_at')->count(); - $criticalCount = (clone $base)->whereNull('completed_at')->whereHas('priority', static fn ($q) => $q->whereIn('slug', ['critical', 'urgent', 'high']))->count(); - $closedPercent = $totalCount > 0 ? (int) round(($closedCount / $totalCount) * 100) : 0; - $openPercent = $totalCount > 0 ? (int) round(($openCount / $totalCount) * 100) : 0; + $inProgressCount = (clone $base)->whereNull('completed_at')->whereHas('status', static fn ($q) => $q->whereIn('slug', ['assigned', 'in-progress', 'in_progress', 'progress']))->count(); + + $period = CarbonPeriod::create(now()->subDays($this->days - 1)->startOfDay(), now()->startOfDay()); + $trendRows = (clone $base)->where('created_at', '>=', now()->subDays($this->days - 1)->startOfDay())->get(['created_at', 'completed_at']); + $trend = collect($period)->map(function ($date) use ($trendRows): array { + $day = $date->toDateString(); + return [ + 'label' => $date->format('M j'), + 'created' => $trendRows->filter(fn ($ticket) => optional($ticket->created_at)->toDateString() === $day)->count(), + 'resolved' => $trendRows->filter(fn ($ticket) => optional($ticket->completed_at)->toDateString() === $day)->count(), + ]; + })->values(); + + $priorityRows = (clone $base)->with('priority')->get(); + $priorityBreakdown = $priorityRows->groupBy(fn ($ticket) => strtolower((string) ($ticket->priority?->name ?? 'Unspecified'))) + ->map(fn ($rows, $name) => ['name' => ucfirst($name), 'count' => $rows->count()]) + ->sortByDesc('count')->take(4)->values(); + + $requesterCounts = (clone $base)->selectRaw('user_id, COUNT(*) as ticket_count')->whereNotNull('user_id')->groupBy('user_id')->orderByDesc('ticket_count')->limit(5)->get(); + $requesters = User::query()->whereIn('id', $requesterCounts->pluck('user_id'))->get()->keyBy('id'); + $topRequesters = $requesterCounts->map(fn ($row) => ['user' => $requesters->get($row->user_id), 'count' => (int) $row->ticket_count])->filter(fn ($row) => $row['user'] !== null)->values(); return view('modules.helpdesk.livewire.dashboard-widget', [ 'totalCount' => $totalCount, 'openCount' => $openCount, 'closedCount' => $closedCount, - 'criticalCount' => $criticalCount, - 'closedPercent' => $closedPercent, - 'openPercent' => $openPercent, - 'recentCommits' => GitCommit::query()->with(['repository', 'ticket'])->latest('committed_at')->limit(6)->get(), - 'recentTickets' => (clone $base) - ->with(['status', 'priority']) - ->latest('last_activity_at') - ->limit(5) - ->get(), + 'inProgressCount' => $inProgressCount, + 'trend' => $trend, + 'priorityBreakdown' => $priorityBreakdown, + 'topRequesters' => $topRequesters, + 'recentTickets' => (clone $base)->with(['status', 'priority'])->latest('last_activity_at')->limit(5)->get(), ]); } } diff --git a/Modules/Helpdesk/Livewire/TicketEdit.php b/Modules/Helpdesk/Livewire/TicketEdit.php index b260faf..829f48e 100644 --- a/Modules/Helpdesk/Livewire/TicketEdit.php +++ b/Modules/Helpdesk/Livewire/TicketEdit.php @@ -222,7 +222,6 @@ final class TicketEdit extends Component { return $ticket->fresh([ 'user:id,name,email', - 'owner:id,name,email', 'agent:id,name,email', 'status', 'priority', diff --git a/Modules/UserManagement/Http/Controllers/UserController.php b/Modules/UserManagement/Http/Controllers/UserController.php new file mode 100644 index 0000000..254be62 --- /dev/null +++ b/Modules/UserManagement/Http/Controllers/UserController.php @@ -0,0 +1,115 @@ +query('search', '')); + + $users = User::query() + ->when($search !== '', static function ($query) use ($search): void { + $query->where(static function ($query) use ($search): void { + $query->where('name', 'like', '%'.$search.'%') + ->orWhere('email', 'like', '%'.$search.'%'); + }); + }) + ->orderBy('name') + ->paginate(20) + ->withQueryString(); + + return view('modules.user-management.index', compact('users', 'search')); + } + + public function create(): View + { + return view('modules.user-management.create'); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', 'unique:users,email'], + 'password' => ['required', 'string', 'min:8', 'confirmed'], + 'helpdesk_agent' => ['sometimes', 'boolean'], + 'helpdesk_admin' => ['sometimes', 'boolean'], + ]); + + $user = User::query()->create([ + 'name' => $data['name'], + 'email' => $data['email'], + 'password' => Hash::make($data['password']), + 'email_verified_at' => now(), + 'helpdesk_agent' => $request->boolean('helpdesk_agent') || $request->boolean('helpdesk_admin'), + 'helpdesk_admin' => $request->boolean('helpdesk_admin'), + ]); + + return redirect()->route('user-management.edit', $user) + ->with('status', 'User created successfully.'); + } + + public function edit(User $user): View + { + return view('modules.user-management.edit', compact('user')); + } + + public function update(Request $request, User $user): RedirectResponse + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)], + 'password' => ['nullable', 'string', 'min:8', 'confirmed'], + 'helpdesk_agent' => ['sometimes', 'boolean'], + 'helpdesk_admin' => ['sometimes', 'boolean'], + ]); + + $user->name = $data['name']; + $user->email = $data['email']; + $user->helpdesk_admin = $request->boolean('helpdesk_admin'); + $user->helpdesk_agent = $user->helpdesk_admin || $request->boolean('helpdesk_agent'); + + if (filled($data['password'] ?? null)) { + $user->password = Hash::make($data['password']); + } + + $user->save(); + + return back()->with('status', 'User updated successfully.'); + } + + public function destroy(Request $request, User $user): RedirectResponse + { + abort_if((int) $request->user()->id === (int) $user->id, 422, 'You cannot delete your own account.'); + + if ($user->isHelpdeskAdmin() && User::query()->where('helpdesk_admin', true)->count() <= 1) { + return back()->withErrors(['user' => 'The last Helpdesk administrator cannot be deleted.']); + } + + $user->delete(); + + return redirect()->route('user-management.index')->with('status', 'User deleted.'); + } + + public function roles(): View + { + $counts = [ + 'admins' => User::query()->where('helpdesk_admin', true)->count(), + 'agents' => User::query()->where('helpdesk_agent', true)->count(), + 'users' => User::query()->count(), + ]; + + return view('modules.user-management.roles', compact('counts')); + } +} diff --git a/Modules/UserManagement/Http/Middleware/EnsureUserManagementAdmin.php b/Modules/UserManagement/Http/Middleware/EnsureUserManagementAdmin.php new file mode 100644 index 0000000..f0aa76b --- /dev/null +++ b/Modules/UserManagement/Http/Middleware/EnsureUserManagementAdmin.php @@ -0,0 +1,27 @@ +user(); + + $allowed = $user !== null && ( + (method_exists($user, 'isHelpdeskAdmin') && $user->isHelpdeskAdmin()) + || (method_exists($user, 'isAdmin') && $user->isAdmin()) + || (bool) ($user->helpdesk_admin ?? false) + ); + + abort_unless($allowed, 403); + + return $next($request); + } +} diff --git a/Modules/UserManagement/Providers/RouteServiceProvider.php b/Modules/UserManagement/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..c50a260 --- /dev/null +++ b/Modules/UserManagement/Providers/RouteServiceProvider.php @@ -0,0 +1,22 @@ +prefix('admin/users') + ->name('user-management.') + ->group(module_path('UserManagement', 'Routes/web.php')); + } +} diff --git a/Modules/UserManagement/Providers/UserManagementServiceProvider.php b/Modules/UserManagement/Providers/UserManagementServiceProvider.php new file mode 100644 index 0000000..5abe93a --- /dev/null +++ b/Modules/UserManagement/Providers/UserManagementServiceProvider.php @@ -0,0 +1,15 @@ +app->register(RouteServiceProvider::class); + } +} diff --git a/Modules/UserManagement/Routes/web.php b/Modules/UserManagement/Routes/web.php new file mode 100644 index 0000000..13ba516 --- /dev/null +++ b/Modules/UserManagement/Routes/web.php @@ -0,0 +1,14 @@ +name('index'); +Route::get('/create', [UserController::class, 'create'])->name('create'); +Route::post('/', [UserController::class, 'store'])->name('store'); +Route::get('/roles', [UserController::class, 'roles'])->name('roles'); +Route::get('/{user}/edit', [UserController::class, 'edit'])->name('edit'); +Route::put('/{user}', [UserController::class, 'update'])->name('update'); +Route::delete('/{user}', [UserController::class, 'destroy'])->name('destroy'); diff --git a/Modules/UserManagement/composer.json b/Modules/UserManagement/composer.json new file mode 100644 index 0000000..469b01d --- /dev/null +++ b/Modules/UserManagement/composer.json @@ -0,0 +1,9 @@ +{ + "name": "modules/user-management", + "description": "Helpdesk user management module", + "autoload": { + "psr-4": { + "Modules\\UserManagement\\": "" + } + } +} diff --git a/Modules/UserManagement/module.json b/Modules/UserManagement/module.json new file mode 100644 index 0000000..0c35e18 --- /dev/null +++ b/Modules/UserManagement/module.json @@ -0,0 +1,12 @@ +{ + "name": "UserManagement", + "alias": "user-management", + "version": "1.0.0", + "description": "Administrator-only user, Helpdesk role and permission management.", + "keywords": ["users", "administration", "roles", "permissions"], + "priority": 0, + "providers": ["Modules\\UserManagement\\Providers\\UserManagementServiceProvider"], + "aliases": {}, + "files": [], + "requires": [] +} diff --git a/app/Livewire/Settings/Appearance.php b/app/Livewire/Settings/Appearance.php index 7550e32..4a612d8 100644 --- a/app/Livewire/Settings/Appearance.php +++ b/app/Livewire/Settings/Appearance.php @@ -2,6 +2,8 @@ namespace App\Livewire\Settings; +use Illuminate\Contracts\View\View; +use Livewire\Attributes\Layout; use Livewire\Attributes\Title; use Livewire\Component; @@ -9,4 +11,12 @@ use Livewire\Component; class Appearance extends Component { // + #[Layout('layouts.app')] + public function render(): View + { + return view()->file( + resource_path('themes/backend/views/livewire/settings/appearance.blade.php') + ); + } + } diff --git a/app/Livewire/Settings/DeleteUserForm.php b/app/Livewire/Settings/DeleteUserForm.php index 542b72f..66967a8 100644 --- a/app/Livewire/Settings/DeleteUserForm.php +++ b/app/Livewire/Settings/DeleteUserForm.php @@ -2,6 +2,7 @@ namespace App\Livewire\Settings; +use Illuminate\Contracts\View\View; use App\Concerns\PasswordValidationRules; use App\Livewire\Actions\Logout; use Illuminate\Support\Facades\Auth; @@ -26,4 +27,11 @@ class DeleteUserForm extends Component $this->redirect('/', navigate: true); } + public function render(): View + { + return view()->file( + resource_path('themes/backend/views/livewire/settings/delete-user-form.blade.php') + ); + } + } diff --git a/app/Livewire/Settings/Profile.php b/app/Livewire/Settings/Profile.php index a78533b..5366dee 100644 --- a/app/Livewire/Settings/Profile.php +++ b/app/Livewire/Settings/Profile.php @@ -2,10 +2,12 @@ namespace App\Livewire\Settings; +use Illuminate\Contracts\View\View; use App\Concerns\ProfileValidationRules; use Flux\Flux; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Support\Facades\Auth; +use Livewire\Attributes\Layout; use Livewire\Attributes\Computed; use Livewire\Attributes\Title; use Livewire\Component; @@ -81,4 +83,12 @@ class Profile extends Component return ! $user instanceof MustVerifyEmail || $user->hasVerifiedEmail(); } + #[Layout('layouts.app')] + public function render(): View + { + return view()->file( + resource_path('themes/backend/views/livewire/settings/profile.blade.php') + ); + } + } diff --git a/app/Livewire/Settings/Security.php b/app/Livewire/Settings/Security.php index 85ec4a6..ffef75c 100644 --- a/app/Livewire/Settings/Security.php +++ b/app/Livewire/Settings/Security.php @@ -2,6 +2,7 @@ namespace App\Livewire\Settings; +use Illuminate\Contracts\View\View; use App\Concerns\PasswordValidationRules; use Exception; use Flux\Flux; @@ -13,6 +14,7 @@ use Laravel\Fortify\Actions\EnableTwoFactorAuthentication; use Laravel\Fortify\Features; use Laravel\Fortify\Fortify; use Laravel\Passkeys\Actions\DeletePasskey; +use Livewire\Attributes\Layout; use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; use Livewire\Attributes\Title; @@ -309,4 +311,12 @@ class Security extends Component 'buttonText' => __('Continue'), ]; } + #[Layout('layouts.app')] + public function render(): View + { + return view()->file( + resource_path('themes/backend/views/livewire/settings/security.blade.php') + ); + } + } diff --git a/app/Livewire/Settings/TwoFactor/RecoveryCodes.php b/app/Livewire/Settings/TwoFactor/RecoveryCodes.php index ab4d890..cbafe2e 100644 --- a/app/Livewire/Settings/TwoFactor/RecoveryCodes.php +++ b/app/Livewire/Settings/TwoFactor/RecoveryCodes.php @@ -2,6 +2,7 @@ namespace App\Livewire\Settings\TwoFactor; +use Illuminate\Contracts\View\View; use Exception; use Laravel\Fortify\Actions\GenerateNewRecoveryCodes; use Livewire\Attributes\Locked; @@ -48,4 +49,11 @@ class RecoveryCodes extends Component } } } + public function render(): View + { + return view()->file( + resource_path('themes/backend/views/livewire/settings/two-factor/recovery-codes.blade.php') + ); + } + } diff --git a/app/Models/User.php b/app/Models/User.php index 2419458..0bfb92a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,7 +2,6 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; @@ -14,33 +13,22 @@ use Illuminate\Support\Str; use Laravel\Fortify\Contracts\PasskeyUser; use Laravel\Fortify\PasskeyAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable; -use Modules\Helpdesk\Concerns\HasHelpdeskTickets; /** * @property int $id * @property string $name * @property string $email * @property Carbon|null $email_verified_at - * @property string $password - * @property string|null $two_factor_secret - * @property string|null $two_factor_recovery_codes - * @property Carbon|null $two_factor_confirmed_at - * @property string|null $remember_token - * @property Carbon|null $created_at - * @property Carbon|null $updated_at + * @property bool $helpdesk_agent + * @property bool $helpdesk_admin */ -#[Fillable(['name', 'email', 'password'])] +#[Fillable(['name', 'email', 'password', 'email_verified_at', 'helpdesk_agent', 'helpdesk_admin'])] #[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])] class User extends Authenticatable implements PasskeyUser { /** @use HasFactory */ - use HasFactory, HasHelpdeskTickets, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable; + use HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable; - /** - * Get the attributes that should be cast. - * - * @return array - */ protected function casts(): array { return [ @@ -51,28 +39,6 @@ class User extends Authenticatable implements PasskeyUser ]; } - /** - * Backwards-compatible application-level admin check. - * - * Helpdesk currently owns the admin role, so this maps to the - * Helpdesk admin permission flag. - */ - public function isAdmin(): bool - { - return $this->isHelpdeskAdmin(); - } - - /** - * Backwards-compatible application-level agent check. - */ - public function isAgent(): bool - { - return $this->isHelpdeskAgent(); - } - - /** - * Get the user's initials - */ public function initials(): string { $initials = Str::initials($this->name, true); @@ -81,4 +47,24 @@ class User extends Authenticatable implements PasskeyUser ? Str::substr($initials, 0, 1).Str::substr($initials, -1) : $initials; } + + public function isHelpdeskAdmin(): bool + { + return (bool) $this->helpdesk_admin; + } + + public function isHelpdeskAgent(): bool + { + return $this->isHelpdeskAdmin() || (bool) $this->helpdesk_agent; + } + + public function isAdmin(): bool + { + return $this->isHelpdeskAdmin(); + } + + public function isAgent(): bool + { + return $this->isHelpdeskAgent(); + } } diff --git a/modules_statuses.json b/modules_statuses.json index ccb0b88..6125530 100644 --- a/modules_statuses.json +++ b/modules_statuses.json @@ -1,5 +1,6 @@ { - "Stylist":true, + "Stylist": true, "Helpdesk": true, - "Documentation" : true + "Documentation": true, + "UserManagement": true } diff --git a/resources/themes/backend/assets/css/helpdesk.css b/resources/themes/backend/assets/css/helpdesk.css index 4f25b6f..18075ad 100644 --- a/resources/themes/backend/assets/css/helpdesk.css +++ b/resources/themes/backend/assets/css/helpdesk.css @@ -1265,3 +1265,75 @@ span.flatpickr-weekday { display: block !important; } } + + +/* ===== Application shell ================================================= */ +:root { --hd-sidebar-width: 284px; --hd-topbar-height: 72px; } +.hd-app-body { margin:0; min-height:100vh; background:#0b0e13; color:#e5e7eb; overflow-x:hidden; } +.hd-app-shell { min-height:100vh; } +.hd-topbar { position:fixed; inset:0 0 auto 0; height:var(--hd-topbar-height); z-index:70; display:grid; grid-template-columns:var(--hd-sidebar-width) minmax(0,1fr); background:linear-gradient(90deg,#10141b,#171d27); border-bottom:1px solid #2a303b; box-shadow:0 8px 24px rgba(0,0,0,.16); } +.hd-topbar-brand { min-width:0; display:flex; align-items:center; gap:14px; padding:0 18px; border-right:1px solid #29303a; } +.hd-brand-name { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#fff; text-transform:uppercase; letter-spacing:-.02em; font-size:1.18rem; font-weight:850; } +.hd-topbar-main { min-width:0; display:flex; align-items:center; gap:16px; padding:0 24px; } +.hd-topbar-spacer { flex:1; } +.hd-icon-button { display:inline-flex; width:38px; height:38px; align-items:center; justify-content:center; border-radius:10px; color:#aab2c0; border:1px solid transparent; background:transparent; } +.hd-icon-button:hover { color:#fff; background:#232a35; border-color:#343d4a; } +.hd-mobile-menu-button { display:none; } +.hd-top-search { width:min(420px,40vw); height:40px; display:flex; align-items:center; gap:10px; padding:0 13px; border:1px solid #303845; border-radius:11px; background:#10151d; color:#8e98a8; } +.hd-top-search input { width:100%; border:0!important; outline:0!important; background:transparent!important; color:#e5e7eb!important; box-shadow:none!important; min-height:auto!important; } +.hd-account-button { display:flex; align-items:center; gap:10px; padding:6px 9px; border-radius:12px; background:transparent; border:1px solid transparent; color:#fff; } +.hd-account-button:hover { background:#202732; border-color:#333c49; } +.hd-account-copy { display:grid; text-align:left; line-height:1.15; } +.hd-account-copy strong { font-size:.9rem; } +.hd-account-copy small { margin-top:3px; color:#9aa4b3; font-size:.72rem; } +.hd-user-avatar { display:inline-flex; width:38px; height:38px; flex:0 0 38px; align-items:center; justify-content:center; border-radius:10px; background:linear-gradient(145deg,#3a4351,#252c36); border:1px solid #505a68; color:#fff; font-weight:800; } +.hd-shell-sidebar { position:fixed; z-index:60; top:var(--hd-topbar-height); left:0; bottom:0; width:var(--hd-sidebar-width); background:linear-gradient(180deg,#151920,#11151b); border-right:1px solid #2d3440; transition:width .08s linear,transform .25s ease; } +.hd-shell-nav { height:100%; overflow-y:auto; padding:22px 16px 30px; scrollbar-width:thin; } +.hd-nav-section + .hd-nav-section { margin-top:24px; } +.hd-nav-heading { margin:0 10px 9px; color:#7f8998; font-size:.68rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; } +.hd-nav-link { display:flex; align-items:center; gap:12px; min-height:44px; margin:3px 0; padding:0 12px; border-radius:9px; color:#b9c0cb; font-size:.9rem; font-weight:560; transition:.16s ease; } +.hd-nav-link i { width:18px; text-align:center; color:#8892a2; } +.hd-nav-link:hover { background:#1d242e; color:#fff; } +.hd-nav-link.is-active { background:linear-gradient(90deg,#242c38,#1e2631); color:#fff; box-shadow:inset 3px 0 #ef4444; } +.hd-nav-link.is-active i { color:#fff; } +.hd-sidebar-resizer { position:absolute; z-index:4; top:0; right:-5px; width:10px; height:100%; cursor:col-resize; display:flex; align-items:center; justify-content:center; } +.hd-sidebar-resizer span { width:2px; height:42px; opacity:0; border-radius:99px; background:#64748b; transition:.15s; } +.hd-sidebar-resizer:hover span,.hd-sidebar-resizing .hd-sidebar-resizer span { opacity:1; } +.hd-sidebar-backdrop { display:none; } +.hd-shell-main { min-height:100vh; margin-left:var(--hd-sidebar-width); padding:calc(var(--hd-topbar-height) + 28px) 30px 34px; background:radial-gradient(circle at 75% 0,rgba(37,99,235,.07),transparent 35%),#0b0e13; transition:margin-left .08s linear; } +.hd-sidebar-resizing,.hd-sidebar-resizing * { cursor:col-resize!important; user-select:none!important; } + +/* ===== Dashboard ========================================================= */ +.hd-dashboard { max-width:1600px; margin:0 auto; } +.hd-dashboard-heading { display:flex; align-items:center; justify-content:space-between; gap:18px; } +.hd-dashboard-heading h1 { margin:0; font-size:2rem; line-height:1; color:#fff; font-weight:850; letter-spacing:-.03em; } +.hd-dashboard-heading p { margin:7px 0 0; color:#9ca7b8; } +.hd-range-select { min-width:220px; display:flex; align-items:center; gap:9px; border:1px solid #303846; border-radius:10px; padding:0 12px; background:#151a22; color:#c8cfda; } +.hd-range-select select { flex:1; min-height:44px; border:0!important; background:transparent!important; color:#e5e7eb!important; outline:0; } +.hd-metric-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:14px; } +.hd-metric-card { min-height:132px; display:flex; align-items:flex-start; gap:15px; padding:20px; border:1px solid #303845; border-radius:12px; background:linear-gradient(145deg,#1a202a,#151a22); box-shadow:0 8px 20px rgba(0,0,0,.16); } +.hd-metric-icon { width:50px; height:50px; display:flex; align-items:center; justify-content:center; border-radius:10px; font-size:1.35rem; } +.hd-metric-card div { display:grid; gap:3px; } +.hd-metric-card small { color:#aeb7c5; font-size:.9rem; } +.hd-metric-card strong { color:#fff; font-size:2rem; line-height:1.1; } +.hd-metric-card em { color:#8290a2; font-size:.72rem; font-style:normal; } +.hd-metric-blue .hd-metric-icon{background:#18365f;color:#60a5fa}.hd-metric-green .hd-metric-icon{background:#123f32;color:#6ee7b7}.hd-metric-amber .hd-metric-icon{background:#4a3518;color:#fbbf24}.hd-metric-red .hd-metric-icon{background:#4b2226;color:#f87171} +.hd-dashboard-grid { display:grid; grid-template-columns:minmax(0,1.45fr) minmax(340px,.75fr); gap:16px; } +.hd-dashboard-card { border:1px solid #303845; border-radius:12px; background:linear-gradient(145deg,#171c25,#131820); overflow:hidden; } +.hd-dashboard-card>header { min-height:58px; display:flex; align-items:center; justify-content:space-between; gap:12px; padding:0 18px; border-bottom:1px solid #2b3340; } +.hd-dashboard-card>header h2 { margin:0; color:#fff; font-size:1rem; font-weight:750; }.hd-dashboard-card>header span,.hd-dashboard-card>header a{color:#a8b1be;font-size:.78rem}.hd-dashboard-card>header a{padding:7px 10px;border:1px solid #3b4451;border-radius:8px}.hd-dashboard-card>header a:hover{color:#fff;background:#242c37} +.hd-chart-legend { display:flex; gap:18px; padding:15px 20px 0; color:#a7b0bd; font-size:.74rem; }.hd-chart-legend span{display:flex;align-items:center;gap:7px}.hd-chart-legend b{width:9px;height:9px;border-radius:50%} +.hd-line-chart { height:230px; padding:14px 20px 0; }.hd-line-chart svg{width:100%;height:100%;overflow:visible}.hd-chart-grid-lines line{stroke:#2d3541;stroke-width:.35}.hd-chart-line{fill:none;stroke-width:1.6;vector-effect:non-scaling-stroke}.hd-chart-line-blue{stroke:#3b82f6}.hd-chart-line-green{stroke:#22c55e}.hd-chart-labels{display:flex;justify-content:space-between;gap:5px;padding:0 20px 17px;color:#8f9aaa;font-size:.68rem;overflow:hidden} +.hd-priority-body { display:grid; grid-template-columns:170px 1fr; gap:20px; align-items:center; min-height:310px; padding:22px; }.hd-donut{width:150px;height:150px;margin:auto;border-radius:50%;display:flex;align-items:center;justify-content:center;position:relative}.hd-donut:after{content:"";position:absolute;inset:34px;border-radius:50%;background:#171c25}.hd-donut span{position:relative;z-index:1;font-weight:800;font-size:1.25rem;color:#fff}.hd-priority-legend{display:grid;gap:14px}.hd-priority-legend>div{display:flex;justify-content:space-between;gap:12px;color:#b4bdca;font-size:.76rem}.hd-priority-legend span{display:flex;align-items:center;gap:8px}.hd-priority-legend b{width:10px;height:10px;border-radius:50%}.hd-priority-legend strong{color:#e5e7eb} +.hd-latest-card { min-height:300px; }.hd-latest-list>a{display:grid;grid-template-columns:72px minmax(0,1fr) 88px 94px;align-items:center;gap:12px;padding:14px 18px;border-bottom:1px solid #29313c;color:#dbe2eb}.hd-latest-list>a:hover{background:#1b222c}.hd-latest-list>a:last-child{border-bottom:0}.hd-latest-list div{min-width:0;display:grid}.hd-latest-list strong{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:.86rem}.hd-latest-list small,.hd-latest-list time{color:#8490a1;font-size:.68rem}.hd-priority-mini,.hd-status-mini{justify-self:start;padding:4px 8px;border-radius:6px;background:#423027;color:#fcd9b1;font-size:.61rem;font-weight:750;text-transform:uppercase}.hd-status-mini{background:#1d355b;color:#93c5fd;text-transform:none} +.hd-requester-list { padding:8px 18px 16px; }.hd-requester-list>div{display:grid;grid-template-columns:38px minmax(0,1fr) auto;align-items:center;gap:11px;min-height:58px;border-bottom:1px solid #29313c}.hd-requester-list>div:last-child{border-bottom:0}.hd-requester-list strong{font-size:.82rem}.hd-requester-list em{color:#9aa5b5;font-size:.72rem;font-style:normal}.hd-empty{padding:28px;text-align:center;color:#7f8a99} + +/* ===== Common admin/user module ========================================= */ +.hd-page-shell{max-width:1500px;margin:0 auto}.hd-page-hero{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:24px 26px;border:1px solid #2d3849;border-radius:14px;background:linear-gradient(115deg,#091126,#152952);box-shadow:inset 0 1px rgba(255,255,255,.03)}.hd-page-hero h1{margin:3px 0 0;color:#fff;font-size:1.8rem;font-weight:850}.hd-page-hero p{margin:6px 0 0;color:#aebbd0}.hd-eyebrow{color:#b7ccf7;font-size:.68rem;font-weight:800;text-transform:uppercase;letter-spacing:.22em}.hd-panel{border:1px solid #343b44;border-radius:13px;background:#16191d;overflow:hidden}.hd-toolbar{display:flex;gap:12px;padding:16px;border-bottom:1px solid #30363e}.hd-search-field{flex:1;display:flex;align-items:center;gap:10px;padding:0 12px;border:1px solid #454b54;border-radius:9px;background:#272b30;color:#9da5b0}.hd-search-field input{width:100%;min-height:42px;border:0!important;background:transparent!important;color:#fff!important;outline:0!important;box-shadow:none!important}.hd-table-wrap{overflow:auto}.hd-table{width:100%;min-width:760px;border-collapse:collapse}.hd-table th,.hd-table td{padding:14px 16px;border-bottom:1px solid #30353b;text-align:left}.hd-table th{color:#aeb5bf;background:#202326;font-size:.7rem;text-transform:uppercase;letter-spacing:.05em}.hd-table td{color:#e1e5ea;font-size:.82rem}.hd-action{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:40px;padding:0 14px;border:1px solid #404854;border-radius:8px;background:#242a32;color:#e7ebf0;font-weight:700;font-size:.78rem}.hd-action:hover{filter:brightness(1.1)}.hd-action-sm{min-height:32px;padding:0 10px}.hd-action-blue{background:#2563eb;border-color:#3b82f6;color:#fff}.hd-action-green{background:#16a34a;border-color:#22c55e;color:#fff}.hd-action-red{background:#dc2626;border-color:#ef4444;color:#fff}.hd-alert{padding:12px 14px;border-radius:9px;border:1px solid}.hd-alert-success{background:#0d3225;border-color:#1f7a50;color:#86efac}.hd-alert-danger{background:#3e171b;border-color:#8d2f36;color:#fca5a5}.hd-role-pill{padding:5px 8px;border-radius:99px;background:#313844;color:#cbd5e1;font-size:.67rem;font-weight:750}.hd-role-admin{background:#422258;color:#e9d5ff}.hd-role-agent{background:#173d58;color:#bae6fd}.hd-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.hd-field{display:grid;gap:7px}.hd-field span{color:#cbd1da;font-size:.75rem;font-weight:650}.hd-field input{min-height:44px;border:1px solid #474d56!important;border-radius:8px!important;background:#292d32!important;color:#fff!important;padding:0 12px!important}.hd-role-options{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:13px}.hd-role-options label{display:flex;gap:11px;align-items:flex-start;padding:15px;border:1px solid #3d444d;border-radius:10px;background:#1e2227}.hd-role-options input{margin-top:3px}.hd-role-options span{display:grid}.hd-role-options small{margin-top:4px;color:#8f99a7;font-size:.7rem}.hd-role-card{display:grid;gap:9px;min-height:210px;padding:24px;border:1px solid #343d49;border-radius:13px;background:linear-gradient(145deg,#191e26,#14181f)}.hd-role-card i{font-size:1.6rem;color:#60a5fa}.hd-role-card h2{margin:0;color:#fff;font-size:1rem}.hd-role-card strong{font-size:2.1rem;color:#fff}.hd-role-card p{margin:0;color:#96a2b2;font-size:.78rem}.hd-role-card-admin{border-color:#6d3a89;background:linear-gradient(145deg,#25152e,#17171d)}.hd-role-card-admin i{color:#c084fc}.hd-role-card-agent{border-color:#255c7d;background:linear-gradient(145deg,#132633,#171b20)}.hd-role-card-agent i{color:#38bdf8} + +/* ===== Kanban drag quality ============================================== */ +.hd-kanban-drag-handle{display:inline-flex;align-items:center;justify-content:center;width:30px;height:27px;border:1px solid #3f4854;border-radius:7px;background:#232933;color:#9ba7b5;cursor:grab;user-select:none}.hd-kanban-drag-handle:hover{color:#fff;background:#2d3541}.hd-kanban-drag-handle:active{cursor:grabbing}.hd-is-dragging{opacity:.42!important;transform:scale(.985)!important}.hd-is-saving{opacity:.64;pointer-events:none}.helpdesk-kanban-column[data-drag-over="true"]{outline:2px dashed #3b82f6;outline-offset:-8px;background:rgba(37,99,235,.08)!important} + +@media(max-width:1100px){.hd-metric-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.hd-dashboard-grid{grid-template-columns:1fr}.hd-priority-body{min-height:240px}.hd-top-search{display:none}} +@media(max-width:1023px){:root{--hd-sidebar-width:300px}.hd-topbar{display:flex}.hd-topbar-brand{width:auto;border-right:0}.hd-topbar-main{flex:1;padding-left:8px}.hd-mobile-menu-button{display:inline-flex}.hd-shell-sidebar{transform:translateX(-102%);width:min(84vw,320px);box-shadow:15px 0 40px rgba(0,0,0,.35)}.hd-sidebar-open .hd-shell-sidebar{transform:translateX(0)}.hd-sidebar-resizer{display:none}.hd-shell-main{margin-left:0;padding:calc(var(--hd-topbar-height) + 20px) 18px 25px}.hd-sidebar-backdrop{position:fixed;z-index:55;inset:var(--hd-topbar-height) 0 0 0;background:rgba(0,0,0,.52);border:0}.hd-sidebar-open .hd-sidebar-backdrop{display:block}} +@media(max-width:700px){.hd-account-copy{display:none}.hd-topbar-main{padding-right:10px}.hd-topbar-brand{padding-left:10px;padding-right:8px}.hd-brand-name{font-size:.95rem}.hd-shell-main{padding-left:10px;padding-right:10px}.hd-dashboard-heading{align-items:flex-start;flex-direction:column}.hd-range-select{width:100%}.hd-metric-grid{grid-template-columns:1fr}.hd-dashboard-card{border-radius:10px}.hd-priority-body{grid-template-columns:1fr}.hd-latest-list>a{grid-template-columns:65px minmax(0,1fr);gap:10px}.hd-latest-list time,.hd-latest-list .hd-status-mini{display:none}.hd-page-hero{align-items:flex-start;flex-direction:column;padding:18px}.hd-page-hero .hd-action{width:100%}.hd-toolbar{flex-direction:column}.hd-form-grid,.hd-role-options{grid-template-columns:1fr}.hd-action{min-height:44px}} diff --git a/resources/themes/backend/assets/js/layout.js b/resources/themes/backend/assets/js/layout.js new file mode 100644 index 0000000..99d220b --- /dev/null +++ b/resources/themes/backend/assets/js/layout.js @@ -0,0 +1,56 @@ +const STORAGE_KEY = 'helpdesk.sidebar.width'; +const MIN_WIDTH = 220; +const MAX_WIDTH = 420; + +function initialiseShell() { + const shell = document.querySelector('[data-hd-shell]'); + const sidebar = document.querySelector('[data-hd-sidebar]'); + const resizer = document.querySelector('[data-hd-sidebar-resizer]'); + if (!shell || !sidebar) return; + + const stored = Number(localStorage.getItem(STORAGE_KEY)); + if (Number.isFinite(stored) && stored >= MIN_WIDTH && stored <= MAX_WIDTH) { + document.documentElement.style.setProperty('--hd-sidebar-width', `${stored}px`); + } + + document.querySelectorAll('[data-hd-sidebar-toggle]').forEach((button) => { + if (button.dataset.hdBound === '1') return; + button.dataset.hdBound = '1'; + button.addEventListener('click', () => document.body.classList.toggle('hd-sidebar-open')); + }); + + sidebar.querySelectorAll('a').forEach((link) => { + link.addEventListener('click', () => document.body.classList.remove('hd-sidebar-open')); + }); + + if (!resizer || resizer.dataset.hdBound === '1') return; + resizer.dataset.hdBound = '1'; + + resizer.addEventListener('pointerdown', (event) => { + if (window.matchMedia('(max-width: 1023px)').matches) return; + event.preventDefault(); + resizer.setPointerCapture(event.pointerId); + document.body.classList.add('hd-sidebar-resizing'); + + const move = (moveEvent) => { + const width = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, moveEvent.clientX)); + document.documentElement.style.setProperty('--hd-sidebar-width', `${width}px`); + }; + const finish = (upEvent) => { + const value = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--hd-sidebar-width'), 10); + if (Number.isFinite(value)) localStorage.setItem(STORAGE_KEY, String(value)); + document.body.classList.remove('hd-sidebar-resizing'); + resizer.releasePointerCapture?.(upEvent.pointerId); + resizer.removeEventListener('pointermove', move); + resizer.removeEventListener('pointerup', finish); + resizer.removeEventListener('pointercancel', finish); + }; + resizer.addEventListener('pointermove', move); + resizer.addEventListener('pointerup', finish); + resizer.addEventListener('pointercancel', finish); + }); +} + +document.addEventListener('DOMContentLoaded', initialiseShell); +document.addEventListener('livewire:navigated', initialiseShell); +initialiseShell(); diff --git a/resources/themes/backend/views/dashboard.blade.php b/resources/themes/backend/views/dashboard.blade.php index 691d246..274c1f4 100644 --- a/resources/themes/backend/views/dashboard.blade.php +++ b/resources/themes/backend/views/dashboard.blade.php @@ -1,3 +1,3 @@ - + diff --git a/resources/themes/backend/views/layouts/app.blade.php b/resources/themes/backend/views/layouts/app.blade.php index 037dd1b..f2e85e6 100644 --- a/resources/themes/backend/views/layouts/app.blade.php +++ b/resources/themes/backend/views/layouts/app.blade.php @@ -1,5 +1,3 @@ - - {{ $slot }} - + {{ $slot }} diff --git a/resources/themes/backend/views/layouts/app/sidebar.blade.php b/resources/themes/backend/views/layouts/app/sidebar.blade.php index f92a13d..955f55f 100644 --- a/resources/themes/backend/views/layouts/app/sidebar.blade.php +++ b/resources/themes/backend/views/layouts/app/sidebar.blade.php @@ -1,228 +1,82 @@ - - @include('partials.head') - - - - @php - $helpdeskSettingsRoute = Route::has('helpdesk.settings') - ? 'helpdesk.settings' - : (Route::has('helpdesk.settings.index') - ? 'helpdesk.settings.index' - : null); - @endphp - - - - - - - - - - - - {{ __('Dashboard') }} - - - - - @if (Route::has('helpdesk.tickets.index')) - - {{ __('Tickets') }} - - @endif - - @if (Route::has('helpdesk.kanban')) - - {{ __('Kanban') }} - - @endif - - @if ($helpdeskSettingsRoute) - - {{ __('Settings') }} - - @endif - - - - @if (Route::has('documentation.index') || Route::has('documentation.documents.index')) - - @if (Route::has('documentation.documents.index')) - - {{ __('Documents') }} - - @elseif (Route::has('documentation.index')) - - {{ __('Documents') }} - - @endif - - @if (Route::has('documentation.categories.index')) - - {{ __('Categories') }} - - @endif - - @endif - - - - - - - - - - - - - - - +@include('partials.head') + +@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 +
+
+ +
+ @if(Route::has('helpdesk.tickets.index')) + + @endif +
+ + - -
-
- - -
- - {{ auth()->user()->name }} - - - - {{ auth()->user()->email }} - -
-
-
-
- + @if(Route::has('profile.edit'))Profile@endif + @if(Route::has('helpdesk.settings'))Helpdesk Settings@endif + @if($isAdmin)User Management@endif - - - - @if (Route::has('documentation.documents.index') || Route::has('documentation.index')) - - {{ __('Documentation') }} - - @endif - @if ($helpdeskSettingsRoute) - - {{ __('Helpdesk Settings') }} - - @endif - - @if (Route::has('profile.edit')) - - {{ __('Profile') }} - - @endif - - - - -
- @csrf - - - {{ __('Log out') }} - -
+
@csrfLog out
- +
+
- {{ $slot }} + + + +
{{ $slot }}
+
+ +@persist('toast')@endpersist +@fluxScripts + diff --git a/resources/themes/backend/views/modules/helpdesk/livewire/dashboard-widget.blade.php b/resources/themes/backend/views/modules/helpdesk/livewire/dashboard-widget.blade.php index 3e13ab5..9bb02fc 100644 --- a/resources/themes/backend/views/modules/helpdesk/livewire/dashboard-widget.blade.php +++ b/resources/themes/backend/views/modules/helpdesk/livewire/dashboard-widget.blade.php @@ -1,196 +1,52 @@ -
- Total tickets Open tickets Closed tickets -
-
-
-
-
Total tickets
-
{{ $totalCount }}
-
-
- -
-
-
-
-
-
+@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 +
+
+

Dashboard

Overview of your helpdesk system

+ +
-
-
-
-
Open tickets
-
{{ $openCount }}
-
-
- -
-
-
-
-
-
- -
-
-
-
Closed tickets
-
{{ $closedCount }}
-
-
- -
-
-
-
-
-
- -
-
-
-
High priority
-
{{ $criticalCount }}
-
-
- -
-
-
Open urgent/high/critical workload.
-
+
+
Total Tickets{{ $totalCount }}All visible tickets
+
Resolved{{ $closedCount }}Completed workload
+
In Progress{{ $inProgressCount }}Actively being worked
+
Open{{ $openCount }}Awaiting resolution
-
-
-
-
- - - -
-

Recent tickets

-

Latest support activity.

-
-
- View all tickets -
- {{-- Recent Tickets --}} - - -
-
-
-
-

SLA overview

-

Closure rate and open workload.

-
- {{ $closedPercent }}% closed -
- -
-
-
-
-
{{ $closedPercent }}%
-
closed
-
-
-
-
-
Closed{{ $closedPercent }}%
-
-
-
-
Open{{ $openPercent }}%
-
-
-
-
-
- -
- -
- - - - -
-

Recent commits

-

Latest linked Git activity

-
-
- - - - -
- - - -
- @forelse ($recentCommits as $commit) -
-
- {{ $commit->short_hash }} · {{ $commit->repository?->name }} -
-
- {{ $commit->message }} -
-
- @empty -
- No commits tracked yet. -
- @endforelse -
-
-
+
+

Top Requesters

+
@forelse($topRequesters as $row)
{{ $row['user']->initials() }}{{ $row['user']->name }}{{ $row['count'] }} tickets
@empty
No requester data yet.
@endforelse
+
diff --git a/resources/themes/backend/views/modules/helpdesk/livewire/kanban-board.blade.php b/resources/themes/backend/views/modules/helpdesk/livewire/kanban-board.blade.php index ad7bf41..b10b0c3 100644 --- a/resources/themes/backend/views/modules/helpdesk/livewire/kanban-board.blade.php +++ b/resources/themes/backend/views/modules/helpdesk/livewire/kanban-board.blade.php @@ -107,10 +107,15 @@ wire:key="kanban-dropzone-{{ $board?->id ?? 0 }}-{{ $status->id }}-{{ $refreshNonce }}"> @forelse ($tickets as $ticket)
+
+ + + +
- 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(); + })(); @endscript
diff --git a/resources/themes/backend/views/modules/user-management/create.blade.php b/resources/themes/backend/views/modules/user-management/create.blade.php new file mode 100644 index 0000000..eb52200 --- /dev/null +++ b/resources/themes/backend/views/modules/user-management/create.blade.php @@ -0,0 +1,20 @@ + +
+
User administration

Create user

Create a Helpdesk account and assign its access level.

Back
+@if($errors->any())
    @foreach($errors->all() as $error)
  • {{ $error }}
  • @endforeach
@endif +
@csrf + +
+ + + + +
+
+ + +
+ +
+
+
diff --git a/resources/themes/backend/views/modules/user-management/edit.blade.php b/resources/themes/backend/views/modules/user-management/edit.blade.php new file mode 100644 index 0000000..2108403 --- /dev/null +++ b/resources/themes/backend/views/modules/user-management/edit.blade.php @@ -0,0 +1,22 @@ + +
+
User administration

Edit {{ $user->name }}

Update identity and Helpdesk access.

Back
+@if(session('status'))
{{ session('status') }}
@endif +@if($errors->any())
    @foreach($errors->all() as $error)
  • {{ $error }}
  • @endforeach
@endif +
@csrf @method('PUT') + +
+ + + + +
+
+ + +
+ +
+@if(auth()->id() !== $user->id)
@csrf @method('DELETE')
@endif +
+
diff --git a/resources/themes/backend/views/modules/user-management/index.blade.php b/resources/themes/backend/views/modules/user-management/index.blade.php new file mode 100644 index 0000000..905224c --- /dev/null +++ b/resources/themes/backend/views/modules/user-management/index.blade.php @@ -0,0 +1,61 @@ + +
+
+
+
Administration
+

Users

+

Manage Helpdesk users, administrators and agents.

+
+ + New user + +
+ + @if (session('status')) +
{{ session('status') }}
+ @endif + +
+
+
+ + +
+ +
+ +
+ + + + @forelse($users as $user) + + + + + + + + @empty + + @endforelse + +
UserEmailRoleVerifiedActions
+
+ {{ $user->initials() }} + {{ $user->name }} +
+
{{ $user->email }} + @if($user->helpdesk_admin) + Administrator + @elseif($user->helpdesk_agent) + Agent + @else + User + @endif + {{ $user->email_verified_at ? 'Yes' : 'No' }}Edit
No users found.
+
+
{{ $users->links() }}
+
+
+
diff --git a/resources/themes/backend/views/modules/user-management/roles.blade.php b/resources/themes/backend/views/modules/user-management/roles.blade.php new file mode 100644 index 0000000..1108b3c --- /dev/null +++ b/resources/themes/backend/views/modules/user-management/roles.blade.php @@ -0,0 +1,10 @@ + +
+
Administration

Roles & permissions

Helpdesk uses three clear access levels.

+
+

Administrator

{{ $counts['admins'] }}

Full tickets, settings, documentation and user management access.

+

Agent

{{ $counts['agents'] }}

Can work tickets, Kanban workflow and support operations.

+

User

{{ $counts['users'] }}

Standard authenticated requester access.

+
+
+
diff --git a/resources/themes/backend/views/partials/head.blade.php b/resources/themes/backend/views/partials/head.blade.php index d23024a..e47f942 100644 --- a/resources/themes/backend/views/partials/head.blade.php +++ b/resources/themes/backend/views/partials/head.blade.php @@ -1,25 +1,11 @@ - - - {{ filled($title ?? null) ? $title.' - '.config('app.name', 'Laravel') : config('app.name', 'Laravel') }} - - - - - - +{{ filled($title ?? null) ? $title.' - '.config('app.name', 'Laravel') : config('app.name', 'Laravel') }} + @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 diff --git a/vite.config.js b/vite.config.js index f68b60b..7c58d61 100644 --- a/vite.config.js +++ b/vite.config.js @@ -6,88 +6,34 @@ import { existsSync, readdirSync, statSync } from 'node:fs'; import { join, relative } from 'node:path'; const projectRoot = process.cwd(); - function discoverModuleInputs() { const modulesPath = join(projectRoot, 'Modules'); - - if (!existsSync(modulesPath)) { - return []; - } - + if (!existsSync(modulesPath)) return []; const inputs = []; - for (const moduleName of readdirSync(modulesPath)) { const modulePath = join(modulesPath, moduleName); - - if (!statSync(modulePath).isDirectory()) { - continue; - } - - // Canonical Laravel Modules asset paths only. - for (const entry of [ - 'Resources/assets/css/app.css', - 'Resources/assets/js/app.js', - ]) { + if (!statSync(modulePath).isDirectory()) continue; + for (const entry of ['Resources/assets/css/app.css','Resources/assets/js/app.js']) { const candidate = join(modulePath, entry); - - if (existsSync(candidate)) { - inputs.push(relative(projectRoot, candidate)); - } + if (existsSync(candidate)) inputs.push(relative(projectRoot, candidate)); } } - return inputs; } - const moduleInputs = discoverModuleInputs(); - -console.log('[Vite] Backend theme inputs:'); -console.log(' resources/themes/backend/assets/css/app.css'); -console.log(' resources/themes/backend/assets/js/passkeys.js'); - -if (moduleInputs.length > 0) { - console.log('[Vite] Module inputs:'); - - for (const input of moduleInputs) { - console.log(` ${input}`); - } -} - export default defineConfig({ - plugins: [ + plugins:[ laravel({ - input: [ + input:[ 'resources/themes/backend/assets/css/app.css', 'resources/themes/backend/assets/js/passkeys.js', + 'resources/themes/backend/assets/js/layout.js', ...moduleInputs, ], - - refresh: [ - 'resources/themes/backend/views/**/*.blade.php', - 'resources/themes/backend/assets/**/*', - 'Modules/*/Resources/views/**/*.blade.php', - 'Modules/*/Resources/assets/**/*', - 'Modules/**/*.php', - ], - - fonts: [ - bunny('Instrument Sans', { - weights: [400, 500, 600], - optimizedFallbacks: false, - }), - ], + refresh:['resources/themes/backend/views/**/*.blade.php','resources/themes/backend/assets/**/*','Modules/*/Resources/views/**/*.blade.php','Modules/*/Resources/assets/**/*','Modules/**/*.php'], + fonts:[bunny('Instrument Sans',{weights:[400,500,600],optimizedFallbacks:false})], }), - tailwindcss(), ], - - server: { - cors: true, - watch: { - ignored: [ - '**/storage/framework/views/**', - '**/Modules/*/node_modules/**', - ], - }, - }, + server:{cors:true,watch:{ignored:['**/storage/framework/views/**','**/Modules/*/node_modules/**']}}, });