Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0943829331 |
@@ -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.
|
||||||
|
|||||||
45
CHANGELOG.md
45
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
18
CLAUDE.md
18
CLAUDE.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 są puste, opcja nie pojawi się w żadnej podkategorii.</p>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
|||||||
@@ -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()" />
|
||||||
|
|||||||
@@ -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() }} · {{ \App\Support\Rel::format($ticket->updated_at) }}</div>
|
<div class="card-meta">{{ $ticket->categoryLabel() }} · {{ \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>
|
||||||
|
|||||||
@@ -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">← 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">← 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
|
||||||
|
|||||||
@@ -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">
|
||||||
{{ $label }}
|
<input type="checkbox" checked wire:click="toggleColumn('{{ $key }}')">
|
||||||
</label>
|
<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
|
@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>
|
||||||
|
@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)
|
||||||
<td data-label="Numer" class="td-title">
|
@switch($key)
|
||||||
<a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->displayNumber() }}</a>
|
@case('id')
|
||||||
@if ($t->source === 'email')
|
<td data-label="ID">{{ $t->id }}</td>
|
||||||
<span class="material-symbols-outlined" style="font-size:15px;vertical-align:-3px;opacity:0.7" title="Utworzone przez e-mail">mail</span>
|
@break
|
||||||
@endif
|
@case('number')
|
||||||
</td>
|
<td data-label="Numer" class="td-title">
|
||||||
@endif
|
<a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->displayNumber() }}</a>
|
||||||
@if (in_array('subject', $visibleColumns))
|
@if ($t->source === 'email')
|
||||||
<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>
|
<span class="material-symbols-outlined" style="font-size:15px;vertical-align:-3px;opacity:0.7" title="Utworzone przez e-mail">mail</span>
|
||||||
@endif
|
@endif
|
||||||
@if (in_array('customer', $visibleColumns))
|
</td>
|
||||||
<td data-label="Klient" style="white-space:nowrap">{{ $t->name }}</td>
|
@break
|
||||||
@endif
|
@case('subject')
|
||||||
@if (in_array('category', $visibleColumns))
|
<td data-label="Temat" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer;white-space:nowrap">{{ $t->subject }}</a></td>
|
||||||
<td data-label="Kategoria" style="white-space:nowrap">{{ $t->categoryLabel() }}</td>
|
@break
|
||||||
@endif
|
@case('customer')
|
||||||
@if (in_array('subcategory', $visibleColumns))
|
<td data-label="Klient" style="white-space:nowrap">{{ $t->name }}</td>
|
||||||
<td data-label="Podkategoria" style="white-space:nowrap">{{ $t->subcategory?->name ?? '—' }}</td>
|
@break
|
||||||
@endif
|
@case('email')
|
||||||
@if (in_array('priority', $visibleColumns))
|
<td data-label="E-mail" style="white-space:nowrap">{{ $t->email }}</td>
|
||||||
<td data-label="Priorytet"><span style="{{ $t->priorityStyle() }}">{{ $t->priorityLabel() }}</span></td>
|
@break
|
||||||
@endif
|
@case('category')
|
||||||
@if (in_array('status', $visibleColumns))
|
<td data-label="Kategoria" style="white-space:nowrap">{{ $t->categoryLabel() }}</td>
|
||||||
<td data-label="Status"><span style="{{ $t->statusStyle() }}">{{ $t->statusLabel() }}</span></td>
|
@break
|
||||||
@endif
|
@case('subcategory')
|
||||||
@if (in_array('sla', $visibleColumns))
|
<td data-label="Podkategoria" style="white-space:nowrap">{{ $t->subcategory?->name ?? '—' }}</td>
|
||||||
<td data-label="SLA"><span class="{{ $sla['cls'] }}">{{ $sla['short'] }}</span></td>
|
@break
|
||||||
@endif
|
@case('priority')
|
||||||
@if (in_array('assignee', $visibleColumns))
|
<td data-label="Priorytet"><span style="{{ $t->priorityStyle() }}">{{ $t->priorityLabel() }}</span></td>
|
||||||
<td data-label="Przypisany" style="white-space:nowrap">{{ $t->assignee?->name ?? 'Nieprzypisane' }}</td>
|
@break
|
||||||
@endif
|
@case('status')
|
||||||
@if (in_array('team', $visibleColumns))
|
<td data-label="Status"><span style="{{ $t->statusStyle() }}">{{ $t->statusLabel() }}</span></td>
|
||||||
<td data-label="Zespół" style="white-space:nowrap">{{ $t->team?->name ?? '—' }}</td>
|
@break
|
||||||
@endif
|
@case('sla')
|
||||||
@if (in_array('created', $visibleColumns))
|
<td data-label="SLA"><span class="{{ $sla['cls'] }}">{{ $sla['short'] }}</span></td>
|
||||||
<td data-label="Utworzono" style="white-space:nowrap">{{ \App\Support\Rel::format($t->created_at) }}</td>
|
@break
|
||||||
@endif
|
@case('assignee')
|
||||||
|
<td data-label="Przypisany" style="white-space:nowrap">{{ $t->assignee?->name ?? 'Nieprzypisane' }}</td>
|
||||||
|
@break
|
||||||
|
@case('team')
|
||||||
|
<td data-label="Zespół" style="white-space:nowrap">{{ $t->team?->name ?? '—' }}</td>
|
||||||
|
@break
|
||||||
|
@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>
|
||||||
|
@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>
|
||||||
|
|||||||
@@ -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">← 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">← Wróć do listy</a>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
70
src/resources/views/vendor/livewire/tailwind.blade.php
vendored
Normal file
70
src/resources/views/vendor/livewire/tailwind.blade.php
vendored
Normal 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>
|
||||||
56
src/resources/views/vendor/pagination/tailwind.blade.php
vendored
Normal file
56
src/resources/views/vendor/pagination/tailwind.blade.php
vendored
Normal 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
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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 () {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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']));
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 pojawiają 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user