2 Commits

Author SHA1 Message Date
0943829331 v1.5.1
Co nowego:
- Kolumny kolejki operatora: dwie nowe (ID, e-mail) obok istniejących, oraz
  możliwość zmiany kolejności widocznych kolumn strzałkami ↑/↓ w picker
  „Kolumny” — nie tylko włączanie/wyłączanie. Kolejność zapamiętywana jest
  per operator tak samo jak dotąd widoczność.
- Wybór sprzętu klienta (Snipe-IT): druga lista dozwolonych kategorii obok
  istniejącej listy podkategorii — pozwala objąć od razu wszystkie
  podkategorie danej kategorii jednym zaznaczeniem.
- Pulpit klienta i widok zgłoszenia operatora pamiętają teraz aktywną
  zakładkę, więc „Wróć do listy” wraca do tej samej, a nie zawsze do
  domyślnej.

Poprawki:
- Paginacja (kolejka operatora, pulpit klienta) używała domyślnego,
  szarego stylu Laravela reagującego na motyw systemu/przeglądarki, a nie
  przełącznik jasny/ciemny w aplikacji — stąd ciemne przyciski nawet w
  trybie jasnym. Podmieniony na własny widok zgodny z kolorami aplikacji
  (w tym własne tło/border każdego przycisku i wyśrodkowanie na telefonie).
- Kolorystyka boksu z informacją o logowaniu nie zmienia już odcienia
  między trybem jasnym i ciemnym (wcześniej pochodziła z --color-accent) —
  teraz stałe, ciemne tło w obu trybach, więc kolory tekstu ustawione przez
  admina (np. biały) zostają czytelne niezależnie od motywu. Poszerzona
  karta logowania (380px → 480px).
- Lista zgłoszeń klienta: etykiety priorytetu/statusu nie zawijają się już
  do osobnej linii przy długim temacie na wąskich ekranach — zostają
  przypięte do prawej, a temat zawija się we własnej kolumnie.
- Liczniki czasu pracy (resumeTimer/stopTimer/...) nie dotykają już
  updated_at — samo otwarcie zgłoszenia nie liczy się jako aktualizacja.
  Kolejka operatora i pulpit klienta domyślnie sortują po dacie utworzenia
  z tego samego powodu.

Zaktualizowana dokumentacja: README, CLAUDE.md, ARCHITECTURE.md,
CHANGELOG.md, wiki/admin, wiki/client, wiki/operator.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 09:01:34 +02:00
4b70b910a9 v1.5.0
Co nowego:
- Podgląd logów w panelu admina (Admin > Logi) — pliki storage/logs/*.log
  bez potrzeby dostępu do kontenera, z filtrami poziomu/tekstu/liczby wpisów
  i auto-odświeżaniem.
- Filtr „Bez kategorii” w kolejce operatora — izoluje zgłoszenia bez
  przypisanej kategorii/podkategorii.
- Narzędzie importu z Heska: już nie tworzy automatycznie kont klientów dla
  nieznanych e-maili (pomija takie zgłoszenia zamiast zakładać konto),
  łączy odpowiedzi/właścicieli zgłoszeń z realnymi kontami operatorów po
  e-mailu, nowe flagi --assign-operators i --fix-closed-dates do
  donaprawiania wcześniejszych importów, dedykowany log
  storage/logs/hesk-import.log.
- Poprawka: pulpit statystyk operatora (rozkład wg kategorii/podkategorii i
  filtr kategorii) pomijał zgłoszenia przypisane do samej kategorii bez
  podkategorii (np. z poczty IMAP) — teraz liczone poprawnie.
- Poprawka: błąd JS i zawieszone w tle liczniki przy nawigacji z widoku z
  aktywnym licznikiem (najbardziej odczuwalne w liczniku czasu pracy
  operatora).
- Porządki w bazie: usunięte niewykorzystywane kolumny
  (users.remember_token, users.email_verified_at,
  email_templates.trigger_label); wartości pól dodatkowych, stan
  triage/podsumowania AI i powiązany sprzęt Snipe-IT przeniesione z tabeli
  tickets do osobnych tabel (ticket_field_values, ticket_ai_summaries,
  ticket_snipeit_assets) — bez zmiany zachowania, ale pola dodatkowe są
  teraz efektywnie przeszukiwalne; dodane brakujące indeksy na 4 tabelach
  pivot; tickets.source/ticket_messages.source walidowane względem znanego
  zestawu wartości.

Zaktualizowana dokumentacja: README, CLAUDE.md, ARCHITECTURE.md,
CHANGELOG.md, wiki/admin, wiki/operator.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 14:28:03 +02:00
59 changed files with 2721 additions and 259 deletions

View File

@@ -36,13 +36,17 @@ Category ─< Subcategory ─< CustomField (per-subcategory custom fields
├──< TicketMessage (public replies + internal notes) ├──< TicketMessage (public replies + internal notes)
├──< TicketAttachment ├──< TicketAttachment
├──< TicketHistory ├──< TicketHistory
├──< TicketFieldValue (queryable custom_fields values, kept in sync)
├── aiSummary → TicketAiSummary (1:1, triage+summary state)
├── snipeitAsset → TicketSnipeitAsset (1:1, linked asset)
├── customer/assignee → User ├── customer/assignee → User
├── status → Status (fixed stages: new/open/closed) ├── status → Status (fixed stages: new/open/closed)
├── priority → Priority → SlaRule (response/resolution minutes) ├── priority → Priority → SlaRule (response/resolution minutes)
└── 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)
@@ -63,6 +67,51 @@ queue + unassigned + anything assigned to them, an admin sees everything), and
work-timer tracking (`timerElapsedSeconds()`). Keep ticket-shaped logic here work-timer tracking (`timerElapsedSeconds()`). Keep ticket-shaped logic here
rather than spreading it across Livewire components. rather than spreading it across Livewire components.
**Virtual `ai_*`/`snipeit_*` attributes.** The AI triage/summary fields
(`ai_triaged_at`, `ai_summary`, `ai_suggested_action`, `ai_summary_generated_at`)
and the Snipe-IT link (`snipeit_asset_id`, `snipeit_asset_name`) are **not**
real columns on `tickets` — they live on the related `TicketAiSummary`/
`TicketSnipeitAsset` rows shown in the diagram above (each table's own columns
drop the prefix, e.g. `ticket_ai_summaries.summary`). `Ticket` overrides
`getAttribute()`/`setAttribute()` (see `AI_SUMMARY_FIELD_MAP`/
`SNIPEIT_FIELD_MAP`) so every existing `$ticket->ai_summary`/
`$ticket->update(['snipeit_asset_id' => ...])` call site keeps working
unchanged against the new tables — the same pattern `TicketMessage` already
uses for its own virtual `role`/`author_id`. A write is queued
(`$pendingVirtualAttributes`) and flushed into the related row's
`updateOrCreate()` on the model's `saved` event, since a brand-new ticket has
no id yet to key the related row on until that point. If you add a new
`ai_*`/`snipeit_*` field, add it to the relevant `FIELD_MAP` rather than to
`tickets` directly.
**Custom field values.** `tickets.custom_fields` (a JSON blob, `field.id =>
value`) stays the source of truth for reads/writes — `TicketFieldValue`
(`ticket_field_values`, one row per non-blank entry) is a queryable mirror
kept in sync automatically by `Ticket::syncFieldValues()` (called from the
same `saved` hook whenever `custom_fields` changes), so reporting can
filter/join on "tickets where custom field X = Y" without scanning JSON.
Nothing else needs to write to `ticket_field_values` directly.
**`source` validation.** `Ticket::SOURCES`/`TicketMessage::SOURCES` are the
only values ever allowed in `tickets.source`/`ticket_messages.source`
(`'web'`/`'email'`/`'hesk_import'`; `null` still means "web" for messages) —
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 ## 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:
@@ -449,6 +498,26 @@ Requires the same external `schedule:run` cron entry as SLA/automation (see
[CLAUDE.md](CLAUDE.md)) — without it, only the manual "Pobierz teraz" button [CLAUDE.md](CLAUDE.md)) — without it, only the manual "Pobierz teraz" button
does anything. does anything.
## Log channels & the admin log viewer
`config/logging.php` defines three dedicated channels alongside the app's
default one, each daily/14-day-retention and always `debug` level regardless
of `.env`'s `LOG_LEVEL` (so they stay useful even when the app itself runs at
`error`): `imap` (`storage/logs/imap-*.log` — see "IMAP e-mail intake"
above), `ai` (`storage/logs/ai.log` — every `ai:run-ticket-automation` run,
used by both `TicketAiTriageService` and `TicketAiSummaryService`, plus
`AiClient`'s own request/response/failure logging), and `hesk_import`
(`storage/logs/hesk-import.log` — every `hesk:import` run). `Admin\Logs`
(`app/Livewire/Admin/Logs.php`, Admin > Logi) is a read-only viewer over
`storage/logs/*.log` (any file, not just these three) — it reads only the
last 4 MB of a file to bound memory on large ones, splits raw log text back
into individual entries by the `[YYYY-MM-DD HH:MM:SS]` line prefix (so a
multi-line stack trace stays grouped with the line that started it), and
offers level/free-text/entry-count filters plus an optional `wire:poll.5s`
auto-refresh. `selectedFile` is validated against the real glob'd file list
on every read, not trusted as a path — a crafted value (e.g. `../../.env`)
is silently ignored rather than read.
## API ## API
`routes/api.php` + `app/Http/Controllers/Api/` expose a small ability-scoped REST `routes/api.php` + `app/Http/Controllers/Api/` expose a small ability-scoped REST
@@ -550,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.
@@ -592,9 +665,12 @@ Blade component renders that shape everywhere an asset list shows up
skips its own wrapping `<div class="card">` when embedded inside a skips its own wrapping `<div class="card">` when embedded inside a
caller-provided one (the inventory-search box + its results share one card). caller-provided one (the inventory-search box + its results share one card).
A linked ticket only stores `tickets.snipeit_asset_id` + a cached A linked ticket only stores an `asset_id` + a cached `asset_name` label on
`snipeit_asset_name` label (`TicketService::setSnipeitAsset()`, which also the related `ticket_snipeit_assets` row (`TicketService::setSnipeitAsset()`,
writes a ticket-history line)no other Snipe-IT fields are persisted. which also writes a ticket-history line — see "Virtual `ai_*`/`snipeit_*`
attributes" above for how this reads/writes as `$ticket->snipeit_asset_id`
despite not being a `tickets` column) — no other Snipe-IT fields are
persisted.
Anywhere a linked asset's live detail is shown (the "Powiązany sprzęt" card), Anywhere a linked asset's live detail is shown (the "Powiązany sprzęt" card),
it's re-fetched fresh via `SnipeItClient::asset($id)` rather than trusted it's re-fetched fresh via `SnipeItClient::asset($id)` rather than trusted
from the cache, so a status/reassignment change made directly in Snipe-IT is from the cache, so a status/reassignment change made directly in Snipe-IT is
@@ -617,7 +693,9 @@ live customer submitting a ticket:
state (no category/subcategory at all → assign both; category but no state (no category/subcategory at all → assign both; category but no
subcategory → pick one within it; already has a subcategory → recheck and subcategory → pick one within it; already has a subcategory → recheck and
possibly correct), independently of the subject/priority toggles. Every possibly correct), independently of the subject/priority toggles. Every
scanned ticket gets `tickets.ai_triaged_at` stamped exactly once — this is scanned ticket gets `ai_triaged_at` stamped exactly once (on the related
`ticket_ai_summaries` row, not `tickets` itself — see "Virtual
`ai_*`/`snipeit_*` attributes" above) — this is
a one-shot pass, not a continuous recheck, and there's deliberately no a one-shot pass, not a continuous recheck, and there's deliberately no
manual per-ticket re-trigger. Resolution is fail-closed the same way as the manual per-ticket re-trigger. Resolution is fail-closed the same way as the
BookStack tagger: every value the model returns is matched against the BookStack tagger: every value the model returns is matched against the
@@ -637,8 +715,8 @@ live customer submitting a ticket:
per pass. per pass.
- **`App\Services\TicketAiSummaryService`** — a summary + suggested next - **`App\Services\TicketAiSummaryService`** — a summary + suggested next
action for **every** ticket (gated by a single `ai_summary_enabled` action for **every** ticket (gated by a single `ai_summary_enabled`
toggle), cached on `tickets.ai_summary`/`ai_suggested_action`/ toggle), cached on the related `ticket_ai_summaries` row's `summary`/
`ai_summary_generated_at` and shown only in the operator ticket view (a `suggested_action`/`summary_generated_at` and shown only in the operator ticket view (a
"Podsumowanie AI" sidebar card, lazy-loaded via `wire:init` like the "Podsumowanie AI" sidebar card, lazy-loaded via `wire:init` like the
BookStack suggestions card next to it). `run()` (the scheduled sweep) BookStack suggestions card next to it). `run()` (the scheduled sweep)
regenerates whenever a ticket's latest message postdates its last summary regenerates whenever a ticket's latest message postdates its last summary

View File

@@ -3,6 +3,112 @@
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
### Added
- **Admin log viewer** (Admin > Logi) — browse `storage/logs/*.log` from the
admin panel without shell access to the container: a file picker (size +
last-modified, most recent first), level/free-text/entry-count filters, and
an optional 5s auto-refresh. Read-only, admin-only; reads only the tail of
large files to keep it fast.
- **"Bez kategorii" filter** in the operator queue's category dropdown —
isolates tickets with neither a category nor subcategory assigned (e.g. an
IMAP mailbox routed to nothing in particular), previously only reachable by
scanning the unfiltered list.
### Changed
- **Hesk import tool** (`scripts/hesk-import/`) no longer auto-creates client
accounts for unrecognized requester e-mails — the servicedesk user base is
now treated as authoritative, so a Hesk ticket whose requester has no
matching account is skipped instead (reported at the end, with the list of
skipped e-mails). Hesk staff replies/notes and ticket ownership are now
linked to real operator/admin accounts when the staff e-mail matches one.
Two new backfill flags cover tickets imported before these existed:
`--assign-operators` (sets `assignee_id` from Hesk's ticket owner, never
overwriting a manual reassignment) and `--fix-closed-dates` (corrects a
closed ticket's date to Hesk's own `closedat` column instead of the
drifting `lastchange`, and adds the missing "Zamknięte" history entry).
Every newly imported ticket also records its source Hesk id
(`tickets.hesk_ticket_id`, unique) as a second, DB-level guard against
duplicate imports on top of the existing state file. See
`scripts/hesk-import/README.md` for details.
- Internal database cleanup: removed three columns confirmed unused against
live data (`users.remember_token`, `users.email_verified_at`,
`email_templates.trigger_label`); moved custom field values, AI
triage/summary state, and the linked Snipe-IT asset off the `tickets` row
into three dedicated one-to-one tables (`ticket_field_values`,
`ticket_ai_summaries`, `ticket_snipeit_assets`) — no visible behavior
change, but custom field values are now efficiently queryable instead of
living only in a JSON blob, and the `tickets` row itself is narrower; added
missing reverse indexes on 4 pivot tables (`team_subcategory`, `role_user`,
`team_user`, `custom_field_subcategory`); `tickets.source` and
`ticket_messages.source` now validate against a known set of values
instead of silently accepting any string.
### Fixed
- The operator statistics dashboard's category/subcategory breakdown (and
its category filter) only counted tickets routed through a subcategory —
a ticket routed to a whole category with no subcategory (e.g. via an IMAP
mailbox routed to "całą kategorię") was silently excluded from those
charts and from filtering by that category. Now counted correctly, with
bare-category tickets shown as their own "(bez podkategorii)" row in the
subcategory breakdown.
- A JavaScript error (and stray background timers left running) could occur
when navigating away from a page with an active countdown/timer widget —
ticket/queue auto-refresh, the theme switcher, file-attachment
drag-and-drop. Most consequential in the operator ticket time tracker,
where it could throw console errors and momentarily break other UI
elements (e.g. dropdown menus) after leaving a ticket with the timer
running.
## [1.4.0] - 2026-08-05 ## [1.4.0] - 2026-08-05
### Added ### Added

View File

@@ -86,7 +86,11 @@ case, just check the container instead of the crontab. IMAP-specific activity
ids) is logged separately from the app's normal `LOG_LEVEL` to ids) is logged separately from the app's normal `LOG_LEVEL` to
`storage/logs/imap-*.log` (see the `imap` channel in `config/logging.php`) — `storage/logs/imap-*.log` (see the `imap` channel in `config/logging.php`) —
check there first when a mailbox isn't behaving as expected, before assuming check there first when a mailbox isn't behaving as expected, before assuming
the scheduler itself isn't firing. the scheduler itself isn't firing. `ai:run-ticket-automation` and
`hesk:import` get the same always-debug treatment via the `ai`/`hesk_import`
channels (`storage/logs/ai.log`/`hesk-import.log`). All of `storage/logs/*.log`
is also browsable from Admin > Logi (`app/Livewire/Admin/Logs.php`) if you'd
rather not `docker exec` in just to tail a file.
All four commands' intervals are admin-configurable (Admin > Konfiguracja — All four commands' intervals are admin-configurable (Admin > Konfiguracja —
`schedule_sla_check_minutes`/`schedule_automation_rules_minutes`/ `schedule_sla_check_minutes`/`schedule_automation_rules_minutes`/
@@ -127,6 +131,24 @@ reason — never add a `public/icons/` directory.
attributes on `<td>`s. Currently applied to the operator ticket queue; apply attributes on `<td>`s. Currently applied to the operator ticket queue; apply
the same treatment to any other wide table you add or make mobile-relevant the same treatment to any other wide table you add or make mobile-relevant
(admin panel tables don't have it yet). (admin panel tables don't have it yet).
- **Ticket list default sort** is `created_at` (or the raw `id`, which is
equivalent — both are monotonic) descending, everywhere a ticket list is
shown: the operator queue (`App\Livewire\Operator\Queue::$sortBy`, default
`'created'`) and both tabs of the client dashboard
(`App\Livewire\Client\Dashboard::baseQuery()`, `orderByDesc('created_at')`).
Deliberately not `updated_at` — see the timer/`updated_at` note in
[ARCHITECTURE.md](ARCHITECTURE.md#data-model); even with that fixed, sorting
by "last touched" is a worse default for a support queue than a stable
creation order. Keep any new ticket list consistent with this rather than
defaulting to `updated_at`.
- **Per-user table customization** (shown/hidden columns + their order): the
operator queue's pattern (`Queue::$visibleColumns`, persisted to
`users.operator_queue_columns` via `persistVisibleColumns()`, reordered with
`moveColumnUp()`/`moveColumnDown()`) is the template to reuse if another
table gains the same feature — auto-save on every toggle/reorder, no
explicit "save" step required from the user. This is intentionally separate
from `SavedQueueView` (named, manually-saved, multi-field filter presets);
don't conflate the two.
## Testing & code style ## Testing & code style

View File

@@ -38,7 +38,10 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
assignee, custom fields (per subcategory), attachments, full message thread assignee, custom fields (per subcategory), attachments, full message thread
(public replies + internal notes), history log, merge, delete. The operator (public replies + internal notes), history log, merge, delete. The operator
queue (50/page) and client dashboard (20/page, current/archive tracked queue (50/page) and client dashboard (20/page, current/archive tracked
separately) paginate rather than rendering every matching ticket at once. separately) paginate rather than rendering every matching ticket at once,
and both default to newest-created-first. The operator queue's columns
(including the raw DB id, e-mail, source, last-updated, ...) can be
individually shown/hidden and reordered, remembered per operator.
- **SLA** — per-priority response/resolution time targets; a scheduled command - **SLA** — per-priority response/resolution time targets; a scheduled command
(`tickets:check-sla-breaches`, every 15 min) flags overdue tickets and can notify (`tickets:check-sla-breaches`, every 15 min) flags overdue tickets and can notify
the assigned operator. the assigned operator.
@@ -86,6 +89,10 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
e-mail layout/footer, SMTP connection (Admin > E-MAIL), attachment limits, e-mail layout/footer, SMTP connection (Admin > E-MAIL), attachment limits,
session lifetime, timezone (Admin > Konfiguracja), and LDAP connection + user session lifetime, timezone (Admin > Konfiguracja), and LDAP connection + user
sync + BookStack (Admin > Integracje). sync + BookStack (Admin > Integracje).
- **Log viewer** (Admin > Logi) — browse `storage/logs/*.log` (app, IMAP, AI
automation, Hesk import, ...) from the admin panel, with level/text/entry-count
filters and an optional auto-refresh, so diagnosing a scheduled integration
doesn't need shell access to the container.
- **LDAP auth** — logins bind against a directory (`config/auth.php`, - **LDAP auth** — logins bind against a directory (`config/auth.php`,
`config/ldap.php`); local accounts (e.g. the emergency `admin` account) fall back `config/ldap.php`); local accounts (e.g. the emergency `admin` account) fall back
to e-mail + local password when the LDAP bind doesn't match. A "Typ katalogu" to e-mail + local password when the LDAP bind doesn't match. A "Typ katalogu"

View File

@@ -20,13 +20,24 @@ old Hesk install; it doesn't pick up edits made in Hesk afterward.
- Auto-assigns a team when every subcategory under the matched category - Auto-assigns a team when every subcategory under the matched category
routes to the same single team (same rule `TicketService::autoAssignTeam()` routes to the same single team (same rule `TicketService::autoAssignTeam()`
uses for normal ticket creation); ambiguous categories are left unrouted. uses for normal ticket creation); ambiguous categories are left unrouted.
- Finds or creates a client account per requester e-mail, reusing an existing - Finds the local client account per requester e-mail (matched by e-mail,
account (adding the `client` role if it doesn't have it yet) rather than adding the `client` role if it doesn't have it yet). **Never creates a
duplicating. User** — the servicedesk user base is treated as authoritative/complete, so
- Hesk staff replies are **not** linked to a real operator account (this a Hesk requester e-mail with no matching account means that ticket is
script never creates operator accounts) — the reply still shows the skipped (reported at the end, with the list of skipped e-mails).
correct staff name and "operator" badge via `author_name`, just without a - Hesk staff replies/notes are linked to a real operator account when the
clickable user behind it. Hesk staff member's e-mail matches an existing servicedesk operator/admin
account; otherwise they fall back to showing the correct staff name and
"operator" badge via `author_name` only, without a clickable user behind
it (this script never creates operator accounts either).
- Every imported ticket also stores its source Hesk ticket id
(`tickets.hesk_ticket_id`, unique). This is a second, DB-level guard
against duplicate imports on top of the state file below — if the state
file is ever lost or out of sync, a re-run still can't create a duplicate
ticket for the same Hesk id.
- A ticket's Hesk owner is matched the same way as reply/note authors and
set as `assignee_id`, so imported tickets show up correctly assigned in
the operator queue instead of everything landing in "Nieprzypisane".
## Setup ## Setup
@@ -73,6 +84,39 @@ tickets without importing anything new:
scripts/hesk-import/hesk-import.sh --assign-teams --commit scripts/hesk-import/hesk-import.sh --assign-teams --commit
``` ```
### Fixing closed-ticket dates
New imports already use Hesk's dedicated `closedat` column (not `lastchange`,
which moves forward on any later edit — e.g. a note added after closing) for
a closed ticket's date, and record a matching "Status zmieniony na: Zamknięte"
history entry. To apply the same correction to tickets imported before this
existed (including the original import, from before `hesk_ticket_id` was
even tracked — matched back to Hesk via the unique `(email, created_at)` pair
instead):
```bash
scripts/hesk-import/hesk-import.sh --fix-closed-dates --commit
```
Doesn't import anything new; safe to re-run (already-correct tickets are left
alone).
### Backfilling ticket ownership
New imports already set `assignee_id` from Hesk's ticket owner. To apply the
same to tickets imported before this existed:
```bash
scripts/hesk-import/hesk-import.sh --assign-operators --commit
```
Only touches tickets with no `assignee_id` yet (never overwrites a manual
reassignment made since import) and never invents an assignment — a Hesk
owner of 0 or one with no matching servicedesk account is left unassigned,
unless it's one of the two ids in `ImportHeskTickets::DELETED_STAFF_REASSIGNMENT`
(Hesk staff accounts deleted since, with historical tickets explicitly
reassigned to a current operator per the app owner).
## How it's wired up ## How it's wired up
`hesk-import.sh` is a thin wrapper: it loads `.env` in this folder, then runs `hesk-import.sh` is a thin wrapper: it loads `.env` in this folder, then runs

View File

@@ -4,13 +4,16 @@ namespace App\Console\Commands;
use App\Models\Category; use App\Models\Category;
use App\Models\Role; use App\Models\Role;
use App\Models\Status;
use App\Models\Team; use App\Models\Team;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\TicketHistory;
use App\Models\User; use App\Models\User;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use PDO; use PDO;
/** /**
@@ -20,10 +23,16 @@ use PDO;
* Runs in dry-run mode by default (reports what it would do); pass --commit * Runs in dry-run mode by default (reports what it would do); pass --commit
* to actually write. Resumable: every successfully imported Hesk ticket id * to actually write. Resumable: every successfully imported Hesk ticket id
* is recorded in a local state file (--state, defaults to * is recorded in a local state file (--state, defaults to
* storage/app/hesk-import-state.json), so a re-run (interrupted connection, * storage/app/hesk-import-state.json). Belt-and-suspenders: tickets.hesk_ticket_id
* crashed midway, etc.) skips tickets already imported instead of * is also unique at the DB level, so a state file that's lost/desynced from
* duplicating them. Not idempotent across *edits* on the Hesk side this is * a crash between commit and state-save can't turn into a silent duplicate
* a one-time historical import, not an ongoing sync. * see alreadyImported(). Not idempotent across *edits* on the Hesk side
* this is a one-time historical import, not an ongoing sync.
*
* As of the 2026-08 re-import pass, this never creates new local User
* accounts (the servicedesk user base is considered authoritative/complete)
* a Hesk requester e-mail with no matching account means the ticket is
* skipped rather than auto-provisioning one. See resolveCustomer().
*/ */
class ImportHeskTickets extends Command class ImportHeskTickets extends Command
{ {
@@ -33,7 +42,9 @@ class ImportHeskTickets extends Command
{--limit= : Only process this many Hesk tickets (after the domain filter), useful for a test run} {--limit= : Only process this many Hesk tickets (after the domain filter), useful for a test run}
{--state= : Path to the resume-state JSON file (default storage/app/hesk-import-state.json)} {--state= : Path to the resume-state JSON file (default storage/app/hesk-import-state.json)}
{--include-unmapped-categories : Also import tickets whose Hesk category has no matching servicedesk category (default: skip them)} {--include-unmapped-categories : Also import tickets whose Hesk category has no matching servicedesk category (default: skip them)}
{--assign-teams : Backfill team_id (by category) on already-imported tickets that don\'t have one yet, then exit — does not import anything}'; {--assign-teams : Backfill team_id (by category) on already-imported tickets that don\'t have one yet, then exit does not import anything}
{--fix-closed-dates : Backfill accurate closedat-based updated_at + a closure history entry on already-imported closed tickets, then exit does not import anything}
{--assign-operators : Backfill assignee_id (from Hesk\'s ticket owner, matched by e-mail to an existing operator account) on already-imported tickets that don\'t have one yet, then exit — does not import anything}';
protected $description = 'Import tickets (with full reply/note history) from a Hesk 3.x database, restricted to one e-mail domain'; protected $description = 'Import tickets (with full reply/note history) from a Hesk 3.x database, restricted to one e-mail domain';
@@ -71,6 +82,21 @@ class ImportHeskTickets extends Command
/** @var array<int, string> Hesk help_users.id => name, loaded once */ /** @var array<int, string> Hesk help_users.id => name, loaded once */
private array $heskStaffNames = []; private array $heskStaffNames = [];
/** @var array<int, ?string> Hesk help_users.id => email, loaded once */
private array $heskStaffEmails = [];
/** @var array<int, ?User> Hesk help_users.id => matching local operator/admin account (or null), memoized */
private array $operatorCache = [];
/** @var int[] hesk ticket ids already present in tickets.hesk_ticket_id — see alreadyImported() */
private array $importedHeskIds = [];
/** @var Collection<int, Collection> Hesk ticket id => its help_replies rows, preloaded in bulk for the whole run — see fix for the old per-ticket N+1 query */
private Collection $repliesByTicket;
/** @var Collection<int, Collection> Hesk ticket id => its help_notes rows, preloaded in bulk */
private Collection $notesByTicket;
private array $state = ['imported' => []]; private array $state = ['imported' => []];
private string $statePath; private string $statePath;
@@ -81,6 +107,18 @@ class ImportHeskTickets extends Command
return $this->runAssignTeams((bool) $this->option('commit')); return $this->runAssignTeams((bool) $this->option('commit'));
} }
if ($this->option('fix-closed-dates')) {
return $this->configureHeskConnection()
? $this->runFixClosedDates((bool) $this->option('commit'))
: self::FAILURE;
}
if ($this->option('assign-operators')) {
return $this->configureHeskConnection()
? $this->runAssignOperators((bool) $this->option('commit'))
: self::FAILURE;
}
$domain = trim((string) $this->option('domain'), " \t\n\r\0\x0B@"); $domain = trim((string) $this->option('domain'), " \t\n\r\0\x0B@");
if ($domain === '') { if ($domain === '') {
@@ -102,6 +140,7 @@ class ImportHeskTickets extends Command
$this->buildCategoryMap(); $this->buildCategoryMap();
$this->buildTeamMap(); $this->buildTeamMap();
$this->loadHeskStaffNames(); $this->loadHeskStaffNames();
$this->importedHeskIds = Ticket::query()->whereNotNull('hesk_ticket_id')->pluck('hesk_ticket_id')->all();
$tickets = DB::connection('hesk')->table('help_tickets') $tickets = DB::connection('hesk')->table('help_tickets')
->where('email', 'like', '%@'.$domain) ->where('email', 'like', '%@'.$domain)
@@ -109,16 +148,29 @@ class ImportHeskTickets extends Command
->when($limit, fn ($q) => $q->limit($limit)) ->when($limit, fn ($q) => $q->limit($limit))
->get(); ->get();
$this->info(sprintf( // Only needed once a ticket is actually about to be imported, so
// skip the two bulk queries entirely on a dry run.
if ($commit) {
$ticketIds = $tickets->pluck('id')->all();
$this->repliesByTicket = DB::connection('hesk')->table('help_replies')
->whereIn('replyto', $ticketIds)->orderBy('dt')->get()->groupBy('replyto');
$this->notesByTicket = DB::connection('hesk')->table('help_notes')
->whereIn('ticket', $ticketIds)->orderBy('dt')->get()->groupBy('ticket');
}
$startMessage = sprintf(
'%s tryb: %d zgłoszeń z Heska pasuje do domeny @%s (%d już zaimportowanych wcześniej, zostaną pominięte).', '%s tryb: %d zgłoszeń z Heska pasuje do domeny @%s (%d już zaimportowanych wcześniej, zostaną pominięte).',
$commit ? 'KOMMIT' : 'DRY-RUN', $commit ? 'KOMMIT' : 'DRY-RUN',
$tickets->count(), $tickets->count(),
$domain, $domain,
$tickets->whereIn('id', $this->state['imported'])->count(), $tickets->pluck('id')->filter(fn ($id) => $this->alreadyImported($id))->count(),
)); );
$this->info($startMessage);
Log::channel('hesk_import')->info($startMessage);
$stats = ['created' => 0, 'skipped' => 0, 'skipped_unmapped_category' => 0, 'failed' => 0, 'messages' => 0, 'customers_created' => 0]; $stats = ['created' => 0, 'skipped' => 0, 'skipped_unmapped_category' => 0, 'skipped_unknown_customer' => 0, 'failed' => 0, 'messages' => 0, 'operator_messages_linked' => 0];
$unmappedCategories = []; $unmappedCategories = [];
$unknownCustomerEmails = [];
$bar = $this->output->createProgressBar($tickets->count()); $bar = $this->output->createProgressBar($tickets->count());
$bar->start(); $bar->start();
@@ -126,7 +178,7 @@ class ImportHeskTickets extends Command
foreach ($tickets as $heskTicket) { foreach ($tickets as $heskTicket) {
$bar->advance(); $bar->advance();
if (in_array($heskTicket->id, $this->state['imported'], true)) { if ($this->alreadyImported($heskTicket->id)) {
$stats['skipped']++; $stats['skipped']++;
continue; continue;
@@ -147,6 +199,22 @@ class ImportHeskTickets extends Command
continue; continue;
} }
// The local user base is authoritative as of the 2026-08
// re-import — a requester e-mail with no matching account is
// skipped rather than auto-provisioning a new client (see
// resolveCustomer()). Checked even in dry-run so the preview
// accurately reflects what --commit would do.
$customerEmail = trim($heskTicket->email);
if (! User::query()->where('email', $customerEmail)->exists()) {
$stats['skipped_unknown_customer']++;
if (! in_array($customerEmail, $unknownCustomerEmails, true)) {
$unknownCustomerEmails[] = $customerEmail;
}
continue;
}
if (! $commit) { if (! $commit) {
$stats['created']++; $stats['created']++;
@@ -163,6 +231,9 @@ class ImportHeskTickets extends Command
$stats['failed']++; $stats['failed']++;
$this->newLine(); $this->newLine();
$this->error("Zgłoszenie Hesk #{$heskTicket->id} ({$heskTicket->trackid}) nie zostało zaimportowane: ".$e->getMessage()); $this->error("Zgłoszenie Hesk #{$heskTicket->id} ({$heskTicket->trackid}) nie zostało zaimportowane: ".$e->getMessage());
Log::channel('hesk_import')->error(
"Zgłoszenie Hesk #{$heskTicket->id} ({$heskTicket->trackid}) nie zostało zaimportowane: {$e->getMessage()}\n".$e->getTraceAsString()
);
} }
} }
@@ -172,9 +243,10 @@ class ImportHeskTickets extends Command
$this->table(['Miara', 'Wartość'], [ $this->table(['Miara', 'Wartość'], [
['Zgłoszenia utworzone', $stats['created']], ['Zgłoszenia utworzone', $stats['created']],
['Wiadomości/notatki utworzone', $stats['messages']], ['Wiadomości/notatki utworzone', $stats['messages']],
['Nowe konta klientów', $stats['customers_created']], ['...w tym powiązane z realnym kontem operatora', $stats['operator_messages_linked']],
['Pominięte (już zaimportowane)', $stats['skipped']], ['Pominięte (już zaimportowane)', $stats['skipped']],
['Pominięte (kategoria bez odpowiednika)', $stats['skipped_unmapped_category']], ['Pominięte (kategoria bez odpowiednika)', $stats['skipped_unmapped_category']],
['Pominięte (brak konta klienta w servicedesk)', $stats['skipped_unknown_customer']],
['Błędy', $stats['failed']], ['Błędy', $stats['failed']],
]); ]);
@@ -186,6 +258,26 @@ class ImportHeskTickets extends Command
$this->warn("Kategorie Heska bez odpowiednika w servicedesk ({$action}): {$names}"); $this->warn("Kategorie Heska bez odpowiednika w servicedesk ({$action}): {$names}");
} }
if ($unknownCustomerEmails) {
$this->warn(sprintf(
'E-maile z Heska bez konta w servicedesk (%d zgłoszeń POMINIĘTYCH): %s',
$stats['skipped_unknown_customer'],
implode(', ', $unknownCustomerEmails),
));
}
$summaryMessage = sprintf(
'Zakończono (%s): utworzone=%d, wiadomości=%d, pominięte=%d, pominięte(kategoria)=%d, pominięte(brak konta)=%d, błędy=%d.',
$commit ? 'commit' : 'dry-run',
$stats['created'],
$stats['messages'],
$stats['skipped'],
$stats['skipped_unmapped_category'],
$stats['skipped_unknown_customer'],
$stats['failed'],
);
Log::channel('hesk_import')->info($summaryMessage);
if (! $commit) { if (! $commit) {
$this->newLine(); $this->newLine();
$this->comment('To był dry-run — nic nie zostało zapisane. Uruchom ponownie z --commit, żeby faktycznie zaimportować.'); $this->comment('To był dry-run — nic nie zostało zapisane. Uruchom ponownie z --commit, żeby faktycznie zaimportować.');
@@ -194,6 +286,29 @@ class ImportHeskTickets extends Command
return self::SUCCESS; return self::SUCCESS;
} }
/**
* State file first (cheap, in-memory), then the DB as a fallback/self-heal
* a hesk_ticket_id already present on a ticket row means it was
* genuinely committed even if the state file never got updated (crash
* between the transaction commit and saveState()). Tickets imported
* before the hesk_ticket_id column existed have no such row to fall back
* on, so the state file remains authoritative for those no regression.
*/
private function alreadyImported(int $heskTicketId): bool
{
if (in_array($heskTicketId, $this->state['imported'], true)) {
return true;
}
if (in_array($heskTicketId, $this->importedHeskIds, true)) {
$this->state['imported'][] = $heskTicketId;
return true;
}
return false;
}
private function configureHeskConnection(): bool private function configureHeskConnection(): bool
{ {
$host = env('HESK_DB_HOST'); $host = env('HESK_DB_HOST');
@@ -328,15 +443,283 @@ class ImportHeskTickets extends Command
return self::SUCCESS; return self::SUCCESS;
} }
/**
* Backfill mode (--fix-closed-dates): corrects already-imported closed
* tickets whose updated_at is Hesk's lastchange (any edit a later note
* or reply rather than the actual closure) instead of Hesk's dedicated
* closedat column, and adds the missing "Status zmieniony na: Zamknięte"
* history entry every closed ticket should have (importOneTicket() now
* does both automatically for new imports see there). Idempotent: an
* already-correct ticket, or one that already has that exact history
* line, is left alone.
*
* Also covers tickets imported before tickets.hesk_ticket_id existed
* (no direct link back to Hesk at all the vast majority of closed
* imported tickets are in this group) by matching them to their source
* Hesk row via (email, created_at) <-> Hesk's (email, dt): every
* imported ticket's created_at is Hesk's dt passed through unchanged, and
* that pair is unique across every hesk_import ticket today (checked
* zero collisions), since dt is second-precision and two tickets from
* the same requester in the same second essentially never happens. Their
* hesk_ticket_id gets backfilled too as a side effect, closing the gap
* where that link never existed for them.
*/
private function runFixClosedDates(bool $commit): int
{
$tickets = DB::table('tickets')
->where('source', 'hesk_import')
->where('status_key', 'closed')
->get(['id', 'hesk_ticket_id', 'email', 'created_at', 'updated_at']);
if ($tickets->isEmpty()) {
$this->warn('Brak zamkniętych, zaimportowanych z Heska zgłoszeń.');
return self::SUCCESS;
}
[$withId, $withoutId] = $tickets->partition(fn ($t) => $t->hesk_ticket_id !== null);
$heskById = $withId->isEmpty() ? collect() : DB::connection('hesk')->table('help_tickets')
->whereIn('id', $withId->pluck('hesk_ticket_id'))
->get(['id', 'closedat', 'lastchange'])
->keyBy('id');
$heskByEmailDt = $withoutId->isEmpty() ? collect() : DB::connection('hesk')->table('help_tickets')
->whereIn('email', $withoutId->pluck('email')->unique())
->get(['id', 'email', 'dt', 'closedat', 'lastchange'])
->keyBy(fn ($row) => $row->email.'|'.$row->dt);
$closureLabel = 'Status zmieniony na: '.Status::labelFor('closed');
$alreadyRecorded = TicketHistory::query()
->whereIn('ticket_id', $tickets->pluck('id'))
->where('text', $closureLabel)
->pluck('ticket_id')
->all();
$dateFixed = 0;
$historyAdded = 0;
$idBackfilled = 0;
$unmatched = 0;
foreach ($tickets as $ticket) {
$heskRow = $ticket->hesk_ticket_id !== null
? $heskById->get($ticket->hesk_ticket_id)
: $heskByEmailDt->get($ticket->email.'|'.$ticket->created_at);
if (! $heskRow) {
$unmatched++;
continue;
}
if ($ticket->hesk_ticket_id === null) {
$idBackfilled++;
if ($commit) {
DB::table('tickets')->where('id', $ticket->id)->update(['hesk_ticket_id' => $heskRow->id]);
}
}
$closedAt = $heskRow->closedat ?: $heskRow->lastchange;
if (! $closedAt) {
continue;
}
if ((string) $ticket->updated_at !== (string) $closedAt) {
$dateFixed++;
if ($commit) {
DB::table('tickets')->where('id', $ticket->id)->update(['updated_at' => $closedAt]);
}
}
if (! in_array($ticket->id, $alreadyRecorded, true)) {
$historyAdded++;
if ($commit) {
TicketHistory::query()->create([
'ticket_id' => $ticket->id,
'text' => $closureLabel,
'created_at' => $closedAt,
]);
}
}
}
$this->table(['Miara', 'Wartość'], [
['Sprawdzone zamknięte zgłoszenia', $tickets->count()],
['Poprawiona data zamknięcia', $dateFixed],
['Dodany wpis historii zamknięcia', $historyAdded],
['Uzupełniony hesk_ticket_id (stare importy)', $idBackfilled],
['Bez dopasowania w bazie Heska', $unmatched],
]);
if (! $commit) {
$this->newLine();
$this->comment('To był dry-run — uruchom ponownie z --fix-closed-dates --commit, żeby faktycznie zapisać.');
}
return self::SUCCESS;
}
/**
* Hesk's dedicated closedat column (set once when a ticket transitions
* to Resolved) is a more accurate "when was this actually closed" than
* lastchange, which moves forward on ANY later edit a note added
* after closing, for instance. Falls back to lastchange for the rare
* closed ticket with no closedat recorded (seen on very old rows).
*/
private function heskClosedAt(object $heskTicket): ?string
{
return $heskTicket->closedat ?: $heskTicket->lastchange;
}
/**
* Backfill mode (--assign-operators): sets assignee_id (from Hesk's
* ticket owner, resolved to a local operator by e-mail same
* resolveOperator() used for reply/note authorship) on already-imported
* tickets that don't have one yet. importOneTicket() does this
* automatically for new imports see there. Never overwrites an
* assignee_id an operator may have since set manually in servicedesk
* (only touches tickets where it's still null), and never invents an
* assignment: a Hesk owner of 0 (unassigned) or one whose e-mail
* doesn't match any local operator (e.g. Hesk staff since deleted
* seen in practice on this data) is left unassigned rather than guessed.
*/
private function runAssignOperators(bool $commit): int
{
$tickets = DB::table('tickets')
->where('source', 'hesk_import')
->whereNotNull('hesk_ticket_id')
->whereNull('assignee_id')
->get(['id', 'hesk_ticket_id']);
if ($tickets->isEmpty()) {
$this->warn('Brak zaimportowanych zgłoszeń bez przypisanego operatora do sprawdzenia.');
return self::SUCCESS;
}
$this->loadHeskStaffNames();
$heskOwners = DB::connection('hesk')->table('help_tickets')
->whereIn('id', $tickets->pluck('hesk_ticket_id'))
->pluck('owner', 'id');
$assigned = 0;
$noHeskOwner = 0;
$ownerUnmatched = 0;
foreach ($tickets as $ticket) {
$ownerId = (int) ($heskOwners[$ticket->hesk_ticket_id] ?? 0);
if ($ownerId <= 0) {
$noHeskOwner++;
continue;
}
$operator = $this->resolveAssignee($ownerId);
if (! $operator) {
$ownerUnmatched++;
continue;
}
$assigned++;
if ($commit) {
DB::table('tickets')->where('id', $ticket->id)->update(['assignee_id' => $operator->id]);
}
}
$this->table(['Miara', 'Wartość'], [
['Sprawdzone zgłoszenia bez operatora', $tickets->count()],
['Przypisano operatora', $assigned],
['Brak właściciela w Hesku (nieprzypisane)', $noHeskOwner],
['Właściciel w Hesku bez konta w servicedesk', $ownerUnmatched],
]);
if (! $commit) {
$this->newLine();
$this->comment('To był dry-run — uruchom ponownie z --assign-operators --commit, żeby faktycznie zapisać.');
}
return self::SUCCESS;
}
private function loadHeskStaffNames(): void private function loadHeskStaffNames(): void
{ {
$this->heskStaffNames = DB::connection('hesk')->table('help_users')->pluck('name', 'id')->all(); $rows = DB::connection('hesk')->table('help_users')->select('id', 'name', 'email')->get();
$this->heskStaffNames = $rows->pluck('name', 'id')->all();
$this->heskStaffEmails = $rows->pluck('email', 'id')->all();
}
/**
* Matches a Hesk staff member to a real local User by e-mail, so
* imported replies/notes are linked to an actual clickable operator
* account rather than only carrying the right name via author_name.
* Falls back to null (unchanged prior behavior) when no local account
* has that e-mail this never creates operator accounts.
*/
private function resolveOperator(int $heskStaffId): ?User
{
if (! array_key_exists($heskStaffId, $this->operatorCache)) {
$email = $this->heskStaffEmails[$heskStaffId] ?? null;
$this->operatorCache[$heskStaffId] = $email ? User::query()->where('email', $email)->first() : null;
}
return $this->operatorCache[$heskStaffId];
}
/**
* Hesk staff ids whose account has since been fully deleted from Hesk
* (no help_users row left at all resolveOperator() has no e-mail to
* even look up for these) mapped to the servicedesk operator who should
* own their historical tickets now, per explicit instruction from the
* app owner. staffid 6 was Katarzyna Piewiszkis, 7 was Agnieszka
* Konopka identified from ticket-history text remnants, not from any
* structured Hesk data (there wasn't any left).
*/
private const DELETED_STAFF_REASSIGNMENT = [
6 => 'agolebiowska@polagent.com',
7 => 'agolebiowska@polagent.com',
];
/**
* Ticket-*ownership*-specific resolution: falls back to
* DELETED_STAFF_REASSIGNMENT when resolveOperator() can't do anything
* (no e-mail left in Hesk to look up). Deliberately not folded into
* resolveOperator() itself reassigning who now owns a ticket is
* reasonable, but reply/note *authorship* should keep reflecting who
* actually wrote it rather than being silently rewritten to whoever
* ticket ownership was reassigned to.
*/
private function resolveAssignee(int $heskOwnerId): ?User
{
return $this->resolveOperator($heskOwnerId)
?? (isset(self::DELETED_STAFF_REASSIGNMENT[$heskOwnerId])
? User::query()->where('email', self::DELETED_STAFF_REASSIGNMENT[$heskOwnerId])->first()
: null);
} }
private function importOneTicket(object $heskTicket, array &$stats): void private function importOneTicket(object $heskTicket, array &$stats): void
{ {
$customer = $this->resolveCustomer($heskTicket->email, $heskTicket->name, $stats); $customer = $this->resolveCustomer($heskTicket->email);
if (! $customer) {
// Guarded against in handle() before this is ever called — kept
// here as a hard stop rather than silently creating an
// orphaned/incorrect ticket if that guard is ever bypassed.
throw new \RuntimeException("Brak konta klienta dla {$heskTicket->email} — nie powinno się zdarzyć, sprawdzono wcześniej w handle().");
}
$categoryId = $this->categoryMap[$heskTicket->category] ?? null; $categoryId = $this->categoryMap[$heskTicket->category] ?? null;
$statusKey = self::STATUS_MAP[(int) $heskTicket->status] ?? 'open';
$closedAt = $statusKey === 'closed' ? $this->heskClosedAt($heskTicket) : null;
$assignee = ((int) $heskTicket->owner) > 0 ? $this->resolveAssignee((int) $heskTicket->owner) : null;
$ticket = Ticket::query()->create([ $ticket = Ticket::query()->create([
'number' => Ticket::nextNumber(), 'number' => Ticket::nextNumber(),
@@ -345,21 +728,23 @@ class ImportHeskTickets extends Command
'name' => $heskTicket->name ?: $heskTicket->email, 'name' => $heskTicket->name ?: $heskTicket->email,
'category_id' => $categoryId, 'category_id' => $categoryId,
'team_id' => $categoryId ? ($this->teamByCategory[$categoryId] ?? null) : null, 'team_id' => $categoryId ? ($this->teamByCategory[$categoryId] ?? null) : null,
'assignee_id' => $assignee?->id,
'subject' => $this->cleanText($heskTicket->subject) ?: '(bez tematu)', 'subject' => $this->cleanText($heskTicket->subject) ?: '(bez tematu)',
'body' => $this->cleanText($heskTicket->message), 'body' => $this->cleanText($heskTicket->message),
'status_key' => self::STATUS_MAP[(int) $heskTicket->status] ?? 'open', 'status_key' => $statusKey,
'priority_key' => self::PRIORITY_MAP[(int) $heskTicket->priority] ?? 'medium', 'priority_key' => self::PRIORITY_MAP[(int) $heskTicket->priority] ?? 'medium',
'source' => 'hesk_import', 'source' => 'hesk_import',
'hesk_ticket_id' => $heskTicket->id,
'last_customer_activity_at' => $heskTicket->lastchange, 'last_customer_activity_at' => $heskTicket->lastchange,
'created_at' => $heskTicket->dt, 'created_at' => $heskTicket->dt,
'updated_at' => $heskTicket->lastchange, 'updated_at' => $closedAt ?? $heskTicket->lastchange,
]); ]);
// Ticket::booted() re-saves the row right after create() to stamp a // Ticket::booted() re-saves the row right after create() to stamp a
// checksum, which — being a normal Eloquent save() — stomps // checksum, which — being a normal Eloquent save() — stomps
// updated_at back to "now". Restore the historical value via the // updated_at back to "now". Restore the historical value via the
// query builder so it bypasses Eloquent's timestamp handling. // query builder so it bypasses Eloquent's timestamp handling.
DB::table('tickets')->where('id', $ticket->id)->update(['updated_at' => $heskTicket->lastchange]); DB::table('tickets')->where('id', $ticket->id)->update(['updated_at' => $closedAt ?? $heskTicket->lastchange]);
$opening = $ticket->messages()->create([ $opening = $ticket->messages()->create([
'author_name' => $heskTicket->name ?: $heskTicket->email, 'author_name' => $heskTicket->name ?: $heskTicket->email,
@@ -378,30 +763,43 @@ class ImportHeskTickets extends Command
$this->importNote($ticket, $note, $stats); $this->importNote($ticket, $note, $stats);
} }
// Mirrors what a real in-app closure leaves behind (see
// TicketService::setStatus()) so an imported closed ticket's
// "Historia zmian" tab isn't empty and shows an accurate closure
// date — without this, nothing else records when/that it closed.
if ($closedAt) {
$ticket->histories()->create([
'text' => 'Status zmieniony na: '.Status::labelFor('closed'),
'created_at' => $closedAt,
]);
}
$stats['created']++; $stats['created']++;
} }
/**
* Both pulled from repliesByTicket/notesByTicket, bulk-preloaded once
* for the whole run in handle() rather than queried per ticket here
* on a multi-thousand-ticket import that was 2 extra DB round-trips per
* ticket for no reason, since the domain filter already bounds the
* result set to something worth loading in one shot.
*/
private function heskReplies(int $heskTicketId): Collection private function heskReplies(int $heskTicketId): Collection
{ {
return DB::connection('hesk')->table('help_replies') return $this->repliesByTicket->get($heskTicketId, collect());
->where('replyto', $heskTicketId)
->orderBy('dt')
->get();
} }
private function heskNotes(int $heskTicketId): Collection private function heskNotes(int $heskTicketId): Collection
{ {
return DB::connection('hesk')->table('help_notes') return $this->notesByTicket->get($heskTicketId, collect());
->where('ticket', $heskTicketId)
->orderBy('dt')
->get();
} }
private function importReply(Ticket $ticket, object $reply, User $customer, array &$stats): void private function importReply(Ticket $ticket, object $reply, User $customer, array &$stats): void
{ {
$isStaff = (int) $reply->staffid > 0; $isStaff = (int) $reply->staffid > 0;
$operator = $isStaff ? $this->resolveOperator((int) $reply->staffid) : null;
$authorName = $isStaff $authorName = $isStaff
? ($this->heskStaffNames[$reply->staffid] ?? 'Personel') ? ($operator->name ?? $this->heskStaffNames[$reply->staffid] ?? 'Personel')
: ($reply->name ?: $customer->name); : ($reply->name ?: $customer->name);
$message = $ticket->messages()->create([ $message = $ticket->messages()->create([
@@ -411,48 +809,54 @@ class ImportHeskTickets extends Command
'updated_at' => $reply->dt, 'updated_at' => $reply->dt,
]); ]);
// Staff replies aren't linked to a real User (we deliberately don't // Linked to a real operator User when the Hesk staff e-mail matches
// create operator accounts for imported Hesk staff — see the // one (resolveOperator()); otherwise falls back to the previous
// migration script's design questions) — attachAuthor(null, // behavior — attachAuthor(null, 'operator') still tags the
// 'operator') still tags the role/badge correctly via author_name. // role/badge correctly via author_name, just without a clickable
$message->attachAuthor($isStaff ? null : $customer->id, $isStaff ? 'operator' : 'client'); // user behind it.
$message->attachAuthor($isStaff ? $operator?->id : $customer->id, $isStaff ? 'operator' : 'client');
$stats['messages']++; $stats['messages']++;
if ($isStaff && $operator) {
$stats['operator_messages_linked']++;
}
} }
private function importNote(Ticket $ticket, object $note, array &$stats): void private function importNote(Ticket $ticket, object $note, array &$stats): void
{ {
$operator = $this->resolveOperator((int) $note->who);
$message = $ticket->messages()->create([ $message = $ticket->messages()->create([
'author_name' => $this->heskStaffNames[$note->who] ?? 'Personel', 'author_name' => $operator->name ?? $this->heskStaffNames[$note->who] ?? 'Personel',
'internal' => true, 'internal' => true,
'body' => $this->cleanText($note->message), 'body' => $this->cleanText($note->message),
'created_at' => $note->dt, 'created_at' => $note->dt,
'updated_at' => $note->dt, 'updated_at' => $note->dt,
]); ]);
$message->attachAuthor(null, 'operator'); $message->attachAuthor($operator?->id, 'operator');
$stats['messages']++; $stats['messages']++;
if ($operator) {
$stats['operator_messages_linked']++;
}
} }
/** /**
* Finds or creates the local client account for a Hesk requester e-mail. * Finds the local client account for a Hesk requester e-mail. As of the
* Reuses an existing account (e.g. the one real admin account, or one * 2026-08 re-import the servicedesk user base is considered
* already created by an earlier ticket from the same person) rather than * authoritative/complete this deliberately never creates a User
* duplicating, and only ever adds the 'client' role never removes * anymore (unlike the original 2026-08-04 run); handle() checks
* whatever roles the account already had. * existence before a ticket ever reaches here, so returning null is not
* expected in normal operation (see importOneTicket()'s hard-stop guard).
* Only ever adds the 'client' role to a match never removes whatever
* roles the account already had.
*/ */
private function resolveCustomer(string $email, ?string $name, array &$stats): User private function resolveCustomer(string $email): ?User
{ {
$email = trim($email); $user = User::query()->where('email', trim($email))->first();
$user = User::query()->where('email', $email)->first();
if (! $user) { if (! $user) {
$user = User::query()->create([ return null;
'name' => $name ?: $email,
'email' => $email,
'roles' => ['client'],
]);
$stats['customers_created']++;
return $user;
} }
if (! in_array('client', $user->roles, true)) { if (! in_array('client', $user->roles, true)) {

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Livewire\Admin;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Livewire\Attributes\Computed;
use Livewire\Component;
/**
* Read-only viewer over storage/logs/*.log the only way to see what the
* scheduled integrations (IMAP fetch, automation rules, AI automation) are
* doing without shell access to the container. Deliberately whitelists
* files via files()/glob() rather than trusting $selectedFile directly,
* since it's a public Livewire property a client could otherwise tamper
* with into a path-traversal read of arbitrary files.
*/
class Logs extends Component
{
/**
* How much of a (possibly multi-MB, e.g. browser.log) file to read from
* the tail per request bounds memory/response size regardless of how
* large the underlying file grows.
*/
private const MAX_BYTES = 4 * 1024 * 1024;
private const LEVELS = ['EMERGENCY', 'ALERT', 'CRITICAL', 'ERROR', 'WARNING', 'NOTICE', 'INFO', 'DEBUG'];
public string $selectedFile = '';
public string $levelFilter = '';
public string $search = '';
public int $limit = 300;
public bool $autoRefresh = false;
public function mount(): void
{
$names = $this->files()->pluck('name');
$this->selectedFile = $names->first(fn (string $n) => $n === 'laravel.log') ?? $names->first() ?? '';
}
public static function availableLevels(): array
{
return self::LEVELS;
}
/**
* @return array{class: string, style: string} CSS for the level badge
* reuses the existing .tag-* palette (accent/accent-2/neutral) where
* it fits, and falls back to inline color-mix() (matching the
* danger/warning treatment already used elsewhere, e.g.
* admin/api-keys.blade.php's status tags) for severities with no
* existing tag class.
*/
public static function levelBadge(?string $level): array
{
return match ($level) {
'DEBUG' => ['class' => 'tag tag-neutral', 'style' => ''],
'INFO' => ['class' => 'tag tag-accent-2', 'style' => ''],
'NOTICE' => ['class' => 'tag tag-accent', 'style' => ''],
'WARNING' => ['class' => 'tag', 'style' => 'background:color-mix(in srgb, var(--color-warning) 20%, transparent);color:var(--color-warning)'],
'ERROR' => ['class' => 'tag', 'style' => 'background:color-mix(in srgb, var(--color-danger) 18%, transparent);color:var(--color-danger)'],
'CRITICAL', 'ALERT', 'EMERGENCY' => ['class' => 'tag', 'style' => 'background:var(--color-danger);color:#fff'],
default => ['class' => 'tag tag-outline', 'style' => ''],
};
}
#[Computed]
public function files(): Collection
{
$paths = glob(storage_path('logs/*.log')) ?: [];
return collect($paths)
->map(fn (string $path) => [
'name' => basename($path),
'size' => filesize($path) ?: 0,
'modified' => Carbon::createFromTimestamp(filemtime($path) ?: time()),
])
->sortByDesc('modified')
->values();
}
#[Computed]
public function entries(): Collection
{
if ($this->selectedFile === '' || ! $this->files()->pluck('name')->contains($this->selectedFile)) {
return collect();
}
$path = storage_path('logs/'.$this->selectedFile);
if (! is_file($path)) {
return collect();
}
$size = filesize($path);
$handle = fopen($path, 'r');
$truncated = $size > self::MAX_BYTES;
if ($truncated) {
fseek($handle, -self::MAX_BYTES, SEEK_END);
}
$content = stream_get_contents($handle);
fclose($handle);
// A new log entry starts at a "[YYYY-MM-DD HH:MM:SS]" line; anything
// after it (stack traces, multi-line messages) belongs to that entry.
$chunks = preg_split('/(?=^\[\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})/m', (string) $content);
$chunks = array_values(array_filter($chunks, fn (string $c) => trim($c) !== ''));
if ($truncated && count($chunks) > 1) {
// First chunk was very likely cut mid-entry by the seek above.
array_shift($chunks);
}
$entries = collect($chunks)->map(function (string $chunk) {
preg_match('/^\[[^\]]+\]\s+\S+\.(\w+):/', $chunk, $m);
return [
'level' => isset($m[1]) ? strtoupper($m[1]) : null,
'text' => rtrim($chunk),
];
});
if ($this->levelFilter !== '') {
$entries = $entries->filter(fn (array $e) => $e['level'] === $this->levelFilter);
}
if (trim($this->search) !== '') {
$needle = mb_strtolower($this->search);
$entries = $entries->filter(fn (array $e) => str_contains(mb_strtolower($e['text']), $needle));
}
return $entries->values()->slice(-$this->limit)->values();
}
public function selectFile(string $name): void
{
if ($this->files()->pluck('name')->contains($name)) {
$this->selectedFile = $name;
}
}
public function formatBytes(int $bytes): string
{
if ($bytes < 1024) {
return "{$bytes} B";
}
$units = ['KB', 'MB', 'GB'];
$value = $bytes / 1024;
foreach ($units as $unit) {
if ($value < 1024 || $unit === end($units)) {
return number_format($value, 1).' '.$unit;
}
$value /= 1024;
}
return "{$bytes} B";
}
public function render()
{
return view('livewire.admin.logs');
}
}

View File

@@ -252,6 +252,7 @@ class Panel extends Component
'skipSslVerification' => ! Settings::bool('snipeit_verify_ssl'), 'skipSslVerification' => ! Settings::bool('snipeit_verify_ssl'),
'clientCanSelectAsset' => Settings::bool('snipeit_client_can_select_asset'), 'clientCanSelectAsset' => Settings::bool('snipeit_client_can_select_asset'),
'clientAssetSubcategoryIds' => $this->parseShelfIds(Settings::get('snipeit_client_asset_subcategory_ids', '')), 'clientAssetSubcategoryIds' => $this->parseShelfIds(Settings::get('snipeit_client_asset_subcategory_ids', '')),
'clientAssetCategoryIds' => $this->parseShelfIds(Settings::get('snipeit_client_asset_category_ids', '')),
'operatorViewRequesterAssets' => Settings::bool('snipeit_operator_view_requester_assets'), 'operatorViewRequesterAssets' => Settings::bool('snipeit_operator_view_requester_assets'),
'operatorSearchInventory' => Settings::bool('snipeit_operator_search_inventory'), 'operatorSearchInventory' => Settings::bool('snipeit_operator_search_inventory'),
]; ];
@@ -779,6 +780,14 @@ class Panel extends Component
->values(); ->values();
} }
#[Computed]
public function categoriesForSnipeitForm()
{
return Category::query()->orderBy('name')->get()
->map(fn (Category $c) => ['id' => $c->id, 'label' => $c->name])
->values();
}
public function openTeamForm(): void public function openTeamForm(): void
{ {
$this->teamForm = ['id' => null, 'name' => '', 'memberIds' => [], 'subcategoryIds' => []]; $this->teamForm = ['id' => null, 'name' => '', 'memberIds' => [], 'subcategoryIds' => []];
@@ -1682,6 +1691,7 @@ class Panel extends Component
Settings::set('snipeit_verify_ssl', $this->snipeitConfig['skipSslVerification'] ? '0' : '1'); Settings::set('snipeit_verify_ssl', $this->snipeitConfig['skipSslVerification'] ? '0' : '1');
Settings::set('snipeit_client_can_select_asset', $this->snipeitConfig['clientCanSelectAsset'] ? '1' : '0'); Settings::set('snipeit_client_can_select_asset', $this->snipeitConfig['clientCanSelectAsset'] ? '1' : '0');
Settings::set('snipeit_client_asset_subcategory_ids', implode(',', $this->snipeitConfig['clientAssetSubcategoryIds'])); Settings::set('snipeit_client_asset_subcategory_ids', implode(',', $this->snipeitConfig['clientAssetSubcategoryIds']));
Settings::set('snipeit_client_asset_category_ids', implode(',', $this->snipeitConfig['clientAssetCategoryIds']));
Settings::set('snipeit_operator_view_requester_assets', $this->snipeitConfig['operatorViewRequesterAssets'] ? '1' : '0'); Settings::set('snipeit_operator_view_requester_assets', $this->snipeitConfig['operatorViewRequesterAssets'] ? '1' : '0');
Settings::set('snipeit_operator_search_inventory', $this->snipeitConfig['operatorSearchInventory'] ? '1' : '0'); Settings::set('snipeit_operator_search_inventory', $this->snipeitConfig['operatorSearchInventory'] ? '1' : '0');
@@ -1698,6 +1708,22 @@ class Panel extends Component
: [...$ids, $id]; : [...$ids, $id];
} }
/**
* The category-level counterpart to toggleSnipeitClientSubcategory()
* a coarser allow-list for admins who want the picker on for every
* subcategory of a category at once, without ticking each one
* individually. NewTicket::snipeitAssets() allows a ticket through if
* its category OR its subcategory is on either list.
*/
public function toggleSnipeitClientCategory(int $id): void
{
$ids = $this->snipeitConfig['clientAssetCategoryIds'];
$this->snipeitConfig['clientAssetCategoryIds'] = in_array($id, $ids, true)
? array_values(array_diff($ids, [$id]))
: [...$ids, $id];
}
public function testSnipeitConnection(): void public function testSnipeitConnection(): void
{ {
$cfg = $this->snipeitConfig; $cfg = $this->snipeitConfig;
@@ -1840,6 +1866,7 @@ class Panel extends Component
'config' => 'Konfiguracja', 'config' => 'Konfiguracja',
'integrations' => 'Integracje', 'integrations' => 'Integracje',
'api-keys' => 'Klucze API', 'api-keys' => 'Klucze API',
'logs' => 'Logi',
'about' => 'O aplikacji', 'about' => 'O aplikacji',
]; ];

View File

@@ -6,6 +6,7 @@ use App\Models\Status;
use App\Support\Settings; use App\Support\Settings;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
@@ -15,16 +16,28 @@ class Dashboard extends Component
private const PER_PAGE = 20; private const PER_PAGE = 20;
#[Url]
public string $tab = 'current'; public string $tab = 'current';
public string $search = ''; public string $search = '';
/**
* Session-only, mirrors Operator\Queue::rememberQueueTab() lets
* "Wróć do listy" on the ticket-detail page return to whichever tab
* (Bieżące/Archiwum) was actually active, instead of always resetting
* to the default.
*/
public function mount(): void
{
session(['client_dashboard_tab' => $this->tab]);
}
protected function baseQuery() protected function baseQuery()
{ {
return Auth::user()->ticketsAsCustomer() return Auth::user()->ticketsAsCustomer()
->search($this->search) ->search($this->search)
->with('subcategory.category') ->with('subcategory.category')
->orderByDesc('updated_at'); ->orderByDesc('created_at');
} }
/** /**
@@ -54,6 +67,7 @@ class Dashboard extends Component
public function setTab(string $tab): void public function setTab(string $tab): void
{ {
$this->tab = $tab; $this->tab = $tab;
session(['client_dashboard_tab' => $tab]);
} }
public function render() public function render()

View File

@@ -53,11 +53,13 @@ class NewTicket extends Component
} }
/** /**
* Empty unless the admin turned the picker on AND allow-listed the * Empty unless the admin turned the picker on AND allow-listed either
* currently selected subcategory for it (see * the currently selected subcategory (snipeit_client_asset_subcategory_ids)
* snipeit_client_asset_subcategory_ids) an empty allow-list means * or its parent category (snipeit_client_asset_category_ids) an empty
* "no subcategory", not "every subcategory", mirroring how BookStack's * pair of allow-lists means "nowhere", not "everywhere", mirroring how
* shelf allow-lists work. * BookStack's shelf allow-lists work. The category list is the coarser
* of the two, for admins who want every subcategory of a category
* covered at once instead of ticking each one individually.
* *
* @return array<int, array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, url: string}> * @return array<int, array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, url: string}>
*/ */
@@ -68,7 +70,10 @@ class NewTicket extends Component
return []; return [];
} }
if (! in_array($this->subcategoryId, $this->snipeitAllowedSubcategoryIds(), true)) { $subcategoryAllowed = in_array($this->subcategoryId, $this->snipeitAllowedSubcategoryIds(), true);
$categoryAllowed = $this->categoryId && in_array($this->categoryId, $this->snipeitAllowedCategoryIds(), true);
if (! $subcategoryAllowed && ! $categoryAllowed) {
return []; return [];
} }
@@ -80,7 +85,23 @@ class NewTicket extends Component
*/ */
protected function snipeitAllowedSubcategoryIds(): array protected function snipeitAllowedSubcategoryIds(): array
{ {
return collect(explode(',', Settings::get('snipeit_client_asset_subcategory_ids', ''))) return $this->parseIdList(Settings::get('snipeit_client_asset_subcategory_ids', ''));
}
/**
* @return int[]
*/
protected function snipeitAllowedCategoryIds(): array
{
return $this->parseIdList(Settings::get('snipeit_client_asset_category_ids', ''));
}
/**
* @return int[]
*/
private function parseIdList(string $raw): array
{
return collect(explode(',', $raw))
->map(fn ($v) => (int) trim($v)) ->map(fn ($v) => (int) trim($v))
->filter() ->filter()
->values() ->values()

View File

@@ -48,12 +48,12 @@ class Queue extends Component
public string $search = ''; public string $search = '';
public string $sortBy = 'updated_at'; public string $sortBy = 'created';
public string $sortDir = 'desc'; public string $sortDir = 'desc';
/** @var string[] */ /** @var string[] */
public array $visibleColumns = ['number', 'subject', 'customer', 'category', 'priority', 'status', 'sla', 'assignee']; public array $visibleColumns = ['id', 'number', 'subject', 'customer', 'category', 'priority', 'status', 'sla', 'assignee'];
/** @var int[] */ /** @var int[] */
public array $selectedIds = []; public array $selectedIds = [];
@@ -72,7 +72,15 @@ class Queue extends Component
*/ */
public function mount(): void public function mount(): void
{ {
$savedColumns = Auth::user()->operator_queue_columns;
if (is_array($savedColumns) && $savedColumns !== []) {
$this->visibleColumns = array_values(array_intersect($savedColumns, array_keys($this->columnDefs())));
}
if ($this->savedViewId !== null) { if ($this->savedViewId !== null) {
$this->rememberQueueTab();
return; return;
} }
@@ -82,6 +90,20 @@ class Queue extends Component
$this->applyViewFilters($default->filters); $this->applyViewFilters($default->filters);
$this->savedViewId = $default->id; $this->savedViewId = $default->id;
} }
$this->rememberQueueTab();
}
/**
* Session-only (not the per-user `operator_queue_columns` column a tab
* selection is transient, not a durable preference): lets "Wróć do
* listy" on the ticket-detail page return to whichever queue tab was
* actually active, instead of always resetting to the "Otwarte" default.
* See TicketShow's back-link, which reads this same key.
*/
private function rememberQueueTab(): void
{
session(['operator_queue_tab' => $this->queue]);
} }
/** /**
@@ -146,6 +168,7 @@ class Queue extends Component
$this->visibleColumns = $filters['visibleColumns'] ?? $this->visibleColumns; $this->visibleColumns = $filters['visibleColumns'] ?? $this->visibleColumns;
$this->selectedIds = []; $this->selectedIds = [];
$this->resetPage(); $this->resetPage();
$this->rememberQueueTab();
} }
/** /**
@@ -354,7 +377,9 @@ class Queue extends Component
if ($this->filterPriority !== 'all') { if ($this->filterPriority !== 'all') {
$query->where('priority_key', $this->filterPriority); $query->where('priority_key', $this->filterPriority);
} }
if ($this->filterCategory !== 'all') { if ($this->filterCategory === 'none') {
$query->whereNull('subcategory_id')->whereNull('category_id');
} elseif ($this->filterCategory !== 'all') {
// A ticket carries a category either via its subcategory or, // A ticket carries a category either via its subcategory or,
// when routed to a whole category with no subcategory (e.g. an // when routed to a whole category with no subcategory (e.g. an
// IMAP mailbox), directly on tickets.category_id. // IMAP mailbox), directly on tickets.category_id.
@@ -394,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();
@@ -416,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',
@@ -426,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',
]; ];
} }
@@ -437,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
@@ -465,12 +498,57 @@ class Queue extends Component
} else { } else {
$this->visibleColumns[] = $column; $this->visibleColumns[] = $column;
} }
$this->persistVisibleColumns();
}
public function moveColumnUp(string $column): void
{
$this->reorderColumn($column, -1);
}
public function moveColumnDown(string $column): void
{
$this->reorderColumn($column, 1);
}
private function reorderColumn(string $column, int $delta): void
{
$from = array_search($column, $this->visibleColumns, true);
if ($from === false) {
return;
}
$to = $from + $delta;
if ($to < 0 || $to >= count($this->visibleColumns)) {
return;
}
$columns = $this->visibleColumns;
[$columns[$from], $columns[$to]] = [$columns[$to], $columns[$from]];
$this->visibleColumns = $columns;
$this->persistVisibleColumns();
}
/**
* Auto-remembers shown/hidden columns *and* their order per operator,
* independent of the named/default SavedQueueView mechanism above a
* plain column toggle or drag shouldn't require explicitly "saving a
* view" for it to stick between visits.
*/
private function persistVisibleColumns(): void
{
Auth::user()->forceFill(['operator_queue_columns' => $this->visibleColumns])->save();
} }
public function setQueue(string $key): void public function setQueue(string $key): void
{ {
$this->queue = $key; $this->queue = $key;
$this->selectedIds = []; $this->selectedIds = [];
$this->rememberQueueTab();
// Every tab except "closed" now excludes closed-stage tickets (see // Every tab except "closed" now excludes closed-stage tickets (see
// queueDefs()), so a stale closed-stage status filter would silently // queueDefs()), so a stale closed-stage status filter would silently

View File

@@ -128,7 +128,17 @@ class Stats extends Component
$query->where('tickets.priority_key', $this->filterPriority); $query->where('tickets.priority_key', $this->filterPriority);
} }
if ($this->filterCategory !== 'all') { if ($this->filterCategory !== 'all') {
$query->whereHas('subcategory', fn ($q) => $q->where('category_id', $this->filterCategory)); // A ticket can carry its category two ways — via a subcategory
// (whose own category_id we check through) or, when routed to a
// bare category with no subcategory, via tickets.category_id
// directly (see Ticket::category()). Only checking the
// subcategory relation silently dropped every bare-category
// ticket from the filter.
$categoryId = $this->filterCategory;
$query->where(function ($q) use ($categoryId) {
$q->where('tickets.category_id', $categoryId)
->orWhereHas('subcategory', fn ($sq) => $sq->where('category_id', $categoryId));
});
} }
if ($this->filterAssignee === 'unassigned') { if ($this->filterAssignee === 'unassigned') {
$query->whereNull('tickets.assignee_id'); $query->whereNull('tickets.assignee_id');
@@ -299,37 +309,70 @@ class Stats extends Component
])->values(); ])->values();
} }
/**
* A ticket carries its category two ways: via a subcategory (whose
* category_id we join through), or when routed to a bare category with
* no subcategory via tickets.category_id directly (see
* Ticket::category()). Counting only the subcategory join silently
* dropped every bare-category ticket, so this sums both paths per
* category id before joining to categories for the label.
*/
#[Computed] #[Computed]
public function byCategory() public function byCategory()
{ {
return (clone $this->baseQuery) $viaSubcategory = (clone $this->baseQuery)
->whereNotNull('tickets.subcategory_id') ->whereNotNull('tickets.subcategory_id')
->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id') ->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id')
->join('categories', 'categories.id', '=', 'subcategories.category_id') ->select('subcategories.category_id', DB::raw('count(*) as count'))
->select('categories.name as label', DB::raw('count(*) as count')) ->groupBy('subcategories.category_id')
->groupBy('categories.id', 'categories.name') ->pluck('count', 'category_id');
->orderByDesc('count')
->get() $viaCategory = (clone $this->baseQuery)
->map(fn ($row) => ['label' => $row->label, 'count' => (int) $row->count]); ->whereNull('tickets.subcategory_id')
->whereNotNull('tickets.category_id')
->select('tickets.category_id', DB::raw('count(*) as count'))
->groupBy('tickets.category_id')
->pluck('count', 'category_id');
$counts = $viaSubcategory->keys()->merge($viaCategory->keys())->unique()
->mapWithKeys(fn ($id) => [$id => ($viaSubcategory[$id] ?? 0) + ($viaCategory[$id] ?? 0)]);
return Category::query()->whereIn('id', $counts->keys())->get()
->map(fn (Category $c) => ['label' => $c->name, 'count' => (int) $counts[$c->id]])
->sortByDesc('count')
->values();
} }
/** /**
* One level deeper than byCategory() same shape, but grouped by the * One level deeper than byCategory() grouped by the actual subcategory,
* actual subcategory, labeled "Category / Subcategory" to disambiguate * labeled "Category / Subcategory" to disambiguate subcategories that
* subcategories that share a name across different parent categories. * share a name across different parent categories. Tickets routed to a
* bare category (no subcategory see byCategory()'s docblock) have no
* subcategory to group by, so they get their own "Category (bez
* podkategorii)" row instead of being silently dropped.
*/ */
#[Computed] #[Computed]
public function bySubcategory() public function bySubcategory()
{ {
return (clone $this->baseQuery) $withSubcategory = (clone $this->baseQuery)
->whereNotNull('tickets.subcategory_id') ->whereNotNull('tickets.subcategory_id')
->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id') ->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id')
->join('categories', 'categories.id', '=', 'subcategories.category_id') ->join('categories', 'categories.id', '=', 'subcategories.category_id')
->select('subcategories.id', 'categories.name as category_name', 'subcategories.name as sub_name', DB::raw('count(*) as count')) ->select('subcategories.id', 'categories.name as category_name', 'subcategories.name as sub_name', DB::raw('count(*) as count'))
->groupBy('subcategories.id', 'categories.name', 'subcategories.name') ->groupBy('subcategories.id', 'categories.name', 'subcategories.name')
->orderByDesc('count')
->get() ->get()
->map(fn ($row) => ['label' => $row->category_name.' / '.$row->sub_name, 'count' => (int) $row->count]); ->map(fn ($row) => ['label' => $row->category_name.' / '.$row->sub_name, 'count' => (int) $row->count]);
$bareCategory = (clone $this->baseQuery)
->whereNull('tickets.subcategory_id')
->whereNotNull('tickets.category_id')
->join('categories', 'categories.id', '=', 'tickets.category_id')
->select('categories.name as category_name', DB::raw('count(*) as count'))
->groupBy('categories.id', 'categories.name')
->get()
->map(fn ($row) => ['label' => $row->category_name.' (bez podkategorii)', 'count' => (int) $row->count]);
return $withSubcategory->concat($bareCategory)->sortByDesc('count')->values();
} }
#[Computed] #[Computed]

View File

@@ -5,7 +5,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
#[Fillable(['key', 'name', 'trigger_label', 'subject', 'body'])] #[Fillable(['key', 'name', 'subject', 'body'])]
class EmailTemplate extends Model class EmailTemplate extends Model
{ {
public function render(array $placeholders): array public function render(array $placeholders): array

View File

@@ -9,12 +9,13 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
#[Fillable([ #[Fillable([
'number', 'checksum', 'customer_id', 'email', 'name', 'subcategory_id', 'category_id', 'subject', 'body', 'number', 'checksum', 'customer_id', 'email', 'name', 'subcategory_id', 'category_id', 'subject', 'body',
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id', 'source', 'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id', 'source', 'hesk_ticket_id',
'sla_notified_at', 'last_customer_activity_at', 'time_spent_seconds', 'timer_started_at', 'sla_notified_at', 'last_customer_activity_at', 'time_spent_seconds', 'timer_started_at',
'created_at', 'updated_at', 'csat_rating', 'csat_comment', 'csat_rated_at', 'created_at', 'updated_at', 'csat_rating', 'csat_comment', 'csat_rated_at',
'ai_triaged_at', 'ai_summary', 'ai_suggested_action', 'ai_summary_generated_at', 'ai_triaged_at', 'ai_summary', 'ai_suggested_action', 'ai_summary_generated_at',
@@ -22,6 +23,43 @@ use Illuminate\Support\Facades\DB;
])] ])]
class Ticket extends Model class Ticket extends Model
{ {
/**
* Every value ever written to tickets.source across the app web
* submission (the default), IMAP-fetched e-mail, and the Hesk import
* command. Enforced on save (see booted() below) so a typo'd literal
* fails loudly instead of silently sticking in the column.
*/
public const SOURCES = ['web', 'email', 'hesk_import'];
/**
* ai_* and snipeit_* fields are no longer real columns on `tickets`
* they live in aiSummary()/snipeitAsset(), one-to-one extension tables (see the
* 2026_08_05_000164/000165 migrations for why: both blocks are wide and
* null on most tickets). These maps back the getAttribute()/
* setAttribute() overrides below, which keep every existing
* `$ticket->ai_summary`/`$ticket->snipeit_asset_id` read/write working
* unchanged against the new tables, so callers never had to change.
*/
private const AI_SUMMARY_FIELD_MAP = [
'ai_triaged_at' => 'triaged_at',
'ai_summary' => 'summary',
'ai_suggested_action' => 'suggested_action',
'ai_summary_generated_at' => 'summary_generated_at',
];
private const SNIPEIT_FIELD_MAP = [
'snipeit_asset_id' => 'asset_id',
'snipeit_asset_name' => 'asset_name',
];
/**
* Queued writes to the virtual ai_* and snipeit_* fields above, flushed into
* the related row once the ticket itself is saved (see booted()) rather
* than applied immediately a brand-new ticket has no id yet to key the
* related row on.
*/
protected array $pendingVirtualAttributes = [];
/** /**
* Every ticket gets a stable, unique checksum the moment its id is known * Every ticket gets a stable, unique checksum the moment its id is known
* it never needs to change afterward, and having it always populated * it never needs to change afterward, and having it always populated
@@ -34,6 +72,87 @@ class Ticket extends Model
$ticket->checksum = static::generateUniqueChecksum($ticket->id); $ticket->checksum = static::generateUniqueChecksum($ticket->id);
$ticket->saveQuietly(); $ticket->saveQuietly();
}); });
static::saving(function (Ticket $ticket) {
if ($ticket->source !== null && ! in_array($ticket->source, self::SOURCES, true)) {
throw new \InvalidArgumentException("Invalid ticket source: {$ticket->source}");
}
});
static::saved(function (Ticket $ticket) {
$ticket->flushPendingVirtualAttributes();
// Keeps ticket_field_values (queryable EAV rows) in sync with the
// freeform custom_fields JSON blob — see syncFieldValues().
if ($ticket->wasChanged('custom_fields') || $ticket->wasRecentlyCreated) {
$ticket->syncFieldValues();
}
});
}
/**
* @see AI_SUMMARY_FIELD_MAP, SNIPEIT_FIELD_MAP
*/
public function getAttribute($key)
{
if (isset(self::AI_SUMMARY_FIELD_MAP[$key])) {
return array_key_exists($key, $this->pendingVirtualAttributes)
? $this->pendingVirtualAttributes[$key]
: $this->aiSummary?->{self::AI_SUMMARY_FIELD_MAP[$key]};
}
if (isset(self::SNIPEIT_FIELD_MAP[$key])) {
return array_key_exists($key, $this->pendingVirtualAttributes)
? $this->pendingVirtualAttributes[$key]
: $this->snipeitAsset?->{self::SNIPEIT_FIELD_MAP[$key]};
}
return parent::getAttribute($key);
}
/**
* @see AI_SUMMARY_FIELD_MAP, SNIPEIT_FIELD_MAP
*/
public function setAttribute($key, $value)
{
if (isset(self::AI_SUMMARY_FIELD_MAP[$key]) || isset(self::SNIPEIT_FIELD_MAP[$key])) {
$this->pendingVirtualAttributes[$key] = $value;
return $this;
}
return parent::setAttribute($key, $value);
}
protected function flushPendingVirtualAttributes(): void
{
if (! $this->pendingVirtualAttributes) {
return;
}
$ai = array_intersect_key($this->pendingVirtualAttributes, self::AI_SUMMARY_FIELD_MAP);
$snipeit = array_intersect_key($this->pendingVirtualAttributes, self::SNIPEIT_FIELD_MAP);
$this->pendingVirtualAttributes = [];
if ($ai) {
$this->aiSummary()->updateOrCreate([], collect($ai)
->mapWithKeys(fn ($value, $key) => [self::AI_SUMMARY_FIELD_MAP[$key] => $value])->all());
}
if ($snipeit) {
$this->snipeitAsset()->updateOrCreate([], collect($snipeit)
->mapWithKeys(fn ($value, $key) => [self::SNIPEIT_FIELD_MAP[$key] => $value])->all());
}
}
public function aiSummary(): HasOne
{
return $this->hasOne(TicketAiSummary::class);
}
public function snipeitAsset(): HasOne
{
return $this->hasOne(TicketSnipeitAsset::class);
} }
protected function casts(): array protected function casts(): array
@@ -46,9 +165,6 @@ class Ticket extends Model
'timer_started_at' => 'datetime', 'timer_started_at' => 'datetime',
'csat_rating' => 'integer', 'csat_rating' => 'integer',
'csat_rated_at' => 'datetime', 'csat_rated_at' => 'datetime',
'ai_triaged_at' => 'datetime',
'ai_summary_generated_at' => 'datetime',
'snipeit_asset_id' => 'integer',
]; ];
} }
@@ -87,6 +203,16 @@ class Ticket extends Model
return $this->belongsTo(Category::class); return $this->belongsTo(Category::class);
} }
/**
* Queryable counterpart to the custom_fields JSON blob see
* syncFieldValues(). Read-only from the app's perspective; write custom
* field values via the custom_fields attribute as before.
*/
public function fieldValues(): HasMany
{
return $this->hasMany(TicketFieldValue::class);
}
public function watchers(): BelongsToMany public function watchers(): BelongsToMany
{ {
return $this->belongsToMany(User::class, 'ticket_watchers'); return $this->belongsToMany(User::class, 'ticket_watchers');
@@ -157,9 +283,18 @@ class Ticket extends Model
return $this->hasMany(AutomationRuleTicketLog::class); return $this->hasMany(AutomationRuleTicketLog::class);
} }
/**
* Numeric-safe "max + 1" without pulling every ticket's number into PHP
* memory (`number` is a plain string column, so a DB-level MAX() would
* sort lexicographically "999" > "1000" hence ordering by length
* first). LENGTH()/ORDER BY/LIMIT are portable across MySQL and the
* sqlite connection tests run against, unlike a driver-specific CAST.
*/
public static function nextNumber(): string public static function nextNumber(): string
{ {
$max = static::query()->pluck('number')->map(fn ($n) => (int) $n)->max(); $max = (int) static::query()
->orderByRaw('LENGTH(number) DESC, number DESC')
->value('number');
return (string) (($max ?: 1000) + 1); return (string) (($max ?: 1000) + 1);
} }
@@ -476,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(),
]); ]);
@@ -486,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,
]); ]);
@@ -505,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]);
} }
/** /**
@@ -522,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.
* *
@@ -553,4 +703,33 @@ class Ticket extends Model
->values() ->values()
->all(); ->all();
} }
/**
* Mirrors the custom_fields JSON blob into ticket_field_values, one row
* per non-blank entry called automatically on save (see booted()).
* Deleted/blanked entries are removed rather than left stale, and
* unrecognized field ids (e.g. a value left over after its custom_fields
* definition was deleted) are skipped, matching the migration's
* backfill.
*/
public function syncFieldValues(): void
{
$values = $this->custom_fields ?? [];
$validFieldIds = CustomField::query()->pluck('id')->all();
$this->fieldValues()->whereNotIn('custom_field_id', array_keys($values))->delete();
foreach ($values as $fieldId => $value) {
if ($value === '' || $value === null || ! in_array((int) $fieldId, $validFieldIds, true)) {
$this->fieldValues()->where('custom_field_id', $fieldId)->delete();
continue;
}
$this->fieldValues()->updateOrCreate(
['custom_field_id' => $fieldId],
['value' => is_bool($value) ? ($value ? '1' : '0') : (string) $value],
);
}
}
} }

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['ticket_id', 'triaged_at', 'summary', 'suggested_action', 'summary_generated_at'])]
class TicketAiSummary extends Model
{
protected function casts(): array
{
return [
'triaged_at' => 'datetime',
'summary_generated_at' => 'datetime',
];
}
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['ticket_id', 'custom_field_id', 'value'])]
class TicketFieldValue extends Model
{
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
public function field(): BelongsTo
{
return $this->belongsTo(CustomField::class, 'custom_field_id');
}
}

View File

@@ -12,6 +12,23 @@ use Illuminate\Database\Eloquent\Relations\HasOneThrough;
#[Fillable(['ticket_id', 'author_name', 'internal', 'body', 'edited', 'api_client_id', 'source', 'created_at', 'updated_at'])] #[Fillable(['ticket_id', 'author_name', 'internal', 'body', 'edited', 'api_client_id', 'source', 'created_at', 'updated_at'])]
class TicketMessage extends Model class TicketMessage extends Model
{ {
/**
* Non-null values ever written to ticket_messages.source null means
* "web" (see the migration that added this column); only IMAP-fetched
* replies set it to 'email'. Enforced on save (see booted() below) so a
* typo'd literal fails loudly instead of silently sticking.
*/
public const SOURCES = ['email'];
protected static function booted(): void
{
static::saving(function (TicketMessage $message) {
if ($message->source !== null && ! in_array($message->source, self::SOURCES, true)) {
throw new \InvalidArgumentException("Invalid ticket message source: {$message->source}");
}
});
}
protected function casts(): array protected function casts(): array
{ {
return [ return [

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['ticket_id', 'asset_id', 'asset_name'])]
class TicketSnipeitAsset extends Model
{
protected function casts(): array
{
return [
'asset_id' => 'integer',
];
}
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\Hidden;
@@ -15,8 +14,8 @@ 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', 'remember_token'])] #[Hidden(['password'])]
class User extends Authenticatable implements LdapAuthenticatable class User extends Authenticatable implements LdapAuthenticatable
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
@@ -109,8 +108,8 @@ class User extends Authenticatable implements LdapAuthenticatable
protected function casts(): array protected function casts(): array
{ {
return [ return [
'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'operator_queue_columns' => 'array',
]; ];
} }

View File

@@ -4,6 +4,8 @@ namespace App\Services;
use App\Support\Settings; use App\Support\Settings;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/** /**
* Generic OpenAI-compatible chat-completions client works against Groq, * Generic OpenAI-compatible chat-completions client works against Groq,
@@ -32,19 +34,38 @@ class AiClient
return null; return null;
} }
$model = Settings::get('ai_model');
try { try {
$response = $this->client()->post('/chat/completions', [ $response = $this->client()->post('/chat/completions', [
'model' => Settings::get('ai_model'), 'model' => $model,
'messages' => $messages, 'messages' => $messages,
...$options, ...$options,
]); ]);
if (! $response->successful()) { if (! $response->successful()) {
Log::channel('ai')->warning(sprintf(
'Zapytanie do modelu %s zakończone błędem HTTP %d: %s',
$model,
$response->status(),
$response->json('error.message') ?? Str::limit($response->body(), 300),
));
return null; return null;
} }
return $response->json('choices.0.message.content'); $content = $response->json('choices.0.message.content');
} catch (\Throwable) { Log::channel('ai')->debug(sprintf(
'Zapytanie do modelu %s: %d wiadomości wejściowych, odpowiedź %d znaków.',
$model,
count($messages),
mb_strlen((string) $content),
));
return $content;
} catch (\Throwable $e) {
Log::channel('ai')->error("Zapytanie do modelu {$model} nie powiodło się: {$e->getMessage()}");
return null; return null;
} }
} }

View File

@@ -6,6 +6,7 @@ use App\Models\Ticket;
use App\Support\Settings; use App\Support\Settings;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
/** /**
@@ -48,6 +49,13 @@ class TicketAiSummaryService
$this->summarizeOne($ticket) ? $totals['updated']++ : $totals['failed']++; $this->summarizeOne($ticket) ? $totals['updated']++ : $totals['failed']++;
}); });
Log::channel('ai')->info(sprintf(
'Podsumowania: przeskanowano %d, zaktualizowano %d, błędów %d.',
$totals['scanned'],
$totals['updated'],
$totals['failed'],
));
return $totals; return $totals;
} }
@@ -58,18 +66,28 @@ class TicketAiSummaryService
* latter also changes on unrelated actions (status/priority/timer * latter also changes on unrelated actions (status/priority/timer
* edits), which would otherwise trigger spurious re-summarization on * edits), which would otherwise trigger spurious re-summarization on
* every scheduler tick for an active ticket. * every scheduler tick for an active ticket.
*
* ai_summary_generated_at now lives on the related ticket_ai_summaries
* row (see Ticket::aiSummary()), so this joins to it directly rather
* than going through the model relation a plain whereNull() on the
* left-joined column covers "no row yet" the same way it used to cover
* "column is null" when it lived on tickets itself.
*/ */
protected function staleQuery(): Builder protected function staleQuery(): Builder
{ {
return Ticket::query()->where(function (Builder $q) { return Ticket::query()
$q->whereNull('ai_summary_generated_at') ->leftJoin('ticket_ai_summaries', 'ticket_ai_summaries.ticket_id', '=', 'tickets.id')
->where(function (Builder $q) {
$q->whereNull('ticket_ai_summaries.summary_generated_at')
->orWhere(function (Builder $q2) { ->orWhere(function (Builder $q2) {
$q2->whereNotNull('ai_summary_generated_at') $q2->whereNotNull('ticket_ai_summaries.summary_generated_at')
->whereColumn('ai_summary_generated_at', '<', DB::raw( ->whereColumn('ticket_ai_summaries.summary_generated_at', '<', DB::raw(
'(select max(ticket_messages.created_at) from ticket_messages where ticket_messages.ticket_id = tickets.id)' '(select max(ticket_messages.created_at) from ticket_messages where ticket_messages.ticket_id = tickets.id)'
)); ));
}); });
})->orderBy('id'); })
->select('tickets.*')
->orderBy('tickets.id');
} }
/** /**
@@ -99,6 +117,8 @@ class TicketAiSummaryService
// Leaves any prior summary untouched and generated_at unchanged, // Leaves any prior summary untouched and generated_at unchanged,
// so the ticket stays in the stale set and gets retried next run // so the ticket stays in the stale set and gets retried next run
// rather than silently losing a working summary. // rather than silently losing a working summary.
Log::channel('ai')->warning("Podsumowanie zgłoszenia #{$ticket->number}: nie udało się wygenerować (brak lub niepoprawna odpowiedź modelu).");
return false; return false;
} }
@@ -108,6 +128,8 @@ class TicketAiSummaryService
'ai_summary_generated_at' => now(), 'ai_summary_generated_at' => now(),
]); ]);
Log::channel('ai')->debug("Podsumowanie zgłoszenia #{$ticket->number}: zaktualizowane.");
return true; return true;
} }

View File

@@ -6,6 +6,7 @@ use App\Models\Category;
use App\Models\Priority; use App\Models\Priority;
use App\Models\Ticket; use App\Models\Ticket;
use App\Support\Settings; use App\Support\Settings;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
/** /**
@@ -43,7 +44,12 @@ class TicketAiTriageService
$vocabulary = $this->buildVocabulary(); $vocabulary = $this->buildVocabulary();
$priorities = Priority::query()->orderBy('sort_order')->pluck('label', 'key')->all(); $priorities = Priority::query()->orderBy('sort_order')->pluck('label', 'key')->all();
Ticket::query()->whereNull('ai_triaged_at') // ai_triaged_at now lives on the related ticket_ai_summaries row (see
// Ticket::aiSummary()) — whereDoesntHave() matches both "no row yet"
// and "row exists but triaged_at is still null", same as the plain
// whereNull() this replaces did when the column lived on tickets.
Ticket::query()
->whereDoesntHave('aiSummary', fn ($q) => $q->whereNotNull('triaged_at'))
->orderBy('id') ->orderBy('id')
->limit($limit ?? self::BATCH_LIMIT) ->limit($limit ?? self::BATCH_LIMIT)
->get() ->get()
@@ -52,6 +58,13 @@ class TicketAiTriageService
$this->triageOne($ticket, $vocabulary, $priorities, $totals); $this->triageOne($ticket, $vocabulary, $priorities, $totals);
}); });
Log::channel('ai')->info(sprintf(
'Triage: przeskanowano %d, zmieniono %d, błędów %d.',
$totals['scanned'],
$totals['changed'],
$totals['failed'],
));
return $totals; return $totals;
} }
@@ -92,6 +105,7 @@ class TicketAiTriageService
// response doesn't get retried forever. // response doesn't get retried forever.
if ($raw !== null && $parsed === null) { if ($raw !== null && $parsed === null) {
$totals['failed']++; $totals['failed']++;
Log::channel('ai')->warning("Triage zgłoszenia #{$ticket->number}: odpowiedź modelu nie dała się zinterpretować jako JSON.");
} }
[$changes, $historyLines] = $parsed [$changes, $historyLines] = $parsed
@@ -101,6 +115,9 @@ class TicketAiTriageService
if ($changes) { if ($changes) {
$this->tickets->applyAiTriage($ticket, $changes, $historyLines); $this->tickets->applyAiTriage($ticket, $changes, $historyLines);
$totals['changed']++; $totals['changed']++;
Log::channel('ai')->info("Triage zgłoszenia #{$ticket->number}: ".implode('; ', $historyLines));
} else {
Log::channel('ai')->debug("Triage zgłoszenia #{$ticket->number}: bez zmian.");
} }
$ticket->update(['ai_triaged_at' => now()]); $ticket->update(['ai_triaged_at' => now()]);

View File

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

View File

@@ -86,6 +86,29 @@ return [
'replace_placeholders' => true, 'replace_placeholders' => true,
], ],
// Dedicated, always-verbose channel for the scheduled AI ticket
// automation (ai:run-ticket-automation — triage + summaries) — same
// rationale as 'imap' below: full visibility into what the AI
// integration did on every run without depending on LOG_LEVEL.
'ai' => [
'driver' => 'daily',
'path' => storage_path('logs/ai.log'),
'level' => 'debug',
'days' => 14,
'replace_placeholders' => true,
],
// Dedicated channel for the one-off Hesk import command (hesk:import)
// — console output alone is lost once the terminal is closed, so
// failures/summary go here too.
'hesk_import' => [
'driver' => 'daily',
'path' => storage_path('logs/hesk-import.log'),
'level' => 'debug',
'days' => 14,
'replace_placeholders' => true,
],
'slack' => [ 'slack' => [
'driver' => 'slack', 'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'), 'url' => env('LOG_SLACK_WEBHOOK_URL'),

View File

@@ -5,7 +5,6 @@ namespace Database\Factories;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/** /**
* @extends Factory<User> * @extends Factory<User>
@@ -27,20 +26,8 @@ class UserFactory extends Factory
return [ return [
'name' => fake()->name(), 'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(), 'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'), 'password' => static::$password ??= Hash::make('password'),
'roles' => ['client'], 'roles' => ['client'],
'remember_token' => Str::random(10),
]; ];
} }
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
} }

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Ties an imported ticket back to its source Hesk ticket id (source is
* otherwise just the generic string 'hesk_import', shared by every
* imported row). Nullable only ever set by hesk:import and unique so
* a repeat INSERT for the same Hesk ticket (e.g. the resume-state JSON
* file was lost or desynced from a crash between commit and state save)
* fails loudly at the DB level instead of silently duplicating the ticket.
*/
public function up(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->unsignedInteger('hesk_ticket_id')->nullable()->unique()->after('source');
});
}
public function down(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->dropColumn('hesk_ticket_id');
});
}
};

View File

@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Three columns confirmed dead against live production data (not just
* code checked actual row counts before writing this):
*
* - users.remember_token: 0 non-null rows. No "remember me" checkbox in
* the login form, Auth::attempt() never passes $remember.
* - users.email_verified_at: 0 non-null rows. MustVerifyEmail was never
* implemented on the User model (this app authenticates via LDAP +
* local password fallback, not e-mail verification).
* - email_templates.trigger_label: fully populated (10/10 rows) but
* write-only the "Szablony e-mail" admin tab actually renders
* notification_settings.trigger_label, a different table that
* happens to share the column name.
*
* users.domain was also on this list initially grep found no app-level
* read/write, but the test suite caught what grep couldn't: LdapRecord's
* own Import\Synchronizer (vendor/directorytree/ldaprecord-laravel)
* force-fills it on every LDAP sync, bypassing $fillable entirely. Left
* alone; dropping it would break LDAP login.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['remember_token', 'email_verified_at']);
});
Schema::table('email_templates', function (Blueprint $table) {
$table->dropColumn('trigger_label');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->rememberToken();
$table->timestamp('email_verified_at')->nullable();
});
Schema::table('email_templates', function (Blueprint $table) {
$table->string('trigger_label')->default('');
});
}
};

View File

@@ -0,0 +1,77 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Queryable counterpart to tickets.custom_fields (a freeform JSON blob)
* mirrors the user_fields/user_field_values pattern, so reporting can
* filter/join on "tickets where custom field X = Y" without scanning
* JSON. The JSON column stays the source of truth for reads/writes (see
* Ticket::syncFieldValues(), called on every save); this table is kept
* in sync automatically and exists purely for querying.
*/
public function up(): void
{
Schema::create('ticket_field_values', function (Blueprint $table) {
$table->id();
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
$table->foreignId('custom_field_id')->constrained()->cascadeOnDelete();
$table->text('value')->nullable();
$table->timestamps();
$table->unique(['ticket_id', 'custom_field_id']);
});
$this->backfill();
}
/**
* One-time backfill from the existing custom_fields JSON blob, so
* reporting against ticket_field_values also covers tickets created
* before this table existed. Skips any field id no longer present in
* custom_fields (a deleted field definition would otherwise violate the
* FK constraint) and any blank/null value, matching
* Ticket::syncFieldValues()'s own filtering.
*/
private function backfill(): void
{
$validFieldIds = DB::table('custom_fields')->pluck('id')->all();
$now = now();
DB::table('tickets')->whereNotNull('custom_fields')->orderBy('id')
->chunkById(500, function ($tickets) use ($validFieldIds, $now) {
$rows = [];
foreach ($tickets as $ticket) {
$values = json_decode($ticket->custom_fields, true) ?? [];
foreach ($values as $fieldId => $value) {
if ($value === '' || $value === null || ! in_array((int) $fieldId, $validFieldIds, true)) {
continue;
}
$rows[] = [
'ticket_id' => $ticket->id,
'custom_field_id' => (int) $fieldId,
'value' => is_bool($value) ? ($value ? '1' : '0') : (string) $value,
'created_at' => $now,
'updated_at' => $now,
];
}
}
if ($rows) {
DB::table('ticket_field_values')->insert($rows);
}
});
}
public function down(): void
{
Schema::dropIfExists('ticket_field_values');
}
};

View File

@@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Each of these pivot tables has a composite primary key covering both
* FK columns, in a fixed order e.g. team_subcategory's PK is
* (team_id, subcategory_id). Under the leftmost-prefix rule that index
* only serves lookups by the first column; a query keyed on the second
* column alone (e.g. "which teams can see subcategory X") has no index
* to use. Adds the missing reverse index to each.
*/
public function up(): void
{
Schema::table('team_subcategory', function (Blueprint $table) {
$table->index('subcategory_id');
});
Schema::table('role_user', function (Blueprint $table) {
$table->index('user_id');
});
Schema::table('team_user', function (Blueprint $table) {
$table->index('user_id');
});
Schema::table('custom_field_subcategory', function (Blueprint $table) {
$table->index('subcategory_id');
});
}
public function down(): void
{
Schema::table('team_subcategory', function (Blueprint $table) {
$table->dropIndex(['subcategory_id']);
});
Schema::table('role_user', function (Blueprint $table) {
$table->dropIndex(['user_id']);
});
Schema::table('team_user', function (Blueprint $table) {
$table->dropIndex(['user_id']);
});
Schema::table('custom_field_subcategory', function (Blueprint $table) {
$table->dropIndex(['subcategory_id']);
});
}
};

View File

@@ -0,0 +1,74 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Moves the four AI columns off `tickets` (added by
* 2026_07_24_000156_add_ai_triage_and_summary_to_tickets.php) into their
* own one-to-one table most tickets never get an AI pass at all, so
* this keeps the wide, mostly-null block off the main row. Ticket's
* getAttribute()/setAttribute() overrides keep every existing
* `$ticket->ai_summary` etc. read/write working unchanged against this
* table (see Ticket::AI_SUMMARY_FIELD_MAP).
*/
public function up(): void
{
Schema::create('ticket_ai_summaries', function (Blueprint $table) {
$table->id();
$table->foreignId('ticket_id')->unique()->constrained()->cascadeOnDelete();
$table->timestamp('triaged_at')->nullable();
$table->text('summary')->nullable();
$table->text('suggested_action')->nullable();
$table->timestamp('summary_generated_at')->nullable();
$table->timestamps();
});
$this->backfill();
Schema::table('tickets', function (Blueprint $table) {
$table->dropColumn(['ai_triaged_at', 'ai_summary', 'ai_suggested_action', 'ai_summary_generated_at']);
});
}
private function backfill(): void
{
$now = now();
DB::table('tickets')
->where(function ($q) {
$q->whereNotNull('ai_triaged_at')
->orWhereNotNull('ai_summary')
->orWhereNotNull('ai_suggested_action')
->orWhereNotNull('ai_summary_generated_at');
})
->orderBy('id')
->chunkById(500, function ($tickets) use ($now) {
DB::table('ticket_ai_summaries')->insert($tickets->map(fn ($t) => [
'ticket_id' => $t->id,
'triaged_at' => $t->ai_triaged_at,
'summary' => $t->ai_summary,
'suggested_action' => $t->ai_suggested_action,
'summary_generated_at' => $t->ai_summary_generated_at,
'created_at' => $now,
'updated_at' => $now,
])->all());
});
}
public function down(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->timestamp('ai_triaged_at')->nullable()->after('source');
$table->text('ai_summary')->nullable()->after('ai_triaged_at');
$table->text('ai_suggested_action')->nullable()->after('ai_summary');
$table->timestamp('ai_summary_generated_at')->nullable()->after('ai_suggested_action');
});
Schema::dropIfExists('ticket_ai_summaries');
}
};

View File

@@ -0,0 +1,64 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Moves the two Snipe-IT columns off `tickets` (added by
* 2026_07_27_000158_add_snipeit_asset_to_tickets_table.php) into their
* own one-to-one table only a small subset of tickets ever link an
* asset. Ticket's getAttribute()/setAttribute() overrides keep every
* existing `$ticket->snipeit_asset_id`/`snipeit_asset_name` read/write
* working unchanged against this table (see Ticket::SNIPEIT_FIELD_MAP).
*/
public function up(): void
{
Schema::create('ticket_snipeit_assets', function (Blueprint $table) {
$table->id();
$table->foreignId('ticket_id')->unique()->constrained()->cascadeOnDelete();
$table->unsignedInteger('asset_id')->nullable();
$table->string('asset_name')->nullable();
$table->timestamps();
});
$this->backfill();
Schema::table('tickets', function (Blueprint $table) {
$table->dropColumn(['snipeit_asset_id', 'snipeit_asset_name']);
});
}
private function backfill(): void
{
$now = now();
DB::table('tickets')
->where(function ($q) {
$q->whereNotNull('snipeit_asset_id')->orWhereNotNull('snipeit_asset_name');
})
->orderBy('id')
->chunkById(500, function ($tickets) use ($now) {
DB::table('ticket_snipeit_assets')->insert($tickets->map(fn ($t) => [
'ticket_id' => $t->id,
'asset_id' => $t->snipeit_asset_id,
'asset_name' => $t->snipeit_asset_name,
'created_at' => $now,
'updated_at' => $now,
])->all());
});
}
public function down(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->unsignedInteger('snipeit_asset_id')->nullable()->after('source');
$table->string('snipeit_asset_name')->nullable()->after('snipeit_asset_id');
});
Schema::dropIfExists('ticket_snipeit_assets');
}
};

View File

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

View File

@@ -296,52 +296,52 @@ class DatabaseSeeder extends Seeder
$templates = [ $templates = [
'tpl-new' => [ 'tpl-new' => [
'name' => 'Nowe zgłoszenie przyjęte', 'trigger_label' => 'Zgłoszenie utworzone', 'name' => 'Nowe zgłoszenie przyjęte',
'subject' => 'Otrzymaliśmy Twoje zgłoszenie #{numer}', 'subject' => 'Otrzymaliśmy Twoje zgłoszenie #{numer}',
'body' => '<p>Cześć {imie},</p><p>Otrzymaliśmy Twoje zgłoszenie „{temat}”. Nasz zespół zajmie się nim najszybciej jak to możliwe.</p>'.$link.$footer, 'body' => '<p>Cześć {imie},</p><p>Otrzymaliśmy Twoje zgłoszenie „{temat}”. Nasz zespół zajmie się nim najszybciej jak to możliwe.</p>'.$link.$footer,
], ],
'tpl-status' => [ 'tpl-status' => [
'name' => 'Zmiana statusu', 'trigger_label' => 'Status zgłoszenia zmieniony', 'name' => 'Zmiana statusu',
'subject' => 'Aktualizacja zgłoszenia #{numer}', 'subject' => 'Aktualizacja zgłoszenia #{numer}',
'body' => '<p>Cześć {imie},</p><p>Status Twojego zgłoszenia „{temat}” zmienił się na: {status}.</p>'.$link.$footer, 'body' => '<p>Cześć {imie},</p><p>Status Twojego zgłoszenia „{temat}” zmienił się na: {status}.</p>'.$link.$footer,
], ],
'tpl-category' => [ 'tpl-category' => [
'name' => 'Zmiana kategorii', 'trigger_label' => 'Kategoria zgłoszenia zmieniona', 'name' => 'Zmiana kategorii',
'subject' => 'Zmieniono kategorię zgłoszenia #{numer}', 'subject' => 'Zmieniono kategorię zgłoszenia #{numer}',
'body' => '<p>Cześć {imie},</p><p>Kategoria Twojego zgłoszenia „{temat}” została zmieniona na: {kategoria}.</p>'.$link.$footer, 'body' => '<p>Cześć {imie},</p><p>Kategoria Twojego zgłoszenia „{temat}” została zmieniona na: {kategoria}.</p>'.$link.$footer,
], ],
'tpl-assignee' => [ 'tpl-assignee' => [
'name' => 'Zmiana przypisanego operatora', 'trigger_label' => 'Przypisany operator zmieniony', 'name' => 'Zmiana przypisanego operatora',
'subject' => 'Zmieniono osobę obsługującą zgłoszenie #{numer}', 'subject' => 'Zmieniono osobę obsługującą zgłoszenie #{numer}',
'body' => '<p>Cześć {imie},</p><p>Twoim zgłoszeniem „{temat}” zajmie się teraz: {operator}.</p>'.$link.$footer, 'body' => '<p>Cześć {imie},</p><p>Twoim zgłoszeniem „{temat}” zajmie się teraz: {operator}.</p>'.$link.$footer,
], ],
'tpl-priority' => [ 'tpl-priority' => [
'name' => 'Zmiana priorytetu', 'trigger_label' => 'Priorytet zgłoszenia zmieniony', 'name' => 'Zmiana priorytetu',
'subject' => 'Zmieniono priorytet zgłoszenia #{numer}', 'subject' => 'Zmieniono priorytet zgłoszenia #{numer}',
'body' => '<p>Cześć {imie},</p><p>Priorytet Twojego zgłoszenia „{temat}” zmienił się na: {priorytet}.</p>'.$link.$footer, 'body' => '<p>Cześć {imie},</p><p>Priorytet Twojego zgłoszenia „{temat}” zmienił się na: {priorytet}.</p>'.$link.$footer,
], ],
'tpl-team' => [ 'tpl-team' => [
'name' => 'Zmiana zespołu', 'trigger_label' => 'Zespół obsługujący zmieniony', 'name' => 'Zmiana zespołu',
'subject' => 'Zmieniono zespół obsługujący zgłoszenie #{numer}', 'subject' => 'Zmieniono zespół obsługujący zgłoszenie #{numer}',
'body' => '<p>Cześć {imie},</p><p>Twoim zgłoszeniem „{temat}” zajmuje się teraz zespół: {zespol}.</p>'.$link.$footer, 'body' => '<p>Cześć {imie},</p><p>Twoim zgłoszeniem „{temat}” zajmuje się teraz zespół: {zespol}.</p>'.$link.$footer,
], ],
'tpl-closed' => [ 'tpl-closed' => [
'name' => 'Zgłoszenie zamknięte', 'trigger_label' => 'Status = Zamknięte', 'name' => 'Zgłoszenie zamknięte',
'subject' => 'Zgłoszenie #{numer} zostało zamknięte', 'subject' => 'Zgłoszenie #{numer} zostało zamknięte',
'body' => '<p>Cześć {imie},</p><p>Twoje zgłoszenie „{temat}” zostało zamknięte. Jeśli temat nie został rozwiązany, odpowiedz na tego maila lub zgłoś sprawę ponownie.</p>'.$link.$csatLink.$footer, 'body' => '<p>Cześć {imie},</p><p>Twoje zgłoszenie „{temat}” zostało zamknięte. Jeśli temat nie został rozwiązany, odpowiedz na tego maila lub zgłoś sprawę ponownie.</p>'.$link.$csatLink.$footer,
], ],
'tpl-reply' => [ 'tpl-reply' => [
'name' => 'Nowa odpowiedź operatora', 'trigger_label' => 'Operator odpowiedział', 'name' => 'Nowa odpowiedź operatora',
'subject' => 'Nowa odpowiedź w zgłoszeniu #{numer}', 'subject' => 'Nowa odpowiedź w zgłoszeniu #{numer}',
'body' => '<p>Cześć {imie},</p><p>Otrzymałeś/aś nową odpowiedź w zgłoszeniu „{temat}”.</p>'.$link.$footer, 'body' => '<p>Cześć {imie},</p><p>Otrzymałeś/aś nową odpowiedź w zgłoszeniu „{temat}”.</p>'.$link.$footer,
], ],
'tpl-sla-breach' => [ 'tpl-sla-breach' => [
'name' => 'Przekroczenie SLA', 'trigger_label' => 'SLA przekroczone — operator', 'name' => 'Przekroczenie SLA',
'subject' => 'Przekroczono SLA zgłoszenia #{numer}', 'subject' => 'Przekroczono SLA zgłoszenia #{numer}',
'body' => '<p>Cześć {operator},</p><p>Zgłoszenie „{temat}” (#{numer}) przekroczyło ustalony czas rozwiązania SLA.</p>'.$link.$footer, 'body' => '<p>Cześć {operator},</p><p>Zgłoszenie „{temat}” (#{numer}) przekroczyło ustalony czas rozwiązania SLA.</p>'.$link.$footer,
], ],
'tpl-team-new-ticket' => [ 'tpl-team-new-ticket' => [
'name' => 'Nowe zgłoszenie w zespole', 'trigger_label' => 'Nowe zgłoszenie w zespole — operator', 'name' => 'Nowe zgłoszenie w zespole',
'subject' => 'Nowe zgłoszenie w Twoim zespole (#{numer})', 'subject' => 'Nowe zgłoszenie w Twoim zespole (#{numer})',
'body' => '<p>Cześć,</p><p>Nowe zgłoszenie „{temat}” (#{numer}, kategoria: {kategoria}) trafiło do zespołu {zespol}.</p>'.$link.$footer, 'body' => '<p>Cześć,</p><p>Nowe zgłoszenie „{temat}” (#{numer}, kategoria: {kategoria}) trafiło do zespołu {zespol}.</p>'.$link.$footer,
], ],
@@ -352,7 +352,6 @@ class DatabaseSeeder extends Seeder
foreach ($templates as $key => $tpl) { foreach ($templates as $key => $tpl) {
$ids[$key] = EmailTemplate::query()->firstOrCreate(['key' => $key], [ $ids[$key] = EmailTemplate::query()->firstOrCreate(['key' => $key], [
'name' => $tpl['name'], 'name' => $tpl['name'],
'trigger_label' => $tpl['trigger_label'],
'subject' => $tpl['subject'], 'subject' => $tpl['subject'],
'body' => $tpl['body'], 'body' => $tpl['body'],
])->id; ])->id;

View File

@@ -55,11 +55,6 @@
[data-theme='light'] .tag-neutral { background: var(--color-neutral-200); color: var(--color-neutral-700); } [data-theme='light'] .tag-neutral { background: var(--color-neutral-200); color: var(--color-neutral-700); }
[data-theme='light'] .dialog-backdrop { background: color-mix(in srgb, var(--color-neutral-900) 35%, transparent); } [data-theme='light'] .dialog-backdrop { background: color-mix(in srgb, var(--color-neutral-900) 35%, transparent); }
[data-theme='light'] .login-notice-info { background: color-mix(in srgb, var(--color-accent) 14%, white); color: var(--color-accent-700); }
[data-theme='light'] .login-notice-warning { background: color-mix(in srgb, var(--color-warning) 20%, white); color: color-mix(in srgb, var(--color-warning) 80%, black); }
[data-theme='light'] .login-notice-success { background: color-mix(in srgb, var(--color-success) 18%, white); color: color-mix(in srgb, var(--color-success) 75%, black); }
[data-theme='light'] .login-notice-danger { background: color-mix(in srgb, var(--color-danger) 16%, white); color: color-mix(in srgb, var(--color-danger) 80%, black); }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { body {
@@ -211,6 +206,40 @@ body {
.seg-opt:has(input:checked) { background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent); } .seg-opt:has(input:checked) { background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent); }
.seg-opt input { position: absolute; opacity: 0; width: 0; height: 0; } .seg-opt input { position: absolute; opacity: 0; width: 0; height: 0; }
.pagination-wrap { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; }
.pagination-summary { font-size: 12.5px; color: color-mix(in srgb, var(--color-text) 55%, transparent); }
.pagination-links { display: inline-flex; gap: 4px; flex-wrap: wrap; }
.pagination-links a,
.pagination-links button,
.pagination-links span {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
padding: 0;
font-size: 12.5px;
font-weight: 500;
font-family: inherit;
border-radius: 7px;
border: none;
background: var(--color-surface);
color: var(--color-text);
text-decoration: none;
cursor: pointer;
}
.pagination-links > span { padding: 0; }
.pagination-links a:hover,
.pagination-links button:hover { background: color-mix(in srgb, var(--color-text) 6%, transparent); }
.pagination-links span[aria-disabled='true'] { opacity: 0.4; cursor: not-allowed; }
.pagination-links span.pagination-dots { background: transparent; }
.pagination-links button:disabled { opacity: 0.4; cursor: not-allowed; }
.pagination-links [aria-current='page'] span {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
}
.panel-switch { .panel-switch {
display: inline-flex; display: inline-flex;
padding: 3px; padding: 3px;
@@ -291,9 +320,18 @@ body {
and Tailwind's @layer'd rules always lose to unlayered ones including and Tailwind's @layer'd rules always lose to unlayered ones including
Quill's CDN stylesheet regardless of source order, so anything meant to Quill's CDN stylesheet regardless of source order, so anything meant to
override .ql-editor here (like the padding reset) has to be unlayered too. */ override .ql-editor here (like the padding reset) has to be unlayered too. */
/* Fixed (not [data-theme]-dependent) colors on purpose this box holds
admin-authored Quill HTML with its own inline text colors (including
plain "white"), so the container needs one stable dark background in
both app themes rather than a tint derived from --color-accent, which
changed hue/lightness between light and dark mode and could swallow
light-colored text the admin picked. */
.login-notice { .login-notice {
padding: 0 14px; padding: 14px;
border-radius: 8px; border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: #17262d;
color: #eef3f4;
font-size: 13px; font-size: 13px;
line-height: 1.5; line-height: 1.5;
height: auto; height: auto;
@@ -302,10 +340,10 @@ body {
} }
.login-notice > *:first-child { margin-top: 0; } .login-notice > *:first-child { margin-top: 0; }
.login-notice > *:last-child { margin-bottom: 0; } .login-notice > *:last-child { margin-bottom: 0; }
.login-notice-info { background: color-mix(in srgb, var(--color-accent) 20%, transparent); color: var(--color-accent); } .login-notice hr { border-color: rgba(255, 255, 255, 0.14); }
.login-notice-warning { background: color-mix(in srgb, var(--color-warning) 20%, transparent); color: var(--color-warning); } .login-notice-warning { border-color: color-mix(in srgb, var(--color-warning) 45%, rgba(255, 255, 255, 0.14)); }
.login-notice-success { background: color-mix(in srgb, var(--color-success) 20%, transparent); color: var(--color-success); } .login-notice-success { border-color: color-mix(in srgb, var(--color-success) 45%, rgba(255, 255, 255, 0.14)); }
.login-notice-danger { background: color-mix(in srgb, var(--color-danger) 20%, transparent); color: var(--color-danger); } .login-notice-danger { border-color: color-mix(in srgb, var(--color-danger) 45%, rgba(255, 255, 255, 0.14)); }
/* ---- Responsive layout (phones/tablets) ---- */ /* ---- Responsive layout (phones/tablets) ---- */
@@ -399,6 +437,7 @@ body {
@media (max-width: 640px) { @media (max-width: 640px) {
.page-pad { padding: 16px !important; } .page-pad { padding: 16px !important; }
.nav { padding-left: 14px !important; padding-right: 14px !important; gap: 10px; flex-wrap: wrap; } .nav { padding-left: 14px !important; padding-right: 14px !important; gap: 10px; flex-wrap: wrap; }
.pagination-links { width: 100%; justify-content: center; }
/* At this width the switcher's own centered slot collides with the /* At this width the switcher's own centered slot collides with the
brand text and the icon buttons sharing the row (nothing left to brand text and the icon buttons sharing the row (nothing left to

View File

@@ -16,10 +16,10 @@
style="position:relative;display:inline-block" style="position:relative;display:inline-block"
> >
<button type="button" class="btn btn-secondary btn-icon" @click="open = !open"> <button type="button" class="btn btn-secondary btn-icon" @click="open = !open">
<span class="material-symbols-outlined" x-text="icon()"></span> <span class="material-symbols-outlined" x-text="typeof icon === 'function' ? icon() : ''"></span>
</button> </button>
<div <div
x-show="open" x-show="typeof open !== 'undefined' && open"
x-cloak x-cloak
class="nav-dropdown" class="nav-dropdown"
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:150px;overflow:hidden;z-index:20" style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:150px;overflow:hidden;z-index:20"

View File

@@ -0,0 +1,69 @@
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<h3 style="margin:0">Logi</h3>
<label style="display:flex;align-items:center;gap:6px;font-size:12.5px;font-weight:400;color:var(--color-text)">
<input type="checkbox" wire:model.live="autoRefresh" style="position:static;opacity:1;width:auto;height:auto">
Odświeżaj automatycznie (5 s)
</label>
</div>
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">
Podgląd plików z <code>storage/logs/</code> co robi aplikacja i zaplanowane integracje (IMAP, automatyzacje, SLA). Widok tylko do odczytu, pokazuje ostatni fragment pliku.
</p>
@if ($autoRefresh)
<div wire:poll.5s="$refresh" style="display:none"></div>
@endif
@if ($this->files->isEmpty())
<p class="text-muted" style="font-size:13px">Brak plików logów w <code>storage/logs/</code>.</p>
@else
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px">
@foreach ($this->files as $file)
<button type="button" wire:click="selectFile('{{ $file['name'] }}')"
style="display:flex;flex-direction:column;align-items:flex-start;gap:2px;padding:6px 12px;border-radius:8px;cursor:pointer;font-size:12.5px;text-align:left;border:1px solid {{ $selectedFile === $file['name'] ? 'var(--color-accent)' : 'var(--color-divider)' }};background:{{ $selectedFile === $file['name'] ? 'color-mix(in srgb, var(--color-accent) 14%, transparent)' : 'transparent' }};color:{{ $selectedFile === $file['name'] ? 'var(--color-accent)' : 'var(--color-text)' }}">
<span style="font-weight:500">{{ $file['name'] }}</span>
<span class="text-muted" style="font-size:11px">{{ $this->formatBytes($file['size']) }} &middot; {{ $file['modified']->diffForHumans() }}</span>
</button>
@endforeach
</div>
<div style="display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:12px">
<select class="input" wire:model.live="levelFilter" style="width:auto">
<option value="">Wszystkie poziomy</option>
@foreach (\App\Livewire\Admin\Logs::availableLevels() as $level)
<option value="{{ $level }}">{{ $level }}</option>
@endforeach
</select>
<input class="input" type="search" wire:model.live.debounce.400ms="search" placeholder="Szukaj w treści..." style="width:220px">
<select class="input" wire:model.live="limit" style="width:auto">
<option value="100">ostatnie 100</option>
<option value="300">ostatnie 300</option>
<option value="1000">ostatnie 1000</option>
<option value="3000">ostatnie 3000</option>
</select>
<button type="button" class="btn btn-ghost" wire:click="$refresh">
<span class="material-symbols-outlined" style="font-size:16px;vertical-align:-3px">refresh</span>
Odśwież
</button>
<span class="text-muted" style="font-size:12px;margin-left:auto">{{ $this->entries->count() }} wpisów</span>
</div>
<div wire:key="log-box-{{ $selectedFile }}-{{ $levelFilter }}-{{ $search }}-{{ $limit }}"
x-data x-init="$el.scrollTop = $el.scrollHeight"
style="height:65vh;overflow:auto;border:1px solid var(--color-divider);border-radius:8px;background:color-mix(in srgb, var(--color-text) 4%, var(--color-bg));padding:10px 12px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.5">
@forelse ($this->entries as $entry)
@php $badge = \App\Livewire\Admin\Logs::levelBadge($entry['level']); @endphp
<div style="display:flex;gap:8px;align-items:flex-start;padding:3px 0;border-bottom:1px solid color-mix(in srgb, var(--color-text) 6%, transparent)">
<span class="{{ $badge['class'] }}" style="flex:none;font-size:10px;padding:1px 6px;margin-top:2px;{{ $badge['style'] }}">{{ $entry['level'] ?? '?' }}</span>
<span style="white-space:pre-wrap;word-break:break-word;flex:1">{{ $entry['text'] }}</span>
</div>
@empty
<p class="text-muted" style="margin:0">Brak wpisów spełniających kryteria.</p>
@endforelse
</div>
@endif
</div>

View File

@@ -22,6 +22,7 @@ $tabGroups = [
['key' => 'config', 'label' => 'Konfiguracja', 'icon' => 'settings'], ['key' => 'config', 'label' => 'Konfiguracja', 'icon' => 'settings'],
['key' => 'integrations', 'label' => 'Integracje', 'icon' => 'hub'], ['key' => 'integrations', 'label' => 'Integracje', 'icon' => 'hub'],
['key' => 'api-keys', 'label' => 'Klucze API', 'icon' => 'vpn_key'], ['key' => 'api-keys', 'label' => 'Klucze API', 'icon' => 'vpn_key'],
['key' => 'logs', 'label' => 'Logi', 'icon' => 'terminal'],
['key' => 'about', 'label' => 'O aplikacji', 'icon' => 'info'], ['key' => 'about', 'label' => 'O aplikacji', 'icon' => 'info'],
], ],
]; ];
@@ -620,9 +621,12 @@ $tabGroups = [
<h4 style="margin:0">Sesja i strefa czasowa</h4> <h4 style="margin:0">Sesja i strefa czasowa</h4>
<div class="field"><label>Czas wygaśnięcia sesji (minuty)</label><input class="input" type="number" wire:model="systemConfig.sessionLifetimeMinutes"></div> <div class="field"><label>Czas wygaśnięcia sesji (minuty)</label><input class="input" type="number" wire:model="systemConfig.sessionLifetimeMinutes"></div>
<div class="field" <div class="field"
wire:key="admin-timezone-clock"
x-data="{ x-data="{
tz: @js($systemConfig['timezone']), tz: @js($systemConfig['timezone']),
now: '', now: '',
tickHandle: null,
navigatingHandler: null,
tick() { tick() {
try { try {
this.now = new Intl.DateTimeFormat('pl-PL', { timeZone: this.tz, dateStyle: 'medium', timeStyle: 'medium' }).format(new Date()); this.now = new Intl.DateTimeFormat('pl-PL', { timeZone: this.tz, dateStyle: 'medium', timeStyle: 'medium' }).format(new Date());
@@ -630,8 +634,13 @@ $tabGroups = [
this.now = '—'; this.now = '—';
} }
}, },
}" init() {
x-init="tick(); setInterval(() => tick(), 1000)"> this.tick();
this.tickHandle = setInterval(() => this.tick(), 1000);
this.navigatingHandler = () => clearInterval(this.tickHandle);
document.addEventListener('livewire:navigating', this.navigatingHandler);
},
}">
<label>Strefa czasowa</label> <label>Strefa czasowa</label>
<select class="input" wire:model="systemConfig.timezone" x-on:change="tz = $event.target.value; tick()"> <select class="input" wire:model="systemConfig.timezone" x-on:change="tz = $event.target.value; tick()">
@foreach (\DateTimeZone::listIdentifiers() as $tzId) @foreach (\DateTimeZone::listIdentifiers() as $tzId)
@@ -640,7 +649,7 @@ $tabGroups = [
</select> </select>
<div style="display:flex;gap:4px;font-size:12px"> <div style="display:flex;gap:4px;font-size:12px">
<span class="text-muted">Aktualny czas:</span> <span class="text-muted">Aktualny czas:</span>
<strong x-text="now"></strong> <strong x-text="typeof now !== 'undefined' ? now : ''"></strong>
</div> </div>
</div> </div>
</div> </div>
@@ -874,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
@@ -882,7 +902,7 @@ $tabGroups = [
toggle-action="toggleSnipeitClientSubcategory" toggle-action="toggleSnipeitClientSubcategory"
placeholder="Brak wybranych podkategorii" placeholder="Brak wybranych podkategorii"
/> />
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Wybór sprzętu pojawi się klientowi tylko przy tworzeniu zgłoszenia w zaznaczonych tu podkategoriach. Jeśli nic nie jest zaznaczone, opcja nie pojawi się w żadnej podkategorii.</p> <p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Dodatkowo, wybór sprzętu pojawi się też w pojedynczych podkategoriach zaznaczonych tutaj, nawet jeśli ich kategoria nie jest zaznaczona wyżej. Jeśli obie listy puste, opcja nie pojawi się w żadnej podkategorii.</p>
</div> </div>
@endif @endif
@@ -983,6 +1003,11 @@ $tabGroups = [
<livewire:admin.api-keys /> <livewire:admin.api-keys />
@endif @endif
{{-- ================= LOGS ================= --}}
@if ($tab === 'logs')
<livewire:admin.logs />
@endif
@if ($tab === 'about') @if ($tab === 'about')
<h3 style="margin:0 0 14px">O aplikacji</h3> <h3 style="margin:0 0 14px">O aplikacji</h3>
<div class="card" style="padding:18px;gap:10px;max-width:420px"> <div class="card" style="padding:18px;gap:10px;max-width:420px">

View File

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

View File

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

View File

@@ -95,7 +95,7 @@
@dragover.prevent="dragging = true" @dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false" @dragleave.prevent="dragging = false"
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))" @drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
:style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }" :style="{ borderColor: (typeof dragging !== 'undefined' && dragging) ? 'var(--color-accent)' : undefined, background: (typeof dragging !== 'undefined' && dragging) ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
style="border:1px dashed var(--color-divider);border-radius:8px;padding:14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px" style="border:1px dashed var(--color-divider);border-radius:8px;padding:14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
> >
<label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label> <label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>

View File

@@ -3,7 +3,8 @@
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:1180px;width:100%;margin:0 auto;box-sizing:border-box"> <div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:1180px;width:100%;margin:0 auto;box-sizing:border-box">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap"> <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<a href="{{ route('client.dashboard') }}" wire:navigate class="btn btn-ghost" style="padding:0">&larr; Wróć do listy</a> @php $backTab = session('client_dashboard_tab', 'current'); @endphp
<a href="{{ route('client.dashboard', $backTab !== 'current' ? ['tab' => $backTab] : []) }}" wire:navigate class="btn btn-ghost" style="padding:0">&larr; Wróć do listy</a>
{{-- Live updates arrive via broadcasting, but websocket connections can {{-- Live updates arrive via broadcasting, but websocket connections can
drop silently this is a periodic fallback refresh, with a visible drop silently this is a periodic fallback refresh, with a visible
@@ -13,15 +14,27 @@
$refreshTicketSeconds = max(1, (int) \App\Support\Settings::get('refresh_ticket_view_seconds')); $refreshTicketSeconds = max(1, (int) \App\Support\Settings::get('refresh_ticket_view_seconds'));
@endphp @endphp
<div <div
wire:key="ticket-autorefresh-{{ $ticket->id }}"
class="btn btn-secondary" class="btn btn-secondary"
style="cursor:pointer;gap:6px" style="cursor:pointer;gap:6px"
x-data="{ remaining: {{ $refreshTicketSeconds }}, total: {{ $refreshTicketSeconds }} }" x-data="{
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)" remaining: {{ $refreshTicketSeconds }}, total: {{ $refreshTicketSeconds }},
tick: null,
navigatingHandler: null,
init() {
this.tick = setInterval(() => {
this.remaining = this.remaining <= 1 ? this.total : this.remaining - 1;
if (this.remaining === this.total) $wire.refreshTicketData();
}, 1000);
this.navigatingHandler = () => clearInterval(this.tick);
document.addEventListener('livewire:navigating', this.navigatingHandler);
},
}"
x-on:click="remaining = total; $wire.refreshTicketData()" x-on:click="remaining = total; $wire.refreshTicketData()"
title="Zgłoszenie odświeża się automatycznie — kliknij, aby odświeżyć teraz" title="Zgłoszenie odświeża się automatycznie — kliknij, aby odświeżyć teraz"
> >
<span class="material-symbols-outlined" style="font-size:18px">schedule</span> <span class="material-symbols-outlined" style="font-size:18px">schedule</span>
<span x-text="remaining + 's'"></span> <span x-text="typeof remaining !== 'undefined' ? (remaining + 's') : ''"></span>
</div> </div>
</div> </div>
@@ -89,7 +102,7 @@
@dragover.prevent="dragging = true" @dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false" @dragleave.prevent="dragging = false"
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))" @drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
:style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }" :style="{ borderColor: (typeof dragging !== 'undefined' && dragging) ? 'var(--color-accent)' : undefined, background: (typeof dragging !== 'undefined' && dragging) ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px" style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
> >
<label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label> <label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>

View File

@@ -92,7 +92,7 @@
@dragover.prevent="dragging = true" @dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false" @dragleave.prevent="dragging = false"
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))" @drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
:style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }" :style="{ borderColor: (typeof dragging !== 'undefined' && dragging) ? 'var(--color-accent)' : undefined, background: (typeof dragging !== 'undefined' && dragging) ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
style="border:1px dashed var(--color-divider);border-radius:8px;padding:14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px" style="border:1px dashed var(--color-divider);border-radius:8px;padding:14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
> >
<label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label> <label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>

View File

@@ -6,12 +6,12 @@
class="side-rail" class="side-rail"
style="padding:10px;gap:6px;background:color-mix(in srgb, var(--color-text) 5%, var(--color-bg))" style="padding:10px;gap:6px;background:color-mix(in srgb, var(--color-text) 5%, var(--color-bg))"
x-data="{ collapsed: localStorage.getItem('operatorSidebarCollapsed') === '1' }" x-data="{ collapsed: localStorage.getItem('operatorSidebarCollapsed') === '1' }"
x-effect="localStorage.setItem('operatorSidebarCollapsed', collapsed ? '1' : '0')" x-effect="typeof collapsed !== 'undefined' && localStorage.setItem('operatorSidebarCollapsed', collapsed ? '1' : '0')"
:class="collapsed ? 'side-rail-collapsed' : ''" :class="(typeof collapsed !== 'undefined' && collapsed) ? 'side-rail-collapsed' : ''"
> >
<button type="button" class="side-rail-toggle" @click="collapsed = ! collapsed" :title="collapsed ? 'Rozwiń panel' : 'Zwiń panel'"> <button type="button" class="side-rail-toggle" @click="collapsed = ! collapsed" :title="(typeof collapsed !== 'undefined' && collapsed) ? 'Rozwiń panel' : 'Zwiń panel'">
<span class="material-symbols-outlined" style="font-size:18px" x-text="collapsed ? 'left_panel_open' : 'left_panel_close'"></span> <span class="material-symbols-outlined" style="font-size:18px" x-text="(typeof collapsed !== 'undefined' && collapsed) ? 'left_panel_open' : 'left_panel_close'"></span>
<span class="side-rail-toggle-label" x-text="collapsed ? 'Przegląd' : 'Zwiń panel'"></span> <span class="side-rail-toggle-label" x-text="(typeof collapsed !== 'undefined' && collapsed) ? 'Przegląd' : 'Zwiń panel'"></span>
</button> </button>
<button type="button" class="side-rail-toggle" title="Szukaj" @click="window.dispatchEvent(new CustomEvent('open-global-search'))"> <button type="button" class="side-rail-toggle" title="Szukaj" @click="window.dispatchEvent(new CustomEvent('open-global-search'))">
@@ -90,6 +90,7 @@
</select> </select>
<select class="input" style="width:auto" wire:model.live="filterCategory"> <select class="input" style="width:auto" wire:model.live="filterCategory">
<option value="all">Wszystkie kategorie</option> <option value="all">Wszystkie kategorie</option>
<option value="none">Bez kategorii</option>
@foreach ($this->categories as $c) @foreach ($this->categories as $c)
<option value="{{ $c->id }}">{{ $c->name }}</option> <option value="{{ $c->id }}">{{ $c->name }}</option>
@endforeach @endforeach
@@ -100,7 +101,7 @@
<span class="material-symbols-outlined" style="font-size:18px">bookmark</span> <span class="material-symbols-outlined" style="font-size:18px">bookmark</span>
Zapisane widoki Zapisane widoki
</button> </button>
<div x-show="open" x-cloak @click.outside="open = false; adding = false" style="position:absolute;top:100%;left: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"> <div x-show="typeof open !== 'undefined' && open" x-cloak @click.outside="open = false; adding = false" style="position:absolute;top:100%;left: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">
@forelse ($this->savedViews as $view) @forelse ($this->savedViews as $view)
<div style="display:flex;align-items:center;gap:4px;padding:2px 2px 2px 8px;border-radius:5px;{{ $savedViewId === $view->id ? 'background:color-mix(in srgb, var(--color-accent) 12%, transparent)' : '' }}"> <div style="display:flex;align-items:center;gap:4px;padding:2px 2px 2px 8px;border-radius:5px;{{ $savedViewId === $view->id ? 'background:color-mix(in srgb, var(--color-accent) 12%, transparent)' : '' }}">
<button type="button" wire:click="applySavedView({{ $view->id }})" style="flex:1;min-width:0;text-align:left;background:none;border:none;cursor:pointer;padding:6px 0;font-size:13px;color:{{ $savedViewId === $view->id ? 'var(--color-accent)' : 'inherit' }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $view->name }}</button> <button type="button" wire:click="applySavedView({{ $view->id }})" style="flex:1;min-width:0;text-align:left;background:none;border:none;cursor:pointer;padding:6px 0;font-size:13px;color:{{ $savedViewId === $view->id ? 'var(--color-accent)' : 'inherit' }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $view->name }}</button>
@@ -116,7 +117,7 @@
<template x-if="! adding"> <template x-if="! adding">
<button type="button" class="btn btn-secondary btn-block" @click="adding = true" style="font-size:12.5px">+ Zapisz bieżące filtry…</button> <button type="button" class="btn btn-secondary btn-block" @click="adding = true" style="font-size:12.5px">+ Zapisz bieżące filtry…</button>
</template> </template>
<div x-show="adding" style="display:flex;gap:6px;padding:4px 2px"> <div x-show="typeof adding !== 'undefined' && adding" style="display:flex;gap:6px;padding:4px 2px">
<input class="input" style="flex:1;font-size:12.5px" placeholder="Nazwa widoku" wire:model="newViewName" @keydown.enter="$wire.saveCurrentView(); adding = false"> <input class="input" style="flex:1;font-size:12.5px" placeholder="Nazwa widoku" wire:model="newViewName" @keydown.enter="$wire.saveCurrentView(); adding = false">
<button type="button" class="btn btn-primary" style="flex:none;padding:6px 10px" @click="$wire.saveCurrentView(); adding = false">Zapisz</button> <button type="button" class="btn btn-primary" style="flex:none;padding:6px 10px" @click="$wire.saveCurrentView(); adding = false">Zapisz</button>
</div> </div>
@@ -128,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="open" x-cloak @click.outside="open = false" style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);z-index:30;padding:6px;min-width:180px"> <div x-show="typeof open !== 'undefined' && open" x-cloak @click.outside="open = false" style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);z-index:30;padding:6px;min-width:220px">
@foreach ($columnDefs as $key => $label) @foreach ($visibleColumns as $i => $key)
<label style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:400;padding:6px 8px;border-radius:5px;cursor:pointer"> <div style="display:flex;align-items:center;gap:4px;padding:2px 2px 2px 8px;border-radius:5px">
<input type="checkbox" @checked(in_array($key, $visibleColumns)) wire:click="toggleColumn('{{ $key }}')"> <label style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:400;flex:1;min-width:0;cursor:pointer">
<input type="checkbox" checked wire:click="toggleColumn('{{ $key }}')">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $columnDefs[$key] ?? $key }}</span>
</label>
<span class="material-symbols-outlined" style="font-size:16px;flex:none;cursor:{{ $i === 0 ? 'default' : 'pointer' }};opacity:{{ $i === 0 ? '0.25' : '0.7' }}" title="Przesuń wcześniej (w lewo w tabeli)" wire:click="moveColumnUp('{{ $key }}')">arrow_upward</span>
<span class="material-symbols-outlined" style="font-size:16px;flex:none;cursor:{{ $i === count($visibleColumns) - 1 ? 'default' : 'pointer' }};opacity:{{ $i === count($visibleColumns) - 1 ? '0.25' : '0.7' }}" title="Przesuń później (w prawo w tabeli)" wire:click="moveColumnDown('{{ $key }}')">arrow_downward</span>
</div>
@endforeach
@php $hiddenColumnDefs = array_diff_key($columnDefs, array_flip($visibleColumns)); @endphp
@if (! empty($hiddenColumnDefs))
<div style="border-top:1px solid var(--color-divider);margin:4px 0"></div>
@foreach ($hiddenColumnDefs as $key => $label)
<label style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:400;padding:6px 8px;border-radius:5px;cursor:pointer;opacity:0.75">
<input type="checkbox" wire:click="toggleColumn('{{ $key }}')">
{{ $label }} {{ $label }}
</label> </label>
@endforeach @endforeach
@endif
</div> </div>
</div> </div>
@@ -147,15 +163,27 @@
$refreshQueueSeconds = max(1, (int) \App\Support\Settings::get('refresh_queue_seconds')); $refreshQueueSeconds = max(1, (int) \App\Support\Settings::get('refresh_queue_seconds'));
@endphp @endphp
<div <div
wire:key="queue-autorefresh"
class="btn btn-secondary" class="btn btn-secondary"
style="cursor:pointer;gap:6px" style="cursor:pointer;gap:6px"
x-data="{ remaining: {{ $refreshQueueSeconds }}, total: {{ $refreshQueueSeconds }} }" x-data="{
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshQueue(); }, 1000)" remaining: {{ $refreshQueueSeconds }}, total: {{ $refreshQueueSeconds }},
tick: null,
navigatingHandler: null,
init() {
this.tick = setInterval(() => {
this.remaining = this.remaining <= 1 ? this.total : this.remaining - 1;
if (this.remaining === this.total) $wire.refreshQueue();
}, 1000);
this.navigatingHandler = () => clearInterval(this.tick);
document.addEventListener('livewire:navigating', this.navigatingHandler);
},
}"
x-on:click="remaining = total; $wire.refreshQueue()" x-on:click="remaining = total; $wire.refreshQueue()"
title="Kolejka odświeża się automatycznie — kliknij, aby odświeżyć teraz" title="Kolejka odświeża się automatycznie — kliknij, aby odświeżyć teraz"
> >
<span class="material-symbols-outlined" style="font-size:18px">schedule</span> <span class="material-symbols-outlined" style="font-size:18px">schedule</span>
<span x-text="remaining + 's'"></span> <span x-text="typeof remaining !== 'undefined' ? (remaining + 's') : ''"></span>
</div> </div>
</div> </div>
@@ -164,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">
@@ -186,44 +214,60 @@
@php $sla = $t->slaInfo(); @endphp @php $sla = $t->slaInfo(); @endphp
<tr wire:key="ticket-{{ $t->id }}"> <tr wire:key="ticket-{{ $t->id }}">
<td class="td-select"><input type="checkbox" @checked(in_array($t->id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})"></td> <td class="td-select"><input type="checkbox" @checked(in_array($t->id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})"></td>
@if (in_array('number', $visibleColumns)) @foreach ($visibleColumns as $key)
@switch($key)
@case('id')
<td data-label="ID">{{ $t->id }}</td>
@break
@case('number')
<td data-label="Numer" class="td-title"> <td data-label="Numer" class="td-title">
<a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->displayNumber() }}</a> <a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->displayNumber() }}</a>
@if ($t->source === 'email') @if ($t->source === 'email')
<span class="material-symbols-outlined" style="font-size:15px;vertical-align:-3px;opacity:0.7" title="Utworzone przez e-mail">mail</span> <span class="material-symbols-outlined" style="font-size:15px;vertical-align:-3px;opacity:0.7" title="Utworzone przez e-mail">mail</span>
@endif @endif
</td> </td>
@endif @break
@if (in_array('subject', $visibleColumns)) @case('subject')
<td data-label="Temat" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer;white-space:nowrap">{{ $t->subject }}</a></td> <td data-label="Temat" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer;white-space:nowrap">{{ $t->subject }}</a></td>
@endif @break
@if (in_array('customer', $visibleColumns)) @case('customer')
<td data-label="Klient" style="white-space:nowrap">{{ $t->name }}</td> <td data-label="Klient" style="white-space:nowrap">{{ $t->name }}</td>
@endif @break
@if (in_array('category', $visibleColumns)) @case('email')
<td data-label="E-mail" style="white-space:nowrap">{{ $t->email }}</td>
@break
@case('category')
<td data-label="Kategoria" style="white-space:nowrap">{{ $t->categoryLabel() }}</td> <td data-label="Kategoria" style="white-space:nowrap">{{ $t->categoryLabel() }}</td>
@endif @break
@if (in_array('subcategory', $visibleColumns)) @case('subcategory')
<td data-label="Podkategoria" style="white-space:nowrap">{{ $t->subcategory?->name ?? '—' }}</td> <td data-label="Podkategoria" style="white-space:nowrap">{{ $t->subcategory?->name ?? '—' }}</td>
@endif @break
@if (in_array('priority', $visibleColumns)) @case('priority')
<td data-label="Priorytet"><span style="{{ $t->priorityStyle() }}">{{ $t->priorityLabel() }}</span></td> <td data-label="Priorytet"><span style="{{ $t->priorityStyle() }}">{{ $t->priorityLabel() }}</span></td>
@endif @break
@if (in_array('status', $visibleColumns)) @case('status')
<td data-label="Status"><span style="{{ $t->statusStyle() }}">{{ $t->statusLabel() }}</span></td> <td data-label="Status"><span style="{{ $t->statusStyle() }}">{{ $t->statusLabel() }}</span></td>
@endif @break
@if (in_array('sla', $visibleColumns)) @case('sla')
<td data-label="SLA"><span class="{{ $sla['cls'] }}">{{ $sla['short'] }}</span></td> <td data-label="SLA"><span class="{{ $sla['cls'] }}">{{ $sla['short'] }}</span></td>
@endif @break
@if (in_array('assignee', $visibleColumns)) @case('assignee')
<td data-label="Przypisany" style="white-space:nowrap">{{ $t->assignee?->name ?? 'Nieprzypisane' }}</td> <td data-label="Przypisany" style="white-space:nowrap">{{ $t->assignee?->name ?? 'Nieprzypisane' }}</td>
@endif @break
@if (in_array('team', $visibleColumns)) @case('team')
<td data-label="Zespół" style="white-space:nowrap">{{ $t->team?->name ?? '—' }}</td> <td data-label="Zespół" style="white-space:nowrap">{{ $t->team?->name ?? '—' }}</td>
@endif @break
@if (in_array('created', $visibleColumns)) @case('source')
<td data-label="Źródło" style="white-space:nowrap">{{ match ($t->source) { 'email' => 'E-mail', 'hesk_import' => 'Import HESK', default => 'WWW' } }}</td>
@break
@case('created')
<td data-label="Utworzono" style="white-space:nowrap">{{ \App\Support\Rel::format($t->created_at) }}</td> <td data-label="Utworzono" style="white-space:nowrap">{{ \App\Support\Rel::format($t->created_at) }}</td>
@endif @break
@case('updated')
<td data-label="Zaktualizowano" style="white-space:nowrap">{{ \App\Support\Rel::format($t->updated_at) }}</td>
@break
@endswitch
@endforeach
</tr> </tr>
@endforeach @endforeach
</tbody> </tbody>

View File

@@ -4,7 +4,8 @@
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto"> <div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto">
<div style="display:flex;flex-direction:column;gap:16px;max-width:1180px;margin:0 auto"> <div style="display:flex;flex-direction:column;gap:16px;max-width:1180px;margin:0 auto">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap"> <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="padding:0;margin-right:auto">&larr; Wróć do listy</a> @php $backQueueTab = session('operator_queue_tab', 'all'); @endphp
<a href="{{ route('operator.queue', $backQueueTab !== 'all' ? ['queue' => $backQueueTab] : []) }}" wire:navigate class="btn btn-ghost" style="padding:0;margin-right:auto">&larr; Wróć do listy</a>
<button <button
type="button" type="button"
@@ -25,15 +26,27 @@
$refreshTicketSeconds = max(1, (int) \App\Support\Settings::get('refresh_ticket_view_seconds')); $refreshTicketSeconds = max(1, (int) \App\Support\Settings::get('refresh_ticket_view_seconds'));
@endphp @endphp
<div <div
wire:key="ticket-autorefresh-{{ $ticket->id }}"
class="btn btn-secondary" class="btn btn-secondary"
style="cursor:pointer;gap:6px" style="cursor:pointer;gap:6px"
x-data="{ remaining: {{ $refreshTicketSeconds }}, total: {{ $refreshTicketSeconds }} }" x-data="{
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)" remaining: {{ $refreshTicketSeconds }}, total: {{ $refreshTicketSeconds }},
tick: null,
navigatingHandler: null,
init() {
this.tick = setInterval(() => {
this.remaining = this.remaining <= 1 ? this.total : this.remaining - 1;
if (this.remaining === this.total) $wire.refreshTicketData();
}, 1000);
this.navigatingHandler = () => clearInterval(this.tick);
document.addEventListener('livewire:navigating', this.navigatingHandler);
},
}"
x-on:click="remaining = total; $wire.refreshTicketData()" x-on:click="remaining = total; $wire.refreshTicketData()"
title="Zgłoszenie odświeża się automatycznie — kliknij, aby odświeżyć teraz" title="Zgłoszenie odświeża się automatycznie — kliknij, aby odświeżyć teraz"
> >
<span class="material-symbols-outlined" style="font-size:18px">schedule</span> <span class="material-symbols-outlined" style="font-size:18px">schedule</span>
<span x-text="remaining + 's'"></span> <span x-text="typeof remaining !== 'undefined' ? (remaining + 's') : ''"></span>
</div> </div>
</div> </div>
@@ -140,7 +153,7 @@
@dragover.prevent="dragging = true" @dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false" @dragleave.prevent="dragging = false"
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))" @drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
:style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }" :style="{ borderColor: (typeof dragging !== 'undefined' && dragging) ? 'var(--color-accent)' : undefined, background: (typeof dragging !== 'undefined' && dragging) ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px" style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
> >
<label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="noteAttachments"></label> <label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="noteAttachments"></label>
@@ -216,7 +229,7 @@
@dragover.prevent="dragging = true" @dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false" @dragleave.prevent="dragging = false"
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))" @drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
:style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }" :style="{ borderColor: (typeof dragging !== 'undefined' && dragging) ? 'var(--color-accent)' : undefined, background: (typeof dragging !== 'undefined' && dragging) ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px" style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
> >
<label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="replyAttachments"></label> <label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="replyAttachments"></label>
@@ -235,7 +248,7 @@
<button class="btn btn-primary" type="button" style="border-top-left-radius:0;border-bottom-left-radius:0;border-left:1px solid var(--color-bg);padding:0 6px" @click="open = !open"> <button class="btn btn-primary" type="button" style="border-top-left-radius:0;border-bottom-left-radius:0;border-left:1px solid var(--color-bg);padding:0 6px" @click="open = !open">
<span class="material-symbols-outlined" style="font-size:16px">expand_more</span> <span class="material-symbols-outlined" style="font-size:16px">expand_more</span>
</button> </button>
<div x-show="open" x-cloak style="position:absolute;bottom:100%;right:0;margin-bottom:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:220px;overflow:hidden;z-index:10"> <div x-show="typeof open !== 'undefined' && open" x-cloak style="position:absolute;bottom:100%;right:0;margin-bottom:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:220px;overflow:hidden;z-index:10">
@forelse ($this->replyQuickActions as $qa) @forelse ($this->replyQuickActions as $qa)
<button type="button" class="theme-toggle-option" @click="open = false" wire:click="sendAndTransition({{ $qa->id }})">{{ $qa->label }}</button> <button type="button" class="theme-toggle-option" @click="open = false" wire:click="sendAndTransition({{ $qa->id }})">{{ $qa->label }}</button>
@empty @empty
@@ -439,6 +452,7 @@
<div class="card" style="padding:16px;gap:8px"> <div class="card" style="padding:16px;gap:8px">
<div class="card-kicker">MONITOR CZASU PRACY</div> <div class="card-kicker">MONITOR CZASU PRACY</div>
<div <div
wire:key="ticket-timer-{{ $ticket->id }}"
style="display:flex;flex-direction:column;gap:6px" style="display:flex;flex-direction:column;gap:6px"
x-data="{ x-data="{
seconds: {{ $ticket->timerElapsedSeconds() }}, seconds: {{ $ticket->timerElapsedSeconds() }},
@@ -464,16 +478,43 @@
// The timer only tracks time this ticket is actually open in // The timer only tracks time this ticket is actually open in
// the browser: stop it server-side the moment the operator // the browser: stop it server-side the moment the operator
// navigates elsewhere in the app (wire:navigate — a normal // navigates elsewhere in the app (wire:navigate — a normal
// Livewire call, since the document itself isn't unloading)… // SPA transition, since the document itself isn't unloading)…
//
// Alpine has no automatic on-element-removed hook (a
// `destroy(){}` method here — as this used to have — is never
// called by anything), so wire:navigate leaving this ticket
// page previously left `tick`'s setInterval running forever in
// the background against a stale scope, eventually throwing
// 'format is not defined' or similar once Alpine's reactivity
// for it was in a half-torn-down state. Explicitly tearing
// everything down here (the one hook that's actually invoked,
// on navigation away) fixes that at the source.
//
// This deliberately does NOT use $wire.call('stopTimer') —
// that used to be here, but firing a Livewire action from
// inside the livewire:navigating handler makes Livewire process
// that action's response (and morph the DOM for it) while the
// SPA navigation to the next page is already underway, which
// corrupts unrelated Alpine components elsewhere on the page
// (confirmed via a browser console capture: 'Illegal invocation'
// errors on the shared topbar's dropdowns, traced back to this
// exact handler in the stack). sendBeacon — the same mechanism
// pagehideHandler already uses below, for the same underlying
// reason — is a plain HTTP POST that never touches Livewire's
// component/morph pipeline, so it can't race with navigation.
this.navigatingHandler = () => { this.navigatingHandler = () => {
if (this.running) $wire.call('stopTimer'); if (this.running) {
navigator.sendBeacon(@js(route('operator.ticket.stop-timer', $ticket)));
}
this.teardown();
}; };
document.addEventListener('livewire:navigating', this.navigatingHandler); document.addEventListener('livewire:navigating', this.navigatingHandler);
// …or closes/reloads the tab. Regular fetch/Livewire calls // …or closes/reloads the tab. Regular fetch/Livewire calls
// can get cut off mid-flight during an actual page unload, so // can get cut off mid-flight during an actual page unload, so
// this uses sendBeacon against a plain endpoint instead, which // this uses sendBeacon against a plain endpoint instead, which
// browsers guarantee to deliver. // browsers guarantee to deliver. No teardown() needed here —
// the whole JS realm is going away with the page.
this.pagehideHandler = () => { this.pagehideHandler = () => {
if (this.running) { if (this.running) {
navigator.sendBeacon(@js(route('operator.ticket.stop-timer', $ticket))); navigator.sendBeacon(@js(route('operator.ticket.stop-timer', $ticket)));
@@ -481,7 +522,7 @@
}; };
window.addEventListener('pagehide', this.pagehideHandler); window.addEventListener('pagehide', this.pagehideHandler);
}, },
destroy() { teardown() {
clearInterval(this.tick); clearInterval(this.tick);
document.removeEventListener('livewire:navigating', this.navigatingHandler); document.removeEventListener('livewire:navigating', this.navigatingHandler);
window.removeEventListener('pagehide', this.pagehideHandler); window.removeEventListener('pagehide', this.pagehideHandler);
@@ -500,7 +541,12 @@
</div> </div>
@else @else
<div style="font-size:12px;display:flex;align-items:center;gap:6px"> <div style="font-size:12px;display:flex;align-items:center;gap:6px">
<span>Czas w zgłoszeniu: <strong x-text="format()"></strong></span> {{-- Guarded call: even with the interval/listener cleanup above, a
refreshTicketData() response already in flight when the operator
navigates away can still land afterward and get morphed against
a scope that's mid-teardown typeof-guarding here means that
race can no longer throw, whatever exactly triggers it. --}}
<span>Czas w zgłoszeniu: <strong x-text="typeof format === 'function' ? format() : ''"></strong></span>
<span class="material-symbols-outlined" style="font-size:16px;cursor:pointer;opacity:0.7" wire:click="startEditTimer">edit</span> <span class="material-symbols-outlined" style="font-size:16px;cursor:pointer;opacity:0.7" wire:click="startEditTimer">edit</span>
</div> </div>
@if ($ticket->isClosed()) @if ($ticket->isClosed())

View File

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

View File

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

View File

@@ -0,0 +1,78 @@
<?php
use App\Livewire\Admin\Logs;
use App\Models\User;
use Livewire\Livewire;
function adminUserForLogsTest(): User
{
return User::query()->create(['name' => 'Admin', 'email' => 'admin-logs@example.com', 'roles' => ['admin']]);
}
beforeEach(function () {
$this->logPath = storage_path('logs/pest-admin-logs-test.log');
file_put_contents($this->logPath, implode("\n", [
'[2026-08-05 08:00:00] production.INFO: normalny wpis o połączeniu',
'[2026-08-05 08:01:00] production.ERROR: coś się nie udało',
'#0 stack trace line belonging to the ERROR entry above',
'[2026-08-05 08:02:00] production.WARNING: uwaga na przyszłość',
])."\n");
});
afterEach(function () {
@unlink($this->logPath);
});
test('admin sees the log file list and its tail content', function () {
$admin = adminUserForLogsTest();
Livewire::actingAs($admin)->test(Logs::class)
->set('selectedFile', 'pest-admin-logs-test.log')
->assertSee('pest-admin-logs-test.log')
->assertSee('normalny wpis o połączeniu')
->assertSee('coś się nie udało')
->assertSee('stack trace line belonging to the ERROR entry above');
});
test('multi-line entries stay grouped and level filter narrows to matching entries', function () {
$admin = adminUserForLogsTest();
$component = Livewire::actingAs($admin)->test(Logs::class)
->set('selectedFile', 'pest-admin-logs-test.log');
expect($component->get('entries'))->toHaveCount(3);
$component->set('levelFilter', 'ERROR');
$entries = $component->get('entries');
expect($entries)->toHaveCount(1)
->and($entries[0]['text'])->toContain('coś się nie udało')
->and($entries[0]['text'])->toContain('stack trace line belonging to the ERROR entry above');
});
test('search filters entries by substring', function () {
$admin = adminUserForLogsTest();
$component = Livewire::actingAs($admin)->test(Logs::class)
->set('selectedFile', 'pest-admin-logs-test.log')
->set('search', 'uwaga');
expect($component->get('entries'))->toHaveCount(1)
->and($component->get('entries')[0]['text'])->toContain('uwaga na przyszłość');
});
test('selecting an unknown file name is ignored, preventing path traversal via the public property', function () {
$admin = adminUserForLogsTest();
$component = Livewire::actingAs($admin)->test(Logs::class)
->set('selectedFile', 'pest-admin-logs-test.log')
->call('selectFile', '../../.env');
expect($component->get('selectedFile'))->toBe('pest-admin-logs-test.log');
});
test('non-admin cannot open the admin panel logs tab', function () {
$operator = User::query()->create(['name' => 'Op', 'email' => 'op-logs@example.com', 'roles' => ['operator']]);
$this->actingAs($operator)->get('/admin')->assertForbidden();
});

View File

@@ -35,9 +35,30 @@ test('the subcategory scope picker only appears once "klient może wybrać sprz
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations']) Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->set('snipeitConfig.enabled', true) ->set('snipeitConfig.enabled', true)
->assertDontSee('Ogranicz do podkategorii') ->assertDontSee('Ogranicz do podkategorii')
->assertDontSee('Ogranicz do kategorii')
->set('snipeitConfig.clientCanSelectAsset', true) ->set('snipeitConfig.clientCanSelectAsset', true)
->assertSee('Ogranicz do podkategorii') ->assertSee('Ogranicz do podkategorii')
->assertSee('Sprzęt / Laptop'); ->assertSee('Sprzęt / Laptop')
->assertSee('Ogranicz do kategorii')
->assertSee('Sprzęt');
});
test('an admin can allow-list a whole category for the Snipe-IT client picker', function () {
$admin = adminUserForSnipeitTest();
$itHelp = Category::query()->create(['name' => 'IT-Pomoc']);
$orders = Category::query()->create(['name' => 'Zamówienia']);
$component = Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->set('snipeitConfig.enabled', true)
->set('snipeitConfig.clientCanSelectAsset', true)
->call('toggleSnipeitClientCategory', $itHelp->id)
->call('toggleSnipeitClientCategory', $orders->id);
expect($component->get('snipeitConfig.clientAssetCategoryIds'))->toEqualCanonicalizing([$itHelp->id, $orders->id]);
// Toggling again removes it.
$component->call('toggleSnipeitClientCategory', $orders->id);
expect($component->get('snipeitConfig.clientAssetCategoryIds'))->toBe([$itHelp->id]);
}); });
test('saving the Snipe-IT config persists settings, encrypts the token at rest, and inverts the SSL checkbox', function () { test('saving the Snipe-IT config persists settings, encrypts the token at rest, and inverts the SSL checkbox', function () {
@@ -52,6 +73,7 @@ test('saving the Snipe-IT config persists settings, encrypts the token at rest,
->set('snipeitConfig.skipSslVerification', true) ->set('snipeitConfig.skipSslVerification', true)
->set('snipeitConfig.clientCanSelectAsset', true) ->set('snipeitConfig.clientCanSelectAsset', true)
->call('toggleSnipeitClientSubcategory', $sub->id) ->call('toggleSnipeitClientSubcategory', $sub->id)
->call('toggleSnipeitClientCategory', $category->id)
->set('snipeitConfig.operatorViewRequesterAssets', true) ->set('snipeitConfig.operatorViewRequesterAssets', true)
->set('snipeitConfig.operatorSearchInventory', false) ->set('snipeitConfig.operatorSearchInventory', false)
->call('saveSnipeitConfig') ->call('saveSnipeitConfig')
@@ -64,6 +86,7 @@ test('saving the Snipe-IT config persists settings, encrypts the token at rest,
expect(Settings::bool('snipeit_verify_ssl'))->toBeFalse(); expect(Settings::bool('snipeit_verify_ssl'))->toBeFalse();
expect(Settings::bool('snipeit_client_can_select_asset'))->toBeTrue(); expect(Settings::bool('snipeit_client_can_select_asset'))->toBeTrue();
expect(Settings::get('snipeit_client_asset_subcategory_ids'))->toBe((string) $sub->id); expect(Settings::get('snipeit_client_asset_subcategory_ids'))->toBe((string) $sub->id);
expect(Settings::get('snipeit_client_asset_category_ids'))->toBe((string) $category->id);
expect(Settings::bool('snipeit_operator_view_requester_assets'))->toBeTrue(); expect(Settings::bool('snipeit_operator_view_requester_assets'))->toBeTrue();
expect(Settings::bool('snipeit_operator_search_inventory'))->toBeFalse(); expect(Settings::bool('snipeit_operator_search_inventory'))->toBeFalse();

View File

@@ -45,7 +45,7 @@ test('sla is not a clickable sortable column', function () {
Livewire::actingAs($operator)->test(Queue::class) Livewire::actingAs($operator)->test(Queue::class)
->call('sortByColumn', 'sla') ->call('sortByColumn', 'sla')
->assertSet('sortBy', 'updated_at'); ->assertSet('sortBy', 'created');
}); });
test('columns can be hidden and shown again, but at least one must stay visible', function () { test('columns can be hidden and shown again, but at least one must stay visible', function () {

View File

@@ -133,6 +133,36 @@ test('the client asset picker only shows for subcategories the admin allow-liste
->assertDontSee('SN123'); ->assertDontSee('SN123');
}); });
test('the client asset picker shows for any subcategory of an allow-listed category, without listing that subcategory itself', function () {
seedStatusesAndPriorities();
$email = 'client-snipeit6@example.com';
fakeSnipeitUserAsset($email);
$client = User::query()->create(['name' => 'Test Client', 'email' => $email, 'roles' => ['client']]);
$category = Category::query()->create(['name' => 'IT-Pomoc']);
$sub = $category->subcategories()->create(['name' => 'Awaria sprzętu']);
$otherCategory = Category::query()->create(['name' => 'Kadry']);
$otherSub = $otherCategory->subcategories()->create(['name' => 'Urlop']);
enableSnipeitForLinkingTest();
Settings::set('snipeit_client_asset_category_ids', (string) $category->id);
// Any subcategory under the allow-listed category shows the picker,
// even though only the category (not this specific subcategory) is listed.
Livewire::actingAs($client)->test(ClientNewTicket::class)
->call('selectCategory', $category->id)
->call('selectSubcategory', $sub->id)
->call('loadSnipeitAssets')
->assertSee('SI-001 - SN123 - Dell Latitude 5420');
// A subcategory under a different, non-allow-listed category stays hidden.
Livewire::actingAs($client)->test(ClientNewTicket::class)
->call('selectCategory', $otherCategory->id)
->call('selectSubcategory', $otherSub->id)
->call('loadSnipeitAssets')
->assertDontSee('SN123');
});
test('changing subcategory clears a previously selected asset', function () { test('changing subcategory clears a previously selected asset', function () {
seedStatusesAndPriorities(); seedStatusesAndPriorities();
$email = 'client-snipeit5@example.com'; $email = 'client-snipeit5@example.com';

View File

@@ -0,0 +1,59 @@
<?php
use App\Livewire\Operator\Stats;
use App\Models\Category;
use App\Models\User;
use Livewire\Livewire;
test('byCategory counts tickets routed to a bare category (no subcategory) alongside subcategorized ones', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-cat@example.com', 'roles' => ['admin']]);
$it = Category::query()->create(['name' => 'IT-Pomoc']);
$vpn = $it->subcategories()->create(['name' => 'VPN']);
$delegacje = Category::query()->create(['name' => 'Delegacje']);
makeTicket(['number' => '7001', 'subcategory_id' => $vpn->id]);
makeTicket(['number' => '7002', 'subcategory_id' => $vpn->id]);
// Routed to a whole category with no subcategory (e.g. an IMAP mailbox
// routed to "całą kategorię") — previously invisible to byCategory().
makeTicket(['number' => '7003', 'category_id' => $delegacje->id]);
makeTicket(['number' => '7004', 'category_id' => $delegacje->id]);
makeTicket(['number' => '7005', 'category_id' => $delegacje->id]);
$rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->byCategory;
expect($rows->firstWhere('label', 'IT-Pomoc'))->toBe(['label' => 'IT-Pomoc', 'count' => 2])
->and($rows->firstWhere('label', 'Delegacje'))->toBe(['label' => 'Delegacje', 'count' => 3])
->and($rows->count())->toBe(2);
});
test('byCategory sums both bare-category and subcategorized tickets into the same category total', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-cat-2@example.com', 'roles' => ['admin']]);
$it = Category::query()->create(['name' => 'IT-Pomoc']);
$vpn = $it->subcategories()->create(['name' => 'VPN']);
makeTicket(['number' => '7101', 'subcategory_id' => $vpn->id]);
makeTicket(['number' => '7102', 'category_id' => $it->id]);
$rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->byCategory;
expect($rows->firstWhere('label', 'IT-Pomoc'))->toBe(['label' => 'IT-Pomoc', 'count' => 2]);
});
test('filterCategory includes tickets routed to a bare category with no subcategory', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-cat-3@example.com', 'roles' => ['admin']]);
$delegacje = Category::query()->create(['name' => 'Delegacje']);
makeTicket(['number' => '7201', 'category_id' => $delegacje->id]);
makeTicket(['number' => '7202']);
$kpis = Livewire::actingAs($admin)->test(Stats::class)
->set('filterCategory', (string) $delegacje->id)
->instance()->kpis;
expect($kpis['total'])->toBe(1);
});

View File

@@ -28,3 +28,22 @@ test('bySubcategory groups tickets per subcategory, labeled "Category / Subcateg
->and($rows->firstWhere('label', 'Zamówienia / Sprzęt'))->toBe(['label' => 'Zamówienia / Sprzęt', 'count' => 1]) ->and($rows->firstWhere('label', 'Zamówienia / Sprzęt'))->toBe(['label' => 'Zamówienia / Sprzęt', 'count' => 1])
->and($rows->count())->toBe(3); ->and($rows->count())->toBe(3);
}); });
test('bySubcategory gives tickets routed to a bare category (no subcategory) their own "(bez podkategorii)" row', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-sub-2@example.com', 'roles' => ['admin']]);
$it = Category::query()->create(['name' => 'IT-Pomoc']);
$vpn = $it->subcategories()->create(['name' => 'VPN']);
$delegacje = Category::query()->create(['name' => 'Delegacje']);
makeTicket(['number' => '4101', 'subcategory_id' => $vpn->id]);
makeTicket(['number' => '4102', 'category_id' => $delegacje->id]);
makeTicket(['number' => '4103', 'category_id' => $delegacje->id]);
$rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->bySubcategory;
expect($rows->firstWhere('label', 'IT-Pomoc / VPN'))->toBe(['label' => 'IT-Pomoc / VPN', 'count' => 1])
->and($rows->firstWhere('label', 'Delegacje (bez podkategorii)'))->toBe(['label' => 'Delegacje (bez podkategorii)', 'count' => 2])
->and($rows->count())->toBe(2);
});

View File

@@ -1,8 +1,11 @@
<?php <?php
use App\Livewire\Admin\Panel; use App\Livewire\Admin\Panel;
use App\Livewire\Client\Dashboard;
use App\Livewire\Operator\Queue; use App\Livewire\Operator\Queue;
use App\Livewire\Operator\TicketShow;
use App\Livewire\Settings\NotificationPreferences; use App\Livewire\Settings\NotificationPreferences;
use App\Models\User;
use Livewire\Livewire; use Livewire\Livewire;
test('the admin panel remembers the active tab across a fresh page load via the URL', function () { test('the admin panel remembers the active tab across a fresh page load via the URL', function () {
@@ -37,3 +40,36 @@ test('the notifications settings page has a back link to the operator queue', fu
Livewire::actingAs($operator)->test(NotificationPreferences::class) Livewire::actingAs($operator)->test(NotificationPreferences::class)
->assertSeeHtml(route('operator.queue')); ->assertSeeHtml(route('operator.queue'));
}); });
test('the operator ticket page\'s "Wróć do listy" link returns to whichever queue tab was actually active', function () {
seedStatusesAndPriorities();
$operator = operatorUser('backlink-op@example.com');
$ticket = makeTicket(['number' => '7001', 'status_key' => 'closed']);
// Switching tabs (as clicking the sidebar would) remembers it in the session.
Livewire::actingAs($operator)->test(Queue::class)->call('setQueue', 'closed');
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
->assertSeeHtml(route('operator.queue', ['queue' => 'closed']));
});
test('the operator ticket page\'s back link omits the query string for the default "all" tab', function () {
seedStatusesAndPriorities();
$operator = operatorUser('backlink-op-default@example.com');
$ticket = makeTicket(['number' => '7002']);
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
->assertSeeHtml(route('operator.queue'))
->assertDontSeeHtml(route('operator.queue', ['queue' => 'all']));
});
test('the client ticket page\'s "Wróć do listy" link returns to the Archiwum tab when that\'s where the ticket was opened from', function () {
seedStatusesAndPriorities();
$client = User::query()->create(['name' => 'Client', 'email' => 'backlink-client@example.com', 'roles' => ['client']]);
$ticket = makeTicket(['number' => '7003', 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name, 'status_key' => 'closed']);
Livewire::actingAs($client)->test(Dashboard::class)->call('setTab', 'archive');
Livewire::actingAs($client)->test(App\Livewire\Client\TicketShow::class, ['ticket' => $ticket])
->assertSeeHtml(route('client.dashboard', ['tab' => 'archive']));
});

View File

@@ -41,6 +41,18 @@ test('next ticket number is one above the current max', function () {
expect(Ticket::nextNumber())->toBe('1002'); expect(Ticket::nextNumber())->toBe('1002');
}); });
test('next ticket number compares numerically, not lexicographically, across differing digit counts', function () {
seedStatusesAndPriorities();
// A plain string MAX()/ORDER BY would rank '999' above '1000' (lexicographic
// "9" > "1"), which is exactly the bug nextNumber()'s length-first ordering
// guards against — see Ticket::nextNumber().
makeTicket(['number' => '999']);
makeTicket(['number' => '1000']);
expect(Ticket::nextNumber())->toBe('1001');
});
test('sla info reports overdue once the resolution deadline has passed', function () { test('sla info reports overdue once the resolution deadline has passed', function () {
seedStatusesAndPriorities(); seedStatusesAndPriorities();

View File

@@ -0,0 +1,50 @@
<?php
use App\Models\Category;
use App\Models\CustomField;
test('creating a ticket with custom_fields populates ticket_field_values with matching rows', function () {
seedStatusesAndPriorities();
$category = Category::query()->create(['name' => 'IT-Pomoc']);
$subcategory = $category->subcategories()->create(['name' => 'VPN']);
$field = CustomField::query()->create(['label' => 'Teamviewer-ID', 'type' => 'text', 'required' => false, 'sort_order' => 1]);
$field->subcategories()->attach($subcategory->id, ['position' => 1]);
$ticket = makeTicket(['subcategory_id' => $subcategory->id, 'custom_fields' => [$field->id => '123-456']]);
expect($ticket->fieldValues)->toHaveCount(1);
expect($ticket->fieldValues->first()->custom_field_id)->toBe($field->id);
expect($ticket->fieldValues->first()->value)->toBe('123-456');
});
test('updating custom_fields keeps ticket_field_values in sync — added, changed and removed entries', function () {
seedStatusesAndPriorities();
$category = Category::query()->create(['name' => 'IT-Pomoc']);
$subcategory = $category->subcategories()->create(['name' => 'VPN']);
$fieldA = CustomField::query()->create(['label' => 'A', 'type' => 'text', 'required' => false, 'sort_order' => 1]);
$fieldB = CustomField::query()->create(['label' => 'B', 'type' => 'text', 'required' => false, 'sort_order' => 2]);
$fieldA->subcategories()->attach($subcategory->id, ['position' => 1]);
$fieldB->subcategories()->attach($subcategory->id, ['position' => 2]);
$ticket = makeTicket([
'subcategory_id' => $subcategory->id,
'custom_fields' => [$fieldA->id => 'first', $fieldB->id => 'second'],
]);
$ticket->update(['custom_fields' => [$fieldA->id => 'changed']]);
expect($ticket->fieldValues()->count())->toBe(1);
expect($ticket->fieldValues()->first()->value)->toBe('changed');
});
test('a blank custom field value is not synced into ticket_field_values', function () {
seedStatusesAndPriorities();
$category = Category::query()->create(['name' => 'IT-Pomoc']);
$subcategory = $category->subcategories()->create(['name' => 'VPN']);
$field = CustomField::query()->create(['label' => 'A', 'type' => 'text', 'required' => false, 'sort_order' => 1]);
$field->subcategories()->attach($subcategory->id, ['position' => 1]);
$ticket = makeTicket(['subcategory_id' => $subcategory->id, 'custom_fields' => [$field->id => '']]);
expect($ticket->fieldValues()->count())->toBe(0);
});

View File

@@ -0,0 +1,38 @@
<?php
use App\Models\Ticket;
test('a ticket accepts every known source value', function () {
seedStatusesAndPriorities();
foreach (Ticket::SOURCES as $source) {
$ticket = makeTicket(['number' => uniqid(), 'source' => $source]);
expect($ticket->source)->toBe($source);
}
});
test('creating a ticket with an unrecognized source throws instead of silently storing it', function () {
seedStatusesAndPriorities();
expect(fn () => makeTicket(['source' => 'totally-made-up']))
->toThrow(InvalidArgumentException::class);
});
test('a ticket message accepts a null source (implicit web) and the known email source', function () {
seedStatusesAndPriorities();
$ticket = makeTicket();
$webMessage = $ticket->messages()->create(['author_name' => 'A', 'body' => 'web reply']);
$mailMessage = $ticket->messages()->create(['author_name' => 'B', 'body' => 'mail reply', 'source' => 'email']);
expect($webMessage->source)->toBeNull()
->and($mailMessage->source)->toBe('email');
});
test('a ticket message with an unrecognized source throws instead of silently storing it', function () {
seedStatusesAndPriorities();
$ticket = makeTicket();
expect(fn () => $ticket->messages()->create(['author_name' => 'A', 'body' => 'x', 'source' => 'totally-made-up']))
->toThrow(InvalidArgumentException::class);
});

View File

@@ -297,11 +297,16 @@ razem, zamiast być rozrzucone po różnych zakładkach.
- **Klient może wybrać sprzęt, którego dotyczy zgłoszenie** — przy - **Klient może wybrać sprzęt, którego dotyczy zgłoszenie** — przy
tworzeniu zgłoszenia klient widzi listę swojego sprzętu z Snipe-IT tworzeniu zgłoszenia klient widzi listę swojego sprzętu z Snipe-IT
(dopasowanego po adresie e-mail) i może je powiązać ze zgłoszeniem. Po (dopasowanego po adresie e-mail) i może je powiązać ze zgłoszeniem. Po
zaznaczeniu pojawia się dodatkowa lista wielokrotnego wyboru **„Ogranicz zaznaczeniu pojawia się dwie dodatkowe listy wielokrotnego wyboru:
do podkategorii”** — wybór sprzętu pokaże się klientowi **tylko** dla **„Ogranicz do kategorii”** — najszybszy sposób, żeby włączyć wybór
zaznaczonych tam podkategorii; jeśli nic nie jest zaznaczone, opcja nie sprzętu dla wszystkich podkategorii naraz w zaznaczonych tu kategoriach
pojawi się w żadnej podkategorii (tak samo jak dozwolone półki BookStack (np. zaznacz „IT-Pomoc” i „Zamówienia”, żeby objąć każdą ich
wyżej — trzeba świadomie wskazać zakres). podkategorię) — oraz **„Ogranicz do podkategorii”**, do doprecyzowania
pojedynczych podkategorii niezależnie od tego, czy ich kategoria jest
zaznaczona wyżej. Wybór sprzętu pokaże się klientowi tylko tam, gdzie
trafi w którąkolwiek z dwóch list; jeśli obie są puste, opcja nie pojawi
się w żadnej podkategorii (tak samo jak dozwolone półki BookStack wyżej —
trzeba świadomie wskazać zakres).
- **Operator może zobaczyć sprzęt zgłaszającego w widoku zgłoszenia** — ta - **Operator może zobaczyć sprzęt zgłaszającego w widoku zgłoszenia** — ta
sama lista sprzętu zgłaszającego, tym razem w panelu bocznym operatora sama lista sprzętu zgłaszającego, tym razem w panelu bocznym operatora
na widoku zgłoszenia, z przyciskiem „Powiąż” przy każdej pozycji. na widoku zgłoszenia, z przyciskiem „Powiąż” przy każdej pozycji.
@@ -353,6 +358,17 @@ razem, zamiast być rozrzucone po różnych zakładkach.
- **Prompt systemowy podsumowania** — edytowalne pole tekstowe z gotową - **Prompt systemowy podsumowania** — edytowalne pole tekstowe z gotową
wartością domyślną i przyciskiem **„Resetuj”**. wartością domyślną i przyciskiem **„Resetuj”**.
## Logi
Zakładka **„Logi”** pokazuje zawartość plików `storage/logs/*.log` (aplikacja,
IMAP, automatyzacja AI, import z Heska, itd.) bez potrzeby dostępu do
kontenera przez SSH/shell. Lista plików po lewej pokazuje rozmiar i datę
ostatniej modyfikacji (najnowsze na górze) — kliknięcie ładuje zawartość.
Filtry: poziom (ERROR/WARNING/INFO/...), wyszukiwanie tekstowe i liczba
pokazywanych wpisów (1003000, pokazuje ostatnie N pasujących), plus
opcjonalne automatyczne odświeżanie co 5 s. Widok jest tylko do odczytu i
dostępny wyłącznie dla administratorów.
## API ## API
Panel `/admin/api-docs` udostępnia interaktywną dokumentację (Swagger) REST API Panel `/admin/api-docs` udostępnia interaktywną dokumentację (Swagger) REST API

View File

@@ -43,7 +43,8 @@ Dashboard klienta dzieli zgłoszenia na dwie zakładki:
- **Bieżące** — zgłoszenia jeszcze nie zamknięte. - **Bieżące** — zgłoszenia jeszcze nie zamknięte.
- **Archiwum** — zgłoszenia zamknięte. - **Archiwum** — zgłoszenia zamknięte.
Pole wyszukiwania nad listą przeszukuje numer, temat i treść zgłoszenia (oraz Obie listy pokazują zgłoszenia od najnowszych (wg daty utworzenia). Pole
wyszukiwania nad listą przeszukuje numer, temat i treść zgłoszenia (oraz
odpowiedzi w wątku). Obie zakładki są stronicowane (20 zgłoszeń na stronę), odpowiedzi w wątku). Obie zakładki są stronicowane (20 zgłoszeń na stronę),
niezależnie od siebie — przełączenie zakładki nie cofa Cię na pierwszą stronę niezależnie od siebie — przełączenie zakładki nie cofa Cię na pierwszą stronę
drugiej. **Ctrl+K**/**Cmd+K** otwiera też globalną wyszukiwarkę zgłoszeń z drugiej. **Ctrl+K**/**Cmd+K** otwiera też globalną wyszukiwarkę zgłoszeń z

View File

@@ -47,12 +47,18 @@ otworzyłeś, najnowsze na górze.
e-mailu dowolnego konta (nie tylko klientów), pokazuje liczbę jego zgłoszeń i e-mailu dowolnego konta (nie tylko klientów), pokazuje liczbę jego zgłoszeń i
prowadzi od razu do kolejki przefiltrowanej do tego klienta. prowadzi od razu do kolejki przefiltrowanej do tego klienta.
**Filtry** nad tabelą: status, priorytet, kategoria, wyszukiwanie po numerze/ **Filtry** nad tabelą: status, priorytet, kategoria (opcja **„Bez kategorii”**
pokazuje zgłoszenia bez przypisanej kategorii i podkategorii — np. z poczty
IMAP, która nie trafiła w żadną), wyszukiwanie po numerze/
temacie/kliencie/treści zgłoszenia i odpowiedzi w wątku. **Kolumny** można dowolnie 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ą
@@ -141,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,