settings updates, fixed

This commit is contained in:
Mark Gregory
2026-08-25 22:30:59 +01:00
parent 1cad54ce42
commit d359ac29b6
13 changed files with 61 additions and 815 deletions
@@ -1,181 +0,0 @@
<?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 [];
}
}
+3 -3
View File
@@ -73,7 +73,7 @@ final class KanbanBoard extends Component
'last_activity_at' => now(),
])->save();
$freshTicket = $ticket->fresh(['user:id,name,email', 'owner:id,name,email', 'agent:id,name,email', 'status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board']) ?? $ticket;
$freshTicket = $ticket->fresh(['user:id,name,email', 'agent:id,name,email', 'status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board']) ?? $ticket;
$workflow->dispatch(
$freshTicket,
@@ -121,7 +121,7 @@ final class KanbanBoard extends Component
$this->board_id = $board->id;
$fresh = $ticket->fresh(['user:id,name,email', 'owner:id,name,email', 'agent:id,name,email', 'status', 'priority', 'category', 'project', 'team', 'board']) ?? $ticket;
$fresh = $ticket->fresh(['user:id,name,email', 'agent:id,name,email', 'status', 'priority', 'category', 'project', 'team', 'board']) ?? $ticket;
$workflow->dispatch($fresh, 'ticket.updated', Auth::user());
$message = 'Ticket '.$fresh->reference.' moved from '.$oldBoardName.' to '.$board->name.'.';
@@ -206,7 +206,7 @@ final class KanbanBoard extends Component
}
return Ticket::query()
->with(['user:id,name,email', 'owner:id,name,email', 'agent:id,name,email', 'status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board'])
->with(['user:id,name,email', 'agent:id,name,email', 'status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board'])
->visibleTo(Auth::user())
->where('board_id', $board->id)
->latest('last_activity_at')
+14 -7
View File
@@ -27,7 +27,7 @@ final class Ticket extends Model
protected $table = 'helpdesk_tickets';
protected $with = ['user', 'owner', 'agent'];
protected $with = ['user'];
/** @var list<string> */
protected $fillable = [
@@ -62,17 +62,24 @@ final class Ticket extends Model
return $this->belongsTo(User::class, 'user_id');
}
/**
* Compatibility alias for the requester.
*
* This is deliberately an accessor, not another BelongsTo relation.
* `owner()` and `user()` previously pointed to the same users.user_id
* relation and caused duplicate User relationship queries.
*/
public function getOwnerAttribute(): ?User
{
return $this->user;
}
public function tickets(): Ticket|HasMany
{
return $this->hasMany(Ticket::class, 'ticket_id');
}
/** @return BelongsTo<User, Ticket> */
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/** @return BelongsTo<User, Ticket> */
public function agent(): BelongsTo
{
@@ -69,7 +69,7 @@ final class WorkflowNotificationService
*/
private function hydrateTicketUsers(Ticket $ticket): void
{
if ($ticket->relationLoaded('owner') && $ticket->relationLoaded('agent')) {
if ($ticket->relationLoaded('user') && $ticket->relationLoaded('agent')) {
return;
}
@@ -81,7 +81,6 @@ final class WorkflowNotificationService
if ($userIds->isEmpty()) {
$ticket->setRelation('user', null);
$ticket->setRelation('owner', null);
$ticket->setRelation('agent', null);
return;
@@ -96,7 +95,6 @@ final class WorkflowNotificationService
$agent = $ticket->agent_id === null ? null : $users->get((int) $ticket->agent_id);
$ticket->setRelation('user', $owner);
$ticket->setRelation('owner', $owner);
$ticket->setRelation('agent', $agent);
}
@@ -105,7 +103,7 @@ final class WorkflowNotificationService
return match ($role) {
'agent' => $ticket->agent,
'actor' => $actor,
default => $ticket->owner,
default => $ticket->user,
};
}
@@ -119,7 +117,7 @@ final class WorkflowNotificationService
'{{ ticket.category }}' => (string) ($ticket->category?->name ?? ''),
'{{ ticket.project }}' => (string) ($ticket->project?->title ?? ''),
'{{ ticket.team }}' => (string) ($ticket->team?->name ?? ''),
'{{ requester.name }}' => (string) ($ticket->owner?->name ?? ''),
'{{ requester.name }}' => (string) ($ticket->user?->name ?? ''),
'{{ agent.name }}' => (string) ($ticket->agent?->name ?? ''),
'{{ actor.name }}' => (string) ($actor?->name ?? ''),
'{{ ticket.url }}' => route('helpdesk.tickets.show', ['ticket' => $ticket->getKey()]),
@@ -1,457 +0,0 @@
<?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 ?: '[]').'); ?>');
}
}
@@ -4,7 +4,6 @@
class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
<tr>
<th class="px-3 py-2.5">Project</th>
<th class="px-3 py-2.5">Company</th>
<th class="px-3 py-2.5">Team</th>
<th class="px-3 py-2.5">Colour</th>
<th class="px-3 py-2.5">Default</th>
@@ -16,7 +15,6 @@
@forelse ($items as $item)
<tr>
<td class="px-3 py-2.5 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->title }}</td>
<td class="px-3 py-2.5 text-zinc-500">{{ $item->company?->name ?? 'Global' }}</td>
<td class="px-3 py-2.5 text-zinc-500">{{ $item->team?->name ?? 'No team' }}</td>
<td class="px-3 py-2.5">
<span class="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-xs font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200">
@@ -4,7 +4,6 @@
class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
<tr>
<th class="px-3 py-2.5">Team</th>
<th class="px-3 py-2.5">Company</th>
<th class="px-3 py-2.5">Colour</th>
<th class="px-3 py-2.5">Default</th>
<th class="px-3 py-2.5">Tickets</th>
@@ -15,7 +14,6 @@
@forelse ($items as $item)
<tr>
<td class="px-3 py-2.5 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->name }}</td>
<td class="px-3 py-2.5 text-zinc-500">{{ $item->company?->name ?? 'Global' }}</td>
<td class="px-3 py-2.5">
<span class="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-xs font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200">
<span class="h-3 w-3 rounded-full" style="background-color: {{ \Modules\Helpdesk\Support\Color::css($item->color) }}"></span>
@@ -6,19 +6,11 @@
<body class="min-h-screen bg-white dark:bg-zinc-800">
@php
/*
* Match the Core WMS support navigation strategy:
* detect routes rather than hiding navigation behind role checks.
*/
$helpdeskEnabled = Route::has('helpdesk.index') || Route::has('helpdesk.tickets.index');
$helpdeskSettingsRoute = Route::has('helpdesk.settings')
? 'helpdesk.settings'
: (Route::has('helpdesk.settings.index')
? 'helpdesk.settings.index'
: null);
$helpdeskActive = request()->routeIs('helpdesk.*');
@endphp
<flux:sidebar
@@ -48,63 +40,40 @@
</flux:sidebar.item>
</flux:sidebar.group>
@if ($helpdeskEnabled)
<div class="hd-support-group">
{{-- Core WMS uses Support as the selected parent group. --}}
@if (Route::has('helpdesk.index'))
<flux:sidebar.item
icon="lifebuoy"
:href="route('helpdesk.index')"
class="hd-support-parent {{ $helpdeskActive ? 'is-section-active' : '' }}"
wire:navigate
>
{{ __('Support') }}
</flux:sidebar.item>
@else
<div class="hd-support-parent is-section-active">
<span class="hd-support-parent-label">{{ __('Support') }}</span>
</div>
@endif
<flux:sidebar.group :heading="__('Helpdesk')" class="grid">
@if (Route::has('helpdesk.tickets.index'))
<flux:sidebar.item
icon="inbox"
:href="route('helpdesk.tickets.index')"
:current="request()->routeIs('helpdesk.tickets.*')"
wire:navigate
>
{{ __('Tickets') }}
</flux:sidebar.item>
@endif
<div class="hd-support-children">
@if (Route::has('helpdesk.tickets.index'))
<flux:sidebar.item
icon="inbox"
:href="route('helpdesk.tickets.index')"
:current="request()->routeIs('helpdesk.tickets.*')"
class="hd-support-child"
wire:navigate
>
{{ __('Tickets') }}
</flux:sidebar.item>
@endif
@if (Route::has('helpdesk.kanban'))
<flux:sidebar.item
icon="squares-2x2"
:href="route('helpdesk.kanban')"
:current="request()->routeIs('helpdesk.kanban')"
wire:navigate
>
{{ __('Kanban') }}
</flux:sidebar.item>
@endif
@if (Route::has('helpdesk.kanban'))
<flux:sidebar.item
icon="squares-2x2"
:href="route('helpdesk.kanban')"
:current="request()->routeIs('helpdesk.kanban')"
class="hd-support-child"
wire:navigate
>
{{ __('Kanban') }}
</flux:sidebar.item>
@endif
@if ($helpdeskSettingsRoute)
<flux:sidebar.item
icon="cog"
:href="route($helpdeskSettingsRoute)"
:current="request()->routeIs('helpdesk.settings') || request()->routeIs('helpdesk.settings.index')"
class="hd-support-child"
wire:navigate
>
{{ __('Settings') }}
</flux:sidebar.item>
@endif
</div>
</div>
@endif
@if ($helpdeskSettingsRoute)
<flux:sidebar.item
icon="cog"
:href="route($helpdeskSettingsRoute)"
:current="request()->routeIs('helpdesk.settings') || request()->routeIs('helpdesk.settings.index')"
wire:navigate
>
{{ __('Settings') }}
</flux:sidebar.item>
@endif
</flux:sidebar.group>
</flux:sidebar.nav>
<flux:spacer />
@@ -156,16 +125,6 @@
<flux:menu.separator />
<flux:menu.radio.group>
@if (Route::has('helpdesk.index'))
<flux:menu.item
:href="route('helpdesk.index')"
icon="lifebuoy"
wire:navigate
>
{{ __('Support') }}
</flux:menu.item>
@endif
@if ($helpdeskSettingsRoute)
<flux:menu.item
:href="route($helpdeskSettingsRoute)"
@@ -173,16 +173,10 @@
@if ($section === 'categories')
<x-helpdesk.settings-section title="Categories"
description="Group tickets by support area, optionally per company.">
description="Group tickets by support area.">
<x-slot:form>
<form wire:submit.prevent="saveCategory" class="grid gap-3 xl:grid-cols-12">
<div class="xl:col-span-3">
<flux:select wire:model="categoryForm.company_id" label="Company">
<option value="">Global</option>
@foreach ($companies as $company)
<option value="{{ $company->id }}">{{ $company->name }}</option>
@endforeach
</flux:select>
</div>
<div class="xl:col-span-3">
<flux:input wire:model="categoryForm.name" label="Name" />
@@ -215,16 +209,10 @@
@if ($section === 'teams')
<x-helpdesk.settings-section title="Teams"
description="Create support teams under each company and assign projects to them.">
description="Create support teams and assign projects to them.">
<x-slot:form>
<form wire:submit.prevent="saveTeam" class="grid gap-3 xl:grid-cols-12">
<div class="xl:col-span-3">
<flux:select wire:model="teamForm.company_id" label="Company">
<option value="">Global</option>
@foreach ($companies as $company)
<option value="{{ $company->id }}">{{ $company->name }}</option>
@endforeach
</flux:select>
</div>
<div class="xl:col-span-3">
<flux:input wire:model="teamForm.name" label="Name" />
@@ -257,16 +245,10 @@
@if ($section === 'projects')
<x-helpdesk.settings-section title="Projects"
description="Create company projects and optionally assign them to support teams.">
description="Create projects and optionally assign them to support teams.">
<x-slot:form>
<form wire:submit.prevent="saveProject" class="grid gap-3 xl:grid-cols-12">
<div class="xl:col-span-2">
<flux:select wire:model="projectForm.company_id" label="Company">
<option value="">Global</option>
@foreach ($companies as $company)
<option value="{{ $company->id }}">{{ $company->name }}</option>
@endforeach
</flux:select>
</div>
<div class="xl:col-span-2">
<flux:select wire:model="projectForm.team_id" label="Team">
@@ -420,12 +402,6 @@
description="Configure repositories that can be linked to projects and tickets. Commits can then be stored against tickets for traceability.">
<x-slot:form>
<form wire:submit.prevent="saveRepository" class="grid gap-3 md:grid-cols-6">
<flux:select wire:model="repositoryForm.company_id" label="Company">
<option value="">Global</option>
@foreach ($companies as $company)
<option value="{{ $company->id }}">{{ $company->name }}</option>
@endforeach
</flux:select>
<flux:select wire:model="repositoryForm.project_id" label="Project">
<option value="">No project</option>
@foreach ($projects as $project)
@@ -439,7 +415,7 @@
<flux:input wire:model="repositoryForm.webhook_secret" label="Webhook secret" />
<div class="md:col-span-full">
<flux:input wire:model="repositoryForm.remote_url" label="Remote URL"
placeholder="git@github.com:company/repo.git" />
placeholder="git@github.com:organisation/repo.git" />
</div>
<div class="md:col-span-full flex gap-2">
<flux:button size="sm" type="submit" variant="primary"
@@ -73,30 +73,6 @@
@enderror
</div>
@if ($companies->isNotEmpty())
<div class="xl:col-span-2">
<label class="block text-sm font-semibold text-zinc-200" for="company_id">
Company <span class="helpdesk-required-star">*</span>
</label>
<div class="mt-2" wire:ignore>
<select id="company_id" dusk="ticket-company" data-helpdesk-select2 wire:model.defer="company_id" name="company_id"
data-placeholder="Select option" data-wire-model="company_id"
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">Select company</option>
@foreach ($companies as $company)
<option value="{{ $company->id }}" @selected((string) $company_id === (string) $company->id)>
{{ $company->name }}</option>
@endforeach
</select>
</div>
@error('company_id')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
@endif
<div class="xl:col-span-2">
<label class="block text-sm font-semibold text-zinc-200" for="board_id">
Board <span class="helpdesk-required-star">*</span>
@@ -59,30 +59,6 @@
@enderror
</div>
@if ($companies->isNotEmpty())
<div class="xl:col-span-2">
<label class="block text-sm font-semibold text-zinc-200" for="company_id">
Company <span class="helpdesk-required-star">*</span>
</label>
<div class="mt-2" wire:ignore>
<select id="company_id" data-helpdesk-select2 data-placeholder="Select option"
data-wire-model="company_id"
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">Select company</option>
@foreach ($companies as $company)
<option value="{{ $company->id }}" @selected((string) $company_id === (string) $company->id)>
{{ $company->name }}</option>
@endforeach
</select>
</div>
@error('company_id')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
@endif
<div class="xl:col-span-2">
<label class="block text-sm font-semibold text-zinc-200" for="board_id">Board</label>
@@ -7,7 +7,7 @@
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Ticket management</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight">Active tickets</h1>
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">Search, triage and manage support requests across
companies, projects, teams, priorities and statuses.</p>
projects, teams, priorities and statuses.</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ route('helpdesk.index') }}" wire:navigate class="hd-btn hd-btn-blue">
@@ -9,11 +9,11 @@
use Illuminate\Support\Str;
use Modules\Helpdesk\Models\TicketTimeLog;
$currentUser = auth()->user();
$currentCompany = $currentUser?->company;
$canSeeInternalNotes =
(bool) ($currentCompany?->is_system_company ?? false) ||
Str::lower((string) ($currentCompany?->slug ?? '')) === 'mpba-limited' ||
Str::contains(Str::lower((string) ($currentCompany?->name ?? '')), 'mpba');
(bool) ($currentUser?->helpdesk_admin ?? false) ||
(bool) ($currentUser?->helpdesk_agent ?? false) ||
(method_exists($currentUser, 'isHelpdeskAdmin') && $currentUser->isHelpdeskAdmin()) ||
(method_exists($currentUser, 'isHelpdeskAgent') && $currentUser->isHelpdeskAgent());
$visibleCommentCount = $ticket->comments->count();
$ticketTimeLogs = TicketTimeLog::query()
->with('user')
@@ -1285,10 +1285,6 @@
<dt class="text-zinc-500">Email</dt>
<dd class="truncate">{{ $ticket->owner?->email ?? 'None' }}</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-zinc-500">Company</dt>
<dd>{{ $ticket->owner?->company?->name ?? 'Current company' }}</dd>
</div>
</dl>
@if ($ticket->tags->isNotEmpty())