diff --git a/Modules/Helpdesk/Helpdesk/Http/Controllers/TicketBoardController.php b/Modules/Helpdesk/Helpdesk/Http/Controllers/TicketBoardController.php new file mode 100644 index 00000000..17010cb4 --- /dev/null +++ b/Modules/Helpdesk/Helpdesk/Http/Controllers/TicketBoardController.php @@ -0,0 +1,119 @@ +validate([ + 'board_id' => ['required', 'integer', Rule::exists('helpdesk_kanban_boards', 'id')], + 'current_board_id' => ['nullable', 'integer', Rule::exists('helpdesk_kanban_boards', 'id')], + ]); + + $targetBoard = KanbanBoard::query() + ->with(['statuses', 'owner:id,name,email']) + ->findOrFail((int) $validated['board_id']); + + if ($targetBoard->statuses->isEmpty()) { + $this->ensureBoardHasStatuses($targetBoard); + $targetBoard->load(['statuses', 'owner:id,name,email']); + } + + $fresh = DB::transaction(function () use ($ticket, $targetBoard): Ticket { + /** @var Ticket $lockedTicket */ + $lockedTicket = Ticket::query() + ->visibleTo(Auth::user()) + ->with(['board', 'status']) + ->lockForUpdate() + ->findOrFail($ticket->id); + + $targetStatus = $targetBoard->statuses->firstWhere('id', (int) $lockedTicket->status_id) + ?: $targetBoard->statuses->first(); + + if ($targetStatus === null) { + $targetStatus = Status::query() + ->orderBy('sort_order') + ->orderBy('name') + ->firstOrFail(); + + $this->addStatusToBoard($targetBoard->id, $targetStatus->id); + } + + $lockedTicket->forceFill([ + 'board_id' => $targetBoard->id, + 'status_id' => $targetStatus->id, + 'completed_at' => $targetStatus->is_closed ? now() : null, + 'last_activity_at' => now(), + ])->save(); + + return $lockedTicket->fresh([ + 'status', + 'priority', + 'category', + 'project', + 'team', + 'product', + 'tags', + 'checklistItems', + 'board', + ]) ?? $lockedTicket; + }); + + $workflow->dispatch($fresh, 'ticket.updated', Auth::user()); + + return redirect() + ->route('helpdesk.kanban', ['board_id' => $targetBoard->id]) + ->with('success', 'Ticket ' . $fresh->reference . ' moved to ' . $targetBoard->name . '.'); + } + + private function addStatusToBoard(int $boardId, int $statusId): void + { + DB::table('helpdesk_kanban_board_statuses')->updateOrInsert( + ['board_id' => $boardId, 'status_id' => $statusId], + ['sort_order' => 999, 'updated_at' => now(), 'created_at' => now()], + ); + } + + private function ensureBoardHasStatuses(KanbanBoard $board): void + { + $statuses = Status::query() + ->orderBy('sort_order') + ->orderBy('name') + ->get(['id']); + + foreach ($statuses as $index => $status) { + DB::table('helpdesk_kanban_board_statuses')->updateOrInsert( + ['board_id' => $board->id, 'status_id' => $status->id], + [ + 'sort_order' => ($index + 1) * 10, + 'created_at' => now(), + 'updated_at' => now(), + ], + ); + } + } +} diff --git a/Modules/Helpdesk/Helpdesk/Livewire/KanbanBoard.php b/Modules/Helpdesk/Helpdesk/Livewire/KanbanBoard.php new file mode 100644 index 00000000..13dcf90f --- /dev/null +++ b/Modules/Helpdesk/Helpdesk/Livewire/KanbanBoard.php @@ -0,0 +1,294 @@ +query('board_id'); + + if (is_numeric($requestedBoardId)) { + $requestedBoardId = (int) $requestedBoardId; + + if (KanbanBoardModel::query()->whereKey($requestedBoardId)->exists()) { + $this->board_id = $requestedBoardId; + + return; + } + } + + $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 on the active board. + */ + public function moveTicket(int|string $ticketId, int|string $statusId, WorkflowNotificationService $workflow): void + { + if ($this->isMovingTicket) { + return; + } + + $this->isMovingTicket = true; + + try { + $ticket = Ticket::query()->visibleTo(Auth::user())->findOrFail((int) $ticketId); + $status = Status::query()->findOrFail((int) $statusId); + $board = $this->activeBoard(); + $oldStatusName = $ticket->status?->name ?? 'previous status'; + + $this->authorize('update', $ticket); + + if ($board !== null && ! $board->statuses()->whereKey($status->id)->exists()) { + $this->addStatusToBoard($board->id, $status->id); + } + + $ticket->forceFill([ + 'board_id' => $board?->id, + 'status_id' => $status->id, + 'completed_at' => $status->is_closed ? now() : null, + 'last_activity_at' => now(), + ])->save(); + + $freshTicket = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board']) ?? $ticket; + + $workflow->dispatch( + $freshTicket, + $status->is_closed ? 'ticket.closed' : 'ticket.updated', + Auth::user(), + ); + + $message = 'Ticket ' . $freshTicket->reference . ' moved from ' . $oldStatusName . ' to ' . $status->name . '.'; + session()->flash('success', $message); + + $this->refreshBoard(); + $this->dispatch('helpdesk-kanban-moved', ticketId: (int) $freshTicket->id, statusId: (int) $freshTicket->status_id); + $this->dispatch('wms:toast', type: 'success', title: 'Ticket moved', message: $message); + } finally { + $this->isMovingTicket = false; + } + } + + /** + * Move a ticket from its current board to another board. A ticket can only belong to one board. + */ + public function moveTicketToBoard(int|string $ticketId, int|string|null $boardId, WorkflowNotificationService $workflow): void + { + $ticket = Ticket::query()->with('board')->visibleTo(Auth::user())->findOrFail((int) $ticketId); + $board = KanbanBoardModel::query()->with(['statuses', 'owner:id,name,email'])->findOrFail((int) $boardId); + $oldBoardName = $ticket->board?->name ?? 'No board'; + + $this->authorize('update', $ticket); + + if (! $board->statuses->contains('id', $ticket->status_id)) { + $firstStatus = $board->statuses->first(); + + if ($firstStatus !== null) { + $ticket->status_id = $firstStatus->id; + $ticket->completed_at = $firstStatus->is_closed ? now() : null; + } else { + $this->addStatusToBoard($board->id, (int) $ticket->status_id); + } + } + + $ticket->forceFill([ + 'board_id' => $board->id, + 'last_activity_at' => now(), + ])->save(); + + $this->board_id = $board->id; + + $fresh = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'board']) ?? $ticket; + $workflow->dispatch($fresh, 'ticket.updated', Auth::user()); + + $message = 'Ticket ' . $fresh->reference . ' moved from ' . $oldBoardName . ' to ' . $board->name . '.'; + session()->flash('success', $message); + + $this->refreshBoard(); + $this->dispatch('helpdesk-kanban-moved', ticketId: (int) $fresh->id, statusId: (int) $fresh->status_id); + $this->dispatch('wms:toast', type: 'success', title: 'Board changed', message: $message); + } + + /** + * Force the board to fetch fresh database rows after moves or polling. + */ + #[On('helpdesk-kanban-refresh')] + public function refreshBoard(): void + { + $this->refreshNonce++; + unset($this->ticketsByStatus); + } + + /** + * Normalise the board selector value from Livewire/browser select events. + */ + public function updatedBoardId(int|string|null $value): void + { + $this->board_id = is_numeric($value) ? (int) $value : null; + $this->refreshBoard(); + } + + /** + * Switch the visible board without relying on DOM morph side effects. + */ + public function selectBoard(int|string|null $value): void + { + $this->board_id = is_numeric($value) ? (int) $value : null; + $this->refreshBoard(); + } + + /** + * Render the Kanban board. + */ + public function render(): View + { + $boards = KanbanBoardModel::query() + ->with(['statuses', 'owner:id,name,email']) + ->orderBy('sort_order') + ->orderBy('name') + ->get(); + + $board = $this->resolveSelectedBoard($boards); + $this->board_id = $board?->id; + + if ($board instanceof KanbanBoardModel && $board->statuses->isEmpty()) { + $this->ensureBoardHasStatuses($board); + $board->load(['statuses', 'owner:id,name,email']); + + $boards = KanbanBoardModel::query() + ->with(['statuses', 'owner:id,name,email']) + ->orderBy('sort_order') + ->orderBy('name') + ->get(); + } + + return view('helpdesk::livewire.kanban-board', [ + 'boards' => $boards, + 'board' => $board, + 'statuses' => $board?->statuses ?? collect(), + 'ticketsByStatus' => $this->ticketsByStatus($board), + 'refreshNonce' => $this->refreshNonce, + ])->layout('layouts.app'); + } + + /** + * Get visible tickets grouped by status for the selected board only. + * + * @return array> + */ + private function ticketsByStatus(?KanbanBoardModel $board): array + { + if ($board === null) { + return []; + } + + return Ticket::query() + ->with(['user:id,name,email', 'owner:id,name,email', 'agent:id,name,email', 'status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board']) + ->visibleTo(Auth::user()) + ->where('board_id', $board->id) + ->latest('last_activity_at') + ->limit(300) + ->get() + ->groupBy('status_id') + ->all(); + } + + /** + * Resolve the current board from the selected board ID. + */ + private function activeBoard(): ?KanbanBoardModel + { + if ($this->board_id !== null) { + return KanbanBoardModel::query()->find((int) $this->board_id); + } + + return KanbanBoardModel::query() + ->where('is_default', true) + ->orderBy('sort_order') + ->first() ?: KanbanBoardModel::query()->orderBy('sort_order')->first(); + } + + /** + * Resolve the selected board from an already-loaded board collection. + * + * @param Collection $boards + */ + private function resolveSelectedBoard(Collection $boards): ?KanbanBoardModel + { + if ($boards->isEmpty()) { + return null; + } + + if ($this->board_id !== null) { + $selected = $boards->firstWhere('id', (int) $this->board_id); + + if ($selected instanceof KanbanBoardModel) { + return $selected; + } + } + + $default = $boards->firstWhere('is_default', true); + + return $default instanceof KanbanBoardModel ? $default : $boards->first(); + } + + /** + * Attach a missing status to a board when a move requires it. + */ + private function addStatusToBoard(int $boardId, int $statusId): void + { + DB::table('helpdesk_kanban_board_statuses')->updateOrInsert( + ['board_id' => $boardId, 'status_id' => $statusId], + ['sort_order' => 999, 'updated_at' => now(), 'created_at' => now()], + ); + } + + /** + * Ensure a board has workflow columns. This keeps newly-created boards usable immediately. + */ + private function ensureBoardHasStatuses(KanbanBoardModel $board): void + { + if ($board->statuses()->exists()) { + return; + } + + $statuses = Status::query() + ->orderBy('sort_order') + ->orderBy('name') + ->get(['id']); + + foreach ($statuses as $index => $status) { + DB::table('helpdesk_kanban_board_statuses')->updateOrInsert( + ['board_id' => $board->id, 'status_id' => $status->id], + [ + 'sort_order' => ($index + 1) * 10, + 'created_at' => now(), + 'updated_at' => now(), + ], + ); + } + } +} diff --git a/Modules/Helpdesk/Helpdesk/Resources/views/livewire/kanban-board.blade.php b/Modules/Helpdesk/Helpdesk/Resources/views/livewire/kanban-board.blade.php new file mode 100644 index 00000000..e3dd773f --- /dev/null +++ b/Modules/Helpdesk/Helpdesk/Resources/views/livewire/kanban-board.blade.php @@ -0,0 +1,349 @@ +@vite(['resources/css/app.css']) + +
+
+
+
+
+

Helpdesk workflow

+

Kanban board

+

Drag tickets between board columns. Boards and + columns are configured in Helpdesk Settings.

+
+ + +
+
+
+ @if ($boards->isEmpty()) +
+ No Kanban boards exist yet. Open Helpdesk Settings and create a board with at least one status column. +
+ @else +
+
+

Active board: {{ $board?->name ?? 'None' }}

+

+ {{ $board?->description ?: 'Use settings to configure which statuses appear as draggable columns.' }} +

+ @if ($board?->owner) +

Owner: + {{ $board->owner->name }}

+ @endif +
+ +
+ +
+
+ + @if ($statuses->isEmpty()) +
+ This board is being initialised with the default workflow statuses. Refresh the page if the columns do + not appear immediately. +
+ @else +
+ @foreach ($statuses as $status) + @php($tickets = $ticketsByStatus[$status->id] ?? collect()) + +
+
+
+
+

+ {{ $status->name }}

+

{{ $tickets->count() }} + tickets

+
+ +
+
+ +
+ @forelse ($tickets as $ticket) +
+
+
+
+ {{ $ticket->reference }}
+

+ {{ $ticket->subject }}

+
+ + @if ($ticket->priority) + + {{ $ticket->priority->name }} + + @endif +
+ +
+
{{ $ticket->owner?->name ?? 'Unknown' }}
+
{{ $ticket->agent?->name ?? 'Unassigned' }} +
+ @if ($ticket->project) +
+ {{ $ticket->project->title }}
+ @endif + @if ($ticket->team) +
{{ $ticket->team->name }}
+ @endif + @if ($ticket->product) +
{{ $ticket->product->name }}
+ @endif +
+ + +
+ @csrf + + +
+ +
+
+ @if ($ticket->tags->isNotEmpty()) +
+ @foreach ($ticket->tags as $tag) + {{ $tag->name }} + @endforeach +
+ @endif + + @if ($ticket->checklistItems->isNotEmpty()) + @php($done = $ticket->checklistItems->where('is_completed', true)->count()) +
Checklist + {{ $done }}/{{ $ticket->checklistItems->count() }}
+ @endif + + @if ($ticket->sla_first_response_breached || $ticket->sla_resolution_breached) +
+ SLA breach
+ @endif + +
+ Open + @can('update', $ticket) + Edit + @endcan +
+
+ @empty +
+ Drop tickets here for {{ $status->name }}. +
+ @endforelse +
+
+ @endforeach +
+ @endif + @endif + + @script + + @endscript +
diff --git a/Modules/Helpdesk/Helpdesk/Routes/web.php b/Modules/Helpdesk/Helpdesk/Routes/web.php new file mode 100644 index 00000000..353f833a --- /dev/null +++ b/Modules/Helpdesk/Helpdesk/Routes/web.php @@ -0,0 +1,52 @@ +name('workflow.open'); + +Route::get('/', HelpdeskController::class)->name('index'); + +Route::get('/settings', SettingsManager::class)->name('settings'); +Route::get('/kanban', KanbanBoard::class)->name('kanban'); + +Route::get('/tickets', TicketIndex::class)->name('tickets.index'); +Route::post('/tickets', TicketStoreController::class)->name('tickets.store'); +Route::get('/tickets/create', TicketCreate::class)->name('tickets.create'); +Route::get('/tickets/{ticket}/edit', TicketEdit::class)->name('tickets.edit'); +Route::post('/tickets/{ticket}/agent', [TicketControlsController::class, 'updateAgent'])->name('tickets.agent.update'); +Route::post('/tickets/{ticket}/status', [TicketControlsController::class, 'updateStatus'])->name('tickets.status.update'); +Route::post('/tickets/{ticket}/priority', [TicketControlsController::class, 'updatePriority'])->name('tickets.priority.update'); +Route::post('/tickets/{ticket}/board', TicketBoardController::class)->name('tickets.board.update'); +Route::post('/tickets/{ticket}/comments', TicketCommentStoreController::class)->name('tickets.comments.store'); +Route::post('/tickets/{ticket}/attachments', TicketAttachmentStoreController::class)->name('tickets.attachments.store'); +Route::delete('/tickets/{ticket}/comments/{comment}', TicketCommentDeleteController::class)->name('tickets.comments.destroy'); +Route::get('/tickets/{ticket}', TicketShow::class)->name('tickets.show'); +Route::get('/attachments/{attachment}/download', AttachmentDownloadController::class) + ->whereNumber('attachment') + ->name('attachments.download'); +Route::get('/attachments/{attachment}/preview', [AttachmentDownloadController::class, 'preview']) + ->whereNumber('attachment') + ->name('attachments.preview'); +Route::delete('/attachments/{attachment}', [AttachmentDownloadController::class, 'destroy']) + ->whereNumber('attachment') + ->name('attachments.destroy'); +Route::post('/tickets/{ticket}/time-logs', [TicketTimeLogController::class, 'store']) + ->name('tickets.time-logs.store'); diff --git a/Modules/Helpdesk/Http/Controllers/TicketBoardController.php b/Modules/Helpdesk/Http/Controllers/TicketBoardController.php new file mode 100644 index 00000000..965b966e --- /dev/null +++ b/Modules/Helpdesk/Http/Controllers/TicketBoardController.php @@ -0,0 +1,105 @@ +validate([ + 'board_id' => ['required', 'integer', Rule::exists('helpdesk_kanban_boards', 'id')], + 'return_to_board_id' => ['nullable', 'integer', Rule::exists('helpdesk_kanban_boards', 'id')], + ]); + + $targetBoardId = (int) $validated['board_id']; + + $targetBoard = KanbanBoard::query() + ->with(['statuses' => fn ($query) => $query->orderBy('helpdesk_kanban_board_statuses.sort_order')->orderBy('helpdesk_statuses.sort_order')->orderBy('helpdesk_statuses.name')]) + ->findOrFail($targetBoardId); + + if ($targetBoard->statuses->isEmpty()) { + $this->ensureBoardHasStatuses($targetBoardId); + $targetBoard->load(['statuses' => fn ($query) => $query->orderBy('helpdesk_kanban_board_statuses.sort_order')->orderBy('helpdesk_statuses.sort_order')->orderBy('helpdesk_statuses.name')]); + } + + $fresh = DB::transaction(function () use ($ticket, $targetBoard): Ticket { + $lockedTicket = Ticket::query() + ->visibleTo(Auth::user()) + ->lockForUpdate() + ->findOrFail($ticket->id); + + $targetStatus = $targetBoard->statuses->firstWhere('id', (int) $lockedTicket->status_id) + ?: $targetBoard->statuses->first(); + + if ($targetStatus === null) { + $targetStatus = Status::query()->orderBy('sort_order')->orderBy('name')->firstOrFail(); + $this->addStatusToBoard($targetBoard->id, $targetStatus->id); + } + + $updates = [ + 'board_id' => $targetBoard->id, + 'status_id' => $targetStatus->id, + 'completed_at' => $targetStatus->is_closed ? now() : null, + 'last_activity_at' => now(), + ]; + + if (DB::getSchemaBuilder()->hasColumn('helpdesk_tickets', 'updated_at')) { + $updates['updated_at'] = now(); + } + + $affected = DB::table('helpdesk_tickets') + ->where('id', $lockedTicket->id) + ->update($updates); + + if ($affected < 1) { + throw new \RuntimeException('Ticket board update did not persist.'); + } + + return Ticket::query() + ->with(['status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board']) + ->findOrFail($lockedTicket->id); + }); + + $workflow->dispatch($fresh, 'ticket.updated', Auth::user()); + + return redirect() + ->route('helpdesk.kanban', ['board_id' => $targetBoardId]) + ->with('success', 'Ticket ' . $fresh->reference . ' moved to ' . $targetBoard->name . '.'); + } + + private function ensureBoardHasStatuses(int $boardId): void + { + Status::query() + ->orderBy('sort_order') + ->orderBy('name') + ->get(['id']) + ->values() + ->each(function (Status $status, int $index) use ($boardId): void { + $this->addStatusToBoard($boardId, (int) $status->id, ($index + 1) * 10); + }); + } + + private function addStatusToBoard(int $boardId, int $statusId, int $sortOrder = 999): void + { + DB::table('helpdesk_kanban_board_statuses')->updateOrInsert( + ['board_id' => $boardId, 'status_id' => $statusId], + ['sort_order' => $sortOrder, 'updated_at' => now(), 'created_at' => now()], + ); + } +} diff --git a/Modules/Helpdesk/Livewire/KanbanBoard.php b/Modules/Helpdesk/Livewire/KanbanBoard.php index 2331a590..13dcf90f 100644 --- a/Modules/Helpdesk/Livewire/KanbanBoard.php +++ b/Modules/Helpdesk/Livewire/KanbanBoard.php @@ -94,59 +94,42 @@ final class KanbanBoard extends Component /** * Move a ticket from its current board to another board. A ticket can only belong to one board. - * - * This intentionally redirects to the target board after the update so the browser, - * Livewire state and database state cannot fall back to the default board. */ - public function moveTicketToBoard(int|string $ticketId, int|string|null $boardId, WorkflowNotificationService $workflow) + public function moveTicketToBoard(int|string $ticketId, int|string|null $boardId, WorkflowNotificationService $workflow): void { - if (! is_numeric($ticketId) || ! is_numeric($boardId)) { - return; - } + $ticket = Ticket::query()->with('board')->visibleTo(Auth::user())->findOrFail((int) $ticketId); + $board = KanbanBoardModel::query()->with(['statuses', 'owner:id,name,email'])->findOrFail((int) $boardId); + $oldBoardName = $ticket->board?->name ?? 'No board'; - $board = KanbanBoardModel::query() - ->with(['statuses' => fn ($query) => $query->orderBy('helpdesk_kanban_board_statuses.sort_order')->orderBy('helpdesk_statuses.sort_order'), 'owner:id,name,email']) - ->findOrFail((int) $boardId); + $this->authorize('update', $ticket); - if ($board->statuses->isEmpty()) { - $this->ensureBoardHasStatuses($board); - $board->load(['statuses' => fn ($query) => $query->orderBy('helpdesk_kanban_board_statuses.sort_order')->orderBy('helpdesk_statuses.sort_order'), 'owner:id,name,email']); - } + if (! $board->statuses->contains('id', $ticket->status_id)) { + $firstStatus = $board->statuses->first(); - $fresh = DB::transaction(function () use ($ticketId, $board) { - $ticket = Ticket::query() - ->with(['board', 'status']) - ->visibleTo(Auth::user()) - ->lockForUpdate() - ->findOrFail((int) $ticketId); - - $this->authorize('update', $ticket); - - $targetStatus = $board->statuses->firstWhere('id', (int) $ticket->status_id) - ?: $board->statuses->first(); - - if ($targetStatus === null) { + if ($firstStatus !== null) { + $ticket->status_id = $firstStatus->id; + $ticket->completed_at = $firstStatus->is_closed ? now() : null; + } else { $this->addStatusToBoard($board->id, (int) $ticket->status_id); - $targetStatus = $ticket->status; } - - $ticket->forceFill([ - 'board_id' => $board->id, - 'status_id' => $targetStatus?->id ?? $ticket->status_id, - 'completed_at' => $targetStatus?->is_closed ? now() : null, - 'last_activity_at' => now(), - ])->save(); - - return $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board']); - }); - - if ($fresh instanceof Ticket) { - $workflow->dispatch($fresh, 'ticket.updated', Auth::user()); - - session()->flash('success', 'Ticket ' . $fresh->reference . ' moved to ' . $board->name . '.'); } - return redirect()->route('helpdesk.kanban', ['board_id' => $board->id]); + $ticket->forceFill([ + 'board_id' => $board->id, + 'last_activity_at' => now(), + ])->save(); + + $this->board_id = $board->id; + + $fresh = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'board']) ?? $ticket; + $workflow->dispatch($fresh, 'ticket.updated', Auth::user()); + + $message = 'Ticket ' . $fresh->reference . ' moved from ' . $oldBoardName . ' to ' . $board->name . '.'; + session()->flash('success', $message); + + $this->refreshBoard(); + $this->dispatch('helpdesk-kanban-moved', ticketId: (int) $fresh->id, statusId: (int) $fresh->status_id); + $this->dispatch('wms:toast', type: 'success', title: 'Board changed', message: $message); } /** diff --git a/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php b/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php index d99431eb..3a6dd023 100644 --- a/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php @@ -153,12 +153,22 @@ -
+
+ @csrf +
-