From 3c7361023c6ca9d946050f309af63cfb551a9973 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 25 Aug 2026 14:41:15 +0100 Subject: [PATCH] updated design --- .../Providers/HelpdeskServiceProvider.php | 181 ++++ Modules/Helpdesk/Config/config.php | 2 +- .../Http/Controllers/HelpdeskController.php | 6 +- .../TicketCommentDeleteController.php | 2 +- .../TicketCommentStoreController.php | 2 +- .../Livewire/HelpdeskDashboardWidget.php | 4 +- Modules/Helpdesk/Livewire/TicketShow.php | 2 +- Modules/Helpdesk/Models/Ticket.php | 4 +- Modules/Helpdesk/Policies/TicketPolicy.php | 14 +- .../Providers/HelpdeskServiceProvider.php | 32 +- .../Providers/StylistServiceProvider.php | 51 +- Stylist/Providers/StylistServiceProvider.php | 457 ++++++++ app/Models/User.php | 24 +- resources/themes/backend/assets/css/app.css | 3 + .../themes/backend/assets/css/helpdesk.css | 974 ++++++++++++++++++ .../themes/backend/views/dashboard.blade.php | 19 +- .../views/modules/helpdesk/index.blade.php | 3 + .../livewire/dashboard-widget.blade.php | 124 +++ 18 files changed, 1828 insertions(+), 76 deletions(-) create mode 100644 Helpdesk/Providers/HelpdeskServiceProvider.php create mode 100644 Stylist/Providers/StylistServiceProvider.php create mode 100644 resources/themes/backend/assets/css/helpdesk.css create mode 100644 resources/themes/backend/views/modules/helpdesk/index.blade.php create mode 100644 resources/themes/backend/views/modules/helpdesk/livewire/dashboard-widget.blade.php diff --git a/Helpdesk/Providers/HelpdeskServiceProvider.php b/Helpdesk/Providers/HelpdeskServiceProvider.php new file mode 100644 index 0000000..c186c70 --- /dev/null +++ b/Helpdesk/Providers/HelpdeskServiceProvider.php @@ -0,0 +1,181 @@ +app->register(RouteServiceProvider::class); + + $this->mergeConfigFrom( + module_path(ModuleInfo::name(), 'Config/config.php'), + ModuleInfo::nameLower(), + ); + } + + /** + * Bootstrap module services. + */ + public function boot(): void + { + $this->registerTranslations(); + $this->registerConfig(); + $this->registerViews(); + $this->registerHelpers(); + $this->registerCommands(); + $this->registerLivewireComponents(); + $this->registerPolicies(); + + $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations')); + } + + /** + * Register Livewire components used by the module. + */ + protected function registerLivewireComponents(): void + { + Livewire::component('helpdesk.ticket-index', TicketIndex::class); + Livewire::component('helpdesk.ticket-create', TicketCreate::class); + Livewire::component('helpdesk.ticket-show', TicketShow::class); + Livewire::component('helpdesk.ticket-timer', TicketTimer::class); + Livewire::component('helpdesk.dashboard-widget', HelpdeskDashboardWidget::class); + Livewire::component('helpdesk.kanban-board', KanbanBoard::class); + Livewire::component('helpdesk.settings-manager', SettingsManager::class); + } + + /** + * Register policies and gates. + */ + protected function registerPolicies(): void + { + Gate::policy(Ticket::class, TicketPolicy::class); + + Gate::define('helpdesk-admin', static fn ($user): bool => method_exists($user, 'isAdmin') && $user->isAdmin()); + Gate::define('helpdesk-agent', static fn ($user): bool => (method_exists($user, 'isAdmin') && $user->isAdmin()) || (bool) ($user->helpdesk_agent ?? false)); + } + + /** + * Register helpers. + */ + protected function registerHelpers(): void + { + foreach (glob(module_path(ModuleInfo::name(), 'Helpers/*.php')) ?: [] as $filename) { + require_once $filename; + } + } + + /** + * Dynamically register console commands. + */ + protected function registerCommands(): void + { + if (! $this->app->runningInConsole()) { + return; + } + + $commandPath = module_path(ModuleInfo::name(), 'Console'); + + if (! is_dir($commandPath)) { + return; + } + + $commands = collect(File::allFiles($commandPath)) + ->map(fn ($file): string => 'Modules\\'.$this->moduleName.'\\Console\\'.$file->getFilenameWithoutExtension()) + ->filter(fn (string $class): bool => class_exists($class)) + ->values() + ->all(); + + $this->commands($commands); + } + + /** + * Register config publishing. + */ + protected function registerConfig(): void + { + $this->publishes([ + module_path(ModuleInfo::name(), 'Config/config.php') => config_path(ModuleInfo::nameLower().'.php'), + ], 'config'); + } + + /** + * Register module views. + */ + protected function registerViews(): void + { + $sourcePath = module_path(ModuleInfo::name(), 'Resources/views'); + $themeRoot = rtrim((string) config('stylist.themes_path', resource_path('themes')), DIRECTORY_SEPARATOR); + $backendTheme = (string) config('stylist.default_backend_theme', 'backend'); + $viewPath = $themeRoot.DIRECTORY_SEPARATOR.$backendTheme + .DIRECTORY_SEPARATOR.'views'.DIRECTORY_SEPARATOR.'modules'.DIRECTORY_SEPARATOR.ModuleInfo::nameLower(); + + // Module source views remain module-owned. Any application override is + // published into the selected backend theme, never resources/views. + $this->publishes([ + $sourcePath => $viewPath, + ], ['views', ModuleInfo::nameLower().'-module-views']); + + $this->loadViewsFrom($sourcePath, ModuleInfo::nameLower()); + + Blade::componentNamespace('Modules\Helpdesk\View\Components', 'helpdesk'); + Blade::anonymousComponentPath($sourcePath.'/components', 'helpdesk'); + } + + /** + * Register translations. + */ + protected function registerTranslations(): void + { + $langPath = resource_path('lang/modules/'.ModuleInfo::nameLower()); + + if (is_dir($langPath)) { + $this->loadTranslationsFrom($langPath, ModuleInfo::nameLower()); + + return; + } + + $this->loadTranslationsFrom(module_path(ModuleInfo::name(), 'Resources/lang'), ModuleInfo::nameLower()); + } + + /** + * Get provided services. + * + * @return array + */ + public function provides(): array + { + return []; + } +} diff --git a/Modules/Helpdesk/Config/config.php b/Modules/Helpdesk/Config/config.php index ef6525a..99ea6ba 100644 --- a/Modules/Helpdesk/Config/config.php +++ b/Modules/Helpdesk/Config/config.php @@ -4,7 +4,7 @@ declare(strict_types=1); return [ 'route_prefix' => env('HELPDESK_ROUTE_PREFIX', 'admin/helpdesk'), - 'middleware' => ['web', 'auth', 'thredary.admin'], + 'middleware' => ['web', 'auth'], 'disk' => env('HELPDESK_ATTACHMENTS_DISK', 'local'), 'max_upload_kb' => (int) env('HELPDESK_MAX_UPLOAD_KB', 10240), 'allowed_extensions' => [ diff --git a/Modules/Helpdesk/Http/Controllers/HelpdeskController.php b/Modules/Helpdesk/Http/Controllers/HelpdeskController.php index 8cbefea..b8420a8 100644 --- a/Modules/Helpdesk/Http/Controllers/HelpdeskController.php +++ b/Modules/Helpdesk/Http/Controllers/HelpdeskController.php @@ -6,7 +6,6 @@ namespace Modules\Helpdesk\Http\Controllers; use App\Http\Controllers\Controller; use Illuminate\Contracts\View\View; -use Illuminate\Support\HtmlString; final class HelpdeskController extends Controller { @@ -15,9 +14,6 @@ final class HelpdeskController extends Controller */ public function __invoke(): View { - return view('layouts.app', [ - 'title' => __('Helpdesk'), - 'slot' => new HtmlString(view('helpdesk::index')->render()), - ]); + return view('modules.helpdesk.index'); } } diff --git a/Modules/Helpdesk/Http/Controllers/TicketCommentDeleteController.php b/Modules/Helpdesk/Http/Controllers/TicketCommentDeleteController.php index 60a88ef..1fdc315 100644 --- a/Modules/Helpdesk/Http/Controllers/TicketCommentDeleteController.php +++ b/Modules/Helpdesk/Http/Controllers/TicketCommentDeleteController.php @@ -21,7 +21,7 @@ final class TicketCommentDeleteController extends Controller { $user = $request->user(); - abort_unless($user !== null && method_exists($user, 'isAdmin') && $user->isAdmin(), 403); + abort_unless($user !== null && $user->isHelpdeskAdmin(), 403); abort_unless((int) $comment->ticket_id === (int) $ticket->id, 404); DB::transaction(function () use ($comment, $ticket, $user): void { diff --git a/Modules/Helpdesk/Http/Controllers/TicketCommentStoreController.php b/Modules/Helpdesk/Http/Controllers/TicketCommentStoreController.php index ba8f461..23215ee 100644 --- a/Modules/Helpdesk/Http/Controllers/TicketCommentStoreController.php +++ b/Modules/Helpdesk/Http/Controllers/TicketCommentStoreController.php @@ -156,7 +156,7 @@ final class TicketCommentStoreController extends Controller return false; } - return (method_exists($user, 'isAdmin') && $user->isAdmin()) || (bool) ($user->helpdesk_agent ?? false); + return $user->isHelpdeskAgent(); } /** diff --git a/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php b/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php index f28fd4e..3be7987 100644 --- a/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php +++ b/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php @@ -17,9 +17,9 @@ final class HelpdeskDashboardWidget extends Component $user = Auth::user(); $base = Ticket::query(); - if ($user->helpdesk_admin ?? false) { + if ($user->isHelpdeskAdmin()) { // no filter - } elseif ($user->helpdesk_agent ?? false) { + } elseif ($user->isHelpdeskAgent()) { $base->where(function ($q) use ($user) { $q->where('agent_id', $user->id) ->orWhere('user_id', $user->id); diff --git a/Modules/Helpdesk/Livewire/TicketShow.php b/Modules/Helpdesk/Livewire/TicketShow.php index c2df095..cb81907 100644 --- a/Modules/Helpdesk/Livewire/TicketShow.php +++ b/Modules/Helpdesk/Livewire/TicketShow.php @@ -691,7 +691,7 @@ final class TicketShow extends Component { $user = Auth::user(); - return $user !== null && (method_exists($user, 'isAdmin') ? $user->isAdmin() : (bool) ($user->helpdesk_admin ?? false)); + return $user !== null && $user->isHelpdeskAdmin(); } private function buildCommentTree(Collection $grouped, ?int $parentId = null): Collection diff --git a/Modules/Helpdesk/Models/Ticket.php b/Modules/Helpdesk/Models/Ticket.php index a19d8bc..acfd504 100644 --- a/Modules/Helpdesk/Models/Ticket.php +++ b/Modules/Helpdesk/Models/Ticket.php @@ -194,11 +194,11 @@ final class Ticket extends Model /** @param Builder $query @return Builder */ public function scopeVisibleTo(Builder $query, User $user): Builder { - if ($user->isAdmin() || (bool) ($user->helpdesk_admin ?? false)) { + if ($user->isHelpdeskAdmin()) { return $query; } - if ((bool) ($user->helpdesk_agent ?? false)) { + if ($user->isHelpdeskAgent()) { return $query->where(static function (Builder $subQuery) use ($user): void { $subQuery->where('agent_id', $user->id)->orWhere('user_id', $user->id); }); diff --git a/Modules/Helpdesk/Policies/TicketPolicy.php b/Modules/Helpdesk/Policies/TicketPolicy.php index faa0660..1f47afe 100644 --- a/Modules/Helpdesk/Policies/TicketPolicy.php +++ b/Modules/Helpdesk/Policies/TicketPolicy.php @@ -17,7 +17,7 @@ final class TicketPolicy public function view(User $user, Ticket $ticket): bool { return $this->isPrivileged($user) - || ((bool) ($user->helpdesk_agent ?? false) && ((int) $ticket->agent_id === (int) $user->id || (int) $ticket->user_id === (int) $user->id)) + || ($user->isHelpdeskAgent() && ((int) $ticket->agent_id === (int) $user->id || (int) $ticket->user_id === (int) $user->id)) || (int) $ticket->user_id === (int) $user->id; } @@ -29,7 +29,7 @@ final class TicketPolicy public function update(User $user, Ticket $ticket): bool { return $this->isPrivileged($user) - || ((bool) ($user->helpdesk_agent ?? false) && (int) $ticket->agent_id === (int) $user->id); + || ($user->isHelpdeskAgent() && (int) $ticket->agent_id === (int) $user->id); } public function comment(User $user, Ticket $ticket): bool @@ -44,14 +44,6 @@ final class TicketPolicy private function isPrivileged(User $user): bool { - if ($user->isAdmin() || (bool) ($user->helpdesk_admin ?? false)) { - return true; - } - - if (method_exists($user, 'isAdmin') && $user->isAdmin()) { - return true; - } - - return false; + return $user->isHelpdeskAdmin(); } } diff --git a/Modules/Helpdesk/Providers/HelpdeskServiceProvider.php b/Modules/Helpdesk/Providers/HelpdeskServiceProvider.php index c186c70..db4d37e 100644 --- a/Modules/Helpdesk/Providers/HelpdeskServiceProvider.php +++ b/Modules/Helpdesk/Providers/HelpdeskServiceProvider.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Modules\Helpdesk\Providers; use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; @@ -82,8 +83,8 @@ final class HelpdeskServiceProvider extends ServiceProvider { Gate::policy(Ticket::class, TicketPolicy::class); - Gate::define('helpdesk-admin', static fn ($user): bool => method_exists($user, 'isAdmin') && $user->isAdmin()); - Gate::define('helpdesk-agent', static fn ($user): bool => (method_exists($user, 'isAdmin') && $user->isAdmin()) || (bool) ($user->helpdesk_agent ?? false)); + Gate::define('helpdesk-admin', static fn ($user): bool => $user->isHelpdeskAdmin()); + Gate::define('helpdesk-agent', static fn ($user): bool => $user->isHelpdeskAgent()); } /** @@ -135,19 +136,14 @@ final class HelpdeskServiceProvider extends ServiceProvider */ protected function registerViews(): void { + $viewPath = resource_path('views/modules/'.ModuleInfo::nameLower()); $sourcePath = module_path(ModuleInfo::name(), 'Resources/views'); - $themeRoot = rtrim((string) config('stylist.themes_path', resource_path('themes')), DIRECTORY_SEPARATOR); - $backendTheme = (string) config('stylist.default_backend_theme', 'backend'); - $viewPath = $themeRoot.DIRECTORY_SEPARATOR.$backendTheme - .DIRECTORY_SEPARATOR.'views'.DIRECTORY_SEPARATOR.'modules'.DIRECTORY_SEPARATOR.ModuleInfo::nameLower(); - // Module source views remain module-owned. Any application override is - // published into the selected backend theme, never resources/views. $this->publishes([ $sourcePath => $viewPath, ], ['views', ModuleInfo::nameLower().'-module-views']); - $this->loadViewsFrom($sourcePath, ModuleInfo::nameLower()); + $this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), ModuleInfo::nameLower()); Blade::componentNamespace('Modules\Helpdesk\View\Components', 'helpdesk'); Blade::anonymousComponentPath($sourcePath.'/components', 'helpdesk'); @@ -169,6 +165,24 @@ final class HelpdeskServiceProvider extends ServiceProvider $this->loadTranslationsFrom(module_path(ModuleInfo::name(), 'Resources/lang'), ModuleInfo::nameLower()); } + /** + * Get publishable view override paths. + * + * @return array + */ + private function getPublishableViewPaths(): array + { + $paths = []; + + foreach (Config::get('view.paths') as $path) { + if (is_dir($path.'/modules/'.ModuleInfo::nameLower())) { + $paths[] = $path.'/modules/'.ModuleInfo::nameLower(); + } + } + + return $paths; + } + /** * Get provided services. * diff --git a/Modules/Stylist/Providers/StylistServiceProvider.php b/Modules/Stylist/Providers/StylistServiceProvider.php index 85a0d85..322ebdb 100644 --- a/Modules/Stylist/Providers/StylistServiceProvider.php +++ b/Modules/Stylist/Providers/StylistServiceProvider.php @@ -70,6 +70,7 @@ final class StylistServiceProvider extends ServiceProvider // replace Laravel's base view locations during their own boot phase; the booted // callback makes Stylist the final authority for ordinary view('...') resolution. $this->registerSelectedThemeOverrides(); + $this->registerSelectedThemeFluxOverrides(); $this->app->booted(function (): void { $this->registerSelectedThemeOverrides(); @@ -247,10 +248,12 @@ final class StylistServiceProvider extends ServiceProvider /** * Make Flux application overrides theme-owned. * - * Flux normally discovers custom components from resources/views/flux. This - * application deliberately has no resources/views tree, so expose the selected - * theme's views/flux directory through Flux's existing view namespace instead. - * Package-provided Flux views remain available as the final fallback. + * Flux registers anonymous component paths using the prefix "flux". Laravel + * converts that prefix into a hashed view namespace (xxh128 on Laravel 12+). + * Registering only a normal "flux" view namespace therefore does not satisfy + * Flux::componentExists(). Register each themed views/flux directory through + * Blade exactly as Flux itself does, then prepend it to the hashed namespace so + * the selected theme wins over package stubs while retaining package fallbacks. */ private function registerSelectedThemeFluxOverrides(): void { @@ -281,7 +284,7 @@ final class StylistServiceProvider extends ServiceProvider $fluxPath = $theme->path().DIRECTORY_SEPARATOR.'views'.DIRECTORY_SEPARATOR.'flux'; - if (is_dir($fluxPath)) { + if (is_dir($fluxPath) && ! in_array($fluxPath, $themeFluxPaths, true)) { $themeFluxPaths[] = $fluxPath; } } @@ -290,27 +293,25 @@ final class StylistServiceProvider extends ServiceProvider return; } + // This mirrors FluxServiceProvider::bootComponentPath(). It is essential: + // Flux::componentExists() checks the hashed namespace created by + // Blade::anonymousComponentPath(..., 'flux'), not a literal flux:: namespace. + foreach ($themeFluxPaths as $fluxPath) { + Blade::anonymousComponentPath($fluxPath, 'flux'); + } + + $fluxNamespace = version_compare($this->app->version(), '12.0.0', '>=') + ? hash('xxh128', 'flux') + : md5('flux'); + + // anonymousComponentPath() adds locations to the namespace. Prepend the + // selected theme paths in reverse order so the first selected theme remains + // authoritative while Flux package views continue to work as fallbacks. + foreach (array_reverse($themeFluxPaths) as $fluxPath) { + View::prependNamespace($fluxNamespace, $fluxPath); + } + $finder = View::getFinder(); - - if (! method_exists($finder, 'getHints')) { - return; - } - - $hints = $finder->getHints(); - $existingFluxPaths = array_values(array_filter( - (array) ($hints['flux'] ?? []), - static fn (string $path): bool => is_dir($path), - )); - $paths = array_values(array_unique(array_merge($themeFluxPaths, $existingFluxPaths))); - - if (method_exists($finder, 'replaceNamespace')) { - $finder->replaceNamespace('flux', $paths); - } else { - foreach (array_reverse($themeFluxPaths) as $fluxPath) { - View::prependNamespace('flux', $fluxPath); - } - } - if (method_exists($finder, 'flush')) { $finder->flush(); } diff --git a/Stylist/Providers/StylistServiceProvider.php b/Stylist/Providers/StylistServiceProvider.php new file mode 100644 index 0000000..322ebdb --- /dev/null +++ b/Stylist/Providers/StylistServiceProvider.php @@ -0,0 +1,457 @@ +app->register(RouteServiceProvider::class); + + $this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower()); + + $this->app->singleton('stylist', fn ($app) => $app->make(Stylist::class)); + $this->app->singleton('stylist.html', fn ($app) => $app->make(ThemeHtmlBuilder::class)); + $this->app->singleton('stylist.seo', fn ($app) => $app->make(ThemeSeoBuilder::class)); + } + + public function boot(): void + { + $this->registerViews(); + $this->registerThemeViews(); + $this->registerTranslations(); + $this->registerPublishing(); + $this->registerHelpers(); + $this->registerBlade(); + + if ($this->app->runningInConsole()) { + $this->commands([ + PublishAssetsCommand::class, + ValidateThemeCommand::class, + InspectThemesCommand::class, + FindDuplicateViewsCommand::class, + ThemeDoctorCommand::class, + CreateThemeCommand::class, + ]); + } + + $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations')); + + View::composer('*', function ($view): void { + if (! $this->app->bound(Stylist::class)) { + return; + } + + $view->with('activeTheme', $this->app->make(Stylist::class)->current()); + }); + + // Register both selected presentation trees immediately, then register them again + // after every service provider has finished booting. Some package/module providers + // replace Laravel's base view locations during their own boot phase; the booted + // callback makes Stylist the final authority for ordinary view('...') resolution. + $this->registerSelectedThemeOverrides(); + $this->registerSelectedThemeFluxOverrides(); + + $this->app->booted(function (): void { + $this->registerSelectedThemeOverrides(); + $this->registerSelectedThemeFluxOverrides(); + }); + + } + + private function registerViews(): void + { + $sourcePath = module_path(ModuleInfo::name(), 'Resources/views'); + + // Module-owned package views stay inside the module. Application overrides + // belong to resources/themes/{theme}/views/modules/stylist, which Stylist + // registers as the authoritative themed namespace. Never publish back into + // resources/views; the standard baseline intentionally has no legacy view tree. + $this->loadViewsFrom($sourcePath, ModuleInfo::nameLower()); + } + + private function registerThemeViews(): void + { + foreach ($this->themeRoots() as $root) { + if (! is_dir($root)) { + logger()->warning('Stylist theme root does not exist', [ + 'root' => $root, + ]); + + continue; + } + + $themeDirectories = glob($root.'/*', GLOB_ONLYDIR) ?: []; + + foreach ($themeDirectories as $themeDirectory) { + $themeViewsPath = $themeDirectory.'/views'; + + if (! is_dir($themeViewsPath)) { + continue; + } + + $themeKey = strtolower(basename($themeDirectory)); + + // Themes always live outside the module, normally in resources/themes. + // Register the canonical namespace and the legacy namespace as an alias + // to the same application-owned directory. The alias keeps existing + // routes and Blade templates working without reintroducing module themes. + $namespaces = [ + "stylist-theme-{$themeKey}", + "stylist-module-theme-{$themeKey}", + ]; + + $componentsPath = $themeViewsPath.'/components'; + + // The common theme is the application-wide owner of layouts, + // authentication, settings and shared components. Livewire 4 + // resolves its default full-page layout through layouts::app, + // so point that compatibility namespace at the common theme. + if ($themeKey === 'common') { + $layoutsPath = $componentsPath.'/layouts'; + + if (is_dir($layoutsPath)) { + View::addNamespace('layouts', $layoutsPath); + Blade::anonymousComponentPath($layoutsPath, 'layouts'); + } + + View::addNamespace('common', $themeViewsPath); + + if (is_dir($componentsPath)) { + Blade::anonymousComponentPath($componentsPath, 'common'); + } + } + + foreach ($namespaces as $namespace) { + View::addNamespace($namespace, $themeViewsPath); + + if (is_dir($componentsPath)) { + Blade::anonymousComponentPath($componentsPath, $namespace); + } + } + } + } + } + + private function registerSelectedThemeOverrides(): void + { + $stylist = $this->app->make(Stylist::class); + + $current = $stylist->current(); + $frontend = $stylist->frontend(); + $backend = $stylist->backend(); + + // The active request context must win ordinary view resolution. Keep the + // opposite presentation tree available as a compatibility fallback, but + // never let frontend views shadow backend views on backend requests (or + // vice versa) simply because of provider registration order. + $orderedThemes = [$current, $frontend, $backend]; + $chains = []; + $seenThemes = []; + + foreach ($orderedThemes as $theme) { + if (isset($seenThemes[$theme->key()])) { + continue; + } + + $seenThemes[$theme->key()] = true; + $chains[] = $this->themeChain($theme); + } + + $keys = []; + foreach ($chains as $chain) { + foreach ($chain as $key) { + if (! in_array($key, $keys, true)) { + $keys[] = $key; + } + } + } + + if (! in_array('common', $keys, true)) { + $keys[] = 'common'; + } + + // Laravel's starter kits resolve authenticated layouts through the + // x-layouts:: namespace. Once those views live inside the backend + // theme, Stylist must make that selected theme authoritative for the + // namespace as well as for ordinary view('...') lookups. + $this->registerLayoutCompatibilityNamespace($stylist->backend()); + + $finder = View::getFinder(); + $themePaths = []; + + foreach ($keys as $key) { + try { + $theme = $this->app->make(Loader::class)->find($key); + } catch (\Throwable) { + continue; + } + + $viewsPath = $theme->path().DIRECTORY_SEPARATOR.'views'; + if (is_dir($viewsPath)) { + $themePaths[] = $viewsPath; + } + + $this->registerThemeModuleNamespaces($key); + } + + // FileViewFinder is the source of truth for ordinary view('...') resolution. + // Set the complete ordered path list explicitly instead of relying on provider + // boot order and incremental prependLocation() calls. Theme paths stay first; + // Laravel's normal view locations remain as the final fallback. + if (method_exists($finder, 'getPaths') && method_exists($finder, 'setPaths')) { + // The application intentionally has no resources/views tree. Laravel's + // default FileViewFinder still starts with that location, and view:cache + // asks Symfony Finder to scan every registered path. Remove missing legacy + // locations so theme-only applications can be optimized on a clean release. + $existingPaths = array_values(array_filter( + $finder->getPaths(), + static fn (string $path): bool => is_dir($path), + )); + $paths = array_values(array_unique(array_merge($themePaths, $existingPaths))); + $finder->setPaths($paths); + + if (method_exists($finder, 'flush')) { + $finder->flush(); + } + } else { + foreach (array_reverse($themePaths) as $viewsPath) { + if (method_exists($finder, 'prependLocation')) { + $finder->prependLocation($viewsPath); + } else { + View::addLocation($viewsPath); + } + } + } + } + + /** + * Make Flux application overrides theme-owned. + * + * Flux registers anonymous component paths using the prefix "flux". Laravel + * converts that prefix into a hashed view namespace (xxh128 on Laravel 12+). + * Registering only a normal "flux" view namespace therefore does not satisfy + * Flux::componentExists(). Register each themed views/flux directory through + * Blade exactly as Flux itself does, then prepend it to the hashed namespace so + * the selected theme wins over package stubs while retaining package fallbacks. + */ + private function registerSelectedThemeFluxOverrides(): void + { + $stylist = $this->app->make(Stylist::class); + $orderedThemes = [$stylist->current(), $stylist->frontend(), $stylist->backend()]; + $keys = []; + + foreach ($orderedThemes as $theme) { + foreach ($this->themeChain($theme) as $key) { + if (! in_array($key, $keys, true)) { + $keys[] = $key; + } + } + } + + if (! in_array('common', $keys, true)) { + $keys[] = 'common'; + } + + $themeFluxPaths = []; + + foreach ($keys as $key) { + try { + $theme = $this->app->make(Loader::class)->find($key); + } catch (\Throwable) { + continue; + } + + $fluxPath = $theme->path().DIRECTORY_SEPARATOR.'views'.DIRECTORY_SEPARATOR.'flux'; + + if (is_dir($fluxPath) && ! in_array($fluxPath, $themeFluxPaths, true)) { + $themeFluxPaths[] = $fluxPath; + } + } + + if ($themeFluxPaths === []) { + return; + } + + // This mirrors FluxServiceProvider::bootComponentPath(). It is essential: + // Flux::componentExists() checks the hashed namespace created by + // Blade::anonymousComponentPath(..., 'flux'), not a literal flux:: namespace. + foreach ($themeFluxPaths as $fluxPath) { + Blade::anonymousComponentPath($fluxPath, 'flux'); + } + + $fluxNamespace = version_compare($this->app->version(), '12.0.0', '>=') + ? hash('xxh128', 'flux') + : md5('flux'); + + // anonymousComponentPath() adds locations to the namespace. Prepend the + // selected theme paths in reverse order so the first selected theme remains + // authoritative while Flux package views continue to work as fallbacks. + foreach (array_reverse($themeFluxPaths) as $fluxPath) { + View::prependNamespace($fluxNamespace, $fluxPath); + } + + $finder = View::getFinder(); + if (method_exists($finder, 'flush')) { + $finder->flush(); + } + } + + /** @return array */ + private function themeChain(Theme $theme): array + { + $loader = $this->app->make(Loader::class); + $chain = []; + $seen = []; + + while (! isset($seen[$theme->key()])) { + $seen[$theme->key()] = true; + $chain[] = $theme->key(); + + $parent = $theme->parent(); + if (! $parent) { + break; + } + + try { + $theme = $loader->find($parent); + } catch (\Throwable) { + break; + } + } + + return $chain; + } + + private function prependThemeViewTree(string $themeKey): void + { + try { + $theme = $this->app->make(Loader::class)->find($themeKey); + } catch (\Throwable) { + return; + } + + $viewsPath = $theme->path().DIRECTORY_SEPARATOR.'views'; + if (! is_dir($viewsPath)) { + return; + } + + $finder = View::getFinder(); + if (method_exists($finder, 'prependLocation')) { + $finder->prependLocation($viewsPath); + } else { + View::addLocation($viewsPath); + } + + $this->registerThemeModuleNamespaces($themeKey); + } + + private function registerLayoutCompatibilityNamespace(Theme $theme): void + { + $candidates = [ + $theme->path().DIRECTORY_SEPARATOR.'views'.DIRECTORY_SEPARATOR.'layouts', + $theme->path().DIRECTORY_SEPARATOR.'views'.DIRECTORY_SEPARATOR.'components'.DIRECTORY_SEPARATOR.'layouts', + ]; + + foreach ($candidates as $layoutsPath) { + if (! is_dir($layoutsPath)) { + continue; + } + + View::prependNamespace('layouts', $layoutsPath); + Blade::anonymousComponentPath($layoutsPath, 'layouts'); + } + } + + private function registerThemeModuleNamespaces(string $themeKey): void + { + try { + $theme = $this->app->make(Loader::class)->find($themeKey); + } catch (\Throwable) { + return; + } + + $modulesPath = $theme->path().DIRECTORY_SEPARATOR.'views'.DIRECTORY_SEPARATOR.'modules'; + if (! is_dir($modulesPath)) { + return; + } + + foreach (glob($modulesPath.'/*', GLOB_ONLYDIR) ?: [] as $moduleViewsPath) { + $namespace = strtolower(basename($moduleViewsPath)); + View::prependNamespace($namespace, $moduleViewsPath); + + $componentsPath = $moduleViewsPath.DIRECTORY_SEPARATOR.'components'; + if (is_dir($componentsPath)) { + Blade::anonymousComponentPath($componentsPath, $namespace); + } + } + } + + private function normalisePath(string $path): string + { + return rtrim( + str_replace('\\', '/', realpath($path) ?: $path), + '/' + ); + } + + /** @return array */ + private function themeRoots(): array + { + return array_values(array_unique(array_filter([ + (string) config('stylist.themes_path'), + ]))); + } + + private function registerTranslations(): void + { + $langPath = resource_path('lang/modules/'.ModuleInfo::nameLower()); + $sourcePath = module_path(ModuleInfo::name(), 'Resources/lang'); + + $this->loadTranslationsFrom(is_dir($langPath) ? $langPath : $sourcePath, ModuleInfo::nameLower()); + } + + private function registerPublishing(): void + { + $this->publishes([ + module_path(ModuleInfo::name(), 'Config/config.php') => config_path('stylist.php'), + ], ['config', 'stylist-config']); + + } + + private function registerHelpers(): void + { + foreach (glob(module_path(ModuleInfo::name(), 'Helpers/*.php')) ?: [] as $filename) { + require_once $filename; + } + } + + private function registerBlade(): void + { + Blade::directive('themeVariables', fn () => 'variables(); ?>'); + Blade::directive('themeStylesheet', fn ($expression) => 'stylesheet('.($expression ?: 'null').'); ?>'); + Blade::directive('themeScript', fn ($expression) => 'script('.($expression ?: 'null').'); ?>'); + Blade::directive('themeSwitcher', fn () => 'switcher(); ?>'); + Blade::directive('themeSeo', fn ($expression) => 'render('.($expression ?: '[]').'); ?>'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 047225e..2419458 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -14,6 +14,7 @@ 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 @@ -33,7 +34,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable; class User extends Authenticatable implements PasskeyUser { /** @use HasFactory */ - use HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable; + use HasFactory, HasHelpdeskTickets, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable; /** * Get the attributes that should be cast. @@ -45,9 +46,30 @@ class User extends Authenticatable implements PasskeyUser return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'helpdesk_agent' => 'boolean', + 'helpdesk_admin' => 'boolean', ]; } + /** + * 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 */ diff --git a/resources/themes/backend/assets/css/app.css b/resources/themes/backend/assets/css/app.css index df58402..87828d7 100644 --- a/resources/themes/backend/assets/css/app.css +++ b/resources/themes/backend/assets/css/app.css @@ -1,7 +1,10 @@ @import 'tailwindcss'; @import '../../../../../vendor/livewire/flux/dist/flux.css'; +@import './helpdesk.css'; @source '../../views'; +@source '../../views/modules/helpdesk/**/*.blade.php'; +@source '../../../../../Modules/Helpdesk/Livewire/**/*.php'; @source '../../../**/*.blade.php'; @source '../../../../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../../../../vendor/livewire/flux-pro/stubs/**/*.blade.php'; diff --git a/resources/themes/backend/assets/css/helpdesk.css b/resources/themes/backend/assets/css/helpdesk.css new file mode 100644 index 0000000..16f7afa --- /dev/null +++ b/resources/themes/backend/assets/css/helpdesk.css @@ -0,0 +1,974 @@ +.hd-helpdesk-surface .hd-btn, +.hd-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: .5rem; + border-radius: .85rem; + padding: .62rem 1.05rem; + font-size: .875rem; + line-height: 1.25rem; + font-weight: 800; + color: #ffffff !important; + text-decoration: none !important; + border: 1px solid rgba(255,255,255,.14); + box-shadow: 0 12px 28px rgba(0,0,0,.22); + transition: background-color .15s ease, transform .15s ease, box-shadow .15s ease; +} +.hd-helpdesk-surface .hd-btn:hover, +.hd-btn:hover { + transform: translateY(-1px); + box-shadow: 0 16px 34px rgba(0,0,0,.30); +} +.hd-helpdesk-surface .hd-btn-sm, +.hd-btn-sm { + border-radius: .65rem; + padding: .42rem .78rem; + font-size: .8125rem; +} +.hd-helpdesk-surface .hd-btn-blue, +.hd-btn-blue { background: #2563eb !important; } +.hd-helpdesk-surface .hd-btn-blue:hover, +.hd-btn-blue:hover { background: #1d4ed8 !important; } +.hd-helpdesk-surface .hd-btn-green, +.hd-btn-green { background: #16a34a !important; } +.hd-helpdesk-surface .hd-btn-green:hover, +.hd-btn-green:hover { background: #15803d !important; } +.hd-helpdesk-surface .hd-btn-amber, +.hd-btn-amber { background: #f59e0b !important; } +.hd-helpdesk-surface .hd-btn-amber:hover, +.hd-btn-amber:hover { background: #d97706 !important; } +.hd-helpdesk-surface .hd-btn-red, +.hd-btn-red { background: #dc2626 !important; } +.hd-helpdesk-surface .hd-btn-red:hover, +.hd-btn-red:hover { background: #b91c1c !important; } +.hd-helpdesk-surface .hd-btn-violet, +.hd-btn-violet { background: #7c3aed !important; } +.hd-helpdesk-surface .hd-btn-violet:hover, +.hd-btn-violet:hover { background: #6d28d9 !important; } + +.hd-helpdesk-surface button[type="submit"], +.hd-helpdesk-surface button[data-flux-button][type="submit"] { + background: #16a34a !important; + color: #ffffff !important; + border-color: rgba(255,255,255,.14) !important; +} +.hd-helpdesk-surface button[wire\:click^="reset"], +.hd-helpdesk-surface button[wire\:target^="reset"] { + background: #f59e0b !important; + color: #ffffff !important; + border-color: rgba(255,255,255,.14) !important; +} +.hd-helpdesk-surface button[wire\:click^="edit"], +.hd-helpdesk-surface button[wire\:target^="edit"] { + background: #2563eb !important; + color: #ffffff !important; + border-color: rgba(255,255,255,.14) !important; +} +.hd-helpdesk-surface button[wire\:click^="delete"], +.hd-helpdesk-surface button[wire\:target^="delete"] { + background: #dc2626 !important; + color: #ffffff !important; + border-color: rgba(255,255,255,.14) !important; +} + +.hd-select { + width: 100%; + min-height: 3.35rem; + border-radius: 1rem; + border: 1px solid #3f3f46 !important; + background: #09090b !important; + color: #f4f4f5 !important; + padding: .78rem 3rem .78rem 1rem; + font-weight: 800; + outline: none; + box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 12px 26px rgba(0,0,0,.24); +} +.hd-select:focus { + border-color: #60a5fa !important; + box-shadow: 0 0 0 3px rgba(96,165,250,.38), inset 0 1px 0 rgba(255,255,255,.05); +} + +.helpdesk-ticket-form, +.helpdesk-ticket-form * { + -webkit-tap-highlight-color: transparent; +} +.helpdesk-ticket-form input, +.helpdesk-ticket-form textarea, +.helpdesk-ticket-form select, +.helpdesk-ticket-form .select2-container--default .select2-selection--single, +.helpdesk-ticket-form .select2-container--default .select2-selection--multiple, +.helpdesk-ticket-form .select2-container--default .select2-selection__rendered, +.helpdesk-ticket-form .select2-container--default .select2-selection__choice { + background: #09090b !important; + background-color: #09090b !important; + color: #f4f4f5 !important; + border-color: #3f3f46 !important; + caret-color: #f4f4f5 !important; + opacity: 1 !important; +} +.helpdesk-ticket-form input:disabled, +.helpdesk-ticket-form textarea:disabled, +.helpdesk-ticket-form select:disabled, +.helpdesk-ticket-form .select2-container--disabled .select2-selection--single, +.helpdesk-ticket-form .select2-container--disabled .select2-selection--multiple, +.helpdesk-ticket-form [disabled], +.helpdesk-ticket-form [aria-disabled="true"] { + background: #09090b !important; + background-color: #09090b !important; + color: #a1a1aa !important; + opacity: 1 !important; +} +.helpdesk-ticket-form input:-webkit-autofill, +.helpdesk-ticket-form input:-webkit-autofill:hover, +.helpdesk-ticket-form input:-webkit-autofill:focus, +.helpdesk-ticket-form textarea:-webkit-autofill, +.helpdesk-ticket-form textarea:-webkit-autofill:hover, +.helpdesk-ticket-form textarea:-webkit-autofill:focus, +.helpdesk-ticket-form select:-webkit-autofill, +.helpdesk-ticket-form select:-webkit-autofill:hover, +.helpdesk-ticket-form select:-webkit-autofill:focus { + -webkit-text-fill-color: #f4f4f5 !important; + box-shadow: 0 0 0 1000px #09090b inset !important; + transition: background-color 9999s ease-in-out 0s; +} + +.hd-helpdesk-surface .hd-btn, +.hd-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: .5rem; + border-radius: .85rem; + padding: .62rem 1.05rem; + font-size: .875rem; + line-height: 1.25rem; + font-weight: 800; + color: #fff !important; + text-decoration: none !important; + border: 1px solid rgba(255,255,255,.14); + box-shadow: 0 12px 28px rgba(0,0,0,.22); + transition: background-color .15s ease, transform .15s ease, box-shadow .15s ease; +} + +.hd-helpdesk-surface .hd-btn:hover, +.hd-btn:hover { + transform: translateY(-1px); + box-shadow: 0 16px 34px rgba(0,0,0,.30); +} + +.hd-btn-sm { + border-radius: .65rem; + padding: .42rem .78rem; + font-size: .8125rem; +} + +.hd-btn-blue { background: #2563eb !important; } +.hd-btn-blue:hover { background: #1d4ed8 !important; } + +.hd-btn-green { background: #16a34a !important; } +.hd-btn-green:hover { background: #15803d !important; } + +.hd-btn-amber { background: #f59e0b !important; } +.hd-btn-amber:hover { background: #d97706 !important; } + +.hd-btn-red { background: #dc2626 !important; } +.hd-btn-red:hover { background: #b91c1c !important; } + +.hd-btn-violet { background: #7c3aed !important; } +.hd-btn-violet:hover { background: #6d28d9 !important; } + +.hd-helpdesk-surface button[type="submit"], +.hd-helpdesk-surface button[data-flux-button][type="submit"] { + background: #16a34a !important; + color: #fff !important; + border-color: rgba(255,255,255,.14) !important; +} + +.hd-helpdesk-surface button[wire\:click^="reset"], +.hd-helpdesk-surface button[wire\:target^="reset"] { + background: #f59e0b !important; + color: #fff !important; + border-color: rgba(255,255,255,.14) !important; +} + +.hd-helpdesk-surface button[wire\:click^="edit"], +.hd-helpdesk-surface button[wire\:target^="edit"] { + background: #2563eb !important; + color: #fff !important; + border-color: rgba(255,255,255,.14) !important; +} + +.hd-helpdesk-surface button[wire\:click^="delete"], +.hd-helpdesk-surface button[wire\:target^="delete"] { + background: #dc2626 !important; + color: #fff !important; + border-color: rgba(255,255,255,.14) !important; +} + +.hd-select { + width: 100%; + min-height: 3.35rem; + border-radius: 1rem; + border: 1px solid #3f3f46 !important; + background: #09090b !important; + color: #f4f4f5 !important; + padding: .78rem 3rem .78rem 1rem; + font-weight: 800; + outline: none; + box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 12px 26px rgba(0,0,0,.24); +} + +.hd-select:focus { + border-color: #60a5fa !important; + box-shadow: + 0 0 0 3px rgba(96,165,250,.38), + inset 0 1px 0 rgba(255,255,255,.05); +} + +.helpdesk-required-star { + color: #ef4444 !important; + font-weight: 800; +} + +.helpdesk-ticket-form, +.helpdesk-ticket-form * { + -webkit-tap-highlight-color: transparent; +} + +.helpdesk-ticket-form input, +.helpdesk-ticket-form textarea, +.helpdesk-ticket-form select, +.helpdesk-ticket-form .select2-container--default .select2-selection--single, +.helpdesk-ticket-form .select2-container--default .select2-selection--multiple, +.helpdesk-ticket-form .select2-container--default .select2-selection__rendered, +.helpdesk-ticket-form .select2-container--default .select2-selection__choice { + background: #09090b !important; + color: #f4f4f5 !important; + border-color: #3f3f46 !important; + caret-color: #f4f4f5 !important; + opacity: 1 !important; +} + +.helpdesk-ticket-form input:disabled, +.helpdesk-ticket-form textarea:disabled, +.helpdesk-ticket-form select:disabled, +.helpdesk-ticket-form .select2-container--disabled .select2-selection--single, +.helpdesk-ticket-form .select2-container--disabled .select2-selection--multiple { + background: #09090b !important; + color: #a1a1aa !important; + opacity: 1 !important; +} + +.helpdesk-ticket-form input:-webkit-autofill, +.helpdesk-ticket-form input:-webkit-autofill:hover, +.helpdesk-ticket-form input:-webkit-autofill:focus, +.helpdesk-ticket-form textarea:-webkit-autofill, +.helpdesk-ticket-form textarea:-webkit-autofill:hover, +.helpdesk-ticket-form textarea:-webkit-autofill:focus, +.helpdesk-ticket-form select:-webkit-autofill, +.helpdesk-ticket-form select:-webkit-autofill:hover, +.helpdesk-ticket-form select:-webkit-autofill:focus { + -webkit-text-fill-color: #f4f4f5 !important; + box-shadow: 0 0 0 1000px #09090b inset !important; + transition: background-color 9999s ease-in-out 0s; +} + +.select2-container--default .select2-selection--single { + position: relative; +} + +.select2-container--default .select2-selection--single .select2-selection__clear { + position: absolute !important; + top: 50% !important; + right: 2.25rem !important; + transform: translateY(-50%); + color: #f87171 !important; + font-weight: 800 !important; + z-index: 5; +} + +/* Select2 base */ +.helpdesk-ticket-form .select2-container { + width: 100% !important; +} + +.helpdesk-ticket-form .select2-container--default .select2-selection--single, +.helpdesk-ticket-form .select2-container--default .select2-selection--multiple { + min-height: 3.35rem; + border-radius: 1rem; + border: 1px solid #d4d4d8 !important; + background: #ffffff !important; + color: #18181b !important; + box-shadow: 0 10px 22px rgba(24, 24, 27, .08); +} + +.helpdesk-ticket-form .select2-container--default .select2-selection--single .select2-selection__rendered { + color: #18181b !important; + line-height: 3.25rem; + padding-left: 1rem; + padding-right: 2.75rem; + font-weight: 700; +} + +.helpdesk-ticket-form .select2-container--default .select2-selection--multiple { + align-items: center; + padding: .4rem .65rem; +} + +.helpdesk-ticket-form .select2-container--default .select2-selection--multiple .select2-selection__choice { + border: 1px solid #2563eb !important; + border-radius: 9999px !important; + background: #dbeafe !important; + color: #1e3a8a !important; + font-weight: 700; + padding: .2rem .55rem; +} + +.helpdesk-ticket-form .select2-container--default .select2-selection--multiple .select2-selection__choice__remove { + color: #dc2626 !important; + margin-right: .35rem; +} + +.select2-dropdown { + border: 1px solid #d4d4d8 !important; + border-radius: .9rem !important; + background: #ffffff !important; + color: #18181b !important; + overflow: hidden; + box-shadow: 0 18px 42px rgba(24, 24, 27, .18); +} + +.select2-search__field { + border: 1px solid #d4d4d8 !important; + border-radius: .7rem !important; + background: #ffffff !important; + color: #18181b !important; +} + +.select2-container--default .select2-results__option { + color: #18181b !important; +} + +.select2-container--default .select2-results__option--highlighted[aria-selected] { + background: #2563eb !important; + color: #ffffff !important; +} + +.select2-container--default .select2-results__option[aria-selected="true"] { + background: #e0f2fe !important; + color: #075985 !important; +} + +/* Dark mode */ +.dark .helpdesk-ticket-form .select2-container--default .select2-selection--single, +.dark .helpdesk-ticket-form .select2-container--default .select2-selection--multiple { + border-color: #3f3f46 !important; + background: #09090b !important; + color: #f4f4f5 !important; + box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 12px 26px rgba(0,0,0,.24); +} + +.dark .helpdesk-ticket-form .select2-container--default .select2-selection--single .select2-selection__rendered { + color: #f4f4f5 !important; +} + +.dark .helpdesk-ticket-form .select2-container--default .select2-selection--multiple .select2-selection__choice { + border-color: #60a5fa !important; + background: #1d4ed8 !important; + color: #ffffff !important; +} + +.dark .select2-dropdown { + border-color: #3f3f46 !important; + background: #18181b !important; + color: #f4f4f5 !important; + box-shadow: 0 18px 42px rgba(0,0,0,.55); +} + +.dark .select2-search__field { + border-color: #3f3f46 !important; + background: #09090b !important; + color: #f4f4f5 !important; +} + +.dark .select2-container--default .select2-results__option { + color: #f4f4f5 !important; +} + +.dark .select2-container--default .select2-results__option--highlighted[aria-selected] { + background: #2563eb !important; + color: #ffffff !important; +} + +.dark .select2-container--default .select2-results__option[aria-selected="true"] { + background: #27272a !important; + color: #ffffff !important; +} + +.select2-container--default .select2-results__option { + background: rgb(9 9 11); + color: rgb(244 244 245); +} + +.select2-container--default .select2-results__option--highlighted[aria-selected], +.select2-container--default .select2-results__option--highlighted.select2-results__option--selectable { + background: rgb(37 99 235) !important; + color: rgb(255 255 255) !important; +} + +.select2-container--default .select2-results__option[aria-selected="true"], +.select2-container--default .select2-results__option--selected { + background: rgb(30 64 175) !important; + color: rgb(255 255 255) !important; +} + +.select2-dropdown { + border-color: rgb(63 63 70) !important; + background: rgb(9 9 11) !important; +} + +.select2-container--default .select2-selection--single { + border: 1px solid rgb(63 63 70) !important; + background: rgb(9 9 11) !important; +} + +.select2-container--default .select2-selection--single .select2-selection__rendered { + color: rgb(244 244 245) !important; +} + +.select2-container--default .select2-selection--single .select2-selection__placeholder { + color: rgb(113 113 122) !important; +} + +.select2-container--default .select2-selection--single .select2-selection__arrow b { + border-color: rgb(161 161 170) transparent transparent transparent !important; +} + +.select2-container--default.select2-container--open .select2-selection--single { + border-color: rgb(59 130 246) !important; + box-shadow: 0 0 0 2px rgb(59 130 246 / 0.35); +} + +.select2-container--default .select2-selection--multiple { + border: 1px solid rgb(63 63 70) !important; + background: rgb(9 9 11) !important; +} + +.select2-container--default .select2-selection--multiple .select2-selection__choice { + border: 1px solid rgb(59 130 246 / 0.45) !important; + background: rgb(30 64 175 / 0.35) !important; + color: rgb(219 234 254) !important; +} + +.select2-container--default .select2-selection--multiple .select2-selection__choice__remove { + color: rgb(191 219 254) !important; +} + +.select2-container--default .select2-selection--single, +.select2-container--default .select2-selection--multiple { + border-radius: 0.75rem !important; +} + +.select2-dropdown { + overflow: hidden; + border-radius: 0.75rem !important; +} + +.select2-container--open .select2-dropdown--below { + margin-top: 0.35rem; + border-radius: 0.75rem !important; +} + +.select2-container--open .select2-dropdown--above { + margin-bottom: 0.35rem; + border-radius: 0.75rem !important; +} + +.select2-container--default .select2-selection--single { + position: relative; + padding-right: 3.5rem; +} + +.select2-container--default .select2-selection--single .select2-selection__rendered { + padding-right: 3.5rem; +} + +.select2-container--default .select2-selection--single .select2-selection__clear { + position: absolute; + right: 2rem; + top: 50%; + z-index: 3; + margin: 0; + transform: translateY(-50%); + color: rgb(161 161 170); + font-size: 1rem; + line-height: 1; +} + +.select2-container--default .select2-selection--single .select2-selection__clear:hover { + color: rgb(248 113 113); +} + +.select2-container--default .select2-selection--single .select2-selection__arrow { + right: 0.75rem; +} + +.select2-container--default .select2-selection--single { + position: relative !important; +} + +.select2-container--default .select2-selection--single .select2-selection__rendered { + padding-left: 0.75rem !important; + padding-right: 4.25rem !important; +} + +.select2-container--default .select2-selection--single .select2-selection__clear { + position: absolute !important; + right: 2.35rem !important; + top: 50% !important; + float: none !important; + margin: 0 !important; + transform: translateY(-50%) !important; +} + +.select2-container--default .select2-selection--single .select2-selection__clear span { + color: rgb(161 161 170) !important; + font-size: 1.1rem !important; + line-height: 1 !important; +} + +.select2-container--default .select2-selection--single .select2-selection__arrow { + right: 0.75rem !important; +} + +.select2-container--default .select2-selection--single { + display: flex !important; + align-items: center !important; + position: relative !important; +} + +.select2-container--default .select2-selection--single .select2-selection__rendered { + flex: 1 1 auto !important; + width: 100% !important; + padding-right: 4.5rem !important; +} + +.select2-container--default .select2-selection--single .select2-selection__clear { + position: absolute !important; + right: 2.25rem !important; + top: 50% !important; + z-index: 20 !important; + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + width: 1.25rem !important; + height: 1.25rem !important; + float: none !important; + margin: 0 !important; + padding: 0 !important; + transform: translateY(-50%) !important; + color: rgb(161 161 170) !important; + cursor: pointer !important; +} + +.select2-container--default .select2-selection--single .select2-selection__clear:hover { + color: rgb(248 113 113) !important; +} + +.select2-container--default .select2-selection--single .select2-selection__arrow { + position: absolute !important; + right: 0.75rem !important; + top: 50% !important; + transform: translateY(-50%) !important; +} + +/* Helpdesk Flatpickr date picker */ +.flatpickr-calendar { + border: 1px solid rgb(63 63 70); + background: rgb(24 24 27); + color: rgb(244 244 245); + box-shadow: 0 20px 45px rgb(0 0 0 / 0.35); +} + +.flatpickr-calendar.arrowTop::before, +.flatpickr-calendar.arrowTop::after, +.flatpickr-calendar.arrowBottom::before, +.flatpickr-calendar.arrowBottom::after { + border-bottom-color: rgb(24 24 27); + border-top-color: rgb(24 24 27); +} + +.flatpickr-months, +.flatpickr-months .flatpickr-month, +.flatpickr-current-month .flatpickr-monthDropdown-months, +.flatpickr-current-month input.cur-year { + background: rgb(24 24 27); + color: rgb(244 244 245); +} + +.flatpickr-weekday, +span.flatpickr-weekday { + color: rgb(161 161 170); +} + +.flatpickr-day { + color: rgb(228 228 231); +} + +.flatpickr-day:hover, +.flatpickr-day:focus { + border-color: rgb(59 130 246); + background: rgb(39 39 42); + color: rgb(255 255 255); +} + +.flatpickr-day.selected, +.flatpickr-day.startRange, +.flatpickr-day.endRange { + border-color: rgb(37 99 235); + background: rgb(37 99 235); + color: rgb(255 255 255); +} + +.flatpickr-day.today { + border-color: rgb(34 197 94); +} + +.flatpickr-day.flatpickr-disabled, +.flatpickr-day.prevMonthDay, +.flatpickr-day.nextMonthDay { + color: rgb(113 113 122); +} +.hd-helpdesk-surface .hd-btn, +.hd-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: .5rem; + border-radius: .85rem; + padding: .62rem 1.05rem; + font-size: .875rem; + line-height: 1.25rem; + font-weight: 800; + font-size: .95rem; + color: #ffffff !important; + text-decoration: none !important; + border: 1px solid rgba(255,255,255,.14); + box-shadow: 0 12px 28px rgba(0,0,0,.22); + transition: background-color .15s ease, transform .15s ease, box-shadow .15s ease; +} +.hd-helpdesk-surface .hd-btn:hover, +.hd-btn:hover { + transform: translateY(-1px); + box-shadow: 0 16px 34px rgba(0,0,0,.30); +} +.hd-helpdesk-surface .hd-btn-sm, +.hd-btn-sm { + border-radius: .65rem; + padding: .42rem .78rem; + font-size: .8125rem; +} +.hd-helpdesk-surface .hd-btn-blue, +.hd-btn-blue { background: #2563eb !important; } +.hd-helpdesk-surface .hd-btn-blue:hover, +.hd-btn-blue:hover { background: #1d4ed8 !important; } +.hd-helpdesk-surface .hd-btn-green, +.hd-btn-green { background: #16a34a !important; } +.hd-helpdesk-surface .hd-btn-green:hover, +.hd-btn-green:hover { background: #15803d !important; } +.hd-helpdesk-surface .hd-btn-amber, +.hd-btn-amber { background: #f59e0b !important; } +.hd-helpdesk-surface .hd-btn-amber:hover, +.hd-btn-amber:hover { background: #d97706 !important; } +.hd-helpdesk-surface .hd-btn-red, +.hd-btn-red { background: #dc2626 !important; } +.hd-helpdesk-surface .hd-btn-red:hover, +.hd-btn-red:hover { background: #b91c1c !important; } +.hd-helpdesk-surface .hd-btn-violet, +.hd-btn-violet { background: #7c3aed !important; } +.hd-helpdesk-surface .hd-btn-violet:hover, +.hd-btn-violet:hover { background: #6d28d9 !important; } + +.hd-helpdesk-surface button[type="submit"], +.hd-helpdesk-surface button[data-flux-button][type="submit"] { + background: #16a34a !important; + color: #ffffff !important; + border-color: rgba(255,255,255,.14) !important; +} +.hd-helpdesk-surface button[wire\:click^="reset"], +.hd-helpdesk-surface button[wire\:target^="reset"] { + background: #f59e0b !important; + color: #ffffff !important; + border-color: rgba(255,255,255,.14) !important; +} +.hd-helpdesk-surface button[wire\:click^="edit"], +.hd-helpdesk-surface button[wire\:target^="edit"] { + background: #2563eb !important; + color: #ffffff !important; + border-color: rgba(255,255,255,.14) !important; +} +.hd-helpdesk-surface button[wire\:click^="delete"], +.hd-helpdesk-surface button[wire\:target^="delete"] { + background: #dc2626 !important; + color: #ffffff !important; + border-color: rgba(255,255,255,.14) !important; +} + +.hd-select-wrap { + position: relative; + width: 100%; + max-width: 18rem; +} +.hd-select-wrap::after { + position: absolute; + top: 50%; + right: 1rem; + width: .55rem; + height: .55rem; + content: ""; + pointer-events: none; + border-right: 2px solid #93c5fd; + border-bottom: 2px solid #93c5fd; + transform: translateY(-65%) rotate(45deg); +} +.hd-select { + width: 100%; + min-height: 2.75rem; + appearance: none; + -webkit-appearance: none; + border-radius: .9rem; + border: 1px solid rgba(96, 165, 250, .38) !important; + background: linear-gradient(135deg, #030712 0%, #111827 54%, #0f172a 100%) !important; + color: #f8fafc !important; + padding: .62rem 2.45rem .62rem .9rem; + font-weight: 800; + outline: none; + box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 14px 30px rgba(2,6,23,.38); +} +.hd-select option { + background: #020617 !important; + color: #f8fafc !important; +} +.hd-select:focus { + border-color: #60a5fa !important; + box-shadow: 0 0 0 3px rgba(96,165,250,.38), inset 0 1px 0 rgba(255,255,255,.08), 0 14px 30px rgba(2,6,23,.38); +} +.hd-ticket-board-select-wrap { + position: relative; + max-width: 12.5rem; +} +.hd-ticket-board-select-wrap::after { + position: absolute; + top: 50%; + right: .7rem; + width: .45rem; + height: .45rem; + content: ""; + pointer-events: none; + border-right: 2px solid #86efac; + border-bottom: 2px solid #86efac; + transform: translateY(-65%) rotate(45deg); +} +.hd-ticket-board-select { + width: 100%; + min-height: 2.35rem; + appearance: none; + -webkit-appearance: none; + border-radius: .85rem; + border: 1px solid rgba(34,197,94,.35) !important; + background: linear-gradient(135deg, #052e16 0%, #064e3b 100%) !important; + color: #ecfdf5 !important; + padding: .48rem 2rem .48rem .75rem; + font-size: .75rem; + font-weight: 800; + outline: none; + box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 10px 22px rgba(0,0,0,.26); +} +.hd-ticket-board-select option { + background: #052e16 !important; + color: #ecfdf5 !important; +} +.hd-ticket-board-select:focus { + border-color: #86efac !important; + box-shadow: 0 0 0 3px rgba(34,197,94,.28), inset 0 1px 0 rgba(255,255,255,.08); +} + +.helpdesk-ticket-form, +.helpdesk-ticket-form * { + -webkit-tap-highlight-color: transparent; +} +.helpdesk-ticket-form input, +.helpdesk-ticket-form textarea, +.helpdesk-ticket-form select, +.helpdesk-ticket-form .select2-container--default .select2-selection--single, +.helpdesk-ticket-form .select2-container--default .select2-selection--multiple, +.helpdesk-ticket-form .select2-container--default .select2-selection__rendered, +.helpdesk-ticket-form .select2-container--default .select2-selection__choice { + background: #09090b !important; + background-color: #09090b !important; + color: #f4f4f5 !important; + border-color: #3f3f46 !important; + caret-color: #f4f4f5 !important; + opacity: 1 !important; +} +.helpdesk-ticket-form input:disabled, +.helpdesk-ticket-form textarea:disabled, +.helpdesk-ticket-form select:disabled, +.helpdesk-ticket-form .select2-container--disabled .select2-selection--single, +.helpdesk-ticket-form .select2-container--disabled .select2-selection--multiple, +.helpdesk-ticket-form [disabled], +.helpdesk-ticket-form [aria-disabled="true"] { + background: #09090b !important; + background-color: #09090b !important; + color: #a1a1aa !important; + opacity: 1 !important; +} +.helpdesk-ticket-form input:-webkit-autofill, +.helpdesk-ticket-form input:-webkit-autofill:hover, +.helpdesk-ticket-form input:-webkit-autofill:focus, +.helpdesk-ticket-form textarea:-webkit-autofill, +.helpdesk-ticket-form textarea:-webkit-autofill:hover, +.helpdesk-ticket-form textarea:-webkit-autofill:focus, +.helpdesk-ticket-form select:-webkit-autofill, +.helpdesk-ticket-form select:-webkit-autofill:hover, +.helpdesk-ticket-form select:-webkit-autofill:focus { + -webkit-text-fill-color: #f4f4f5 !important; + box-shadow: 0 0 0 1000px #09090b inset !important; + transition: background-color 9999s ease-in-out 0s; +} + +.hd-helpdesk-surface .hd-btn, +.hd-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: .5rem; + border-radius: .85rem; + padding: .62rem 1.05rem; + font-size: .875rem; + line-height: 1.25rem; + font-weight: 800; + color: #ffffff !important; + text-decoration: none !important; + border: 1px solid rgba(255,255,255,.14); + box-shadow: 0 12px 28px rgba(0,0,0,.22); + transition: background-color .15s ease, transform .15s ease, box-shadow .15s ease; +} +.hd-helpdesk-surface .hd-btn:hover, +.hd-btn:hover { + transform: translateY(-1px); + box-shadow: 0 16px 34px rgba(0,0,0,.30); +} +.hd-helpdesk-surface .hd-btn-sm, +.hd-btn-sm { + border-radius: .65rem; + padding: .42rem .78rem; + font-size: .8125rem; +} +.hd-helpdesk-surface .hd-btn-blue, +.hd-btn-blue { background: #2563eb !important; } +.hd-helpdesk-surface .hd-btn-blue:hover, +.hd-btn-blue:hover { background: #1d4ed8 !important; } +.hd-helpdesk-surface .hd-btn-green, +.hd-btn-green { background: #16a34a !important; } +.hd-helpdesk-surface .hd-btn-green:hover, +.hd-btn-green:hover { background: #15803d !important; } +.hd-helpdesk-surface .hd-btn-amber, +.hd-btn-amber { background: #f59e0b !important; } +.hd-helpdesk-surface .hd-btn-amber:hover, +.hd-btn-amber:hover { background: #d97706 !important; } +.hd-helpdesk-surface .hd-btn-red, +.hd-btn-red { background: #dc2626 !important; } +.hd-helpdesk-surface .hd-btn-red:hover, +.hd-btn-red:hover { background: #b91c1c !important; } +.hd-helpdesk-surface .hd-btn-violet, +.hd-btn-violet { background: #7c3aed !important; } +.hd-helpdesk-surface .hd-btn-violet:hover, +.hd-btn-violet:hover { background: #6d28d9 !important; } + +.hd-helpdesk-surface button[type="submit"], +.hd-helpdesk-surface button[data-flux-button][type="submit"] { + background: #16a34a !important; + color: #ffffff !important; + border-color: rgba(255,255,255,.14) !important; +} +.hd-helpdesk-surface button[wire\:click^="reset"], +.hd-helpdesk-surface button[wire\:target^="reset"] { + background: #f59e0b !important; + color: #ffffff !important; + border-color: rgba(255,255,255,.14) !important; +} +.hd-helpdesk-surface button[wire\:click^="edit"], +.hd-helpdesk-surface button[wire\:target^="edit"] { + background: #2563eb !important; + color: #ffffff !important; + border-color: rgba(255,255,255,.14) !important; +} +.hd-helpdesk-surface button[wire\:click^="delete"], +.hd-helpdesk-surface button[wire\:target^="delete"] { + background: #dc2626 !important; + color: #ffffff !important; + border-color: rgba(255,255,255,.14) !important; +} + +.hd-select { + width: 100%; + min-height: 3.35rem; + border-radius: 1rem; + border: 1px solid #3f3f46 !important; + background: #09090b !important; + color: #f4f4f5 !important; + padding: .78rem 3rem .78rem 1rem; + font-weight: 800; + outline: none; + box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 12px 26px rgba(0,0,0,.24); +} +.hd-select:focus { + border-color: #60a5fa !important; + box-shadow: 0 0 0 3px rgba(96,165,250,.38), inset 0 1px 0 rgba(255,255,255,.05); +} + +.helpdesk-ticket-form, +.helpdesk-ticket-form * { + -webkit-tap-highlight-color: transparent; +} +.helpdesk-ticket-form input, +.helpdesk-ticket-form textarea, +.helpdesk-ticket-form select, +.helpdesk-ticket-form .select2-container--default .select2-selection--single, +.helpdesk-ticket-form .select2-container--default .select2-selection--multiple, +.helpdesk-ticket-form .select2-container--default .select2-selection__rendered, +.helpdesk-ticket-form .select2-container--default .select2-selection__choice { + background: #09090b !important; + background-color: #09090b !important; + color: #f4f4f5 !important; + border-color: #3f3f46 !important; + caret-color: #f4f4f5 !important; + opacity: 1 !important; +} +.helpdesk-ticket-form input:disabled, +.helpdesk-ticket-form textarea:disabled, +.helpdesk-ticket-form select:disabled, +.helpdesk-ticket-form .select2-container--disabled .select2-selection--single, +.helpdesk-ticket-form .select2-container--disabled .select2-selection--multiple, +.helpdesk-ticket-form [disabled], +.helpdesk-ticket-form [aria-disabled="true"] { + background: #09090b !important; + background-color: #09090b !important; + color: #a1a1aa !important; + opacity: 1 !important; +} +.helpdesk-ticket-form input:-webkit-autofill, +.helpdesk-ticket-form input:-webkit-autofill:hover, +.helpdesk-ticket-form input:-webkit-autofill:focus, +.helpdesk-ticket-form textarea:-webkit-autofill, +.helpdesk-ticket-form textarea:-webkit-autofill:hover, +.helpdesk-ticket-form textarea:-webkit-autofill:focus, +.helpdesk-ticket-form select:-webkit-autofill, +.helpdesk-ticket-form select:-webkit-autofill:hover, +.helpdesk-ticket-form select:-webkit-autofill:focus { + -webkit-text-fill-color: #f4f4f5 !important; + box-shadow: 0 0 0 1000px #09090b inset !important; + transition: background-color 9999s ease-in-out 0s; +} diff --git a/resources/themes/backend/views/dashboard.blade.php b/resources/themes/backend/views/dashboard.blade.php index 8f08c05..691d246 100644 --- a/resources/themes/backend/views/dashboard.blade.php +++ b/resources/themes/backend/views/dashboard.blade.php @@ -1,18 +1,3 @@ - -
-
-
- -
-
- -
-
- -
-
-
- -
-
+ + diff --git a/resources/themes/backend/views/modules/helpdesk/index.blade.php b/resources/themes/backend/views/modules/helpdesk/index.blade.php new file mode 100644 index 0000000..b02ebec --- /dev/null +++ b/resources/themes/backend/views/modules/helpdesk/index.blade.php @@ -0,0 +1,3 @@ + + + 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 new file mode 100644 index 0000000..068d2c9 --- /dev/null +++ b/resources/themes/backend/views/modules/helpdesk/livewire/dashboard-widget.blade.php @@ -0,0 +1,124 @@ +
+
+
+ {{ __('Helpdesk') }} + {{ __('Support operations, workload and recent activity.') }} +
+ +
+ + {{ __('Tickets') }} + + + {{ __('Kanban') }} + + + {{ __('New ticket') }} + +
+
+ +
+
+ {{ __('Total tickets') }} +
{{ $totalCount }}
+ {{ __('All tickets visible to you') }} +
+ +
+ {{ __('Open') }} +
{{ $openCount }}
+ {{ $openPercent }}% {{ __('of total') }} +
+ +
+ {{ __('Closed') }} +
{{ $closedCount }}
+ {{ $closedPercent }}% {{ __('resolved') }} +
+ +
+ {{ __('High priority') }} +
{{ $criticalCount }}
+ {{ __('Critical, urgent or high') }} +
+
+ +
+
+
+
+ {{ __('Recent tickets') }} + {{ __('Latest support activity') }} +
+ + {{ __('View all') }} + +
+ + +
+ +
+
+ {{ __('Recent Git activity') }} + {{ __('Commits linked to Helpdesk') }} +
+ +
+ @forelse ($recentCommits as $commit) +
+
+ {{ $commit->repository?->name ?? __('Repository') }} +
+
+ {{ $commit->message ?? __('Commit') }} +
+
+ @if ($commit->sha) + {{ substr($commit->sha, 0, 8) }} + @endif + @if ($commit->committed_at) + {{ $commit->committed_at->diffForHumans() }} + @endif +
+
+ @empty +
+ {{ __('No Git activity yet.') }} +
+ @endforelse +
+
+
+