1103 lines
49 KiB
PHP
1103 lines
49 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
use App\Concerns\HandlesOperations;
|
|
use App\Data\Ticket\CreateTicketRequest;
|
|
use App\Data\Ticket\PostReplyRequest;
|
|
use App\Data\Ui\Ticket\TicketData;
|
|
use App\Data\Ui\Ticket\TicketListData;
|
|
use App\Enums\DefaultUserRole;
|
|
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;
|
|
use Livewire\WithPagination;
|
|
use Spatie\LaravelData\PaginatedDataCollection;
|
|
|
|
new
|
|
#[Layout('layouts.app.sidebar')]
|
|
#[Title('Support Tickets')]
|
|
class extends Component {
|
|
use WithFileUploads, HandlesOperations, WithPagination;
|
|
|
|
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;
|
|
|
|
#[Url(as: 'ticket', keep: true)]
|
|
public ?int $ticketParam = 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 = [];
|
|
|
|
// Assignable Users search, select-all, clear & pagination state
|
|
public array $assignableUsersSearchable = [];
|
|
public string $assignableUsersSearchTerm = '';
|
|
public int $assignableUsersPerPage = 10;
|
|
public bool $assignableUsersHasMore = false;
|
|
|
|
// Selected ticket for details pane
|
|
public ?int $selectedTicketId = null;
|
|
public ?TicketData $selectedTicket = null;
|
|
|
|
// Selected status for filtering (Admin/Support only)
|
|
public string $statusFilter = 'all';
|
|
|
|
// Ticket search keyword by code/ID
|
|
public string $search = '';
|
|
|
|
// Comment and assignment state
|
|
public string $replyMessage = '';
|
|
public $assignedUserId = null;
|
|
|
|
public function updatedSearch(): void
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function updatedStatusFilter(): void
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function boot(TicketService $ticketService, UserAppAccessService $appAccessService): void
|
|
{
|
|
$this->ticketService = $ticketService;
|
|
$this->appAccessService = $appAccessService;
|
|
}
|
|
|
|
public function mount(): void
|
|
{
|
|
// Auto-open drawer if requested from URL
|
|
if ($this->raise) {
|
|
$this->drawerOpen = true;
|
|
if ($this->appId) {
|
|
$this->form->connectedAppId = $this->appId;
|
|
}
|
|
}
|
|
|
|
// Pre-populate dynamic choice lists
|
|
$this->searchApps();
|
|
$this->searchRoles();
|
|
$this->searchUsers();
|
|
$this->searchAssignableUsers();
|
|
|
|
// 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 ticket if requested from URL
|
|
if ($this->ticketParam && (int) $this->ticketParam > 0) {
|
|
$this->showTicket((int) $this->ticketParam);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
if (empty($this->assignableUsersSearchable)) {
|
|
$this->searchAssignableUsers($this->assignableUsersSearchTerm);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compute visible tickets based on permissions.
|
|
*/
|
|
#[Computed]
|
|
public function tickets(): PaginatedDataCollection
|
|
{
|
|
$user = auth()->user();
|
|
$search = trim($this->search);
|
|
$search = empty($search) ? null : $search;
|
|
|
|
if ($user->hasRole(DefaultUserRole::Admin) || $user->can(TicketPermissionEnum::Read->value)) {
|
|
return $this->ticketService->getList($search, $this->statusFilter, 10);
|
|
}
|
|
return $this->ticketService->getListForUser($user->id, $search, $this->statusFilter, 10);
|
|
}
|
|
|
|
/**
|
|
* 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->isWarning);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
|
|
if ($selectedId && !$apps->contains('id', $selectedId)) {
|
|
$selectedApp = ConnectedApp::find($selectedId);
|
|
if ($selectedApp) {
|
|
$apps->push($selectedApp);
|
|
}
|
|
}
|
|
|
|
$this->appsSearchable = array_values($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 = array_values(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 = array_values(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());
|
|
}
|
|
|
|
/**
|
|
* Search assignable users dynamically with incremental pagination capability.
|
|
*/
|
|
public function searchAssignableUsers(string $value = ''): void
|
|
{
|
|
// Reset page limits if search keyword shifts
|
|
if ($this->assignableUsersSearchTerm !== $value) {
|
|
$this->assignableUsersSearchTerm = $value;
|
|
$this->assignableUsersPerPage = 10;
|
|
}
|
|
|
|
$query = User::query();
|
|
|
|
$currentAssigneeId = $this->assignedUserId;
|
|
if (is_array($currentAssigneeId)) {
|
|
$currentAssigneeId = !empty($currentAssigneeId) ? $currentAssigneeId[0] : null;
|
|
}
|
|
|
|
if ('' !== $value) {
|
|
$query->where(function ($q) use ($value) {
|
|
$q->where('name', 'like', "%{$value}%")
|
|
->orWhere('email', 'like', "%{$value}%");
|
|
});
|
|
|
|
if ($currentAssigneeId && (int) $currentAssigneeId > 0) {
|
|
$query->orWhere('id', (int) $currentAssigneeId);
|
|
}
|
|
}
|
|
|
|
// Track total count matching query criteria to determine if "Load More" exists
|
|
$totalMatches = $query->count();
|
|
$this->assignableUsersHasMore = $totalMatches > $this->assignableUsersPerPage;
|
|
|
|
$users = $query
|
|
->select('id', 'name')
|
|
->limit($this->assignableUsersPerPage)
|
|
->get();
|
|
|
|
// If there is no search term and we have an assigned user, ensure they are in the list
|
|
if ('' === $value && $currentAssigneeId && (int) $currentAssigneeId > 0) {
|
|
if (!$users->contains('id', (int) $currentAssigneeId)) {
|
|
$assignedUser = User::find($currentAssigneeId);
|
|
if ($assignedUser) {
|
|
$users->push($assignedUser);
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->assignableUsersSearchable = $users
|
|
->map(fn($u) => ['id' => $u->id, 'name' => $u->name])
|
|
->toArray();
|
|
}
|
|
|
|
/**
|
|
* Increments the page window for assignable users dropdown.
|
|
*/
|
|
public function loadMoreAssignableUsers(): void
|
|
{
|
|
$this->assignableUsersPerPage += 10;
|
|
$this->searchAssignableUsers($this->assignableUsersSearchTerm);
|
|
}
|
|
|
|
/**
|
|
* Select all matching assignable users based on current search context.
|
|
*/
|
|
public function selectAllAssignableUsers(): void
|
|
{
|
|
// Pluck IDs matching current search context criteria
|
|
$matchedIds = User::query()
|
|
->when('' !== $this->assignableUsersSearchTerm, function ($q) {
|
|
$q->where('name', 'like', "%{$this->assignableUsersSearchTerm}%")
|
|
->orWhere('email', 'like', "%{$this->assignableUsersSearchTerm}%");
|
|
})
|
|
->pluck('id')
|
|
->toArray();
|
|
|
|
// Assign the first matched record or pass the array depending on single/multiple context.
|
|
// Since updateAssignment expects a single nullable ID, we take the primary match or convert accordingly.
|
|
$this->assignedUserId = !empty($matchedIds) ? (int) $matchedIds[0] : null;
|
|
$this->updateAssignment();
|
|
}
|
|
|
|
/**
|
|
* Clear assignment selection.
|
|
*/
|
|
public function clearAssignableUsers(): void
|
|
{
|
|
$this->assignedUserId = null;
|
|
$this->updateAssignment();
|
|
}
|
|
|
|
public function selectAllRoles(): void
|
|
{
|
|
$this->form->notifiedRoleIds = Role::query()->pluck('id')->toArray();
|
|
$this->searchRoles();
|
|
}
|
|
|
|
public function clearRoles(): void
|
|
{
|
|
$this->form->notifiedRoleIds = [];
|
|
}
|
|
|
|
public function selectAllUsers(): void
|
|
{
|
|
$this->form->notifiedUserIds = User::query()
|
|
->where('id', '!=', auth()->id())
|
|
->pluck('id')
|
|
->toArray();
|
|
$this->searchUsers();
|
|
}
|
|
|
|
public function clearUsers(): void
|
|
{
|
|
$this->form->notifiedUserIds = [];
|
|
}
|
|
|
|
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);
|
|
$this->form->reset();
|
|
$this->drawerOpen = false;
|
|
$this->raise = false;
|
|
$this->appId = null;
|
|
|
|
$this->searchApps();
|
|
$this->searchRoles();
|
|
$this->searchUsers();
|
|
},
|
|
successMessage: 'Support Ticket has been raised successfully!'
|
|
);
|
|
}
|
|
|
|
public function showTicket(int $id): void
|
|
{
|
|
$this->attempt(
|
|
action: function () use ($id): void {
|
|
$this->selectedTicketId = $id;
|
|
$this->selectedTicket = $this->ticketService->getTicket($id);
|
|
$this->assignedUserId = $this->selectedTicket->assignedUserId;
|
|
$this->replyMessage = '';
|
|
|
|
// Keep assignment lookup synchronized upon details injection
|
|
$this->searchAssignableUsers();
|
|
$this->detailsModalOpen = true;
|
|
},
|
|
showSuccess: false
|
|
);
|
|
}
|
|
|
|
public function changeStatus(int $ticketId, string $statusSlug): void
|
|
{
|
|
$this->attempt(
|
|
action: function () use ($ticketId, $statusSlug): void {
|
|
$this->ticketService->updateStatus($ticketId, $statusSlug);
|
|
if ($this->selectedTicketId === $ticketId) {
|
|
$this->selectedTicket = $this->ticketService->getTicket($ticketId);
|
|
}
|
|
},
|
|
successMessage: 'Ticket status updated successfully!'
|
|
);
|
|
}
|
|
|
|
public function saveReply(): void
|
|
{
|
|
$this->validate(['replyMessage' => 'required|string|min:1']);
|
|
|
|
$this->attempt(
|
|
action: function (): void {
|
|
$dto = new PostReplyRequest(
|
|
ticketId: $this->selectedTicketId,
|
|
userId: (int) auth()->id(),
|
|
message: $this->replyMessage,
|
|
);
|
|
|
|
$this->ticketService->postReply($dto);
|
|
$this->replyMessage = '';
|
|
$this->selectedTicket = $this->ticketService->getTicket($this->selectedTicketId);
|
|
},
|
|
successMessage: 'Comment posted successfully!'
|
|
);
|
|
}
|
|
|
|
public function updateAssignment(): void
|
|
{
|
|
$this->attempt(
|
|
action: function (): void {
|
|
$assignedId = $this->assignedUserId;
|
|
if (is_array($assignedId)) {
|
|
$assignedId = !empty($assignedId) ? $assignedId[0] : null;
|
|
}
|
|
$finalAssignedUserId = ($assignedId && (int) $assignedId > 0) ? (int) $assignedId : null;
|
|
|
|
$this->ticketService->assignUser(
|
|
$this->selectedTicketId,
|
|
$finalAssignedUserId
|
|
);
|
|
$this->selectedTicket = $this->ticketService->getTicket($this->selectedTicketId);
|
|
|
|
// Refresh list options with correct paginated users
|
|
$this->searchAssignableUsers($this->assignableUsersSearchTerm);
|
|
},
|
|
successMessage: 'Ticket assignment updated successfully!'
|
|
);
|
|
}
|
|
|
|
public function updatedAssignedUserId($value): void
|
|
{
|
|
$this->updateAssignment();
|
|
}
|
|
|
|
#[Computed]
|
|
public function canComment(): bool
|
|
{
|
|
if (!$this->selectedTicket) {
|
|
return false;
|
|
}
|
|
|
|
$user = auth()->user();
|
|
if (!$user) {
|
|
return false;
|
|
}
|
|
|
|
if ($user->hasRole(DefaultUserRole::Admin)) {
|
|
return true;
|
|
}
|
|
|
|
if ($this->selectedTicket->userId === $user->id) {
|
|
return true;
|
|
}
|
|
|
|
if ($this->selectedTicket->assignedUserId === $user->id) {
|
|
return true;
|
|
}
|
|
|
|
$ticket = Ticket::find($this->selectedTicketId);
|
|
if ($ticket) {
|
|
if ($ticket->notifiedUsers()->where('users.id', $user->id)->exists()) {
|
|
return true;
|
|
}
|
|
if ($ticket->notifiedRoles()->whereIn('roles.id', $user->roles->pluck('id')->toArray())->exists()) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public function toggleDrawer(): void
|
|
{
|
|
$this->drawerOpen = !$this->drawerOpen;
|
|
if (!$this->drawerOpen) {
|
|
$this->reset(['raise', 'appId']);
|
|
$this->form->reset();
|
|
}
|
|
}
|
|
};
|
|
?>
|
|
|
|
<div class="space-y-6">
|
|
@if(!auth()->user()->hasRole(DefaultUserRole::Admin) && $this->eligibleApps->isEmpty())
|
|
<x-mary-alert icon="lucide.alert-triangle" class="alert-warning text-xs shadow-sm rounded-xl" dismissible>
|
|
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
|
|
|
|
<!-- Page Header Card -->
|
|
<x-shared.card title="Support Tickets">
|
|
<x-slot:actions>
|
|
<x-mary-input
|
|
wire:model.live.debounce.300ms="search"
|
|
placeholder="Search ticket ID..."
|
|
icon="lucide.search"
|
|
class="input-sm w-48"
|
|
clearable
|
|
/>
|
|
|
|
<x-mary-select
|
|
wire:model.live="statusFilter"
|
|
:options="array_merge([['id' => 'all', 'name' => 'All Statuses']], $this->statuses->map(fn($s) => ['id' => $s->name, 'name' => TicketStatusEnum::tryFrom($s->name)?->label() ?? ucfirst($s->name)])->toArray())"
|
|
class="select-sm select-bordered w-48"
|
|
/>
|
|
|
|
<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->items()->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>
|
|
@if(!empty(trim($search)) || $statusFilter !== 'all')
|
|
<h3 class="text-lg font-semibold text-gray-900">No Matching Tickets Found</h3>
|
|
<p class="text-sm text-gray-500 max-w-sm mt-1 mb-6">
|
|
We couldn't find any support tickets matching your search query or filter criteria. Try
|
|
adjusting your search term or status filter.
|
|
</p>
|
|
@else
|
|
<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>
|
|
@endif
|
|
</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->items()"
|
|
with-pagination
|
|
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->status) {
|
|
TicketStatusEnum::Open => 'badge-warning badge-soft',
|
|
TicketStatusEnum::InProgress => 'badge-info badge-soft',
|
|
TicketStatusEnum::Resolved => 'badge-success badge-soft',
|
|
TicketStatusEnum::Closed => 'badge-neutral badge-soft',
|
|
TicketStatusEnum::Hold => 'badge-warning badge-soft',
|
|
default => 'badge-ghost',
|
|
};
|
|
@endphp
|
|
<x-mary-badge class="{{ $badgeClass }} font-semibold text-xs px-2.5 py-1">
|
|
{{ $row->status->label() }}
|
|
</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()->hasRole('admin') || auth()->user()->can('ticket:update') || $row->assignedUserId === auth()->id())
|
|
<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)
|
|
@php
|
|
$enumCase = TicketStatusEnum::tryFrom($status->name);
|
|
@endphp
|
|
@if($enumCase && $enumCase !== $row->status)
|
|
<x-mary-menu-item
|
|
title="Mark as {{ $enumCase->label() }}"
|
|
wire:click="changeStatus({{ $row->id }}, '{{ $enumCase->value }}')"
|
|
/>
|
|
@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">
|
|
<!-- Connected App Selection (Nullable & Searchable) -->
|
|
<x-mary-choices
|
|
wire:model="form.connectedAppId"
|
|
:label="__('Select Apps')"
|
|
placeholder="Search/Select apps..."
|
|
:options="$appsSearchable"
|
|
:single="true"
|
|
search-function="searchApps"
|
|
searchable
|
|
option-value="id"
|
|
/>
|
|
<!-- 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" box-class="max-w-[67rem]">
|
|
@if($selectedTicket)
|
|
<div class="grid grid-cols-1 lg:grid-cols-12 text-left">
|
|
<!-- Left Column: Details & Assignments (col-span-6) -->
|
|
<div class="lg:col-span-6 space-y-6 border-r border-gray-100 pr-0 lg:pr-4">
|
|
<!-- ID & Status Section -->
|
|
<div class="flex justify-between items-start border-b border-gray-100 pb-4">
|
|
<div>
|
|
<span class="text-[10px] font-bold 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->status) {
|
|
TicketStatusEnum::Open => 'badge-warning badge-soft',
|
|
TicketStatusEnum::InProgress => 'badge-info badge-soft',
|
|
TicketStatusEnum::Resolved => 'badge-success badge-soft',
|
|
TicketStatusEnum::Closed => 'badge-neutral badge-soft',
|
|
TicketStatusEnum::Hold => 'badge-warning badge-soft',
|
|
default => 'badge-ghost',
|
|
};
|
|
@endphp
|
|
<div class="flex flex-col items-end gap-1">
|
|
<x-mary-badge class="{{ $badgeClass }} font-semibold text-xs px-2.5 py-1">
|
|
{{ $selectedTicket->status->label() }}
|
|
</x-mary-badge>
|
|
<span
|
|
class="text-[10px] text-gray-400">Raised {{ Carbon\Carbon::parse($selectedTicket->createdAt)->diffForHumans() }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Ticket Properties Grid -->
|
|
<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>
|
|
|
|
<!-- Assigned User Section -->
|
|
<div>
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-xs font-semibold text-gray-400 uppercase">Assigned Support</span>
|
|
<span wire:loading wire:target="assignedUserId, updateAssignment"
|
|
class="flex items-center gap-1 text-[10px] text-primary font-semibold animate-pulse">
|
|
<x-mary-icon name="lucide.loader-2" class="w-3.5 h-3.5 animate-spin"/>
|
|
Saving...
|
|
</span>
|
|
</div>
|
|
@if(auth()->user()->hasRole(DefaultUserRole::Admin))
|
|
<div wire:loading.class="opacity-60 pointer-events-none"
|
|
wire:target="assignedUserId, updateAssignment">
|
|
<x-mary-choices
|
|
wire:model.live.debounce.300ms="assignedUserId"
|
|
:options="$assignableUsersSearchable"
|
|
search-function="searchAssignableUsers"
|
|
:single="true"
|
|
option-label="name"
|
|
option-value="id"
|
|
placeholder="Search staff to assign..."
|
|
searchable
|
|
class="select-sm"
|
|
clearable
|
|
/>
|
|
</div>
|
|
@else
|
|
@if($selectedTicket->assignedUserName)
|
|
<div class="flex items-center gap-1.5 mt-0.5">
|
|
<x-mary-badge class="badge-soft badge-primary font-semibold text-xs py-1 px-2">
|
|
<x-mary-icon name="lucide.user" class="w-3 h-3 text-primary shrink-0 mr-1"/>
|
|
{{ $selectedTicket->assignedUserName }}
|
|
</x-mary-badge>
|
|
</div>
|
|
@else
|
|
<x-mary-badge
|
|
class="badge-soft badge-neutral font-semibold text-xs py-1 px-2.5 mt-0.5">
|
|
Unassigned
|
|
</x-mary-badge>
|
|
@endif
|
|
@endif
|
|
</div>
|
|
|
|
<div class="col-span-2">
|
|
<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-50 p-4 rounded-xl space-y-1">
|
|
<span class="text-xs font-semibold text-gray-400 uppercase block">Description Details</span>
|
|
@php
|
|
$description = $selectedTicket->description ?? 'No custom details provided.';
|
|
$words = preg_split('/\s+/', trim($description));
|
|
$wordCount = count($words);
|
|
$isLong = $wordCount > 50;
|
|
|
|
if ($isLong) {
|
|
$shortDescription = implode(' ', array_slice($words, 0, 50)) . '...';
|
|
} else {
|
|
$shortDescription = $description;
|
|
}
|
|
@endphp
|
|
<div x-data="{ expanded: false }">
|
|
<p class="text-sm text-gray-700 leading-relaxed whitespace-pre-line" x-show="!expanded">
|
|
{{ $shortDescription }}
|
|
</p>
|
|
@if($isLong)
|
|
<p class="text-sm text-gray-700 leading-relaxed whitespace-pre-line" x-show="expanded"
|
|
x-cloak>
|
|
{{ $description }}
|
|
</p>
|
|
<button type="button" @click="expanded = !expanded"
|
|
class="text-xs font-semibold text-blue-600 hover:underline mt-2 focus:outline-none flex items-center gap-1">
|
|
<span x-show="!expanded">Show More</span>
|
|
<span x-show="expanded" x-cloak>Show Less</span>
|
|
<x-mary-icon name="lucide.chevron-down"
|
|
class="w-3 h-3 transition-transform duration-200"
|
|
x-bind:class="{ 'rotate-180': expanded }"/>
|
|
</button>
|
|
@endif
|
|
</div>
|
|
</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 or Assigned operations inside modal -->
|
|
@if(auth()->user()->hasRole('Admin') || auth()->user()->hasRole('admin') || auth()->user()->can('ticket:update') || auth()->id() === $selectedTicket->assignedUserId)
|
|
<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)
|
|
@php
|
|
$enumCase = TicketStatusEnum::tryFrom($status->name);
|
|
@endphp
|
|
@if($enumCase && $enumCase !== $selectedTicket->status)
|
|
<x-mary-button
|
|
label="{{ $enumCase->label() }}"
|
|
wire:click="changeStatus({{ $selectedTicket->id }}, '{{ $enumCase->value }}')"
|
|
class="btn-xs rounded-lg btn-soft"
|
|
/>
|
|
@endif
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
<!-- Right Column: Comments Feed (col-span-6) -->
|
|
<div class="lg:col-span-6 flex flex-col h-130 w-lg pl-4">
|
|
<div class="border-b border-gray-100 pb-3 mb-4 flex items-center justify-between">
|
|
<h3 class="text-sm font-bold text-gray-800 flex items-center gap-2">
|
|
<x-mary-icon name="lucide.message-square" class="w-4 h-4 text-blue-600"/>
|
|
Discussion Comments
|
|
</h3>
|
|
<span class="text-xs font-medium text-gray-400 bg-gray-50 px-2 py-0.5 rounded-full">
|
|
{{ count($selectedTicket->replies) }} comments
|
|
</span>
|
|
</div>
|
|
|
|
@if($this->canComment)
|
|
<!-- Comments List -->
|
|
<div class="flex-1 overflow-y-auto pr-2 space-y-4 mb-4 scrollbar-thin scrollbar-thumb-gray-200">
|
|
@if(empty($selectedTicket->replies))
|
|
<div
|
|
class="flex flex-col items-center justify-center text-center py-16 text-gray-400 space-y-2">
|
|
<x-mary-icon name="lucide.messages-square" class="w-8 h-8 text-gray-300"/>
|
|
<p class="text-xs font-medium">No comments yet</p>
|
|
<p class="text-[10px] max-w-50">Be the first to start the conversation on this
|
|
support ticket.</p>
|
|
</div>
|
|
@else
|
|
@foreach($selectedTicket->replies as $reply)
|
|
|
|
<div
|
|
class="p-3.5 rounded-xl border border-gray-100 bg-white hover:shadow-2xs transition-shadow duration-250 flex items-start gap-3">
|
|
<!-- User initials avatar -->
|
|
<div class="avatar placeholder shrink-0">
|
|
<div
|
|
class="bg-blue-100 text-blue-600 rounded-full w-8 h-8 flex items-center justify-center font-bold text-xs uppercase">
|
|
{{ $reply['userInitials'] }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Comment details -->
|
|
<div class="space-y-1 min-w-0 flex-1">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<div class="flex items-center gap-1.5 min-w-0">
|
|
<span class="text-xs font-bold text-gray-800 truncate"
|
|
title="{{ $reply['userName'] }}">{{ $reply['userName'] }}
|
|
</span>
|
|
</div>
|
|
<span
|
|
class="text-[10px] text-gray-400 shrink-0">{{ $reply['createdAt'] }}</span>
|
|
</div>
|
|
<p class="text-xs text-gray-700 leading-relaxed whitespace-pre-wrap">{{ $reply['message'] }}</p>
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
@endif
|
|
</div>
|
|
|
|
<!-- Post Comment Form -->
|
|
<div class="border-t border-gray-100 pt-4 mt-auto">
|
|
<form wire:submit.prevent="saveReply" class="space-y-2">
|
|
<x-mary-textarea
|
|
wire:model="replyMessage"
|
|
placeholder="Write a comment..."
|
|
rows="3"
|
|
required
|
|
class="text-xs textarea-bordered rounded-xl w-full"
|
|
/>
|
|
<div class="flex justify-end">
|
|
<x-mary-button
|
|
type="submit"
|
|
label="Post Comment"
|
|
icon="lucide.send"
|
|
class="btn-primary btn-sm font-semibold"
|
|
spinner="saveReply"
|
|
/>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
@else
|
|
<div
|
|
class="flex-1 flex flex-col items-center justify-center text-center p-8 bg-gray-50 rounded-2xl border border-dashed border-gray-200">
|
|
<x-mary-icon name="lucide.shield-alert" class="w-10 h-10 text-amber-500 mb-2"/>
|
|
<p class="text-xs font-semibold text-gray-800">Conversation Access Restricted</p>
|
|
<p class="text-[10px] text-gray-400 max-w-xs mt-1">
|
|
Only raised/tagged participants (ticket owner, assigned support, directly notified
|
|
users, or notified-role members) have access to the discussion feed.
|
|
</p>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</x-mary-modal>
|
|
</div>
|