From 09438293310f9bf25c9a0858fd333951aec30e71 Mon Sep 17 00:00:00 2001 From: Kacper Date: Thu, 6 Aug 2026 09:01:34 +0200 Subject: [PATCH] v1.5.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co nowego: - Kolumny kolejki operatora: dwie nowe (ID, e-mail) obok istniejących, oraz możliwość zmiany kolejności widocznych kolumn strzałkami ↑/↓ w picker „Kolumny” — nie tylko włączanie/wyłączanie. Kolejność zapamiętywana jest per operator tak samo jak dotąd widoczność. - Wybór sprzętu klienta (Snipe-IT): druga lista dozwolonych kategorii obok istniejącej listy podkategorii — pozwala objąć od razu wszystkie podkategorie danej kategorii jednym zaznaczeniem. - Pulpit klienta i widok zgłoszenia operatora pamiętają teraz aktywną zakładkę, więc „Wróć do listy” wraca do tej samej, a nie zawsze do domyślnej. Poprawki: - Paginacja (kolejka operatora, pulpit klienta) używała domyślnego, szarego stylu Laravela reagującego na motyw systemu/przeglądarki, a nie przełącznik jasny/ciemny w aplikacji — stąd ciemne przyciski nawet w trybie jasnym. Podmieniony na własny widok zgodny z kolorami aplikacji (w tym własne tło/border każdego przycisku i wyśrodkowanie na telefonie). - Kolorystyka boksu z informacją o logowaniu nie zmienia już odcienia między trybem jasnym i ciemnym (wcześniej pochodziła z --color-accent) — teraz stałe, ciemne tło w obu trybach, więc kolory tekstu ustawione przez admina (np. biały) zostają czytelne niezależnie od motywu. Poszerzona karta logowania (380px → 480px). - Lista zgłoszeń klienta: etykiety priorytetu/statusu nie zawijają się już do osobnej linii przy długim temacie na wąskich ekranach — zostają przypięte do prawej, a temat zawija się we własnej kolumnie. - Liczniki czasu pracy (resumeTimer/stopTimer/...) nie dotykają już updated_at — samo otwarcie zgłoszenia nie liczy się jako aktualizacja. Kolejka operatora i pulpit klienta domyślnie sortują po dacie utworzenia z tego samego powodu. Zaktualizowana dokumentacja: README, CLAUDE.md, ARCHITECTURE.md, CHANGELOG.md, wiki/admin, wiki/client, wiki/operator. Co-Authored-By: Claude Sonnet 5 --- ARCHITECTURE.md | 38 ++++-- CHANGELOG.md | 45 +++++++ CLAUDE.md | 18 +++ README.md | 5 +- src/app/Livewire/Admin/Panel.php | 26 ++++ src/app/Livewire/Client/Dashboard.php | 16 ++- src/app/Livewire/Client/NewTicket.php | 35 ++++- src/app/Livewire/Operator/Queue.php | 84 +++++++++++- src/app/Models/Ticket.php | 25 +++- src/app/Models/User.php | 3 +- src/app/Support/Settings.php | 1 + ...66_add_operator_queue_columns_to_users.php | 30 +++++ src/resources/css/app.css | 59 +++++++-- .../views/livewire/admin/panel.blade.php | 13 +- .../views/livewire/auth/login.blade.php | 2 +- .../views/livewire/client/dashboard.blade.php | 6 +- .../livewire/client/ticket-show.blade.php | 3 +- .../views/livewire/operator/queue.blade.php | 123 +++++++++++------- .../livewire/operator/ticket-show.blade.php | 3 +- .../views/vendor/livewire/tailwind.blade.php | 70 ++++++++++ .../vendor/pagination/tailwind.blade.php | 56 ++++++++ .../AdminSnipeitIntegrationConfigTest.php | 25 +++- .../OperatorQueueSearchSortColumnsTest.php | 2 +- src/tests/Feature/SnipeitAssetLinkingTest.php | 30 +++++ .../TabPersistenceAndNavigationTest.php | 36 +++++ wiki/admin/README.md | 15 ++- wiki/client/README.md | 3 +- wiki/operator/README.md | 16 ++- 28 files changed, 684 insertions(+), 104 deletions(-) create mode 100644 src/database/migrations/2026_08_05_000166_add_operator_queue_columns_to_users.php create mode 100644 src/resources/views/vendor/livewire/tailwind.blade.php create mode 100644 src/resources/views/vendor/pagination/tailwind.blade.php diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d25542c..f4f5543 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -45,7 +45,8 @@ Category ─< Subcategory ─< CustomField (per-subcategory custom fields └── csat_rating/csat_comment/csat_rated_at (nullable — set once, on close) User ─< UserFieldValue >─ UserField -User ─< SavedQueueView (operator's own saved queue filter/sort/column presets) +User ─< SavedQueueView (operator's own named/default saved queue filter/sort/column presets) +User.operator_queue_columns (JSON, auto-remembers shown/hidden queue columns + their order, independent of SavedQueueView) User ─< notifications (Laravel's database channel — polymorphic, morph-mapped as 'user') ApiClient (Sanctum token owner, ability-scoped) Setting (single-row-per-key config store, see below) @@ -98,6 +99,19 @@ enforced by a `saving` listener that throws `InvalidArgumentException` on anything else, so a typo'd literal fails loudly instead of sticking silently. Add new values to the constant before writing them anywhere. +**Timer bookkeeping never touches `updated_at`.** `flushTimer()`/`stopTimer()`/ +`resumeTimer()`/`resetTimer()`/`setTimeSpent()` all route their writes through +the private `updateTimerFields()`, which toggles `$this->timestamps = false` +around the `update()` call. `resumeTimer()` runs on every single ticket open +(`TicketShow::mount()`) and `stopTimer()` on every navigate-away/tab-close — +without this, merely viewing a ticket (no reply, no status change) would bump +`updated_at`, which used to drown out genuinely stale tickets in any list +sorted by that column (operator queue, client dashboard — both now default to +sorting by `created_at` instead, for the same reason). Real content changes +still touch `updated_at` normally, via their own separate `update()`/`save()` +calls elsewhere. If you add another timer-only field, write it through +`updateTimerFields()` too rather than a plain `update()`. + ## Ticket numbering & URLs A ticket carries three distinct identifiers, each with a different job: @@ -605,15 +619,19 @@ toggleable settings gate what a client/operator can actually do with it — none of them affect `SnipeItClient` itself, only which Livewire methods are willing to call it: -- `snipeit_client_can_select_asset` (+ `snipeit_client_asset_subcategory_ids`, - a comma-separated allow-list) — gates `Client\NewTicket`'s asset picker. - Mirrors BookStack's shelf allow-lists: an **empty** subcategory list means - the picker never shows for any subcategory, not "every subcategory" — - `NewTicket::snipeitAssets()` checks both the toggle and that the currently - selected `subcategoryId` is in the list before calling - `assetsForEmail()`. `selectCategory()`/`selectSubcategory()` reset any - already-picked asset, so switching to an out-of-scope subcategory can't - silently carry a stale selection through to `submit()`. +- `snipeit_client_can_select_asset` (+ `snipeit_client_asset_subcategory_ids` + and `snipeit_client_asset_category_ids`, two independent comma-separated + allow-lists) — gates `Client\NewTicket`'s asset picker. Mirrors BookStack's + shelf allow-lists: **empty** lists mean the picker never shows for any + subcategory, not "every subcategory" — `NewTicket::snipeitAssets()` checks + the toggle and that *either* the currently selected `subcategoryId` is in + the subcategory list *or* `categoryId` is in the (coarser) category list + before calling `assetsForEmail()`. The category list exists so an admin can + cover every subcategory of a category in one click instead of ticking each + one individually; the two lists are additive, not exclusive. + `selectCategory()`/`selectSubcategory()` reset any already-picked asset, so + switching to an out-of-scope subcategory can't silently carry a stale + selection through to `submit()`. - `snipeit_operator_view_requester_assets` — gates the same `assetsForEmail()` lookup (by the ticket's own `email`, not the viewing operator's) in `Operator\TicketShow`'s sidebar. diff --git a/CHANGELOG.md b/CHANGELOG.md index d1d2443..cdb6853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,51 @@ All notable changes to this project are documented in this file. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [1.5.1] - 2026-08-06 + +### Added + +- **Operator queue columns**: two new optional columns, ID (raw DB id) and + e-mail, alongside the existing set; visible columns can now also be + **reordered** with ↑/↓ arrows next to each entry in the "Kolumny" picker, + not just shown/hidden — order is remembered per operator the same way + visibility already was (`users.operator_queue_columns`). +- **SnipeIT client asset picker**: a second, category-level allow-list + (`snipeit_client_asset_category_ids`) alongside the existing subcategory + one — lets an admin cover every subcategory of a category in one click + instead of ticking each one individually. The two lists are additive. +- Client dashboard and operator ticket-detail page now remember which tab + (Bieżące/Archiwum, or whichever queue tab) was active, so "Wróć do listy" + returns to it instead of always resetting to the default. + +### Fixed + +- **Pagination controls** (operator queue, client dashboard) no longer + render as Laravel's stock gray Tailwind styling, which only reacted to the + browser/OS's `prefers-color-scheme` and stayed dark even when the app's own + light/dark toggle was set to light. They're now a themed override + (`resources/views/vendor/livewire/tailwind.blade.php` — pagination here + actually renders through Livewire's own pagination view, not Laravel's + default) styled with the app's own light/dark CSS variables, with visible + per-button background/border and a centered layout on mobile. +- **Login notice box** background/border no longer shift hue between light + and dark mode (previously derived from `--color-accent`, which differs per + theme) — it's now one fixed dark color in both themes, so admin-picked + text colors (e.g. white) stay legible regardless of the viewer's theme. + Login card widened (380px → 480px). +- **Client dashboard ticket list**: priority/status badges no longer wrap + onto their own left-aligned line below a long ticket subject on narrow + screens — they stay pinned to the right while the subject text wraps + within its own column instead. +- Timer bookkeeping (`resumeTimer()`/`stopTimer()`/etc.) no longer touches + `updated_at` — merely opening a ticket (or the background timer + starting/stopping) no longer counted as an update, which used to drown out + genuinely stale tickets in any list sorted by that column. The operator + queue and client dashboard both now default to sorting by creation date + instead for the same reason. +- Admin panel and SnipeIT integration config test coverage extended for the + new category-level allow-list. + ## [1.5.0] - 2026-08-05 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index c2e7387..1c92057 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,6 +131,24 @@ reason — never add a `public/icons/` directory. attributes on ``s. Currently applied to the operator ticket queue; apply the same treatment to any other wide table you add or make mobile-relevant (admin panel tables don't have it yet). +- **Ticket list default sort** is `created_at` (or the raw `id`, which is + equivalent — both are monotonic) descending, everywhere a ticket list is + shown: the operator queue (`App\Livewire\Operator\Queue::$sortBy`, default + `'created'`) and both tabs of the client dashboard + (`App\Livewire\Client\Dashboard::baseQuery()`, `orderByDesc('created_at')`). + Deliberately not `updated_at` — see the timer/`updated_at` note in + [ARCHITECTURE.md](ARCHITECTURE.md#data-model); even with that fixed, sorting + by "last touched" is a worse default for a support queue than a stable + creation order. Keep any new ticket list consistent with this rather than + defaulting to `updated_at`. +- **Per-user table customization** (shown/hidden columns + their order): the + operator queue's pattern (`Queue::$visibleColumns`, persisted to + `users.operator_queue_columns` via `persistVisibleColumns()`, reordered with + `moveColumnUp()`/`moveColumnDown()`) is the template to reuse if another + table gains the same feature — auto-save on every toggle/reorder, no + explicit "save" step required from the user. This is intentionally separate + from `SavedQueueView` (named, manually-saved, multi-field filter presets); + don't conflate the two. ## Testing & code style diff --git a/README.md b/README.md index b524617..bc2ce56 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,10 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides. assignee, custom fields (per subcategory), attachments, full message thread (public replies + internal notes), history log, merge, delete. The operator queue (50/page) and client dashboard (20/page, current/archive tracked - separately) paginate rather than rendering every matching ticket at once. + separately) paginate rather than rendering every matching ticket at once, + and both default to newest-created-first. The operator queue's columns + (including the raw DB id, e-mail, source, last-updated, ...) can be + individually shown/hidden and reordered, remembered per operator. - **SLA** — per-priority response/resolution time targets; a scheduled command (`tickets:check-sla-breaches`, every 15 min) flags overdue tickets and can notify the assigned operator. diff --git a/src/app/Livewire/Admin/Panel.php b/src/app/Livewire/Admin/Panel.php index ee3189e..80edd20 100644 --- a/src/app/Livewire/Admin/Panel.php +++ b/src/app/Livewire/Admin/Panel.php @@ -252,6 +252,7 @@ class Panel extends Component 'skipSslVerification' => ! Settings::bool('snipeit_verify_ssl'), 'clientCanSelectAsset' => Settings::bool('snipeit_client_can_select_asset'), 'clientAssetSubcategoryIds' => $this->parseShelfIds(Settings::get('snipeit_client_asset_subcategory_ids', '')), + 'clientAssetCategoryIds' => $this->parseShelfIds(Settings::get('snipeit_client_asset_category_ids', '')), 'operatorViewRequesterAssets' => Settings::bool('snipeit_operator_view_requester_assets'), 'operatorSearchInventory' => Settings::bool('snipeit_operator_search_inventory'), ]; @@ -779,6 +780,14 @@ class Panel extends Component ->values(); } + #[Computed] + public function categoriesForSnipeitForm() + { + return Category::query()->orderBy('name')->get() + ->map(fn (Category $c) => ['id' => $c->id, 'label' => $c->name]) + ->values(); + } + public function openTeamForm(): void { $this->teamForm = ['id' => null, 'name' => '', 'memberIds' => [], 'subcategoryIds' => []]; @@ -1682,6 +1691,7 @@ class Panel extends Component Settings::set('snipeit_verify_ssl', $this->snipeitConfig['skipSslVerification'] ? '0' : '1'); Settings::set('snipeit_client_can_select_asset', $this->snipeitConfig['clientCanSelectAsset'] ? '1' : '0'); Settings::set('snipeit_client_asset_subcategory_ids', implode(',', $this->snipeitConfig['clientAssetSubcategoryIds'])); + Settings::set('snipeit_client_asset_category_ids', implode(',', $this->snipeitConfig['clientAssetCategoryIds'])); Settings::set('snipeit_operator_view_requester_assets', $this->snipeitConfig['operatorViewRequesterAssets'] ? '1' : '0'); Settings::set('snipeit_operator_search_inventory', $this->snipeitConfig['operatorSearchInventory'] ? '1' : '0'); @@ -1698,6 +1708,22 @@ class Panel extends Component : [...$ids, $id]; } + /** + * The category-level counterpart to toggleSnipeitClientSubcategory() — + * a coarser allow-list for admins who want the picker on for every + * subcategory of a category at once, without ticking each one + * individually. NewTicket::snipeitAssets() allows a ticket through if + * its category OR its subcategory is on either list. + */ + public function toggleSnipeitClientCategory(int $id): void + { + $ids = $this->snipeitConfig['clientAssetCategoryIds']; + + $this->snipeitConfig['clientAssetCategoryIds'] = in_array($id, $ids, true) + ? array_values(array_diff($ids, [$id])) + : [...$ids, $id]; + } + public function testSnipeitConnection(): void { $cfg = $this->snipeitConfig; diff --git a/src/app/Livewire/Client/Dashboard.php b/src/app/Livewire/Client/Dashboard.php index 787eb7d..b1a2314 100644 --- a/src/app/Livewire/Client/Dashboard.php +++ b/src/app/Livewire/Client/Dashboard.php @@ -6,6 +6,7 @@ use App\Models\Status; use App\Support\Settings; use Illuminate\Support\Facades\Auth; use Livewire\Attributes\Computed; +use Livewire\Attributes\Url; use Livewire\Component; use Livewire\WithPagination; @@ -15,16 +16,28 @@ class Dashboard extends Component private const PER_PAGE = 20; + #[Url] public string $tab = 'current'; public string $search = ''; + /** + * Session-only, mirrors Operator\Queue::rememberQueueTab() — lets + * "Wróć do listy" on the ticket-detail page return to whichever tab + * (Bieżące/Archiwum) was actually active, instead of always resetting + * to the default. + */ + public function mount(): void + { + session(['client_dashboard_tab' => $this->tab]); + } + protected function baseQuery() { return Auth::user()->ticketsAsCustomer() ->search($this->search) ->with('subcategory.category') - ->orderByDesc('updated_at'); + ->orderByDesc('created_at'); } /** @@ -54,6 +67,7 @@ class Dashboard extends Component public function setTab(string $tab): void { $this->tab = $tab; + session(['client_dashboard_tab' => $tab]); } public function render() diff --git a/src/app/Livewire/Client/NewTicket.php b/src/app/Livewire/Client/NewTicket.php index fc025e8..56e365a 100644 --- a/src/app/Livewire/Client/NewTicket.php +++ b/src/app/Livewire/Client/NewTicket.php @@ -53,11 +53,13 @@ class NewTicket extends Component } /** - * Empty unless the admin turned the picker on AND allow-listed the - * currently selected subcategory for it (see - * snipeit_client_asset_subcategory_ids) — an empty allow-list means - * "no subcategory", not "every subcategory", mirroring how BookStack's - * shelf allow-lists work. + * Empty unless the admin turned the picker on AND allow-listed either + * the currently selected subcategory (snipeit_client_asset_subcategory_ids) + * or its parent category (snipeit_client_asset_category_ids) — an empty + * pair of allow-lists means "nowhere", not "everywhere", mirroring how + * BookStack's shelf allow-lists work. The category list is the coarser + * of the two, for admins who want every subcategory of a category + * covered at once instead of ticking each one individually. * * @return array */ @@ -68,7 +70,10 @@ class NewTicket extends Component return []; } - if (! in_array($this->subcategoryId, $this->snipeitAllowedSubcategoryIds(), true)) { + $subcategoryAllowed = in_array($this->subcategoryId, $this->snipeitAllowedSubcategoryIds(), true); + $categoryAllowed = $this->categoryId && in_array($this->categoryId, $this->snipeitAllowedCategoryIds(), true); + + if (! $subcategoryAllowed && ! $categoryAllowed) { return []; } @@ -80,7 +85,23 @@ class NewTicket extends Component */ protected function snipeitAllowedSubcategoryIds(): array { - return collect(explode(',', Settings::get('snipeit_client_asset_subcategory_ids', ''))) + return $this->parseIdList(Settings::get('snipeit_client_asset_subcategory_ids', '')); + } + + /** + * @return int[] + */ + protected function snipeitAllowedCategoryIds(): array + { + return $this->parseIdList(Settings::get('snipeit_client_asset_category_ids', '')); + } + + /** + * @return int[] + */ + private function parseIdList(string $raw): array + { + return collect(explode(',', $raw)) ->map(fn ($v) => (int) trim($v)) ->filter() ->values() diff --git a/src/app/Livewire/Operator/Queue.php b/src/app/Livewire/Operator/Queue.php index b945cbc..03323ab 100644 --- a/src/app/Livewire/Operator/Queue.php +++ b/src/app/Livewire/Operator/Queue.php @@ -48,12 +48,12 @@ class Queue extends Component public string $search = ''; - public string $sortBy = 'updated_at'; + public string $sortBy = 'created'; public string $sortDir = 'desc'; /** @var string[] */ - public array $visibleColumns = ['number', 'subject', 'customer', 'category', 'priority', 'status', 'sla', 'assignee']; + public array $visibleColumns = ['id', 'number', 'subject', 'customer', 'category', 'priority', 'status', 'sla', 'assignee']; /** @var int[] */ public array $selectedIds = []; @@ -72,7 +72,15 @@ class Queue extends Component */ public function mount(): void { + $savedColumns = Auth::user()->operator_queue_columns; + + if (is_array($savedColumns) && $savedColumns !== []) { + $this->visibleColumns = array_values(array_intersect($savedColumns, array_keys($this->columnDefs()))); + } + if ($this->savedViewId !== null) { + $this->rememberQueueTab(); + return; } @@ -82,6 +90,20 @@ class Queue extends Component $this->applyViewFilters($default->filters); $this->savedViewId = $default->id; } + + $this->rememberQueueTab(); + } + + /** + * Session-only (not the per-user `operator_queue_columns` column — a tab + * selection is transient, not a durable preference): lets "Wróć do + * listy" on the ticket-detail page return to whichever queue tab was + * actually active, instead of always resetting to the "Otwarte" default. + * See TicketShow's back-link, which reads this same key. + */ + private function rememberQueueTab(): void + { + session(['operator_queue_tab' => $this->queue]); } /** @@ -146,6 +168,7 @@ class Queue extends Component $this->visibleColumns = $filters['visibleColumns'] ?? $this->visibleColumns; $this->selectedIds = []; $this->resetPage(); + $this->rememberQueueTab(); } /** @@ -396,17 +419,21 @@ class Queue extends Component $desc = $this->sortDir === 'desc'; $sorted = match ($this->sortBy) { + 'id' => $tickets->sortBy(fn (Ticket $t) => $t->id, SORT_REGULAR, $desc), 'number' => $tickets->sortBy(fn (Ticket $t) => (int) $t->number, SORT_REGULAR, $desc), 'subject' => $tickets->sortBy('subject', SORT_NATURAL | SORT_FLAG_CASE, $desc), 'customer' => $tickets->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE, $desc), + 'email' => $tickets->sortBy('email', SORT_NATURAL | SORT_FLAG_CASE, $desc), 'category' => $tickets->sortBy(fn (Ticket $t) => $t->categoryLabel(), SORT_NATURAL | SORT_FLAG_CASE, $desc), '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), + 'source' => $tickets->sortBy(fn (Ticket $t) => $t->source ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc), + 'updated' => $tickets->sortBy(fn (Ticket $t) => $t->updated_at, SORT_REGULAR, $desc), 'created' => $tickets->sortBy(fn (Ticket $t) => $t->created_at, SORT_REGULAR, $desc), - default => $tickets->sortBy('updated_at', SORT_REGULAR, $desc), + default => $tickets->sortBy(fn (Ticket $t) => $t->created_at, SORT_REGULAR, $desc), }; return $sorted->values(); @@ -418,9 +445,11 @@ class Queue extends Component public function columnDefs(): array { return [ + 'id' => 'ID', 'number' => 'Numer', 'subject' => 'Temat', 'customer' => 'Klient', + 'email' => 'E-mail', 'category' => 'Kategoria', 'subcategory' => 'Podkategoria', 'priority' => 'Priorytet', @@ -428,7 +457,9 @@ class Queue extends Component 'sla' => 'SLA', 'assignee' => 'Przypisany', 'team' => 'Zespół', + 'source' => 'Źródło', 'created' => 'Utworzono', + 'updated' => 'Zaktualizowano', ]; } @@ -439,7 +470,7 @@ class Queue extends Component */ public function sortableColumns(): array { - return ['number', 'subject', 'customer', 'category', 'subcategory', 'priority', 'status', 'assignee', 'team', 'created']; + return ['id', 'number', 'subject', 'customer', 'email', 'category', 'subcategory', 'priority', 'status', 'assignee', 'team', 'source', 'created', 'updated']; } public function sortByColumn(string $column): void @@ -467,12 +498,57 @@ class Queue extends Component } else { $this->visibleColumns[] = $column; } + + $this->persistVisibleColumns(); + } + + public function moveColumnUp(string $column): void + { + $this->reorderColumn($column, -1); + } + + public function moveColumnDown(string $column): void + { + $this->reorderColumn($column, 1); + } + + private function reorderColumn(string $column, int $delta): void + { + $from = array_search($column, $this->visibleColumns, true); + + if ($from === false) { + return; + } + + $to = $from + $delta; + + if ($to < 0 || $to >= count($this->visibleColumns)) { + return; + } + + $columns = $this->visibleColumns; + [$columns[$from], $columns[$to]] = [$columns[$to], $columns[$from]]; + $this->visibleColumns = $columns; + + $this->persistVisibleColumns(); + } + + /** + * Auto-remembers shown/hidden columns *and* their order per operator, + * independent of the named/default SavedQueueView mechanism above — a + * plain column toggle or drag shouldn't require explicitly "saving a + * view" for it to stick between visits. + */ + private function persistVisibleColumns(): void + { + Auth::user()->forceFill(['operator_queue_columns' => $this->visibleColumns])->save(); } public function setQueue(string $key): void { $this->queue = $key; $this->selectedIds = []; + $this->rememberQueueTab(); // Every tab except "closed" now excludes closed-stage tickets (see // queueDefs()), so a stale closed-stage status filter would silently diff --git a/src/app/Models/Ticket.php b/src/app/Models/Ticket.php index 085a4f7..41ce75b 100644 --- a/src/app/Models/Ticket.php +++ b/src/app/Models/Ticket.php @@ -611,7 +611,7 @@ class Ticket extends Model public function flushTimer(): void { if ($this->timer_started_at) { - $this->update([ + $this->updateTimerFields([ 'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(), 'timer_started_at' => now(), ]); @@ -621,7 +621,7 @@ class Ticket extends Model public function stopTimer(): void { if ($this->timer_started_at) { - $this->update([ + $this->updateTimerFields([ 'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(), 'timer_started_at' => null, ]); @@ -640,13 +640,13 @@ class Ticket extends Model } if (! $this->timer_started_at) { - $this->update(['timer_started_at' => now()]); + $this->updateTimerFields(['timer_started_at' => now()]); } } public function resetTimer(): void { - $this->update(['time_spent_seconds' => 0, 'timer_started_at' => null]); + $this->updateTimerFields(['time_spent_seconds' => 0, 'timer_started_at' => null]); } /** @@ -657,12 +657,27 @@ class Ticket extends Model */ public function setTimeSpent(int $seconds): void { - $this->update([ + $this->updateTimerFields([ 'time_spent_seconds' => max(0, $seconds), 'timer_started_at' => $this->timer_started_at ? now() : null, ]); } + /** + * Timer bookkeeping alone is never a ticket "update" worth surfacing — + * merely opening a ticket (resumeTimer on mount, stopTimer on leaving) + * would otherwise bump `updated_at` on every single view, drowning out + * genuinely stale tickets in queues/lists sorted by that column. Real + * content changes (replies, status/priority/assignee edits, ...) go + * through their own ->update() calls elsewhere and still touch it. + */ + private function updateTimerFields(array $attributes): void + { + $this->timestamps = false; + $this->update($attributes); + $this->timestamps = true; + } + /** * Custom field values for this ticket's subcategory, in display order, skipping blanks. * diff --git a/src/app/Models/User.php b/src/app/Models/User.php index 847f538..1a84b33 100644 --- a/src/app/Models/User.php +++ b/src/app/Models/User.php @@ -14,7 +14,7 @@ use Illuminate\Notifications\Notifiable; use LdapRecord\Laravel\Auth\AuthenticatesWithLdap; use LdapRecord\Laravel\Auth\LdapAuthenticatable; -#[Fillable(['name', 'email', 'password', 'roles', 'custom_field_values'])] +#[Fillable(['name', 'email', 'password', 'roles', 'custom_field_values', 'operator_queue_columns'])] #[Hidden(['password'])] class User extends Authenticatable implements LdapAuthenticatable { @@ -109,6 +109,7 @@ class User extends Authenticatable implements LdapAuthenticatable { return [ 'password' => 'hashed', + 'operator_queue_columns' => 'array', ]; } diff --git a/src/app/Support/Settings.php b/src/app/Support/Settings.php index 06e8f84..3380cea 100644 --- a/src/app/Support/Settings.php +++ b/src/app/Support/Settings.php @@ -67,6 +67,7 @@ class Settings 'snipeit_verify_ssl' => '1', 'snipeit_client_can_select_asset' => '0', 'snipeit_client_asset_subcategory_ids' => '', + 'snipeit_client_asset_category_ids' => '', 'snipeit_operator_view_requester_assets' => '1', 'snipeit_operator_search_inventory' => '1', 'ai_enabled' => '0', diff --git a/src/database/migrations/2026_08_05_000166_add_operator_queue_columns_to_users.php b/src/database/migrations/2026_08_05_000166_add_operator_queue_columns_to_users.php new file mode 100644 index 0000000..91bdce7 --- /dev/null +++ b/src/database/migrations/2026_08_05_000166_add_operator_queue_columns_to_users.php @@ -0,0 +1,30 @@ +json('operator_queue_columns')->nullable(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('operator_queue_columns'); + }); + } +}; diff --git a/src/resources/css/app.css b/src/resources/css/app.css index a612235..0cfe20a 100644 --- a/src/resources/css/app.css +++ b/src/resources/css/app.css @@ -55,11 +55,6 @@ [data-theme='light'] .tag-neutral { background: var(--color-neutral-200); color: var(--color-neutral-700); } [data-theme='light'] .dialog-backdrop { background: color-mix(in srgb, var(--color-neutral-900) 35%, transparent); } -[data-theme='light'] .login-notice-info { background: color-mix(in srgb, var(--color-accent) 14%, white); color: var(--color-accent-700); } -[data-theme='light'] .login-notice-warning { background: color-mix(in srgb, var(--color-warning) 20%, white); color: color-mix(in srgb, var(--color-warning) 80%, black); } -[data-theme='light'] .login-notice-success { background: color-mix(in srgb, var(--color-success) 18%, white); color: color-mix(in srgb, var(--color-success) 75%, black); } -[data-theme='light'] .login-notice-danger { background: color-mix(in srgb, var(--color-danger) 16%, white); color: color-mix(in srgb, var(--color-danger) 80%, black); } - * { box-sizing: border-box; } body { @@ -211,6 +206,40 @@ body { .seg-opt:has(input:checked) { background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent); } .seg-opt input { position: absolute; opacity: 0; width: 0; height: 0; } + .pagination-wrap { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; } + .pagination-summary { font-size: 12.5px; color: color-mix(in srgb, var(--color-text) 55%, transparent); } + .pagination-links { display: inline-flex; gap: 4px; flex-wrap: wrap; } + .pagination-links a, + .pagination-links button, + .pagination-links span { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 32px; + padding: 0; + font-size: 12.5px; + font-weight: 500; + font-family: inherit; + border-radius: 7px; + border: none; + background: var(--color-surface); + color: var(--color-text); + text-decoration: none; + cursor: pointer; + } + .pagination-links > span { padding: 0; } + .pagination-links a:hover, + .pagination-links button:hover { background: color-mix(in srgb, var(--color-text) 6%, transparent); } + .pagination-links span[aria-disabled='true'] { opacity: 0.4; cursor: not-allowed; } + .pagination-links span.pagination-dots { background: transparent; } + .pagination-links button:disabled { opacity: 0.4; cursor: not-allowed; } + .pagination-links [aria-current='page'] span { + background: var(--color-accent); + border-color: var(--color-accent); + color: #fff; + } + .panel-switch { display: inline-flex; padding: 3px; @@ -291,9 +320,18 @@ body { and Tailwind's @layer'd rules always lose to unlayered ones — including Quill's CDN stylesheet — regardless of source order, so anything meant to override .ql-editor here (like the padding reset) has to be unlayered too. */ +/* Fixed (not [data-theme]-dependent) colors on purpose — this box holds + admin-authored Quill HTML with its own inline text colors (including + plain "white"), so the container needs one stable dark background in + both app themes rather than a tint derived from --color-accent, which + changed hue/lightness between light and dark mode and could swallow + light-colored text the admin picked. */ .login-notice { - padding: 0 14px; + padding: 14px; border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: #17262d; + color: #eef3f4; font-size: 13px; line-height: 1.5; height: auto; @@ -302,10 +340,10 @@ body { } .login-notice > *:first-child { margin-top: 0; } .login-notice > *:last-child { margin-bottom: 0; } -.login-notice-info { background: color-mix(in srgb, var(--color-accent) 20%, transparent); color: var(--color-accent); } -.login-notice-warning { background: color-mix(in srgb, var(--color-warning) 20%, transparent); color: var(--color-warning); } -.login-notice-success { background: color-mix(in srgb, var(--color-success) 20%, transparent); color: var(--color-success); } -.login-notice-danger { background: color-mix(in srgb, var(--color-danger) 20%, transparent); color: var(--color-danger); } +.login-notice hr { border-color: rgba(255, 255, 255, 0.14); } +.login-notice-warning { border-color: color-mix(in srgb, var(--color-warning) 45%, rgba(255, 255, 255, 0.14)); } +.login-notice-success { border-color: color-mix(in srgb, var(--color-success) 45%, rgba(255, 255, 255, 0.14)); } +.login-notice-danger { border-color: color-mix(in srgb, var(--color-danger) 45%, rgba(255, 255, 255, 0.14)); } /* ---- Responsive layout (phones/tablets) ---- */ @@ -399,6 +437,7 @@ body { @media (max-width: 640px) { .page-pad { padding: 16px !important; } .nav { padding-left: 14px !important; padding-right: 14px !important; gap: 10px; flex-wrap: wrap; } + .pagination-links { width: 100%; justify-content: center; } /* At this width the switcher's own centered slot collides with the brand text and the icon buttons sharing the row (nothing left to diff --git a/src/resources/views/livewire/admin/panel.blade.php b/src/resources/views/livewire/admin/panel.blade.php index 20d94c1..39e4560 100644 --- a/src/resources/views/livewire/admin/panel.blade.php +++ b/src/resources/views/livewire/admin/panel.blade.php @@ -883,6 +883,17 @@ $tabGroups = [ @if ($snipeitConfig['clientCanSelectAsset']) +
+ + +

Wybór sprzętu pojawi się klientowi przy zakładaniu zgłoszenia w dowolnej podkategorii zaznaczonych tu kategorii — najszybszy sposób, żeby włączyć to dla całej kategorii naraz.

+
+
-

Wybór sprzętu pojawi się klientowi tylko przy tworzeniu zgłoszenia w zaznaczonych tu podkategoriach. Jeśli nic nie jest zaznaczone, opcja nie pojawi się w żadnej podkategorii.

+

Dodatkowo, wybór sprzętu pojawi się też w pojedynczych podkategoriach zaznaczonych tutaj, nawet jeśli ich kategoria nie jest zaznaczona wyżej. Jeśli obie listy są puste, opcja nie pojawi się w żadnej podkategorii.

@endif diff --git a/src/resources/views/livewire/auth/login.blade.php b/src/resources/views/livewire/auth/login.blade.php index a36ff18..9d70eec 100644 --- a/src/resources/views/livewire/auth/login.blade.php +++ b/src/resources/views/livewire/auth/login.blade.php @@ -4,7 +4,7 @@
-
+ {{ \App\Support\Settings::get('company_name') }}

Zaloguj się

diff --git a/src/resources/views/livewire/client/dashboard.blade.php b/src/resources/views/livewire/client/dashboard.blade.php index 03748b8..5080c80 100644 --- a/src/resources/views/livewire/client/dashboard.blade.php +++ b/src/resources/views/livewire/client/dashboard.blade.php @@ -17,12 +17,12 @@
@foreach (($tab === 'current' ? $this->currentTickets : $this->archiveTickets) as $ticket) - -
+ +
{{ $ticket->displayNumber() }} — {{ $ticket->subject }}
{{ $ticket->categoryLabel() }} · {{ \App\Support\Rel::format($ticket->updated_at) }}
-
+
{{ $ticket->priorityLabel() }} {{ $ticket->statusLabel() }}
diff --git a/src/resources/views/livewire/client/ticket-show.blade.php b/src/resources/views/livewire/client/ticket-show.blade.php index f6092e8..5f12a0d 100644 --- a/src/resources/views/livewire/client/ticket-show.blade.php +++ b/src/resources/views/livewire/client/ticket-show.blade.php @@ -3,7 +3,8 @@
- ← Wróć do listy + @php $backTab = session('client_dashboard_tab', 'current'); @endphp + ← Wróć do listy {{-- Live updates arrive via broadcasting, but websocket connections can drop silently — this is a periodic fallback refresh, with a visible diff --git a/src/resources/views/livewire/operator/queue.blade.php b/src/resources/views/livewire/operator/queue.blade.php index 01038a8..667a3ab 100644 --- a/src/resources/views/livewire/operator/queue.blade.php +++ b/src/resources/views/livewire/operator/queue.blade.php @@ -129,13 +129,28 @@ view_column Kolumny -
- @foreach ($columnDefs as $key => $label) - +
+ @foreach ($visibleColumns as $i => $key) +
+ + arrow_upward + arrow_downward +
@endforeach + + @php $hiddenColumnDefs = array_diff_key($columnDefs, array_flip($visibleColumns)); @endphp + @if (! empty($hiddenColumnDefs)) +
+ @foreach ($hiddenColumnDefs as $key => $label) + + @endforeach + @endif
@@ -177,8 +192,8 @@ filteredTickets->isNotEmpty() && empty($this->filteredTickets->pluck('id')->diff($selectedIds)->all())) wire:click="toggleSelectAll" title="Zaznacz wszystkie"> - @foreach ($columnDefs as $key => $label) - @continue(! in_array($key, $visibleColumns)) + @foreach ($visibleColumns as $key) + @php $label = $columnDefs[$key] ?? $key; @endphp @if (in_array($key, $sortableColumns)) + @endif + + {{-- Pagination Elements --}} + @foreach ($elements as $element) + {{-- "Three Dots" Separator --}} + @if (is_string($element)) + {{ $element }} + @endif + + {{-- Array Of Links --}} + @if (is_array($element)) + @foreach ($element as $page => $url) + + @if ($page == $paginator->currentPage()) + {{ $page }} + @else + + @endif + + @endforeach + @endif + @endforeach + + {{-- Next Page Link --}} + @if ($paginator->hasMorePages()) + + @else + + + + @endif + + + @endif +
diff --git a/src/resources/views/vendor/pagination/tailwind.blade.php b/src/resources/views/vendor/pagination/tailwind.blade.php new file mode 100644 index 0000000..e6db7ae --- /dev/null +++ b/src/resources/views/vendor/pagination/tailwind.blade.php @@ -0,0 +1,56 @@ +@if ($paginator->hasPages()) + +@endif diff --git a/src/tests/Feature/AdminSnipeitIntegrationConfigTest.php b/src/tests/Feature/AdminSnipeitIntegrationConfigTest.php index 1e281e7..ec915d5 100644 --- a/src/tests/Feature/AdminSnipeitIntegrationConfigTest.php +++ b/src/tests/Feature/AdminSnipeitIntegrationConfigTest.php @@ -35,9 +35,30 @@ test('the subcategory scope picker only appears once "klient może wybrać sprz Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations']) ->set('snipeitConfig.enabled', true) ->assertDontSee('Ogranicz do podkategorii') + ->assertDontSee('Ogranicz do kategorii') ->set('snipeitConfig.clientCanSelectAsset', true) ->assertSee('Ogranicz do podkategorii') - ->assertSee('Sprzęt / Laptop'); + ->assertSee('Sprzęt / Laptop') + ->assertSee('Ogranicz do kategorii') + ->assertSee('Sprzęt'); +}); + +test('an admin can allow-list a whole category for the Snipe-IT client picker', function () { + $admin = adminUserForSnipeitTest(); + $itHelp = Category::query()->create(['name' => 'IT-Pomoc']); + $orders = Category::query()->create(['name' => 'Zamówienia']); + + $component = Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations']) + ->set('snipeitConfig.enabled', true) + ->set('snipeitConfig.clientCanSelectAsset', true) + ->call('toggleSnipeitClientCategory', $itHelp->id) + ->call('toggleSnipeitClientCategory', $orders->id); + + expect($component->get('snipeitConfig.clientAssetCategoryIds'))->toEqualCanonicalizing([$itHelp->id, $orders->id]); + + // Toggling again removes it. + $component->call('toggleSnipeitClientCategory', $orders->id); + expect($component->get('snipeitConfig.clientAssetCategoryIds'))->toBe([$itHelp->id]); }); test('saving the Snipe-IT config persists settings, encrypts the token at rest, and inverts the SSL checkbox', function () { @@ -52,6 +73,7 @@ test('saving the Snipe-IT config persists settings, encrypts the token at rest, ->set('snipeitConfig.skipSslVerification', true) ->set('snipeitConfig.clientCanSelectAsset', true) ->call('toggleSnipeitClientSubcategory', $sub->id) + ->call('toggleSnipeitClientCategory', $category->id) ->set('snipeitConfig.operatorViewRequesterAssets', true) ->set('snipeitConfig.operatorSearchInventory', false) ->call('saveSnipeitConfig') @@ -64,6 +86,7 @@ test('saving the Snipe-IT config persists settings, encrypts the token at rest, expect(Settings::bool('snipeit_verify_ssl'))->toBeFalse(); expect(Settings::bool('snipeit_client_can_select_asset'))->toBeTrue(); expect(Settings::get('snipeit_client_asset_subcategory_ids'))->toBe((string) $sub->id); + expect(Settings::get('snipeit_client_asset_category_ids'))->toBe((string) $category->id); expect(Settings::bool('snipeit_operator_view_requester_assets'))->toBeTrue(); expect(Settings::bool('snipeit_operator_search_inventory'))->toBeFalse(); diff --git a/src/tests/Feature/OperatorQueueSearchSortColumnsTest.php b/src/tests/Feature/OperatorQueueSearchSortColumnsTest.php index 139d4fc..ed376a5 100644 --- a/src/tests/Feature/OperatorQueueSearchSortColumnsTest.php +++ b/src/tests/Feature/OperatorQueueSearchSortColumnsTest.php @@ -45,7 +45,7 @@ test('sla is not a clickable sortable column', function () { Livewire::actingAs($operator)->test(Queue::class) ->call('sortByColumn', 'sla') - ->assertSet('sortBy', 'updated_at'); + ->assertSet('sortBy', 'created'); }); test('columns can be hidden and shown again, but at least one must stay visible', function () { diff --git a/src/tests/Feature/SnipeitAssetLinkingTest.php b/src/tests/Feature/SnipeitAssetLinkingTest.php index e2b21f5..0714ecc 100644 --- a/src/tests/Feature/SnipeitAssetLinkingTest.php +++ b/src/tests/Feature/SnipeitAssetLinkingTest.php @@ -133,6 +133,36 @@ test('the client asset picker only shows for subcategories the admin allow-liste ->assertDontSee('SN123'); }); +test('the client asset picker shows for any subcategory of an allow-listed category, without listing that subcategory itself', function () { + seedStatusesAndPriorities(); + $email = 'client-snipeit6@example.com'; + fakeSnipeitUserAsset($email); + + $client = User::query()->create(['name' => 'Test Client', 'email' => $email, 'roles' => ['client']]); + $category = Category::query()->create(['name' => 'IT-Pomoc']); + $sub = $category->subcategories()->create(['name' => 'Awaria sprzętu']); + $otherCategory = Category::query()->create(['name' => 'Kadry']); + $otherSub = $otherCategory->subcategories()->create(['name' => 'Urlop']); + + enableSnipeitForLinkingTest(); + Settings::set('snipeit_client_asset_category_ids', (string) $category->id); + + // Any subcategory under the allow-listed category shows the picker, + // even though only the category (not this specific subcategory) is listed. + Livewire::actingAs($client)->test(ClientNewTicket::class) + ->call('selectCategory', $category->id) + ->call('selectSubcategory', $sub->id) + ->call('loadSnipeitAssets') + ->assertSee('SI-001 - SN123 - Dell Latitude 5420'); + + // A subcategory under a different, non-allow-listed category stays hidden. + Livewire::actingAs($client)->test(ClientNewTicket::class) + ->call('selectCategory', $otherCategory->id) + ->call('selectSubcategory', $otherSub->id) + ->call('loadSnipeitAssets') + ->assertDontSee('SN123'); +}); + test('changing subcategory clears a previously selected asset', function () { seedStatusesAndPriorities(); $email = 'client-snipeit5@example.com'; diff --git a/src/tests/Feature/TabPersistenceAndNavigationTest.php b/src/tests/Feature/TabPersistenceAndNavigationTest.php index 1e43c53..db7e141 100644 --- a/src/tests/Feature/TabPersistenceAndNavigationTest.php +++ b/src/tests/Feature/TabPersistenceAndNavigationTest.php @@ -1,8 +1,11 @@ test(NotificationPreferences::class) ->assertSeeHtml(route('operator.queue')); }); + +test('the operator ticket page\'s "Wróć do listy" link returns to whichever queue tab was actually active', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('backlink-op@example.com'); + $ticket = makeTicket(['number' => '7001', 'status_key' => 'closed']); + + // Switching tabs (as clicking the sidebar would) remembers it in the session. + Livewire::actingAs($operator)->test(Queue::class)->call('setQueue', 'closed'); + + Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket]) + ->assertSeeHtml(route('operator.queue', ['queue' => 'closed'])); +}); + +test('the operator ticket page\'s back link omits the query string for the default "all" tab', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('backlink-op-default@example.com'); + $ticket = makeTicket(['number' => '7002']); + + Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket]) + ->assertSeeHtml(route('operator.queue')) + ->assertDontSeeHtml(route('operator.queue', ['queue' => 'all'])); +}); + +test('the client ticket page\'s "Wróć do listy" link returns to the Archiwum tab when that\'s where the ticket was opened from', function () { + seedStatusesAndPriorities(); + $client = User::query()->create(['name' => 'Client', 'email' => 'backlink-client@example.com', 'roles' => ['client']]); + $ticket = makeTicket(['number' => '7003', 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name, 'status_key' => 'closed']); + + Livewire::actingAs($client)->test(Dashboard::class)->call('setTab', 'archive'); + + Livewire::actingAs($client)->test(App\Livewire\Client\TicketShow::class, ['ticket' => $ticket]) + ->assertSeeHtml(route('client.dashboard', ['tab' => 'archive'])); +}); diff --git a/wiki/admin/README.md b/wiki/admin/README.md index 144339c..fa2d983 100644 --- a/wiki/admin/README.md +++ b/wiki/admin/README.md @@ -297,11 +297,16 @@ razem, zamiast być rozrzucone po różnych zakładkach. - **Klient może wybrać sprzęt, którego dotyczy zgłoszenie** — przy tworzeniu zgłoszenia klient widzi listę swojego sprzętu z Snipe-IT (dopasowanego po adresie e-mail) i może je powiązać ze zgłoszeniem. Po - zaznaczeniu pojawia się dodatkowa lista wielokrotnego wyboru **„Ogranicz - do podkategorii”** — wybór sprzętu pokaże się klientowi **tylko** dla - zaznaczonych tam podkategorii; jeśli nic nie jest zaznaczone, opcja nie - pojawi się w żadnej podkategorii (tak samo jak dozwolone półki BookStack - wyżej — trzeba świadomie wskazać zakres). + zaznaczeniu pojawiają się dwie dodatkowe listy wielokrotnego wyboru: + **„Ogranicz do kategorii”** — najszybszy sposób, żeby włączyć wybór + sprzętu dla wszystkich podkategorii naraz w zaznaczonych tu kategoriach + (np. zaznacz „IT-Pomoc” i „Zamówienia”, żeby objąć każdą ich + podkategorię) — oraz **„Ogranicz do podkategorii”**, do doprecyzowania + pojedynczych podkategorii niezależnie od tego, czy ich kategoria jest + zaznaczona wyżej. Wybór sprzętu pokaże się klientowi tylko tam, gdzie + trafi w którąkolwiek z dwóch list; jeśli obie są puste, opcja nie pojawi + się w żadnej podkategorii (tak samo jak dozwolone półki BookStack wyżej — + trzeba świadomie wskazać zakres). - **Operator może zobaczyć sprzęt zgłaszającego w widoku zgłoszenia** — ta sama lista sprzętu zgłaszającego, tym razem w panelu bocznym operatora na widoku zgłoszenia, z przyciskiem „Powiąż” przy każdej pozycji. diff --git a/wiki/client/README.md b/wiki/client/README.md index 263c998..1ae61a3 100644 --- a/wiki/client/README.md +++ b/wiki/client/README.md @@ -43,7 +43,8 @@ Dashboard klienta dzieli zgłoszenia na dwie zakładki: - **Bieżące** — zgłoszenia jeszcze nie zamknięte. - **Archiwum** — zgłoszenia zamknięte. -Pole wyszukiwania nad listą przeszukuje numer, temat i treść zgłoszenia (oraz +Obie listy pokazują zgłoszenia od najnowszych (wg daty utworzenia). Pole +wyszukiwania nad listą przeszukuje numer, temat i treść zgłoszenia (oraz odpowiedzi w wątku). Obie zakładki są stronicowane (20 zgłoszeń na stronę), niezależnie od siebie — przełączenie zakładki nie cofa Cię na pierwszą stronę drugiej. **Ctrl+K**/**Cmd+K** otwiera też globalną wyszukiwarkę zgłoszeń z diff --git a/wiki/operator/README.md b/wiki/operator/README.md index 331eac5..469f437 100644 --- a/wiki/operator/README.md +++ b/wiki/operator/README.md @@ -51,10 +51,14 @@ prowadzi od razu do kolejki przefiltrowanej do tego klienta. pokazuje zgłoszenia bez przypisanej kategorii i podkategorii — np. z poczty IMAP, która nie trafiła w żadną), wyszukiwanie po numerze/ temacie/kliencie/treści zgłoszenia i odpowiedzi w wątku. **Kolumny** można dowolnie -włączać/wyłączać przyciskiem „Kolumny” (numer, temat, klient, kategoria, -podkategoria, priorytet, status, SLA, przypisany, zespół, utworzono — kilka z -nich domyślnie ukryte), a nagłówki kolumn sortują listę. Wybrana zakładka i -kolumny zostają zapamiętane w adresie strony, więc odświeżenie nie cofa Cię do +włączać/wyłączać przyciskiem „Kolumny” (ID, numer, temat, klient, e-mail, +kategoria, podkategoria, priorytet, status, SLA, przypisany, zespół, źródło, +utworzono, zaktualizowano — kilka z nich domyślnie ukryte) oraz zmieniać ich +kolejność strzałkami ↑/↓ przy każdej widocznej kolumnie na tej samej liście, a +nagłówki kolumn sortują listę — domyślnie po dacie utworzenia, najnowsze na +górze. Które kolumny są widoczne i w jakiej kolejności zapamiętuje się +automatycznie na Twoim koncie (nie trzeba do tego zapisywać widoku). Wybrana +zakładka zostaje zapamiętana w adresie strony, więc odświeżenie nie cofa Cię do pierwszej zakładki. **Zapisane widoki** — przycisk „Zapisane widoki” pozwala zapisać bieżącą @@ -143,6 +147,10 @@ W widoku pojedynczego zgłoszenia: jest automatycznie wstrzymywane, gdy zgłoszenie ma status zamknięty — nie uruchomi się przy otwarciu zamkniętego zgłoszenia ani nie będzie dalej biec po jego zamknięciu; wcześniej naliczony czas można wciąż ręcznie skorygować. + Samo otwarcie zgłoszenia (i uruchomienie/zatrzymanie licznika w tle) nie + liczy się jako aktualizacja — kolumna „Zaktualizowano” w kolejce odzwierciedla + wyłącznie realne zmiany (odpowiedź, zmiana statusu/priorytetu/przypisania + itp.), nie samo przeglądanie zgłoszenia. - **Edycja danych zgłoszenia** — temat, opis, podkategoria, pola dodatkowe; zmiana kategorii może wysłać powiadomienie do klienta. - **Historia** — log każdej zmiany (status, priorytet, zespół, przypisanie,