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.