users menu listing
This commit is contained in:
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\UserManagement\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class UserController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$search = trim((string) $request->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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\UserManagement\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class EnsureUserManagementAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\UserManagement\Providers;
|
||||
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\UserManagement\Http\Middleware\EnsureUserManagementAdmin;
|
||||
|
||||
final class RouteServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
Route::middleware(['web', 'auth', EnsureUserManagementAdmin::class])
|
||||
->prefix('admin/users')
|
||||
->name('user-management.')
|
||||
->group(module_path('UserManagement', 'Routes/web.php'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\UserManagement\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
final class UserManagementServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->register(RouteServiceProvider::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\UserManagement\Http\Controllers\UserController;
|
||||
|
||||
Route::get('/', [UserController::class, 'index'])->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');
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "modules/user-management",
|
||||
"description": "Helpdesk user management module",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Modules\\UserManagement\\": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+24
-38
@@ -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<UserFactory> */
|
||||
use HasFactory, HasHelpdeskTickets, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
|
||||
use HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"Stylist":true,
|
||||
"Stylist": true,
|
||||
"Helpdesk": true,
|
||||
"Documentation" : true
|
||||
"Documentation": true,
|
||||
"UserManagement": true
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
|
||||
+44
-188
@@ -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
|
||||
|
||||
+10
-64
@@ -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/**']}},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user