settings updates

This commit is contained in:
Mark Gregory
2026-08-25 22:19:04 +01:00
parent 1991f27ee8
commit 037c08498b
37 changed files with 5141 additions and 585 deletions
+8 -8
View File
@@ -73,7 +73,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,
@@ -81,12 +81,12 @@ final class KanbanBoard extends Component
Auth::user(),
);
$message = 'Ticket ' . $freshTicket->reference . ' moved from ' . $oldStatusName . ' to ' . $status->name . '.';
$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('helpdesk:toast', type: 'success', title: 'Ticket moved', message: $message);
$this->dispatch('thredary:toast', type: 'success', title: 'Ticket moved', message: $message);
} finally {
$this->isMovingTicket = false;
}
@@ -121,15 +121,15 @@ 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 . '.';
$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('helpdesk:toast', type: 'success', title: 'Board changed', message: $message);
$this->dispatch('thredary:toast', type: 'success', title: 'Board changed', message: $message);
}
/**
@@ -191,7 +191,7 @@ final class KanbanBoard extends Component
'statuses' => $board?->statuses ?? collect(),
'ticketsByStatus' => $this->ticketsByStatus($board),
'refreshNonce' => $this->refreshNonce,
])->layout('layouts.app', ['title' => __('Helpdesk Kanban')]);
])->layout('layouts.app');
}
/**
@@ -234,7 +234,7 @@ final class KanbanBoard extends Component
/**
* Resolve the selected board from an already-loaded board collection.
*
* @param Collection<int, KanbanBoardModel> $boards
* @param Collection<int, KanbanBoardModel> $boards
*/
private function resolveSelectedBoard(Collection $boards): ?KanbanBoardModel
{
@@ -718,7 +718,7 @@ final class SettingsManager extends Component
'tags' => Tag::query()->orderBy('name')->get(),
'checklistTemplates' => ChecklistTemplate::query()->orderBy('name')->get(),
'colourOptions' => Color::options(),
])->layout('layouts.app', ['title' => __('Helpdesk Settings')]);
])->layout('layouts.app');
}
private function orderedStatuses(): mixed
+1 -1
View File
@@ -146,7 +146,7 @@ final class TicketCreate extends Component
'projects' => $this->projectOptions(),
'products' => Product::query()->where('is_active', true)->orderBy('name')->get(),
'tags' => Tag::query()->orderBy('name')->get(),
])->layout('layouts.app', ['title' => __('Create Helpdesk Ticket')]);
])->layout('layouts.app');
}
/**
+1 -1
View File
@@ -198,7 +198,7 @@ final class TicketEdit extends Component
'products' => Product::query()->where('is_active', true)->orderBy('name')->get(),
'tags' => Tag::query()->orderBy('name')->get(),
'agents' => $this->agentOptions(),
])->layout('layouts.app', ['title' => __('Edit Helpdesk Ticket')]);
])->layout('layouts.app');
}
/**
+1 -1
View File
@@ -105,7 +105,7 @@ final class TicketIndex extends Component
'tickets' => $this->tickets(),
'statuses' => Status::query()->orderBy('sort_order')->get(),
'priorities' => Priority::query()->orderBy('sort_order')->get(),
])->layout('layouts.app', ['title' => __('Helpdesk Tickets')]);
])->layout('layouts.app');
}
private function resetTicketDeletionState(): void
+1 -1
View File
@@ -325,7 +325,7 @@ final class TicketShow extends Component
'priorities' => Priority::query()->orderBy('sort_order')->get(),
'agents' => $this->agentOptions(),
'cannedResponses' => CannedResponse::query()->where('is_active', true)->orderBy('sort_order')->orderBy('name')->get(),
])->layout('layouts.app', ['title' => __('Helpdesk Ticket')]);
])->layout('layouts.app');
}
private function normaliseNullableInt(mixed $value): ?int
+3 -3
View File
@@ -27,7 +27,7 @@ final class Ticket extends Model
protected $table = 'helpdesk_tickets';
protected $with = ['user'];
protected $with = ['user', 'owner', 'agent'];
/** @var list<string> */
protected $fillable = [
@@ -194,11 +194,11 @@ final class Ticket extends Model
/** @param Builder<Ticket> $query @return Builder<Ticket> */
public function scopeVisibleTo(Builder $query, User $user): Builder
{
if ($user->isHelpdeskAdmin()) {
if ($user->isAdmin() || (bool) ($user->helpdesk_admin ?? false)) {
return $query;
}
if ($user->isHelpdeskAgent()) {
if ((method_exists($user, 'isHelpdeskAgent') && $user->isHelpdeskAgent()) || (bool) ($user->helpdesk_agent ?? false)) {
return $query->where(static function (Builder $subQuery) use ($user): void {
$subQuery->where('agent_id', $user->id)->orWhere('user_id', $user->id);
});
+12 -49
View File
@@ -16,15 +16,10 @@ final class TicketPolicy
public function view(User $user, Ticket $ticket): bool
{
if ($this->isHelpdeskAdmin($user)) {
if ($this->isAdmin($user) || $this->isAgent($user)) {
return true;
}
if ($this->isHelpdeskAgent($user)) {
return (int) $ticket->agent_id === (int) $user->id
|| (int) $ticket->user_id === (int) $user->id;
}
return (int) $ticket->user_id === (int) $user->id;
}
@@ -33,29 +28,13 @@ final class TicketPolicy
return true;
}
/**
* Helpdesk workflow updates.
*
* Admins can update every Helpdesk ticket.
*
* Agents can update any ticket that is visible to them under the Helpdesk
* visibility rules. This is important for Kanban drag/drop: a card must
* never be displayed as movable and then fail authorization on drop.
*
* Normal requesters cannot change workflow/status/assignment data.
*/
public function update(User $user, Ticket $ticket): bool
{
if ($this->isHelpdeskAdmin($user)) {
if ($this->isAdmin($user) || $this->isAgent($user)) {
return true;
}
if (! $this->isHelpdeskAgent($user)) {
return false;
}
return (int) $ticket->agent_id === (int) $user->id
|| (int) $ticket->user_id === (int) $user->id;
return (int) $ticket->user_id === (int) $user->id;
}
public function comment(User $user, Ticket $ticket): bool
@@ -65,36 +44,20 @@ final class TicketPolicy
public function delete(User $user, Ticket $ticket): bool
{
return $this->isHelpdeskAdmin($user);
return $this->isAdmin($user);
}
private function isHelpdeskAdmin(User $user): bool
private function isAdmin(User $user): bool
{
if (method_exists($user, 'isHelpdeskAdmin')) {
return $user->isHelpdeskAdmin();
}
if ((bool) ($user->helpdesk_admin ?? false)) {
return true;
}
if (method_exists($user, 'isAdmin')) {
return $user->isAdmin();
}
if (method_exists($user, 'isMpbaPlatformAdmin')) {
return $user->isMpbaPlatformAdmin();
}
return false;
return (method_exists($user, 'isHelpdeskAdmin') && $user->isHelpdeskAdmin())
|| (bool) ($user->helpdesk_admin ?? false)
|| (method_exists($user, 'isAdmin') && $user->isAdmin());
}
private function isHelpdeskAgent(User $user): bool
private function isAgent(User $user): bool
{
if (method_exists($user, 'isHelpdeskAgent')) {
return $user->isHelpdeskAgent();
}
return (bool) ($user->helpdesk_agent ?? false);
return (method_exists($user, 'isHelpdeskAgent') && $user->isHelpdeskAgent())
|| (bool) ($user->helpdesk_agent ?? false)
|| (method_exists($user, 'isAgent') && $user->isAgent());
}
}
+1
View File
@@ -0,0 +1 @@
+10
View File
@@ -5,6 +5,7 @@
"packages": {
"": {
"dependencies": {
"@fortawesome/fontawesome-free": "^7.0.0",
"@laravel/passkeys": "^0.2.0",
"@tailwindcss/vite": "^4.1.11",
"concurrently": "^10.0.3",
@@ -33,6 +34,15 @@
"node": ">=18"
}
},
"node_modules/@fortawesome/fontawesome-free": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.1.tgz",
"integrity": "sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg==",
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
"engines": {
"node": ">=6"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+5 -5
View File
@@ -3,10 +3,9 @@
"private": true,
"type": "module",
"scripts": {
"build": "npm run build:backend && npm run build:helpdesk",
"build:backend": "vite build --config ./vite.config.js",
"build:helpdesk": "npm --prefix Modules/Helpdesk ci && npm --prefix Modules/Helpdesk run build",
"dev": "vite --config ./vite.config.js"
"build": "vite build",
"dev": "vite",
"build:backend": "vite build --config ./vite.config.js"
},
"dependencies": {
"@laravel/passkeys": "^0.2.0",
@@ -14,7 +13,8 @@
"concurrently": "^10.0.3",
"laravel-vite-plugin": "^3.1",
"tailwindcss": "^4.0.7",
"vite": "^8.0.0"
"vite": "^8.0.0",
"@fortawesome/fontawesome-free": "^7.0.0"
},
"optionalDependencies": {
"@laravel/multiplex": "^0.4.1",
+1 -10
View File
@@ -1,4 +1,5 @@
@import 'tailwindcss';
@import '@fortawesome/fontawesome-free/css/all.min.css';
@import '../../../../../vendor/livewire/flux/dist/flux.css';
@import './helpdesk.css';
@@ -14,13 +15,3 @@
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
}
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@@ -972,3 +972,36 @@ span.flatpickr-weekday {
box-shadow: 0 0 0 1000px #09090b inset !important;
transition: background-color 9999s ease-in-out 0s;
}
/* Backend theme integration: keep Core Helpdesk surfaces full-width and dark. */
.hd-helpdesk-surface {
width: 100%;
}
.dark .hd-core-settings input:not([type="checkbox"]):not([type="radio"]):not([type="color"]),
.dark .hd-core-settings textarea,
.dark .hd-core-settings select,
.dark .hd-core-settings [data-flux-control] {
background: #2b2b2b !important;
border-color: #4b4b4b !important;
color: #f4f4f5 !important;
}
.dark .hd-core-settings input::placeholder,
.dark .hd-core-settings textarea::placeholder {
color: #9ca3af !important;
}
.dark .hd-core-settings [data-flux-field] label,
.dark .hd-core-settings [data-flux-label] {
color: #f4f4f5 !important;
}
/* Coloured Settings cards remain cards at all common desktop widths. */
.hd-core-settings > .grid.md\:grid-cols-4.xl\:grid-cols-7 > a {
min-height: 4.6rem;
display: flex;
flex-direction: column;
justify-content: center;
}
@@ -0,0 +1,16 @@
@props([
'color' => 'gray',
'label' => null,
])
@php
$background = \Modules\Helpdesk\Support\Color::css($color);
$foreground = \Modules\Helpdesk\Support\Color::text($color);
@endphp
<span
{{ $attributes->class('inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold shadow-sm ring-1 ring-zinc-900/10') }}
style="background-color: {{ $background }}; color: {{ $foreground }};"
>
{{ $label ?? $slot }}
</span>
@@ -0,0 +1,74 @@
@props([
'label' => 'Badge colour',
'model',
'options' => [],
])
@php
$currentValue = data_get($this, $model, 'gray');
$currentHex = \Modules\Helpdesk\Support\Color::css($currentValue);
$currentText = \Modules\Helpdesk\Support\Color::text($currentValue);
$currentLabel = \Modules\Helpdesk\Support\Color::label($currentValue);
@endphp
<div {{ $attributes->class('rounded-xl border border-zinc-200 bg-white p-3 shadow-sm dark:border-zinc-700 dark:bg-zinc-950/50') }}>
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<div class="min-w-0 space-y-2">
<label class="block text-xs font-bold uppercase tracking-wide text-zinc-600 dark:text-zinc-300">
{{ $label }}
</label>
<div class="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<select
wire:model.live="{{ $model }}"
class="h-9 w-full rounded-lg border border-zinc-300 bg-white px-3 text-sm font-semibold text-zinc-900 shadow-sm outline-none transition-colors duration-150 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100"
>
@foreach ($options as $key => $option)
<option value="{{ $key }}" style="color: {{ $option['hex'] }};">
● {{ $option['name'] }} — {{ $option['hex'] }}
</option>
@endforeach
</select>
<span
class="inline-flex h-9 min-w-28 items-center justify-center rounded-lg px-3 text-xs font-bold shadow-sm ring-1 ring-zinc-900/10 dark:ring-white/10"
style="background-color: {{ $currentHex }}; color: {{ $currentText }};"
>
{{ $currentLabel }}
</span>
</div>
<div class="flex flex-wrap items-center gap-1.5" aria-label="Available Helpdesk colours">
@foreach ($options as $key => $option)
<button
type="button"
wire:click="$set('{{ $model }}', '{{ $key }}')"
title="{{ $option['name'] }} {{ $option['hex'] }}"
class="inline-flex h-6 w-6 items-center justify-center rounded-full border border-white/80 shadow-sm ring-1 ring-zinc-900/10 transition hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500/40 dark:ring-white/15"
style="background-color: {{ $option['hex'] }}"
>
<span class="sr-only">Use {{ $option['name'] }}</span>
</button>
@endforeach
</div>
</div>
<div class="grid grid-cols-[2.75rem_minmax(0,11rem)] items-center gap-2 lg:justify-end">
<input
type="color"
value="{{ $currentHex }}"
wire:change="$set('{{ $model }}', $event.target.value)"
class="h-9 w-11 cursor-pointer rounded-lg border border-zinc-300 bg-white p-1 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
aria-label="Choose a custom hex colour"
/>
<input
type="text"
wire:model.live.debounce.500ms="{{ $model }}"
class="h-9 w-full rounded-lg border border-zinc-300 bg-white px-3 text-xs font-semibold text-zinc-900 placeholder:text-zinc-400 shadow-sm outline-none transition-colors duration-150 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder:text-zinc-500"
placeholder="#ffffff"
aria-label="Custom hex colour"
/>
</div>
</div>
</div>
@@ -0,0 +1,21 @@
@props(['id', 'model', 'label' => null, 'required' => false, 'placeholder' => 'Select date'])
<div>
@if ($label)
<label class="block text-sm font-semibold text-zinc-200" for="{{ $id }}">
{{ $label }}
@if ($required)
<span class="helpdesk-required-star">*</span>
@endif
</label>
@endif
<div class="mt-2" wire:ignore>
<input id="{{ $id }}" type="text" value="{{ $attributes->get('value') }}" data-helpdesk-flatpickr
data-wire-model="{{ $model }}" placeholder="{{ $placeholder }}" autocomplete="off"
{{ $attributes->except('value')->merge([
'class' =>
'block w-full rounded-xl border border-zinc-700 bg-zinc-950 px-4 py-3 text-sm text-zinc-100 shadow-sm outline-none transition-colors duration-150 placeholder:text-zinc-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/40',
]) }} />
</div>
</div>
@@ -0,0 +1,143 @@
@props([
'model' => 'body',
'label' => 'Details',
'placeholder' => 'Describe the ticket, include steps to reproduce, expected result, actual result and any useful technical details.',
'rows' => 10,
'editorDusk' => null,
])
<div
class="space-y-2"
x-data="{
model: @js($model),
value: @entangle($model),
init() {
this.$nextTick(() => {
this.writeEditorValue(this.value || '');
});
},
writeEditorValue(value) {
if (!this.$refs.editor) {
return;
}
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 = true) {
this.value = this.$refs.editor?.innerText || '';
if (this.$refs.input) {
this.$refs.input.value = this.value;
}
this.$wire.set(this.model, this.value, live);
},
resetEditor() {
this.value = '';
this.writeEditorValue('');
this.syncToLivewire(true);
},
runCommand(command) {
this.$refs.editor?.focus();
document.execCommand(command, false, null);
this.syncToLivewire(true);
},
wrapBlock(tagName) {
this.$refs.editor?.focus();
document.execCommand('formatBlock', false, tagName);
this.syncToLivewire(true);
},
}"
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()"
>
<label for="{{ $model }}-editor" class="block text-sm font-semibold text-zinc-200">
{{ $label }}
</label>
<div class="overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-950 shadow-sm">
<div class="flex flex-wrap items-center gap-1.5 border-b border-zinc-700 bg-zinc-900/80 px-4 py-2 text-sm text-zinc-300" data-helpdesk-editor-toolbar>
<span class="mr-2 rounded-lg bg-zinc-800 px-2 py-1 text-xs font-black uppercase tracking-wide text-zinc-100">Spellcheck enabled</span>
<button type="button" x-on:click="runCommand('bold')" data-helpdesk-editor-command="bold"
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-xs font-black text-zinc-100 shadow-sm transition hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-400/70"
aria-label="Bold selected text">
B
</button>
<button type="button" x-on:click="runCommand('italic')" data-helpdesk-editor-command="italic"
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-xs font-black italic text-zinc-100 shadow-sm transition hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-400/70"
aria-label="Italic selected text">
I
</button>
<button type="button" x-on:click="runCommand('underline')" data-helpdesk-editor-command="underline"
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-xs font-black underline text-zinc-100 shadow-sm transition hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-400/70"
aria-label="Underline selected text">
U
</button>
<button type="button" x-on:click="runCommand('insertUnorderedList')" data-helpdesk-editor-command="insertUnorderedList"
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-xs font-black text-zinc-100 shadow-sm transition hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-400/70">
• List
</button>
<button type="button" x-on:click="runCommand('insertOrderedList')" data-helpdesk-editor-command="insertOrderedList"
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-xs font-black text-zinc-100 shadow-sm transition hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-400/70">
1. List
</button>
<button type="button" x-on:click="wrapBlock('blockquote')" data-helpdesk-editor-command="blockquote"
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-xs font-black text-zinc-100 shadow-sm transition hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-400/70">
Quote
</button>
<button type="button" x-on:click="wrapBlock('pre')" data-helpdesk-editor-command="pre"
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-xs font-black text-zinc-100 shadow-sm transition hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-400/70">
Code
</button>
</div>
<div
id="{{ $model }}-editor"
x-ref="editor"
contenteditable="true"
spellcheck="true"
lang="en-GB"
autocorrect="on"
autocomplete="on"
autocapitalize="sentences"
inputmode="text"
role="textbox"
aria-multiline="true"
data-placeholder="{{ $placeholder }}"
data-wire-model="{{ $model }}"
data-helpdesk-sync-target="{{ $model }}"
data-helpdesk-editor-model="{{ $model }}"
@if ($editorDusk) data-dusk="{{ $editorDusk }}" dusk="{{ $editorDusk }}" @endif
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"
></div>
<textarea
x-ref="input"
wire:model.defer="{{ $model }}"
spellcheck="true"
lang="en-GB"
autocorrect="on"
autocomplete="on"
autocapitalize="sentences"
inputmode="text"
class="sr-only"
tabindex="-1"
aria-hidden="true"
data-helpdesk-spellcheck-mirror="{{ $model }}"
></textarea>
</div>
<p class="text-xs font-semibold text-zinc-400">
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.
</p>
</div>
@@ -0,0 +1,9 @@
@props(['title' => null])
{{--
Helpdesk compatibility component.
Do not call <x-layouts.app> 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')
@@ -0,0 +1,47 @@
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
<thead
class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
<tr>
<th class="px-3 py-2.5">Name</th>
<th class="px-3 py-2.5">Slug</th>
<th class="px-3 py-2.5">Colour</th>
<th class="px-3 py-2.5">Default</th>
<th class="px-3 py-2.5">Tickets</th>
<th class="px-3 py-2.5 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse ($items as $item)
<tr>
<td class="px-3 py-2.5 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->name }}</td>
<td class="px-3 py-2.5 font-mono text-xs text-zinc-500">{{ $item->slug }}</td>
<td class="px-3 py-2.5">
<span class="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-xs font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200">
<span class="h-3 w-3 rounded-full" style="background-color: {{ \Modules\Helpdesk\Support\Color::css($item->color) }}"></span>
{{ \Illuminate\Support\Str::of(\Modules\Helpdesk\Support\Color::normalise($item->color))->headline() }}
</span>
</td>
<td class="px-3 py-2.5">
<flux:badge color="{{ $item->is_default ?? false ? 'emerald' : 'zinc' }}">
{{ $item->is_default ?? false ? 'Default' : 'No' }}
</flux:badge>
</td>
<td class="px-3 py-2.5 text-zinc-500">{{ $item->tickets_count ?? 0 }}</td>
<td class="px-3 py-2.5 text-right">
<div class="flex justify-end gap-1.5">
<button type="button" wire:click="{{ $edit }}({{ $item->id }})"
class="hd-btn hd-btn-sm hd-btn-blue">Edit</button>
<button type="button" wire:click="{{ $delete }}({{ $item->id }})"
class="hd-btn hd-btn-sm hd-btn-red">Delete</button>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-3 py-8 text-center text-zinc-500">No records yet.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@@ -0,0 +1,47 @@
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
<thead
class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
<tr>
<th class="px-3 py-2.5">Project</th>
<th class="px-3 py-2.5">Company</th>
<th class="px-3 py-2.5">Team</th>
<th class="px-3 py-2.5">Colour</th>
<th class="px-3 py-2.5">Default</th>
<th class="px-3 py-2.5">Tickets</th>
<th class="px-3 py-2.5 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse ($items as $item)
<tr>
<td class="px-3 py-2.5 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->title }}</td>
<td class="px-3 py-2.5 text-zinc-500">{{ $item->company?->name ?? 'Global' }}</td>
<td class="px-3 py-2.5 text-zinc-500">{{ $item->team?->name ?? 'No team' }}</td>
<td class="px-3 py-2.5">
<span class="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-xs font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200">
<span class="h-3 w-3 rounded-full" style="background-color: {{ \Modules\Helpdesk\Support\Color::css($item->color) }}"></span>
{{ \Illuminate\Support\Str::of(\Modules\Helpdesk\Support\Color::normalise($item->color))->headline() }}
</span>
</td>
<td class="px-3 py-2.5">
<flux:badge color="{{ $item->is_default ?? false ? 'emerald' : 'zinc' }}">
{{ $item->is_default ?? false ? 'Default' : 'No' }}</flux:badge>
</td>
<td class="px-3 py-2.5 text-zinc-500">{{ $item->tickets_count ?? 0 }}</td>
<td class="px-3 py-2.5 text-right">
<div class="flex justify-end gap-1.5"><button type="button"
wire:click="editProject({{ $item->id }})"
class="hd-btn hd-btn-sm hd-btn-blue">Edit</button><button type="button"
wire:click="deleteProject({{ $item->id }})"
class="hd-btn hd-btn-sm hd-btn-red">Delete</button></div>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-3 py-8 text-center text-zinc-500">No projects yet.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@@ -0,0 +1,36 @@
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
<thead
class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
<tr>
<th class="px-3 py-2.5">Name</th>
<th class="px-3 py-2.5">Provider</th>
<th class="px-3 py-2.5">Project</th>
<th class="px-3 py-2.5">Commits</th>
<th class="px-3 py-2.5 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse ($items as $item)
<tr>
<td class="px-3 py-2.5 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->name }}</td>
<td class="px-3 py-2.5 text-zinc-500">{{ strtoupper($item->provider) }}</td>
<td class="px-3 py-2.5 text-zinc-500">{{ $item->project?->title ?? 'No project' }}</td>
<td class="px-3 py-2.5 text-zinc-500">{{ $item->commits_count ?? 0 }}</td>
<td class="px-3 py-2.5 text-right">
<div class="flex justify-end gap-1.5"><button type="button"
wire:click="editRepository({{ $item->id }})"
class="hd-btn hd-btn-sm hd-btn-blue">Edit</button><button type="button"
wire:click="deleteRepository({{ $item->id }})"
wire:confirm="Delete this repository?"
class="hd-btn hd-btn-sm hd-btn-red">Delete</button></div>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-3 py-8 text-center text-zinc-500">No repositories yet.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@@ -0,0 +1,33 @@
@props([
'title' => 'Settings',
'description' => '',
])
<section
{{ $attributes->merge([
'class' => 'overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm ring-1 ring-zinc-900/5 dark:border-zinc-700 dark:bg-zinc-900 dark:ring-white/10',
]) }}>
<div class="border-b border-zinc-200 bg-gradient-to-r from-slate-950 via-blue-950 to-slate-900 px-4 py-3 text-white dark:border-zinc-700">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<p class="text-[10px] font-black uppercase tracking-[0.22em] text-blue-200">Configuration</p>
<h2 class="mt-0.5 text-base font-bold tracking-tight text-white">{{ $title }}</h2>
<p class="mt-1 max-w-3xl text-xs font-medium leading-5 text-blue-100/90 sm:text-sm">{{ $description }}</p>
</div>
<span class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-white/10 text-blue-100 shadow-inner">
<i class="fa-solid fa-sliders text-xs" aria-hidden="true"></i>
</span>
</div>
</div>
<div class="border-b border-zinc-200 bg-zinc-50 px-3 py-3 dark:border-zinc-800 dark:bg-zinc-950/45 sm:px-4">
<div class="mx-auto max-w-7xl rounded-xl border border-zinc-200 bg-white p-3 shadow-sm dark:border-zinc-700 dark:bg-zinc-900/90 sm:p-4 [&_input:not([type='checkbox']):not([type='radio']):not([type='color'])]:min-h-9 [&_textarea]:min-h-20 [&_textarea]:leading-6">
{{ $form }}
</div>
</div>
<div class="bg-white dark:bg-zinc-900">
{{ $slot }}
</div>
</section>
@@ -0,0 +1,45 @@
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
<thead
class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
<tr>
<th class="px-3 py-2.5">Team</th>
<th class="px-3 py-2.5">Company</th>
<th class="px-3 py-2.5">Colour</th>
<th class="px-3 py-2.5">Default</th>
<th class="px-3 py-2.5">Tickets</th>
<th class="px-3 py-2.5 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse ($items as $item)
<tr>
<td class="px-3 py-2.5 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->name }}</td>
<td class="px-3 py-2.5 text-zinc-500">{{ $item->company?->name ?? 'Global' }}</td>
<td class="px-3 py-2.5">
<span class="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-xs font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200">
<span class="h-3 w-3 rounded-full" style="background-color: {{ \Modules\Helpdesk\Support\Color::css($item->color) }}"></span>
{{ \Illuminate\Support\Str::of(\Modules\Helpdesk\Support\Color::normalise($item->color))->headline() }}
</span>
</td>
<td class="px-3 py-2.5">
<flux:badge color="{{ $item->is_default ?? false ? 'emerald' : 'zinc' }}">
{{ $item->is_default ?? false ? 'Default' : 'No' }}</flux:badge>
</td>
<td class="px-3 py-2.5 text-zinc-500">{{ $item->tickets_count ?? 0 }}</td>
<td class="px-3 py-2.5 text-right">
<div class="flex justify-end gap-1.5"><button type="button"
wire:click="editTeam({{ $item->id }})"
class="hd-btn hd-btn-sm hd-btn-blue">Edit</button><button type="button"
wire:click="deleteTeam({{ $item->id }})"
class="hd-btn hd-btn-sm hd-btn-red">Delete</button></div>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-3 py-8 text-center text-zinc-500">No teams yet.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@@ -0,0 +1 @@
<livewire:helpdesk.dashboard-widget />
@@ -1,124 +1,196 @@
<div class="flex w-full flex-1 flex-col gap-6">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<flux:heading size="xl">{{ __('Helpdesk') }}</flux:heading>
<flux:text class="mt-1">{{ __('Support operations, workload and recent activity.') }}</flux:text>
</div>
<div class="flex flex-wrap gap-2">
<flux:button :href="route('helpdesk.tickets.index')" variant="ghost" icon="list-bullet" wire:navigate>
{{ __('Tickets') }}
</flux:button>
<flux:button :href="route('helpdesk.kanban')" variant="ghost" icon="view-columns" wire:navigate>
{{ __('Kanban') }}
</flux:button>
<flux:button :href="route('helpdesk.tickets.create')" variant="primary" icon="plus" wire:navigate>
{{ __('New ticket') }}
</flux:button>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div class="rounded-xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<flux:text>{{ __('Total tickets') }}</flux:text>
<div class="mt-2 text-3xl font-semibold tracking-tight text-zinc-950 dark:text-white">{{ $totalCount }}</div>
<flux:text class="mt-2">{{ __('All tickets visible to you') }}</flux:text>
</div>
<div class="rounded-xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<flux:text>{{ __('Open') }}</flux:text>
<div class="mt-2 text-3xl font-semibold tracking-tight text-zinc-950 dark:text-white">{{ $openCount }}</div>
<flux:text class="mt-2">{{ $openPercent }}% {{ __('of total') }}</flux:text>
</div>
<div class="rounded-xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<flux:text>{{ __('Closed') }}</flux:text>
<div class="mt-2 text-3xl font-semibold tracking-tight text-zinc-950 dark:text-white">{{ $closedCount }}</div>
<flux:text class="mt-2">{{ $closedPercent }}% {{ __('resolved') }}</flux:text>
</div>
<div class="rounded-xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<flux:text>{{ __('High priority') }}</flux:text>
<div class="mt-2 text-3xl font-semibold tracking-tight text-zinc-950 dark:text-white">{{ $criticalCount }}</div>
<flux:text class="mt-2">{{ __('Critical, urgent or high') }}</flux:text>
</div>
</div>
<div class="grid gap-6 xl:grid-cols-3">
<section class="xl:col-span-2 rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="flex items-center justify-between border-b border-zinc-200 px-5 py-4 dark:border-zinc-700">
<div class="space-y-5" dusk="helpdesk-dashboard-widget">
<span class="sr-only">Total tickets Open tickets Closed tickets</span>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-2xl border border-blue-400/20 bg-gradient-to-br from-slate-950 to-blue-950/40 p-4 shadow-sm ring-1 ring-white/5">
<div class="flex items-center justify-between gap-3">
<div>
<flux:heading size="lg">{{ __('Recent tickets') }}</flux:heading>
<flux:text>{{ __('Latest support activity') }}</flux:text>
<div class="text-xs font-black uppercase tracking-[0.18em] text-blue-200/80">Total tickets</div>
<div class="mt-2 text-3xl font-black text-white">{{ $totalCount }}</div>
</div>
<div class="flex h-11 w-11 items-center justify-center rounded-xl bg-blue-500/20 text-blue-200 ring-1 ring-blue-300/20">
<i class="fa-solid fa-ticket" aria-hidden="true"></i>
</div>
<flux:button :href="route('helpdesk.tickets.index')" size="sm" variant="ghost" wire:navigate>
{{ __('View all') }}
</flux:button>
</div>
<div class="mt-4 h-1.5 rounded-full bg-slate-800">
<div class="h-1.5 rounded-full bg-blue-400" style="width: 100%"></div>
</div>
</div>
<div class="divide-y divide-zinc-200 dark:divide-zinc-700">
<div class="rounded-2xl border border-blue-400/20 bg-gradient-to-br from-slate-950 to-blue-950/40 p-4 shadow-sm ring-1 ring-white/5">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-xs font-black uppercase tracking-[0.18em] text-blue-200/80">Open tickets</div>
<div class="mt-2 text-3xl font-black text-blue-200">{{ $openCount }}</div>
</div>
<div class="flex h-11 w-11 items-center justify-center rounded-xl bg-blue-500/20 text-blue-200 ring-1 ring-blue-300/20">
<i class="fa-solid fa-inbox" aria-hidden="true"></i>
</div>
</div>
<div class="mt-4 h-1.5 rounded-full bg-slate-800">
<div class="h-1.5 rounded-full bg-blue-400" style="width: {{ $openPercent }}%"></div>
</div>
</div>
<div class="rounded-2xl border border-emerald-400/20 bg-gradient-to-br from-slate-950 to-emerald-950/30 p-4 shadow-sm ring-1 ring-white/5">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-xs font-black uppercase tracking-[0.18em] text-emerald-200/80">Closed tickets</div>
<div class="mt-2 text-3xl font-black text-emerald-200">{{ $closedCount }}</div>
</div>
<div class="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-500/20 text-emerald-200 ring-1 ring-emerald-300/20">
<i class="fa-solid fa-circle-check" aria-hidden="true"></i>
</div>
</div>
<div class="mt-4 h-1.5 rounded-full bg-slate-800">
<div class="h-1.5 rounded-full bg-emerald-400" style="width: {{ $closedPercent }}%"></div>
</div>
</div>
<div class="rounded-2xl border border-rose-400/20 bg-gradient-to-br from-slate-950 to-rose-950/25 p-4 shadow-sm ring-1 ring-white/5">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-xs font-black uppercase tracking-[0.18em] text-rose-200/80">High priority</div>
<div class="mt-2 text-3xl font-black text-rose-200">{{ $criticalCount }}</div>
</div>
<div class="flex h-11 w-11 items-center justify-center rounded-xl bg-rose-500/20 text-rose-200 ring-1 ring-rose-300/20">
<i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
</div>
</div>
<div class="mt-4 text-xs font-semibold text-slate-400">Open urgent/high/critical workload.</div>
</div>
</div>
<div class="grid gap-5 xl:grid-cols-[minmax(0,2fr)_minmax(22rem,1fr)]">
<div class="rounded-2xl border border-zinc-700 bg-gradient-to-br from-slate-950 via-slate-950 to-blue-950/20 shadow-sm ring-1 ring-white/5">
<div class="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div class="flex items-center gap-3">
<span class="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-500/15 text-blue-200 ring-1 ring-blue-300/20">
<i class="fa-solid fa-list-check" aria-hidden="true"></i>
</span>
<div>
<h2 class="text-lg font-black text-white">Recent tickets</h2>
<p class="text-sm text-slate-400">Latest support activity.</p>
</div>
</div>
<a href="{{ route('helpdesk.tickets.index') }}" wire:navigate class="hd-btn hd-btn-sm hd-btn-blue">View all tickets</a>
</div>
{{-- Recent Tickets --}}
<div class="divide-y divide-white/10">
@forelse ($recentTickets as $ticket)
<a
href="{{ route('helpdesk.tickets.show', $ticket) }}"
wire:navigate
class="flex items-start justify-between gap-4 px-5 py-4 transition hover:bg-zinc-50 dark:hover:bg-zinc-800/60"
>
<div class="min-w-0">
<div class="truncate font-medium text-zinc-950 dark:text-white">
{{ $ticket->reference ?? $ticket->id }} · {{ $ticket->subject ?? __('Untitled ticket') }}
</div>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-zinc-500 dark:text-zinc-400">
@if ($ticket->status)
<span>{{ $ticket->status->name }}</span>
@endif
@if ($ticket->priority)
<span>{{ $ticket->priority->name }}</span>
@endif
@if ($ticket->last_activity_at)
<span>{{ $ticket->last_activity_at->diffForHumans() }}</span>
@endif
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate
class="flex flex-nowrap items-center gap-4 px-5 py-4 transition hover:bg-blue-500/10">
<div class="flex w-36 shrink-0 items-center gap-3 text-sm font-semibold text-slate-300">
<span class="h-2 w-2 shrink-0 rounded-full bg-blue-400"></span>
<span class="whitespace-nowrap">{{ $ticket->reference }}</span>
</div>
<div class="min-w-0 flex-1">
<div class="truncate font-black text-white">{{ $ticket->subject }}</div>
<div class="mt-1 truncate text-xs text-slate-400">
{{ optional($ticket->last_activity_at)->format('d M Y H:i') ?? 'No activity yet' }}
</div>
</div>
<flux:icon.chevron-right class="mt-1 size-4 shrink-0 text-zinc-400" />
<div class="w-28 shrink-0">
<x-helpdesk.colour-badge :color="$ticket->status?->color ?? 'zinc'">
{{ $ticket->status?->name ?? 'No status' }}
</x-helpdesk.colour-badge>
</div>
<div class="w-28 shrink-0">
<x-helpdesk.colour-badge :color="$ticket->priority?->color ?? 'zinc'">
{{ $ticket->priority?->name ?? 'No priority' }}
</x-helpdesk.colour-badge>
</div>
<div class="w-28 shrink-0 whitespace-nowrap text-right text-sm font-semibold text-slate-400">
{{ optional($ticket->last_activity_at)->diffForHumans() ?? 'No activity' }}
</div>
</a>
@empty
<div class="px-5 py-10 text-center">
<flux:text>{{ __('No tickets yet.') }}</flux:text>
</div>
<div class="px-5 py-8 text-center text-sm text-slate-400">No helpdesk tickets yet.</div>
@endforelse
</div>
</section>
{{-- Recent Tickets End--}}
</div>
<section class="rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="border-b border-zinc-200 px-5 py-4 dark:border-zinc-700">
<flux:heading size="lg">{{ __('Recent Git activity') }}</flux:heading>
<flux:text>{{ __('Commits linked to Helpdesk') }}</flux:text>
</div>
<div class="space-y-5">
<div class="rounded-2xl border border-zinc-700 bg-gradient-to-br from-slate-950 to-slate-900 p-5 shadow-sm ring-1 ring-white/5">
<div class="mb-5 flex items-center justify-between">
<div>
<h2 class="text-lg font-black text-white">SLA overview</h2>
<p class="text-sm text-slate-400">Closure rate and open workload.</p>
</div>
<span class="rounded-xl bg-emerald-500/15 px-3 py-1.5 text-sm font-black text-emerald-200 ring-1 ring-emerald-300/20">{{ $closedPercent }}% closed</span>
</div>
<div class="divide-y divide-zinc-200 dark:divide-zinc-700">
@forelse ($recentCommits as $commit)
<div class="px-5 py-4">
<div class="truncate font-medium text-zinc-950 dark:text-white">
{{ $commit->repository?->name ?? __('Repository') }}
</div>
<div class="mt-1 line-clamp-2 text-sm text-zinc-600 dark:text-zinc-300">
{{ $commit->message ?? __('Commit') }}
</div>
<div class="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-zinc-500 dark:text-zinc-400">
@if ($commit->sha)
<span>{{ substr($commit->sha, 0, 8) }}</span>
@endif
@if ($commit->committed_at)
<span>{{ $commit->committed_at->diffForHumans() }}</span>
@endif
<div class="grid gap-5 sm:grid-cols-[9rem_minmax(0,1fr)] sm:items-center">
<div class="relative mx-auto flex h-32 w-32 items-center justify-center rounded-full bg-slate-900 ring-8 ring-emerald-400/20">
<div class="absolute inset-2 rounded-full border-8 border-emerald-400" style="clip-path: polygon(0 0, {{ max($closedPercent, 8) }}% 0, {{ max($closedPercent, 8) }}% 100%, 0 100%);"></div>
<div class="relative text-center">
<div class="text-3xl font-black text-white">{{ $closedPercent }}%</div>
<div class="text-xs font-bold text-slate-400">closed</div>
</div>
</div>
@empty
<div class="px-5 py-10 text-center">
<flux:text>{{ __('No Git activity yet.') }}</flux:text>
<div class="space-y-4">
<div>
<div class="mb-2 flex justify-between text-sm font-semibold text-slate-300"><span>Closed</span><span>{{ $closedPercent }}%</span></div>
<div class="h-2 rounded-full bg-slate-800"><div class="h-2 rounded-full bg-emerald-400" style="width: {{ $closedPercent }}%"></div></div>
</div>
<div>
<div class="mb-2 flex justify-between text-sm font-semibold text-slate-300"><span>Open</span><span>{{ $openPercent }}%</span></div>
<div class="h-2 rounded-full bg-slate-800"><div class="h-2 rounded-full bg-blue-400" style="width: {{ $openPercent }}%"></div></div>
</div>
</div>
@endforelse
</div>
</div>
</section>
<details
class="group rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
dusk="recent-commits-card">
<summary
class="-mx-4 -mt-4 mb-4 flex cursor-pointer list-none items-center justify-between gap-3 rounded-t-xl bg-violet-700 px-4 py-4 text-white marker:hidden">
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-violet-950 text-white ring-1 ring-violet-300/60 shadow-lg">
<i class="fa-solid fa-code-branch text-lg" aria-hidden="true"></i>
</span>
<div class="min-w-0">
<h2 class="text-lg font-black uppercase tracking-wide">Recent commits</h2>
<p class="text-xs font-semibold text-violet-100">Latest linked Git activity</p>
</div>
</div>
<span
class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white/15 text-white ring-1 ring-white/20">
<i class="fa-solid fa-chevron-right text-xs transition group-open:rotate-90" aria-hidden="true"></i>
</span>
</summary>
<div class="mb-4 flex justify-end">
<a href="{{ route('helpdesk.settings') }}" wire:navigate class="hd-btn hd-btn-sm hd-btn-violet">
Git setup
</a>
</div>
<div class="space-y-2">
@forelse ($recentCommits as $commit)
<div class="rounded-xl border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-950">
<div class="font-mono text-xs text-zinc-500 dark:text-zinc-400">
{{ $commit->short_hash }} · {{ $commit->repository?->name }}
</div>
<div class="mt-1 truncate text-sm font-semibold text-zinc-900 dark:text-zinc-100">
{{ $commit->message }}
</div>
</div>
@empty
<div class="rounded-xl border border-dashed border-zinc-300 p-4 text-center text-sm text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
No commits tracked yet.
</div>
@endforelse
</div>
</details>
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
@@ -1,76 +1,405 @@
<div class="space-y-6">
<div class="flex items-center justify-between gap-4">
<div>
<flux:heading size="xl">{{ __('Create ticket') }}</flux:heading>
<flux:text>{{ __('Create a new Helpdesk request.') }}</flux:text>
</div>
<flux:button :href="route('helpdesk.tickets.index')" wire:navigate>{{ __('Back to tickets') }}</flux:button>
</div>
@php
$helpdeskAttachmentAccept = collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
->filter(fn ($extension) => is_string($extension) && trim($extension) !== '')
->map(fn ($extension) => '.' . strtolower(ltrim(trim($extension), '.')))
->unique()
->implode(',');
@endphp
<div class="hd-helpdesk-surface space-y-6" dusk="helpdesk-ticket-create" x-data="{ helpdeskAttachmentsUploading: false }">
@include('modules.helpdesk.partials.page-header', [
'eyebrow' => 'Helpdesk',
'title' => 'Create ticket',
'subtitle' =>
'Raise a new support request with priority, product/service, project, category, team and full details.',
])
<form wire:submit="save" class="space-y-6">
<div class="grid gap-6 rounded-2xl border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-900 lg:grid-cols-2">
<div class="lg:col-span-2">
<flux:input wire:model="subject" :label="__('Subject')" required />
@error('subject') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
<form
wire:submit.prevent="save"
x-on:submit.capture="
if (helpdeskAttachmentsUploading) {
$event.preventDefault();
return;
}
const editor = document.querySelector('[data-helpdesk-sync-target=&quot;body&quot;], [data-helpdesk-editor-model=&quot;body&quot;]');
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) {
target.value = body;
target.dispatchEvent(new Event('input', { bubbles: true }));
target.dispatchEvent(new Event('change', { bubbles: true }));
}
if ($wire) {
$wire.set('body', body, false);
}
$dispatch('helpdesk-editor-sync', { model: 'body', live: false });
"
class="helpdesk-ticket-form space-y-6"
>
<div class="overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-900 shadow-sm">
<div class="border-b border-zinc-700 bg-zinc-800/80 px-6 py-4">
<h2 class="text-lg font-semibold text-zinc-100">Ticket details</h2>
<p class="mt-1 text-sm text-zinc-400">
Mandatory fields are marked with <span class="helpdesk-required-star">*</span>.
Use the searchable lists to keep the ticket correctly routed.
</p>
</div>
<div class="lg:col-span-2">
<flux:textarea wire:model="body" rows="10" :label="__('Description')" required />
@error('body') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
<div class="grid gap-5 p-6 lg:grid-cols-3 xl:grid-cols-6">
<div class="lg:col-span-3 xl:col-span-6">
<label class="block text-sm font-semibold text-zinc-200" for="subject">
Subject <span class="helpdesk-required-star">*</span>
</label>
<input id="subject" type="text" spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" autocapitalize="sentences" inputmode="text" maxlength="180" wire:model.defer="subject"
class="mt-2 block h-14 w-full max-w-4xl rounded-xl border border-zinc-700 bg-zinc-950 px-4 text-base font-medium text-zinc-100 shadow-sm outline-none transition-colors duration-150 placeholder:text-zinc-500 focus:border-blue-500 focus:bg-zinc-950 focus:ring-2 focus:ring-blue-500/40"
data-dusk="ticket-subject" dusk="ticket-subject" autocomplete="off" />
@error('subject')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
@if ($companies->isNotEmpty())
<div class="xl:col-span-2">
<label class="block text-sm font-semibold text-zinc-200" for="company_id">
Company <span class="helpdesk-required-star">*</span>
</label>
<div class="mt-2" wire:ignore>
<select id="company_id" dusk="ticket-company" data-helpdesk-select2 wire:model.defer="company_id" name="company_id"
data-placeholder="Select option" data-wire-model="company_id"
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">Select company</option>
@foreach ($companies as $company)
<option value="{{ $company->id }}" @selected((string) $company_id === (string) $company->id)>
{{ $company->name }}</option>
@endforeach
</select>
</div>
@error('company_id')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
@endif
<div class="xl:col-span-2">
<label class="block text-sm font-semibold text-zinc-200" for="board_id">
Board <span class="helpdesk-required-star">*</span>
</label>
<div class="mt-2" wire:ignore>
<select id="board_id" data-helpdesk-select2 wire:model.defer="board_id" name="board_id" data-placeholder="Select option"
data-wire-model="board_id"
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500"
data-dusk="ticket-board" dusk="ticket-board">
<option value="">No board</option>
@foreach ($boards as $board)
<option value="{{ $board->id }}" @selected((int) $board_id === (int) $board->id)>
{{ $board->name }}{{ $board->owner ? ' · ' . $board->owner->name : '' }}</option>
@endforeach
</select>
</div>
@error('board_id')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="xl:col-span-2">
<label class="block text-sm font-semibold text-zinc-200" for="priority_id">
Priority <span class="helpdesk-required-star">*</span>
</label>
<div class="mt-2" wire:ignore>
<select id="priority_id" data-helpdesk-select2 wire:model.defer="priority_id" name="priority_id" data-placeholder="Select option"
data-wire-model="priority_id"
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500"
data-dusk="ticket-priority" dusk="ticket-priority">
<option value="">Select priority</option>
@foreach ($priorities as $priority)
<option value="{{ $priority->id }}" @selected((string) $priority_id === (string) $priority->id)>{{ $priority->name }}
</option>
@endforeach
</select>
</div>
@error('priority_id')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="xl:col-span-2">
<label class="block text-sm font-semibold text-zinc-200" for="category_id">
Category <span class="helpdesk-required-star">*</span>
</label>
<div class="mt-2" wire:ignore>
<select id="category_id" data-helpdesk-select2 wire:model.defer="category_id" name="category_id" data-placeholder="Select option"
data-wire-model="category_id"
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">Select category</option>
@foreach ($categories as $category)
<option value="{{ $category->id }}" @selected((string) $category_id === (string) $category->id)>{{ $category->name }}
</option>
@endforeach
</select>
</div>
@error('category_id')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="xl:col-span-2">
<label class="block text-sm font-semibold text-zinc-200" for="project_id">
Project <span class="helpdesk-required-star">*</span>
</label>
<div class="mt-2" wire:ignore>
<select id="project_id" data-helpdesk-select2 wire:model.defer="project_id" name="project_id" data-placeholder="Select option"
data-wire-model="project_id"
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">Select project</option>
@foreach ($projects as $project)
<option value="{{ $project->id }}" @selected((string) $project_id === (string) $project->id)>
{{ $project->title ?? $project->name }}</option>
@endforeach
</select>
</div>
@error('project_id')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="xl:col-span-2">
<label class="block text-sm font-semibold text-zinc-200" for="team_id">
Team <span class="helpdesk-required-star">*</span>
</label>
<div class="mt-2" wire:ignore>
<select id="team_id" data-helpdesk-select2 wire:model.defer="team_id" name="team_id" data-placeholder="Select option"
data-wire-model="team_id"
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">Select team</option>
@foreach ($teams as $team)
<option value="{{ $team->id }}" @selected((string) $team_id === (string) $team->id)>{{ $team->name }}
</option>
@endforeach
</select>
</div>
@error('team_id')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="lg:col-span-3 xl:col-span-6">
<fieldset class="rounded-2xl border border-zinc-700 bg-zinc-950/70 p-4">
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<legend class="text-sm font-semibold text-zinc-200">
Tags
</legend>
<span class="text-xs text-zinc-500">
Optional · select all that apply
</span>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
@forelse ($tags as $tag)
<label for="tag_{{ $tag->id }}"
class="flex cursor-pointer items-center gap-3 rounded-xl border border-zinc-700 bg-zinc-900 px-4 py-3 text-sm font-medium text-zinc-200 transition hover:border-blue-500 hover:bg-zinc-800">
<input id="tag_{{ $tag->id }}" type="checkbox" wire:model.defer="tag_ids"
value="{{ $tag->id }}"
class="h-4 w-4 rounded border-zinc-600 bg-zinc-950 text-blue-600 focus:ring-blue-500">
<span class="truncate">
{{ $tag->name }}
</span>
</label>
@empty
<div
class="rounded-xl border border-dashed border-zinc-700 bg-zinc-900 px-4 py-3 text-sm text-zinc-400">
No tags are available.
</div>
@endforelse
</div>
@error('tag_ids')
<p class="mt-3 text-sm text-red-500">{{ $message }}</p>
@enderror
@error('tag_ids.*')
<p class="mt-3 text-sm text-red-500">{{ $message }}</p>
@enderror
</fieldset>
</div>
<div class="xl:col-span-2">
<x-helpdesk.date-picker id="due_at" model="due_at" label="Due date"
placeholder="Select due date" value="{{ $due_at }}" />
@error('due_at')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="lg:col-span-3 xl:col-span-6">
<x-helpdesk.html-editor
model="body"
label="Details"
rows="12"
placeholder="Describe the issue, expected outcome, steps to reproduce and any useful context..."
editor-dusk="ticket-body-editor"
/>
<p class="mt-2 flex items-center gap-2 text-xs font-medium text-zinc-400">
<i class="fa-solid fa-circle-info text-blue-400" aria-hidden="true"></i>
Rich formatting is available. Browser spelling suggestions appear by right-clicking or Ctrl-clicking red-underlined words.
</p>
</div>
<div class="lg:col-span-3 xl:col-span-6">
<div
wire:key="ticket-create-attachments-uploader"
class="rounded-2xl border border-zinc-700 bg-zinc-950/70 p-4"
x-data="{ progress: 0 }"
x-on:livewire-upload-start="helpdeskAttachmentsUploading = true"
x-on:livewire-upload-progress="progress = $event.detail.progress"
x-on:livewire-upload-finish="helpdeskAttachmentsUploading = false"
x-on:livewire-upload-error="helpdeskAttachmentsUploading = false"
x-on:livewire-upload-cancel="helpdeskAttachmentsUploading = false"
>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="block text-sm font-semibold text-zinc-200" for="attachments">
Attachments
</label>
<p class="mt-1 text-sm text-zinc-500">
Files are uploaded to Livewire temporary storage first. They are attached permanently only after the ticket is created.
</p>
</div>
<label for="attachments"
class="inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-zinc-950">
<i class="fa-solid fa-paperclip" aria-hidden="true"></i>
<span>Add attachments</span>
</label>
</div>
<label for="attachments"
class="mt-4 flex cursor-pointer flex-col items-center justify-center rounded-2xl border border-dashed border-zinc-700 bg-zinc-900/70 px-4 py-8 text-center transition hover:border-blue-500 hover:bg-zinc-900">
<i class="fa-solid fa-cloud-arrow-up text-2xl text-blue-300" aria-hidden="true"></i>
<span class="mt-3 text-sm font-bold text-zinc-100">Click to choose files</span>
<span class="mt-1 text-xs text-zinc-500">Maximum {{ (int) config('helpdesk.max_upload_kb', 10240) }} KB per file.</span>
</label>
<div x-data class="rounded-2xl border border-zinc-700 bg-zinc-950/70 p-4">
<input
x-ref="attachmentsInput"
id="attachments"
type="file"
wire:model="attachments"
multiple
accept="{{ $helpdeskAttachmentAccept }}"
class="hidden"
wire:key="ticket-create-attachments-input"
dusk="ticket-create-attachments-input"
data-dusk="ticket-create-attachments-input"/>
<button
type="button"
x-on:click="$refs.attachmentsInput.click()"
class="inline-flex items-center justify-center gap-2 rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white">
<i class="fa-solid fa-paperclip" aria-hidden="true"></i>
Add attachments
</button>
<button
type="button"
x-on:click="$refs.attachmentsInput.click()"
class="mt-4 flex w-full flex-col items-center justify-center rounded-2xl border border-dashed border-zinc-700 bg-zinc-900/70 px-4 py-8 text-center">
<i class="fa-solid fa-cloud-arrow-up text-2xl text-blue-300" aria-hidden="true"></i>
<span class="mt-3 text-sm font-bold text-zinc-100">Click to choose files</span>
<span class="mt-1 text-xs text-zinc-500">
Maximum {{ (int) config('helpdesk.max_upload_kb', 10240) }} KB per file.
</span>
</button>
</div>
@if (count($attachments) > 0)
<div class="mt-4 grid gap-2 sm:grid-cols-2" dusk="ticket-create-selected-attachments">
@foreach ($attachments as $attachment)
<div class="flex items-center gap-3 rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-xs text-zinc-300" wire:key="ticket-create-selected-attachment-{{ $loop->index }}">
<i class="fa-regular fa-file-lines text-blue-300" aria-hidden="true"></i>
<span class="min-w-0 flex-1 truncate">{{ $attachment->getClientOriginalName() }}</span>
<span class="shrink-0 text-zinc-500">{{ number_format($attachment->getSize() / 1024, 1) }} KB</span>
</div>
@endforeach
</div>
@endif
<div x-show="helpdeskAttachmentsUploading" x-cloak
class="mt-4 rounded-xl border border-blue-500/30 bg-blue-500/10 px-4 py-3 text-sm font-medium text-blue-200">
<div class="flex items-center justify-between gap-3">
<span class="inline-flex items-center gap-2"><i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>Uploading attachments...</span>
<span x-text="progress + '%'"></span>
</div>
<div class="mt-3 h-2 overflow-hidden rounded-full bg-zinc-800">
<div class="h-full rounded-full bg-blue-500 transition-all" x-bind:style="`width: ${progress}%`"></div>
</div>
</div>
@error('attachments')
<p class="mt-3 text-sm text-red-500">{{ $message }}</p>
@enderror
@error('attachments.*')
<p class="mt-3 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
</div>
</div>
<flux:select wire:model="board_id" :label="__('Board')">
<option value="">{{ __('No board') }}</option>
@foreach ($boards as $item)<option value="{{ $item->id }}">{{ $item->name }}</option>@endforeach
</flux:select>
<div class="flex flex-col-reverse gap-2 border-t border-zinc-700 bg-zinc-900 px-6 py-4 sm:flex-row sm:items-center sm:justify-end">
<button type="button" wire:click="clearForm" wire:loading.attr="disabled" wire:target="clearForm"
class="inline-flex min-h-10 items-center justify-center rounded-lg bg-red-600 px-4 py-2 text-xs font-black text-white shadow-sm transition hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400/70 disabled:cursor-not-allowed disabled:opacity-50"
data-dusk="ticket-create-clear" dusk="ticket-create-clear">
<i class="fa-solid fa-eraser mr-2" aria-hidden="true"></i>
<span wire:loading.remove wire:target="clearForm">Clear</span>
<span wire:loading wire:target="clearForm">Clearing...</span>
</button>
<flux:select wire:model="priority_id" :label="__('Priority')" required>
<option value="">{{ __('Select priority') }}</option>
@foreach ($priorities as $item)<option value="{{ $item->id }}">{{ $item->name }}</option>@endforeach
</flux:select>
<a href="{{ route('helpdesk.tickets.index') }}" wire:navigate
class="inline-flex min-h-10 items-center justify-center rounded-lg bg-blue-600 px-4 py-2 text-xs font-black text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400/70"
data-dusk="ticket-create-cancel" dusk="ticket-create-cancel">
<i class="fa-solid fa-arrow-left mr-2" aria-hidden="true"></i>
Cancel
</a>
<flux:select wire:model="category_id" :label="__('Category')">
<option value="">{{ __('No category') }}</option>
@foreach ($categories as $item)<option value="{{ $item->id }}">{{ $item->name }}</option>@endforeach
</flux:select>
<flux:select wire:model="team_id" :label="__('Team')">
<option value="">{{ __('No team') }}</option>
@foreach ($teams as $item)<option value="{{ $item->id }}">{{ $item->name }}</option>@endforeach
</flux:select>
<flux:select wire:model="project_id" :label="__('Project')">
<option value="">{{ __('No project') }}</option>
@foreach ($projects as $item)<option value="{{ $item->id }}">{{ $item->title }}</option>@endforeach
</flux:select>
<flux:select wire:model="product_id" :label="__('Product')">
<option value="">{{ __('No product') }}</option>
@foreach ($products as $item)<option value="{{ $item->id }}">{{ $item->name }}</option>@endforeach
</flux:select>
<flux:input wire:model="due_at" type="date" :label="__('Due date')" />
<div>
<label class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ __('Tags') }}</label>
<select wire:model="tag_ids" multiple class="w-full rounded-lg border-zinc-300 bg-white dark:border-zinc-700 dark:bg-zinc-800">
@foreach ($tags as $item)<option value="{{ $item->id }}">{{ $item->name }}</option>@endforeach
</select>
<button type="submit"
class="inline-flex min-h-10 items-center justify-center rounded-lg bg-emerald-600 px-4 py-2 text-xs font-black text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70 disabled:cursor-not-allowed disabled:opacity-50"
data-dusk="ticket-create-submit" dusk="ticket-create-submit" wire:loading.attr="disabled" wire:target="save,attachments" x-bind:disabled="helpdeskAttachmentsUploading" wire:dirty.class="ring-2 ring-amber-400/40">
<i class="fa-regular fa-floppy-disk mr-2" aria-hidden="true"></i>
<span wire:loading.remove wire:target="save,attachments">Create ticket</span>
<span wire:loading wire:target="attachments">Uploading...</span>
<span wire:loading wire:target="save">Creating...</span>
</button>
</div>
<div class="lg:col-span-2">
<label class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ __('Attachments') }}</label>
<input wire:model="attachments" type="file" multiple class="block w-full text-sm text-zinc-600 dark:text-zinc-300">
@error('attachments.*') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
</div>
<div class="flex justify-end gap-3">
<flux:button type="button" wire:click="clearForm">{{ __('Clear') }}</flux:button>
<flux:button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="save">
<span wire:loading.remove wire:target="save">{{ __('Create ticket') }}</span>
<span wire:loading wire:target="save">{{ __('Saving…') }}</span>
</flux:button>
</div>
</form>
</div>
@@ -1,59 +1,428 @@
@php
$helpdeskAttachmentAccept = collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
->filter(fn ($extension) => is_string($extension) && trim($extension) !== '')
->map(fn ($extension) => '.' . strtolower(ltrim(trim($extension), '.')))
->unique()
->implode(',');
@endphp
<div class="space-y-6">
<div class="flex items-center justify-between gap-4">
<div>
<flux:heading size="xl">{{ __('Edit :reference', ['reference' => $ticket->reference]) }}</flux:heading>
<flux:text>{{ __('Update ticket details and assignment.') }}</flux:text>
</div>
<flux:button :href="route('helpdesk.tickets.show', $ticket)" wire:navigate>{{ __('Cancel') }}</flux:button>
</div>
@include('modules.helpdesk.partials.page-header', [
'eyebrow' => 'Helpdesk',
'title' => 'Edit Ticket',
'subtitle' =>
'Update ownership, assignment, workflow status, product/service, project, team, category, priority and ticket details.',
])
<form wire:submit="save" class="space-y-6">
<div class="grid gap-6 rounded-2xl border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-900 lg:grid-cols-2">
<div class="lg:col-span-2"><flux:input wire:model="subject" :label="__('Subject')" required /></div>
<div class="lg:col-span-2"><flux:textarea wire:model="body" rows="10" :label="__('Description')" required /></div>
<form wire:submit.prevent="save" class="space-y-6">
<div class="overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-900 shadow-sm">
<div class="border-b border-zinc-700 bg-zinc-800/80 px-6 py-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<div class="flex flex-wrap items-center gap-3">
<h2 class="text-lg font-semibold text-zinc-100">
{{ $ticket->reference }} · Ticket controls
</h2>
<flux:select wire:model="status_id" :label="__('Status')">@foreach ($statuses as $item)<option value="{{ $item->id }}">{{ $item->name }}</option>@endforeach</flux:select>
<flux:select wire:model="priority_id" :label="__('Priority')">@foreach ($priorities as $item)<option value="{{ $item->id }}">{{ $item->name }}</option>@endforeach</flux:select>
<flux:select wire:model="board_id" :label="__('Board')"><option value="">{{ __('No board') }}</option>@foreach ($boards as $item)<option value="{{ $item->id }}">{{ $item->name }}</option>@endforeach</flux:select>
<flux:select wire:model="agent_id" :label="__('Agent')"><option value="">{{ __('Unassigned') }}</option>@foreach ($agents as $item)<option value="{{ $item->id }}">{{ $item->name }}</option>@endforeach</flux:select>
<flux:select wire:model="category_id" :label="__('Category')"><option value="">{{ __('No category') }}</option>@foreach ($categories as $item)<option value="{{ $item->id }}">{{ $item->name }}</option>@endforeach</flux:select>
<flux:select wire:model="team_id" :label="__('Team')"><option value="">{{ __('No team') }}</option>@foreach ($teams as $item)<option value="{{ $item->id }}">{{ $item->name }}</option>@endforeach</flux:select>
<flux:select wire:model="project_id" :label="__('Project')"><option value="">{{ __('No project') }}</option>@foreach ($projects as $item)<option value="{{ $item->id }}">{{ $item->title }}</option>@endforeach</flux:select>
<flux:select wire:model="product_id" :label="__('Product')"><option value="">{{ __('No product') }}</option>@foreach ($products as $item)<option value="{{ $item->id }}">{{ $item->name }}</option>@endforeach</flux:select>
<flux:input wire:model="due_at" type="date" :label="__('Due date')" />
<div>
<label class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ __('Tags') }}</label>
<select wire:model="tag_ids" multiple class="w-full rounded-lg border-zinc-300 bg-white dark:border-zinc-700 dark:bg-zinc-800">
@foreach ($tags as $item)<option value="{{ $item->id }}">{{ $item->name }}</option>@endforeach
</select>
</div>
<div class="lg:col-span-2">
<label class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ __('New attachments') }}</label>
<input wire:model="attachments" type="file" multiple class="block w-full text-sm">
</div>
@if ($ticket->attachments->isNotEmpty())
<div class="lg:col-span-2 space-y-2">
<flux:heading size="sm">{{ __('Existing attachments') }}</flux:heading>
@foreach ($ticket->attachments as $attachment)
<div class="flex items-center justify-between rounded-lg border border-zinc-200 px-3 py-2 dark:border-zinc-700">
<span class="text-sm">{{ $attachment->original_name ?? $attachment->filename ?? __('Attachment') }}</span>
<flux:button size="sm" variant="danger" type="button" wire:click="removeAttachment({{ $attachment->id }})">{{ __('Remove') }}</flux:button>
<span
class="inline-flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-200">
<i class="fa-regular fa-user text-zinc-400" aria-hidden="true"></i>
Owner:
<span class="font-semibold text-white">{{ $ticket->owner?->name ?? 'Unknown' }}</span>
</span>
</div>
@endforeach
</div>
@endif
</div>
<div class="flex justify-end gap-3">
<flux:button type="button" wire:click="clearForm">{{ __('Clear') }}</flux:button>
<flux:button type="submit" variant="primary">{{ __('Save changes') }}</flux:button>
<p class="mt-2 text-sm text-zinc-400">
Mandatory fields are marked with <span class="helpdesk-required-star">*</span>.
Assignment and status updates trigger configured workflow messages.
</p>
</div>
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate
class="inline-flex items-center justify-center rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">
Back to ticket
</a>
</div>
</div>
<div class="grid gap-5 p-6 lg:grid-cols-3 xl:grid-cols-6">
<div class="lg:col-span-3 xl:col-span-6">
<label class="block text-sm font-semibold text-zinc-200" for="subject">
Subject <span class="helpdesk-required-star">*</span>
</label>
<input id="subject" type="text" spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" autocapitalize="sentences" inputmode="text" maxlength="180" wire:model.defer="subject"
class="mt-2 block h-14 w-full max-w-4xl rounded-xl border border-zinc-700 bg-zinc-950 px-4 text-base font-medium text-zinc-100 shadow-sm outline-none transition-colors duration-150 placeholder:text-zinc-500 focus:border-blue-500 focus:bg-zinc-950 focus:ring-2 focus:ring-blue-500/40"
data-dusk="ticket-edit-subject" dusk="ticket-edit-subject" />
@error('subject')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
@if ($companies->isNotEmpty())
<div class="xl:col-span-2">
<label class="block text-sm font-semibold text-zinc-200" for="company_id">
Company <span class="helpdesk-required-star">*</span>
</label>
<div class="mt-2" wire:ignore>
<select id="company_id" data-helpdesk-select2 data-placeholder="Select option"
data-wire-model="company_id"
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">Select company</option>
@foreach ($companies as $company)
<option value="{{ $company->id }}" @selected((string) $company_id === (string) $company->id)>
{{ $company->name }}</option>
@endforeach
</select>
</div>
@error('company_id')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
@endif
<div class="xl:col-span-2">
<label class="block text-sm font-semibold text-zinc-200" for="board_id">Board</label>
<div class="mt-2" wire:ignore>
<select id="board_id" data-helpdesk-select2 data-placeholder="Select option"
data-wire-model="board_id"
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500"
data-dusk="ticket-edit-board">
<option value="">No board</option>
@foreach ($boards as $board)
<option value="{{ $board->id }}" @selected((string) $board_id === (string) $board->id)>
{{ $board->name }}{{ $board->owner ? ' · ' . $board->owner->name : '' }}</option>
@endforeach
</select>
</div>
@error('board_id')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="xl:col-span-2">
<label class="block text-sm font-semibold text-zinc-200" for="status_id">
Status <span class="helpdesk-required-star">*</span>
</label>
<div class="mt-2" wire:ignore>
<select id="status_id" data-helpdesk-select2 data-placeholder="Select option"
data-wire-model="status_id"
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500"
data-dusk="ticket-edit-status">
<option value="">No status</option>
@foreach ($statuses as $status)
<option value="{{ $status->id }}" @selected((string) $status_id === (string) $status->id)>{{ $status->name }}
</option>
@endforeach
</select>
</div>
@error('status_id')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="xl:col-span-2">
<label class="block text-sm font-semibold text-zinc-200" for="priority_id">
Priority <span class="helpdesk-required-star">*</span>
</label>
<div class="mt-2" wire:ignore>
<select id="priority_id" data-helpdesk-select2 data-placeholder="Select option"
data-wire-model="priority_id"
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500"
data-dusk="ticket-edit-priority">
<option value="">Select priority</option>
@foreach ($priorities as $priority)
<option value="{{ $priority->id }}" @selected((string) $priority_id === (string) $priority->id)>{{ $priority->name }}
</option>
@endforeach
</select>
</div>
@error('priority_id')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="xl:col-span-2">
<label class="block text-sm font-semibold text-zinc-200" for="agent_id">Assigned agent</label>
<div class="mt-2">
<select id="agent_id" wire:model.live="agent_id"
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500"
data-dusk="ticket-edit-agent">
<option value="">Unassigned</option>
@forelse ($agents as $agent)
<option value="{{ $agent->id }}">{{ $agent->name }} · {{ $agent->email }}
</option>
@empty
<option value="" disabled>No users available for assignment</option>
@endforelse
</select>
</div>
@error('agent_id')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="xl:col-span-2">
<label class="block text-sm font-semibold text-zinc-200" for="category_id">
Category <span class="helpdesk-required-star">*</span>
</label>
<div class="mt-2" wire:ignore>
<select id="category_id" data-helpdesk-select2 data-placeholder="Select option"
data-wire-model="category_id"
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">No category</option>
@foreach ($categories as $category)
<option value="{{ $category->id }}" @selected((string) $category_id === (string) $category->id)>
{{ $category->name }}</option>
@endforeach
</select>
</div>
@error('category_id')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="xl:col-span-2">
<label class="block text-sm font-semibold text-zinc-200" for="project_id">
Project <span class="helpdesk-required-star">*</span>
</label>
<div class="mt-2" wire:ignore>
<select id="project_id" data-helpdesk-select2 data-placeholder="Select option"
data-wire-model="project_id"
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">No project</option>
@foreach ($projects as $project)
<option value="{{ $project->id }}" @selected((string) $project_id === (string) $project->id)>
{{ $project->title ?? $project->name }}</option>
@endforeach
</select>
</div>
@error('project_id')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="xl:col-span-2">
<label class="block text-sm font-semibold text-zinc-200" for="team_id">
Team <span class="helpdesk-required-star">*</span>
</label>
<div class="mt-2" wire:ignore>
<select id="team_id" data-helpdesk-select2 data-placeholder="Select option"
data-wire-model="team_id"
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">No team</option>
@foreach ($teams as $team)
<option value="{{ $team->id }}" @selected((string) $team_id === (string) $team->id)>{{ $team->name }}
</option>
@endforeach
</select>
</div>
@error('team_id')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="lg:col-span-3 xl:col-span-6">
<fieldset class="rounded-2xl border border-zinc-700 bg-zinc-950/70 p-4">
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<legend class="text-sm font-semibold text-zinc-200">
Tags
</legend>
<span class="text-xs text-zinc-500">
Optional · select all that apply
</span>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
@forelse ($tags as $tag)
<label for="tag_{{ $tag->id }}"
class="flex cursor-pointer items-center gap-3 rounded-xl border border-zinc-700 bg-zinc-900 px-4 py-3 text-sm font-medium text-zinc-200 transition hover:border-blue-500 hover:bg-zinc-800">
<input id="tag_{{ $tag->id }}" type="checkbox" wire:model.defer="tag_ids"
value="{{ $tag->id }}"
class="h-4 w-4 rounded border-zinc-600 bg-zinc-950 text-blue-600 focus:ring-blue-500">
<span class="truncate">
{{ $tag->name }}
</span>
</label>
@empty
<div
class="rounded-xl border border-dashed border-zinc-700 bg-zinc-900 px-4 py-3 text-sm text-zinc-400">
No tags are available.
</div>
@endforelse
</div>
@error('tag_ids')
<p class="mt-3 text-sm text-red-500">{{ $message }}</p>
@enderror
@error('tag_ids.*')
<p class="mt-3 text-sm text-red-500">{{ $message }}</p>
@enderror
</fieldset>
</div>
<div class="xl:col-span-2">
<x-helpdesk.date-picker id="due_at" model="due_at" label="Due date"
placeholder="Select due date" value="{{ $due_at }}" />
@error('due_at')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="lg:col-span-3 xl:col-span-6">
<x-helpdesk.html-editor
model="body"
label="Details *"
rows="10"
placeholder="Update the ticket details..."
editor-dusk="ticket-edit-body-editor"
/>
@error('body')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="lg:col-span-3 xl:col-span-6">
<div
class="overflow-hidden rounded-2xl border border-blue-500/30 bg-zinc-950/70 shadow-sm shadow-blue-950/30">
<div class="bg-gradient-to-r from-blue-700 via-sky-600 to-emerald-600 px-5 py-4 text-white">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<label
class="flex items-center gap-2 text-base font-extrabold text-white drop-shadow-sm"
for="attachments">
<span
class="inline-flex h-9 w-9 items-center justify-center rounded-xl bg-white/15 ring-1 ring-white/25">
<i class="fa-solid fa-paperclip" aria-hidden="true"></i>
</span>
Attachments
</label>
<p class="mt-2 max-w-2xl text-sm font-medium text-blue-50/90">
Add screenshots, documents, logs or supporting files to help resolve the ticket.
</p>
</div>
<label for="attachments"
class="inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl bg-white px-4 py-2.5 text-sm font-bold text-blue-700 shadow-sm transition hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-white/80 focus:ring-offset-2 focus:ring-offset-blue-700">
<i class="fa-solid fa-plus" aria-hidden="true"></i>
<span>Add attachments</span>
</label>
</div>
</div>
<div class="p-4">
<input id="attachments" type="file" wire:model="attachments" multiple accept="{{ $helpdeskAttachmentAccept }}"
class="sr-only">
<div wire:loading.flex wire:target="attachments"
class="mt-4 items-center gap-2 rounded-xl border border-blue-500/30 bg-blue-500/10 px-4 py-3 text-sm font-medium text-blue-200">
<i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>
Uploading attachments...
</div>
@if ($ticket->attachments->isNotEmpty())
<div class="mt-4 space-y-3">
<div
class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-3">
<h3
class="inline-flex items-center gap-2 text-sm font-extrabold text-emerald-200">
<span
class="inline-flex h-7 w-7 items-center justify-center rounded-lg bg-emerald-500 text-white shadow-sm">
<i class="fa-solid fa-folder-open" aria-hidden="true"></i>
</span>
Existing attachments
</h3>
<span
class="inline-flex items-center gap-2 rounded-full bg-emerald-100 px-3 py-1 text-xs font-extrabold text-emerald-800 shadow-sm ring-1 ring-emerald-300/70">
<span class="inline-flex h-2 w-2 rounded-full bg-emerald-500"></span>
{{ $ticket->attachments->count() }} file(s)
</span>
</div>
@foreach ($ticket->attachments as $attachment)
<div
class="flex flex-col gap-3 rounded-xl border border-zinc-700 bg-zinc-900 p-3 text-sm sm:flex-row sm:items-center sm:justify-between">
<a href="{{ route('helpdesk.attachments.download', $attachment->id) }}"
target="_blank"
class="min-w-0 font-medium text-blue-300 hover:text-blue-200">
<i class="fa-regular fa-file mr-2" aria-hidden="true"></i>
{{ $attachment->original_name ?? ($attachment->original_filename ?? ($attachment->filename ?? ($attachment->file_name ?? basename($attachment->path ?? ($attachment->file_path ?? ''))))) }}
</a>
<button type="button"
wire:click="removeAttachment({{ $attachment->id }})"
wire:confirm="Remove this attachment?"
class="inline-flex items-center justify-center rounded-lg bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-700">
Remove
</button>
</div>
@endforeach
</div>
@endif
@if ($attachments !== [])
<div
class="mt-4 rounded-xl border border-blue-500/30 bg-blue-500/10 px-4 py-3 text-sm text-blue-100">
{{ count($attachments) }} new attachment(s) selected. Save the ticket to attach
them.
</div>
@endif
@error('attachments')
<p class="mt-3 text-sm text-red-500">{{ $message }}</p>
@enderror
@error('attachments.*')
<p class="mt-3 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
</div>
</div>
</div>
<div class="flex items-center justify-end gap-3 border-t border-zinc-700 bg-zinc-900 px-6 py-4">
<button type="button" wire:click="clearForm"
class="rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-700">
Clear
</button>
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate
class="rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">
Cancel
</a>
<button type="submit"
class="inline-flex items-center gap-2 rounded-xl bg-green-600 px-5 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
data-dusk="ticket-edit-submit" wire:loading.attr="disabled">
<i class="fa-regular fa-floppy-disk" aria-hidden="true"></i>
<span wire:loading.remove>Save Ticket</span>
<span wire:loading>Saving...</span>
</button>
</div>
</div>
</form>
</div>
@@ -1,202 +1,217 @@
<div class="space-y-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<flux:heading size="xl">{{ __('Tickets') }}</flux:heading>
<flux:text>{{ __('Manage Helpdesk tickets, assignments and status.') }}</flux:text>
</div>
<div class="flex items-center gap-2">
<flux:button :href="route('helpdesk.kanban')" wire:navigate>
{{ __('Kanban') }}
</flux:button>
<flux:button variant="primary" :href="route('helpdesk.tickets.create')" wire:navigate>
{{ __('New ticket') }}
</flux:button>
<div class="hd-helpdesk-surface space-y-6" dusk="helpdesk-ticket-index">
<div
class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Ticket management</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight">Active tickets</h1>
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">Search, triage and manage support requests across
companies, projects, teams, priorities and statuses.</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ route('helpdesk.index') }}" wire:navigate class="hd-btn hd-btn-blue">
<i class="fa-solid fa-chart-bar" aria-hidden="true"></i>
<span>Dashboard</span>
</a>
<a href="{{ route('helpdesk.tickets.create') }}" wire:navigate class="hd-btn hd-btn-green">
<i class="fa-solid fa-plus" aria-hidden="true"></i>
<span>New Ticket</span>
</a>
</div>
</div>
</div>
</div>
<div class="grid gap-3 md:grid-cols-4">
<flux:input
wire:model.live.debounce.300ms="search"
:label="__('Search')"
:placeholder="__('Reference, subject or text')"
/>
<div class="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="grid gap-4 md:grid-cols-4">
<flux:input wire:model.live.debounce.350ms="search" icon="magnifying-glass"
placeholder="Search reference, subject or body" />
<flux:select wire:model.live="status" :label="__('Status')">
<option value="">{{ __('All statuses') }}</option>
@foreach ($statuses as $statusOption)
<option value="{{ $statusOption->id }}">{{ $statusOption->name }}</option>
@endforeach
</flux:select>
<flux:select wire:model.live="scope">
<option value="open">Open tickets</option>
<option value="closed">Closed tickets</option>
<option value="all">All tickets</option>
</flux:select>
<flux:select wire:model.live="priority" :label="__('Priority')">
<option value="">{{ __('All priorities') }}</option>
@foreach ($priorities as $priorityOption)
<option value="{{ $priorityOption->id }}">{{ $priorityOption->name }}</option>
@endforeach
</flux:select>
<flux:select wire:model.live="status">
<option value="">All statuses</option>
@foreach ($statuses as $status)
<option value="{{ $status->id }}">{{ $status->name }}</option>
@endforeach
</flux:select>
<flux:select wire:model.live="scope" :label="__('Scope')">
<option value="open">{{ __('Open') }}</option>
<option value="closed">{{ __('Closed') }}</option>
<option value="all">{{ __('All') }}</option>
</flux:select>
<flux:select wire:model.live="priority">
<option value="">All priorities</option>
@foreach ($priorities as $priority)
<option value="{{ $priority->id }}">{{ $priority->name }}</option>
@endforeach
</flux:select>
</div>
</div>
<div class="overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900">
<div
class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div wire:loading.flex
class="items-center gap-2 border-b border-zinc-200 bg-blue-50 px-4 py-3 text-sm text-blue-800 dark:border-zinc-700 dark:bg-blue-950 dark:text-blue-200">
<flux:icon.arrow-path class="size-4 animate-spin" /> Refreshing tickets...
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-700">
<thead class="bg-zinc-50 dark:bg-zinc-800/80">
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
<thead
class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500">
{{ __('Reference') }}
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500">
{{ __('Subject') }}
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500">
{{ __('Status') }}
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500">
{{ __('Priority') }}
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500">
{{ __('Agent') }}
</th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wide text-zinc-500">
{{ __('Actions') }}
</th>
<th class="px-4 py-3">Reference</th>
<th class="px-4 py-3">Subject</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Priority</th>
<th class="px-4 py-3">Agent</th>
<th class="px-4 py-3 text-center">Files</th>
<th class="px-4 py-3">Last activity</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse ($tickets as $ticket)
<tr wire:key="ticket-{{ $ticket->id }}" class="hover:bg-zinc-50/70 dark:hover:bg-zinc-800/50">
<td class="whitespace-nowrap px-4 py-3 text-sm font-medium text-zinc-900 dark:text-zinc-100">
<a
href="{{ route('helpdesk.tickets.show', $ticket) }}"
wire:navigate
class="hover:underline"
>
<tr class="hover:bg-zinc-50 dark:hover:bg-zinc-800/70">
<td class="whitespace-nowrap px-4 py-3 font-mono text-xs">
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate
class="font-semibold text-blue-700 hover:underline dark:text-blue-300">
{{ $ticket->reference }}
</a>
</td>
<td class="px-4 py-3">
<div class="max-w-xl">
<a
href="{{ route('helpdesk.tickets.show', $ticket) }}"
wire:navigate
class="font-medium text-zinc-900 hover:underline dark:text-zinc-100"
>
{{ $ticket->subject }}
</a>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-zinc-500">
@if ($ticket->category)
<span>{{ $ticket->category->name }}</span>
@endif
@if ($ticket->project)
<span>{{ $ticket->project->title }}</span>
@endif
@if ($ticket->attachments_count)
<span>{{ trans_choice(':count attachment|:count attachments', $ticket->attachments_count, ['count' => $ticket->attachments_count]) }}</span>
@endif
</div>
<div class="font-medium text-zinc-900 dark:text-zinc-100">{{ $ticket->subject }}</div>
<div class="text-xs text-zinc-500">
{{ $ticket->category?->name ?? 'No category' }}
@if ($ticket->project)
· {{ $ticket->project->title }}
@endif
@if ($ticket->team)
· {{ $ticket->team->name }}
@endif
</div>
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-zinc-700 dark:text-zinc-300">
{{ $ticket->status?->name ?? '—' }}
<td class="px-4 py-3">
<x-helpdesk.colour-badge :color="$ticket->status?->color ?? 'zinc'">
{{ $ticket->status?->name ?? 'No status' }}
</x-helpdesk.colour-badge>
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-zinc-700 dark:text-zinc-300">
{{ $ticket->priority?->name ?? '—' }}
<td class="px-4 py-3">
<x-helpdesk.colour-badge :color="$ticket->priority?->color ?? 'zinc'">
{{ $ticket->priority?->name ?? 'No priority' }}
</x-helpdesk.colour-badge>
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-zinc-700 dark:text-zinc-300">
{{ $ticket->agent_name ?? '—' }}
<td class="px-4 py-3 text-zinc-600 dark:text-zinc-300">
{{ $ticket->agent_name ?? 'Unassigned' }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-center">
@if (($ticket->attachments_count ?? 0) > 0)
<span
class="inline-flex items-center justify-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-black text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-200"
title="{{ $ticket->attachments_count }} attachment{{ $ticket->attachments_count === 1 ? '' : 's' }}">
<i class="fa-solid fa-paperclip" aria-hidden="true"></i>
<span>{{ $ticket->attachments_count }}</span>
<span
class="sr-only">attachment{{ $ticket->attachments_count === 1 ? '' : 's' }}</span>
</span>
@else
<span
class="inline-flex items-center justify-center rounded-full border border-zinc-200 bg-zinc-50 px-2.5 py-1 text-xs font-bold text-zinc-400 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-500"
title="No attachments">
<i class="fa-regular fa-circle" aria-hidden="true"></i>
<span class="sr-only">No attachments</span>
</span>
@endif
</td>
<td class="whitespace-nowrap px-4 py-3 text-zinc-500">
{{ $ticket->last_activity_at?->diffForHumans() ?? $ticket->created_at->diffForHumans() }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-right">
<div class="flex justify-end gap-2">
<flux:button
size="sm"
:href="route('helpdesk.tickets.show', $ticket)"
wire:navigate
>
{{ __('View') }}
</flux:button>
@can('update', $ticket)
<flux:button
size="sm"
:href="route('helpdesk.tickets.edit', $ticket)"
wire:navigate
>
{{ __('Edit') }}
</flux:button>
@endcan
@can('delete', $ticket)
<flux:button
size="sm"
variant="danger"
wire:click="askToDeleteTicket({{ $ticket->id }})"
>
{{ __('Delete') }}
</flux:button>
@endcan
</div>
@can('delete', $ticket)
<button type="button" wire:click="askToDeleteTicket({{ $ticket->id }})"
wire:loading.attr="disabled" wire:target="askToDeleteTicket({{ $ticket->id }})"
class="hd-btn hd-btn-sm hd-btn-red disabled:cursor-not-allowed disabled:opacity-60"
title="Delete {{ $ticket->reference }}" dusk="delete-ticket-{{ $ticket->id }}">
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<span>Delete</span>
</button>
@endcan
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-6 py-12 text-center">
<flux:heading size="lg">{{ __('No tickets found') }}</flux:heading>
<flux:text class="mt-1">
{{ __('Try changing the filters or create a new ticket.') }}
</flux:text>
</td>
<td colspan="8" class="px-4 py-12 text-center text-zinc-500">No tickets matched your
filters.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if ($tickets->hasPages())
<div class="border-t border-zinc-200 px-4 py-3 dark:border-zinc-700">
{{ $tickets->links() }}
</div>
@endif
<div class="border-t border-zinc-200 p-4 dark:border-zinc-700">
{{ $tickets->links() }}
</div>
</div>
@if ($deleteModalOpen)
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
wire:key="delete-ticket-modal"
>
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-zinc-900">
<flux:heading size="lg">{{ __('Delete ticket?') }}</flux:heading>
<div class="fixed inset-0 z-[9998] flex items-center justify-center bg-black/75 px-4 py-6 backdrop-blur-sm"
role="dialog" aria-modal="true" aria-labelledby="delete-ticket-modal-title"
wire:key="ticket-delete-modal-{{ $pendingDeleteTicketId ?? 'none' }}"
dusk="delete-ticket-confirmation-modal">
<div
class="w-full max-w-lg overflow-hidden rounded-3xl border border-red-500/30 bg-zinc-950 text-zinc-100 shadow-2xl">
<div class="border-b border-red-500/20 bg-red-950/40 px-6 py-5">
<div class="flex items-start gap-4">
<span
class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-red-600 text-white shadow-lg shadow-red-950/40">
<i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
</span>
<flux:text class="mt-2">
{{ __('This will delete :reference — :subject.', [
'reference' => $pendingDeleteTicketReference,
'subject' => $pendingDeleteTicketSubject,
]) }}
</flux:text>
<div class="min-w-0">
<h2 id="delete-ticket-modal-title" class="text-xl font-black tracking-tight text-white">
Delete ticket?
</h2>
<div class="mt-6 flex justify-end gap-2">
<flux:button wire:click="cancelTicketDelete">
{{ __('Cancel') }}
</flux:button>
<p class="mt-1 text-sm leading-6 text-red-100/90">
This will delete <span
class="font-mono font-bold">{{ $pendingDeleteTicketReference }}</span>. This
action cannot be undone.
</p>
</div>
</div>
</div>
<flux:button variant="danger" wire:click="confirmTicketDelete">
{{ __('Delete') }}
</flux:button>
<div class="space-y-3 px-6 py-5">
@if ($pendingDeleteTicketSubject)
<div class="rounded-2xl border border-zinc-800 bg-zinc-900 px-4 py-3">
<p class="text-xs font-bold uppercase tracking-[0.22em] text-zinc-500">Ticket subject</p>
<p class="mt-1 text-sm font-semibold text-zinc-100">{{ $pendingDeleteTicketSubject }}</p>
</div>
@endif
<p class="text-sm leading-6 text-zinc-300">
Confirm only if this ticket was created in error or is no longer required.
</p>
</div>
<div
class="flex flex-col-reverse gap-3 border-t border-zinc-800 bg-zinc-900/80 px-6 py-4 sm:flex-row sm:justify-end">
<button type="button" wire:click="cancelTicketDelete" class="hd-btn hd-btn-sm hd-btn-blue"
dusk="cancel-delete-ticket">
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
<span>Cancel</span>
</button>
<button type="button" wire:click="confirmTicketDelete" wire:loading.attr="disabled"
wire:target="confirmTicketDelete"
class="hd-btn hd-btn-sm hd-btn-red disabled:cursor-not-allowed disabled:opacity-60"
dusk="confirm-delete-ticket">
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<span wire:loading.remove wire:target="confirmTicketDelete">Delete ticket</span>
<span wire:loading wire:target="confirmTicketDelete">Deleting...</span>
</button>
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
@@ -1,22 +1,151 @@
<section class="rounded-2xl border border-zinc-200 bg-white p-5 dark:border-zinc-700 dark:bg-zinc-900">
<div class="flex items-center justify-between gap-4">
<div>
<flux:heading size="sm">{{ __('Time tracking') }}</flux:heading>
<flux:text>{{ $this->elapsedTime }}</flux:text>
<div
class="mb-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-950"
data-helpdesk-ticket-timer
>
@php
$hasRunningTimer = $activeTimer && $activeTimer->ended_at === null;
$startedAtIso = $hasRunningTimer ? $activeTimer->started_at?->toIso8601String() : null;
$startedAtDisplay = $hasRunningTimer ? $activeTimer->started_at?->format('d M Y H:i') : null;
@endphp
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="flex min-w-0 items-start gap-3">
<span class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-blue-600 text-white shadow-sm">
<i class="fa-solid fa-stopwatch text-sm" aria-hidden="true"></i>
</span>
<div class="min-w-0">
<h3 class="text-sm font-black text-zinc-950 dark:text-zinc-100">
Ticket timer
</h3>
<p class="mt-1 text-xs font-semibold text-zinc-600 dark:text-zinc-300">
Record focused work against this ticket.
</p>
</div>
</div>
@if ($hasRunningTimer)
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-right shadow-sm dark:border-emerald-900 dark:bg-emerald-950/40">
<div class="flex items-center justify-end gap-2 text-[0.65rem] font-black uppercase tracking-[0.16em] text-emerald-700 dark:text-emerald-300">
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
Running
</div>
<div
class="mt-1 font-mono text-2xl font-black leading-none text-emerald-950 dark:text-emerald-100"
data-helpdesk-live-elapsed
data-helpdesk-started-at="{{ $startedAtIso }}"
data-dusk="ticket-running-time"
>
{{ gmdate('H:i:s', max(0, (int) $elapsedSeconds)) }}
</div>
<p class="mt-1 text-[0.68rem] font-bold text-emerald-700 dark:text-emerald-300">
Started {{ $startedAtDisplay }}
</p>
</div>
@endif
</div>
@if ($this->isRunning)
<flux:button variant="danger" wire:click="endTimer">{{ __('Stop timer') }}</flux:button>
@else
<flux:button variant="primary" wire:click="startTimer">{{ __('Start timer') }}</flux:button>
@if ($message)
<div class="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-bold text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-100">
{{ $message }}
</div>
@endif
@error('timer')
<div class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs font-bold text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100">
{{ $message }}
</div>
@enderror
<div class="space-y-2">
<label class="block text-xs font-bold text-zinc-800 dark:text-zinc-100" for="ticket_timer_description">
Timer note
</label>
<textarea
id="ticket_timer_description"
wire:model.defer="description"
rows="2"
placeholder="Optional work note"
class="block w-full rounded-lg border border-zinc-300 bg-white p-3 text-sm font-semibold text-zinc-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100"
data-dusk="ticket-timer-description"
></textarea>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">
{{ $hasRunningTimer ? 'End the timer to add the elapsed time to the ticket.' : 'Start a timer when work begins. Only one active timer is allowed.' }}
</p>
@if ($hasRunningTimer)
<button
type="button"
wire:click="endTimer"
wire:loading.attr="disabled"
wire:target="endTimer"
class="inline-flex min-w-[9.5rem] shrink-0 items-center justify-center whitespace-nowrap rounded-md bg-red-600 px-4 py-2 text-sm font-black text-white shadow-sm transition hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:focus:ring-offset-zinc-950"
data-dusk="end-ticket-timer"
>
<i class="fa-solid fa-stop mr-2" aria-hidden="true"></i>
<span wire:loading.remove wire:target="endTimer">End timer</span>
<span wire:loading wire:target="endTimer">Ending...</span>
</button>
@else
<button
type="button"
wire:click="startTimer"
wire:loading.attr="disabled"
wire:target="startTimer"
class="inline-flex min-w-[9.5rem] shrink-0 items-center justify-center whitespace-nowrap rounded-md bg-blue-600 px-4 py-2 text-sm font-black text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:focus:ring-offset-zinc-950"
data-dusk="start-ticket-timer"
>
<i class="fa-solid fa-play mr-2 text-xs" aria-hidden="true"></i>
<span wire:loading.remove wire:target="startTimer">Start timer</span>
<span wire:loading wire:target="startTimer">Starting...</span>
</button>
@endif
</div>
</div>
<div class="mt-4">
<flux:input wire:model="description" :label="__('Description')" :placeholder="__('What are you working on?')" />
</div>
<script>
(() => {
if (window.helpdeskTicketTimerDisplayBooted === true) {
return;
}
@if ($message)
<p class="mt-3 text-sm text-zinc-600 dark:text-zinc-300">{{ $message }}</p>
@endif
</section>
window.helpdeskTicketTimerDisplayBooted = true;
const formatElapsed = (totalSeconds) => {
const seconds = Math.max(0, Number.parseInt(totalSeconds, 10) || 0);
const hours = String(Math.floor(seconds / 3600)).padStart(2, '0');
const minutes = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0');
const remainingSeconds = String(seconds % 60).padStart(2, '0');
return `${hours}:${minutes}:${remainingSeconds}`;
};
const updateTimers = () => {
document.querySelectorAll('[data-helpdesk-live-elapsed][data-helpdesk-started-at]').forEach((timer) => {
const startedAt = Date.parse(timer.getAttribute('data-helpdesk-started-at') || '');
if (!Number.isFinite(startedAt)) {
timer.textContent = '00:00:00';
return;
}
timer.textContent = formatElapsed(Math.floor((Date.now() - startedAt) / 1000));
});
};
updateTimers();
window.setInterval(updateTimers, 1000);
document.addEventListener('livewire:navigated', updateTimers);
document.addEventListener('livewire:update', updateTimers);
document.addEventListener('livewire:updated', updateTimers);
})();
</script>
</div>
@@ -0,0 +1,327 @@
@php
$helpdeskAttachmentAccept = collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
->filter(fn ($extension) => is_string($extension) && trim($extension) !== '')
->map(fn ($extension) => '.' . strtolower(ltrim(trim($extension), '.')))
->unique()
->implode(',');
@endphp
@props(['comments', 'level' => 0, 'ticket' => null, 'canSeeInternalNotes' => false])
<div class="space-y-3" data-helpdesk-thread-level="{{ (int) $level }}">
@foreach ($comments as $comment)
@php
$commentUser = $comment->relationLoaded('user') ? $comment->getRelation('user') : null;
$commentAttachments = $comment->relationLoaded('attachments')
? $comment->getRelation('attachments')
: collect();
$commentReplies = $comment->relationLoaded('replies') ? $comment->getRelation('replies') : collect();
$replyFormId = 'helpdesk-reply-form-' . $comment->id;
$currentTicket = $ticket ?? ($comment->ticket ?? null);
$replyCount = $commentReplies->count();
$isInternal = (bool) $comment->is_internal;
$borderClass = $isInternal
? 'border-purple-400/70 dark:border-purple-600/70'
: 'border-zinc-200 dark:border-zinc-700';
$railClass = $isInternal
? 'bg-purple-500'
: ((int) $level % 3 === 0
? 'bg-red-500'
: ((int) $level % 3 === 1
? 'bg-blue-500'
: 'bg-emerald-500'));
$commentSubject = trim((string) ($comment->subject ?? ''));
if ($commentSubject === '') {
$plainSubject = trim((string) ($comment->body_text ?? strip_tags((string) $comment->body)));
$commentSubject = Str::limit($plainSubject !== '' ? $plainSubject : 'Response', 72);
}
if ($isInternal && !Str::startsWith(Str::lower($commentSubject), 'internal')) {
$commentSubject = 'Internal note';
}
$renderedBody = html_entity_decode((string) $comment->body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
while (
str_contains($renderedBody, '&lt;') ||
str_contains($renderedBody, '&gt;') ||
str_contains($renderedBody, '&amp;lt;')
) {
$decodedBody = html_entity_decode($renderedBody, ENT_QUOTES | ENT_HTML5, 'UTF-8');
if ($decodedBody === $renderedBody) {
break;
}
$renderedBody = $decodedBody;
}
if ($currentTicket !== null) {
$renderedBody = strtr($renderedBody, [
'{{ ticket . reference }}' => (string) e((string) ($currentTicket->reference ?? '')),
'{{ ticket . subject }}' => (string) e((string) ($currentTicket->subject ?? '')),
'{{ ticket . status }}' => (string) e((string) ($currentTicket->status?->name ?? '')),
'{{ ticket . priority }}' => (string) e((string) ($currentTicket->priority?->name ?? '')),
'{{ ticket . category }}' => (string) e((string) ($currentTicket->category?->name ?? '')),
'{{ ticket . project }}' => (string) e((string) ($currentTicket->project?->title ?? '')),
'{{ ticket . team }}' => (string) e((string) ($currentTicket->team?->name ?? '')),
'{{ requester . name }}' => (string) e(
(string) ($currentTicket->owner?->name ?? ($currentTicket->user?->name ?? '')),
),
'{{ agent . name }}' => (string) e((string) ($currentTicket->agent?->name ?? '')),
'{{ actor . name }}' => (string) e((string) ($commentUser?->name ?? '')),
'{{ ticket . url }}' => (string) e(
route('helpdesk.tickets.show', ['ticket' => $currentTicket->getKey()]),
),
]);
}
$containsHtml = $renderedBody !== strip_tags($renderedBody);
$renderedBody = preg_replace('/<\s*(script|style)[^>]*>.*?<\s*\/\s*\1\s*>/is', '', $renderedBody) ?: '';
$renderedBody = preg_replace('/\s+on[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $renderedBody) ?: '';
$renderedBody = preg_replace('/javascript\s*:/i', '', $renderedBody) ?: '';
$renderedBody = strip_tags(
$renderedBody,
'<p><br><strong><b><em><i><u><ul><ol><li><blockquote><pre><code><a>',
);
$renderedBody = $containsHtml ? $renderedBody : nl2br(e($renderedBody));
@endphp
@if (!$isInternal || $canSeeInternalNotes)
<details class="group rounded-2xl border {{ $borderClass }} bg-white shadow-sm transition dark:bg-zinc-900"
data-helpdesk-comment-card
data-helpdesk-comment-text="{{ e(Str::lower($commentSubject . ' ' . strip_tags((string) $comment->body) . ' ' . ($comment->body_text ?? '') . ' ' . ($commentUser?->name ?? '') . ' ' . ($comment->unid ?? '') . ' ' . ($comment->guid ?? ''))) }}"
style="margin-left: {{ min((int) $level, 4) * 1.25 }}rem">
<summary class="flex cursor-pointer list-none items-start gap-3 rounded-2xl p-4 marker:hidden">
<span class="mt-1 inline-flex h-12 w-2 shrink-0 rounded-full {{ $railClass }}"
aria-hidden="true"></span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-zinc-100 text-zinc-700 ring-1 ring-zinc-200 dark:bg-zinc-800 dark:text-zinc-100 dark:ring-zinc-700">
<i class="fa-solid {{ $isInternal ? 'fa-note-sticky' : 'fa-envelope' }}"
aria-hidden="true"></i>
</span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span
class="truncate text-base font-extrabold text-zinc-900 dark:text-zinc-100">{{ $commentSubject }}</span>
<span
class="rounded-full bg-zinc-100 px-2.5 py-1 text-[11px] font-extrabold text-zinc-700 dark:bg-zinc-800 dark:text-zinc-100">{{ $replyCount }}
{{ $replyCount === 1 ? 'reply' : 'replies' }}</span>
@if ($commentAttachments->isNotEmpty())
<span
class="rounded-full bg-emerald-100 px-2.5 py-1 text-[11px] font-extrabold text-emerald-800 dark:bg-emerald-950 dark:text-emerald-100">
<i class="fa-solid fa-paperclip mr-1"
aria-hidden="true"></i>{{ $commentAttachments->count() }}
</span>
@endif
@if ($isInternal)
<span
class="rounded-full bg-purple-600 px-2.5 py-1 text-[11px] font-extrabold uppercase tracking-wide text-white">Internal</span>
@else
<span
class="rounded-full bg-emerald-600 px-2.5 py-1 text-[11px] font-extrabold uppercase tracking-wide text-white">Public</span>
@endif
</div>
<div
class="mt-1 flex flex-wrap items-center gap-2 text-xs font-semibold text-zinc-500 dark:text-zinc-400">
<span>{{ $commentUser?->name ?? 'Unknown user' }}</span>
<span aria-hidden="true">•</span>
<span>{{ $comment->created_at?->format('d M Y H:i') }}</span>
</div>
</div>
</div>
</div>
<span
class="mt-1 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-zinc-100 text-zinc-600 transition group-open:rotate-90 dark:bg-zinc-800 dark:text-zinc-200">
<i class="fa-solid fa-chevron-right text-xs" aria-hidden="true"></i>
</span>
</summary>
<div class="border-t border-zinc-100 px-4 pb-4 pt-3 dark:border-zinc-800">
<div class="prose prose-sm max-w-none dark:prose-invert">
{!! $renderedBody !!}
</div>
@if ($commentAttachments->isNotEmpty())
<div
class="mt-4 rounded-xl border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-950/60">
<div
class="mb-2 flex items-center gap-2 text-xs font-extrabold uppercase tracking-wide text-zinc-500">
<i class="fa-solid fa-paperclip text-blue-500" aria-hidden="true"></i>
Attachments
</div>
<div class="flex flex-wrap gap-2">
@foreach ($commentAttachments as $attachment)
<a href="{{ route('helpdesk.attachments.download', $attachment->id) }}"
target="_blank"
class="inline-flex items-center gap-2 rounded-lg border border-blue-200 bg-white px-3 py-2 text-xs font-bold text-blue-700 shadow-sm hover:bg-blue-50 dark:border-blue-900 dark:bg-zinc-900 dark:text-blue-200 dark:hover:bg-blue-950/50">
<i class="fa-solid fa-thumbtack" aria-hidden="true"></i>
{{ $attachment->original_name ?? ($attachment->original_filename ?? ($attachment->filename ?? ($attachment->file_name ?? basename($attachment->path ?? ($attachment->file_path ?? ''))))) }}
</a>
@endforeach
</div>
</div>
@endif
@if ($currentTicket !== null)
<div class="mt-4 flex flex-wrap justify-end gap-2">
<button type="button" dusk="reply-to-comment-{{ $comment->id }}"
data-helpdesk-reply-toggle="{{ $replyFormId }}"
class="inline-flex items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400/70">
<i class="fa-solid fa-reply mr-2" aria-hidden="true"></i>
Respond
</button>
@if (auth()->user()?->isMpbaPlatformAdmin())
<form method="POST"
action="{{ route('helpdesk.tickets.comments.destroy', [$currentTicket, $comment]) }}"
onsubmit="return confirm('Delete this response? This will remove it from the visible conversation but keep the replication identity/audit trail.');">
@csrf
@method('DELETE')
<button type="submit" dusk="delete-comment-{{ $comment->id }}"
class="inline-flex items-center justify-center rounded-lg bg-red-600 px-3 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400/70">
<i class="fa-solid fa-trash-can mr-2" aria-hidden="true"></i>
Delete
</button>
</form>
@endif
</div>
<form id="{{ $replyFormId }}" method="POST"
action="{{ route('helpdesk.tickets.comments.store', $currentTicket) }}"
enctype="multipart/form-data"
class="mt-4 hidden rounded-xl border border-red-200 bg-red-50/60 p-4 dark:border-red-900 dark:bg-red-950/20"
data-helpdesk-comment-form>
@csrf
<input type="hidden" name="parent_id" value="{{ $comment->id }}">
<div class="mb-3">
<label for="reply-subject-{{ $comment->id }}"
class="mb-2 block text-sm font-bold text-zinc-700 dark:text-zinc-200">Subject</label>
<input id="reply-subject-{{ $comment->id }}" name="subject"
value="{{ old('subject', 'RE: ' . $commentSubject) }}"
spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" autocapitalize="sentences" inputmode="text"
class="block w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm font-semibold text-zinc-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100">
</div>
<label for="reply-body-{{ $comment->id }}"
class="mb-2 block text-sm font-bold text-zinc-700 dark:text-zinc-200">Response to
response</label>
<div class="mb-2 flex flex-wrap gap-2"
data-helpdesk-html-toolbar="reply-body-{{ $comment->id }}">
<button type="button" data-helpdesk-tag="strong"
class="rounded-lg border px-2 py-1 text-xs font-black">B</button>
<button type="button" data-helpdesk-tag="em"
class="rounded-lg border px-2 py-1 text-xs italic">I</button>
<button type="button" data-helpdesk-tag="ul"
class="rounded-lg border px-2 py-1 text-xs">List</button>
<button type="button" data-helpdesk-wrap="<blockquote>"
data-helpdesk-wrap-end="</blockquote>"
class="rounded-lg border px-2 py-1 text-xs">Quote</button>
<button type="button" data-helpdesk-wrap="<code>" data-helpdesk-wrap-end="</code>"
class="rounded-lg border px-2 py-1 text-xs">Code</button>
</div>
<textarea spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" autocapitalize="sentences" inputmode="text" id="reply-body-{{ $comment->id }}" dusk="reply-body-{{ $comment->id }}" name="commentBody" rows="4"
required
class="block w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm text-zinc-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
placeholder="Write a threaded Lotus Notes-style response..."></textarea>
<div class="mt-4 rounded-xl border border-zinc-200 bg-white p-3 shadow-sm dark:border-zinc-700 dark:bg-zinc-950">
<p class="text-xs font-extrabold uppercase tracking-[0.16em] text-zinc-500 dark:text-zinc-400">Visibility</p>
@can('update', $currentTicket)
@if ($canSeeInternalNotes)
<div class="mt-3 grid gap-3 sm:grid-cols-2">
<label class="flex cursor-pointer items-start gap-3 rounded-xl border border-emerald-300 bg-emerald-50 p-3 text-emerald-950 shadow-sm transition hover:border-emerald-400 hover:bg-emerald-100/80 focus-within:border-emerald-500 focus-within:bg-emerald-100/80 focus-within:ring-2 focus-within:ring-emerald-400/30 active:bg-emerald-100/80 dark:border-emerald-900 dark:bg-emerald-950/60 dark:text-emerald-100 dark:hover:border-emerald-700 dark:hover:bg-emerald-900/50 dark:focus-within:bg-emerald-900/50">
<input type="radio" name="internal" value="0" checked class="mt-1 border-emerald-400 text-emerald-600 focus:ring-emerald-500">
<span>
<span class="block text-sm font-extrabold">Public reply</span>
<span class="mt-1 block text-xs font-semibold opacity-80">Visible to the requester and counts towards SLA.</span>
</span>
</label>
<label class="flex cursor-pointer items-start gap-3 rounded-xl border border-purple-300 bg-purple-50 p-3 text-purple-950 shadow-sm transition hover:border-purple-400 hover:bg-purple-100/80 focus-within:border-purple-500 focus-within:bg-purple-100/80 focus-within:ring-2 focus-within:ring-purple-400/30 active:bg-purple-100/80 dark:border-purple-900 dark:bg-purple-950/60 dark:text-purple-100 dark:hover:border-purple-700 dark:hover:bg-purple-900/50 dark:focus-within:bg-purple-900/50">
<input type="radio" name="internal" value="1" class="mt-1 border-purple-400 text-purple-600 focus:ring-purple-500">
<span>
<span class="block text-sm font-extrabold">Internal note</span>
<span class="mt-1 block text-xs font-semibold opacity-80">Support-only note. Does not count as public SLA response.</span>
</span>
</label>
</div>
@else
<input type="hidden" name="internal" value="0">
<div class="mt-3 rounded-xl border border-emerald-300 bg-emerald-50 p-3 text-sm font-semibold text-emerald-950 dark:border-emerald-900 dark:bg-emerald-950/60 dark:text-emerald-100">
<i class="fa-solid fa-envelope-circle-check mr-2" aria-hidden="true"></i>
This threaded response will be public.
</div>
@endif
@else
<input type="hidden" name="internal" value="0">
@endcan
</div>
<details
class="mt-4 overflow-hidden rounded-xl border border-zinc-200 bg-white/80 shadow-sm dark:border-zinc-800 dark:bg-zinc-950/50">
<summary
class="group flex cursor-pointer list-none items-center justify-between gap-3 bg-gradient-to-r from-slate-950 via-blue-950 to-slate-900 px-4 py-3 text-white [&::-webkit-details-marker]:hidden">
<span class="flex min-w-0 items-center gap-3">
<span
class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-blue-400/30 bg-blue-500/15 text-blue-200">
<i class="fa-solid fa-paperclip text-xs" aria-hidden="true"></i>
</span>
<span class="min-w-0">
<span
class="block text-[10px] font-black uppercase tracking-[0.22em] text-blue-200">Attachments</span>
<span class="block truncate text-xs font-semibold text-zinc-300">Pin
response files.</span>
</span>
</span>
<span class="flex shrink-0 items-center gap-2">
<span
class="rounded-full border border-white/10 bg-white/10 px-2.5 py-1 text-[10px] font-black text-white"
data-helpdesk-file-count>0 files</span>
<i class="fa-solid fa-chevron-down text-[10px] text-blue-200 transition group-open:rotate-180"
aria-hidden="true"></i>
</span>
</summary>
<div class="p-4">
<div
class="flex min-h-[4.5rem] flex-col gap-3 rounded-xl border border-zinc-200 bg-zinc-50/80 p-4 dark:border-zinc-800 dark:bg-zinc-950/70 sm:flex-row sm:items-center sm:justify-between">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400"
data-helpdesk-file-label>No files attached</span>
<label data-helpdesk-file-button>
<i class="fa-solid fa-paperclip" aria-hidden="true"></i>
<span>Attach files</span>
<input name="attachments[]" type="file" multiple accept="{{ $helpdeskAttachmentAccept }}" class="sr-only"
data-helpdesk-file-input>
</label>
</div>
</div>
</details>
<div class="mt-4 flex justify-end gap-2">
<button type="button" data-helpdesk-reply-toggle="{{ $replyFormId }}"
class="inline-flex items-center justify-center rounded-lg bg-slate-600 px-3 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-400/70">Cancel</button>
<button type="submit" dusk="post-reply-{{ $comment->id }}"
class="inline-flex items-center justify-center rounded-lg bg-emerald-600 px-3 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70">Post
response</button>
</div>
</form>
@endif
@if ($commentReplies->isNotEmpty())
<div class="mt-4 border-l-4 border-red-500 pl-4">
@include('modules.helpdesk.partials.comment-thread', [
'comments' => $commentReplies,
'level' => ((int) $level) + 1,
'ticket' => $currentTicket,
'canSeeInternalNotes' => $canSeeInternalNotes,
])
</div>
@endif
</div>
</details>
@endif
@endforeach
</div>
@@ -0,0 +1,27 @@
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">
{{ $eyebrow ?? 'Helpdesk' }}
</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight">
{{ $title ?? 'Helpdesk' }}
</h1>
@if (!empty($subtitle))
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">
{{ $subtitle }}
</p>
@endif
</div>
@isset($actions)
<div class="flex flex-wrap gap-2">
{{ $actions }}
</div>
@endisset
</div>
</div>
</div>
@@ -0,0 +1,275 @@
@can('update', $ticket)
<details
class="group rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
data-helpdesk-ticket-controls>
<summary
class="-mx-4 -mt-4 mb-4 flex cursor-pointer list-none items-center justify-between gap-3 rounded-t-xl bg-indigo-700 px-4 py-4 text-white marker:hidden">
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-indigo-950 text-white ring-1 ring-indigo-300/60 shadow-lg">
<i class="fa-solid fa-sliders text-lg" aria-hidden="true"></i>
</span>
<div class="min-w-0">
<h2 class="text-lg font-black uppercase tracking-wide">Ticket controls</h2>
<p class="text-xs font-semibold text-indigo-100">
Log time, assignment, status and priority
</p>
</div>
</div>
<span
class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white/15 text-white ring-1 ring-white/20">
<i class="fa-solid fa-chevron-right text-xs transition group-open:rotate-90" aria-hidden="true"></i>
</span>
</summary>
@if (session('ticket-action-updated'))
<div
class="mb-4 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-bold text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-100">
{{ session('ticket-action-updated') }}
</div>
@endif
@include('modules.helpdesk.partials.ticket-time-timer', [
'ticket' => $ticket,
])
<form method="POST" action="{{ route('helpdesk.tickets.time-logs.store', $ticket) }}"
class="mb-4 space-y-2">
@csrf
<label class="block text-xs font-bold text-zinc-700 dark:text-zinc-200"
for="ticket_time_minutes">Log time</label>
<div class="grid grid-cols-[1fr_auto] gap-2">
<input id="ticket_time_minutes" name="minutes" type="number" min="1" max="1440"
placeholder="Minutes"
class="block w-full rounded-lg border border-zinc-300 bg-white p-2 text-xs font-semibold text-zinc-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
data-dusk="ticket-time-minutes">
<button type="submit" dusk="log-ticket-time" data-dusk="log-ticket-time"
class="inline-flex items-center justify-center rounded-lg bg-emerald-600 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70">
<i class="fa-solid fa-clock mr-2" aria-hidden="true"></i>
<span>Log</span>
</button>
</div>
<textarea spellcheck="true" lang="en-GB" name="description" rows="2" placeholder="Optional work note"
class="block w-full rounded-lg border border-zinc-300 bg-white p-2 text-xs font-semibold text-zinc-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
data-dusk="ticket-time-description"></textarea>
@error('minutes')
<p class="text-xs font-bold text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
@error('description')
<p class="text-xs font-bold text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
</form>
<form method="POST" action="{{ route('helpdesk.tickets.agent.update', $ticket) }}"
class="mb-4 space-y-2">
@csrf
<label class="block text-xs font-bold text-zinc-700 dark:text-zinc-200"
for="show_agent_id">Assigned agent</label>
<select id="show_agent_id" name="agent_id"
class="hd-select block w-full rounded-lg border border-zinc-300 bg-white p-2 text-xs font-semibold text-zinc-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
data-dusk="ticket-show-agent">
<option value="" @selected($agent_id === null)>Unassigned</option>
@forelse ($agents as $agent)
<option
value="{{ $agent->id }}" @selected((int) $agent_id === (int) $agent->id)>{{ $agent->name }}
·
{{ $agent->email }}</option>
@empty
<option value="" disabled>No users available for assignment</option>
@endforelse
</select>
@error('agent_id')
<p class="text-xs font-bold text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
<button type="submit" dusk="update-agent" data-dusk="update-agent"
class="inline-flex items-center justify-center rounded-lg bg-emerald-600 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70">
<i class="fa-solid fa-user-check mr-2" aria-hidden="true"></i>
<span>Assign agent</span>
</button>
</form>
<form method="POST" action="{{ route('helpdesk.tickets.status.update', $ticket) }}"
class="mb-4 space-y-2"
data-helpdesk-status-form>
@csrf
<label class="block text-xs font-bold text-zinc-700 dark:text-zinc-200"
for="show_status_id">Status</label>
<select id="show_status_id" name="status_id"
class="hd-select block min-h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-xs font-semibold text-zinc-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
data-dusk="ticket-show-status">
@foreach ($statuses as $status)
<option
value="{{ $status->id }}"
data-is-closed="{{ $status->is_closed ? '1' : '0' }}"
@selected((int) $status_id === (int) $status->id)>{{ $status->name }}
</option>
@endforeach
</select>
@error('status_id')
<p class="text-xs font-bold text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
@error('closure_comment')
<p class="text-xs font-bold text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
<dialog data-helpdesk-close-ticket-dialog
class="w-full max-w-lg rounded-2xl border border-zinc-200 bg-white p-0 shadow-2xl backdrop:bg-slate-950/70 dark:border-zinc-700 dark:bg-zinc-900">
<div class="bg-gradient-to-r from-red-800 via-red-700 to-slate-900 px-5 py-4 text-white">
<div class="flex items-center gap-3">
<span class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white/15 text-white ring-1 ring-white/20">
<i class="fa-solid fa-lock" aria-hidden="true"></i>
</span>
<div class="min-w-0">
<h3 class="text-base font-black tracking-tight">Close ticket</h3>
<p class="text-xs font-semibold text-red-100">A closure comment is required for the audit trail.</p>
</div>
</div>
</div>
<div class="space-y-4 p-5">
<label class="block text-xs font-bold text-zinc-700 dark:text-zinc-200" for="closure_comment">
Closure comment <span class="text-red-600">*</span>
</label>
<textarea id="closure_comment" spellcheck="true" lang="en-GB" name="closure_comment" rows="5" minlength="10" maxlength="5000"
placeholder="Explain what was completed, verified, or agreed before closing this ticket."
class="block w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm font-semibold text-zinc-900 shadow-sm focus:border-red-500 focus:ring-red-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
data-helpdesk-closure-comment>{{ old('closure_comment') }}</textarea>
<label class="flex items-start gap-3 rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-950 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100">
<input type="hidden" name="closure_internal" value="0">
<input type="checkbox" name="closure_internal" value="1"
class="mt-1 rounded border-emerald-400 text-purple-600 focus:ring-purple-500">
<span>
<span class="block font-extrabold">Internal closure note</span>
<span class="mt-1 block text-xs font-semibold opacity-80">Leave unticked to save the closure as a public customer-visible response.</span>
</span>
</label>
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" data-helpdesk-close-dialog-cancel
class="inline-flex items-center justify-center rounded-lg border border-zinc-300 bg-white px-4 py-2 text-xs font-black text-zinc-700 shadow-sm hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800">
Cancel
</button>
<button type="submit" data-helpdesk-confirm-close
class="inline-flex items-center justify-center rounded-lg bg-red-600 px-4 py-2 text-xs font-black text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400/70">
<i class="fa-solid fa-lock mr-2" aria-hidden="true"></i>
Close ticket
</button>
</div>
</div>
</dialog>
<button type="submit" dusk="update-status"
class="inline-flex items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400/70">
<i class="fa-solid fa-circle-check mr-2" aria-hidden="true"></i>
<span>Update status</span>
</button>
</form>
<form method="POST" action="{{ route('helpdesk.tickets.priority.update', $ticket) }}"
class="space-y-2">
@csrf
<label class="block text-xs font-bold text-zinc-700 dark:text-zinc-200"
for="show_priority_id">Priority</label>
<select id="show_priority_id" name="priority_id"
class="hd-select block w-full rounded-lg border border-zinc-300 bg-white p-2 text-xs font-semibold text-zinc-900 shadow-sm focus:border-amber-500 focus:ring-amber-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
data-dusk="ticket-show-priority">
@foreach ($priorities as $priority)
<option
value="{{ $priority->id }}" @selected((int) $priority_id === (int) $priority->id)>{{ $priority->name }}
</option>
@endforeach
</select>
@error('priority_id')
<p class="text-xs font-bold text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
<button type="submit" dusk="update-priority"
class="inline-flex items-center justify-center rounded-lg bg-amber-500 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-amber-600 focus:outline-none focus:ring-2 focus:ring-amber-300/70">
<i class="fa-solid fa-flag mr-2" aria-hidden="true"></i>
<span>Update priority</span>
</button>
</form>
</details>
<script>
(() => {
const initialiseCloseTicketDialogs = () => {
document.querySelectorAll('[data-helpdesk-status-form]').forEach((form) => {
if (form.dataset.helpdeskCloseDialogInitialised === '1') {
return;
}
form.dataset.helpdeskCloseDialogInitialised = '1';
const statusSelect = form.querySelector('[data-dusk="ticket-show-status"]');
const dialog = form.querySelector('[data-helpdesk-close-ticket-dialog]');
const comment = form.querySelector('[data-helpdesk-closure-comment]');
const cancel = form.querySelector('[data-helpdesk-close-dialog-cancel]');
const originalStatus = statusSelect?.value || '';
if (!statusSelect || !dialog || !comment) {
return;
}
const selectedStatusIsClosed = () => {
const selectedOption = statusSelect.options[statusSelect.selectedIndex];
return selectedOption?.dataset?.isClosed === '1';
};
const openDialog = () => {
if (!dialog.open) {
dialog.showModal();
}
window.setTimeout(() => comment.focus(), 50);
};
statusSelect.addEventListener('change', () => {
if (selectedStatusIsClosed()) {
openDialog();
}
});
form.addEventListener('submit', (event) => {
if (!selectedStatusIsClosed() || comment.value.trim().length >= 10) {
return;
}
event.preventDefault();
openDialog();
});
cancel?.addEventListener('click', () => {
dialog.close();
statusSelect.value = originalStatus;
statusSelect.dispatchEvent(new Event('change', { bubbles: true }));
});
});
};
document.addEventListener('DOMContentLoaded', initialiseCloseTicketDialogs);
document.addEventListener('livewire:navigated', initialiseCloseTicketDialogs);
initialiseCloseTicketDialogs();
})();
</script>
@endcan
@@ -0,0 +1,16 @@
@php
$ticketTimeLogs = \Modules\Helpdesk\Models\TicketTimeLog::query()
->with('user')
->where('ticket_id', $ticket->getKey())
->whereNotNull('ended_at')
->latest('logged_at')
->get();
$ticketLoggedSeconds = (int) $ticketTimeLogs->sum(function ($timeLog) {
return (int) ($timeLog->seconds ?? ((int) $timeLog->minutes * 60));
});
$ticketLoggedHours = intdiv($ticketLoggedSeconds, 3600);
$ticketLoggedMinutes = intdiv($ticketLoggedSeconds % 3600, 60);
$ticketLoggedRemainingSeconds = $ticketLoggedSeconds % 60;
@endphp
@@ -0,0 +1,3 @@
@can('update', $ticket)
@livewire('helpdesk.ticket-timer', ['ticket' => $ticket], key('helpdesk-ticket-timer-'.$ticket->getKey()))
@endcan
@@ -0,0 +1,32 @@
<form method="POST" action="{{ route('helpdesk.tickets.time-logs.store', $ticket) }}" class="mb-4 space-y-2">
@csrf
<label class="block text-xs font-bold text-zinc-700 dark:text-zinc-200" for="time_minutes">
Log time
</label>
<div class="grid grid-cols-[1fr_auto] gap-2">
<input id="time_minutes" name="minutes" type="number" min="1" max="1440" value="{{ old('minutes') }}"
placeholder="Minutes"
class="block w-full rounded-lg border border-zinc-300 bg-white p-2 text-xs font-semibold text-zinc-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
data-dusk="ticket-time-minutes">
<button type="submit" dusk="log-ticket-time" data-dusk="log-ticket-time"
class="inline-flex items-center justify-center rounded-lg bg-emerald-600 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70">
<i class="fa-solid fa-clock mr-2" aria-hidden="true"></i>
<span>Log</span>
</button>
</div>
<textarea spellcheck="true" lang="en-GB" name="description" rows="2" placeholder="Optional work note"
class="block w-full rounded-lg border border-zinc-300 bg-white p-2 text-xs font-semibold text-zinc-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
data-dusk="ticket-time-description">{{ old('description') }}</textarea>
@error('minutes')
<p class="text-xs font-bold text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
@error('description')
<p class="text-xs font-bold text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
</form>