updated design

This commit is contained in:
Mark Gregory
2026-08-25 14:41:15 +01:00
parent 3e6b3bfc79
commit 3c7361023c
18 changed files with 1828 additions and 76 deletions
@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
use Modules\Helpdesk\Livewire\HelpdeskDashboardWidget;
use Modules\Helpdesk\Livewire\KanbanBoard;
use Modules\Helpdesk\Livewire\SettingsManager;
use Modules\Helpdesk\Livewire\TicketCreate;
use Modules\Helpdesk\Livewire\TicketIndex;
use Modules\Helpdesk\Livewire\TicketShow;
use Modules\Helpdesk\Livewire\TicketTimer;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Policies\TicketPolicy;
use Modules\Helpdesk\Support\ModuleInfo;
final class HelpdeskServiceProvider extends ServiceProvider
{
/**
* Module name.
*/
protected string $moduleName = 'Helpdesk';
/**
* Lowercase module name.
*/
protected string $moduleNameLower = 'helpdesk';
/**
* Register module services.
*/
public function register(): void
{
$this->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<int, string>
*/
public function provides(): array
{
return [];
}
}
+1 -1
View File
@@ -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' => [
@@ -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');
}
}
@@ -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 {
@@ -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();
}
/**
@@ -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);
+1 -1
View File
@@ -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
+2 -2
View File
@@ -194,11 +194,11 @@ final class Ticket extends Model
/** @param Builder<Ticket> $query @return Builder<Ticket> */
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);
});
+3 -11
View File
@@ -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();
}
}
@@ -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<int, string>
*/
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.
*
@@ -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();
}
@@ -0,0 +1,457 @@
<?php
namespace Modules\Stylist\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Modules\Stylist\Console\CreateThemeCommand;
use Modules\Stylist\Console\FindDuplicateViewsCommand;
use Modules\Stylist\Console\InspectThemesCommand;
use Modules\Stylist\Console\PublishAssetsCommand;
use Modules\Stylist\Console\ThemeDoctorCommand;
use Modules\Stylist\Console\ValidateThemeCommand;
use Modules\Stylist\Html\ThemeHtmlBuilder;
use Modules\Stylist\Html\ThemeSeoBuilder;
use Modules\Stylist\Support\ModuleInfo;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\Theme\Stylist;
use Modules\Stylist\Theme\Theme;
final class StylistServiceProvider extends ServiceProvider
{
protected string $moduleName = 'Stylist';
protected string $moduleNameLower = 'stylist';
public function register(): void
{
$this->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<int, string> */
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<int, string> */
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 () => '<?php echo stylist_html()->variables(); ?>');
Blade::directive('themeStylesheet', fn ($expression) => '<?php echo stylist_html()->stylesheet('.($expression ?: 'null').'); ?>');
Blade::directive('themeScript', fn ($expression) => '<?php echo stylist_html()->script('.($expression ?: 'null').'); ?>');
Blade::directive('themeSwitcher', fn () => '<?php echo stylist_html()->switcher(); ?>');
Blade::directive('themeSeo', fn ($expression) => '<?php echo app(\Modules\Stylist\Html\ThemeSeoBuilder::class)->render('.($expression ?: '[]').'); ?>');
}
}
+23 -1
View File
@@ -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<UserFactory> */
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
*/
@@ -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';
@@ -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;
}
@@ -1,18 +1,3 @@
<x-layouts::app :title="__('Dashboard')">
<div class="flex h-full w-full flex-1 flex-col gap-4 rounded-xl">
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
<div class="relative aspect-video overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
</div>
<div class="relative aspect-video overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
</div>
<div class="relative aspect-video overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
</div>
</div>
<div class="relative h-full flex-1 overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
</div>
</div>
<x-layouts::app :title="__('Helpdesk Dashboard')">
<livewire:helpdesk.dashboard-widget />
</x-layouts::app>
@@ -0,0 +1,3 @@
<x-layouts::app :title="__('Helpdesk')">
<livewire:helpdesk.dashboard-widget />
</x-layouts::app>
@@ -0,0 +1,124 @@
<div class="flex w-full flex-1 flex-col gap-6">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<flux:heading size="xl">{{ __('Helpdesk') }}</flux:heading>
<flux:text class="mt-1">{{ __('Support operations, workload and recent activity.') }}</flux:text>
</div>
<div class="flex flex-wrap gap-2">
<flux:button :href="route('helpdesk.tickets.index')" variant="ghost" icon="list-bullet" wire:navigate>
{{ __('Tickets') }}
</flux:button>
<flux:button :href="route('helpdesk.kanban')" variant="ghost" icon="view-columns" wire:navigate>
{{ __('Kanban') }}
</flux:button>
<flux:button :href="route('helpdesk.tickets.create')" variant="primary" icon="plus" wire:navigate>
{{ __('New ticket') }}
</flux:button>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div class="rounded-xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<flux:text>{{ __('Total tickets') }}</flux:text>
<div class="mt-2 text-3xl font-semibold tracking-tight text-zinc-950 dark:text-white">{{ $totalCount }}</div>
<flux:text class="mt-2">{{ __('All tickets visible to you') }}</flux:text>
</div>
<div class="rounded-xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<flux:text>{{ __('Open') }}</flux:text>
<div class="mt-2 text-3xl font-semibold tracking-tight text-zinc-950 dark:text-white">{{ $openCount }}</div>
<flux:text class="mt-2">{{ $openPercent }}% {{ __('of total') }}</flux:text>
</div>
<div class="rounded-xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<flux:text>{{ __('Closed') }}</flux:text>
<div class="mt-2 text-3xl font-semibold tracking-tight text-zinc-950 dark:text-white">{{ $closedCount }}</div>
<flux:text class="mt-2">{{ $closedPercent }}% {{ __('resolved') }}</flux:text>
</div>
<div class="rounded-xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<flux:text>{{ __('High priority') }}</flux:text>
<div class="mt-2 text-3xl font-semibold tracking-tight text-zinc-950 dark:text-white">{{ $criticalCount }}</div>
<flux:text class="mt-2">{{ __('Critical, urgent or high') }}</flux:text>
</div>
</div>
<div class="grid gap-6 xl:grid-cols-3">
<section class="xl:col-span-2 rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="flex items-center justify-between border-b border-zinc-200 px-5 py-4 dark:border-zinc-700">
<div>
<flux:heading size="lg">{{ __('Recent tickets') }}</flux:heading>
<flux:text>{{ __('Latest support activity') }}</flux:text>
</div>
<flux:button :href="route('helpdesk.tickets.index')" size="sm" variant="ghost" wire:navigate>
{{ __('View all') }}
</flux:button>
</div>
<div class="divide-y divide-zinc-200 dark:divide-zinc-700">
@forelse ($recentTickets as $ticket)
<a
href="{{ route('helpdesk.tickets.show', $ticket) }}"
wire:navigate
class="flex items-start justify-between gap-4 px-5 py-4 transition hover:bg-zinc-50 dark:hover:bg-zinc-800/60"
>
<div class="min-w-0">
<div class="truncate font-medium text-zinc-950 dark:text-white">
{{ $ticket->reference ?? $ticket->id }} · {{ $ticket->subject ?? __('Untitled ticket') }}
</div>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-zinc-500 dark:text-zinc-400">
@if ($ticket->status)
<span>{{ $ticket->status->name }}</span>
@endif
@if ($ticket->priority)
<span>{{ $ticket->priority->name }}</span>
@endif
@if ($ticket->last_activity_at)
<span>{{ $ticket->last_activity_at->diffForHumans() }}</span>
@endif
</div>
</div>
<flux:icon.chevron-right class="mt-1 size-4 shrink-0 text-zinc-400" />
</a>
@empty
<div class="px-5 py-10 text-center">
<flux:text>{{ __('No tickets yet.') }}</flux:text>
</div>
@endforelse
</div>
</section>
<section class="rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="border-b border-zinc-200 px-5 py-4 dark:border-zinc-700">
<flux:heading size="lg">{{ __('Recent Git activity') }}</flux:heading>
<flux:text>{{ __('Commits linked to Helpdesk') }}</flux:text>
</div>
<div class="divide-y divide-zinc-200 dark:divide-zinc-700">
@forelse ($recentCommits as $commit)
<div class="px-5 py-4">
<div class="truncate font-medium text-zinc-950 dark:text-white">
{{ $commit->repository?->name ?? __('Repository') }}
</div>
<div class="mt-1 line-clamp-2 text-sm text-zinc-600 dark:text-zinc-300">
{{ $commit->message ?? __('Commit') }}
</div>
<div class="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-zinc-500 dark:text-zinc-400">
@if ($commit->sha)
<span>{{ substr($commit->sha, 0, 8) }}</span>
@endif
@if ($commit->committed_at)
<span>{{ $commit->committed_at->diffForHumans() }}</span>
@endif
</div>
</div>
@empty
<div class="px-5 py-10 text-center">
<flux:text>{{ __('No Git activity yet.') }}</flux:text>
</div>
@endforelse
</div>
</section>
</div>
</div>