major ui updates

This commit is contained in:
Mark Gregory
2026-06-12 15:11:51 +01:00
parent 3c9b39d83b
commit 7bc8f20ed5
154 changed files with 3796 additions and 4117 deletions
@@ -12,7 +12,6 @@ use Modules\Helpdesk\Models\Ticket;
final class HelpdeskDashboardWidget extends Component
{
/** @return View */
public function render(): View
{
$user = Auth::user();
+30 -15
View File
@@ -8,26 +8,32 @@ use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use Modules\Helpdesk\Models\KanbanBoard as KanbanBoardModel;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Services\WorkflowNotificationService;
final class KanbanBoard extends Component
{
public ?int $board_id = null;
public function mount(): void
{
$this->board_id = KanbanBoardModel::query()
->where('is_default', true)
->orderBy('sort_order')
->value('id') ?: KanbanBoardModel::query()->orderBy('sort_order')->value('id');
}
/**
* Move a ticket to another workflow status.
*
* @param int $ticketId
* @param int $statusId
* @param WorkflowNotificationService $workflow
* @return void
*/
public function moveTicket(int $ticketId, int $statusId, WorkflowNotificationService $workflow): void
public function moveTicket(int|string $ticketId, int|string $statusId, WorkflowNotificationService $workflow): void
{
$ticket = Ticket::query()->visibleTo(Auth::user())->findOrFail($ticketId);
$status = Status::query()->findOrFail($statusId);
$ticket = Ticket::query()->visibleTo(Auth::user())->findOrFail((int) $ticketId);
$status = Status::query()->findOrFail((int) $statusId);
$oldStatus = $ticket->status?->name;
$this->authorize('update', $ticket);
$ticket->forceFill([
'status_id' => $status->id,
@@ -36,8 +42,8 @@ final class KanbanBoard extends Component
])->save();
$workflow->dispatch(
$ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team']),
$status->is_closed ? 'ticket_closed' : 'ticket_updated',
$ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team']) ?? $ticket,
$status->is_closed ? 'ticket.closed' : 'ticket.updated',
Auth::user()
);
@@ -46,13 +52,22 @@ final class KanbanBoard extends Component
/**
* Render the Kanban board.
*
* @return View
*/
public function render(): View
{
$boards = KanbanBoardModel::query()
->with('statuses')
->orderBy('sort_order')
->orderBy('name')
->get();
$board = $boards->firstWhere('id', $this->board_id) ?? $boards->first();
$statuses = $board?->statuses ?? Status::query()->orderBy('sort_order')->orderBy('name')->get();
return view('helpdesk::livewire.kanban-board', [
'statuses' => Status::query()->orderBy('sort_order')->get(),
'boards' => $boards,
'board' => $board,
'statuses' => $statuses,
'ticketsByStatus' => $this->ticketsByStatus(),
])->layout('layouts.app');
}
@@ -68,7 +83,7 @@ final class KanbanBoard extends Component
->with(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team'])
->visibleTo(Auth::user())
->latest('last_activity_at')
->limit(200)
->limit(300)
->get()
->groupBy('status_id')
->all();
@@ -42,7 +42,6 @@ final class SettingsManager extends Component
/** @var array<string, mixed> */
public array $workflowForm = ['id' => null, 'name' => '', 'event' => 'ticket.created', 'subject' => '', 'html_body' => '', 'text_body' => '', 'to_role' => 'requester', 'cc' => '', 'bcc' => '', 'is_active' => true];
/** @return void */
public function saveStatus(): void
{
$data = $this->validate([
@@ -59,27 +58,23 @@ final class SettingsManager extends Component
session()->flash('success', 'Status saved.');
}
/** @return void */
public function editStatus(int $id): void
{
$this->statusForm = Status::query()->findOrFail($id)->only(['id', 'name', 'slug', 'color', 'sort_order', 'is_closed']);
$this->section = 'statuses';
}
/** @return void */
public function deleteStatus(int $id): void
{
Status::query()->findOrFail($id)->delete();
session()->flash('success', 'Status deleted.');
}
/** @return void */
public function resetStatusForm(): void
{
$this->statusForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0, 'is_closed' => false];
}
/** @return void */
public function savePriority(): void
{
$data = $this->validate([
@@ -95,27 +90,23 @@ final class SettingsManager extends Component
session()->flash('success', 'Priority saved.');
}
/** @return void */
public function editPriority(int $id): void
{
$this->priorityForm = Priority::query()->findOrFail($id)->only(['id', 'name', 'slug', 'color', 'sort_order']);
$this->section = 'priorities';
}
/** @return void */
public function deletePriority(int $id): void
{
Priority::query()->findOrFail($id)->delete();
session()->flash('success', 'Priority deleted.');
}
/** @return void */
public function resetPriorityForm(): void
{
$this->priorityForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0];
}
/** @return void */
public function saveCategory(): void
{
$data = $this->validate([
@@ -132,27 +123,23 @@ final class SettingsManager extends Component
session()->flash('success', 'Category saved.');
}
/** @return void */
public function editCategory(int $id): void
{
$this->categoryForm = Category::query()->findOrFail($id)->only(['id', 'company_id', 'name', 'slug', 'color', 'description']);
$this->section = 'categories';
}
/** @return void */
public function deleteCategory(int $id): void
{
Category::query()->findOrFail($id)->delete();
session()->flash('success', 'Category deleted.');
}
/** @return void */
public function resetCategoryForm(): void
{
$this->categoryForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => ''];
}
/** @return void */
public function saveTeam(): void
{
$data = $this->validate([
@@ -169,27 +156,23 @@ final class SettingsManager extends Component
session()->flash('success', 'Team saved.');
}
/** @return void */
public function editTeam(int $id): void
{
$this->teamForm = Team::query()->findOrFail($id)->only(['id', 'company_id', 'name', 'slug', 'color', 'description', 'archived_at']);
$this->section = 'teams';
}
/** @return void */
public function deleteTeam(int $id): void
{
Team::query()->findOrFail($id)->delete();
session()->flash('success', 'Team deleted.');
}
/** @return void */
public function resetTeamForm(): void
{
$this->teamForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'archived_at' => null];
}
/** @return void */
public function saveProject(): void
{
$data = $this->validate([
@@ -207,28 +190,23 @@ final class SettingsManager extends Component
session()->flash('success', 'Project saved.');
}
/** @return void */
public function editProject(int $id): void
{
$this->projectForm = Project::query()->findOrFail($id)->only(['id', 'company_id', 'team_id', 'title', 'slug', 'color', 'description', 'archived_at']);
$this->section = 'projects';
}
/** @return void */
public function deleteProject(int $id): void
{
Project::query()->findOrFail($id)->delete();
session()->flash('success', 'Project deleted.');
}
/** @return void */
public function resetProjectForm(): void
{
$this->projectForm = ['id' => null, 'company_id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'archived_at' => null];
}
/** @return void */
public function saveRepository(): void
{
$data = $this->validate([
@@ -246,28 +224,23 @@ final class SettingsManager extends Component
session()->flash('success', 'Git repository saved.');
}
/** @return void */
public function editRepository(int $id): void
{
$this->repositoryForm = GitRepository::query()->findOrFail($id)->only(['id', 'company_id', 'project_id', 'name', 'provider', 'remote_url', 'default_branch', 'webhook_secret']);
$this->section = 'git';
}
/** @return void */
public function deleteRepository(int $id): void
{
GitRepository::query()->findOrFail($id)->delete();
session()->flash('success', 'Git repository deleted.');
}
/** @return void */
public function resetRepositoryForm(): void
{
$this->repositoryForm = ['id' => null, 'company_id' => null, 'project_id' => null, 'name' => '', 'provider' => 'git', 'remote_url' => '', 'default_branch' => 'main', 'webhook_secret' => ''];
}
/** @return void */
public function saveWorkflowTemplate(): void
{
$data = $this->validate([
@@ -287,27 +260,23 @@ final class SettingsManager extends Component
session()->flash('success', 'Workflow message saved.');
}
/** @return void */
public function editWorkflowTemplate(int $id): void
{
$this->workflowForm = WorkflowTemplate::query()->findOrFail($id)->only(['id', 'name', 'event', 'subject', 'html_body', 'text_body', 'to_role', 'cc', 'bcc', 'is_active']);
$this->section = 'workflow';
}
/** @return void */
public function deleteWorkflowTemplate(int $id): void
{
WorkflowTemplate::query()->findOrFail($id)->delete();
session()->flash('success', 'Workflow message deleted.');
}
/** @return void */
public function resetWorkflowTemplateForm(): void
{
$this->workflowForm = ['id' => null, 'name' => '', 'event' => 'ticket.created', 'subject' => '', 'html_body' => '', 'text_body' => '', 'to_role' => 'requester', 'cc' => '', 'bcc' => '', 'is_active' => true];
}
/** @return View */
public function render(): View
{
return view('helpdesk::livewire.settings-manager', [
+12 -21
View File
@@ -26,9 +26,6 @@ final class TicketEdit extends Component
#[Validate('nullable|integer')]
public ?int $company_id = null;
#[Validate('nullable|integer|exists:users,id')]
public ?int $agent_id = null;
#[Validate('required|string|max:200')]
public string $subject = '';
@@ -50,19 +47,18 @@ final class TicketEdit extends Component
#[Validate('nullable|integer|exists:helpdesk_teams,id')]
public ?int $team_id = null;
#[Validate('nullable|integer|exists:users,id')]
public ?int $agent_id = null;
#[Validate('nullable|date')]
public ?string $due_at = null;
/**
* Populate editable ticket state.
*/
public function mount(Ticket $ticket): void
{
$this->authorize('update', $ticket);
$this->ticket = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'agent']) ?? $ticket;
$this->company_id = $this->ticket->company_id;
$this->agent_id = $this->ticket->agent_id;
$this->subject = $this->ticket->subject;
$this->body = $this->ticket->body;
$this->status_id = $this->ticket->status_id;
@@ -70,12 +66,10 @@ final class TicketEdit extends Component
$this->category_id = $this->ticket->category_id;
$this->project_id = $this->ticket->project_id;
$this->team_id = $this->ticket->team_id;
$this->agent_id = $this->ticket->agent_id;
$this->due_at = $this->ticket->due_at?->format('Y-m-d');
}
/**
* Save ticket changes.
*/
public function save(WorkflowNotificationService $workflow): mixed
{
$this->authorize('update', $this->ticket);
@@ -85,7 +79,6 @@ final class TicketEdit extends Component
$this->ticket->forceFill([
'company_id' => $this->company_id,
'agent_id' => $this->agent_id,
'subject' => $this->subject,
'body' => $this->body,
'status_id' => $status->id,
@@ -93,27 +86,25 @@ final class TicketEdit extends Component
'category_id' => $this->category_id,
'project_id' => $this->project_id,
'team_id' => $this->team_id,
'due_at' => $this->due_at ?: null,
'agent_id' => $this->agent_id,
'due_at' => $this->due_at,
'completed_at' => $status->is_closed ? now() : null,
'last_activity_at' => now(),
])->save();
$this->ticket = $this->ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team']) ?? $this->ticket;
$workflow->dispatch($this->ticket, $status->is_closed ? 'ticket.closed' : 'ticket.updated', Auth::user());
$fresh = $this->ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team']) ?? $this->ticket;
$workflow->dispatch($fresh, $status->is_closed ? 'ticket.closed' : 'ticket.updated', Auth::user());
session()->flash('success', 'Ticket ' . $this->ticket->reference . ' has been updated.');
session()->flash('success', 'Ticket ' . $fresh->reference . ' has been updated.');
return redirect()->route('helpdesk.tickets.show', $this->ticket);
return redirect()->route('helpdesk.tickets.show', $fresh);
}
/**
* Render the ticket edit screen.
*/
public function render(): View
{
return view('helpdesk::livewire.ticket-edit', [
'statuses' => Status::query()->orderBy('sort_order')->get(),
'priorities' => Priority::query()->orderBy('sort_order')->get(),
'statuses' => Status::query()->orderBy('sort_order')->orderBy('name')->get(),
'priorities' => Priority::query()->orderBy('sort_order')->orderBy('name')->get(),
'categories' => Category::query()->orderBy('name')->get(),
'companies' => Schema::hasTable('companies') ? DB::table('companies')->select(['id', 'name'])->orderBy('name')->get() : collect(),
'teams' => Team::query()->whereNull('archived_at')->orderBy('name')->get(),
+3 -7
View File
@@ -33,8 +33,6 @@ final class TicketIndex extends Component
/**
* Reset pagination when filters change.
*
* @return void
*/
public function updated(): void
{
@@ -43,8 +41,6 @@ final class TicketIndex extends Component
/**
* Render the ticket list.
*
* @return View
*/
public function render(): View
{
@@ -73,9 +69,9 @@ final class TicketIndex extends Component
->when($this->priority !== '', fn (Builder $query): Builder => $query->where('priority_id', (int) $this->priority))
->when($this->search !== '', function (Builder $query): Builder {
return $query->where(static function (Builder $subQuery): void {
$subQuery->where('reference', 'like', '%' . $this->search . '%')
->orWhere('subject', 'like', '%' . $this->search . '%')
->orWhere('body', 'like', '%' . $this->search . '%');
$subQuery->where('reference', 'like', '%'.$this->search.'%')
->orWhere('subject', 'like', '%'.$this->search.'%')
->orWhere('body', 'like', '%'.$this->search.'%');
});
})
->latest('last_activity_at')
+24 -22
View File
@@ -12,7 +12,6 @@ use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Ticket;
@@ -145,36 +144,39 @@ final class TicketShow extends Component
'team',
'attachments.user',
'gitCommits.repository',
'comments.attachments',
'comments.replies.attachments',
'comments.replies.replies.attachments',
]) ?? $ticket;
$comments = Comment::query()
->where('ticket_id', $ticket->id)
->with(['user', 'attachments'])
->oldest()
->get();
$comments = $ticket->comments
->merge($ticket->comments->flatMap->replies)
->merge($ticket->comments->flatMap->replies->flatMap->replies);
$grouped = $comments->groupBy('parent_id');
$users = User::query()
->whereIn('id', $comments->pluck('user_id')->filter()->unique())
->get()
->keyBy('id');
$comments->each(function ($comment) use ($grouped): void {
$comment->setRelation('replies', $grouped->get($comment->id, collect()));
});
$ticket->setRelation('comments', $this->buildCommentTree($grouped));
$this->attachCommentUsers($ticket->comments, $users);
return $ticket;
}
private function buildCommentTree($grouped, ?int $parentId = null)
/**
* Attach loaded users to comment hierarchy without triggering lazy loading.
*
* @param Collection<int, mixed> $comments
* @param Collection<int, User> $users
*/
private function attachCommentUsers(Collection $comments, Collection $users): void
{
foreach ($comments as $comment) {
$comment->setRelation('user', $users->get($comment->user_id));
return $grouped->get($parentId, collect())->map(function ($comment) use ($grouped) {
$comment->setRelation('replies', $this->buildCommentTree($grouped, $comment->id));
return $comment;
});
if ($comment->relationLoaded('replies')) {
$this->attachCommentUsers($comment->replies, $users);
}
}
}
}