Files
servicedesk/src/resources/views/livewire/operator/queue.blade.php
Kacper 63178b366e v1.1.4
- Configurable ticket numbering (Admin > Konfiguracja > Ogólne): admin-set
  prefix and minimum zero-padded length for the ticket number.
- "Ukryj kolejność zgłoszeń": an opt-in mode that displays a stable,
  HMAC-derived checksum instead of the sequential ticket number, so it gives
  no indication of ticket volume or creation order. Ticket URLs switch to
  the same checksum when this is on, so a link and the number on the page it
  points to always match. The REST API is unaffected — pinned to `id`
  regardless of this setting. Search now also matches by checksum.
- Fixed: attachments no longer show an inline image thumbnail in the
  message thread — every attachment (images included) shows as just its
  filename, opening in a new tab on click.
- Docs: README/ARCHITECTURE/wiki updated for all of the above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 09:57:39 +02:00

220 lines
16 KiB
PHP

<div style="flex:1;display:flex;flex-direction:column">
<x-topbar />
<div class="app-split" style="flex:1;display:flex;min-height:0">
<div
class="side-rail"
style="padding:10px;gap:6px;background:color-mix(in srgb, var(--color-text) 5%, var(--color-bg))"
x-data="{ collapsed: localStorage.getItem('operatorSidebarCollapsed') === '1' }"
x-effect="localStorage.setItem('operatorSidebarCollapsed', collapsed ? '1' : '0')"
:class="collapsed ? 'side-rail-collapsed' : ''"
>
<button type="button" class="side-rail-toggle" @click="collapsed = ! collapsed" :title="collapsed ? 'Rozwiń panel' : 'Zwiń panel'">
<span class="material-symbols-outlined" style="font-size:18px" x-text="collapsed ? 'left_panel_open' : 'left_panel_close'"></span>
<span class="side-rail-toggle-label" x-text="collapsed ? 'Przegląd' : 'Zwiń panel'"></span>
</button>
<div class="side-rail-scroll">
@foreach ($this->queueGroups as $groupLabel => $items)
<div style="display:flex;flex-direction:column;gap:1px">
<div class="side-rail-group-label" style="font-size:10px;letter-spacing:0.09em;text-transform:uppercase;color:color-mix(in srgb, var(--color-text) 45%, transparent);padding:4px 12px 6px;white-space:nowrap">{{ $groupLabel }}</div>
@foreach ($items as $item)
<button type="button" class="side-rail-item" wire:click="setQueue('{{ $item['key'] }}')" title="{{ $item['label'] }}" style="display:flex;align-items:center;gap:10px;width:100%;padding:8px 12px;border-radius:6px;border:none;cursor:pointer;font-size:13.5px;text-align:left;white-space:nowrap;background:{{ $item['active'] ? 'color-mix(in srgb, var(--color-accent) 14%, transparent)' : 'transparent' }};color:{{ $item['active'] ? 'var(--color-accent)' : 'var(--color-text)' }}">
<span class="material-symbols-outlined" style="font-size:18px">{{ $item['icon'] }}</span>
<span class="side-rail-label">{{ $item['label'] }}</span>
<span class="side-rail-count" style="margin-left:auto;font-size:11px;padding:1px 7px;border-radius:10px;background:{{ $item['active'] ? 'color-mix(in srgb, var(--color-accent) 25%, transparent)' : 'color-mix(in srgb, var(--color-text) 12%, transparent)' }};color:{{ $item['active'] ? 'var(--color-accent)' : 'color-mix(in srgb, var(--color-text) 70%, transparent)' }}">{{ $item['count'] }}</span>
</button>
@endforeach
</div>
@endforeach
</div>
</div>
<div class="page-pad" style="flex:1;min-width:0;padding:20px 24px;overflow:auto">
<div class="queue-toolbar">
@if ($this->filteredCustomer)
<button type="button" class="btn btn-ghost" wire:click="clearCustomerFilter">Wyczyść filtr</button>
@endif
<div class="queue-actions">
<div class="queue-actions-pair">
<button type="button" class="btn btn-secondary" @disabled(count($selectedIds) < 2) wire:click="mergeSelected">Scal zgłoszenia ({{ count($selectedIds) }})</button>
<button type="button" class="btn btn-secondary" @disabled(count($selectedIds) < 1) wire:click="requestDeleteSelected" style="color:var(--color-danger)">Usuń zgłoszenia ({{ count($selectedIds) }})</button>
</div>
<a href="{{ route('operator.stats') }}" wire:navigate class="btn btn-secondary" style="display:flex;align-items:center;gap:6px">
<span class="material-symbols-outlined" style="font-size:18px">bar_chart</span>
Statystyki
</a>
<a href="{{ route('operator.new') }}" wire:navigate class="btn btn-primary">+ Zgłoszenie w imieniu klienta</a>
</div>
</div>
@php
$columnDefs = $this->columnDefs();
$sortableColumns = $this->sortableColumns();
@endphp
<div class="queue-filters">
<input class="input queue-filters-search" type="search" placeholder="Szukaj po numerze, temacie, kliencie…" wire:model.live.debounce.400ms="search">
<select class="input" style="width:auto" wire:model.live="filterStatus">
<option value="all">Wszystkie statusy</option>
@foreach ($this->filterableStatuses as $s)
<option value="{{ $s->key }}">{{ $s->label }}</option>
@endforeach
</select>
<select class="input" style="width:auto" wire:model.live="filterPriority">
<option value="all">Wszystkie priorytety</option>
@foreach ($this->priorities as $p)
<option value="{{ $p->key }}">{{ $p->label }}</option>
@endforeach
</select>
<select class="input" style="width:auto" wire:model.live="filterCategory">
<option value="all">Wszystkie kategorie</option>
@foreach ($this->categories as $c)
<option value="{{ $c->id }}">{{ $c->name }}</option>
@endforeach
</select>
<div class="queue-filters-saved" x-data="{ open: false, adding: false }">
<button type="button" class="btn btn-secondary" @click="open = ! open" style="display:flex;align-items:center;justify-content:center;gap:6px">
<span class="material-symbols-outlined" style="font-size:18px">bookmark</span>
Zapisane widoki
</button>
<div x-show="open" x-cloak @click.outside="open = false; adding = false" style="position:absolute;top:100%;left:0;margin-top:4px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);z-index:30;padding:6px;min-width:220px">
@forelse ($this->savedViews as $view)
<div style="display:flex;align-items:center;gap:4px;padding:2px 2px 2px 8px;border-radius:5px;{{ $savedViewId === $view->id ? 'background:color-mix(in srgb, var(--color-accent) 12%, transparent)' : '' }}">
<button type="button" wire:click="applySavedView({{ $view->id }})" style="flex:1;min-width:0;text-align:left;background:none;border:none;cursor:pointer;padding:6px 0;font-size:13px;color:{{ $savedViewId === $view->id ? 'var(--color-accent)' : 'inherit' }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $view->name }}</button>
<span class="material-symbols-outlined" style="font-size:16px;cursor:pointer;flex:none;opacity:{{ $view->is_default ? '1' : '0.4' }};color:{{ $view->is_default ? 'var(--color-accent)' : 'inherit' }}" title="Ustaw jako domyślny" wire:click="setDefaultView({{ $view->id }})">star</span>
<span class="material-symbols-outlined" style="font-size:16px;cursor:pointer;flex:none;opacity:0.6" title="Usuń" wire:click="deleteSavedView({{ $view->id }})">delete</span>
</div>
@empty
<p class="text-muted" style="font-size:12px;margin:2px 8px">Brak zapisanych widoków.</p>
@endforelse
<div style="border-top:1px solid var(--color-divider);margin:4px 0"></div>
<template x-if="! adding">
<button type="button" class="btn btn-secondary btn-block" @click="adding = true" style="font-size:12.5px">+ Zapisz bieżące filtry…</button>
</template>
<div x-show="adding" style="display:flex;gap:6px;padding:4px 2px">
<input class="input" style="flex:1;font-size:12.5px" placeholder="Nazwa widoku" wire:model="newViewName" @keydown.enter="$wire.saveCurrentView(); adding = false">
<button type="button" class="btn btn-primary" style="flex:none;padding:6px 10px" @click="$wire.saveCurrentView(); adding = false">Zapisz</button>
</div>
</div>
</div>
<div class="queue-filters-columns" x-data="{ open: false }">
<button type="button" class="btn btn-secondary" @click="open = ! open" style="display:flex;align-items:center;justify-content:center;gap:6px">
<span class="material-symbols-outlined" style="font-size:18px">view_column</span>
Kolumny
</button>
<div x-show="open" x-cloak @click.outside="open = false" style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);z-index:30;padding:6px;min-width:180px">
@foreach ($columnDefs as $key => $label)
<label style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:400;padding:6px 8px;border-radius:5px;cursor:pointer">
<input type="checkbox" @checked(in_array($key, $visibleColumns)) wire:click="toggleColumn('{{ $key }}')">
{{ $label }}
</label>
@endforeach
</div>
</div>
{{-- Live updates arrive via broadcasting, but websocket connections can
drop silently (backgrounded tab, network blip) this is a periodic
fallback refresh, with a visible countdown so it's clear the queue
is still refreshing itself rather than just stuck. --}}
<div
class="btn btn-secondary"
style="cursor:default;gap:6px"
x-data="{ remaining: 60, total: 60 }"
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshQueue(); }, 1000)"
title="Kolejka odświeża się automatycznie co minutę"
>
<span class="material-symbols-outlined" style="font-size:18px">schedule</span>
<span x-text="remaining + 's'"></span>
</div>
</div>
<div class="table-wrap">
<table class="table table-cards-mobile">
<thead>
<tr>
<th></th>
@foreach ($columnDefs as $key => $label)
@continue(! in_array($key, $visibleColumns))
<th>
@if (in_array($key, $sortableColumns))
<button type="button" wire:click="sortByColumn('{{ $key }}')" style="display:inline-flex;align-items:center;gap:2px;background:none;border:none;cursor:pointer;padding:0;font:inherit;color:inherit;text-transform:inherit;letter-spacing:inherit">
{{ $label }}
@if ($sortBy === $key)
<span class="material-symbols-outlined" style="font-size:15px">{{ $sortDir === 'asc' ? 'arrow_upward' : 'arrow_downward' }}</span>
@endif
</button>
@else
{{ $label }}
@endif
</th>
@endforeach
</tr>
</thead>
<tbody>
@foreach ($this->filteredTickets as $t)
@php $sla = $t->slaInfo(); @endphp
<tr wire:key="ticket-{{ $t->id }}">
<td class="td-select"><input type="checkbox" @checked(in_array($t->id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})"></td>
@if (in_array('number', $visibleColumns))
<td data-label="Numer" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->displayNumber() }}</a></td>
@endif
@if (in_array('subject', $visibleColumns))
<td data-label="Temat" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer;white-space:nowrap">{{ $t->subject }}</a></td>
@endif
@if (in_array('customer', $visibleColumns))
<td data-label="Klient" style="white-space:nowrap">{{ $t->name }}</td>
@endif
@if (in_array('category', $visibleColumns))
<td data-label="Kategoria" style="white-space:nowrap">{{ $t->categoryLabel() }}</td>
@endif
@if (in_array('subcategory', $visibleColumns))
<td data-label="Podkategoria" style="white-space:nowrap">{{ $t->subcategory?->name ?? '' }}</td>
@endif
@if (in_array('priority', $visibleColumns))
<td data-label="Priorytet"><span style="{{ $t->priorityStyle() }}">{{ $t->priorityLabel() }}</span></td>
@endif
@if (in_array('status', $visibleColumns))
<td data-label="Status"><span style="{{ $t->statusStyle() }}">{{ $t->statusLabel() }}</span></td>
@endif
@if (in_array('sla', $visibleColumns))
<td data-label="SLA"><span class="{{ $sla['cls'] }}">{{ $sla['short'] }}</span></td>
@endif
@if (in_array('assignee', $visibleColumns))
<td data-label="Przypisany" style="white-space:nowrap">{{ $t->assignee?->name ?? 'Nieprzypisane' }}</td>
@endif
@if (in_array('team', $visibleColumns))
<td data-label="Zespół" style="white-space:nowrap">{{ $t->team?->name ?? '' }}</td>
@endif
@if (in_array('created', $visibleColumns))
<td data-label="Utworzono" style="white-space:nowrap">{{ \App\Support\Rel::format($t->created_at) }}</td>
@endif
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
@if ($pendingDeleteSelected)
<div class="dialog-backdrop">
<div class="dialog" style="max-width:400px">
<div class="dialog-title">Potwierdź usunięcie</div>
<div class="dialog-body">
{{ count($selectedIds) === 1 ? 'Wybrane zgłoszenie zostanie trwale usunięte.' : 'Wybrane zgłoszenia ('.count($selectedIds).') zostaną trwale usunięte.' }}
</div>
<div class="dialog-actions">
<button class="btn btn-secondary" wire:click="cancelDeleteSelected">Anuluj</button>
<button class="btn btn-primary" wire:click="confirmDeleteSelected">Usuń</button>
</div>
</div>
</div>
@endif
</div>