Compare commits
2 Commits
03c6ec7cae
...
v1.5.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 0943829331 | |||
| 4b70b910a9 |
110
ARCHITECTURE.md
110
ARCHITECTURE.md
@@ -36,13 +36,17 @@ Category ─< Subcategory ─< CustomField (per-subcategory custom fields
|
||||
├──< TicketMessage (public replies + internal notes)
|
||||
├──< TicketAttachment
|
||||
├──< 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
|
||||
├── status → Status (fixed stages: new/open/closed)
|
||||
├── priority → Priority → SlaRule (response/resolution minutes)
|
||||
└── csat_rating/csat_comment/csat_rated_at (nullable — set once, on close)
|
||||
|
||||
User ─< UserFieldValue >─ UserField
|
||||
User ─< SavedQueueView (operator's own saved queue filter/sort/column presets)
|
||||
User ─< SavedQueueView (operator's own named/default saved queue filter/sort/column presets)
|
||||
User.operator_queue_columns (JSON, auto-remembers shown/hidden queue columns + their order, independent of SavedQueueView)
|
||||
User ─< notifications (Laravel's database channel — polymorphic, morph-mapped as 'user')
|
||||
ApiClient (Sanctum token owner, ability-scoped)
|
||||
Setting (single-row-per-key config store, see below)
|
||||
@@ -63,6 +67,51 @@ queue + unassigned + anything assigned to them, an admin sees everything), and
|
||||
work-timer tracking (`timerElapsedSeconds()`). Keep ticket-shaped logic here
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
`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
|
||||
willing to call it:
|
||||
|
||||
- `snipeit_client_can_select_asset` (+ `snipeit_client_asset_subcategory_ids`,
|
||||
a comma-separated allow-list) — gates `Client\NewTicket`'s asset picker.
|
||||
Mirrors BookStack's shelf allow-lists: an **empty** subcategory list means
|
||||
the picker never shows for any subcategory, not "every subcategory" —
|
||||
`NewTicket::snipeitAssets()` checks both the toggle and that the currently
|
||||
selected `subcategoryId` is in the list before calling
|
||||
`assetsForEmail()`. `selectCategory()`/`selectSubcategory()` reset any
|
||||
already-picked asset, so switching to an out-of-scope subcategory can't
|
||||
silently carry a stale selection through to `submit()`.
|
||||
- `snipeit_client_can_select_asset` (+ `snipeit_client_asset_subcategory_ids`
|
||||
and `snipeit_client_asset_category_ids`, two independent comma-separated
|
||||
allow-lists) — gates `Client\NewTicket`'s asset picker. Mirrors BookStack's
|
||||
shelf allow-lists: **empty** lists mean the picker never shows for any
|
||||
subcategory, not "every subcategory" — `NewTicket::snipeitAssets()` checks
|
||||
the toggle and that *either* the currently selected `subcategoryId` is in
|
||||
the subcategory list *or* `categoryId` is in the (coarser) category list
|
||||
before calling `assetsForEmail()`. The category list exists so an admin can
|
||||
cover every subcategory of a category in one click instead of ticking each
|
||||
one individually; the two lists are additive, not exclusive.
|
||||
`selectCategory()`/`selectSubcategory()` reset any already-picked asset, so
|
||||
switching to an out-of-scope subcategory can't silently carry a stale
|
||||
selection through to `submit()`.
|
||||
- `snipeit_operator_view_requester_assets` — gates the same
|
||||
`assetsForEmail()` lookup (by the ticket's own `email`, not the viewing
|
||||
operator's) in `Operator\TicketShow`'s sidebar.
|
||||
@@ -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
|
||||
caller-provided one (the inventory-search box + its results share one card).
|
||||
|
||||
A linked ticket only stores `tickets.snipeit_asset_id` + a cached
|
||||
`snipeit_asset_name` label (`TicketService::setSnipeitAsset()`, which also
|
||||
writes a ticket-history line) — no other Snipe-IT fields are persisted.
|
||||
A linked ticket only stores an `asset_id` + a cached `asset_name` label on
|
||||
the related `ticket_snipeit_assets` row (`TicketService::setSnipeitAsset()`,
|
||||
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),
|
||||
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
|
||||
@@ -617,7 +693,9 @@ live customer submitting a ticket:
|
||||
state (no category/subcategory at all → assign both; category but no
|
||||
subcategory → pick one within it; already has a subcategory → recheck and
|
||||
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
|
||||
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
|
||||
@@ -637,8 +715,8 @@ live customer submitting a ticket:
|
||||
per pass.
|
||||
- **`App\Services\TicketAiSummaryService`** — a summary + suggested next
|
||||
action for **every** ticket (gated by a single `ai_summary_enabled`
|
||||
toggle), cached on `tickets.ai_summary`/`ai_suggested_action`/
|
||||
`ai_summary_generated_at` and shown only in the operator ticket view (a
|
||||
toggle), cached on the related `ticket_ai_summaries` row's `summary`/
|
||||
`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
|
||||
BookStack suggestions card next to it). `run()` (the scheduled sweep)
|
||||
regenerates whenever a ticket's latest message postdates its last summary
|
||||
|
||||
106
CHANGELOG.md
106
CHANGELOG.md
@@ -3,6 +3,112 @@
|
||||
All notable changes to this project are documented in this file. Format loosely
|
||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [1.5.1] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Operator queue columns**: two new optional columns, ID (raw DB id) and
|
||||
e-mail, alongside the existing set; visible columns can now also be
|
||||
**reordered** with ↑/↓ arrows next to each entry in the "Kolumny" picker,
|
||||
not just shown/hidden — order is remembered per operator the same way
|
||||
visibility already was (`users.operator_queue_columns`).
|
||||
- **SnipeIT client asset picker**: a second, category-level allow-list
|
||||
(`snipeit_client_asset_category_ids`) alongside the existing subcategory
|
||||
one — lets an admin cover every subcategory of a category in one click
|
||||
instead of ticking each one individually. The two lists are additive.
|
||||
- Client dashboard and operator ticket-detail page now remember which tab
|
||||
(Bieżące/Archiwum, or whichever queue tab) was active, so "Wróć do listy"
|
||||
returns to it instead of always resetting to the default.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Pagination controls** (operator queue, client dashboard) no longer
|
||||
render as Laravel's stock gray Tailwind styling, which only reacted to the
|
||||
browser/OS's `prefers-color-scheme` and stayed dark even when the app's own
|
||||
light/dark toggle was set to light. They're now a themed override
|
||||
(`resources/views/vendor/livewire/tailwind.blade.php` — pagination here
|
||||
actually renders through Livewire's own pagination view, not Laravel's
|
||||
default) styled with the app's own light/dark CSS variables, with visible
|
||||
per-button background/border and a centered layout on mobile.
|
||||
- **Login notice box** background/border no longer shift hue between light
|
||||
and dark mode (previously derived from `--color-accent`, which differs per
|
||||
theme) — it's now one fixed dark color in both themes, so admin-picked
|
||||
text colors (e.g. white) stay legible regardless of the viewer's theme.
|
||||
Login card widened (380px → 480px).
|
||||
- **Client dashboard ticket list**: priority/status badges no longer wrap
|
||||
onto their own left-aligned line below a long ticket subject on narrow
|
||||
screens — they stay pinned to the right while the subject text wraps
|
||||
within its own column instead.
|
||||
- Timer bookkeeping (`resumeTimer()`/`stopTimer()`/etc.) no longer touches
|
||||
`updated_at` — merely opening a ticket (or the background timer
|
||||
starting/stopping) no longer counted as an update, which used to drown out
|
||||
genuinely stale tickets in any list sorted by that column. The operator
|
||||
queue and client dashboard both now default to sorting by creation date
|
||||
instead for the same reason.
|
||||
- Admin panel and SnipeIT integration config test coverage extended for the
|
||||
new category-level allow-list.
|
||||
|
||||
## [1.5.0] - 2026-08-05
|
||||
|
||||
### Added
|
||||
|
||||
- **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
|
||||
|
||||
### Added
|
||||
|
||||
24
CLAUDE.md
24
CLAUDE.md
@@ -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
|
||||
`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
|
||||
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 —
|
||||
`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
|
||||
the same treatment to any other wide table you add or make mobile-relevant
|
||||
(admin panel tables don't have it yet).
|
||||
- **Ticket list default sort** is `created_at` (or the raw `id`, which is
|
||||
equivalent — both are monotonic) descending, everywhere a ticket list is
|
||||
shown: the operator queue (`App\Livewire\Operator\Queue::$sortBy`, default
|
||||
`'created'`) and both tabs of the client dashboard
|
||||
(`App\Livewire\Client\Dashboard::baseQuery()`, `orderByDesc('created_at')`).
|
||||
Deliberately not `updated_at` — see the timer/`updated_at` note in
|
||||
[ARCHITECTURE.md](ARCHITECTURE.md#data-model); even with that fixed, sorting
|
||||
by "last touched" is a worse default for a support queue than a stable
|
||||
creation order. Keep any new ticket list consistent with this rather than
|
||||
defaulting to `updated_at`.
|
||||
- **Per-user table customization** (shown/hidden columns + their order): the
|
||||
operator queue's pattern (`Queue::$visibleColumns`, persisted to
|
||||
`users.operator_queue_columns` via `persistVisibleColumns()`, reordered with
|
||||
`moveColumnUp()`/`moveColumnDown()`) is the template to reuse if another
|
||||
table gains the same feature — auto-save on every toggle/reorder, no
|
||||
explicit "save" step required from the user. This is intentionally separate
|
||||
from `SavedQueueView` (named, manually-saved, multi-field filter presets);
|
||||
don't conflate the two.
|
||||
|
||||
## Testing & code style
|
||||
|
||||
|
||||
@@ -38,7 +38,10 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
|
||||
assignee, custom fields (per subcategory), attachments, full message thread
|
||||
(public replies + internal notes), history log, merge, delete. The operator
|
||||
queue (50/page) and client dashboard (20/page, current/archive tracked
|
||||
separately) paginate rather than rendering every matching ticket at once.
|
||||
separately) paginate rather than rendering every matching ticket at once,
|
||||
and both default to newest-created-first. The operator queue's columns
|
||||
(including the raw DB id, e-mail, source, last-updated, ...) can be
|
||||
individually shown/hidden and reordered, remembered per operator.
|
||||
- **SLA** — per-priority response/resolution time targets; a scheduled command
|
||||
(`tickets:check-sla-breaches`, every 15 min) flags overdue tickets and can notify
|
||||
the assigned operator.
|
||||
@@ -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,
|
||||
session lifetime, timezone (Admin > Konfiguracja), and LDAP connection + user
|
||||
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`,
|
||||
`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"
|
||||
|
||||
@@ -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
|
||||
routes to the same single team (same rule `TicketService::autoAssignTeam()`
|
||||
uses for normal ticket creation); ambiguous categories are left unrouted.
|
||||
- Finds or creates a client account per requester e-mail, reusing an existing
|
||||
account (adding the `client` role if it doesn't have it yet) rather than
|
||||
duplicating.
|
||||
- Hesk staff replies are **not** linked to a real operator account (this
|
||||
script never creates operator accounts) — the reply still shows the
|
||||
correct staff name and "operator" badge via `author_name`, just without a
|
||||
clickable user behind it.
|
||||
- Finds the local client account per requester e-mail (matched by e-mail,
|
||||
adding the `client` role if it doesn't have it yet). **Never creates a
|
||||
User** — the servicedesk user base is treated as authoritative/complete, so
|
||||
a Hesk requester e-mail with no matching account means that ticket is
|
||||
skipped (reported at the end, with the list of skipped e-mails).
|
||||
- Hesk staff replies/notes are linked to a real operator account when the
|
||||
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
|
||||
|
||||
@@ -73,6 +84,39 @@ tickets without importing anything new:
|
||||
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
|
||||
|
||||
`hesk-import.sh` is a thin wrapper: it loads `.env` in this folder, then runs
|
||||
|
||||
@@ -4,13 +4,16 @@ namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Role;
|
||||
use App\Models\Status;
|
||||
use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketHistory;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
@@ -20,10 +23,16 @@ use PDO;
|
||||
* Runs in dry-run mode by default (reports what it would do); pass --commit
|
||||
* to actually write. Resumable: every successfully imported Hesk ticket id
|
||||
* is recorded in a local state file (--state, defaults to
|
||||
* storage/app/hesk-import-state.json), so a re-run (interrupted connection,
|
||||
* crashed midway, etc.) skips tickets already imported instead of
|
||||
* duplicating them. Not idempotent across *edits* on the Hesk side — this is
|
||||
* a one-time historical import, not an ongoing sync.
|
||||
* storage/app/hesk-import-state.json). Belt-and-suspenders: tickets.hesk_ticket_id
|
||||
* is also unique at the DB level, so a state file that's lost/desynced from
|
||||
* a crash between commit and state-save can't turn into a silent duplicate —
|
||||
* 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
|
||||
{
|
||||
@@ -33,7 +42,9 @@ class ImportHeskTickets extends Command
|
||||
{--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)}
|
||||
{--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';
|
||||
|
||||
@@ -71,6 +82,21 @@ class ImportHeskTickets extends Command
|
||||
/** @var array<int, string> Hesk help_users.id => name, loaded once */
|
||||
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 string $statePath;
|
||||
@@ -81,6 +107,18 @@ class ImportHeskTickets extends Command
|
||||
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@");
|
||||
|
||||
if ($domain === '') {
|
||||
@@ -102,6 +140,7 @@ class ImportHeskTickets extends Command
|
||||
$this->buildCategoryMap();
|
||||
$this->buildTeamMap();
|
||||
$this->loadHeskStaffNames();
|
||||
$this->importedHeskIds = Ticket::query()->whereNotNull('hesk_ticket_id')->pluck('hesk_ticket_id')->all();
|
||||
|
||||
$tickets = DB::connection('hesk')->table('help_tickets')
|
||||
->where('email', 'like', '%@'.$domain)
|
||||
@@ -109,16 +148,29 @@ class ImportHeskTickets extends Command
|
||||
->when($limit, fn ($q) => $q->limit($limit))
|
||||
->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).',
|
||||
$commit ? 'KOMMIT' : 'DRY-RUN',
|
||||
$tickets->count(),
|
||||
$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 = [];
|
||||
$unknownCustomerEmails = [];
|
||||
|
||||
$bar = $this->output->createProgressBar($tickets->count());
|
||||
$bar->start();
|
||||
@@ -126,7 +178,7 @@ class ImportHeskTickets extends Command
|
||||
foreach ($tickets as $heskTicket) {
|
||||
$bar->advance();
|
||||
|
||||
if (in_array($heskTicket->id, $this->state['imported'], true)) {
|
||||
if ($this->alreadyImported($heskTicket->id)) {
|
||||
$stats['skipped']++;
|
||||
|
||||
continue;
|
||||
@@ -147,6 +199,22 @@ class ImportHeskTickets extends Command
|
||||
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) {
|
||||
$stats['created']++;
|
||||
|
||||
@@ -163,6 +231,9 @@ class ImportHeskTickets extends Command
|
||||
$stats['failed']++;
|
||||
$this->newLine();
|
||||
$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ść'], [
|
||||
['Zgłoszenia utworzone', $stats['created']],
|
||||
['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 (kategoria bez odpowiednika)', $stats['skipped_unmapped_category']],
|
||||
['Pominięte (brak konta klienta w servicedesk)', $stats['skipped_unknown_customer']],
|
||||
['Błędy', $stats['failed']],
|
||||
]);
|
||||
|
||||
@@ -186,6 +258,26 @@ class ImportHeskTickets extends Command
|
||||
$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) {
|
||||
$this->newLine();
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$host = env('HESK_DB_HOST');
|
||||
@@ -328,15 +443,283 @@ class ImportHeskTickets extends Command
|
||||
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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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;
|
||||
$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([
|
||||
'number' => Ticket::nextNumber(),
|
||||
@@ -345,21 +728,23 @@ class ImportHeskTickets extends Command
|
||||
'name' => $heskTicket->name ?: $heskTicket->email,
|
||||
'category_id' => $categoryId,
|
||||
'team_id' => $categoryId ? ($this->teamByCategory[$categoryId] ?? null) : null,
|
||||
'assignee_id' => $assignee?->id,
|
||||
'subject' => $this->cleanText($heskTicket->subject) ?: '(bez tematu)',
|
||||
'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',
|
||||
'source' => 'hesk_import',
|
||||
'hesk_ticket_id' => $heskTicket->id,
|
||||
'last_customer_activity_at' => $heskTicket->lastchange,
|
||||
'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
|
||||
// checksum, which — being a normal Eloquent save() — stomps
|
||||
// updated_at back to "now". Restore the historical value via the
|
||||
// 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([
|
||||
'author_name' => $heskTicket->name ?: $heskTicket->email,
|
||||
@@ -378,30 +763,43 @@ class ImportHeskTickets extends Command
|
||||
$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']++;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
return DB::connection('hesk')->table('help_replies')
|
||||
->where('replyto', $heskTicketId)
|
||||
->orderBy('dt')
|
||||
->get();
|
||||
return $this->repliesByTicket->get($heskTicketId, collect());
|
||||
}
|
||||
|
||||
private function heskNotes(int $heskTicketId): Collection
|
||||
{
|
||||
return DB::connection('hesk')->table('help_notes')
|
||||
->where('ticket', $heskTicketId)
|
||||
->orderBy('dt')
|
||||
->get();
|
||||
return $this->notesByTicket->get($heskTicketId, collect());
|
||||
}
|
||||
|
||||
private function importReply(Ticket $ticket, object $reply, User $customer, array &$stats): void
|
||||
{
|
||||
$isStaff = (int) $reply->staffid > 0;
|
||||
$operator = $isStaff ? $this->resolveOperator((int) $reply->staffid) : null;
|
||||
$authorName = $isStaff
|
||||
? ($this->heskStaffNames[$reply->staffid] ?? 'Personel')
|
||||
? ($operator->name ?? $this->heskStaffNames[$reply->staffid] ?? 'Personel')
|
||||
: ($reply->name ?: $customer->name);
|
||||
|
||||
$message = $ticket->messages()->create([
|
||||
@@ -411,48 +809,54 @@ class ImportHeskTickets extends Command
|
||||
'updated_at' => $reply->dt,
|
||||
]);
|
||||
|
||||
// Staff replies aren't linked to a real User (we deliberately don't
|
||||
// create operator accounts for imported Hesk staff — see the
|
||||
// migration script's design questions) — attachAuthor(null,
|
||||
// 'operator') still tags the role/badge correctly via author_name.
|
||||
$message->attachAuthor($isStaff ? null : $customer->id, $isStaff ? 'operator' : 'client');
|
||||
// Linked to a real operator User when the Hesk staff e-mail matches
|
||||
// one (resolveOperator()); otherwise falls back to the previous
|
||||
// behavior — attachAuthor(null, 'operator') still tags the
|
||||
// role/badge correctly via author_name, just without a clickable
|
||||
// user behind it.
|
||||
$message->attachAuthor($isStaff ? $operator?->id : $customer->id, $isStaff ? 'operator' : 'client');
|
||||
$stats['messages']++;
|
||||
|
||||
if ($isStaff && $operator) {
|
||||
$stats['operator_messages_linked']++;
|
||||
}
|
||||
}
|
||||
|
||||
private function importNote(Ticket $ticket, object $note, array &$stats): void
|
||||
{
|
||||
$operator = $this->resolveOperator((int) $note->who);
|
||||
|
||||
$message = $ticket->messages()->create([
|
||||
'author_name' => $this->heskStaffNames[$note->who] ?? 'Personel',
|
||||
'author_name' => $operator->name ?? $this->heskStaffNames[$note->who] ?? 'Personel',
|
||||
'internal' => true,
|
||||
'body' => $this->cleanText($note->message),
|
||||
'created_at' => $note->dt,
|
||||
'updated_at' => $note->dt,
|
||||
]);
|
||||
$message->attachAuthor(null, 'operator');
|
||||
$message->attachAuthor($operator?->id, 'operator');
|
||||
$stats['messages']++;
|
||||
|
||||
if ($operator) {
|
||||
$stats['operator_messages_linked']++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds or creates the local client account for a Hesk requester e-mail.
|
||||
* Reuses an existing account (e.g. the one real admin account, or one
|
||||
* already created by an earlier ticket from the same person) rather than
|
||||
* duplicating, and only ever adds the 'client' role — never removes
|
||||
* whatever roles the account already had.
|
||||
* Finds the local client account for a Hesk requester e-mail. As of the
|
||||
* 2026-08 re-import the servicedesk user base is considered
|
||||
* authoritative/complete — this deliberately never creates a User
|
||||
* anymore (unlike the original 2026-08-04 run); handle() checks
|
||||
* 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', $email)->first();
|
||||
$user = User::query()->where('email', trim($email))->first();
|
||||
|
||||
if (! $user) {
|
||||
$user = User::query()->create([
|
||||
'name' => $name ?: $email,
|
||||
'email' => $email,
|
||||
'roles' => ['client'],
|
||||
]);
|
||||
$stats['customers_created']++;
|
||||
|
||||
return $user;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! in_array('client', $user->roles, true)) {
|
||||
|
||||
171
src/app/Livewire/Admin/Logs.php
Normal file
171
src/app/Livewire/Admin/Logs.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -252,6 +252,7 @@ class Panel extends Component
|
||||
'skipSslVerification' => ! Settings::bool('snipeit_verify_ssl'),
|
||||
'clientCanSelectAsset' => Settings::bool('snipeit_client_can_select_asset'),
|
||||
'clientAssetSubcategoryIds' => $this->parseShelfIds(Settings::get('snipeit_client_asset_subcategory_ids', '')),
|
||||
'clientAssetCategoryIds' => $this->parseShelfIds(Settings::get('snipeit_client_asset_category_ids', '')),
|
||||
'operatorViewRequesterAssets' => Settings::bool('snipeit_operator_view_requester_assets'),
|
||||
'operatorSearchInventory' => Settings::bool('snipeit_operator_search_inventory'),
|
||||
];
|
||||
@@ -779,6 +780,14 @@ class Panel extends Component
|
||||
->values();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function categoriesForSnipeitForm()
|
||||
{
|
||||
return Category::query()->orderBy('name')->get()
|
||||
->map(fn (Category $c) => ['id' => $c->id, 'label' => $c->name])
|
||||
->values();
|
||||
}
|
||||
|
||||
public function openTeamForm(): void
|
||||
{
|
||||
$this->teamForm = ['id' => null, 'name' => '', 'memberIds' => [], 'subcategoryIds' => []];
|
||||
@@ -1682,6 +1691,7 @@ class Panel extends Component
|
||||
Settings::set('snipeit_verify_ssl', $this->snipeitConfig['skipSslVerification'] ? '0' : '1');
|
||||
Settings::set('snipeit_client_can_select_asset', $this->snipeitConfig['clientCanSelectAsset'] ? '1' : '0');
|
||||
Settings::set('snipeit_client_asset_subcategory_ids', implode(',', $this->snipeitConfig['clientAssetSubcategoryIds']));
|
||||
Settings::set('snipeit_client_asset_category_ids', implode(',', $this->snipeitConfig['clientAssetCategoryIds']));
|
||||
Settings::set('snipeit_operator_view_requester_assets', $this->snipeitConfig['operatorViewRequesterAssets'] ? '1' : '0');
|
||||
Settings::set('snipeit_operator_search_inventory', $this->snipeitConfig['operatorSearchInventory'] ? '1' : '0');
|
||||
|
||||
@@ -1698,6 +1708,22 @@ class Panel extends Component
|
||||
: [...$ids, $id];
|
||||
}
|
||||
|
||||
/**
|
||||
* The category-level counterpart to toggleSnipeitClientSubcategory() —
|
||||
* a coarser allow-list for admins who want the picker on for every
|
||||
* subcategory of a category at once, without ticking each one
|
||||
* individually. NewTicket::snipeitAssets() allows a ticket through if
|
||||
* its category OR its subcategory is on either list.
|
||||
*/
|
||||
public function toggleSnipeitClientCategory(int $id): void
|
||||
{
|
||||
$ids = $this->snipeitConfig['clientAssetCategoryIds'];
|
||||
|
||||
$this->snipeitConfig['clientAssetCategoryIds'] = in_array($id, $ids, true)
|
||||
? array_values(array_diff($ids, [$id]))
|
||||
: [...$ids, $id];
|
||||
}
|
||||
|
||||
public function testSnipeitConnection(): void
|
||||
{
|
||||
$cfg = $this->snipeitConfig;
|
||||
@@ -1840,6 +1866,7 @@ class Panel extends Component
|
||||
'config' => 'Konfiguracja',
|
||||
'integrations' => 'Integracje',
|
||||
'api-keys' => 'Klucze API',
|
||||
'logs' => 'Logi',
|
||||
'about' => 'O aplikacji',
|
||||
];
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\Status;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
@@ -15,16 +16,28 @@ class Dashboard extends Component
|
||||
|
||||
private const PER_PAGE = 20;
|
||||
|
||||
#[Url]
|
||||
public string $tab = 'current';
|
||||
|
||||
public string $search = '';
|
||||
|
||||
/**
|
||||
* Session-only, mirrors Operator\Queue::rememberQueueTab() — lets
|
||||
* "Wróć do listy" on the ticket-detail page return to whichever tab
|
||||
* (Bieżące/Archiwum) was actually active, instead of always resetting
|
||||
* to the default.
|
||||
*/
|
||||
public function mount(): void
|
||||
{
|
||||
session(['client_dashboard_tab' => $this->tab]);
|
||||
}
|
||||
|
||||
protected function baseQuery()
|
||||
{
|
||||
return Auth::user()->ticketsAsCustomer()
|
||||
->search($this->search)
|
||||
->with('subcategory.category')
|
||||
->orderByDesc('updated_at');
|
||||
->orderByDesc('created_at');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,6 +67,7 @@ class Dashboard extends Component
|
||||
public function setTab(string $tab): void
|
||||
{
|
||||
$this->tab = $tab;
|
||||
session(['client_dashboard_tab' => $tab]);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -53,11 +53,13 @@ class NewTicket extends Component
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty unless the admin turned the picker on AND allow-listed the
|
||||
* currently selected subcategory for it (see
|
||||
* snipeit_client_asset_subcategory_ids) — an empty allow-list means
|
||||
* "no subcategory", not "every subcategory", mirroring how BookStack's
|
||||
* shelf allow-lists work.
|
||||
* Empty unless the admin turned the picker on AND allow-listed either
|
||||
* the currently selected subcategory (snipeit_client_asset_subcategory_ids)
|
||||
* or its parent category (snipeit_client_asset_category_ids) — an empty
|
||||
* pair of allow-lists means "nowhere", not "everywhere", mirroring how
|
||||
* BookStack's shelf allow-lists work. The category list is the coarser
|
||||
* of the two, for admins who want every subcategory of a category
|
||||
* covered at once instead of ticking each one individually.
|
||||
*
|
||||
* @return array<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 [];
|
||||
}
|
||||
|
||||
if (! in_array($this->subcategoryId, $this->snipeitAllowedSubcategoryIds(), true)) {
|
||||
$subcategoryAllowed = in_array($this->subcategoryId, $this->snipeitAllowedSubcategoryIds(), true);
|
||||
$categoryAllowed = $this->categoryId && in_array($this->categoryId, $this->snipeitAllowedCategoryIds(), true);
|
||||
|
||||
if (! $subcategoryAllowed && ! $categoryAllowed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -80,7 +85,23 @@ class NewTicket extends Component
|
||||
*/
|
||||
protected function snipeitAllowedSubcategoryIds(): array
|
||||
{
|
||||
return collect(explode(',', Settings::get('snipeit_client_asset_subcategory_ids', '')))
|
||||
return $this->parseIdList(Settings::get('snipeit_client_asset_subcategory_ids', ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
protected function snipeitAllowedCategoryIds(): array
|
||||
{
|
||||
return $this->parseIdList(Settings::get('snipeit_client_asset_category_ids', ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
private function parseIdList(string $raw): array
|
||||
{
|
||||
return collect(explode(',', $raw))
|
||||
->map(fn ($v) => (int) trim($v))
|
||||
->filter()
|
||||
->values()
|
||||
|
||||
@@ -48,12 +48,12 @@ class Queue extends Component
|
||||
|
||||
public string $search = '';
|
||||
|
||||
public string $sortBy = 'updated_at';
|
||||
public string $sortBy = 'created';
|
||||
|
||||
public string $sortDir = 'desc';
|
||||
|
||||
/** @var string[] */
|
||||
public array $visibleColumns = ['number', 'subject', 'customer', 'category', 'priority', 'status', 'sla', 'assignee'];
|
||||
public array $visibleColumns = ['id', 'number', 'subject', 'customer', 'category', 'priority', 'status', 'sla', 'assignee'];
|
||||
|
||||
/** @var int[] */
|
||||
public array $selectedIds = [];
|
||||
@@ -72,7 +72,15 @@ class Queue extends Component
|
||||
*/
|
||||
public function mount(): void
|
||||
{
|
||||
$savedColumns = Auth::user()->operator_queue_columns;
|
||||
|
||||
if (is_array($savedColumns) && $savedColumns !== []) {
|
||||
$this->visibleColumns = array_values(array_intersect($savedColumns, array_keys($this->columnDefs())));
|
||||
}
|
||||
|
||||
if ($this->savedViewId !== null) {
|
||||
$this->rememberQueueTab();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,6 +90,20 @@ class Queue extends Component
|
||||
$this->applyViewFilters($default->filters);
|
||||
$this->savedViewId = $default->id;
|
||||
}
|
||||
|
||||
$this->rememberQueueTab();
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-only (not the per-user `operator_queue_columns` column — a tab
|
||||
* selection is transient, not a durable preference): lets "Wróć do
|
||||
* listy" on the ticket-detail page return to whichever queue tab was
|
||||
* actually active, instead of always resetting to the "Otwarte" default.
|
||||
* See TicketShow's back-link, which reads this same key.
|
||||
*/
|
||||
private function rememberQueueTab(): void
|
||||
{
|
||||
session(['operator_queue_tab' => $this->queue]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,6 +168,7 @@ class Queue extends Component
|
||||
$this->visibleColumns = $filters['visibleColumns'] ?? $this->visibleColumns;
|
||||
$this->selectedIds = [];
|
||||
$this->resetPage();
|
||||
$this->rememberQueueTab();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -354,7 +377,9 @@ class Queue extends Component
|
||||
if ($this->filterPriority !== 'all') {
|
||||
$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,
|
||||
// when routed to a whole category with no subcategory (e.g. an
|
||||
// IMAP mailbox), directly on tickets.category_id.
|
||||
@@ -394,17 +419,21 @@ class Queue extends Component
|
||||
$desc = $this->sortDir === 'desc';
|
||||
|
||||
$sorted = match ($this->sortBy) {
|
||||
'id' => $tickets->sortBy(fn (Ticket $t) => $t->id, SORT_REGULAR, $desc),
|
||||
'number' => $tickets->sortBy(fn (Ticket $t) => (int) $t->number, SORT_REGULAR, $desc),
|
||||
'subject' => $tickets->sortBy('subject', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||
'customer' => $tickets->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||
'email' => $tickets->sortBy('email', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||
'category' => $tickets->sortBy(fn (Ticket $t) => $t->categoryLabel(), SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||
'priority' => $tickets->sortBy(fn (Ticket $t) => $t->priority?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc),
|
||||
'status' => $tickets->sortBy(fn (Ticket $t) => $t->status?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc),
|
||||
'assignee' => $tickets->sortBy(fn (Ticket $t) => $t->assignee?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||
'team' => $tickets->sortBy(fn (Ticket $t) => $t->team?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||
'subcategory' => $tickets->sortBy(fn (Ticket $t) => $t->subcategory?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||
'source' => $tickets->sortBy(fn (Ticket $t) => $t->source ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||
'updated' => $tickets->sortBy(fn (Ticket $t) => $t->updated_at, SORT_REGULAR, $desc),
|
||||
'created' => $tickets->sortBy(fn (Ticket $t) => $t->created_at, SORT_REGULAR, $desc),
|
||||
default => $tickets->sortBy('updated_at', SORT_REGULAR, $desc),
|
||||
default => $tickets->sortBy(fn (Ticket $t) => $t->created_at, SORT_REGULAR, $desc),
|
||||
};
|
||||
|
||||
return $sorted->values();
|
||||
@@ -416,9 +445,11 @@ class Queue extends Component
|
||||
public function columnDefs(): array
|
||||
{
|
||||
return [
|
||||
'id' => 'ID',
|
||||
'number' => 'Numer',
|
||||
'subject' => 'Temat',
|
||||
'customer' => 'Klient',
|
||||
'email' => 'E-mail',
|
||||
'category' => 'Kategoria',
|
||||
'subcategory' => 'Podkategoria',
|
||||
'priority' => 'Priorytet',
|
||||
@@ -426,7 +457,9 @@ class Queue extends Component
|
||||
'sla' => 'SLA',
|
||||
'assignee' => 'Przypisany',
|
||||
'team' => 'Zespół',
|
||||
'source' => 'Źródło',
|
||||
'created' => 'Utworzono',
|
||||
'updated' => 'Zaktualizowano',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -437,7 +470,7 @@ class Queue extends Component
|
||||
*/
|
||||
public function sortableColumns(): array
|
||||
{
|
||||
return ['number', 'subject', 'customer', 'category', 'subcategory', 'priority', 'status', 'assignee', 'team', 'created'];
|
||||
return ['id', 'number', 'subject', 'customer', 'email', 'category', 'subcategory', 'priority', 'status', 'assignee', 'team', 'source', 'created', 'updated'];
|
||||
}
|
||||
|
||||
public function sortByColumn(string $column): void
|
||||
@@ -465,12 +498,57 @@ class Queue extends Component
|
||||
} else {
|
||||
$this->visibleColumns[] = $column;
|
||||
}
|
||||
|
||||
$this->persistVisibleColumns();
|
||||
}
|
||||
|
||||
public function moveColumnUp(string $column): void
|
||||
{
|
||||
$this->reorderColumn($column, -1);
|
||||
}
|
||||
|
||||
public function moveColumnDown(string $column): void
|
||||
{
|
||||
$this->reorderColumn($column, 1);
|
||||
}
|
||||
|
||||
private function reorderColumn(string $column, int $delta): void
|
||||
{
|
||||
$from = array_search($column, $this->visibleColumns, true);
|
||||
|
||||
if ($from === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$to = $from + $delta;
|
||||
|
||||
if ($to < 0 || $to >= count($this->visibleColumns)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$columns = $this->visibleColumns;
|
||||
[$columns[$from], $columns[$to]] = [$columns[$to], $columns[$from]];
|
||||
$this->visibleColumns = $columns;
|
||||
|
||||
$this->persistVisibleColumns();
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-remembers shown/hidden columns *and* their order per operator,
|
||||
* independent of the named/default SavedQueueView mechanism above — a
|
||||
* plain column toggle or drag shouldn't require explicitly "saving a
|
||||
* view" for it to stick between visits.
|
||||
*/
|
||||
private function persistVisibleColumns(): void
|
||||
{
|
||||
Auth::user()->forceFill(['operator_queue_columns' => $this->visibleColumns])->save();
|
||||
}
|
||||
|
||||
public function setQueue(string $key): void
|
||||
{
|
||||
$this->queue = $key;
|
||||
$this->selectedIds = [];
|
||||
$this->rememberQueueTab();
|
||||
|
||||
// Every tab except "closed" now excludes closed-stage tickets (see
|
||||
// queueDefs()), so a stale closed-stage status filter would silently
|
||||
|
||||
@@ -128,7 +128,17 @@ class Stats extends Component
|
||||
$query->where('tickets.priority_key', $this->filterPriority);
|
||||
}
|
||||
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') {
|
||||
$query->whereNull('tickets.assignee_id');
|
||||
@@ -299,37 +309,70 @@ class Stats extends Component
|
||||
])->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]
|
||||
public function byCategory()
|
||||
{
|
||||
return (clone $this->baseQuery)
|
||||
$viaSubcategory = (clone $this->baseQuery)
|
||||
->whereNotNull('tickets.subcategory_id')
|
||||
->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id')
|
||||
->join('categories', 'categories.id', '=', 'subcategories.category_id')
|
||||
->select('categories.name as label', DB::raw('count(*) as count'))
|
||||
->groupBy('categories.id', 'categories.name')
|
||||
->orderByDesc('count')
|
||||
->get()
|
||||
->map(fn ($row) => ['label' => $row->label, 'count' => (int) $row->count]);
|
||||
->select('subcategories.category_id', DB::raw('count(*) as count'))
|
||||
->groupBy('subcategories.category_id')
|
||||
->pluck('count', 'category_id');
|
||||
|
||||
$viaCategory = (clone $this->baseQuery)
|
||||
->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
|
||||
* actual subcategory, labeled "Category / Subcategory" to disambiguate
|
||||
* subcategories that share a name across different parent categories.
|
||||
* One level deeper than byCategory() — grouped by the actual subcategory,
|
||||
* labeled "Category / Subcategory" to disambiguate subcategories that
|
||||
* 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]
|
||||
public function bySubcategory()
|
||||
{
|
||||
return (clone $this->baseQuery)
|
||||
$withSubcategory = (clone $this->baseQuery)
|
||||
->whereNotNull('tickets.subcategory_id')
|
||||
->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_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'))
|
||||
->groupBy('subcategories.id', 'categories.name', 'subcategories.name')
|
||||
->orderByDesc('count')
|
||||
->get()
|
||||
->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]
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['key', 'name', 'trigger_label', 'subject', 'body'])]
|
||||
#[Fillable(['key', 'name', 'subject', 'body'])]
|
||||
class EmailTemplate extends Model
|
||||
{
|
||||
public function render(array $placeholders): array
|
||||
|
||||
@@ -9,12 +9,13 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
#[Fillable([
|
||||
'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',
|
||||
'created_at', 'updated_at', 'csat_rating', 'csat_comment', 'csat_rated_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
|
||||
{
|
||||
/**
|
||||
* 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
|
||||
* — 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->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
|
||||
@@ -46,9 +165,6 @@ class Ticket extends Model
|
||||
'timer_started_at' => 'datetime',
|
||||
'csat_rating' => 'integer',
|
||||
'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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'ticket_watchers');
|
||||
@@ -157,9 +283,18 @@ class Ticket extends Model
|
||||
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
|
||||
{
|
||||
$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);
|
||||
}
|
||||
@@ -476,7 +611,7 @@ class Ticket extends Model
|
||||
public function flushTimer(): void
|
||||
{
|
||||
if ($this->timer_started_at) {
|
||||
$this->update([
|
||||
$this->updateTimerFields([
|
||||
'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(),
|
||||
'timer_started_at' => now(),
|
||||
]);
|
||||
@@ -486,7 +621,7 @@ class Ticket extends Model
|
||||
public function stopTimer(): void
|
||||
{
|
||||
if ($this->timer_started_at) {
|
||||
$this->update([
|
||||
$this->updateTimerFields([
|
||||
'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(),
|
||||
'timer_started_at' => null,
|
||||
]);
|
||||
@@ -505,13 +640,13 @@ class Ticket extends Model
|
||||
}
|
||||
|
||||
if (! $this->timer_started_at) {
|
||||
$this->update(['timer_started_at' => now()]);
|
||||
$this->updateTimerFields(['timer_started_at' => now()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function resetTimer(): void
|
||||
{
|
||||
$this->update(['time_spent_seconds' => 0, 'timer_started_at' => null]);
|
||||
$this->updateTimerFields(['time_spent_seconds' => 0, 'timer_started_at' => null]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -522,12 +657,27 @@ class Ticket extends Model
|
||||
*/
|
||||
public function setTimeSpent(int $seconds): void
|
||||
{
|
||||
$this->update([
|
||||
$this->updateTimerFields([
|
||||
'time_spent_seconds' => max(0, $seconds),
|
||||
'timer_started_at' => $this->timer_started_at ? now() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Timer bookkeeping alone is never a ticket "update" worth surfacing —
|
||||
* merely opening a ticket (resumeTimer on mount, stopTimer on leaving)
|
||||
* would otherwise bump `updated_at` on every single view, drowning out
|
||||
* genuinely stale tickets in queues/lists sorted by that column. Real
|
||||
* content changes (replies, status/priority/assignee edits, ...) go
|
||||
* through their own ->update() calls elsewhere and still touch it.
|
||||
*/
|
||||
private function updateTimerFields(array $attributes): void
|
||||
{
|
||||
$this->timestamps = false;
|
||||
$this->update($attributes);
|
||||
$this->timestamps = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom field values for this ticket's subcategory, in display order, skipping blanks.
|
||||
*
|
||||
@@ -553,4 +703,33 @@ class Ticket extends Model
|
||||
->values()
|
||||
->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],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
24
src/app/Models/TicketAiSummary.php
Normal file
24
src/app/Models/TicketAiSummary.php
Normal 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);
|
||||
}
|
||||
}
|
||||
21
src/app/Models/TicketFieldValue.php
Normal file
21
src/app/Models/TicketFieldValue.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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'])]
|
||||
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
|
||||
{
|
||||
return [
|
||||
|
||||
23
src/app/Models/TicketSnipeitAsset.php
Normal file
23
src/app/Models/TicketSnipeitAsset.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
@@ -15,8 +14,8 @@ use Illuminate\Notifications\Notifiable;
|
||||
use LdapRecord\Laravel\Auth\AuthenticatesWithLdap;
|
||||
use LdapRecord\Laravel\Auth\LdapAuthenticatable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password', 'roles', 'custom_field_values'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
#[Fillable(['name', 'email', 'password', 'roles', 'custom_field_values', 'operator_queue_columns'])]
|
||||
#[Hidden(['password'])]
|
||||
class User extends Authenticatable implements LdapAuthenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
@@ -109,8 +108,8 @@ class User extends Authenticatable implements LdapAuthenticatable
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'operator_queue_columns' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace App\Services;
|
||||
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Generic OpenAI-compatible chat-completions client — works against Groq,
|
||||
@@ -32,19 +34,38 @@ class AiClient
|
||||
return null;
|
||||
}
|
||||
|
||||
$model = Settings::get('ai_model');
|
||||
|
||||
try {
|
||||
$response = $this->client()->post('/chat/completions', [
|
||||
'model' => Settings::get('ai_model'),
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
...$options,
|
||||
]);
|
||||
|
||||
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 $response->json('choices.0.message.content');
|
||||
} catch (\Throwable) {
|
||||
$content = $response->json('choices.0.message.content');
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\Ticket;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
@@ -48,6 +49,13 @@ class TicketAiSummaryService
|
||||
$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;
|
||||
}
|
||||
|
||||
@@ -58,18 +66,28 @@ class TicketAiSummaryService
|
||||
* latter also changes on unrelated actions (status/priority/timer
|
||||
* edits), which would otherwise trigger spurious re-summarization on
|
||||
* 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
|
||||
{
|
||||
return Ticket::query()->where(function (Builder $q) {
|
||||
$q->whereNull('ai_summary_generated_at')
|
||||
->orWhere(function (Builder $q2) {
|
||||
$q2->whereNotNull('ai_summary_generated_at')
|
||||
->whereColumn('ai_summary_generated_at', '<', DB::raw(
|
||||
'(select max(ticket_messages.created_at) from ticket_messages where ticket_messages.ticket_id = tickets.id)'
|
||||
));
|
||||
});
|
||||
})->orderBy('id');
|
||||
return Ticket::query()
|
||||
->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) {
|
||||
$q2->whereNotNull('ticket_ai_summaries.summary_generated_at')
|
||||
->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('tickets.*')
|
||||
->orderBy('tickets.id');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,6 +117,8 @@ class TicketAiSummaryService
|
||||
// Leaves any prior summary untouched and generated_at unchanged,
|
||||
// so the ticket stays in the stale set and gets retried next run
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -108,6 +128,8 @@ class TicketAiSummaryService
|
||||
'ai_summary_generated_at' => now(),
|
||||
]);
|
||||
|
||||
Log::channel('ai')->debug("Podsumowanie zgłoszenia #{$ticket->number}: zaktualizowane.");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\Category;
|
||||
use App\Models\Priority;
|
||||
use App\Models\Ticket;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
@@ -43,7 +44,12 @@ class TicketAiTriageService
|
||||
$vocabulary = $this->buildVocabulary();
|
||||
$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')
|
||||
->limit($limit ?? self::BATCH_LIMIT)
|
||||
->get()
|
||||
@@ -52,6 +58,13 @@ class TicketAiTriageService
|
||||
$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;
|
||||
}
|
||||
|
||||
@@ -92,6 +105,7 @@ class TicketAiTriageService
|
||||
// response doesn't get retried forever.
|
||||
if ($raw !== null && $parsed === null) {
|
||||
$totals['failed']++;
|
||||
Log::channel('ai')->warning("Triage zgłoszenia #{$ticket->number}: odpowiedź modelu nie dała się zinterpretować jako JSON.");
|
||||
}
|
||||
|
||||
[$changes, $historyLines] = $parsed
|
||||
@@ -101,6 +115,9 @@ class TicketAiTriageService
|
||||
if ($changes) {
|
||||
$this->tickets->applyAiTriage($ticket, $changes, $historyLines);
|
||||
$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()]);
|
||||
|
||||
@@ -67,6 +67,7 @@ class Settings
|
||||
'snipeit_verify_ssl' => '1',
|
||||
'snipeit_client_can_select_asset' => '0',
|
||||
'snipeit_client_asset_subcategory_ids' => '',
|
||||
'snipeit_client_asset_category_ids' => '',
|
||||
'snipeit_operator_view_requester_assets' => '1',
|
||||
'snipeit_operator_search_inventory' => '1',
|
||||
'ai_enabled' => '0',
|
||||
|
||||
@@ -86,6 +86,29 @@ return [
|
||||
'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' => [
|
||||
'driver' => 'slack',
|
||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace Database\Factories;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<User>
|
||||
@@ -27,20 +26,8 @@ class UserFactory extends Factory
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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('');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -296,52 +296,52 @@ class DatabaseSeeder extends Seeder
|
||||
|
||||
$templates = [
|
||||
'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}',
|
||||
'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' => [
|
||||
'name' => 'Zmiana statusu', 'trigger_label' => 'Status zgłoszenia zmieniony',
|
||||
'name' => 'Zmiana statusu',
|
||||
'subject' => 'Aktualizacja zgłoszenia #{numer}',
|
||||
'body' => '<p>Cześć {imie},</p><p>Status Twojego zgłoszenia „{temat}” zmienił się na: {status}.</p>'.$link.$footer,
|
||||
],
|
||||
'tpl-category' => [
|
||||
'name' => 'Zmiana kategorii', 'trigger_label' => 'Kategoria zgłoszenia zmieniona',
|
||||
'name' => 'Zmiana kategorii',
|
||||
'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,
|
||||
],
|
||||
'tpl-assignee' => [
|
||||
'name' => 'Zmiana przypisanego operatora', 'trigger_label' => 'Przypisany operator zmieniony',
|
||||
'name' => 'Zmiana przypisanego operatora',
|
||||
'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,
|
||||
],
|
||||
'tpl-priority' => [
|
||||
'name' => 'Zmiana priorytetu', 'trigger_label' => 'Priorytet zgłoszenia zmieniony',
|
||||
'name' => 'Zmiana priorytetu',
|
||||
'subject' => 'Zmieniono priorytet zgłoszenia #{numer}',
|
||||
'body' => '<p>Cześć {imie},</p><p>Priorytet Twojego zgłoszenia „{temat}” zmienił się na: {priorytet}.</p>'.$link.$footer,
|
||||
],
|
||||
'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}',
|
||||
'body' => '<p>Cześć {imie},</p><p>Twoim zgłoszeniem „{temat}” zajmuje się teraz zespół: {zespol}.</p>'.$link.$footer,
|
||||
],
|
||||
'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',
|
||||
'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' => [
|
||||
'name' => 'Nowa odpowiedź operatora', 'trigger_label' => 'Operator odpowiedział',
|
||||
'name' => 'Nowa odpowiedź operatora',
|
||||
'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,
|
||||
],
|
||||
'tpl-sla-breach' => [
|
||||
'name' => 'Przekroczenie SLA', 'trigger_label' => 'SLA przekroczone — operator',
|
||||
'name' => 'Przekroczenie SLA',
|
||||
'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,
|
||||
],
|
||||
'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})',
|
||||
'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) {
|
||||
$ids[$key] = EmailTemplate::query()->firstOrCreate(['key' => $key], [
|
||||
'name' => $tpl['name'],
|
||||
'trigger_label' => $tpl['trigger_label'],
|
||||
'subject' => $tpl['subject'],
|
||||
'body' => $tpl['body'],
|
||||
])->id;
|
||||
|
||||
@@ -55,11 +55,6 @@
|
||||
[data-theme='light'] .tag-neutral { background: var(--color-neutral-200); color: var(--color-neutral-700); }
|
||||
[data-theme='light'] .dialog-backdrop { background: color-mix(in srgb, var(--color-neutral-900) 35%, transparent); }
|
||||
|
||||
[data-theme='light'] .login-notice-info { background: color-mix(in srgb, var(--color-accent) 14%, white); color: var(--color-accent-700); }
|
||||
[data-theme='light'] .login-notice-warning { background: color-mix(in srgb, var(--color-warning) 20%, white); color: color-mix(in srgb, var(--color-warning) 80%, black); }
|
||||
[data-theme='light'] .login-notice-success { background: color-mix(in srgb, var(--color-success) 18%, white); color: color-mix(in srgb, var(--color-success) 75%, black); }
|
||||
[data-theme='light'] .login-notice-danger { background: color-mix(in srgb, var(--color-danger) 16%, white); color: color-mix(in srgb, var(--color-danger) 80%, black); }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
@@ -211,6 +206,40 @@ body {
|
||||
.seg-opt:has(input:checked) { background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent); }
|
||||
.seg-opt input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||
|
||||
.pagination-wrap { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; }
|
||||
.pagination-summary { font-size: 12.5px; color: color-mix(in srgb, var(--color-text) 55%, transparent); }
|
||||
.pagination-links { display: inline-flex; gap: 4px; flex-wrap: wrap; }
|
||||
.pagination-links a,
|
||||
.pagination-links button,
|
||||
.pagination-links span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
border-radius: 7px;
|
||||
border: none;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pagination-links > span { padding: 0; }
|
||||
.pagination-links a:hover,
|
||||
.pagination-links button:hover { background: color-mix(in srgb, var(--color-text) 6%, transparent); }
|
||||
.pagination-links span[aria-disabled='true'] { opacity: 0.4; cursor: not-allowed; }
|
||||
.pagination-links span.pagination-dots { background: transparent; }
|
||||
.pagination-links button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.pagination-links [aria-current='page'] span {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.panel-switch {
|
||||
display: inline-flex;
|
||||
padding: 3px;
|
||||
@@ -291,9 +320,18 @@ body {
|
||||
and Tailwind's @layer'd rules always lose to unlayered ones — including
|
||||
Quill's CDN stylesheet — regardless of source order, so anything meant to
|
||||
override .ql-editor here (like the padding reset) has to be unlayered too. */
|
||||
/* Fixed (not [data-theme]-dependent) colors on purpose — this box holds
|
||||
admin-authored Quill HTML with its own inline text colors (including
|
||||
plain "white"), so the container needs one stable dark background in
|
||||
both app themes rather than a tint derived from --color-accent, which
|
||||
changed hue/lightness between light and dark mode and could swallow
|
||||
light-colored text the admin picked. */
|
||||
.login-notice {
|
||||
padding: 0 14px;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: #17262d;
|
||||
color: #eef3f4;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
height: auto;
|
||||
@@ -302,10 +340,10 @@ body {
|
||||
}
|
||||
.login-notice > *:first-child { margin-top: 0; }
|
||||
.login-notice > *:last-child { margin-bottom: 0; }
|
||||
.login-notice-info { background: color-mix(in srgb, var(--color-accent) 20%, transparent); color: var(--color-accent); }
|
||||
.login-notice-warning { background: color-mix(in srgb, var(--color-warning) 20%, transparent); color: var(--color-warning); }
|
||||
.login-notice-success { background: color-mix(in srgb, var(--color-success) 20%, transparent); color: var(--color-success); }
|
||||
.login-notice-danger { background: color-mix(in srgb, var(--color-danger) 20%, transparent); color: var(--color-danger); }
|
||||
.login-notice hr { border-color: rgba(255, 255, 255, 0.14); }
|
||||
.login-notice-warning { border-color: color-mix(in srgb, var(--color-warning) 45%, rgba(255, 255, 255, 0.14)); }
|
||||
.login-notice-success { border-color: color-mix(in srgb, var(--color-success) 45%, rgba(255, 255, 255, 0.14)); }
|
||||
.login-notice-danger { border-color: color-mix(in srgb, var(--color-danger) 45%, rgba(255, 255, 255, 0.14)); }
|
||||
|
||||
/* ---- Responsive layout (phones/tablets) ---- */
|
||||
|
||||
@@ -399,6 +437,7 @@ body {
|
||||
@media (max-width: 640px) {
|
||||
.page-pad { padding: 16px !important; }
|
||||
.nav { padding-left: 14px !important; padding-right: 14px !important; gap: 10px; flex-wrap: wrap; }
|
||||
.pagination-links { width: 100%; justify-content: center; }
|
||||
|
||||
/* At this width the switcher's own centered slot collides with the
|
||||
brand text and the icon buttons sharing the row (nothing left to
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
style="position:relative;display:inline-block"
|
||||
>
|
||||
<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>
|
||||
<div
|
||||
x-show="open"
|
||||
x-show="typeof open !== 'undefined' && open"
|
||||
x-cloak
|
||||
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"
|
||||
|
||||
69
src/resources/views/livewire/admin/logs.blade.php
Normal file
69
src/resources/views/livewire/admin/logs.blade.php
Normal 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']) }} · {{ $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>
|
||||
@@ -22,6 +22,7 @@ $tabGroups = [
|
||||
['key' => 'config', 'label' => 'Konfiguracja', 'icon' => 'settings'],
|
||||
['key' => 'integrations', 'label' => 'Integracje', 'icon' => 'hub'],
|
||||
['key' => 'api-keys', 'label' => 'Klucze API', 'icon' => 'vpn_key'],
|
||||
['key' => 'logs', 'label' => 'Logi', 'icon' => 'terminal'],
|
||||
['key' => 'about', 'label' => 'O aplikacji', 'icon' => 'info'],
|
||||
],
|
||||
];
|
||||
@@ -620,9 +621,12 @@ $tabGroups = [
|
||||
<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"
|
||||
wire:key="admin-timezone-clock"
|
||||
x-data="{
|
||||
tz: @js($systemConfig['timezone']),
|
||||
now: '',
|
||||
tickHandle: null,
|
||||
navigatingHandler: null,
|
||||
tick() {
|
||||
try {
|
||||
this.now = new Intl.DateTimeFormat('pl-PL', { timeZone: this.tz, dateStyle: 'medium', timeStyle: 'medium' }).format(new Date());
|
||||
@@ -630,8 +634,13 @@ $tabGroups = [
|
||||
this.now = '—';
|
||||
}
|
||||
},
|
||||
}"
|
||||
x-init="tick(); setInterval(() => tick(), 1000)">
|
||||
init() {
|
||||
this.tick();
|
||||
this.tickHandle = setInterval(() => this.tick(), 1000);
|
||||
this.navigatingHandler = () => clearInterval(this.tickHandle);
|
||||
document.addEventListener('livewire:navigating', this.navigatingHandler);
|
||||
},
|
||||
}">
|
||||
<label>Strefa czasowa</label>
|
||||
<select class="input" wire:model="systemConfig.timezone" x-on:change="tz = $event.target.value; tick()">
|
||||
@foreach (\DateTimeZone::listIdentifiers() as $tzId)
|
||||
@@ -640,7 +649,7 @@ $tabGroups = [
|
||||
</select>
|
||||
<div style="display:flex;gap:4px;font-size:12px">
|
||||
<span class="text-muted">Aktualny czas:</span>
|
||||
<strong x-text="now"></strong>
|
||||
<strong x-text="typeof now !== 'undefined' ? now : ''"></strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -874,6 +883,17 @@ $tabGroups = [
|
||||
</div>
|
||||
|
||||
@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">
|
||||
<label>Ogranicz do podkategorii</label>
|
||||
<x-multiselect
|
||||
@@ -882,7 +902,7 @@ $tabGroups = [
|
||||
toggle-action="toggleSnipeitClientSubcategory"
|
||||
placeholder="Brak wybranych podkategorii"
|
||||
/>
|
||||
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Wybór sprzętu pojawi się klientowi tylko przy tworzeniu zgłoszenia w zaznaczonych tu podkategoriach. Jeśli nic nie jest zaznaczone, opcja nie pojawi się w żadnej podkategorii.</p>
|
||||
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Dodatkowo, wybór sprzętu pojawi się też w pojedynczych podkategoriach zaznaczonych tutaj, nawet jeśli ich kategoria nie jest zaznaczona wyżej. Jeśli obie listy są puste, opcja nie pojawi się w żadnej podkategorii.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -983,6 +1003,11 @@ $tabGroups = [
|
||||
<livewire:admin.api-keys />
|
||||
@endif
|
||||
|
||||
{{-- ================= LOGS ================= --}}
|
||||
@if ($tab === 'logs')
|
||||
<livewire:admin.logs />
|
||||
@endif
|
||||
|
||||
@if ($tab === 'about')
|
||||
<h3 style="margin:0 0 14px">O aplikacji</h3>
|
||||
<div class="card" style="padding:18px;gap:10px;max-width:420px">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
</x-topbar>
|
||||
|
||||
<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">
|
||||
<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()" />
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
@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">
|
||||
<div>
|
||||
<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 style="flex:1;min-width:0">
|
||||
<div style="font-weight:500">{{ $ticket->displayNumber() }} — {{ $ticket->subject }}</div>
|
||||
<div class="card-meta">{{ $ticket->categoryLabel() }} · {{ \App\Support\Rel::format($ticket->updated_at) }}</div>
|
||||
</div>
|
||||
<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->statusStyle() }}">{{ $ticket->statusLabel() }}</span>
|
||||
</div>
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
@dragover.prevent="dragging = true"
|
||||
@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'))"
|
||||
: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"
|
||||
>
|
||||
<label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>
|
||||
|
||||
@@ -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 style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
<a href="{{ route('client.dashboard') }}" wire:navigate class="btn btn-ghost" style="padding:0">← Wróć do listy</a>
|
||||
@php $backTab = session('client_dashboard_tab', 'current'); @endphp
|
||||
<a href="{{ route('client.dashboard', $backTab !== 'current' ? ['tab' => $backTab] : []) }}" wire:navigate class="btn btn-ghost" style="padding:0">← Wróć do listy</a>
|
||||
|
||||
{{-- Live updates arrive via broadcasting, but websocket connections can
|
||||
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'));
|
||||
@endphp
|
||||
<div
|
||||
wire:key="ticket-autorefresh-{{ $ticket->id }}"
|
||||
class="btn btn-secondary"
|
||||
style="cursor:pointer;gap:6px"
|
||||
x-data="{ remaining: {{ $refreshTicketSeconds }}, total: {{ $refreshTicketSeconds }} }"
|
||||
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)"
|
||||
x-data="{
|
||||
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()"
|
||||
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 x-text="remaining + 's'"></span>
|
||||
<span x-text="typeof remaining !== 'undefined' ? (remaining + 's') : ''"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -89,7 +102,7 @@
|
||||
@dragover.prevent="dragging = true"
|
||||
@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'))"
|
||||
: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"
|
||||
>
|
||||
<label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
@dragover.prevent="dragging = true"
|
||||
@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'))"
|
||||
: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"
|
||||
>
|
||||
<label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
class="side-rail"
|
||||
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-effect="localStorage.setItem('operatorSidebarCollapsed', collapsed ? '1' : '0')"
|
||||
:class="collapsed ? 'side-rail-collapsed' : ''"
|
||||
x-effect="typeof collapsed !== 'undefined' && localStorage.setItem('operatorSidebarCollapsed', collapsed ? '1' : '0')"
|
||||
:class="(typeof collapsed !== 'undefined' && collapsed) ? 'side-rail-collapsed' : ''"
|
||||
>
|
||||
<button type="button" class="side-rail-toggle" @click="collapsed = ! collapsed" :title="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="side-rail-toggle-label" x-text="collapsed ? 'Przegląd' : 'Zwiń panel'"></span>
|
||||
<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="(typeof collapsed !== 'undefined' && collapsed) ? 'left_panel_open' : 'left_panel_close'"></span>
|
||||
<span class="side-rail-toggle-label" x-text="(typeof collapsed !== 'undefined' && collapsed) ? 'Przegląd' : 'Zwiń panel'"></span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="side-rail-toggle" title="Szukaj" @click="window.dispatchEvent(new CustomEvent('open-global-search'))">
|
||||
@@ -90,6 +90,7 @@
|
||||
</select>
|
||||
<select class="input" style="width:auto" wire:model.live="filterCategory">
|
||||
<option value="all">Wszystkie kategorie</option>
|
||||
<option value="none">Bez kategorii</option>
|
||||
@foreach ($this->categories as $c)
|
||||
<option value="{{ $c->id }}">{{ $c->name }}</option>
|
||||
@endforeach
|
||||
@@ -100,7 +101,7 @@
|
||||
<span class="material-symbols-outlined" style="font-size:18px">bookmark</span>
|
||||
Zapisane widoki
|
||||
</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)
|
||||
<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>
|
||||
@@ -116,7 +117,7 @@
|
||||
<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>
|
||||
</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">
|
||||
<button type="button" class="btn btn-primary" style="flex:none;padding:6px 10px" @click="$wire.saveCurrentView(); adding = false">Zapisz</button>
|
||||
</div>
|
||||
@@ -128,13 +129,28 @@
|
||||
<span class="material-symbols-outlined" style="font-size:18px">view_column</span>
|
||||
Kolumny
|
||||
</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">
|
||||
@foreach ($columnDefs 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">
|
||||
<input type="checkbox" @checked(in_array($key, $visibleColumns)) wire:click="toggleColumn('{{ $key }}')">
|
||||
{{ $label }}
|
||||
</label>
|
||||
<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 ($visibleColumns as $i => $key)
|
||||
<div style="display:flex;align-items:center;gap:4px;padding:2px 2px 2px 8px;border-radius:5px">
|
||||
<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>
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,15 +163,27 @@
|
||||
$refreshQueueSeconds = max(1, (int) \App\Support\Settings::get('refresh_queue_seconds'));
|
||||
@endphp
|
||||
<div
|
||||
wire:key="queue-autorefresh"
|
||||
class="btn btn-secondary"
|
||||
style="cursor:pointer;gap:6px"
|
||||
x-data="{ remaining: {{ $refreshQueueSeconds }}, total: {{ $refreshQueueSeconds }} }"
|
||||
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshQueue(); }, 1000)"
|
||||
x-data="{
|
||||
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()"
|
||||
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 x-text="remaining + 's'"></span>
|
||||
<span x-text="typeof remaining !== 'undefined' ? (remaining + 's') : ''"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -164,8 +192,8 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<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)
|
||||
@continue(! in_array($key, $visibleColumns))
|
||||
@foreach ($visibleColumns as $key)
|
||||
@php $label = $columnDefs[$key] ?? $key; @endphp
|
||||
<th>
|
||||
@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">
|
||||
@@ -186,44 +214,60 @@
|
||||
@php $sla = $t->slaInfo(); @endphp
|
||||
<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>
|
||||
@if (in_array('number', $visibleColumns))
|
||||
<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>
|
||||
@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>
|
||||
@endif
|
||||
</td>
|
||||
@endif
|
||||
@if (in_array('subject', $visibleColumns))
|
||||
<td data-label="Temat" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer;white-space:nowrap">{{ $t->subject }}</a></td>
|
||||
@endif
|
||||
@if (in_array('customer', $visibleColumns))
|
||||
<td data-label="Klient" style="white-space:nowrap">{{ $t->name }}</td>
|
||||
@endif
|
||||
@if (in_array('category', $visibleColumns))
|
||||
<td data-label="Kategoria" style="white-space:nowrap">{{ $t->categoryLabel() }}</td>
|
||||
@endif
|
||||
@if (in_array('subcategory', $visibleColumns))
|
||||
<td data-label="Podkategoria" style="white-space:nowrap">{{ $t->subcategory?->name ?? '—' }}</td>
|
||||
@endif
|
||||
@if (in_array('priority', $visibleColumns))
|
||||
<td data-label="Priorytet"><span style="{{ $t->priorityStyle() }}">{{ $t->priorityLabel() }}</span></td>
|
||||
@endif
|
||||
@if (in_array('status', $visibleColumns))
|
||||
<td data-label="Status"><span style="{{ $t->statusStyle() }}">{{ $t->statusLabel() }}</span></td>
|
||||
@endif
|
||||
@if (in_array('sla', $visibleColumns))
|
||||
<td data-label="SLA"><span class="{{ $sla['cls'] }}">{{ $sla['short'] }}</span></td>
|
||||
@endif
|
||||
@if (in_array('assignee', $visibleColumns))
|
||||
<td data-label="Przypisany" style="white-space:nowrap">{{ $t->assignee?->name ?? 'Nieprzypisane' }}</td>
|
||||
@endif
|
||||
@if (in_array('team', $visibleColumns))
|
||||
<td data-label="Zespół" style="white-space:nowrap">{{ $t->team?->name ?? '—' }}</td>
|
||||
@endif
|
||||
@if (in_array('created', $visibleColumns))
|
||||
<td data-label="Utworzono" style="white-space:nowrap">{{ \App\Support\Rel::format($t->created_at) }}</td>
|
||||
@endif
|
||||
@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">
|
||||
<a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->displayNumber() }}</a>
|
||||
@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>
|
||||
@endif
|
||||
</td>
|
||||
@break
|
||||
@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>
|
||||
@break
|
||||
@case('customer')
|
||||
<td data-label="Klient" style="white-space:nowrap">{{ $t->name }}</td>
|
||||
@break
|
||||
@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>
|
||||
@break
|
||||
@case('subcategory')
|
||||
<td data-label="Podkategoria" style="white-space:nowrap">{{ $t->subcategory?->name ?? '—' }}</td>
|
||||
@break
|
||||
@case('priority')
|
||||
<td data-label="Priorytet"><span style="{{ $t->priorityStyle() }}">{{ $t->priorityLabel() }}</span></td>
|
||||
@break
|
||||
@case('status')
|
||||
<td data-label="Status"><span style="{{ $t->statusStyle() }}">{{ $t->statusLabel() }}</span></td>
|
||||
@break
|
||||
@case('sla')
|
||||
<td data-label="SLA"><span class="{{ $sla['cls'] }}">{{ $sla['short'] }}</span></td>
|
||||
@break
|
||||
@case('assignee')
|
||||
<td data-label="Przypisany" style="white-space:nowrap">{{ $t->assignee?->name ?? 'Nieprzypisane' }}</td>
|
||||
@break
|
||||
@case('team')
|
||||
<td data-label="Zespół" style="white-space:nowrap">{{ $t->team?->name ?? '—' }}</td>
|
||||
@break
|
||||
@case('source')
|
||||
<td data-label="Źródło" style="white-space:nowrap">{{ match ($t->source) { 'email' => 'E-mail', 'hesk_import' => 'Import HESK', default => 'WWW' } }}</td>
|
||||
@break
|
||||
@case('created')
|
||||
<td data-label="Utworzono" style="white-space:nowrap">{{ \App\Support\Rel::format($t->created_at) }}</td>
|
||||
@break
|
||||
@case('updated')
|
||||
<td data-label="Zaktualizowano" style="white-space:nowrap">{{ \App\Support\Rel::format($t->updated_at) }}</td>
|
||||
@break
|
||||
@endswitch
|
||||
@endforeach
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
<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;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="padding:0;margin-right:auto">← Wróć do listy</a>
|
||||
@php $backQueueTab = session('operator_queue_tab', 'all'); @endphp
|
||||
<a href="{{ route('operator.queue', $backQueueTab !== 'all' ? ['queue' => $backQueueTab] : []) }}" wire:navigate class="btn btn-ghost" style="padding:0;margin-right:auto">← Wróć do listy</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -25,15 +26,27 @@
|
||||
$refreshTicketSeconds = max(1, (int) \App\Support\Settings::get('refresh_ticket_view_seconds'));
|
||||
@endphp
|
||||
<div
|
||||
wire:key="ticket-autorefresh-{{ $ticket->id }}"
|
||||
class="btn btn-secondary"
|
||||
style="cursor:pointer;gap:6px"
|
||||
x-data="{ remaining: {{ $refreshTicketSeconds }}, total: {{ $refreshTicketSeconds }} }"
|
||||
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)"
|
||||
x-data="{
|
||||
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()"
|
||||
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 x-text="remaining + 's'"></span>
|
||||
<span x-text="typeof remaining !== 'undefined' ? (remaining + 's') : ''"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,7 +153,7 @@
|
||||
@dragover.prevent="dragging = true"
|
||||
@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'))"
|
||||
: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"
|
||||
>
|
||||
<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"
|
||||
@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'))"
|
||||
: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"
|
||||
>
|
||||
<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">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">expand_more</span>
|
||||
</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)
|
||||
<button type="button" class="theme-toggle-option" @click="open = false" wire:click="sendAndTransition({{ $qa->id }})">{{ $qa->label }}</button>
|
||||
@empty
|
||||
@@ -439,6 +452,7 @@
|
||||
<div class="card" style="padding:16px;gap:8px">
|
||||
<div class="card-kicker">MONITOR CZASU PRACY</div>
|
||||
<div
|
||||
wire:key="ticket-timer-{{ $ticket->id }}"
|
||||
style="display:flex;flex-direction:column;gap:6px"
|
||||
x-data="{
|
||||
seconds: {{ $ticket->timerElapsedSeconds() }},
|
||||
@@ -464,16 +478,43 @@
|
||||
// The timer only tracks time this ticket is actually open in
|
||||
// the browser: stop it server-side the moment the operator
|
||||
// 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 = () => {
|
||||
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);
|
||||
|
||||
// …or closes/reloads the tab. Regular fetch/Livewire calls
|
||||
// can get cut off mid-flight during an actual page unload, so
|
||||
// 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 = () => {
|
||||
if (this.running) {
|
||||
navigator.sendBeacon(@js(route('operator.ticket.stop-timer', $ticket)));
|
||||
@@ -481,7 +522,7 @@
|
||||
};
|
||||
window.addEventListener('pagehide', this.pagehideHandler);
|
||||
},
|
||||
destroy() {
|
||||
teardown() {
|
||||
clearInterval(this.tick);
|
||||
document.removeEventListener('livewire:navigating', this.navigatingHandler);
|
||||
window.removeEventListener('pagehide', this.pagehideHandler);
|
||||
@@ -500,7 +541,12 @@
|
||||
</div>
|
||||
@else
|
||||
<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>
|
||||
</div>
|
||||
@if ($ticket->isClosed())
|
||||
|
||||
70
src/resources/views/vendor/livewire/tailwind.blade.php
vendored
Normal file
70
src/resources/views/vendor/livewire/tailwind.blade.php
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
@php
|
||||
if (! isset($scrollTo)) {
|
||||
$scrollTo = 'body';
|
||||
}
|
||||
|
||||
$scrollIntoViewJsSnippet = ($scrollTo !== false)
|
||||
? <<<JS
|
||||
(\$el.closest('{$scrollTo}') || document.querySelector('{$scrollTo}')).scrollIntoView()
|
||||
JS
|
||||
: '';
|
||||
@endphp
|
||||
|
||||
<div>
|
||||
@if ($paginator->hasPages())
|
||||
<nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" class="pagination-wrap">
|
||||
<p class="pagination-summary">
|
||||
{!! __('Showing') !!}
|
||||
<strong>{{ $paginator->firstItem() }}</strong> {!! __('to') !!} <strong>{{ $paginator->lastItem() }}</strong>
|
||||
{!! __('of') !!} <strong>{{ $paginator->total() }}</strong> {!! __('results') !!}
|
||||
</p>
|
||||
|
||||
<span class="pagination-links">
|
||||
{{-- Previous Page Link --}}
|
||||
@if ($paginator->onFirstPage())
|
||||
<span aria-disabled="true" aria-label="{{ __('pagination.previous') }}">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>
|
||||
</span>
|
||||
@else
|
||||
<button type="button" wire:click="previousPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="previousPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}.before" aria-label="{{ __('pagination.previous') }}">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>
|
||||
</button>
|
||||
@endif
|
||||
|
||||
{{-- Pagination Elements --}}
|
||||
@foreach ($elements as $element)
|
||||
{{-- "Three Dots" Separator --}}
|
||||
@if (is_string($element))
|
||||
<span aria-disabled="true" class="pagination-dots">{{ $element }}</span>
|
||||
@endif
|
||||
|
||||
{{-- Array Of Links --}}
|
||||
@if (is_array($element))
|
||||
@foreach ($element as $page => $url)
|
||||
<span wire:key="paginator-{{ $paginator->getPageName() }}-page{{ $page }}">
|
||||
@if ($page == $paginator->currentPage())
|
||||
<span aria-current="page"><span>{{ $page }}</span></span>
|
||||
@else
|
||||
<button type="button" wire:click="gotoPage({{ $page }}, '{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" aria-label="{{ __('Go to page :page', ['page' => $page]) }}">
|
||||
{{ $page }}
|
||||
</button>
|
||||
@endif
|
||||
</span>
|
||||
@endforeach
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
{{-- Next Page Link --}}
|
||||
@if ($paginator->hasMorePages())
|
||||
<button type="button" wire:click="nextPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="nextPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}.before" aria-label="{{ __('pagination.next') }}">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /></svg>
|
||||
</button>
|
||||
@else
|
||||
<span aria-disabled="true" aria-label="{{ __('pagination.next') }}">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /></svg>
|
||||
</span>
|
||||
@endif
|
||||
</span>
|
||||
</nav>
|
||||
@endif
|
||||
</div>
|
||||
56
src/resources/views/vendor/pagination/tailwind.blade.php
vendored
Normal file
56
src/resources/views/vendor/pagination/tailwind.blade.php
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
@if ($paginator->hasPages())
|
||||
<nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" class="pagination-wrap">
|
||||
<p class="pagination-summary">
|
||||
{!! __('Showing') !!}
|
||||
@if ($paginator->firstItem())
|
||||
<strong>{{ $paginator->firstItem() }}</strong> {!! __('to') !!} <strong>{{ $paginator->lastItem() }}</strong>
|
||||
@else
|
||||
{{ $paginator->count() }}
|
||||
@endif
|
||||
{!! __('of') !!} <strong>{{ $paginator->total() }}</strong> {!! __('results') !!}
|
||||
</p>
|
||||
|
||||
<span class="pagination-links">
|
||||
{{-- Previous Page Link --}}
|
||||
@if ($paginator->onFirstPage())
|
||||
<span aria-disabled="true" aria-label="{{ __('pagination.previous') }}">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>
|
||||
</span>
|
||||
@else
|
||||
<a href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="{{ __('pagination.previous') }}">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
{{-- Pagination Elements --}}
|
||||
@foreach ($elements as $element)
|
||||
{{-- "Three Dots" Separator --}}
|
||||
@if (is_string($element))
|
||||
<span aria-disabled="true" class="pagination-dots">{{ $element }}</span>
|
||||
@endif
|
||||
|
||||
{{-- Array Of Links --}}
|
||||
@if (is_array($element))
|
||||
@foreach ($element as $page => $url)
|
||||
@if ($page == $paginator->currentPage())
|
||||
<span aria-current="page"><span>{{ $page }}</span></span>
|
||||
@else
|
||||
<a href="{{ $url }}" aria-label="{{ __('Go to page :page', ['page' => $page]) }}">{{ $page }}</a>
|
||||
@endif
|
||||
@endforeach
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
{{-- Next Page Link --}}
|
||||
@if ($paginator->hasMorePages())
|
||||
<a href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="{{ __('pagination.next') }}">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /></svg>
|
||||
</a>
|
||||
@else
|
||||
<span aria-disabled="true" aria-label="{{ __('pagination.next') }}">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /></svg>
|
||||
</span>
|
||||
@endif
|
||||
</span>
|
||||
</nav>
|
||||
@endif
|
||||
78
src/tests/Feature/AdminLogsViewerTest.php
Normal file
78
src/tests/Feature/AdminLogsViewerTest.php
Normal 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();
|
||||
});
|
||||
@@ -35,9 +35,30 @@ test('the subcategory scope picker only appears once "klient może wybrać sprz
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->set('snipeitConfig.enabled', true)
|
||||
->assertDontSee('Ogranicz do podkategorii')
|
||||
->assertDontSee('Ogranicz do kategorii')
|
||||
->set('snipeitConfig.clientCanSelectAsset', true)
|
||||
->assertSee('Ogranicz do podkategorii')
|
||||
->assertSee('Sprzęt / Laptop');
|
||||
->assertSee('Sprzęt / Laptop')
|
||||
->assertSee('Ogranicz do kategorii')
|
||||
->assertSee('Sprzęt');
|
||||
});
|
||||
|
||||
test('an admin can allow-list a whole category for the Snipe-IT client picker', function () {
|
||||
$admin = adminUserForSnipeitTest();
|
||||
$itHelp = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
$orders = Category::query()->create(['name' => 'Zamówienia']);
|
||||
|
||||
$component = Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->set('snipeitConfig.enabled', true)
|
||||
->set('snipeitConfig.clientCanSelectAsset', true)
|
||||
->call('toggleSnipeitClientCategory', $itHelp->id)
|
||||
->call('toggleSnipeitClientCategory', $orders->id);
|
||||
|
||||
expect($component->get('snipeitConfig.clientAssetCategoryIds'))->toEqualCanonicalizing([$itHelp->id, $orders->id]);
|
||||
|
||||
// Toggling again removes it.
|
||||
$component->call('toggleSnipeitClientCategory', $orders->id);
|
||||
expect($component->get('snipeitConfig.clientAssetCategoryIds'))->toBe([$itHelp->id]);
|
||||
});
|
||||
|
||||
test('saving the Snipe-IT config persists settings, encrypts the token at rest, and inverts the SSL checkbox', function () {
|
||||
@@ -52,6 +73,7 @@ test('saving the Snipe-IT config persists settings, encrypts the token at rest,
|
||||
->set('snipeitConfig.skipSslVerification', true)
|
||||
->set('snipeitConfig.clientCanSelectAsset', true)
|
||||
->call('toggleSnipeitClientSubcategory', $sub->id)
|
||||
->call('toggleSnipeitClientCategory', $category->id)
|
||||
->set('snipeitConfig.operatorViewRequesterAssets', true)
|
||||
->set('snipeitConfig.operatorSearchInventory', false)
|
||||
->call('saveSnipeitConfig')
|
||||
@@ -64,6 +86,7 @@ test('saving the Snipe-IT config persists settings, encrypts the token at rest,
|
||||
expect(Settings::bool('snipeit_verify_ssl'))->toBeFalse();
|
||||
expect(Settings::bool('snipeit_client_can_select_asset'))->toBeTrue();
|
||||
expect(Settings::get('snipeit_client_asset_subcategory_ids'))->toBe((string) $sub->id);
|
||||
expect(Settings::get('snipeit_client_asset_category_ids'))->toBe((string) $category->id);
|
||||
expect(Settings::bool('snipeit_operator_view_requester_assets'))->toBeTrue();
|
||||
expect(Settings::bool('snipeit_operator_search_inventory'))->toBeFalse();
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ test('sla is not a clickable sortable column', function () {
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('sortByColumn', 'sla')
|
||||
->assertSet('sortBy', 'updated_at');
|
||||
->assertSet('sortBy', 'created');
|
||||
});
|
||||
|
||||
test('columns can be hidden and shown again, but at least one must stay visible', function () {
|
||||
|
||||
@@ -133,6 +133,36 @@ test('the client asset picker only shows for subcategories the admin allow-liste
|
||||
->assertDontSee('SN123');
|
||||
});
|
||||
|
||||
test('the client asset picker shows for any subcategory of an allow-listed category, without listing that subcategory itself', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$email = 'client-snipeit6@example.com';
|
||||
fakeSnipeitUserAsset($email);
|
||||
|
||||
$client = User::query()->create(['name' => 'Test Client', 'email' => $email, 'roles' => ['client']]);
|
||||
$category = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
$sub = $category->subcategories()->create(['name' => 'Awaria sprzętu']);
|
||||
$otherCategory = Category::query()->create(['name' => 'Kadry']);
|
||||
$otherSub = $otherCategory->subcategories()->create(['name' => 'Urlop']);
|
||||
|
||||
enableSnipeitForLinkingTest();
|
||||
Settings::set('snipeit_client_asset_category_ids', (string) $category->id);
|
||||
|
||||
// Any subcategory under the allow-listed category shows the picker,
|
||||
// even though only the category (not this specific subcategory) is listed.
|
||||
Livewire::actingAs($client)->test(ClientNewTicket::class)
|
||||
->call('selectCategory', $category->id)
|
||||
->call('selectSubcategory', $sub->id)
|
||||
->call('loadSnipeitAssets')
|
||||
->assertSee('SI-001 - SN123 - Dell Latitude 5420');
|
||||
|
||||
// A subcategory under a different, non-allow-listed category stays hidden.
|
||||
Livewire::actingAs($client)->test(ClientNewTicket::class)
|
||||
->call('selectCategory', $otherCategory->id)
|
||||
->call('selectSubcategory', $otherSub->id)
|
||||
->call('loadSnipeitAssets')
|
||||
->assertDontSee('SN123');
|
||||
});
|
||||
|
||||
test('changing subcategory clears a previously selected asset', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$email = 'client-snipeit5@example.com';
|
||||
|
||||
59
src/tests/Feature/StatsCategoryBreakdownTest.php
Normal file
59
src/tests/Feature/StatsCategoryBreakdownTest.php
Normal 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);
|
||||
});
|
||||
@@ -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->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);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Livewire\Client\Dashboard;
|
||||
use App\Livewire\Operator\Queue;
|
||||
use App\Livewire\Operator\TicketShow;
|
||||
use App\Livewire\Settings\NotificationPreferences;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
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)
|
||||
->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']));
|
||||
});
|
||||
|
||||
@@ -41,6 +41,18 @@ test('next ticket number is one above the current max', function () {
|
||||
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 () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
|
||||
50
src/tests/Feature/TicketFieldValuesSyncTest.php
Normal file
50
src/tests/Feature/TicketFieldValuesSyncTest.php
Normal 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);
|
||||
});
|
||||
38
src/tests/Feature/TicketSourceValidationTest.php
Normal file
38
src/tests/Feature/TicketSourceValidationTest.php
Normal 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);
|
||||
});
|
||||
@@ -297,11 +297,16 @@ razem, zamiast być rozrzucone po różnych zakładkach.
|
||||
- **Klient może wybrać sprzęt, którego dotyczy zgłoszenie** — przy
|
||||
tworzeniu zgłoszenia klient widzi listę swojego sprzętu z Snipe-IT
|
||||
(dopasowanego po adresie e-mail) i może je powiązać ze zgłoszeniem. Po
|
||||
zaznaczeniu pojawia się dodatkowa lista wielokrotnego wyboru **„Ogranicz
|
||||
do podkategorii”** — wybór sprzętu pokaże się klientowi **tylko** dla
|
||||
zaznaczonych tam podkategorii; jeśli nic nie jest zaznaczone, opcja nie
|
||||
pojawi się w żadnej podkategorii (tak samo jak dozwolone półki BookStack
|
||||
wyżej — trzeba świadomie wskazać zakres).
|
||||
zaznaczeniu pojawiają się dwie dodatkowe listy wielokrotnego wyboru:
|
||||
**„Ogranicz do kategorii”** — najszybszy sposób, żeby włączyć wybór
|
||||
sprzętu dla wszystkich podkategorii naraz w zaznaczonych tu kategoriach
|
||||
(np. zaznacz „IT-Pomoc” i „Zamówienia”, żeby objąć każdą ich
|
||||
podkategorię) — oraz **„Ogranicz do podkategorii”**, do doprecyzowania
|
||||
pojedynczych podkategorii niezależnie od tego, czy ich kategoria jest
|
||||
zaznaczona wyżej. Wybór sprzętu pokaże się klientowi tylko tam, gdzie
|
||||
trafi w którąkolwiek z dwóch list; jeśli obie są puste, opcja nie pojawi
|
||||
się w żadnej podkategorii (tak samo jak dozwolone półki BookStack wyżej —
|
||||
trzeba świadomie wskazać zakres).
|
||||
- **Operator może zobaczyć sprzęt zgłaszającego w widoku zgłoszenia** — ta
|
||||
sama lista sprzętu zgłaszającego, tym razem w panelu bocznym operatora
|
||||
na widoku zgłoszenia, z przyciskiem „Powiąż” przy każdej pozycji.
|
||||
@@ -353,6 +358,17 @@ razem, zamiast być rozrzucone po różnych zakładkach.
|
||||
- **Prompt systemowy podsumowania** — edytowalne pole tekstowe z gotową
|
||||
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 (100–3000, 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
|
||||
|
||||
Panel `/admin/api-docs` udostępnia interaktywną dokumentację (Swagger) REST API
|
||||
|
||||
@@ -43,7 +43,8 @@ Dashboard klienta dzieli zgłoszenia na dwie zakładki:
|
||||
- **Bieżące** — zgłoszenia jeszcze nie zamknięte.
|
||||
- **Archiwum** — zgłoszenia zamknięte.
|
||||
|
||||
Pole wyszukiwania nad listą przeszukuje numer, temat i treść zgłoszenia (oraz
|
||||
Obie listy pokazują zgłoszenia od najnowszych (wg daty utworzenia). Pole
|
||||
wyszukiwania nad listą przeszukuje numer, temat i treść zgłoszenia (oraz
|
||||
odpowiedzi w wątku). Obie zakładki są stronicowane (20 zgłoszeń na stronę),
|
||||
niezależnie od siebie — przełączenie zakładki nie cofa Cię na pierwszą stronę
|
||||
drugiej. **Ctrl+K**/**Cmd+K** otwiera też globalną wyszukiwarkę zgłoszeń z
|
||||
|
||||
@@ -47,12 +47,18 @@ otworzyłeś, najnowsze na górze.
|
||||
e-mailu dowolnego konta (nie tylko klientów), pokazuje liczbę jego zgłoszeń i
|
||||
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
|
||||
włączać/wyłączać przyciskiem „Kolumny” (numer, temat, klient, kategoria,
|
||||
podkategoria, priorytet, status, SLA, przypisany, zespół, utworzono — kilka z
|
||||
nich domyślnie ukryte), a nagłówki kolumn sortują listę. Wybrana zakładka i
|
||||
kolumny zostają zapamiętane w adresie strony, więc odświeżenie nie cofa Cię do
|
||||
włączać/wyłączać przyciskiem „Kolumny” (ID, numer, temat, klient, e-mail,
|
||||
kategoria, podkategoria, priorytet, status, SLA, przypisany, zespół, źródło,
|
||||
utworzono, zaktualizowano — kilka z nich domyślnie ukryte) oraz zmieniać ich
|
||||
kolejność strzałkami ↑/↓ przy każdej widocznej kolumnie na tej samej liście, a
|
||||
nagłówki kolumn sortują listę — domyślnie po dacie utworzenia, najnowsze na
|
||||
górze. Które kolumny są widoczne i w jakiej kolejności zapamiętuje się
|
||||
automatycznie na Twoim koncie (nie trzeba do tego zapisywać widoku). Wybrana
|
||||
zakładka zostaje zapamiętana w adresie strony, więc odświeżenie nie cofa Cię do
|
||||
pierwszej zakładki.
|
||||
|
||||
**Zapisane widoki** — przycisk „Zapisane widoki” pozwala zapisać bieżącą
|
||||
@@ -141,6 +147,10 @@ W widoku pojedynczego zgłoszenia:
|
||||
jest automatycznie wstrzymywane, gdy zgłoszenie ma status zamknięty — nie
|
||||
uruchomi się przy otwarciu zamkniętego zgłoszenia ani nie będzie dalej biec
|
||||
po jego zamknięciu; wcześniej naliczony czas można wciąż ręcznie skorygować.
|
||||
Samo otwarcie zgłoszenia (i uruchomienie/zatrzymanie licznika w tle) nie
|
||||
liczy się jako aktualizacja — kolumna „Zaktualizowano” w kolejce odzwierciedla
|
||||
wyłącznie realne zmiany (odpowiedź, zmiana statusu/priorytetu/przypisania
|
||||
itp.), nie samo przeglądanie zgłoszenia.
|
||||
- **Edycja danych zgłoszenia** — temat, opis, podkategoria, pola dodatkowe;
|
||||
zmiana kategorii może wysłać powiadomienie do klienta.
|
||||
- **Historia** — log każdej zmiany (status, priorytet, zespół, przypisanie,
|
||||
|
||||
Reference in New Issue
Block a user