- 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>
220 lines
16 KiB
PHP
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>
|