Co nowego:
- Wsparcie Active Directory dla LDAP (obok LLDAP/OpenLDAP), przełącznik typu
  katalogu w Admin > Integracje.
- Wyszukiwarka klientów dla operatora (Operator > Klienci).
- Stronicowanie kolejki operatora (50/stronę) i dashboardu klienta (20/stronę).
- Globalna wyszukiwarka zgłoszeń (Ctrl+K/Cmd+K) z operatorami w stylu Gmaila
  (od:, temat:, treść:, numer:), plus przycisk "Szukaj" w panelu bocznym.
- Ostatnio przeglądane zgłoszenia w panelu bocznym operatora.
- Przeprojektowany pasek nawigacji: suwak Klient/Operator/Administrator
  zamiast rozwijanego menu, bogatsze menu profilu (nazwa/e-mail/role),
  dynamiczne tytuły kart przeglądarki na każdej podstronie.
- Narzędzie do jednorazowego importu historii zgłoszeń z Heska 3.x
  (scripts/hesk-import/).
- Poprawka: paginacja pokazywała surowe klucze tłumaczeń zamiast tekstu
  (brakujący lang/pl/pagination.php).

Zaktualizowana dokumentacja: README, CLAUDE.md, install.md, ARCHITECTURE.md,
CHANGELOG.md, wiki/*.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 00:58:35 +02:00
parent 7a8cf2037c
commit 03c6ec7cae
57 changed files with 2029 additions and 162 deletions

View File

@@ -27,10 +27,15 @@ $tabGroups = [
];
@endphp
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar />
<x-topbar area="admin" />
<div class="app-split" style="flex:1;display:flex;min-height:0">
<div class="side-rail" style="padding:16px 10px;gap:16px;overflow:auto;background:color-mix(in srgb, var(--color-text) 5%, var(--color-bg))">
<button type="button" @click="window.dispatchEvent(new CustomEvent('open-global-search'))" 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:transparent;color:var(--color-text)">
<span class="material-symbols-outlined" style="font-size:18px">search</span>
<span>Szukaj</span>
</button>
@foreach ($tabGroups as $groupLabel => $items)
<div style="display:flex;flex-direction:column;gap:1px">
<div 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>
@@ -684,6 +689,13 @@ $tabGroups = [
<label class="radio"><input type="checkbox" wire:model="ldapConfig.enabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz autentykację LDAP/AD</strong></label>
@if ($ldapConfig['enabled'])
<div class="field">
<label>Typ katalogu</label>
<select class="input" wire:model="ldapConfig.directoryType">
<option value="lldap">LLDAP / OpenLDAP</option>
<option value="ad">Active Directory</option>
</select>
</div>
<div class="field"><label>Host serwera LDAP</label><input class="input" placeholder="ldap.example.com" wire:model="ldapConfig.host"></div>
<div style="display:flex;gap:10px">
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" placeholder="389" wire:model="ldapConfig.port"></div>
@@ -692,7 +704,13 @@ $tabGroups = [
<div class="field"><label>Base DN</label><input class="input" placeholder="dc=example,dc=com" wire:model="ldapConfig.baseDn"></div>
<div class="field"><label>Bind DN</label><input class="input" placeholder="cn=admin,dc=example,dc=com" wire:model="ldapConfig.bindDn"></div>
<div class="field"><label>Hasło Bind</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="ldapConfig.bindPassword"></div>
<div class="field"><label>User Filter</label><input class="input" placeholder="(uid={0})" wire:model="ldapConfig.userFilter"></div>
<div class="field">
<label>User Filter</label>
<input class="input" placeholder="{{ $ldapConfig['directoryType'] === 'ad' ? '(sAMAccountName={0})' : '(uid={0})' }}" wire:model="ldapConfig.userFilter">
@if ($ldapConfig['directoryType'] === 'ad')
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Active Directory nie ma atrybutu "uid" puste pole domyślnie użyje sAMAccountName.</p>
@endif
</div>
<label class="radio"><input type="checkbox" wire:model="ldapConfig.autoProvisionGuests" style="position:static;opacity:1;width:auto;height:auto">Automatycznie zakładaj konto dla gościa zgłaszającego, jeśli jego e-mail istnieje w LDAP</label>
<label class="radio"><input type="checkbox" wire:model="ldapConfig.restrictUserCreationToLdap" style="position:static;opacity:1;width:auto;height:auto">Zezwalaj na ręczne zapraszanie użytkowników tylko, jeśli ich e-mail istnieje w LDAP</label>
<label class="radio"><input type="checkbox" wire:model="ldapConfig.restrictTicketsToLdap" style="position:static;opacity:1;width:auto;height:auto">Zezwalaj na tworzenie zgłoszeń bez logowania tylko dla adresów e-mail istniejących w LDAP</label>

View File

@@ -1,5 +1,5 @@
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar />
<x-topbar area="client" />
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:920px;width:100%;margin:0 auto;box-sizing:border-box">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
@@ -9,8 +9,8 @@
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<div class="seg">
<label class="seg-opt"><input type="radio" name="ctab" @checked($tab === 'current') wire:click="setTab('current')">Aktualne ({{ $this->currentTickets->count() }})</label>
<label class="seg-opt"><input type="radio" name="ctab" @checked($tab === 'archive') wire:click="setTab('archive')">Archiwalne ({{ $this->archiveTickets->count() }})</label>
<label class="seg-opt"><input type="radio" name="ctab" @checked($tab === 'current') wire:click="setTab('current')">Aktualne ({{ $this->currentTickets->total() }})</label>
<label class="seg-opt"><input type="radio" name="ctab" @checked($tab === 'archive') wire:click="setTab('archive')">Archiwalne ({{ $this->archiveTickets->total() }})</label>
</div>
<input class="input" type="search" placeholder="Szukaj po numerze, temacie, treści…" wire:model.live.debounce.400ms="search" style="max-width:280px">
</div>
@@ -32,6 +32,10 @@
@if (($tab === 'current' ? $this->currentTickets : $this->archiveTickets)->isEmpty())
<p class="text-muted" style="font-size:13px">Brak zgłoszeń w tej zakładce.</p>
@endif
<div style="margin-top:4px">
{{ ($tab === 'current' ? $this->currentTickets : $this->archiveTickets)->links() }}
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar />
<x-topbar area="client" />
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:920px;width:100%;margin:0 auto;box-sizing:border-box">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">

View File

@@ -1,5 +1,5 @@
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar />
<x-topbar area="client" />
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:1180px;width:100%;margin:0 auto;box-sizing:border-box">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
@@ -182,13 +182,18 @@
<div class="card" style="padding:16px;gap:8px">
<div class="card-kicker">Inne Twoje zgłoszenia</div>
@forelse ($this->otherTickets as $ot)
<a href="{{ route('client.ticket', $ot) }}" wire:navigate style="display:flex;justify-content:space-between;align-items:center;gap:8px;cursor:pointer;text-decoration:none;color:inherit">
<a wire:key="other-ticket-{{ $ot->id }}" href="{{ route('client.ticket', $ot) }}" wire:navigate style="display:flex;justify-content:space-between;align-items:center;gap:8px;cursor:pointer;text-decoration:none;color:inherit">
<span style="font-size:13px">{{ $ot->displayNumber() }} {{ $ot->subject }}</span>
<span style="{{ $ot->statusStyle() }};flex:none">{{ $ot->statusLabel() }}</span>
</a>
@empty
<p class="text-muted" style="font-size:12px;margin:0">Brak innych zgłoszeń.</p>
<p wire:key="other-tickets-empty" class="text-muted" style="font-size:12px;margin:0">Brak innych zgłoszeń.</p>
@endforelse
@if (! $showAllOtherTickets && $this->otherTicketsCount > count($this->otherTickets))
<button wire:key="show-all-other-tickets" type="button" wire:click="revealAllOtherTickets" class="btn btn-secondary" style="font-size:12px;padding:6px 10px;align-self:flex-start">
Pokaż wszystkie ({{ $this->otherTicketsCount }})
</button>
@endif
</div>
</div>
</div>

View File

@@ -0,0 +1,51 @@
<div
x-data="{ open: false }"
x-on:open-global-search.window="open = true; $nextTick(() => $refs.searchInput.focus())"
x-on:keydown.cmd.k.window.prevent="open = true; $nextTick(() => $refs.searchInput.focus())"
x-on:keydown.ctrl.k.window.prevent="open = true; $nextTick(() => $refs.searchInput.focus())"
x-on:keydown.escape.window="open = false"
>
<div class="dialog-backdrop" x-show="open" x-cloak style="align-items:flex-start;padding-top:10vh">
<div class="dialog" style="max-width:720px;padding:0;gap:0;overflow:hidden" @click.outside="open = false">
<div style="display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:1px solid var(--color-divider)">
<span class="material-symbols-outlined" style="font-size:26px;color:color-mix(in srgb, var(--color-text) 55%, transparent)">search</span>
<input
x-ref="searchInput"
type="text"
wire:model.live.debounce.200ms="search"
placeholder="Szukaj zgłoszenia… (np. od:kacper temat:drukarka)"
class="input"
style="border:none;padding:10px 12px;box-shadow:none;font-size:18px"
x-on:keydown.enter="$refs.resultsList?.querySelector('a')?.click()"
>
<kbd style="font-size:11px;color:color-mix(in srgb, var(--color-text) 45%, transparent);border:1px solid var(--color-divider);border-radius:4px;padding:2px 7px;flex:none">Esc</kbd>
</div>
<div x-ref="resultsList" style="max-height:460px;overflow-y:auto">
@forelse ($this->results as $ticket)
<a
href="{{ $this->urlFor($ticket) }}"
wire:navigate
@click="open = false"
style="display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 22px;text-decoration:none;color:inherit;border-bottom:1px solid var(--color-divider)"
>
<div style="min-width:0">
<div style="font-weight:500;font-size:14.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $ticket->displayNumber() }} {{ $ticket->subject }}</div>
<div class="card-meta" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
{{ $ticket->categoryLabel() }} &middot; {{ $ticket->name }} &middot; {{ \App\Support\Rel::format($ticket->updated_at) }}
</div>
</div>
<div style="display:flex;gap:6px;flex:none">
<span style="{{ $ticket->priorityStyle() }}">{{ $ticket->priorityLabel() }}</span>
<span style="{{ $ticket->statusStyle() }}">{{ $ticket->statusLabel() }}</span>
</div>
</a>
@empty
<div style="padding:28px 22px;text-align:center;font-size:13.5px;color:color-mix(in srgb, var(--color-text) 55%, transparent)">
{{ trim($search) === '' ? 'Zacznij pisać, aby wyszukać zgłoszenie… (obsługuje też od:, temat:, treść:, numer:)' : 'Brak wyników.' }}
</div>
@endforelse
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,53 @@
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar area="operator" />
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto;display:flex;flex-direction:column;gap:20px;max-width:820px;width:100%;margin:0 auto;box-sizing:border-box">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<h2 style="margin:0">Klienci</h2>
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost">&larr; Wróć do listy</a>
</div>
<input
class="input"
type="search"
placeholder="Szukaj po imieniu, nazwisku lub adresie e-mail…"
wire:model.live.debounce.400ms="search"
autofocus
>
<div style="display:flex;flex-direction:column;gap:10px">
@if (trim($search) === '')
<p class="text-muted" style="font-size:13px">Zacznij pisać, aby wyszukać klienta.</p>
@else
@forelse ($this->results as $u)
<a
wire:key="client-{{ $u->id }}"
href="{{ route('operator.queue', ['filterCustomerId' => $u->id]) }}"
wire:navigate
class="card elev-sm"
style="padding:14px 16px;cursor:pointer;flex-direction:row;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;text-decoration:none;color:inherit"
>
<div>
<div style="font-weight:500">{{ $u->name }}</div>
<div class="card-meta">{{ $u->email }}</div>
</div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end">
@if ($u->hasRole('client'))
<span class="tag tag-outline">Klient</span>
@endif
@if ($u->hasRole('operator'))
<span class="tag tag-outline">Operator</span>
@endif
@if ($u->hasRole('admin'))
<span class="tag tag-outline">Administrator</span>
@endif
<span class="tag tag-neutral">{{ $u->tickets_as_customer_count }} {{ $u->tickets_as_customer_count === 1 ? 'zgłoszenie' : 'zgłoszeń' }}</span>
</div>
</a>
@empty
<p class="text-muted" style="font-size:13px">Brak wyników dla {{ $search }}.</p>
@endforelse
@endif
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar />
<x-topbar area="operator" />
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:920px;width:100%;margin:0 auto;box-sizing:border-box">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">

View File

@@ -1,5 +1,5 @@
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar />
<x-topbar area="operator" />
<div class="app-split" style="flex:1;display:flex;min-height:0">
<div
@@ -14,6 +14,11 @@
<span class="side-rail-toggle-label" x-text="collapsed ? 'Przegląd' : 'Zwiń panel'"></span>
</button>
<button type="button" class="side-rail-toggle" title="Szukaj" @click="window.dispatchEvent(new CustomEvent('open-global-search'))">
<span class="material-symbols-outlined" style="font-size:18px">search</span>
<span class="side-rail-toggle-label">Szukaj</span>
</button>
<div class="side-rail-scroll">
@foreach ($this->queueGroups as $groupLabel => $items)
<div style="display:flex;flex-direction:column;gap:1px">
@@ -27,6 +32,18 @@
@endforeach
</div>
@endforeach
@if ($this->recentlyViewed->isNotEmpty())
<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">Ostatnio przeglądane</div>
@foreach ($this->recentlyViewed as $ticket)
<a href="{{ route('operator.ticket', $ticket) }}" wire:navigate class="side-rail-item" title="{{ $ticket->displayNumber() }} — {{ $ticket->subject }}" style="display:flex;align-items:center;gap:10px;width:100%;padding:8px 12px;border-radius:6px;font-size:13.5px;text-align:left;white-space:nowrap;text-decoration:none;color:var(--color-text);overflow:hidden">
<span class="material-symbols-outlined" style="font-size:18px;flex:none">history</span>
<span class="side-rail-label" style="overflow:hidden;text-overflow:ellipsis">{{ $ticket->displayNumber() }} {{ $ticket->subject }}</span>
</a>
@endforeach
</div>
@endif
</div>
</div>
@@ -40,6 +57,10 @@
<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.clients') }}" wire:navigate class="btn btn-secondary" style="display:flex;align-items:center;gap:6px">
<span class="material-symbols-outlined" style="font-size:18px">person_search</span>
Klienci
</a>
<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
@@ -208,6 +229,9 @@
</tbody>
</table>
</div>
<div style="margin-top:12px">
{{ $this->filteredTickets->links() }}
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar />
<x-topbar area="operator" />
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto;display:flex;flex-direction:column;gap:20px">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">

View File

@@ -1,5 +1,5 @@
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar />
<x-topbar area="operator" />
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto">
<div style="display:flex;flex-direction:column;gap:16px;max-width:1180px;margin:0 auto">