= cac5f4905b WIP: implement ticketing system with support for notifications and attachments
- Added database migrations for tickets, statuses, roles, users, and related associations.
- Introduced models and enums for ticket status, permissions, and notifications.
- Implemented `TicketService` for ticket management and notification handling.
- Created Livewire form and UI for raising tickets with file attachments and routing options.
- Added tests covering ticket creation, validation, notifications, and file uploads.
2026-05-25 13:04:43 +00:00

739 lines
30 KiB
PHP

<?php
declare(strict_types=1);
use App\Concerns\HandlesOperations;
use App\Data\Ticket\CreateTicketRequest;
use App\Data\Ui\Ticket\TicketData;
use App\Enums\Permissions\TicketPermissionEnum;
use App\Enums\TicketStatusEnum;
use App\Livewire\Forms\CreateTicketForm;
use App\Models\{ConnectedApp, Role, Ticket, TicketStatus, User};
use App\Services\{TicketService, UserAppAccessService};
use Illuminate\Support\Collection;
use Livewire\Attributes\{Computed, Layout, Title, Url};
use Livewire\Component;
use Livewire\WithFileUploads;
new
#[Layout('layouts.app.sidebar')]
#[Title('Support Tickets')]
class extends Component {
use WithFileUploads, HandlesOperations;
private TicketService $ticketService;
private UserAppAccessService $appAccessService;
// URL parameters to auto-trigger the drawer
#[Url(as: 'raise', keep: true)]
public bool $raise = false;
#[Url(as: 'app_id', keep: true)]
public ?int $appId = null;
// Livewire Form Object
public CreateTicketForm $form;
// Drawer Open States
public bool $drawerOpen = false;
public bool $detailsModalOpen = false;
// Searchable lists for mary-choices
public array $appsSearchable = [];
public array $rolesSearchable = [];
public array $usersSearchable = [];
// Selected ticket for details pane
public ?int $selectedTicketId = null;
public ?TicketData $selectedTicket = null;
// Selected status for filtering (Admin/Support only)
public string $statusFilter = 'all';
public function boot(TicketService $ticketService, UserAppAccessService $appAccessService): void
{
$this->ticketService = $ticketService;
$this->appAccessService = $appAccessService;
}
public function mount(): void
{
// Pre-populate dynamic choice lists
$this->searchApps();
$this->searchRoles();
$this->searchUsers();
// Select first role by default in notified roles array if empty
$defaultRole = Role::where('name', 'Support')->first();
if ($defaultRole && empty($this->form->notifiedRoleIds)) {
$this->form->notifiedRoleIds = [$defaultRole->id];
}
// Auto-open drawer if requested from URL
if ($this->raise) {
$this->drawerOpen = true;
if ($this->appId) {
$this->form->connectedAppId = $this->appId;
}
}
}
/**
* Re-hydrate searchable arrays before every render to prevent losing selections.
*/
public function rendering(): void
{
if (empty($this->appsSearchable)) {
$this->searchApps();
}
if (empty($this->rolesSearchable)) {
$this->searchRoles();
}
if (empty($this->usersSearchable)) {
$this->searchUsers();
}
}
/**
* Compute visible tickets based on permissions.
*/
#[Computed]
public function tickets(): Collection
{
$user = auth()->user();
if (!$user) {
return collect();
}
if ($user->hasRole('Admin') || $user->can(TicketPermissionEnum::Read->value)) {
$tickets = $this->ticketService->getAll();
if ($this->statusFilter !== 'all') {
$tickets = $tickets->filter(fn($t) => $t->statusSlug === $this->statusFilter);
}
return $tickets;
}
return $this->ticketService->getAllForUser($user->id);
}
/**
* Compute expiring apps for the current user.
*/
#[Computed]
public function eligibleApps(): Collection
{
$user = auth()->user();
if (!$user) {
return collect();
}
return $this->appAccessService->getActiveServices($user)
->filter(fn($app) => $app->is_warning);
}
/**
* Compute available ticket statuses for filtering.
*/
#[Computed]
public function statuses(): Collection
{
return TicketStatus::query()->get();
}
/**
* Search connected apps for mary-choices select dropdown.
*/
public function searchApps(string $value = ''): void
{
$selectedId = $this->form->connectedAppId;
$apps = $this->eligibleApps();
// Ensure selected app is preserved in the list even if warning state changed or not in initial list
if ($selectedId && !$apps->contains('id', $selectedId)) {
$selectedApp = ConnectedApp::find($selectedId);
if ($selectedApp) {
$apps->push($selectedApp);
}
}
$this->appsSearchable = $apps
->when('' !== $value,
fn($col) => $col->filter(fn($app) => str_contains(strtolower($app->name), strtolower($value))))
->map(fn($app) => [
'id' => $app->id,
'name' => $app->name.(isset($app->days_remaining) ? ' ('.$app->days_remaining.' days left)' : ''),
])
->toArray();
}
/**
* Search roles for mary-choices select dropdown.
*/
public function searchRoles(string $value = ''): void
{
$selectedIds = $this->form->notifiedRoleIds;
$this->rolesSearchable = Role::query()
->where(function ($query) use ($value, $selectedIds): void {
if ('' !== $value) {
$query->where('name', 'like', "%{$value}%");
}
if (!empty($selectedIds)) {
$query->orWhereIn('id', $selectedIds);
}
})
->select('id', 'name')
->get()
->toArray();
}
/**
* Search users for mary-choices select dropdown.
*/
public function searchUsers(string $value = ''): void
{
$selectedIds = $this->form->notifiedUserIds;
$this->usersSearchable = User::query()
->where('id', '!=', auth()->id())
->where(function ($query) use ($value, $selectedIds): void {
if ('' !== $value) {
$query->where('name', 'like', "%{$value}%")
->orWhere('email', 'like', "%{$value}%");
}
if (!empty($selectedIds)) {
$query->orWhereIn('id', $selectedIds);
}
})
->select('id', 'name')
->get()
->toArray();
}
/**
* Select all roles in choices element.
*/
public function selectAllRoles(): void
{
$this->form->notifiedRoleIds = Role::query()
->pluck('id')
->toArray();
$this->searchRoles();
}
/**
* Clear all selected roles in choices element.
*/
public function clearRoles(): void
{
$this->form->notifiedRoleIds = [];
}
/**
* Select all users in choices element.
*/
public function selectAllUsers(): void
{
$this->form->notifiedUserIds = User::query()
->where('id', '!=', auth()->id())
->pluck('id')
->toArray();
$this->searchUsers();
}
/**
* Clear all selected users in choices element.
*/
public function clearUsers(): void
{
$this->form->notifiedUserIds = [];
}
/**
* Submit and save support ticket using the new Livewire Form class.
*/
public function saveTicket(): void
{
$this->form->validate();
$dto = new CreateTicketRequest(
userId: (int) auth()->id(),
connectedAppId: $this->form->connectedAppId ? (int) $this->form->connectedAppId : null,
issueCategory: $this->form->issueCategory,
description: $this->form->description ? trim($this->form->description) : null,
notifiedRoleIds: $this->form->routingType === 'role' ? array_map('intval',
$this->form->notifiedRoleIds) : [],
notifiedUserIds: $this->form->routingType === 'user' ? array_map('intval',
$this->form->notifiedUserIds) : [],
attachment: $this->form->attachment,
);
$this->attempt(
action: function () use ($dto): void {
$this->ticketService->create($dto);
// Reset form state & close drawer
$this->form->reset();
$this->drawerOpen = false;
$this->raise = false;
$this->appId = null;
// Re-hydrate choice lists
$this->searchApps();
$this->searchRoles();
$this->searchUsers();
},
successMessage: 'Support Ticket has been raised successfully!'
);
}
/**
* Open Ticket Details modal.
*/
public function showTicket(int $id): void
{
$this->attempt(
action: function () use ($id): void {
$this->selectedTicketId = $id;
$this->selectedTicket = $this->ticketService->getTicket($id);
$this->detailsModalOpen = true;
},
showSuccess: false
);
}
/**
* Update ticket status (Admin/Support only).
*/
public function changeStatus(int $ticketId, string $statusSlug): void
{
$this->attempt(
action: function () use ($ticketId, $statusSlug): void {
$this->ticketService->updateStatus($ticketId, $statusSlug);
// Refresh active selected ticket details if open
if ($this->selectedTicketId === $ticketId) {
$this->selectedTicket = $this->ticketService->getTicket($ticketId);
}
},
successMessage: 'Ticket status updated successfully!'
);
}
/**
* Toggle Drawer manually.
*/
public function toggleDrawer(): void
{
$this->drawerOpen = !$this->drawerOpen;
if (!$this->drawerOpen) {
$this->reset(['raise', 'appId']);
$this->form->reset();
}
}
};
?>
<div class="space-y-6">
<!-- Page Header Card -->
<x-shared.card title="Support Tickets">
<x-slot:actions>
@if(auth()->user()->hasRole('Admin') || auth()->user()->can('ticket:read'))
<x-mary-select
wire:model.live="statusFilter"
:options="array_merge([['id' => 'all', 'name' => 'All Statuses']], $this->statuses->map(fn($s) => ['id' => $s->slug, 'name' => $s->name])->toArray())"
class="select-sm select-bordered w-48"
/>
@endif
<x-mary-button
wire:click="toggleDrawer"
icon="lucide.plus"
class="btn-primary btn-sm rounded-xl font-semibold shadow-xs"
>
Raise Ticket
</x-mary-button>
</x-slot:actions>
@if($this->tickets->isEmpty())
<div class="flex flex-col items-center justify-center text-center p-16">
<div class="p-4 bg-blue-50 dark:bg-blue-900/10 rounded-full mb-4">
<x-mary-icon name="lucide.life-buoy" class="w-12 h-12 text-blue-500"/>
</div>
<h3 class="text-lg font-semibold text-gray-900">No Support Tickets Found</h3>
<p class="text-sm text-gray-500 max-w-sm mt-1 mb-6">
You have not raised any support tickets yet. Click "Raise Ticket" to submit your renewal queries.
</p>
</div>
@else
<x-mary-table
:headers="[
['key' => 'ticketId', 'label' => 'Ticket ID', 'class' => 'font-semibold font-mono text-xs'],
['key' => 'userName', 'label' => 'Customer', 'hidden' => !(auth()->user()->hasRole('Admin') || auth()->user()->can('ticket:read'))],
['key' => 'appName', 'label' => 'Service'],
['key' => 'issueCategory', 'label' => 'Category'],
['key' => 'createdAt', 'label' => 'Date Raised'],
['key' => 'statusName', 'label' => 'Status'],
]"
:rows="$this->tickets->toArray()"
wire:loading.class="opacity-60"
>
@scope('cell_ticketId', $row)
<button
wire:click="showTicket({{ $row['id'] }})"
class="font-mono font-semibold text-blue-600 hover:underline text-left cursor-pointer"
>
{{ $row['ticketId'] }}
</button>
@endscope
@scope('cell_appName', $row)
@if($row['appName'])
<span class="font-medium text-gray-700">{{ $row['appName'] }}</span>
@else
<span class="text-gray-400 italic">General Support</span>
@endif
@endscope
@scope('cell_statusName', $row)
@php
$badgeClass = match($row['statusSlug']) {
'open' => 'badge-warning badge-soft',
'in-progress' => 'badge-info badge-soft',
'resolved' => 'badge-success badge-soft',
'closed' => 'badge-neutral badge-soft',
default => 'badge-ghost',
};
@endphp
<x-mary-badge class="{{ $badgeClass }} font-semibold text-xs px-2.5 py-1">
{{ $row['statusName'] }}
</x-mary-badge>
@endscope
@scope('cell_createdAt', $row)
<span class="text-xs text-gray-500">
{{ Carbon\Carbon::parse($row['createdAt'])->diffForHumans() }}
</span>
@endscope
@scope('actions', $row)
<div class="flex items-center gap-2">
<x-mary-button
wire:click="showTicket({{ $row['id'] }})"
icon="lucide.eye"
class="btn-xs btn-circle btn-ghost text-gray-500"
tooltip="View Details"
/>
@if(auth()->user()->hasRole('Admin') || auth()->user()->can('ticket:update'))
<x-mary-dropdown right class="btn-xs">
<x-slot:trigger>
<x-mary-button icon="lucide.settings-2"
class="btn-xs btn-circle btn-ghost text-gray-500"/>
</x-slot:trigger>
@foreach($this->statuses as $status)
@if($status->slug !== $row['statusSlug'])
<x-mary-menu-item
title="Mark as {{ $status->name }}"
wire:click="changeStatus({{ $row['id'] }}, '{{ $status->slug }}')"
/>
@endif
@endforeach
</x-mary-dropdown>
@endif
</div>
@endscope
</x-mary-table>
@endif
</x-shared.card>
<!-- Support Ticket Drawer (Right slide-out) -->
<x-mary-drawer
wire:model="drawerOpen"
title="Raise Support Ticket"
right
separator
class="w-11/12 md:w-5/12 p-6 bg-white"
>
<x-mary-form wire:submit="saveTicket" class="space-y-4">
<!-- Alert if no expiring services are found -->
@if($this->eligibleApps->isEmpty())
<x-mary-alert icon="lucide.alert-triangle" class="alert-warning text-xs">
You do not currently have any active services expiring within 3 days. You can still submit a general
inquiry ticket without choosing a service.
</x-mary-alert>
@endif
<!-- Connected App Selection (Nullable & Searchable) -->
<x-mary-choices
wire:model="form.connectedAppId"
:label="__('Select Service (Expiring Soon)')"
placeholder="Search/Select service..."
:options="$appsSearchable"
:single="true"
search-function="searchApps"
searchable
/>
<!-- Issue Category -->
<x-mary-select
wire:model.live="form.issueCategory"
required
:label="__('Issue Category')"
:options="[
['id' => 'Billing Query', 'name' => 'Billing Query'],
['id' => 'Service Extension', 'name' => 'Service Extension'],
['id' => 'Technical Issue', 'name' => 'Technical Issue'],
['id' => 'Others', 'name' => 'Others (Requires custom details)']
]"
/>
<!-- Dynamic Conditional Fields (When "Others" is selected) -->
@if($form->issueCategory === 'Others')
<!-- Description Field (Mandatory if "Others") -->
<div wire:key="description-others-container">
<x-mary-textarea
wire:model="form.description"
required
:label="__('Description')"
placeholder="Describe your query or issue details..."
rows="4"
hint="Mandatory: Minimum 20 characters required."
/>
</div>
<!-- File Attachment -->
<div wire:key="attachment-others-container" class="space-y-2">
<x-mary-file
wire:model="form.attachment"
:label="__('File Attachment')"
accept=".jpg,.jpeg,.png,.pdf"
hint="Accepted formats: .jpg, .jpeg, .png, .pdf (Max size: 5MB)"
/>
@if($form->attachment)
<div class="text-xs text-green-600 flex items-center gap-1">
<x-mary-icon name="lucide.check-circle" class="w-3.5 h-3.5"/>
File successfully uploaded.
</div>
@endif
</div>
@else
<!-- Optional Description for other categories -->
<div wire:key="description-optional-container">
<x-mary-textarea
wire:model="form.description"
:label="__('Description (Optional)')"
placeholder="Provide any additional details..."
rows="4"
/>
</div>
@endif
<hr class="border-gray-100 my-2"/>
<!-- Notification Routing (Role or User selection with dynamic choices and select-all actions) -->
<div class="space-y-3">
<label class="text-xs font-semibold text-gray-500">Notification Routing</label>
<!-- Select between Role or User or null -->
<div class="flex gap-4 mb-2">
<label class="flex items-center gap-2 text-xs cursor-pointer">
<input type="radio" wire:model.live="form.routingType" value="role"
class="radio radio-primary radio-xs"/>
Notify Roles
</label>
<label class="flex items-center gap-2 text-xs cursor-pointer">
<input type="radio" wire:model.live="form.routingType" value="user"
class="radio radio-primary radio-xs"/>
Notify Specific Users
</label>
<label class="flex items-center gap-2 text-xs cursor-pointer">
<input type="radio" wire:model.live="form.routingType" value="none"
class="radio radio-primary radio-xs"/>
None
</label>
</div>
@if($form->routingType === 'role')
<div wire:key="routing-roles-container">
<x-shared.choices
wire:model="form.notifiedRoleIds"
:options="$rolesSearchable"
search-function="searchRoles"
select-all-action="selectAllRoles"
clear-action="clearRoles"
label="Roles to Notify"
/>
</div>
@else
<div wire:key="routing-users-container">
<x-shared.choices
wire:model="form.notifiedUserIds"
:options="$usersSearchable"
search-function="searchUsers"
select-all-action="selectAllUsers"
clear-action="clearUsers"
label="Users to Notify"
/>
</div>
@endif
</div>
<!-- Submit actions inside Drawer footer -->
<x-slot:actions>
<x-mary-button label="Cancel" wire:click="toggleDrawer" class="btn-ghost"/>
<x-mary-button label="Submit" type="submit" class="btn-primary" spinner="saveTicket"/>
</x-slot:actions>
</x-mary-form>
</x-mary-drawer>
<!-- Support Ticket Details Modal -->
<x-mary-modal wire:model="detailsModalOpen" title="Ticket Details" class="">
@if($selectedTicket)
<div class="space-y-6">
<!-- ID & Status Section -->
<div class="flex justify-between items-start border-b border-gray-100 pb-4">
<div>
<span class="text-xs font-semibold uppercase tracking-wider text-gray-400 font-mono">Support Ticket</span>
<h2 class="text-xl font-bold font-mono text-gray-900 mt-0.5">{{ $selectedTicket->ticketId }}</h2>
</div>
@php
$badgeClass = match($selectedTicket->statusSlug) {
'open' => 'badge-warning badge-soft',
'in-progress' => 'badge-info badge-soft',
'resolved' => 'badge-success badge-soft',
'closed' => 'badge-neutral badge-soft',
default => 'badge-ghost',
};
@endphp
<div class="flex flex-col items-end gap-1.5">
<x-mary-badge class="{{ $badgeClass }} font-semibold px-3 py-1.5">
{{ $selectedTicket->statusName }}
</x-mary-badge>
<span
class="text-[10px] text-gray-400">Raised {{ Carbon\Carbon::parse($selectedTicket->createdAt)->diffForHumans() }}</span>
</div>
</div>
<!-- Ticket Properties -->
<div class="grid grid-cols-2 gap-y-4 gap-x-6 text-sm">
<div>
<span class="text-xs font-semibold text-gray-400 uppercase">Customer</span>
<p class="font-medium text-gray-900 mt-0.5">{{ $selectedTicket->userName }}</p>
</div>
<div>
<span class="text-xs font-semibold text-gray-400 uppercase">Associated Service</span>
<p class="font-medium text-gray-900 mt-0.5">{{ $selectedTicket->appName ?? 'General Inquiry' }}</p>
</div>
<div>
<span class="text-xs font-semibold text-gray-400 uppercase">Issue Category</span>
<p class="font-medium text-gray-900 mt-0.5">{{ $selectedTicket->issueCategory }}</p>
</div>
<div>
<span class="text-xs font-semibold text-gray-400 uppercase">Notified Recipient(s)</span>
@if(!empty($selectedTicket->notifiedUserNames))
<div class="flex flex-wrap gap-1 mt-1">
<span class="text-xs text-gray-500 mr-1 flex items-center gap-1 font-semibold">
<x-mary-icon name="lucide.user" class="w-3 h-3 text-gray-400"/>
Users:
</span>
@foreach($selectedTicket->notifiedUserNames as $name)
<x-mary-badge class="badge-soft badge-primary text-[10px] px-1.5 py-0.5"
:value="$name"/>
@endforeach
</div>
@endif
@if(!empty($selectedTicket->notifiedRoleNames))
<div class="flex flex-wrap gap-1 mt-1">
<span class="text-xs text-gray-500 mr-1 flex items-center gap-1 font-semibold">
<x-mary-icon name="lucide.users" class="w-3 h-3 text-gray-400"/>
Roles:
</span>
@foreach($selectedTicket->notifiedRoleNames as $name)
<x-mary-badge class="badge-soft badge-secondary text-[10px] px-1.5 py-0.5"
:value="$name"/>
@endforeach
</div>
@endif
@if(empty($selectedTicket->notifiedUserNames) && empty($selectedTicket->notifiedRoleNames))
<p class="text-gray-400 italic">None specified</p>
@endif
</div>
</div>
<!-- Description -->
<div class="bg-gray-100 p-2 rounded-xl space-y-1">
<span class="text-xs font-semibold text-gray-400 uppercase pb-1 border-b border-gray-300">Description Details</span>
<p class="text-sm text-left text-gray-700 mt-1">
{{ $selectedTicket->description ?? 'No custom details provided.' }}
</p>
</div>
<!-- Attachments Section -->
@if(!empty($selectedTicket->attachments))
<div class="space-y-2">
<span class="text-xs font-semibold text-gray-400 uppercase block">Attachments</span>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
@foreach($selectedTicket->attachments as $attachment)
<div
class="flex items-center justify-between p-3 border border-gray-100 rounded-xl bg-white shadow-2xs hover:border-blue-100 transition-colors">
<div class="flex items-center gap-2.5 min-w-0">
@php
$icon = match(true) {
Str::contains($attachment['mime_type'], 'pdf') => 'lucide.file-text',
Str::contains($attachment['mime_type'], 'image') => 'lucide.image',
default => 'lucide.file',
};
@endphp
<x-mary-icon :name="$icon" class="w-8 h-8 text-blue-500 shrink-0"/>
<div class="min-w-0">
<p class="text-xs font-medium text-gray-800 truncate"
title="{{ $attachment['file_name'] }}">
{{ $attachment['file_name'] }}
</p>
<p class="text-[10px] text-gray-400">
{{ round($attachment['file_size'] / 1024, 1) }} KB
</p>
</div>
</div>
<x-mary-button
:link="asset('storage/' . $attachment['file_path'])"
target="_blank"
icon="lucide.download"
class="btn-xs btn-soft btn-primary btn-circle shrink-0"
tooltip="Download Attachment"
/>
</div>
@endforeach
</div>
</div>
@endif
<!-- Admin operations inside modal -->
@if(auth()->user()->hasRole('Admin') || auth()->user()->can('ticket:update'))
<div class="pt-4 border-t border-gray-100 flex items-center justify-between gap-4">
<span class="text-xs font-semibold text-gray-400 uppercase">Change Status</span>
<div class="flex gap-2">
@foreach($this->statuses as $status)
@if($status->slug !== $selectedTicket->statusSlug)
<x-mary-button
label="{{ $status->name }}"
wire:click="changeStatus({{ $selectedTicket->id }}, '{{ $status->slug }}')"
class="btn-xs rounded-lg btn-soft"
/>
@endif
@endforeach
</div>
</div>
@endif
</div>
@endif
<x-slot:actions>
<x-mary-button label="Close" wire:click="$set('detailsModalOpen', false)" class="btn-ghost"/>
</x-slot:actions>
</x-mary-modal>
</div>