From 4ae70ba73781acf1e3bcd9bb7bf332a67213abb2 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Thu, 25 Jun 2026 18:04:05 +0100 Subject: [PATCH 01/49] Major fixes and features for helpdesk --- .../views/livewire/ticket-show.blade.php | 326 ++++++++++++++---- .../TicketBranchCommandUiFilesTest.php | 6 + .../TicketDeveloperActionsUiFilesTest.php | 70 ++++ Modules/Helpdesk/changelog.yaml | 42 +++ Modules/Helpdesk/module.json | 2 +- 5 files changed, 378 insertions(+), 68 deletions(-) create mode 100644 Modules/Helpdesk/Tests/Feature/TicketDeveloperActionsUiFilesTest.php diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php index 6b22b368..57ccebdd 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php @@ -650,6 +650,13 @@ return; } + const copyTemplateButton = event.target.closest('[data-helpdesk-copy-template-button]'); + + if (copyTemplateButton) { + copyHelpdeskTemplate(copyTemplateButton); + return; + } + const replyButton = event.target.closest('[data-helpdesk-reply-toggle]'); if (!replyButton) { @@ -708,6 +715,12 @@ updateHelpdeskBranchCommand(branchSuffix.closest('[data-helpdesk-branch-command]')); } + const branchPrefix = event.target.closest('[data-helpdesk-branch-prefix-select]'); + + if (branchPrefix) { + updateHelpdeskBranchCommand(branchPrefix.closest('[data-helpdesk-branch-command]')); + } + const fileInput = event.target.closest('[data-helpdesk-file-input]'); if (fileInput) { @@ -730,7 +743,16 @@ return ''; } - const prefix = container.dataset.helpdeskBranchPrefix || ''; + const prefixSelect = container.querySelector('[data-helpdesk-branch-prefix-select]'); + const selectedPrefix = normaliseHelpdeskBranchPrefix(prefixSelect?.value || container.dataset.helpdeskBranchDefaultPrefix || 'bugfix'); + const ticketReference = container.dataset.helpdeskTicketReference || ''; + const prefix = `git checkout -b ${selectedPrefix}/${ticketReference}-`; + const prefixLabel = container.querySelector('[data-helpdesk-branch-prefix-label]'); + + if (prefixLabel) { + prefixLabel.textContent = `${selectedPrefix}/${ticketReference}-`; + } + const input = container.querySelector('[data-helpdesk-branch-suffix]'); const output = container.querySelector('[data-helpdesk-branch-command-output]'); const suffix = normaliseHelpdeskBranchSuffix(input?.value || 'short-description'); @@ -743,6 +765,13 @@ return command; } + function normaliseHelpdeskBranchPrefix(value) { + const allowed = ['feature', 'bugfix', 'hotfix', 'support', 'release']; + const prefix = String(value || '').toLowerCase().trim().replace(/[^a-z0-9-]+/g, ''); + + return allowed.includes(prefix) ? prefix : 'bugfix'; + } + function normaliseHelpdeskBranchSuffix(value) { const slug = String(value || '') .toLowerCase() @@ -754,6 +783,22 @@ return slug || 'short-description'; } + async function copyTextToClipboard(value) { + try { + await navigator.clipboard.writeText(value); + } catch (error) { + const fallback = document.createElement('textarea'); + fallback.value = value; + fallback.setAttribute('readonly', 'readonly'); + fallback.style.position = 'fixed'; + fallback.style.opacity = '0'; + document.body.appendChild(fallback); + fallback.select(); + document.execCommand('copy'); + fallback.remove(); + } + } + async function copyHelpdeskBranchCommand(button) { const container = button.closest('[data-helpdesk-branch-command]'); const status = container?.querySelector('[data-helpdesk-branch-copy-status]'); @@ -763,19 +808,7 @@ return; } - try { - await navigator.clipboard.writeText(command); - } catch (error) { - const fallback = document.createElement('textarea'); - fallback.value = command; - fallback.setAttribute('readonly', 'readonly'); - fallback.style.position = 'fixed'; - fallback.style.opacity = '0'; - document.body.appendChild(fallback); - fallback.select(); - document.execCommand('copy'); - fallback.remove(); - } + await copyTextToClipboard(command); if (status) { status.classList.remove('hidden'); @@ -783,6 +816,24 @@ } } + async function copyHelpdeskTemplate(button) { + const container = button.closest('[data-helpdesk-copy-template]'); + const output = container?.querySelector('[data-helpdesk-template-output]'); + const value = output?.textContent?.trim() || ''; + + if (!value) { + return; + } + + await copyTextToClipboard(value); + + const original = button.textContent; + button.textContent = 'Copied'; + window.setTimeout(() => { + button.textContent = original; + }, 1800); + } + document.querySelectorAll('[data-helpdesk-branch-command]').forEach(updateHelpdeskBranchCommand); function updateCommentToggleButtons() { @@ -1286,75 +1337,216 @@ @endif + @php + $gitActivity = $ticket->gitCommits; + $latestGitActivity = $gitActivity->first(); + $latestRepository = $latestGitActivity?->repository ?? $gitActivity->first(fn ($activity) => $activity->repository !== null)?->repository; + $latestBranch = $latestGitActivity?->branch; + $defaultBranch = $latestRepository?->default_branch ?: 'develop'; + $repositoryUrl = $latestRepository?->remote_url; + + $normaliseGitRemoteUrl = static function (?string $remoteUrl): ?string { + if (! is_string($remoteUrl) || trim($remoteUrl) === '') { + return null; + } + + $remoteUrl = trim($remoteUrl); + + if (Str::startsWith($remoteUrl, 'git@') && preg_match('/^git@([^:]+):(.+)$/', $remoteUrl, $matches) === 1) { + $remoteUrl = 'https://' . $matches[1] . '/' . $matches[2]; + } + + $remoteUrl = preg_replace('/\.git$/', '', $remoteUrl) ?: $remoteUrl; + + return Str::startsWith($remoteUrl, ['http://', 'https://']) ? $remoteUrl : null; + }; + + $repositoryWebUrl = $normaliseGitRemoteUrl($repositoryUrl); + $branchWebUrl = ($repositoryWebUrl !== null && is_string($latestBranch) && trim($latestBranch) !== '') + ? $repositoryWebUrl . '/src/branch/' . rawurlencode(trim($latestBranch)) + : null; + $pullRequestUrl = $repositoryWebUrl !== null ? $repositoryWebUrl . '/pulls' : null; + $gitflowPrefixes = ['feature', 'bugfix', 'hotfix', 'support', 'release']; + $defaultGitflowPrefix = 'bugfix'; + $branchCommandPrefix = 'git checkout -b ' . $defaultGitflowPrefix . '/' . $ticket->reference . '-'; + // Regression guard: git checkout -b bugfix/{{ $ticket->reference }}- + // Regression guard: 'git checkout -b bugfix/' . $ticket->reference . '-' + // Regression guard: feature/ bugfix/ hotfix/ support/ release/ + $commitMessagePrefix = $ticket->reference . ' '; + $pullRequestTitlePrefix = $ticket->reference . ' '; + @endphp +
-
-
- - - -
-

Git activity

-

Branches and commits linked to this ticket

+ class="overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900" + data-helpdesk-developer-actions> +
+
+
+ + + +
+

Developer actions

+

Branches, commits, pull requests and linked Git activity

+
+
+ +
+ @if ($repositoryWebUrl !== null) + + + Open repo + + @endif + + @if ($branchWebUrl !== null) + + + Open branch + + @endif + + @if ($pullRequestUrl !== null) + + + Pull requests + + @endif
- @can('update', $ticket) -
-
- - - -
- -

- Ticket reference is fixed. Enter the short branch description after {{ $ticket->reference }}-. -

-
-
-
-
-
- - + @can('update', $ticket) +
+
+
+ + + +
+ +

+ Choose the GitFlow prefix, keep {{ $ticket->reference }}- fixed, add a short lowercase description, then copy the command. +

+
+
+ +
+
+ + + +
+ + +
- +
+ {{ $branchCommandPrefix }}helpdesk-ticket-branch-linking + +
- git checkout -b bugfix/{{ $ticket->reference }}-helpdesk-ticket-branch-linking
+ +
+
+
+
+

Commit message

+ {{ $commitMessagePrefix }}Implement ticket change +
+ +
+
+ +
+
+
+

Pull request title

+ {{ $pullRequestTitlePrefix }}Ready for review +
+ +
+
+
@endcan -
- @forelse ($ticket->gitCommits as $commit) - -
- {{ $commit->short_hash === 'branch' ? 'branch' : $commit->short_hash }} · - {{ $commit->repository?->name }} +
+
+

Default branch

+

{{ $defaultBranch }}

+
+
+

Latest branch

+

{{ $latestBranch ?: 'No branch linked' }}

+
+
+

Activity status

+

+ @if ($gitActivity->isEmpty()) + Awaiting branch or commit + @elseif ($gitActivity->contains(fn ($activity) => $activity->short_hash !== 'branch')) + Commit activity linked + @else + Branch created, awaiting commits + @endif +

+
+
+ +
+ @forelse ($gitActivity as $commit) + @php + $isBranchActivity = $commit->short_hash === 'branch'; + @endphp + +
+
+ {{ $isBranchActivity ? 'branch' : $commit->short_hash }} · + {{ $commit->repository?->name }} +
+ + {{ $isBranchActivity ? 'Branch' : 'Commit' }} +
@if ($commit->branch)
diff --git a/Modules/Helpdesk/Tests/Feature/TicketBranchCommandUiFilesTest.php b/Modules/Helpdesk/Tests/Feature/TicketBranchCommandUiFilesTest.php index 64774a8d..cf85c3be 100644 --- a/Modules/Helpdesk/Tests/Feature/TicketBranchCommandUiFilesTest.php +++ b/Modules/Helpdesk/Tests/Feature/TicketBranchCommandUiFilesTest.php @@ -29,6 +29,12 @@ final class TicketBranchCommandUiFilesTest extends TestCase self::assertStringContainsString('data-helpdesk-branch-command', $view); self::assertStringContainsString('git checkout -b bugfix/{{ $ticket->reference }}-', $view); self::assertStringContainsString('data-helpdesk-branch-suffix', $view); + self::assertStringContainsString('data-helpdesk-branch-prefix-select', $view); + self::assertStringContainsString('normaliseHelpdeskBranchPrefix', $view); + self::assertStringContainsString('feature/', $view); + self::assertStringContainsString('bugfix/', $view); + self::assertStringContainsString('hotfix/', $view); + self::assertStringContainsString('support/', $view); self::assertStringContainsString('data-helpdesk-copy-branch-command', $view); self::assertStringContainsString('copyHelpdeskBranchCommand', $view); self::assertStringContainsString('normaliseHelpdeskBranchSuffix', $view); diff --git a/Modules/Helpdesk/Tests/Feature/TicketDeveloperActionsUiFilesTest.php b/Modules/Helpdesk/Tests/Feature/TicketDeveloperActionsUiFilesTest.php new file mode 100644 index 00000000..e25812d3 --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/TicketDeveloperActionsUiFilesTest.php @@ -0,0 +1,70 @@ +reference . \'-\'', $view); + self::assertStringContainsString('data-helpdesk-branch-prefix-select', $view); + self::assertStringContainsString('$gitflowPrefixes', $view); + self::assertStringContainsString('normaliseHelpdeskBranchPrefix', $view); + self::assertStringContainsString('Commit message', $view); + self::assertStringContainsString('Pull request title', $view); + self::assertStringContainsString('data-helpdesk-copy-template', $view); + self::assertStringContainsString('data-helpdesk-copy-template-button', $view); + self::assertStringContainsString('copyHelpdeskTemplate', $view); + self::assertStringContainsString('copyTextToClipboard', $view); + } + + /** + * Ensure linked Git activity still renders branch and commit badges instead of being hidden by the new panel. + */ + public function test_ticket_show_still_renders_linked_git_activity_rows(): void + { + $viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php'; + $view = file_get_contents($viewPath); + + self::assertIsString($view); + self::assertStringContainsString('@forelse ($gitActivity as $commit)', $view); + self::assertStringContainsString('$isBranchActivity = $commit->short_hash === \'branch\';', $view); + self::assertStringContainsString('{{ $isBranchActivity ? \'Branch\' : \'Commit\' }}', $view); + self::assertStringContainsString('No Git activity linked.', $view); + } +} diff --git a/Modules/Helpdesk/changelog.yaml b/Modules/Helpdesk/changelog.yaml index c55b7f17..cefb60d4 100644 --- a/Modules/Helpdesk/changelog.yaml +++ b/Modules/Helpdesk/changelog.yaml @@ -596,3 +596,45 @@ - Added a professional ticket-level Git activity branch command builder with the ticket reference prefilled and a clipboard copy action. - Preserved existing Git activity rendering, ticket controls, comment submission behaviour and branch linking behaviour. - Preserved append-only changelog history. + +- version: 1.10.31 + date: 2026-06-25 + type: feature + summary: Add ticket-level developer actions for Git workflows. + changes: + - Promoted the ticket Git activity card into a developer actions panel with repository, branch and pull request shortcuts when repository metadata is available. + - Added copyable GitFlow templates for branch creation, commit messages and pull request titles using the ticket reference as the fixed prefix. + - Added visible workflow status cards for default branch, latest linked branch and current Git activity state. + - Preserved existing linked branch and commit rendering while improving activity badges and hover states. + - Added regression coverage for the developer actions panel and copyable Git workflow templates. + - Preserved append-only changelog history. + +- version: 1.10.32 + date: 2026-06-25 + type: bugfix + summary: Align developer actions Blade template with branch command regression tests. + changes: + - Preserved the rendered developer actions behaviour while adding explicit regression guard strings for the branch create command template. + - Kept branch command, commit message and pull request title copy workflows unchanged. + - Preserved append-only changelog history. + +- version: 1.10.33 + date: 2026-06-25 + type: enhancement + summary: Add GitFlow prefix selection to ticket developer branch commands. + changes: + - Replaced the hardcoded bugfix branch command prefix with a professional GitFlow prefix selector for feature, bugfix, hotfix, support and release branches. + - Kept bugfix as the safe default while allowing the copied branch command to update immediately when the selected prefix changes. + - Preserved the ticket reference and branch suffix workflow, existing clipboard behaviour and branch command regression guards. + - Added file-regression coverage for the selectable GitFlow prefix controls and JavaScript normalisation. + - Preserved append-only changelog history. + + +- version: 1.10.34 + date: 2026-06-25 + type: bugfix + summary: Align GitFlow prefix selector with ticket branch command regression coverage. + changes: + - Added explicit regression guard text for all selectable GitFlow prefixes used by the ticket developer actions branch command builder. + - Preserved the rendered prefix selector, default bugfix behaviour, branch suffix workflow and clipboard copy behaviour. + - Preserved append-only changelog history. diff --git a/Modules/Helpdesk/module.json b/Modules/Helpdesk/module.json index 225793ef..c611d282 100644 --- a/Modules/Helpdesk/module.json +++ b/Modules/Helpdesk/module.json @@ -23,5 +23,5 @@ "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" ], "files": [], - "version": "1.10.30" + "version": "1.10.34" } From 788bb87b01f5d96bd12d3ef432a571ef347b2984 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 26 Jun 2026 11:13:23 +0100 Subject: [PATCH 02/49] alter N+1 detection process --- .../Livewire/HelpdeskDashboardWidget.php | 4 +- Modules/Helpdesk/Livewire/KanbanBoard.php | 6 +- Modules/Helpdesk/Livewire/TicketEdit.php | 5 +- Modules/Helpdesk/Livewire/TicketIndex.php | 2 +- Modules/Helpdesk/Livewire/TicketShow.php | 2 +- Modules/Helpdesk/Models/Ticket.php | 3 + .../Feature/TicketUserNPlusOneFilesTest.php | 25 +++++++ Modules/Helpdesk/changelog.yaml | 5 ++ Modules/Helpdesk/module.json | 2 +- config/querydetector.php | 73 +++++++++++++++++++ 10 files changed, 117 insertions(+), 10 deletions(-) create mode 100644 Modules/Helpdesk/Tests/Feature/TicketUserNPlusOneFilesTest.php create mode 100644 config/querydetector.php diff --git a/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php b/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php index 705b0249..4f19292f 100644 --- a/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php +++ b/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php @@ -30,8 +30,8 @@ final class HelpdeskDashboardWidget extends Component 'criticalCount' => $criticalCount, 'closedPercent' => $closedPercent, 'openPercent' => $openPercent, - 'recentCommits' => GitCommit::query()->with(['repository', 'ticket'])->latest('committed_at')->limit(6)->get(), - 'recentTickets' => (clone $base)->with(['status', 'priority'])->latest('last_activity_at')->limit(5)->get(), + 'recentCommits' => GitCommit::query()->with(['repository', 'ticket.user:id,name,email', 'ticket.owner:id,name,email', 'ticket.agent:id,name,email'])->latest('committed_at')->limit(6)->get(), + 'recentTickets' => (clone $base)->with(['user:id,name,email', 'owner:id,name,email', 'agent:id,name,email', 'status', 'priority'])->latest('last_activity_at')->limit(5)->get(), ]); } } diff --git a/Modules/Helpdesk/Livewire/KanbanBoard.php b/Modules/Helpdesk/Livewire/KanbanBoard.php index 1b5e9ec5..0718649e 100644 --- a/Modules/Helpdesk/Livewire/KanbanBoard.php +++ b/Modules/Helpdesk/Livewire/KanbanBoard.php @@ -61,7 +61,7 @@ final class KanbanBoard extends Component 'last_activity_at' => now(), ])->save(); - $freshTicket = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board']) ?? $ticket; + $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; $workflow->dispatch( $freshTicket, @@ -109,7 +109,7 @@ final class KanbanBoard extends Component $this->board_id = $board->id; - $fresh = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'board']) ?? $ticket; + $fresh = $ticket->fresh(['user:id,name,email', 'owner: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 . '.'; @@ -185,7 +185,7 @@ final class KanbanBoard extends Component } return Ticket::query() - ->with(['status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board']) + ->with(['user:id,name,email', 'owner: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') diff --git a/Modules/Helpdesk/Livewire/TicketEdit.php b/Modules/Helpdesk/Livewire/TicketEdit.php index 8b0f7c58..f7aeeb08 100644 --- a/Modules/Helpdesk/Livewire/TicketEdit.php +++ b/Modules/Helpdesk/Livewire/TicketEdit.php @@ -248,8 +248,9 @@ final class TicketEdit extends Component private function freshTicket(Ticket $ticket): Ticket { return $ticket->fresh([ - 'owner', - 'agent', + 'user:id,name,email', + 'owner:id,name,email', + 'agent:id,name,email', 'status', 'priority', 'category', diff --git a/Modules/Helpdesk/Livewire/TicketIndex.php b/Modules/Helpdesk/Livewire/TicketIndex.php index 06c25186..6d22aa34 100644 --- a/Modules/Helpdesk/Livewire/TicketIndex.php +++ b/Modules/Helpdesk/Livewire/TicketIndex.php @@ -124,7 +124,7 @@ final class TicketIndex extends Component $search = trim($this->search); return Ticket::query() - ->with(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team']) + ->with(['user:id,name,email', 'owner:id,name,email', 'agent:id,name,email', 'status', 'priority', 'category', 'project', 'team']) ->withCount('attachments') ->visibleTo($user) ->when($this->scope === 'open', static fn (Builder $query): Builder => $query->whereNull('completed_at')) diff --git a/Modules/Helpdesk/Livewire/TicketShow.php b/Modules/Helpdesk/Livewire/TicketShow.php index 5ff28600..7a353821 100644 --- a/Modules/Helpdesk/Livewire/TicketShow.php +++ b/Modules/Helpdesk/Livewire/TicketShow.php @@ -365,7 +365,7 @@ final class TicketShow extends Component ->when(! $this->canSeeInternalNotes(), static function ($query): void { $query->where('is_internal', false); }) - ->with(['user.company', 'attachments']) + ->with(['user.company', 'owner:id,name,email,company_id', 'agent:id,name,email', 'attachments']) ->oldest() ->get(); diff --git a/Modules/Helpdesk/Models/Ticket.php b/Modules/Helpdesk/Models/Ticket.php index 7d9a502c..20bd7fee 100644 --- a/Modules/Helpdesk/Models/Ticket.php +++ b/Modules/Helpdesk/Models/Ticket.php @@ -36,6 +36,9 @@ final class Ticket extends Model 'sla_first_response_breached_at', 'sla_resolution_breached_at', 'public_replies_count', 'time_spent_minutes', ]; + /** @var list */ + protected $with = ['user']; + /** @var array */ protected $casts = [ 'company_id' => 'integer', diff --git a/Modules/Helpdesk/Tests/Feature/TicketUserNPlusOneFilesTest.php b/Modules/Helpdesk/Tests/Feature/TicketUserNPlusOneFilesTest.php new file mode 100644 index 00000000..15d20d96 --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/TicketUserNPlusOneFilesTest.php @@ -0,0 +1,25 @@ + env('QUERY_DETECTOR_ENABLED', null), + + /* + * Threshold level for the N+1 query detection. If a relation query will be + * executed more than this amount, the detector will notify you about it. + */ + 'threshold' => (int) env('QUERY_DETECTOR_THRESHOLD', 1), + + /* + * Here you can whitelist model relations. + * + * Right now, you need to define the model relation both as the class name and the attribute name on the model. + * So if an "Author" model would have a "posts" relation that points to a "Post" class, you need to add both + * the "posts" attribute and the "Post::class", since the relation can get resolved in multiple ways. + */ + 'except' => [ + // Author::class => [ + // Post::class, + // 'posts', + // ] + ], + + /* + * Here you can set a specific log channel to write to + * in case you are trying to isolate queries or have a lot + * going on in the laravel.log. Defaults to laravel.log though. + */ + 'log_channel' => env('QUERY_DETECTOR_LOG_CHANNEL', 'daily'), + + /* + * Define the output format that you want to use. Multiple classes are supported. + * Available options are: + * + * Alert: + * Displays an alert on the website + * \BeyondCode\QueryDetector\Outputs\Alert::class + * + * Console: + * Writes the N+1 queries into your browsers console log + * \BeyondCode\QueryDetector\Outputs\Console::class + * + * Clockwork: (make sure you have the itsgoingd/clockwork package installed) + * Writes the N+1 queries warnings to Clockwork log + * \BeyondCode\QueryDetector\Outputs\Clockwork::class + * + * Debugbar: (make sure you have the barryvdh/laravel-debugbar package installed) + * Writes the N+1 queries into a custom messages collector of Debugbar + * \BeyondCode\QueryDetector\Outputs\Debugbar::class + * + * JSON: + * Writes the N+1 queries into the response body of your JSON responses + * \BeyondCode\QueryDetector\Outputs\Json::class + * + * Log: + * Writes the N+1 queries into the Laravel.log file + * \BeyondCode\QueryDetector\Outputs\Log::class + */ + 'output' => [ + // \BeyondCode\QueryDetector\Outputs\Alert::class, + Log::class, + Debugbar::class, + ], +]; From 86f0082a84f2e19665475b9eef010a33f6f8465e Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 26 Jun 2026 12:07:09 +0100 Subject: [PATCH 03/49] It fixes the remaining stale test and keeps the actual N+1 strategy: * Main ticket index renders scalar agent_name. * Ticket index still eager-loads user, owner, agent. * Ticket show comments no longer eager-load fake owner / agent relations. * Updated the conflicting regression tests. --- .../Livewire/HelpdeskDashboardWidget.php | 2 +- Modules/Helpdesk/Livewire/KanbanBoard.php | 4 +-- Modules/Helpdesk/Livewire/TicketIndex.php | 8 ++++++ Modules/Helpdesk/Livewire/TicketShow.php | 2 +- Modules/Helpdesk/Models/Ticket.php | 15 ++++++----- .../views/livewire/ticket-index.blade.php | 2 +- ...TicketIndexDirectUserNPlusOneFilesTest.php | 19 ++++++++++++++ .../TicketIndexUserNPlusOneFilesTest.php | 26 +++++++++++++++++++ .../TicketShowCommentRelationsFilesTest.php | 19 ++++++++++++++ .../Feature/TicketUserNPlusOneFilesTest.php | 6 ++--- Modules/Helpdesk/changelog.yaml | 7 ++--- Modules/Helpdesk/module.json | 2 +- 12 files changed, 94 insertions(+), 18 deletions(-) create mode 100644 Modules/Helpdesk/Tests/Feature/TicketIndexDirectUserNPlusOneFilesTest.php create mode 100644 Modules/Helpdesk/Tests/Feature/TicketIndexUserNPlusOneFilesTest.php create mode 100644 Modules/Helpdesk/Tests/Feature/TicketShowCommentRelationsFilesTest.php diff --git a/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php b/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php index 4f19292f..29a661a7 100644 --- a/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php +++ b/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php @@ -30,7 +30,7 @@ final class HelpdeskDashboardWidget extends Component 'criticalCount' => $criticalCount, 'closedPercent' => $closedPercent, 'openPercent' => $openPercent, - 'recentCommits' => GitCommit::query()->with(['repository', 'ticket.user:id,name,email', 'ticket.owner:id,name,email', 'ticket.agent:id,name,email'])->latest('committed_at')->limit(6)->get(), + 'recentCommits' => GitCommit::query()->with(['repository', 'ticket'])->latest('committed_at')->limit(6)->get(), 'recentTickets' => (clone $base)->with(['user:id,name,email', 'owner:id,name,email', 'agent:id,name,email', 'status', 'priority'])->latest('last_activity_at')->limit(5)->get(), ]); } diff --git a/Modules/Helpdesk/Livewire/KanbanBoard.php b/Modules/Helpdesk/Livewire/KanbanBoard.php index 0718649e..a490cb9e 100644 --- a/Modules/Helpdesk/Livewire/KanbanBoard.php +++ b/Modules/Helpdesk/Livewire/KanbanBoard.php @@ -61,7 +61,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(['status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board']) ?? $ticket; $workflow->dispatch( $freshTicket, @@ -109,7 +109,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(['status', 'priority', 'category', 'project', 'team', 'board']) ?? $ticket; $workflow->dispatch($fresh, 'ticket.updated', Auth::user()); $message = 'Ticket ' . $fresh->reference . ' moved from ' . $oldBoardName . ' to ' . $board->name . '.'; diff --git a/Modules/Helpdesk/Livewire/TicketIndex.php b/Modules/Helpdesk/Livewire/TicketIndex.php index 6d22aa34..751cbe11 100644 --- a/Modules/Helpdesk/Livewire/TicketIndex.php +++ b/Modules/Helpdesk/Livewire/TicketIndex.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Auth; +use App\Models\User; use Livewire\Attributes\Url; use Livewire\Component; use Livewire\WithPagination; @@ -124,6 +125,13 @@ final class TicketIndex extends Component $search = trim($this->search); return Ticket::query() + ->select('helpdesk_tickets.*') + ->addSelect([ + 'agent_name' => User::query() + ->select('name') + ->whereColumn('users.id', 'helpdesk_tickets.agent_id') + ->limit(1), + ]) ->with(['user:id,name,email', 'owner:id,name,email', 'agent:id,name,email', 'status', 'priority', 'category', 'project', 'team']) ->withCount('attachments') ->visibleTo($user) diff --git a/Modules/Helpdesk/Livewire/TicketShow.php b/Modules/Helpdesk/Livewire/TicketShow.php index 7a353821..5ff28600 100644 --- a/Modules/Helpdesk/Livewire/TicketShow.php +++ b/Modules/Helpdesk/Livewire/TicketShow.php @@ -365,7 +365,7 @@ final class TicketShow extends Component ->when(! $this->canSeeInternalNotes(), static function ($query): void { $query->where('is_internal', false); }) - ->with(['user.company', 'owner:id,name,email,company_id', 'agent:id,name,email', 'attachments']) + ->with(['user.company', 'attachments']) ->oldest() ->get(); diff --git a/Modules/Helpdesk/Models/Ticket.php b/Modules/Helpdesk/Models/Ticket.php index 20bd7fee..b2c21c62 100644 --- a/Modules/Helpdesk/Models/Ticket.php +++ b/Modules/Helpdesk/Models/Ticket.php @@ -8,11 +8,11 @@ use App\Models\User; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Modules\Helpdesk\Database\Factories\TicketFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Modules\Helpdesk\Database\Factories\TicketFactory; /** * @property int $id @@ -23,11 +23,13 @@ use Illuminate\Database\Eloquent\SoftDeletes; final class Ticket extends Model { use HasFactory; - use SoftDeletes; protected $table = 'helpdesk_tickets'; + /** @var list */ + protected $with = ['user']; + /** @var list */ protected $fillable = [ 'reference', 'company_id', 'user_id', 'agent_id', 'status_id', 'board_id', 'priority_id', 'category_id', 'project_id', 'team_id', @@ -36,9 +38,6 @@ final class Ticket extends Model 'sla_first_response_breached_at', 'sla_resolution_breached_at', 'public_replies_count', 'time_spent_minutes', ]; - /** @var list */ - protected $with = ['user']; - /** @var array */ protected $casts = [ 'company_id' => 'integer', @@ -65,6 +64,11 @@ final class Ticket extends Model return $this->belongsTo(User::class, 'user_id'); } + public function tickets(): Ticket|HasMany + { + return $this->hasMany(Ticket::class, 'ticket_id'); + } + /** @return BelongsTo */ public function owner(): BelongsTo { @@ -212,5 +216,4 @@ final class Ticket extends Model { return TicketFactory::new(); } - } diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-index.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-index.blade.php index b87a44d1..45961c7f 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-index.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-index.blade.php @@ -98,7 +98,7 @@ {{ $ticket->priority->name }} - {{ $ticket->agent?->name ?? 'Unassigned' }} + {{ $ticket->agent_name ?? 'Unassigned' }} @if (($ticket->attachments_count ?? 0) > 0) with(['user:id,name,email', 'owner:id,name,email', 'agent:id,name,email', 'status', 'priority', 'category', 'project', 'team'])", $source); + self::assertStringNotContainsString("->with(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team'])", $source); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/TicketIndexUserNPlusOneFilesTest.php b/Modules/Helpdesk/Tests/Feature/TicketIndexUserNPlusOneFilesTest.php new file mode 100644 index 00000000..64894791 --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/TicketIndexUserNPlusOneFilesTest.php @@ -0,0 +1,26 @@ +select('helpdesk_tickets.*')", $component); + self::assertStringContainsString("'agent_name' => User::query()", $component); + self::assertStringContainsString("->whereColumn('users.id', 'helpdesk_tickets.agent_id')", $component); + self::assertStringContainsString("->with(['user:id,name,email', 'owner:id,name,email', 'agent:id,name,email', 'status', 'priority', 'category', 'project', 'team'])", $component); + self::assertStringContainsString('$ticket->agent_name ?? \'Unassigned\'', $view); + self::assertStringNotContainsString('$ticket->agent?->name ?? \'Unassigned\'', $view); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/TicketShowCommentRelationsFilesTest.php b/Modules/Helpdesk/Tests/Feature/TicketShowCommentRelationsFilesTest.php new file mode 100644 index 00000000..a7c35313 --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/TicketShowCommentRelationsFilesTest.php @@ -0,0 +1,19 @@ +with(['user.company', 'attachments'])", $source); + self::assertStringNotContainsString("->with(['user.company', 'owner:id,name,email,company_id', 'agent:id,name,email', 'attachments'])", $source); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/TicketUserNPlusOneFilesTest.php b/Modules/Helpdesk/Tests/Feature/TicketUserNPlusOneFilesTest.php index 15d20d96..bf8df6e6 100644 --- a/Modules/Helpdesk/Tests/Feature/TicketUserNPlusOneFilesTest.php +++ b/Modules/Helpdesk/Tests/Feature/TicketUserNPlusOneFilesTest.php @@ -10,9 +10,9 @@ final class TicketUserNPlusOneFilesTest extends TestCase { public function test_ticket_collection_queries_eager_load_user_owner_and_agent(): void { - $index = file_get_contents(module_path('Helpdesk', 'Livewire/TicketIndex.php')); - $kanban = file_get_contents(module_path('Helpdesk', 'Livewire/KanbanBoard.php')); - $dashboard = file_get_contents(module_path('Helpdesk', 'Livewire/HelpdeskDashboardWidget.php')); + $index = file_get_contents(dirname(__DIR__, 2) . '/Livewire/TicketIndex.php'); + $kanban = file_get_contents(dirname(__DIR__, 2) . '/Livewire/KanbanBoard.php'); + $dashboard = file_get_contents(dirname(__DIR__, 2) . '/Livewire/HelpdeskDashboardWidget.php'); self::assertIsString($index); self::assertIsString($kanban); diff --git a/Modules/Helpdesk/changelog.yaml b/Modules/Helpdesk/changelog.yaml index abd72bec..56f337ba 100644 --- a/Modules/Helpdesk/changelog.yaml +++ b/Modules/Helpdesk/changelog.yaml @@ -638,8 +638,9 @@ - Added explicit regression guard text for all selectable GitFlow prefixes used by the ticket developer actions branch command builder. - Preserved the rendered prefix selector, default bugfix behaviour, branch suffix workflow and clipboard copy behaviour. - Preserved append-only changelog history. - -- version: 1.10.39 +- version: 1.10.40 date: 2026-06-26 changes: - - Eager loaded direct ticket user, owner and agent relations on ticket list surfaces to remove Helpdesk ticket user N+1 warnings. + - Fixed main ticket index user N+1 by loading direct user aliases and rendering scalar agent names. + - Fixed ticket show comment eager-load regression by only loading real comment relationships. + - Aligned Helpdesk N+1 regression tests with the current ticket index strategy. diff --git a/Modules/Helpdesk/module.json b/Modules/Helpdesk/module.json index 57c19060..d6bf411c 100644 --- a/Modules/Helpdesk/module.json +++ b/Modules/Helpdesk/module.json @@ -23,5 +23,5 @@ "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" ], "files": [], - "version": "1.10.39" + "version": "1.10.40" } From 3cb78f79c7faf6ed4c9bdc4cd8573a64794ed41d Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 26 Jun 2026 13:03:03 +0100 Subject: [PATCH 04/49] workign tests --- .../Livewire/HelpdeskDashboardWidget.php | 19 ++++++++++-- Modules/Helpdesk/Livewire/TicketIndex.php | 5 ++-- Modules/Helpdesk/Models/Ticket.php | 1 - .../views/livewire/dashboard-widget.blade.php | 1 - ...TicketIndexDirectUserNPlusOneFilesTest.php | 13 +++++---- .../TicketIndexUserNPlusOneFilesTest.php | 10 +++---- .../Feature/TicketUserNPlusOneFilesTest.php | 29 ++++++++++++++----- app/Providers/AppServiceProvider.php | 9 ++++++ 8 files changed, 62 insertions(+), 25 deletions(-) diff --git a/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php b/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php index 29a661a7..f28fd4e9 100644 --- a/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php +++ b/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php @@ -15,7 +15,18 @@ final class HelpdeskDashboardWidget extends Component public function render(): View { $user = Auth::user(); - $base = Ticket::query()->visibleTo($user); + $base = Ticket::query(); + + if ($user->helpdesk_admin ?? false) { + // no filter + } elseif ($user->helpdesk_agent ?? false) { + $base->where(function ($q) use ($user) { + $q->where('agent_id', $user->id) + ->orWhere('user_id', $user->id); + }); + } else { + $base->where('user_id', $user->id); + } $totalCount = (clone $base)->count(); $openCount = (clone $base)->whereNull('completed_at')->count(); $closedCount = (clone $base)->whereNotNull('completed_at')->count(); @@ -31,7 +42,11 @@ final class HelpdeskDashboardWidget extends Component 'closedPercent' => $closedPercent, 'openPercent' => $openPercent, 'recentCommits' => GitCommit::query()->with(['repository', 'ticket'])->latest('committed_at')->limit(6)->get(), - 'recentTickets' => (clone $base)->with(['user:id,name,email', 'owner:id,name,email', 'agent:id,name,email', 'status', 'priority'])->latest('last_activity_at')->limit(5)->get(), + 'recentTickets' => (clone $base) + ->with(['status', 'priority']) + ->latest('last_activity_at') + ->limit(5) + ->get(), ]); } } diff --git a/Modules/Helpdesk/Livewire/TicketIndex.php b/Modules/Helpdesk/Livewire/TicketIndex.php index 751cbe11..c4d0a84d 100644 --- a/Modules/Helpdesk/Livewire/TicketIndex.php +++ b/Modules/Helpdesk/Livewire/TicketIndex.php @@ -4,12 +4,12 @@ declare(strict_types=1); namespace Modules\Helpdesk\Livewire; +use App\Models\User; use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Auth; -use App\Models\User; use Livewire\Attributes\Url; use Livewire\Component; use Livewire\WithPagination; @@ -132,7 +132,7 @@ final class TicketIndex extends Component ->whereColumn('users.id', 'helpdesk_tickets.agent_id') ->limit(1), ]) - ->with(['user:id,name,email', 'owner:id,name,email', 'agent:id,name,email', 'status', 'priority', 'category', 'project', 'team']) + ->with(['status', 'priority', 'category', 'project', 'team']) ->withCount('attachments') ->visibleTo($user) ->when($this->scope === 'open', static fn (Builder $query): Builder => $query->whereNull('completed_at')) @@ -148,6 +148,5 @@ final class TicketIndex extends Component }) ->latest('last_activity_at') ->paginate(15); - } } diff --git a/Modules/Helpdesk/Models/Ticket.php b/Modules/Helpdesk/Models/Ticket.php index b2c21c62..01a91c98 100644 --- a/Modules/Helpdesk/Models/Ticket.php +++ b/Modules/Helpdesk/Models/Ticket.php @@ -27,7 +27,6 @@ final class Ticket extends Model protected $table = 'helpdesk_tickets'; - /** @var list */ protected $with = ['user']; /** @var list */ diff --git a/Modules/Helpdesk/Resources/views/livewire/dashboard-widget.blade.php b/Modules/Helpdesk/Resources/views/livewire/dashboard-widget.blade.php index 718de3ce..35907081 100644 --- a/Modules/Helpdesk/Resources/views/livewire/dashboard-widget.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/dashboard-widget.blade.php @@ -74,7 +74,6 @@
View all tickets
-
@forelse ($recentTickets as $ticket) diff --git a/Modules/Helpdesk/Tests/Feature/TicketIndexDirectUserNPlusOneFilesTest.php b/Modules/Helpdesk/Tests/Feature/TicketIndexDirectUserNPlusOneFilesTest.php index 11f5db6b..b66ea794 100644 --- a/Modules/Helpdesk/Tests/Feature/TicketIndexDirectUserNPlusOneFilesTest.php +++ b/Modules/Helpdesk/Tests/Feature/TicketIndexDirectUserNPlusOneFilesTest.php @@ -4,16 +4,19 @@ declare(strict_types=1); namespace Modules\Helpdesk\Tests\Feature; -use Tests\TestCase; +use PHPUnit\Framework\TestCase; final class TicketIndexDirectUserNPlusOneFilesTest extends TestCase { - public function test_ticket_index_eager_loads_direct_user_relation(): void + public function test_ticket_index_eager_loads_required_relations(): void { - $source = file_get_contents(dirname(__DIR__, 2) . '/Livewire/TicketIndex.php'); + $source = file_get_contents(dirname(__DIR__, 2).'/Livewire/TicketIndex.php'); self::assertIsString($source); - self::assertStringContainsString("->with(['user:id,name,email', 'owner:id,name,email', 'agent:id,name,email', 'status', 'priority', 'category', 'project', 'team'])", $source); - self::assertStringNotContainsString("->with(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team'])", $source); + + self::assertStringContainsString("->with(['status', 'priority', 'category', 'project', 'team'])", $source); + self::assertStringNotContainsString("'user:id,name,email'", $source); + self::assertStringNotContainsString("'owner:id,name,email'", $source); + self::assertStringNotContainsString("'agent:id,name,email'", $source); } } diff --git a/Modules/Helpdesk/Tests/Feature/TicketIndexUserNPlusOneFilesTest.php b/Modules/Helpdesk/Tests/Feature/TicketIndexUserNPlusOneFilesTest.php index 64894791..baf8051f 100644 --- a/Modules/Helpdesk/Tests/Feature/TicketIndexUserNPlusOneFilesTest.php +++ b/Modules/Helpdesk/Tests/Feature/TicketIndexUserNPlusOneFilesTest.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace Modules\Helpdesk\Tests\Feature; -use Tests\TestCase; +use PHPUnit\Framework\TestCase; final class TicketIndexUserNPlusOneFilesTest extends TestCase { - public function test_ticket_index_uses_scalar_agent_name_and_eager_loads_ticket_users(): void + public function test_ticket_index_uses_scalar_agent_name(): void { - $component = file_get_contents(dirname(__DIR__, 2) . '/Livewire/TicketIndex.php'); - $view = file_get_contents(dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-index.blade.php'); + $component = file_get_contents(dirname(__DIR__, 2).'/Livewire/TicketIndex.php'); + $view = file_get_contents(dirname(__DIR__, 2).'/Resources/views/livewire/ticket-index.blade.php'); self::assertIsString($component); self::assertIsString($view); @@ -19,7 +19,7 @@ final class TicketIndexUserNPlusOneFilesTest extends TestCase self::assertStringContainsString("->select('helpdesk_tickets.*')", $component); self::assertStringContainsString("'agent_name' => User::query()", $component); self::assertStringContainsString("->whereColumn('users.id', 'helpdesk_tickets.agent_id')", $component); - self::assertStringContainsString("->with(['user:id,name,email', 'owner:id,name,email', 'agent:id,name,email', 'status', 'priority', 'category', 'project', 'team'])", $component); + self::assertStringContainsString('$ticket->agent_name ?? \'Unassigned\'', $view); self::assertStringNotContainsString('$ticket->agent?->name ?? \'Unassigned\'', $view); } diff --git a/Modules/Helpdesk/Tests/Feature/TicketUserNPlusOneFilesTest.php b/Modules/Helpdesk/Tests/Feature/TicketUserNPlusOneFilesTest.php index bf8df6e6..ada3fee8 100644 --- a/Modules/Helpdesk/Tests/Feature/TicketUserNPlusOneFilesTest.php +++ b/Modules/Helpdesk/Tests/Feature/TicketUserNPlusOneFilesTest.php @@ -4,22 +4,35 @@ declare(strict_types=1); namespace Modules\Helpdesk\Tests\Feature; -use Tests\TestCase; +use PHPUnit\Framework\TestCase; final class TicketUserNPlusOneFilesTest extends TestCase { - public function test_ticket_collection_queries_eager_load_user_owner_and_agent(): void + public function test_ticket_queries_only_eager_load_required_relations(): void { - $index = file_get_contents(dirname(__DIR__, 2) . '/Livewire/TicketIndex.php'); - $kanban = file_get_contents(dirname(__DIR__, 2) . '/Livewire/KanbanBoard.php'); - $dashboard = file_get_contents(dirname(__DIR__, 2) . '/Livewire/HelpdeskDashboardWidget.php'); + $index = file_get_contents(dirname(__DIR__, 2).'/Livewire/TicketIndex.php'); + $kanban = file_get_contents(dirname(__DIR__, 2).'/Livewire/KanbanBoard.php'); + $dashboard = file_get_contents(dirname(__DIR__, 2).'/Livewire/HelpdeskDashboardWidget.php'); self::assertIsString($index); self::assertIsString($kanban); self::assertIsString($dashboard); - self::assertStringContainsString("'user:id,name,email', 'owner:id,name,email', 'agent:id,name,email'", $index); - self::assertStringContainsString("'user:id,name,email', 'owner:id,name,email', 'agent:id,name,email'", $kanban); - self::assertStringContainsString("'user:id,name,email', 'owner:id,name,email', 'agent:id,name,email'", $dashboard); + // Index deliberately avoids loading user relations. + self::assertStringNotContainsString("'user:id,name,email'", $index); + self::assertStringNotContainsString("'owner:id,name,email'", $index); + self::assertStringNotContainsString("'agent:id,name,email'", $index); + + self::assertStringContainsString("'owner:id,name,email'", $kanban); + + self::assertStringContainsString("'agent:id,name,email'", $kanban); + +// Dashboard intentionally only loads the relations it renders. + + self::assertStringContainsString("->with(['status', 'priority'])", $dashboard); + + self::assertStringNotContainsString("'owner:id,name,email'", $dashboard); + + self::assertStringNotContainsString("'agent:id,name,email'", $dashboard); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 4ac9703a..aa062008 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,8 @@ namespace App\Providers; use Carbon\CarbonImmutable; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\LazyLoadingViolationException; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; use Illuminate\Support\ServiceProvider; @@ -24,6 +26,13 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { $this->configureDefaults(); + Model::preventLazyLoading(! app()->isProduction()); + Model::handleLazyLoadingViolationUsing(function ($model, $relation) { + throw new LazyLoadingViolationException( + $model, + $relation, + ); + }); } /** From 7b40a55b0e83426469560f0a048ca484dd39ad5b Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 26 Jun 2026 15:59:12 +0100 Subject: [PATCH 05/49] create issues with attachmens --- ...pdeskCreateTicketAttachmentBrowserTest.php | 220 ++++++++++++++++++ ...icketCreateAttachmentBrowserSafetyTest.php | 52 +++++ .../TicketCreateAttachmentPipelineTest.php | 143 ++++++++++++ 3 files changed, 415 insertions(+) create mode 100644 Modules/Helpdesk/Tests/Browser/HelpdeskCreateTicketAttachmentBrowserTest.php create mode 100644 Modules/Helpdesk/Tests/Feature/TicketCreateAttachmentBrowserSafetyTest.php create mode 100644 Modules/Helpdesk/Tests/Feature/TicketCreateAttachmentPipelineTest.php diff --git a/Modules/Helpdesk/Tests/Browser/HelpdeskCreateTicketAttachmentBrowserTest.php b/Modules/Helpdesk/Tests/Browser/HelpdeskCreateTicketAttachmentBrowserTest.php new file mode 100644 index 00000000..e4d6a907 --- /dev/null +++ b/Modules/Helpdesk/Tests/Browser/HelpdeskCreateTicketAttachmentBrowserTest.php @@ -0,0 +1,220 @@ +seedLookupData(); + $user = $this->user(['name' => 'Browser Attachment User']); + $subject = 'Browser attachment ticket ' . uniqid('', true); + $filePath = $this->makeBrowserUploadFile('browser-upload-proof.log', 'Browser upload proof file.'); + + $this->browse(function (Browser $browser) use ($user, $fixtures, $subject, $filePath): void { + $browser->loginAs($user) + ->visitRoute('helpdesk.tickets.create') + ->waitFor('@helpdesk-ticket-create', 10) + ->assertSee('Create ticket') + ->type('@ticket-subject', $subject); + + $this->setHelpdeskEditor( + $browser, + 'ticket-body-editor', + 'This browser ticket proves attachments can be uploaded and saved.', + ); + + $this->setHelpdeskSelect($browser, 'board_id', $fixtures['board']->id); + $this->setHelpdeskSelect($browser, 'priority_id', $fixtures['high']->id); + $this->setHelpdeskSelect($browser, 'category_id', $fixtures['category']->id); + $this->setHelpdeskSelect($browser, 'team_id', $fixtures['team']->id); + $this->setHelpdeskSelect($browser, 'project_id', $fixtures['project']->id); + + $browser->attach('#attachments', $filePath); + + $this->waitForHelpdeskUploadToFinish($browser); + + $browser->press('@ticket-create-submit') + ->waitUntilMissing('@helpdesk-ticket-create', 20) + ->assertPathIs('/helpdesk/tickets') + ->assertSee($subject); + }); + + $ticket = Ticket::query() + ->where('subject', $subject) + ->firstOrFail(); + + $attachment = Attachment::query() + ->where('ticket_id', $ticket->id) + ->whereNull('comment_id') + ->where('original_name', 'browser-upload-proof.log') + ->firstOrFail(); + + self::assertSame($ticket->id, (int) $attachment->ticket_id); + } + + public function test_user_can_create_ticket_with_multiple_attachments_in_browser(): void + { + $fixtures = $this->seedLookupData(); + $user = $this->user(['name' => 'Browser Multi Attachment User']); + $subject = 'Browser multiple attachment ticket ' . uniqid('', true); + $firstFile = $this->makeBrowserUploadFile('browser-first-proof.txt', 'First browser upload proof file.'); + $secondFile = $this->makeBrowserUploadFile('browser-second-proof.csv', 'name,value' . PHP_EOL . 'proof,1'); + + $this->browse(function (Browser $browser) use ($user, $fixtures, $subject, $firstFile, $secondFile): void { + $browser->loginAs($user) + ->visitRoute('helpdesk.tickets.create') + ->waitFor('@helpdesk-ticket-create', 10) + ->assertSee('Create ticket') + ->type('@ticket-subject', $subject); + + $this->setHelpdeskEditor( + $browser, + 'ticket-body-editor', + 'This browser ticket proves multiple attachments can be uploaded and saved.', + ); + + $this->setHelpdeskSelect($browser, 'board_id', $fixtures['board']->id); + $this->setHelpdeskSelect($browser, 'priority_id', $fixtures['normal']->id); + $this->setHelpdeskSelect($browser, 'category_id', $fixtures['category']->id); + $this->setHelpdeskSelect($browser, 'team_id', $fixtures['team']->id); + $this->setHelpdeskSelect($browser, 'project_id', $fixtures['project']->id); + + $browser->attach('#attachments', $firstFile . "\n" . $secondFile); + + $this->waitForHelpdeskUploadToFinish($browser); + + $browser->press('@ticket-create-submit') + ->waitUntilMissing('@helpdesk-ticket-create', 20) + ->assertPathIs('/helpdesk/tickets') + ->assertSee($subject); + }); + + $ticket = Ticket::query() + ->where('subject', $subject) + ->firstOrFail(); + + self::assertSame( + 2, + Attachment::query()->where('ticket_id', $ticket->id)->whereNull('comment_id')->count(), + ); + + foreach (['browser-first-proof.txt', 'browser-second-proof.csv'] as $name) { + $this->assertDatabaseHas('helpdesk_attachments', [ + 'ticket_id' => $ticket->id, + 'comment_id' => null, + 'original_name' => $name, + ]); + } + } + + private function makeBrowserUploadFile(string $name, string $contents): string + { + $directory = storage_path('framework/testing/helpdesk-browser-uploads'); + + if (! is_dir($directory)) { + mkdir($directory, 0777, true); + } + + $path = $directory . DIRECTORY_SEPARATOR . $name; + file_put_contents($path, $contents); + + return $path; + } + + private function setHelpdeskEditor(Browser $browser, string $dusk, string $text): void + { + $encodedDusk = json_encode($dusk, JSON_THROW_ON_ERROR); + $encodedText = json_encode($text, JSON_THROW_ON_ERROR); + + $browser->script(<<pause(500); + } + + private function setHelpdeskSelect(Browser $browser, string $model, int|string|null $value): void + { + $encodedModel = json_encode($model, JSON_THROW_ON_ERROR); + $encodedValue = json_encode($value === null ? '' : (string) $value, JSON_THROW_ON_ERROR); + + $browser->script(<<pause(250); + } + + private function waitForHelpdeskUploadToFinish(Browser $browser): void + { + $browser->waitUsing(30, 250, static function () use ($browser): bool { + return (bool) $browser->script(<<<'JS' +const input = document.querySelector('#attachments'); +const button = document.querySelector('[dusk="ticket-create-submit"], [data-dusk="ticket-create-submit"]'); + +const isVisible = (element) => { + if (! element) { + return false; + } + + const style = window.getComputedStyle(element); + + return style.display !== 'none' + && style.visibility !== 'hidden' + && style.opacity !== '0' + && element.getClientRects().length > 0; +}; + +const visibleUploadText = Array.from(document.querySelectorAll('div, span')) + .some((element) => { + const text = (element.textContent || '').trim(); + + return (text.includes('Uploading attachments...') || text === 'Uploading...') + && isVisible(element); + }); + +return Boolean(input) + && input.files.length > 0 + && Boolean(button) + && button.disabled === false + && visibleUploadText === false; +JS)[0]; + }, 'The attachment upload did not finish before submit.'); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/TicketCreateAttachmentBrowserSafetyTest.php b/Modules/Helpdesk/Tests/Feature/TicketCreateAttachmentBrowserSafetyTest.php new file mode 100644 index 00000000..9a74ca82 --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/TicketCreateAttachmentBrowserSafetyTest.php @@ -0,0 +1,52 @@ +set('helpdesk.disk', 'local'); + + $fixtures = $this->seedLookupData(); + $user = $this->user(['name' => 'Attachment Create User']); + $file = UploadedFile::fake()->create('warehouse-error.log', 12, 'text/plain'); + + Livewire::actingAs($user) + ->test(TicketCreate::class) + ->set('subject', 'Create ticket with one attachment') + ->set('body', '

This ticket must persist a Livewire upload attachment.

') + ->set('board_id', $fixtures['board']->id) + ->set('priority_id', $fixtures['high']->id) + ->set('category_id', $fixtures['category']->id) + ->set('team_id', $fixtures['team']->id) + ->set('project_id', $fixtures['project']->id) + ->set('attachments', [$file]) + ->call('save') + ->assertHasNoErrors() + ->assertRedirect(route('helpdesk.tickets.index')); + + $ticket = Ticket::query() + ->where('subject', 'Create ticket with one attachment') + ->firstOrFail(); + + $attachment = Attachment::query() + ->where('ticket_id', $ticket->id) + ->whereNull('comment_id') + ->firstOrFail(); + + self::assertSame($user->id, (int) $attachment->user_id); + self::assertSame('local', $attachment->disk); + self::assertSame('warehouse-error.log', $attachment->original_name); + self::assertSame('log', $attachment->extension()); + Storage::disk('local')->assertExists($attachment->path); + } + + public function test_ticket_create_persists_multiple_attachments_through_livewire(): void + { + Storage::fake('local'); + config()->set('helpdesk.disk', 'local'); + + $fixtures = $this->seedLookupData(); + $user = $this->user(['name' => 'Multiple Attachment User']); + + Livewire::actingAs($user) + ->test(TicketCreate::class) + ->set('subject', 'Create ticket with multiple attachments') + ->set('body', '

This ticket must persist every selected Livewire upload.

') + ->set('board_id', $fixtures['board']->id) + ->set('priority_id', $fixtures['normal']->id) + ->set('category_id', $fixtures['category']->id) + ->set('team_id', $fixtures['team']->id) + ->set('project_id', $fixtures['project']->id) + ->set('attachments', [ + UploadedFile::fake()->create('first-proof.pdf', 20, 'application/pdf'), + UploadedFile::fake()->create('second-proof.txt', 8, 'text/plain'), + UploadedFile::fake()->create('third-proof.csv', 6, 'text/csv'), + ]) + ->call('save') + ->assertHasNoErrors() + ->assertRedirect(route('helpdesk.tickets.index')); + + $ticket = Ticket::query() + ->where('subject', 'Create ticket with multiple attachments') + ->firstOrFail(); + + self::assertSame(3, Attachment::query()->where('ticket_id', $ticket->id)->whereNull('comment_id')->count()); + + foreach (['first-proof.pdf', 'second-proof.txt', 'third-proof.csv'] as $name) { + $attachment = Attachment::query() + ->where('ticket_id', $ticket->id) + ->where('original_name', $name) + ->firstOrFail(); + + Storage::disk('local')->assertExists($attachment->path); + } + } + + public function test_ticket_create_accepts_source_code_attachments_through_livewire(): void + { + Storage::fake('local'); + config()->set('helpdesk.disk', 'local'); + + $fixtures = $this->seedLookupData(); + $user = $this->user(['name' => 'Source Attachment User']); + + Livewire::actingAs($user) + ->test(TicketCreate::class) + ->set('subject', 'Create ticket with source attachments') + ->set('body', '

This ticket must accept source files by extension.

') + ->set('board_id', $fixtures['board']->id) + ->set('priority_id', $fixtures['normal']->id) + ->set('category_id', $fixtures['category']->id) + ->set('team_id', $fixtures['team']->id) + ->set('project_id', $fixtures['project']->id) + ->set('attachments', [ + UploadedFile::fake()->create('TicketCreate.php', 10, 'text/plain'), + UploadedFile::fake()->create('upload-widget.js', 10, 'text/plain'), + UploadedFile::fake()->create('warehouse-sync.rpgle', 10, 'text/plain'), + ]) + ->call('save') + ->assertHasNoErrors() + ->assertRedirect(route('helpdesk.tickets.index')); + + $ticket = Ticket::query() + ->where('subject', 'Create ticket with source attachments') + ->firstOrFail(); + + foreach (['TicketCreate.php', 'upload-widget.js', 'warehouse-sync.rpgle'] as $name) { + $attachment = Attachment::query() + ->where('ticket_id', $ticket->id) + ->where('original_name', $name) + ->firstOrFail(); + + self::assertTrue($attachment->isSourceCode(), $name . ' should be categorised as source code.'); + Storage::disk('local')->assertExists($attachment->path); + } + } +} From eaa8c6ebe3d4a2dc802dbf7a5141cbd6d19dc384 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 29 Jun 2026 09:42:00 +0100 Subject: [PATCH 06/49] misc updates with tests failing --- Modules/Helpdesk/Livewire/TicketCreate.php | 46 ++++- .../views/components/html-editor.blade.php | 36 ++-- .../views/layouts/app/sidebar.blade.php | 6 +- .../views/livewire/ticket-create.blade.php | 157 +++++------------- ...pdeskCreateTicketAttachmentBrowserTest.php | 28 +--- ...icketCreateAttachmentBrowserSafetyTest.php | 42 ++--- 6 files changed, 132 insertions(+), 183 deletions(-) diff --git a/Modules/Helpdesk/Livewire/TicketCreate.php b/Modules/Helpdesk/Livewire/TicketCreate.php index 77a4d677..b2ddd5b3 100644 --- a/Modules/Helpdesk/Livewire/TicketCreate.php +++ b/Modules/Helpdesk/Livewire/TicketCreate.php @@ -86,10 +86,54 @@ final class TicketCreate extends Component $this->applyDefaults(); } + public function save(TicketService $tickets) + { + logger()->info('SAVE START'); + + try { + + logger()->info('VALIDATING'); + + $this->validate(); + + logger()->info('VALIDATED'); + + logger()->info([ + 'attachments' => count($this->attachments), + 'body' => $this->body, + ]); + + $ticket = $tickets->create(Auth::user(), [ + 'company_id' => $this->company_id, + 'subject' => $this->subject, + 'body' => $this->body, + 'board_id' => $this->board_id, + 'priority_id' => $this->priority_id, + 'category_id' => $this->category_id, + 'project_id' => $this->project_id, + 'product_id' => $this->product_id, + 'team_id' => $this->team_id, + 'due_at' => $this->due_at, + 'tag_ids' => $this->tag_ids, + ], $this->attachments); + session()->flash('success', 'Ticket ' . $ticket->reference . ' has been created.'); + + logger()->info('CREATED '.$ticket->id); + + return redirect()->route('helpdesk.tickets.index'); + + } catch (\Throwable $e) { + + logger()->error($e); + + throw $e; + } + } + /** * Store a new helpdesk ticket. */ - public function save(TicketService $tickets) + public function save_old(TicketService $tickets) { if ($this->isSaving) { return null; diff --git a/Modules/Helpdesk/Resources/views/components/html-editor.blade.php b/Modules/Helpdesk/Resources/views/components/html-editor.blade.php index 8a16c3a1..d4f42200 100644 --- a/Modules/Helpdesk/Resources/views/components/html-editor.blade.php +++ b/Modules/Helpdesk/Resources/views/components/html-editor.blade.php @@ -10,7 +10,7 @@ class="space-y-2" x-data="{ model: @js($model), - value: @entangle($model).defer, + value: @entangle($model), init() { this.$nextTick(() => { this.writeEditorValue(this.value || ''); @@ -21,33 +21,43 @@ return; } - this.$refs.editor.textContent = value || ''; + const nextValue = value || ''; + const currentValue = this.$refs.editor.innerText || ''; + + if (document.activeElement === this.$refs.editor && currentValue !== '' && currentValue !== nextValue) { + return; + } + + this.$refs.editor.textContent = nextValue; }, - syncToLivewire(live = false) { + syncToLivewire(live = true) { const value = this.$refs.editor?.innerText || ''; this.value = value; - this.$refs.input.value = value; - this.$refs.input.dispatchEvent(new Event('input', { bubbles: true })); - this.$wire.set(this.model, this.value, live); + + if (this.$refs.input) { + this.$refs.input.value = value; + } + + this.$wire.set(this.model, value, live); }, resetEditor() { this.value = ''; this.writeEditorValue(''); - this.syncToLivewire(false); + this.syncToLivewire(true); }, runCommand(command) { this.$refs.editor?.focus(); document.execCommand(command, false, null); - this.syncToLivewire(false); + this.syncToLivewire(true); }, wrapBlock(tagName) { this.$refs.editor?.focus(); document.execCommand('formatBlock', false, tagName); - this.syncToLivewire(false); + this.syncToLivewire(true); }, }" - x-on:helpdesk-editor-sync.window="if (! $event.detail?.model || $event.detail.model === @js($model)) syncToLivewire(false)" + x-on:helpdesk-editor-sync.window="if (! $event.detail?.model || $event.detail.model === @js($model)) syncToLivewire($event.detail?.live ?? true)" x-on:helpdesk-editor-reset.window="if (! $event.detail?.model || $event.detail.model === @js($model)) resetEditor()" >
@@ -129,6 +141,6 @@

- Browser spellcheck stays active in the native editable area. Formatting is synchronised only on blur and submit to avoid Livewire keypress regressions. + Browser spellcheck stays active in the native editable area. Formatting is synchronised before uploads and submit so Livewire does not lose unsaved editor text during attachment refreshes.

diff --git a/Modules/Helpdesk/Resources/views/layouts/app/sidebar.blade.php b/Modules/Helpdesk/Resources/views/layouts/app/sidebar.blade.php index b3012602..e8d1b184 100644 --- a/Modules/Helpdesk/Resources/views/layouts/app/sidebar.blade.php +++ b/Modules/Helpdesk/Resources/views/layouts/app/sidebar.blade.php @@ -767,14 +767,14 @@ @endif } - let wmsSessionToastsShown = false; + window.wmsSessionToastsShown = window.wmsSessionToastsShown || false; function showSessionToastsOnce() { - if (wmsSessionToastsShown) { + if (window.wmsSessionToastsShown) { return; } - wmsSessionToastsShown = true; + window.wmsSessionToastsShown = true; showSessionToasts(); } diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php index 77391e55..16b31af4 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php @@ -6,7 +6,6 @@ ->implode(','); @endphp
- @vite(['resources/css/app.css', 'resources/js/app.js']) @include('helpdesk::partials.page-header', [ 'eyebrow' => 'Helpdesk', 'title' => 'Create ticket', @@ -16,39 +15,18 @@
@@ -278,16 +256,16 @@
-
+ x-on:livewire-upload-cancel="uploading = false" + >

- Drag and drop screenshots, PDFs, logs, source files or documents. PHP, JS, RPG/RPGLE, CLLE and other source files are accepted. + Files are uploaded to Livewire temporary storage first. They are attached permanently only after the ticket is created.

@@ -307,27 +285,35 @@
- + - + @endif
@@ -369,7 +355,7 @@
-
diff --git a/Modules/Helpdesk/Tests/Browser/HelpdeskCreateTicketAttachmentBrowserTest.php b/Modules/Helpdesk/Tests/Browser/HelpdeskCreateTicketAttachmentBrowserTest.php index e4d6a907..d8f06e52 100644 --- a/Modules/Helpdesk/Tests/Browser/HelpdeskCreateTicketAttachmentBrowserTest.php +++ b/Modules/Helpdesk/Tests/Browser/HelpdeskCreateTicketAttachmentBrowserTest.php @@ -187,34 +187,14 @@ JS); return (bool) $browser->script(<<<'JS' const input = document.querySelector('#attachments'); const button = document.querySelector('[dusk="ticket-create-submit"], [data-dusk="ticket-create-submit"]'); - -const isVisible = (element) => { - if (! element) { - return false; - } - - const style = window.getComputedStyle(element); - - return style.display !== 'none' - && style.visibility !== 'hidden' - && style.opacity !== '0' - && element.getClientRects().length > 0; -}; - -const visibleUploadText = Array.from(document.querySelectorAll('div, span')) - .some((element) => { - const text = (element.textContent || '').trim(); - - return (text.includes('Uploading attachments...') || text === 'Uploading...') - && isVisible(element); - }); +const selected = document.querySelector('[dusk="ticket-create-selected-attachments"], [data-dusk="ticket-create-selected-attachments"]'); return Boolean(input) && input.files.length > 0 + && Boolean(selected) && Boolean(button) - && button.disabled === false - && visibleUploadText === false; + && button.disabled === false; JS)[0]; - }, 'The attachment upload did not finish before submit.'); + }, 'The Livewire temporary attachment upload did not finish before submit.'); } } diff --git a/Modules/Helpdesk/Tests/Feature/TicketCreateAttachmentBrowserSafetyTest.php b/Modules/Helpdesk/Tests/Feature/TicketCreateAttachmentBrowserSafetyTest.php index 9a74ca82..c994c264 100644 --- a/Modules/Helpdesk/Tests/Feature/TicketCreateAttachmentBrowserSafetyTest.php +++ b/Modules/Helpdesk/Tests/Feature/TicketCreateAttachmentBrowserSafetyTest.php @@ -8,45 +8,39 @@ use Tests\TestCase; final class TicketCreateAttachmentBrowserSafetyTest extends TestCase { - public function test_ticket_create_view_does_not_reinject_application_assets_inside_livewire_component(): void + public function test_ticket_create_uses_standard_livewire_temporary_uploads(): void { $view = file_get_contents(dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-create.blade.php'); self::assertIsString($view); - self::assertStringNotContainsString("@vite(['resources/css/app.css', 'resources/js/app.js'])", $view); self::assertStringContainsString('wire:submit.prevent="save"', $view); - } - - public function test_ticket_create_attachment_input_is_owned_by_livewire_and_has_a_stable_key(): void - { - $view = file_get_contents(dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-create.blade.php'); - - self::assertIsString($view); - self::assertStringContainsString('wire:key="ticket-create-attachments-uploader"', $view); + self::assertStringContainsString('type="file"', $view); + self::assertStringContainsString('wire:model="attachments"', $view); + self::assertStringContainsString('multiple', $view); self::assertStringContainsString('wire:key="ticket-create-attachments-input"', $view); - self::assertStringContainsString('type="file" wire:model="attachments" multiple', $view); - self::assertStringNotContainsString('$wire.uploadMultiple', $view); - self::assertStringNotContainsString("new CustomEvent('livewire-upload-start'", $view); - self::assertStringNotContainsString("new CustomEvent('livewire-upload-finish'", $view); } - public function test_ticket_create_attachment_script_registers_global_listeners_once(): void + public function test_ticket_create_does_not_mix_existing_ticket_upload_behaviour_into_create_form(): void { $view = file_get_contents(dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-create.blade.php'); self::assertIsString($view); - self::assertStringContainsString('window.__helpdeskTicketCreateAttachmentListenersRegistered', $view); - self::assertStringContainsString('window.__helpdeskTicketCreateLivewireClearListenerRegistered', $view); - self::assertStringContainsString("window.dispatchEvent(new CustomEvent('helpdesk-attachments-reset'))", $view); + self::assertStringNotContainsString('@vite([\'resources/css/app.css\', \'resources/js/app.js\'])', $view); + self::assertStringNotContainsString('helpdeskAttachmentDropzone', $view); + self::assertStringNotContainsString('DataTransfer', $view); + self::assertStringNotContainsString('$wire.uploadMultiple', $view); + self::assertStringNotContainsString('input.files =', $view); + self::assertStringNotContainsString('helpdesk-editor-files-pasted', $view); + self::assertStringNotContainsString('x-bind:disabled="helpdeskAttachmentsUploading"', $view); } - public function test_create_attachment_browser_test_does_not_use_livewire_internal_dom_selectors(): void + public function test_ticket_create_shows_uploaded_temporary_file_names_from_livewire_property(): void { - $test = file_get_contents(dirname(__DIR__, 1) . '/Browser/HelpdeskCreateTicketAttachmentBrowserTest.php'); + $view = file_get_contents(dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-create.blade.php'); - self::assertIsString($test); - self::assertStringNotContainsString('closest(', $test); - self::assertStringNotContainsString('Livewire.find', $test); - self::assertStringContainsString("attach('#attachments'", $test); + self::assertIsString($view); + self::assertStringContainsString('@if (count($attachments) > 0)', $view); + self::assertStringContainsString('$attachment->getClientOriginalName()', $view); + self::assertStringContainsString('wire:loading wire:target="attachments"', $view); } } From 8a07f14b63a65ab25a3d36f65177f2638be35a14 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 29 Jun 2026 11:14:47 +0100 Subject: [PATCH 07/49] fully working tests both phpunit and dusk --- Modules/Helpdesk/Livewire/TicketCreate.php | 48 +----- .../views/components/html-editor.blade.php | 13 +- .../views/livewire/ticket-create.blade.php | 32 ++-- ...pdeskCreateTicketAttachmentBrowserTest.php | 151 +++++++++++++++--- ...icketCreateAttachmentBrowserSafetyTest.php | 1 - Modules/Helpdesk/changelog.yaml | 12 ++ Modules/Helpdesk/module.json | 2 +- 7 files changed, 169 insertions(+), 90 deletions(-) diff --git a/Modules/Helpdesk/Livewire/TicketCreate.php b/Modules/Helpdesk/Livewire/TicketCreate.php index b2ddd5b3..a6467145 100644 --- a/Modules/Helpdesk/Livewire/TicketCreate.php +++ b/Modules/Helpdesk/Livewire/TicketCreate.php @@ -86,54 +86,10 @@ final class TicketCreate extends Component $this->applyDefaults(); } - public function save(TicketService $tickets) - { - logger()->info('SAVE START'); - - try { - - logger()->info('VALIDATING'); - - $this->validate(); - - logger()->info('VALIDATED'); - - logger()->info([ - 'attachments' => count($this->attachments), - 'body' => $this->body, - ]); - - $ticket = $tickets->create(Auth::user(), [ - 'company_id' => $this->company_id, - 'subject' => $this->subject, - 'body' => $this->body, - 'board_id' => $this->board_id, - 'priority_id' => $this->priority_id, - 'category_id' => $this->category_id, - 'project_id' => $this->project_id, - 'product_id' => $this->product_id, - 'team_id' => $this->team_id, - 'due_at' => $this->due_at, - 'tag_ids' => $this->tag_ids, - ], $this->attachments); - session()->flash('success', 'Ticket ' . $ticket->reference . ' has been created.'); - - logger()->info('CREATED '.$ticket->id); - - return redirect()->route('helpdesk.tickets.index'); - - } catch (\Throwable $e) { - - logger()->error($e); - - throw $e; - } - } - /** * Store a new helpdesk ticket. */ - public function save_old(TicketService $tickets) + public function save(TicketService $tickets) { if ($this->isSaving) { return null; @@ -159,7 +115,9 @@ final class TicketCreate extends Component 'due_at' => $this->due_at, 'tag_ids' => $this->tag_ids, ], $this->attachments); + session()->flash('success', 'Ticket ' . $ticket->reference . ' has been created.'); + return redirect()->to(route('helpdesk.tickets.index')); } finally { $this->isSaving = false; diff --git a/Modules/Helpdesk/Resources/views/components/html-editor.blade.php b/Modules/Helpdesk/Resources/views/components/html-editor.blade.php index d4f42200..d1d6f764 100644 --- a/Modules/Helpdesk/Resources/views/components/html-editor.blade.php +++ b/Modules/Helpdesk/Resources/views/components/html-editor.blade.php @@ -31,15 +31,13 @@ this.$refs.editor.textContent = nextValue; }, syncToLivewire(live = true) { - const value = this.$refs.editor?.innerText || ''; - - this.value = value; + this.value = this.$refs.editor?.innerText || ''; if (this.$refs.input) { - this.$refs.input.value = value; + this.$refs.input.value = this.value; } - this.$wire.set(this.model, value, live); + this.$wire.set(this.model, this.value, live); }, resetEditor() { this.value = ''; @@ -118,8 +116,7 @@ data-helpdesk-sync-target="{{ $model }}" data-helpdesk-editor-model="{{ $model }}" @if ($editorDusk) data-dusk="{{ $editorDusk }}" dusk="{{ $editorDusk }}" @endif - x-on:input.debounce.350ms="syncToLivewire(true)" - x-on:paste.debounce.350ms="syncToLivewire(true)" + x-on:paste="syncToLivewire(false)" x-on:blur="syncToLivewire(true)" class="helpdesk-rich-editor block min-h-[14rem] w-full overflow-y-auto whitespace-pre-wrap break-words border-0 bg-zinc-950 px-5 py-4 text-base leading-7 text-zinc-100 outline-none transition placeholder:text-zinc-500 empty:before:pointer-events-none empty:before:text-zinc-500 empty:before:content-[attr(data-placeholder)] focus:ring-2 focus:ring-blue-500" >
@@ -141,6 +138,6 @@

- Browser spellcheck stays active in the native editable area. Formatting is synchronised before uploads and submit so Livewire does not lose unsaved editor text during attachment refreshes. + Browser spellcheck stays active in the native editable area. Formatting is synchronised before submit so Livewire does not lose unsaved editor text during attachment refreshes.

diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php index 16b31af4..bf4894a3 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php @@ -5,7 +5,7 @@ ->unique() ->implode(','); @endphp -
+
@include('helpdesk::partials.page-header', [ 'eyebrow' => 'Helpdesk', 'title' => 'Create ticket', @@ -16,16 +16,26 @@
@@ -259,12 +269,12 @@
@@ -315,7 +325,7 @@
@endif -
Uploading attachments... @@ -355,7 +365,7 @@ + +
@if (count($attachments) > 0)
@foreach ($attachments as $attachment) From 7e6e53463bf553e6c88409fe62096a40b4105ec7 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 29 Jun 2026 12:42:09 +0100 Subject: [PATCH 09/49] board switching --- Modules/Helpdesk/Livewire/KanbanBoard.php | 21 +++++++++++++++++++ .../views/livewire/kanban-board.blade.php | 12 +++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/Modules/Helpdesk/Livewire/KanbanBoard.php b/Modules/Helpdesk/Livewire/KanbanBoard.php index a490cb9e..13dcf90f 100644 --- a/Modules/Helpdesk/Livewire/KanbanBoard.php +++ b/Modules/Helpdesk/Livewire/KanbanBoard.php @@ -25,6 +25,18 @@ final class KanbanBoard extends Component public function mount(): void { + $requestedBoardId = request()->query('board_id'); + + if (is_numeric($requestedBoardId)) { + $requestedBoardId = (int) $requestedBoardId; + + if (KanbanBoardModel::query()->whereKey($requestedBoardId)->exists()) { + $this->board_id = $requestedBoardId; + + return; + } + } + $this->board_id = KanbanBoardModel::query() ->where('is_default', true) ->orderBy('sort_order') @@ -139,6 +151,15 @@ final class KanbanBoard extends Component $this->refreshBoard(); } + /** + * Switch the visible board without relying on DOM morph side effects. + */ + public function selectBoard(int|string|null $value): void + { + $this->board_id = is_numeric($value) ? (int) $value : null; + $this->refreshBoard(); + } + /** * Render the Kanban board. */ diff --git a/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php b/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php index b81da65d..d99431eb 100644 --- a/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php @@ -1,7 +1,7 @@ @vite(['resources/css/app.css'])
+ wire:poll.visible.5s="refreshBoard" wire:key="helpdesk-kanban-root">
@@ -46,7 +46,7 @@
-

Active board

+

Active board: {{ $board?->name ?? 'None' }}

{{ $board?->description ?: 'Use settings to configure which statuses appear as draggable columns.' }}

@@ -57,9 +57,13 @@
- @foreach ($boards as $availableBoard) - @endforeach From 3f3673946429083d9d0914c17f86319ef8bd9ee8 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 29 Jun 2026 12:52:43 +0100 Subject: [PATCH 10/49] intermin fix for board move --- Modules/Helpdesk/Livewire/KanbanBoard.php | 73 ++++++++++++++--------- 1 file changed, 45 insertions(+), 28 deletions(-) diff --git a/Modules/Helpdesk/Livewire/KanbanBoard.php b/Modules/Helpdesk/Livewire/KanbanBoard.php index 13dcf90f..2331a590 100644 --- a/Modules/Helpdesk/Livewire/KanbanBoard.php +++ b/Modules/Helpdesk/Livewire/KanbanBoard.php @@ -94,42 +94,59 @@ final class KanbanBoard extends Component /** * Move a ticket from its current board to another board. A ticket can only belong to one board. + * + * This intentionally redirects to the target board after the update so the browser, + * Livewire state and database state cannot fall back to the default board. */ - public function moveTicketToBoard(int|string $ticketId, int|string|null $boardId, WorkflowNotificationService $workflow): void + public function moveTicketToBoard(int|string $ticketId, int|string|null $boardId, WorkflowNotificationService $workflow) { - $ticket = Ticket::query()->with('board')->visibleTo(Auth::user())->findOrFail((int) $ticketId); - $board = KanbanBoardModel::query()->with(['statuses', 'owner:id,name,email'])->findOrFail((int) $boardId); - $oldBoardName = $ticket->board?->name ?? 'No board'; - - $this->authorize('update', $ticket); - - if (! $board->statuses->contains('id', $ticket->status_id)) { - $firstStatus = $board->statuses->first(); - - if ($firstStatus !== null) { - $ticket->status_id = $firstStatus->id; - $ticket->completed_at = $firstStatus->is_closed ? now() : null; - } else { - $this->addStatusToBoard($board->id, (int) $ticket->status_id); - } + if (! is_numeric($ticketId) || ! is_numeric($boardId)) { + return; } - $ticket->forceFill([ - 'board_id' => $board->id, - 'last_activity_at' => now(), - ])->save(); + $board = KanbanBoardModel::query() + ->with(['statuses' => fn ($query) => $query->orderBy('helpdesk_kanban_board_statuses.sort_order')->orderBy('helpdesk_statuses.sort_order'), 'owner:id,name,email']) + ->findOrFail((int) $boardId); - $this->board_id = $board->id; + if ($board->statuses->isEmpty()) { + $this->ensureBoardHasStatuses($board); + $board->load(['statuses' => fn ($query) => $query->orderBy('helpdesk_kanban_board_statuses.sort_order')->orderBy('helpdesk_statuses.sort_order'), 'owner:id,name,email']); + } - $fresh = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'board']) ?? $ticket; - $workflow->dispatch($fresh, 'ticket.updated', Auth::user()); + $fresh = DB::transaction(function () use ($ticketId, $board) { + $ticket = Ticket::query() + ->with(['board', 'status']) + ->visibleTo(Auth::user()) + ->lockForUpdate() + ->findOrFail((int) $ticketId); - $message = 'Ticket ' . $fresh->reference . ' moved from ' . $oldBoardName . ' to ' . $board->name . '.'; - session()->flash('success', $message); + $this->authorize('update', $ticket); - $this->refreshBoard(); - $this->dispatch('helpdesk-kanban-moved', ticketId: (int) $fresh->id, statusId: (int) $fresh->status_id); - $this->dispatch('wms:toast', type: 'success', title: 'Board changed', message: $message); + $targetStatus = $board->statuses->firstWhere('id', (int) $ticket->status_id) + ?: $board->statuses->first(); + + if ($targetStatus === null) { + $this->addStatusToBoard($board->id, (int) $ticket->status_id); + $targetStatus = $ticket->status; + } + + $ticket->forceFill([ + 'board_id' => $board->id, + 'status_id' => $targetStatus?->id ?? $ticket->status_id, + 'completed_at' => $targetStatus?->is_closed ? now() : null, + 'last_activity_at' => now(), + ])->save(); + + return $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board']); + }); + + if ($fresh instanceof Ticket) { + $workflow->dispatch($fresh, 'ticket.updated', Auth::user()); + + session()->flash('success', 'Ticket ' . $fresh->reference . ' moved to ' . $board->name . '.'); + } + + return redirect()->route('helpdesk.kanban', ['board_id' => $board->id]); } /** From 7c815854c60af38f5ba44b774173ccdffa55fb85 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 29 Jun 2026 13:07:11 +0100 Subject: [PATCH 11/49] intermin fix for board move, final fix all working --- .../Controllers/TicketBoardController.php | 119 ++++++ .../Helpdesk/Livewire/KanbanBoard.php | 294 +++++++++++++++ .../views/livewire/kanban-board.blade.php | 349 ++++++++++++++++++ Modules/Helpdesk/Helpdesk/Routes/web.php | 52 +++ .../Controllers/TicketBoardController.php | 105 ++++++ Modules/Helpdesk/Livewire/KanbanBoard.php | 71 ++-- .../views/livewire/kanban-board.blade.php | 18 +- Modules/Helpdesk/Routes/web.php | 2 + 8 files changed, 962 insertions(+), 48 deletions(-) create mode 100644 Modules/Helpdesk/Helpdesk/Http/Controllers/TicketBoardController.php create mode 100644 Modules/Helpdesk/Helpdesk/Livewire/KanbanBoard.php create mode 100644 Modules/Helpdesk/Helpdesk/Resources/views/livewire/kanban-board.blade.php create mode 100644 Modules/Helpdesk/Helpdesk/Routes/web.php create mode 100644 Modules/Helpdesk/Http/Controllers/TicketBoardController.php diff --git a/Modules/Helpdesk/Helpdesk/Http/Controllers/TicketBoardController.php b/Modules/Helpdesk/Helpdesk/Http/Controllers/TicketBoardController.php new file mode 100644 index 00000000..17010cb4 --- /dev/null +++ b/Modules/Helpdesk/Helpdesk/Http/Controllers/TicketBoardController.php @@ -0,0 +1,119 @@ +validate([ + 'board_id' => ['required', 'integer', Rule::exists('helpdesk_kanban_boards', 'id')], + 'current_board_id' => ['nullable', 'integer', Rule::exists('helpdesk_kanban_boards', 'id')], + ]); + + $targetBoard = KanbanBoard::query() + ->with(['statuses', 'owner:id,name,email']) + ->findOrFail((int) $validated['board_id']); + + if ($targetBoard->statuses->isEmpty()) { + $this->ensureBoardHasStatuses($targetBoard); + $targetBoard->load(['statuses', 'owner:id,name,email']); + } + + $fresh = DB::transaction(function () use ($ticket, $targetBoard): Ticket { + /** @var Ticket $lockedTicket */ + $lockedTicket = Ticket::query() + ->visibleTo(Auth::user()) + ->with(['board', 'status']) + ->lockForUpdate() + ->findOrFail($ticket->id); + + $targetStatus = $targetBoard->statuses->firstWhere('id', (int) $lockedTicket->status_id) + ?: $targetBoard->statuses->first(); + + if ($targetStatus === null) { + $targetStatus = Status::query() + ->orderBy('sort_order') + ->orderBy('name') + ->firstOrFail(); + + $this->addStatusToBoard($targetBoard->id, $targetStatus->id); + } + + $lockedTicket->forceFill([ + 'board_id' => $targetBoard->id, + 'status_id' => $targetStatus->id, + 'completed_at' => $targetStatus->is_closed ? now() : null, + 'last_activity_at' => now(), + ])->save(); + + return $lockedTicket->fresh([ + 'status', + 'priority', + 'category', + 'project', + 'team', + 'product', + 'tags', + 'checklistItems', + 'board', + ]) ?? $lockedTicket; + }); + + $workflow->dispatch($fresh, 'ticket.updated', Auth::user()); + + return redirect() + ->route('helpdesk.kanban', ['board_id' => $targetBoard->id]) + ->with('success', 'Ticket ' . $fresh->reference . ' moved to ' . $targetBoard->name . '.'); + } + + private function addStatusToBoard(int $boardId, int $statusId): void + { + DB::table('helpdesk_kanban_board_statuses')->updateOrInsert( + ['board_id' => $boardId, 'status_id' => $statusId], + ['sort_order' => 999, 'updated_at' => now(), 'created_at' => now()], + ); + } + + private function ensureBoardHasStatuses(KanbanBoard $board): void + { + $statuses = Status::query() + ->orderBy('sort_order') + ->orderBy('name') + ->get(['id']); + + foreach ($statuses as $index => $status) { + DB::table('helpdesk_kanban_board_statuses')->updateOrInsert( + ['board_id' => $board->id, 'status_id' => $status->id], + [ + 'sort_order' => ($index + 1) * 10, + 'created_at' => now(), + 'updated_at' => now(), + ], + ); + } + } +} diff --git a/Modules/Helpdesk/Helpdesk/Livewire/KanbanBoard.php b/Modules/Helpdesk/Helpdesk/Livewire/KanbanBoard.php new file mode 100644 index 00000000..13dcf90f --- /dev/null +++ b/Modules/Helpdesk/Helpdesk/Livewire/KanbanBoard.php @@ -0,0 +1,294 @@ +query('board_id'); + + if (is_numeric($requestedBoardId)) { + $requestedBoardId = (int) $requestedBoardId; + + if (KanbanBoardModel::query()->whereKey($requestedBoardId)->exists()) { + $this->board_id = $requestedBoardId; + + return; + } + } + + $this->board_id = KanbanBoardModel::query() + ->where('is_default', true) + ->orderBy('sort_order') + ->value('id') ?: KanbanBoardModel::query()->orderBy('sort_order')->value('id'); + } + + /** + * Move a ticket to another workflow status on the active board. + */ + public function moveTicket(int|string $ticketId, int|string $statusId, WorkflowNotificationService $workflow): void + { + if ($this->isMovingTicket) { + return; + } + + $this->isMovingTicket = true; + + try { + $ticket = Ticket::query()->visibleTo(Auth::user())->findOrFail((int) $ticketId); + $status = Status::query()->findOrFail((int) $statusId); + $board = $this->activeBoard(); + $oldStatusName = $ticket->status?->name ?? 'previous status'; + + $this->authorize('update', $ticket); + + if ($board !== null && ! $board->statuses()->whereKey($status->id)->exists()) { + $this->addStatusToBoard($board->id, $status->id); + } + + $ticket->forceFill([ + 'board_id' => $board?->id, + 'status_id' => $status->id, + 'completed_at' => $status->is_closed ? now() : null, + 'last_activity_at' => now(), + ])->save(); + + $freshTicket = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board']) ?? $ticket; + + $workflow->dispatch( + $freshTicket, + $status->is_closed ? 'ticket.closed' : 'ticket.updated', + Auth::user(), + ); + + $message = 'Ticket ' . $freshTicket->reference . ' moved from ' . $oldStatusName . ' to ' . $status->name . '.'; + session()->flash('success', $message); + + $this->refreshBoard(); + $this->dispatch('helpdesk-kanban-moved', ticketId: (int) $freshTicket->id, statusId: (int) $freshTicket->status_id); + $this->dispatch('wms:toast', type: 'success', title: 'Ticket moved', message: $message); + } finally { + $this->isMovingTicket = false; + } + } + + /** + * Move a ticket from its current board to another board. A ticket can only belong to one board. + */ + public function moveTicketToBoard(int|string $ticketId, int|string|null $boardId, WorkflowNotificationService $workflow): void + { + $ticket = Ticket::query()->with('board')->visibleTo(Auth::user())->findOrFail((int) $ticketId); + $board = KanbanBoardModel::query()->with(['statuses', 'owner:id,name,email'])->findOrFail((int) $boardId); + $oldBoardName = $ticket->board?->name ?? 'No board'; + + $this->authorize('update', $ticket); + + if (! $board->statuses->contains('id', $ticket->status_id)) { + $firstStatus = $board->statuses->first(); + + if ($firstStatus !== null) { + $ticket->status_id = $firstStatus->id; + $ticket->completed_at = $firstStatus->is_closed ? now() : null; + } else { + $this->addStatusToBoard($board->id, (int) $ticket->status_id); + } + } + + $ticket->forceFill([ + 'board_id' => $board->id, + 'last_activity_at' => now(), + ])->save(); + + $this->board_id = $board->id; + + $fresh = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'board']) ?? $ticket; + $workflow->dispatch($fresh, 'ticket.updated', Auth::user()); + + $message = 'Ticket ' . $fresh->reference . ' moved from ' . $oldBoardName . ' to ' . $board->name . '.'; + session()->flash('success', $message); + + $this->refreshBoard(); + $this->dispatch('helpdesk-kanban-moved', ticketId: (int) $fresh->id, statusId: (int) $fresh->status_id); + $this->dispatch('wms:toast', type: 'success', title: 'Board changed', message: $message); + } + + /** + * Force the board to fetch fresh database rows after moves or polling. + */ + #[On('helpdesk-kanban-refresh')] + public function refreshBoard(): void + { + $this->refreshNonce++; + unset($this->ticketsByStatus); + } + + /** + * Normalise the board selector value from Livewire/browser select events. + */ + public function updatedBoardId(int|string|null $value): void + { + $this->board_id = is_numeric($value) ? (int) $value : null; + $this->refreshBoard(); + } + + /** + * Switch the visible board without relying on DOM morph side effects. + */ + public function selectBoard(int|string|null $value): void + { + $this->board_id = is_numeric($value) ? (int) $value : null; + $this->refreshBoard(); + } + + /** + * Render the Kanban board. + */ + public function render(): View + { + $boards = KanbanBoardModel::query() + ->with(['statuses', 'owner:id,name,email']) + ->orderBy('sort_order') + ->orderBy('name') + ->get(); + + $board = $this->resolveSelectedBoard($boards); + $this->board_id = $board?->id; + + if ($board instanceof KanbanBoardModel && $board->statuses->isEmpty()) { + $this->ensureBoardHasStatuses($board); + $board->load(['statuses', 'owner:id,name,email']); + + $boards = KanbanBoardModel::query() + ->with(['statuses', 'owner:id,name,email']) + ->orderBy('sort_order') + ->orderBy('name') + ->get(); + } + + return view('helpdesk::livewire.kanban-board', [ + 'boards' => $boards, + 'board' => $board, + 'statuses' => $board?->statuses ?? collect(), + 'ticketsByStatus' => $this->ticketsByStatus($board), + 'refreshNonce' => $this->refreshNonce, + ])->layout('layouts.app'); + } + + /** + * Get visible tickets grouped by status for the selected board only. + * + * @return array> + */ + private function ticketsByStatus(?KanbanBoardModel $board): array + { + if ($board === null) { + return []; + } + + 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']) + ->visibleTo(Auth::user()) + ->where('board_id', $board->id) + ->latest('last_activity_at') + ->limit(300) + ->get() + ->groupBy('status_id') + ->all(); + } + + /** + * Resolve the current board from the selected board ID. + */ + private function activeBoard(): ?KanbanBoardModel + { + if ($this->board_id !== null) { + return KanbanBoardModel::query()->find((int) $this->board_id); + } + + return KanbanBoardModel::query() + ->where('is_default', true) + ->orderBy('sort_order') + ->first() ?: KanbanBoardModel::query()->orderBy('sort_order')->first(); + } + + /** + * Resolve the selected board from an already-loaded board collection. + * + * @param Collection $boards + */ + private function resolveSelectedBoard(Collection $boards): ?KanbanBoardModel + { + if ($boards->isEmpty()) { + return null; + } + + if ($this->board_id !== null) { + $selected = $boards->firstWhere('id', (int) $this->board_id); + + if ($selected instanceof KanbanBoardModel) { + return $selected; + } + } + + $default = $boards->firstWhere('is_default', true); + + return $default instanceof KanbanBoardModel ? $default : $boards->first(); + } + + /** + * Attach a missing status to a board when a move requires it. + */ + private function addStatusToBoard(int $boardId, int $statusId): void + { + DB::table('helpdesk_kanban_board_statuses')->updateOrInsert( + ['board_id' => $boardId, 'status_id' => $statusId], + ['sort_order' => 999, 'updated_at' => now(), 'created_at' => now()], + ); + } + + /** + * Ensure a board has workflow columns. This keeps newly-created boards usable immediately. + */ + private function ensureBoardHasStatuses(KanbanBoardModel $board): void + { + if ($board->statuses()->exists()) { + return; + } + + $statuses = Status::query() + ->orderBy('sort_order') + ->orderBy('name') + ->get(['id']); + + foreach ($statuses as $index => $status) { + DB::table('helpdesk_kanban_board_statuses')->updateOrInsert( + ['board_id' => $board->id, 'status_id' => $status->id], + [ + 'sort_order' => ($index + 1) * 10, + 'created_at' => now(), + 'updated_at' => now(), + ], + ); + } + } +} diff --git a/Modules/Helpdesk/Helpdesk/Resources/views/livewire/kanban-board.blade.php b/Modules/Helpdesk/Helpdesk/Resources/views/livewire/kanban-board.blade.php new file mode 100644 index 00000000..e3dd773f --- /dev/null +++ b/Modules/Helpdesk/Helpdesk/Resources/views/livewire/kanban-board.blade.php @@ -0,0 +1,349 @@ +@vite(['resources/css/app.css']) + +
+ + @if ($boards->isEmpty()) +
+ No Kanban boards exist yet. Open Helpdesk Settings and create a board with at least one status column. +
+ @else +
+
+

Active board: {{ $board?->name ?? 'None' }}

+

+ {{ $board?->description ?: 'Use settings to configure which statuses appear as draggable columns.' }} +

+ @if ($board?->owner) +

Owner: + {{ $board->owner->name }}

+ @endif +
+ +
+ +
+
+ + @if ($statuses->isEmpty()) +
+ This board is being initialised with the default workflow statuses. Refresh the page if the columns do + not appear immediately. +
+ @else +
+ @foreach ($statuses as $status) + @php($tickets = $ticketsByStatus[$status->id] ?? collect()) + +
+
+
+
+

+ {{ $status->name }}

+

{{ $tickets->count() }} + tickets

+
+ +
+
+ +
+ @forelse ($tickets as $ticket) +
+
+
+
+ {{ $ticket->reference }}
+

+ {{ $ticket->subject }}

+
+ + @if ($ticket->priority) + + {{ $ticket->priority->name }} + + @endif +
+ +
+
{{ $ticket->owner?->name ?? 'Unknown' }}
+
{{ $ticket->agent?->name ?? 'Unassigned' }} +
+ @if ($ticket->project) +
+ {{ $ticket->project->title }}
+ @endif + @if ($ticket->team) +
{{ $ticket->team->name }}
+ @endif + @if ($ticket->product) +
{{ $ticket->product->name }}
+ @endif +
+ + + + @csrf + + +
+ +
+ + @if ($ticket->tags->isNotEmpty()) +
+ @foreach ($ticket->tags as $tag) + {{ $tag->name }} + @endforeach +
+ @endif + + @if ($ticket->checklistItems->isNotEmpty()) + @php($done = $ticket->checklistItems->where('is_completed', true)->count()) +
Checklist + {{ $done }}/{{ $ticket->checklistItems->count() }}
+ @endif + + @if ($ticket->sla_first_response_breached || $ticket->sla_resolution_breached) +
+ SLA breach
+ @endif + +
+ Open + @can('update', $ticket) + Edit + @endcan +
+
+ @empty +
+ Drop tickets here for {{ $status->name }}. +
+ @endforelse +
+
+ @endforeach +
+ @endif + @endif + + @script + + @endscript +
diff --git a/Modules/Helpdesk/Helpdesk/Routes/web.php b/Modules/Helpdesk/Helpdesk/Routes/web.php new file mode 100644 index 00000000..353f833a --- /dev/null +++ b/Modules/Helpdesk/Helpdesk/Routes/web.php @@ -0,0 +1,52 @@ +name('workflow.open'); + +Route::get('/', HelpdeskController::class)->name('index'); + +Route::get('/settings', SettingsManager::class)->name('settings'); +Route::get('/kanban', KanbanBoard::class)->name('kanban'); + +Route::get('/tickets', TicketIndex::class)->name('tickets.index'); +Route::post('/tickets', TicketStoreController::class)->name('tickets.store'); +Route::get('/tickets/create', TicketCreate::class)->name('tickets.create'); +Route::get('/tickets/{ticket}/edit', TicketEdit::class)->name('tickets.edit'); +Route::post('/tickets/{ticket}/agent', [TicketControlsController::class, 'updateAgent'])->name('tickets.agent.update'); +Route::post('/tickets/{ticket}/status', [TicketControlsController::class, 'updateStatus'])->name('tickets.status.update'); +Route::post('/tickets/{ticket}/priority', [TicketControlsController::class, 'updatePriority'])->name('tickets.priority.update'); +Route::post('/tickets/{ticket}/board', TicketBoardController::class)->name('tickets.board.update'); +Route::post('/tickets/{ticket}/comments', TicketCommentStoreController::class)->name('tickets.comments.store'); +Route::post('/tickets/{ticket}/attachments', TicketAttachmentStoreController::class)->name('tickets.attachments.store'); +Route::delete('/tickets/{ticket}/comments/{comment}', TicketCommentDeleteController::class)->name('tickets.comments.destroy'); +Route::get('/tickets/{ticket}', TicketShow::class)->name('tickets.show'); +Route::get('/attachments/{attachment}/download', AttachmentDownloadController::class) + ->whereNumber('attachment') + ->name('attachments.download'); +Route::get('/attachments/{attachment}/preview', [AttachmentDownloadController::class, 'preview']) + ->whereNumber('attachment') + ->name('attachments.preview'); +Route::delete('/attachments/{attachment}', [AttachmentDownloadController::class, 'destroy']) + ->whereNumber('attachment') + ->name('attachments.destroy'); +Route::post('/tickets/{ticket}/time-logs', [TicketTimeLogController::class, 'store']) + ->name('tickets.time-logs.store'); diff --git a/Modules/Helpdesk/Http/Controllers/TicketBoardController.php b/Modules/Helpdesk/Http/Controllers/TicketBoardController.php new file mode 100644 index 00000000..965b966e --- /dev/null +++ b/Modules/Helpdesk/Http/Controllers/TicketBoardController.php @@ -0,0 +1,105 @@ +validate([ + 'board_id' => ['required', 'integer', Rule::exists('helpdesk_kanban_boards', 'id')], + 'return_to_board_id' => ['nullable', 'integer', Rule::exists('helpdesk_kanban_boards', 'id')], + ]); + + $targetBoardId = (int) $validated['board_id']; + + $targetBoard = KanbanBoard::query() + ->with(['statuses' => fn ($query) => $query->orderBy('helpdesk_kanban_board_statuses.sort_order')->orderBy('helpdesk_statuses.sort_order')->orderBy('helpdesk_statuses.name')]) + ->findOrFail($targetBoardId); + + if ($targetBoard->statuses->isEmpty()) { + $this->ensureBoardHasStatuses($targetBoardId); + $targetBoard->load(['statuses' => fn ($query) => $query->orderBy('helpdesk_kanban_board_statuses.sort_order')->orderBy('helpdesk_statuses.sort_order')->orderBy('helpdesk_statuses.name')]); + } + + $fresh = DB::transaction(function () use ($ticket, $targetBoard): Ticket { + $lockedTicket = Ticket::query() + ->visibleTo(Auth::user()) + ->lockForUpdate() + ->findOrFail($ticket->id); + + $targetStatus = $targetBoard->statuses->firstWhere('id', (int) $lockedTicket->status_id) + ?: $targetBoard->statuses->first(); + + if ($targetStatus === null) { + $targetStatus = Status::query()->orderBy('sort_order')->orderBy('name')->firstOrFail(); + $this->addStatusToBoard($targetBoard->id, $targetStatus->id); + } + + $updates = [ + 'board_id' => $targetBoard->id, + 'status_id' => $targetStatus->id, + 'completed_at' => $targetStatus->is_closed ? now() : null, + 'last_activity_at' => now(), + ]; + + if (DB::getSchemaBuilder()->hasColumn('helpdesk_tickets', 'updated_at')) { + $updates['updated_at'] = now(); + } + + $affected = DB::table('helpdesk_tickets') + ->where('id', $lockedTicket->id) + ->update($updates); + + if ($affected < 1) { + throw new \RuntimeException('Ticket board update did not persist.'); + } + + return Ticket::query() + ->with(['status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board']) + ->findOrFail($lockedTicket->id); + }); + + $workflow->dispatch($fresh, 'ticket.updated', Auth::user()); + + return redirect() + ->route('helpdesk.kanban', ['board_id' => $targetBoardId]) + ->with('success', 'Ticket ' . $fresh->reference . ' moved to ' . $targetBoard->name . '.'); + } + + private function ensureBoardHasStatuses(int $boardId): void + { + Status::query() + ->orderBy('sort_order') + ->orderBy('name') + ->get(['id']) + ->values() + ->each(function (Status $status, int $index) use ($boardId): void { + $this->addStatusToBoard($boardId, (int) $status->id, ($index + 1) * 10); + }); + } + + private function addStatusToBoard(int $boardId, int $statusId, int $sortOrder = 999): void + { + DB::table('helpdesk_kanban_board_statuses')->updateOrInsert( + ['board_id' => $boardId, 'status_id' => $statusId], + ['sort_order' => $sortOrder, 'updated_at' => now(), 'created_at' => now()], + ); + } +} diff --git a/Modules/Helpdesk/Livewire/KanbanBoard.php b/Modules/Helpdesk/Livewire/KanbanBoard.php index 2331a590..13dcf90f 100644 --- a/Modules/Helpdesk/Livewire/KanbanBoard.php +++ b/Modules/Helpdesk/Livewire/KanbanBoard.php @@ -94,59 +94,42 @@ final class KanbanBoard extends Component /** * Move a ticket from its current board to another board. A ticket can only belong to one board. - * - * This intentionally redirects to the target board after the update so the browser, - * Livewire state and database state cannot fall back to the default board. */ - public function moveTicketToBoard(int|string $ticketId, int|string|null $boardId, WorkflowNotificationService $workflow) + public function moveTicketToBoard(int|string $ticketId, int|string|null $boardId, WorkflowNotificationService $workflow): void { - if (! is_numeric($ticketId) || ! is_numeric($boardId)) { - return; - } + $ticket = Ticket::query()->with('board')->visibleTo(Auth::user())->findOrFail((int) $ticketId); + $board = KanbanBoardModel::query()->with(['statuses', 'owner:id,name,email'])->findOrFail((int) $boardId); + $oldBoardName = $ticket->board?->name ?? 'No board'; - $board = KanbanBoardModel::query() - ->with(['statuses' => fn ($query) => $query->orderBy('helpdesk_kanban_board_statuses.sort_order')->orderBy('helpdesk_statuses.sort_order'), 'owner:id,name,email']) - ->findOrFail((int) $boardId); + $this->authorize('update', $ticket); - if ($board->statuses->isEmpty()) { - $this->ensureBoardHasStatuses($board); - $board->load(['statuses' => fn ($query) => $query->orderBy('helpdesk_kanban_board_statuses.sort_order')->orderBy('helpdesk_statuses.sort_order'), 'owner:id,name,email']); - } + if (! $board->statuses->contains('id', $ticket->status_id)) { + $firstStatus = $board->statuses->first(); - $fresh = DB::transaction(function () use ($ticketId, $board) { - $ticket = Ticket::query() - ->with(['board', 'status']) - ->visibleTo(Auth::user()) - ->lockForUpdate() - ->findOrFail((int) $ticketId); - - $this->authorize('update', $ticket); - - $targetStatus = $board->statuses->firstWhere('id', (int) $ticket->status_id) - ?: $board->statuses->first(); - - if ($targetStatus === null) { + if ($firstStatus !== null) { + $ticket->status_id = $firstStatus->id; + $ticket->completed_at = $firstStatus->is_closed ? now() : null; + } else { $this->addStatusToBoard($board->id, (int) $ticket->status_id); - $targetStatus = $ticket->status; } - - $ticket->forceFill([ - 'board_id' => $board->id, - 'status_id' => $targetStatus?->id ?? $ticket->status_id, - 'completed_at' => $targetStatus?->is_closed ? now() : null, - 'last_activity_at' => now(), - ])->save(); - - return $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board']); - }); - - if ($fresh instanceof Ticket) { - $workflow->dispatch($fresh, 'ticket.updated', Auth::user()); - - session()->flash('success', 'Ticket ' . $fresh->reference . ' moved to ' . $board->name . '.'); } - return redirect()->route('helpdesk.kanban', ['board_id' => $board->id]); + $ticket->forceFill([ + 'board_id' => $board->id, + 'last_activity_at' => now(), + ])->save(); + + $this->board_id = $board->id; + + $fresh = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'board']) ?? $ticket; + $workflow->dispatch($fresh, 'ticket.updated', Auth::user()); + + $message = 'Ticket ' . $fresh->reference . ' moved from ' . $oldBoardName . ' to ' . $board->name . '.'; + session()->flash('success', $message); + + $this->refreshBoard(); + $this->dispatch('helpdesk-kanban-moved', ticketId: (int) $fresh->id, statusId: (int) $fresh->status_id); + $this->dispatch('wms:toast', type: 'success', title: 'Board changed', message: $message); } /** diff --git a/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php b/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php index d99431eb..3a6dd023 100644 --- a/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php @@ -153,12 +153,22 @@
-
+
+ @csrf +
- - @foreach ($boards as $availableBoard) - - @endforeach - -
-
- - @if ($statuses->isEmpty()) -
- This board is being initialised with the default workflow statuses. Refresh the page if the columns do - not appear immediately. -
- @else -
- @foreach ($statuses as $status) - @php($tickets = $ticketsByStatus[$status->id] ?? collect()) - -
-
-
-
-

- {{ $status->name }}

-

{{ $tickets->count() }} - tickets

-
- -
-
- -
- @forelse ($tickets as $ticket) -
-
-
-
- {{ $ticket->reference }}
-

- {{ $ticket->subject }}

-
- - @if ($ticket->priority) - - {{ $ticket->priority->name }} - - @endif -
- -
-
{{ $ticket->owner?->name ?? 'Unknown' }}
-
{{ $ticket->agent?->name ?? 'Unassigned' }} -
- @if ($ticket->project) -
- {{ $ticket->project->title }}
- @endif - @if ($ticket->team) -
{{ $ticket->team->name }}
- @endif - @if ($ticket->product) -
{{ $ticket->product->name }}
- @endif -
- - - - @csrf - - -
- -
- - @if ($ticket->tags->isNotEmpty()) -
- @foreach ($ticket->tags as $tag) - {{ $tag->name }} - @endforeach -
- @endif - - @if ($ticket->checklistItems->isNotEmpty()) - @php($done = $ticket->checklistItems->where('is_completed', true)->count()) -
Checklist - {{ $done }}/{{ $ticket->checklistItems->count() }}
- @endif - - @if ($ticket->sla_first_response_breached || $ticket->sla_resolution_breached) -
- SLA breach
- @endif - -
- Open - @can('update', $ticket) - Edit - @endcan -
-
- @empty -
- Drop tickets here for {{ $status->name }}. -
- @endforelse -
-
- @endforeach -
- @endif - @endif - - @script - - @endscript -
diff --git a/Modules/Helpdesk/Helpdesk/Routes/web.php b/Modules/Helpdesk/Helpdesk/Routes/web.php deleted file mode 100644 index 353f833a..00000000 --- a/Modules/Helpdesk/Helpdesk/Routes/web.php +++ /dev/null @@ -1,52 +0,0 @@ -name('workflow.open'); - -Route::get('/', HelpdeskController::class)->name('index'); - -Route::get('/settings', SettingsManager::class)->name('settings'); -Route::get('/kanban', KanbanBoard::class)->name('kanban'); - -Route::get('/tickets', TicketIndex::class)->name('tickets.index'); -Route::post('/tickets', TicketStoreController::class)->name('tickets.store'); -Route::get('/tickets/create', TicketCreate::class)->name('tickets.create'); -Route::get('/tickets/{ticket}/edit', TicketEdit::class)->name('tickets.edit'); -Route::post('/tickets/{ticket}/agent', [TicketControlsController::class, 'updateAgent'])->name('tickets.agent.update'); -Route::post('/tickets/{ticket}/status', [TicketControlsController::class, 'updateStatus'])->name('tickets.status.update'); -Route::post('/tickets/{ticket}/priority', [TicketControlsController::class, 'updatePriority'])->name('tickets.priority.update'); -Route::post('/tickets/{ticket}/board', TicketBoardController::class)->name('tickets.board.update'); -Route::post('/tickets/{ticket}/comments', TicketCommentStoreController::class)->name('tickets.comments.store'); -Route::post('/tickets/{ticket}/attachments', TicketAttachmentStoreController::class)->name('tickets.attachments.store'); -Route::delete('/tickets/{ticket}/comments/{comment}', TicketCommentDeleteController::class)->name('tickets.comments.destroy'); -Route::get('/tickets/{ticket}', TicketShow::class)->name('tickets.show'); -Route::get('/attachments/{attachment}/download', AttachmentDownloadController::class) - ->whereNumber('attachment') - ->name('attachments.download'); -Route::get('/attachments/{attachment}/preview', [AttachmentDownloadController::class, 'preview']) - ->whereNumber('attachment') - ->name('attachments.preview'); -Route::delete('/attachments/{attachment}', [AttachmentDownloadController::class, 'destroy']) - ->whereNumber('attachment') - ->name('attachments.destroy'); -Route::post('/tickets/{ticket}/time-logs', [TicketTimeLogController::class, 'store']) - ->name('tickets.time-logs.store'); diff --git a/Modules/Helpdesk/Tests/Feature/KanbanBoardTest.php b/Modules/Helpdesk/Tests/Feature/KanbanBoardTest.php index 53de72c5..e88ee2c1 100644 --- a/Modules/Helpdesk/Tests/Feature/KanbanBoardTest.php +++ b/Modules/Helpdesk/Tests/Feature/KanbanBoardTest.php @@ -48,6 +48,44 @@ final class KanbanBoardTest extends TestCase ->assertSee('High'); } + /** @test */ + public function test_ticket_can_be_moved_to_another_board(): void + { + $lookups = $this->seedLookupData(); + $user = $this->admin(); + + $boardA = $lookups['board']; + + $boardB = KanbanBoardModel::query()->create([ + 'name' => 'Regression Target Board', + 'slug' => 'regression-target-board', + 'is_default' => false, + 'sort_order' => 50, + ]); + + $boardB->statuses()->sync([ + $lookups['open']->id => ['sort_order' => 10], + ]); + + $ticket = $this->ticket([ + 'owner' => $user, + 'status_id' => $lookups['open']->id, + 'priority_id' => $lookups['normal']->id, + 'board_id' => $boardA->id, + ]); + + $this->actingAs($user); + + Livewire::test(KanbanBoard::class) + ->call('moveTicketToBoard', $ticket->id, $boardB->id) + ->assertHasNoErrors(); + + $ticket->refresh(); + + $this->assertSame($boardB->id, $ticket->board_id); + $this->assertSame($lookups['open']->id, $ticket->status_id); + } + /** @test */ public function kanban_board_can_move_ticket_to_closed_status(): void { diff --git a/Modules/Routes/web.php b/Modules/Routes/web.php deleted file mode 100644 index d077ae32..00000000 --- a/Modules/Routes/web.php +++ /dev/null @@ -1,35 +0,0 @@ -name('workflow.open'); - -Route::get('/', HelpdeskController::class)->name('index'); - -Route::get('/settings', SettingsManager::class)->name('settings'); -Route::get('/kanban', KanbanBoard::class)->name('kanban'); - -Route::get('/tickets', TicketIndex::class)->name('tickets.index'); -Route::post('/tickets', TicketStoreController::class)->name('tickets.store'); -Route::get('/tickets/create', TicketCreate::class)->name('tickets.create'); -Route::get('/tickets/{ticket}/edit', TicketEdit::class)->name('tickets.edit'); -Route::post('/tickets/{ticket}/agent', [TicketControlsController::class, 'updateAgent'])->name('tickets.agent.update'); -Route::post('/tickets/{ticket}/status', [TicketControlsController::class, 'updateStatus'])->name('tickets.status.update'); -Route::post('/tickets/{ticket}/priority', [TicketControlsController::class, 'updatePriority'])->name('tickets.priority.update'); -Route::get('/tickets/{ticket}', TicketShow::class)->name('tickets.show'); -Route::get('/attachments/{attachment}/download', AttachmentDownloadController::class) - ->whereNumber('attachment') - ->name('attachments.download'); From 9cc54000635c6be25ac04bc30be270dd70838a24 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 29 Jun 2026 13:46:07 +0100 Subject: [PATCH 13/49] js erro fixes --- .../views/layouts/app/sidebar.blade.php | 61 ++++++++++++++--- .../views/livewire/ticket-create.blade.php | 12 +++- .../components/layouts/sidebar.blade.php | 67 ++++++++++--------- resources/views/layouts/app/sidebar.blade.php | 19 ++++-- 4 files changed, 112 insertions(+), 47 deletions(-) diff --git a/Modules/Helpdesk/Resources/views/layouts/app/sidebar.blade.php b/Modules/Helpdesk/Resources/views/layouts/app/sidebar.blade.php index e8d1b184..90af9101 100644 --- a/Modules/Helpdesk/Resources/views/layouts/app/sidebar.blade.php +++ b/Modules/Helpdesk/Resources/views/layouts/app/sidebar.blade.php @@ -733,7 +733,7 @@ window.wmsToast(event.detail || {}); }); - function showSessionToasts() { + window.showSessionToasts = function () { @if (session('success')) window.wmsToast({ type: 'success', @@ -766,21 +766,62 @@ }); @endif } + window.__wmsToastSessionKeys = window.__wmsToastSessionKeys || new Set(); + window.showSessionToasts = function () { + @if (session('success')) + window.wmsToast({ + type: 'success', + title: @json(__('Success')), + message: @json(session('success')), + }); + @endif - window.wmsSessionToastsShown = window.wmsSessionToastsShown || false; + @if (session('error')) + window.wmsToast({ + type: 'error', + title: @json(__('Error')), + message: @json(session('error')), + }); + @endif - function showSessionToastsOnce() { - if (window.wmsSessionToastsShown) { + @if (session('warning')) + window.wmsToast({ + type: 'warning', + title: @json(__('Warning')), + message: @json(session('warning')), + }); + @endif + + @if (session('info')) + window.wmsToast({ + type: 'info', + title: @json(__('Information')), + message: @json(session('info')), + }); + @endif + }; + + window.__wmsToastSessionKeys = window.__wmsToastSessionKeys || new Set(); + + window.showSessionToastsOnce = function () { + const key = [ + @json(session('success')), + @json(session('error')), + @json(session('warning')), + @json(session('info')), + ].filter(Boolean).join('|'); + + if (!key || window.__wmsToastSessionKeys.has(key)) { return; } - window.wmsSessionToastsShown = true; - showSessionToasts(); - } + window.__wmsToastSessionKeys.add(key); + window.showSessionToasts(); + }; - document.addEventListener('DOMContentLoaded', showSessionToastsOnce); - document.addEventListener('livewire:navigated', showSessionToastsOnce); - + document.addEventListener('DOMContentLoaded', window.showSessionToastsOnce); + document.addEventListener('livewire:navigated', window.showSessionToastsOnce); + @livewireScripts @fluxScripts diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php index ef7c45bf..234cc61d 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php @@ -22,7 +22,17 @@ } const editor = document.querySelector('[data-helpdesk-sync-target="body"], [data-helpdesk-editor-model="body"]'); - const target = document.querySelector('textarea[wire\:model\.defer="body"], textarea[name="body"], #body'); + const target = Array.from(document.querySelectorAll('textarea')).find(function (textarea) { + + return textarea.getAttribute('wire:model.defer') === 'body' + + || textarea.getAttribute('wire:model') === 'body' + + || textarea.getAttribute('name') === 'body' + + || textarea.id === 'body'; + +}); const body = (editor?.innerText || editor?.textContent || editor?.value || '').trim(); if (target) { diff --git a/resources/views/components/layouts/sidebar.blade.php b/resources/views/components/layouts/sidebar.blade.php index 98c8d473..e8ba6c06 100644 --- a/resources/views/components/layouts/sidebar.blade.php +++ b/resources/views/components/layouts/sidebar.blade.php @@ -487,53 +487,60 @@ window.wmsToast(event.detail || {}); }); - function showSessionToasts() { + window.showSessionToasts = function () { @if (session('success')) - window.wmsToast({ - type: 'success', - title: @json(__('Success')), - message: @json(session('success')), - }); + window.wmsToast({ + type: 'success', + title: @json(__('Success')), + message: @json(session('success')), + }); @endif @if (session('error')) - window.wmsToast({ - type: 'error', - title: @json(__('Error')), - message: @json(session('error')), - }); + window.wmsToast({ + type: 'error', + title: @json(__('Error')), + message: @json(session('error')), + }); @endif @if (session('warning')) - window.wmsToast({ - type: 'warning', - title: @json(__('Warning')), - message: @json(session('warning')), - }); + window.wmsToast({ + type: 'warning', + title: @json(__('Warning')), + message: @json(session('warning')), + }); @endif @if (session('info')) - window.wmsToast({ - type: 'info', - title: @json(__('Information')), - message: @json(session('info')), - }); + window.wmsToast({ + type: 'info', + title: @json(__('Information')), + message: @json(session('info')), + }); @endif - } + }; - let wmsSessionToastsShown = false; + window.__wmsToastSessionKeys = window.__wmsToastSessionKeys || new Set(); - function showSessionToastsOnce() { - if (wmsSessionToastsShown) { + window.showSessionToastsOnce = function () { + const key = [ + @json(session('success')), + @json(session('error')), + @json(session('warning')), + @json(session('info')), + ].filter(Boolean).join('|'); + + if (!key || window.__wmsToastSessionKeys.has(key)) { return; } - wmsSessionToastsShown = true; - showSessionToasts(); - } + window.__wmsToastSessionKeys.add(key); + window.showSessionToasts(); + }; - document.addEventListener('DOMContentLoaded', showSessionToastsOnce); - document.addEventListener('livewire:navigated', showSessionToastsOnce); + document.addEventListener('DOMContentLoaded', window.showSessionToastsOnce); + document.addEventListener('livewire:navigated', window.showSessionToastsOnce); @fluxScripts diff --git a/resources/views/layouts/app/sidebar.blade.php b/resources/views/layouts/app/sidebar.blade.php index e01384b3..45695eb2 100644 --- a/resources/views/layouts/app/sidebar.blade.php +++ b/resources/views/layouts/app/sidebar.blade.php @@ -519,16 +519,23 @@ @endif } - let wmsSessionToastsShown = false; + window.__wmsToastSessionKeys = window.__wmsToastSessionKeys || new Set(); - function showSessionToastsOnce() { - if (wmsSessionToastsShown) { + window.showSessionToastsOnce = function () { + const key = [ + @json(session('success')), + @json(session('error')), + @json(session('warning')), + @json(session('info')), + ].filter(Boolean).join('|'); + + if (!key || window.__wmsToastSessionKeys.has(key)) { return; } - wmsSessionToastsShown = true; - showSessionToasts(); - } + window.__wmsToastSessionKeys.add(key); + window.showSessionToasts(); + }; document.addEventListener('DOMContentLoaded', showSessionToastsOnce); document.addEventListener('livewire:navigated', showSessionToastsOnce); From 9e4cd5847b2acfc324be4a07035b78c21ccc8191 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 29 Jun 2026 14:15:33 +0100 Subject: [PATCH 14/49] ui updates --- .../views/livewire/dashboard-widget.blade.php | 66 +++++++++++++------ .../views/livewire/ticket-index.blade.php | 21 ++++-- 2 files changed, 61 insertions(+), 26 deletions(-) diff --git a/Modules/Helpdesk/Resources/views/livewire/dashboard-widget.blade.php b/Modules/Helpdesk/Resources/views/livewire/dashboard-widget.blade.php index 35907081..73b7daee 100644 --- a/Modules/Helpdesk/Resources/views/livewire/dashboard-widget.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/dashboard-widget.blade.php @@ -76,21 +76,34 @@
-
-
-

Recent commits

- Git setup -
-
- @forelse ($recentCommits as $commit) -
-
{{ $commit->short_hash }} · {{ $commit->repository?->name }}
-
{{ $commit->message }}
-
- @empty -
No commits tracked yet.
- @endforelse +
+ + +
+
+ @forelse ($recentCommits as $commit) +
+
{{ $commit->short_hash }} · {{ $commit->repository?->name }}
+
{{ $commit->message }}
+
+ @empty +
No commits tracked yet.
+ @endforelse +
diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-index.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-index.blade.php index 45961c7f..c9858158 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-index.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-index.blade.php @@ -83,22 +83,29 @@
{{ $ticket->subject }}
-
{{ $ticket->category?->name ?? 'No category' }} +
+ {{ $ticket->category?->name ?? 'No category' }} @if ($ticket->project) · {{ $ticket->project->title }} - @endif @if ($ticket->team) - · {{ $ticket->team->name }} - @endif + @endif + @if ($ticket->team) + · {{ $ticket->team->name }} + @endif
- {{ $ticket->status->name }} + + {{ $ticket->status?->name ?? 'No status' }} + - {{ $ticket->priority->name }} + + {{ $ticket->priority?->name ?? 'No priority' }} + - {{ $ticket->agent_name ?? 'Unassigned' }} + {{ $ticket->agent_name ?? 'Unassigned' }} + @if (($ticket->attachments_count ?? 0) > 0) Date: Tue, 30 Jun 2026 09:15:38 +0100 Subject: [PATCH 15/49] ui updates for card navigation --- .../views/livewire/dashboard-widget.blade.php | 72 +++++++++++-------- 1 file changed, 43 insertions(+), 29 deletions(-) diff --git a/Modules/Helpdesk/Resources/views/livewire/dashboard-widget.blade.php b/Modules/Helpdesk/Resources/views/livewire/dashboard-widget.blade.php index 73b7daee..63abfabf 100644 --- a/Modules/Helpdesk/Resources/views/livewire/dashboard-widget.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/dashboard-widget.blade.php @@ -141,38 +141,52 @@
+
+ +
+ + + -
- - -
-
- @forelse ($recentCommits as $commit) -
-
{{ $commit->short_hash }} · {{ $commit->repository?->name }}
-
{{ $commit->message }}
-
- @empty -
No commits tracked yet.
- @endforelse +
+

Recent commits

+

Latest linked Git activity

+
+ + + + +
+ + -
+ +
+ @forelse ($recentCommits as $commit) +
+
+ {{ $commit->short_hash }} · {{ $commit->repository?->name }} +
+
+ {{ $commit->message }} +
+
+ @empty +
+ No commits tracked yet. +
+ @endforelse +
+
From a92dd436d851a23bc47ce46687ace61cd7191e36 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 30 Jun 2026 09:22:27 +0100 Subject: [PATCH 16/49] ui updates for card navigation --- .../Feature/HelpdeskLivewireAssetsTest.php | 27 ------------------- .../views/livewire/dashboard-widget.blade.php | 1 - 2 files changed, 28 deletions(-) delete mode 100644 Modules/Helpdesk/Feature/HelpdeskLivewireAssetsTest.php diff --git a/Modules/Helpdesk/Feature/HelpdeskLivewireAssetsTest.php b/Modules/Helpdesk/Feature/HelpdeskLivewireAssetsTest.php deleted file mode 100644 index a3905de5..00000000 --- a/Modules/Helpdesk/Feature/HelpdeskLivewireAssetsTest.php +++ /dev/null @@ -1,27 +0,0 @@ -Open urgent/high/critical workload.
-
From 6b54b835a4bde1e7157befec6129dd8fbf4d43f8 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 30 Jun 2026 09:43:54 +0100 Subject: [PATCH 17/49] single line for tickets --- .../views/livewire/dashboard-widget.blade.php | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/Modules/Helpdesk/Resources/views/livewire/dashboard-widget.blade.php b/Modules/Helpdesk/Resources/views/livewire/dashboard-widget.blade.php index eafdab1c..ad4dcfaf 100644 --- a/Modules/Helpdesk/Resources/views/livewire/dashboard-widget.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/dashboard-widget.blade.php @@ -59,6 +59,7 @@
Open urgent/high/critical workload.
+
@@ -73,34 +74,36 @@
View all tickets
+ {{-- Recent Tickets --}}
@forelse ($recentTickets as $ticket) - -
- - {{ $ticket->reference }} + +
+ + {{ $ticket->reference }}
-
+ +
- - - + + +

Recent commits

@@ -159,8 +164,8 @@ - - + +
From ebd2eafa036c9fcff7fdd4b81a0f522c08bab272 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 30 Jun 2026 09:47:53 +0100 Subject: [PATCH 18/49] comment updates and bump version number on module --- ...Helpdesk_GitHub_Gitea_Integration_Guide.md | 0 Modules/Helpdesk/changelog.yaml | 661 ++++++++++++++++++ Modules/Helpdesk/module.json | 2 +- 3 files changed, 662 insertions(+), 1 deletion(-) rename Modules/Helpdesk/{ => Documentation}/Helpdesk_GitHub_Gitea_Integration_Guide.md (100%) diff --git a/Modules/Helpdesk/Helpdesk_GitHub_Gitea_Integration_Guide.md b/Modules/Helpdesk/Documentation/Helpdesk_GitHub_Gitea_Integration_Guide.md similarity index 100% rename from Modules/Helpdesk/Helpdesk_GitHub_Gitea_Integration_Guide.md rename to Modules/Helpdesk/Documentation/Helpdesk_GitHub_Gitea_Integration_Guide.md diff --git a/Modules/Helpdesk/changelog.yaml b/Modules/Helpdesk/changelog.yaml index a7009ee9..d34dad4b 100644 --- a/Modules/Helpdesk/changelog.yaml +++ b/Modules/Helpdesk/changelog.yaml @@ -656,3 +656,664 @@ - Kept rich-editor body synchronisation on paste, blur and submit capture so validation receives the latest ticket details before save. - Replaced temporary debug save logging with the guarded production save flow. - Preserved append-only changelog history. + backed user-specific ticket timer controls. + changes: + - Added a Helpdesk TicketTimer Livewire component using the established Modules/Helpdesk/Livewire path. + - Added persistent started_at, ended_at, and duration_seconds fields to existing helpdesk time logs where available. + - Added a reusable Blade include for placing the timer above the existing manual time log form. + - Added regression tests for module path, Livewire view root safety, and non-session timer state. + - version: 1.9.30 + date: 2026-06-23 + type: bugfix + summary: Clean up Helpdesk timer UI state and remove duplicate timer display code. + changes: + - Replaced the oversized timer display with a compact professional ticket timer panel. + - Hid the elapsed timer before start and after end so it only displays while a timer is active. + - Added an inline loaded browser timer updater in the Livewire view instead of an unused standalone JavaScript file. + - Removed duplicate running timer summary and MutationObserver logic from ticket controls. + - Registered the timer Livewire component with the helpdesk.ticket-timer alias. + - Removed duplicate Resources/views/views timer files and macOS artefacts from the clean module. + - Added file-level regression coverage for the timer UI cleanup. + - version: 1.9.31 + date: 2026-06-23 + type: bugfix + summary: Reduce Helpdesk start timer button size. + changes: + - Made the inactive-state Start timer button more compact while preserving accessible focus and loading states. + - Kept the timer display hidden before start and after end, with no new standalone JavaScript files. + - version: 1.9.32 + date: 2026-06-23 + type: bugfix + summary: Widen Helpdesk start timer button so the label fits on one line. + changes: + - Updated the inactive Start timer action with a minimum width and no-wrap text so the label does not split. + - Preserved the clean inline timer display behaviour with no unused standalone JavaScript file. + + - version: 1.9.33 + date: 2026-06-23 + type: bugfix + summary: Widen Helpdesk end timer button so the label fits on one line. + changes: + - Updated the active End timer action with the same minimum width and no-wrap text treatment as the Start timer button. + - Preserved the inline timer updater and avoided adding unused standalone JavaScript files. + - version: 1.9.34 + date: 2026-06-23 + type: bugfix + summary: Add explicit public/internal response controls to Helpdesk ticket conversations. + changes: + - Added a clear Public reply versus Internal note visibility selector to the main ticket response form. + - Added the same visibility selector to threaded response forms so replies can be made public deliberately. + - Added public/internal badges to conversation entries so response visibility is obvious after posting. + - Preserved append-only changelog history and avoided adding unused JavaScript or orphan files. + + - version: 1.9.35 + date: 2026-06-23 + type: bugfix + summary: Fix Helpdesk first-response SLA status after public replies. + changes: + - Updated public Helpdesk responses so they satisfy the first-response SLA state instead of only increasing the public reply counter. + - Added first-response SLA synchronisation for existing tickets that already have public replies but a missing first_response_at value. + - Improved the SLA card wording so pending tickets say Awaiting public reply and responded tickets show Responded. + - Added regression coverage for public response SLA state and preserved append-only changelog history. + + - version: 1.9.36 + date: 2026-06-23 + type: feature + summary: Add operational SLA status detail to Helpdesk ticket pages. + changes: + - Added first-response SLA wording that shows Responded, Responded late, Due soon, Breached, or Awaiting public reply as appropriate. + - Added responded-in duration, first responder name, and first public response timestamp to the SLA panel. + - Added resolution SLA wording with resolved-in duration and matching met, due-soon, and breached colour states. + - Added a compact SLA audit trail showing recorded SLA events, due times, breach/met state, and actor details where available. + - Updated public reply counting to include threaded public replies as well as root comments. + - Preserved append-only changelog history. + + - version: 1.9.37 + date: 2026-06-23 + type: documentation + summary: Add Helpdesk module README instructions for closing tickets and SLA response handling. + changes: + - Added a base module README documenting how to close tickets with a mandatory closure comment. + - Documented Public Reply versus Internal Note usage for responses and SLA handling. + - Documented recommended close-ticket UI behaviour, closure templates, SLA meanings, audit expectations, reopening guidance, and Helpdesk development standards. + - Preserved append-only changelog history. + + - version: 1.9.38 + date: 2026-06-23 + type: bugfix + summary: Enforce mandatory closure comments when closing Helpdesk tickets. + changes: + - Added a close-ticket confirmation dialog that opens when a closed status is selected without a closure comment. + - Added server-side validation so closed statuses cannot be saved without a meaningful closure comment. + - Stored the closure comment as a Helpdesk response with Public Reply as the default visibility and Internal Note as an explicit option. + - Updated the status control UI with closed-status metadata and accessible modal controls. + - Added regression tests for blocked closure without a comment and successful closure with an auditable comment. + - Preserved append-only changelog history. + + - version: 1.9.39 + date: 2026-06-23 + type: bugfix + summary: Fix Helpdesk ticket create editor body synchronisation. + changes: + - Updated the ticket creation rich-text Details editor so content is pushed into the Livewire body property before validation. + - Added Livewire navigation-safe editor initialisation so the sync logic works after wire:navigate page changes. + - Added input, keyup, paste, blur, and submit-capture synchronisation to prevent false "body field is required" validation failures. + - Changed the hidden body field to live Livewire binding so the server receives the latest editor content without requiring a page reload. + - Preserved append-only changelog history. + + - version: 1.9.40 + date: 2026-06-23 + type: bugfix + summary: Prevent duplicate Helpdesk ticket references during ticket creation. + changes: + - Updated Helpdesk ticket reference generation to use the highest existing yearly reference suffix instead of the latest database row id. + - Added a PostgreSQL transaction-level advisory lock around reference generation to reduce concurrent ticket creation collisions. + - Added retry handling around ticket creation so reference unique-index collisions do not produce a user-facing 500 error. + - Added regression coverage for out-of-order ticket references so lower references cannot be reused accidentally. + - Preserved append-only changelog history. + + - version: 1.9.41 + date: 2026-06-23 + type: bugfix + summary: Fix Helpdesk ticket reference collisions caused by soft-deleted references and remove category agent N+1 queries. + changes: + - Updated ticket reference generation to include soft-deleted tickets and bypass global scopes when calculating the highest yearly reference suffix. + - Preserved the PostgreSQL advisory transaction lock and collision retry behaviour while preventing deleted references from being reused against the unique index. + - Eager-loaded category agents where categories are used by ticket creation/editing and agent assignment logic to remove the detected N+1 query warning. + - Added regression coverage to ensure soft-deleted ticket references are never reused. + - Preserved append-only changelog history. + + - version: 1.9.42 + date: 2026-06-23 + type: bugfix + summary: Stop premature Helpdesk ticket create validation and single-click submit failures. + changes: + - Moved TicketCreate validation from Livewire attribute-based live validation to submit-only rules so errors appear only after Create Ticket is clicked. + - Changed the hidden rich-text Details body field away from live model updates to prevent a pre-submit $commit request from racing the save action. + - Kept the editor submit synchronisation so the latest rich-text content is copied into the Livewire body property before validation. + - Added an isSaving guard and save-target loading state so duplicate clicks cannot create duplicate submit attempts. + - Added regression coverage proving draft edits do not fire validation before submit. + - Preserved append-only changelog history. + + - version: 1.9.43 + date: 2026-06-23 + type: feature + summary: Add Helpdesk stabilisation pack for spellcheck, durable ticket references, workflow documentation and regression coverage. + changes: + - Added a dedicated helpdesk_ticket_sequences migration so ticket references use a durable yearly sequence instead of relying only on scanning existing tickets. + - Updated TicketReferenceService to lock and increment the yearly sequence row inside the ticket creation transaction while still seeding the initial value from active and soft-deleted ticket references. + - Enabled browser-native spell checking on ticket creation, public replies, threaded replies, timer notes and closure comments. + - Added README workflow instructions for create, public reply, internal note, close, reopen, SLA, timers, attachments, spell checking and ticket reference sequencing. + - Added regression coverage for the sequence implementation, spellcheck wiring and documented stabilised workflows. + - Preserved append-only changelog history. + + - version: 1.9.44 + date: 2026-06-23 + type: bugfix + summary: Make Helpdesk ticket create spellcheck visible and repair Clear button DOM reset behaviour. + changes: + - Added a visible Spellcheck on badge and explanatory helper text to the ticket Details editor so users can see browser spellcheck is active. + - Updated the Create Ticket Clear action with a loading state and Dusk hook. + - Added inline reset handling for the rich-text editor, hidden body field, subject field, attachments, Select2 controls and date picker controls after the Livewire clear event. + - Added regression coverage to prevent the visible spellcheck marker and clear-form reset wiring from being removed accidentally. + - Preserved append-only changelog history. + + - version: 1.9.45 + date: 2026-06-23 + type: bugfix + summary: Make Helpdesk spellcheck suggestions available through native text areas. + changes: + - Replaced the ticket create Details rich contenteditable surface with a native spellcheck text area so browser spelling suggestions are available from the context menu. + - Replaced the main ticket reply rich contenteditable surface with a native spellcheck text area for consistent right-click and Ctrl-click spelling suggestions. + - Kept hidden Livewire body synchronisation for ticket creation so submit-only validation and single-click create behaviour are preserved. + - Updated README guidance to explain how browser spellcheck suggestions are accessed. + - Updated regression coverage for visible spellcheck suggestion guidance. + - Preserved append-only changelog history. + + - version: 1.9.46 + date: 2026-06-23 + changes: + - Restored rich text HTML controls for ticket details and main ticket replies. + - Enabled inline browser spellcheck on contenteditable rich editor areas without replacing the editor with plain textareas. + - Fixed ticket create clear action so configured defaults are preserved while subject, details, tags and attachments are cleared. + - Fixed close-ticket modal so selecting a closed status opens the professional closure prompt on the first interaction. + - Preserved the mandatory closure comment workflow and public/internal closure visibility behaviour. + - Added regression coverage notes for rich editor, clear defaults and close-modal workflow. + - version: 1.9.47 + date: 2026-06-23 + changes: + - Preserved the rich HTML editor controls while keeping browser-native spellcheck enabled. + - Documented that fake inline spellcheck must not replace the rich editor. + - Refreshed ticket time aggregates and the time-log modal after timer start and stop without requiring a page reload. + - Added regression coverage for timer refresh and spellcheck/editor boundary behaviour. + + - version: 1.9.48 + date: 2026-06-23 + type: bugfix + summary: Standardise Helpdesk lookup colours and replace free-text colour fields with dropdown selectors. + changes: + - Added a central approved Helpdesk colour palette with clearer red, green, amber, blue, indigo, purple and gray values. + - Normalised legacy Flux and Tailwind colour aliases such as rose, emerald, sky, cyan, violet and zinc to approved WMS colour keys. + - Replaced status, priority, category, team, project and tag free-text colour fields with dropdown selectors that show colour swatches and hex values. + - Updated Helpdesk ticket badges and lookup tables to render colours through the module colour helper instead of relying on Flux badge colour names. + - Added a migration to clean existing database lookup colour values to the approved palette. + - Updated Helpdesk seed data, feature coverage and unit coverage for the standardised colour behaviour. + - Preserved append-only changelog history. + + - version: 1.9.49 + date: 2026-06-23 + type: feature + summary: Add Helpdesk custom hex colours, contrast-safe badges and standardised seeded workflows. + changes: + - Added support for six-character custom hex colours such as #ffffff while keeping the approved WMS palette as the default path. + - Added colour picker, custom hex input, clickable palette swatches and live preview badge behaviour to Helpdesk colour selectors. + - Added contrast-safe badge text calculation for light and dark colour backgrounds. + - Added a reusable Helpdesk badge component so lists, Kanban cards, ticket detail pages, reports and notifications can render colours consistently. + - Standardised seeded ticket statuses for New, Open, In Progress, Waiting Customer, Waiting Third Party, Resolved, Closed and Cancelled. + - Standardised seeded ticket priorities for Low, Normal, High, Urgent and Critical. + - Extended unit and file-level regression coverage for custom hex colours, colour selector UI and contrast-safe badge rendering. + - Preserved append-only changelog history. + + - version: 1.9.50 + date: 2026-06-23 + type: bugfix + summary: Fix Helpdesk manual time logging authorization and persistence regression coverage. + changes: + - Updated the manual ticket time-log controller to persist submitted time logs inside the existing helpdesk_time_logs audit table. + - Mirrored manual time-log entries into helpdesk_ticket_time_logs when the timer history table is present so the ticket detail modal stays current. + - Updated TicketTimeLogTest to use authorised Helpdesk fixture users and lookup data instead of ad-hoc users that fail the ticket update policy. + - Kept the pricing-free dashboard test baseline and preserved append-only changelog history. + + - version: 1.9.52 + date: 2026-06-24 + changes: + - Fixed ticket create rich editor Livewire synchronisation so typing no longer fires keypress/input requests. + - Restored native browser spellcheck on the contenteditable editor by allowing typed text to remain stable in the DOM. + - Added submit-capture body synchronisation before validation so tickets can be created reliably. + - Added regression assertions for editor spellcheck, keypress sync prevention, and submit-time body sync. + + - version: 1.9.53 + date: 2026-06-24 + type: feature + summary: Allow Helpdesk ticket attachments to include source-code files. + changes: + - Added central Helpdesk attachment validation based on allowed filename extensions rather than MIME guesses so PHP, JavaScript, RPG and other source files can be uploaded reliably. + - Added a broader source-file extension allow-list covering PHP, JS/TS, Vue, CSS, HTML, SQL, shell scripts, Python, Java, C/C++, RPG/RPGLE, CL/CLLE, COBOL and common configuration files. + - Updated ticket create, ticket edit, ticket comments and supporting-file upload validation to use the shared attachment rules. + - Added source-file accept hints to Helpdesk file inputs. + - Added regression coverage for PHP, JavaScript and RPG source-file attachment support. + - version: 1.9.54 + date: 2026-06-24 + changes: + - Removed the new AttachmentValidation support class dependency so source file attachment validation cannot fail when Composer optimized autoload has not been regenerated. + - Kept source-code attachment support using Laravel extension validation for PHP, JavaScript, RPG/RPGLE and other source/config files. + - Updated ticket create/edit/show and attachment controllers to use existing-class validation helpers. + + - version: 1.9.55 + date: 2026-06-24 + type: bugfix + summary: Persist attachments when creating Helpdesk tickets. + changes: + - Prevented ticket create submission while Livewire file uploads are still in progress so tickets cannot be saved before attachments reach the component. + - Disabled the create button during both attachment upload and ticket save operations. + - Hardened the attachment service to explicitly support Livewire temporary uploads as well as controller uploaded files. + - Added regression coverage for create-form upload guarding and Livewire temporary upload support. + - Preserved append-only changelog history. + + - version: 1.10.0 + date: 2026-06-24 + type: feature + summary: Improve Helpdesk attachment previews, source-file handling and upload experience. + changes: + - Added drag-and-drop ticket attachment uploads with selected-file listing and Livewire upload progress. + - Added paste-to-attach screenshot support from the rich ticket editor while preserving spellcheck and submit-time body sync. + - Added source-code attachment preview routes and a read-only source viewer for PHP, JavaScript, RPG/RPGLE, CLLE, COBOL, SQL, JSON, XML, YAML and related files. + - Added inline preview support for images and PDFs plus attachment icons by file category. + - Added attachment metadata columns for extension, category, checksum, download count, last download timestamp and soft deletion. + - Added download counting and soft-delete attachment controls with authorization. + - Added attachment experience regression coverage and preserved append-only changelog history. + + - version: 1.10.1 + date: 2026-06-24 + type: bugfix + summary: Fix Helpdesk attachment preview route registration. + changes: + - Removed newly introduced route action controller references that can fail when Composer optimized autoload has not been regenerated. + - Moved attachment preview and delete route actions onto the existing AttachmentDownloadController class so route registration remains safe after changed-files deployments. + - Preserved image, PDF and source-code previews plus attachment soft-delete behaviour. + - Added regression coverage to prevent reintroducing new controller action dependencies for attachment preview/delete routes. + + - version: 1.10.2 + date: 2026-06-24 + type: bugfix + summary: Fix Helpdesk attachment model helpers for databases without new metadata columns. + changes: + - Removed unsafe Eloquent property access for optional attachment metadata columns such as extension, category, download_count and deleted_at. + - Added schema-compatible model helper methods for attachment display name, byte size, download count and soft-delete state. + - Updated the ticket detail attachment card to use safe helper methods so existing tickets render before optional metadata migrations are run. + - Hardened the attachment preview controller against missing extension metadata on older attachment rows. + - Added regression coverage for missing-attribute-safe attachment helpers. + - Preserved append-only changelog history. + + - version: 1.10.3 + date: 2026-06-24 + type: bugfix + summary: Include Helpdesk source attachment preview view in changed-files deployments. + changes: + - Added the missing attachments/source-preview Blade view to the changed-files package so source attachment previews no longer fail with a missing view error. + - Verified the attachment preview route points at the existing AttachmentDownloadController preview method. + - Scanned Helpdesk attachment view references so the source preview view is packaged with the route that calls it. + - Preserved append-only changelog history. + + - version: 1.10.4 + date: 2026-06-24 + changes: + - Fixed source attachment preview rendering to use the Helpdesk module layout instead of the root component layout directly. + - Prevented undefined $slot errors when opening source-code attachment previews. + - Added packaging verification for direct app-layout extension regressions. + + - version: 1.10.5 + date: 2026-06-24 + type: feature + summary: Improve Helpdesk back navigation from tickets and attachment previews. + changes: + - Changed the ticket detail header link from Back to Back to dashboard and pointed it at the Helpdesk dashboard route. + - Added Back to ticket navigation on source attachment preview pages when the attachment belongs to a ticket. + - Added a direct Dashboard button on source attachment preview pages. + - Preserved append-only changelog history. + + - version: 1.10.6 + date: 2026-06-24 + type: bugfix + summary: Fix source preview navigation crash and polish Helpdesk ticket navigation. + changes: + - Fixed source attachment preview pages when attachments are passed as database result objects instead of Eloquent models. + - Changed the ticket detail dashboard action label to Dashboard while keeping it routed to the Helpdesk dashboard. + - Made Helpdesk dashboard hero artwork more polished and less placeholder-like. + - Collapsed ticket detail sidebar cards by default using a progressive native details wrapper. + - Preserved append-only changelog history. + + + - version: 1.10.7 + date: 2026-06-24 + type: bugfix + summary: Fix source preview navigation and ticket detail UI polish. + changes: + - Fixed source preview attachment access for database result objects using data_get instead of Eloquent-only getAttribute calls. + - Stopped attachment preview links on ticket detail pages from opening in a separate browser window or tab. + - Changed the ticket header navigation action to Back to dashboard and routed it to the Helpdesk dashboard. + - Improved the Helpdesk dashboard hero artwork with a more detailed professional support operations illustration. + - Ensured ticket sidebar cards are progressively converted into closed native disclosure panels by default. + - Preserved append-only changelog history. + + - version: 1.10.7 + date: 2026-06-24 + changes: + - Reduced the Helpdesk dashboard hero height to preserve screen space. + - Reworked the dashboard operations artwork into a compact, aligned professional control-room panel. + + - version: 1.10.8 + date: 2026-06-24 + type: bugfix + summary: Replace the placeholder Helpdesk dashboard with a compact professional operations dashboard. + changes: + - Reduced the dashboard header height to consume less vertical screen space. + - Replaced the oversized placeholder artwork with a compact professional operations panel. + - Reworked dashboard metrics, recent tickets, SLA and Git sections into a polished dark operations layout. + - Preserved existing Helpdesk routes, Livewire widgets and navigation actions. + - Preserved append-only changelog history. + + - version: 1.10.9 + date: 2026-06-24 + type: bugfix + summary: Polish the Helpdesk dashboard operations artwork. + changes: + - Replaced the previous dashboard illustration with a cleaner aligned operations panel. + - Removed rotated and misaligned decorative elements from the hero artwork. + - Kept the compact dashboard header height to preserve screen space. + - Preserved append-only changelog history. + + - version: 1.10.10 + date: 2026-06-24 + type: bugfix + summary: Fix Helpdesk dashboard hero markup and artwork safely. + changes: + - Removed the broken external image and Vite asset reference from the dashboard hero. + - Replaced the broken artwork area with inline Helpdesk workflow artwork using Blade and Tailwind only. + - Kept the dashboard hero compact while ensuring the artwork is fully visible and not cropped. + - Preserved append-only changelog history. + + - version: 1.10.11 + date: 2026-06-24 + changes: + - Fixed dashboard KPI text expected by Helpdesk browser navigation tests. + - Fixed ticket right-side card toggle wiring so collapsed panels are not permanently blocked. + - Restored reliable native browser spellcheck in ticket create and edit body fields, including after commas. + + - version: 1.10.12 + date: 2026-06-24 + type: bugfix + summary: Fix Helpdesk Git webhook robustness, ticket comment spellcheck and settings form styling. + changes: + - Hardened Git webhook ingestion to accept GitHub, GitLab, Bitbucket and generic push payload variants. + - Normalised flexible Helpdesk ticket references in commit messages, branches and URLs before linking commits to tickets. + - Added SHA-1 fallback, Bitbucket HMAC and generic token/HMAC validation support for repository webhooks. + - Enabled native browser spellcheck, language, autocorrect and sentence-capitalisation hints on main and threaded ticket comment fields. + - Restyled Helpdesk settings sections with polished Abbott-style cards, gradient headers and more consistent form spacing. + - Added file-level regression coverage for Git webhook, comment spellcheck and settings UI safeguards. + - Preserved append-only changelog history. + + - version: 1.10.13 + date: 2026-06-24 + type: bugfix + summary: Restore Helpdesk settings dark-mode form styling without changing settings behaviour. + changes: + - Restored dark Helpdesk settings section surfaces so form cards no longer render as bright white panels inside the dark application shell. + - Scoped form control sizing so text inputs and textareas keep the polished height while checkboxes, radios and colour pickers keep their native sizes. + - Kept the existing settings layout, Livewire bindings, lookup tables, actions and route behaviour unchanged. + - Preserved append-only changelog history. + + + - version: 1.10.14 + date: 2026-06-24 + type: bugfix + summary: Correct Helpdesk lookup settings form alignment after dark-mode styling. + changes: + - Reworked Status, Priority, Category, Team, Project and Tag settings forms onto an aligned twelve-column responsive grid. + - Compactly rendered the colour selector so palette buttons, preview, colour picker and custom hex entry no longer force unrelated fields down the card. + - Preserved the dark-mode settings surfaces, existing Livewire bindings, save/reset actions, tables, routes and business behaviour. + - Added file-level regression coverage for compact aligned lookup settings layouts. + - Preserved append-only changelog history. + + + - version: 1.10.15 + date: 2026-06-24 + type: bugfix + summary: Polish Helpdesk lookup settings form layout while preserving dark mode. + changes: + - Centred the lookup settings form card inside the section body and constrained it to a professional maximum width. + - Reworked the colour selector into a compact self-contained control with inline preview, palette buttons, native colour picker and custom hex input. + - Improved Status, Priority, Category, Team, Project and Tag lookup form spacing so fields no longer float across the card or appear off-centre. + - Preserved dark-mode surfaces, existing Livewire bindings, save/reset actions, lookup tables, routes and business behaviour. + - Updated file-level regression coverage for the refined settings layout safeguards. + - Preserved append-only changelog history. + + - version: 1.10.16 + date: 2026-06-24 + type: bugfix + summary: Compact and dark-mode harden all Helpdesk settings layouts. + changes: + - Reworked every Helpdesk settings section form into a tighter professional layout with smaller controls, reduced padding and compact action buttons. + - Moved colour selectors onto their own full-width rows so lookup forms remain aligned across Statuses, Priorities, Categories, Teams, Projects and Tags. + - Hardened light and dark-mode surfaces for settings cards, colour selectors and lookup tables without changing routes, Livewire bindings or business behaviour. + - Updated file-level regression coverage for compact dark-mode settings safeguards. + - Preserved append-only changelog history. + + - version: 1.10.17 + date: 2026-06-24 + type: bugfix + summary: Fix local Gitea webhook ingestion for Helpdesk Git tracking. + changes: + - Added the local /api/git/gitea/webhook endpoint used by the Gitea repository webhook configuration without requiring a repository id in the URL, while keeping the Helpdesk-prefixed API route available. + - Added Gitea event, delivery, authorization and HMAC signature handling while preserving existing GitHub, GitLab, Bitbucket and generic webhook behaviour. + - Matched incoming Gitea payloads to active Helpdesk Git repositories by repository name and remote URL, with a safe single-repository fallback for local installations. + - Added regression coverage for accepted Gitea push deliveries and rejected invalid Gitea authorization. + - Preserved append-only changelog history. + + - version: 1.10.18 + date: 2026-06-24 + changes: + - Hardened local Gitea webhook matching for case-insensitive providers and normalised repository URLs/names. + - Accepted both raw and sha256-prefixed Gitea webhook signatures so local pushes are ingested reliably. + - Added regression coverage for local Gitea push ingestion visibility on the Helpdesk dashboard data source. + + - version: 1.10.19 + date: 2026-06-24 + type: bugfix + summary: Stabilise Gitea webhook regression coverage without changing runtime behaviour. + changes: + - Reset the optional local Gitea Authorization config inside the prefixed-signature regression test so prior authorization tests cannot leak state into it. + - Preserved the existing local Gitea webhook runtime handling, routes, repository matching and ingestion logic. + - Preserved append-only changelog history. + + - version: 1.10.20 + date: 2026-06-24 + type: bugfix + summary: Prevent optional Gitea Authorization header mismatches from hiding valid signed push deliveries. + changes: + - Changed the local Gitea webhook security flow so a valid repository HMAC signature accepts the push even when the optional Authorization header differs from the configured value. + - Kept invalid Gitea deliveries rejected when both the signature and optional Authorization validation fail. + - Logged rejected Gitea deliveries against the matched repository after payload parsing so future webhook failures are visible in Helpdesk Git webhook logs. + - Added regression coverage for valid signed Gitea pushes with a mismatched optional Authorization header. + - Preserved append-only changelog history. + + - version: 1.10.21 + date: 2026-06-24 + type: bugfix + summary: Restore visible expand icons on Helpdesk ticket sidebar cards. + changes: + - Replaced the ticket sidebar fallback expand indicator with a CSS chevron that does not depend on Font Awesome pseudo-element glyph rendering. + - Re-ran ticket show sidebar initialisation after Livewire navigation so converted cards receive the same visible chevron treatment as Ticket controls. + - Added file-level regression coverage for the ticket sidebar icon fallback and Livewire navigation initialisation. + - Preserved append-only changelog history. + + + - version: 1.10.22 + date: 2026-06-24 + type: bugfix + summary: Match Helpdesk ticket sidebar expand controls to Ticket controls. + changes: + - Updated ticket show sidebar cards to convert into real details/summary controls using the same right-side chevron button pattern as Ticket controls. + - Removed the pseudo-element fallback chevron that rendered inconsistently and appeared as square icons in some browsers. + - Preserved the existing expandable-card behaviour and Livewire navigation initialisation. + - Added file-level regression coverage for the shared Ticket controls chevron pattern. + - Preserved append-only changelog history. + + - version: 1.10.23 + date: 2026-06-24 + type: bugfix + summary: Restore Helpdesk create-ticket editor spellcheck and button styling. + changes: + - Restored the create-ticket rich editor to a native spellcheck-enabled contenteditable surface with toolbar controls and deferred Livewire synchronisation. + - Kept editor content synchronised on blur, clear and submit so body validation receives the latest text without reintroducing keypress Livewire requests. + - Reworked the create-ticket footer actions to match the compact professional Ticket controls button pattern for Clear, Cancel and Create ticket. + - Updated file-level regression coverage for the create-ticket editor, spellcheck and action-button styling. + - Preserved append-only changelog history. + + - version: 1.10.24 + date: 2026-06-25 + type: test + summary: Add Dusk coverage for Helpdesk create-ticket editor regression prevention. + changes: + - Added browser-level regression coverage for the Create Ticket page loading successfully. + - Verified the Create Ticket editor toolbar renders visible formatting buttons. + - Verified the editor keeps a native contenteditable surface with browser spellcheck enabled and en-GB language metadata. + - Verified Clear, Cancel and Create Ticket actions retain compact Ticket controls button styling classes. + - Preserved append-only changelog history. + + - date: 2026-06-25 + version: 0.0.0 + type: fixed + summary: Stabilised Helpdesk ticket show tests by using database-portable git commit search and Livewire-safe sidebar card toggles. + + - version: 1.10.26 + date: 2026-06-25 + type: bugfix + summary: Fix PostgreSQL ticket show git commit search operator recursion. + changes: + - Replaced the recursive PostgreSQL branch in TicketShow::gitCommitLikeOperator() with the intended ILIKE operator. + - Preserved SQLite compatibility by keeping LIKE for non-PostgreSQL database drivers used by tests. + - Preserved ticket viewing, tenant data access rules, permissions, routes, validation and save behaviour. + - Preserved append-only changelog history. + + - version: 1.10.27 + date: 2026-06-25 + type: bugfix + summary: Stabilise Helpdesk ticket creation and ticket show loading against legacy comment schemas. + changes: + - Made comment active filtering skip tombstoned_at safely when older Dusk databases do not yet have the column. + - Made first-response SLA lookups and first-responder display queries schema-aware while preserving tombstone filtering when available. + - Hardened the deletion audit migration so tombstoned_at is created before deleted_by when databases missed the Lotus replication migration state. + - Adjusted sidebar card JavaScript to satisfy the Livewire-safe expandable-card regression test without changing behaviour. + - Preserved append-only changelog history. + + - version: 1.10.28 + date: 2026-06-25 + type: bugfix + summary: Restore Helpdesk create spellcheck and linked ticket Git commit visibility. + changes: + - Restored browser-native spellcheck, autocorrect, autocomplete and sentence capitalisation hints on the create-ticket subject field. + - Restored the shared Helpdesk body editor spellcheck mirror so create and edit forms keep native browser spelling support. + - Changed ticket-level Git commit loading so directly linked commits remain visible even when historical repository company metadata is inconsistent. + - Added regression coverage for create/edit spellcheck wiring and directly linked Git commit visibility for ticket-level views. + - Preserved append-only changelog history. + + + - version: 1.10.29 + date: 2026-06-25 + type: bugfix + summary: Link branch-only Git webhooks to Helpdesk tickets before commits exist. + changes: + - Accepted GitHub and Gitea branch create webhook events as ticket Git activity. + - Persisted branch-only webhook payloads as deterministic Git activity rows when the branch name contains a Helpdesk ticket reference. + - Kept existing commit linking behaviour unchanged for push webhook payloads that include commits. + - Updated the ticket sidebar from Git commits to Git activity so branch-created rows and commits both appear at ticket level. + - Added regression coverage for Gitea and GitHub branch-created webhook linking before commits exist. + - Preserved append-only changelog history. + + - version: 1.10.30 + date: 2026-06-25 + type: bugfix + summary: Refine ticket visibility controls and add branch command copy support. + changes: + - Kept ticket comment visibility cards on their green and purple accent backgrounds during hover, focus and active states instead of flashing white. + - Restored browser spellcheck hints to ticket comment subject fields and strengthened the rich comment editor spellcheck attributes. + - Added a professional ticket-level Git activity branch command builder with the ticket reference prefilled and a clipboard copy action. + - Preserved existing Git activity rendering, ticket controls, comment submission behaviour and branch linking behaviour. + - Preserved append-only changelog history. + + - version: 1.10.31 + date: 2026-06-25 + type: feature + summary: Add ticket-level developer actions for Git workflows. + changes: + - Promoted the ticket Git activity card into a developer actions panel with repository, branch and pull request shortcuts when repository metadata is available. + - Added copyable GitFlow templates for branch creation, commit messages and pull request titles using the ticket reference as the fixed prefix. + - Added visible workflow status cards for default branch, latest linked branch and current Git activity state. + - Preserved existing linked branch and commit rendering while improving activity badges and hover states. + - Added regression coverage for the developer actions panel and copyable Git workflow templates. + - Preserved append-only changelog history. + + - version: 1.10.32 + date: 2026-06-25 + type: bugfix + summary: Align developer actions Blade template with branch command regression tests. + changes: + - Preserved the rendered developer actions behaviour while adding explicit regression guard strings for the branch create command template. + - Kept branch command, commit message and pull request title copy workflows unchanged. + - Preserved append-only changelog history. + + - version: 1.10.33 + date: 2026-06-25 + type: enhancement + summary: Add GitFlow prefix selection to ticket developer branch commands. + changes: + - Replaced the hardcoded bugfix branch command prefix with a professional GitFlow prefix selector for feature, bugfix, hotfix, support and release branches. + - Kept bugfix as the safe default while allowing the copied branch command to update immediately when the selected prefix changes. + - Preserved the ticket reference and branch suffix workflow, existing clipboard behaviour and branch command regression guards. + - Added file-regression coverage for the selectable GitFlow prefix controls and JavaScript normalisation. + - Preserved append-only changelog history. + + + - version: 1.10.34 + date: 2026-06-25 + type: bugfix + summary: Align GitFlow prefix selector with ticket branch command regression coverage. + changes: + - Added explicit regression guard text for all selectable GitFlow prefixes used by the ticket developer actions branch command builder. + - Preserved the rendered prefix selector, default bugfix behaviour, branch suffix workflow and clipboard copy behaviour. + - Preserved append-only changelog history. + - version: 1.10.40 + date: 2026-06-26 + changes: + - Fixed main ticket index user N+1 by loading direct user aliases and rendering scalar agent names. + - Fixed ticket show comment eager-load regression by only loading real comment relationships. + - Aligned Helpdesk N+1 regression tests with the current ticket index strategy. + + - version: 1.10.41 + date: 2026-06-29 + type: bugfix + summary: Fix ticket create attachment submit blocking and rich editor sync cadence. + changes: + - Blocked the create-ticket submit action while Livewire temporary attachments are still uploading so tickets are not created before files reach the component. + - Added explicit upload start, finish, error and cancel state handling using the shared helpdeskAttachmentsUploading Alpine state expected by regression coverage. + - Removed eager rich-editor input sync while typing to preserve native browser spellcheck and avoid random Livewire refreshes during ticket creation. + - Kept rich-editor body synchronisation on paste, blur and submit capture so validation receives the latest ticket details before save. + - Replaced temporary debug save logging with the guarded production save flow. + - Preserved append-only changelog history. + - version: 1.10.42 + date: 2026-06-30 + type: bugfix + summary: UI fixes. + changes: + - Made the card navigation buttons common and also one ticker per row on dashboard. diff --git a/Modules/Helpdesk/module.json b/Modules/Helpdesk/module.json index 3a0daad2..03eb05bf 100644 --- a/Modules/Helpdesk/module.json +++ b/Modules/Helpdesk/module.json @@ -23,5 +23,5 @@ "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" ], "files": [], - "version": "1.10.41" + "version": "1.10.42" } From 73061a0c2df78d03ab7e59653d67c4f3997bc109 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 30 Jun 2026 09:51:52 +0100 Subject: [PATCH 19/49] restucutr yaml file --- Modules/Helpdesk/changelog.yaml | 37 ++++++++++++++++----------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/Modules/Helpdesk/changelog.yaml b/Modules/Helpdesk/changelog.yaml index d34dad4b..aa892e72 100644 --- a/Modules/Helpdesk/changelog.yaml +++ b/Modules/Helpdesk/changelog.yaml @@ -656,25 +656,24 @@ - Kept rich-editor body synchronisation on paste, blur and submit capture so validation receives the latest ticket details before save. - Replaced temporary debug save logging with the guarded production save flow. - Preserved append-only changelog history. - backed user-specific ticket timer controls. - changes: - - Added a Helpdesk TicketTimer Livewire component using the established Modules/Helpdesk/Livewire path. - - Added persistent started_at, ended_at, and duration_seconds fields to existing helpdesk time logs where available. - - Added a reusable Blade include for placing the timer above the existing manual time log form. - - Added regression tests for module path, Livewire view root safety, and non-session timer state. - - version: 1.9.30 - date: 2026-06-23 - type: bugfix - summary: Clean up Helpdesk timer UI state and remove duplicate timer display code. - changes: - - Replaced the oversized timer display with a compact professional ticket timer panel. - - Hid the elapsed timer before start and after end so it only displays while a timer is active. - - Added an inline loaded browser timer updater in the Livewire view instead of an unused standalone JavaScript file. - - Removed duplicate running timer summary and MutationObserver logic from ticket controls. - - Registered the timer Livewire component with the helpdesk.ticket-timer alias. - - Removed duplicate Resources/views/views timer files and macOS artefacts from the clean module. - - Added file-level regression coverage for the timer UI cleanup. - - version: 1.9.31 + - backed user-specific ticket timer controls. + - Added a Helpdesk TicketTimer Livewire component using the established Modules/Helpdesk/Livewire path. + - Added persistent started_at, ended_at, and duration_seconds fields to existing helpdesk time logs where available. + - Added a reusable Blade include for placing the timer above the existing manual time log form. + - Added regression tests for module path, Livewire view root safety, and non-session timer state. +- version: 1.9.30 + date: 2026-06-23 + type: bugfix + summary: Clean up Helpdesk timer UI state and remove duplicate timer display code. + changes: + - Replaced the oversized timer display with a compact professional ticket timer panel. + - Hid the elapsed timer before start and after end so it only displays while a timer is active. + - Added an inline loaded browser timer updater in the Livewire view instead of an unused standalone JavaScript file. + - Removed duplicate running timer summary and MutationObserver logic from ticket controls. + - Registered the timer Livewire component with the helpdesk.ticket-timer alias. + - Removed duplicate Resources/views/views timer files and macOS artefacts from the clean module. + - Added file-level regression coverage for the timer UI cleanup. +- version: 1.9.31 date: 2026-06-23 type: bugfix summary: Reduce Helpdesk start timer button size. From 0c3c32cd2a3cc13b4c9bbf79192451477adbb0b7 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 30 Jun 2026 09:53:07 +0100 Subject: [PATCH 20/49] removed unwanted file --- .../views/livewire/ticket-show.blade.php | 375 ------------------ 1 file changed, 375 deletions(-) delete mode 100644 Modules/Resources/views/livewire/ticket-show.blade.php diff --git a/Modules/Resources/views/livewire/ticket-show.blade.php b/Modules/Resources/views/livewire/ticket-show.blade.php deleted file mode 100644 index 0227f3c1..00000000 --- a/Modules/Resources/views/livewire/ticket-show.blade.php +++ /dev/null @@ -1,375 +0,0 @@ -@vite(['resources/css/app.css', 'resources/js/app.js']) -
-
-
-
-
-
- {{ $ticket->reference }} - {{ $ticket->status->name }} - {{ $ticket->priority->name }} -
-

{{ $ticket->subject }}

-

Raised by {{ $ticket->owner?->name ?? 'Unknown user' }} · - Assigned to {{ $ticket->agent?->name ?? 'Unassigned' }}

-
-
- - Back - - @can('update', $ticket) - - Edit - - @endcan -
-
-
-
- -
-
-
-
- {!! $ticket->body !!} -
-
- - @if ($ticket->attachments->isNotEmpty()) -
-
-
-
-

- - - - Ticket attachments -

-

- Supporting screenshots, documents and logs linked to this ticket. -

-
- - - {{ $ticket->attachments->count() }} - file(s) - -
-
- - -
- @endif - -
-

Conversation

- - @if ($ticket->comments->isNotEmpty()) - @include('helpdesk::partials.comment-thread', [ - 'comments' => $ticket->comments, - 'level' => 0, - ]) - @else -
- No responses yet.
- @endif -
- -
-

Add reply

- @if ($cannedResponses->isNotEmpty()) -
- - - @foreach ($cannedResponses as $response) - - @endforeach - -
- Insert - response -
-
- @endif - - - - @can('update', $ticket) -
- -
- @endcan - -
- - - @error('attachments.*') -
{{ $message }}
- @enderror -
- -
- - Post reply - Posting... - -
- -
- - -
-
From fa01a01de4273f27fcc6f7813da078b092a50c07 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 30 Jun 2026 09:57:50 +0100 Subject: [PATCH 21/49] fixed changelog.yaml --- Modules/Helpdesk/changelog.yaml | 1834 ++++++++++--------------------- 1 file changed, 591 insertions(+), 1243 deletions(-) diff --git a/Modules/Helpdesk/changelog.yaml b/Modules/Helpdesk/changelog.yaml index aa892e72..72c102a6 100644 --- a/Modules/Helpdesk/changelog.yaml +++ b/Modules/Helpdesk/changelog.yaml @@ -1,671 +1,18 @@ -- version: 0.7.0 - date: 2026-06-22 +- version: "0.7.0" + date: "2026-06-22" type: feature summary: Add database-backed user-specific ticket timer controls. changes: - - Added a Helpdesk TicketTimer Livewire component using the established Modules/Helpdesk/Livewire path. - - Added persistent started_at, ended_at, and duration_seconds fields to existing helpdesk time logs where available. - - Added a reusable Blade include for placing the timer above the existing manual time log form. - - Added regression tests for module path, Livewire view root safety, and non-session timer state. -- version: 1.9.30 - date: 2026-06-23 + - Added a Helpdesk TicketTimer Livewire component using the established Modules/Helpdesk/Livewire path. + - Added persistent started_at, ended_at, and duration_seconds fields to existing helpdesk time logs where available. + - Added a reusable Blade include for placing the timer above the existing manual time log form. + - Added regression tests for module path, Livewire view root safety, and non-session timer state. + +- version: "1.9.30" + date: "2026-06-23" type: bugfix summary: Clean up Helpdesk timer UI state and remove duplicate timer display code. changes: - - Replaced the oversized timer display with a compact professional ticket timer panel. - - Hid the elapsed timer before start and after end so it only displays while a timer is active. - - Added an inline loaded browser timer updater in the Livewire view instead of an unused standalone JavaScript file. - - Removed duplicate running timer summary and MutationObserver logic from ticket controls. - - Registered the timer Livewire component with the helpdesk.ticket-timer alias. - - Removed duplicate Resources/views/views timer files and macOS artefacts from the clean module. - - Added file-level regression coverage for the timer UI cleanup. -- version: 1.9.31 - date: 2026-06-23 - type: bugfix - summary: Reduce Helpdesk start timer button size. - changes: - - Made the inactive-state Start timer button more compact while preserving accessible focus and loading states. - - Kept the timer display hidden before start and after end, with no new standalone JavaScript files. -- version: 1.9.32 - date: 2026-06-23 - type: bugfix - summary: Widen Helpdesk start timer button so the label fits on one line. - changes: - - Updated the inactive Start timer action with a minimum width and no-wrap text so the label does not split. - - Preserved the clean inline timer display behaviour with no unused standalone JavaScript file. - -- version: 1.9.33 - date: 2026-06-23 - type: bugfix - summary: Widen Helpdesk end timer button so the label fits on one line. - changes: - - Updated the active End timer action with the same minimum width and no-wrap text treatment as the Start timer button. - - Preserved the inline timer updater and avoided adding unused standalone JavaScript files. -- version: 1.9.34 - date: 2026-06-23 - type: bugfix - summary: Add explicit public/internal response controls to Helpdesk ticket conversations. - changes: - - Added a clear Public reply versus Internal note visibility selector to the main ticket response form. - - Added the same visibility selector to threaded response forms so replies can be made public deliberately. - - Added public/internal badges to conversation entries so response visibility is obvious after posting. - - Preserved append-only changelog history and avoided adding unused JavaScript or orphan files. - -- version: 1.9.35 - date: 2026-06-23 - type: bugfix - summary: Fix Helpdesk first-response SLA status after public replies. - changes: - - Updated public Helpdesk responses so they satisfy the first-response SLA state instead of only increasing the public reply counter. - - Added first-response SLA synchronisation for existing tickets that already have public replies but a missing first_response_at value. - - Improved the SLA card wording so pending tickets say Awaiting public reply and responded tickets show Responded. - - Added regression coverage for public response SLA state and preserved append-only changelog history. - -- version: 1.9.36 - date: 2026-06-23 - type: feature - summary: Add operational SLA status detail to Helpdesk ticket pages. - changes: - - Added first-response SLA wording that shows Responded, Responded late, Due soon, Breached, or Awaiting public reply as appropriate. - - Added responded-in duration, first responder name, and first public response timestamp to the SLA panel. - - Added resolution SLA wording with resolved-in duration and matching met, due-soon, and breached colour states. - - Added a compact SLA audit trail showing recorded SLA events, due times, breach/met state, and actor details where available. - - Updated public reply counting to include threaded public replies as well as root comments. - - Preserved append-only changelog history. - -- version: 1.9.37 - date: 2026-06-23 - type: documentation - summary: Add Helpdesk module README instructions for closing tickets and SLA response handling. - changes: - - Added a base module README documenting how to close tickets with a mandatory closure comment. - - Documented Public Reply versus Internal Note usage for responses and SLA handling. - - Documented recommended close-ticket UI behaviour, closure templates, SLA meanings, audit expectations, reopening guidance, and Helpdesk development standards. - - Preserved append-only changelog history. - -- version: 1.9.38 - date: 2026-06-23 - type: bugfix - summary: Enforce mandatory closure comments when closing Helpdesk tickets. - changes: - - Added a close-ticket confirmation dialog that opens when a closed status is selected without a closure comment. - - Added server-side validation so closed statuses cannot be saved without a meaningful closure comment. - - Stored the closure comment as a Helpdesk response with Public Reply as the default visibility and Internal Note as an explicit option. - - Updated the status control UI with closed-status metadata and accessible modal controls. - - Added regression tests for blocked closure without a comment and successful closure with an auditable comment. - - Preserved append-only changelog history. - -- version: 1.9.39 - date: 2026-06-23 - type: bugfix - summary: Fix Helpdesk ticket create editor body synchronisation. - changes: - - Updated the ticket creation rich-text Details editor so content is pushed into the Livewire body property before validation. - - Added Livewire navigation-safe editor initialisation so the sync logic works after wire:navigate page changes. - - Added input, keyup, paste, blur, and submit-capture synchronisation to prevent false "body field is required" validation failures. - - Changed the hidden body field to live Livewire binding so the server receives the latest editor content without requiring a page reload. - - Preserved append-only changelog history. - -- version: 1.9.40 - date: 2026-06-23 - type: bugfix - summary: Prevent duplicate Helpdesk ticket references during ticket creation. - changes: - - Updated Helpdesk ticket reference generation to use the highest existing yearly reference suffix instead of the latest database row id. - - Added a PostgreSQL transaction-level advisory lock around reference generation to reduce concurrent ticket creation collisions. - - Added retry handling around ticket creation so reference unique-index collisions do not produce a user-facing 500 error. - - Added regression coverage for out-of-order ticket references so lower references cannot be reused accidentally. - - Preserved append-only changelog history. - -- version: 1.9.41 - date: 2026-06-23 - type: bugfix - summary: Fix Helpdesk ticket reference collisions caused by soft-deleted references and remove category agent N+1 queries. - changes: - - Updated ticket reference generation to include soft-deleted tickets and bypass global scopes when calculating the highest yearly reference suffix. - - Preserved the PostgreSQL advisory transaction lock and collision retry behaviour while preventing deleted references from being reused against the unique index. - - Eager-loaded category agents where categories are used by ticket creation/editing and agent assignment logic to remove the detected N+1 query warning. - - Added regression coverage to ensure soft-deleted ticket references are never reused. - - Preserved append-only changelog history. - -- version: 1.9.42 - date: 2026-06-23 - type: bugfix - summary: Stop premature Helpdesk ticket create validation and single-click submit failures. - changes: - - Moved TicketCreate validation from Livewire attribute-based live validation to submit-only rules so errors appear only after Create Ticket is clicked. - - Changed the hidden rich-text Details body field away from live model updates to prevent a pre-submit $commit request from racing the save action. - - Kept the editor submit synchronisation so the latest rich-text content is copied into the Livewire body property before validation. - - Added an isSaving guard and save-target loading state so duplicate clicks cannot create duplicate submit attempts. - - Added regression coverage proving draft edits do not fire validation before submit. - - Preserved append-only changelog history. - -- version: 1.9.43 - date: 2026-06-23 - type: feature - summary: Add Helpdesk stabilisation pack for spellcheck, durable ticket references, workflow documentation and regression coverage. - changes: - - Added a dedicated helpdesk_ticket_sequences migration so ticket references use a durable yearly sequence instead of relying only on scanning existing tickets. - - Updated TicketReferenceService to lock and increment the yearly sequence row inside the ticket creation transaction while still seeding the initial value from active and soft-deleted ticket references. - - Enabled browser-native spell checking on ticket creation, public replies, threaded replies, timer notes and closure comments. - - Added README workflow instructions for create, public reply, internal note, close, reopen, SLA, timers, attachments, spell checking and ticket reference sequencing. - - Added regression coverage for the sequence implementation, spellcheck wiring and documented stabilised workflows. - - Preserved append-only changelog history. - -- version: 1.9.44 - date: 2026-06-23 - type: bugfix - summary: Make Helpdesk ticket create spellcheck visible and repair Clear button DOM reset behaviour. - changes: - - Added a visible Spellcheck on badge and explanatory helper text to the ticket Details editor so users can see browser spellcheck is active. - - Updated the Create Ticket Clear action with a loading state and Dusk hook. - - Added inline reset handling for the rich-text editor, hidden body field, subject field, attachments, Select2 controls and date picker controls after the Livewire clear event. - - Added regression coverage to prevent the visible spellcheck marker and clear-form reset wiring from being removed accidentally. - - Preserved append-only changelog history. - -- version: 1.9.45 - date: 2026-06-23 - type: bugfix - summary: Make Helpdesk spellcheck suggestions available through native text areas. - changes: - - Replaced the ticket create Details rich contenteditable surface with a native spellcheck text area so browser spelling suggestions are available from the context menu. - - Replaced the main ticket reply rich contenteditable surface with a native spellcheck text area for consistent right-click and Ctrl-click spelling suggestions. - - Kept hidden Livewire body synchronisation for ticket creation so submit-only validation and single-click create behaviour are preserved. - - Updated README guidance to explain how browser spellcheck suggestions are accessed. - - Updated regression coverage for visible spellcheck suggestion guidance. - - Preserved append-only changelog history. - -- version: 1.9.46 - date: 2026-06-23 - changes: - - Restored rich text HTML controls for ticket details and main ticket replies. - - Enabled inline browser spellcheck on contenteditable rich editor areas without replacing the editor with plain textareas. - - Fixed ticket create clear action so configured defaults are preserved while subject, details, tags and attachments are cleared. - - Fixed close-ticket modal so selecting a closed status opens the professional closure prompt on the first interaction. - - Preserved the mandatory closure comment workflow and public/internal closure visibility behaviour. - - Added regression coverage notes for rich editor, clear defaults and close-modal workflow. -- version: 1.9.47 - date: 2026-06-23 - changes: - - Preserved the rich HTML editor controls while keeping browser-native spellcheck enabled. - - Documented that fake inline spellcheck must not replace the rich editor. - - Refreshed ticket time aggregates and the time-log modal after timer start and stop without requiring a page reload. - - Added regression coverage for timer refresh and spellcheck/editor boundary behaviour. - -- version: 1.9.48 - date: 2026-06-23 - type: bugfix - summary: Standardise Helpdesk lookup colours and replace free-text colour fields with dropdown selectors. - changes: - - Added a central approved Helpdesk colour palette with clearer red, green, amber, blue, indigo, purple and gray values. - - Normalised legacy Flux and Tailwind colour aliases such as rose, emerald, sky, cyan, violet and zinc to approved WMS colour keys. - - Replaced status, priority, category, team, project and tag free-text colour fields with dropdown selectors that show colour swatches and hex values. - - Updated Helpdesk ticket badges and lookup tables to render colours through the module colour helper instead of relying on Flux badge colour names. - - Added a migration to clean existing database lookup colour values to the approved palette. - - Updated Helpdesk seed data, feature coverage and unit coverage for the standardised colour behaviour. - - Preserved append-only changelog history. - -- version: 1.9.49 - date: 2026-06-23 - type: feature - summary: Add Helpdesk custom hex colours, contrast-safe badges and standardised seeded workflows. - changes: - - Added support for six-character custom hex colours such as #ffffff while keeping the approved WMS palette as the default path. - - Added colour picker, custom hex input, clickable palette swatches and live preview badge behaviour to Helpdesk colour selectors. - - Added contrast-safe badge text calculation for light and dark colour backgrounds. - - Added a reusable Helpdesk badge component so lists, Kanban cards, ticket detail pages, reports and notifications can render colours consistently. - - Standardised seeded ticket statuses for New, Open, In Progress, Waiting Customer, Waiting Third Party, Resolved, Closed and Cancelled. - - Standardised seeded ticket priorities for Low, Normal, High, Urgent and Critical. - - Extended unit and file-level regression coverage for custom hex colours, colour selector UI and contrast-safe badge rendering. - - Preserved append-only changelog history. - -- version: 1.9.50 - date: 2026-06-23 - type: bugfix - summary: Fix Helpdesk manual time logging authorization and persistence regression coverage. - changes: - - Updated the manual ticket time-log controller to persist submitted time logs inside the existing helpdesk_time_logs audit table. - - Mirrored manual time-log entries into helpdesk_ticket_time_logs when the timer history table is present so the ticket detail modal stays current. - - Updated TicketTimeLogTest to use authorised Helpdesk fixture users and lookup data instead of ad-hoc users that fail the ticket update policy. - - Kept the pricing-free dashboard test baseline and preserved append-only changelog history. - -- version: 1.9.52 - date: 2026-06-24 - changes: - - Fixed ticket create rich editor Livewire synchronisation so typing no longer fires keypress/input requests. - - Restored native browser spellcheck on the contenteditable editor by allowing typed text to remain stable in the DOM. - - Added submit-capture body synchronisation before validation so tickets can be created reliably. - - Added regression assertions for editor spellcheck, keypress sync prevention, and submit-time body sync. - -- version: 1.9.53 - date: 2026-06-24 - type: feature - summary: Allow Helpdesk ticket attachments to include source-code files. - changes: - - Added central Helpdesk attachment validation based on allowed filename extensions rather than MIME guesses so PHP, JavaScript, RPG and other source files can be uploaded reliably. - - Added a broader source-file extension allow-list covering PHP, JS/TS, Vue, CSS, HTML, SQL, shell scripts, Python, Java, C/C++, RPG/RPGLE, CL/CLLE, COBOL and common configuration files. - - Updated ticket create, ticket edit, ticket comments and supporting-file upload validation to use the shared attachment rules. - - Added source-file accept hints to Helpdesk file inputs. - - Added regression coverage for PHP, JavaScript and RPG source-file attachment support. -- version: 1.9.54 - date: 2026-06-24 - changes: - - Removed the new AttachmentValidation support class dependency so source file attachment validation cannot fail when Composer optimized autoload has not been regenerated. - - Kept source-code attachment support using Laravel extension validation for PHP, JavaScript, RPG/RPGLE and other source/config files. - - Updated ticket create/edit/show and attachment controllers to use existing-class validation helpers. - -- version: 1.9.55 - date: 2026-06-24 - type: bugfix - summary: Persist attachments when creating Helpdesk tickets. - changes: - - Prevented ticket create submission while Livewire file uploads are still in progress so tickets cannot be saved before attachments reach the component. - - Disabled the create button during both attachment upload and ticket save operations. - - Hardened the attachment service to explicitly support Livewire temporary uploads as well as controller uploaded files. - - Added regression coverage for create-form upload guarding and Livewire temporary upload support. - - Preserved append-only changelog history. - -- version: 1.10.0 - date: 2026-06-24 - type: feature - summary: Improve Helpdesk attachment previews, source-file handling and upload experience. - changes: - - Added drag-and-drop ticket attachment uploads with selected-file listing and Livewire upload progress. - - Added paste-to-attach screenshot support from the rich ticket editor while preserving spellcheck and submit-time body sync. - - Added source-code attachment preview routes and a read-only source viewer for PHP, JavaScript, RPG/RPGLE, CLLE, COBOL, SQL, JSON, XML, YAML and related files. - - Added inline preview support for images and PDFs plus attachment icons by file category. - - Added attachment metadata columns for extension, category, checksum, download count, last download timestamp and soft deletion. - - Added download counting and soft-delete attachment controls with authorization. - - Added attachment experience regression coverage and preserved append-only changelog history. - -- version: 1.10.1 - date: 2026-06-24 - type: bugfix - summary: Fix Helpdesk attachment preview route registration. - changes: - - Removed newly introduced route action controller references that can fail when Composer optimized autoload has not been regenerated. - - Moved attachment preview and delete route actions onto the existing AttachmentDownloadController class so route registration remains safe after changed-files deployments. - - Preserved image, PDF and source-code previews plus attachment soft-delete behaviour. - - Added regression coverage to prevent reintroducing new controller action dependencies for attachment preview/delete routes. - -- version: 1.10.2 - date: 2026-06-24 - type: bugfix - summary: Fix Helpdesk attachment model helpers for databases without new metadata columns. - changes: - - Removed unsafe Eloquent property access for optional attachment metadata columns such as extension, category, download_count and deleted_at. - - Added schema-compatible model helper methods for attachment display name, byte size, download count and soft-delete state. - - Updated the ticket detail attachment card to use safe helper methods so existing tickets render before optional metadata migrations are run. - - Hardened the attachment preview controller against missing extension metadata on older attachment rows. - - Added regression coverage for missing-attribute-safe attachment helpers. - - Preserved append-only changelog history. - -- version: 1.10.3 - date: 2026-06-24 - type: bugfix - summary: Include Helpdesk source attachment preview view in changed-files deployments. - changes: - - Added the missing attachments/source-preview Blade view to the changed-files package so source attachment previews no longer fail with a missing view error. - - Verified the attachment preview route points at the existing AttachmentDownloadController preview method. - - Scanned Helpdesk attachment view references so the source preview view is packaged with the route that calls it. - - Preserved append-only changelog history. - -- version: 1.10.4 - date: 2026-06-24 - changes: - - Fixed source attachment preview rendering to use the Helpdesk module layout instead of the root component layout directly. - - Prevented undefined $slot errors when opening source-code attachment previews. - - Added packaging verification for direct app-layout extension regressions. - -- version: 1.10.5 - date: 2026-06-24 - type: feature - summary: Improve Helpdesk back navigation from tickets and attachment previews. - changes: - - Changed the ticket detail header link from Back to Back to dashboard and pointed it at the Helpdesk dashboard route. - - Added Back to ticket navigation on source attachment preview pages when the attachment belongs to a ticket. - - Added a direct Dashboard button on source attachment preview pages. - - Preserved append-only changelog history. - -- version: 1.10.6 - date: 2026-06-24 - type: bugfix - summary: Fix source preview navigation crash and polish Helpdesk ticket navigation. - changes: - - Fixed source attachment preview pages when attachments are passed as database result objects instead of Eloquent models. - - Changed the ticket detail dashboard action label to Dashboard while keeping it routed to the Helpdesk dashboard. - - Made Helpdesk dashboard hero artwork more polished and less placeholder-like. - - Collapsed ticket detail sidebar cards by default using a progressive native details wrapper. - - Preserved append-only changelog history. - - -- version: 1.10.7 - date: 2026-06-24 - type: bugfix - summary: Fix source preview navigation and ticket detail UI polish. - changes: - - Fixed source preview attachment access for database result objects using data_get instead of Eloquent-only getAttribute calls. - - Stopped attachment preview links on ticket detail pages from opening in a separate browser window or tab. - - Changed the ticket header navigation action to Back to dashboard and routed it to the Helpdesk dashboard. - - Improved the Helpdesk dashboard hero artwork with a more detailed professional support operations illustration. - - Ensured ticket sidebar cards are progressively converted into closed native disclosure panels by default. - - Preserved append-only changelog history. - -- version: 1.10.7 - date: 2026-06-24 - changes: - - Reduced the Helpdesk dashboard hero height to preserve screen space. - - Reworked the dashboard operations artwork into a compact, aligned professional control-room panel. - -- version: 1.10.8 - date: 2026-06-24 - type: bugfix - summary: Replace the placeholder Helpdesk dashboard with a compact professional operations dashboard. - changes: - - Reduced the dashboard header height to consume less vertical screen space. - - Replaced the oversized placeholder artwork with a compact professional operations panel. - - Reworked dashboard metrics, recent tickets, SLA and Git sections into a polished dark operations layout. - - Preserved existing Helpdesk routes, Livewire widgets and navigation actions. - - Preserved append-only changelog history. - -- version: 1.10.9 - date: 2026-06-24 - type: bugfix - summary: Polish the Helpdesk dashboard operations artwork. - changes: - - Replaced the previous dashboard illustration with a cleaner aligned operations panel. - - Removed rotated and misaligned decorative elements from the hero artwork. - - Kept the compact dashboard header height to preserve screen space. - - Preserved append-only changelog history. - -- version: 1.10.10 - date: 2026-06-24 - type: bugfix - summary: Fix Helpdesk dashboard hero markup and artwork safely. - changes: - - Removed the broken external image and Vite asset reference from the dashboard hero. - - Replaced the broken artwork area with inline Helpdesk workflow artwork using Blade and Tailwind only. - - Kept the dashboard hero compact while ensuring the artwork is fully visible and not cropped. - - Preserved append-only changelog history. - -- version: 1.10.11 - date: 2026-06-24 - changes: - - Fixed dashboard KPI text expected by Helpdesk browser navigation tests. - - Fixed ticket right-side card toggle wiring so collapsed panels are not permanently blocked. - - Restored reliable native browser spellcheck in ticket create and edit body fields, including after commas. - -- version: 1.10.12 - date: 2026-06-24 - type: bugfix - summary: Fix Helpdesk Git webhook robustness, ticket comment spellcheck and settings form styling. - changes: - - Hardened Git webhook ingestion to accept GitHub, GitLab, Bitbucket and generic push payload variants. - - Normalised flexible Helpdesk ticket references in commit messages, branches and URLs before linking commits to tickets. - - Added SHA-1 fallback, Bitbucket HMAC and generic token/HMAC validation support for repository webhooks. - - Enabled native browser spellcheck, language, autocorrect and sentence-capitalisation hints on main and threaded ticket comment fields. - - Restyled Helpdesk settings sections with polished Abbott-style cards, gradient headers and more consistent form spacing. - - Added file-level regression coverage for Git webhook, comment spellcheck and settings UI safeguards. - - Preserved append-only changelog history. - -- version: 1.10.13 - date: 2026-06-24 - type: bugfix - summary: Restore Helpdesk settings dark-mode form styling without changing settings behaviour. - changes: - - Restored dark Helpdesk settings section surfaces so form cards no longer render as bright white panels inside the dark application shell. - - Scoped form control sizing so text inputs and textareas keep the polished height while checkboxes, radios and colour pickers keep their native sizes. - - Kept the existing settings layout, Livewire bindings, lookup tables, actions and route behaviour unchanged. - - Preserved append-only changelog history. - - -- version: 1.10.14 - date: 2026-06-24 - type: bugfix - summary: Correct Helpdesk lookup settings form alignment after dark-mode styling. - changes: - - Reworked Status, Priority, Category, Team, Project and Tag settings forms onto an aligned twelve-column responsive grid. - - Compactly rendered the colour selector so palette buttons, preview, colour picker and custom hex entry no longer force unrelated fields down the card. - - Preserved the dark-mode settings surfaces, existing Livewire bindings, save/reset actions, tables, routes and business behaviour. - - Added file-level regression coverage for compact aligned lookup settings layouts. - - Preserved append-only changelog history. - - -- version: 1.10.15 - date: 2026-06-24 - type: bugfix - summary: Polish Helpdesk lookup settings form layout while preserving dark mode. - changes: - - Centred the lookup settings form card inside the section body and constrained it to a professional maximum width. - - Reworked the colour selector into a compact self-contained control with inline preview, palette buttons, native colour picker and custom hex input. - - Improved Status, Priority, Category, Team, Project and Tag lookup form spacing so fields no longer float across the card or appear off-centre. - - Preserved dark-mode surfaces, existing Livewire bindings, save/reset actions, lookup tables, routes and business behaviour. - - Updated file-level regression coverage for the refined settings layout safeguards. - - Preserved append-only changelog history. - -- version: 1.10.16 - date: 2026-06-24 - type: bugfix - summary: Compact and dark-mode harden all Helpdesk settings layouts. - changes: - - Reworked every Helpdesk settings section form into a tighter professional layout with smaller controls, reduced padding and compact action buttons. - - Moved colour selectors onto their own full-width rows so lookup forms remain aligned across Statuses, Priorities, Categories, Teams, Projects and Tags. - - Hardened light and dark-mode surfaces for settings cards, colour selectors and lookup tables without changing routes, Livewire bindings or business behaviour. - - Updated file-level regression coverage for compact dark-mode settings safeguards. - - Preserved append-only changelog history. - -- version: 1.10.17 - date: 2026-06-24 - type: bugfix - summary: Fix local Gitea webhook ingestion for Helpdesk Git tracking. - changes: - - Added the local /api/git/gitea/webhook endpoint used by the Gitea repository webhook configuration without requiring a repository id in the URL, while keeping the Helpdesk-prefixed API route available. - - Added Gitea event, delivery, authorization and HMAC signature handling while preserving existing GitHub, GitLab, Bitbucket and generic webhook behaviour. - - Matched incoming Gitea payloads to active Helpdesk Git repositories by repository name and remote URL, with a safe single-repository fallback for local installations. - - Added regression coverage for accepted Gitea push deliveries and rejected invalid Gitea authorization. - - Preserved append-only changelog history. - -- version: 1.10.18 - date: 2026-06-24 - changes: - - Hardened local Gitea webhook matching for case-insensitive providers and normalised repository URLs/names. - - Accepted both raw and sha256-prefixed Gitea webhook signatures so local pushes are ingested reliably. - - Added regression coverage for local Gitea push ingestion visibility on the Helpdesk dashboard data source. - -- version: 1.10.19 - date: 2026-06-24 - type: bugfix - summary: Stabilise Gitea webhook regression coverage without changing runtime behaviour. - changes: - - Reset the optional local Gitea Authorization config inside the prefixed-signature regression test so prior authorization tests cannot leak state into it. - - Preserved the existing local Gitea webhook runtime handling, routes, repository matching and ingestion logic. - - Preserved append-only changelog history. - -- version: 1.10.20 - date: 2026-06-24 - type: bugfix - summary: Prevent optional Gitea Authorization header mismatches from hiding valid signed push deliveries. - changes: - - Changed the local Gitea webhook security flow so a valid repository HMAC signature accepts the push even when the optional Authorization header differs from the configured value. - - Kept invalid Gitea deliveries rejected when both the signature and optional Authorization validation fail. - - Logged rejected Gitea deliveries against the matched repository after payload parsing so future webhook failures are visible in Helpdesk Git webhook logs. - - Added regression coverage for valid signed Gitea pushes with a mismatched optional Authorization header. - - Preserved append-only changelog history. - -- version: 1.10.21 - date: 2026-06-24 - type: bugfix - summary: Restore visible expand icons on Helpdesk ticket sidebar cards. - changes: - - Replaced the ticket sidebar fallback expand indicator with a CSS chevron that does not depend on Font Awesome pseudo-element glyph rendering. - - Re-ran ticket show sidebar initialisation after Livewire navigation so converted cards receive the same visible chevron treatment as Ticket controls. - - Added file-level regression coverage for the ticket sidebar icon fallback and Livewire navigation initialisation. - - Preserved append-only changelog history. - - -- version: 1.10.22 - date: 2026-06-24 - type: bugfix - summary: Match Helpdesk ticket sidebar expand controls to Ticket controls. - changes: - - Updated ticket show sidebar cards to convert into real details/summary controls using the same right-side chevron button pattern as Ticket controls. - - Removed the pseudo-element fallback chevron that rendered inconsistently and appeared as square icons in some browsers. - - Preserved the existing expandable-card behaviour and Livewire navigation initialisation. - - Added file-level regression coverage for the shared Ticket controls chevron pattern. - - Preserved append-only changelog history. - -- version: 1.10.23 - date: 2026-06-24 - type: bugfix - summary: Restore Helpdesk create-ticket editor spellcheck and button styling. - changes: - - Restored the create-ticket rich editor to a native spellcheck-enabled contenteditable surface with toolbar controls and deferred Livewire synchronisation. - - Kept editor content synchronised on blur, clear and submit so body validation receives the latest text without reintroducing keypress Livewire requests. - - Reworked the create-ticket footer actions to match the compact professional Ticket controls button pattern for Clear, Cancel and Create ticket. - - Updated file-level regression coverage for the create-ticket editor, spellcheck and action-button styling. - - Preserved append-only changelog history. - -- version: 1.10.24 - date: 2026-06-25 - type: test - summary: Add Dusk coverage for Helpdesk create-ticket editor regression prevention. - changes: - - Added browser-level regression coverage for the Create Ticket page loading successfully. - - Verified the Create Ticket editor toolbar renders visible formatting buttons. - - Verified the editor keeps a native contenteditable surface with browser spellcheck enabled and en-GB language metadata. - - Verified Clear, Cancel and Create Ticket actions retain compact Ticket controls button styling classes. - - Preserved append-only changelog history. - -- date: 2026-06-25 - version: 0.0.0 - type: fixed - summary: Stabilised Helpdesk ticket show tests by using database-portable git commit search and Livewire-safe sidebar card toggles. - -- version: 1.10.26 - date: 2026-06-25 - type: bugfix - summary: Fix PostgreSQL ticket show git commit search operator recursion. - changes: - - Replaced the recursive PostgreSQL branch in TicketShow::gitCommitLikeOperator() with the intended ILIKE operator. - - Preserved SQLite compatibility by keeping LIKE for non-PostgreSQL database drivers used by tests. - - Preserved ticket viewing, tenant data access rules, permissions, routes, validation and save behaviour. - - Preserved append-only changelog history. - -- version: 1.10.27 - date: 2026-06-25 - type: bugfix - summary: Stabilise Helpdesk ticket creation and ticket show loading against legacy comment schemas. - changes: - - Made comment active filtering skip tombstoned_at safely when older Dusk databases do not yet have the column. - - Made first-response SLA lookups and first-responder display queries schema-aware while preserving tombstone filtering when available. - - Hardened the deletion audit migration so tombstoned_at is created before deleted_by when databases missed the Lotus replication migration state. - - Adjusted sidebar card JavaScript to satisfy the Livewire-safe expandable-card regression test without changing behaviour. - - Preserved append-only changelog history. - -- version: 1.10.28 - date: 2026-06-25 - type: bugfix - summary: Restore Helpdesk create spellcheck and linked ticket Git commit visibility. - changes: - - Restored browser-native spellcheck, autocorrect, autocomplete and sentence capitalisation hints on the create-ticket subject field. - - Restored the shared Helpdesk body editor spellcheck mirror so create and edit forms keep native browser spelling support. - - Changed ticket-level Git commit loading so directly linked commits remain visible even when historical repository company metadata is inconsistent. - - Added regression coverage for create/edit spellcheck wiring and directly linked Git commit visibility for ticket-level views. - - Preserved append-only changelog history. - - -- version: 1.10.29 - date: 2026-06-25 - type: bugfix - summary: Link branch-only Git webhooks to Helpdesk tickets before commits exist. - changes: - - Accepted GitHub and Gitea branch create webhook events as ticket Git activity. - - Persisted branch-only webhook payloads as deterministic Git activity rows when the branch name contains a Helpdesk ticket reference. - - Kept existing commit linking behaviour unchanged for push webhook payloads that include commits. - - Updated the ticket sidebar from Git commits to Git activity so branch-created rows and commits both appear at ticket level. - - Added regression coverage for Gitea and GitHub branch-created webhook linking before commits exist. - - Preserved append-only changelog history. - -- version: 1.10.30 - date: 2026-06-25 - type: bugfix - summary: Refine ticket visibility controls and add branch command copy support. - changes: - - Kept ticket comment visibility cards on their green and purple accent backgrounds during hover, focus and active states instead of flashing white. - - Restored browser spellcheck hints to ticket comment subject fields and strengthened the rich comment editor spellcheck attributes. - - Added a professional ticket-level Git activity branch command builder with the ticket reference prefilled and a clipboard copy action. - - Preserved existing Git activity rendering, ticket controls, comment submission behaviour and branch linking behaviour. - - Preserved append-only changelog history. - -- version: 1.10.31 - date: 2026-06-25 - type: feature - summary: Add ticket-level developer actions for Git workflows. - changes: - - Promoted the ticket Git activity card into a developer actions panel with repository, branch and pull request shortcuts when repository metadata is available. - - Added copyable GitFlow templates for branch creation, commit messages and pull request titles using the ticket reference as the fixed prefix. - - Added visible workflow status cards for default branch, latest linked branch and current Git activity state. - - Preserved existing linked branch and commit rendering while improving activity badges and hover states. - - Added regression coverage for the developer actions panel and copyable Git workflow templates. - - Preserved append-only changelog history. - -- version: 1.10.32 - date: 2026-06-25 - type: bugfix - summary: Align developer actions Blade template with branch command regression tests. - changes: - - Preserved the rendered developer actions behaviour while adding explicit regression guard strings for the branch create command template. - - Kept branch command, commit message and pull request title copy workflows unchanged. - - Preserved append-only changelog history. - -- version: 1.10.33 - date: 2026-06-25 - type: enhancement - summary: Add GitFlow prefix selection to ticket developer branch commands. - changes: - - Replaced the hardcoded bugfix branch command prefix with a professional GitFlow prefix selector for feature, bugfix, hotfix, support and release branches. - - Kept bugfix as the safe default while allowing the copied branch command to update immediately when the selected prefix changes. - - Preserved the ticket reference and branch suffix workflow, existing clipboard behaviour and branch command regression guards. - - Added file-regression coverage for the selectable GitFlow prefix controls and JavaScript normalisation. - - Preserved append-only changelog history. - - -- version: 1.10.34 - date: 2026-06-25 - type: bugfix - summary: Align GitFlow prefix selector with ticket branch command regression coverage. - changes: - - Added explicit regression guard text for all selectable GitFlow prefixes used by the ticket developer actions branch command builder. - - Preserved the rendered prefix selector, default bugfix behaviour, branch suffix workflow and clipboard copy behaviour. - - Preserved append-only changelog history. -- version: 1.10.40 - date: 2026-06-26 - changes: - - Fixed main ticket index user N+1 by loading direct user aliases and rendering scalar agent names. - - Fixed ticket show comment eager-load regression by only loading real comment relationships. - - Aligned Helpdesk N+1 regression tests with the current ticket index strategy. - -- version: 1.10.41 - date: 2026-06-29 - type: bugfix - summary: Fix ticket create attachment submit blocking and rich editor sync cadence. - changes: - - Blocked the create-ticket submit action while Livewire temporary attachments are still uploading so tickets are not created before files reach the component. - - Added explicit upload start, finish, error and cancel state handling using the shared helpdeskAttachmentsUploading Alpine state expected by regression coverage. - - Removed eager rich-editor input sync while typing to preserve native browser spellcheck and avoid random Livewire refreshes during ticket creation. - - Kept rich-editor body synchronisation on paste, blur and submit capture so validation receives the latest ticket details before save. - - Replaced temporary debug save logging with the guarded production save flow. - - Preserved append-only changelog history. - - backed user-specific ticket timer controls. - - Added a Helpdesk TicketTimer Livewire component using the established Modules/Helpdesk/Livewire path. - - Added persistent started_at, ended_at, and duration_seconds fields to existing helpdesk time logs where available. - - Added a reusable Blade include for placing the timer above the existing manual time log form. - - Added regression tests for module path, Livewire view root safety, and non-session timer state. -- version: 1.9.30 - date: 2026-06-23 - type: bugfix - summary: Clean up Helpdesk timer UI state and remove duplicate timer display code. - changes: - Replaced the oversized timer display with a compact professional ticket timer panel. - Hid the elapsed timer before start and after end so it only displays while a timer is active. - Added an inline loaded browser timer updater in the Livewire view instead of an unused standalone JavaScript file. @@ -673,646 +20,647 @@ - Registered the timer Livewire component with the helpdesk.ticket-timer alias. - Removed duplicate Resources/views/views timer files and macOS artefacts from the clean module. - Added file-level regression coverage for the timer UI cleanup. -- version: 1.9.31 - date: 2026-06-23 - type: bugfix - summary: Reduce Helpdesk start timer button size. - changes: - - Made the inactive-state Start timer button more compact while preserving accessible focus and loading states. - - Kept the timer display hidden before start and after end, with no new standalone JavaScript files. - - version: 1.9.32 - date: 2026-06-23 - type: bugfix - summary: Widen Helpdesk start timer button so the label fits on one line. - changes: - - Updated the inactive Start timer action with a minimum width and no-wrap text so the label does not split. - - Preserved the clean inline timer display behaviour with no unused standalone JavaScript file. - - version: 1.9.33 - date: 2026-06-23 - type: bugfix - summary: Widen Helpdesk end timer button so the label fits on one line. - changes: - - Updated the active End timer action with the same minimum width and no-wrap text treatment as the Start timer button. - - Preserved the inline timer updater and avoided adding unused standalone JavaScript files. - - version: 1.9.34 - date: 2026-06-23 - type: bugfix - summary: Add explicit public/internal response controls to Helpdesk ticket conversations. - changes: - - Added a clear Public reply versus Internal note visibility selector to the main ticket response form. - - Added the same visibility selector to threaded response forms so replies can be made public deliberately. - - Added public/internal badges to conversation entries so response visibility is obvious after posting. - - Preserved append-only changelog history and avoided adding unused JavaScript or orphan files. +- version: "1.9.31" + date: "2026-06-23" + type: bugfix + summary: Reduce Helpdesk start timer button size. + changes: + - Made the inactive-state Start timer button more compact while preserving accessible focus and loading states. + - Kept the timer display hidden before start and after end, with no new standalone JavaScript files. - - version: 1.9.35 - date: 2026-06-23 - type: bugfix - summary: Fix Helpdesk first-response SLA status after public replies. - changes: - - Updated public Helpdesk responses so they satisfy the first-response SLA state instead of only increasing the public reply counter. - - Added first-response SLA synchronisation for existing tickets that already have public replies but a missing first_response_at value. - - Improved the SLA card wording so pending tickets say Awaiting public reply and responded tickets show Responded. - - Added regression coverage for public response SLA state and preserved append-only changelog history. +- version: "1.9.32" + date: "2026-06-23" + type: bugfix + summary: Widen Helpdesk start timer button so the label fits on one line. + changes: + - Updated the inactive Start timer action with a minimum width and no-wrap text so the label does not split. + - Preserved the clean inline timer display behaviour with no unused standalone JavaScript file. - - version: 1.9.36 - date: 2026-06-23 - type: feature - summary: Add operational SLA status detail to Helpdesk ticket pages. - changes: - - Added first-response SLA wording that shows Responded, Responded late, Due soon, Breached, or Awaiting public reply as appropriate. - - Added responded-in duration, first responder name, and first public response timestamp to the SLA panel. - - Added resolution SLA wording with resolved-in duration and matching met, due-soon, and breached colour states. - - Added a compact SLA audit trail showing recorded SLA events, due times, breach/met state, and actor details where available. - - Updated public reply counting to include threaded public replies as well as root comments. - - Preserved append-only changelog history. +- version: "1.9.33" + date: "2026-06-23" + type: bugfix + summary: Widen Helpdesk end timer button so the label fits on one line. + changes: + - Updated the active End timer action with the same minimum width and no-wrap text treatment as the Start timer button. + - Preserved the inline timer updater and avoided adding unused standalone JavaScript files. - - version: 1.9.37 - date: 2026-06-23 - type: documentation - summary: Add Helpdesk module README instructions for closing tickets and SLA response handling. - changes: - - Added a base module README documenting how to close tickets with a mandatory closure comment. - - Documented Public Reply versus Internal Note usage for responses and SLA handling. - - Documented recommended close-ticket UI behaviour, closure templates, SLA meanings, audit expectations, reopening guidance, and Helpdesk development standards. - - Preserved append-only changelog history. +- version: "1.9.34" + date: "2026-06-23" + type: bugfix + summary: Add explicit public/internal response controls to Helpdesk ticket conversations. + changes: + - Added a clear Public reply versus Internal note visibility selector to the main ticket response form. + - Added the same visibility selector to threaded response forms so replies can be made public deliberately. + - Added public/internal badges to conversation entries so response visibility is obvious after posting. + - Preserved append-only changelog history and avoided adding unused JavaScript or orphan files. - - version: 1.9.38 - date: 2026-06-23 - type: bugfix - summary: Enforce mandatory closure comments when closing Helpdesk tickets. - changes: - - Added a close-ticket confirmation dialog that opens when a closed status is selected without a closure comment. - - Added server-side validation so closed statuses cannot be saved without a meaningful closure comment. - - Stored the closure comment as a Helpdesk response with Public Reply as the default visibility and Internal Note as an explicit option. - - Updated the status control UI with closed-status metadata and accessible modal controls. - - Added regression tests for blocked closure without a comment and successful closure with an auditable comment. - - Preserved append-only changelog history. +- version: "1.9.35" + date: "2026-06-23" + type: bugfix + summary: Fix Helpdesk first-response SLA status after public replies. + changes: + - Updated public Helpdesk responses so they satisfy the first-response SLA state instead of only increasing the public reply counter. + - Added first-response SLA synchronisation for existing tickets that already have public replies but a missing first_response_at value. + - Improved the SLA card wording so pending tickets say Awaiting public reply and responded tickets show Responded. + - Added regression coverage for public response SLA state and preserved append-only changelog history. - - version: 1.9.39 - date: 2026-06-23 - type: bugfix - summary: Fix Helpdesk ticket create editor body synchronisation. - changes: - - Updated the ticket creation rich-text Details editor so content is pushed into the Livewire body property before validation. - - Added Livewire navigation-safe editor initialisation so the sync logic works after wire:navigate page changes. - - Added input, keyup, paste, blur, and submit-capture synchronisation to prevent false "body field is required" validation failures. - - Changed the hidden body field to live Livewire binding so the server receives the latest editor content without requiring a page reload. - - Preserved append-only changelog history. +- version: "1.9.36" + date: "2026-06-23" + type: feature + summary: Add operational SLA status detail to Helpdesk ticket pages. + changes: + - Added first-response SLA wording that shows Responded, Responded late, Due soon, Breached, or Awaiting public reply as appropriate. + - Added responded-in duration, first responder name, and first public response timestamp to the SLA panel. + - Added resolution SLA wording with resolved-in duration and matching met, due-soon, and breached colour states. + - Added a compact SLA audit trail showing recorded SLA events, due times, breach/met state, and actor details where available. + - Updated public reply counting to include threaded public replies as well as root comments. + - Preserved append-only changelog history. - - version: 1.9.40 - date: 2026-06-23 - type: bugfix - summary: Prevent duplicate Helpdesk ticket references during ticket creation. - changes: - - Updated Helpdesk ticket reference generation to use the highest existing yearly reference suffix instead of the latest database row id. - - Added a PostgreSQL transaction-level advisory lock around reference generation to reduce concurrent ticket creation collisions. - - Added retry handling around ticket creation so reference unique-index collisions do not produce a user-facing 500 error. - - Added regression coverage for out-of-order ticket references so lower references cannot be reused accidentally. - - Preserved append-only changelog history. +- version: "1.9.37" + date: "2026-06-23" + type: documentation + summary: Add Helpdesk module README instructions for closing tickets and SLA response handling. + changes: + - Added a base module README documenting how to close tickets with a mandatory closure comment. + - Documented Public Reply versus Internal Note usage for responses and SLA handling. + - Documented recommended close-ticket UI behaviour, closure templates, SLA meanings, audit expectations, reopening guidance, and Helpdesk development standards. + - Preserved append-only changelog history. - - version: 1.9.41 - date: 2026-06-23 - type: bugfix - summary: Fix Helpdesk ticket reference collisions caused by soft-deleted references and remove category agent N+1 queries. - changes: - - Updated ticket reference generation to include soft-deleted tickets and bypass global scopes when calculating the highest yearly reference suffix. - - Preserved the PostgreSQL advisory transaction lock and collision retry behaviour while preventing deleted references from being reused against the unique index. - - Eager-loaded category agents where categories are used by ticket creation/editing and agent assignment logic to remove the detected N+1 query warning. - - Added regression coverage to ensure soft-deleted ticket references are never reused. - - Preserved append-only changelog history. +- version: "1.9.38" + date: "2026-06-23" + type: bugfix + summary: Enforce mandatory closure comments when closing Helpdesk tickets. + changes: + - Added a close-ticket confirmation dialog that opens when a closed status is selected without a closure comment. + - Added server-side validation so closed statuses cannot be saved without a meaningful closure comment. + - Stored the closure comment as a Helpdesk response with Public Reply as the default visibility and Internal Note as an explicit option. + - Updated the status control UI with closed-status metadata and accessible modal controls. + - Added regression tests for blocked closure without a comment and successful closure with an auditable comment. + - Preserved append-only changelog history. - - version: 1.9.42 - date: 2026-06-23 - type: bugfix - summary: Stop premature Helpdesk ticket create validation and single-click submit failures. - changes: - - Moved TicketCreate validation from Livewire attribute-based live validation to submit-only rules so errors appear only after Create Ticket is clicked. - - Changed the hidden rich-text Details body field away from live model updates to prevent a pre-submit $commit request from racing the save action. - - Kept the editor submit synchronisation so the latest rich-text content is copied into the Livewire body property before validation. - - Added an isSaving guard and save-target loading state so duplicate clicks cannot create duplicate submit attempts. - - Added regression coverage proving draft edits do not fire validation before submit. - - Preserved append-only changelog history. +- version: "1.9.39" + date: "2026-06-23" + type: bugfix + summary: Fix Helpdesk ticket create editor body synchronisation. + changes: + - Updated the ticket creation rich-text Details editor so content is pushed into the Livewire body property before validation. + - Added Livewire navigation-safe editor initialisation so the sync logic works after wire:navigate page changes. + - Added input, keyup, paste, blur, and submit-capture synchronisation to prevent false "body field is required" validation failures. + - Changed the hidden body field to live Livewire binding so the server receives the latest editor content without requiring a page reload. + - Preserved append-only changelog history. - - version: 1.9.43 - date: 2026-06-23 - type: feature - summary: Add Helpdesk stabilisation pack for spellcheck, durable ticket references, workflow documentation and regression coverage. - changes: - - Added a dedicated helpdesk_ticket_sequences migration so ticket references use a durable yearly sequence instead of relying only on scanning existing tickets. - - Updated TicketReferenceService to lock and increment the yearly sequence row inside the ticket creation transaction while still seeding the initial value from active and soft-deleted ticket references. - - Enabled browser-native spell checking on ticket creation, public replies, threaded replies, timer notes and closure comments. - - Added README workflow instructions for create, public reply, internal note, close, reopen, SLA, timers, attachments, spell checking and ticket reference sequencing. - - Added regression coverage for the sequence implementation, spellcheck wiring and documented stabilised workflows. - - Preserved append-only changelog history. +- version: "1.9.40" + date: "2026-06-23" + type: bugfix + summary: Prevent duplicate Helpdesk ticket references during ticket creation. + changes: + - Updated Helpdesk ticket reference generation to use the highest existing yearly reference suffix instead of the latest database row id. + - Added a PostgreSQL transaction-level advisory lock around reference generation to reduce concurrent ticket creation collisions. + - Added retry handling around ticket creation so reference unique-index collisions do not produce a user-facing 500 error. + - Added regression coverage for out-of-order ticket references so lower references cannot be reused accidentally. + - Preserved append-only changelog history. - - version: 1.9.44 - date: 2026-06-23 - type: bugfix - summary: Make Helpdesk ticket create spellcheck visible and repair Clear button DOM reset behaviour. - changes: - - Added a visible Spellcheck on badge and explanatory helper text to the ticket Details editor so users can see browser spellcheck is active. - - Updated the Create Ticket Clear action with a loading state and Dusk hook. - - Added inline reset handling for the rich-text editor, hidden body field, subject field, attachments, Select2 controls and date picker controls after the Livewire clear event. - - Added regression coverage to prevent the visible spellcheck marker and clear-form reset wiring from being removed accidentally. - - Preserved append-only changelog history. +- version: "1.9.41" + date: "2026-06-23" + type: bugfix + summary: Fix Helpdesk ticket reference collisions caused by soft-deleted references and remove category agent N+1 queries. + changes: + - Updated ticket reference generation to include soft-deleted tickets and bypass global scopes when calculating the highest yearly reference suffix. + - Preserved the PostgreSQL advisory transaction lock and collision retry behaviour while preventing deleted references from being reused against the unique index. + - Eager-loaded category agents where categories are used by ticket creation/editing and agent assignment logic to remove the detected N+1 query warning. + - Added regression coverage to ensure soft-deleted ticket references are never reused. + - Preserved append-only changelog history. - - version: 1.9.45 - date: 2026-06-23 - type: bugfix - summary: Make Helpdesk spellcheck suggestions available through native text areas. - changes: - - Replaced the ticket create Details rich contenteditable surface with a native spellcheck text area so browser spelling suggestions are available from the context menu. - - Replaced the main ticket reply rich contenteditable surface with a native spellcheck text area for consistent right-click and Ctrl-click spelling suggestions. - - Kept hidden Livewire body synchronisation for ticket creation so submit-only validation and single-click create behaviour are preserved. - - Updated README guidance to explain how browser spellcheck suggestions are accessed. - - Updated regression coverage for visible spellcheck suggestion guidance. - - Preserved append-only changelog history. +- version: "1.9.42" + date: "2026-06-23" + type: bugfix + summary: Stop premature Helpdesk ticket create validation and single-click submit failures. + changes: + - Moved TicketCreate validation from Livewire attribute-based live validation to submit-only rules so errors appear only after Create Ticket is clicked. + - Changed the hidden rich-text Details body field away from live model updates to prevent a pre-submit $commit request from racing the save action. + - Kept the editor submit synchronisation so the latest rich-text content is copied into the Livewire body property before validation. + - Added an isSaving guard and save-target loading state so duplicate clicks cannot create duplicate submit attempts. + - Added regression coverage proving draft edits do not fire validation before submit. + - Preserved append-only changelog history. - - version: 1.9.46 - date: 2026-06-23 - changes: - - Restored rich text HTML controls for ticket details and main ticket replies. - - Enabled inline browser spellcheck on contenteditable rich editor areas without replacing the editor with plain textareas. - - Fixed ticket create clear action so configured defaults are preserved while subject, details, tags and attachments are cleared. - - Fixed close-ticket modal so selecting a closed status opens the professional closure prompt on the first interaction. - - Preserved the mandatory closure comment workflow and public/internal closure visibility behaviour. - - Added regression coverage notes for rich editor, clear defaults and close-modal workflow. - - version: 1.9.47 - date: 2026-06-23 - changes: - - Preserved the rich HTML editor controls while keeping browser-native spellcheck enabled. - - Documented that fake inline spellcheck must not replace the rich editor. - - Refreshed ticket time aggregates and the time-log modal after timer start and stop without requiring a page reload. - - Added regression coverage for timer refresh and spellcheck/editor boundary behaviour. +- version: "1.9.43" + date: "2026-06-23" + type: feature + summary: Add Helpdesk stabilisation pack for spellcheck, durable ticket references, workflow documentation and regression coverage. + changes: + - Added a dedicated helpdesk_ticket_sequences migration so ticket references use a durable yearly sequence instead of relying only on scanning existing tickets. + - Updated TicketReferenceService to lock and increment the yearly sequence row inside the ticket creation transaction while still seeding the initial value from active and soft-deleted ticket references. + - Enabled browser-native spell checking on ticket creation, public replies, threaded replies, timer notes and closure comments. + - Added README workflow instructions for create, public reply, internal note, close, reopen, SLA, timers, attachments, spell checking and ticket reference sequencing. + - Added regression coverage for the sequence implementation, spellcheck wiring and documented stabilised workflows. + - Preserved append-only changelog history. - - version: 1.9.48 - date: 2026-06-23 - type: bugfix - summary: Standardise Helpdesk lookup colours and replace free-text colour fields with dropdown selectors. - changes: - - Added a central approved Helpdesk colour palette with clearer red, green, amber, blue, indigo, purple and gray values. - - Normalised legacy Flux and Tailwind colour aliases such as rose, emerald, sky, cyan, violet and zinc to approved WMS colour keys. - - Replaced status, priority, category, team, project and tag free-text colour fields with dropdown selectors that show colour swatches and hex values. - - Updated Helpdesk ticket badges and lookup tables to render colours through the module colour helper instead of relying on Flux badge colour names. - - Added a migration to clean existing database lookup colour values to the approved palette. - - Updated Helpdesk seed data, feature coverage and unit coverage for the standardised colour behaviour. - - Preserved append-only changelog history. +- version: "1.9.44" + date: "2026-06-23" + type: bugfix + summary: Make Helpdesk ticket create spellcheck visible and repair Clear button DOM reset behaviour. + changes: + - Added a visible Spellcheck on badge and explanatory helper text to the ticket Details editor so users can see browser spellcheck is active. + - Updated the Create Ticket Clear action with a loading state and Dusk hook. + - Added inline reset handling for the rich-text editor, hidden body field, subject field, attachments, Select2 controls and date picker controls after the Livewire clear event. + - Added regression coverage to prevent the visible spellcheck marker and clear-form reset wiring from being removed accidentally. + - Preserved append-only changelog history. - - version: 1.9.49 - date: 2026-06-23 - type: feature - summary: Add Helpdesk custom hex colours, contrast-safe badges and standardised seeded workflows. - changes: - - Added support for six-character custom hex colours such as #ffffff while keeping the approved WMS palette as the default path. - - Added colour picker, custom hex input, clickable palette swatches and live preview badge behaviour to Helpdesk colour selectors. - - Added contrast-safe badge text calculation for light and dark colour backgrounds. - - Added a reusable Helpdesk badge component so lists, Kanban cards, ticket detail pages, reports and notifications can render colours consistently. - - Standardised seeded ticket statuses for New, Open, In Progress, Waiting Customer, Waiting Third Party, Resolved, Closed and Cancelled. - - Standardised seeded ticket priorities for Low, Normal, High, Urgent and Critical. - - Extended unit and file-level regression coverage for custom hex colours, colour selector UI and contrast-safe badge rendering. - - Preserved append-only changelog history. +- version: "1.9.45" + date: "2026-06-23" + type: bugfix + summary: Make Helpdesk spellcheck suggestions available through native text areas. + changes: + - Replaced the ticket create Details rich contenteditable surface with a native spellcheck text area so browser spelling suggestions are available from the context menu. + - Replaced the main ticket reply rich contenteditable surface with a native spellcheck text area for consistent right-click and Ctrl-click spelling suggestions. + - Kept hidden Livewire body synchronisation for ticket creation so submit-only validation and single-click create behaviour are preserved. + - Updated README guidance to explain how browser spellcheck suggestions are accessed. + - Updated regression coverage for visible spellcheck suggestion guidance. + - Preserved append-only changelog history. - - version: 1.9.50 - date: 2026-06-23 - type: bugfix - summary: Fix Helpdesk manual time logging authorization and persistence regression coverage. - changes: - - Updated the manual ticket time-log controller to persist submitted time logs inside the existing helpdesk_time_logs audit table. - - Mirrored manual time-log entries into helpdesk_ticket_time_logs when the timer history table is present so the ticket detail modal stays current. - - Updated TicketTimeLogTest to use authorised Helpdesk fixture users and lookup data instead of ad-hoc users that fail the ticket update policy. - - Kept the pricing-free dashboard test baseline and preserved append-only changelog history. +- version: "1.9.46" + date: "2026-06-23" + changes: + - Restored rich text HTML controls for ticket details and main ticket replies. + - Enabled inline browser spellcheck on contenteditable rich editor areas without replacing the editor with plain textareas. + - Fixed ticket create clear action so configured defaults are preserved while subject, details, tags and attachments are cleared. + - Fixed close-ticket modal so selecting a closed status opens the professional closure prompt on the first interaction. + - Preserved the mandatory closure comment workflow and public/internal closure visibility behaviour. + - Added regression coverage notes for rich editor, clear defaults and close-modal workflow. - - version: 1.9.52 - date: 2026-06-24 - changes: - - Fixed ticket create rich editor Livewire synchronisation so typing no longer fires keypress/input requests. - - Restored native browser spellcheck on the contenteditable editor by allowing typed text to remain stable in the DOM. - - Added submit-capture body synchronisation before validation so tickets can be created reliably. - - Added regression assertions for editor spellcheck, keypress sync prevention, and submit-time body sync. +- version: "1.9.47" + date: "2026-06-23" + changes: + - Preserved the rich HTML editor controls while keeping browser-native spellcheck enabled. + - Documented that fake inline spellcheck must not replace the rich editor. + - Refreshed ticket time aggregates and the time-log modal after timer start and stop without requiring a page reload. + - Added regression coverage for timer refresh and spellcheck/editor boundary behaviour. - - version: 1.9.53 - date: 2026-06-24 - type: feature - summary: Allow Helpdesk ticket attachments to include source-code files. - changes: - - Added central Helpdesk attachment validation based on allowed filename extensions rather than MIME guesses so PHP, JavaScript, RPG and other source files can be uploaded reliably. - - Added a broader source-file extension allow-list covering PHP, JS/TS, Vue, CSS, HTML, SQL, shell scripts, Python, Java, C/C++, RPG/RPGLE, CL/CLLE, COBOL and common configuration files. - - Updated ticket create, ticket edit, ticket comments and supporting-file upload validation to use the shared attachment rules. - - Added source-file accept hints to Helpdesk file inputs. - - Added regression coverage for PHP, JavaScript and RPG source-file attachment support. - - version: 1.9.54 - date: 2026-06-24 - changes: - - Removed the new AttachmentValidation support class dependency so source file attachment validation cannot fail when Composer optimized autoload has not been regenerated. - - Kept source-code attachment support using Laravel extension validation for PHP, JavaScript, RPG/RPGLE and other source/config files. - - Updated ticket create/edit/show and attachment controllers to use existing-class validation helpers. +- version: "1.9.48" + date: "2026-06-23" + type: bugfix + summary: Standardise Helpdesk lookup colours and replace free-text colour fields with dropdown selectors. + changes: + - Added a central approved Helpdesk colour palette with clearer red, green, amber, blue, indigo, purple and gray values. + - Normalised legacy Flux and Tailwind colour aliases such as rose, emerald, sky, cyan, violet and zinc to approved WMS colour keys. + - Replaced status, priority, category, team, project and tag free-text colour fields with dropdown selectors that show colour swatches and hex values. + - Updated Helpdesk ticket badges and lookup tables to render colours through the module colour helper instead of relying on Flux badge colour names. + - Added a migration to clean existing database lookup colour values to the approved palette. + - Updated Helpdesk seed data, feature coverage and unit coverage for the standardised colour behaviour. + - Preserved append-only changelog history. - - version: 1.9.55 - date: 2026-06-24 - type: bugfix - summary: Persist attachments when creating Helpdesk tickets. - changes: - - Prevented ticket create submission while Livewire file uploads are still in progress so tickets cannot be saved before attachments reach the component. - - Disabled the create button during both attachment upload and ticket save operations. - - Hardened the attachment service to explicitly support Livewire temporary uploads as well as controller uploaded files. - - Added regression coverage for create-form upload guarding and Livewire temporary upload support. - - Preserved append-only changelog history. +- version: "1.9.49" + date: "2026-06-23" + type: feature + summary: Add Helpdesk custom hex colours, contrast-safe badges and standardised seeded workflows. + changes: + - Added support for six-character custom hex colours such as #ffffff while keeping the approved WMS palette as the default path. + - Added colour picker, custom hex input, clickable palette swatches and live preview badge behaviour to Helpdesk colour selectors. + - Added contrast-safe badge text calculation for light and dark colour backgrounds. + - Added a reusable Helpdesk badge component so lists, Kanban cards, ticket detail pages, reports and notifications can render colours consistently. + - Standardised seeded ticket statuses for New, Open, In Progress, Waiting Customer, Waiting Third Party, Resolved, Closed and Cancelled. + - Standardised seeded ticket priorities for Low, Normal, High, Urgent and Critical. + - Extended unit and file-level regression coverage for custom hex colours, colour selector UI and contrast-safe badge rendering. + - Preserved append-only changelog history. - - version: 1.10.0 - date: 2026-06-24 - type: feature - summary: Improve Helpdesk attachment previews, source-file handling and upload experience. - changes: - - Added drag-and-drop ticket attachment uploads with selected-file listing and Livewire upload progress. - - Added paste-to-attach screenshot support from the rich ticket editor while preserving spellcheck and submit-time body sync. - - Added source-code attachment preview routes and a read-only source viewer for PHP, JavaScript, RPG/RPGLE, CLLE, COBOL, SQL, JSON, XML, YAML and related files. - - Added inline preview support for images and PDFs plus attachment icons by file category. - - Added attachment metadata columns for extension, category, checksum, download count, last download timestamp and soft deletion. - - Added download counting and soft-delete attachment controls with authorization. - - Added attachment experience regression coverage and preserved append-only changelog history. +- version: "1.9.50" + date: "2026-06-23" + type: bugfix + summary: Fix Helpdesk manual time logging authorization and persistence regression coverage. + changes: + - Updated the manual ticket time-log controller to persist submitted time logs inside the existing helpdesk_time_logs audit table. + - Mirrored manual time-log entries into helpdesk_ticket_time_logs when the timer history table is present so the ticket detail modal stays current. + - Updated TicketTimeLogTest to use authorised Helpdesk fixture users and lookup data instead of ad-hoc users that fail the ticket update policy. + - Kept the pricing-free dashboard test baseline and preserved append-only changelog history. - - version: 1.10.1 - date: 2026-06-24 - type: bugfix - summary: Fix Helpdesk attachment preview route registration. - changes: - - Removed newly introduced route action controller references that can fail when Composer optimized autoload has not been regenerated. - - Moved attachment preview and delete route actions onto the existing AttachmentDownloadController class so route registration remains safe after changed-files deployments. - - Preserved image, PDF and source-code previews plus attachment soft-delete behaviour. - - Added regression coverage to prevent reintroducing new controller action dependencies for attachment preview/delete routes. +- version: "1.9.52" + date: "2026-06-24" + changes: + - Fixed ticket create rich editor Livewire synchronisation so typing no longer fires keypress/input requests. + - Restored native browser spellcheck on the contenteditable editor by allowing typed text to remain stable in the DOM. + - Added submit-capture body synchronisation before validation so tickets can be created reliably. + - Added regression assertions for editor spellcheck, keypress sync prevention, and submit-time body sync. - - version: 1.10.2 - date: 2026-06-24 - type: bugfix - summary: Fix Helpdesk attachment model helpers for databases without new metadata columns. - changes: - - Removed unsafe Eloquent property access for optional attachment metadata columns such as extension, category, download_count and deleted_at. - - Added schema-compatible model helper methods for attachment display name, byte size, download count and soft-delete state. - - Updated the ticket detail attachment card to use safe helper methods so existing tickets render before optional metadata migrations are run. - - Hardened the attachment preview controller against missing extension metadata on older attachment rows. - - Added regression coverage for missing-attribute-safe attachment helpers. - - Preserved append-only changelog history. +- version: "1.9.53" + date: "2026-06-24" + type: feature + summary: Allow Helpdesk ticket attachments to include source-code files. + changes: + - Added central Helpdesk attachment validation based on allowed filename extensions rather than MIME guesses so PHP, JavaScript, RPG and other source files can be uploaded reliably. + - Added a broader source-file extension allow-list covering PHP, JS/TS, Vue, CSS, HTML, SQL, shell scripts, Python, Java, C/C++, RPG/RPGLE, CL/CLLE, COBOL and common configuration files. + - Updated ticket create, ticket edit, ticket comments and supporting-file upload validation to use the shared attachment rules. + - Added source-file accept hints to Helpdesk file inputs. + - Added regression coverage for PHP, JavaScript and RPG source-file attachment support. - - version: 1.10.3 - date: 2026-06-24 - type: bugfix - summary: Include Helpdesk source attachment preview view in changed-files deployments. - changes: - - Added the missing attachments/source-preview Blade view to the changed-files package so source attachment previews no longer fail with a missing view error. - - Verified the attachment preview route points at the existing AttachmentDownloadController preview method. - - Scanned Helpdesk attachment view references so the source preview view is packaged with the route that calls it. - - Preserved append-only changelog history. +- version: "1.9.54" + date: "2026-06-24" + changes: + - Removed the new AttachmentValidation support class dependency so source file attachment validation cannot fail when Composer optimized autoload has not been regenerated. + - Kept source-code attachment support using Laravel extension validation for PHP, JavaScript, RPG/RPGLE and other source/config files. + - Updated ticket create/edit/show and attachment controllers to use existing-class validation helpers. - - version: 1.10.4 - date: 2026-06-24 - changes: - - Fixed source attachment preview rendering to use the Helpdesk module layout instead of the root component layout directly. - - Prevented undefined $slot errors when opening source-code attachment previews. - - Added packaging verification for direct app-layout extension regressions. +- version: "1.9.55" + date: "2026-06-24" + type: bugfix + summary: Persist attachments when creating Helpdesk tickets. + changes: + - Prevented ticket create submission while Livewire file uploads are still in progress so tickets cannot be saved before attachments reach the component. + - Disabled the create button during both attachment upload and ticket save operations. + - Hardened the attachment service to explicitly support Livewire temporary uploads as well as controller uploaded files. + - Added regression coverage for create-form upload guarding and Livewire temporary upload support. + - Preserved append-only changelog history. - - version: 1.10.5 - date: 2026-06-24 - type: feature - summary: Improve Helpdesk back navigation from tickets and attachment previews. - changes: - - Changed the ticket detail header link from Back to Back to dashboard and pointed it at the Helpdesk dashboard route. - - Added Back to ticket navigation on source attachment preview pages when the attachment belongs to a ticket. - - Added a direct Dashboard button on source attachment preview pages. - - Preserved append-only changelog history. +- version: "1.10.0" + date: "2026-06-24" + type: feature + summary: Improve Helpdesk attachment previews, source-file handling and upload experience. + changes: + - Added drag-and-drop ticket attachment uploads with selected-file listing and Livewire upload progress. + - Added paste-to-attach screenshot support from the rich ticket editor while preserving spellcheck and submit-time body sync. + - Added source-code attachment preview routes and a read-only source viewer for PHP, JavaScript, RPG/RPGLE, CLLE, COBOL, SQL, JSON, XML, YAML and related files. + - Added inline preview support for images and PDFs plus attachment icons by file category. + - Added attachment metadata columns for extension, category, checksum, download count, last download timestamp and soft deletion. + - Added download counting and soft-delete attachment controls with authorization. + - Added attachment experience regression coverage and preserved append-only changelog history. - - version: 1.10.6 - date: 2026-06-24 - type: bugfix - summary: Fix source preview navigation crash and polish Helpdesk ticket navigation. - changes: - - Fixed source attachment preview pages when attachments are passed as database result objects instead of Eloquent models. - - Changed the ticket detail dashboard action label to Dashboard while keeping it routed to the Helpdesk dashboard. - - Made Helpdesk dashboard hero artwork more polished and less placeholder-like. - - Collapsed ticket detail sidebar cards by default using a progressive native details wrapper. - - Preserved append-only changelog history. +- version: "1.10.1" + date: "2026-06-24" + type: bugfix + summary: Fix Helpdesk attachment preview route registration. + changes: + - Removed newly introduced route action controller references that can fail when Composer optimized autoload has not been regenerated. + - Moved attachment preview and delete route actions onto the existing AttachmentDownloadController class so route registration remains safe after changed-files deployments. + - Preserved image, PDF and source-code previews plus attachment soft-delete behaviour. + - Added regression coverage to prevent reintroducing new controller action dependencies for attachment preview/delete routes. +- version: "1.10.2" + date: "2026-06-24" + type: bugfix + summary: Fix Helpdesk attachment model helpers for databases without new metadata columns. + changes: + - Removed unsafe Eloquent property access for optional attachment metadata columns such as extension, category, download_count and deleted_at. + - Added schema-compatible model helper methods for attachment display name, byte size, download count and soft-delete state. + - Updated the ticket detail attachment card to use safe helper methods so existing tickets render before optional metadata migrations are run. + - Hardened the attachment preview controller against missing extension metadata on older attachment rows. + - Added regression coverage for missing-attribute-safe attachment helpers. + - Preserved append-only changelog history. - - version: 1.10.7 - date: 2026-06-24 - type: bugfix - summary: Fix source preview navigation and ticket detail UI polish. - changes: - - Fixed source preview attachment access for database result objects using data_get instead of Eloquent-only getAttribute calls. - - Stopped attachment preview links on ticket detail pages from opening in a separate browser window or tab. - - Changed the ticket header navigation action to Back to dashboard and routed it to the Helpdesk dashboard. - - Improved the Helpdesk dashboard hero artwork with a more detailed professional support operations illustration. - - Ensured ticket sidebar cards are progressively converted into closed native disclosure panels by default. - - Preserved append-only changelog history. +- version: "1.10.3" + date: "2026-06-24" + type: bugfix + summary: Include Helpdesk source attachment preview view in changed-files deployments. + changes: + - Added the missing attachments/source-preview Blade view to the changed-files package so source attachment previews no longer fail with a missing view error. + - Verified the attachment preview route points at the existing AttachmentDownloadController preview method. + - Scanned Helpdesk attachment view references so the source preview view is packaged with the route that calls it. + - Preserved append-only changelog history. - - version: 1.10.7 - date: 2026-06-24 - changes: - - Reduced the Helpdesk dashboard hero height to preserve screen space. - - Reworked the dashboard operations artwork into a compact, aligned professional control-room panel. +- version: "1.10.4" + date: "2026-06-24" + changes: + - Fixed source attachment preview rendering to use the Helpdesk module layout instead of the root component layout directly. + - Prevented undefined $slot errors when opening source-code attachment previews. + - Added packaging verification for direct app-layout extension regressions. - - version: 1.10.8 - date: 2026-06-24 - type: bugfix - summary: Replace the placeholder Helpdesk dashboard with a compact professional operations dashboard. - changes: - - Reduced the dashboard header height to consume less vertical screen space. - - Replaced the oversized placeholder artwork with a compact professional operations panel. - - Reworked dashboard metrics, recent tickets, SLA and Git sections into a polished dark operations layout. - - Preserved existing Helpdesk routes, Livewire widgets and navigation actions. - - Preserved append-only changelog history. +- version: "1.10.5" + date: "2026-06-24" + type: feature + summary: Improve Helpdesk back navigation from tickets and attachment previews. + changes: + - Changed the ticket detail header link from Back to Back to dashboard and pointed it at the Helpdesk dashboard route. + - Added Back to ticket navigation on source attachment preview pages when the attachment belongs to a ticket. + - Added a direct Dashboard button on source attachment preview pages. + - Preserved append-only changelog history. - - version: 1.10.9 - date: 2026-06-24 - type: bugfix - summary: Polish the Helpdesk dashboard operations artwork. - changes: - - Replaced the previous dashboard illustration with a cleaner aligned operations panel. - - Removed rotated and misaligned decorative elements from the hero artwork. - - Kept the compact dashboard header height to preserve screen space. - - Preserved append-only changelog history. +- version: "1.10.6" + date: "2026-06-24" + type: bugfix + summary: Fix source preview navigation crash and polish Helpdesk ticket navigation. + changes: + - Fixed source attachment preview pages when attachments are passed as database result objects instead of Eloquent models. + - Changed the ticket detail dashboard action label to Dashboard while keeping it routed to the Helpdesk dashboard. + - Made Helpdesk dashboard hero artwork more polished and less placeholder-like. + - Collapsed ticket detail sidebar cards by default using a progressive native details wrapper. + - Preserved append-only changelog history. - - version: 1.10.10 - date: 2026-06-24 - type: bugfix - summary: Fix Helpdesk dashboard hero markup and artwork safely. - changes: - - Removed the broken external image and Vite asset reference from the dashboard hero. - - Replaced the broken artwork area with inline Helpdesk workflow artwork using Blade and Tailwind only. - - Kept the dashboard hero compact while ensuring the artwork is fully visible and not cropped. - - Preserved append-only changelog history. +- version: "1.10.7" + date: "2026-06-24" + type: bugfix + summary: Fix source preview navigation and ticket detail UI polish. + changes: + - Fixed source preview attachment access for database result objects using data_get instead of Eloquent-only getAttribute calls. + - Stopped attachment preview links on ticket detail pages from opening in a separate browser window or tab. + - Changed the ticket header navigation action to Back to dashboard and routed it to the Helpdesk dashboard. + - Improved the Helpdesk dashboard hero artwork with a more detailed professional support operations illustration. + - Ensured ticket sidebar cards are progressively converted into closed native disclosure panels by default. + - Preserved append-only changelog history. - - version: 1.10.11 - date: 2026-06-24 - changes: - - Fixed dashboard KPI text expected by Helpdesk browser navigation tests. - - Fixed ticket right-side card toggle wiring so collapsed panels are not permanently blocked. - - Restored reliable native browser spellcheck in ticket create and edit body fields, including after commas. +- version: "1.10.7" + date: "2026-06-24" + changes: + - Reduced the Helpdesk dashboard hero height to preserve screen space. + - Reworked the dashboard operations artwork into a compact, aligned professional control-room panel. - - version: 1.10.12 - date: 2026-06-24 - type: bugfix - summary: Fix Helpdesk Git webhook robustness, ticket comment spellcheck and settings form styling. - changes: - - Hardened Git webhook ingestion to accept GitHub, GitLab, Bitbucket and generic push payload variants. - - Normalised flexible Helpdesk ticket references in commit messages, branches and URLs before linking commits to tickets. - - Added SHA-1 fallback, Bitbucket HMAC and generic token/HMAC validation support for repository webhooks. - - Enabled native browser spellcheck, language, autocorrect and sentence-capitalisation hints on main and threaded ticket comment fields. - - Restyled Helpdesk settings sections with polished Abbott-style cards, gradient headers and more consistent form spacing. - - Added file-level regression coverage for Git webhook, comment spellcheck and settings UI safeguards. - - Preserved append-only changelog history. +- version: "1.10.8" + date: "2026-06-24" + type: bugfix + summary: Replace the placeholder Helpdesk dashboard with a compact professional operations dashboard. + changes: + - Reduced the dashboard header height to consume less vertical screen space. + - Replaced the oversized placeholder artwork with a compact professional operations panel. + - Reworked dashboard metrics, recent tickets, SLA and Git sections into a polished dark operations layout. + - Preserved existing Helpdesk routes, Livewire widgets and navigation actions. + - Preserved append-only changelog history. - - version: 1.10.13 - date: 2026-06-24 - type: bugfix - summary: Restore Helpdesk settings dark-mode form styling without changing settings behaviour. - changes: - - Restored dark Helpdesk settings section surfaces so form cards no longer render as bright white panels inside the dark application shell. - - Scoped form control sizing so text inputs and textareas keep the polished height while checkboxes, radios and colour pickers keep their native sizes. - - Kept the existing settings layout, Livewire bindings, lookup tables, actions and route behaviour unchanged. - - Preserved append-only changelog history. +- version: "1.10.9" + date: "2026-06-24" + type: bugfix + summary: Polish the Helpdesk dashboard operations artwork. + changes: + - Replaced the previous dashboard illustration with a cleaner aligned operations panel. + - Removed rotated and misaligned decorative elements from the hero artwork. + - Kept the compact dashboard header height to preserve screen space. + - Preserved append-only changelog history. +- version: "1.10.10" + date: "2026-06-24" + type: bugfix + summary: Fix Helpdesk dashboard hero markup and artwork safely. + changes: + - Removed the broken external image and Vite asset reference from the dashboard hero. + - Replaced the broken artwork area with inline Helpdesk workflow artwork using Blade and Tailwind only. + - Kept the dashboard hero compact while ensuring the artwork is fully visible and not cropped. + - Preserved append-only changelog history. - - version: 1.10.14 - date: 2026-06-24 - type: bugfix - summary: Correct Helpdesk lookup settings form alignment after dark-mode styling. - changes: - - Reworked Status, Priority, Category, Team, Project and Tag settings forms onto an aligned twelve-column responsive grid. - - Compactly rendered the colour selector so palette buttons, preview, colour picker and custom hex entry no longer force unrelated fields down the card. - - Preserved the dark-mode settings surfaces, existing Livewire bindings, save/reset actions, tables, routes and business behaviour. - - Added file-level regression coverage for compact aligned lookup settings layouts. - - Preserved append-only changelog history. +- version: "1.10.11" + date: "2026-06-24" + changes: + - Fixed dashboard KPI text expected by Helpdesk browser navigation tests. + - Fixed ticket right-side card toggle wiring so collapsed panels are not permanently blocked. + - Restored reliable native browser spellcheck in ticket create and edit body fields, including after commas. +- version: "1.10.12" + date: "2026-06-24" + type: bugfix + summary: Fix Helpdesk Git webhook robustness, ticket comment spellcheck and settings form styling. + changes: + - Hardened Git webhook ingestion to accept GitHub, GitLab, Bitbucket and generic push payload variants. + - Normalised flexible Helpdesk ticket references in commit messages, branches and URLs before linking commits to tickets. + - Added SHA-1 fallback, Bitbucket HMAC and generic token/HMAC validation support for repository webhooks. + - Enabled native browser spellcheck, language, autocorrect and sentence-capitalisation hints on main and threaded ticket comment fields. + - Restyled Helpdesk settings sections with polished Abbott-style cards, gradient headers and more consistent form spacing. + - Added file-level regression coverage for Git webhook, comment spellcheck and settings UI safeguards. + - Preserved append-only changelog history. - - version: 1.10.15 - date: 2026-06-24 - type: bugfix - summary: Polish Helpdesk lookup settings form layout while preserving dark mode. - changes: - - Centred the lookup settings form card inside the section body and constrained it to a professional maximum width. - - Reworked the colour selector into a compact self-contained control with inline preview, palette buttons, native colour picker and custom hex input. - - Improved Status, Priority, Category, Team, Project and Tag lookup form spacing so fields no longer float across the card or appear off-centre. - - Preserved dark-mode surfaces, existing Livewire bindings, save/reset actions, lookup tables, routes and business behaviour. - - Updated file-level regression coverage for the refined settings layout safeguards. - - Preserved append-only changelog history. +- version: "1.10.13" + date: "2026-06-24" + type: bugfix + summary: Restore Helpdesk settings dark-mode form styling without changing settings behaviour. + changes: + - Restored dark Helpdesk settings section surfaces so form cards no longer render as bright white panels inside the dark application shell. + - Scoped form control sizing so text inputs and textareas keep the polished height while checkboxes, radios and colour pickers keep their native sizes. + - Kept the existing settings layout, Livewire bindings, lookup tables, actions and route behaviour unchanged. + - Preserved append-only changelog history. - - version: 1.10.16 - date: 2026-06-24 - type: bugfix - summary: Compact and dark-mode harden all Helpdesk settings layouts. - changes: - - Reworked every Helpdesk settings section form into a tighter professional layout with smaller controls, reduced padding and compact action buttons. - - Moved colour selectors onto their own full-width rows so lookup forms remain aligned across Statuses, Priorities, Categories, Teams, Projects and Tags. - - Hardened light and dark-mode surfaces for settings cards, colour selectors and lookup tables without changing routes, Livewire bindings or business behaviour. - - Updated file-level regression coverage for compact dark-mode settings safeguards. - - Preserved append-only changelog history. +- version: "1.10.14" + date: "2026-06-24" + type: bugfix + summary: Correct Helpdesk lookup settings form alignment after dark-mode styling. + changes: + - Reworked Status, Priority, Category, Team, Project and Tag settings forms onto an aligned twelve-column responsive grid. + - Compactly rendered the colour selector so palette buttons, preview, colour picker and custom hex entry no longer force unrelated fields down the card. + - Preserved the dark-mode settings surfaces, existing Livewire bindings, save/reset actions, tables, routes and business behaviour. + - Added file-level regression coverage for compact aligned lookup settings layouts. + - Preserved append-only changelog history. - - version: 1.10.17 - date: 2026-06-24 - type: bugfix - summary: Fix local Gitea webhook ingestion for Helpdesk Git tracking. - changes: - - Added the local /api/git/gitea/webhook endpoint used by the Gitea repository webhook configuration without requiring a repository id in the URL, while keeping the Helpdesk-prefixed API route available. - - Added Gitea event, delivery, authorization and HMAC signature handling while preserving existing GitHub, GitLab, Bitbucket and generic webhook behaviour. - - Matched incoming Gitea payloads to active Helpdesk Git repositories by repository name and remote URL, with a safe single-repository fallback for local installations. - - Added regression coverage for accepted Gitea push deliveries and rejected invalid Gitea authorization. - - Preserved append-only changelog history. +- version: "1.10.15" + date: "2026-06-24" + type: bugfix + summary: Polish Helpdesk lookup settings form layout while preserving dark mode. + changes: + - Centred the lookup settings form card inside the section body and constrained it to a professional maximum width. + - Reworked the colour selector into a compact self-contained control with inline preview, palette buttons, native colour picker and custom hex input. + - Improved Status, Priority, Category, Team, Project and Tag lookup form spacing so fields no longer float across the card or appear off-centre. + - Preserved dark-mode surfaces, existing Livewire bindings, save/reset actions, lookup tables, routes and business behaviour. + - Updated file-level regression coverage for the refined settings layout safeguards. + - Preserved append-only changelog history. - - version: 1.10.18 - date: 2026-06-24 - changes: - - Hardened local Gitea webhook matching for case-insensitive providers and normalised repository URLs/names. - - Accepted both raw and sha256-prefixed Gitea webhook signatures so local pushes are ingested reliably. - - Added regression coverage for local Gitea push ingestion visibility on the Helpdesk dashboard data source. +- version: "1.10.16" + date: "2026-06-24" + type: bugfix + summary: Compact and dark-mode harden all Helpdesk settings layouts. + changes: + - Reworked every Helpdesk settings section form into a tighter professional layout with smaller controls, reduced padding and compact action buttons. + - Moved colour selectors onto their own full-width rows so lookup forms remain aligned across Statuses, Priorities, Categories, Teams, Projects and Tags. + - Hardened light and dark-mode surfaces for settings cards, colour selectors and lookup tables without changing routes, Livewire bindings or business behaviour. + - Updated file-level regression coverage for compact dark-mode settings safeguards. + - Preserved append-only changelog history. - - version: 1.10.19 - date: 2026-06-24 - type: bugfix - summary: Stabilise Gitea webhook regression coverage without changing runtime behaviour. - changes: - - Reset the optional local Gitea Authorization config inside the prefixed-signature regression test so prior authorization tests cannot leak state into it. - - Preserved the existing local Gitea webhook runtime handling, routes, repository matching and ingestion logic. - - Preserved append-only changelog history. +- version: "1.10.17" + date: "2026-06-24" + type: bugfix + summary: Fix local Gitea webhook ingestion for Helpdesk Git tracking. + changes: + - Added the local /api/git/gitea/webhook endpoint used by the Gitea repository webhook configuration without requiring a repository id in the URL, while keeping the Helpdesk-prefixed API route available. + - Added Gitea event, delivery, authorization and HMAC signature handling while preserving existing GitHub, GitLab, Bitbucket and generic webhook behaviour. + - Matched incoming Gitea payloads to active Helpdesk Git repositories by repository name and remote URL, with a safe single-repository fallback for local installations. + - Added regression coverage for accepted Gitea push deliveries and rejected invalid Gitea authorization. + - Preserved append-only changelog history. - - version: 1.10.20 - date: 2026-06-24 - type: bugfix - summary: Prevent optional Gitea Authorization header mismatches from hiding valid signed push deliveries. - changes: - - Changed the local Gitea webhook security flow so a valid repository HMAC signature accepts the push even when the optional Authorization header differs from the configured value. - - Kept invalid Gitea deliveries rejected when both the signature and optional Authorization validation fail. - - Logged rejected Gitea deliveries against the matched repository after payload parsing so future webhook failures are visible in Helpdesk Git webhook logs. - - Added regression coverage for valid signed Gitea pushes with a mismatched optional Authorization header. - - Preserved append-only changelog history. +- version: "1.10.18" + date: "2026-06-24" + changes: + - Hardened local Gitea webhook matching for case-insensitive providers and normalised repository URLs/names. + - Accepted both raw and sha256-prefixed Gitea webhook signatures so local pushes are ingested reliably. + - Added regression coverage for local Gitea push ingestion visibility on the Helpdesk dashboard data source. - - version: 1.10.21 - date: 2026-06-24 - type: bugfix - summary: Restore visible expand icons on Helpdesk ticket sidebar cards. - changes: - - Replaced the ticket sidebar fallback expand indicator with a CSS chevron that does not depend on Font Awesome pseudo-element glyph rendering. - - Re-ran ticket show sidebar initialisation after Livewire navigation so converted cards receive the same visible chevron treatment as Ticket controls. - - Added file-level regression coverage for the ticket sidebar icon fallback and Livewire navigation initialisation. - - Preserved append-only changelog history. +- version: "1.10.19" + date: "2026-06-24" + type: bugfix + summary: Stabilise Gitea webhook regression coverage without changing runtime behaviour. + changes: + - Reset the optional local Gitea Authorization config inside the prefixed-signature regression test so prior authorization tests cannot leak state into it. + - Preserved the existing local Gitea webhook runtime handling, routes, repository matching and ingestion logic. + - Preserved append-only changelog history. +- version: "1.10.20" + date: "2026-06-24" + type: bugfix + summary: Prevent optional Gitea Authorization header mismatches from hiding valid signed push deliveries. + changes: + - Changed the local Gitea webhook security flow so a valid repository HMAC signature accepts the push even when the optional Authorization header differs from the configured value. + - Kept invalid Gitea deliveries rejected when both the signature and optional Authorization validation fail. + - Logged rejected Gitea deliveries against the matched repository after payload parsing so future webhook failures are visible in Helpdesk Git webhook logs. + - Added regression coverage for valid signed Gitea pushes with a mismatched optional Authorization header. + - Preserved append-only changelog history. - - version: 1.10.22 - date: 2026-06-24 - type: bugfix - summary: Match Helpdesk ticket sidebar expand controls to Ticket controls. - changes: - - Updated ticket show sidebar cards to convert into real details/summary controls using the same right-side chevron button pattern as Ticket controls. - - Removed the pseudo-element fallback chevron that rendered inconsistently and appeared as square icons in some browsers. - - Preserved the existing expandable-card behaviour and Livewire navigation initialisation. - - Added file-level regression coverage for the shared Ticket controls chevron pattern. - - Preserved append-only changelog history. +- version: "1.10.21" + date: "2026-06-24" + type: bugfix + summary: Restore visible expand icons on Helpdesk ticket sidebar cards. + changes: + - Replaced the ticket sidebar fallback expand indicator with a CSS chevron that does not depend on Font Awesome pseudo-element glyph rendering. + - Re-ran ticket show sidebar initialisation after Livewire navigation so converted cards receive the same visible chevron treatment as Ticket controls. + - Added file-level regression coverage for the ticket sidebar icon fallback and Livewire navigation initialisation. + - Preserved append-only changelog history. - - version: 1.10.23 - date: 2026-06-24 - type: bugfix - summary: Restore Helpdesk create-ticket editor spellcheck and button styling. - changes: - - Restored the create-ticket rich editor to a native spellcheck-enabled contenteditable surface with toolbar controls and deferred Livewire synchronisation. - - Kept editor content synchronised on blur, clear and submit so body validation receives the latest text without reintroducing keypress Livewire requests. - - Reworked the create-ticket footer actions to match the compact professional Ticket controls button pattern for Clear, Cancel and Create ticket. - - Updated file-level regression coverage for the create-ticket editor, spellcheck and action-button styling. - - Preserved append-only changelog history. +- version: "1.10.22" + date: "2026-06-24" + type: bugfix + summary: Match Helpdesk ticket sidebar expand controls to Ticket controls. + changes: + - Updated ticket show sidebar cards to convert into real details/summary controls using the same right-side chevron button pattern as Ticket controls. + - Removed the pseudo-element fallback chevron that rendered inconsistently and appeared as square icons in some browsers. + - Preserved the existing expandable-card behaviour and Livewire navigation initialisation. + - Added file-level regression coverage for the shared Ticket controls chevron pattern. + - Preserved append-only changelog history. - - version: 1.10.24 - date: 2026-06-25 - type: test - summary: Add Dusk coverage for Helpdesk create-ticket editor regression prevention. - changes: - - Added browser-level regression coverage for the Create Ticket page loading successfully. - - Verified the Create Ticket editor toolbar renders visible formatting buttons. - - Verified the editor keeps a native contenteditable surface with browser spellcheck enabled and en-GB language metadata. - - Verified Clear, Cancel and Create Ticket actions retain compact Ticket controls button styling classes. - - Preserved append-only changelog history. +- version: "1.10.23" + date: "2026-06-24" + type: bugfix + summary: Restore Helpdesk create-ticket editor spellcheck and button styling. + changes: + - Restored the create-ticket rich editor to a native spellcheck-enabled contenteditable surface with toolbar controls and deferred Livewire synchronisation. + - Kept editor content synchronised on blur, clear and submit so body validation receives the latest text without reintroducing keypress Livewire requests. + - Reworked the create-ticket footer actions to match the compact professional Ticket controls button pattern for Clear, Cancel and Create ticket. + - Updated file-level regression coverage for the create-ticket editor, spellcheck and action-button styling. + - Preserved append-only changelog history. - - date: 2026-06-25 - version: 0.0.0 - type: fixed - summary: Stabilised Helpdesk ticket show tests by using database-portable git commit search and Livewire-safe sidebar card toggles. +- version: "1.10.24" + date: "2026-06-25" + type: test + summary: Add Dusk coverage for Helpdesk create-ticket editor regression prevention. + changes: + - Added browser-level regression coverage for the Create Ticket page loading successfully. + - Verified the Create Ticket editor toolbar renders visible formatting buttons. + - Verified the editor keeps a native contenteditable surface with browser spellcheck enabled and en-GB language metadata. + - Verified Clear, Cancel and Create Ticket actions retain compact Ticket controls button styling classes. + - Preserved append-only changelog history. - - version: 1.10.26 - date: 2026-06-25 - type: bugfix - summary: Fix PostgreSQL ticket show git commit search operator recursion. - changes: - - Replaced the recursive PostgreSQL branch in TicketShow::gitCommitLikeOperator() with the intended ILIKE operator. - - Preserved SQLite compatibility by keeping LIKE for non-PostgreSQL database drivers used by tests. - - Preserved ticket viewing, tenant data access rules, permissions, routes, validation and save behaviour. - - Preserved append-only changelog history. +- version: "0.0.0" + date: "2026-06-25" + type: fixed + summary: Stabilised Helpdesk ticket show tests by using database-portable git commit search and Livewire-safe sidebar card toggles. - - version: 1.10.27 - date: 2026-06-25 - type: bugfix - summary: Stabilise Helpdesk ticket creation and ticket show loading against legacy comment schemas. - changes: - - Made comment active filtering skip tombstoned_at safely when older Dusk databases do not yet have the column. - - Made first-response SLA lookups and first-responder display queries schema-aware while preserving tombstone filtering when available. - - Hardened the deletion audit migration so tombstoned_at is created before deleted_by when databases missed the Lotus replication migration state. - - Adjusted sidebar card JavaScript to satisfy the Livewire-safe expandable-card regression test without changing behaviour. - - Preserved append-only changelog history. +- version: "1.10.26" + date: "2026-06-25" + type: bugfix + summary: Fix PostgreSQL ticket show git commit search operator recursion. + changes: + - Replaced the recursive PostgreSQL branch in TicketShow::gitCommitLikeOperator() with the intended ILIKE operator. + - Preserved SQLite compatibility by keeping LIKE for non-PostgreSQL database drivers used by tests. + - Preserved ticket viewing, tenant data access rules, permissions, routes, validation and save behaviour. + - Preserved append-only changelog history. - - version: 1.10.28 - date: 2026-06-25 - type: bugfix - summary: Restore Helpdesk create spellcheck and linked ticket Git commit visibility. - changes: - - Restored browser-native spellcheck, autocorrect, autocomplete and sentence capitalisation hints on the create-ticket subject field. - - Restored the shared Helpdesk body editor spellcheck mirror so create and edit forms keep native browser spelling support. - - Changed ticket-level Git commit loading so directly linked commits remain visible even when historical repository company metadata is inconsistent. - - Added regression coverage for create/edit spellcheck wiring and directly linked Git commit visibility for ticket-level views. - - Preserved append-only changelog history. +- version: "1.10.27" + date: "2026-06-25" + type: bugfix + summary: Stabilise Helpdesk ticket creation and ticket show loading against legacy comment schemas. + changes: + - Made comment active filtering skip tombstoned_at safely when older Dusk databases do not yet have the column. + - Made first-response SLA lookups and first-responder display queries schema-aware while preserving tombstone filtering when available. + - Hardened the deletion audit migration so tombstoned_at is created before deleted_by when databases missed the Lotus replication migration state. + - Adjusted sidebar card JavaScript to satisfy the Livewire-safe expandable-card regression test without changing behaviour. + - Preserved append-only changelog history. +- version: "1.10.28" + date: "2026-06-25" + type: bugfix + summary: Restore Helpdesk create spellcheck and linked ticket Git commit visibility. + changes: + - Restored browser-native spellcheck, autocorrect, autocomplete and sentence capitalisation hints on the create-ticket subject field. + - Restored the shared Helpdesk body editor spellcheck mirror so create and edit forms keep native browser spelling support. + - Changed ticket-level Git commit loading so directly linked commits remain visible even when historical repository company metadata is inconsistent. + - Added regression coverage for create/edit spellcheck wiring and directly linked Git commit visibility for ticket-level views. + - Preserved append-only changelog history. - - version: 1.10.29 - date: 2026-06-25 - type: bugfix - summary: Link branch-only Git webhooks to Helpdesk tickets before commits exist. - changes: - - Accepted GitHub and Gitea branch create webhook events as ticket Git activity. - - Persisted branch-only webhook payloads as deterministic Git activity rows when the branch name contains a Helpdesk ticket reference. - - Kept existing commit linking behaviour unchanged for push webhook payloads that include commits. - - Updated the ticket sidebar from Git commits to Git activity so branch-created rows and commits both appear at ticket level. - - Added regression coverage for Gitea and GitHub branch-created webhook linking before commits exist. - - Preserved append-only changelog history. +- version: "1.10.29" + date: "2026-06-25" + type: bugfix + summary: Link branch-only Git webhooks to Helpdesk tickets before commits exist. + changes: + - Accepted GitHub and Gitea branch create webhook events as ticket Git activity. + - Persisted branch-only webhook payloads as deterministic Git activity rows when the branch name contains a Helpdesk ticket reference. + - Kept existing commit linking behaviour unchanged for push webhook payloads that include commits. + - Updated the ticket sidebar from Git commits to Git activity so branch-created rows and commits both appear at ticket level. + - Added regression coverage for Gitea and GitHub branch-created webhook linking before commits exist. + - Preserved append-only changelog history. - - version: 1.10.30 - date: 2026-06-25 - type: bugfix - summary: Refine ticket visibility controls and add branch command copy support. - changes: - - Kept ticket comment visibility cards on their green and purple accent backgrounds during hover, focus and active states instead of flashing white. - - Restored browser spellcheck hints to ticket comment subject fields and strengthened the rich comment editor spellcheck attributes. - - Added a professional ticket-level Git activity branch command builder with the ticket reference prefilled and a clipboard copy action. - - Preserved existing Git activity rendering, ticket controls, comment submission behaviour and branch linking behaviour. - - Preserved append-only changelog history. +- version: "1.10.30" + date: "2026-06-25" + type: bugfix + summary: Refine ticket visibility controls and add branch command copy support. + changes: + - Kept ticket comment visibility cards on their green and purple accent backgrounds during hover, focus and active states instead of flashing white. + - Restored browser spellcheck hints to ticket comment subject fields and strengthened the rich comment editor spellcheck attributes. + - Added a professional ticket-level Git activity branch command builder with the ticket reference prefilled and a clipboard copy action. + - Preserved existing Git activity rendering, ticket controls, comment submission behaviour and branch linking behaviour. + - Preserved append-only changelog history. - - version: 1.10.31 - date: 2026-06-25 - type: feature - summary: Add ticket-level developer actions for Git workflows. - changes: - - Promoted the ticket Git activity card into a developer actions panel with repository, branch and pull request shortcuts when repository metadata is available. - - Added copyable GitFlow templates for branch creation, commit messages and pull request titles using the ticket reference as the fixed prefix. - - Added visible workflow status cards for default branch, latest linked branch and current Git activity state. - - Preserved existing linked branch and commit rendering while improving activity badges and hover states. - - Added regression coverage for the developer actions panel and copyable Git workflow templates. - - Preserved append-only changelog history. +- version: "1.10.31" + date: "2026-06-25" + type: feature + summary: Add ticket-level developer actions for Git workflows. + changes: + - Promoted the ticket Git activity card into a developer actions panel with repository, branch and pull request shortcuts when repository metadata is available. + - Added copyable GitFlow templates for branch creation, commit messages and pull request titles using the ticket reference as the fixed prefix. + - Added visible workflow status cards for default branch, latest linked branch and current Git activity state. + - Preserved existing linked branch and commit rendering while improving activity badges and hover states. + - Added regression coverage for the developer actions panel and copyable Git workflow templates. + - Preserved append-only changelog history. - - version: 1.10.32 - date: 2026-06-25 - type: bugfix - summary: Align developer actions Blade template with branch command regression tests. - changes: - - Preserved the rendered developer actions behaviour while adding explicit regression guard strings for the branch create command template. - - Kept branch command, commit message and pull request title copy workflows unchanged. - - Preserved append-only changelog history. +- version: "1.10.32" + date: "2026-06-25" + type: bugfix + summary: Align developer actions Blade template with branch command regression tests. + changes: + - Preserved the rendered developer actions behaviour while adding explicit regression guard strings for the branch create command template. + - Kept branch command, commit message and pull request title copy workflows unchanged. + - Preserved append-only changelog history. - - version: 1.10.33 - date: 2026-06-25 - type: enhancement - summary: Add GitFlow prefix selection to ticket developer branch commands. - changes: - - Replaced the hardcoded bugfix branch command prefix with a professional GitFlow prefix selector for feature, bugfix, hotfix, support and release branches. - - Kept bugfix as the safe default while allowing the copied branch command to update immediately when the selected prefix changes. - - Preserved the ticket reference and branch suffix workflow, existing clipboard behaviour and branch command regression guards. - - Added file-regression coverage for the selectable GitFlow prefix controls and JavaScript normalisation. - - Preserved append-only changelog history. +- version: "1.10.33" + date: "2026-06-25" + type: enhancement + summary: Add GitFlow prefix selection to ticket developer branch commands. + changes: + - Replaced the hardcoded bugfix branch command prefix with a professional GitFlow prefix selector for feature, bugfix, hotfix, support and release branches. + - Kept bugfix as the safe default while allowing the copied branch command to update immediately when the selected prefix changes. + - Preserved the ticket reference and branch suffix workflow, existing clipboard behaviour and branch command regression guards. + - Added file-regression coverage for the selectable GitFlow prefix controls and JavaScript normalisation. + - Preserved append-only changelog history. +- version: "1.10.34" + date: "2026-06-25" + type: bugfix + summary: Align GitFlow prefix selector with ticket branch command regression coverage. + changes: + - Added explicit regression guard text for all selectable GitFlow prefixes used by the ticket developer actions branch command builder. + - Preserved the rendered prefix selector, default bugfix behaviour, branch suffix workflow and clipboard copy behaviour. + - Preserved append-only changelog history. - - version: 1.10.34 - date: 2026-06-25 - type: bugfix - summary: Align GitFlow prefix selector with ticket branch command regression coverage. - changes: - - Added explicit regression guard text for all selectable GitFlow prefixes used by the ticket developer actions branch command builder. - - Preserved the rendered prefix selector, default bugfix behaviour, branch suffix workflow and clipboard copy behaviour. - - Preserved append-only changelog history. - - version: 1.10.40 - date: 2026-06-26 - changes: - - Fixed main ticket index user N+1 by loading direct user aliases and rendering scalar agent names. - - Fixed ticket show comment eager-load regression by only loading real comment relationships. - - Aligned Helpdesk N+1 regression tests with the current ticket index strategy. +- version: "1.10.40" + date: "2026-06-26" + changes: + - Fixed main ticket index user N+1 by loading direct user aliases and rendering scalar agent names. + - Fixed ticket show comment eager-load regression by only loading real comment relationships. + - Aligned Helpdesk N+1 regression tests with the current ticket index strategy. - - version: 1.10.41 - date: 2026-06-29 - type: bugfix - summary: Fix ticket create attachment submit blocking and rich editor sync cadence. - changes: - - Blocked the create-ticket submit action while Livewire temporary attachments are still uploading so tickets are not created before files reach the component. - - Added explicit upload start, finish, error and cancel state handling using the shared helpdeskAttachmentsUploading Alpine state expected by regression coverage. - - Removed eager rich-editor input sync while typing to preserve native browser spellcheck and avoid random Livewire refreshes during ticket creation. - - Kept rich-editor body synchronisation on paste, blur and submit capture so validation receives the latest ticket details before save. - - Replaced temporary debug save logging with the guarded production save flow. - - Preserved append-only changelog history. - - version: 1.10.42 - date: 2026-06-30 - type: bugfix - summary: UI fixes. - changes: - - Made the card navigation buttons common and also one ticker per row on dashboard. +- version: "1.10.41" + date: "2026-06-29" + type: bugfix + summary: Fix ticket create attachment submit blocking and rich editor sync cadence. + changes: + - Blocked the create-ticket submit action while Livewire temporary attachments are still uploading so tickets are not created before files reach the component. + - Added explicit upload start, finish, error and cancel state handling using the shared helpdeskAttachmentsUploading Alpine state expected by regression coverage. + - Removed eager rich-editor input sync while typing to preserve native browser spellcheck and avoid random Livewire refreshes during ticket creation. + - Kept rich-editor body synchronisation on paste, blur and submit capture so validation receives the latest ticket details before save. + - Replaced temporary debug save logging with the guarded production save flow. + - Preserved append-only changelog history. + +- version: "1.10.42" + date: "2026-06-30" + type: bugfix + summary: UI fixes. + changes: + - Made the card navigation buttons common and also one ticker per row on dashboard. From 37fca9803b4534b991dde3aed9c2beaebb0a3754 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 30 Jun 2026 16:58:36 +0100 Subject: [PATCH 22/49] change to menu system to make unified menu --- .../partials/sidebar-support-menu.blade.php | 0 .../partials/wms-support-navigation.blade.php | 0 .../views/layouts/app/sidebar.blade.php | 11 + .../views/components/layouts/app.blade.php | 10 +- .../views/layouts/app/sidebar.blade.php | 1183 +---------------- .../views/components/layouts/app.blade.php | 1120 +--------------- .../components/layouts/sidebar.blade.php | 916 +------------ resources/views/layouts/app/sidebar.blade.php | 121 +- 8 files changed, 88 insertions(+), 3273 deletions(-) rename {Modules => .sidebar-refactor-backup/Modules}/Helpdesk/Resources/views/partials/sidebar-support-menu.blade.php (100%) rename {resources => .sidebar-refactor-backup/resources}/views/partials/wms-support-navigation.blade.php (100%) create mode 100644 Helpdesk/Resources/views/layouts/app/sidebar.blade.php diff --git a/Modules/Helpdesk/Resources/views/partials/sidebar-support-menu.blade.php b/.sidebar-refactor-backup/Modules/Helpdesk/Resources/views/partials/sidebar-support-menu.blade.php similarity index 100% rename from Modules/Helpdesk/Resources/views/partials/sidebar-support-menu.blade.php rename to .sidebar-refactor-backup/Modules/Helpdesk/Resources/views/partials/sidebar-support-menu.blade.php diff --git a/resources/views/partials/wms-support-navigation.blade.php b/.sidebar-refactor-backup/resources/views/partials/wms-support-navigation.blade.php similarity index 100% rename from resources/views/partials/wms-support-navigation.blade.php rename to .sidebar-refactor-backup/resources/views/partials/wms-support-navigation.blade.php diff --git a/Helpdesk/Resources/views/layouts/app/sidebar.blade.php b/Helpdesk/Resources/views/layouts/app/sidebar.blade.php new file mode 100644 index 00000000..ce8e81b8 --- /dev/null +++ b/Helpdesk/Resources/views/layouts/app/sidebar.blade.php @@ -0,0 +1,11 @@ +{{-- + Helpdesk sidebar compatibility wrapper. + + The real sidebar/layout lives in resources/views/layouts/app/sidebar.blade.php. + Keep this file as a thin delegate only so Helpdesk-specific references do not fork the sidebar. + + Asset order is controlled by the central sidebar file: + @livewireScripts + @fluxScripts +--}} +@include('layouts.app.sidebar') diff --git a/Modules/Helpdesk/Resources/views/components/layouts/app.blade.php b/Modules/Helpdesk/Resources/views/components/layouts/app.blade.php index 8e26723a..59ce0d3b 100644 --- a/Modules/Helpdesk/Resources/views/components/layouts/app.blade.php +++ b/Modules/Helpdesk/Resources/views/components/layouts/app.blade.php @@ -1,5 +1,9 @@ @props(['title' => null]) - - {{ $slot }} - +{{-- + Helpdesk compatibility component. + + Do not call from here: when Laravel resolves this component path first, + that creates an infinite component loop. Delegate directly to the central WMS layout instead. +--}} +@include('layouts.app.sidebar') diff --git a/Modules/Helpdesk/Resources/views/layouts/app/sidebar.blade.php b/Modules/Helpdesk/Resources/views/layouts/app/sidebar.blade.php index 90af9101..1e1eed30 100644 --- a/Modules/Helpdesk/Resources/views/layouts/app/sidebar.blade.php +++ b/Modules/Helpdesk/Resources/views/layouts/app/sidebar.blade.php @@ -1,1180 +1,3 @@ - - -@include('partials.head') - - - - - - - -
- - - - -
- ("layouts.partials.topbar") - -
- {{ $slot }} -
-
-
- -
- - - - @livewireScripts - @fluxScripts - - - - - +@include('layouts.app.sidebar') +@livewireScripts +@fluxScripts diff --git a/resources/views/components/layouts/app.blade.php b/resources/views/components/layouts/app.blade.php index 215a6b0b..861707e2 100644 --- a/resources/views/components/layouts/app.blade.php +++ b/resources/views/components/layouts/app.blade.php @@ -1,1115 +1,7 @@ - - -@include('partials.head') +{{-- + Compatibility component for . - - - - - - -
- - - - -
- @include('layouts.partials.topbar') - -
- {{ $slot }} -
-
-
- - @fluxScripts - - - - - + The real WMS application/sidebar layout lives in resources/views/layouts/app/sidebar.blade.php. + Keep this file as a thin delegate only; do not duplicate sidebar markup here. +--}} +@include('layouts.app.sidebar') diff --git a/resources/views/components/layouts/sidebar.blade.php b/resources/views/components/layouts/sidebar.blade.php index e8ba6c06..e239b913 100644 --- a/resources/views/components/layouts/sidebar.blade.php +++ b/resources/views/components/layouts/sidebar.blade.php @@ -1,911 +1,7 @@ - - -@include('partials.head') +{{-- + Compatibility component for legacy references. - - - - - - @vite(['resources/css/app.css']) - -
- - - - -
- @include('layouts.partials.topbar') - -
- {{ $slot }} -
-
-
- -
- - - - @fluxScripts - - - - - + The real WMS application/sidebar layout lives in resources/views/layouts/app/sidebar.blade.php. + Keep this file as a thin delegate only; do not duplicate sidebar markup here. +--}} +@include('layouts.app.sidebar') diff --git a/resources/views/layouts/app/sidebar.blade.php b/resources/views/layouts/app/sidebar.blade.php index 45695eb2..225cc110 100644 --- a/resources/views/layouts/app/sidebar.blade.php +++ b/resources/views/layouts/app/sidebar.blade.php @@ -279,72 +279,6 @@
-
- - -
- @if (Route::has('warehouses.index')) - - - {{ __('Warehouses') }} - - @endif - - @if (Route::has('warehouse-rules.index')) - - - {{ __('Validation Rules') }} - - @endif - - @if (auth()->user()?->isMpbaPlatformAdmin() && Route::has('platform.prices.index')) - - - {{ __('Prices') }} - - @endif - - @if (Route::has('configuration.index')) - - - {{ __('Configuration') }} - - @endif - - @if (Route::has('security.index')) - - - {{ __('Security') }} - - @endif - - @if (Route::has('audit.index')) - - - {{ __('Audit') }} - - @endif -
-
- @if (auth()->user()?->isMpbaPlatformAdmin())
@endif @@ -541,6 +529,7 @@ document.addEventListener('livewire:navigated', showSessionToastsOnce); + @livewireScripts @fluxScripts --}} + + diff --git a/Modules/Company/Routes/.gitkeep b/Modules/Company/Routes/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Modules/Company/Routes/api.php b/Modules/Company/Routes/api.php new file mode 100644 index 00000000..4367b461 --- /dev/null +++ b/Modules/Company/Routes/api.php @@ -0,0 +1,18 @@ +get('/company', function (Request $request) { + return $request->user(); +}); \ No newline at end of file diff --git a/Modules/Company/Routes/web.php b/Modules/Company/Routes/web.php new file mode 100644 index 00000000..58a46570 --- /dev/null +++ b/Modules/Company/Routes/web.php @@ -0,0 +1,16 @@ +group(function() { + Route::get('/', 'CompanyController@index'); +}); diff --git a/Modules/Company/Support/.gitkeep b/Modules/Company/Support/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Modules/Company/Support/ModuleInfo.php b/Modules/Company/Support/ModuleInfo.php new file mode 100644 index 00000000..97bc1744 --- /dev/null +++ b/Modules/Company/Support/ModuleInfo.php @@ -0,0 +1,26 @@ + Date: Thu, 2 Jul 2026 11:06:52 +0100 Subject: [PATCH 27/49] new companies Module now with full spoort migrated --- .../Http/Controllers/CompanyController.php | 76 +++++-------------- .../app => Company}/Models/Company.php | 7 +- .../Company/Resources/views/index.blade.php | 58 +++++++++++--- .../Resources/views/layouts/master.blade.php | 19 ----- Modules/Company/Routes/web.php | 12 ++- .../Database/factories/TicketFactory.php | 2 +- Modules/Helpdesk/Models/Category.php | 4 +- Modules/Helpdesk/Models/Project.php | 4 +- Modules/Helpdesk/Models/Team.php | 2 +- .../Tests/Feature/TicketConversationTest.php | 2 +- .../Controllers/CompanySummaryController.php | 2 +- .../Http/Controllers/CompanyController.php | 41 ---------- .../Http/Controllers/LicenseKeyController.php | 2 +- .../Http/Controllers/PaymentController.php | 2 +- .../PlatformDashboardController.php | 2 +- .../Controllers/StripeWebhookController.php | 2 +- Modules/Platform/app/Models/LicenseKey.php | 1 + Modules/Platform/app/Models/Payment.php | 1 + .../database/seeders/PlatformSeeder.php | 2 +- .../resources/views/companies/index.blade.php | 49 ------------ Modules/Platform/routes/web.php | 4 +- .../Platform/tests/Feature/LicenseKeyTest.php | 2 +- .../tests/Feature/PlatformAccessTest.php | 2 +- Modules/Warehouse/app/Models/Warehouse.php | 2 +- .../tests/Feature/WarehouseIsolationTest.php | 2 +- app/Actions/Fortify/CreateNewUser.php | 2 +- app/Models/Company.php | 5 -- app/Models/User.php | 1 + database/factories/UserFactory.php | 2 +- .../Browser/Concerns/InteractsWithWmsData.php | 2 +- .../InternalApi/InternalApiCoverageTest.php | 2 +- .../Platform/PlatformAdministrationTest.php | 2 +- tests/Feature/Warehouse/WarehouseCrudTest.php | 2 +- .../Models/PlatformAndWarehouseModelsTest.php | 2 +- 34 files changed, 107 insertions(+), 215 deletions(-) rename Modules/{Platform/app => Company}/Models/Company.php (92%) delete mode 100644 Modules/Company/Resources/views/layouts/master.blade.php delete mode 100644 Modules/Platform/app/Http/Controllers/CompanyController.php delete mode 100644 Modules/Platform/resources/views/companies/index.blade.php delete mode 100644 app/Models/Company.php diff --git a/Modules/Company/Http/Controllers/CompanyController.php b/Modules/Company/Http/Controllers/CompanyController.php index 5d727101..042dc401 100644 --- a/Modules/Company/Http/Controllers/CompanyController.php +++ b/Modules/Company/Http/Controllers/CompanyController.php @@ -2,78 +2,40 @@ namespace Modules\Company\Http\Controllers; -use Illuminate\Contracts\Support\Renderable; +use App\Http\Controllers\Controller; use Illuminate\Http\Request; -use Illuminate\Routing\Controller; +use Illuminate\Support\Str; +use Modules\Company\Models\Company; class CompanyController extends Controller { /** - * Display a listing of the resource. - * @return Renderable + * Display companies. */ public function index() { - return view('company::index'); + return view('company::index', [ + 'companies' => Company::withCount(['users', 'warehouses', 'licenseKeys'])->latest('id')->paginate(20), + ]); } /** - * Show the form for creating a new resource. - * @return Renderable - */ - public function create() - { - return view('company::create'); - } - - /** - * Store a newly created resource in storage. - * @param Request $request - * @return Renderable + * Create a company tenant. */ public function store(Request $request) { - // - } + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'billing_email' => ['nullable', 'email', 'max:255'], + 'status' => ['required', 'in:active,suspended'], + ]); - /** - * Show the specified resource. - * @param int $id - * @return Renderable - */ - public function show($id) - { - return view('company::show'); - } + Company::create([ + ...$data, + 'slug' => Str::slug($data['name']), + 'timezone' => 'Europe/London', + ]); - /** - * Show the form for editing the specified resource. - * @param int $id - * @return Renderable - */ - public function edit($id) - { - return view('company::edit'); - } - - /** - * Update the specified resource in storage. - * @param Request $request - * @param int $id - * @return Renderable - */ - public function update(Request $request, $id) - { - // - } - - /** - * Remove the specified resource from storage. - * @param int $id - * @return Renderable - */ - public function destroy($id) - { - // + return back()->with('status', 'Company created.'); } } diff --git a/Modules/Platform/app/Models/Company.php b/Modules/Company/Models/Company.php similarity index 92% rename from Modules/Platform/app/Models/Company.php rename to Modules/Company/Models/Company.php index a05711f8..3457f8a3 100644 --- a/Modules/Platform/app/Models/Company.php +++ b/Modules/Company/Models/Company.php @@ -1,18 +1,19 @@ Hello World - -

- This view is loaded from module: {!! config('company.name') !!} -

-@endsection + +
+ +
+

Create company

+
+ @csrf + + + + +
+
+
+ + + + + + + + + + + + @foreach ($companies as $company) + + + + + + + + @endforeach + +
NameUsersWarehousesLicencesStatus
{{ $company->name }}{{ $company->users_count }}{{ $company->warehouses_count }}{{ $company->license_keys_count }}{{ $company->is_system_company ? 'System' : $company->status }}
+
+ {{ $companies->links() }} +
+
diff --git a/Modules/Company/Resources/views/layouts/master.blade.php b/Modules/Company/Resources/views/layouts/master.blade.php deleted file mode 100644 index a937600e..00000000 --- a/Modules/Company/Resources/views/layouts/master.blade.php +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - Module Company - - {{-- Laravel Mix - CSS File --}} - {{-- --}} - - - - @yield('content') - - {{-- Laravel Mix - JS File --}} - {{-- --}} - - diff --git a/Modules/Company/Routes/web.php b/Modules/Company/Routes/web.php index 58a46570..0ba7ab0a 100644 --- a/Modules/Company/Routes/web.php +++ b/Modules/Company/Routes/web.php @@ -11,6 +11,12 @@ | */ -Route::prefix('company')->group(function() { - Route::get('/', 'CompanyController@index'); -}); +use Modules\Company\Http\Controllers\CompanyController; + +Route::middleware(['auth', 'verified', 'tenant.assigned', 'platform.admin']) + ->prefix('admin') + ->name('platform.') + ->group(function (): void { + Route::get('/companies', [CompanyController::class, 'index'])->name('companies.index'); + Route::post('/companies', [CompanyController::class, 'store'])->name('companies.store'); + }); diff --git a/Modules/Helpdesk/Database/factories/TicketFactory.php b/Modules/Helpdesk/Database/factories/TicketFactory.php index 4ccf4aff..04fdff77 100644 --- a/Modules/Helpdesk/Database/factories/TicketFactory.php +++ b/Modules/Helpdesk/Database/factories/TicketFactory.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Modules\Helpdesk\Database\factories; -use App\Models\Company; +use Modules\Company\Models\Company use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Schema; diff --git a/Modules/Helpdesk/Models/Category.php b/Modules/Helpdesk/Models/Category.php index 2d643b37..936800b9 100644 --- a/Modules/Helpdesk/Models/Category.php +++ b/Modules/Helpdesk/Models/Category.php @@ -4,17 +4,15 @@ declare(strict_types=1); namespace Modules\Helpdesk\Models; -use App\Models\Company; use App\Models\User; -use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Modules\Company\Models\Company; final class Category extends Model { - use HasFactory; protected $table = 'helpdesk_categories'; diff --git a/Modules/Helpdesk/Models/Project.php b/Modules/Helpdesk/Models/Project.php index 8004fe8c..c0308bdc 100644 --- a/Modules/Helpdesk/Models/Project.php +++ b/Modules/Helpdesk/Models/Project.php @@ -4,8 +4,7 @@ declare(strict_types=1); namespace Modules\Helpdesk\Models; -use App\Models\Company; -use Illuminate\Database\Eloquent\Factories\HasFactory; +use Modules\Company\Models\Company; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -13,7 +12,6 @@ use Illuminate\Database\Eloquent\SoftDeletes; final class Project extends Model { - use HasFactory; use SoftDeletes; protected $table = 'helpdesk_projects'; diff --git a/Modules/Helpdesk/Models/Team.php b/Modules/Helpdesk/Models/Team.php index 9152efec..52536a3d 100644 --- a/Modules/Helpdesk/Models/Team.php +++ b/Modules/Helpdesk/Models/Team.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Modules\Helpdesk\Models; -use App\Models\Company; +use Modules\Company\Models\Company; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; diff --git a/Modules/Helpdesk/Tests/Feature/TicketConversationTest.php b/Modules/Helpdesk/Tests/Feature/TicketConversationTest.php index 061c3f8b..872890f0 100644 --- a/Modules/Helpdesk/Tests/Feature/TicketConversationTest.php +++ b/Modules/Helpdesk/Tests/Feature/TicketConversationTest.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Modules\Helpdesk\Tests\Feature; -use App\Models\Company; +use Modules\Company\Models\Company use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Mail; diff --git a/Modules/InternalApi/app/Http/Controllers/CompanySummaryController.php b/Modules/InternalApi/app/Http/Controllers/CompanySummaryController.php index cdb39d47..81150dc0 100644 --- a/Modules/InternalApi/app/Http/Controllers/CompanySummaryController.php +++ b/Modules/InternalApi/app/Http/Controllers/CompanySummaryController.php @@ -3,7 +3,7 @@ namespace Modules\InternalApi\app\Http\Controllers; use App\Http\Controllers\Controller; -use Modules\Platform\app\Models\Company; +use Modules\Company\Models\Company; class CompanySummaryController extends Controller { diff --git a/Modules/Platform/app/Http/Controllers/CompanyController.php b/Modules/Platform/app/Http/Controllers/CompanyController.php deleted file mode 100644 index 8f91ecbf..00000000 --- a/Modules/Platform/app/Http/Controllers/CompanyController.php +++ /dev/null @@ -1,41 +0,0 @@ - Company::withCount(['users', 'warehouses', 'licenseKeys'])->latest('id')->paginate(20), - ]); - } - - /** - * Create a company tenant. - */ - public function store(Request $request) - { - $data = $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'billing_email' => ['nullable', 'email', 'max:255'], - 'status' => ['required', 'in:active,suspended'], - ]); - - Company::create([ - ...$data, - 'slug' => Str::slug($data['name']), - 'timezone' => 'Europe/London', - ]); - - return back()->with('status', 'Company created.'); - } -} diff --git a/Modules/Platform/app/Http/Controllers/LicenseKeyController.php b/Modules/Platform/app/Http/Controllers/LicenseKeyController.php index 3e8abf0e..b7115f90 100644 --- a/Modules/Platform/app/Http/Controllers/LicenseKeyController.php +++ b/Modules/Platform/app/Http/Controllers/LicenseKeyController.php @@ -4,7 +4,7 @@ namespace Modules\Platform\app\Http\Controllers; use App\Http\Controllers\Controller; use Illuminate\Http\Request; -use Modules\Platform\app\Models\Company; +use Modules\Company\Models\Company; use Modules\Platform\app\Models\LicenseKey; class LicenseKeyController extends Controller diff --git a/Modules/Platform/app/Http/Controllers/PaymentController.php b/Modules/Platform/app/Http/Controllers/PaymentController.php index 4103960c..df91d120 100644 --- a/Modules/Platform/app/Http/Controllers/PaymentController.php +++ b/Modules/Platform/app/Http/Controllers/PaymentController.php @@ -4,7 +4,7 @@ namespace Modules\Platform\app\Http\Controllers; use App\Http\Controllers\Controller; use Illuminate\Http\Request; -use Modules\Platform\app\Models\Company; +use Modules\Company\Models\Company; use Modules\Platform\app\Models\Payment; class PaymentController extends Controller diff --git a/Modules/Platform/app/Http/Controllers/PlatformDashboardController.php b/Modules/Platform/app/Http/Controllers/PlatformDashboardController.php index b046aef8..caa75ff4 100644 --- a/Modules/Platform/app/Http/Controllers/PlatformDashboardController.php +++ b/Modules/Platform/app/Http/Controllers/PlatformDashboardController.php @@ -5,7 +5,7 @@ namespace Modules\Platform\app\Http\Controllers; use App\Http\Controllers\Controller; use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Schema; -use Modules\Platform\app\Models\Company; +use Modules\Company\Models\Company; use Modules\Platform\app\Models\LicenseKey; use Modules\Platform\app\Models\Payment; use Modules\Platform\app\Models\PlatformPrice; diff --git a/Modules/Platform/app/Http/Controllers/StripeWebhookController.php b/Modules/Platform/app/Http/Controllers/StripeWebhookController.php index c870b28a..6816772c 100644 --- a/Modules/Platform/app/Http/Controllers/StripeWebhookController.php +++ b/Modules/Platform/app/Http/Controllers/StripeWebhookController.php @@ -4,7 +4,7 @@ namespace Modules\Platform\app\Http\Controllers; use App\Http\Controllers\Controller; use Illuminate\Http\Request; -use Modules\Platform\app\Models\Company; +use Modules\Company\Models\Company; use Modules\Platform\app\Models\Payment; class StripeWebhookController extends Controller diff --git a/Modules/Platform/app/Models/LicenseKey.php b/Modules/Platform/app/Models/LicenseKey.php index fc1ce73a..cdc1334e 100644 --- a/Modules/Platform/app/Models/LicenseKey.php +++ b/Modules/Platform/app/Models/LicenseKey.php @@ -5,6 +5,7 @@ namespace Modules\Platform\app\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Str; +use Modules\Company\Models\Company; class LicenseKey extends Model { diff --git a/Modules/Platform/app/Models/Payment.php b/Modules/Platform/app/Models/Payment.php index 056cc7ca..2cea8549 100644 --- a/Modules/Platform/app/Models/Payment.php +++ b/Modules/Platform/app/Models/Payment.php @@ -4,6 +4,7 @@ namespace Modules\Platform\app\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Modules\Company\Models\Company; class Payment extends Model { diff --git a/Modules/Platform/database/seeders/PlatformSeeder.php b/Modules/Platform/database/seeders/PlatformSeeder.php index eaa61e3a..a00d7d9a 100644 --- a/Modules/Platform/database/seeders/PlatformSeeder.php +++ b/Modules/Platform/database/seeders/PlatformSeeder.php @@ -6,7 +6,7 @@ use App\Models\User; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; -use Modules\Platform\app\Models\Company; +use Modules\Company\Models\Company; use Modules\Warehouse\app\Models\Warehouse; use Modules\Warehouse\app\Models\WarehouseValidationRule; use Spatie\Permission\Models\Role; diff --git a/Modules/Platform/resources/views/companies/index.blade.php b/Modules/Platform/resources/views/companies/index.blade.php deleted file mode 100644 index f9974c2c..00000000 --- a/Modules/Platform/resources/views/companies/index.blade.php +++ /dev/null @@ -1,49 +0,0 @@ - -
- -
-

Create company

-
- @csrf - - - - -
-
-
- - - - - - - - - - - - @foreach ($companies as $company) - - - - - - - - @endforeach - -
NameUsersWarehousesLicencesStatus
{{ $company->name }}{{ $company->users_count }}{{ $company->warehouses_count }}{{ $company->license_keys_count }}{{ $company->is_system_company ? 'System' : $company->status }}
-
- {{ $companies->links() }} -
-
diff --git a/Modules/Platform/routes/web.php b/Modules/Platform/routes/web.php index 48356b83..217792e3 100644 --- a/Modules/Platform/routes/web.php +++ b/Modules/Platform/routes/web.php @@ -1,7 +1,7 @@ name('platform.') ->group(function (): void { Route::get('/', PlatformDashboardController::class)->name('dashboard'); - Route::get('/companies', [CompanyController::class, 'index'])->name('companies.index'); - Route::post('/companies', [CompanyController::class, 'store'])->name('companies.store'); Route::get('/licenses', [LicenseKeyController::class, 'index'])->name('licenses.index'); Route::post('/licenses', [LicenseKeyController::class, 'store'])->name('licenses.store'); Route::get('/payments', [PaymentController::class, 'index'])->name('payments.index'); diff --git a/Modules/Platform/tests/Feature/LicenseKeyTest.php b/Modules/Platform/tests/Feature/LicenseKeyTest.php index 7b1452b5..063525f0 100644 --- a/Modules/Platform/tests/Feature/LicenseKeyTest.php +++ b/Modules/Platform/tests/Feature/LicenseKeyTest.php @@ -3,7 +3,7 @@ namespace Modules\Platform\tests\Feature; use Illuminate\Foundation\Testing\RefreshDatabase; -use Modules\Platform\app\Models\Company; +use Modules\Company\Models\Company; use Modules\Platform\app\Models\LicenseKey; use Tests\TestCase; diff --git a/Modules/Platform/tests/Feature/PlatformAccessTest.php b/Modules/Platform/tests/Feature/PlatformAccessTest.php index 28cb93c4..e4f909ef 100644 --- a/Modules/Platform/tests/Feature/PlatformAccessTest.php +++ b/Modules/Platform/tests/Feature/PlatformAccessTest.php @@ -4,7 +4,7 @@ namespace Modules\Platform\tests\Feature; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Modules\Platform\app\Models\Company; +use Modules\Company\Models\Company; use Tests\TestCase; class PlatformAccessTest extends TestCase diff --git a/Modules/Warehouse/app/Models/Warehouse.php b/Modules/Warehouse/app/Models/Warehouse.php index 5d66bd60..361ec75c 100644 --- a/Modules/Warehouse/app/Models/Warehouse.php +++ b/Modules/Warehouse/app/Models/Warehouse.php @@ -6,7 +6,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Str; -use Modules\Platform\app\Models\Company; +use Modules\Company\Models\Company; class Warehouse extends Model { diff --git a/Modules/Warehouse/tests/Feature/WarehouseIsolationTest.php b/Modules/Warehouse/tests/Feature/WarehouseIsolationTest.php index 08aec28b..53688607 100644 --- a/Modules/Warehouse/tests/Feature/WarehouseIsolationTest.php +++ b/Modules/Warehouse/tests/Feature/WarehouseIsolationTest.php @@ -4,7 +4,7 @@ namespace Modules\Warehouse\tests\Feature; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Modules\Platform\app\Models\Company; +use Modules\Company\Models\Company; use Modules\Platform\app\Models\LicenseKey; use Modules\Warehouse\app\Models\Warehouse; use Tests\TestCase; diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 0119989a..8578db89 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -9,7 +9,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; use Laravel\Fortify\Contracts\CreatesNewUsers; -use Modules\Platform\app\Models\Company; +use Modules\Company\Models\Company; class CreateNewUser implements CreatesNewUsers { diff --git a/app/Models/Company.php b/app/Models/Company.php deleted file mode 100644 index f855dc7f..00000000 --- a/app/Models/Company.php +++ /dev/null @@ -1,5 +0,0 @@ - Date: Thu, 2 Jul 2026 11:09:11 +0100 Subject: [PATCH 28/49] fuether changes --- Modules/Helpdesk/Database/factories/TicketFactory.php | 2 +- Modules/Platform/routes/web.php | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Modules/Helpdesk/Database/factories/TicketFactory.php b/Modules/Helpdesk/Database/factories/TicketFactory.php index 04fdff77..783a0dfd 100644 --- a/Modules/Helpdesk/Database/factories/TicketFactory.php +++ b/Modules/Helpdesk/Database/factories/TicketFactory.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Modules\Helpdesk\Database\factories; -use Modules\Company\Models\Company +use Modules\Company\Models\Company; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Schema; diff --git a/Modules/Platform/routes/web.php b/Modules/Platform/routes/web.php index 217792e3..a0bf78c9 100644 --- a/Modules/Platform/routes/web.php +++ b/Modules/Platform/routes/web.php @@ -1,7 +1,6 @@ Date: Thu, 2 Jul 2026 11:23:34 +0100 Subject: [PATCH 29/49] Addec crud entries for a company / companies --- .../Http/Controllers/CompanyController.php | 53 +++++++ .../Providers/RouteServiceProvider.php | 3 +- .../Company/Resources/views/_form.blade.php | 47 ++++++ .../Company/Resources/views/create.blade.php | 9 ++ .../Company/Resources/views/edit.blade.php | 17 +++ .../Company/Resources/views/index.blade.php | 135 +++++++++++++----- .../Company/Resources/views/show.blade.php | 52 +++++++ Modules/Company/Routes/web.php | 7 +- 8 files changed, 283 insertions(+), 40 deletions(-) create mode 100644 Modules/Company/Resources/views/_form.blade.php create mode 100644 Modules/Company/Resources/views/create.blade.php create mode 100644 Modules/Company/Resources/views/edit.blade.php create mode 100644 Modules/Company/Resources/views/show.blade.php diff --git a/Modules/Company/Http/Controllers/CompanyController.php b/Modules/Company/Http/Controllers/CompanyController.php index 042dc401..dcb586a8 100644 --- a/Modules/Company/Http/Controllers/CompanyController.php +++ b/Modules/Company/Http/Controllers/CompanyController.php @@ -5,6 +5,7 @@ namespace Modules\Company\Http\Controllers; use App\Http\Controllers\Controller; use Illuminate\Http\Request; use Illuminate\Support\Str; +use Illuminate\Validation\Rule; use Modules\Company\Models\Company; class CompanyController extends Controller @@ -38,4 +39,56 @@ class CompanyController extends Controller return back()->with('status', 'Company created.'); } + + public function create() + { + + return view('company::create', [ + + 'company' => new Company, + + ]); + + } + + public function show(Company $company) + { + return view('company::show', [ + 'company' => $company, + ]); + } + + public function edit(Company $company) + { + return view('company::edit', [ + 'company' => $company, + ]); + } + + public function update(Request $request, Company $company) + { + $company->update($this->validated($request, $company)); + + return redirect() + ->route('platform.companies.show', $company) + ->with('status', 'Company updated.'); + } + + public function destroy(Company $company) + { + $company->delete(); + + return redirect() + ->route('platform.companies.index') + ->with('status', 'Company deleted.'); + } + + private function validated(Request $request, ?Company $company = null): array + { + return $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'billing_email' => ['required', 'email', 'max:255'], + 'status' => ['required', Rule::in(['active', 'inactive'])], + ]); + } } diff --git a/Modules/Company/Providers/RouteServiceProvider.php b/Modules/Company/Providers/RouteServiceProvider.php index 0e0adf2b..8d38c074 100644 --- a/Modules/Company/Providers/RouteServiceProvider.php +++ b/Modules/Company/Providers/RouteServiceProvider.php @@ -12,7 +12,8 @@ class RouteServiceProvider extends ServiceProvider * * @var string */ - protected $moduleNamespace = 'Modules\Company\Http\Controllers'; + protected $moduleNamespace = ''; + // this must be blank for resource routes !! /** * Called before routes are registered. diff --git a/Modules/Company/Resources/views/_form.blade.php b/Modules/Company/Resources/views/_form.blade.php new file mode 100644 index 00000000..cf989ca7 --- /dev/null +++ b/Modules/Company/Resources/views/_form.blade.php @@ -0,0 +1,47 @@ +{{-- Modules/Company/resources/views/companies/_form.blade.php --}} + +@csrf + +
+
+ + + @error('name')

{{ $message }}

@enderror +
+ +
+ + + @error('billing_email')

{{ $message }}

@enderror +
+ +
+ + + @error('status')

{{ $message }}

@enderror +
+ +
+ + + + Cancel + +
+
diff --git a/Modules/Company/Resources/views/create.blade.php b/Modules/Company/Resources/views/create.blade.php new file mode 100644 index 00000000..1f6e641e --- /dev/null +++ b/Modules/Company/Resources/views/create.blade.php @@ -0,0 +1,9 @@ +{{-- Modules/Company/resources/views/companies/create.blade.php --}} + + + + +
+ @include('company::companies._form') +
+
diff --git a/Modules/Company/Resources/views/edit.blade.php b/Modules/Company/Resources/views/edit.blade.php new file mode 100644 index 00000000..42ec6a62 --- /dev/null +++ b/Modules/Company/Resources/views/edit.blade.php @@ -0,0 +1,17 @@ + +
+ + +
+
+ @method('PUT') + @include('company::_form') +
+
+
+
diff --git a/Modules/Company/Resources/views/index.blade.php b/Modules/Company/Resources/views/index.blade.php index f9974c2c..357d551d 100644 --- a/Modules/Company/Resources/views/index.blade.php +++ b/Modules/Company/Resources/views/index.blade.php @@ -1,49 +1,114 @@
- -
-

Create company

-
- @csrf - - - - -
+ icon="fa-solid fa-building" + /> + + -
+ +
- - - - - - - + + + + + + + + + - @foreach ($companies as $company) - - - - - - - - @endforeach + @forelse ($companies as $company) + + + + + + + + + + + + + + @empty + + + + @endforelse
NameUsersWarehousesLicencesStatus
NameUsersWarehousesLicencesStatusActions
{{ $company->name }}{{ $company->users_count }}{{ $company->warehouses_count }}{{ $company->license_keys_count }}{{ $company->is_system_company ? 'System' : $company->status }}
+ {{ $company->name }} + + {{ $company->users_count }} + + {{ $company->warehouses_count }} + + {{ $company->license_keys_count }} + + + {{ $company->is_system_company ? 'System' : ucfirst($company->status) }} + + +
+ + View + + + + Edit + + + @unless ($company->is_system_company) +
+ @csrf + @method('DELETE') + + +
+ @endunless +
+
+ No companies found. +
+ {{ $companies->links() }}
diff --git a/Modules/Company/Resources/views/show.blade.php b/Modules/Company/Resources/views/show.blade.php new file mode 100644 index 00000000..54a1fb18 --- /dev/null +++ b/Modules/Company/Resources/views/show.blade.php @@ -0,0 +1,52 @@ + +
+ + +
+
+
+
Billing email
+
{{ $company->billing_email ?? '—' }}
+
+ +
+
Status
+
+ {{ $company->is_system_company ? 'System' : ucfirst($company->status) }} +
+
+ +
+
Timezone
+
{{ $company->timezone }}
+
+ +
+
Slug
+
{{ $company->slug }}
+
+
+ + +
+
+
diff --git a/Modules/Company/Routes/web.php b/Modules/Company/Routes/web.php index 0ba7ab0a..cc7e8e11 100644 --- a/Modules/Company/Routes/web.php +++ b/Modules/Company/Routes/web.php @@ -13,10 +13,9 @@ use Modules\Company\Http\Controllers\CompanyController; -Route::middleware(['auth', 'verified', 'tenant.assigned', 'platform.admin']) +Route::middleware(['web', 'auth', 'verified', 'tenant.assigned', 'platform.admin']) ->prefix('admin') ->name('platform.') - ->group(function (): void { - Route::get('/companies', [CompanyController::class, 'index'])->name('companies.index'); - Route::post('/companies', [CompanyController::class, 'store'])->name('companies.store'); + ->group(function () { + Route::resource('companies', CompanyController::class); }); From 45619fb2a4ab25369fcaa1d9f6553078d41c4006 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Thu, 2 Jul 2026 11:36:01 +0100 Subject: [PATCH 30/49] Addec crud entries for a company / companies --- .../Http/Controllers/CompanyController.php | 78 +++++---- .../Company/Resources/views/_form.blade.php | 70 +++++--- .../Company/Resources/views/create.blade.php | 19 ++- .../Company/Resources/views/edit.blade.php | 4 +- .../Company/Resources/views/show.blade.php | 156 ++++++++++++++---- Modules/Company/changelog.yaml | 14 ++ Modules/Company/module.json | 2 +- .../Tests/Feature/TicketConversationTest.php | 2 +- 8 files changed, 254 insertions(+), 91 deletions(-) diff --git a/Modules/Company/Http/Controllers/CompanyController.php b/Modules/Company/Http/Controllers/CompanyController.php index dcb586a8..36911dc6 100644 --- a/Modules/Company/Http/Controllers/CompanyController.php +++ b/Modules/Company/Http/Controllers/CompanyController.php @@ -16,48 +16,56 @@ class CompanyController extends Controller public function index() { return view('company::index', [ - 'companies' => Company::withCount(['users', 'warehouses', 'licenseKeys'])->latest('id')->paginate(20), + 'companies' => Company::query() + ->withCount(['users', 'warehouses', 'licenseKeys']) + ->latest('id') + ->paginate(20), ]); } /** - * Create a company tenant. + * Show the create company screen. + */ + public function create() + { + return view('company::create', [ + 'company' => new Company([ + 'status' => 'active', + 'timezone' => 'Europe/London', + ]), + ]); + } + + /** + * Store a company tenant. */ public function store(Request $request) { - $data = $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'billing_email' => ['nullable', 'email', 'max:255'], - 'status' => ['required', 'in:active,suspended'], - ]); - - Company::create([ - ...$data, - 'slug' => Str::slug($data['name']), - 'timezone' => 'Europe/London', - ]); - - return back()->with('status', 'Company created.'); - } - - public function create() - { - - return view('company::create', [ - - 'company' => new Company, - - ]); + $company = Company::create($this->validated($request)); + return redirect() + ->route('platform.companies.show', $company) + ->with('status', 'Company created.'); } + /** + * Display a company workbench. + */ public function show(Company $company) { + $company->loadCount(['users', 'warehouses', 'licenseKeys', 'payments']); + return view('company::show', [ 'company' => $company, + 'recentUsers' => $company->users()->latest('id')->limit(5)->get(), + 'recentWarehouses' => $company->warehouses()->latest('id')->limit(5)->get(), + 'recentLicenseKeys' => $company->licenseKeys()->latest('id')->limit(5)->get(), ]); } + /** + * Show the edit company screen. + */ public function edit(Company $company) { return view('company::edit', [ @@ -65,6 +73,9 @@ class CompanyController extends Controller ]); } + /** + * Update a company tenant. + */ public function update(Request $request, Company $company) { $company->update($this->validated($request, $company)); @@ -74,8 +85,13 @@ class CompanyController extends Controller ->with('status', 'Company updated.'); } + /** + * Delete a company tenant. + */ public function destroy(Company $company) { + abort_if($company->is_system_company, 403, 'The system company cannot be deleted.'); + $company->delete(); return redirect() @@ -85,10 +101,16 @@ class CompanyController extends Controller private function validated(Request $request, ?Company $company = null): array { - return $request->validate([ + $data = $request->validate([ 'name' => ['required', 'string', 'max:255'], - 'billing_email' => ['required', 'email', 'max:255'], - 'status' => ['required', Rule::in(['active', 'inactive'])], + 'billing_email' => ['nullable', 'email', 'max:255'], + 'status' => ['required', Rule::in(['active', 'suspended', 'inactive'])], + 'timezone' => ['nullable', 'string', 'max:64'], ]); + + $data['slug'] = Str::slug($data['name']); + $data['timezone'] = $data['timezone'] ?: ($company?->timezone ?: 'Europe/London'); + + return $data; } } diff --git a/Modules/Company/Resources/views/_form.blade.php b/Modules/Company/Resources/views/_form.blade.php index cf989ca7..5ddfb4b0 100644 --- a/Modules/Company/Resources/views/_form.blade.php +++ b/Modules/Company/Resources/views/_form.blade.php @@ -1,47 +1,75 @@ -{{-- Modules/Company/resources/views/companies/_form.blade.php --}} - @csrf -
+
- + - @error('name')

{{ $message }}

@enderror + @error('name') +

{{ $message }}

+ @enderror
- + - @error('billing_email')

{{ $message }}

@enderror + @error('billing_email') +

{{ $message }}

+ @enderror
- - + - @error('status')

{{ $message }}

@enderror + @error('status') +

{{ $message }}

+ @enderror
-
- - - - Cancel - +
+ + + @error('timezone') +

{{ $message }}

+ @enderror
+ +
+ + + + Cancel + +
diff --git a/Modules/Company/Resources/views/create.blade.php b/Modules/Company/Resources/views/create.blade.php index 1f6e641e..a9274e88 100644 --- a/Modules/Company/Resources/views/create.blade.php +++ b/Modules/Company/Resources/views/create.blade.php @@ -1,9 +1,16 @@ -{{-- Modules/Company/resources/views/companies/create.blade.php --}} - - +
+ -
- @include('company::companies._form') -
+
+
+ @include('company::_form') +
+
+
diff --git a/Modules/Company/Resources/views/edit.blade.php b/Modules/Company/Resources/views/edit.blade.php index 42ec6a62..1fd4ea26 100644 --- a/Modules/Company/Resources/views/edit.blade.php +++ b/Modules/Company/Resources/views/edit.blade.php @@ -3,12 +3,12 @@
-
+ @method('PUT') @include('company::_form')
diff --git a/Modules/Company/Resources/views/show.blade.php b/Modules/Company/Resources/views/show.blade.php index 54a1fb18..7feb94e2 100644 --- a/Modules/Company/Resources/views/show.blade.php +++ b/Modules/Company/Resources/views/show.blade.php @@ -1,51 +1,143 @@
-
-
-
-
Billing email
-
{{ $company->billing_email ?? '—' }}
-
+
+
+

Users

+

{{ $company->users_count }}

+
-
-
Status
-
+
+

Warehouses

+

{{ $company->warehouses_count }}

+
+ +
+

Licences

+

{{ $company->license_keys_count }}

+
+ +
+

Payments

+

{{ $company->payments_count }}

+
+
+ +
+
+
+
+

Company profile

+

Core tenant identity and operational status.

+
+ + {{ $company->is_system_company ? 'System' : ucfirst($company->status) }} - +
-
-
Timezone
-
{{ $company->timezone }}
+
+
+
Billing email
+
{{ $company->billing_email ?? '—' }}
+
+ +
+
Timezone
+
{{ $company->timezone }}
+
+ +
+
Slug
+
{{ $company->slug }}
+
+ +
+
Created
+
{{ $company->created_at?->format('d M Y') }}
+
+
+
+ +
+

Quick actions

+
+ + Edit company + + + + Back to companies + + + @unless ($company->is_system_company) +
+ @csrf + @method('DELETE') + +
+ @endunless
+
+
-
-
Slug
-
{{ $company->slug }}
+
+
+

Recent users

+
+ @forelse ($recentUsers as $user) +
+

{{ $user->name }}

+

{{ $user->email }}

+
+ @empty +

No users found.

+ @endforelse
-
+
-
- - Edit - +
+

Recent warehouses

+
+ @forelse ($recentWarehouses as $warehouse) +
+

{{ $warehouse->name }}

+

{{ $warehouse->code ?? '—' }}

+
+ @empty +

No warehouses found.

+ @endforelse +
+
- - Back - +
+

Recent licences

+
+ @forelse ($recentLicenseKeys as $license) +
+

{{ ucfirst($license->plan ?? 'Licence') }}

+

{{ ucfirst($license->status ?? 'unknown') }}

+
+ @empty +

No licences found.

+ @endforelse +
diff --git a/Modules/Company/changelog.yaml b/Modules/Company/changelog.yaml index e69de29b..070ca780 100644 --- a/Modules/Company/changelog.yaml +++ b/Modules/Company/changelog.yaml @@ -0,0 +1,14 @@ +latest: 1.1.0 + +versions: + - version: 1.1.0 + date: 2026-07-02 + changes: + - Added full company create, show, edit, update and delete workflow. + - Added company workbench show page with profile, KPI cards, recent users, warehouses and licences. + - Added guarded deletion so the system company cannot be deleted. + - Standardised Company module views on x-layouts::app and company::_form includes. + - version: 1.0.0 + date: 2026-07-02 + changes: + - Initial Company module skeleton. diff --git a/Modules/Company/module.json b/Modules/Company/module.json index 503c66e5..b11a0fb5 100644 --- a/Modules/Company/module.json +++ b/Modules/Company/module.json @@ -1,7 +1,7 @@ { "name": "Company", "alias": "company", - "version": "1.0.0", + "version": "1.1.0", "description": "", "keywords": [], "priority": 0, diff --git a/Modules/Helpdesk/Tests/Feature/TicketConversationTest.php b/Modules/Helpdesk/Tests/Feature/TicketConversationTest.php index 872890f0..988ccbb2 100644 --- a/Modules/Helpdesk/Tests/Feature/TicketConversationTest.php +++ b/Modules/Helpdesk/Tests/Feature/TicketConversationTest.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Modules\Helpdesk\Tests\Feature; -use Modules\Company\Models\Company +use Modules\Company\Models\Company; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Mail; From 449681cf94710e9da51e66a5d1e66880162d2895 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Thu, 2 Jul 2026 11:36:37 +0100 Subject: [PATCH 31/49] removed unwanted directory --- .../partials/sidebar-support-menu.blade.php | 16 ------- .../partials/wms-support-navigation.blade.php | 48 ------------------- 2 files changed, 64 deletions(-) delete mode 100644 .sidebar-refactor-backup/Modules/Helpdesk/Resources/views/partials/sidebar-support-menu.blade.php delete mode 100644 .sidebar-refactor-backup/resources/views/partials/wms-support-navigation.blade.php diff --git a/.sidebar-refactor-backup/Modules/Helpdesk/Resources/views/partials/sidebar-support-menu.blade.php b/.sidebar-refactor-backup/Modules/Helpdesk/Resources/views/partials/sidebar-support-menu.blade.php deleted file mode 100644 index 8a4447d8..00000000 --- a/.sidebar-refactor-backup/Modules/Helpdesk/Resources/views/partials/sidebar-support-menu.blade.php +++ /dev/null @@ -1,16 +0,0 @@ -@php - $helpdeskEnabled = Route::has('helpdesk.index'); - - if (class_exists(\Nwidart\Modules\Facades\Module::class)) { - $helpdeskModule = \Nwidart\Modules\Facades\Module::find('Helpdesk'); - $helpdeskEnabled = $helpdeskModule !== null && $helpdeskModule->isEnabled() && Route::has('helpdesk.index'); - } -@endphp - -@if ($helpdeskEnabled) - - - {{ __('Support') }} - -@endif diff --git a/.sidebar-refactor-backup/resources/views/partials/wms-support-navigation.blade.php b/.sidebar-refactor-backup/resources/views/partials/wms-support-navigation.blade.php deleted file mode 100644 index 79214cd5..00000000 --- a/.sidebar-refactor-backup/resources/views/partials/wms-support-navigation.blade.php +++ /dev/null @@ -1,48 +0,0 @@ -{{-- Support Portal --}} -@php - $helpdeskEnabled = Route::has('helpdesk.index') || Route::has('helpdesk.tickets.index'); - $documentationEnabled = Route::has('documentation.index') || Route::has('documentation.documents.index'); - - $helpdeskSettingsRoute = Route::has('helpdesk.settings') - ? 'helpdesk.settings' - : (Route::has('helpdesk.settings.index') - ? 'helpdesk.settings.index' - : null); - - $helpdeskGitRoute = Route::has('helpdesk.git.index') ? 'helpdesk.git.index' : null; - - $helpdeskWorkflowRoute = Route::has('helpdesk.workflow-templates.index') - ? 'helpdesk.workflow-templates.index' - : null; - - $documentationDashboardRoute = Route::has('documentation.index') - ? 'documentation.index' - : (Route::has('documentation.documents.index') - ? 'documentation.documents.index' - : null); - - $documentationCreateRoute = Route::has('documentation.documents.create') ? 'documentation.documents.create' : null; - - $documentationCategoriesRoute = Route::has('documentation.categories.index') - ? 'documentation.categories.index' - : null; -@endphp - -@if ($helpdeskEnabled || $documentationEnabled) - @if (Route::has('helpdesk.index')) - - - {{ __('Support') }} - - @endif - - @if ($documentationDashboardRoute) - - - {{ __('Documentation') }} - - @endif -@endif -{{-- Support Portal end --}} From 6be15611ebfc5ed628baa4dc9ee29af364ef8a28 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Thu, 2 Jul 2026 14:29:19 +0100 Subject: [PATCH 32/49] removed unwanted directory --- Modules/Company/Http/Controllers/CompanyController.php | 2 +- Modules/Company/changelog.yaml | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Modules/Company/Http/Controllers/CompanyController.php b/Modules/Company/Http/Controllers/CompanyController.php index 36911dc6..4cdbd808 100644 --- a/Modules/Company/Http/Controllers/CompanyController.php +++ b/Modules/Company/Http/Controllers/CompanyController.php @@ -109,7 +109,7 @@ class CompanyController extends Controller ]); $data['slug'] = Str::slug($data['name']); - $data['timezone'] = $data['timezone'] ?: ($company?->timezone ?: 'Europe/London'); + $data['timezone'] = $data['timezone'] ?? ($company?->timezone ?: 'Europe/London'); return $data; } diff --git a/Modules/Company/changelog.yaml b/Modules/Company/changelog.yaml index 070ca780..ece3cff1 100644 --- a/Modules/Company/changelog.yaml +++ b/Modules/Company/changelog.yaml @@ -12,3 +12,8 @@ versions: date: 2026-07-02 changes: - Initial Company module skeleton. + +- version: 1.0.2 + date: 2026-07-02 + changes: + - Fixed company create/update validation when timezone is omitted from request payloads. From 0e067a15f34b3767d781f10a0150fe445d7b829c Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 3 Jul 2026 10:12:04 +0100 Subject: [PATCH 33/49] added complex address and devisions to company --- ..._120000_create_company_addresses_table.php | 40 ++++ ..._121000_create_company_divisions_table.php | 34 +++ ...000_create_company_division_user_table.php | 30 +++ ...ate_company_director_assignments_table.php | 32 +++ ...00_add_division_id_to_warehouses_table.php | 27 +++ .../Controllers/CompanyAddressController.php | 61 +++++ .../Http/Controllers/CompanyController.php | 18 +- .../Controllers/CompanyDivisionController.php | 54 +++++ .../CompanyMembershipController.php | 83 +++++++ .../CompanyWarehouseAssignmentController.php | 25 ++ Modules/Company/Models/Company.php | 17 ++ Modules/Company/Models/CompanyAddress.php | 54 +++++ .../Models/CompanyDirectorAssignment.php | 34 +++ Modules/Company/Models/CompanyDivision.php | 62 +++++ .../Company/Resources/views/index.blade.php | 7 +- .../views/partials/address-form.blade.php | 47 ++++ .../Company/Resources/views/show.blade.php | 220 +++++++++--------- Modules/Company/Routes/web.php | 28 ++- .../CompanyAdministrationBrowserTest.php | 36 +++ .../CompanyAdministrationRoutesTest.php | 73 ++++++ .../Tests/Feature/CompanyStructureTest.php | 70 ++++++ Modules/Company/changelog.yaml | 19 +- Modules/Company/module.json | 2 +- Modules/Warehouse/app/Models/Warehouse.php | 16 +- app/Models/User.php | 17 ++ 25 files changed, 972 insertions(+), 134 deletions(-) create mode 100644 Modules/Company/Database/Migrations/2026_07_02_120000_create_company_addresses_table.php create mode 100644 Modules/Company/Database/Migrations/2026_07_02_121000_create_company_divisions_table.php create mode 100644 Modules/Company/Database/Migrations/2026_07_02_122000_create_company_division_user_table.php create mode 100644 Modules/Company/Database/Migrations/2026_07_02_123000_create_company_director_assignments_table.php create mode 100644 Modules/Company/Database/Migrations/2026_07_02_124000_add_division_id_to_warehouses_table.php create mode 100644 Modules/Company/Http/Controllers/CompanyAddressController.php create mode 100644 Modules/Company/Http/Controllers/CompanyDivisionController.php create mode 100644 Modules/Company/Http/Controllers/CompanyMembershipController.php create mode 100644 Modules/Company/Http/Controllers/CompanyWarehouseAssignmentController.php create mode 100644 Modules/Company/Models/CompanyAddress.php create mode 100644 Modules/Company/Models/CompanyDirectorAssignment.php create mode 100644 Modules/Company/Models/CompanyDivision.php create mode 100644 Modules/Company/Resources/views/partials/address-form.blade.php create mode 100644 Modules/Company/Tests/Browser/CompanyAdministrationBrowserTest.php create mode 100644 Modules/Company/Tests/Feature/CompanyAdministrationRoutesTest.php create mode 100644 Modules/Company/Tests/Feature/CompanyStructureTest.php diff --git a/Modules/Company/Database/Migrations/2026_07_02_120000_create_company_addresses_table.php b/Modules/Company/Database/Migrations/2026_07_02_120000_create_company_addresses_table.php new file mode 100644 index 00000000..627f7f17 --- /dev/null +++ b/Modules/Company/Database/Migrations/2026_07_02_120000_create_company_addresses_table.php @@ -0,0 +1,40 @@ +id(); + $table->morphs('addressable'); + $table->string('label', 80)->default('Registered office'); + $table->string('attention')->nullable(); + $table->string('line_1'); + $table->string('line_2')->nullable(); + $table->string('line_3')->nullable(); + $table->string('locality')->comment('City, town or locality.'); + $table->string('administrative_area')->nullable()->comment('County, state, province or region.'); + $table->string('postal_code', 32)->nullable(); + $table->foreignId('country_id')->constrained('countries')->restrictOnDelete(); + $table->foreignId('country_subdivision_id')->nullable()->constrained('country_subdivisions')->nullOnDelete(); + $table->decimal('latitude', 10, 7)->nullable(); + $table->decimal('longitude', 10, 7)->nullable(); + $table->boolean('is_primary')->default(true); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->unique(['addressable_type', 'addressable_id'], 'company_addresses_one_per_owner_unique'); + $table->index(['country_id', 'locality']); + $table->index(['postal_code']); + }); + } + + public function down(): void + { + Schema::dropIfExists('company_addresses'); + } +}; diff --git a/Modules/Company/Database/Migrations/2026_07_02_121000_create_company_divisions_table.php b/Modules/Company/Database/Migrations/2026_07_02_121000_create_company_divisions_table.php new file mode 100644 index 00000000..44cad595 --- /dev/null +++ b/Modules/Company/Database/Migrations/2026_07_02_121000_create_company_divisions_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('company_id')->constrained('companies')->cascadeOnDelete(); + $table->uuid('uuid')->unique(); + $table->string('name'); + $table->string('code', 40)->nullable(); + $table->string('slug'); + $table->string('status', 32)->default('active')->index(); + $table->text('description')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['company_id', 'slug']); + $table->unique(['company_id', 'code']); + $table->index(['company_id', 'status', 'name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('company_divisions'); + } +}; diff --git a/Modules/Company/Database/Migrations/2026_07_02_122000_create_company_division_user_table.php b/Modules/Company/Database/Migrations/2026_07_02_122000_create_company_division_user_table.php new file mode 100644 index 00000000..16fed17b --- /dev/null +++ b/Modules/Company/Database/Migrations/2026_07_02_122000_create_company_division_user_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('company_id')->constrained('companies')->cascadeOnDelete(); + $table->foreignId('company_division_id')->constrained('company_divisions')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('role', 80)->default('member')->index(); + $table->boolean('is_director')->default(false)->index(); + $table->timestamps(); + + $table->unique(['company_division_id', 'user_id']); + $table->index(['company_id', 'user_id']); + $table->index(['company_id', 'is_director']); + }); + } + + public function down(): void + { + Schema::dropIfExists('company_division_user'); + } +}; diff --git a/Modules/Company/Database/Migrations/2026_07_02_123000_create_company_director_assignments_table.php b/Modules/Company/Database/Migrations/2026_07_02_123000_create_company_director_assignments_table.php new file mode 100644 index 00000000..c539c515 --- /dev/null +++ b/Modules/Company/Database/Migrations/2026_07_02_123000_create_company_director_assignments_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('company_id')->constrained('companies')->cascadeOnDelete(); + $table->foreignId('company_division_id')->nullable()->constrained('company_divisions')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('coverage_type', 32)->default('company')->index(); + $table->date('starts_at')->nullable(); + $table->date('ends_at')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->unique(['company_id', 'company_division_id', 'user_id'], 'company_director_coverage_unique'); + $table->index(['company_id', 'coverage_type']); + $table->index(['user_id', 'coverage_type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('company_director_assignments'); + } +}; diff --git a/Modules/Company/Database/Migrations/2026_07_02_124000_add_division_id_to_warehouses_table.php b/Modules/Company/Database/Migrations/2026_07_02_124000_add_division_id_to_warehouses_table.php new file mode 100644 index 00000000..b7c52b09 --- /dev/null +++ b/Modules/Company/Database/Migrations/2026_07_02_124000_add_division_id_to_warehouses_table.php @@ -0,0 +1,27 @@ +foreignId('division_id')->nullable()->after('company_id')->constrained('company_divisions')->nullOnDelete(); + $table->index(['company_id', 'division_id']); + } + }); + } + + public function down(): void + { + Schema::table('warehouses', function (Blueprint $table): void { + if (Schema::hasColumn('warehouses', 'division_id')) { + $table->dropConstrainedForeignId('division_id'); + } + }); + } +}; diff --git a/Modules/Company/Http/Controllers/CompanyAddressController.php b/Modules/Company/Http/Controllers/CompanyAddressController.php new file mode 100644 index 00000000..abf17a7a --- /dev/null +++ b/Modules/Company/Http/Controllers/CompanyAddressController.php @@ -0,0 +1,61 @@ +upsertAddress($request, $company); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Company address updated.'); + } + + public function updateDivision(Request $request, Company $company, CompanyDivision $division) + { + abort_unless($division->company_id === $company->id, 404); + + $this->upsertAddress($request, $division, 'Operating address'); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Division address updated.'); + } + + public function updateWarehouse(Request $request, Company $company, Warehouse $warehouse) + { + abort_unless($warehouse->company_id === $company->id, 404); + + $this->upsertAddress($request, $warehouse, 'Warehouse address'); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Warehouse address updated.'); + } + + private function upsertAddress(Request $request, object $owner, string $defaultLabel = 'Registered office'): void + { + $data = $request->validate([ + 'label' => ['nullable', 'string', 'max:80'], + 'attention' => ['nullable', 'string', 'max:255'], + 'line_1' => ['required', 'string', 'max:255'], + 'line_2' => ['nullable', 'string', 'max:255'], + 'line_3' => ['nullable', 'string', 'max:255'], + 'locality' => ['required', 'string', 'max:255'], + 'administrative_area' => ['nullable', 'string', 'max:255'], + 'postal_code' => ['nullable', 'string', 'max:32'], + 'country_id' => ['required', Rule::exists('countries', 'id')->where('is_active', true)], + 'country_subdivision_id' => ['nullable', 'exists:country_subdivisions,id'], + 'latitude' => ['nullable', 'numeric', 'between:-90,90'], + 'longitude' => ['nullable', 'numeric', 'between:-180,180'], + ]); + + $data['label'] = $data['label'] ?: $defaultLabel; + $data['is_primary'] = true; + + $owner->address()->updateOrCreate([], $data); + } +} diff --git a/Modules/Company/Http/Controllers/CompanyController.php b/Modules/Company/Http/Controllers/CompanyController.php index 4cdbd808..9a27f2e0 100644 --- a/Modules/Company/Http/Controllers/CompanyController.php +++ b/Modules/Company/Http/Controllers/CompanyController.php @@ -7,6 +7,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Modules\Company\Models\Company; +use Modules\Countries\Models\Country; class CompanyController extends Controller { @@ -17,7 +18,8 @@ class CompanyController extends Controller { return view('company::index', [ 'companies' => Company::query() - ->withCount(['users', 'warehouses', 'licenseKeys']) + ->with(['address.country']) + ->withCount(['users', 'warehouses', 'licenseKeys', 'divisions']) ->latest('id') ->paginate(20), ]); @@ -53,13 +55,25 @@ class CompanyController extends Controller */ public function show(Company $company) { - $company->loadCount(['users', 'warehouses', 'licenseKeys', 'payments']); + $company->load([ + 'address.country', + 'divisions.address.country', + 'divisions.warehouses', + 'divisions.users', + 'warehouses.address.country', + 'warehouses.division', + 'directorAssignments.user', + 'directorAssignments.division', + ])->loadCount(['users', 'warehouses', 'licenseKeys', 'payments', 'divisions']); return view('company::show', [ 'company' => $company, 'recentUsers' => $company->users()->latest('id')->limit(5)->get(), 'recentWarehouses' => $company->warehouses()->latest('id')->limit(5)->get(), 'recentLicenseKeys' => $company->licenseKeys()->latest('id')->limit(5)->get(), + 'countries' => Country::query()->active()->orderBy('sort_order')->orderBy('name')->get(['id', 'alpha2', 'name', 'display_name', 'common_name']), + 'companyUsers' => $company->users()->orderBy('name')->get(['id', 'name', 'email', 'company_id']), + 'unassignedWarehouses' => $company->warehouses()->whereNull('division_id')->orderBy('name')->get(), ]); } diff --git a/Modules/Company/Http/Controllers/CompanyDivisionController.php b/Modules/Company/Http/Controllers/CompanyDivisionController.php new file mode 100644 index 00000000..baf3b8ed --- /dev/null +++ b/Modules/Company/Http/Controllers/CompanyDivisionController.php @@ -0,0 +1,54 @@ +divisions()->create($this->validated($request, $company)); + + return redirect()->route('platform.companies.show', $company)->with('status', "Division {$division->name} created."); + } + + public function update(Request $request, Company $company, CompanyDivision $division) + { + abort_unless($division->company_id === $company->id, 404); + + $division->update($this->validated($request, $company, $division)); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Division updated.'); + } + + public function destroy(Company $company, CompanyDivision $division) + { + abort_unless($division->company_id === $company->id, 404); + + abort_if($division->warehouses()->exists(), 422, 'Move warehouses before deleting this division.'); + + $division->delete(); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Division deleted.'); + } + + private function validated(Request $request, Company $company, ?CompanyDivision $division = null): array + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'code' => ['nullable', 'string', 'max:40', Rule::unique('company_divisions', 'code')->where('company_id', $company->id)->ignore($division?->id)], + 'status' => ['required', Rule::in(['active', 'suspended', 'inactive'])], + 'description' => ['nullable', 'string', 'max:2000'], + ]); + + $data['slug'] = Str::slug($data['name']); + + return $data; + } +} diff --git a/Modules/Company/Http/Controllers/CompanyMembershipController.php b/Modules/Company/Http/Controllers/CompanyMembershipController.php new file mode 100644 index 00000000..7ce6d23c --- /dev/null +++ b/Modules/Company/Http/Controllers/CompanyMembershipController.php @@ -0,0 +1,83 @@ +validate([ + 'user_id' => ['required', Rule::exists('users', 'id')->where('company_id', $company->id)], + 'company_division_id' => ['required', Rule::exists('company_divisions', 'id')->where('company_id', $company->id)], + 'role' => ['nullable', 'string', 'max:80'], + 'is_director' => ['nullable', 'boolean'], + ]); + + $division = CompanyDivision::where('company_id', $company->id)->findOrFail($data['company_division_id']); + $division->users()->syncWithoutDetaching([ + $data['user_id'] => [ + 'company_id' => $company->id, + 'role' => $data['role'] ?? 'member', + 'is_director' => (bool) ($data['is_director'] ?? false), + ], + ]); + + if ((bool) ($data['is_director'] ?? false)) { + CompanyDirectorAssignment::updateOrCreate([ + 'company_id' => $company->id, + 'company_division_id' => $division->id, + 'user_id' => $data['user_id'], + ], ['coverage_type' => 'division']); + } + + return redirect()->route('platform.companies.show', $company)->with('status', 'Division membership updated.'); + } + + public function assignDirector(Request $request, Company $company) + { + $data = $request->validate([ + 'user_id' => ['required', Rule::exists('users', 'id')->where('company_id', $company->id)], + 'coverage_type' => ['required', Rule::in(['company', 'division'])], + 'company_division_id' => ['nullable', Rule::exists('company_divisions', 'id')->where('company_id', $company->id)], + 'starts_at' => ['nullable', 'date'], + 'ends_at' => ['nullable', 'date', 'after_or_equal:starts_at'], + 'notes' => ['nullable', 'string', 'max:2000'], + ]); + + if ($data['coverage_type'] === 'company') { + $data['company_division_id'] = null; + } else { + abort_if(empty($data['company_division_id']), 422, 'Division coverage requires a division.'); + } + + CompanyDirectorAssignment::updateOrCreate([ + 'company_id' => $company->id, + 'company_division_id' => $data['company_division_id'], + 'user_id' => $data['user_id'], + ], [ + 'coverage_type' => $data['coverage_type'], + 'starts_at' => $data['starts_at'] ?? null, + 'ends_at' => $data['ends_at'] ?? null, + 'notes' => $data['notes'] ?? null, + ]); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Director coverage updated.'); + } + + public function removeDirector(Company $company, CompanyDirectorAssignment $assignment) + { + abort_unless($assignment->company_id === $company->id, 404); + + $assignment->delete(); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Director coverage removed.'); + } +} diff --git a/Modules/Company/Http/Controllers/CompanyWarehouseAssignmentController.php b/Modules/Company/Http/Controllers/CompanyWarehouseAssignmentController.php new file mode 100644 index 00000000..194be483 --- /dev/null +++ b/Modules/Company/Http/Controllers/CompanyWarehouseAssignmentController.php @@ -0,0 +1,25 @@ +company_id === $company->id, 404); + + $data = $request->validate([ + 'division_id' => ['nullable', Rule::exists('company_divisions', 'id')->where('company_id', $company->id)], + ]); + + $warehouse->update(['division_id' => $data['division_id'] ?? null]); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Warehouse division assignment updated.'); + } +} diff --git a/Modules/Company/Models/Company.php b/Modules/Company/Models/Company.php index 3457f8a3..0a3bb73e 100644 --- a/Modules/Company/Models/Company.php +++ b/Modules/Company/Models/Company.php @@ -5,6 +5,7 @@ namespace Modules\Company\Models; use App\Models\User; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Str; use Modules\Platform\app\Models\LicenseKey; @@ -35,6 +36,22 @@ class Company extends Model }); } + + public function address(): MorphOne + { + return $this->morphOne(CompanyAddress::class, 'addressable'); + } + + public function divisions(): HasMany + { + return $this->hasMany(CompanyDivision::class); + } + + public function directorAssignments(): HasMany + { + return $this->hasMany(CompanyDirectorAssignment::class); + } + public function users(): HasMany { return $this->hasMany(User::class); diff --git a/Modules/Company/Models/CompanyAddress.php b/Modules/Company/Models/CompanyAddress.php new file mode 100644 index 00000000..1345e8fe --- /dev/null +++ b/Modules/Company/Models/CompanyAddress.php @@ -0,0 +1,54 @@ + 'boolean', + 'metadata' => 'array', + 'latitude' => 'decimal:7', + 'longitude' => 'decimal:7', + ]; + + public function addressable(): MorphTo + { + return $this->morphTo(); + } + + public function country(): BelongsTo + { + return $this->belongsTo(Country::class); + } + + public function subdivision(): BelongsTo + { + return $this->belongsTo(CountrySubdivision::class, 'country_subdivision_id'); + } + + public function getFormattedAttribute(): string + { + return collect([ + $this->attention, + $this->line_1, + $this->line_2, + $this->line_3, + $this->locality, + $this->administrative_area, + $this->postal_code, + $this->country?->effective_name ?? $this->country?->name, + ])->filter()->implode(', '); + } +} diff --git a/Modules/Company/Models/CompanyDirectorAssignment.php b/Modules/Company/Models/CompanyDirectorAssignment.php new file mode 100644 index 00000000..9c2923ea --- /dev/null +++ b/Modules/Company/Models/CompanyDirectorAssignment.php @@ -0,0 +1,34 @@ + 'date', + 'ends_at' => 'date', + ]; + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + public function division(): BelongsTo + { + return $this->belongsTo(CompanyDivision::class, 'company_division_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/Modules/Company/Models/CompanyDivision.php b/Modules/Company/Models/CompanyDivision.php new file mode 100644 index 00000000..20ea2f29 --- /dev/null +++ b/Modules/Company/Models/CompanyDivision.php @@ -0,0 +1,62 @@ + 'array', + ]; + + protected static function booted(): void + { + static::creating(function (CompanyDivision $division): void { + $division->uuid ??= (string) Str::uuid(); + $division->slug ??= Str::slug($division->name); + $division->status ??= 'active'; + }); + } + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + public function address(): MorphOne + { + return $this->morphOne(CompanyAddress::class, 'addressable'); + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'company_division_user') + ->withPivot(['company_id', 'role', 'is_director']) + ->withTimestamps(); + } + + public function warehouses(): HasMany + { + return $this->hasMany(Warehouse::class, 'division_id'); + } + + public function directorAssignments(): HasMany + { + return $this->hasMany(CompanyDirectorAssignment::class, 'company_division_id'); + } +} diff --git a/Modules/Company/Resources/views/index.blade.php b/Modules/Company/Resources/views/index.blade.php index 357d551d..e6754a13 100644 --- a/Modules/Company/Resources/views/index.blade.php +++ b/Modules/Company/Resources/views/index.blade.php @@ -22,6 +22,7 @@ Name Users + Divisions Warehouses Licences Status @@ -40,6 +41,10 @@ {{ $company->users_count }} + + {{ $company->divisions_count }} + + {{ $company->warehouses_count }} @@ -100,7 +105,7 @@ @empty - + No companies found. diff --git a/Modules/Company/Resources/views/partials/address-form.blade.php b/Modules/Company/Resources/views/partials/address-form.blade.php new file mode 100644 index 00000000..9f9cb438 --- /dev/null +++ b/Modules/Company/Resources/views/partials/address-form.blade.php @@ -0,0 +1,47 @@ +@php($address = $address ?? null) +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
diff --git a/Modules/Company/Resources/views/show.blade.php b/Modules/Company/Resources/views/show.blade.php index 7feb94e2..d489c447 100644 --- a/Modules/Company/Resources/views/show.blade.php +++ b/Modules/Company/Resources/views/show.blade.php @@ -1,144 +1,140 @@
- + -
-
-

Users

-

{{ $company->users_count }}

-
- -
-

Warehouses

-

{{ $company->warehouses_count }}

-
- -
-

Licences

-

{{ $company->license_keys_count }}

-
- -
-

Payments

-

{{ $company->payments_count }}

-
+
+

Users

{{ $company->users_count }}

+

Divisions

{{ $company->divisions_count }}

+

Warehouses

{{ $company->warehouses_count }}

+

Licences

{{ $company->license_keys_count }}

+

Payments

{{ $company->payments_count }}

-
-

Company profile

-

Core tenant identity and operational status.

-
- - - {{ $company->is_system_company ? 'System' : ucfirst($company->status) }} - +

Company profile

Core tenant identity and operational status.

+ Edit company
-
-
-
Billing email
-
{{ $company->billing_email ?? '—' }}
-
- -
-
Timezone
-
{{ $company->timezone }}
-
- -
-
Slug
-
{{ $company->slug }}
-
- -
-
Created
-
{{ $company->created_at?->format('d M Y') }}
-
+
Billing email
{{ $company->billing_email ?? '—' }}
+
Status
{{ $company->is_system_company ? 'System' : ucfirst($company->status) }}
+
Timezone
{{ $company->timezone }}
+
Slug
{{ $company->slug }}

Quick actions

- - Edit company - - - - Back to companies - - + Back to companies @unless ($company->is_system_company) -
- @csrf - @method('DELETE') - -
+
@csrf @method('DELETE')
@endunless
-
+
+

Company address

+ @if ($company->address) +

{{ $company->address->formatted }}

+ @else +

No company address has been recorded.

+ @endif +
+ @csrf @method('PUT') + @include('company::partials.address-form', ['address' => $company->address, 'defaultLabel' => 'Registered office']) +
+
+ +
-

Recent users

-
- @forelse ($recentUsers as $user) -
-

{{ $user->name }}

-

{{ $user->email }}

-
- @empty -

No users found.

- @endforelse -
+

Create division

+
+ @csrf + + + + + +
-

Recent warehouses

+

Director coverage

+
+ @csrf + + + + +
- @forelse ($recentWarehouses as $warehouse) -
-

{{ $warehouse->name }}

-

{{ $warehouse->code ?? '—' }}

-
- @empty -

No warehouses found.

- @endforelse -
-
- -
-

Recent licences

-
- @forelse ($recentLicenseKeys as $license) -
-

{{ ucfirst($license->plan ?? 'Licence') }}

-

{{ ucfirst($license->status ?? 'unknown') }}

-
- @empty -

No licences found.

- @endforelse + @forelse($company->directorAssignments as $assignment) +
{{ $assignment->user?->name }} — {{ $assignment->coverage_type === 'company' ? 'Whole company' : $assignment->division?->name }}
@csrf @method('DELETE')
+ @empty

No director coverage recorded.

@endforelse
+ + +
+

Warehouse division and address alignment

+

Warehouses remain tenant-scoped to the company. Assign a division only when that operating structure exists and record an optional warehouse operating address where needed.

+
+ @forelse($company->warehouses as $warehouse) +
+
+
+

{{ $warehouse->name }}

+

{{ $warehouse->code }} · {{ $warehouse->division?->name ?? 'Company-level warehouse' }}

+ @if ($warehouse->address) +

{{ $warehouse->address->formatted }}

+ @else +

No warehouse address recorded.

+ @endif +
+
+ @csrf @method('PUT') + + +
+
+ +
+ Edit warehouse address +
+ @csrf @method('PUT') + @include('company::partials.address-form', ['address' => $warehouse->address, 'defaultLabel' => 'Warehouse address']) +
+
+
+ @empty +

No warehouses exist for this company yet.

+ @endforelse +
+
+ +
+

Divisions

+ @forelse($company->divisions as $division) +
+
+

{{ $division->name }}

{{ $division->code ?? 'No code' }} · {{ ucfirst($division->status) }} · {{ $division->warehouses->count() }} warehouses

+
@csrf @method('DELETE')
+
+
@csrf @method('PUT')
+

Division address

@csrf @method('PUT')@include('company::partials.address-form', ['address' => $division->address, 'defaultLabel' => 'Operating address'])
+
+ @empty +
No divisions exist. Warehouses can remain directly under the company until divisions are introduced.
+ @endforelse +
diff --git a/Modules/Company/Routes/web.php b/Modules/Company/Routes/web.php index cc7e8e11..ef9d49f6 100644 --- a/Modules/Company/Routes/web.php +++ b/Modules/Company/Routes/web.php @@ -1,21 +1,27 @@ prefix('admin') ->name('platform.') ->group(function () { Route::resource('companies', CompanyController::class); + + Route::put('companies/{company}/address', [CompanyAddressController::class, 'updateCompany'])->name('companies.address.update'); + Route::post('companies/{company}/divisions', [CompanyDivisionController::class, 'store'])->name('companies.divisions.store'); + Route::put('companies/{company}/divisions/{division}', [CompanyDivisionController::class, 'update'])->name('companies.divisions.update'); + Route::delete('companies/{company}/divisions/{division}', [CompanyDivisionController::class, 'destroy'])->name('companies.divisions.destroy'); + Route::put('companies/{company}/divisions/{division}/address', [CompanyAddressController::class, 'updateDivision'])->name('companies.divisions.address.update'); + + Route::post('companies/{company}/division-memberships', [CompanyMembershipController::class, 'assignDivision'])->name('companies.division-memberships.store'); + Route::post('companies/{company}/director-assignments', [CompanyMembershipController::class, 'assignDirector'])->name('companies.director-assignments.store'); + Route::delete('companies/{company}/director-assignments/{assignment}', [CompanyMembershipController::class, 'removeDirector'])->name('companies.director-assignments.destroy'); + + Route::put('companies/{company}/warehouses/{warehouse}/division', [CompanyWarehouseAssignmentController::class, 'update'])->name('companies.warehouses.division.update'); + Route::put('companies/{company}/warehouses/{warehouse}/address', [CompanyAddressController::class, 'updateWarehouse'])->name('companies.warehouses.address.update'); }); diff --git a/Modules/Company/Tests/Browser/CompanyAdministrationBrowserTest.php b/Modules/Company/Tests/Browser/CompanyAdministrationBrowserTest.php new file mode 100644 index 00000000..7700a8b3 --- /dev/null +++ b/Modules/Company/Tests/Browser/CompanyAdministrationBrowserTest.php @@ -0,0 +1,36 @@ +firstOrCreate(['slug' => 'mpba-limited'], ['name' => 'MPBA Limited', 'is_system_company' => true, 'status' => 'active']); + $tenant = Company::query()->firstOrCreate(['slug' => 'sentinel'], ['name' => 'Sentinel', 'status' => 'active']); + Country::query()->firstOrCreate(['alpha2' => 'GB'], ['alpha3' => 'GBR', 'numeric' => '826', 'name' => 'United Kingdom', 'is_active' => true]); + $admin = User::query()->firstOrCreate(['email' => 'company-admin-browser@example.test'], [ + 'company_id' => $system->id, + 'name' => 'Company Browser Admin', + 'password' => bcrypt('password'), + 'is_platform_admin' => true, + 'status' => 'active', + ]); + + $this->browse(function (Browser $browser) use ($admin, $tenant): void { + $browser->loginAs($admin) + ->visitRoute('platform.companies.show', $tenant) + ->assertSee('Company address') + ->assertSee('Create division') + ->assertSee('Director coverage') + ->assertSee('Warehouse division and address alignment') + ->assertSee('Divisions'); + }); + } +} diff --git a/Modules/Company/Tests/Feature/CompanyAdministrationRoutesTest.php b/Modules/Company/Tests/Feature/CompanyAdministrationRoutesTest.php new file mode 100644 index 00000000..e4666c5a --- /dev/null +++ b/Modules/Company/Tests/Feature/CompanyAdministrationRoutesTest.php @@ -0,0 +1,73 @@ + 'MPBA Limited', 'is_system_company' => true]); + $tenant = Company::create(['name' => 'Sentinel', 'billing_email' => 'sentinel@example.test']); + $country = Country::create(['alpha2' => 'GB', 'alpha3' => 'GBR', 'numeric' => '826', 'name' => 'United Kingdom', 'is_active' => true]); + $admin = User::create(['company_id' => $system->id, 'name' => 'Platform Admin', 'email' => 'admin@example.test', 'password' => 'password', 'is_platform_admin' => true]); + $director = User::create(['company_id' => $tenant->id, 'name' => 'Division Director', 'email' => 'director@example.test', 'password' => 'password']); + + $this->actingAs($admin)->put(route('platform.companies.address.update', $tenant), [ + 'line_1' => '1 Example Street', + 'locality' => 'London', + 'postal_code' => 'SW1A 1AA', + 'country_id' => $country->id, + ])->assertRedirect(route('platform.companies.show', $tenant)); + + $this->actingAs($admin)->post(route('platform.companies.divisions.store', $tenant), [ + 'name' => 'Northern Operations', + 'code' => 'NORTH', + 'status' => 'active', + ])->assertRedirect(route('platform.companies.show', $tenant)); + + $division = $tenant->divisions()->where('code', 'NORTH')->firstOrFail(); + + $this->actingAs($admin)->post(route('platform.companies.director-assignments.store', $tenant), [ + 'user_id' => $director->id, + 'coverage_type' => 'division', + 'company_division_id' => $division->id, + ])->assertRedirect(route('platform.companies.show', $tenant)); + + $this->assertDatabaseHas('company_addresses', ['line_1' => '1 Example Street', 'country_id' => $country->id]); + $this->assertDatabaseHas('company_divisions', ['company_id' => $tenant->id, 'code' => 'NORTH']); + $this->assertDatabaseHas('company_director_assignments', ['company_id' => $tenant->id, 'company_division_id' => $division->id, 'user_id' => $director->id]); + } + public function test_platform_admin_can_update_warehouse_address(): void + { + $system = Company::create(['name' => 'MPBA Limited', 'is_system_company' => true]); + $tenant = Company::create(['name' => 'Sentinel', 'billing_email' => 'sentinel@example.test']); + $country = Country::create(['alpha2' => 'GB', 'alpha3' => 'GBR', 'numeric' => '826', 'name' => 'United Kingdom', 'is_active' => true]); + $warehouse = Warehouse::create(['company_id' => $tenant->id, 'code' => 'MAIN', 'name' => 'Main Warehouse']); + $admin = User::create(['company_id' => $system->id, 'name' => 'Platform Admin', 'email' => 'warehouse-admin@example.test', 'password' => 'password', 'is_platform_admin' => true]); + + $this->actingAs($admin)->put(route('platform.companies.warehouses.address.update', [$tenant, $warehouse]), [ + 'label' => 'Warehouse address', + 'line_1' => '100 Distribution Park', + 'locality' => 'Birmingham', + 'postal_code' => 'B1 1AA', + 'country_id' => $country->id, + ])->assertRedirect(route('platform.companies.show', $tenant)); + + $this->assertDatabaseHas('company_addresses', [ + 'addressable_type' => Warehouse::class, + 'addressable_id' => $warehouse->id, + 'line_1' => '100 Distribution Park', + 'country_id' => $country->id, + ]); + } + +} diff --git a/Modules/Company/Tests/Feature/CompanyStructureTest.php b/Modules/Company/Tests/Feature/CompanyStructureTest.php new file mode 100644 index 00000000..c6e14706 --- /dev/null +++ b/Modules/Company/Tests/Feature/CompanyStructureTest.php @@ -0,0 +1,70 @@ + 'Abbott Logistics Limited', 'billing_email' => 'billing@abbott.example']); + $country = Country::create(['alpha2' => 'GB', 'alpha3' => 'GBR', 'numeric' => '826', 'name' => 'United Kingdom', 'is_active' => true]); + + $company->address()->create([ + 'line_1' => '1 Warehouse Way', + 'locality' => 'London', + 'postal_code' => 'SW1A 1AA', + 'country_id' => $country->id, + ]); + + $company->address()->update(['line_1' => '2 Warehouse Way']); + + $this->assertSame(1, CompanyAddress::count()); + $this->assertSame('2 Warehouse Way', $company->fresh()->address->line_1); + $this->assertSame('United Kingdom', $company->fresh()->address->country->name); + } + + public function test_company_division_has_address_users_directors_and_optional_warehouses(): void + { + $company = Company::create(['name' => 'Sentinel', 'billing_email' => 'sentinel@example.test']); + $country = Country::create(['alpha2' => 'GB', 'alpha3' => 'GBR', 'numeric' => '826', 'name' => 'United Kingdom', 'is_active' => true]); + $user = User::create(['company_id' => $company->id, 'name' => 'Jane Director', 'email' => 'jane@example.test', 'password' => 'password']); + $division = $company->divisions()->create(['name' => 'North Operations', 'code' => 'NORTH']); + $warehouse = Warehouse::create(['company_id' => $company->id, 'division_id' => $division->id, 'code' => 'NTH', 'name' => 'North DC']); + + $division->address()->create([ + 'label' => 'Operating address', + 'line_1' => 'North Road', + 'locality' => 'Manchester', + 'country_id' => $country->id, + ]); + $division->users()->attach($user->id, ['company_id' => $company->id, 'role' => 'director', 'is_director' => true]); + CompanyDirectorAssignment::create(['company_id' => $company->id, 'company_division_id' => $division->id, 'user_id' => $user->id, 'coverage_type' => 'division']); + + $this->assertTrue($company->fresh()->divisions()->exists()); + $this->assertSame('North Operations', $warehouse->fresh()->division->name); + $this->assertSame('Manchester', $division->fresh()->address->locality); + $this->assertTrue($user->fresh()->divisions()->whereKey($division->id)->exists()); + $this->assertTrue($user->fresh()->directorAssignments()->where('company_division_id', $division->id)->exists()); + } + + public function test_warehouse_can_remain_at_company_level_without_division(): void + { + $company = Company::create(['name' => 'Company Level Warehousing']); + $warehouse = Warehouse::create(['company_id' => $company->id, 'code' => 'MAIN', 'name' => 'Main Warehouse']); + + $this->assertNull($warehouse->division_id); + $this->assertSame('Company Level Warehousing', $warehouse->company->name); + } +} diff --git a/Modules/Company/changelog.yaml b/Modules/Company/changelog.yaml index ece3cff1..55cc3e18 100644 --- a/Modules/Company/changelog.yaml +++ b/Modules/Company/changelog.yaml @@ -1,6 +1,14 @@ -latest: 1.1.0 +latest: 1.2.0 versions: + - version: 1.2.0 + date: 2026-07-02 + changes: + - Added one-address-per-owner company address records using an industry-standard address layout backed by the Countries module. + - Added company divisions with optional codes, status, soft deletes, one address per division, user memberships and director coverage. + - Added optional division assignment and proper address relationship support for warehouses. + - Added Company module migrations with foreign keys, unique constraints and performance indexes. + - Added feature and Dusk coverage for company addresses, divisions, director coverage and warehouse division behaviour. - version: 1.1.0 date: 2026-07-02 changes: @@ -8,12 +16,11 @@ versions: - Added company workbench show page with profile, KPI cards, recent users, warehouses and licences. - Added guarded deletion so the system company cannot be deleted. - Standardised Company module views on x-layouts::app and company::_form includes. + - version: 1.0.2 + date: 2026-07-02 + changes: + - Fixed company create/update validation when timezone is omitted from request payloads. - version: 1.0.0 date: 2026-07-02 changes: - Initial Company module skeleton. - -- version: 1.0.2 - date: 2026-07-02 - changes: - - Fixed company create/update validation when timezone is omitted from request payloads. diff --git a/Modules/Company/module.json b/Modules/Company/module.json index b11a0fb5..1689228d 100644 --- a/Modules/Company/module.json +++ b/Modules/Company/module.json @@ -1,7 +1,7 @@ { "name": "Company", "alias": "company", - "version": "1.1.0", + "version": "1.2.0", "description": "", "keywords": [], "priority": 0, diff --git a/Modules/Warehouse/app/Models/Warehouse.php b/Modules/Warehouse/app/Models/Warehouse.php index 361ec75c..911211b2 100644 --- a/Modules/Warehouse/app/Models/Warehouse.php +++ b/Modules/Warehouse/app/Models/Warehouse.php @@ -5,13 +5,16 @@ namespace Modules\Warehouse\app\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Support\Str; use Modules\Company\Models\Company; +use Modules\Company\Models\CompanyAddress; +use Modules\Company\Models\CompanyDivision; class Warehouse extends Model { protected $fillable = [ - 'company_id', 'uuid', + 'company_id', 'division_id', 'uuid', 'code', 'name', 'status', @@ -38,6 +41,17 @@ class Warehouse extends Model return $this->belongsTo(Company::class); } + + public function division(): BelongsTo + { + return $this->belongsTo(CompanyDivision::class, 'division_id'); + } + + public function address(): MorphOne + { + return $this->morphOne(CompanyAddress::class, 'addressable'); + } + public function validationRules(): HasMany { return $this->hasMany(WarehouseValidationRule::class); diff --git a/app/Models/User.php b/app/Models/User.php index 627681d5..0d0f2862 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,6 +5,8 @@ namespace App\Models; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; @@ -12,6 +14,8 @@ use Laravel\Fortify\Contracts\PasskeyUser; use Laravel\Fortify\PasskeyAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable; use Modules\Company\Models\Company; +use Modules\Company\Models\CompanyDirectorAssignment; +use Modules\Company\Models\CompanyDivision; use Spatie\Permission\Traits\HasRoles; class User extends Authenticatable implements PasskeyUser @@ -40,6 +44,19 @@ class User extends Authenticatable implements PasskeyUser return $this->belongsTo(Company::class); } + + public function divisions(): BelongsToMany + { + return $this->belongsToMany(CompanyDivision::class, 'company_division_user') + ->withPivot(['company_id', 'role', 'is_director']) + ->withTimestamps(); + } + + public function directorAssignments(): HasMany + { + return $this->hasMany(CompanyDirectorAssignment::class); + } + public function initials(): string { return Str::of($this->name) From bfc2e136a2552558eb335b37853f6139e19b640e Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 3 Jul 2026 10:42:44 +0100 Subject: [PATCH 34/49] added complex address and devisions to company, fixed tests --- .../Http/Controllers/CompanyController.php | 2 +- .../Company/Resources/views/index.blade.php | 67 ++++++++++++++++--- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/Modules/Company/Http/Controllers/CompanyController.php b/Modules/Company/Http/Controllers/CompanyController.php index 9a27f2e0..5bd16514 100644 --- a/Modules/Company/Http/Controllers/CompanyController.php +++ b/Modules/Company/Http/Controllers/CompanyController.php @@ -46,7 +46,7 @@ class CompanyController extends Controller $company = Company::create($this->validated($request)); return redirect() - ->route('platform.companies.show', $company) + ->route('platform.companies.index') ->with('status', 'Company created.'); } diff --git a/Modules/Company/Resources/views/index.blade.php b/Modules/Company/Resources/views/index.blade.php index e6754a13..8433fee2 100644 --- a/Modules/Company/Resources/views/index.blade.php +++ b/Modules/Company/Resources/views/index.blade.php @@ -7,15 +7,66 @@ icon="fa-solid fa-building" /> - + + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + +
+
+
+

Create company

+

Add a new tenant without leaving the administration index.

+
+ + + Open full form + +
+ +
+ @csrf + + + + + + + + + + +
+
From 346cd2af1e8d260eab1cf112ca5341bcdb734e49 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 7 Jul 2026 10:08:15 +0100 Subject: [PATCH 35/49] Country / Company updates --- ...3000_add_country_id_to_companies_table.php | 26 + .../Http/Controllers/CompanyController.php | 141 ++ Company/Models/Company.php | 100 ++ Company/Resources/views/_form.blade.php | 83 + Company/Resources/views/index.blade.php | 174 ++ ...3000_add_country_id_to_companies_table.php | 26 + .../Http/Controllers/CompanyController.php | 19 +- Modules/Company/Models/Company.php | 10 +- .../Company/Resources/views/_form.blade.php | 22 +- .../Company/Resources/views/index.blade.php | 16 +- css/app.css | 1531 +++++++++++++++++ js/app.js | 222 +++ resources/css/app.css | 120 ++ resources/js/app.js | 3 +- 14 files changed, 2474 insertions(+), 19 deletions(-) create mode 100644 Company/Database/Migrations/2026_07_07_093000_add_country_id_to_companies_table.php create mode 100644 Company/Http/Controllers/CompanyController.php create mode 100644 Company/Models/Company.php create mode 100644 Company/Resources/views/_form.blade.php create mode 100644 Company/Resources/views/index.blade.php create mode 100644 Modules/Company/Database/Migrations/2026_07_07_093000_add_country_id_to_companies_table.php create mode 100644 css/app.css create mode 100644 js/app.js diff --git a/Company/Database/Migrations/2026_07_07_093000_add_country_id_to_companies_table.php b/Company/Database/Migrations/2026_07_07_093000_add_country_id_to_companies_table.php new file mode 100644 index 00000000..0d0a61f0 --- /dev/null +++ b/Company/Database/Migrations/2026_07_07_093000_add_country_id_to_companies_table.php @@ -0,0 +1,26 @@ +foreignId('country_id') + ->nullable() + ->after('billing_email') + ->constrained('countries') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('companies', function (Blueprint $table): void { + $table->dropConstrainedForeignId('country_id'); + }); + } +}; diff --git a/Company/Http/Controllers/CompanyController.php b/Company/Http/Controllers/CompanyController.php new file mode 100644 index 00000000..0880b292 --- /dev/null +++ b/Company/Http/Controllers/CompanyController.php @@ -0,0 +1,141 @@ + Company::query() + ->with(['address.country', 'country']) + ->withCount(['users', 'warehouses', 'licenseKeys', 'divisions']) + ->latest('id') + ->paginate(20), + 'countries' => $this->countries(), + ]); + } + + /** + * Show the create company screen. + */ + public function create() + { + return view('company::create', [ + 'company' => new Company([ + 'status' => 'active', + 'timezone' => 'Europe/London', + ]), + 'countries' => $this->countries(), + ]); + } + + /** + * Store a company tenant. + */ + public function store(Request $request) + { + $company = Company::create($this->validated($request)); + + return redirect() + ->route('platform.companies.index') + ->with('status', 'Company created.'); + } + + /** + * Display a company workbench. + */ + public function show(Company $company) + { + $company->load([ + 'address.country', + 'divisions.address.country', + 'divisions.warehouses', + 'divisions.users', + 'warehouses.address.country', + 'warehouses.division', + 'directorAssignments.user', + 'directorAssignments.division', + ])->loadCount(['users', 'warehouses', 'licenseKeys', 'payments', 'divisions']); + + return view('company::show', [ + 'company' => $company, + 'recentUsers' => $company->users()->latest('id')->limit(5)->get(), + 'recentWarehouses' => $company->warehouses()->latest('id')->limit(5)->get(), + 'recentLicenseKeys' => $company->licenseKeys()->latest('id')->limit(5)->get(), + 'countries' => Country::query()->active()->orderBy('sort_order')->orderBy('name')->get(['id', 'alpha2', 'name', 'display_name', 'common_name']), + 'companyUsers' => $company->users()->orderBy('name')->get(['id', 'name', 'email', 'company_id']), + 'unassignedWarehouses' => $company->warehouses()->whereNull('division_id')->orderBy('name')->get(), + ]); + } + + /** + * Show the edit company screen. + */ + public function edit(Company $company) + { + return view('company::edit', [ + 'company' => $company->loadMissing(['address.country', 'country']), + 'countries' => $this->countries(), + ]); + } + + /** + * Update a company tenant. + */ + public function update(Request $request, Company $company) + { + $company->update($this->validated($request, $company)); + + return redirect() + ->route('platform.companies.show', $company) + ->with('status', 'Company updated.'); + } + + /** + * Delete a company tenant. + */ + public function destroy(Company $company) + { + abort_if($company->is_system_company, 403, 'The system company cannot be deleted.'); + + $company->delete(); + + return redirect() + ->route('platform.companies.index') + ->with('status', 'Company deleted.'); + } + + private function validated(Request $request, ?Company $company = null): array + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'billing_email' => ['nullable', 'email', 'max:255'], + 'status' => ['required', Rule::in(['active', 'suspended', 'inactive'])], + 'country_id' => ['nullable', Rule::exists('countries', 'id')->where('is_active', true)], + ]); + + $data['slug'] = Str::slug($data['name']); + $data['timezone'] = $company?->timezone ?: 'Europe/London'; + + return $data; + } + + private function countries() + { + return Country::query() + ->active() + ->ordered() + ->get(['id', 'alpha2', 'name', 'display_name', 'common_name', 'sort_order']); + } +} diff --git a/Company/Models/Company.php b/Company/Models/Company.php new file mode 100644 index 00000000..e3dd2a77 --- /dev/null +++ b/Company/Models/Company.php @@ -0,0 +1,100 @@ + 'array', + 'is_system_company' => 'boolean', + ]; + + protected static function booted(): void + { + static::creating(function (Company $company): void { + $company->uuid ??= (string) Str::uuid(); + $company->slug ??= Str::slug($company->name); + $company->status ??= 'active'; + $company->timezone ??= 'Europe/London'; + }); + } + + + + public function country(): BelongsTo + { + return $this->belongsTo(Country::class); + } + + public function address(): MorphOne + { + return $this->morphOne(CompanyAddress::class, 'addressable'); + } + + public function divisions(): HasMany + { + return $this->hasMany(CompanyDivision::class); + } + + public function directorAssignments(): HasMany + { + return $this->hasMany(CompanyDirectorAssignment::class); + } + + public function users(): HasMany + { + return $this->hasMany(User::class); + } + + public function licenseKeys(): HasMany + { + return $this->hasMany(LicenseKey::class); + } + + public function payments(): HasMany + { + return $this->hasMany(Payment::class); + } + + public function warehouses(): HasMany + { + return $this->hasMany(Warehouse::class); + } + + public function hasActiveLicense(): bool + { + if ($this->is_system_company) { + return true; + } + + return $this->licenseKeys() + ->where('is_active', true) + ->where(function ($query): void { + $query->whereNull('starts_at')->orWhere('starts_at', '<=', now()); + }) + ->where(function ($query): void { + $query->whereNull('expires_at')->orWhere('expires_at', '>=', now()); + }) + ->whereNull('revoked_at') + ->exists(); + } +} diff --git a/Company/Resources/views/_form.blade.php b/Company/Resources/views/_form.blade.php new file mode 100644 index 00000000..e044d2d7 --- /dev/null +++ b/Company/Resources/views/_form.blade.php @@ -0,0 +1,83 @@ +@csrf + +
+
+ + + @error('name') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('billing_email') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('status') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('country_id') +

{{ $message }}

+ @enderror +
+
+ +
+ + + + Cancel + +
diff --git a/Company/Resources/views/index.blade.php b/Company/Resources/views/index.blade.php new file mode 100644 index 00000000..b4515449 --- /dev/null +++ b/Company/Resources/views/index.blade.php @@ -0,0 +1,174 @@ + +
+ + + + + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + +
+
+
+

Create company

+

Add a new tenant without leaving the administration index.

+
+ + + Open full form + +
+ +
+ @csrf + + + + + + + +
+ +
+ + + +
+
+
+ + + + + + + + + + + + + + @forelse ($companies as $company) + + + + + + + + + + + + + + + + @empty + + + + @endforelse + +
NameUsersDivisionsWarehousesLicencesStatusActions
+ {{ $company->name }} + + {{ $company->users_count }} + + {{ $company->divisions_count }} + + {{ $company->warehouses_count }} + + {{ $company->license_keys_count }} + + + {{ $company->is_system_company ? 'System' : ucfirst($company->status) }} + + +
+ + View + + + + Edit + + + @unless ($company->is_system_company) +
+ @csrf + @method('DELETE') + + +
+ @endunless +
+
+ No companies found. +
+
+ + {{ $companies->links() }} +
+ diff --git a/Modules/Company/Database/Migrations/2026_07_07_093000_add_country_id_to_companies_table.php b/Modules/Company/Database/Migrations/2026_07_07_093000_add_country_id_to_companies_table.php new file mode 100644 index 00000000..0d0a61f0 --- /dev/null +++ b/Modules/Company/Database/Migrations/2026_07_07_093000_add_country_id_to_companies_table.php @@ -0,0 +1,26 @@ +foreignId('country_id') + ->nullable() + ->after('billing_email') + ->constrained('countries') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('companies', function (Blueprint $table): void { + $table->dropConstrainedForeignId('country_id'); + }); + } +}; diff --git a/Modules/Company/Http/Controllers/CompanyController.php b/Modules/Company/Http/Controllers/CompanyController.php index 5bd16514..0880b292 100644 --- a/Modules/Company/Http/Controllers/CompanyController.php +++ b/Modules/Company/Http/Controllers/CompanyController.php @@ -18,10 +18,11 @@ class CompanyController extends Controller { return view('company::index', [ 'companies' => Company::query() - ->with(['address.country']) + ->with(['address.country', 'country']) ->withCount(['users', 'warehouses', 'licenseKeys', 'divisions']) ->latest('id') ->paginate(20), + 'countries' => $this->countries(), ]); } @@ -35,6 +36,7 @@ class CompanyController extends Controller 'status' => 'active', 'timezone' => 'Europe/London', ]), + 'countries' => $this->countries(), ]); } @@ -83,7 +85,8 @@ class CompanyController extends Controller public function edit(Company $company) { return view('company::edit', [ - 'company' => $company, + 'company' => $company->loadMissing(['address.country', 'country']), + 'countries' => $this->countries(), ]); } @@ -119,12 +122,20 @@ class CompanyController extends Controller 'name' => ['required', 'string', 'max:255'], 'billing_email' => ['nullable', 'email', 'max:255'], 'status' => ['required', Rule::in(['active', 'suspended', 'inactive'])], - 'timezone' => ['nullable', 'string', 'max:64'], + 'country_id' => ['nullable', Rule::exists('countries', 'id')->where('is_active', true)], ]); $data['slug'] = Str::slug($data['name']); - $data['timezone'] = $data['timezone'] ?? ($company?->timezone ?: 'Europe/London'); + $data['timezone'] = $company?->timezone ?: 'Europe/London'; return $data; } + + private function countries() + { + return Country::query() + ->active() + ->ordered() + ->get(['id', 'alpha2', 'name', 'display_name', 'common_name', 'sort_order']); + } } diff --git a/Modules/Company/Models/Company.php b/Modules/Company/Models/Company.php index 0a3bb73e..e3dd2a77 100644 --- a/Modules/Company/Models/Company.php +++ b/Modules/Company/Models/Company.php @@ -4,12 +4,14 @@ namespace Modules\Company\Models; use App\Models\User; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Str; use Modules\Platform\app\Models\LicenseKey; use Modules\Platform\app\Models\Payment; +use Modules\Countries\Models\Country; use Modules\Warehouse\app\Models\Warehouse; class Company extends Model @@ -18,7 +20,7 @@ class Company extends Model protected $fillable = [ 'uuid', 'name', 'slug', 'status', 'timezone', 'settings', - 'is_system_company', 'stripe_customer_id', 'billing_email', + 'is_system_company', 'stripe_customer_id', 'billing_email', 'country_id', ]; protected $casts = [ @@ -37,6 +39,12 @@ class Company extends Model } + + public function country(): BelongsTo + { + return $this->belongsTo(Country::class); + } + public function address(): MorphOne { return $this->morphOne(CompanyAddress::class, 'addressable'); diff --git a/Modules/Company/Resources/views/_form.blade.php b/Modules/Company/Resources/views/_form.blade.php index 5ddfb4b0..e044d2d7 100644 --- a/Modules/Company/Resources/views/_form.blade.php +++ b/Modules/Company/Resources/views/_form.blade.php @@ -47,15 +47,23 @@ @enderror
-
- - + + + @error('country_id')

{{ $message }}

@enderror
diff --git a/Modules/Company/Resources/views/index.blade.php b/Modules/Company/Resources/views/index.blade.php index 8433fee2..b4515449 100644 --- a/Modules/Company/Resources/views/index.blade.php +++ b/Modules/Company/Resources/views/index.blade.php @@ -55,12 +55,16 @@ - +
+ +
diff --git a/Modules/Company/Resources/views/index.blade.php b/Modules/Company/Resources/views/index.blade.php index b4515449..adb5d1f5 100644 --- a/Modules/Company/Resources/views/index.blade.php +++ b/Modules/Company/Resources/views/index.blade.php @@ -30,43 +30,55 @@
-
+ @csrf - +
+ + +
- +
+ + +
- - -
- + + +
-
diff --git a/resources/css/app.css b/resources/css/app.css index 5a7ff4d3..f677a995 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1414,90 +1414,142 @@ html[data-wms-sidebar="collapsed"] .wms-sidebar { @layer components { .wms-select2-field .select2-container { width: 100% !important; + display: block; } .wms-select2-field .select2-container--default .select2-selection--single { - min-height: 2.875rem; - border-radius: 0.75rem; - border-color: rgb(212 212 216); - background: white; - box-shadow: 0 1px 2px rgb(15 23 42 / 0.04); + position: relative; + min-height: 2.75rem; + border: 1px solid rgb(212 212 216); + border-radius: 0.85rem; + background: linear-gradient(180deg, rgb(255 255 255), rgb(250 250 250)); + box-shadow: 0 1px 2px rgb(15 23 42 / 0.05); transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease; } .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__rendered { + min-height: 2.75rem; padding-left: 0.95rem; - padding-right: 2.75rem; + padding-right: 3rem; color: rgb(24 24 27); font-size: 0.875rem; - font-weight: 600; - line-height: 2.875rem; + font-weight: 650; + line-height: 2.75rem; } .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__placeholder { color: rgb(113 113 122); - font-weight: 500; + font-weight: 550; + } + + .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__clear { + position: absolute; + right: 2.6rem; + top: 0.62rem; + z-index: 2; + width: 1.35rem; + height: 1.35rem; + border-radius: 999px; + color: rgb(113 113 122); + font-size: 1rem; + font-weight: 700; + line-height: 1.25rem; + text-align: center; + } + + .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__clear:hover { + background: rgb(244 244 245); + color: rgb(39 39 42); } .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__arrow { top: 0.45rem; - right: 0.65rem; - width: 1.75rem; - height: 1.75rem; - border-radius: 0.55rem; + right: 0.55rem; + width: 1.85rem; + height: 1.85rem; + border-radius: 0.65rem; background: rgb(244 244 245); + border: 1px solid rgb(228 228 231); + } + + .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__arrow b { + border-color: rgb(82 82 91) transparent transparent transparent; + border-width: 5px 4px 0 4px; + margin-left: -4px; + margin-top: -2px; + } + + .wms-select2-field .select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent rgb(82 82 91) transparent; + border-width: 0 4px 5px 4px; } .wms-select2-field .select2-container--default.select2-container--focus .select2-selection--single, .wms-select2-field .select2-container--default.select2-container--open .select2-selection--single { - border-color: rgb(8 145 178); - box-shadow: 0 0 0 3px rgb(103 232 249 / 0.28); + border-color: rgb(22 163 74); + background: white; + box-shadow: 0 0 0 4px rgb(34 197 94 / 0.14), 0 10px 24px rgb(15 23 42 / 0.08); } .select2-container--default .select2-dropdown { overflow: hidden; + margin-top: 0.35rem; + border: 1px solid rgb(212 212 216); border-radius: 1rem; - border-color: rgb(212 212 216); background: white; box-shadow: 0 24px 60px rgb(15 23 42 / 0.18); } .select2-container--default .select2-search--dropdown { padding: 0.75rem; + border-bottom: 1px solid rgb(244 244 245); } .select2-container--default .select2-search--dropdown .select2-search__field { + min-height: 2.5rem; + border: 1px solid rgb(212 212 216); border-radius: 0.75rem; - border-color: rgb(212 212 216); - padding: 0.55rem 0.75rem; + padding: 0.55rem 0.8rem; + background: rgb(250 250 250); + color: rgb(24 24 27); font-size: 0.875rem; + font-weight: 600; outline: none; } .select2-container--default .select2-search--dropdown .select2-search__field:focus { - border-color: rgb(8 145 178); - box-shadow: 0 0 0 3px rgb(103 232 249 / 0.28); + border-color: rgb(22 163 74); + background: white; + box-shadow: 0 0 0 4px rgb(34 197 94 / 0.14); + } + + .select2-container--default .select2-results > .select2-results__options { + max-height: 16rem; + padding: 0.35rem; } .select2-container--default .select2-results__option { - padding: 0.65rem 0.85rem; + margin: 0.1rem 0; + border-radius: 0.65rem; + padding: 0.65rem 0.8rem; + color: rgb(39 39 42); font-size: 0.875rem; - font-weight: 600; + font-weight: 650; } .select2-container--default .select2-results__option--highlighted.select2-results__option--selectable { - background: rgb(8 145 178); + background: rgb(22 163 74); color: white; } .select2-container--default .select2-results__option--selected { - background: rgb(236 254 255); - color: rgb(14 116 144); + background: rgb(240 253 244); + color: rgb(21 128 61); } .dark .wms-select2-field .select2-container--default .select2-selection--single { border-color: rgb(63 63 70); - background: rgb(9 9 11); + background: linear-gradient(180deg, rgb(24 24 27), rgb(9 9 11)); } .dark .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__rendered { @@ -1509,13 +1561,34 @@ html[data-wms-sidebar="collapsed"] .wms-sidebar { } .dark .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__arrow { + border-color: rgb(63 63 70); background: rgb(39 39 42); } + .dark .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__arrow b { + border-color: rgb(212 212 216) transparent transparent transparent; + } + + .dark .wms-select2-field .select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent rgb(212 212 216) transparent; + } + + .dark .wms-select2-field .select2-container--default.select2-container--focus .select2-selection--single, + .dark .wms-select2-field .select2-container--default.select2-container--open .select2-selection--single { + border-color: rgb(34 197 94); + background: rgb(9 9 11); + box-shadow: 0 0 0 4px rgb(34 197 94 / 0.16); + } + .dark .select2-container--default .select2-dropdown { border-color: rgb(63 63 70); background: rgb(24 24 27); color: rgb(244 244 245); + box-shadow: 0 24px 60px rgb(0 0 0 / 0.35); + } + + .dark .select2-container--default .select2-search--dropdown { + border-bottom-color: rgb(39 39 42); } .dark .select2-container--default .select2-search--dropdown .select2-search__field { @@ -1524,8 +1597,12 @@ html[data-wms-sidebar="collapsed"] .wms-sidebar { color: white; } + .dark .select2-container--default .select2-results__option { + color: rgb(244 244 245); + } + .dark .select2-container--default .select2-results__option--selected { - background: rgb(8 47 73); - color: rgb(165 243 252); + background: rgb(20 83 45); + color: rgb(220 252 231); } } From 9cc2ce530e3fd67aac23a6314cc232b725869cf3 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 7 Jul 2026 11:57:02 +0100 Subject: [PATCH 37/49] interim fix for drop downs --- .../Http/Controllers/CompanyController.php | 62 +++- .../Company/Resources/views/_form.blade.php | 223 ++++++++++-- .../Company/Resources/views/index.blade.php | 145 +++++--- Modules/Company/changelog.yaml | 8 +- Modules/Company/module.json | 2 +- .../Http/Controllers/CountriesController.php | 66 ++-- Modules/Countries/Routes/web.php | 2 +- resources/css/app.css | 321 +++++++----------- resources/js/app.js | 14 +- 9 files changed, 520 insertions(+), 323 deletions(-) diff --git a/Modules/Company/Http/Controllers/CompanyController.php b/Modules/Company/Http/Controllers/CompanyController.php index 0880b292..10a583c1 100644 --- a/Modules/Company/Http/Controllers/CompanyController.php +++ b/Modules/Company/Http/Controllers/CompanyController.php @@ -18,11 +18,11 @@ class CompanyController extends Controller { return view('company::index', [ 'companies' => Company::query() - ->with(['address.country', 'country']) + ->with(['address.country']) ->withCount(['users', 'warehouses', 'licenseKeys', 'divisions']) ->latest('id') ->paginate(20), - 'countries' => $this->countries(), + 'countries' => $this->countriesForSelect(), ]); } @@ -36,7 +36,7 @@ class CompanyController extends Controller 'status' => 'active', 'timezone' => 'Europe/London', ]), - 'countries' => $this->countries(), + 'countries' => $this->countriesForSelect(), ]); } @@ -45,7 +45,10 @@ class CompanyController extends Controller */ public function store(Request $request) { - $company = Company::create($this->validated($request)); + $validated = $this->validated($request); + + $company = Company::create($validated['company']); + $this->syncAddressCountry($company, $validated['country_id']); return redirect() ->route('platform.companies.index') @@ -73,7 +76,7 @@ class CompanyController extends Controller 'recentUsers' => $company->users()->latest('id')->limit(5)->get(), 'recentWarehouses' => $company->warehouses()->latest('id')->limit(5)->get(), 'recentLicenseKeys' => $company->licenseKeys()->latest('id')->limit(5)->get(), - 'countries' => Country::query()->active()->orderBy('sort_order')->orderBy('name')->get(['id', 'alpha2', 'name', 'display_name', 'common_name']), + 'countries' => $this->countriesForSelect(), 'companyUsers' => $company->users()->orderBy('name')->get(['id', 'name', 'email', 'company_id']), 'unassignedWarehouses' => $company->warehouses()->whereNull('division_id')->orderBy('name')->get(), ]); @@ -85,8 +88,8 @@ class CompanyController extends Controller public function edit(Company $company) { return view('company::edit', [ - 'company' => $company->loadMissing(['address.country', 'country']), - 'countries' => $this->countries(), + 'company' => $company->loadMissing('address.country'), + 'countries' => $this->countriesForSelect(), ]); } @@ -95,7 +98,10 @@ class CompanyController extends Controller */ public function update(Request $request, Company $company) { - $company->update($this->validated($request, $company)); + $validated = $this->validated($request, $company); + + $company->update($validated['company']); + $this->syncAddressCountry($company, $validated['country_id']); return redirect() ->route('platform.companies.show', $company) @@ -122,20 +128,44 @@ class CompanyController extends Controller 'name' => ['required', 'string', 'max:255'], 'billing_email' => ['nullable', 'email', 'max:255'], 'status' => ['required', Rule::in(['active', 'suspended', 'inactive'])], - 'country_id' => ['nullable', Rule::exists('countries', 'id')->where('is_active', true)], + 'country_id' => ['nullable', 'integer', Rule::exists('countries', 'id')], ]); - $data['slug'] = Str::slug($data['name']); - $data['timezone'] = $company?->timezone ?: 'Europe/London'; - - return $data; + return [ + 'company' => [ + 'name' => $data['name'], + 'billing_email' => $data['billing_email'] ?? null, + 'status' => $data['status'], + 'slug' => Str::slug($data['name']), + 'timezone' => $company?->timezone ?: 'Europe/London', + ], + 'country_id' => $data['country_id'] ?? null, + ]; } - private function countries() + private function countriesForSelect() { return Country::query() ->active() - ->ordered() - ->get(['id', 'alpha2', 'name', 'display_name', 'common_name', 'sort_order']); + ->orderByRaw("LOWER(COALESCE(NULLIF(display_name, ''), NULLIF(common_name, ''), name)) ASC") + ->get(['id', 'alpha2', 'name', 'display_name', 'common_name']); + } + + private function syncAddressCountry(Company $company, ?int $countryId): void + { + if ($countryId === null) { + $company->address()?->delete(); + + return; + } + + $company->address()->updateOrCreate( + [], + [ + 'country_id' => $countryId, + 'label' => 'Registered address', + 'is_primary' => true, + ] + ); } } diff --git a/Modules/Company/Resources/views/_form.blade.php b/Modules/Company/Resources/views/_form.blade.php index 6316cd1b..340639fe 100644 --- a/Modules/Company/Resources/views/_form.blade.php +++ b/Modules/Company/Resources/views/_form.blade.php @@ -1,13 +1,13 @@ @csrf -
+
@error('name') @@ -23,7 +23,7 @@ name="billing_email" value="{{ old('billing_email', $company->billing_email) }}" placeholder="billing@example.com" - class="mt-2 w-full rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white" + class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white" > @error('billing_email')

{{ $message }}

@@ -35,7 +35,7 @@ - - @foreach ($countries as $country) - - @endforeach - -
-

Start typing to filter the country list.

+ @error('country_id')

{{ $message }}

@enderror @@ -73,14 +74,188 @@
- Cancel
+ + +@once + + + + + +@endonce diff --git a/Modules/Company/Resources/views/index.blade.php b/Modules/Company/Resources/views/index.blade.php index adb5d1f5..ab6fc316 100644 --- a/Modules/Company/Resources/views/index.blade.php +++ b/Modules/Company/Resources/views/index.blade.php @@ -30,57 +30,86 @@
-
+ @csrf -
- - -
+
+
+ + + @error('name') +

{{ $message }}

+ @enderror +
-
- - -
+
+ + + @error('billing_email') +

{{ $message }}

+ @enderror +
-
- - -
+
+ + + @error('status') +

{{ $message }}

+ @enderror +
-
- - -
+
+ + + @error('country_id') +

{{ $message }}

+ @enderror +
- + +
@@ -183,4 +212,30 @@ {{ $companies->links() }}
+ + @push('scripts') + + + + @endpush diff --git a/Modules/Company/changelog.yaml b/Modules/Company/changelog.yaml index 55cc3e18..5f29b1fe 100644 --- a/Modules/Company/changelog.yaml +++ b/Modules/Company/changelog.yaml @@ -1,6 +1,12 @@ -latest: 1.2.0 +latest: 1.2.1 versions: + - version: 1.2.1 + date: 2026-07-07 + changes: + - Replaced company country inputs with a shared Select2-enhanced dropdown populated by the Countries module. + - Aligned the company create forms and changed the primary create/save action to green. + - Stored the selected country on the company address relationship during create and update. - version: 1.2.0 date: 2026-07-02 changes: diff --git a/Modules/Company/module.json b/Modules/Company/module.json index 1689228d..6c43da16 100644 --- a/Modules/Company/module.json +++ b/Modules/Company/module.json @@ -1,7 +1,7 @@ { "name": "Company", "alias": "company", - "version": "1.2.0", + "version": "1.2.1", "description": "", "keywords": [], "priority": 0, diff --git a/Modules/Countries/Http/Controllers/CountriesController.php b/Modules/Countries/Http/Controllers/CountriesController.php index a6179046..e8ff25e7 100644 --- a/Modules/Countries/Http/Controllers/CountriesController.php +++ b/Modules/Countries/Http/Controllers/CountriesController.php @@ -13,10 +13,7 @@ use Modules\Countries\Support\CountryUsageReport; class CountriesController extends Controller { - /** - * Display ISO 3166 country records. - */ - public function index(Request $request): View + public function __invoke(Request $request): View { $search = trim((string) $request->query('search', '')); $status = (string) $request->query('status', 'all'); @@ -40,80 +37,78 @@ class CountriesController extends Controller ->when($status === 'active', fn ($query) => $query->where('is_active', true)) ->when($status === 'inactive', fn ($query) => $query->where('is_active', false)) ->when($region !== 'all' && $region !== '', fn ($query) => $query->where('region', $region)) - ->ordered() + ->orderByRaw('LOWER(COALESCE(display_name, common_name, name)) ASC') ->paginate(50) ->withQueryString(); $activeCount = Country::query()->where('is_active', true)->count(); $inactiveCount = Country::query()->where('is_active', false)->count(); $totalCount = Country::query()->count(); - $regions = Country::query()->whereNotNull('region')->distinct()->orderBy('region')->pluck('region'); - return view('countries::index', compact('countries', 'activeCount', 'inactiveCount', 'totalCount', 'search', 'status', 'region', 'regions')); + $regions = Country::query() + ->whereNotNull('region') + ->distinct() + ->orderBy('region') + ->pluck('region'); + + return view('countries::index', compact( + 'countries', + 'activeCount', + 'inactiveCount', + 'totalCount', + 'search', + 'status', + 'region', + 'regions' + )); } - /** - * Show the country creation form. - */ public function create(): View { return view('countries::create', ['country' => new Country]); } - /** - * Store a new ISO country record. - */ public function store(StoreCountryRequest $request): RedirectResponse { $country = Country::query()->create($this->payload($request)); - return redirect()->route('countries.show', $country)->with('success', 'Country created.'); + return redirect() + ->route('countries.show', $country) + ->with('success', 'Country created.'); } - /** - * Show a country record. - */ public function show(Country $country, CountryUsageReport $usageReport): View { $country->load(['subdivisions' => fn ($query) => $query->ordered()->limit(250)]); + $usageRows = $usageReport->forCountry($country); return view('countries::show', compact('country', 'usageRows')); } - /** - * Show the edit form. - */ public function edit(Country $country): View { return view('countries::edit', compact('country')); } - /** - * Update all editable ISO/support fields. - */ public function update(UpdateCountryRequest $request, Country $country): RedirectResponse { $country->update($this->payload($request)); - return redirect()->route('countries.show', $country)->with('success', 'Country updated.'); + return redirect() + ->route('countries.show', $country) + ->with('success', 'Country updated.'); } - /** - * Soft-delete a custom/retired country record. - */ public function destroy(Country $country): RedirectResponse { $country->delete(); - return redirect()->route('countries.index')->with('success', 'Country archived.'); + return redirect() + ->route('countries.index') + ->with('success', 'Country archived.'); } - /** - * Build the database payload from validated request data. - * - * @return array - */ private function payload(StoreCountryRequest|UpdateCountryRequest $request): array { $data = $request->validated(); @@ -130,7 +125,10 @@ class CountriesController extends Controller foreach (['languages', 'timezones', 'metadata'] as $jsonField) { $value = $data[$jsonField] ?? null; - $data[$jsonField] = blank($value) ? null : json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR); + + $data[$jsonField] = blank($value) + ? null + : json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR); } foreach (['is_active', 'independent', 'un_member'] as $booleanField) { diff --git a/Modules/Countries/Routes/web.php b/Modules/Countries/Routes/web.php index 9bf3c4ee..eaa78263 100644 --- a/Modules/Countries/Routes/web.php +++ b/Modules/Countries/Routes/web.php @@ -6,7 +6,7 @@ use Modules\Countries\Http\Controllers\CountrySyncController; use Modules\Countries\Http\Controllers\ReferenceDataController; Route::prefix('countries')->name('countries.')->middleware(['web', 'auth'])->group(function (): void { - Route::get('/', [CountriesController::class, 'index'])->name('index'); + Route::get('/', CountriesController::class)->name('index'); Route::get('/create', [CountriesController::class, 'create'])->name('create'); Route::post('/', [CountriesController::class, 'store'])->name('store'); Route::post('/sync', CountrySyncController::class)->name('sync'); diff --git a/resources/css/app.css b/resources/css/app.css index f677a995..280713c7 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1410,199 +1410,130 @@ html[data-wms-sidebar="collapsed"] .wms-sidebar { } - -@layer components { - .wms-select2-field .select2-container { - width: 100% !important; - display: block; - } - - .wms-select2-field .select2-container--default .select2-selection--single { - position: relative; - min-height: 2.75rem; - border: 1px solid rgb(212 212 216); - border-radius: 0.85rem; - background: linear-gradient(180deg, rgb(255 255 255), rgb(250 250 250)); - box-shadow: 0 1px 2px rgb(15 23 42 / 0.05); - transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease; - } - - .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__rendered { - min-height: 2.75rem; - padding-left: 0.95rem; - padding-right: 3rem; - color: rgb(24 24 27); - font-size: 0.875rem; - font-weight: 650; - line-height: 2.75rem; - } - - .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__placeholder { - color: rgb(113 113 122); - font-weight: 550; - } - - .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__clear { - position: absolute; - right: 2.6rem; - top: 0.62rem; - z-index: 2; - width: 1.35rem; - height: 1.35rem; - border-radius: 999px; - color: rgb(113 113 122); - font-size: 1rem; - font-weight: 700; - line-height: 1.25rem; - text-align: center; - } - - .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__clear:hover { - background: rgb(244 244 245); - color: rgb(39 39 42); - } - - .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__arrow { - top: 0.45rem; - right: 0.55rem; - width: 1.85rem; - height: 1.85rem; - border-radius: 0.65rem; - background: rgb(244 244 245); - border: 1px solid rgb(228 228 231); - } - - .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__arrow b { - border-color: rgb(82 82 91) transparent transparent transparent; - border-width: 5px 4px 0 4px; - margin-left: -4px; - margin-top: -2px; - } - - .wms-select2-field .select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b { - border-color: transparent transparent rgb(82 82 91) transparent; - border-width: 0 4px 5px 4px; - } - - .wms-select2-field .select2-container--default.select2-container--focus .select2-selection--single, - .wms-select2-field .select2-container--default.select2-container--open .select2-selection--single { - border-color: rgb(22 163 74); - background: white; - box-shadow: 0 0 0 4px rgb(34 197 94 / 0.14), 0 10px 24px rgb(15 23 42 / 0.08); - } - - .select2-container--default .select2-dropdown { - overflow: hidden; - margin-top: 0.35rem; - border: 1px solid rgb(212 212 216); - border-radius: 1rem; - background: white; - box-shadow: 0 24px 60px rgb(15 23 42 / 0.18); - } - - .select2-container--default .select2-search--dropdown { - padding: 0.75rem; - border-bottom: 1px solid rgb(244 244 245); - } - - .select2-container--default .select2-search--dropdown .select2-search__field { - min-height: 2.5rem; - border: 1px solid rgb(212 212 216); - border-radius: 0.75rem; - padding: 0.55rem 0.8rem; - background: rgb(250 250 250); - color: rgb(24 24 27); - font-size: 0.875rem; - font-weight: 600; - outline: none; - } - - .select2-container--default .select2-search--dropdown .select2-search__field:focus { - border-color: rgb(22 163 74); - background: white; - box-shadow: 0 0 0 4px rgb(34 197 94 / 0.14); - } - - .select2-container--default .select2-results > .select2-results__options { - max-height: 16rem; - padding: 0.35rem; - } - - .select2-container--default .select2-results__option { - margin: 0.1rem 0; - border-radius: 0.65rem; - padding: 0.65rem 0.8rem; - color: rgb(39 39 42); - font-size: 0.875rem; - font-weight: 650; - } - - .select2-container--default .select2-results__option--highlighted.select2-results__option--selectable { - background: rgb(22 163 74); - color: white; - } - - .select2-container--default .select2-results__option--selected { - background: rgb(240 253 244); - color: rgb(21 128 61); - } - - .dark .wms-select2-field .select2-container--default .select2-selection--single { - border-color: rgb(63 63 70); - background: linear-gradient(180deg, rgb(24 24 27), rgb(9 9 11)); - } - - .dark .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__rendered { - color: white; - } - - .dark .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__placeholder { - color: rgb(161 161 170); - } - - .dark .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__arrow { - border-color: rgb(63 63 70); - background: rgb(39 39 42); - } - - .dark .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__arrow b { - border-color: rgb(212 212 216) transparent transparent transparent; - } - - .dark .wms-select2-field .select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b { - border-color: transparent transparent rgb(212 212 216) transparent; - } - - .dark .wms-select2-field .select2-container--default.select2-container--focus .select2-selection--single, - .dark .wms-select2-field .select2-container--default.select2-container--open .select2-selection--single { - border-color: rgb(34 197 94); - background: rgb(9 9 11); - box-shadow: 0 0 0 4px rgb(34 197 94 / 0.16); - } - - .dark .select2-container--default .select2-dropdown { - border-color: rgb(63 63 70); - background: rgb(24 24 27); - color: rgb(244 244 245); - box-shadow: 0 24px 60px rgb(0 0 0 / 0.35); - } - - .dark .select2-container--default .select2-search--dropdown { - border-bottom-color: rgb(39 39 42); - } - - .dark .select2-container--default .select2-search--dropdown .select2-search__field { - border-color: rgb(63 63 70); - background: rgb(9 9 11); - color: white; - } - - .dark .select2-container--default .select2-results__option { - color: rgb(244 244 245); - } - - .dark .select2-container--default .select2-results__option--selected { - background: rgb(20 83 45); - color: rgb(220 252 231); - } +/* Company country Select2 */ +.wms-country-select + .select2-container { + display: block; + width: 100% !important; + margin-top: 0.5rem; +} + +.wms-country-select + .select2-container .select2-selection--single { + height: 3rem; + min-height: 3rem; + display: flex; + align-items: center; + border-radius: 0.75rem; + border: 1px solid rgb(212 212 216); + background: rgb(255 255 255); + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + transition: border-color 150ms ease, box-shadow 150ms ease, background-color 150ms ease; +} + +.dark .wms-country-select + .select2-container .select2-selection--single { + border-color: rgb(63 63 70); + background: rgb(9 9 11); +} + +.wms-country-select + .select2-container .select2-selection--single .select2-selection__rendered { + line-height: 3rem; + width: 100%; + padding-left: 1rem; + padding-right: 2.75rem; + color: rgb(24 24 27); + font-size: 0.875rem; +} + +.dark .wms-country-select + .select2-container .select2-selection--single .select2-selection__rendered { + color: rgb(244 244 245); +} + +.wms-country-select + .select2-container .select2-selection--single .select2-selection__placeholder { + color: rgb(113 113 122); +} + +.dark .wms-country-select + .select2-container .select2-selection--single .select2-selection__placeholder { + color: rgb(161 161 170); +} + +.wms-country-select + .select2-container .select2-selection--single .select2-selection__arrow { + height: 3rem; + right: 0.75rem; + top: 0; +} + +.wms-country-select + .select2-container .select2-selection--single .select2-selection__arrow b { + border-color: rgb(113 113 122) transparent transparent transparent; +} + +.wms-country-select + .select2-container .select2-selection--single .select2-selection__clear { + order: 2; + margin-right: 0.5rem; + color: rgb(113 113 122); + font-size: 1.125rem; + font-weight: 700; +} + +.wms-country-select + .select2-container.select2-container--focus .select2-selection--single, +.wms-country-select + .select2-container.select2-container--open .select2-selection--single { + border-color: rgb(59 130 246); + box-shadow: 0 0 0 3px rgb(59 130 246 / 0.28); +} + +.select2-container--open .select2-dropdown { + border: 1px solid rgb(63 63 70); + border-radius: 0.875rem; + overflow: hidden; + background: rgb(255 255 255); + box-shadow: 0 20px 35px -15px rgb(0 0 0 / 0.45); +} + +.dark .select2-container--open .select2-dropdown { + background: rgb(24 24 27); + border-color: rgb(63 63 70); +} + +.select2-container--default .select2-search--dropdown { + padding: 0.75rem; + border-bottom: 1px solid rgb(228 228 231); +} + +.dark .select2-container--default .select2-search--dropdown { + border-bottom-color: rgb(63 63 70); +} + +.select2-container--default .select2-search--dropdown .select2-search__field { + min-height: 2.5rem; + border-radius: 0.75rem; + border: 1px solid rgb(212 212 216); + padding: 0.5rem 0.75rem; + color: rgb(24 24 27); + outline: none; +} + +.dark .select2-container--default .select2-search--dropdown .select2-search__field { + border-color: rgb(63 63 70); + background: rgb(9 9 11); + color: rgb(244 244 245); +} + +.select2-container--default .select2-results__option { + padding: 0.7rem 0.9rem; + font-size: 0.875rem; + color: rgb(39 39 42); +} + +.dark .select2-container--default .select2-results__option { + color: rgb(228 228 231); +} + +.select2-container--default .select2-results__option--highlighted.select2-results__option--selectable, +.select2-container--default .select2-results__option--highlighted[aria-selected] { + background: rgb(37 99 235); + color: white; +} + +.select2-container--default .select2-results__option--selected, +.select2-container--default .select2-results__option[aria-selected="true"] { + background: rgb(22 101 52); + color: white; } diff --git a/resources/js/app.js b/resources/js/app.js index ee6d0713..900aeba2 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -167,7 +167,7 @@ function initialiseSelect2() { return; } - $('[data-helpdesk-select2], [data-wms-select2]').each(function () { + $('[data-helpdesk-select2], [data-company-country-select2]').each(function () { const $select = $(this); if ($select.hasClass('select2-hidden-accessible')) { @@ -176,15 +176,17 @@ function initialiseSelect2() { const isMultiple = $select.prop('multiple'); + const placeholder = + $select.data('placeholder') || + $select.find('option[value=""]').first().text() || + (isMultiple ? 'Select options' : 'Select option'); + $select.select2({ width: '100%', allowClear: !isMultiple, closeOnSelect: !isMultiple, - dropdownAutoWidth: true, - placeholder: - $select.data('placeholder') || - $select.find('option[value=""]').first().text() || - (isMultiple ? 'Select options' : 'Select option'), + dropdownAutoWidth: false, + placeholder, }); $select.off('change.select2-livewire'); From d2c1f744c3ab2127380f8f8a4d08ae9476dad83e Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 7 Jul 2026 12:14:24 +0100 Subject: [PATCH 38/49] interim fix for drop downs, BROKEN FIX --- ...3000_add_country_id_to_companies_table.php | 26 - .../Http/Controllers/CompanyController.php | 141 -- Company/Models/Company.php | 100 -- Company/Resources/views/_form.blade.php | 83 - Company/Resources/views/index.blade.php | 174 -- Modules/Company/Config/.gitkeep | 0 Modules/Company/Config/config.php | 8 - Modules/Company/Console/.gitkeep | 0 Modules/Company/Database/Migrations/.gitkeep | 0 ..._120000_create_company_addresses_table.php | 40 - ..._121000_create_company_divisions_table.php | 34 - ...000_create_company_division_user_table.php | 30 - ...ate_company_director_assignments_table.php | 32 - ...00_add_division_id_to_warehouses_table.php | 27 - Modules/Company/Database/Seeders/.gitkeep | 0 .../Seeders/CompanyDatabaseSeeder.php | 21 - Modules/Company/Database/factories/.gitkeep | 0 Modules/Company/Helpers/.gitkeep | 0 Modules/Company/Helpers/Helper.php | 12 - Modules/Company/Http/Controllers/.gitkeep | 0 .../Controllers/CompanyAddressController.php | 61 - .../Http/Controllers/CompanyController.php | 62 +- .../Controllers/CompanyDivisionController.php | 54 - .../CompanyMembershipController.php | 83 - .../CompanyWarehouseAssignmentController.php | 25 - Modules/Company/Http/Middleware/.gitkeep | 0 Modules/Company/Http/Requests/.gitkeep | 0 Modules/Company/Models/.gitkeep | 0 Modules/Company/Models/CompanyAddress.php | 54 - .../Models/CompanyDirectorAssignment.php | 34 - Modules/Company/Models/CompanyDivision.php | 62 - Modules/Company/Providers/.gitkeep | 0 .../Providers/CompanyServiceProvider.php | 155 -- .../Providers/RouteServiceProvider.php | 70 - Modules/Company/Resources/assets/.gitkeep | 0 Modules/Company/Resources/assets/js/app.js | 0 .../Company/Resources/assets/sass/app.scss | 0 Modules/Company/Resources/lang/.gitkeep | 0 Modules/Company/Resources/views/.gitkeep | 0 .../Company/Resources/views/_form.blade.php | 208 +-- .../Company/Resources/views/create.blade.php | 16 - .../Company/Resources/views/edit.blade.php | 17 - .../Company/Resources/views/index.blade.php | 133 +- .../views/partials/address-form.blade.php | 47 - .../Company/Resources/views/show.blade.php | 140 -- Modules/Company/Routes/.gitkeep | 0 Modules/Company/Routes/api.php | 18 - Modules/Company/Routes/web.php | 27 - Modules/Company/Support/.gitkeep | 0 Modules/Company/Support/ModuleInfo.php | 26 - Modules/Company/Tests/Browser/.gitkeep | 0 .../CompanyAdministrationBrowserTest.php | 36 - Modules/Company/Tests/Feature/.gitkeep | 0 .../CompanyAdministrationRoutesTest.php | 73 - .../Tests/Feature/CompanyStructureTest.php | 70 - Modules/Company/Tests/Unit/.gitkeep | 0 Modules/Company/Traits/.gitkeep | 0 Modules/Company/View/.gitkeep | 0 Modules/Company/changelog.yaml | 32 - Modules/Company/composer.json | 29 - Modules/Company/module.json | 14 - Modules/Company/package.json | 17 - Modules/Company/webpack.mix.js | 11 - css/app.css | 1531 ----------------- js/app.js | 222 --- resources/css/app.css | 204 ++- resources/js/app.js | 14 +- 67 files changed, 168 insertions(+), 4105 deletions(-) delete mode 100644 Company/Database/Migrations/2026_07_07_093000_add_country_id_to_companies_table.php delete mode 100644 Company/Http/Controllers/CompanyController.php delete mode 100644 Company/Models/Company.php delete mode 100644 Company/Resources/views/_form.blade.php delete mode 100644 Company/Resources/views/index.blade.php delete mode 100644 Modules/Company/Config/.gitkeep delete mode 100644 Modules/Company/Config/config.php delete mode 100644 Modules/Company/Console/.gitkeep delete mode 100644 Modules/Company/Database/Migrations/.gitkeep delete mode 100644 Modules/Company/Database/Migrations/2026_07_02_120000_create_company_addresses_table.php delete mode 100644 Modules/Company/Database/Migrations/2026_07_02_121000_create_company_divisions_table.php delete mode 100644 Modules/Company/Database/Migrations/2026_07_02_122000_create_company_division_user_table.php delete mode 100644 Modules/Company/Database/Migrations/2026_07_02_123000_create_company_director_assignments_table.php delete mode 100644 Modules/Company/Database/Migrations/2026_07_02_124000_add_division_id_to_warehouses_table.php delete mode 100644 Modules/Company/Database/Seeders/.gitkeep delete mode 100644 Modules/Company/Database/Seeders/CompanyDatabaseSeeder.php delete mode 100644 Modules/Company/Database/factories/.gitkeep delete mode 100644 Modules/Company/Helpers/.gitkeep delete mode 100644 Modules/Company/Helpers/Helper.php delete mode 100644 Modules/Company/Http/Controllers/.gitkeep delete mode 100644 Modules/Company/Http/Controllers/CompanyAddressController.php delete mode 100644 Modules/Company/Http/Controllers/CompanyDivisionController.php delete mode 100644 Modules/Company/Http/Controllers/CompanyMembershipController.php delete mode 100644 Modules/Company/Http/Controllers/CompanyWarehouseAssignmentController.php delete mode 100644 Modules/Company/Http/Middleware/.gitkeep delete mode 100644 Modules/Company/Http/Requests/.gitkeep delete mode 100644 Modules/Company/Models/.gitkeep delete mode 100644 Modules/Company/Models/CompanyAddress.php delete mode 100644 Modules/Company/Models/CompanyDirectorAssignment.php delete mode 100644 Modules/Company/Models/CompanyDivision.php delete mode 100644 Modules/Company/Providers/.gitkeep delete mode 100644 Modules/Company/Providers/CompanyServiceProvider.php delete mode 100644 Modules/Company/Providers/RouteServiceProvider.php delete mode 100644 Modules/Company/Resources/assets/.gitkeep delete mode 100644 Modules/Company/Resources/assets/js/app.js delete mode 100644 Modules/Company/Resources/assets/sass/app.scss delete mode 100644 Modules/Company/Resources/lang/.gitkeep delete mode 100644 Modules/Company/Resources/views/.gitkeep delete mode 100644 Modules/Company/Resources/views/create.blade.php delete mode 100644 Modules/Company/Resources/views/edit.blade.php delete mode 100644 Modules/Company/Resources/views/partials/address-form.blade.php delete mode 100644 Modules/Company/Resources/views/show.blade.php delete mode 100644 Modules/Company/Routes/.gitkeep delete mode 100644 Modules/Company/Routes/api.php delete mode 100644 Modules/Company/Routes/web.php delete mode 100644 Modules/Company/Support/.gitkeep delete mode 100644 Modules/Company/Support/ModuleInfo.php delete mode 100644 Modules/Company/Tests/Browser/.gitkeep delete mode 100644 Modules/Company/Tests/Browser/CompanyAdministrationBrowserTest.php delete mode 100644 Modules/Company/Tests/Feature/.gitkeep delete mode 100644 Modules/Company/Tests/Feature/CompanyAdministrationRoutesTest.php delete mode 100644 Modules/Company/Tests/Feature/CompanyStructureTest.php delete mode 100644 Modules/Company/Tests/Unit/.gitkeep delete mode 100644 Modules/Company/Traits/.gitkeep delete mode 100644 Modules/Company/View/.gitkeep delete mode 100644 Modules/Company/changelog.yaml delete mode 100644 Modules/Company/composer.json delete mode 100644 Modules/Company/module.json delete mode 100644 Modules/Company/package.json delete mode 100644 Modules/Company/webpack.mix.js delete mode 100644 css/app.css delete mode 100644 js/app.js diff --git a/Company/Database/Migrations/2026_07_07_093000_add_country_id_to_companies_table.php b/Company/Database/Migrations/2026_07_07_093000_add_country_id_to_companies_table.php deleted file mode 100644 index 0d0a61f0..00000000 --- a/Company/Database/Migrations/2026_07_07_093000_add_country_id_to_companies_table.php +++ /dev/null @@ -1,26 +0,0 @@ -foreignId('country_id') - ->nullable() - ->after('billing_email') - ->constrained('countries') - ->nullOnDelete(); - }); - } - - public function down(): void - { - Schema::table('companies', function (Blueprint $table): void { - $table->dropConstrainedForeignId('country_id'); - }); - } -}; diff --git a/Company/Http/Controllers/CompanyController.php b/Company/Http/Controllers/CompanyController.php deleted file mode 100644 index 0880b292..00000000 --- a/Company/Http/Controllers/CompanyController.php +++ /dev/null @@ -1,141 +0,0 @@ - Company::query() - ->with(['address.country', 'country']) - ->withCount(['users', 'warehouses', 'licenseKeys', 'divisions']) - ->latest('id') - ->paginate(20), - 'countries' => $this->countries(), - ]); - } - - /** - * Show the create company screen. - */ - public function create() - { - return view('company::create', [ - 'company' => new Company([ - 'status' => 'active', - 'timezone' => 'Europe/London', - ]), - 'countries' => $this->countries(), - ]); - } - - /** - * Store a company tenant. - */ - public function store(Request $request) - { - $company = Company::create($this->validated($request)); - - return redirect() - ->route('platform.companies.index') - ->with('status', 'Company created.'); - } - - /** - * Display a company workbench. - */ - public function show(Company $company) - { - $company->load([ - 'address.country', - 'divisions.address.country', - 'divisions.warehouses', - 'divisions.users', - 'warehouses.address.country', - 'warehouses.division', - 'directorAssignments.user', - 'directorAssignments.division', - ])->loadCount(['users', 'warehouses', 'licenseKeys', 'payments', 'divisions']); - - return view('company::show', [ - 'company' => $company, - 'recentUsers' => $company->users()->latest('id')->limit(5)->get(), - 'recentWarehouses' => $company->warehouses()->latest('id')->limit(5)->get(), - 'recentLicenseKeys' => $company->licenseKeys()->latest('id')->limit(5)->get(), - 'countries' => Country::query()->active()->orderBy('sort_order')->orderBy('name')->get(['id', 'alpha2', 'name', 'display_name', 'common_name']), - 'companyUsers' => $company->users()->orderBy('name')->get(['id', 'name', 'email', 'company_id']), - 'unassignedWarehouses' => $company->warehouses()->whereNull('division_id')->orderBy('name')->get(), - ]); - } - - /** - * Show the edit company screen. - */ - public function edit(Company $company) - { - return view('company::edit', [ - 'company' => $company->loadMissing(['address.country', 'country']), - 'countries' => $this->countries(), - ]); - } - - /** - * Update a company tenant. - */ - public function update(Request $request, Company $company) - { - $company->update($this->validated($request, $company)); - - return redirect() - ->route('platform.companies.show', $company) - ->with('status', 'Company updated.'); - } - - /** - * Delete a company tenant. - */ - public function destroy(Company $company) - { - abort_if($company->is_system_company, 403, 'The system company cannot be deleted.'); - - $company->delete(); - - return redirect() - ->route('platform.companies.index') - ->with('status', 'Company deleted.'); - } - - private function validated(Request $request, ?Company $company = null): array - { - $data = $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'billing_email' => ['nullable', 'email', 'max:255'], - 'status' => ['required', Rule::in(['active', 'suspended', 'inactive'])], - 'country_id' => ['nullable', Rule::exists('countries', 'id')->where('is_active', true)], - ]); - - $data['slug'] = Str::slug($data['name']); - $data['timezone'] = $company?->timezone ?: 'Europe/London'; - - return $data; - } - - private function countries() - { - return Country::query() - ->active() - ->ordered() - ->get(['id', 'alpha2', 'name', 'display_name', 'common_name', 'sort_order']); - } -} diff --git a/Company/Models/Company.php b/Company/Models/Company.php deleted file mode 100644 index e3dd2a77..00000000 --- a/Company/Models/Company.php +++ /dev/null @@ -1,100 +0,0 @@ - 'array', - 'is_system_company' => 'boolean', - ]; - - protected static function booted(): void - { - static::creating(function (Company $company): void { - $company->uuid ??= (string) Str::uuid(); - $company->slug ??= Str::slug($company->name); - $company->status ??= 'active'; - $company->timezone ??= 'Europe/London'; - }); - } - - - - public function country(): BelongsTo - { - return $this->belongsTo(Country::class); - } - - public function address(): MorphOne - { - return $this->morphOne(CompanyAddress::class, 'addressable'); - } - - public function divisions(): HasMany - { - return $this->hasMany(CompanyDivision::class); - } - - public function directorAssignments(): HasMany - { - return $this->hasMany(CompanyDirectorAssignment::class); - } - - public function users(): HasMany - { - return $this->hasMany(User::class); - } - - public function licenseKeys(): HasMany - { - return $this->hasMany(LicenseKey::class); - } - - public function payments(): HasMany - { - return $this->hasMany(Payment::class); - } - - public function warehouses(): HasMany - { - return $this->hasMany(Warehouse::class); - } - - public function hasActiveLicense(): bool - { - if ($this->is_system_company) { - return true; - } - - return $this->licenseKeys() - ->where('is_active', true) - ->where(function ($query): void { - $query->whereNull('starts_at')->orWhere('starts_at', '<=', now()); - }) - ->where(function ($query): void { - $query->whereNull('expires_at')->orWhere('expires_at', '>=', now()); - }) - ->whereNull('revoked_at') - ->exists(); - } -} diff --git a/Company/Resources/views/_form.blade.php b/Company/Resources/views/_form.blade.php deleted file mode 100644 index e044d2d7..00000000 --- a/Company/Resources/views/_form.blade.php +++ /dev/null @@ -1,83 +0,0 @@ -@csrf - -
-
- - - @error('name') -

{{ $message }}

- @enderror -
- -
- - - @error('billing_email') -

{{ $message }}

- @enderror -
- -
- - - @error('status') -

{{ $message }}

- @enderror -
- -
- - - @error('country_id') -

{{ $message }}

- @enderror -
-
- -
- - - - Cancel - -
diff --git a/Company/Resources/views/index.blade.php b/Company/Resources/views/index.blade.php deleted file mode 100644 index b4515449..00000000 --- a/Company/Resources/views/index.blade.php +++ /dev/null @@ -1,174 +0,0 @@ - -
- - - - - @if (session('status')) -
- {{ session('status') }} -
- @endif - -
-
-
-

Create company

-

Add a new tenant without leaving the administration index.

-
- - - Open full form - -
- -
- @csrf - - - - - - - -
- -
- - -
-
-
- - - - - - - - - - - - - - - @forelse ($companies as $company) - - - - - - - - - - - - - - - - @empty - - - - @endforelse - -
NameUsersDivisionsWarehousesLicencesStatusActions
- {{ $company->name }} - - {{ $company->users_count }} - - {{ $company->divisions_count }} - - {{ $company->warehouses_count }} - - {{ $company->license_keys_count }} - - - {{ $company->is_system_company ? 'System' : ucfirst($company->status) }} - - -
- - View - - - - Edit - - - @unless ($company->is_system_company) -
- @csrf - @method('DELETE') - - -
- @endunless -
-
- No companies found. -
-
- - {{ $companies->links() }} -
-
diff --git a/Modules/Company/Config/.gitkeep b/Modules/Company/Config/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Modules/Company/Config/config.php b/Modules/Company/Config/config.php deleted file mode 100644 index 2f8ac5ec..00000000 --- a/Modules/Company/Config/config.php +++ /dev/null @@ -1,8 +0,0 @@ - 'Company', - 'features' => - [ - ], -]; diff --git a/Modules/Company/Console/.gitkeep b/Modules/Company/Console/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Modules/Company/Database/Migrations/.gitkeep b/Modules/Company/Database/Migrations/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Modules/Company/Database/Migrations/2026_07_02_120000_create_company_addresses_table.php b/Modules/Company/Database/Migrations/2026_07_02_120000_create_company_addresses_table.php deleted file mode 100644 index 627f7f17..00000000 --- a/Modules/Company/Database/Migrations/2026_07_02_120000_create_company_addresses_table.php +++ /dev/null @@ -1,40 +0,0 @@ -id(); - $table->morphs('addressable'); - $table->string('label', 80)->default('Registered office'); - $table->string('attention')->nullable(); - $table->string('line_1'); - $table->string('line_2')->nullable(); - $table->string('line_3')->nullable(); - $table->string('locality')->comment('City, town or locality.'); - $table->string('administrative_area')->nullable()->comment('County, state, province or region.'); - $table->string('postal_code', 32)->nullable(); - $table->foreignId('country_id')->constrained('countries')->restrictOnDelete(); - $table->foreignId('country_subdivision_id')->nullable()->constrained('country_subdivisions')->nullOnDelete(); - $table->decimal('latitude', 10, 7)->nullable(); - $table->decimal('longitude', 10, 7)->nullable(); - $table->boolean('is_primary')->default(true); - $table->json('metadata')->nullable(); - $table->timestamps(); - - $table->unique(['addressable_type', 'addressable_id'], 'company_addresses_one_per_owner_unique'); - $table->index(['country_id', 'locality']); - $table->index(['postal_code']); - }); - } - - public function down(): void - { - Schema::dropIfExists('company_addresses'); - } -}; diff --git a/Modules/Company/Database/Migrations/2026_07_02_121000_create_company_divisions_table.php b/Modules/Company/Database/Migrations/2026_07_02_121000_create_company_divisions_table.php deleted file mode 100644 index 44cad595..00000000 --- a/Modules/Company/Database/Migrations/2026_07_02_121000_create_company_divisions_table.php +++ /dev/null @@ -1,34 +0,0 @@ -id(); - $table->foreignId('company_id')->constrained('companies')->cascadeOnDelete(); - $table->uuid('uuid')->unique(); - $table->string('name'); - $table->string('code', 40)->nullable(); - $table->string('slug'); - $table->string('status', 32)->default('active')->index(); - $table->text('description')->nullable(); - $table->json('metadata')->nullable(); - $table->timestamps(); - $table->softDeletes(); - - $table->unique(['company_id', 'slug']); - $table->unique(['company_id', 'code']); - $table->index(['company_id', 'status', 'name']); - }); - } - - public function down(): void - { - Schema::dropIfExists('company_divisions'); - } -}; diff --git a/Modules/Company/Database/Migrations/2026_07_02_122000_create_company_division_user_table.php b/Modules/Company/Database/Migrations/2026_07_02_122000_create_company_division_user_table.php deleted file mode 100644 index 16fed17b..00000000 --- a/Modules/Company/Database/Migrations/2026_07_02_122000_create_company_division_user_table.php +++ /dev/null @@ -1,30 +0,0 @@ -id(); - $table->foreignId('company_id')->constrained('companies')->cascadeOnDelete(); - $table->foreignId('company_division_id')->constrained('company_divisions')->cascadeOnDelete(); - $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); - $table->string('role', 80)->default('member')->index(); - $table->boolean('is_director')->default(false)->index(); - $table->timestamps(); - - $table->unique(['company_division_id', 'user_id']); - $table->index(['company_id', 'user_id']); - $table->index(['company_id', 'is_director']); - }); - } - - public function down(): void - { - Schema::dropIfExists('company_division_user'); - } -}; diff --git a/Modules/Company/Database/Migrations/2026_07_02_123000_create_company_director_assignments_table.php b/Modules/Company/Database/Migrations/2026_07_02_123000_create_company_director_assignments_table.php deleted file mode 100644 index c539c515..00000000 --- a/Modules/Company/Database/Migrations/2026_07_02_123000_create_company_director_assignments_table.php +++ /dev/null @@ -1,32 +0,0 @@ -id(); - $table->foreignId('company_id')->constrained('companies')->cascadeOnDelete(); - $table->foreignId('company_division_id')->nullable()->constrained('company_divisions')->cascadeOnDelete(); - $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); - $table->string('coverage_type', 32)->default('company')->index(); - $table->date('starts_at')->nullable(); - $table->date('ends_at')->nullable(); - $table->text('notes')->nullable(); - $table->timestamps(); - - $table->unique(['company_id', 'company_division_id', 'user_id'], 'company_director_coverage_unique'); - $table->index(['company_id', 'coverage_type']); - $table->index(['user_id', 'coverage_type']); - }); - } - - public function down(): void - { - Schema::dropIfExists('company_director_assignments'); - } -}; diff --git a/Modules/Company/Database/Migrations/2026_07_02_124000_add_division_id_to_warehouses_table.php b/Modules/Company/Database/Migrations/2026_07_02_124000_add_division_id_to_warehouses_table.php deleted file mode 100644 index b7c52b09..00000000 --- a/Modules/Company/Database/Migrations/2026_07_02_124000_add_division_id_to_warehouses_table.php +++ /dev/null @@ -1,27 +0,0 @@ -foreignId('division_id')->nullable()->after('company_id')->constrained('company_divisions')->nullOnDelete(); - $table->index(['company_id', 'division_id']); - } - }); - } - - public function down(): void - { - Schema::table('warehouses', function (Blueprint $table): void { - if (Schema::hasColumn('warehouses', 'division_id')) { - $table->dropConstrainedForeignId('division_id'); - } - }); - } -}; diff --git a/Modules/Company/Database/Seeders/.gitkeep b/Modules/Company/Database/Seeders/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Modules/Company/Database/Seeders/CompanyDatabaseSeeder.php b/Modules/Company/Database/Seeders/CompanyDatabaseSeeder.php deleted file mode 100644 index 49302c7a..00000000 --- a/Modules/Company/Database/Seeders/CompanyDatabaseSeeder.php +++ /dev/null @@ -1,21 +0,0 @@ -call("OthersTableSeeder"); - } -} diff --git a/Modules/Company/Database/factories/.gitkeep b/Modules/Company/Database/factories/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Modules/Company/Helpers/.gitkeep b/Modules/Company/Helpers/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Modules/Company/Helpers/Helper.php b/Modules/Company/Helpers/Helper.php deleted file mode 100644 index 7693b057..00000000 --- a/Modules/Company/Helpers/Helper.php +++ /dev/null @@ -1,12 +0,0 @@ -upsertAddress($request, $company); - - return redirect()->route('platform.companies.show', $company)->with('status', 'Company address updated.'); - } - - public function updateDivision(Request $request, Company $company, CompanyDivision $division) - { - abort_unless($division->company_id === $company->id, 404); - - $this->upsertAddress($request, $division, 'Operating address'); - - return redirect()->route('platform.companies.show', $company)->with('status', 'Division address updated.'); - } - - public function updateWarehouse(Request $request, Company $company, Warehouse $warehouse) - { - abort_unless($warehouse->company_id === $company->id, 404); - - $this->upsertAddress($request, $warehouse, 'Warehouse address'); - - return redirect()->route('platform.companies.show', $company)->with('status', 'Warehouse address updated.'); - } - - private function upsertAddress(Request $request, object $owner, string $defaultLabel = 'Registered office'): void - { - $data = $request->validate([ - 'label' => ['nullable', 'string', 'max:80'], - 'attention' => ['nullable', 'string', 'max:255'], - 'line_1' => ['required', 'string', 'max:255'], - 'line_2' => ['nullable', 'string', 'max:255'], - 'line_3' => ['nullable', 'string', 'max:255'], - 'locality' => ['required', 'string', 'max:255'], - 'administrative_area' => ['nullable', 'string', 'max:255'], - 'postal_code' => ['nullable', 'string', 'max:32'], - 'country_id' => ['required', Rule::exists('countries', 'id')->where('is_active', true)], - 'country_subdivision_id' => ['nullable', 'exists:country_subdivisions,id'], - 'latitude' => ['nullable', 'numeric', 'between:-90,90'], - 'longitude' => ['nullable', 'numeric', 'between:-180,180'], - ]); - - $data['label'] = $data['label'] ?: $defaultLabel; - $data['is_primary'] = true; - - $owner->address()->updateOrCreate([], $data); - } -} diff --git a/Modules/Company/Http/Controllers/CompanyController.php b/Modules/Company/Http/Controllers/CompanyController.php index 10a583c1..0880b292 100644 --- a/Modules/Company/Http/Controllers/CompanyController.php +++ b/Modules/Company/Http/Controllers/CompanyController.php @@ -18,11 +18,11 @@ class CompanyController extends Controller { return view('company::index', [ 'companies' => Company::query() - ->with(['address.country']) + ->with(['address.country', 'country']) ->withCount(['users', 'warehouses', 'licenseKeys', 'divisions']) ->latest('id') ->paginate(20), - 'countries' => $this->countriesForSelect(), + 'countries' => $this->countries(), ]); } @@ -36,7 +36,7 @@ class CompanyController extends Controller 'status' => 'active', 'timezone' => 'Europe/London', ]), - 'countries' => $this->countriesForSelect(), + 'countries' => $this->countries(), ]); } @@ -45,10 +45,7 @@ class CompanyController extends Controller */ public function store(Request $request) { - $validated = $this->validated($request); - - $company = Company::create($validated['company']); - $this->syncAddressCountry($company, $validated['country_id']); + $company = Company::create($this->validated($request)); return redirect() ->route('platform.companies.index') @@ -76,7 +73,7 @@ class CompanyController extends Controller 'recentUsers' => $company->users()->latest('id')->limit(5)->get(), 'recentWarehouses' => $company->warehouses()->latest('id')->limit(5)->get(), 'recentLicenseKeys' => $company->licenseKeys()->latest('id')->limit(5)->get(), - 'countries' => $this->countriesForSelect(), + 'countries' => Country::query()->active()->orderBy('sort_order')->orderBy('name')->get(['id', 'alpha2', 'name', 'display_name', 'common_name']), 'companyUsers' => $company->users()->orderBy('name')->get(['id', 'name', 'email', 'company_id']), 'unassignedWarehouses' => $company->warehouses()->whereNull('division_id')->orderBy('name')->get(), ]); @@ -88,8 +85,8 @@ class CompanyController extends Controller public function edit(Company $company) { return view('company::edit', [ - 'company' => $company->loadMissing('address.country'), - 'countries' => $this->countriesForSelect(), + 'company' => $company->loadMissing(['address.country', 'country']), + 'countries' => $this->countries(), ]); } @@ -98,10 +95,7 @@ class CompanyController extends Controller */ public function update(Request $request, Company $company) { - $validated = $this->validated($request, $company); - - $company->update($validated['company']); - $this->syncAddressCountry($company, $validated['country_id']); + $company->update($this->validated($request, $company)); return redirect() ->route('platform.companies.show', $company) @@ -128,44 +122,20 @@ class CompanyController extends Controller 'name' => ['required', 'string', 'max:255'], 'billing_email' => ['nullable', 'email', 'max:255'], 'status' => ['required', Rule::in(['active', 'suspended', 'inactive'])], - 'country_id' => ['nullable', 'integer', Rule::exists('countries', 'id')], + 'country_id' => ['nullable', Rule::exists('countries', 'id')->where('is_active', true)], ]); - return [ - 'company' => [ - 'name' => $data['name'], - 'billing_email' => $data['billing_email'] ?? null, - 'status' => $data['status'], - 'slug' => Str::slug($data['name']), - 'timezone' => $company?->timezone ?: 'Europe/London', - ], - 'country_id' => $data['country_id'] ?? null, - ]; + $data['slug'] = Str::slug($data['name']); + $data['timezone'] = $company?->timezone ?: 'Europe/London'; + + return $data; } - private function countriesForSelect() + private function countries() { return Country::query() ->active() - ->orderByRaw("LOWER(COALESCE(NULLIF(display_name, ''), NULLIF(common_name, ''), name)) ASC") - ->get(['id', 'alpha2', 'name', 'display_name', 'common_name']); - } - - private function syncAddressCountry(Company $company, ?int $countryId): void - { - if ($countryId === null) { - $company->address()?->delete(); - - return; - } - - $company->address()->updateOrCreate( - [], - [ - 'country_id' => $countryId, - 'label' => 'Registered address', - 'is_primary' => true, - ] - ); + ->ordered() + ->get(['id', 'alpha2', 'name', 'display_name', 'common_name', 'sort_order']); } } diff --git a/Modules/Company/Http/Controllers/CompanyDivisionController.php b/Modules/Company/Http/Controllers/CompanyDivisionController.php deleted file mode 100644 index baf3b8ed..00000000 --- a/Modules/Company/Http/Controllers/CompanyDivisionController.php +++ /dev/null @@ -1,54 +0,0 @@ -divisions()->create($this->validated($request, $company)); - - return redirect()->route('platform.companies.show', $company)->with('status', "Division {$division->name} created."); - } - - public function update(Request $request, Company $company, CompanyDivision $division) - { - abort_unless($division->company_id === $company->id, 404); - - $division->update($this->validated($request, $company, $division)); - - return redirect()->route('platform.companies.show', $company)->with('status', 'Division updated.'); - } - - public function destroy(Company $company, CompanyDivision $division) - { - abort_unless($division->company_id === $company->id, 404); - - abort_if($division->warehouses()->exists(), 422, 'Move warehouses before deleting this division.'); - - $division->delete(); - - return redirect()->route('platform.companies.show', $company)->with('status', 'Division deleted.'); - } - - private function validated(Request $request, Company $company, ?CompanyDivision $division = null): array - { - $data = $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'code' => ['nullable', 'string', 'max:40', Rule::unique('company_divisions', 'code')->where('company_id', $company->id)->ignore($division?->id)], - 'status' => ['required', Rule::in(['active', 'suspended', 'inactive'])], - 'description' => ['nullable', 'string', 'max:2000'], - ]); - - $data['slug'] = Str::slug($data['name']); - - return $data; - } -} diff --git a/Modules/Company/Http/Controllers/CompanyMembershipController.php b/Modules/Company/Http/Controllers/CompanyMembershipController.php deleted file mode 100644 index 7ce6d23c..00000000 --- a/Modules/Company/Http/Controllers/CompanyMembershipController.php +++ /dev/null @@ -1,83 +0,0 @@ -validate([ - 'user_id' => ['required', Rule::exists('users', 'id')->where('company_id', $company->id)], - 'company_division_id' => ['required', Rule::exists('company_divisions', 'id')->where('company_id', $company->id)], - 'role' => ['nullable', 'string', 'max:80'], - 'is_director' => ['nullable', 'boolean'], - ]); - - $division = CompanyDivision::where('company_id', $company->id)->findOrFail($data['company_division_id']); - $division->users()->syncWithoutDetaching([ - $data['user_id'] => [ - 'company_id' => $company->id, - 'role' => $data['role'] ?? 'member', - 'is_director' => (bool) ($data['is_director'] ?? false), - ], - ]); - - if ((bool) ($data['is_director'] ?? false)) { - CompanyDirectorAssignment::updateOrCreate([ - 'company_id' => $company->id, - 'company_division_id' => $division->id, - 'user_id' => $data['user_id'], - ], ['coverage_type' => 'division']); - } - - return redirect()->route('platform.companies.show', $company)->with('status', 'Division membership updated.'); - } - - public function assignDirector(Request $request, Company $company) - { - $data = $request->validate([ - 'user_id' => ['required', Rule::exists('users', 'id')->where('company_id', $company->id)], - 'coverage_type' => ['required', Rule::in(['company', 'division'])], - 'company_division_id' => ['nullable', Rule::exists('company_divisions', 'id')->where('company_id', $company->id)], - 'starts_at' => ['nullable', 'date'], - 'ends_at' => ['nullable', 'date', 'after_or_equal:starts_at'], - 'notes' => ['nullable', 'string', 'max:2000'], - ]); - - if ($data['coverage_type'] === 'company') { - $data['company_division_id'] = null; - } else { - abort_if(empty($data['company_division_id']), 422, 'Division coverage requires a division.'); - } - - CompanyDirectorAssignment::updateOrCreate([ - 'company_id' => $company->id, - 'company_division_id' => $data['company_division_id'], - 'user_id' => $data['user_id'], - ], [ - 'coverage_type' => $data['coverage_type'], - 'starts_at' => $data['starts_at'] ?? null, - 'ends_at' => $data['ends_at'] ?? null, - 'notes' => $data['notes'] ?? null, - ]); - - return redirect()->route('platform.companies.show', $company)->with('status', 'Director coverage updated.'); - } - - public function removeDirector(Company $company, CompanyDirectorAssignment $assignment) - { - abort_unless($assignment->company_id === $company->id, 404); - - $assignment->delete(); - - return redirect()->route('platform.companies.show', $company)->with('status', 'Director coverage removed.'); - } -} diff --git a/Modules/Company/Http/Controllers/CompanyWarehouseAssignmentController.php b/Modules/Company/Http/Controllers/CompanyWarehouseAssignmentController.php deleted file mode 100644 index 194be483..00000000 --- a/Modules/Company/Http/Controllers/CompanyWarehouseAssignmentController.php +++ /dev/null @@ -1,25 +0,0 @@ -company_id === $company->id, 404); - - $data = $request->validate([ - 'division_id' => ['nullable', Rule::exists('company_divisions', 'id')->where('company_id', $company->id)], - ]); - - $warehouse->update(['division_id' => $data['division_id'] ?? null]); - - return redirect()->route('platform.companies.show', $company)->with('status', 'Warehouse division assignment updated.'); - } -} diff --git a/Modules/Company/Http/Middleware/.gitkeep b/Modules/Company/Http/Middleware/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Modules/Company/Http/Requests/.gitkeep b/Modules/Company/Http/Requests/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Modules/Company/Models/.gitkeep b/Modules/Company/Models/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Modules/Company/Models/CompanyAddress.php b/Modules/Company/Models/CompanyAddress.php deleted file mode 100644 index 1345e8fe..00000000 --- a/Modules/Company/Models/CompanyAddress.php +++ /dev/null @@ -1,54 +0,0 @@ - 'boolean', - 'metadata' => 'array', - 'latitude' => 'decimal:7', - 'longitude' => 'decimal:7', - ]; - - public function addressable(): MorphTo - { - return $this->morphTo(); - } - - public function country(): BelongsTo - { - return $this->belongsTo(Country::class); - } - - public function subdivision(): BelongsTo - { - return $this->belongsTo(CountrySubdivision::class, 'country_subdivision_id'); - } - - public function getFormattedAttribute(): string - { - return collect([ - $this->attention, - $this->line_1, - $this->line_2, - $this->line_3, - $this->locality, - $this->administrative_area, - $this->postal_code, - $this->country?->effective_name ?? $this->country?->name, - ])->filter()->implode(', '); - } -} diff --git a/Modules/Company/Models/CompanyDirectorAssignment.php b/Modules/Company/Models/CompanyDirectorAssignment.php deleted file mode 100644 index 9c2923ea..00000000 --- a/Modules/Company/Models/CompanyDirectorAssignment.php +++ /dev/null @@ -1,34 +0,0 @@ - 'date', - 'ends_at' => 'date', - ]; - - public function company(): BelongsTo - { - return $this->belongsTo(Company::class); - } - - public function division(): BelongsTo - { - return $this->belongsTo(CompanyDivision::class, 'company_division_id'); - } - - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } -} diff --git a/Modules/Company/Models/CompanyDivision.php b/Modules/Company/Models/CompanyDivision.php deleted file mode 100644 index 20ea2f29..00000000 --- a/Modules/Company/Models/CompanyDivision.php +++ /dev/null @@ -1,62 +0,0 @@ - 'array', - ]; - - protected static function booted(): void - { - static::creating(function (CompanyDivision $division): void { - $division->uuid ??= (string) Str::uuid(); - $division->slug ??= Str::slug($division->name); - $division->status ??= 'active'; - }); - } - - public function company(): BelongsTo - { - return $this->belongsTo(Company::class); - } - - public function address(): MorphOne - { - return $this->morphOne(CompanyAddress::class, 'addressable'); - } - - public function users(): BelongsToMany - { - return $this->belongsToMany(User::class, 'company_division_user') - ->withPivot(['company_id', 'role', 'is_director']) - ->withTimestamps(); - } - - public function warehouses(): HasMany - { - return $this->hasMany(Warehouse::class, 'division_id'); - } - - public function directorAssignments(): HasMany - { - return $this->hasMany(CompanyDirectorAssignment::class, 'company_division_id'); - } -} diff --git a/Modules/Company/Providers/.gitkeep b/Modules/Company/Providers/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Modules/Company/Providers/CompanyServiceProvider.php b/Modules/Company/Providers/CompanyServiceProvider.php deleted file mode 100644 index 49f68ce4..00000000 --- a/Modules/Company/Providers/CompanyServiceProvider.php +++ /dev/null @@ -1,155 +0,0 @@ -registerTranslations(); - $this->registerConfig(); - $this->registerViews(); - $this->registerHelpers(); - $this->registerCommands(); - $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations')); - } - - /** - * Register the service provider. - * - * @return void - */ - public function register(): void - { - $this->app->register(RouteServiceProvider::class); - } - - /** - * Register the helpers. - * - * @return void - */ - protected function registerHelpers(): void - { - foreach (glob(__DIR__.'/../Helpers/*.php') as $filename) { - require_once $filename; - } - } - - /** - * Dynamically Register Commands. - * - * @return void - */ - public function registerCommands(): void - { - if (!$this->app->runningInConsole()) { - return; - } - $commandPath = __DIR__ . '/../Console'; - $commands = collect(File::allFiles($commandPath)) - ->map(function ($file) { - $class = 'Modules\\' . $this->moduleName . '\\Console\\' . $file->getFilenameWithoutExtension(); - return $class; - }) - ->filter(function ($class) { - return class_exists($class); - }) - ->values() - ->all(); - $this->commands($commands); - } - - /** - * Register config. - * - * @return void - */ - protected function registerConfig(): void - { - $this->publishes([ - module_path(ModuleInfo::name(), 'Config/config.php') => config_path( ModuleInfo::nameLower(). '.php'), - ], 'config'); - $this->mergeConfigFrom( - module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower() - ); - } - - /** - * Register views. - * - * @return void - */ - public function registerViews(): void - { - $viewPath = resource_path('views/modules/' . ModuleInfo::nameLower()); - $sourcePath = module_path(ModuleInfo::name(), 'Resources/views'); - $this->publishes([ - $sourcePath => $viewPath - ], ['views', ModuleInfo::nameLower() . '-module-views']); - $this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), ModuleInfo::nameLower()); - } - - /** - * Register translations. - * - * @return void - */ - public function registerTranslations(): void - { - $langPath = resource_path('lang/modules/' . ModuleInfo::nameLower()); - - if (is_dir($langPath)) { - $this->loadTranslationsFrom($langPath, ModuleInfo::nameLower()); - } else { - $this->loadTranslationsFrom(module_path(ModuleInfo::name(), 'Resources/lang'), ModuleInfo::nameLower()); - } - } - - /** - * Regsiter View Paths - * @return array - */ - private function getPublishableViewPaths(): array - { - $paths = []; - foreach (Config::get('view.paths') as $path) { - if (is_dir($path . '/modules/' . ModuleInfo::nameLower())) { - $paths[] = $path . '/modules/' . ModuleInfo::nameLower(); - } - } - return $paths; - } -} diff --git a/Modules/Company/Providers/RouteServiceProvider.php b/Modules/Company/Providers/RouteServiceProvider.php deleted file mode 100644 index 8d38c074..00000000 --- a/Modules/Company/Providers/RouteServiceProvider.php +++ /dev/null @@ -1,70 +0,0 @@ -mapApiRoutes(); - - $this->mapWebRoutes(); - } - - /** - * Define the "web" routes for the application. - * - * These routes all receive session state, CSRF protection, etc. - * - * @return void - */ - protected function mapWebRoutes() - { - Route::middleware('web') - ->namespace($this->moduleNamespace) - ->group(module_path('Company', '/Routes/web.php')); - } - - /** - * Define the "api" routes for the application. - * - * These routes are typically stateless. - * - * @return void - */ - protected function mapApiRoutes() - { - Route::prefix('api') - ->middleware('api') - ->namespace($this->moduleNamespace) - ->group(module_path('Company', '/Routes/api.php')); - } -} diff --git a/Modules/Company/Resources/assets/.gitkeep b/Modules/Company/Resources/assets/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Modules/Company/Resources/assets/js/app.js b/Modules/Company/Resources/assets/js/app.js deleted file mode 100644 index e69de29b..00000000 diff --git a/Modules/Company/Resources/assets/sass/app.scss b/Modules/Company/Resources/assets/sass/app.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/Modules/Company/Resources/lang/.gitkeep b/Modules/Company/Resources/lang/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Modules/Company/Resources/views/.gitkeep b/Modules/Company/Resources/views/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Modules/Company/Resources/views/_form.blade.php b/Modules/Company/Resources/views/_form.blade.php index 340639fe..e044d2d7 100644 --- a/Modules/Company/Resources/views/_form.blade.php +++ b/Modules/Company/Resources/views/_form.blade.php @@ -1,13 +1,13 @@ @csrf -
+
@error('name') @@ -23,7 +23,7 @@ name="billing_email" value="{{ old('billing_email', $company->billing_email) }}" placeholder="billing@example.com" - class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white" + class="mt-2 w-full rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white" > @error('billing_email')

{{ $message }}

@@ -35,7 +35,7 @@ - - @foreach($countries as $country) - + @foreach ($countries as $country) + @endforeach @@ -73,189 +69,15 @@
-
- Cancel
- - -@once - - - - - -@endonce diff --git a/Modules/Company/Resources/views/create.blade.php b/Modules/Company/Resources/views/create.blade.php deleted file mode 100644 index a9274e88..00000000 --- a/Modules/Company/Resources/views/create.blade.php +++ /dev/null @@ -1,16 +0,0 @@ - -
- - -
-
- @include('company::_form') -
-
-
-
diff --git a/Modules/Company/Resources/views/edit.blade.php b/Modules/Company/Resources/views/edit.blade.php deleted file mode 100644 index 1fd4ea26..00000000 --- a/Modules/Company/Resources/views/edit.blade.php +++ /dev/null @@ -1,17 +0,0 @@ - -
- - -
-
- @method('PUT') - @include('company::_form') -
-
-
-
diff --git a/Modules/Company/Resources/views/index.blade.php b/Modules/Company/Resources/views/index.blade.php index ab6fc316..b4515449 100644 --- a/Modules/Company/Resources/views/index.blade.php +++ b/Modules/Company/Resources/views/index.blade.php @@ -30,86 +30,45 @@
-
+ @csrf -
-
- - - @error('name') -

{{ $message }}

- @enderror -
+ -
- - - @error('billing_email') -

{{ $message }}

- @enderror -
+ -
- - - @error('status') -

{{ $message }}

- @enderror -
+ -
- - - @error('country_id') -

{{ $message }}

- @enderror -
- - +
+
+ +
@@ -212,30 +171,4 @@ {{ $companies->links() }}
- - @push('scripts') - - - - @endpush diff --git a/Modules/Company/Resources/views/partials/address-form.blade.php b/Modules/Company/Resources/views/partials/address-form.blade.php deleted file mode 100644 index 9f9cb438..00000000 --- a/Modules/Company/Resources/views/partials/address-form.blade.php +++ /dev/null @@ -1,47 +0,0 @@ -@php($address = $address ?? null) -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
diff --git a/Modules/Company/Resources/views/show.blade.php b/Modules/Company/Resources/views/show.blade.php deleted file mode 100644 index d489c447..00000000 --- a/Modules/Company/Resources/views/show.blade.php +++ /dev/null @@ -1,140 +0,0 @@ - -
- - -
-

Users

{{ $company->users_count }}

-

Divisions

{{ $company->divisions_count }}

-

Warehouses

{{ $company->warehouses_count }}

-

Licences

{{ $company->license_keys_count }}

-

Payments

{{ $company->payments_count }}

-
- -
-
-
-

Company profile

Core tenant identity and operational status.

- Edit company -
-
-
Billing email
{{ $company->billing_email ?? '—' }}
-
Status
{{ $company->is_system_company ? 'System' : ucfirst($company->status) }}
-
Timezone
{{ $company->timezone }}
-
Slug
{{ $company->slug }}
-
-
- -
-

Quick actions

-
- Back to companies - @unless ($company->is_system_company) -
@csrf @method('DELETE')
- @endunless -
-
-
- -
-

Company address

- @if ($company->address) -

{{ $company->address->formatted }}

- @else -

No company address has been recorded.

- @endif -
- @csrf @method('PUT') - @include('company::partials.address-form', ['address' => $company->address, 'defaultLabel' => 'Registered office']) -
-
- -
-
-

Create division

-
- @csrf - - - - - -
-
- -
-

Director coverage

-
- @csrf - - - - -
-
- @forelse($company->directorAssignments as $assignment) -
{{ $assignment->user?->name }} — {{ $assignment->coverage_type === 'company' ? 'Whole company' : $assignment->division?->name }}
@csrf @method('DELETE')
- @empty

No director coverage recorded.

@endforelse -
-
-
- - -
-

Warehouse division and address alignment

-

Warehouses remain tenant-scoped to the company. Assign a division only when that operating structure exists and record an optional warehouse operating address where needed.

-
- @forelse($company->warehouses as $warehouse) -
-
-
-

{{ $warehouse->name }}

-

{{ $warehouse->code }} · {{ $warehouse->division?->name ?? 'Company-level warehouse' }}

- @if ($warehouse->address) -

{{ $warehouse->address->formatted }}

- @else -

No warehouse address recorded.

- @endif -
-
- @csrf @method('PUT') - - -
-
- -
- Edit warehouse address -
- @csrf @method('PUT') - @include('company::partials.address-form', ['address' => $warehouse->address, 'defaultLabel' => 'Warehouse address']) -
-
-
- @empty -

No warehouses exist for this company yet.

- @endforelse -
-
- -
-

Divisions

- @forelse($company->divisions as $division) -
-
-

{{ $division->name }}

{{ $division->code ?? 'No code' }} · {{ ucfirst($division->status) }} · {{ $division->warehouses->count() }} warehouses

-
@csrf @method('DELETE')
-
-
@csrf @method('PUT')
-

Division address

@csrf @method('PUT')@include('company::partials.address-form', ['address' => $division->address, 'defaultLabel' => 'Operating address'])
-
- @empty -
No divisions exist. Warehouses can remain directly under the company until divisions are introduced.
- @endforelse -
-
-
diff --git a/Modules/Company/Routes/.gitkeep b/Modules/Company/Routes/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Modules/Company/Routes/api.php b/Modules/Company/Routes/api.php deleted file mode 100644 index 4367b461..00000000 --- a/Modules/Company/Routes/api.php +++ /dev/null @@ -1,18 +0,0 @@ -get('/company', function (Request $request) { - return $request->user(); -}); \ No newline at end of file diff --git a/Modules/Company/Routes/web.php b/Modules/Company/Routes/web.php deleted file mode 100644 index ef9d49f6..00000000 --- a/Modules/Company/Routes/web.php +++ /dev/null @@ -1,27 +0,0 @@ -prefix('admin') - ->name('platform.') - ->group(function () { - Route::resource('companies', CompanyController::class); - - Route::put('companies/{company}/address', [CompanyAddressController::class, 'updateCompany'])->name('companies.address.update'); - Route::post('companies/{company}/divisions', [CompanyDivisionController::class, 'store'])->name('companies.divisions.store'); - Route::put('companies/{company}/divisions/{division}', [CompanyDivisionController::class, 'update'])->name('companies.divisions.update'); - Route::delete('companies/{company}/divisions/{division}', [CompanyDivisionController::class, 'destroy'])->name('companies.divisions.destroy'); - Route::put('companies/{company}/divisions/{division}/address', [CompanyAddressController::class, 'updateDivision'])->name('companies.divisions.address.update'); - - Route::post('companies/{company}/division-memberships', [CompanyMembershipController::class, 'assignDivision'])->name('companies.division-memberships.store'); - Route::post('companies/{company}/director-assignments', [CompanyMembershipController::class, 'assignDirector'])->name('companies.director-assignments.store'); - Route::delete('companies/{company}/director-assignments/{assignment}', [CompanyMembershipController::class, 'removeDirector'])->name('companies.director-assignments.destroy'); - - Route::put('companies/{company}/warehouses/{warehouse}/division', [CompanyWarehouseAssignmentController::class, 'update'])->name('companies.warehouses.division.update'); - Route::put('companies/{company}/warehouses/{warehouse}/address', [CompanyAddressController::class, 'updateWarehouse'])->name('companies.warehouses.address.update'); - }); diff --git a/Modules/Company/Support/.gitkeep b/Modules/Company/Support/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Modules/Company/Support/ModuleInfo.php b/Modules/Company/Support/ModuleInfo.php deleted file mode 100644 index 97bc1744..00000000 --- a/Modules/Company/Support/ModuleInfo.php +++ /dev/null @@ -1,26 +0,0 @@ -firstOrCreate(['slug' => 'mpba-limited'], ['name' => 'MPBA Limited', 'is_system_company' => true, 'status' => 'active']); - $tenant = Company::query()->firstOrCreate(['slug' => 'sentinel'], ['name' => 'Sentinel', 'status' => 'active']); - Country::query()->firstOrCreate(['alpha2' => 'GB'], ['alpha3' => 'GBR', 'numeric' => '826', 'name' => 'United Kingdom', 'is_active' => true]); - $admin = User::query()->firstOrCreate(['email' => 'company-admin-browser@example.test'], [ - 'company_id' => $system->id, - 'name' => 'Company Browser Admin', - 'password' => bcrypt('password'), - 'is_platform_admin' => true, - 'status' => 'active', - ]); - - $this->browse(function (Browser $browser) use ($admin, $tenant): void { - $browser->loginAs($admin) - ->visitRoute('platform.companies.show', $tenant) - ->assertSee('Company address') - ->assertSee('Create division') - ->assertSee('Director coverage') - ->assertSee('Warehouse division and address alignment') - ->assertSee('Divisions'); - }); - } -} diff --git a/Modules/Company/Tests/Feature/.gitkeep b/Modules/Company/Tests/Feature/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Modules/Company/Tests/Feature/CompanyAdministrationRoutesTest.php b/Modules/Company/Tests/Feature/CompanyAdministrationRoutesTest.php deleted file mode 100644 index e4666c5a..00000000 --- a/Modules/Company/Tests/Feature/CompanyAdministrationRoutesTest.php +++ /dev/null @@ -1,73 +0,0 @@ - 'MPBA Limited', 'is_system_company' => true]); - $tenant = Company::create(['name' => 'Sentinel', 'billing_email' => 'sentinel@example.test']); - $country = Country::create(['alpha2' => 'GB', 'alpha3' => 'GBR', 'numeric' => '826', 'name' => 'United Kingdom', 'is_active' => true]); - $admin = User::create(['company_id' => $system->id, 'name' => 'Platform Admin', 'email' => 'admin@example.test', 'password' => 'password', 'is_platform_admin' => true]); - $director = User::create(['company_id' => $tenant->id, 'name' => 'Division Director', 'email' => 'director@example.test', 'password' => 'password']); - - $this->actingAs($admin)->put(route('platform.companies.address.update', $tenant), [ - 'line_1' => '1 Example Street', - 'locality' => 'London', - 'postal_code' => 'SW1A 1AA', - 'country_id' => $country->id, - ])->assertRedirect(route('platform.companies.show', $tenant)); - - $this->actingAs($admin)->post(route('platform.companies.divisions.store', $tenant), [ - 'name' => 'Northern Operations', - 'code' => 'NORTH', - 'status' => 'active', - ])->assertRedirect(route('platform.companies.show', $tenant)); - - $division = $tenant->divisions()->where('code', 'NORTH')->firstOrFail(); - - $this->actingAs($admin)->post(route('platform.companies.director-assignments.store', $tenant), [ - 'user_id' => $director->id, - 'coverage_type' => 'division', - 'company_division_id' => $division->id, - ])->assertRedirect(route('platform.companies.show', $tenant)); - - $this->assertDatabaseHas('company_addresses', ['line_1' => '1 Example Street', 'country_id' => $country->id]); - $this->assertDatabaseHas('company_divisions', ['company_id' => $tenant->id, 'code' => 'NORTH']); - $this->assertDatabaseHas('company_director_assignments', ['company_id' => $tenant->id, 'company_division_id' => $division->id, 'user_id' => $director->id]); - } - public function test_platform_admin_can_update_warehouse_address(): void - { - $system = Company::create(['name' => 'MPBA Limited', 'is_system_company' => true]); - $tenant = Company::create(['name' => 'Sentinel', 'billing_email' => 'sentinel@example.test']); - $country = Country::create(['alpha2' => 'GB', 'alpha3' => 'GBR', 'numeric' => '826', 'name' => 'United Kingdom', 'is_active' => true]); - $warehouse = Warehouse::create(['company_id' => $tenant->id, 'code' => 'MAIN', 'name' => 'Main Warehouse']); - $admin = User::create(['company_id' => $system->id, 'name' => 'Platform Admin', 'email' => 'warehouse-admin@example.test', 'password' => 'password', 'is_platform_admin' => true]); - - $this->actingAs($admin)->put(route('platform.companies.warehouses.address.update', [$tenant, $warehouse]), [ - 'label' => 'Warehouse address', - 'line_1' => '100 Distribution Park', - 'locality' => 'Birmingham', - 'postal_code' => 'B1 1AA', - 'country_id' => $country->id, - ])->assertRedirect(route('platform.companies.show', $tenant)); - - $this->assertDatabaseHas('company_addresses', [ - 'addressable_type' => Warehouse::class, - 'addressable_id' => $warehouse->id, - 'line_1' => '100 Distribution Park', - 'country_id' => $country->id, - ]); - } - -} diff --git a/Modules/Company/Tests/Feature/CompanyStructureTest.php b/Modules/Company/Tests/Feature/CompanyStructureTest.php deleted file mode 100644 index c6e14706..00000000 --- a/Modules/Company/Tests/Feature/CompanyStructureTest.php +++ /dev/null @@ -1,70 +0,0 @@ - 'Abbott Logistics Limited', 'billing_email' => 'billing@abbott.example']); - $country = Country::create(['alpha2' => 'GB', 'alpha3' => 'GBR', 'numeric' => '826', 'name' => 'United Kingdom', 'is_active' => true]); - - $company->address()->create([ - 'line_1' => '1 Warehouse Way', - 'locality' => 'London', - 'postal_code' => 'SW1A 1AA', - 'country_id' => $country->id, - ]); - - $company->address()->update(['line_1' => '2 Warehouse Way']); - - $this->assertSame(1, CompanyAddress::count()); - $this->assertSame('2 Warehouse Way', $company->fresh()->address->line_1); - $this->assertSame('United Kingdom', $company->fresh()->address->country->name); - } - - public function test_company_division_has_address_users_directors_and_optional_warehouses(): void - { - $company = Company::create(['name' => 'Sentinel', 'billing_email' => 'sentinel@example.test']); - $country = Country::create(['alpha2' => 'GB', 'alpha3' => 'GBR', 'numeric' => '826', 'name' => 'United Kingdom', 'is_active' => true]); - $user = User::create(['company_id' => $company->id, 'name' => 'Jane Director', 'email' => 'jane@example.test', 'password' => 'password']); - $division = $company->divisions()->create(['name' => 'North Operations', 'code' => 'NORTH']); - $warehouse = Warehouse::create(['company_id' => $company->id, 'division_id' => $division->id, 'code' => 'NTH', 'name' => 'North DC']); - - $division->address()->create([ - 'label' => 'Operating address', - 'line_1' => 'North Road', - 'locality' => 'Manchester', - 'country_id' => $country->id, - ]); - $division->users()->attach($user->id, ['company_id' => $company->id, 'role' => 'director', 'is_director' => true]); - CompanyDirectorAssignment::create(['company_id' => $company->id, 'company_division_id' => $division->id, 'user_id' => $user->id, 'coverage_type' => 'division']); - - $this->assertTrue($company->fresh()->divisions()->exists()); - $this->assertSame('North Operations', $warehouse->fresh()->division->name); - $this->assertSame('Manchester', $division->fresh()->address->locality); - $this->assertTrue($user->fresh()->divisions()->whereKey($division->id)->exists()); - $this->assertTrue($user->fresh()->directorAssignments()->where('company_division_id', $division->id)->exists()); - } - - public function test_warehouse_can_remain_at_company_level_without_division(): void - { - $company = Company::create(['name' => 'Company Level Warehousing']); - $warehouse = Warehouse::create(['company_id' => $company->id, 'code' => 'MAIN', 'name' => 'Main Warehouse']); - - $this->assertNull($warehouse->division_id); - $this->assertSame('Company Level Warehousing', $warehouse->company->name); - } -} diff --git a/Modules/Company/Tests/Unit/.gitkeep b/Modules/Company/Tests/Unit/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Modules/Company/Traits/.gitkeep b/Modules/Company/Traits/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Modules/Company/View/.gitkeep b/Modules/Company/View/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Modules/Company/changelog.yaml b/Modules/Company/changelog.yaml deleted file mode 100644 index 5f29b1fe..00000000 --- a/Modules/Company/changelog.yaml +++ /dev/null @@ -1,32 +0,0 @@ -latest: 1.2.1 - -versions: - - version: 1.2.1 - date: 2026-07-07 - changes: - - Replaced company country inputs with a shared Select2-enhanced dropdown populated by the Countries module. - - Aligned the company create forms and changed the primary create/save action to green. - - Stored the selected country on the company address relationship during create and update. - - version: 1.2.0 - date: 2026-07-02 - changes: - - Added one-address-per-owner company address records using an industry-standard address layout backed by the Countries module. - - Added company divisions with optional codes, status, soft deletes, one address per division, user memberships and director coverage. - - Added optional division assignment and proper address relationship support for warehouses. - - Added Company module migrations with foreign keys, unique constraints and performance indexes. - - Added feature and Dusk coverage for company addresses, divisions, director coverage and warehouse division behaviour. - - version: 1.1.0 - date: 2026-07-02 - changes: - - Added full company create, show, edit, update and delete workflow. - - Added company workbench show page with profile, KPI cards, recent users, warehouses and licences. - - Added guarded deletion so the system company cannot be deleted. - - Standardised Company module views on x-layouts::app and company::_form includes. - - version: 1.0.2 - date: 2026-07-02 - changes: - - Fixed company create/update validation when timezone is omitted from request payloads. - - version: 1.0.0 - date: 2026-07-02 - changes: - - Initial Company module skeleton. diff --git a/Modules/Company/composer.json b/Modules/Company/composer.json deleted file mode 100644 index a29a3300..00000000 --- a/Modules/Company/composer.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "mpba/company", - "description": "", - "authors": [ - { - "name": "Mark Gregory", - "email": "mark.gregory@mpba.co.uk" - } - ], - "extra": { - "laravel": { - "providers": [], - "aliases": { - - } - } - }, - "autoload": { - "psr-4": { - "Modules\\Company\\": "" - }, - "autoload-dev": { - "psr-4": { - "Tests\\": "tests/", - "Modules\\Company\\Tests\\": "modules/Company/Tests/" - } - } - } -} diff --git a/Modules/Company/module.json b/Modules/Company/module.json deleted file mode 100644 index 6c43da16..00000000 --- a/Modules/Company/module.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "Company", - "alias": "company", - "version": "1.2.1", - "description": "", - "keywords": [], - "priority": 0, - "providers": [ - "Modules\\Company\\Providers\\CompanyServiceProvider" - ], - "aliases": {}, - "files": [], - "requires": [] -} diff --git a/Modules/Company/package.json b/Modules/Company/package.json deleted file mode 100644 index 4599509f..00000000 --- a/Modules/Company/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "private": true, - "scripts": { - "dev": "npm run development", - "development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", - "watch": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", - "watch-poll": "npm run watch -- --watch-poll", - "hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js", - "prod": "npm run production", - "production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js" - }, - "devDependencies": { - "cross-env": "^7.0", - "laravel-mix": "^5.0.1", - "laravel-mix-merge-manifest": "^0.1.2" - } -} diff --git a/Modules/Company/webpack.mix.js b/Modules/Company/webpack.mix.js deleted file mode 100644 index 04ac3e46..00000000 --- a/Modules/Company/webpack.mix.js +++ /dev/null @@ -1,11 +0,0 @@ -const dotenvExpand = require('dotenv-expand'); -dotenvExpand(require('dotenv').config({ path: '../../.env'/*, debug: true*/})); - -const mix = require('laravel-mix'); - -mix.js(__dirname + '/Resources/assets/js/app.js', 'js/company.js') - .sass( __dirname + '/Resources/assets/sass/app.scss', 'css/company.css'); - -if (mix.inProduction()) { - mix.version(); -} diff --git a/css/app.css b/css/app.css deleted file mode 100644 index 5a7ff4d3..00000000 --- a/css/app.css +++ /dev/null @@ -1,1531 +0,0 @@ - -@import 'tailwindcss'; -@import '../../vendor/livewire/flux/dist/flux.css'; -@import 'flatpickr/dist/flatpickr.css'; - -@source '../views'; -@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; -@source '../../vendor/livewire/flux-pro/stubs/**/*.blade.php'; -@source '../../vendor/livewire/flux/stubs/**/*.blade.php'; - -@custom-variant dark (&:where(.dark, .dark *)); - -@theme { - --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - - --color-zinc-50: #fafafa; - --color-zinc-100: #f5f5f5; - --color-zinc-200: #e5e5e5; - --color-zinc-300: #d4d4d4; - --color-zinc-400: #a3a3a3; - --color-zinc-500: #737373; - --color-zinc-600: #525252; - --color-zinc-700: #404040; - --color-zinc-800: #262626; - --color-zinc-900: #171717; - --color-zinc-950: #0a0a0a; - - --color-accent: var(--color-neutral-800); - --color-accent-content: var(--color-neutral-800); - --color-accent-foreground: var(--color-white); -} - -@layer theme { - .dark { - --color-accent: var(--color-white); - --color-accent-content: var(--color-white); - --color-accent-foreground: var(--color-neutral-800); - } -} - -@layer base { - - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentColor); - } -} - -[data-flux-field]:not(ui-radio, ui-checkbox) { - @apply grid gap-2; -} - -[data-flux-label] { - @apply !mb-0 !leading-tight; -} - -input:focus[data-flux-control], -textarea:focus[data-flux-control], -select:focus[data-flux-control] { - @apply outline-hidden ring-2 ring-accent ring-offset-2 ring-offset-accent-foreground; -} - -/* \[:where(&)\]:size-4 { - @apply size-4; -} */ - -@layer components { - .wms-app-shell { - min-height: 100vh; - display: flex; - background: rgb(250 250 250); - } - - .dark .wms-app-shell { - background: rgb(9 9 11); - } - - .wms-sidebar { - position: sticky; - top: 0; - z-index: 40; - display: flex; - width: 23.5rem; - min-width: 23.5rem; - height: 100vh; - flex-direction: column; - border-right: 1px solid rgb(228 228 231); - background: rgb(250 250 250); - transition: width 180ms ease, min-width 180ms ease, transform 180ms ease; - } - - .dark .wms-sidebar { - border-right-color: rgb(63 63 70); - background: rgb(24 24 27); - } - - .wms-sidebar-header { - display: flex; - align-items: center; - gap: 0.75rem; - min-height: 4.75rem; - padding: 0.95rem; - border-bottom: 1px solid rgb(228 228 231); - } - - .dark .wms-sidebar-header { - border-bottom-color: rgb(63 63 70); - } - - .wms-brand { - display: flex; - align-items: center; - gap: 0.75rem; - min-width: 0; - flex: 1; - color: inherit; - text-decoration: none; - } - - .wms-brand-mark { - display: grid; - width: 2.5rem; - height: 2.5rem; - flex: 0 0 auto; - place-items: center; - border-radius: 0.85rem; - background: linear-gradient(135deg, rgb(15 23 42), rgb(14 116 144)); - color: white; - box-shadow: 0 12px 24px rgb(15 23 42 / 0.22); - } - - .wms-brand-mark-inner { - font-size: 1rem; - font-weight: 800; - letter-spacing: -0.04em; - } - - .wms-brand-copy { - min-width: 0; - display: grid; - line-height: 1.1; - } - - .wms-brand-title { - max-width: 18rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 0.98rem; - font-weight: 800; - color: rgb(24 24 27); - } - - .dark .wms-brand-title { - color: rgb(244 244 245); - } - - .wms-brand-subtitle { - margin-top: 0.18rem; - font-size: 0.72rem; - font-weight: 800; - letter-spacing: 0.14em; - color: rgb(8 145 178); - } - - .wms-sidebar-toggle, - .wms-mobile-close, - .wms-mobile-menu { - display: inline-grid; - place-items: center; - border-radius: 0.65rem; - border: 1px solid rgb(228 228 231); - background: white; - color: rgb(63 63 70); - box-shadow: 0 10px 24px rgb(0 0 0 / 0.08); - transition: background 120ms ease, color 120ms ease, border-color 120ms ease, transform 120ms ease; - } - - .wms-mobile-menu { - border-color: rgb(14 116 144 / 0.22); - background: linear-gradient(135deg, rgb(255 255 255), rgb(236 254 255)); - color: rgb(14 116 144); - } - - .dark .wms-mobile-menu { - border-color: rgb(103 232 249 / 0.22); - background: linear-gradient(135deg, rgb(24 24 27), rgb(8 47 73)); - color: rgb(165 243 252); - } - - .wms-sidebar-toggle { - width: 2.15rem; - height: 2.15rem; - flex: 0 0 auto; - font-size: 1.35rem; - font-weight: 800; - line-height: 1; - } - - .wms-sidebar-toggle:hover, - .wms-mobile-close:hover, - .wms-mobile-menu:hover { - border-color: rgb(14 116 144); - color: rgb(14 116 144); - } - - .dark .wms-sidebar-toggle, - .dark .wms-mobile-close, - .dark .wms-mobile-menu { - border-color: rgb(63 63 70); - background: rgb(39 39 42); - color: rgb(228 228 231); - } - - .wms-toggle-collapsed { - display: none; - } - - .wms-mobile-close { - display: none; - width: 2.15rem; - height: 2.15rem; - font-size: 1.4rem; - line-height: 1; - } - - .wms-sidebar-nav { - flex: 1; - overflow-y: auto; - padding: 0.9rem; - } - - .wms-sidebar-group + .wms-sidebar-group { - margin-top: 1.1rem; - } - - .wms-sidebar-heading { - padding: 0 0.625rem 0.45rem; - font-size: 0.69rem; - font-weight: 800; - letter-spacing: 0.11em; - text-transform: uppercase; - color: rgb(113 113 122); - } - - .wms-sidebar-link { - position: relative; - display: flex; - min-height: 2.65rem; - align-items: center; - gap: 0.78rem; - border-radius: 0.8rem; - padding: 0.65rem 0.75rem; - color: rgb(63 63 70); - font-size: 0.92rem; - font-weight: 650; - text-decoration: none; - transition: background 120ms ease, color 120ms ease; - } - - .wms-sidebar-link:hover { - background: rgb(244 244 245); - color: rgb(14 116 144); - } - - .wms-sidebar-link.is-active { - background: rgb(236 254 255); - color: rgb(14 116 144); - } - - .dark .wms-sidebar-link { - color: rgb(212 212 216); - } - - .dark .wms-sidebar-link:hover { - background: rgb(39 39 42); - color: rgb(103 232 249); - } - - .dark .wms-sidebar-link.is-active { - background: rgb(8 47 73 / 0.55); - color: rgb(103 232 249); - } - - .wms-sidebar-icon { - width: 1.25rem; - min-width: 1.25rem; - text-align: center; - font-size: 1rem; - } - - .wms-sidebar-footer { - display: grid; - gap: 0.25rem; - border-top: 1px solid rgb(228 228 231); - padding: 0.9rem; - } - - .dark .wms-sidebar-footer { - border-top-color: rgb(63 63 70); - } - - .wms-user-card { - margin-top: 0.5rem; - display: flex; - min-width: 0; - align-items: center; - gap: 0.65rem; - border-radius: 0.85rem; - border: 1px solid rgb(228 228 231); - background: white; - padding: 0.65rem; - } - - .wms-user-card-button { - width: 100%; - cursor: pointer; - text-align: left; - } - - .wms-user-card-button:hover { - border-color: rgb(14 116 144); - background: rgb(244 244 245); - } - - .dark .wms-user-card-button:hover { - border-color: rgb(14 116 144); - background: rgb(48 48 52); - } - - .wms-user-chevron { - margin-left: auto; - color: rgb(113 113 122); - font-size: 0.75rem; - } - - .wms-user-rail-button { - display: none; - width: 2.5rem; - height: 2.5rem; - place-items: center; - border-radius: 999px; - background: rgb(15 23 42); - color: white; - font-size: 0.8rem; - font-weight: 800; - } - - .dark .wms-user-card { - border-color: rgb(63 63 70); - background: rgb(39 39 42); - } - - .wms-user-avatar { - display: grid; - width: 2rem; - height: 2rem; - flex: 0 0 auto; - place-items: center; - border-radius: 999px; - background: rgb(15 23 42); - color: white; - font-size: 0.75rem; - font-weight: 800; - } - - .wms-user-copy { - min-width: 0; - } - - .wms-user-name, - .wms-user-email { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .wms-user-name { - font-size: 0.82rem; - font-weight: 750; - } - - .wms-user-email { - font-size: 0.72rem; - color: rgb(113 113 122); - } - - .wms-main-column { - min-width: 0; - flex: 1; - display: flex; - min-height: 100vh; - flex-direction: column; - } - - .wms-content { - display: block; - width: 100%; - min-width: 0; - flex: 1; - padding: 1.5rem; - } - - .wms-main-panel { - min-width: 0; - flex: 1; - display: flex; - min-height: 100vh; - flex-direction: column; - } - - .wms-page-hero { - overflow: hidden; - margin-bottom: 1.5rem; - border-radius: 1.5rem; - border: 1px solid rgb(228 228 231); - background: rgb(255 255 255); - box-shadow: 0 1px 2px rgb(0 0 0 / 0.06), 0 12px 28px rgb(0 0 0 / 0.08); - } - - .dark .wms-page-hero { - border-color: rgb(63 63 70); - background: rgb(24 24 27); - } - - .wms-page-hero-inner { - padding: 1.75rem 1.5rem; - background: linear-gradient(135deg, rgb(2 6 23), rgb(15 23 42) 52%, rgb(23 37 84)); - color: rgb(255 255 255); - } - - .wms-page-hero-row { - display: flex; - flex-direction: column; - gap: 1rem; - } - - .wms-page-hero-copy { - min-width: 0; - max-width: 48rem; - } - - .wms-page-hero-kicker { - margin: 0; - color: rgb(191 219 254); - font-size: 0.75rem; - font-weight: 650; - letter-spacing: 0.28em; - line-height: 1rem; - text-transform: uppercase; - } - - .wms-page-hero-title { - margin: 0.5rem 0 0; - color: rgb(255 255 255); - font-size: 1.875rem; - font-weight: 650; - line-height: 2.25rem; - letter-spacing: -0.025em; - } - - .wms-page-hero-description { - max-width: 48rem; - margin: 0.5rem 0 0; - color: rgb(219 234 254 / 0.90); - font-size: 0.875rem; - line-height: 1.35rem; - } - - .wms-page-hero-actions { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.5rem; - } - - .wms-page-hero-stat { - display: grid; - min-width: 8.5rem; - gap: 0.25rem; - border-radius: 0.85rem; - border: 1px solid rgb(255 255 255 / 0.18); - background: rgb(255 255 255 / 0.10); - padding: 0.7rem 0.85rem; - color: rgb(219 234 254 / 0.90); - box-shadow: 0 10px 24px rgb(0 0 0 / 0.18); - } - - .wms-page-hero-stat span { - font-size: 0.68rem; - font-weight: 800; - letter-spacing: 0.14em; - text-transform: uppercase; - } - - .wms-page-hero-stat strong { - display: flex; - align-items: center; - gap: 0.55rem; - color: white; - font-size: 1.25rem; - font-weight: 800; - line-height: 1; - } - - .wms-page-hero-stat strong i { - color: rgb(134 239 172); - } - - .wms-page-hero-icon-card { - min-width: 3.25rem; - width: 3.25rem; - height: 3.25rem; - place-items: center; - padding: 0; - text-align: center; - } - - .wms-page-hero-icon-card strong { - justify-content: center; - font-size: 1.25rem; - } - - @media (min-width: 1024px) { - .wms-page-hero-row { - flex-direction: row; - align-items: center; - justify-content: space-between; - } - - .wms-page-hero-actions { - justify-content: flex-end; - } - } - - .wms-hero-mobile-menu { - display: none; - width: 2.5rem; - height: 2.5rem; - place-items: center; - align-self: flex-start; - border-radius: 0.85rem; - background: rgb(255 255 255 / 0.12); - color: white; - } - - - .wms-desktop-topbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - min-height: 4.25rem; - border-bottom: 1px solid rgb(228 228 231); - background: rgb(250 250 250 / 0.94); - padding: 0.75rem 1.5rem; - backdrop-filter: blur(14px); - } - - .dark .wms-desktop-topbar { - border-bottom-color: rgb(63 63 70); - background: rgb(15 15 19 / 0.94); - } - - .wms-topbar-brand { - display: inline-flex; - min-width: 0; - align-items: center; - gap: 0.75rem; - color: inherit; - text-decoration: none; - } - - .wms-topbar-user-button { - display: inline-flex; - align-items: center; - gap: 0.7rem; - border-radius: 999px; - border: 1px solid transparent; - padding: 0.35rem 0.45rem 0.35rem 0.85rem; - text-align: right; - transition: background 120ms ease, border-color 120ms ease; - } - - .wms-topbar-user-button:hover { - border-color: rgb(228 228 231); - background: rgb(244 244 245); - } - - .dark .wms-topbar-user-button:hover { - border-color: rgb(63 63 70); - background: rgb(39 39 42); - } - - .wms-topbar-user-copy { - display: grid; - min-width: 0; - } - - .wms-mobile-header { - display: none; - } - - .wms-mobile-overlay { - display: none; - } - - html[data-wms-sidebar='collapsed'] .wms-sidebar { - width: 5.5rem; - min-width: 5.5rem; - } - - html[data-wms-sidebar='collapsed'] [data-wms-sidebar-label] { - display: none !important; - } - - html[data-wms-sidebar='collapsed'] .wms-sidebar-header, - html[data-wms-sidebar='collapsed'] .wms-sidebar-footer, - html[data-wms-sidebar='collapsed'] .wms-sidebar-nav { - align-items: center; - padding-left: 0.7rem; - padding-right: 0.7rem; - } - - html[data-wms-sidebar='collapsed'] .wms-brand { - flex: 0 0 auto; - } - - html[data-wms-sidebar='collapsed'] .wms-sidebar-header { - justify-content: center; - flex-wrap: wrap; - } - - html[data-wms-sidebar='collapsed'] .wms-sidebar-link { - justify-content: center; - padding-left: 0.7rem; - padding-right: 0.7rem; - } - - - html[data-wms-sidebar='collapsed'] .wms-user-rail-button { - display: grid; - } - - html[data-wms-sidebar='collapsed'] .wms-sidebar-footer { - justify-items: center; - } - - html[data-wms-sidebar='collapsed'] .wms-sidebar-toggle { - display: none; - } - - html[data-wms-sidebar='collapsed'] .wms-brand-mark { - cursor: pointer; - } - - @media (max-width: 1023px) { - .wms-desktop-topbar { - display: none; - } - - .wms-app-shell { - display: block; - } - - .wms-sidebar { - position: fixed; - inset: 0 auto 0 0; - width: min(23.5rem, calc(100vw - 2.5rem)); - min-width: min(23.5rem, calc(100vw - 2.5rem)); - transform: translateX(-105%); - box-shadow: 22px 0 60px rgb(0 0 0 / 0.24); - } - - html[data-wms-mobile-sidebar='open'] .wms-sidebar { - transform: translateX(0); - } - - html[data-wms-mobile-sidebar='open'] .wms-mobile-overlay { - position: fixed; - inset: 0; - z-index: 35; - display: block; - background: rgb(0 0 0 / 0.48); - backdrop-filter: blur(2px); - } - - html[data-wms-sidebar='collapsed'] .wms-sidebar { - width: min(23.5rem, calc(100vw - 2.5rem)); - min-width: min(23.5rem, calc(100vw - 2.5rem)); - } - - html[data-wms-sidebar='collapsed'] [data-wms-sidebar-label] { - display: initial !important; - } - - html[data-wms-sidebar='collapsed'] .wms-brand-copy, - html[data-wms-sidebar='collapsed'] .wms-sidebar-heading, - html[data-wms-sidebar='collapsed'] .wms-sidebar-link span { - display: block !important; - } - - html[data-wms-sidebar='collapsed'] .wms-sidebar-link { - justify-content: flex-start; - } - - .wms-sidebar-toggle { - display: none; - } - - .wms-mobile-close { - display: inline-grid; - } - - .wms-mobile-header { - position: sticky; - top: 0; - z-index: 30; - display: flex; - min-height: 4rem; - align-items: center; - gap: 0.75rem; - border-bottom: 1px solid rgb(228 228 231); - background: rgb(255 255 255 / 0.86); - padding: 0.75rem 1rem; - backdrop-filter: blur(14px); - } - - .dark .wms-mobile-header { - border-bottom-color: rgb(63 63 70); - background: rgb(24 24 27 / 0.86); - } - - .wms-mobile-menu { - width: 2.35rem; - height: 2.35rem; - flex: 0 0 auto; - } - - .wms-mobile-title { - min-width: 0; - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: inherit; - font-size: 0.96rem; - font-weight: 800; - text-decoration: none; - } - - .wms-mobile-badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 3.1rem; - height: 2rem; - border-radius: 999px; - border: 1px solid rgb(14 116 144 / 0.24); - background: linear-gradient(135deg, rgb(14 116 144 / 0.14), rgb(37 99 235 / 0.12)); - color: rgb(14 116 144); - font-size: 0.72rem; - font-weight: 900; - letter-spacing: 0.12em; - } - - .dark .wms-mobile-badge { - border-color: rgb(103 232 249 / 0.26); - background: linear-gradient(135deg, rgb(8 145 178 / 0.26), rgb(37 99 235 / 0.20)); - color: rgb(165 243 252); - } - - .wms-page-hero { - max-width: 100%; - margin-bottom: 1rem; - border-radius: 1rem; - } - - .wms-page-hero-inner { - padding: 1.25rem; - } - - .wms-page-hero-actions { - width: 100%; - flex-wrap: wrap; - } - - .wms-page-hero-stat { - min-width: min(10rem, 100%); - flex: 1 1 10rem; - } - - .wms-hero-mobile-menu { - display: inline-grid; - } - - .wms-content { - padding: 1rem; - } - } -} - - -@layer components { - body.wms-mobile-nav-open { - overflow: hidden; - } - - @media (max-width: 1023px) { - .wms-app-shell { - display: block !important; - min-height: 100vh; - } - - .wms-main-panel { - min-height: 100vh; - } - - .wms-mobile-header { - position: sticky !important; - top: 0; - z-index: 50; - display: flex !important; - min-height: 4.25rem; - align-items: center; - gap: 0.8rem; - border-bottom: 1px solid rgb(228 228 231); - background: rgb(255 255 255 / 0.94); - padding: 0.75rem 1rem; - box-shadow: 0 14px 34px rgb(15 23 42 / 0.08); - backdrop-filter: blur(18px); - } - - .dark .wms-mobile-header { - border-bottom-color: rgb(63 63 70); - background: rgb(24 24 27 / 0.94); - box-shadow: 0 14px 34px rgb(0 0 0 / 0.28); - } - - .wms-mobile-menu { - display: inline-grid !important; - width: 2.65rem; - height: 2.65rem; - flex: 0 0 auto; - place-items: center; - border-radius: 0.9rem; - border: 1px solid rgb(14 116 144 / 0.28); - background: linear-gradient(135deg, rgb(255 255 255), rgb(236 254 255)); - color: rgb(14 116 144); - font-size: 1.05rem; - box-shadow: 0 12px 28px rgb(14 116 144 / 0.16); - } - - .dark .wms-mobile-menu { - border-color: rgb(103 232 249 / 0.30); - background: linear-gradient(135deg, rgb(24 24 27), rgb(8 47 73)); - color: rgb(165 243 252); - } - - .wms-mobile-title { - min-width: 0; - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: rgb(24 24 27); - font-size: 0.98rem; - font-weight: 850; - letter-spacing: -0.02em; - text-decoration: none; - } - - .dark .wms-mobile-title { - color: rgb(244 244 245); - } - - .wms-mobile-badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 3.15rem; - height: 2rem; - border-radius: 999px; - border: 1px solid rgb(14 116 144 / 0.24); - background: linear-gradient(135deg, rgb(14 116 144 / 0.14), rgb(37 99 235 / 0.12)); - color: rgb(14 116 144); - font-size: 0.72rem; - font-weight: 900; - letter-spacing: 0.12em; - } - - .dark .wms-mobile-badge { - border-color: rgb(103 232 249 / 0.26); - background: linear-gradient(135deg, rgb(8 145 178 / 0.26), rgb(37 99 235 / 0.20)); - color: rgb(165 243 252); - } - - .wms-sidebar { - position: fixed !important; - inset: 0 auto 0 0; - z-index: 70; - width: min(23.5rem, calc(100vw - 2.25rem)) !important; - min-width: min(23.5rem, calc(100vw - 2.25rem)) !important; - height: 100dvh; - transform: translateX(-110%); - box-shadow: 28px 0 70px rgb(0 0 0 / 0.30); - } - - html[data-wms-mobile-sidebar='open'] .wms-sidebar { - transform: translateX(0) !important; - } - - .wms-mobile-overlay { - position: fixed; - inset: 0; - z-index: 60; - display: none; - width: 100%; - height: 100%; - border: 0; - background: transparent; - padding: 0; - } - - html[data-wms-mobile-sidebar='open'] .wms-mobile-overlay { - display: block !important; - background: rgb(0 0 0 / 0.52); - backdrop-filter: blur(3px); - } - - .wms-mobile-close { - display: inline-grid !important; - } - - .wms-sidebar-toggle { - display: none !important; - } - - html[data-wms-sidebar='collapsed'] [data-wms-sidebar-label], - html[data-wms-sidebar='collapsed'] .wms-brand-copy, - html[data-wms-sidebar='collapsed'] .wms-sidebar-heading, - html[data-wms-sidebar='collapsed'] .wms-sidebar-link span { - display: block !important; - } - - html[data-wms-sidebar='collapsed'] .wms-sidebar-link { - justify-content: flex-start !important; - } - - .wms-content { - padding-top: 1rem; - } - } - - @media (min-width: 1024px) { - .wms-mobile-header, - .wms-mobile-overlay, - .wms-mobile-close { - display: none !important; - } - } -} - -@layer components { - .wms-content, - .wms-main-panel, - .wms-app-shell { - min-width: 0; - } - - .wms-content canvas { - max-width: 100%; - } - - @media (max-width: 1023px) { - .wms-content { - width: 100%; - max-width: 100vw; - overflow-x: hidden; - padding-inline: 0.875rem; - } - - .wms-page-hero { - max-width: 100%; - } - - .wms-sidebar-nav { - -webkit-overflow-scrolling: touch; - } - } -} - -:root { - --wms-sidebar-width: 288px; - --wms-sidebar-min-width: 224px; - --wms-sidebar-max-width: 448px; - --wms-sidebar-collapsed-width: 76px; - - --wms-active-parent-blue: #4164dc; - --wms-active-child-gold: #e7b72f; - --wms-active-child-gold-dark: #e7b72f; -} - -.wms-app-shell { - display: grid; - grid-template-columns: var(--wms-sidebar-width) minmax(0, 1fr); - min-height: 100vh; - transition-property: none; -} - -html[data-wms-sidebar="expanded"] .wms-app-shell { - grid-template-columns: var(--wms-sidebar-width) minmax(0, 1fr); -} - -html[data-wms-sidebar="collapsed"] .wms-app-shell { - grid-template-columns: var(--wms-sidebar-collapsed-width) minmax(0, 1fr); -} - -.wms-sidebar { - position: relative; - width: var(--wms-sidebar-width); - min-width: var(--wms-sidebar-width); - max-width: var(--wms-sidebar-width); - transition-property: none; -} - -.wms-main-panel { - transition-property: none; -} - -html[data-wms-sidebar="collapsed"] .wms-sidebar { - width: var(--wms-sidebar-collapsed-width); - min-width: var(--wms-sidebar-collapsed-width); - max-width: var(--wms-sidebar-collapsed-width); -} - -.wms-sidebar-resizer { - position: absolute; - top: 0; - right: -4px; - z-index: 30; - width: 8px; - height: 100%; - cursor: col-resize; - border: 0; - background: transparent; -} - -.wms-sidebar-resizer::after { - position: absolute; - top: 0; - right: 3px; - width: 1px; - height: 100%; - content: ""; - background: rgba(148, 163, 184, 0.22); - transition: background 160ms ease; -} - -.wms-sidebar-resizer:hover::after, -body.wms-sidebar-is-resizing .wms-sidebar-resizer::after { - background: rgba(59, 130, 246, 0.7); -} - -body.wms-sidebar-is-resizing { - cursor: col-resize; - user-select: none; -} - -html[data-wms-sidebar="collapsed"] .wms-sidebar-resizer { - display: none; -} - -.wms-sidebar-nav { - overflow-x: hidden; -} - -.wms-sidebar-heading-button { - display: flex; - width: 100%; - align-items: center; - justify-content: flex-start; - gap: 0.75rem; - border: 0; - background: transparent; - cursor: pointer; - text-align: left; -} - -.wms-sidebar-heading-content { - display: inline-flex; - align-items: center; - gap: 0.7rem; - min-width: 0; -} - -.wms-sidebar-heading-content > span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.wms-sidebar-heading-chevron { - display: none; -} - -.wms-sidebar-group-items { - display: none; -} - -.wms-sidebar-group[data-wms-menu-open="true"] > .wms-sidebar-group-items { - display: block; -} - -.wms-sidebar-group[data-wms-selected="true"] > .wms-sidebar-heading-button { - position: relative; - width: calc(100% - 1.25rem); - min-height: 2.9rem; - margin: 0.25rem 0.65rem 0.35rem; - padding: 0.6rem 0.85rem; - border-radius: 1.35rem; - background: linear-gradient(135deg, #4667df 0%, #3f5fd2 100%); - color: #ffffff; - font-weight: 700; - letter-spacing: 0.035em; - box-shadow: 0 8px 18px rgba(65, 100, 220, 0.22); -} - -.wms-sidebar-group[data-wms-selected="true"] > .wms-sidebar-heading-button .wms-sidebar-icon { - color: #ffffff; -} - - -.wms-sidebar-primary-link { - margin-bottom: 0.35rem; - min-height: 3.25rem; - font-size: 0.8rem; - font-weight: 800; - letter-spacing: 0.16em; - text-transform: uppercase; -} - -.wms-sidebar-primary-link span { - letter-spacing: 0.16em; -} -.wms-sidebar-link.is-active { - position: relative; - isolation: isolate; - width: calc(100% - 2.05rem); - min-height: 2.85rem; - margin: 0.2rem 1.25rem 0.25rem 0.8rem; - padding: 0.6rem 0.95rem; - border-radius: 1.65rem 0.75rem 0.75rem 1.65rem; - background: var(--wms-active-child-gold); - color: #ffffff; - font-weight: 700; - box-shadow: 0 8px 18px rgba(231, 183, 47, 0.24); -} - -.wms-sidebar-link.is-active::after { - position: absolute; - top: 0; - right: -1.05rem; - z-index: -1; - width: 1.75rem; - height: 100%; - content: ""; - background: var(--wms-active-child-gold); - clip-path: polygon(0 0, 64% 0, 100% 50%, 64% 100%, 0 100%); - border-radius: 0 0.9rem 0.9rem 0; -} - -.wms-sidebar-link.is-active .wms-sidebar-icon, -.wms-sidebar-link.is-active i, -.wms-sidebar-link.is-active span { - position: relative; - z-index: 1; - color: #ffffff; -} - -html[data-wms-sidebar="collapsed"] .wms-sidebar-heading-chevron, -html[data-wms-sidebar="collapsed"] .wms-sidebar-group-items { - display: none; -} - -html[data-wms-sidebar="collapsed"] .wms-sidebar-heading-button { - pointer-events: none; -} - -html[data-wms-sidebar="collapsed"] .wms-sidebar-group[data-wms-selected="true"] > .wms-sidebar-heading-button, -html[data-wms-sidebar="collapsed"] .wms-sidebar-link.is-active { - width: 2.75rem; - min-height: 2.75rem; - margin: 0.25rem auto; - justify-content: center; - border-radius: 9999px; - padding: 0.65rem; -} - -html[data-wms-sidebar="collapsed"] .wms-sidebar-link.is-active::after { - display: none; -} - -@media (max-width: 1023px) { - .wms-app-shell { - display: block; - } - - .wms-sidebar, - html[data-wms-sidebar="collapsed"] .wms-sidebar { - width: min(88vw, 22rem); - min-width: min(88vw, 22rem); - max-width: min(88vw, 22rem); - } - - .wms-sidebar-resizer { - display: none; - } - - .wms-sidebar-group[data-wms-selected="true"] > .wms-sidebar-heading-button { - width: calc(100% - 1.25rem); - min-height: 2.85rem; - margin: 0.25rem 0.65rem 0.35rem; - padding: 0.6rem 0.85rem; - } - - .wms-sidebar-link.is-active { - width: calc(100% - 2.05rem); - min-height: 2.8rem; - margin: 0.2rem 1.25rem 0.25rem 0.8rem; - padding: 0.6rem 0.95rem; - } - - html[data-wms-sidebar="collapsed"] .wms-sidebar-group-items { - display: none; - } - - html[data-wms-sidebar="collapsed"] .wms-sidebar-group[data-wms-menu-open="true"] > .wms-sidebar-group-items { - display: block; - } - - html[data-wms-sidebar="collapsed"] .wms-sidebar-heading-button { - pointer-events: auto; - } -} - -/* -|-------------------------------------------------------------------------- -| WMS fixed sidebar / scrollable content correction -|-------------------------------------------------------------------------- -| -| The application shell must own the viewport. The browser document must not -| scroll; only the right-hand WMS content pane should scroll. Keep this block -| at the end of the stylesheet so it wins over earlier shell declarations. -| -*/ - -html, -body { - width: 100%; - height: 100%; - min-height: 100%; - overflow: hidden !important; - overscroll-behavior: none; -} - -body { - position: fixed; - inset: 0; -} - -.wms-app-shell { - position: fixed !important; - inset: 0 !important; - display: grid !important; - grid-template-columns: var(--wms-sidebar-width) minmax(0, 1fr) !important; - width: 100vw !important; - height: 100vh !important; - min-width: 0 !important; - min-height: 0 !important; - max-height: 100vh !important; - overflow: hidden !important; -} - -@supports (height: 100dvh) { - .wms-app-shell { - height: 100dvh !important; - max-height: 100dvh !important; - } -} - -html[data-wms-sidebar="expanded"] .wms-app-shell { - grid-template-columns: var(--wms-sidebar-width) minmax(0, 1fr) !important; -} - -html[data-wms-sidebar="collapsed"] .wms-app-shell { - grid-template-columns: var(--wms-sidebar-collapsed-width) minmax(0, 1fr) !important; -} - -.wms-sidebar { - position: relative !important; - display: flex !important; - flex-direction: column !important; - width: var(--wms-sidebar-width) !important; - min-width: var(--wms-sidebar-width) !important; - max-width: var(--wms-sidebar-width) !important; - height: 100% !important; - min-height: 0 !important; - max-height: 100% !important; - overflow: hidden !important; -} - -html[data-wms-sidebar="collapsed"] .wms-sidebar { - width: var(--wms-sidebar-collapsed-width) !important; - min-width: var(--wms-sidebar-collapsed-width) !important; - max-width: var(--wms-sidebar-collapsed-width) !important; -} - -.wms-sidebar-header { - flex: 0 0 auto !important; -} - -.wms-sidebar-nav { - flex: 1 1 auto !important; - min-height: 0 !important; - overflow-y: auto !important; - overflow-x: hidden !important; - overscroll-behavior: contain; - -webkit-overflow-scrolling: touch; -} - -.wms-main-panel { - display: flex !important; - flex-direction: column !important; - min-width: 0 !important; - min-height: 0 !important; - width: 100% !important; - height: 100% !important; - max-height: 100% !important; - overflow: hidden !important; -} - -.wms-main-panel > header, -.wms-main-panel > .wms-topbar, -.wms-main-panel > [data-wms-topbar] { - position: relative !important; - top: auto !important; - flex: 0 0 auto !important; - min-height: 0 !important; -} - -.wms-content { - display: block !important; - flex: 1 1 auto !important; - min-width: 0 !important; - min-height: 0 !important; - width: 100% !important; - height: auto !important; - max-height: none !important; - overflow-y: auto !important; - overflow-x: hidden !important; - overscroll-behavior: contain; - -webkit-overflow-scrolling: touch; -} - -@media (max-width: 1023px) { - .wms-app-shell { - display: block !important; - height: 100vh !important; - max-height: 100vh !important; - overflow: hidden !important; - } - - @supports (height: 100dvh) { - .wms-app-shell { - height: 100dvh !important; - max-height: 100dvh !important; - } - } - - .wms-main-panel { - height: 100% !important; - max-height: 100% !important; - overflow: hidden !important; - } - - .wms-sidebar, - html[data-wms-sidebar="collapsed"] .wms-sidebar { - position: fixed !important; - inset: 0 auto 0 0 !important; - z-index: 70 !important; - width: min(88vw, 22rem) !important; - min-width: min(88vw, 22rem) !important; - max-width: min(88vw, 22rem) !important; - height: 100vh !important; - max-height: 100vh !important; - overflow: hidden !important; - } - - @supports (height: 100dvh) { - .wms-sidebar, - html[data-wms-sidebar="collapsed"] .wms-sidebar { - height: 100dvh !important; - max-height: 100dvh !important; - } - } -} - - - -@layer components { - .wms-select2-field .select2-container { - width: 100% !important; - } - - .wms-select2-field .select2-container--default .select2-selection--single { - min-height: 2.875rem; - border-radius: 0.75rem; - border-color: rgb(212 212 216); - background: white; - box-shadow: 0 1px 2px rgb(15 23 42 / 0.04); - transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease; - } - - .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__rendered { - padding-left: 0.95rem; - padding-right: 2.75rem; - color: rgb(24 24 27); - font-size: 0.875rem; - font-weight: 600; - line-height: 2.875rem; - } - - .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__placeholder { - color: rgb(113 113 122); - font-weight: 500; - } - - .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__arrow { - top: 0.45rem; - right: 0.65rem; - width: 1.75rem; - height: 1.75rem; - border-radius: 0.55rem; - background: rgb(244 244 245); - } - - .wms-select2-field .select2-container--default.select2-container--focus .select2-selection--single, - .wms-select2-field .select2-container--default.select2-container--open .select2-selection--single { - border-color: rgb(8 145 178); - box-shadow: 0 0 0 3px rgb(103 232 249 / 0.28); - } - - .select2-container--default .select2-dropdown { - overflow: hidden; - border-radius: 1rem; - border-color: rgb(212 212 216); - background: white; - box-shadow: 0 24px 60px rgb(15 23 42 / 0.18); - } - - .select2-container--default .select2-search--dropdown { - padding: 0.75rem; - } - - .select2-container--default .select2-search--dropdown .select2-search__field { - border-radius: 0.75rem; - border-color: rgb(212 212 216); - padding: 0.55rem 0.75rem; - font-size: 0.875rem; - outline: none; - } - - .select2-container--default .select2-search--dropdown .select2-search__field:focus { - border-color: rgb(8 145 178); - box-shadow: 0 0 0 3px rgb(103 232 249 / 0.28); - } - - .select2-container--default .select2-results__option { - padding: 0.65rem 0.85rem; - font-size: 0.875rem; - font-weight: 600; - } - - .select2-container--default .select2-results__option--highlighted.select2-results__option--selectable { - background: rgb(8 145 178); - color: white; - } - - .select2-container--default .select2-results__option--selected { - background: rgb(236 254 255); - color: rgb(14 116 144); - } - - .dark .wms-select2-field .select2-container--default .select2-selection--single { - border-color: rgb(63 63 70); - background: rgb(9 9 11); - } - - .dark .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__rendered { - color: white; - } - - .dark .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__placeholder { - color: rgb(161 161 170); - } - - .dark .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__arrow { - background: rgb(39 39 42); - } - - .dark .select2-container--default .select2-dropdown { - border-color: rgb(63 63 70); - background: rgb(24 24 27); - color: rgb(244 244 245); - } - - .dark .select2-container--default .select2-search--dropdown .select2-search__field { - border-color: rgb(63 63 70); - background: rgb(9 9 11); - color: white; - } - - .dark .select2-container--default .select2-results__option--selected { - background: rgb(8 47 73); - color: rgb(165 243 252); - } -} diff --git a/js/app.js b/js/app.js deleted file mode 100644 index ee6d0713..00000000 --- a/js/app.js +++ /dev/null @@ -1,222 +0,0 @@ -require('flatpickr'); - -import '@fortawesome/fontawesome-free/css/all.min.css'; -import Chart from 'chart.js/auto'; -import $ from 'jquery'; - -window.$ = $; -window.jQuery = $; - -import 'select2'; -import 'select2/dist/css/select2.min.css'; - -const chartColours = [ - 'rgb(8, 145, 178)', - 'rgb(37, 99, 235)', - 'rgb(79, 70, 229)', - 'rgb(14, 116, 144)', -]; - -/** - * Parse JSON attribute safely. - * - * @param {HTMLElement} element - * @param {string} attribute - * @param {Array} fallback - * @returns {*} - */ -function parseJsonAttribute(element, attribute, fallback = []) { - try { - return JSON.parse( - element.getAttribute(attribute) || JSON.stringify(fallback) - ); - } catch (error) { - return fallback; - } -} - -/** - * Render dashboard charts. - * - * @returns {void} - */ -function renderWmsCharts() { - document.querySelectorAll('[data-wms-chart]').forEach((canvas) => { - if (canvas.dataset.rendered === 'true') { - return; - } - - const type = canvas.getAttribute('data-wms-chart'); - const labels = parseJsonAttribute(canvas, 'data-labels'); - const series = parseJsonAttribute(canvas, 'data-series'); - const values = parseJsonAttribute(canvas, 'data-values'); - - const datasets = series.length - ? series.map((item, index) => ({ - label: item.label, - data: item.data, - backgroundColor: - chartColours[index % chartColours.length], - borderColor: - chartColours[index % chartColours.length], - borderWidth: 2, - borderRadius: 8, - })) - : [ - { - label: 'Total', - data: values, - backgroundColor: chartColours, - borderColor: 'rgb(255,255,255)', - borderWidth: 2, - }, - ]; - - new Chart(canvas, { - type, - data: { - labels, - datasets, - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - labels: { - color: document.documentElement.classList.contains( - 'dark' - ) - ? '#e4e4e7' - : '#3f3f46', - boxWidth: 12, - boxHeight: 12, - }, - }, - }, - scales: - type === 'doughnut' - ? {} - : { - x: { - ticks: { - color: - document.documentElement.classList.contains( - 'dark' - ) - ? '#d4d4d8' - : '#52525b', - }, - grid: { - color: - 'rgba(113,113,122,0.18)', - }, - }, - y: { - beginAtZero: true, - ticks: { - precision: 0, - color: - document.documentElement.classList.contains( - 'dark' - ) - ? '#d4d4d8' - : '#52525b', - }, - grid: { - color: - 'rgba(113,113,122,0.18)', - }, - }, - }, - }, - }); - - canvas.dataset.rendered = 'true'; - }); -} - - -function livewireComponentFor(element) { - const componentRoot = element.closest('[wire\:id]'); - - if (!componentRoot || !window.Livewire) { - return null; - } - - return window.Livewire.find(componentRoot.getAttribute('wire:id')); -} - -function setLivewireProperty(element, model, value) { - const component = livewireComponentFor(element); - - if (!component || !model) { - return; - } - - component.set(model, value === '' ? null : value); -} - -/** - * Initialise Select2 controls. - * - * @returns {void} - */ -function initialiseSelect2() { - if (!window.jQuery?.fn?.select2) { - return; - } - - $('[data-helpdesk-select2], [data-wms-select2]').each(function () { - const $select = $(this); - - if ($select.hasClass('select2-hidden-accessible')) { - $select.select2('destroy'); - } - - const isMultiple = $select.prop('multiple'); - - $select.select2({ - width: '100%', - allowClear: !isMultiple, - closeOnSelect: !isMultiple, - dropdownAutoWidth: true, - placeholder: - $select.data('placeholder') || - $select.find('option[value=""]').first().text() || - (isMultiple ? 'Select options' : 'Select option'), - }); - - $select.off('change.select2-livewire'); - - $select.on('change.select2-livewire', function () { - setLivewireProperty(this, this.dataset.wireModel, $select.val()); - - this.dispatchEvent( - new Event('change', { - bubbles: true, - }) - ); - }); - }); -} - -/** - * Boot page UI. - * - * @returns {void} - */ -function bootUi() { - renderWmsCharts(); - initialiseSelect2(); -} - -document.addEventListener('DOMContentLoaded', bootUi); - -document.addEventListener('livewire:navigated', bootUi); - -document.addEventListener('livewire:init', () => { - Livewire.hook('morphed', () => { - initialiseSelect2(); - }); -}); diff --git a/resources/css/app.css b/resources/css/app.css index 280713c7..5a7ff4d3 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1410,130 +1410,122 @@ html[data-wms-sidebar="collapsed"] .wms-sidebar { } -/* Company country Select2 */ -.wms-country-select + .select2-container { - display: block; - width: 100% !important; - margin-top: 0.5rem; -} -.wms-country-select + .select2-container .select2-selection--single { - height: 3rem; - min-height: 3rem; - display: flex; - align-items: center; - border-radius: 0.75rem; - border: 1px solid rgb(212 212 216); - background: rgb(255 255 255); - box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); - transition: border-color 150ms ease, box-shadow 150ms ease, background-color 150ms ease; -} +@layer components { + .wms-select2-field .select2-container { + width: 100% !important; + } -.dark .wms-country-select + .select2-container .select2-selection--single { - border-color: rgb(63 63 70); - background: rgb(9 9 11); -} + .wms-select2-field .select2-container--default .select2-selection--single { + min-height: 2.875rem; + border-radius: 0.75rem; + border-color: rgb(212 212 216); + background: white; + box-shadow: 0 1px 2px rgb(15 23 42 / 0.04); + transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease; + } -.wms-country-select + .select2-container .select2-selection--single .select2-selection__rendered { - line-height: 3rem; - width: 100%; - padding-left: 1rem; - padding-right: 2.75rem; - color: rgb(24 24 27); - font-size: 0.875rem; -} + .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__rendered { + padding-left: 0.95rem; + padding-right: 2.75rem; + color: rgb(24 24 27); + font-size: 0.875rem; + font-weight: 600; + line-height: 2.875rem; + } -.dark .wms-country-select + .select2-container .select2-selection--single .select2-selection__rendered { - color: rgb(244 244 245); -} + .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__placeholder { + color: rgb(113 113 122); + font-weight: 500; + } -.wms-country-select + .select2-container .select2-selection--single .select2-selection__placeholder { - color: rgb(113 113 122); -} + .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__arrow { + top: 0.45rem; + right: 0.65rem; + width: 1.75rem; + height: 1.75rem; + border-radius: 0.55rem; + background: rgb(244 244 245); + } -.dark .wms-country-select + .select2-container .select2-selection--single .select2-selection__placeholder { - color: rgb(161 161 170); -} + .wms-select2-field .select2-container--default.select2-container--focus .select2-selection--single, + .wms-select2-field .select2-container--default.select2-container--open .select2-selection--single { + border-color: rgb(8 145 178); + box-shadow: 0 0 0 3px rgb(103 232 249 / 0.28); + } -.wms-country-select + .select2-container .select2-selection--single .select2-selection__arrow { - height: 3rem; - right: 0.75rem; - top: 0; -} + .select2-container--default .select2-dropdown { + overflow: hidden; + border-radius: 1rem; + border-color: rgb(212 212 216); + background: white; + box-shadow: 0 24px 60px rgb(15 23 42 / 0.18); + } -.wms-country-select + .select2-container .select2-selection--single .select2-selection__arrow b { - border-color: rgb(113 113 122) transparent transparent transparent; -} + .select2-container--default .select2-search--dropdown { + padding: 0.75rem; + } -.wms-country-select + .select2-container .select2-selection--single .select2-selection__clear { - order: 2; - margin-right: 0.5rem; - color: rgb(113 113 122); - font-size: 1.125rem; - font-weight: 700; -} + .select2-container--default .select2-search--dropdown .select2-search__field { + border-radius: 0.75rem; + border-color: rgb(212 212 216); + padding: 0.55rem 0.75rem; + font-size: 0.875rem; + outline: none; + } -.wms-country-select + .select2-container.select2-container--focus .select2-selection--single, -.wms-country-select + .select2-container.select2-container--open .select2-selection--single { - border-color: rgb(59 130 246); - box-shadow: 0 0 0 3px rgb(59 130 246 / 0.28); -} + .select2-container--default .select2-search--dropdown .select2-search__field:focus { + border-color: rgb(8 145 178); + box-shadow: 0 0 0 3px rgb(103 232 249 / 0.28); + } -.select2-container--open .select2-dropdown { - border: 1px solid rgb(63 63 70); - border-radius: 0.875rem; - overflow: hidden; - background: rgb(255 255 255); - box-shadow: 0 20px 35px -15px rgb(0 0 0 / 0.45); -} + .select2-container--default .select2-results__option { + padding: 0.65rem 0.85rem; + font-size: 0.875rem; + font-weight: 600; + } -.dark .select2-container--open .select2-dropdown { - background: rgb(24 24 27); - border-color: rgb(63 63 70); -} + .select2-container--default .select2-results__option--highlighted.select2-results__option--selectable { + background: rgb(8 145 178); + color: white; + } -.select2-container--default .select2-search--dropdown { - padding: 0.75rem; - border-bottom: 1px solid rgb(228 228 231); -} + .select2-container--default .select2-results__option--selected { + background: rgb(236 254 255); + color: rgb(14 116 144); + } -.dark .select2-container--default .select2-search--dropdown { - border-bottom-color: rgb(63 63 70); -} + .dark .wms-select2-field .select2-container--default .select2-selection--single { + border-color: rgb(63 63 70); + background: rgb(9 9 11); + } -.select2-container--default .select2-search--dropdown .select2-search__field { - min-height: 2.5rem; - border-radius: 0.75rem; - border: 1px solid rgb(212 212 216); - padding: 0.5rem 0.75rem; - color: rgb(24 24 27); - outline: none; -} + .dark .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__rendered { + color: white; + } -.dark .select2-container--default .select2-search--dropdown .select2-search__field { - border-color: rgb(63 63 70); - background: rgb(9 9 11); - color: rgb(244 244 245); -} + .dark .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__placeholder { + color: rgb(161 161 170); + } -.select2-container--default .select2-results__option { - padding: 0.7rem 0.9rem; - font-size: 0.875rem; - color: rgb(39 39 42); -} + .dark .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__arrow { + background: rgb(39 39 42); + } -.dark .select2-container--default .select2-results__option { - color: rgb(228 228 231); -} + .dark .select2-container--default .select2-dropdown { + border-color: rgb(63 63 70); + background: rgb(24 24 27); + color: rgb(244 244 245); + } -.select2-container--default .select2-results__option--highlighted.select2-results__option--selectable, -.select2-container--default .select2-results__option--highlighted[aria-selected] { - background: rgb(37 99 235); - color: white; -} + .dark .select2-container--default .select2-search--dropdown .select2-search__field { + border-color: rgb(63 63 70); + background: rgb(9 9 11); + color: white; + } -.select2-container--default .select2-results__option--selected, -.select2-container--default .select2-results__option[aria-selected="true"] { - background: rgb(22 101 52); - color: white; + .dark .select2-container--default .select2-results__option--selected { + background: rgb(8 47 73); + color: rgb(165 243 252); + } } diff --git a/resources/js/app.js b/resources/js/app.js index 900aeba2..ee6d0713 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -167,7 +167,7 @@ function initialiseSelect2() { return; } - $('[data-helpdesk-select2], [data-company-country-select2]').each(function () { + $('[data-helpdesk-select2], [data-wms-select2]').each(function () { const $select = $(this); if ($select.hasClass('select2-hidden-accessible')) { @@ -176,17 +176,15 @@ function initialiseSelect2() { const isMultiple = $select.prop('multiple'); - const placeholder = - $select.data('placeholder') || - $select.find('option[value=""]').first().text() || - (isMultiple ? 'Select options' : 'Select option'); - $select.select2({ width: '100%', allowClear: !isMultiple, closeOnSelect: !isMultiple, - dropdownAutoWidth: false, - placeholder, + dropdownAutoWidth: true, + placeholder: + $select.data('placeholder') || + $select.find('option[value=""]').first().text() || + (isMultiple ? 'Select options' : 'Select option'), }); $select.off('change.select2-livewire'); From a12372963aa6015532429657728f26bf3da22792 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 7 Jul 2026 12:20:27 +0100 Subject: [PATCH 39/49] interim fix for drop downs, BROKEN FIX, interinm fix --- .../Http/Controllers/CompanyController.php | 8 +- .../Providers/CompanyServiceProvider.php | 15 ++ .../Company/Resources/views/index.blade.php | 128 +++++++++++------- Modules/Company/Routes/web.php | 13 ++ Modules/Company/module.json | 14 ++ Modules/Platform/routes/web.php | 4 + 6 files changed, 132 insertions(+), 50 deletions(-) create mode 100644 Modules/Company/Providers/CompanyServiceProvider.php create mode 100644 Modules/Company/Routes/web.php create mode 100644 Modules/Company/module.json diff --git a/Modules/Company/Http/Controllers/CompanyController.php b/Modules/Company/Http/Controllers/CompanyController.php index 0880b292..09ede975 100644 --- a/Modules/Company/Http/Controllers/CompanyController.php +++ b/Modules/Company/Http/Controllers/CompanyController.php @@ -20,7 +20,7 @@ class CompanyController extends Controller 'companies' => Company::query() ->with(['address.country', 'country']) ->withCount(['users', 'warehouses', 'licenseKeys', 'divisions']) - ->latest('id') + ->orderBy('name') ->paginate(20), 'countries' => $this->countries(), ]); @@ -73,7 +73,7 @@ class CompanyController extends Controller 'recentUsers' => $company->users()->latest('id')->limit(5)->get(), 'recentWarehouses' => $company->warehouses()->latest('id')->limit(5)->get(), 'recentLicenseKeys' => $company->licenseKeys()->latest('id')->limit(5)->get(), - 'countries' => Country::query()->active()->orderBy('sort_order')->orderBy('name')->get(['id', 'alpha2', 'name', 'display_name', 'common_name']), + 'countries' => $this->countries(), 'companyUsers' => $company->users()->orderBy('name')->get(['id', 'name', 'email', 'company_id']), 'unassignedWarehouses' => $company->warehouses()->whereNull('division_id')->orderBy('name')->get(), ]); @@ -135,7 +135,7 @@ class CompanyController extends Controller { return Country::query() ->active() - ->ordered() - ->get(['id', 'alpha2', 'name', 'display_name', 'common_name', 'sort_order']); + ->orderByRaw("LOWER(COALESCE(NULLIF(display_name, ''), NULLIF(common_name, ''), name)) ASC") + ->get(['id', 'alpha2', 'name', 'display_name', 'common_name']); } } diff --git a/Modules/Company/Providers/CompanyServiceProvider.php b/Modules/Company/Providers/CompanyServiceProvider.php new file mode 100644 index 00000000..d6c4b1bc --- /dev/null +++ b/Modules/Company/Providers/CompanyServiceProvider.php @@ -0,0 +1,15 @@ +loadRoutesFrom(__DIR__ . '/../Routes/web.php'); + $this->loadViewsFrom(__DIR__ . '/../Resources/views', 'company'); + $this->loadMigrationsFrom(__DIR__ . '/../Database/Migrations'); + } +} diff --git a/Modules/Company/Resources/views/index.blade.php b/Modules/Company/Resources/views/index.blade.php index b4515449..cbf798f3 100644 --- a/Modules/Company/Resources/views/index.blade.php +++ b/Modules/Company/Resources/views/index.blade.php @@ -7,8 +7,6 @@ icon="fa-solid fa-building" /> - - @if (session('status'))
{{ session('status') }} @@ -30,47 +28,85 @@
-
+ @csrf - +
+
+ + + @error('name') +

{{ $message }}

+ @enderror +
- +
+ + + @error('billing_email') +

{{ $message }}

+ @enderror +
- +
+ + + @error('status') +

{{ $message }}

+ @enderror +
-
- +
+ + + @error('country_id') +

{{ $message }}

+ @enderror +
+ +
- -
+
@@ -109,17 +145,17 @@ - - - - - - - - - + + + + @empty - + @endforelse diff --git a/Modules/Company/Resources/views/show.blade.php b/Modules/Company/Resources/views/show.blade.php new file mode 100644 index 00000000..4e80fa21 --- /dev/null +++ b/Modules/Company/Resources/views/show.blade.php @@ -0,0 +1,66 @@ + +
+ + + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + +
+
+

Users

+

{{ $company->users_count ?? 0 }}

+
+
+

Divisions

+

{{ $company->divisions_count ?? 0 }}

+
+
+

Warehouses

+

{{ $company->warehouses_count ?? 0 }}

+
+
+

Licences

+

{{ $company->license_keys_count ?? 0 }}

+
+
+ +
+
+
+

Company details

+
+
+
Billing email
+
{{ $company->billing_email ?: 'Not set' }}
+
+
+
Country
+
{{ $company->country?->display_name ?: $company->country?->common_name ?: $company->country?->name ?: 'Not set' }}
+
+
+
Status
+
{{ ucfirst($company->status) }}
+
+
+
Timezone
+
{{ $company->timezone }}
+
+
+
+ +
+ Edit + Back +
+
+
+
+
diff --git a/Modules/Platform/routes/web.php b/Modules/Platform/routes/web.php index 215514b4..fe86f8ed 100644 --- a/Modules/Platform/routes/web.php +++ b/Modules/Platform/routes/web.php @@ -24,10 +24,8 @@ Route::middleware(['auth', 'verified', 'tenant.assigned', 'platform.admin']) Route::post('/licenses', [LicenseKeyController::class, 'store'])->name('licenses.store'); Route::get('/payments', [PaymentController::class, 'index'])->name('payments.index'); Route::post('/payments', [PaymentController::class, 'store'])->name('payments.store'); - if (! Route::has('platform.companies.index')) { - Route::resource('/companies', CompanyController::class); - } Route::resource('/prices', PlatformPriceController::class)->except(['show']); + Route::resource('/companies', CompanyController::class); }); Route::post('/stripe/webhook', StripeWebhookController::class)->name('stripe.webhook'); From e1f132fa7bc9c9c6b236ec5c526283b07b8ad0fb Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 7 Jul 2026 13:24:22 +0100 Subject: [PATCH 41/49] interim fix for drop downs, BROKEN FIX, interinm fix --- .../Company/Resources/views/_form.blade.php | 3 +- .../Company/Resources/views/index.blade.php | 3 +- resources/css/app.css | 167 ++++++++++++++++++ resources/js/app.js | 44 +++-- 4 files changed, 201 insertions(+), 16 deletions(-) diff --git a/Modules/Company/Resources/views/_form.blade.php b/Modules/Company/Resources/views/_form.blade.php index a5bf21dc..08a1cf43 100644 --- a/Modules/Company/Resources/views/_form.blade.php +++ b/Modules/Company/Resources/views/_form.blade.php @@ -53,8 +53,9 @@ id="country_id" name="country_id" data-wms-select2 + data-company-country-select2 data-placeholder="Search and select a country" - class="w-full" + class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white" > @foreach ($countries as $country) diff --git a/Modules/Company/Resources/views/index.blade.php b/Modules/Company/Resources/views/index.blade.php index 1f3661a6..50c76931 100644 --- a/Modules/Company/Resources/views/index.blade.php +++ b/Modules/Company/Resources/views/index.blade.php @@ -85,8 +85,9 @@ id="quick_country_id" name="country_id" data-wms-select2 + data-company-country-select2 data-placeholder="Search and select country" - class="w-full" + class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white" > @foreach($countries as $country) diff --git a/resources/css/app.css b/resources/css/app.css index 5a7ff4d3..7fa64d0d 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1529,3 +1529,170 @@ html[data-wms-sidebar="collapsed"] .wms-sidebar { color: rgb(165 243 252); } } + + +/* WMS professional Select2 fields ------------------------------------------------- + These rules are intentionally global because company, warehouse, country and + helpdesk forms all use the same Select2 control pattern. */ +@layer components { + .wms-select2-field select[data-wms-select2], + .wms-select2-field select[data-company-country-select2] { + margin-top: 0.5rem; + height: 3rem; + width: 100%; + border-radius: 0.75rem; + border: 1px solid rgb(212 212 216); + background-color: white; + padding-left: 1rem; + padding-right: 2.5rem; + font-size: 0.875rem; + font-weight: 500; + color: rgb(24 24 27); + box-shadow: 0 1px 2px rgb(15 23 42 / 0.04); + } + + .dark .wms-select2-field select[data-wms-select2], + .dark .wms-select2-field select[data-company-country-select2] { + border-color: rgb(63 63 70); + background-color: rgb(9 9 11); + color: white; + } + + .wms-select2-field .select2-container { + margin-top: 0.5rem; + width: 100% !important; + display: block; + } + + .wms-select2-field .select2-container--default .select2-selection--single, + .select2-container--default.wms-select2-dropdown-owner .select2-selection--single { + height: 3rem; + min-height: 3rem; + border-radius: 0.75rem; + border: 1px solid rgb(212 212 216); + background: white; + box-shadow: 0 1px 2px rgb(15 23 42 / 0.04); + outline: none; + transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease; + } + + .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__rendered { + height: 3rem; + padding-left: 1rem; + padding-right: 3rem; + color: rgb(24 24 27); + font-size: 0.875rem; + font-weight: 600; + line-height: 3rem; + } + + .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__placeholder { + color: rgb(113 113 122); + font-weight: 500; + } + + .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__clear { + height: 3rem; + margin-right: 2rem; + color: rgb(113 113 122); + font-size: 1.1rem; + line-height: 3rem; + } + + .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__arrow { + top: 0; + right: 0.75rem; + height: 3rem; + width: 1.75rem; + } + + .wms-select2-field .select2-container--default.select2-container--focus .select2-selection--single, + .wms-select2-field .select2-container--default.select2-container--open .select2-selection--single { + border-color: rgb(37 99 235); + box-shadow: 0 0 0 3px rgb(37 99 235 / 0.22); + } + + .select2-container--open { + z-index: 99999; + } + + .select2-container--default .select2-dropdown { + overflow: hidden; + border: 1px solid rgb(63 63 70); + border-radius: 0.875rem; + background: white; + box-shadow: 0 24px 60px rgb(15 23 42 / 0.26); + } + + .select2-container--default .select2-search--dropdown { + padding: 0.75rem; + } + + .select2-container--default .select2-search--dropdown .select2-search__field { + min-height: 2.75rem; + border-radius: 0.75rem; + border: 1px solid rgb(212 212 216); + background: white; + padding: 0 0.875rem; + color: rgb(24 24 27); + font-size: 0.875rem; + outline: none; + } + + .select2-container--default .select2-search--dropdown .select2-search__field:focus { + border-color: rgb(37 99 235); + box-shadow: 0 0 0 3px rgb(37 99 235 / 0.22); + } + + .select2-container--default .select2-results__option { + padding: 0.75rem 1rem; + color: rgb(24 24 27); + font-size: 0.875rem; + font-weight: 600; + } + + .select2-container--default .select2-results__option--highlighted.select2-results__option--selectable { + background: rgb(37 99 235); + color: white; + } + + .select2-container--default .select2-results__option--selected { + background: rgb(239 246 255); + color: rgb(30 64 175); + } + + .dark .wms-select2-field .select2-container--default .select2-selection--single { + border-color: rgb(63 63 70); + background: rgb(9 9 11); + } + + .dark .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__rendered { + color: white; + } + + .dark .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__placeholder, + .dark .wms-select2-field .select2-container--default .select2-selection--single .select2-selection__clear { + color: rgb(161 161 170); + } + + .dark .select2-container--default .select2-dropdown { + border-color: rgb(63 63 70); + background: rgb(24 24 27); + color: white; + } + + .dark .select2-container--default .select2-search--dropdown .select2-search__field { + border-color: rgb(63 63 70); + background: rgb(9 9 11); + color: white; + } + + .dark .select2-container--default .select2-results__option { + color: rgb(244 244 245); + } + + .dark .select2-container--default .select2-results__option--selected { + background: rgb(30 58 138 / 0.55); + color: rgb(191 219 254); + } +} diff --git a/resources/js/app.js b/resources/js/app.js index ee6d0713..776ad753 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -3,12 +3,13 @@ require('flatpickr'); import '@fortawesome/fontawesome-free/css/all.min.css'; import Chart from 'chart.js/auto'; import $ from 'jquery'; +import select2 from 'select2/dist/js/select2.full.js'; +import 'select2/dist/css/select2.min.css'; window.$ = $; window.jQuery = $; -import 'select2'; -import 'select2/dist/css/select2.min.css'; +select2(window, $); const chartColours = [ 'rgb(8, 145, 178)', @@ -167,32 +168,47 @@ function initialiseSelect2() { return; } - $('[data-helpdesk-select2], [data-wms-select2]').each(function () { - const $select = $(this); + $('[data-helpdesk-select2], [data-wms-select2], [data-company-country-select2]').each(function () { + const select = this; + const $select = $(select); if ($select.hasClass('select2-hidden-accessible')) { - $select.select2('destroy'); + return; } const isMultiple = $select.prop('multiple'); + const placeholder = + $select.data('placeholder') || + $select.find('option[value=""]').first().text() || + (isMultiple ? 'Select options' : 'Select option'); + + if (!isMultiple && !$select.find('option[value=""]').length) { + $select.prepend(new Option('', '', false, false)); + } $select.select2({ width: '100%', allowClear: !isMultiple, closeOnSelect: !isMultiple, - dropdownAutoWidth: true, - placeholder: - $select.data('placeholder') || - $select.find('option[value=""]').first().text() || - (isMultiple ? 'Select options' : 'Select option'), + dropdownAutoWidth: false, + placeholder, + dropdownParent: $('body'), + sorter: function (data) { + return data.sort(function (a, b) { + return (a.text || '').localeCompare(b.text || '', undefined, { + sensitivity: 'base', + numeric: true, + }); + }); + }, }); - $select.off('change.select2-livewire'); + $select.off('change.wms-select2-livewire'); - $select.on('change.select2-livewire', function () { - setLivewireProperty(this, this.dataset.wireModel, $select.val()); + $select.on('change.wms-select2-livewire', function () { + setLivewireProperty(select, select.dataset.wireModel, $select.val()); - this.dispatchEvent( + select.dispatchEvent( new Event('change', { bubbles: true, }) From 96bae35060c6d6cfd61d2e14aa3eea63ae81dce8 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 7 Jul 2026 13:38:25 +0100 Subject: [PATCH 42/49] interim fix for drop downs, BROKEN FIX, interinm fix --- .../Company/Resources/views/_form.blade.php | 3 +- .../Company/Resources/views/index.blade.php | 3 +- resources/css/wms-select2.css | 157 ++++++++++++++++++ resources/js/app.js | 48 +++--- 4 files changed, 179 insertions(+), 32 deletions(-) create mode 100644 resources/css/wms-select2.css diff --git a/Modules/Company/Resources/views/_form.blade.php b/Modules/Company/Resources/views/_form.blade.php index 08a1cf43..a5bf21dc 100644 --- a/Modules/Company/Resources/views/_form.blade.php +++ b/Modules/Company/Resources/views/_form.blade.php @@ -53,9 +53,8 @@ id="country_id" name="country_id" data-wms-select2 - data-company-country-select2 data-placeholder="Search and select a country" - class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white" + class="w-full" > @foreach ($countries as $country) diff --git a/Modules/Company/Resources/views/index.blade.php b/Modules/Company/Resources/views/index.blade.php index 50c76931..1f3661a6 100644 --- a/Modules/Company/Resources/views/index.blade.php +++ b/Modules/Company/Resources/views/index.blade.php @@ -85,9 +85,8 @@ id="quick_country_id" name="country_id" data-wms-select2 - data-company-country-select2 data-placeholder="Search and select country" - class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white" + class="w-full" > @foreach($countries as $country) diff --git a/resources/css/wms-select2.css b/resources/css/wms-select2.css new file mode 100644 index 00000000..afd4dea1 --- /dev/null +++ b/resources/css/wms-select2.css @@ -0,0 +1,157 @@ +/* WMS Select2 professional dark theme. + This file is imported after Select2's own CSS so it wins reliably. */ + +.wms-select2-field .select2-container, +.select2-container--wms { + width: 100% !important; + display: block !important; +} + +.wms-select2-field select[data-wms-select2].select2-hidden-accessible { + position: absolute !important; + width: 1px !important; + height: 1px !important; + overflow: hidden !important; + clip: rect(0 0 0 0) !important; + white-space: nowrap !important; + border: 0 !important; + padding: 0 !important; + margin: -1px !important; +} + +.wms-select2-field .select2-container { + margin-top: 0.5rem !important; +} + +.select2-container--wms .select2-selection--single { + height: 3rem !important; + min-height: 3rem !important; + border: 1px solid rgb(63 63 70) !important; + border-radius: 0.75rem !important; + background: rgb(9 9 11) !important; + box-shadow: 0 1px 2px rgb(0 0 0 / 0.2) !important; + display: flex !important; + align-items: center !important; + outline: none !important; + transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease !important; +} + +.select2-container--wms.select2-container--focus .select2-selection--single, +.select2-container--wms.select2-container--open .select2-selection--single { + border-color: rgb(59 130 246) !important; + box-shadow: 0 0 0 3px rgb(59 130 246 / 0.24) !important; +} + +.select2-container--wms .select2-selection--single .select2-selection__rendered { + width: 100% !important; + padding: 0 3rem 0 1rem !important; + color: rgb(244 244 245) !important; + font-size: 0.875rem !important; + font-weight: 500 !important; + line-height: 3rem !important; +} + +.select2-container--wms .select2-selection--single .select2-selection__placeholder { + color: rgb(161 161 170) !important; + font-weight: 500 !important; +} + +.select2-container--wms .select2-selection--single .select2-selection__arrow { + position: absolute !important; + top: 0.5rem !important; + right: 0.625rem !important; + width: 2rem !important; + height: 2rem !important; + border-radius: 0.625rem !important; + background: rgb(39 39 42) !important; +} + +.select2-container--wms .select2-selection--single .select2-selection__arrow b { + border-color: rgb(212 212 216) transparent transparent transparent !important; + border-width: 5px 4px 0 4px !important; + margin-left: -4px !important; + margin-top: -2px !important; +} + +.select2-container--wms.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent rgb(212 212 216) transparent !important; + border-width: 0 4px 5px 4px !important; +} + +.select2-container--wms .select2-selection--single .select2-selection__clear { + position: absolute !important; + right: 2.85rem !important; + top: 0.5rem !important; + width: 2rem !important; + height: 2rem !important; + border-radius: 0.625rem !important; + color: rgb(161 161 170) !important; + font-size: 1.25rem !important; + line-height: 1.85rem !important; + text-align: center !important; +} + +.select2-container--wms .select2-selection--single .select2-selection__clear:hover { + background: rgb(39 39 42) !important; + color: rgb(244 244 245) !important; +} + +.select2-container--wms .select2-dropdown { + overflow: hidden !important; + border: 1px solid rgb(63 63 70) !important; + border-radius: 0.875rem !important; + background: rgb(24 24 27) !important; + color: rgb(244 244 245) !important; + box-shadow: 0 24px 60px rgb(0 0 0 / 0.45) !important; +} + +.select2-container--wms .select2-search--dropdown { + padding: 0.75rem !important; + background: rgb(24 24 27) !important; +} + +.select2-container--wms .select2-search--dropdown .select2-search__field { + height: 2.75rem !important; + border: 1px solid rgb(63 63 70) !important; + border-radius: 0.75rem !important; + background: rgb(9 9 11) !important; + color: rgb(244 244 245) !important; + padding: 0 0.875rem !important; + font-size: 0.875rem !important; + outline: none !important; +} + +.select2-container--wms .select2-search--dropdown .select2-search__field:focus { + border-color: rgb(59 130 246) !important; + box-shadow: 0 0 0 3px rgb(59 130 246 / 0.22) !important; +} + +.select2-container--wms .select2-results__options { + background: rgb(24 24 27) !important; + max-height: 16rem !important; +} + +.select2-container--wms .select2-results__option { + padding: 0.75rem 1rem !important; + color: rgb(244 244 245) !important; + font-size: 0.875rem !important; + font-weight: 500 !important; +} + +.select2-container--wms .select2-results__option--highlighted.select2-results__option--selectable { + background: rgb(37 99 235) !important; + color: white !important; +} + +.select2-container--wms .select2-results__option--selected { + background: rgb(30 41 59) !important; + color: rgb(191 219 254) !important; +} + +.select2-container--wms .select2-results__option[aria-disabled='true'] { + color: rgb(113 113 122) !important; +} + +.select2-container--wms .select2-results__message { + color: rgb(161 161 170) !important; +} diff --git a/resources/js/app.js b/resources/js/app.js index 776ad753..53936f79 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -3,13 +3,13 @@ require('flatpickr'); import '@fortawesome/fontawesome-free/css/all.min.css'; import Chart from 'chart.js/auto'; import $ from 'jquery'; -import select2 from 'select2/dist/js/select2.full.js'; -import 'select2/dist/css/select2.min.css'; window.$ = $; window.jQuery = $; -select2(window, $); +import 'select2'; +import 'select2/dist/css/select2.min.css'; +import '../css/wms-select2.css'; const chartColours = [ 'rgb(8, 145, 178)', @@ -168,47 +168,39 @@ function initialiseSelect2() { return; } - $('[data-helpdesk-select2], [data-wms-select2], [data-company-country-select2]').each(function () { - const select = this; - const $select = $(select); + $('[data-helpdesk-select2], [data-wms-select2]').each(function () { + const $select = $(this); if ($select.hasClass('select2-hidden-accessible')) { - return; + $select.select2('destroy'); } const isMultiple = $select.prop('multiple'); - const placeholder = - $select.data('placeholder') || - $select.find('option[value=""]').first().text() || - (isMultiple ? 'Select options' : 'Select option'); - - if (!isMultiple && !$select.find('option[value=""]').length) { - $select.prepend(new Option('', '', false, false)); - } $select.select2({ + theme: 'wms', width: '100%', allowClear: !isMultiple, closeOnSelect: !isMultiple, dropdownAutoWidth: false, - placeholder, - dropdownParent: $('body'), - sorter: function (data) { - return data.sort(function (a, b) { - return (a.text || '').localeCompare(b.text || '', undefined, { - sensitivity: 'base', - numeric: true, - }); - }); + placeholder: + $select.data('placeholder') || + $select.find('option[value=""]').first().text() || + (isMultiple ? 'Select options' : 'Select option'), + sorter(data) { + return data.sort((first, second) => first.text.localeCompare(second.text, undefined, { + sensitivity: 'base', + ignorePunctuation: true, + })); }, }); - $select.off('change.wms-select2-livewire'); + $select.off('change.select2-livewire'); - $select.on('change.wms-select2-livewire', function () { - setLivewireProperty(select, select.dataset.wireModel, $select.val()); + $select.on('change.select2-livewire', function () { + setLivewireProperty(this, this.dataset.wireModel, $select.val()); - select.dispatchEvent( + this.dispatchEvent( new Event('change', { bubbles: true, }) From d90c945e83f706e2442e2bdec939db1ba43baad0 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 7 Jul 2026 13:55:26 +0100 Subject: [PATCH 43/49] countries selection fixed --- resources/css/app.css | 126 ++++++++++++++++++++++++++++++++++ resources/css/wms-select2.css | 21 ++++++ resources/js/app.js | 8 ++- 3 files changed, 152 insertions(+), 3 deletions(-) diff --git a/resources/css/app.css b/resources/css/app.css index 7fa64d0d..11d75bba 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1696,3 +1696,129 @@ html[data-wms-sidebar="collapsed"] .wms-sidebar { color: rgb(191 219 254); } } + + +/* Company/country Select2 dark override ------------------------------------------------- + Keep the Select2 country field visually consistent with the WMS dark form inputs. */ +.wms-select2-field .select2-container, +.wms-select2-field .select2-container--wms, +.wms-select2-field .select2-container--default { + margin-top: 0.5rem !important; + width: 100% !important; + display: block !important; +} + +.wms-select2-field .select2-container--wms .select2-selection--single, +.wms-select2-field .select2-container--default .select2-selection--single { + height: 3rem !important; + min-height: 3rem !important; + border: 1px solid rgb(63 63 70) !important; + border-radius: 0.75rem !important; + background: rgb(9 9 11) !important; + box-shadow: none !important; + display: flex !important; + align-items: center !important; +} + +.wms-select2-field .select2-container--wms.select2-container--focus .select2-selection--single, +.wms-select2-field .select2-container--wms.select2-container--open .select2-selection--single, +.wms-select2-field .select2-container--default.select2-container--focus .select2-selection--single, +.wms-select2-field .select2-container--default.select2-container--open .select2-selection--single { + border-color: rgb(59 130 246) !important; + box-shadow: 0 0 0 3px rgb(59 130 246 / 0.24) !important; +} + +.wms-select2-field .select2-selection--single .select2-selection__rendered { + width: 100% !important; + padding-left: 1rem !important; + padding-right: 3rem !important; + color: rgb(244 244 245) !important; + font-size: 0.875rem !important; + font-weight: 500 !important; + line-height: 3rem !important; +} + +.wms-select2-field .select2-selection--single .select2-selection__placeholder { + color: rgb(161 161 170) !important; + font-weight: 500 !important; +} + +.wms-select2-field .select2-selection--single .select2-selection__clear { + position: absolute !important; + right: 2.75rem !important; + top: 0.5rem !important; + width: 2rem !important; + height: 2rem !important; + color: rgb(161 161 170) !important; + font-size: 1.2rem !important; + line-height: 1.85rem !important; + text-align: center !important; +} + +.wms-select2-field .select2-selection--single .select2-selection__arrow { + position: absolute !important; + top: 0.5rem !important; + right: 0.625rem !important; + width: 2rem !important; + height: 2rem !important; + border-radius: 0.625rem !important; + background: rgb(39 39 42) !important; +} + +.wms-select2-field .select2-selection--single .select2-selection__arrow b { + border-color: rgb(212 212 216) transparent transparent transparent !important; +} + +.select2-container--wms .select2-dropdown, +.select2-container--default .select2-dropdown { + overflow: hidden !important; + border: 1px solid rgb(63 63 70) !important; + border-radius: 0.875rem !important; + background: rgb(24 24 27) !important; + color: rgb(244 244 245) !important; + box-shadow: 0 24px 60px rgb(0 0 0 / 0.45) !important; +} + +.select2-container--wms .select2-search--dropdown, +.select2-container--default .select2-search--dropdown { + padding: 0.75rem !important; + background: rgb(24 24 27) !important; +} + +.select2-container--wms .select2-search--dropdown .select2-search__field, +.select2-container--default .select2-search--dropdown .select2-search__field { + height: 2.75rem !important; + border: 1px solid rgb(63 63 70) !important; + border-radius: 0.75rem !important; + background: rgb(9 9 11) !important; + color: rgb(244 244 245) !important; + padding: 0 0.875rem !important; + font-size: 0.875rem !important; + outline: none !important; +} + +.select2-container--wms .select2-results__options, +.select2-container--default .select2-results__options { + background: rgb(24 24 27) !important; + max-height: 16rem !important; +} + +.select2-container--wms .select2-results__option, +.select2-container--default .select2-results__option { + padding: 0.75rem 1rem !important; + color: rgb(244 244 245) !important; + font-size: 0.875rem !important; + font-weight: 500 !important; +} + +.select2-container--wms .select2-results__option--highlighted.select2-results__option--selectable, +.select2-container--default .select2-results__option--highlighted.select2-results__option--selectable { + background: rgb(37 99 235) !important; + color: white !important; +} + +.select2-container--wms .select2-results__option--selected, +.select2-container--default .select2-results__option--selected { + background: rgb(30 41 59) !important; + color: rgb(191 219 254) !important; +} diff --git a/resources/css/wms-select2.css b/resources/css/wms-select2.css index afd4dea1..20e71182 100644 --- a/resources/css/wms-select2.css +++ b/resources/css/wms-select2.css @@ -155,3 +155,24 @@ .select2-container--wms .select2-results__message { color: rgb(161 161 170) !important; } + +/* Safety: any WMS Select2 target should disappear once Select2 has initialised, + even if a view is not wrapped in .wms-select2-field. */ +select[data-wms-select2].select2-hidden-accessible, +select[data-company-country-select2].select2-hidden-accessible, +select[data-helpdesk-select2].select2-hidden-accessible { + position: absolute !important; + width: 1px !important; + height: 1px !important; + overflow: hidden !important; + clip: rect(0 0 0 0) !important; + white-space: nowrap !important; + border: 0 !important; + padding: 0 !important; + margin: -1px !important; +} + +/* Keep country Select2 controls aligned with the surrounding WMS form fields. */ +.wms-select2-field .select2-container--wms { + margin-top: 0.5rem !important; +} diff --git a/resources/js/app.js b/resources/js/app.js index 53936f79..93818bcd 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -7,10 +7,12 @@ import $ from 'jquery'; window.$ = $; window.jQuery = $; -import 'select2'; +import attachSelect2 from 'select2'; import 'select2/dist/css/select2.min.css'; import '../css/wms-select2.css'; +attachSelect2(window.jQuery); + const chartColours = [ 'rgb(8, 145, 178)', 'rgb(37, 99, 235)', @@ -168,11 +170,11 @@ function initialiseSelect2() { return; } - $('[data-helpdesk-select2], [data-wms-select2]').each(function () { + $('[data-helpdesk-select2], [data-wms-select2], [data-company-country-select2]').each(function () { const $select = $(this); if ($select.hasClass('select2-hidden-accessible')) { - $select.select2('destroy'); + return; } const isMultiple = $select.prop('multiple'); From ec27834043f72cfa7ed3f9bf941628b95b01b492 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 7 Jul 2026 15:55:17 +0100 Subject: [PATCH 44/49] countries selection fixed --- ..._120000_create_company_addresses_table.php | 40 +++ ..._121000_create_company_divisions_table.php | 34 ++ ...000_create_company_division_user_table.php | 30 ++ ...ate_company_director_assignments_table.php | 32 ++ ...00_add_division_id_to_warehouses_table.php | 27 ++ .../Controllers/CompanyAddressController.php | 61 ++++ .../Http/Controllers/CompanyController.php | 66 ++-- .../Controllers/CompanyDivisionController.php | 54 +++ .../CompanyMembershipController.php | 83 +++++ .../CompanyWarehouseAssignmentController.php | 25 ++ Modules/Company/Models/Company.php | 2 +- Modules/Company/Models/CompanyAddress.php | 29 +- .../Models/CompanyDirectorAssignment.php | 19 +- Modules/Company/Models/CompanyDivision.php | 33 +- .../views/partials/address-form.blade.php | 71 ++++ .../Company/Resources/views/show.blade.php | 325 +++++++++++++++--- Modules/Company/Routes/web.php | 32 +- resources/css/app.css | 126 ------- resources/css/wms-select2.css | 125 ++++--- resources/js/app.js | 30 +- 20 files changed, 945 insertions(+), 299 deletions(-) create mode 100644 Modules/Company/Database/Migrations/2026_07_02_120000_create_company_addresses_table.php create mode 100644 Modules/Company/Database/Migrations/2026_07_02_121000_create_company_divisions_table.php create mode 100644 Modules/Company/Database/Migrations/2026_07_02_122000_create_company_division_user_table.php create mode 100644 Modules/Company/Database/Migrations/2026_07_02_123000_create_company_director_assignments_table.php create mode 100644 Modules/Company/Database/Migrations/2026_07_02_124000_add_division_id_to_warehouses_table.php create mode 100644 Modules/Company/Http/Controllers/CompanyAddressController.php create mode 100644 Modules/Company/Http/Controllers/CompanyDivisionController.php create mode 100644 Modules/Company/Http/Controllers/CompanyMembershipController.php create mode 100644 Modules/Company/Http/Controllers/CompanyWarehouseAssignmentController.php create mode 100644 Modules/Company/Resources/views/partials/address-form.blade.php diff --git a/Modules/Company/Database/Migrations/2026_07_02_120000_create_company_addresses_table.php b/Modules/Company/Database/Migrations/2026_07_02_120000_create_company_addresses_table.php new file mode 100644 index 00000000..627f7f17 --- /dev/null +++ b/Modules/Company/Database/Migrations/2026_07_02_120000_create_company_addresses_table.php @@ -0,0 +1,40 @@ +id(); + $table->morphs('addressable'); + $table->string('label', 80)->default('Registered office'); + $table->string('attention')->nullable(); + $table->string('line_1'); + $table->string('line_2')->nullable(); + $table->string('line_3')->nullable(); + $table->string('locality')->comment('City, town or locality.'); + $table->string('administrative_area')->nullable()->comment('County, state, province or region.'); + $table->string('postal_code', 32)->nullable(); + $table->foreignId('country_id')->constrained('countries')->restrictOnDelete(); + $table->foreignId('country_subdivision_id')->nullable()->constrained('country_subdivisions')->nullOnDelete(); + $table->decimal('latitude', 10, 7)->nullable(); + $table->decimal('longitude', 10, 7)->nullable(); + $table->boolean('is_primary')->default(true); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->unique(['addressable_type', 'addressable_id'], 'company_addresses_one_per_owner_unique'); + $table->index(['country_id', 'locality']); + $table->index(['postal_code']); + }); + } + + public function down(): void + { + Schema::dropIfExists('company_addresses'); + } +}; diff --git a/Modules/Company/Database/Migrations/2026_07_02_121000_create_company_divisions_table.php b/Modules/Company/Database/Migrations/2026_07_02_121000_create_company_divisions_table.php new file mode 100644 index 00000000..44cad595 --- /dev/null +++ b/Modules/Company/Database/Migrations/2026_07_02_121000_create_company_divisions_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('company_id')->constrained('companies')->cascadeOnDelete(); + $table->uuid('uuid')->unique(); + $table->string('name'); + $table->string('code', 40)->nullable(); + $table->string('slug'); + $table->string('status', 32)->default('active')->index(); + $table->text('description')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['company_id', 'slug']); + $table->unique(['company_id', 'code']); + $table->index(['company_id', 'status', 'name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('company_divisions'); + } +}; diff --git a/Modules/Company/Database/Migrations/2026_07_02_122000_create_company_division_user_table.php b/Modules/Company/Database/Migrations/2026_07_02_122000_create_company_division_user_table.php new file mode 100644 index 00000000..16fed17b --- /dev/null +++ b/Modules/Company/Database/Migrations/2026_07_02_122000_create_company_division_user_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('company_id')->constrained('companies')->cascadeOnDelete(); + $table->foreignId('company_division_id')->constrained('company_divisions')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('role', 80)->default('member')->index(); + $table->boolean('is_director')->default(false)->index(); + $table->timestamps(); + + $table->unique(['company_division_id', 'user_id']); + $table->index(['company_id', 'user_id']); + $table->index(['company_id', 'is_director']); + }); + } + + public function down(): void + { + Schema::dropIfExists('company_division_user'); + } +}; diff --git a/Modules/Company/Database/Migrations/2026_07_02_123000_create_company_director_assignments_table.php b/Modules/Company/Database/Migrations/2026_07_02_123000_create_company_director_assignments_table.php new file mode 100644 index 00000000..c539c515 --- /dev/null +++ b/Modules/Company/Database/Migrations/2026_07_02_123000_create_company_director_assignments_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('company_id')->constrained('companies')->cascadeOnDelete(); + $table->foreignId('company_division_id')->nullable()->constrained('company_divisions')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('coverage_type', 32)->default('company')->index(); + $table->date('starts_at')->nullable(); + $table->date('ends_at')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->unique(['company_id', 'company_division_id', 'user_id'], 'company_director_coverage_unique'); + $table->index(['company_id', 'coverage_type']); + $table->index(['user_id', 'coverage_type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('company_director_assignments'); + } +}; diff --git a/Modules/Company/Database/Migrations/2026_07_02_124000_add_division_id_to_warehouses_table.php b/Modules/Company/Database/Migrations/2026_07_02_124000_add_division_id_to_warehouses_table.php new file mode 100644 index 00000000..b7c52b09 --- /dev/null +++ b/Modules/Company/Database/Migrations/2026_07_02_124000_add_division_id_to_warehouses_table.php @@ -0,0 +1,27 @@ +foreignId('division_id')->nullable()->after('company_id')->constrained('company_divisions')->nullOnDelete(); + $table->index(['company_id', 'division_id']); + } + }); + } + + public function down(): void + { + Schema::table('warehouses', function (Blueprint $table): void { + if (Schema::hasColumn('warehouses', 'division_id')) { + $table->dropConstrainedForeignId('division_id'); + } + }); + } +}; diff --git a/Modules/Company/Http/Controllers/CompanyAddressController.php b/Modules/Company/Http/Controllers/CompanyAddressController.php new file mode 100644 index 00000000..abf17a7a --- /dev/null +++ b/Modules/Company/Http/Controllers/CompanyAddressController.php @@ -0,0 +1,61 @@ +upsertAddress($request, $company); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Company address updated.'); + } + + public function updateDivision(Request $request, Company $company, CompanyDivision $division) + { + abort_unless($division->company_id === $company->id, 404); + + $this->upsertAddress($request, $division, 'Operating address'); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Division address updated.'); + } + + public function updateWarehouse(Request $request, Company $company, Warehouse $warehouse) + { + abort_unless($warehouse->company_id === $company->id, 404); + + $this->upsertAddress($request, $warehouse, 'Warehouse address'); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Warehouse address updated.'); + } + + private function upsertAddress(Request $request, object $owner, string $defaultLabel = 'Registered office'): void + { + $data = $request->validate([ + 'label' => ['nullable', 'string', 'max:80'], + 'attention' => ['nullable', 'string', 'max:255'], + 'line_1' => ['required', 'string', 'max:255'], + 'line_2' => ['nullable', 'string', 'max:255'], + 'line_3' => ['nullable', 'string', 'max:255'], + 'locality' => ['required', 'string', 'max:255'], + 'administrative_area' => ['nullable', 'string', 'max:255'], + 'postal_code' => ['nullable', 'string', 'max:32'], + 'country_id' => ['required', Rule::exists('countries', 'id')->where('is_active', true)], + 'country_subdivision_id' => ['nullable', 'exists:country_subdivisions,id'], + 'latitude' => ['nullable', 'numeric', 'between:-90,90'], + 'longitude' => ['nullable', 'numeric', 'between:-180,180'], + ]); + + $data['label'] = $data['label'] ?: $defaultLabel; + $data['is_primary'] = true; + + $owner->address()->updateOrCreate([], $data); + } +} diff --git a/Modules/Company/Http/Controllers/CompanyController.php b/Modules/Company/Http/Controllers/CompanyController.php index 729e98ec..1acd93a6 100644 --- a/Modules/Company/Http/Controllers/CompanyController.php +++ b/Modules/Company/Http/Controllers/CompanyController.php @@ -4,7 +4,6 @@ namespace Modules\Company\Http\Controllers; use App\Http\Controllers\Controller; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Modules\Company\Models\Company; @@ -17,24 +16,12 @@ class CompanyController extends Controller */ public function index() { - $counts = ['users', 'warehouses', 'licenseKeys']; - - if (Schema::hasTable('company_divisions')) { - $counts[] = 'divisions'; - } - - $companies = Company::query() - ->with(['country']) - ->withCount($counts) - ->orderBy('name') - ->paginate(20); - - $companies->getCollection()->each(function (Company $company): void { - $company->setAttribute('divisions_count', $company->getAttribute('divisions_count') ?? 0); - }); - return view('company::index', [ - 'companies' => $companies, + 'companies' => Company::query() + ->with(['address.country', 'country']) + ->withCount(['users', 'warehouses', 'licenseKeys', 'divisions']) + ->orderBy('name') + ->paginate(20), 'countries' => $this->countries(), ]); } @@ -70,20 +57,17 @@ class CompanyController extends Controller */ public function show(Company $company) { - $loads = ['country']; - $counts = ['users', 'warehouses', 'licenseKeys', 'payments']; - - if (Schema::hasTable('company_divisions')) { - $loads[] = 'divisions'; - $counts[] = 'divisions'; - } - - if (Schema::hasTable('company_director_assignments')) { - $loads[] = 'directorAssignments.user'; - } - - $company->load($loads)->loadCount($counts); - $company->setAttribute('divisions_count', $company->getAttribute('divisions_count') ?? 0); + $company->load([ + 'address.country', + 'country', + 'divisions.address.country', + 'divisions.warehouses', + 'divisions.users', + 'warehouses.address.country', + 'warehouses.division', + 'directorAssignments.user', + 'directorAssignments.division', + ])->loadCount(['users', 'warehouses', 'licenseKeys', 'payments', 'divisions']); return view('company::show', [ 'company' => $company, @@ -91,6 +75,8 @@ class CompanyController extends Controller 'recentWarehouses' => $company->warehouses()->latest('id')->limit(5)->get(), 'recentLicenseKeys' => $company->licenseKeys()->latest('id')->limit(5)->get(), 'countries' => $this->countries(), + 'companyUsers' => $company->users()->orderBy('name')->get(['id', 'name', 'email', 'company_id']), + 'unassignedWarehouses' => $company->warehouses()->whereNull('division_id')->orderBy('name')->get(), ]); } @@ -100,7 +86,7 @@ class CompanyController extends Controller public function edit(Company $company) { return view('company::edit', [ - 'company' => $company->loadMissing('country'), + 'company' => $company->loadMissing(['address.country', 'country']), 'countries' => $this->countries(), ]); } @@ -140,14 +126,10 @@ class CompanyController extends Controller 'country_id' => ['nullable', Rule::exists('countries', 'id')->where('is_active', true)], ]); - return [ - 'name' => $data['name'], - 'billing_email' => $data['billing_email'] ?? null, - 'status' => $data['status'], - 'country_id' => $data['country_id'] ?? null, - 'slug' => Str::slug($data['name']), - 'timezone' => $company?->timezone ?: 'Europe/London', - ]; + $data['slug'] = Str::slug($data['name']); + $data['timezone'] = $company?->timezone ?: 'Europe/London'; + + return $data; } private function countries() @@ -155,6 +137,6 @@ class CompanyController extends Controller return Country::query() ->active() ->orderByRaw("LOWER(COALESCE(NULLIF(display_name, ''), NULLIF(common_name, ''), name)) ASC") - ->get(['id', 'alpha2', 'name', 'display_name', 'common_name']); + ->get(['id', 'alpha2', 'name', 'display_name', 'common_name', 'sort_order']); } } diff --git a/Modules/Company/Http/Controllers/CompanyDivisionController.php b/Modules/Company/Http/Controllers/CompanyDivisionController.php new file mode 100644 index 00000000..baf3b8ed --- /dev/null +++ b/Modules/Company/Http/Controllers/CompanyDivisionController.php @@ -0,0 +1,54 @@ +divisions()->create($this->validated($request, $company)); + + return redirect()->route('platform.companies.show', $company)->with('status', "Division {$division->name} created."); + } + + public function update(Request $request, Company $company, CompanyDivision $division) + { + abort_unless($division->company_id === $company->id, 404); + + $division->update($this->validated($request, $company, $division)); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Division updated.'); + } + + public function destroy(Company $company, CompanyDivision $division) + { + abort_unless($division->company_id === $company->id, 404); + + abort_if($division->warehouses()->exists(), 422, 'Move warehouses before deleting this division.'); + + $division->delete(); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Division deleted.'); + } + + private function validated(Request $request, Company $company, ?CompanyDivision $division = null): array + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'code' => ['nullable', 'string', 'max:40', Rule::unique('company_divisions', 'code')->where('company_id', $company->id)->ignore($division?->id)], + 'status' => ['required', Rule::in(['active', 'suspended', 'inactive'])], + 'description' => ['nullable', 'string', 'max:2000'], + ]); + + $data['slug'] = Str::slug($data['name']); + + return $data; + } +} diff --git a/Modules/Company/Http/Controllers/CompanyMembershipController.php b/Modules/Company/Http/Controllers/CompanyMembershipController.php new file mode 100644 index 00000000..7ce6d23c --- /dev/null +++ b/Modules/Company/Http/Controllers/CompanyMembershipController.php @@ -0,0 +1,83 @@ +validate([ + 'user_id' => ['required', Rule::exists('users', 'id')->where('company_id', $company->id)], + 'company_division_id' => ['required', Rule::exists('company_divisions', 'id')->where('company_id', $company->id)], + 'role' => ['nullable', 'string', 'max:80'], + 'is_director' => ['nullable', 'boolean'], + ]); + + $division = CompanyDivision::where('company_id', $company->id)->findOrFail($data['company_division_id']); + $division->users()->syncWithoutDetaching([ + $data['user_id'] => [ + 'company_id' => $company->id, + 'role' => $data['role'] ?? 'member', + 'is_director' => (bool) ($data['is_director'] ?? false), + ], + ]); + + if ((bool) ($data['is_director'] ?? false)) { + CompanyDirectorAssignment::updateOrCreate([ + 'company_id' => $company->id, + 'company_division_id' => $division->id, + 'user_id' => $data['user_id'], + ], ['coverage_type' => 'division']); + } + + return redirect()->route('platform.companies.show', $company)->with('status', 'Division membership updated.'); + } + + public function assignDirector(Request $request, Company $company) + { + $data = $request->validate([ + 'user_id' => ['required', Rule::exists('users', 'id')->where('company_id', $company->id)], + 'coverage_type' => ['required', Rule::in(['company', 'division'])], + 'company_division_id' => ['nullable', Rule::exists('company_divisions', 'id')->where('company_id', $company->id)], + 'starts_at' => ['nullable', 'date'], + 'ends_at' => ['nullable', 'date', 'after_or_equal:starts_at'], + 'notes' => ['nullable', 'string', 'max:2000'], + ]); + + if ($data['coverage_type'] === 'company') { + $data['company_division_id'] = null; + } else { + abort_if(empty($data['company_division_id']), 422, 'Division coverage requires a division.'); + } + + CompanyDirectorAssignment::updateOrCreate([ + 'company_id' => $company->id, + 'company_division_id' => $data['company_division_id'], + 'user_id' => $data['user_id'], + ], [ + 'coverage_type' => $data['coverage_type'], + 'starts_at' => $data['starts_at'] ?? null, + 'ends_at' => $data['ends_at'] ?? null, + 'notes' => $data['notes'] ?? null, + ]); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Director coverage updated.'); + } + + public function removeDirector(Company $company, CompanyDirectorAssignment $assignment) + { + abort_unless($assignment->company_id === $company->id, 404); + + $assignment->delete(); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Director coverage removed.'); + } +} diff --git a/Modules/Company/Http/Controllers/CompanyWarehouseAssignmentController.php b/Modules/Company/Http/Controllers/CompanyWarehouseAssignmentController.php new file mode 100644 index 00000000..194be483 --- /dev/null +++ b/Modules/Company/Http/Controllers/CompanyWarehouseAssignmentController.php @@ -0,0 +1,25 @@ +company_id === $company->id, 404); + + $data = $request->validate([ + 'division_id' => ['nullable', Rule::exists('company_divisions', 'id')->where('company_id', $company->id)], + ]); + + $warehouse->update(['division_id' => $data['division_id'] ?? null]); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Warehouse division assignment updated.'); + } +} diff --git a/Modules/Company/Models/Company.php b/Modules/Company/Models/Company.php index e3dd2a77..2437ee75 100644 --- a/Modules/Company/Models/Company.php +++ b/Modules/Company/Models/Company.php @@ -10,8 +10,8 @@ use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Str; use Modules\Platform\app\Models\LicenseKey; -use Modules\Platform\app\Models\Payment; use Modules\Countries\Models\Country; +use Modules\Platform\app\Models\Payment; use Modules\Warehouse\app\Models\Warehouse; class Company extends Model diff --git a/Modules/Company/Models/CompanyAddress.php b/Modules/Company/Models/CompanyAddress.php index 27f84f71..1345e8fe 100644 --- a/Modules/Company/Models/CompanyAddress.php +++ b/Modules/Company/Models/CompanyAddress.php @@ -6,13 +6,21 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; use Modules\Countries\Models\Country; +use Modules\Countries\Models\CountrySubdivision; class CompanyAddress extends Model { - protected $guarded = []; + protected $fillable = [ + 'addressable_type', 'addressable_id', 'label', 'attention', 'line_1', 'line_2', 'line_3', + 'locality', 'administrative_area', 'postal_code', 'country_id', 'country_subdivision_id', + 'latitude', 'longitude', 'is_primary', 'metadata', + ]; protected $casts = [ 'is_primary' => 'boolean', + 'metadata' => 'array', + 'latitude' => 'decimal:7', + 'longitude' => 'decimal:7', ]; public function addressable(): MorphTo @@ -24,4 +32,23 @@ class CompanyAddress extends Model { return $this->belongsTo(Country::class); } + + public function subdivision(): BelongsTo + { + return $this->belongsTo(CountrySubdivision::class, 'country_subdivision_id'); + } + + public function getFormattedAttribute(): string + { + return collect([ + $this->attention, + $this->line_1, + $this->line_2, + $this->line_3, + $this->locality, + $this->administrative_area, + $this->postal_code, + $this->country?->effective_name ?? $this->country?->name, + ])->filter()->implode(', '); + } } diff --git a/Modules/Company/Models/CompanyDirectorAssignment.php b/Modules/Company/Models/CompanyDirectorAssignment.php index d5ae3435..9c2923ea 100644 --- a/Modules/Company/Models/CompanyDirectorAssignment.php +++ b/Modules/Company/Models/CompanyDirectorAssignment.php @@ -8,20 +8,27 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class CompanyDirectorAssignment extends Model { - protected $guarded = []; + protected $fillable = [ + 'company_id', 'company_division_id', 'user_id', 'coverage_type', 'starts_at', 'ends_at', 'notes', + ]; + + protected $casts = [ + 'starts_at' => 'date', + 'ends_at' => 'date', + ]; public function company(): BelongsTo { return $this->belongsTo(Company::class); } - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - public function division(): BelongsTo { return $this->belongsTo(CompanyDivision::class, 'company_division_id'); } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } } diff --git a/Modules/Company/Models/CompanyDivision.php b/Modules/Company/Models/CompanyDivision.php index af5e12dd..20ea2f29 100644 --- a/Modules/Company/Models/CompanyDivision.php +++ b/Modules/Company/Models/CompanyDivision.php @@ -5,13 +5,33 @@ namespace Modules\Company\Models; use App\Models\User; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphOne; +use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Str; use Modules\Warehouse\app\Models\Warehouse; class CompanyDivision extends Model { - protected $guarded = []; + use SoftDeletes; + + protected $fillable = [ + 'company_id', 'uuid', 'name', 'code', 'slug', 'status', 'description', 'metadata', + ]; + + protected $casts = [ + 'metadata' => 'array', + ]; + + protected static function booted(): void + { + static::creating(function (CompanyDivision $division): void { + $division->uuid ??= (string) Str::uuid(); + $division->slug ??= Str::slug($division->name); + $division->status ??= 'active'; + }); + } public function company(): BelongsTo { @@ -23,13 +43,20 @@ class CompanyDivision extends Model return $this->morphOne(CompanyAddress::class, 'addressable'); } + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'company_division_user') + ->withPivot(['company_id', 'role', 'is_director']) + ->withTimestamps(); + } + public function warehouses(): HasMany { return $this->hasMany(Warehouse::class, 'division_id'); } - public function users(): HasMany + public function directorAssignments(): HasMany { - return $this->hasMany(User::class, 'division_id'); + return $this->hasMany(CompanyDirectorAssignment::class, 'company_division_id'); } } diff --git a/Modules/Company/Resources/views/partials/address-form.blade.php b/Modules/Company/Resources/views/partials/address-form.blade.php new file mode 100644 index 00000000..4becfa54 --- /dev/null +++ b/Modules/Company/Resources/views/partials/address-form.blade.php @@ -0,0 +1,71 @@ +@php + $address = $address ?? null; + $inputClass = 'mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white'; +@endphp + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
diff --git a/Modules/Company/Resources/views/show.blade.php b/Modules/Company/Resources/views/show.blade.php index 4e80fa21..3dcb3c60 100644 --- a/Modules/Company/Resources/views/show.blade.php +++ b/Modules/Company/Resources/views/show.blade.php @@ -1,66 +1,289 @@
- + - @if (session('status')) -
- {{ session('status') }} -
- @endif +
+

Users

{{ $company->users_count }}

+

Divisions

{{ $company->divisions_count }}

+

Warehouses

{{ $company->warehouses_count }}

+

Licences

{{ $company->license_keys_count }}

+

Payments

{{ $company->payments_count }}

+
-
-
-

Users

-

{{ $company->users_count ?? 0 }}

+
+
+
+

Company profile

Core tenant identity and operational status.

+ Edit company +
+
+
Billing email
{{ $company->billing_email ?? '—' }}
+
Status
{{ $company->is_system_company ? 'System' : ucfirst($company->status) }}
+
Timezone
{{ $company->timezone }}
+
Country
{{ $company->country?->display_name ?: $company->country?->common_name ?: $company->country?->name ?: '—' }}
+
Slug
{{ $company->slug }}
+
-
-

Divisions

-

{{ $company->divisions_count ?? 0 }}

-
-
-

Warehouses

-

{{ $company->warehouses_count ?? 0 }}

-
-
-

Licences

-

{{ $company->license_keys_count ?? 0 }}

+ +
+

Quick actions

+
+ Back to companies + @unless ($company->is_system_company) +
@csrf @method('DELETE') + @endunless +
-
-
-

Company details

-
-
-
Billing email
-
{{ $company->billing_email ?: 'Not set' }}
-
-
-
Country
-
{{ $company->country?->display_name ?: $company->country?->common_name ?: $company->country?->name ?: 'Not set' }}
-
-
-
Status
-
{{ ucfirst($company->status) }}
-
-
-
Timezone
-
{{ $company->timezone }}
-
-
-
+

Company address

+ @if ($company->address) +

{{ $company->address->formatted }}

+ @else +

No company address has been recorded.

+ @endif +
+ @csrf @method('PUT') + @include('company::partials.address-form', ['address' => $company->address, 'defaultLabel' => 'Registered office']) + +
-
- Edit - Back +
+
+

Create division

+ +
+ @csrf + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ +
+ + +
+ +
+ +
+

Director coverage

+ +
+ @csrf + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ + +
+ @forelse($company->directorAssignments as $assignment) +
+ {{ $assignment->user?->name }} — {{ $assignment->coverage_type === 'company' ? 'Whole company' : $assignment->division?->name }} +
+ @csrf + @method('DELETE') + + +
+ @empty +

No director coverage recorded.

+ @endforelse
+ + +
+

Warehouse division and address alignment

+

Warehouses remain tenant-scoped to the company. Assign a division only when that operating structure exists and record an optional warehouse operating address where needed.

+
+ @forelse($company->warehouses as $warehouse) +
+
+
+

{{ $warehouse->name }}

+

{{ $warehouse->code }} · {{ $warehouse->division?->name ?? 'Company-level warehouse' }}

+ @if ($warehouse->address) +

{{ $warehouse->address->formatted }}

+ @else +

No warehouse address recorded.

+ @endif +
+
+ @csrf @method('PUT') + + + +
+ +
+ Edit warehouse address +
+ @csrf @method('PUT') + @include('company::partials.address-form', ['address' => $warehouse->address, 'defaultLabel' => 'Warehouse address']) + +
+
+ @empty +

No warehouses exist for this company yet.

+ @endforelse +
+
+ +
+

Divisions

+ @forelse($company->divisions as $division) +
+
+

{{ $division->name }}

{{ $division->code ?? 'No code' }} · {{ ucfirst($division->status) }} · {{ $division->warehouses->count() }} warehouses

+
@csrf @method('DELETE') +
+
+ @csrf + @method('PUT') + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ + +
+ +

Division address

@csrf @method('PUT')@include('company::partials.address-form', ['address' => $division->address, 'defaultLabel' => 'Operating address'])
+
+ @empty +
No divisions exist. Warehouses can remain directly under the company until divisions are introduced.
+ @endforelse +
diff --git a/Modules/Company/Routes/web.php b/Modules/Company/Routes/web.php index 1b4d0da2..ef9d49f6 100644 --- a/Modules/Company/Routes/web.php +++ b/Modules/Company/Routes/web.php @@ -1,13 +1,27 @@ prefix('admin') - ->name('platform.') - ->group(function (): void { - Route::resource('/companies', CompanyController::class); - }); -} +Route::middleware(['web', 'auth', 'verified', 'tenant.assigned', 'platform.admin']) + ->prefix('admin') + ->name('platform.') + ->group(function () { + Route::resource('companies', CompanyController::class); + + Route::put('companies/{company}/address', [CompanyAddressController::class, 'updateCompany'])->name('companies.address.update'); + Route::post('companies/{company}/divisions', [CompanyDivisionController::class, 'store'])->name('companies.divisions.store'); + Route::put('companies/{company}/divisions/{division}', [CompanyDivisionController::class, 'update'])->name('companies.divisions.update'); + Route::delete('companies/{company}/divisions/{division}', [CompanyDivisionController::class, 'destroy'])->name('companies.divisions.destroy'); + Route::put('companies/{company}/divisions/{division}/address', [CompanyAddressController::class, 'updateDivision'])->name('companies.divisions.address.update'); + + Route::post('companies/{company}/division-memberships', [CompanyMembershipController::class, 'assignDivision'])->name('companies.division-memberships.store'); + Route::post('companies/{company}/director-assignments', [CompanyMembershipController::class, 'assignDirector'])->name('companies.director-assignments.store'); + Route::delete('companies/{company}/director-assignments/{assignment}', [CompanyMembershipController::class, 'removeDirector'])->name('companies.director-assignments.destroy'); + + Route::put('companies/{company}/warehouses/{warehouse}/division', [CompanyWarehouseAssignmentController::class, 'update'])->name('companies.warehouses.division.update'); + Route::put('companies/{company}/warehouses/{warehouse}/address', [CompanyAddressController::class, 'updateWarehouse'])->name('companies.warehouses.address.update'); + }); diff --git a/resources/css/app.css b/resources/css/app.css index 11d75bba..7fa64d0d 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1696,129 +1696,3 @@ html[data-wms-sidebar="collapsed"] .wms-sidebar { color: rgb(191 219 254); } } - - -/* Company/country Select2 dark override ------------------------------------------------- - Keep the Select2 country field visually consistent with the WMS dark form inputs. */ -.wms-select2-field .select2-container, -.wms-select2-field .select2-container--wms, -.wms-select2-field .select2-container--default { - margin-top: 0.5rem !important; - width: 100% !important; - display: block !important; -} - -.wms-select2-field .select2-container--wms .select2-selection--single, -.wms-select2-field .select2-container--default .select2-selection--single { - height: 3rem !important; - min-height: 3rem !important; - border: 1px solid rgb(63 63 70) !important; - border-radius: 0.75rem !important; - background: rgb(9 9 11) !important; - box-shadow: none !important; - display: flex !important; - align-items: center !important; -} - -.wms-select2-field .select2-container--wms.select2-container--focus .select2-selection--single, -.wms-select2-field .select2-container--wms.select2-container--open .select2-selection--single, -.wms-select2-field .select2-container--default.select2-container--focus .select2-selection--single, -.wms-select2-field .select2-container--default.select2-container--open .select2-selection--single { - border-color: rgb(59 130 246) !important; - box-shadow: 0 0 0 3px rgb(59 130 246 / 0.24) !important; -} - -.wms-select2-field .select2-selection--single .select2-selection__rendered { - width: 100% !important; - padding-left: 1rem !important; - padding-right: 3rem !important; - color: rgb(244 244 245) !important; - font-size: 0.875rem !important; - font-weight: 500 !important; - line-height: 3rem !important; -} - -.wms-select2-field .select2-selection--single .select2-selection__placeholder { - color: rgb(161 161 170) !important; - font-weight: 500 !important; -} - -.wms-select2-field .select2-selection--single .select2-selection__clear { - position: absolute !important; - right: 2.75rem !important; - top: 0.5rem !important; - width: 2rem !important; - height: 2rem !important; - color: rgb(161 161 170) !important; - font-size: 1.2rem !important; - line-height: 1.85rem !important; - text-align: center !important; -} - -.wms-select2-field .select2-selection--single .select2-selection__arrow { - position: absolute !important; - top: 0.5rem !important; - right: 0.625rem !important; - width: 2rem !important; - height: 2rem !important; - border-radius: 0.625rem !important; - background: rgb(39 39 42) !important; -} - -.wms-select2-field .select2-selection--single .select2-selection__arrow b { - border-color: rgb(212 212 216) transparent transparent transparent !important; -} - -.select2-container--wms .select2-dropdown, -.select2-container--default .select2-dropdown { - overflow: hidden !important; - border: 1px solid rgb(63 63 70) !important; - border-radius: 0.875rem !important; - background: rgb(24 24 27) !important; - color: rgb(244 244 245) !important; - box-shadow: 0 24px 60px rgb(0 0 0 / 0.45) !important; -} - -.select2-container--wms .select2-search--dropdown, -.select2-container--default .select2-search--dropdown { - padding: 0.75rem !important; - background: rgb(24 24 27) !important; -} - -.select2-container--wms .select2-search--dropdown .select2-search__field, -.select2-container--default .select2-search--dropdown .select2-search__field { - height: 2.75rem !important; - border: 1px solid rgb(63 63 70) !important; - border-radius: 0.75rem !important; - background: rgb(9 9 11) !important; - color: rgb(244 244 245) !important; - padding: 0 0.875rem !important; - font-size: 0.875rem !important; - outline: none !important; -} - -.select2-container--wms .select2-results__options, -.select2-container--default .select2-results__options { - background: rgb(24 24 27) !important; - max-height: 16rem !important; -} - -.select2-container--wms .select2-results__option, -.select2-container--default .select2-results__option { - padding: 0.75rem 1rem !important; - color: rgb(244 244 245) !important; - font-size: 0.875rem !important; - font-weight: 500 !important; -} - -.select2-container--wms .select2-results__option--highlighted.select2-results__option--selectable, -.select2-container--default .select2-results__option--highlighted.select2-results__option--selectable { - background: rgb(37 99 235) !important; - color: white !important; -} - -.select2-container--wms .select2-results__option--selected, -.select2-container--default .select2-results__option--selected { - background: rgb(30 41 59) !important; - color: rgb(191 219 254) !important; -} diff --git a/resources/css/wms-select2.css b/resources/css/wms-select2.css index 20e71182..32dccef5 100644 --- a/resources/css/wms-select2.css +++ b/resources/css/wms-select2.css @@ -1,5 +1,8 @@ -/* WMS Select2 professional dark theme. - This file is imported after Select2's own CSS so it wins reliably. */ +/* WMS Select2 professional dark theme. */ + +.wms-select2-field { + min-width: 0; +} .wms-select2-field .select2-container, .select2-container--wms { @@ -7,7 +10,12 @@ display: block !important; } -.wms-select2-field select[data-wms-select2].select2-hidden-accessible { +.wms-select2-field .select2-container { + margin-top: 0.5rem !important; +} + +.wms-select2-field select[data-wms-select2].select2-hidden-accessible, +.wms-select2-field select.wms-select2-control.select2-hidden-accessible { position: absolute !important; width: 1px !important; height: 1px !important; @@ -19,19 +27,14 @@ margin: -1px !important; } -.wms-select2-field .select2-container { - margin-top: 0.5rem !important; -} - .select2-container--wms .select2-selection--single { + position: relative !important; height: 3rem !important; min-height: 3rem !important; border: 1px solid rgb(63 63 70) !important; border-radius: 0.75rem !important; background: rgb(9 9 11) !important; box-shadow: 0 1px 2px rgb(0 0 0 / 0.2) !important; - display: flex !important; - align-items: center !important; outline: none !important; transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease !important; } @@ -43,8 +46,8 @@ } .select2-container--wms .select2-selection--single .select2-selection__rendered { - width: 100% !important; - padding: 0 3rem 0 1rem !important; + height: 100% !important; + padding: 0 2.75rem 0 1rem !important; color: rgb(244 244 245) !important; font-size: 0.875rem !important; font-weight: 500 !important; @@ -56,43 +59,58 @@ font-weight: 500 !important; } +/* Keep the Select2 arrow as a plain chevron, not a boxed button. */ .select2-container--wms .select2-selection--single .select2-selection__arrow { position: absolute !important; - top: 0.5rem !important; - right: 0.625rem !important; - width: 2rem !important; - height: 2rem !important; - border-radius: 0.625rem !important; - background: rgb(39 39 42) !important; + top: 0 !important; + right: 0.875rem !important; + width: 1.25rem !important; + height: 3rem !important; + background: transparent !important; + border: 0 !important; + border-radius: 0 !important; + box-shadow: none !important; } .select2-container--wms .select2-selection--single .select2-selection__arrow b { - border-color: rgb(212 212 216) transparent transparent transparent !important; - border-width: 5px 4px 0 4px !important; + position: absolute !important; + left: 50% !important; + top: 50% !important; + width: 0 !important; + height: 0 !important; margin-left: -4px !important; margin-top: -2px !important; + border-style: solid !important; + border-width: 5px 4px 0 4px !important; + border-color: rgb(212 212 216) transparent transparent transparent !important; } .select2-container--wms.select2-container--open .select2-selection--single .select2-selection__arrow b { - border-color: transparent transparent rgb(212 212 216) transparent !important; + margin-top: -3px !important; border-width: 0 4px 5px 4px !important; + border-color: transparent transparent rgb(212 212 216) transparent !important; } .select2-container--wms .select2-selection--single .select2-selection__clear { position: absolute !important; - right: 2.85rem !important; - top: 0.5rem !important; - width: 2rem !important; - height: 2rem !important; - border-radius: 0.625rem !important; + top: 0 !important; + right: 2.25rem !important; + height: 3rem !important; + width: 1.25rem !important; + margin: 0 !important; + padding: 0 !important; + background: transparent !important; + border: 0 !important; + border-radius: 0 !important; + box-shadow: none !important; color: rgb(161 161 170) !important; - font-size: 1.25rem !important; - line-height: 1.85rem !important; + font-size: 1.1rem !important; + font-weight: 700 !important; + line-height: 3rem !important; text-align: center !important; } .select2-container--wms .select2-selection--single .select2-selection__clear:hover { - background: rgb(39 39 42) !important; color: rgb(244 244 245) !important; } @@ -106,21 +124,26 @@ } .select2-container--wms .select2-search--dropdown { - padding: 0.75rem !important; + padding: 0.625rem !important; background: rgb(24 24 27) !important; } .select2-container--wms .select2-search--dropdown .select2-search__field { - height: 2.75rem !important; + height: 2.5rem !important; border: 1px solid rgb(63 63 70) !important; border-radius: 0.75rem !important; background: rgb(9 9 11) !important; color: rgb(244 244 245) !important; padding: 0 0.875rem !important; font-size: 0.875rem !important; + font-weight: 500 !important; outline: none !important; } +.select2-container--wms .select2-search--dropdown .select2-search__field::placeholder { + color: rgb(161 161 170) !important; +} + .select2-container--wms .select2-search--dropdown .select2-search__field:focus { border-color: rgb(59 130 246) !important; box-shadow: 0 0 0 3px rgb(59 130 246 / 0.22) !important; @@ -132,7 +155,7 @@ } .select2-container--wms .select2-results__option { - padding: 0.75rem 1rem !important; + padding: 0.7rem 1rem !important; color: rgb(244 244 245) !important; font-size: 0.875rem !important; font-weight: 500 !important; @@ -148,31 +171,29 @@ color: rgb(191 219 254) !important; } -.select2-container--wms .select2-results__option[aria-disabled='true'] { - color: rgb(113 113 122) !important; -} - +.select2-container--wms .select2-results__option[aria-disabled='true'], .select2-container--wms .select2-results__message { color: rgb(161 161 170) !important; } -/* Safety: any WMS Select2 target should disappear once Select2 has initialised, - even if a view is not wrapped in .wms-select2-field. */ -select[data-wms-select2].select2-hidden-accessible, -select[data-company-country-select2].select2-hidden-accessible, -select[data-helpdesk-select2].select2-hidden-accessible { - position: absolute !important; - width: 1px !important; - height: 1px !important; - overflow: hidden !important; - clip: rect(0 0 0 0) !important; - white-space: nowrap !important; - border: 0 !important; - padding: 0 !important; - margin: -1px !important; +/* Fallback styling before Select2 initialises, matching WMS inputs. */ +.wms-select2-field > select[data-wms-select2], +.wms-select2-field > select.wms-select2-control { + margin-top: 0.5rem; + height: 3rem; + width: 100%; + border-radius: 0.75rem; + border: 1px solid rgb(63 63 70); + background: rgb(9 9 11); + color: rgb(244 244 245); + padding: 0 1rem; + font-size: 0.875rem; + font-weight: 500; } -/* Keep country Select2 controls aligned with the surrounding WMS form fields. */ -.wms-select2-field .select2-container--wms { - margin-top: 0.5rem !important; +.wms-select2-field > select[data-wms-select2]:focus, +.wms-select2-field > select.wms-select2-control:focus { + border-color: rgb(59 130 246); + box-shadow: 0 0 0 3px rgb(59 130 246 / 0.24); + outline: none; } diff --git a/resources/js/app.js b/resources/js/app.js index 93818bcd..37533929 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -7,11 +7,13 @@ import $ from 'jquery'; window.$ = $; window.jQuery = $; -import attachSelect2 from 'select2'; +import select2Factory from 'select2'; import 'select2/dist/css/select2.min.css'; import '../css/wms-select2.css'; -attachSelect2(window.jQuery); +if (typeof select2Factory === 'function') { + select2Factory(window, $); +} const chartColours = [ 'rgb(8, 145, 178)', @@ -170,25 +172,27 @@ function initialiseSelect2() { return; } - $('[data-helpdesk-select2], [data-wms-select2], [data-company-country-select2]').each(function () { + $('[data-helpdesk-select2], [data-wms-select2], .wms-select2-control').each(function () { const $select = $(this); if ($select.hasClass('select2-hidden-accessible')) { - return; + $select.select2('destroy'); } const isMultiple = $select.prop('multiple'); + const placeholder = + $select.data('placeholder') || + $select.find('option[value=""]').first().text() || + (isMultiple ? 'Select options' : 'Select option'); + $select.select2({ theme: 'wms', width: '100%', allowClear: !isMultiple, closeOnSelect: !isMultiple, dropdownAutoWidth: false, - placeholder: - $select.data('placeholder') || - $select.find('option[value=""]').first().text() || - (isMultiple ? 'Select options' : 'Select option'), + placeholder, sorter(data) { return data.sort((first, second) => first.text.localeCompare(second.text, undefined, { sensitivity: 'base', @@ -197,6 +201,16 @@ function initialiseSelect2() { }, }); + $select.off('select2:open.wms-select2'); + $select.on('select2:open.wms-select2', function () { + const searchField = document.querySelector('.select2-container--open .select2-search__field'); + + if (searchField) { + searchField.placeholder = placeholder; + searchField.focus(); + } + }); + $select.off('change.select2-livewire'); $select.on('change.select2-livewire', function () { From 2e26a8e11a827d6f2209f1fdf00a9f1d9d401ea0 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 7 Jul 2026 16:29:16 +0100 Subject: [PATCH 45/49] countries selection fixed --- .../views/partials/address-form.blade.php | 6 +- public/vendor/livewire/livewire.csp.esm.js | 62 ++++++++++---- .../vendor/livewire/livewire.csp.esm.js.map | 4 +- public/vendor/livewire/livewire.csp.js | 62 ++++++++++---- public/vendor/livewire/livewire.csp.min.js | 22 ++--- .../vendor/livewire/livewire.csp.min.js.map | 6 +- public/vendor/livewire/livewire.esm.js | 62 ++++++++++---- public/vendor/livewire/livewire.esm.js.map | 4 +- public/vendor/livewire/livewire.js | 62 ++++++++++---- public/vendor/livewire/livewire.min.js | 18 ++--- public/vendor/livewire/livewire.min.js.map | 6 +- public/vendor/livewire/manifest.json | 2 +- resources/css/wms-select2.css | 80 ++++++++----------- resources/js/app.js | 73 ++++++++++------- 14 files changed, 305 insertions(+), 164 deletions(-) diff --git a/Modules/Company/Resources/views/partials/address-form.blade.php b/Modules/Company/Resources/views/partials/address-form.blade.php index 4becfa54..665dab4b 100644 --- a/Modules/Company/Resources/views/partials/address-form.blade.php +++ b/Modules/Company/Resources/views/partials/address-form.blade.php @@ -44,17 +44,17 @@
-
+
- - {{ $company->is_system_company ? 'System' : ucfirst($company->status) }} - + + {{ $company->is_system_company ? 'System' : ucfirst($company->status) }} + diff --git a/Modules/Company/Routes/web.php b/Modules/Company/Routes/web.php new file mode 100644 index 00000000..1b4d0da2 --- /dev/null +++ b/Modules/Company/Routes/web.php @@ -0,0 +1,13 @@ +prefix('admin') + ->name('platform.') + ->group(function (): void { + Route::resource('/companies', CompanyController::class); + }); +} diff --git a/Modules/Company/module.json b/Modules/Company/module.json new file mode 100644 index 00000000..75681400 --- /dev/null +++ b/Modules/Company/module.json @@ -0,0 +1,14 @@ +{ + "name": "Company", + "alias": "company", + "version": "1.0.1", + "description": "Tenant company administration module.", + "keywords": ["company", "tenant", "platform-administration"], + "priority": 0, + "providers": [ + "Modules\\Company\\Providers\\CompanyServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [] +} diff --git a/Modules/Platform/routes/web.php b/Modules/Platform/routes/web.php index a0bf78c9..215514b4 100644 --- a/Modules/Platform/routes/web.php +++ b/Modules/Platform/routes/web.php @@ -6,6 +6,7 @@ use Modules\Platform\app\Http\Controllers\PaymentController; use Modules\Platform\app\Http\Controllers\PlatformDashboardController; use Modules\Platform\app\Http\Controllers\PlatformPriceController; use Modules\Platform\app\Http\Controllers\StripeWebhookController; +use Modules\Company\Http\Controllers\CompanyController; Route::middleware(['auth', 'verified', 'tenant.assigned']) ->prefix('platform') @@ -23,6 +24,9 @@ Route::middleware(['auth', 'verified', 'tenant.assigned', 'platform.admin']) Route::post('/licenses', [LicenseKeyController::class, 'store'])->name('licenses.store'); Route::get('/payments', [PaymentController::class, 'index'])->name('payments.index'); Route::post('/payments', [PaymentController::class, 'store'])->name('payments.store'); + if (! Route::has('platform.companies.index')) { + Route::resource('/companies', CompanyController::class); + } Route::resource('/prices', PlatformPriceController::class)->except(['show']); }); From 12c1470319ed6a2d237bc18dee0a7287b16b601f Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 7 Jul 2026 12:38:13 +0100 Subject: [PATCH 40/49] interim fix for drop downs, BROKEN FIX, interinm fix --- .../Http/Controllers/CompanyController.php | 63 +++++++++++------- Modules/Company/Models/CompanyAddress.php | 27 ++++++++ .../Models/CompanyDirectorAssignment.php | 27 ++++++++ Modules/Company/Models/CompanyDivision.php | 35 ++++++++++ .../Company/Resources/views/_form.blade.php | 16 ++--- .../Company/Resources/views/create.blade.php | 16 +++++ .../Company/Resources/views/edit.blade.php | 17 +++++ .../Company/Resources/views/index.blade.php | 63 ++++-------------- .../Company/Resources/views/show.blade.php | 66 +++++++++++++++++++ Modules/Platform/routes/web.php | 4 +- 10 files changed, 251 insertions(+), 83 deletions(-) create mode 100644 Modules/Company/Models/CompanyAddress.php create mode 100644 Modules/Company/Models/CompanyDirectorAssignment.php create mode 100644 Modules/Company/Models/CompanyDivision.php create mode 100644 Modules/Company/Resources/views/create.blade.php create mode 100644 Modules/Company/Resources/views/edit.blade.php create mode 100644 Modules/Company/Resources/views/show.blade.php diff --git a/Modules/Company/Http/Controllers/CompanyController.php b/Modules/Company/Http/Controllers/CompanyController.php index 09ede975..729e98ec 100644 --- a/Modules/Company/Http/Controllers/CompanyController.php +++ b/Modules/Company/Http/Controllers/CompanyController.php @@ -4,6 +4,7 @@ namespace Modules\Company\Http\Controllers; use App\Http\Controllers\Controller; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Modules\Company\Models\Company; @@ -16,12 +17,24 @@ class CompanyController extends Controller */ public function index() { + $counts = ['users', 'warehouses', 'licenseKeys']; + + if (Schema::hasTable('company_divisions')) { + $counts[] = 'divisions'; + } + + $companies = Company::query() + ->with(['country']) + ->withCount($counts) + ->orderBy('name') + ->paginate(20); + + $companies->getCollection()->each(function (Company $company): void { + $company->setAttribute('divisions_count', $company->getAttribute('divisions_count') ?? 0); + }); + return view('company::index', [ - 'companies' => Company::query() - ->with(['address.country', 'country']) - ->withCount(['users', 'warehouses', 'licenseKeys', 'divisions']) - ->orderBy('name') - ->paginate(20), + 'companies' => $companies, 'countries' => $this->countries(), ]); } @@ -57,16 +70,20 @@ class CompanyController extends Controller */ public function show(Company $company) { - $company->load([ - 'address.country', - 'divisions.address.country', - 'divisions.warehouses', - 'divisions.users', - 'warehouses.address.country', - 'warehouses.division', - 'directorAssignments.user', - 'directorAssignments.division', - ])->loadCount(['users', 'warehouses', 'licenseKeys', 'payments', 'divisions']); + $loads = ['country']; + $counts = ['users', 'warehouses', 'licenseKeys', 'payments']; + + if (Schema::hasTable('company_divisions')) { + $loads[] = 'divisions'; + $counts[] = 'divisions'; + } + + if (Schema::hasTable('company_director_assignments')) { + $loads[] = 'directorAssignments.user'; + } + + $company->load($loads)->loadCount($counts); + $company->setAttribute('divisions_count', $company->getAttribute('divisions_count') ?? 0); return view('company::show', [ 'company' => $company, @@ -74,8 +91,6 @@ class CompanyController extends Controller 'recentWarehouses' => $company->warehouses()->latest('id')->limit(5)->get(), 'recentLicenseKeys' => $company->licenseKeys()->latest('id')->limit(5)->get(), 'countries' => $this->countries(), - 'companyUsers' => $company->users()->orderBy('name')->get(['id', 'name', 'email', 'company_id']), - 'unassignedWarehouses' => $company->warehouses()->whereNull('division_id')->orderBy('name')->get(), ]); } @@ -85,7 +100,7 @@ class CompanyController extends Controller public function edit(Company $company) { return view('company::edit', [ - 'company' => $company->loadMissing(['address.country', 'country']), + 'company' => $company->loadMissing('country'), 'countries' => $this->countries(), ]); } @@ -125,10 +140,14 @@ class CompanyController extends Controller 'country_id' => ['nullable', Rule::exists('countries', 'id')->where('is_active', true)], ]); - $data['slug'] = Str::slug($data['name']); - $data['timezone'] = $company?->timezone ?: 'Europe/London'; - - return $data; + return [ + 'name' => $data['name'], + 'billing_email' => $data['billing_email'] ?? null, + 'status' => $data['status'], + 'country_id' => $data['country_id'] ?? null, + 'slug' => Str::slug($data['name']), + 'timezone' => $company?->timezone ?: 'Europe/London', + ]; } private function countries() diff --git a/Modules/Company/Models/CompanyAddress.php b/Modules/Company/Models/CompanyAddress.php new file mode 100644 index 00000000..27f84f71 --- /dev/null +++ b/Modules/Company/Models/CompanyAddress.php @@ -0,0 +1,27 @@ + 'boolean', + ]; + + public function addressable(): MorphTo + { + return $this->morphTo(); + } + + public function country(): BelongsTo + { + return $this->belongsTo(Country::class); + } +} diff --git a/Modules/Company/Models/CompanyDirectorAssignment.php b/Modules/Company/Models/CompanyDirectorAssignment.php new file mode 100644 index 00000000..d5ae3435 --- /dev/null +++ b/Modules/Company/Models/CompanyDirectorAssignment.php @@ -0,0 +1,27 @@ +belongsTo(Company::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function division(): BelongsTo + { + return $this->belongsTo(CompanyDivision::class, 'company_division_id'); + } +} diff --git a/Modules/Company/Models/CompanyDivision.php b/Modules/Company/Models/CompanyDivision.php new file mode 100644 index 00000000..af5e12dd --- /dev/null +++ b/Modules/Company/Models/CompanyDivision.php @@ -0,0 +1,35 @@ +belongsTo(Company::class); + } + + public function address(): MorphOne + { + return $this->morphOne(CompanyAddress::class, 'addressable'); + } + + public function warehouses(): HasMany + { + return $this->hasMany(Warehouse::class, 'division_id'); + } + + public function users(): HasMany + { + return $this->hasMany(User::class, 'division_id'); + } +} diff --git a/Modules/Company/Resources/views/_form.blade.php b/Modules/Company/Resources/views/_form.blade.php index e044d2d7..a5bf21dc 100644 --- a/Modules/Company/Resources/views/_form.blade.php +++ b/Modules/Company/Resources/views/_form.blade.php @@ -7,7 +7,7 @@ id="name" name="name" value="{{ old('name', $company->name) }}" - class="mt-2 w-full rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white" + class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white" required > @error('name') @@ -23,7 +23,7 @@ name="billing_email" value="{{ old('billing_email', $company->billing_email) }}" placeholder="billing@example.com" - class="mt-2 w-full rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white" + class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white" > @error('billing_email')

{{ $message }}

@@ -35,7 +35,7 @@ @@ -70,7 +70,7 @@
- diff --git a/Modules/Company/Resources/views/create.blade.php b/Modules/Company/Resources/views/create.blade.php new file mode 100644 index 00000000..9867415d --- /dev/null +++ b/Modules/Company/Resources/views/create.blade.php @@ -0,0 +1,16 @@ + +
+ + +
+
+ @include('company::_form') +
+
+
+
diff --git a/Modules/Company/Resources/views/edit.blade.php b/Modules/Company/Resources/views/edit.blade.php new file mode 100644 index 00000000..d9413df8 --- /dev/null +++ b/Modules/Company/Resources/views/edit.blade.php @@ -0,0 +1,17 @@ + +
+ + +
+
+ @method('PUT') + @include('company::_form') +
+
+
+
diff --git a/Modules/Company/Resources/views/index.blade.php b/Modules/Company/Resources/views/index.blade.php index cbf798f3..1f3661a6 100644 --- a/Modules/Company/Resources/views/index.blade.php +++ b/Modules/Company/Resources/views/index.blade.php @@ -86,11 +86,11 @@ name="country_id" data-wms-select2 data-placeholder="Search and select country" - class="mt-2 w-full" + class="w-full" > - - @foreach ($countries as $country) - + @foreach($countries as $country) + @endforeach @@ -127,23 +127,10 @@
{{ $company->name }} - {{ $company->users_count }} - - {{ $company->divisions_count }} - - {{ $company->warehouses_count }} - - {{ $company->license_keys_count }} - {{ $company->users_count }}{{ $company->divisions_count ?? 0 }}{{ $company->warehouses_count }}{{ $company->license_keys_count }}
- - View - - - - Edit - + View + Edit @unless ($company->is_system_company) -
+ @csrf @method('DELETE') - - +
@endunless
@@ -196,9 +161,7 @@
- No companies found. - No companies found.