1 Commits
v1.5.0 ... main

Author SHA1 Message Date
0943829331 v1.5.1
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 <noreply@anthropic.com>
2026-08-06 09:01:34 +02:00
28 changed files with 684 additions and 104 deletions

View File

@@ -45,7 +45,8 @@ Category ─< Subcategory ─< CustomField (per-subcategory custom fields
└── csat_rating/csat_comment/csat_rated_at (nullable — set once, on close) └── csat_rating/csat_comment/csat_rated_at (nullable — set once, on close)
User ─< UserFieldValue >─ UserField 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') User ─< notifications (Laravel's database channel — polymorphic, morph-mapped as 'user')
ApiClient (Sanctum token owner, ability-scoped) ApiClient (Sanctum token owner, ability-scoped)
Setting (single-row-per-key config store, see below) 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. anything else, so a typo'd literal fails loudly instead of sticking silently.
Add new values to the constant before writing them anywhere. 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 ## Ticket numbering & URLs
A ticket carries three distinct identifiers, each with a different job: 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 none of them affect `SnipeItClient` itself, only which Livewire methods are
willing to call it: willing to call it:
- `snipeit_client_can_select_asset` (+ `snipeit_client_asset_subcategory_ids`, - `snipeit_client_can_select_asset` (+ `snipeit_client_asset_subcategory_ids`
a comma-separated allow-list) — gates `Client\NewTicket`'s asset picker. and `snipeit_client_asset_category_ids`, two independent comma-separated
Mirrors BookStack's shelf allow-lists: an **empty** subcategory list means allow-lists) — gates `Client\NewTicket`'s asset picker. Mirrors BookStack's
the picker never shows for any subcategory, not "every subcategory" — shelf allow-lists: **empty** lists mean the picker never shows for any
`NewTicket::snipeitAssets()` checks both the toggle and that the currently subcategory, not "every subcategory" — `NewTicket::snipeitAssets()` checks
selected `subcategoryId` is in the list before calling the toggle and that *either* the currently selected `subcategoryId` is in
`assetsForEmail()`. `selectCategory()`/`selectSubcategory()` reset any the subcategory list *or* `categoryId` is in the (coarser) category list
already-picked asset, so switching to an out-of-scope subcategory can't before calling `assetsForEmail()`. The category list exists so an admin can
silently carry a stale selection through to `submit()`. 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 - `snipeit_operator_view_requester_assets` — gates the same
`assetsForEmail()` lookup (by the ticket's own `email`, not the viewing `assetsForEmail()` lookup (by the ticket's own `email`, not the viewing
operator's) in `Operator\TicketShow`'s sidebar. operator's) in `Operator\TicketShow`'s sidebar.

View File

@@ -3,6 +3,51 @@
All notable changes to this project are documented in this file. Format loosely All notable changes to this project are documented in this file. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). 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 ## [1.5.0] - 2026-08-05
### Added ### Added

View File

@@ -131,6 +131,24 @@ reason — never add a `public/icons/` directory.
attributes on `<td>`s. Currently applied to the operator ticket queue; apply attributes on `<td>`s. Currently applied to the operator ticket queue; apply
the same treatment to any other wide table you add or make mobile-relevant the same treatment to any other wide table you add or make mobile-relevant
(admin panel tables don't have it yet). (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 ## Testing & code style

View File

@@ -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 assignee, custom fields (per subcategory), attachments, full message thread
(public replies + internal notes), history log, merge, delete. The operator (public replies + internal notes), history log, merge, delete. The operator
queue (50/page) and client dashboard (20/page, current/archive tracked 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 - **SLA** — per-priority response/resolution time targets; a scheduled command
(`tickets:check-sla-breaches`, every 15 min) flags overdue tickets and can notify (`tickets:check-sla-breaches`, every 15 min) flags overdue tickets and can notify
the assigned operator. the assigned operator.

View File

@@ -252,6 +252,7 @@ class Panel extends Component
'skipSslVerification' => ! Settings::bool('snipeit_verify_ssl'), 'skipSslVerification' => ! Settings::bool('snipeit_verify_ssl'),
'clientCanSelectAsset' => Settings::bool('snipeit_client_can_select_asset'), 'clientCanSelectAsset' => Settings::bool('snipeit_client_can_select_asset'),
'clientAssetSubcategoryIds' => $this->parseShelfIds(Settings::get('snipeit_client_asset_subcategory_ids', '')), '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'), 'operatorViewRequesterAssets' => Settings::bool('snipeit_operator_view_requester_assets'),
'operatorSearchInventory' => Settings::bool('snipeit_operator_search_inventory'), 'operatorSearchInventory' => Settings::bool('snipeit_operator_search_inventory'),
]; ];
@@ -779,6 +780,14 @@ class Panel extends Component
->values(); ->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 public function openTeamForm(): void
{ {
$this->teamForm = ['id' => null, 'name' => '', 'memberIds' => [], 'subcategoryIds' => []]; $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_verify_ssl', $this->snipeitConfig['skipSslVerification'] ? '0' : '1');
Settings::set('snipeit_client_can_select_asset', $this->snipeitConfig['clientCanSelectAsset'] ? '1' : '0'); 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_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_view_requester_assets', $this->snipeitConfig['operatorViewRequesterAssets'] ? '1' : '0');
Settings::set('snipeit_operator_search_inventory', $this->snipeitConfig['operatorSearchInventory'] ? '1' : '0'); Settings::set('snipeit_operator_search_inventory', $this->snipeitConfig['operatorSearchInventory'] ? '1' : '0');
@@ -1698,6 +1708,22 @@ class Panel extends Component
: [...$ids, $id]; : [...$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 public function testSnipeitConnection(): void
{ {
$cfg = $this->snipeitConfig; $cfg = $this->snipeitConfig;

View File

@@ -6,6 +6,7 @@ use App\Models\Status;
use App\Support\Settings; use App\Support\Settings;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
@@ -15,16 +16,28 @@ class Dashboard extends Component
private const PER_PAGE = 20; private const PER_PAGE = 20;
#[Url]
public string $tab = 'current'; public string $tab = 'current';
public string $search = ''; 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() protected function baseQuery()
{ {
return Auth::user()->ticketsAsCustomer() return Auth::user()->ticketsAsCustomer()
->search($this->search) ->search($this->search)
->with('subcategory.category') ->with('subcategory.category')
->orderByDesc('updated_at'); ->orderByDesc('created_at');
} }
/** /**
@@ -54,6 +67,7 @@ class Dashboard extends Component
public function setTab(string $tab): void public function setTab(string $tab): void
{ {
$this->tab = $tab; $this->tab = $tab;
session(['client_dashboard_tab' => $tab]);
} }
public function render() public function render()

View File

@@ -53,11 +53,13 @@ class NewTicket extends Component
} }
/** /**
* Empty unless the admin turned the picker on AND allow-listed the * Empty unless the admin turned the picker on AND allow-listed either
* currently selected subcategory for it (see * the currently selected subcategory (snipeit_client_asset_subcategory_ids)
* snipeit_client_asset_subcategory_ids) an empty allow-list means * or its parent category (snipeit_client_asset_category_ids) an empty
* "no subcategory", not "every subcategory", mirroring how BookStack's * pair of allow-lists means "nowhere", not "everywhere", mirroring how
* shelf allow-lists work. * 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<int, array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, url: string}> * @return array<int, array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, url: string}>
*/ */
@@ -68,7 +70,10 @@ class NewTicket extends Component
return []; 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 []; return [];
} }
@@ -80,7 +85,23 @@ class NewTicket extends Component
*/ */
protected function snipeitAllowedSubcategoryIds(): array 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)) ->map(fn ($v) => (int) trim($v))
->filter() ->filter()
->values() ->values()

View File

@@ -48,12 +48,12 @@ class Queue extends Component
public string $search = ''; public string $search = '';
public string $sortBy = 'updated_at'; public string $sortBy = 'created';
public string $sortDir = 'desc'; public string $sortDir = 'desc';
/** @var string[] */ /** @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[] */ /** @var int[] */
public array $selectedIds = []; public array $selectedIds = [];
@@ -72,7 +72,15 @@ class Queue extends Component
*/ */
public function mount(): void 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) { if ($this->savedViewId !== null) {
$this->rememberQueueTab();
return; return;
} }
@@ -82,6 +90,20 @@ class Queue extends Component
$this->applyViewFilters($default->filters); $this->applyViewFilters($default->filters);
$this->savedViewId = $default->id; $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->visibleColumns = $filters['visibleColumns'] ?? $this->visibleColumns;
$this->selectedIds = []; $this->selectedIds = [];
$this->resetPage(); $this->resetPage();
$this->rememberQueueTab();
} }
/** /**
@@ -396,17 +419,21 @@ class Queue extends Component
$desc = $this->sortDir === 'desc'; $desc = $this->sortDir === 'desc';
$sorted = match ($this->sortBy) { $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), 'number' => $tickets->sortBy(fn (Ticket $t) => (int) $t->number, SORT_REGULAR, $desc),
'subject' => $tickets->sortBy('subject', SORT_NATURAL | SORT_FLAG_CASE, $desc), 'subject' => $tickets->sortBy('subject', SORT_NATURAL | SORT_FLAG_CASE, $desc),
'customer' => $tickets->sortBy('name', 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), '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), '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), '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), '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), '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), '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), '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(); return $sorted->values();
@@ -418,9 +445,11 @@ class Queue extends Component
public function columnDefs(): array public function columnDefs(): array
{ {
return [ return [
'id' => 'ID',
'number' => 'Numer', 'number' => 'Numer',
'subject' => 'Temat', 'subject' => 'Temat',
'customer' => 'Klient', 'customer' => 'Klient',
'email' => 'E-mail',
'category' => 'Kategoria', 'category' => 'Kategoria',
'subcategory' => 'Podkategoria', 'subcategory' => 'Podkategoria',
'priority' => 'Priorytet', 'priority' => 'Priorytet',
@@ -428,7 +457,9 @@ class Queue extends Component
'sla' => 'SLA', 'sla' => 'SLA',
'assignee' => 'Przypisany', 'assignee' => 'Przypisany',
'team' => 'Zespół', 'team' => 'Zespół',
'source' => 'Źródło',
'created' => 'Utworzono', 'created' => 'Utworzono',
'updated' => 'Zaktualizowano',
]; ];
} }
@@ -439,7 +470,7 @@ class Queue extends Component
*/ */
public function sortableColumns(): array 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 public function sortByColumn(string $column): void
@@ -467,12 +498,57 @@ class Queue extends Component
} else { } else {
$this->visibleColumns[] = $column; $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 public function setQueue(string $key): void
{ {
$this->queue = $key; $this->queue = $key;
$this->selectedIds = []; $this->selectedIds = [];
$this->rememberQueueTab();
// Every tab except "closed" now excludes closed-stage tickets (see // Every tab except "closed" now excludes closed-stage tickets (see
// queueDefs()), so a stale closed-stage status filter would silently // queueDefs()), so a stale closed-stage status filter would silently

View File

@@ -611,7 +611,7 @@ class Ticket extends Model
public function flushTimer(): void public function flushTimer(): void
{ {
if ($this->timer_started_at) { if ($this->timer_started_at) {
$this->update([ $this->updateTimerFields([
'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(), 'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(),
'timer_started_at' => now(), 'timer_started_at' => now(),
]); ]);
@@ -621,7 +621,7 @@ class Ticket extends Model
public function stopTimer(): void public function stopTimer(): void
{ {
if ($this->timer_started_at) { if ($this->timer_started_at) {
$this->update([ $this->updateTimerFields([
'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(), 'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(),
'timer_started_at' => null, 'timer_started_at' => null,
]); ]);
@@ -640,13 +640,13 @@ class Ticket extends Model
} }
if (! $this->timer_started_at) { if (! $this->timer_started_at) {
$this->update(['timer_started_at' => now()]); $this->updateTimerFields(['timer_started_at' => now()]);
} }
} }
public function resetTimer(): void 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 public function setTimeSpent(int $seconds): void
{ {
$this->update([ $this->updateTimerFields([
'time_spent_seconds' => max(0, $seconds), 'time_spent_seconds' => max(0, $seconds),
'timer_started_at' => $this->timer_started_at ? now() : null, '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. * Custom field values for this ticket's subcategory, in display order, skipping blanks.
* *

View File

@@ -14,7 +14,7 @@ use Illuminate\Notifications\Notifiable;
use LdapRecord\Laravel\Auth\AuthenticatesWithLdap; use LdapRecord\Laravel\Auth\AuthenticatesWithLdap;
use LdapRecord\Laravel\Auth\LdapAuthenticatable; 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'])] #[Hidden(['password'])]
class User extends Authenticatable implements LdapAuthenticatable class User extends Authenticatable implements LdapAuthenticatable
{ {
@@ -109,6 +109,7 @@ class User extends Authenticatable implements LdapAuthenticatable
{ {
return [ return [
'password' => 'hashed', 'password' => 'hashed',
'operator_queue_columns' => 'array',
]; ];
} }

View File

@@ -67,6 +67,7 @@ class Settings
'snipeit_verify_ssl' => '1', 'snipeit_verify_ssl' => '1',
'snipeit_client_can_select_asset' => '0', 'snipeit_client_can_select_asset' => '0',
'snipeit_client_asset_subcategory_ids' => '', 'snipeit_client_asset_subcategory_ids' => '',
'snipeit_client_asset_category_ids' => '',
'snipeit_operator_view_requester_assets' => '1', 'snipeit_operator_view_requester_assets' => '1',
'snipeit_operator_search_inventory' => '1', 'snipeit_operator_search_inventory' => '1',
'ai_enabled' => '0', 'ai_enabled' => '0',

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Remembers which operator queue table columns a user has shown/hidden
* (App\Livewire\Operator\Queue::$visibleColumns), independent of the
* named/default SavedQueueView mechanism a plain column toggle
* shouldn't require the operator to explicitly "save a view" for it to
* stick between visits. Null means "no preference saved yet, use the
* component's built-in default".
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->json('operator_queue_columns')->nullable();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('operator_queue_columns');
});
}
};

View File

@@ -55,11 +55,6 @@
[data-theme='light'] .tag-neutral { background: var(--color-neutral-200); color: var(--color-neutral-700); } [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'] .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; } * { box-sizing: border-box; }
body { 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: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; } .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 { .panel-switch {
display: inline-flex; display: inline-flex;
padding: 3px; padding: 3px;
@@ -291,9 +320,18 @@ body {
and Tailwind's @layer'd rules always lose to unlayered ones including and Tailwind's @layer'd rules always lose to unlayered ones including
Quill's CDN stylesheet regardless of source order, so anything meant to 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. */ 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 { .login-notice {
padding: 0 14px; padding: 14px;
border-radius: 8px; border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: #17262d;
color: #eef3f4;
font-size: 13px; font-size: 13px;
line-height: 1.5; line-height: 1.5;
height: auto; height: auto;
@@ -302,10 +340,10 @@ body {
} }
.login-notice > *:first-child { margin-top: 0; } .login-notice > *:first-child { margin-top: 0; }
.login-notice > *:last-child { margin-bottom: 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 hr { border-color: rgba(255, 255, 255, 0.14); }
.login-notice-warning { background: color-mix(in srgb, var(--color-warning) 20%, transparent); color: var(--color-warning); } .login-notice-warning { border-color: color-mix(in srgb, var(--color-warning) 45%, rgba(255, 255, 255, 0.14)); }
.login-notice-success { background: color-mix(in srgb, var(--color-success) 20%, transparent); color: var(--color-success); } .login-notice-success { border-color: color-mix(in srgb, var(--color-success) 45%, rgba(255, 255, 255, 0.14)); }
.login-notice-danger { background: color-mix(in srgb, var(--color-danger) 20%, transparent); color: var(--color-danger); } .login-notice-danger { border-color: color-mix(in srgb, var(--color-danger) 45%, rgba(255, 255, 255, 0.14)); }
/* ---- Responsive layout (phones/tablets) ---- */ /* ---- Responsive layout (phones/tablets) ---- */
@@ -399,6 +437,7 @@ body {
@media (max-width: 640px) { @media (max-width: 640px) {
.page-pad { padding: 16px !important; } .page-pad { padding: 16px !important; }
.nav { padding-left: 14px !important; padding-right: 14px !important; gap: 10px; flex-wrap: wrap; } .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 /* At this width the switcher's own centered slot collides with the
brand text and the icon buttons sharing the row (nothing left to brand text and the icon buttons sharing the row (nothing left to

View File

@@ -883,6 +883,17 @@ $tabGroups = [
</div> </div>
@if ($snipeitConfig['clientCanSelectAsset']) @if ($snipeitConfig['clientCanSelectAsset'])
<div class="field">
<label>Ogranicz do kategorii</label>
<x-multiselect
:options="$this->categoriesForSnipeitForm"
:selected-ids="$snipeitConfig['clientAssetCategoryIds']"
toggle-action="toggleSnipeitClientCategory"
placeholder="Brak wybranych kategorii"
/>
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">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.</p>
</div>
<div class="field"> <div class="field">
<label>Ogranicz do podkategorii</label> <label>Ogranicz do podkategorii</label>
<x-multiselect <x-multiselect
@@ -891,7 +902,7 @@ $tabGroups = [
toggle-action="toggleSnipeitClientSubcategory" toggle-action="toggleSnipeitClientSubcategory"
placeholder="Brak wybranych podkategorii" placeholder="Brak wybranych podkategorii"
/> />
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">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.</p> <p class="text-muted" style="font-size:11.5px;margin:2px 0 0">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 puste, opcja nie pojawi się w żadnej podkategorii.</p>
</div> </div>
@endif @endif

View File

@@ -4,7 +4,7 @@
</x-topbar> </x-topbar>
<div class="page-pad" style="flex:1;display:flex;justify-content:center;align-items:center;padding:40px 20px"> <div class="page-pad" style="flex:1;display:flex;justify-content:center;align-items:center;padding:40px 20px">
<form wire:submit="submit" class="card elev-md" style="width:100%;max-width:380px;padding:28px;gap:16px"> <form wire:submit="submit" class="card elev-md" style="width:100%;max-width:480px;padding:28px;gap:16px">
<img src="{{ \App\Support\Settings::logoUrl() }}" alt="{{ \App\Support\Settings::get('company_name') }}" style="height:56px;width:auto;align-self:center"> <img src="{{ \App\Support\Settings::logoUrl() }}" alt="{{ \App\Support\Settings::get('company_name') }}" style="height:56px;width:auto;align-self:center">
<h2 style="margin:0;text-align:center">Zaloguj się</h2> <h2 style="margin:0;text-align:center">Zaloguj się</h2>
<x-login-notice :html="\App\Support\Settings::get('login_notice_html')" :type="\App\Support\Settings::loginNoticeType()" /> <x-login-notice :html="\App\Support\Settings::get('login_notice_html')" :type="\App\Support\Settings::loginNoticeType()" />

View File

@@ -17,12 +17,12 @@
<div style="display:flex;flex-direction:column;gap:10px"> <div style="display:flex;flex-direction:column;gap:10px">
@foreach (($tab === 'current' ? $this->currentTickets : $this->archiveTickets) as $ticket) @foreach (($tab === 'current' ? $this->currentTickets : $this->archiveTickets) as $ticket)
<a href="{{ route('client.ticket', $ticket) }}" wire:navigate class="card elev-sm" style="padding:16px;cursor:pointer;flex-direction:row;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;text-decoration:none;color:inherit"> <a href="{{ route('client.ticket', $ticket) }}" wire:navigate class="card elev-sm" style="padding:16px;cursor:pointer;flex-direction:row;align-items:center;justify-content:space-between;gap:12px;text-decoration:none;color:inherit">
<div> <div style="flex:1;min-width:0">
<div style="font-weight:500">{{ $ticket->displayNumber() }} {{ $ticket->subject }}</div> <div style="font-weight:500">{{ $ticket->displayNumber() }} {{ $ticket->subject }}</div>
<div class="card-meta">{{ $ticket->categoryLabel() }} &middot; {{ \App\Support\Rel::format($ticket->updated_at) }}</div> <div class="card-meta">{{ $ticket->categoryLabel() }} &middot; {{ \App\Support\Rel::format($ticket->updated_at) }}</div>
</div> </div>
<div style="display:flex;gap:6px"> <div style="display:flex;gap:6px;flex-shrink:0">
<span style="{{ $ticket->priorityStyle() }}">{{ $ticket->priorityLabel() }}</span> <span style="{{ $ticket->priorityStyle() }}">{{ $ticket->priorityLabel() }}</span>
<span style="{{ $ticket->statusStyle() }}">{{ $ticket->statusLabel() }}</span> <span style="{{ $ticket->statusStyle() }}">{{ $ticket->statusLabel() }}</span>
</div> </div>

View File

@@ -3,7 +3,8 @@
<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 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"> <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<a href="{{ route('client.dashboard') }}" wire:navigate class="btn btn-ghost" style="padding:0">&larr; Wróć do listy</a> @php $backTab = session('client_dashboard_tab', 'current'); @endphp
<a href="{{ route('client.dashboard', $backTab !== 'current' ? ['tab' => $backTab] : []) }}" wire:navigate class="btn btn-ghost" style="padding:0">&larr; Wróć do listy</a>
{{-- Live updates arrive via broadcasting, but websocket connections can {{-- Live updates arrive via broadcasting, but websocket connections can
drop silently this is a periodic fallback refresh, with a visible drop silently this is a periodic fallback refresh, with a visible

View File

@@ -129,13 +129,28 @@
<span class="material-symbols-outlined" style="font-size:18px">view_column</span> <span class="material-symbols-outlined" style="font-size:18px">view_column</span>
Kolumny Kolumny
</button> </button>
<div x-show="typeof open !== 'undefined' && 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"> <div x-show="typeof open !== 'undefined' && 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:220px">
@foreach ($columnDefs as $key => $label) @foreach ($visibleColumns as $i => $key)
<label style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:400;padding:6px 8px;border-radius:5px;cursor:pointer"> <div style="display:flex;align-items:center;gap:4px;padding:2px 2px 2px 8px;border-radius:5px">
<input type="checkbox" @checked(in_array($key, $visibleColumns)) wire:click="toggleColumn('{{ $key }}')"> <label style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:400;flex:1;min-width:0;cursor:pointer">
<input type="checkbox" checked wire:click="toggleColumn('{{ $key }}')">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $columnDefs[$key] ?? $key }}</span>
</label>
<span class="material-symbols-outlined" style="font-size:16px;flex:none;cursor:{{ $i === 0 ? 'default' : 'pointer' }};opacity:{{ $i === 0 ? '0.25' : '0.7' }}" title="Przesuń wcześniej (w lewo w tabeli)" wire:click="moveColumnUp('{{ $key }}')">arrow_upward</span>
<span class="material-symbols-outlined" style="font-size:16px;flex:none;cursor:{{ $i === count($visibleColumns) - 1 ? 'default' : 'pointer' }};opacity:{{ $i === count($visibleColumns) - 1 ? '0.25' : '0.7' }}" title="Przesuń później (w prawo w tabeli)" wire:click="moveColumnDown('{{ $key }}')">arrow_downward</span>
</div>
@endforeach
@php $hiddenColumnDefs = array_diff_key($columnDefs, array_flip($visibleColumns)); @endphp
@if (! empty($hiddenColumnDefs))
<div style="border-top:1px solid var(--color-divider);margin:4px 0"></div>
@foreach ($hiddenColumnDefs 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;opacity:0.75">
<input type="checkbox" wire:click="toggleColumn('{{ $key }}')">
{{ $label }} {{ $label }}
</label> </label>
@endforeach @endforeach
@endif
</div> </div>
</div> </div>
@@ -177,8 +192,8 @@
<thead> <thead>
<tr> <tr>
<th><input type="checkbox" @checked($this->filteredTickets->isNotEmpty() && empty($this->filteredTickets->pluck('id')->diff($selectedIds)->all())) wire:click="toggleSelectAll" title="Zaznacz wszystkie"></th> <th><input type="checkbox" @checked($this->filteredTickets->isNotEmpty() && empty($this->filteredTickets->pluck('id')->diff($selectedIds)->all())) wire:click="toggleSelectAll" title="Zaznacz wszystkie"></th>
@foreach ($columnDefs as $key => $label) @foreach ($visibleColumns as $key)
@continue(! in_array($key, $visibleColumns)) @php $label = $columnDefs[$key] ?? $key; @endphp
<th> <th>
@if (in_array($key, $sortableColumns)) @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"> <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">
@@ -199,44 +214,60 @@
@php $sla = $t->slaInfo(); @endphp @php $sla = $t->slaInfo(); @endphp
<tr wire:key="ticket-{{ $t->id }}"> <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> <td class="td-select"><input type="checkbox" @checked(in_array($t->id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})"></td>
@if (in_array('number', $visibleColumns)) @foreach ($visibleColumns as $key)
@switch($key)
@case('id')
<td data-label="ID">{{ $t->id }}</td>
@break
@case('number')
<td data-label="Numer" class="td-title"> <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> <a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->displayNumber() }}</a>
@if ($t->source === 'email') @if ($t->source === 'email')
<span class="material-symbols-outlined" style="font-size:15px;vertical-align:-3px;opacity:0.7" title="Utworzone przez e-mail">mail</span> <span class="material-symbols-outlined" style="font-size:15px;vertical-align:-3px;opacity:0.7" title="Utworzone przez e-mail">mail</span>
@endif @endif
</td> </td>
@endif @break
@if (in_array('subject', $visibleColumns)) @case('subject')
<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> <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 @break
@if (in_array('customer', $visibleColumns)) @case('customer')
<td data-label="Klient" style="white-space:nowrap">{{ $t->name }}</td> <td data-label="Klient" style="white-space:nowrap">{{ $t->name }}</td>
@endif @break
@if (in_array('category', $visibleColumns)) @case('email')
<td data-label="E-mail" style="white-space:nowrap">{{ $t->email }}</td>
@break
@case('category')
<td data-label="Kategoria" style="white-space:nowrap">{{ $t->categoryLabel() }}</td> <td data-label="Kategoria" style="white-space:nowrap">{{ $t->categoryLabel() }}</td>
@endif @break
@if (in_array('subcategory', $visibleColumns)) @case('subcategory')
<td data-label="Podkategoria" style="white-space:nowrap">{{ $t->subcategory?->name ?? '—' }}</td> <td data-label="Podkategoria" style="white-space:nowrap">{{ $t->subcategory?->name ?? '—' }}</td>
@endif @break
@if (in_array('priority', $visibleColumns)) @case('priority')
<td data-label="Priorytet"><span style="{{ $t->priorityStyle() }}">{{ $t->priorityLabel() }}</span></td> <td data-label="Priorytet"><span style="{{ $t->priorityStyle() }}">{{ $t->priorityLabel() }}</span></td>
@endif @break
@if (in_array('status', $visibleColumns)) @case('status')
<td data-label="Status"><span style="{{ $t->statusStyle() }}">{{ $t->statusLabel() }}</span></td> <td data-label="Status"><span style="{{ $t->statusStyle() }}">{{ $t->statusLabel() }}</span></td>
@endif @break
@if (in_array('sla', $visibleColumns)) @case('sla')
<td data-label="SLA"><span class="{{ $sla['cls'] }}">{{ $sla['short'] }}</span></td> <td data-label="SLA"><span class="{{ $sla['cls'] }}">{{ $sla['short'] }}</span></td>
@endif @break
@if (in_array('assignee', $visibleColumns)) @case('assignee')
<td data-label="Przypisany" style="white-space:nowrap">{{ $t->assignee?->name ?? 'Nieprzypisane' }}</td> <td data-label="Przypisany" style="white-space:nowrap">{{ $t->assignee?->name ?? 'Nieprzypisane' }}</td>
@endif @break
@if (in_array('team', $visibleColumns)) @case('team')
<td data-label="Zespół" style="white-space:nowrap">{{ $t->team?->name ?? '—' }}</td> <td data-label="Zespół" style="white-space:nowrap">{{ $t->team?->name ?? '—' }}</td>
@endif @break
@if (in_array('created', $visibleColumns)) @case('source')
<td data-label="Źródło" style="white-space:nowrap">{{ match ($t->source) { 'email' => 'E-mail', 'hesk_import' => 'Import HESK', default => 'WWW' } }}</td>
@break
@case('created')
<td data-label="Utworzono" style="white-space:nowrap">{{ \App\Support\Rel::format($t->created_at) }}</td> <td data-label="Utworzono" style="white-space:nowrap">{{ \App\Support\Rel::format($t->created_at) }}</td>
@endif @break
@case('updated')
<td data-label="Zaktualizowano" style="white-space:nowrap">{{ \App\Support\Rel::format($t->updated_at) }}</td>
@break
@endswitch
@endforeach
</tr> </tr>
@endforeach @endforeach
</tbody> </tbody>

View File

@@ -4,7 +4,8 @@
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto"> <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"> <div style="display:flex;flex-direction:column;gap:16px;max-width:1180px;margin:0 auto">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap"> <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="padding:0;margin-right:auto">&larr; Wróć do listy</a> @php $backQueueTab = session('operator_queue_tab', 'all'); @endphp
<a href="{{ route('operator.queue', $backQueueTab !== 'all' ? ['queue' => $backQueueTab] : []) }}" wire:navigate class="btn btn-ghost" style="padding:0;margin-right:auto">&larr; Wróć do listy</a>
<button <button
type="button" type="button"

View File

@@ -0,0 +1,70 @@
@php
if (! isset($scrollTo)) {
$scrollTo = 'body';
}
$scrollIntoViewJsSnippet = ($scrollTo !== false)
? <<<JS
(\$el.closest('{$scrollTo}') || document.querySelector('{$scrollTo}')).scrollIntoView()
JS
: '';
@endphp
<div>
@if ($paginator->hasPages())
<nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" class="pagination-wrap">
<p class="pagination-summary">
{!! __('Showing') !!}
<strong>{{ $paginator->firstItem() }}</strong> {!! __('to') !!} <strong>{{ $paginator->lastItem() }}</strong>
{!! __('of') !!} <strong>{{ $paginator->total() }}</strong> {!! __('results') !!}
</p>
<span class="pagination-links">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<span aria-disabled="true" aria-label="{{ __('pagination.previous') }}">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>
</span>
@else
<button type="button" wire:click="previousPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="previousPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}.before" aria-label="{{ __('pagination.previous') }}">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>
</button>
@endif
{{-- Pagination Elements --}}
@foreach ($elements as $element)
{{-- "Three Dots" Separator --}}
@if (is_string($element))
<span aria-disabled="true" class="pagination-dots">{{ $element }}</span>
@endif
{{-- Array Of Links --}}
@if (is_array($element))
@foreach ($element as $page => $url)
<span wire:key="paginator-{{ $paginator->getPageName() }}-page{{ $page }}">
@if ($page == $paginator->currentPage())
<span aria-current="page"><span>{{ $page }}</span></span>
@else
<button type="button" wire:click="gotoPage({{ $page }}, '{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" aria-label="{{ __('Go to page :page', ['page' => $page]) }}">
{{ $page }}
</button>
@endif
</span>
@endforeach
@endif
@endforeach
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<button type="button" wire:click="nextPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="nextPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}.before" aria-label="{{ __('pagination.next') }}">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /></svg>
</button>
@else
<span aria-disabled="true" aria-label="{{ __('pagination.next') }}">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /></svg>
</span>
@endif
</span>
</nav>
@endif
</div>

View File

@@ -0,0 +1,56 @@
@if ($paginator->hasPages())
<nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" class="pagination-wrap">
<p class="pagination-summary">
{!! __('Showing') !!}
@if ($paginator->firstItem())
<strong>{{ $paginator->firstItem() }}</strong> {!! __('to') !!} <strong>{{ $paginator->lastItem() }}</strong>
@else
{{ $paginator->count() }}
@endif
{!! __('of') !!} <strong>{{ $paginator->total() }}</strong> {!! __('results') !!}
</p>
<span class="pagination-links">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<span aria-disabled="true" aria-label="{{ __('pagination.previous') }}">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>
</span>
@else
<a href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="{{ __('pagination.previous') }}">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>
</a>
@endif
{{-- Pagination Elements --}}
@foreach ($elements as $element)
{{-- "Three Dots" Separator --}}
@if (is_string($element))
<span aria-disabled="true" class="pagination-dots">{{ $element }}</span>
@endif
{{-- Array Of Links --}}
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $paginator->currentPage())
<span aria-current="page"><span>{{ $page }}</span></span>
@else
<a href="{{ $url }}" aria-label="{{ __('Go to page :page', ['page' => $page]) }}">{{ $page }}</a>
@endif
@endforeach
@endif
@endforeach
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<a href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="{{ __('pagination.next') }}">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /></svg>
</a>
@else
<span aria-disabled="true" aria-label="{{ __('pagination.next') }}">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /></svg>
</span>
@endif
</span>
</nav>
@endif

View File

@@ -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']) Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->set('snipeitConfig.enabled', true) ->set('snipeitConfig.enabled', true)
->assertDontSee('Ogranicz do podkategorii') ->assertDontSee('Ogranicz do podkategorii')
->assertDontSee('Ogranicz do kategorii')
->set('snipeitConfig.clientCanSelectAsset', true) ->set('snipeitConfig.clientCanSelectAsset', true)
->assertSee('Ogranicz do podkategorii') ->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 () { 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.skipSslVerification', true)
->set('snipeitConfig.clientCanSelectAsset', true) ->set('snipeitConfig.clientCanSelectAsset', true)
->call('toggleSnipeitClientSubcategory', $sub->id) ->call('toggleSnipeitClientSubcategory', $sub->id)
->call('toggleSnipeitClientCategory', $category->id)
->set('snipeitConfig.operatorViewRequesterAssets', true) ->set('snipeitConfig.operatorViewRequesterAssets', true)
->set('snipeitConfig.operatorSearchInventory', false) ->set('snipeitConfig.operatorSearchInventory', false)
->call('saveSnipeitConfig') ->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_verify_ssl'))->toBeFalse();
expect(Settings::bool('snipeit_client_can_select_asset'))->toBeTrue(); 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_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_view_requester_assets'))->toBeTrue();
expect(Settings::bool('snipeit_operator_search_inventory'))->toBeFalse(); expect(Settings::bool('snipeit_operator_search_inventory'))->toBeFalse();

View File

@@ -45,7 +45,7 @@ test('sla is not a clickable sortable column', function () {
Livewire::actingAs($operator)->test(Queue::class) Livewire::actingAs($operator)->test(Queue::class)
->call('sortByColumn', 'sla') ->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 () { test('columns can be hidden and shown again, but at least one must stay visible', function () {

View File

@@ -133,6 +133,36 @@ test('the client asset picker only shows for subcategories the admin allow-liste
->assertDontSee('SN123'); ->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 () { test('changing subcategory clears a previously selected asset', function () {
seedStatusesAndPriorities(); seedStatusesAndPriorities();
$email = 'client-snipeit5@example.com'; $email = 'client-snipeit5@example.com';

View File

@@ -1,8 +1,11 @@
<?php <?php
use App\Livewire\Admin\Panel; use App\Livewire\Admin\Panel;
use App\Livewire\Client\Dashboard;
use App\Livewire\Operator\Queue; use App\Livewire\Operator\Queue;
use App\Livewire\Operator\TicketShow;
use App\Livewire\Settings\NotificationPreferences; use App\Livewire\Settings\NotificationPreferences;
use App\Models\User;
use Livewire\Livewire; use Livewire\Livewire;
test('the admin panel remembers the active tab across a fresh page load via the URL', function () { test('the admin panel remembers the active tab across a fresh page load via the URL', function () {
@@ -37,3 +40,36 @@ test('the notifications settings page has a back link to the operator queue', fu
Livewire::actingAs($operator)->test(NotificationPreferences::class) Livewire::actingAs($operator)->test(NotificationPreferences::class)
->assertSeeHtml(route('operator.queue')); ->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']));
});

View File

@@ -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 - **Klient może wybrać sprzęt, którego dotyczy zgłoszenie** — przy
tworzeniu zgłoszenia klient widzi listę swojego sprzętu z Snipe-IT 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 (dopasowanego po adresie e-mail) i może je powiązać ze zgłoszeniem. Po
zaznaczeniu pojawia się dodatkowa lista wielokrotnego wyboru **„Ogranicz zaznaczeniu pojawia się dwie dodatkowe listy wielokrotnego wyboru:
do podkategorii”** — wybór sprzętu pokaże się klientowi **tylko** dla **„Ogranicz do kategorii”** — najszybszy sposób, żeby włączyć wybór
zaznaczonych tam podkategorii; jeśli nic nie jest zaznaczone, opcja nie sprzętu dla wszystkich podkategorii naraz w zaznaczonych tu kategoriach
pojawi się w żadnej podkategorii (tak samo jak dozwolone półki BookStack (np. zaznacz „IT-Pomoc” i „Zamówienia”, żeby objąć każdą ich
wyżej — trzeba świadomie wskazać zakres). 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 - **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 sama lista sprzętu zgłaszającego, tym razem w panelu bocznym operatora
na widoku zgłoszenia, z przyciskiem „Powiąż” przy każdej pozycji. na widoku zgłoszenia, z przyciskiem „Powiąż” przy każdej pozycji.

View File

@@ -43,7 +43,8 @@ Dashboard klienta dzieli zgłoszenia na dwie zakładki:
- **Bieżące** — zgłoszenia jeszcze nie zamknięte. - **Bieżące** — zgłoszenia jeszcze nie zamknięte.
- **Archiwum** — zgłoszenia 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ę), 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ę 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 drugiej. **Ctrl+K**/**Cmd+K** otwiera też globalną wyszukiwarkę zgłoszeń z

View File

@@ -51,10 +51,14 @@ prowadzi od razu do kolejki przefiltrowanej do tego klienta.
pokazuje zgłoszenia bez przypisanej kategorii i podkategorii — np. z poczty pokazuje zgłoszenia bez przypisanej kategorii i podkategorii — np. z poczty
IMAP, która nie trafiła w żadną), wyszukiwanie po numerze/ 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 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, włączać/wyłączać przyciskiem „Kolumny” (ID, numer, temat, klient, e-mail,
podkategoria, priorytet, status, SLA, przypisany, zespół, utworzono — kilka z kategoria, podkategoria, priorytet, status, SLA, przypisany, zespół, źródło,
nich domyślnie ukryte), a nagłówki kolumn sortują listę. Wybrana zakładka i utworzono, zaktualizowano — kilka z nich domyślnie ukryte) oraz zmieniać ich
kolumny zostają zapamiętane w adresie strony, więc odświeżenie nie cofa Cię do 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. pierwszej zakładki.
**Zapisane widoki** — przycisk „Zapisane widoki” pozwala zapisać bieżącą **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 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 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ć. 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; - **Edycja danych zgłoszenia** — temat, opis, podkategoria, pola dodatkowe;
zmiana kategorii może wysłać powiadomienie do klienta. zmiana kategorii może wysłać powiadomienie do klienta.
- **Historia** — log każdej zmiany (status, priorytet, zespół, przypisanie, - **Historia** — log każdej zmiany (status, priorytet, zespół, przypisanie,