v1.1.3
- Triggers (Admin > Wyzwalacze): event-driven rules that fire immediately on a ticket lifecycle event (created/updated/status/priority/assignee/team/ category changed, new reply), with AND-conditions and ordered actions (set status/priority/team/assignee, send e-mail). Ships its own dedicated, freely add/edit/delete-able e-mail templates, kept separate from the fixed system templates. - Ticket watching: operators can star/"Obserwuj" any ticket to follow it regardless of assignment/team. - Real-time notification bell (private per-user broadcast channel, 30s fallback poll) with an opt-in in-tab browser push notification. - Per-user notification preferences (/settings/notifications): scope (mine/unassigned/watched/all) and e-mail toggle per event category. - Admin > Integracje: new tab for LDAP/AD + BookStack config, split out of Konfiguracja. - Operator queue: Podkategoria/Zespół/Utworzono columns (off by default). - Obserwuj button moved next to the auto-refresh countdown; trigger condition builder shows subcategory/zgłaszający as name dropdowns instead of raw IDs; /settings/notifications got a back link, full-width push card, and a bordered table container; admin panel tab and operator queue view now persist across a plain page refresh. - Docs: README/ARCHITECTURE/wiki updated for all of the above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use LdapRecord\Connection;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
@@ -33,6 +34,7 @@ class Panel extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
#[Url]
|
||||
public string $tab = 'categories';
|
||||
|
||||
// ---- categories ----
|
||||
|
||||
342
src/app/Livewire/Admin/Triggers.php
Normal file
342
src/app/Livewire/Admin/Triggers.php
Normal file
@@ -0,0 +1,342 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\Priority;
|
||||
use App\Models\Status;
|
||||
use App\Models\Subcategory;
|
||||
use App\Models\Team;
|
||||
use App\Models\Trigger;
|
||||
use App\Models\TriggerEmailTemplate;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class Triggers extends Component
|
||||
{
|
||||
public bool $formOpen = false;
|
||||
|
||||
public ?int $editingId = null;
|
||||
|
||||
public array $form = [
|
||||
'name' => '',
|
||||
'enabled' => true,
|
||||
'event' => 'ticket_created',
|
||||
'conditions' => [],
|
||||
'actions' => [],
|
||||
];
|
||||
|
||||
public bool $templateFormOpen = false;
|
||||
|
||||
public ?int $editingTemplateId = null;
|
||||
|
||||
public array $templateForm = ['name' => '', 'subject' => '', 'body' => ''];
|
||||
|
||||
public static function eventLabels(): array
|
||||
{
|
||||
return [
|
||||
'ticket_created' => 'Zgłoszenie utworzone',
|
||||
'ticket_updated' => 'Zgłoszenie zaktualizowane (dowolne pole)',
|
||||
'status_changed' => 'Zmiana statusu',
|
||||
'priority_changed' => 'Zmiana priorytetu',
|
||||
'assignee_changed' => 'Zmiana przypisanego operatora',
|
||||
'team_changed' => 'Zmiana zespołu',
|
||||
'category_changed' => 'Zmiana kategorii',
|
||||
'comment_added' => 'Nowa wiadomość (publiczna)',
|
||||
];
|
||||
}
|
||||
|
||||
public static function fieldLabels(): array
|
||||
{
|
||||
return [
|
||||
'status_key' => 'Status',
|
||||
'priority_key' => 'Priorytet',
|
||||
'team_id' => 'Zespół',
|
||||
'subcategory_id' => 'Podkategoria',
|
||||
'assignee_id' => 'Operator przypisany',
|
||||
'customer_id' => 'Zgłaszający',
|
||||
'subject' => 'Temat',
|
||||
'body' => 'Treść',
|
||||
];
|
||||
}
|
||||
|
||||
public static function operatorLabels(): array
|
||||
{
|
||||
return [
|
||||
'equals' => 'jest równe',
|
||||
'not_equals' => 'jest różne od',
|
||||
'is_empty' => 'jest puste',
|
||||
'is_not_empty' => 'nie jest puste',
|
||||
'contains' => 'zawiera',
|
||||
];
|
||||
}
|
||||
|
||||
public static function actionTypeLabels(): array
|
||||
{
|
||||
return [
|
||||
'set_status' => 'Ustaw status',
|
||||
'set_priority' => 'Ustaw priorytet',
|
||||
'set_team' => 'Ustaw zespół',
|
||||
'set_assignee' => 'Ustaw operatora',
|
||||
'send_notification' => 'Wyślij powiadomienie e-mail',
|
||||
];
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function triggers(): Collection
|
||||
{
|
||||
return Trigger::query()->orderBy('sort_order')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function statuses(): Collection
|
||||
{
|
||||
return Status::query()->orderBy('sort_order')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function priorities(): Collection
|
||||
{
|
||||
return Priority::query()->orderBy('sort_order')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function teams(): Collection
|
||||
{
|
||||
return Team::query()->orderBy('name')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function operators(): Collection
|
||||
{
|
||||
return User::query()->whereHas('roleAssignments', fn ($q) => $q->whereIn('key', ['operator', 'admin']))->orderBy('name')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function subcategories(): Collection
|
||||
{
|
||||
return Subcategory::query()->with('category')->get()
|
||||
->sortBy(fn (Subcategory $s) => $s->category->name.' / '.$s->name, SORT_NATURAL | SORT_FLAG_CASE)
|
||||
->values();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function customers(): Collection
|
||||
{
|
||||
return User::query()->whereHas('roleAssignments', fn ($q) => $q->where('key', 'client'))->orderBy('name')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function emailTemplates(): Collection
|
||||
{
|
||||
return TriggerEmailTemplate::query()->orderBy('name')->get();
|
||||
}
|
||||
|
||||
public function openForm(): void
|
||||
{
|
||||
$this->editingId = null;
|
||||
$this->form = ['name' => '', 'enabled' => true, 'event' => 'ticket_created', 'conditions' => [], 'actions' => []];
|
||||
$this->resetErrorBag();
|
||||
$this->formOpen = true;
|
||||
}
|
||||
|
||||
public function editTrigger(int $id): void
|
||||
{
|
||||
$trigger = Trigger::query()->findOrFail($id);
|
||||
|
||||
$this->editingId = $trigger->id;
|
||||
$this->form = [
|
||||
'name' => $trigger->name,
|
||||
'enabled' => $trigger->enabled,
|
||||
'event' => $trigger->event,
|
||||
'conditions' => $trigger->conditions,
|
||||
'actions' => $trigger->actions,
|
||||
];
|
||||
$this->resetErrorBag();
|
||||
$this->formOpen = true;
|
||||
}
|
||||
|
||||
public function closeForm(): void
|
||||
{
|
||||
$this->formOpen = false;
|
||||
}
|
||||
|
||||
public function addCondition(): void
|
||||
{
|
||||
$this->form['conditions'][] = ['field' => Trigger::CONDITION_FIELDS[0], 'operator' => 'equals', 'value' => ''];
|
||||
}
|
||||
|
||||
public function removeCondition(int $index): void
|
||||
{
|
||||
unset($this->form['conditions'][$index]);
|
||||
$this->form['conditions'] = array_values($this->form['conditions']);
|
||||
}
|
||||
|
||||
public function addAction(): void
|
||||
{
|
||||
$this->form['actions'][] = ['type' => Trigger::ACTION_TYPES[0], 'value' => '', 'recipient' => 'client', 'email_template_id' => ''];
|
||||
}
|
||||
|
||||
public function removeAction(int $index): void
|
||||
{
|
||||
unset($this->form['actions'][$index]);
|
||||
$this->form['actions'] = array_values($this->form['actions']);
|
||||
}
|
||||
|
||||
public function moveActionUp(int $index): void
|
||||
{
|
||||
$this->swapFormActions($index, $index - 1);
|
||||
}
|
||||
|
||||
public function moveActionDown(int $index): void
|
||||
{
|
||||
$this->swapFormActions($index, $index + 1);
|
||||
}
|
||||
|
||||
protected function swapFormActions(int $a, int $b): void
|
||||
{
|
||||
if ($b < 0 || $b >= count($this->form['actions'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
[$this->form['actions'][$a], $this->form['actions'][$b]] = [$this->form['actions'][$b], $this->form['actions'][$a]];
|
||||
}
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
$this->validate([
|
||||
'form.name' => ['required', 'string', 'max:255'],
|
||||
'form.event' => ['required', 'string', 'in:'.implode(',', Trigger::EVENTS)],
|
||||
'form.conditions' => ['array'],
|
||||
'form.conditions.*.field' => ['required', 'string', 'in:'.implode(',', Trigger::CONDITION_FIELDS)],
|
||||
'form.conditions.*.operator' => ['required', 'string', 'in:'.implode(',', Trigger::CONDITION_OPERATORS)],
|
||||
'form.actions' => ['required', 'array', 'min:1'],
|
||||
'form.actions.*.type' => ['required', 'string', 'in:'.implode(',', Trigger::ACTION_TYPES)],
|
||||
]);
|
||||
|
||||
$data = [
|
||||
'name' => $this->form['name'],
|
||||
'enabled' => (bool) $this->form['enabled'],
|
||||
'event' => $this->form['event'],
|
||||
'conditions' => array_values($this->form['conditions']),
|
||||
'actions' => array_values($this->form['actions']),
|
||||
];
|
||||
|
||||
if ($this->editingId) {
|
||||
Trigger::query()->findOrFail($this->editingId)->update($data);
|
||||
} else {
|
||||
$data['sort_order'] = (Trigger::query()->max('sort_order') ?? 0) + 1;
|
||||
Trigger::query()->create($data);
|
||||
}
|
||||
|
||||
$this->formOpen = false;
|
||||
unset($this->triggers);
|
||||
}
|
||||
|
||||
public function toggleEnabled(int $id): void
|
||||
{
|
||||
$trigger = Trigger::query()->findOrFail($id);
|
||||
$trigger->update(['enabled' => ! $trigger->enabled]);
|
||||
unset($this->triggers);
|
||||
}
|
||||
|
||||
public function removeTrigger(int $id): void
|
||||
{
|
||||
Trigger::query()->findOrFail($id)->delete();
|
||||
unset($this->triggers);
|
||||
}
|
||||
|
||||
public function moveUp(int $id): void
|
||||
{
|
||||
$this->swapAdjacentSortOrder($id, -1);
|
||||
}
|
||||
|
||||
public function moveDown(int $id): void
|
||||
{
|
||||
$this->swapAdjacentSortOrder($id, 1);
|
||||
}
|
||||
|
||||
protected function swapAdjacentSortOrder(int $id, int $direction): void
|
||||
{
|
||||
$ordered = $this->triggers;
|
||||
$index = $ordered->search(fn ($row) => $row->id === $id);
|
||||
$swapIndex = $index + $direction;
|
||||
|
||||
if ($index === false || $swapIndex < 0 || $swapIndex >= $ordered->count()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$row = $ordered[$index];
|
||||
$neighbor = $ordered[$swapIndex];
|
||||
|
||||
[$rowOrder, $neighborOrder] = [$row->sort_order, $neighbor->sort_order];
|
||||
$row->update(['sort_order' => $neighborOrder]);
|
||||
$neighbor->update(['sort_order' => $rowOrder]);
|
||||
|
||||
unset($this->triggers);
|
||||
}
|
||||
|
||||
// ===================== TEMPLATES (wyzwalaczy) =====================
|
||||
|
||||
public function openTemplateForm(): void
|
||||
{
|
||||
$this->editingTemplateId = null;
|
||||
$this->templateForm = ['name' => '', 'subject' => '', 'body' => ''];
|
||||
$this->resetErrorBag();
|
||||
$this->templateFormOpen = true;
|
||||
}
|
||||
|
||||
public function editTemplate(int $id): void
|
||||
{
|
||||
$template = TriggerEmailTemplate::query()->findOrFail($id);
|
||||
|
||||
$this->editingTemplateId = $template->id;
|
||||
$this->templateForm = [
|
||||
'name' => $template->name,
|
||||
'subject' => $template->subject,
|
||||
'body' => $template->body,
|
||||
];
|
||||
$this->resetErrorBag();
|
||||
$this->templateFormOpen = true;
|
||||
}
|
||||
|
||||
public function closeTemplateForm(): void
|
||||
{
|
||||
$this->templateFormOpen = false;
|
||||
}
|
||||
|
||||
public function setTemplateBodyDraft(string $value): void
|
||||
{
|
||||
$this->templateForm['body'] = $value;
|
||||
}
|
||||
|
||||
public function submitTemplate(): void
|
||||
{
|
||||
$this->validate([
|
||||
'templateForm.name' => ['required', 'string', 'max:255'],
|
||||
'templateForm.subject' => ['required', 'string', 'max:255'],
|
||||
'templateForm.body' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
if ($this->editingTemplateId) {
|
||||
TriggerEmailTemplate::query()->findOrFail($this->editingTemplateId)->update($this->templateForm);
|
||||
} else {
|
||||
TriggerEmailTemplate::query()->create($this->templateForm);
|
||||
}
|
||||
|
||||
$this->templateFormOpen = false;
|
||||
unset($this->emailTemplates);
|
||||
}
|
||||
|
||||
public function removeTemplate(int $id): void
|
||||
{
|
||||
TriggerEmailTemplate::query()->findOrFail($id)->delete();
|
||||
unset($this->emailTemplates);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.triggers');
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,23 @@ namespace App\Livewire;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class NotificationBell extends Component
|
||||
{
|
||||
/**
|
||||
* Fired by echo.js the moment a NotificationCreated broadcast arrives on
|
||||
* this user's private channel — refreshes the badge/list instantly
|
||||
* instead of waiting for the next 30s poll, which stays in place below
|
||||
* as a fallback for dropped websocket connections.
|
||||
*/
|
||||
#[On('bell-notification-received')]
|
||||
public function onBellNotification(): void
|
||||
{
|
||||
unset($this->notifications, $this->unreadCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only unread — once a notification is read (clicked through, or via
|
||||
* "mark all as read"), it disappears from the bell rather than staying
|
||||
|
||||
@@ -18,6 +18,7 @@ use Livewire\Component;
|
||||
|
||||
class Queue extends Component
|
||||
{
|
||||
#[Url]
|
||||
public string $queue = 'all';
|
||||
|
||||
public string $filterStatus = 'all';
|
||||
@@ -306,7 +307,7 @@ class Queue extends Component
|
||||
$query->search($this->search);
|
||||
}
|
||||
|
||||
$tickets = $query->with(['subcategory.category', 'assignee', 'priority', 'status'])->get();
|
||||
$tickets = $query->with(['subcategory.category', 'assignee', 'priority', 'status', 'team'])->get();
|
||||
|
||||
return $this->sortTickets($tickets);
|
||||
}
|
||||
@@ -330,6 +331,9 @@ class Queue extends Component
|
||||
'priority' => $tickets->sortBy(fn (Ticket $t) => $t->priority?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc),
|
||||
'status' => $tickets->sortBy(fn (Ticket $t) => $t->status?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc),
|
||||
'assignee' => $tickets->sortBy(fn (Ticket $t) => $t->assignee?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||
'team' => $tickets->sortBy(fn (Ticket $t) => $t->team?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||
'subcategory' => $tickets->sortBy(fn (Ticket $t) => $t->subcategory?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||
'created' => $tickets->sortBy(fn (Ticket $t) => $t->created_at, SORT_REGULAR, $desc),
|
||||
default => $tickets->sortBy('updated_at', SORT_REGULAR, $desc),
|
||||
};
|
||||
|
||||
@@ -346,10 +350,13 @@ class Queue extends Component
|
||||
'subject' => 'Temat',
|
||||
'customer' => 'Klient',
|
||||
'category' => 'Kategoria',
|
||||
'subcategory' => 'Podkategoria',
|
||||
'priority' => 'Priorytet',
|
||||
'status' => 'Status',
|
||||
'sla' => 'SLA',
|
||||
'assignee' => 'Przypisany',
|
||||
'team' => 'Zespół',
|
||||
'created' => 'Utworzono',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -360,7 +367,7 @@ class Queue extends Component
|
||||
*/
|
||||
public function sortableColumns(): array
|
||||
{
|
||||
return ['number', 'subject', 'customer', 'category', 'priority', 'status', 'assignee'];
|
||||
return ['number', 'subject', 'customer', 'category', 'subcategory', 'priority', 'status', 'assignee', 'team', 'created'];
|
||||
}
|
||||
|
||||
public function sortByColumn(string $column): void
|
||||
|
||||
@@ -91,6 +91,18 @@ class TicketShow extends Component
|
||||
$this->ticket->resumeTimer();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function isWatching(): bool
|
||||
{
|
||||
return $this->ticket->isWatchedBy(Auth::user());
|
||||
}
|
||||
|
||||
public function toggleWatch(): void
|
||||
{
|
||||
app(TicketService::class)->toggleWatch($this->ticket, Auth::user());
|
||||
unset($this->isWatching);
|
||||
}
|
||||
|
||||
// -------- time tracking --------
|
||||
|
||||
public function stopTimer(): void
|
||||
|
||||
44
src/app/Livewire/Settings/NotificationPreferences.php
Normal file
44
src/app/Livewire/Settings/NotificationPreferences.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Models\NotificationPreference;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
|
||||
class NotificationPreferences extends Component
|
||||
{
|
||||
protected const SCOPE_FIELDS = ['scope_mine', 'scope_unassigned', 'scope_watched', 'scope_all', 'email'];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
abort_unless(Auth::user()->isOperator() || Auth::user()->isAdmin(), 403);
|
||||
}
|
||||
|
||||
public function rows(): array
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
return collect(NotificationPreference::CATEGORIES)
|
||||
->mapWithKeys(fn (string $category) => [$category => NotificationPreference::rowFor($user, $category)])
|
||||
->all();
|
||||
}
|
||||
|
||||
public function toggle(string $category, string $field): void
|
||||
{
|
||||
abort_unless(in_array($category, NotificationPreference::CATEGORIES, true), 404);
|
||||
abort_unless(in_array($field, self::SCOPE_FIELDS, true), 404);
|
||||
|
||||
$preference = NotificationPreference::query()->firstOrCreate(
|
||||
['user_id' => Auth::id(), 'event_category' => $category],
|
||||
array_merge(['user_id' => Auth::id(), 'event_category' => $category], NotificationPreference::DEFAULTS[$category])
|
||||
);
|
||||
|
||||
$preference->update([$field => ! $preference->$field]);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.notification-preferences', ['rows' => $this->rows()]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user