Feat: add ticket assignment, comments, and notifications
- Added migration for ticket assignment and comments (`assigned_user_id` and `ticket_replies` table). - Implemented `TicketAssignedNotification` and `TicketCommentedNotification`. - Created `ReplyData`, `TicketListData`, and `PostReplyRequest` data classes. - Introduced `ResolveNotificationRouteController` for modular notification redirection. - Updated UI to support assigning users and posting comments on tickets. - User is navigated to comments page when clicked on the ticket, upon navigation ticket details modal is opened. - Extended tests to cover assignments, comments, status transitions, and notifications.
This commit is contained in:
parent
cac5f4905b
commit
67002d6979
@ -19,7 +19,7 @@ public function attempt(callable $action, ?callable $onError = null, string $suc
|
||||
// call the action callback
|
||||
$action();
|
||||
if ($showSuccess) {
|
||||
$this->success($successMessage);
|
||||
$this->success($successMessage, css: 'alert-success alert-dismissible alert-soft');
|
||||
}
|
||||
} catch (ValidationException $exception) {
|
||||
|
||||
@ -36,7 +36,7 @@ public function attempt(callable $action, ?callable $onError = null, string $suc
|
||||
// call the callback
|
||||
$onError();
|
||||
}
|
||||
$this->error($errorMessage);
|
||||
$this->error($errorMessage, css: 'alert-error alert-dismissible alert-soft');
|
||||
Log::error($exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
19
app/Data/Ticket/PostReplyRequest.php
Normal file
19
app/Data/Ticket/PostReplyRequest.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data\Ticket;
|
||||
|
||||
use Spatie\LaravelData\Attributes\MapOutputName;
|
||||
use Spatie\LaravelData\Data;
|
||||
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
|
||||
|
||||
#[MapOutputName(SnakeCaseMapper::class)]
|
||||
class PostReplyRequest extends Data
|
||||
{
|
||||
public function __construct(
|
||||
public int $ticketId,
|
||||
public int $userId,
|
||||
public string $message,
|
||||
) {}
|
||||
}
|
||||
34
app/Data/Ui/Ticket/ReplyData.php
Normal file
34
app/Data/Ui/Ticket/ReplyData.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data\Ui\Ticket;
|
||||
|
||||
use App\Models\TicketReply;
|
||||
use Spatie\LaravelData\Data;
|
||||
|
||||
final class ReplyData extends Data
|
||||
{
|
||||
public function __construct(
|
||||
public int $id,
|
||||
public int $userId,
|
||||
public string $userName,
|
||||
public string $userInitials,
|
||||
public array $userRoles,
|
||||
public string $message,
|
||||
public string $createdAt,
|
||||
) {}
|
||||
|
||||
public static function fromModel(TicketReply $reply): self
|
||||
{
|
||||
return new self(
|
||||
id: $reply->id,
|
||||
userId: $reply->user_id,
|
||||
userName: $reply->user->name,
|
||||
userInitials: method_exists($reply->user, 'initials') ? $reply->user->initials() : mb_strtoupper(mb_substr($reply->user->name, 0, 2)),
|
||||
userRoles: $reply->user->roles->pluck('name')->toArray(),
|
||||
message: $reply->message,
|
||||
createdAt: $reply->created_at->diffForHumans(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Data\Ui\Ticket;
|
||||
|
||||
use App\Enums\TicketStatusEnum;
|
||||
use App\Models\Ticket;
|
||||
use Spatie\LaravelData\Data;
|
||||
|
||||
@ -19,12 +20,14 @@ public function __construct(
|
||||
public string $issueCategory,
|
||||
public ?string $description,
|
||||
public int $statusId,
|
||||
public string $statusName,
|
||||
public string $statusSlug,
|
||||
public TicketStatusEnum $status,
|
||||
public array $notifiedRoleNames,
|
||||
public array $notifiedUserNames,
|
||||
public string $createdAt,
|
||||
public array $attachments = [],
|
||||
public ?int $assignedUserId = null,
|
||||
public ?string $assignedUserName = null,
|
||||
public array $replies = [],
|
||||
) {}
|
||||
|
||||
public static function fromModel(Ticket $ticket): self
|
||||
@ -37,6 +40,10 @@ public static function fromModel(Ticket $ticket): self
|
||||
'mime_type' => $attachment->mime_type,
|
||||
])->toArray();
|
||||
|
||||
$replies = $ticket->relationLoaded('replies')
|
||||
? $ticket->replies->map(fn ($reply) => ReplyData::fromModel($reply))->toArray()
|
||||
: [];
|
||||
|
||||
return new self(
|
||||
id: $ticket->id,
|
||||
ticketId: $ticket->ticket_id,
|
||||
@ -47,12 +54,14 @@ public static function fromModel(Ticket $ticket): self
|
||||
issueCategory: $ticket->issue_category,
|
||||
description: $ticket->description,
|
||||
statusId: $ticket->status_id,
|
||||
statusName: $ticket->status->name,
|
||||
statusSlug: $ticket->status->slug,
|
||||
status: TicketStatusEnum::tryFrom($ticket->status->name),
|
||||
notifiedRoleNames: $ticket->notifiedRoles->pluck('name')->toArray(),
|
||||
notifiedUserNames: $ticket->notifiedUsers->pluck('name')->toArray(),
|
||||
createdAt: $ticket->created_at->toDateTimeString(),
|
||||
attachments: $attachments,
|
||||
assignedUserId: $ticket->assigned_user_id,
|
||||
assignedUserName: $ticket->assignedUser?->name,
|
||||
replies: $replies,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
37
app/Data/Ui/Ticket/TicketListData.php
Normal file
37
app/Data/Ui/Ticket/TicketListData.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data\Ui\Ticket;
|
||||
|
||||
use App\Enums\TicketStatusEnum;
|
||||
use App\Models\Ticket;
|
||||
use Spatie\LaravelData\Data;
|
||||
|
||||
final class TicketListData extends Data
|
||||
{
|
||||
public function __construct(
|
||||
public int $id,
|
||||
public string $ticketId,
|
||||
public string $userName,
|
||||
public ?string $appName,
|
||||
public string $issueCategory,
|
||||
public string $createdAt,
|
||||
public TicketStatusEnum $status,
|
||||
public string $statusSlug,
|
||||
) {}
|
||||
|
||||
public static function fromModel(Ticket $ticket): self
|
||||
{
|
||||
return new self(
|
||||
id: $ticket->id,
|
||||
ticketId: $ticket->ticket_id,
|
||||
userName: $ticket->user->name,
|
||||
appName: $ticket->connectedApp?->name,
|
||||
issueCategory: $ticket->issue_category,
|
||||
createdAt: $ticket->created_at->toDateTimeString(),
|
||||
status: TicketStatusEnum::from($ticket->status->name),
|
||||
statusSlug: $ticket->status->name,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,23 @@ enum TicketStatusEnum: string
|
||||
|
||||
case Open = 'open';
|
||||
case InProgress = 'in-progress';
|
||||
case Hold = 'hold';
|
||||
case Resolved = 'resolved';
|
||||
case Closed = 'closed';
|
||||
|
||||
public static function toLabels()
|
||||
{
|
||||
return array_map(fn ($status) => $status->label(), self::cases());
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Open => 'Open',
|
||||
self::InProgress => 'In Progress',
|
||||
self::Hold => 'Hold',
|
||||
self::Resolved => 'Resolved',
|
||||
self::Closed => 'Closed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
38
app/Http/Controllers/ResolveNotificationRouteController.php
Normal file
38
app/Http/Controllers/ResolveNotificationRouteController.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Notifications\{TicketAssignedNotification, TicketCommentedNotification, TicketRaisedNotification};
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ResolveNotificationRouteController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, string $id)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return to_route('login');
|
||||
}
|
||||
|
||||
$notification = $user->notifications()->findOrFail($id);
|
||||
$notification->markAsRead();
|
||||
|
||||
// Modular pattern: Match notification type to determine where to redirect
|
||||
$url = match ($notification->type) {
|
||||
TicketRaisedNotification::class,
|
||||
TicketAssignedNotification::class,
|
||||
TicketCommentedNotification::class => route('tickets.index', [
|
||||
'ticket' => data_get($notification->data, 'ticket_id'),
|
||||
]),
|
||||
|
||||
// Future expansion:
|
||||
// \App\Notifications\AppExpiredNotification::class => route('apps.index', ['app' => data_get($notification->data, 'app_id')]),
|
||||
|
||||
default => route('dashboard'),
|
||||
};
|
||||
|
||||
return redirect($url);
|
||||
}
|
||||
}
|
||||
@ -4,9 +4,8 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\{Model, SoftDeletes};
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Relations\{BelongsTo, BelongsToMany, HasMany};
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@ -17,10 +16,11 @@
|
||||
'issue_category',
|
||||
'description',
|
||||
'status_id',
|
||||
'assigned_user_id',
|
||||
])]
|
||||
class Ticket extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use SoftDeletes;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
@ -59,6 +59,14 @@ public function status(): BelongsTo
|
||||
return $this->belongsTo(TicketStatus::class, 'status_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function assignedUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'assigned_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Role, $this>
|
||||
*/
|
||||
@ -92,4 +100,12 @@ public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(TicketAttachment::class, 'ticket_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<TicketReply, $this>
|
||||
*/
|
||||
public function replies(): HasMany
|
||||
{
|
||||
return $this->hasMany(TicketReply::class, 'ticket_id');
|
||||
}
|
||||
}
|
||||
|
||||
40
app/Models/TicketReply.php
Normal file
40
app/Models/TicketReply.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['ticket_id', 'user_id', 'message'])]
|
||||
class TicketReply extends Model
|
||||
{
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'ticket_replies';
|
||||
|
||||
/**
|
||||
* Get the ticket that this reply belongs to.
|
||||
*
|
||||
* @return BelongsTo<Ticket, $this>
|
||||
*/
|
||||
public function ticket(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Ticket::class, 'ticket_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user who posted this reply.
|
||||
*
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
}
|
||||
66
app/Notifications/TicketAssignedNotification.php
Normal file
66
app/Notifications/TicketAssignedNotification.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Ticket;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TicketAssignedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Ticket $ticket
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database', 'mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage())
|
||||
->subject("SSO Support Ticket Assigned: {$this->ticket->ticket_id}")
|
||||
->greeting('Hello,')
|
||||
->line("You have been assigned as the support owner for ticket {$this->ticket->ticket_id} regarding {$this->ticket->issue_category}.")
|
||||
->line('Customer: '.$this->ticket->user->name)
|
||||
->line('Description: '.($this->ticket->description ?? 'N/A'))
|
||||
->action('View Ticket Details', route('tickets.index'))
|
||||
->line('Thank you for using our application!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
$appName = $this->ticket->connectedApp->name ?? 'General Inquiry';
|
||||
|
||||
return [
|
||||
'ticket_id' => $this->ticket->id,
|
||||
'ticket_code' => $this->ticket->ticket_id,
|
||||
'user_name' => $this->ticket->user->name,
|
||||
'issue_category' => $this->ticket->issue_category,
|
||||
'app_name' => $appName,
|
||||
'description' => Str::limit($this->ticket->description ?? '', 100),
|
||||
'message' => "You have been assigned to ticket {$this->ticket->ticket_id} by the administrator.",
|
||||
];
|
||||
}
|
||||
}
|
||||
69
app/Notifications/TicketCommentedNotification.php
Normal file
69
app/Notifications/TicketCommentedNotification.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\{Ticket, TicketReply};
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TicketCommentedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Ticket $ticket,
|
||||
public readonly TicketReply $comment
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database', 'mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$commenterName = $this->comment->user->name;
|
||||
|
||||
return (new MailMessage())
|
||||
->subject("New Comment on Ticket: {$this->ticket->ticket_id}")
|
||||
->greeting('Hello,')
|
||||
->line("{$commenterName} left a new comment on ticket {$this->ticket->ticket_id} regarding {$this->ticket->issue_category}.")
|
||||
->line('Comment: "'.Str::limit($this->comment->message, 300).'"')
|
||||
->action('View Discussion', route('tickets.index'))
|
||||
->line('Thank you for using our application!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
$commenterName = $this->comment->user->name;
|
||||
$appName = $this->ticket->connectedApp->name ?? 'General Inquiry';
|
||||
|
||||
return [
|
||||
'ticket_id' => $this->ticket->id,
|
||||
'ticket_code' => $this->ticket->ticket_id,
|
||||
'user_name' => $commenterName,
|
||||
'issue_category' => $this->ticket->issue_category,
|
||||
'app_name' => $appName,
|
||||
'description' => Str::limit($this->ticket->description ?? '', 100),
|
||||
'message' => "{$commenterName} commented on ticket {$this->ticket->ticket_id}.",
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -4,11 +4,12 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Data\Ticket\CreateTicketRequest;
|
||||
use App\Data\Ui\Ticket\TicketData;
|
||||
use App\Data\Ticket\{CreateTicketRequest, PostReplyRequest};
|
||||
use App\Data\Ui\Ticket\{ReplyData, TicketData, TicketListData};
|
||||
use App\Enums\TicketStatusEnum;
|
||||
use App\Models\{Ticket, TicketStatus, User};
|
||||
use App\Notifications\TicketRaisedNotification;
|
||||
use App\Models\{Ticket, TicketReply, TicketStatus, User};
|
||||
use App\Notifications\{TicketAssignedNotification, TicketCommentedNotification, TicketRaisedNotification};
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@ -21,6 +22,195 @@ public function __construct(
|
||||
private readonly TicketAttachmentService $attachmentService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get all tickets raised by a specific user.
|
||||
*
|
||||
* @return Collection<int, TicketData>
|
||||
*/
|
||||
public function getAllForUser(int $userId): Collection
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
throw new InvalidArgumentException('Invalid user ID');
|
||||
}
|
||||
|
||||
$tickets = Ticket::with(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments', 'assignedUser', 'replies.user.roles'])
|
||||
->where('user_id', $userId)
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
return TicketData::collect($tickets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tickets in the system (for admins/support).
|
||||
*
|
||||
* @return Collection<int, TicketData>
|
||||
*/
|
||||
public function getAll(): Collection
|
||||
{
|
||||
$tickets = Ticket::with(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments', 'assignedUser', 'replies.user.roles'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
return TicketData::collect($tickets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lightweight ticket list for a specific user.
|
||||
*
|
||||
* @return Collection<int, TicketListData>
|
||||
*/
|
||||
public function getListForUser(int $userId): Collection
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
throw new InvalidArgumentException('Invalid user ID');
|
||||
}
|
||||
|
||||
$user = User::findOrFail($userId);
|
||||
$roleIds = $user->roles->pluck('id')->toArray();
|
||||
|
||||
$tickets = Ticket::with(['user', 'connectedApp', 'status'])
|
||||
->where(function ($query) use ($userId, $roleIds): void {
|
||||
$query->where('user_id', $userId)
|
||||
->orWhere('assigned_user_id', $userId)
|
||||
->orWhereHas('notifiedUsers', function ($q) use ($userId): void {
|
||||
$q->where('users.id', $userId);
|
||||
})
|
||||
->orWhereHas('notifiedRoles', function ($q) use ($roleIds): void {
|
||||
$q->whereIn('roles.id', $roleIds);
|
||||
});
|
||||
})
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
return TicketListData::collect($tickets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lightweight ticket list of all tickets.
|
||||
*
|
||||
* @return Collection<int, TicketListData>
|
||||
*/
|
||||
public function getList(): Collection
|
||||
{
|
||||
$tickets = Ticket::with(['user', 'connectedApp', 'status'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
return TicketListData::collect($tickets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific ticket.
|
||||
*/
|
||||
public function getTicket(int $id): TicketData
|
||||
{
|
||||
if ($id <= 0) {
|
||||
throw new InvalidArgumentException('Invalid ticket ID');
|
||||
}
|
||||
|
||||
$ticket = Ticket::with(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments', 'assignedUser', 'replies.user.roles'])
|
||||
->findOrFail($id);
|
||||
|
||||
return TicketData::fromModel($ticket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status of a ticket.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function updateStatus(int $ticketId, string $statusSlug): void
|
||||
{
|
||||
if ($ticketId <= 0) {
|
||||
throw new InvalidArgumentException('Invalid ticket ID');
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($ticketId, $statusSlug): void {
|
||||
$ticket = Ticket::findOrFail($ticketId);
|
||||
$status = TicketStatus::where('name', $statusSlug)->firstOrFail();
|
||||
|
||||
$ticket->update(['status_id' => $status->id]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign/remove assigned user to ticket.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function assignUser(int $ticketId, ?int $assignedUserId): void
|
||||
{
|
||||
if ($ticketId <= 0) {
|
||||
throw new InvalidArgumentException('Invalid ticket ID');
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($ticketId, $assignedUserId): void {
|
||||
$ticket = Ticket::findOrFail($ticketId);
|
||||
$oldAssignedUserId = $ticket->assigned_user_id;
|
||||
|
||||
$ticket->update(['assigned_user_id' => $assignedUserId]);
|
||||
|
||||
if ($assignedUserId && $assignedUserId !== $oldAssignedUserId) {
|
||||
$assignedUser = User::findOrFail($assignedUserId);
|
||||
$assignedUser->notify(new TicketAssignedNotification($ticket));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a comment/reply to a ticket.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function postReply(PostReplyRequest $request): ReplyData
|
||||
{
|
||||
return DB::transaction(function () use ($request): ReplyData {
|
||||
$user = User::findOrFail($request->userId);
|
||||
$ticket = Ticket::findOrFail($request->ticketId);
|
||||
|
||||
// Authorization check: Admin, Creator, Assigned, or Notified directly/via role
|
||||
$isAuthorized = $user->hasRole('Admin')
|
||||
|| $user->hasRole('admin')
|
||||
|| $ticket->user_id === $user->id
|
||||
|| $ticket->assigned_user_id === $user->id
|
||||
|| $ticket->notifiedUsers()->where('users.id', $user->id)->exists()
|
||||
|| $ticket->notifiedRoles()->whereIn('roles.id', $user->roles->pluck('id')->toArray())->exists();
|
||||
|
||||
if (! $isAuthorized) {
|
||||
throw new AuthorizationException('You are not authorized to comment on this ticket.');
|
||||
}
|
||||
|
||||
/** @var TicketReply $reply */
|
||||
$reply = TicketReply::query()->create([
|
||||
'ticket_id' => $request->ticketId,
|
||||
'user_id' => $request->userId,
|
||||
'message' => mb_trim($request->message),
|
||||
]);
|
||||
|
||||
// Transition ticket status from open to in-progress if it's currently open
|
||||
$ticket->load(['status', 'user', 'assignedUser']);
|
||||
if ($ticket->status->name === TicketStatusEnum::Open->value) {
|
||||
$inProgressStatus = TicketStatus::where('name', TicketStatusEnum::InProgress->value)->first();
|
||||
if ($inProgressStatus) {
|
||||
$ticket->update(['status_id' => $inProgressStatus->id]);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify ticket raiser if they did not make this comment
|
||||
if ($ticket->user_id !== $request->userId) {
|
||||
$ticket->user->notify(new TicketCommentedNotification($ticket, $reply));
|
||||
}
|
||||
|
||||
// Notify assigned support user if they are set and did not make this comment
|
||||
if ($ticket->assigned_user_id && $ticket->assigned_user_id !== $request->userId) {
|
||||
$ticket->assignedUser->notify(new TicketCommentedNotification($ticket, $reply));
|
||||
}
|
||||
|
||||
return ReplyData::fromModel($reply->load('user.roles'));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new support ticket.
|
||||
*
|
||||
@ -30,7 +220,7 @@ public function create(CreateTicketRequest $request): TicketData
|
||||
{
|
||||
return DB::transaction(function () use ($request): TicketData {
|
||||
$defaultStatus = TicketStatus::query()
|
||||
->where('slug', TicketStatusEnum::Open->value)
|
||||
->where('name', TicketStatusEnum::Open->value)
|
||||
->firstOrFail();
|
||||
|
||||
/** @var Ticket $ticket */
|
||||
@ -55,7 +245,7 @@ public function create(CreateTicketRequest $request): TicketData
|
||||
}
|
||||
|
||||
// Load relations to build complete DTO and trigger notifications
|
||||
$ticket->load(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments']);
|
||||
$ticket->load(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments', 'assignedUser', 'replies.user.roles']);
|
||||
|
||||
$this->sendNotifications($ticket);
|
||||
|
||||
@ -63,73 +253,6 @@ public function create(CreateTicketRequest $request): TicketData
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tickets raised by a specific user.
|
||||
*
|
||||
* @return Collection<int, TicketData>
|
||||
*/
|
||||
public function getAllForUser(int $userId): Collection
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
throw new InvalidArgumentException('Invalid user ID');
|
||||
}
|
||||
|
||||
$tickets = Ticket::with(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments'])
|
||||
->where('user_id', $userId)
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
return TicketData::collect($tickets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tickets in the system (for admins/support).
|
||||
*
|
||||
* @return Collection<int, TicketData>
|
||||
*/
|
||||
public function getAll(): Collection
|
||||
{
|
||||
$tickets = Ticket::with(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
return TicketData::collect($tickets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific ticket.
|
||||
*/
|
||||
public function getTicket(int $id): TicketData
|
||||
{
|
||||
if ($id <= 0) {
|
||||
throw new InvalidArgumentException('Invalid ticket ID');
|
||||
}
|
||||
|
||||
$ticket = Ticket::with(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments'])
|
||||
->findOrFail($id);
|
||||
|
||||
return TicketData::fromModel($ticket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status of a ticket.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function updateStatus(int $ticketId, string $statusSlug): void
|
||||
{
|
||||
if ($ticketId <= 0) {
|
||||
throw new InvalidArgumentException('Invalid ticket ID');
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($ticketId, $statusSlug): void {
|
||||
$ticket = Ticket::findOrFail($ticketId);
|
||||
$status = TicketStatus::where('slug', $statusSlug)->firstOrFail();
|
||||
|
||||
$ticket->update(['status_id' => $status->id]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notifications to all routed recipients.
|
||||
*/
|
||||
|
||||
@ -22,4 +22,9 @@ public function up(): void
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('activity_log');
|
||||
}
|
||||
};
|
||||
|
||||
@ -16,7 +16,6 @@ public function up(): void
|
||||
Schema::create('ticket_statuses', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('name')->unique();
|
||||
$table->string('slug')->unique();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class() extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table): void {
|
||||
$table->foreignId('assigned_user_id')
|
||||
->nullable()
|
||||
->after('status_id')
|
||||
->constrained('users')
|
||||
->nullOnDelete();
|
||||
});
|
||||
|
||||
Schema::create('ticket_replies', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('ticket_id')->constrained('tickets')->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->text('message');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ticket_replies');
|
||||
|
||||
Schema::table('tickets', function (Blueprint $table): void {
|
||||
$table->dropForeign(['assigned_user_id']);
|
||||
$table->dropColumn('assigned_user_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -15,30 +15,9 @@ class TicketStatusSeeder extends Seeder
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$data = [
|
||||
[
|
||||
'name' => 'Open',
|
||||
'slug' => TicketStatusEnum::Open->value,
|
||||
'created_at' => now(),
|
||||
],
|
||||
[
|
||||
'name' => 'In Progress',
|
||||
'slug' => TicketStatusEnum::InProgress->value,
|
||||
'created_at' => now(),
|
||||
],
|
||||
[
|
||||
'name' => 'Resolved',
|
||||
'slug' => TicketStatusEnum::Resolved->value,
|
||||
'created_at' => now(),
|
||||
],
|
||||
[
|
||||
'name' => 'Closed',
|
||||
'slug' => TicketStatusEnum::Closed->value,
|
||||
'created_at' => now(),
|
||||
],
|
||||
];
|
||||
$data = array_map(fn ($value) => ['name' => $value], TicketStatusEnum::values());
|
||||
|
||||
TicketStatus::query()
|
||||
->upsert($data, ['slug'], ['name', 'updated_at']);
|
||||
->upsert($data, ['name'], ['name', 'updated_at']);
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,7 +39,7 @@ class="px-4 py-2 border-b border-gray-100 font-semibold text-[10px] text-gray-50
|
||||
</form>
|
||||
</div>
|
||||
@foreach($unreadNotifications as $notification)
|
||||
<x-mary-menu-item>
|
||||
<x-mary-menu-item link="{{ route('notifications.resolve', $notification->id) }}">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-semibold text-xs text-gray-900">
|
||||
{{ data_get($notification->data, 'issue_category', 'Notification') }}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
<?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\Enums\DefaultUserRole;
|
||||
use App\Enums\Permissions\TicketPermissionEnum;
|
||||
use App\Enums\TicketStatusEnum;
|
||||
use App\Livewire\Forms\CreateTicketForm;
|
||||
@ -31,6 +32,9 @@ class extends Component {
|
||||
#[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;
|
||||
|
||||
@ -43,6 +47,12 @@ class extends Component {
|
||||
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;
|
||||
@ -50,6 +60,10 @@ class extends Component {
|
||||
// Selected status for filtering (Admin/Support only)
|
||||
public string $statusFilter = 'all';
|
||||
|
||||
// Comment and assignment state
|
||||
public string $replyMessage = '';
|
||||
public $assignedUserId = null;
|
||||
|
||||
public function boot(TicketService $ticketService, UserAppAccessService $appAccessService): void
|
||||
{
|
||||
$this->ticketService = $ticketService;
|
||||
@ -62,6 +76,7 @@ public function mount(): void
|
||||
$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();
|
||||
@ -76,6 +91,11 @@ public function mount(): void
|
||||
$this->form->connectedAppId = $this->appId;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-open ticket if requested from URL
|
||||
if ($this->ticketParam && (int) $this->ticketParam > 0) {
|
||||
$this->showTicket((int) $this->ticketParam);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -92,6 +112,9 @@ public function rendering(): void
|
||||
if (empty($this->usersSearchable)) {
|
||||
$this->searchUsers();
|
||||
}
|
||||
if (empty($this->assignableUsersSearchable)) {
|
||||
$this->searchAssignableUsers($this->assignableUsersSearchTerm);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -105,15 +128,19 @@ public function tickets(): Collection
|
||||
return collect();
|
||||
}
|
||||
|
||||
if ($user->hasRole('Admin') || $user->can(TicketPermissionEnum::Read->value)) {
|
||||
$tickets = $this->ticketService->getAll();
|
||||
if ($user->hasRole('Admin') || $user->hasRole('admin') || $user->can(TicketPermissionEnum::Read->value)) {
|
||||
$tickets = $this->ticketService->getList();
|
||||
if ($this->statusFilter !== 'all') {
|
||||
$tickets = $tickets->filter(fn($t) => $t->statusSlug === $this->statusFilter);
|
||||
$tickets = $tickets->filter(fn($t) => $t->status->value === $this->statusFilter);
|
||||
}
|
||||
return $tickets;
|
||||
}
|
||||
|
||||
return $this->ticketService->getAllForUser($user->id);
|
||||
$tickets = $this->ticketService->getListForUser($user->id);
|
||||
if ($this->statusFilter !== 'all') {
|
||||
$tickets = $tickets->filter(fn($t) => $t->status->value === $this->statusFilter);
|
||||
}
|
||||
return $tickets;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -148,7 +175,6 @@ 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) {
|
||||
@ -211,27 +237,107 @@ public function searchUsers(string $value = ''): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Select all roles in choices element.
|
||||
* Search assignable users dynamically with incremental pagination capability.
|
||||
*/
|
||||
public function selectAllRoles(): void
|
||||
public function searchAssignableUsers(string $value = ''): void
|
||||
{
|
||||
$this->form->notifiedRoleIds = Role::query()
|
||||
->pluck('id')
|
||||
// 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();
|
||||
$this->searchRoles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all selected roles in choices element.
|
||||
* 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 = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Select all users in choices element.
|
||||
*/
|
||||
public function selectAllUsers(): void
|
||||
{
|
||||
$this->form->notifiedUserIds = User::query()
|
||||
@ -241,17 +347,11 @@ public function selectAllUsers(): void
|
||||
$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();
|
||||
@ -271,14 +371,11 @@ public function saveTicket(): void
|
||||
$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();
|
||||
@ -287,31 +384,28 @@ public function saveTicket(): void
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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->assignedUserId = $this->selectedTicket->assignedUserId;
|
||||
$this->replyMessage = '';
|
||||
|
||||
// Keep assignment lookup synchronized upon details injection
|
||||
$this->searchAssignableUsers();
|
||||
$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);
|
||||
}
|
||||
@ -320,9 +414,91 @@ public function changeStatus(int $ticketId, string $statusSlug): void
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle Drawer manually.
|
||||
*/
|
||||
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;
|
||||
@ -341,7 +517,7 @@ public function toggleDrawer(): void
|
||||
@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())"
|
||||
: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"
|
||||
/>
|
||||
@endif
|
||||
@ -374,21 +550,21 @@ class="btn-primary btn-sm rounded-xl font-semibold shadow-xs"
|
||||
['key' => 'createdAt', 'label' => 'Date Raised'],
|
||||
['key' => 'statusName', 'label' => 'Status'],
|
||||
]"
|
||||
:rows="$this->tickets->toArray()"
|
||||
:rows="$this->tickets"
|
||||
wire:loading.class="opacity-60"
|
||||
>
|
||||
@scope('cell_ticketId', $row)
|
||||
<button
|
||||
wire:click="showTicket({{ $row['id'] }})"
|
||||
wire:click="showTicket({{ $row->id }})"
|
||||
class="font-mono font-semibold text-blue-600 hover:underline text-left cursor-pointer"
|
||||
>
|
||||
{{ $row['ticketId'] }}
|
||||
{{ $row->ticketId }}
|
||||
</button>
|
||||
@endscope
|
||||
|
||||
@scope('cell_appName', $row)
|
||||
@if($row['appName'])
|
||||
<span class="font-medium text-gray-700">{{ $row['appName'] }}</span>
|
||||
@if($row->appName)
|
||||
<span class="font-medium text-gray-700">{{ $row->appName }}</span>
|
||||
@else
|
||||
<span class="text-gray-400 italic">General Support</span>
|
||||
@endif
|
||||
@ -396,29 +572,30 @@ class="font-mono font-semibold text-blue-600 hover:underline text-left cursor-po
|
||||
|
||||
@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',
|
||||
$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['statusName'] }}
|
||||
{{ $row->status->label() }}
|
||||
</x-mary-badge>
|
||||
@endscope
|
||||
|
||||
@scope('cell_createdAt', $row)
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ Carbon\Carbon::parse($row['createdAt'])->diffForHumans() }}
|
||||
{{ 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'] }})"
|
||||
wire:click="showTicket({{ $row->id }})"
|
||||
icon="lucide.eye"
|
||||
class="btn-xs btn-circle btn-ghost text-gray-500"
|
||||
tooltip="View Details"
|
||||
@ -431,10 +608,13 @@ class="btn-xs btn-circle btn-ghost text-gray-500"
|
||||
class="btn-xs btn-circle btn-ghost text-gray-500"/>
|
||||
</x-slot:trigger>
|
||||
@foreach($this->statuses as $status)
|
||||
@if($status->slug !== $row['statusSlug'])
|
||||
@php
|
||||
$enumCase = TicketStatusEnum::tryFrom($status->name);
|
||||
@endphp
|
||||
@if($enumCase && $enumCase !== $row->status)
|
||||
<x-mary-menu-item
|
||||
title="Mark as {{ $status->name }}"
|
||||
wire:click="changeStatus({{ $row['id'] }}, '{{ $status->slug }}')"
|
||||
title="Mark as {{ $enumCase->label() }}"
|
||||
wire:click="changeStatus({{ $row->id }}, '{{ $enumCase->value }}')"
|
||||
/>
|
||||
@endif
|
||||
@endforeach
|
||||
@ -587,152 +767,315 @@ class="radio radio-primary radio-xs"/>
|
||||
</x-mary-drawer>
|
||||
|
||||
<!-- Support Ticket Details Modal -->
|
||||
<x-mary-modal wire:model="detailsModalOpen" title="Ticket Details" class="">
|
||||
<x-mary-modal wire:model="detailsModalOpen" title="Ticket Details" box-class="max-w-[67rem]">
|
||||
@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 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>
|
||||
|
||||
@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 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>
|
||||
|
||||
<!-- 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">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">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:
|
||||
<!-- 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>
|
||||
@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"
|
||||
@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>
|
||||
@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"
|
||||
/>
|
||||
@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
|
||||
@endforeach
|
||||
@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>
|
||||
@endif
|
||||
|
||||
<!-- 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 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)
|
||||
@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-slot:actions>
|
||||
<x-mary-button label="Close" wire:click="$set('detailsModalOpen', false)" class="btn-ghost"/>
|
||||
</x-slot:actions>
|
||||
</x-mary-modal>
|
||||
</div>
|
||||
|
||||
@ -19,6 +19,8 @@
|
||||
return back();
|
||||
})->name('notifications.read-all');
|
||||
|
||||
Route::get('notifications/{id}/resolve', App\Http\Controllers\ResolveNotificationRouteController::class)->name('notifications.resolve');
|
||||
|
||||
Route::livewire('tickets', 'pages::tickets.index')->name('tickets.index');
|
||||
|
||||
Route::prefix('dashboard')->name('dashboard.')->group(function (): void {
|
||||
|
||||
63
tests/Feature/NotificationResolutionTest.php
Normal file
63
tests/Feature/NotificationResolutionTest.php
Normal file
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\{Ticket, TicketStatus, User};
|
||||
use App\Notifications\TicketAssignedNotification;
|
||||
use Database\Seeders\DatabaseSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class NotificationResolutionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
}
|
||||
|
||||
public function test_guest_cannot_resolve_notifications(): void
|
||||
{
|
||||
$this->get(route('notifications.resolve', ['id' => 'some-uuid']))
|
||||
->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_user_can_resolve_and_redirect_notification_modularly(): void
|
||||
{
|
||||
$creator = User::factory()->create();
|
||||
$creator->assignRole('user');
|
||||
|
||||
$supportUser = User::factory()->create();
|
||||
$supportUser->assignRole('Support');
|
||||
|
||||
$openStatus = TicketStatus::where('name', 'open')->firstOrFail();
|
||||
|
||||
$ticket = Ticket::create([
|
||||
'user_id' => $creator->id,
|
||||
'issue_category' => 'Billing Query',
|
||||
'status_id' => $openStatus->id,
|
||||
'assigned_user_id' => $supportUser->id,
|
||||
]);
|
||||
|
||||
// Send a notification
|
||||
$supportUser->notify(new TicketAssignedNotification($ticket));
|
||||
|
||||
// Retrieve unread notification
|
||||
$notification = $supportUser->unreadNotifications()->firstOrFail();
|
||||
|
||||
$this->actingAs($supportUser);
|
||||
|
||||
// Resolve notification
|
||||
$response = $this->get(route('notifications.resolve', ['id' => $notification->id]));
|
||||
|
||||
// Should mark as read
|
||||
$this->assertSame(0, $supportUser->unreadNotifications()->count());
|
||||
|
||||
// Should redirect modularly to support tickets index page with ticket ID parameter
|
||||
$response->assertRedirect(route('tickets.index', ['ticket' => $ticket->id]));
|
||||
}
|
||||
}
|
||||
@ -55,7 +55,7 @@
|
||||
expect($ticketData->ticketId)->toStartWith('TKT-')
|
||||
->and(mb_strlen($ticketData->ticketId))->toBe(12)
|
||||
->and($ticketData->issueCategory)->toBe('Billing Query')
|
||||
->and($ticketData->statusSlug)->toBe(TicketStatusEnum::Open->value)
|
||||
->and($ticketData->status->value)->toBe(TicketStatusEnum::Open->value)
|
||||
->and($ticketData->notifiedRoleNames)->toContain('Support');
|
||||
|
||||
$this->assertDatabaseHas('tickets', [
|
||||
@ -183,3 +183,176 @@
|
||||
TicketRaisedNotification::class
|
||||
);
|
||||
});
|
||||
|
||||
test('admins can assign users to tickets and trigger assignment notifications', function (): void {
|
||||
Notification::fake();
|
||||
|
||||
$admin = User::where('email', 'admin@example.com')->firstOrFail();
|
||||
$this->actingAs($admin);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole('user');
|
||||
|
||||
$ticket = App\Models\Ticket::create([
|
||||
'user_id' => $user->id,
|
||||
'issue_category' => 'Billing Query',
|
||||
'status_id' => App\Models\TicketStatus::where('name', 'open')->firstOrFail()->id,
|
||||
]);
|
||||
|
||||
$supportUser = User::factory()->create();
|
||||
$supportUser->assignRole('Support');
|
||||
|
||||
$service = app(TicketService::class);
|
||||
$service->assignUser($ticket->id, $supportUser->id);
|
||||
|
||||
$this->assertDatabaseHas('tickets', [
|
||||
'id' => $ticket->id,
|
||||
'assigned_user_id' => $supportUser->id,
|
||||
]);
|
||||
|
||||
Notification::assertSentTo(
|
||||
$supportUser,
|
||||
App\Notifications\TicketAssignedNotification::class
|
||||
);
|
||||
});
|
||||
|
||||
test('authorized users can comment on tickets and unauthorized users are blocked', function (): void {
|
||||
$creator = User::factory()->create();
|
||||
$creator->assignRole('user');
|
||||
|
||||
$unauthorizedUser = User::factory()->create();
|
||||
$unauthorizedUser->assignRole('user');
|
||||
|
||||
$ticket = App\Models\Ticket::create([
|
||||
'user_id' => $creator->id,
|
||||
'issue_category' => 'Technical Issue',
|
||||
'status_id' => App\Models\TicketStatus::where('name', 'open')->firstOrFail()->id,
|
||||
]);
|
||||
|
||||
$service = app(TicketService::class);
|
||||
|
||||
// 1. Authorized user (Creator) can comment
|
||||
$dto = new App\Data\Ticket\PostReplyRequest(
|
||||
ticketId: $ticket->id,
|
||||
userId: $creator->id,
|
||||
message: 'This is a comment from the creator.',
|
||||
);
|
||||
$replyData = $service->postReply($dto);
|
||||
|
||||
expect($replyData->message)->toBe('This is a comment from the creator.');
|
||||
|
||||
$this->assertDatabaseHas('ticket_replies', [
|
||||
'ticket_id' => $ticket->id,
|
||||
'user_id' => $creator->id,
|
||||
'message' => 'This is a comment from the creator.',
|
||||
]);
|
||||
|
||||
// 2. Unauthorized user gets blocked
|
||||
$dto2 = new App\Data\Ticket\PostReplyRequest(
|
||||
ticketId: $ticket->id,
|
||||
userId: $unauthorizedUser->id,
|
||||
message: 'Should be blocked.',
|
||||
);
|
||||
|
||||
expect(fn () => $service->postReply($dto2))
|
||||
->toThrow(Illuminate\Auth\Access\AuthorizationException::class);
|
||||
});
|
||||
|
||||
test('posting a comment transitions the status from open to in-progress', function (): void {
|
||||
$creator = User::factory()->create();
|
||||
$creator->assignRole('user');
|
||||
|
||||
$openStatus = App\Models\TicketStatus::where('name', 'open')->firstOrFail();
|
||||
$inProgressStatus = App\Models\TicketStatus::where('name', 'in-progress')->firstOrFail();
|
||||
|
||||
$ticket = App\Models\Ticket::create([
|
||||
'user_id' => $creator->id,
|
||||
'issue_category' => 'Service Extension',
|
||||
'status_id' => $openStatus->id,
|
||||
]);
|
||||
|
||||
$service = app(TicketService::class);
|
||||
|
||||
$dto = new App\Data\Ticket\PostReplyRequest(
|
||||
ticketId: $ticket->id,
|
||||
userId: $creator->id,
|
||||
message: 'Transition this status.',
|
||||
);
|
||||
|
||||
$service->postReply($dto);
|
||||
|
||||
$this->assertDatabaseHas('tickets', [
|
||||
'id' => $ticket->id,
|
||||
'status_id' => $inProgressStatus->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('assigned support users can see their assigned tickets on their dashboard', function (): void {
|
||||
$creator = User::factory()->create();
|
||||
$creator->assignRole('user');
|
||||
|
||||
$supportUser = User::factory()->create();
|
||||
$supportUser->assignRole('Support');
|
||||
|
||||
$openStatus = App\Models\TicketStatus::where('name', 'open')->firstOrFail();
|
||||
|
||||
$ticket = App\Models\Ticket::create([
|
||||
'user_id' => $creator->id,
|
||||
'issue_category' => 'Technical Issue',
|
||||
'status_id' => $openStatus->id,
|
||||
'assigned_user_id' => $supportUser->id,
|
||||
]);
|
||||
|
||||
$service = app(TicketService::class);
|
||||
$tickets = $service->getListForUser($supportUser->id);
|
||||
|
||||
expect($tickets->contains('id', $ticket->id))->toBeTrue();
|
||||
});
|
||||
|
||||
test('commenting on a ticket notifies the creator and assigned user', function (): void {
|
||||
Notification::fake();
|
||||
|
||||
$creator = User::factory()->create();
|
||||
$creator->assignRole('user');
|
||||
|
||||
$supportUser = User::factory()->create();
|
||||
$supportUser->assignRole('Support');
|
||||
|
||||
$openStatus = App\Models\TicketStatus::where('name', 'open')->firstOrFail();
|
||||
|
||||
$ticket = App\Models\Ticket::create([
|
||||
'user_id' => $creator->id,
|
||||
'issue_category' => 'Billing Query',
|
||||
'status_id' => $openStatus->id,
|
||||
'assigned_user_id' => $supportUser->id,
|
||||
]);
|
||||
|
||||
// Another admin comments
|
||||
$admin = User::where('email', 'admin@example.com')->firstOrFail();
|
||||
|
||||
$dto = new App\Data\Ticket\PostReplyRequest(
|
||||
ticketId: $ticket->id,
|
||||
userId: $admin->id,
|
||||
message: 'This is an admin comment.',
|
||||
);
|
||||
|
||||
$service = app(TicketService::class);
|
||||
$service->postReply($dto);
|
||||
|
||||
// Both the creator and the support assignee should be notified of the comment
|
||||
Notification::assertSentTo(
|
||||
$creator,
|
||||
App\Notifications\TicketCommentedNotification::class
|
||||
);
|
||||
|
||||
Notification::assertSentTo(
|
||||
$supportUser,
|
||||
App\Notifications\TicketCommentedNotification::class
|
||||
);
|
||||
|
||||
// Commenter (admin) should not get notified
|
||||
Notification::assertNotSentTo(
|
||||
$admin,
|
||||
App\Notifications\TicketCommentedNotification::class
|
||||
);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user