Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0943829331 | |||
| 4b70b910a9 | |||
| 03c6ec7cae | |||
| 7a8cf2037c | |||
| 1df697afce | |||
| 313e01ad24 | |||
| 0d116dfd98 | |||
| 63178b366e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
/mysql/
|
/mysql/
|
||||||
.env
|
.env
|
||||||
compose.yaml
|
compose.yaml
|
||||||
|
scripts/hesk-import/.env
|
||||||
547
ARCHITECTURE.md
547
ARCHITECTURE.md
@@ -36,13 +36,17 @@ Category ─< Subcategory ─< CustomField (per-subcategory custom fields
|
|||||||
├──< TicketMessage (public replies + internal notes)
|
├──< TicketMessage (public replies + internal notes)
|
||||||
├──< TicketAttachment
|
├──< TicketAttachment
|
||||||
├──< TicketHistory
|
├──< TicketHistory
|
||||||
|
├──< TicketFieldValue (queryable custom_fields values, kept in sync)
|
||||||
|
├── aiSummary → TicketAiSummary (1:1, triage+summary state)
|
||||||
|
├── snipeitAsset → TicketSnipeitAsset (1:1, linked asset)
|
||||||
├── customer/assignee → User
|
├── customer/assignee → User
|
||||||
├── status → Status (fixed stages: new/open/closed)
|
├── status → Status (fixed stages: new/open/closed)
|
||||||
├── priority → Priority → SlaRule (response/resolution minutes)
|
├── priority → Priority → SlaRule (response/resolution minutes)
|
||||||
└── csat_rating/csat_comment/csat_rated_at (nullable — set once, on close)
|
└── csat_rating/csat_comment/csat_rated_at (nullable — set once, on close)
|
||||||
|
|
||||||
User ─< UserFieldValue >─ UserField
|
User ─< UserFieldValue >─ UserField
|
||||||
User ─< SavedQueueView (operator's own saved queue filter/sort/column presets)
|
User ─< SavedQueueView (operator's own named/default saved queue filter/sort/column presets)
|
||||||
|
User.operator_queue_columns (JSON, auto-remembers shown/hidden queue columns + their order, independent of SavedQueueView)
|
||||||
User ─< notifications (Laravel's database channel — polymorphic, morph-mapped as 'user')
|
User ─< notifications (Laravel's database channel — polymorphic, morph-mapped as 'user')
|
||||||
ApiClient (Sanctum token owner, ability-scoped)
|
ApiClient (Sanctum token owner, ability-scoped)
|
||||||
Setting (single-row-per-key config store, see below)
|
Setting (single-row-per-key config store, see below)
|
||||||
@@ -63,6 +67,124 @@ queue + unassigned + anything assigned to them, an admin sees everything), and
|
|||||||
work-timer tracking (`timerElapsedSeconds()`). Keep ticket-shaped logic here
|
work-timer tracking (`timerElapsedSeconds()`). Keep ticket-shaped logic here
|
||||||
rather than spreading it across Livewire components.
|
rather than spreading it across Livewire components.
|
||||||
|
|
||||||
|
**Virtual `ai_*`/`snipeit_*` attributes.** The AI triage/summary fields
|
||||||
|
(`ai_triaged_at`, `ai_summary`, `ai_suggested_action`, `ai_summary_generated_at`)
|
||||||
|
and the Snipe-IT link (`snipeit_asset_id`, `snipeit_asset_name`) are **not**
|
||||||
|
real columns on `tickets` — they live on the related `TicketAiSummary`/
|
||||||
|
`TicketSnipeitAsset` rows shown in the diagram above (each table's own columns
|
||||||
|
drop the prefix, e.g. `ticket_ai_summaries.summary`). `Ticket` overrides
|
||||||
|
`getAttribute()`/`setAttribute()` (see `AI_SUMMARY_FIELD_MAP`/
|
||||||
|
`SNIPEIT_FIELD_MAP`) so every existing `$ticket->ai_summary`/
|
||||||
|
`$ticket->update(['snipeit_asset_id' => ...])` call site keeps working
|
||||||
|
unchanged against the new tables — the same pattern `TicketMessage` already
|
||||||
|
uses for its own virtual `role`/`author_id`. A write is queued
|
||||||
|
(`$pendingVirtualAttributes`) and flushed into the related row's
|
||||||
|
`updateOrCreate()` on the model's `saved` event, since a brand-new ticket has
|
||||||
|
no id yet to key the related row on until that point. If you add a new
|
||||||
|
`ai_*`/`snipeit_*` field, add it to the relevant `FIELD_MAP` rather than to
|
||||||
|
`tickets` directly.
|
||||||
|
|
||||||
|
**Custom field values.** `tickets.custom_fields` (a JSON blob, `field.id =>
|
||||||
|
value`) stays the source of truth for reads/writes — `TicketFieldValue`
|
||||||
|
(`ticket_field_values`, one row per non-blank entry) is a queryable mirror
|
||||||
|
kept in sync automatically by `Ticket::syncFieldValues()` (called from the
|
||||||
|
same `saved` hook whenever `custom_fields` changes), so reporting can
|
||||||
|
filter/join on "tickets where custom field X = Y" without scanning JSON.
|
||||||
|
Nothing else needs to write to `ticket_field_values` directly.
|
||||||
|
|
||||||
|
**`source` validation.** `Ticket::SOURCES`/`TicketMessage::SOURCES` are the
|
||||||
|
only values ever allowed in `tickets.source`/`ticket_messages.source`
|
||||||
|
(`'web'`/`'email'`/`'hesk_import'`; `null` still means "web" for messages) —
|
||||||
|
enforced by a `saving` listener that throws `InvalidArgumentException` on
|
||||||
|
anything else, so a typo'd literal fails loudly instead of sticking silently.
|
||||||
|
Add new values to the constant before writing them anywhere.
|
||||||
|
|
||||||
|
**Timer bookkeeping never touches `updated_at`.** `flushTimer()`/`stopTimer()`/
|
||||||
|
`resumeTimer()`/`resetTimer()`/`setTimeSpent()` all route their writes through
|
||||||
|
the private `updateTimerFields()`, which toggles `$this->timestamps = false`
|
||||||
|
around the `update()` call. `resumeTimer()` runs on every single ticket open
|
||||||
|
(`TicketShow::mount()`) and `stopTimer()` on every navigate-away/tab-close —
|
||||||
|
without this, merely viewing a ticket (no reply, no status change) would bump
|
||||||
|
`updated_at`, which used to drown out genuinely stale tickets in any list
|
||||||
|
sorted by that column (operator queue, client dashboard — both now default to
|
||||||
|
sorting by `created_at` instead, for the same reason). Real content changes
|
||||||
|
still touch `updated_at` normally, via their own separate `update()`/`save()`
|
||||||
|
calls elsewhere. If you add another timer-only field, write it through
|
||||||
|
`updateTimerFields()` too rather than a plain `update()`.
|
||||||
|
|
||||||
|
## Ticket numbering & URLs
|
||||||
|
|
||||||
|
A ticket carries three distinct identifiers, each with a different job:
|
||||||
|
|
||||||
|
- **`id`** — the DB primary key. Never shown to users; the REST API
|
||||||
|
(`routes/api.php`) is deliberately pinned to it (`{ticket:id}` explicit
|
||||||
|
binding on every `{ticket}` route) so external integrations have a stable
|
||||||
|
contract regardless of the numbering settings below.
|
||||||
|
- **`number`** — a plain sequential string (`Ticket::nextNumber()`, max+1
|
||||||
|
starting at 1001), unique but otherwise unremarkable. Backs `scopeSearch()`
|
||||||
|
and the numeric sort in `Operator/Queue.php` regardless of display mode.
|
||||||
|
- **`checksum`** — a 6-digit HMAC-derived value (salted with `app.key`,
|
||||||
|
keyed off `id`), assigned once in a `Ticket::booted()` `created` listener
|
||||||
|
and never changed afterward. Collisions are handled for real, not just
|
||||||
|
assumed away: `Ticket::generateUniqueChecksum()` walks a nonce forward
|
||||||
|
until the candidate is free (checked against the DB), and the column has a
|
||||||
|
`unique()` constraint as a hard backstop.
|
||||||
|
|
||||||
|
`Ticket::displayNumber()`/`formattedNumber()` pick between `number` (zero-padded
|
||||||
|
to `Settings::get('ticket_number_min_length')`) and `checksum` based on
|
||||||
|
`Settings::bool('ticket_number_obfuscate')` — the "Ukryj kolejność zgłoszeń"
|
||||||
|
toggle in Admin > Konfiguracja. `Ticket` also overrides `getRouteKey()` and
|
||||||
|
`resolveRouteBinding()` to mirror that same choice, so **the web routes**
|
||||||
|
(`routes/web.php`, all plain `{ticket}` implicit bindings — no explicit field)
|
||||||
|
resolve and generate URLs against whichever column is currently the display
|
||||||
|
number: flip the setting and both the visible number *and* every link
|
||||||
|
(`route('client.ticket', $ticket)` etc.) switch together, and a bookmarked URL
|
||||||
|
built under the old mode stops resolving. This is why the API routes need the
|
||||||
|
explicit `{ticket:id}` override — without it, the same global `getRouteKey()`
|
||||||
|
change would silently start requiring `number`/`checksum` in API path params
|
||||||
|
too, breaking the documented `integer` "Ticket id" contract.
|
||||||
|
|
||||||
|
The `{numer}` placeholder available in admin-editable e-mail templates
|
||||||
|
(Admin > Szablony e-mail / Wyzwalacze) resolves to `formattedNumber()`
|
||||||
|
*without* `displayNumber()`'s prefix — those templates already hardcode their
|
||||||
|
own `#{numer}`, so adding the prefix there too would double it up or clash
|
||||||
|
with a non-default prefix.
|
||||||
|
|
||||||
|
A ticket route binding that resolves to nothing (most commonly: the ticket
|
||||||
|
was deleted while someone had it open, and a later request — typically
|
||||||
|
Livewire's own "model missing during hydration" recovery, which does a full
|
||||||
|
`window.location.reload()` of the same page — hits `{ticket}` again) no
|
||||||
|
longer surfaces Laravel's default 404 page. `bootstrap/app.php` registers a
|
||||||
|
`NotFoundHttpException` render callback (note: `Handler::prepareException()`
|
||||||
|
already converts `ModelNotFoundException` into `NotFoundHttpException`,
|
||||||
|
wrapped as `getPrevious()`, *before* any render callback runs — a callback
|
||||||
|
typed against `ModelNotFoundException` itself would never match) that
|
||||||
|
redirects to `operator.queue`/`client.dashboard` instead, for any
|
||||||
|
authenticated request under `operator/*`/`client/*`.
|
||||||
|
|
||||||
|
That global handler only ever sees a full HTTP request (a page load/reload),
|
||||||
|
not Livewire's own AJAX update endpoint (`/livewire/update`, which doesn't
|
||||||
|
match the `operator/*`/`client/*` path check) — so it doesn't cover an
|
||||||
|
operator who already has a ticket open when it's deleted, or whose team gets
|
||||||
|
reassigned (by anyone, including via their own action — see "Teams" in
|
||||||
|
[README.md](README.md)) to one outside their visible scope
|
||||||
|
(`Ticket::isVisibleToOperator()`) mid-session. `Operator\TicketShow` handles
|
||||||
|
that case itself: a Livewire component's typed public model property
|
||||||
|
(`public Ticket $ticket`) is re-fetched by id on every subsequent request via
|
||||||
|
`firstOrFail()` (`Livewire\Features\SupportModels\ModelSynth::hydrate()`),
|
||||||
|
which throws `ModelNotFoundException` *before* any of the component's own
|
||||||
|
method code runs if the row is gone — too early for an ordinary try/catch
|
||||||
|
inside an action method to ever catch. The component instead defines
|
||||||
|
Livewire's `exception($e, $stopPropagation)` lifecycle hook (called for any
|
||||||
|
exception raised anywhere in the component's request lifecycle, hydration
|
||||||
|
included) to catch that case and redirect. The narrower case — ticket still
|
||||||
|
exists but is no longer visible, e.g. after a team reassignment — doesn't
|
||||||
|
throw at all, so it's caught separately: `refreshOrRedirectAway()` re-checks
|
||||||
|
`isVisibleToOperator()` after every live-update refresh
|
||||||
|
(`onQueueChanged()`/`refreshTicketData()`) and after the operator's own
|
||||||
|
`setTeam()` call, redirecting immediately rather than leaving them on a
|
||||||
|
ticket they can no longer legitimately keep viewing.
|
||||||
|
|
||||||
## Roles & permissions
|
## Roles & permissions
|
||||||
|
|
||||||
`$user->roles` reads/writes as a plain array (`['client', 'operator']`), but
|
`$user->roles` reads/writes as a plain array (`['client', 'operator']`), but
|
||||||
@@ -94,13 +216,23 @@ the account used for first login after a fresh install (see
|
|||||||
`SyncUserFieldsFromLdap` keeps `UserFieldValue` rows in sync with directory
|
`SyncUserFieldsFromLdap` keeps `UserFieldValue` rows in sync with directory
|
||||||
attributes.
|
attributes.
|
||||||
|
|
||||||
|
`app/Ldap/` has two directory-schema models — `LldapUser` (LLDAP/OpenLDAP,
|
||||||
|
the default) and `AdUser` (Active Directory, `LdapRecord\Models\ActiveDirectory\User`
|
||||||
|
under the hood). `Settings::ldapUserModelClass()` picks between them based on
|
||||||
|
the `ldap_directory_type` setting, and `AppServiceProvider::applyLdapSettingsOverride()`
|
||||||
|
wires the chosen class into `config('auth.providers.users.model')` on every
|
||||||
|
request — same live-override mechanism as the connection host/base DN below.
|
||||||
|
`LdapUserProvisioner` (used for sync + guest auto-provisioning) resolves the
|
||||||
|
same setting at call time rather than caching the class, so switching
|
||||||
|
directory type takes effect without a redeploy.
|
||||||
|
|
||||||
## Settings override ("live config")
|
## Settings override ("live config")
|
||||||
|
|
||||||
`App\Support\Settings` (`app/Support/Settings.php`) is a cached key/value reader
|
`App\Support\Settings` (`app/Support/Settings.php`) is a cached key/value reader
|
||||||
over the `settings` table, with hardcoded defaults for every key (company name,
|
over the `settings` table, with hardcoded defaults for every key (company name,
|
||||||
LDAP/SMTP connection details, attachment limits, session lifetime, timezone,
|
LDAP/SMTP connection details, attachment limits, session lifetime, timezone,
|
||||||
branding/email HTML, etc.). Admin > Konfiguracja (general/attachments/session),
|
branding/email HTML, etc.). Admin > Konfiguracja (general/attachments/session),
|
||||||
E-MAIL (SMTP) and Integracje (LDAP, BookStack) all write to this same table, and
|
Poczta (SMTP) and Integracje (LDAP, BookStack) all write to this same table, and
|
||||||
`AppServiceProvider::boot()` re-applies the relevant subset of it over
|
`AppServiceProvider::boot()` re-applies the relevant subset of it over
|
||||||
`config()` on every request — meaning **`Setting` rows win over `.env`** for
|
`config()` on every request — meaning **`Setting` rows win over `.env`** for
|
||||||
LDAP, mail, session lifetime and timezone once they're non-empty. This is by
|
LDAP, mail, session lifetime and timezone once they're non-empty. This is by
|
||||||
@@ -109,6 +241,19 @@ source of the "seeded placeholder overrides real `.env` values" gotcha
|
|||||||
documented in [install.md](install.md) — anything touching LDAP/mail/session/
|
documented in [install.md](install.md) — anything touching LDAP/mail/session/
|
||||||
timezone config should go through `Settings`, not raw `config()`/`.env` reads.
|
timezone config should go through `Settings`, not raw `config()`/`.env` reads.
|
||||||
|
|
||||||
|
`settingsTableUsable()` gates all four overrides on whether the `settings`
|
||||||
|
table is safe to query yet — but is deliberately scoped to just the `migrate`
|
||||||
|
command family (`runningConsoleCommand('migrate', 'migrate:fresh', ...)`), not
|
||||||
|
"any console command". It used to blanket-skip for every console invocation
|
||||||
|
(exempting only unit tests), which silently broke every scheduled command's
|
||||||
|
outbound mail: `AppServiceProvider::boot()` runs on each process including
|
||||||
|
`schedule:run`-invoked commands, so `tickets:check-sla-breaches`,
|
||||||
|
`automation:run-rules` and `emails:fetch-imap` (below) all sent notifications
|
||||||
|
through whatever `.env`'s `MAIL_MAILER` happened to be (`log`, i.e. nowhere)
|
||||||
|
instead of the admin-configured SMTP server — with no error, since the `log`
|
||||||
|
mailer never throws. If a scheduled command's notification/lookup ever again
|
||||||
|
seems to silently use `.env` defaults instead of `Settings`, check here first.
|
||||||
|
|
||||||
## Notifications
|
## Notifications
|
||||||
|
|
||||||
`TicketService::notify(Ticket $ticket, string $triggerKey)` is the single
|
`TicketService::notify(Ticket $ticket, string $triggerKey)` is the single
|
||||||
@@ -189,9 +334,19 @@ if "nothing updates live" ever comes back:
|
|||||||
|
|
||||||
As a defense against a dropped websocket connection (backgrounded tab,
|
As a defense against a dropped websocket connection (backgrounded tab,
|
||||||
network blip), the operator queue and both ticket-detail views also poll
|
network blip), the operator queue and both ticket-detail views also poll
|
||||||
themselves every 30–60 seconds via a small Alpine countdown calling
|
themselves via a small Alpine countdown calling `$wire.refreshQueue()` /
|
||||||
`$wire.refreshQueue()` / `$wire.refreshTicketData()` — broadcasting is
|
`$wire.refreshTicketData()` — broadcasting is best-effort, not the only way
|
||||||
best-effort, not the only way these views ever update.
|
these views ever update. The countdown badge is also clickable
|
||||||
|
(`x-on:click="remaining = total; $wire.refresh...()"` on the same element
|
||||||
|
the `x-init="setInterval(...)"` already lives on) to fetch immediately and
|
||||||
|
reset the countdown, rather than only ever firing on its own schedule. Its
|
||||||
|
interval — like the notification bell's `wire:poll` and the 4 scheduled
|
||||||
|
commands below — reads from `Settings` (`refresh_queue_seconds`/
|
||||||
|
`refresh_ticket_view_seconds`/`refresh_notifications_seconds`, admin-editable
|
||||||
|
in Konfiguracja) rather than a hardcoded number: `wire:poll.{{ $seconds }}s`
|
||||||
|
and Alpine's `x-data="{ remaining: {{ $seconds }}, ... }"` both just
|
||||||
|
interpolate to plain text in the rendered HTML, so a `Settings`-sourced value
|
||||||
|
works exactly like a literal one would.
|
||||||
|
|
||||||
A third private channel, **`App.Models.User.{id}`** (Laravel's default
|
A third private channel, **`App.Models.User.{id}`** (Laravel's default
|
||||||
per-notifiable convention, kept verbatim rather than a shorter alias),
|
per-notifiable convention, kept verbatim rather than a shorter alias),
|
||||||
@@ -211,8 +366,9 @@ fires while the tab is open, same limitation as the other Echo listeners here.
|
|||||||
|
|
||||||
`SlaRule` holds per-priority response/resolution targets in minutes. The
|
`SlaRule` holds per-priority response/resolution targets in minutes. The
|
||||||
scheduled command `tickets:check-sla-breaches` (registered in
|
scheduled command `tickets:check-sla-breaches` (registered in
|
||||||
`routes/console.php`, run every 15 minutes via `schedule:run`) flags overdue
|
`routes/console.php`, default every 15 minutes, interval admin-configurable —
|
||||||
tickets and can notify the assigned operator — see [install.md](install.md) for
|
see "Configurable scheduled-command intervals" below) flags overdue tickets
|
||||||
|
and can notify the assigned operator — see [install.md](install.md) for
|
||||||
why this requires an external cron entry (the Docker image ships no
|
why this requires an external cron entry (the Docker image ships no
|
||||||
cron/supervisor of its own).
|
cron/supervisor of its own).
|
||||||
|
|
||||||
@@ -222,8 +378,9 @@ cron/supervisor of its own).
|
|||||||
`scope_subcategory_id`/`scope_team_id`, `action_type` + `action_value`) lets an
|
`scope_subcategory_id`/`scope_team_id`, `action_type` + `action_value`) lets an
|
||||||
admin configure "if a ticket has been silent for N minutes, change its
|
admin configure "if a ticket has been silent for N minutes, change its
|
||||||
priority/status/team/assignee" without code — Admin > Automatyzacja SLA. The
|
priority/status/team/assignee" without code — Admin > Automatyzacja SLA. The
|
||||||
scheduled command `automation:run-rules` (also every 15 minutes) evaluates
|
scheduled command `automation:run-rules` (default also every 15 minutes,
|
||||||
every enabled rule against `Ticket.last_customer_activity_at` (falling back to
|
independently configurable) evaluates every enabled rule against
|
||||||
|
`Ticket.last_customer_activity_at` (falling back to
|
||||||
`created_at` if never set — mirrors how `resolutionDeadline()` treats a
|
`created_at` if never set — mirrors how `resolutionDeadline()` treats a
|
||||||
missing `SlaRule` as "no SLA" rather than backfilling one), and applies a
|
missing `SlaRule` as "no SLA" rather than backfilling one), and applies a
|
||||||
match through the same `TicketService` setters a manual operator action would
|
match through the same `TicketService` setters a manual operator action would
|
||||||
@@ -238,6 +395,129 @@ that closes the ticket doesn't block earlier-ordered rules already applied
|
|||||||
this run, but a later rule's own query naturally excludes an already-closed
|
this run, but a later rule's own query naturally excludes an already-closed
|
||||||
ticket.
|
ticket.
|
||||||
|
|
||||||
|
## Configurable scheduled-command intervals
|
||||||
|
|
||||||
|
All 4 scheduled commands (`tickets:check-sla-breaches`, `automation:run-rules`,
|
||||||
|
`emails:fetch-imap`, `ai:run-ticket-automation`) have an admin-configurable
|
||||||
|
interval (Admin > Konfiguracja — `schedule_sla_check_minutes`/
|
||||||
|
`schedule_automation_rules_minutes`/`schedule_imap_fetch_minutes`/
|
||||||
|
`schedule_ai_automation_minutes`), defaulting to their previous hardcoded
|
||||||
|
values (15/15/5/5 minutes). `routes/console.php` registers all 4 as
|
||||||
|
`->everyMinute()->when(fn () => Settings::dueEveryMinutes($key, $default))`
|
||||||
|
rather than an eagerly-built `->cron('*/N * * * *')` string — this is a
|
||||||
|
deliberate choice, not just a style preference: `routes/console.php` is
|
||||||
|
`require`'d on **every** artisan boot (`migrate`, `tinker`, `php artisan
|
||||||
|
test`, not just `schedule:run`, since it's wired in via `bootstrap/app.php`'s
|
||||||
|
`commands:` key), so anything at its *top level* that queries the database
|
||||||
|
would run before a fresh/test database necessarily has the `settings` table
|
||||||
|
yet — an early version of this feature that built the cron string eagerly at
|
||||||
|
the top level broke exactly this way. A closure passed to `->when()` is only
|
||||||
|
ever evaluated later, when `schedule:run` actually processes due events, so
|
||||||
|
`Settings::dueEveryMinutes()` never runs at boot. One visible side effect:
|
||||||
|
`php artisan schedule:list` shows `* * * * *` for all four regardless of
|
||||||
|
their actual configured interval, since the real interval only exists inside
|
||||||
|
the closure — expected, not a bug.
|
||||||
|
|
||||||
|
## IMAP e-mail intake
|
||||||
|
|
||||||
|
Optional, off by default (`ImapMailbox.enabled` per row — there is no single
|
||||||
|
global toggle since this is a list of N mailboxes, not a `Settings`
|
||||||
|
singleton). Split across three layers, mirroring the plan that shipped it:
|
||||||
|
|
||||||
|
- **`App\Models\ImapMailbox`** — one row per polled mailbox (host/port/
|
||||||
|
encryption/username, `password` cast `'encrypted'` — the first model in
|
||||||
|
this codebase to use Laravel's native encrypted cast rather than the
|
||||||
|
manual `Crypt::` pattern `Settings` uses, since this is a list of records
|
||||||
|
rather than key/value config). `default_subcategory_id` XOR
|
||||||
|
`default_category_id` (enforced by the admin form's single combined
|
||||||
|
selector, not a DB constraint) route new tickets; `category_id` only ever
|
||||||
|
gets populated when there's no subcategory to derive one from (see
|
||||||
|
`Ticket::categoryLabel()`/`TicketService::create()`).
|
||||||
|
- **`App\Services\ImapMessageClassifier`** — pure decision logic, no IMAP
|
||||||
|
connection, fully Pest-testable: `rejectionReason()` (auto-reply/bounce
|
||||||
|
detection via `Auto-Submitted`/`Precedence`/`X-Autoreply` headers + EN/PL
|
||||||
|
subject phrases + a per-mailbox sender blocklist), `matchTicket()`
|
||||||
|
(extracts every digit run ≥4 chars from the subject — after stripping
|
||||||
|
`Re:`/`Odp:`/`Fwd:`/`FW:`/`Aw:` — and tries each through
|
||||||
|
`Ticket::resolveRouteBinding()`, so it transparently matches either the
|
||||||
|
plain sequential number or the obfuscated checksum, whichever mode is
|
||||||
|
active; no changes to outbound mail were needed since every notification
|
||||||
|
subject already carries `{numer}`), `isSenderAllowed()` (mirrors
|
||||||
|
`Landing::emailIsKnown()` — enforces `restrict_tickets_to_ldap` for e-mail
|
||||||
|
exactly like the guest web form), `resolveSender()` (existing local user,
|
||||||
|
or `LdapUserProvisioner::findOrCreateByEmail()` if enabled).
|
||||||
|
- **`App\Services\ImapMailboxFetcher`** — the I/O layer (`webklex/php-imap`,
|
||||||
|
a pure-PHP IMAP client with no `ext-imap` dependency — confirmed available
|
||||||
|
extensions were sufficient, no Dockerfile change needed). Fetches
|
||||||
|
`whereUnseen()` per mailbox, flags/moves a message **before** creating the
|
||||||
|
ticket (a crash mid-batch then risks a "processed but no ticket" message —
|
||||||
|
visible and easy to fix manually — rather than a duplicate ticket on the
|
||||||
|
next run), converts attachments to `UploadedFile` via a temp file (`$test
|
||||||
|
= true` bypasses the `is_uploaded_file()` check outside a real HTTP
|
||||||
|
request) so they flow through the existing `Settings::validateAttachments()`
|
||||||
|
+ `TicketService::attachFiles()` unchanged. Logs every connection attempt
|
||||||
|
and per-message decision to a dedicated `imap` log channel
|
||||||
|
(`storage/logs/imap-*.log`, always `debug` level regardless of the app's
|
||||||
|
own `LOG_LEVEL` — see `config/logging.php`) since this app commonly runs
|
||||||
|
at `LOG_LEVEL=error`, which would otherwise silently swallow this
|
||||||
|
activity entirely.
|
||||||
|
- One real bug worth remembering if IMAP rejection logic ever seems too
|
||||||
|
aggressive again: Webklex's `Header::get($name)` returns an *empty*
|
||||||
|
`Attribute` (not `null`) for a header that isn't present at all, and
|
||||||
|
`Attribute::first()` on that empty instance is `''`, not `null` — a
|
||||||
|
naive `$header !== null` check therefore treats *every* message as
|
||||||
|
carrying *every* header. Guarded in two places: `ImapMailboxFetcher`
|
||||||
|
only keeps a header value that's non-empty, and
|
||||||
|
`InboundEmail::header()` itself also treats `''` as absent, so the bug
|
||||||
|
can't resurface even if some other header source stops filtering.
|
||||||
|
- **`TicketService::guestReply()`** — the one new method added to the
|
||||||
|
existing service: a customer reply with no `User` account (mirrors
|
||||||
|
`clientReply()` — real customer activity, resets SLA silence, fires
|
||||||
|
`comment_added` so an admin-configured Trigger can reopen a closed ticket
|
||||||
|
— rather than `apiMessage()`, which tags a system/integration note, not
|
||||||
|
client content). Both `clientReply()` and `guestReply()` take an optional
|
||||||
|
trailing `string $source = 'web'`, stored as `TicketMessage.source`
|
||||||
|
(`null` for `'web'`) — the per-message counterpart to `Ticket.source`,
|
||||||
|
since a ticket opened on the web can later get an e-mail reply or vice
|
||||||
|
versa. Both surface as a small mail-icon badge (operator queue: next to
|
||||||
|
the ticket number; ticket view: per-message in the thread, plus a tag next
|
||||||
|
to the ticket number in the header).
|
||||||
|
- **`emails:fetch-imap`** (`app/Console/Commands/FetchImapEmails.php`),
|
||||||
|
registered in `routes/console.php` with `->withoutOverlapping()` (like
|
||||||
|
`ai:run-ticket-automation`, unlike the SLA-check/automation-rules
|
||||||
|
commands — both make real outbound HTTP/IMAP calls per record, so a slow
|
||||||
|
run risks overlapping the next tick in a way a pure-DB command doesn't).
|
||||||
|
Early-returns if no `ImapMailbox` is enabled. Also callable directly per
|
||||||
|
mailbox from Admin > Poczta's "Pobierz teraz" button
|
||||||
|
(`ImapMailboxFetcher::fetchMailbox()`, bypassing the enabled-only
|
||||||
|
`fetchAll()` used by the schedule) for on-demand fetching/diagnosis
|
||||||
|
without shell access.
|
||||||
|
|
||||||
|
Requires the same external `schedule:run` cron entry as SLA/automation (see
|
||||||
|
[install.md](install.md) and the crontab note in
|
||||||
|
[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
|
## API
|
||||||
|
|
||||||
`routes/api.php` + `app/Http/Controllers/Api/` expose a small ability-scoped REST
|
`routes/api.php` + `app/Http/Controllers/Api/` expose a small ability-scoped REST
|
||||||
@@ -249,26 +529,233 @@ tighter per-IP limit for unauthenticated requests
|
|||||||
generated by L5-Swagger at `/admin/api-docs`; there is no static Markdown API
|
generated by L5-Swagger at `/admin/api-docs`; there is no static Markdown API
|
||||||
reference in-repo.
|
reference in-repo.
|
||||||
|
|
||||||
|
## Generic AI integration
|
||||||
|
|
||||||
|
`App\Services\AiClient` is a small, feature-agnostic wrapper around an
|
||||||
|
OpenAI-compatible `/chat/completions` endpoint (`chat(array $messages, array
|
||||||
|
$options = []): ?string`) — works against Groq, OpenAI itself, or a
|
||||||
|
self-hosted Ollama instance, whichever `ai_base_url` points at.
|
||||||
|
`Settings`-driven like everything else here: `ai_enabled`, `ai_base_url`,
|
||||||
|
`ai_api_key` (encrypted, optional — deliberately not required by `enabled()`,
|
||||||
|
since a self-hosted Ollama instance typically has no auth at all),
|
||||||
|
`ai_model`, `ai_verify_ssl`. Every call is wrapped in `try/catch(\Throwable)`
|
||||||
|
and returns `null` on any failure (network, non-2xx, unexpected shape),
|
||||||
|
matching `BookStackClient`'s safe-default convention — callers are expected
|
||||||
|
to treat `null` as "AI unavailable" and degrade gracefully rather than throw.
|
||||||
|
Not tied to any single feature: `BookStackContentTagger`,
|
||||||
|
`TicketAiTriageService` and `TicketAiSummaryService` (below) are just its
|
||||||
|
first three consumers, each with their own prompt-building/parsing logic
|
||||||
|
layered on top rather than baked into the client itself.
|
||||||
|
|
||||||
## BookStack integration
|
## BookStack integration
|
||||||
|
|
||||||
`App\Services\BookStackClient` is the only outbound HTTP client in the
|
`App\Services\BookStackClient` is one of three outbound HTTP clients in the
|
||||||
codebase (Laravel's `Http` facade) — everything else here only ever receives
|
codebase (Laravel's `Http` facade), alongside `AiClient` above and
|
||||||
requests. It's entirely `Settings`-driven, no `.env`/`config()` involved:
|
`SnipeItClient` below — everything else here only ever receives requests.
|
||||||
`bookstack_enabled`, `bookstack_base_url`, `bookstack_token_id`/
|
It's entirely `Settings`-driven, no
|
||||||
`bookstack_token_secret` (encrypted, same as the LDAP/SMTP passwords),
|
`.env`/`config()` involved: `bookstack_enabled`, `bookstack_base_url`,
|
||||||
`bookstack_verify_ssl`, `bookstack_search_types` ('both'|'page'|'book'), and
|
`bookstack_token_id`/`bookstack_token_secret` (encrypted, same as the
|
||||||
**two independent** allow-lists of BookStack shelf IDs —
|
LDAP/SMTP passwords), `bookstack_verify_ssl`, and **two independent**
|
||||||
`bookstack_allowed_shelf_ids_creation` (ticket-wizard suggestions) and
|
allow-lists of BookStack shelf IDs — `bookstack_allowed_shelf_ids_creation`
|
||||||
`bookstack_allowed_shelf_ids_ticket_view` (the operator's sidebar on an
|
(ticket-wizard suggestions) and `bookstack_allowed_shelf_ids_ticket_view`
|
||||||
existing ticket) — `search()` takes a `$context` (`CONTEXT_CREATION` /
|
(the operator's sidebar on an existing ticket) — `search()` takes a
|
||||||
`CONTEXT_TICKET_VIEW`) that selects which one applies. **An empty allow-list
|
`$context` (`CONTEXT_CREATION` / `CONTEXT_TICKET_VIEW`) that selects which
|
||||||
means "search nothing"**, not "search everything" — nothing is ever
|
one applies. **An empty allow-list means "search nothing"**, not "search
|
||||||
suggested until an admin explicitly opts shelves in, independently per
|
everything" — nothing is ever suggested until an admin explicitly opts
|
||||||
context. BookStack has no "which shelf is this book on" field in its own
|
shelves in, independently per context. BookStack has no "which shelf is this
|
||||||
search response, so `BookStackClient` fetches `/api/shelves` +
|
book on" field in its own search response, so `BookStackClient` fetches
|
||||||
`/api/shelves/{id}` once (cached 30 min) into a shelf→book-ids map, used both
|
`/api/shelves` + `/api/shelves/{id}` once (cached 30 min) into a
|
||||||
to resolve the allow-list to book IDs and to build the "Shelf > Book"
|
shelf→book-ids map, used both to resolve the allow-list to book IDs and to
|
||||||
breadcrumb shown next to each suggestion. Per-query search results are cached
|
build the "Shelf > Book" breadcrumb shown next to each suggestion. Per-query
|
||||||
10 minutes, keyed on the query text **and** the active allow-list, so toggling
|
search results are cached 10 minutes, keyed on the query text **and** the
|
||||||
which shelves are allowed is reflected immediately instead of serving a
|
active allow-list, so toggling which shelves are allowed is reflected
|
||||||
pre-change result for up to 10 minutes.
|
immediately instead of serving a pre-change result for up to 10 minutes.
|
||||||
|
|
||||||
|
**Content-type filter and "search by" mode**: `bookstack_search_types` is a
|
||||||
|
comma-separated subset of `BookStackClient::SEARCH_TYPES` (`book`, `page`,
|
||||||
|
`chapter` — checkboxes in the admin UI, no more single-select "both/page/book"
|
||||||
|
dropdown), combined into BookStack's own `{type:a|b}` query syntax.
|
||||||
|
`bookstack_search_by` (`'name'`/`'tags'`/`'both'`) picks between matching the
|
||||||
|
title (`{in_name:...}`) and matching a tag whose name equals the query
|
||||||
|
(`[...]` — see BookStack content auto-tagging below for what actually writes
|
||||||
|
those tags); `'both'` runs one request per mode and merges/dedupes the
|
||||||
|
results, since BookStack's own query syntax ANDs filters together rather than
|
||||||
|
OR-ing them, so there's no single-request way to ask for "name OR tag".
|
||||||
|
`search()` takes both a `$query` (full "Category Subcategory" text, used for
|
||||||
|
the name-match variant) and an optional `$tagQuery` (bare subcategory name,
|
||||||
|
used for the tag-match variant) — the two differ because a tag is expected to
|
||||||
|
hold just the subcategory name, not the combined category+subcategory text.
|
||||||
|
|
||||||
|
## BookStack content auto-tagging
|
||||||
|
|
||||||
|
`App\Services\BookStackContentTagger` (used by the "Otaguj nową
|
||||||
|
treść"/"Otaguj wszystko ponownie" buttons on the BookStack admin card and by
|
||||||
|
`php artisan bookstack:tag-content`) is the reason the tag-based search mode
|
||||||
|
above has anything to match: it walks every book/chapter/page via
|
||||||
|
`BookStackClient::listAll()`/`detail()`, builds a Polish prompt naming the
|
||||||
|
current, live `Subcategory` list as the only allowed vocabulary, and asks
|
||||||
|
`AiClient` (above) to return which subcategory name(s) fit each item — a
|
||||||
|
single response per batch of 20 items, to keep prompt size/cost down.
|
||||||
|
Defensive JSON parsing (`parseAssignments()`) regex-extracts the first
|
||||||
|
`{...}` block before decoding, so a chatty or malformed response fails just
|
||||||
|
that one batch (`failed_batches` in the run summary) instead of crashing the
|
||||||
|
whole pass; every returned label is matched case-insensitively against the
|
||||||
|
real subcategory list before being trusted, so a hallucinated name is
|
||||||
|
silently dropped rather than written as a tag. Idempotent by default — an
|
||||||
|
item already carrying a tag matching a current subcategory name is skipped
|
||||||
|
unless `--force`/the "wszystko ponownie" button is used — and new tags are
|
||||||
|
merged into an item's existing tags (`updateTags()` PUTs the whole array;
|
||||||
|
BookStack has no "append a tag" endpoint), never overwriting unrelated ones.
|
||||||
|
|
||||||
|
## Snipe-IT asset inventory integration
|
||||||
|
|
||||||
|
`App\Services\SnipeItClient` talks to a Snipe-IT instance's REST API
|
||||||
|
(`/api/v1/...`, bearer token auth), entirely `Settings`-driven like
|
||||||
|
`BookStackClient`: `snipeit_enabled`, `snipeit_base_url`,
|
||||||
|
`snipeit_api_token` (encrypted), `snipeit_verify_ssl`. Every call is wrapped
|
||||||
|
in `try/catch(\Throwable)` returning `[]`/`null` on failure, same
|
||||||
|
safe-default convention as `AiClient`/`BookStackClient`. Three independently
|
||||||
|
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`
|
||||||
|
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.
|
||||||
|
- `snipeit_operator_search_inventory` — gates `searchAssets()`, a free-text
|
||||||
|
`/hardware?search=` lookup across the *whole* inventory, for linking
|
||||||
|
equipment the requester doesn't personally own (e.g. a shared printer).
|
||||||
|
Rendered inline in the same sidebar card as the requester-assets list, not
|
||||||
|
a separate route/page.
|
||||||
|
|
||||||
|
`Operator\TicketShow::linkSnipeitAsset(int $id)` deliberately does **not**
|
||||||
|
fall back to a direct `SnipeItClient::asset($id)` lookup by id — it only
|
||||||
|
accepts an id present in `snipeitRequesterAssets`/`snipeitSearchResults`,
|
||||||
|
and each of those is itself empty unless its own setting above is on. This
|
||||||
|
means an operator can't link an arbitrary asset through a source the admin
|
||||||
|
has switched off for them, even by tampering with the Livewire request
|
||||||
|
payload. `unlinkSnipeitAsset()` has no such gate — clearing an existing link
|
||||||
|
is a correction, not a new way to browse Snipe-IT, so it stays available
|
||||||
|
even with both toggles off.
|
||||||
|
|
||||||
|
`SnipeItClient::assetsForEmail()` has to resolve an e-mail to a Snipe-IT user
|
||||||
|
first (`GET /users?search=`, no "assets by e-mail" endpoint exists), then
|
||||||
|
lists what's checked out to them (`GET /users/{id}/assets`) — cached 5
|
||||||
|
minutes per e-mail. `normalizeAsset()` is the single place that turns a raw
|
||||||
|
Snipe-IT hardware row into the shape every caller/view uses (`id`, `label`,
|
||||||
|
`serial`, `manufacturer`, `model`, `category`, `status`, `url`); `label`
|
||||||
|
joins whichever of asset tag / serial / "manufacturer model" are actually
|
||||||
|
present with `" - "`, falling back to `Zasób #{id}` if all three are blank —
|
||||||
|
Snipe-IT doesn't guarantee any of them are filled in. The `x-snipeit-assets`
|
||||||
|
Blade component renders that shape everywhere an asset list shows up
|
||||||
|
(client picker, requester sidebar, search results), with a `card` prop that
|
||||||
|
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 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
|
||||||
|
reflected immediately; the cached label is only ever the fallback shown when
|
||||||
|
that live fetch fails (instance unreachable, or the asset was deleted
|
||||||
|
there).
|
||||||
|
|
||||||
|
## AI ticket triage & summary
|
||||||
|
|
||||||
|
Two independent services, both consuming `AiClient` above, both run from a
|
||||||
|
single scheduled command (`ai:run-ticket-automation`) — **never
|
||||||
|
synchronously at ticket creation**, so an LLM call never adds latency to a
|
||||||
|
live customer submitting a ticket:
|
||||||
|
|
||||||
|
- **`App\Services\TicketAiTriageService`** — a one-shot classification pass
|
||||||
|
per ticket, gated by 5 independent toggles
|
||||||
|
(`ai_triage_category_when_missing`/`subcategory_when_category_only`/
|
||||||
|
`recheck_categorized`/`fix_subject`/`set_priority`). `buildPrompt()` picks
|
||||||
|
one of 3 mutually-exclusive category scenarios from the ticket's *current*
|
||||||
|
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 `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
|
||||||
|
real category/subcategory/priority vocabulary before being trusted: a
|
||||||
|
hallucinated or out-of-scope value (e.g. a subcategory claimed under the
|
||||||
|
wrong category) is silently dropped. Applying changes goes through a new
|
||||||
|
`TicketService::applyAiTriage(Ticket $ticket, array $changes, array
|
||||||
|
$historyLines)` — a single `$ticket->update()` for whichever
|
||||||
|
category/subcategory/subject/priority fields actually changed, one
|
||||||
|
specific history line per changed field plus a final "Automatyzacja:
|
||||||
|
klasyfikacja AI" attribution line (mirrors how `RunAutomationRules` logs
|
||||||
|
its own SLA-automation changes), and `notify()`/`TriggerEngine::handle()`
|
||||||
|
fired only for the fields that actually changed — deliberately not
|
||||||
|
composed from the existing `setPriority()`/`updateDetails()` setters, since
|
||||||
|
one AI pass can touch several fields at once and those would each write
|
||||||
|
their own generic line and fire notifications per-field instead of once
|
||||||
|
per pass.
|
||||||
|
- **`App\Services\TicketAiSummaryService`** — a summary + suggested next
|
||||||
|
action for **every** ticket (gated by a single `ai_summary_enabled`
|
||||||
|
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
|
||||||
|
— deliberately compared against `ticket_messages.created_at`, not
|
||||||
|
`tickets.updated_at` (which also changes on unrelated actions like a
|
||||||
|
status/priority edit, which would otherwise trigger spurious
|
||||||
|
re-summarization on every tick for an active ticket). `buildTranscript()`
|
||||||
|
includes the ticket's own `body` (the opening description, outside
|
||||||
|
`ticket_messages`) ahead of the message transcript — needed because that
|
||||||
|
row would otherwise fall outside `TRANSCRIPT_MESSAGE_LIMIT` (30) on any
|
||||||
|
thread longer than that, silently dropping the original request from the
|
||||||
|
prompt. Unlike the triage service, a malformed AI response here leaves the
|
||||||
|
previous summary untouched rather than stamping "done" — the ticket stays
|
||||||
|
in the "stale" set and gets retried next run, since this feature is meant
|
||||||
|
to keep refreshing indefinitely, not run once. The system prompt is
|
||||||
|
admin-editable (`ai_summary_prompt` setting, plain textarea with a
|
||||||
|
"Resetuj" button restoring `Settings::default('ai_summary_prompt')` —
|
||||||
|
same pattern as the e-mail footer editor) and asks the model for a small
|
||||||
|
JSON object (`{"summary": "...", "suggested_action": "..."}`), parsed with
|
||||||
|
the same defensive regex-extract-then-decode approach used throughout
|
||||||
|
these AI services.
|
||||||
|
|
||||||
|
Besides `run()`'s scheduled sweep, two paths call `generateFor(Ticket
|
||||||
|
$ticket): bool` directly, bypassing the staleness check entirely:
|
||||||
|
`Operator\TicketShow::regenerateAiSummary()` (the sidebar's "Wygeneruj
|
||||||
|
teraz" button, a synchronous Livewire call — its `wire:loading` state covers
|
||||||
|
the wait, no need to dispatch anything in the background) and a
|
||||||
|
`TicketMessagePosted` listener registered in
|
||||||
|
`AppServiceProvider::regenerateAiSummaryOnNewMessage()`, active only when
|
||||||
|
both `ai_summary_enabled` and `ai_summary_regenerate_on_message` (off by
|
||||||
|
default) are on. That listener dispatches `App\Jobs\GenerateTicketAiSummaryJob`
|
||||||
|
via `::dispatchAfterResponse()` rather than the normal queue — deliberately
|
||||||
|
**not** `ShouldQueue`, since this deployment's queue worker is optional
|
||||||
|
infrastructure (see install.md) and anything pushed onto the `jobs` table
|
||||||
|
has no guarantee of ever being picked up; `dispatchAfterResponse()` instead
|
||||||
|
runs the job in-process right after the triggering HTTP/console response is
|
||||||
|
sent, needing no worker at all.
|
||||||
|
|
||||||
|
Its own interval (`ai:run-ticket-automation`) is admin-configurable the same
|
||||||
|
way the other 3 scheduled commands are — see "Configurable scheduled-command
|
||||||
|
intervals" above for the mechanism and a boot-time trap worth knowing about
|
||||||
|
before touching `routes/console.php` again.
|
||||||
|
|||||||
370
CHANGELOG.md
370
CHANGELOG.md
@@ -3,6 +3,376 @@
|
|||||||
All notable changes to this project are documented in this file. Format loosely
|
All notable changes to this project are documented in this file. Format loosely
|
||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## [1.5.1] - 2026-08-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Operator queue columns**: two new optional columns, ID (raw DB id) and
|
||||||
|
e-mail, alongside the existing set; visible columns can now also be
|
||||||
|
**reordered** with ↑/↓ arrows next to each entry in the "Kolumny" picker,
|
||||||
|
not just shown/hidden — order is remembered per operator the same way
|
||||||
|
visibility already was (`users.operator_queue_columns`).
|
||||||
|
- **SnipeIT client asset picker**: a second, category-level allow-list
|
||||||
|
(`snipeit_client_asset_category_ids`) alongside the existing subcategory
|
||||||
|
one — lets an admin cover every subcategory of a category in one click
|
||||||
|
instead of ticking each one individually. The two lists are additive.
|
||||||
|
- Client dashboard and operator ticket-detail page now remember which tab
|
||||||
|
(Bieżące/Archiwum, or whichever queue tab) was active, so "Wróć do listy"
|
||||||
|
returns to it instead of always resetting to the default.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Pagination controls** (operator queue, client dashboard) no longer
|
||||||
|
render as Laravel's stock gray Tailwind styling, which only reacted to the
|
||||||
|
browser/OS's `prefers-color-scheme` and stayed dark even when the app's own
|
||||||
|
light/dark toggle was set to light. They're now a themed override
|
||||||
|
(`resources/views/vendor/livewire/tailwind.blade.php` — pagination here
|
||||||
|
actually renders through Livewire's own pagination view, not Laravel's
|
||||||
|
default) styled with the app's own light/dark CSS variables, with visible
|
||||||
|
per-button background/border and a centered layout on mobile.
|
||||||
|
- **Login notice box** background/border no longer shift hue between light
|
||||||
|
and dark mode (previously derived from `--color-accent`, which differs per
|
||||||
|
theme) — it's now one fixed dark color in both themes, so admin-picked
|
||||||
|
text colors (e.g. white) stay legible regardless of the viewer's theme.
|
||||||
|
Login card widened (380px → 480px).
|
||||||
|
- **Client dashboard ticket list**: priority/status badges no longer wrap
|
||||||
|
onto their own left-aligned line below a long ticket subject on narrow
|
||||||
|
screens — they stay pinned to the right while the subject text wraps
|
||||||
|
within its own column instead.
|
||||||
|
- Timer bookkeeping (`resumeTimer()`/`stopTimer()`/etc.) no longer touches
|
||||||
|
`updated_at` — merely opening a ticket (or the background timer
|
||||||
|
starting/stopping) no longer counted as an update, which used to drown out
|
||||||
|
genuinely stale tickets in any list sorted by that column. The operator
|
||||||
|
queue and client dashboard both now default to sorting by creation date
|
||||||
|
instead for the same reason.
|
||||||
|
- Admin panel and SnipeIT integration config test coverage extended for the
|
||||||
|
new category-level allow-list.
|
||||||
|
|
||||||
|
## [1.5.0] - 2026-08-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Admin log viewer** (Admin > Logi) — browse `storage/logs/*.log` from the
|
||||||
|
admin panel without shell access to the container: a file picker (size +
|
||||||
|
last-modified, most recent first), level/free-text/entry-count filters, and
|
||||||
|
an optional 5s auto-refresh. Read-only, admin-only; reads only the tail of
|
||||||
|
large files to keep it fast.
|
||||||
|
- **"Bez kategorii" filter** in the operator queue's category dropdown —
|
||||||
|
isolates tickets with neither a category nor subcategory assigned (e.g. an
|
||||||
|
IMAP mailbox routed to nothing in particular), previously only reachable by
|
||||||
|
scanning the unfiltered list.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Hesk import tool** (`scripts/hesk-import/`) no longer auto-creates client
|
||||||
|
accounts for unrecognized requester e-mails — the servicedesk user base is
|
||||||
|
now treated as authoritative, so a Hesk ticket whose requester has no
|
||||||
|
matching account is skipped instead (reported at the end, with the list of
|
||||||
|
skipped e-mails). Hesk staff replies/notes and ticket ownership are now
|
||||||
|
linked to real operator/admin accounts when the staff e-mail matches one.
|
||||||
|
Two new backfill flags cover tickets imported before these existed:
|
||||||
|
`--assign-operators` (sets `assignee_id` from Hesk's ticket owner, never
|
||||||
|
overwriting a manual reassignment) and `--fix-closed-dates` (corrects a
|
||||||
|
closed ticket's date to Hesk's own `closedat` column instead of the
|
||||||
|
drifting `lastchange`, and adds the missing "Zamknięte" history entry).
|
||||||
|
Every newly imported ticket also records its source Hesk id
|
||||||
|
(`tickets.hesk_ticket_id`, unique) as a second, DB-level guard against
|
||||||
|
duplicate imports on top of the existing state file. See
|
||||||
|
`scripts/hesk-import/README.md` for details.
|
||||||
|
- Internal database cleanup: removed three columns confirmed unused against
|
||||||
|
live data (`users.remember_token`, `users.email_verified_at`,
|
||||||
|
`email_templates.trigger_label`); moved custom field values, AI
|
||||||
|
triage/summary state, and the linked Snipe-IT asset off the `tickets` row
|
||||||
|
into three dedicated one-to-one tables (`ticket_field_values`,
|
||||||
|
`ticket_ai_summaries`, `ticket_snipeit_assets`) — no visible behavior
|
||||||
|
change, but custom field values are now efficiently queryable instead of
|
||||||
|
living only in a JSON blob, and the `tickets` row itself is narrower; added
|
||||||
|
missing reverse indexes on 4 pivot tables (`team_subcategory`, `role_user`,
|
||||||
|
`team_user`, `custom_field_subcategory`); `tickets.source` and
|
||||||
|
`ticket_messages.source` now validate against a known set of values
|
||||||
|
instead of silently accepting any string.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The operator statistics dashboard's category/subcategory breakdown (and
|
||||||
|
its category filter) only counted tickets routed through a subcategory —
|
||||||
|
a ticket routed to a whole category with no subcategory (e.g. via an IMAP
|
||||||
|
mailbox routed to "całą kategorię") was silently excluded from those
|
||||||
|
charts and from filtering by that category. Now counted correctly, with
|
||||||
|
bare-category tickets shown as their own "(bez podkategorii)" row in the
|
||||||
|
subcategory breakdown.
|
||||||
|
- A JavaScript error (and stray background timers left running) could occur
|
||||||
|
when navigating away from a page with an active countdown/timer widget —
|
||||||
|
ticket/queue auto-refresh, the theme switcher, file-attachment
|
||||||
|
drag-and-drop. Most consequential in the operator ticket time tracker,
|
||||||
|
where it could throw console errors and momentarily break other UI
|
||||||
|
elements (e.g. dropdown menus) after leaving a ticket with the timer
|
||||||
|
running.
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-08-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Active Directory support for LDAP auth** (Admin > Integracje > "LDAP /
|
||||||
|
Active Directory") — a "Typ katalogu" dropdown switches between LLDAP/
|
||||||
|
OpenLDAP (the original, still the default) and Active Directory. AD uses a
|
||||||
|
different schema (no inetOrgPerson/posixAccount, a binary `objectGUID`
|
||||||
|
instead of `entryUUID`) and login attribute (`sAMAccountName`, not `uid`);
|
||||||
|
both are now auto-detected from the directory type instead of requiring
|
||||||
|
LLDAP's schema everywhere.
|
||||||
|
- **Operator client search** (Operator > Klienci) — a dedicated search page
|
||||||
|
(name or e-mail, any account, not just role=client) showing each match's
|
||||||
|
role badges and ticket count, linking straight into the queue pre-filtered
|
||||||
|
to that customer.
|
||||||
|
- **Paginated ticket lists** — the operator queue (50/page) and client
|
||||||
|
dashboard (20/page, current/archive tracked as separate pages so switching
|
||||||
|
tabs doesn't lose your place) no longer render every matching ticket at
|
||||||
|
once; sorting still happens over the full filtered result first.
|
||||||
|
- **Command-palette global search (Ctrl+K / Cmd+K)** — searches tickets from
|
||||||
|
anywhere in the app, scoped to what the searching user can actually see
|
||||||
|
(operators/admins search everything visible to them, clients only their
|
||||||
|
own). Supports Gmail-style operators — `od:` (reporter), `temat:`
|
||||||
|
(subject only), `treść:`/`tresc:` (message content only), `numer:`/`nr:`
|
||||||
|
(ticket number), combinable and AND'd together (`od:kacper
|
||||||
|
temat:drukarka`) — plain text with no operator still searches everything
|
||||||
|
as before. A "Szukaj" button in the operator/admin sidebars opens the same
|
||||||
|
dialog for anyone who doesn't know the shortcut.
|
||||||
|
- **Recently viewed tickets** (operator sidebar) — the last 6 tickets an
|
||||||
|
operator actually opened, most-recent first, re-bumped (not duplicated) on
|
||||||
|
a repeat visit.
|
||||||
|
- **Navbar redesign: panel switcher.** The old dropdown-only role switcher
|
||||||
|
(in the profile menu) and the plain "Panel Klienta/Operatora/Administratora"
|
||||||
|
text label are replaced by a single segmented control centered in the top
|
||||||
|
bar — Klient / Operator / Administrator — showing only the areas the
|
||||||
|
logged-in user actually holds, highlighting the current one, and sliding a
|
||||||
|
preview to whichever option is hovered before you click. Adapts to a
|
||||||
|
full-width row below the icons on narrow screens instead of overlapping
|
||||||
|
them.
|
||||||
|
- **Richer profile dropdown** — now shows the account's name, e-mail, and
|
||||||
|
role badges above the existing Powiadomienia/Wyloguj się links.
|
||||||
|
- **Per-page browser tab titles** — every page sets its own `<title>`
|
||||||
|
(e.g. the ticket subject, the selected queue, the active admin tab)
|
||||||
|
instead of every tab just showing the company name, always anchored with
|
||||||
|
the company name as a suffix so it's still identifiable once the browser
|
||||||
|
truncates a long tab title.
|
||||||
|
- **Hesk 3.x historical import** (`scripts/hesk-import/`) — a one-time,
|
||||||
|
read-only migration of tickets (with full reply/note history) from an old
|
||||||
|
Hesk helpdesk database, filtered by e-mail domain. Dry-run by default,
|
||||||
|
resumable, auto-maps categories (exact name match) and teams (when
|
||||||
|
unambiguous). See `scripts/hesk-import/README.md`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Pagination controls ("« Poprzednia" / "Następna »") were showing the raw
|
||||||
|
translation keys `pagination.previous`/`pagination.next` instead of
|
||||||
|
actual text — the app's `APP_LOCALE=pl` had no matching `lang/pl/`
|
||||||
|
translation file and no English fallback (`APP_FALLBACK_LOCALE` is also
|
||||||
|
`pl`), so Laravel had nothing to resolve those strings to.
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-07-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Snipe-IT asset inventory integration** (Admin > Integracje), optional and
|
||||||
|
off by default — connects to a Snipe-IT instance by API address + personal
|
||||||
|
API token (plus a "Nie sprawdzaj SSL" toggle for self-signed instances) and
|
||||||
|
surfaces three independently switchable capabilities:
|
||||||
|
- **Klient może wybrać sprzęt, którego dotyczy zgłoszenie** — while
|
||||||
|
creating a ticket, a client sees the devices checked out to them in
|
||||||
|
Snipe-IT (matched by e-mail) and can pick the one the ticket is about.
|
||||||
|
Scoped to admin-selected subcategories via a multi-select picker that
|
||||||
|
only appears once this is turned on — same "nothing shows until
|
||||||
|
explicitly opted in" convention as BookStack's shelf allow-lists.
|
||||||
|
- **Operator może zobaczyć sprzęt zgłaszającego w widoku zgłoszenia** — the
|
||||||
|
same per-requester asset list, shown in a sidebar card on the ticket
|
||||||
|
view, with a "Powiąż" button per item.
|
||||||
|
- **Zezwól operatorowi na przeszukiwanie całego inwentarza** — a search
|
||||||
|
box + button in the same sidebar (not a separate page) letting an
|
||||||
|
operator link any asset in Snipe-IT, not just the requester's own — for
|
||||||
|
shared equipment like printers.
|
||||||
|
- A linked asset shows live status/category/current assignment (fetched
|
||||||
|
fresh from Snipe-IT, not just the cached label) with an "Odepnij" button
|
||||||
|
that stays available to the operator regardless of the two toggles above
|
||||||
|
— clearing an existing link is a correction, not new Snipe-IT access.
|
||||||
|
Every asset is displayed as "numer środka - numer seryjny - producent
|
||||||
|
model" plus its Snipe-IT category, joining whichever of those pieces are
|
||||||
|
actually present.
|
||||||
|
- **AI summary: manual regenerate + regenerate on new message.** A
|
||||||
|
"Wygeneruj teraz" button now sits on the operator's "Podsumowanie AI" card
|
||||||
|
for an immediate, on-demand refresh. Separately, a new admin toggle
|
||||||
|
("Regeneruj podsumowanie od razu po każdej nowej wiadomości", off by
|
||||||
|
default) re-runs the summary right after any reply/note lands on a ticket,
|
||||||
|
instead of only ever picking it up on the next scheduled
|
||||||
|
`ai:run-ticket-automation` sweep. The transcript sent to the model now also
|
||||||
|
includes the ticket's own opening body text (previously only the reply
|
||||||
|
thread), fixing summaries silently missing the original request on long
|
||||||
|
tickets whose first message had scrolled out of the transcript window.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The status dropdown in the operator ticket view could keep showing the
|
||||||
|
pre-change status after sending a reply via a status-changing quick action
|
||||||
|
(e.g. "Wyślij i oznacz jako rozwiązane") until the next full page load — a
|
||||||
|
Livewire/Alpine-morph quirk for `<select>` elements bound via `wire:change`
|
||||||
|
rather than `wire:model`. Fixed by keying the element to the status value
|
||||||
|
so the DOM node is force-replaced instead of morphed.
|
||||||
|
|
||||||
|
## [1.2.2] - 2026-07-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Subcategories can now be reordered within their category — up/down arrow
|
||||||
|
buttons next to "Edytuj" in Admin > Kategorie, right beside each
|
||||||
|
subcategory's edit/delete buttons. The order set there is used everywhere a
|
||||||
|
subcategory list is shown (subcategory pickers, admin listings, etc.), not
|
||||||
|
just the admin panel itself.
|
||||||
|
|
||||||
|
## [1.2.1] - 2026-07-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Generic AI integration** (Admin > Integracje > "Integracja AI"), optional
|
||||||
|
and off by default — an OpenAI-compatible `/chat/completions` client (works
|
||||||
|
against Groq, OpenAI itself, or a self-hosted Ollama instance) configured by
|
||||||
|
base URL, optional API key, model name, and an SSL-verification toggle for
|
||||||
|
self-signed local endpoints. Not tied to any one feature — it's the shared
|
||||||
|
foundation for the two AI-driven features below, and for anything else that
|
||||||
|
wants an LLM call in the future.
|
||||||
|
- **BookStack automatic content tagging (AI)** — a "Otaguj nową treść"/"Otaguj
|
||||||
|
wszystko ponownie (force)" button pair in the BookStack card, plus
|
||||||
|
`php artisan bookstack:tag-content` (`--dry-run`/`--force`/`--limit=N`) for
|
||||||
|
the command line. Uses the AI integration above to classify every
|
||||||
|
book/chapter/page's title+content against the current list of helpdesk
|
||||||
|
subcategories and tags matching ones by name — idempotent by default
|
||||||
|
(skips already-tagged content), so re-running after adding a few pages is
|
||||||
|
cheap. This is what gives the BookStack "search by tags" option below
|
||||||
|
something to actually match against.
|
||||||
|
- **BookStack search refinement** — "Przeszukuj" is now three independent
|
||||||
|
checkboxes (Książki / Strony / Rozdziały) instead of a single dropdown with
|
||||||
|
no chapter option, plus a new "Szukaj po" setting: słowa kluczowe w nazwie /
|
||||||
|
tagi / oba. Tag matching uses the bare subcategory name (e.g. "Drukarki i
|
||||||
|
skanery"), matching what the auto-tagging feature above writes.
|
||||||
|
- **AI-driven ticket triage + summary** (Admin > Integracje >
|
||||||
|
"Automatyzacja AI dla zgłoszeń", runs via a new scheduled
|
||||||
|
`ai:run-ticket-automation`) — five independent toggles: assign a
|
||||||
|
category/subcategory when a ticket has neither, pick a subcategory when it
|
||||||
|
only has a category, recheck and possibly correct an already-categorized
|
||||||
|
ticket, rewrite an unclear subject, and set a priority based on content.
|
||||||
|
Runs once per ticket in the background (never synchronously at submission,
|
||||||
|
so it adds no latency for a client), and every applied change leaves a
|
||||||
|
specific line plus a "Automatyzacja: klasyfikacja AI" entry in the ticket's
|
||||||
|
history, same convention as the existing SLA automation rules. Separately,
|
||||||
|
an AI-generated summary + suggested next action for **every** ticket, shown
|
||||||
|
only to operators in a new "Podsumowanie AI" sidebar card, refreshed
|
||||||
|
whenever the thread gets a new message — its system prompt is admin-editable
|
||||||
|
as a plain-text field with a "Resetuj" button back to the shipped default.
|
||||||
|
- Operators can now reassign a ticket to **any** team, not just one they
|
||||||
|
belong to (previously the dropdown only ever offered the operator's own
|
||||||
|
teams).
|
||||||
|
- The auto-refresh countdown badges (ticket view, operator queue) are now
|
||||||
|
**clickable** — fetch immediately and reset the countdown, instead of only
|
||||||
|
ever refreshing on their own fixed schedule.
|
||||||
|
- **All 7 "cyclical" intervals** in the app are now configurable from
|
||||||
|
Admin > Konfiguracja instead of fixed in code: the 3 browser auto-refresh
|
||||||
|
countdowns (ticket view, operator queue), the notification bell's poll,
|
||||||
|
and the 4 background scheduled commands (SLA breach check, SLA automation
|
||||||
|
rules, IMAP fetch, AI ticket automation). Defaults match the previous
|
||||||
|
hardcoded values, so nothing changes until an admin edits them.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- An operator viewing a ticket that gets deleted by someone else, or whose
|
||||||
|
team changes to one outside the operator's own scope (including via their
|
||||||
|
own reassignment above), is now redirected back to the operator queue
|
||||||
|
instead of hitting an error mid-session.
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-07-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **E-mail intake (IMAP)**, optional and off by default — clients can create a
|
||||||
|
ticket or reply to an existing one just by sending/replying to an e-mail.
|
||||||
|
Configure any number of mailboxes in the new **Admin > Poczta** page (which
|
||||||
|
now hosts SMTP alongside IMAP, replacing the old "E-MAIL" tab), each with
|
||||||
|
its own host/port/encryption/credentials/folder and routed to either a
|
||||||
|
specific subcategory (routes to that subcategory's team, same as a web
|
||||||
|
ticket) or a whole category with no subcategory (a new `tickets.category_id`
|
||||||
|
column covers this case — previously a ticket's category only ever came
|
||||||
|
through a subcategory).
|
||||||
|
- A reply is matched back to its ticket via the number/checksum already
|
||||||
|
present in every notification e-mail's subject — works with either the
|
||||||
|
plain sequential number or the obfuscated checksum, whichever numbering
|
||||||
|
mode is active, no changes to outbound templates needed.
|
||||||
|
- Automatic replies (autoresponders, "out of office", bounces/mailer-daemon)
|
||||||
|
are detected via headers and common EN/PL subject phrasing and rejected
|
||||||
|
instead of creating a ticket; a per-mailbox sender blocklist covers the
|
||||||
|
rest. The "tylko użytkownicy z LDAP" restriction is enforced for e-mail
|
||||||
|
exactly like the guest web form.
|
||||||
|
- A "Pobierz teraz" button per mailbox fetches immediately, outside the
|
||||||
|
5-minute schedule — useful for testing a freshly-configured mailbox or
|
||||||
|
diagnosing why a specific e-mail didn't turn into a ticket.
|
||||||
|
- Every connection attempt and per-message decision (accepted/rejected/
|
||||||
|
matched to which ticket) is logged to a dedicated `storage/logs/imap-*.log`
|
||||||
|
file, independent of the app's own log level.
|
||||||
|
- Tickets and individual messages that came in by e-mail show a small
|
||||||
|
mail-icon badge in the operator queue and ticket view, distinguishing them
|
||||||
|
from ones created/replied to on the web.
|
||||||
|
- **Operator queue**: a "select all" checkbox in the table header
|
||||||
|
selects/deselects every ticket currently visible under the active
|
||||||
|
filter/tab in one click, instead of clicking each row's checkbox.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Admin's old **"E-MAIL"** tab is now **"Poczta"** and also lists/manages the
|
||||||
|
IMAP mailboxes above — the two halves of "reply by e-mail" (send/receive)
|
||||||
|
now live together instead of SMTP being off on its own.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Scheduled-command notifications were silently going nowhere.**
|
||||||
|
`AppServiceProvider`'s Settings-based config override (SMTP/LDAP/session/
|
||||||
|
timezone) used to skip itself for *any* console command, not just
|
||||||
|
`migrate` — meaning `tickets:check-sla-breaches` and `automation:run-rules`
|
||||||
|
(and now `emails:fetch-imap`) always sent their e-mails through whatever
|
||||||
|
`.env`'s `MAIL_MAILER` happened to be (`log`, i.e. nowhere) instead of the
|
||||||
|
admin-configured SMTP server, with no visible error. Now scoped to just the
|
||||||
|
`migrate` command family, so every scheduled command gets the same live
|
||||||
|
config a web request would.
|
||||||
|
- Visiting a ticket that no longer exists (most commonly: it was deleted
|
||||||
|
while the viewer had it open, and a later background refresh hit the same
|
||||||
|
URL) no longer shows Laravel's default 404 page — redirects back to the
|
||||||
|
operator queue or client dashboard instead.
|
||||||
|
- This host had no crontab entry at all for `php artisan schedule:run` —
|
||||||
|
meaning SLA breach checks and automation rules had never actually run on
|
||||||
|
their own, only ever on request. Documented and configured (see
|
||||||
|
[CLAUDE.md](CLAUDE.md)).
|
||||||
|
|
||||||
|
## [1.1.4] - 2026-07-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Configurable ticket numbering** (Admin > Konfiguracja > Ogólne) — an
|
||||||
|
admin-set prefix (default `#`) and a minimum zero-padded length for the
|
||||||
|
ticket number.
|
||||||
|
- **"Ukryj kolejność zgłoszeń"** — an opt-in mode that displays a stable,
|
||||||
|
HMAC-derived checksum instead of the sequential ticket number, so the
|
||||||
|
number shown gives no indication of ticket volume or creation order. Every
|
||||||
|
ticket gets its checksum assigned once, on creation, guaranteed unique.
|
||||||
|
When this mode is on, ticket URLs switch to the same checksum too (custom
|
||||||
|
`Ticket::getRouteKey()`/`resolveRouteBinding()`), so a link and the number
|
||||||
|
on the page it points to always match — and a URL built under the other
|
||||||
|
mode stops resolving. The REST API is unaffected; it's pinned to `id`
|
||||||
|
regardless of this setting. Search (queue/dashboard) now also matches
|
||||||
|
against the checksum. A live preview against a real ticket from the
|
||||||
|
database shows exactly how the number will look before saving.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Attachments**: dropped the inline image thumbnail preview in the message
|
||||||
|
thread — every attachment (images included) now shows as just its
|
||||||
|
filename, opening in a new tab on click, consistent with how non-image
|
||||||
|
attachments already worked.
|
||||||
|
|
||||||
## [1.1.3] - 2026-07-22
|
## [1.1.3] - 2026-07-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
73
CLAUDE.md
73
CLAUDE.md
@@ -21,7 +21,12 @@ roles). Treat the running database as production, not a sandbox:
|
|||||||
## Container operations: use `sudo`, never build the image locally
|
## Container operations: use `sudo`, never build the image locally
|
||||||
|
|
||||||
All Docker commands against this stack need `sudo` (e.g.
|
All Docker commands against this stack need `sudo` (e.g.
|
||||||
`sudo docker compose exec servicedesk ...`, `sudo docker exec servicedesk-servicedesk-1 ...`).
|
`sudo docker compose exec app ...`, `sudo docker exec servicedesk-app-1 ...`).
|
||||||
|
|
||||||
|
The stack is four services sharing the one `servicedesk` image — `app` (Apache,
|
||||||
|
what actually serves HTTP), `reverb` (websocket server, `php artisan
|
||||||
|
reverb:start`), `cron` (scheduler loop, `php artisan schedule:work` — see
|
||||||
|
below), and `mariadb`. Only `app` and `reverb` are reachable from Traefik.
|
||||||
|
|
||||||
**Never run `docker build`, `docker compose build`, or `--build`.** The
|
**Never run `docker build`, `docker compose build`, or `--build`.** The
|
||||||
`servicedesk` image is built by CI (`.gitea/workflows/build.yml`, triggered on
|
`servicedesk` image is built by CI (`.gitea/workflows/build.yml`, triggered on
|
||||||
@@ -29,7 +34,7 @@ All Docker commands against this stack need `sudo` (e.g.
|
|||||||
`compose.yaml` only ever `pull`s a tag (`sudo docker compose pull && sudo docker
|
`compose.yaml` only ever `pull`s a tag (`sudo docker compose pull && sudo docker
|
||||||
compose up -d`, see [install.md](install.md) 1.3/1.3a) — building locally would
|
compose up -d`, see [install.md](install.md) 1.3/1.3a) — building locally would
|
||||||
just diverge from what CI produces. The app container
|
just diverge from what CI produces. The app container
|
||||||
(`servicedesk-servicedesk-1`) mounts `./src` from the host over NFS
|
(`servicedesk-app-1`) mounts `./src` from the host over NFS
|
||||||
(`/mnt/rabbit-containers` → NFS export), so plain file edits already take effect
|
(`/mnt/rabbit-containers` → NFS export), so plain file edits already take effect
|
||||||
with no rebuild or restart:
|
with no rebuild or restart:
|
||||||
|
|
||||||
@@ -56,10 +61,54 @@ with no rebuild or restart:
|
|||||||
surfaces in production as a 500 with `touch(): Utime failed: Operation not
|
surfaces in production as a 500 with `touch(): Utime failed: Operation not
|
||||||
permitted`. If you ran `php artisan test`/`tinker`/any artisan command via
|
permitted`. If you ran `php artisan test`/`tinker`/any artisan command via
|
||||||
`docker exec` in a session where you also edited Blade files afterward,
|
`docker exec` in a session where you also edited Blade files afterward,
|
||||||
finish with `sudo docker exec servicedesk-servicedesk-1 php artisan
|
finish with `sudo docker exec servicedesk-app-1 php artisan
|
||||||
view:clear` to flush any root-owned compiled views before ending the
|
view:clear` to flush any root-owned compiled views before ending the
|
||||||
session — don't wait for a report of a broken page to catch it.
|
session — don't wait for a report of a broken page to catch it.
|
||||||
|
|
||||||
|
## Scheduled commands run in the dedicated `cron` container
|
||||||
|
|
||||||
|
The Docker image ships no cron/supervisor of its own (see [install.md](install.md)),
|
||||||
|
so `tickets:check-sla-breaches`, `automation:run-rules`, `emails:fetch-imap`,
|
||||||
|
and `ai:run-ticket-automation` (all registered in `routes/console.php` via
|
||||||
|
`Schedule::command(...)`) only ever run if something calls `php artisan
|
||||||
|
schedule:run` on a timer. **As of 2026-08-04 this is the `cron` service** in
|
||||||
|
`compose.yaml` — same `servicedesk` image, running `php artisan schedule:work`
|
||||||
|
(Laravel's own foreground scheduler loop, ticks every minute internally, no
|
||||||
|
external trigger needed). Before this it was a root crontab entry on the host
|
||||||
|
calling `docker compose exec -T servicedesk schedule:run`; that entry has been
|
||||||
|
removed from `sudo crontab -l -u root` now that the container replaces it —
|
||||||
|
don't re-add it, the two would double-run every scheduled command.
|
||||||
|
|
||||||
|
If the `cron` container isn't running (`sudo docker compose ps cron`), none of
|
||||||
|
the four scheduled commands fire — same failure mode as the old missing-crontab
|
||||||
|
case, just check the container instead of the crontab. IMAP-specific activity
|
||||||
|
(connect attempts, per-message accept/reject decisions, created/replied ticket
|
||||||
|
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. `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`/
|
||||||
|
`schedule_imap_fetch_minutes`/`schedule_ai_automation_minutes`), which is why
|
||||||
|
`routes/console.php` registers them as `->everyMinute()->when(fn () =>
|
||||||
|
Settings::dueEveryMinutes(...))` instead of a plain `->everyFifteenMinutes()`/
|
||||||
|
`->cron(...)` call — **never build a cron expression (or otherwise read
|
||||||
|
`Settings`) at that file's top level**. `routes/console.php` is `require`'d on
|
||||||
|
every artisan boot (`migrate`, `tinker`, `php artisan test`, not just
|
||||||
|
`schedule:run` — it's wired in via `bootstrap/app.php`'s `commands:` key), so
|
||||||
|
a top-level `Settings::get(...)` call runs before a fresh/test database
|
||||||
|
necessarily has the `settings` table, and crashes every single artisan
|
||||||
|
invocation, not just the scheduler. A `->when($closure)` guard is the fix —
|
||||||
|
the closure is only ever evaluated later, when `schedule:run` processes due
|
||||||
|
events. One visible side effect: `php artisan schedule:list` shows
|
||||||
|
`* * * * *` for all four regardless of the actual configured interval, since
|
||||||
|
that only exists inside the closure — expected, not a bug worth chasing.
|
||||||
|
|
||||||
## Apache `/icons/` alias trap
|
## Apache `/icons/` alias trap
|
||||||
|
|
||||||
The stock `php:apache` image enables `mods-enabled/alias.conf`, which defines
|
The stock `php:apache` image enables `mods-enabled/alias.conf`, which defines
|
||||||
@@ -82,6 +131,24 @@ reason — never add a `public/icons/` directory.
|
|||||||
attributes on `<td>`s. Currently applied to the operator ticket queue; apply
|
attributes on `<td>`s. Currently applied to the operator ticket queue; apply
|
||||||
the same treatment to any other wide table you add or make mobile-relevant
|
the same treatment to any other wide table you add or make mobile-relevant
|
||||||
(admin panel tables don't have it yet).
|
(admin panel tables don't have it yet).
|
||||||
|
- **Ticket list default sort** is `created_at` (or the raw `id`, which is
|
||||||
|
equivalent — both are monotonic) descending, everywhere a ticket list is
|
||||||
|
shown: the operator queue (`App\Livewire\Operator\Queue::$sortBy`, default
|
||||||
|
`'created'`) and both tabs of the client dashboard
|
||||||
|
(`App\Livewire\Client\Dashboard::baseQuery()`, `orderByDesc('created_at')`).
|
||||||
|
Deliberately not `updated_at` — see the timer/`updated_at` note in
|
||||||
|
[ARCHITECTURE.md](ARCHITECTURE.md#data-model); even with that fixed, sorting
|
||||||
|
by "last touched" is a worse default for a support queue than a stable
|
||||||
|
creation order. Keep any new ticket list consistent with this rather than
|
||||||
|
defaulting to `updated_at`.
|
||||||
|
- **Per-user table customization** (shown/hidden columns + their order): the
|
||||||
|
operator queue's pattern (`Queue::$visibleColumns`, persisted to
|
||||||
|
`users.operator_queue_columns` via `persistVisibleColumns()`, reordered with
|
||||||
|
`moveColumnUp()`/`moveColumnDown()`) is the template to reuse if another
|
||||||
|
table gains the same feature — auto-save on every toggle/reorder, no
|
||||||
|
explicit "save" step required from the user. This is intentionally separate
|
||||||
|
from `SavedQueueView` (named, manually-saved, multi-field filter presets);
|
||||||
|
don't conflate the two.
|
||||||
|
|
||||||
## Testing & code style
|
## Testing & code style
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ build through a throwaway `node:22` container as documented there.
|
|||||||
|
|
||||||
1. **Run the test suite** — see [TESTING.md](TESTING.md) for details:
|
1. **Run the test suite** — see [TESTING.md](TESTING.md) for details:
|
||||||
```bash
|
```bash
|
||||||
docker compose exec servicedesk php artisan test
|
docker compose exec app php artisan test
|
||||||
```
|
```
|
||||||
2. **Run Pint** (Laravel's code-style fixer, default preset, no project overrides):
|
2. **Run Pint** (Laravel's code-style fixer, default preset, no project overrides):
|
||||||
```bash
|
```bash
|
||||||
docker compose exec servicedesk ./vendor/bin/pint
|
docker compose exec app ./vendor/bin/pint
|
||||||
```
|
```
|
||||||
3. If you changed anything under `resources/`, rebuild the frontend bundle and
|
3. If you changed anything under `resources/`, rebuild the frontend bundle and
|
||||||
commit the result if `public/build/` is tracked, or confirm the deploy step
|
commit the result if `public/build/` is tracked, or confirm the deploy step
|
||||||
|
|||||||
149
README.md
149
README.md
@@ -23,9 +23,25 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
|
|||||||
|
|
||||||
## Feature overview
|
## Feature overview
|
||||||
|
|
||||||
|
- **Navigation** — a segmented Klient/Operator/Administrator switcher in the
|
||||||
|
top bar (only the areas a user actually holds, current one highlighted,
|
||||||
|
hover-previews the destination before you click) replaces the old dropdown
|
||||||
|
role switcher; the profile menu shows name/e-mail/role badges above
|
||||||
|
Powiadomienia/Wyloguj się; every page sets its own browser-tab title
|
||||||
|
(ticket subject, selected queue, active admin tab, ...) anchored with the
|
||||||
|
company name; and a command-palette global search (Ctrl+K/Cmd+K, or a
|
||||||
|
"Szukaj" sidebar button) finds tickets from anywhere, scoped to what the
|
||||||
|
searching user can see, with Gmail-style `od:`/`temat:`/`treść:`/`numer:`
|
||||||
|
operators. The operator sidebar also lists the last 6 tickets they
|
||||||
|
actually opened ("Ostatnio przeglądane").
|
||||||
- **Tickets** — number, subject, body, category/subcategory, status, priority, team,
|
- **Tickets** — number, subject, body, category/subcategory, status, priority, team,
|
||||||
assignee, custom fields (per subcategory), attachments, full message thread
|
assignee, custom fields (per subcategory), attachments, full message thread
|
||||||
(public replies + internal notes), history log, merge, delete.
|
(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,
|
||||||
|
and both default to newest-created-first. The operator queue's columns
|
||||||
|
(including the raw DB id, e-mail, source, last-updated, ...) can be
|
||||||
|
individually shown/hidden and reordered, remembered per operator.
|
||||||
- **SLA** — per-priority response/resolution time targets; a scheduled command
|
- **SLA** — per-priority response/resolution time targets; a scheduled command
|
||||||
(`tickets:check-sla-breaches`, every 15 min) flags overdue tickets and can notify
|
(`tickets:check-sla-breaches`, every 15 min) flags overdue tickets and can notify
|
||||||
the assigned operator.
|
the assigned operator.
|
||||||
@@ -40,13 +56,23 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
|
|||||||
(operator and client) update live over WebSockets (Laravel Reverb): new
|
(operator and client) update live over WebSockets (Laravel Reverb): new
|
||||||
tickets, status/priority/team/assignee changes, and new replies ("live chat")
|
tickets, status/priority/team/assignee changes, and new replies ("live chat")
|
||||||
all show up without a manual refresh. A periodic fallback refresh (with a
|
all show up without a manual refresh. A periodic fallback refresh (with a
|
||||||
visible countdown) covers a dropped websocket connection.
|
visible, clickable countdown badge — click it to fetch immediately and reset
|
||||||
|
the countdown) covers a dropped websocket connection. All of the refresh/poll
|
||||||
|
intervals in the app, browser-side and the background scheduled commands
|
||||||
|
alike, are configurable from Admin > Konfiguracja (see below).
|
||||||
- **Categories & custom fields** — admin-defined categories/subcategories, each with
|
- **Categories & custom fields** — admin-defined categories/subcategories, each with
|
||||||
its own set of custom fields (text/textarea/select/checkbox/date/number) and an
|
its own set of custom fields (text/textarea/select/checkbox/date/number) and an
|
||||||
optional default priority.
|
optional default priority. Subcategories within a category can be reordered with
|
||||||
|
up/down arrows in Admin > Kategorie; the order set there is what clients/operators
|
||||||
|
see everywhere a subcategory picker is shown.
|
||||||
- **Teams** — subcategories auto-route to a team; operators only see their own
|
- **Teams** — subcategories auto-route to a team; operators only see their own
|
||||||
team's queue (plus unrouted tickets and anything assigned to them) unless they're
|
team's queue (plus unrouted tickets and anything assigned to them) unless they're
|
||||||
an admin.
|
an admin. Reassigning a ticket to a team, though, is unrestricted — an operator
|
||||||
|
can route a ticket to any team, not just one they belong to.
|
||||||
|
- **Client search** (Operator > Klienci) — find any account by name or e-mail
|
||||||
|
(not just role=client — a ticket's customer can be any user), see its role
|
||||||
|
badges and ticket count, and jump straight into the queue pre-filtered to
|
||||||
|
that customer.
|
||||||
- **Templates** — canned response snippets for the reply box, admin-configurable
|
- **Templates** — canned response snippets for the reply box, admin-configurable
|
||||||
"quick actions" (send + transition status in one click), and HTML e-mail
|
"quick actions" (send + transition status in one click), and HTML e-mail
|
||||||
templates for every ticket lifecycle event (created, status/priority/category/
|
templates for every ticket lifecycle event (created, status/priority/category/
|
||||||
@@ -63,9 +89,16 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
|
|||||||
e-mail layout/footer, SMTP connection (Admin > E-MAIL), attachment limits,
|
e-mail layout/footer, SMTP connection (Admin > E-MAIL), attachment limits,
|
||||||
session lifetime, timezone (Admin > Konfiguracja), and LDAP connection + user
|
session lifetime, timezone (Admin > Konfiguracja), and LDAP connection + user
|
||||||
sync + BookStack (Admin > Integracje).
|
sync + BookStack (Admin > Integracje).
|
||||||
- **LDAP auth** — logins bind against an LDAP/LLDAP directory (`config/auth.php`,
|
- **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
|
`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.
|
to e-mail + local password when the LDAP bind doesn't match. A "Typ katalogu"
|
||||||
|
toggle (Admin > Integracje) switches between LLDAP/OpenLDAP (default) and
|
||||||
|
Active Directory, which auto-selects the right schema/login attribute
|
||||||
|
(`sAMAccountName` + `objectGUID` for AD, vs. `uid` + `entryUUID`).
|
||||||
- **Triggers** (Admin > Wyzwalacze) — event-driven business rules that fire
|
- **Triggers** (Admin > Wyzwalacze) — event-driven business rules that fire
|
||||||
immediately on a ticket lifecycle event (created, any field updated, status/
|
immediately on a ticket lifecycle event (created, any field updated, status/
|
||||||
priority/assignee/team/category changed, new public reply): AND-combined
|
priority/assignee/team/category changed, new public reply): AND-combined
|
||||||
@@ -96,8 +129,27 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
|
|||||||
while the tab is open (see per-user notification preferences above).
|
while the tab is open (see per-user notification preferences above).
|
||||||
Includes a dedicated trigger notifying every operator on a team whose
|
Includes a dedicated trigger notifying every operator on a team whose
|
||||||
subcategories match a newly created ticket.
|
subcategories match a newly created ticket.
|
||||||
- **Attachments** — drag-and-drop upload (in addition to the file picker) and
|
- **Attachments** — drag-and-drop upload (in addition to the file picker); every
|
||||||
inline image thumbnails in the message thread instead of a plain download link.
|
attachment shows in the message thread as just its filename, opening in a new
|
||||||
|
tab on click (no inline image preview).
|
||||||
|
- **E-mail intake (IMAP)** *(optional, off by default)* — clients can create
|
||||||
|
tickets or reply to an existing one just by sending/replying to an e-mail;
|
||||||
|
configure any number of mailboxes in Admin > Poczta (e.g. one address per
|
||||||
|
team), each routed to a specific subcategory or a whole category. A reply
|
||||||
|
is matched back to its ticket via the number/checksum already present in
|
||||||
|
every notification's subject; automatic replies (autoresponders, bounces)
|
||||||
|
are detected and rejected instead of creating junk tickets, and the
|
||||||
|
"restrict tickets to LDAP" setting is enforced for e-mail exactly like the
|
||||||
|
guest web form. A manual "Pobierz teraz" button fetches immediately
|
||||||
|
outside the 5-minute schedule; all activity is logged separately to
|
||||||
|
`storage/logs/imap-*.log`. Tickets/messages that came in by e-mail show a
|
||||||
|
small mail-icon badge in the operator queue and ticket view.
|
||||||
|
- **Configurable ticket numbering** (Admin > Konfiguracja) — a custom prefix and
|
||||||
|
minimum zero-padded length for the ticket number, plus an optional "hide
|
||||||
|
ticket order" mode that displays a stable per-ticket checksum instead of the
|
||||||
|
sequential number. When enabled, ticket URLs switch to the same checksum too,
|
||||||
|
so the number in the link always matches the one on the page; the REST API is
|
||||||
|
unaffected and always addresses tickets by `id`.
|
||||||
- **Customer satisfaction (CSAT)** — clients rate a ticket 1–5 stars (+ optional
|
- **Customer satisfaction (CSAT)** — clients rate a ticket 1–5 stars (+ optional
|
||||||
comment) once it's closed; average/response-rate surfaced as a KPI on the
|
comment) once it's closed; average/response-rate surfaced as a KPI on the
|
||||||
operator stats dashboard, with a link in the "ticket closed" e-mail.
|
operator stats dashboard, with a link in the "ticket closed" e-mail.
|
||||||
@@ -114,28 +166,75 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
|
|||||||
both operators and clients (with a copy-link button for operators). Loads in
|
both operators and clients (with a copy-link button for operators). Loads in
|
||||||
after the page's first paint rather than blocking it. Configured entirely
|
after the page's first paint rather than blocking it. Configured entirely
|
||||||
from Admin > Integracje: connection + API token, optional SSL-verification
|
from Admin > Integracje: connection + API token, optional SSL-verification
|
||||||
bypass for self-signed instances, page/book search-type filter, and two
|
bypass for self-signed instances, a content-type filter (books/pages/
|
||||||
independent per-shelf allow-lists (nothing is searched until an admin opts
|
chapters, independently toggleable) and a "search by" mode (name / tags /
|
||||||
specific shelves in, separately for ticket-creation suggestions vs. the
|
both), and two independent per-shelf allow-lists (nothing is searched until
|
||||||
operator/client ticket-view sidebar).
|
an admin opts specific shelves in, separately for ticket-creation
|
||||||
|
suggestions vs. the operator/client ticket-view sidebar). A pair of
|
||||||
|
"Otaguj nową treść"/"Otaguj wszystko ponownie" buttons (also available as
|
||||||
|
`php artisan bookstack:tag-content`) use the AI integration below to
|
||||||
|
auto-tag every book/chapter/page with matching subcategory names, so the
|
||||||
|
tag-based search mode has something to find.
|
||||||
|
- **Generic AI integration** (Admin > Integracje > "Integracja AI") *(optional,
|
||||||
|
off by default)* — an OpenAI-compatible chat-completions client (Groq,
|
||||||
|
OpenAI, or a self-hosted Ollama instance) configured by base URL, optional
|
||||||
|
API key, model, and an SSL-verification toggle. Not tied to a single
|
||||||
|
feature — it backs the BookStack auto-tagging above and the AI ticket
|
||||||
|
triage/summary below, and is meant to be reused by anything that needs an
|
||||||
|
LLM call in the future.
|
||||||
|
- **AI-driven ticket triage + summary** (Admin > Integracje >
|
||||||
|
"Automatyzacja AI dla zgłoszeń") *(optional, off by default, requires the AI
|
||||||
|
integration above)* — five independent toggles run once per new ticket, in
|
||||||
|
the background (`ai:run-ticket-automation`, never synchronously at
|
||||||
|
submission): assign a category/subcategory when missing, pick a
|
||||||
|
subcategory when only a category is set, recheck/correct an
|
||||||
|
already-categorized ticket, rewrite an unclear subject, and set a priority
|
||||||
|
from the ticket's content. Every applied change is logged in the ticket's
|
||||||
|
history. Separately, an AI-generated summary + suggested next action for
|
||||||
|
every ticket, shown to operators only in a "Podsumowanie AI" sidebar card
|
||||||
|
with a manual "Wygeneruj teraz" button, an admin-editable prompt
|
||||||
|
(reset-to-default button included), and a per-transcript excerpt of the
|
||||||
|
ticket's own opening body alongside the reply thread (so long tickets
|
||||||
|
don't lose the original request once it scrolls out of the message
|
||||||
|
window). Refreshed by the same periodic sweep by default; an optional
|
||||||
|
admin toggle regenerates it immediately after every new reply/note
|
||||||
|
instead of waiting for the next scheduled run.
|
||||||
|
- **Snipe-IT asset inventory integration** *(optional, off by default)* —
|
||||||
|
connects to a Snipe-IT instance (API address + personal API token, plus an
|
||||||
|
SSL-verification bypass for self-signed instances) and adds three
|
||||||
|
independently toggleable capabilities from Admin > Integracje: a client
|
||||||
|
can pick which of their own Snipe-IT assets a ticket concerns while
|
||||||
|
creating it (scoped to admin-selected subcategories, empty selection means
|
||||||
|
it never shows — same convention as BookStack's shelf allow-lists), an
|
||||||
|
operator sees the requester's own assets in a ticket-view sidebar card,
|
||||||
|
and an operator can search the entire Snipe-IT inventory from that same
|
||||||
|
sidebar (not a separate page) to link shared equipment the requester isn't
|
||||||
|
the current owner of. Every asset is shown as "numer środka - numer
|
||||||
|
seryjny - producent model" plus its Snipe-IT category; a linked asset's
|
||||||
|
live status/assignment is fetched fresh on the ticket page, and unlinking
|
||||||
|
stays available to an operator even if both view/search toggles are later
|
||||||
|
turned off.
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
- **Backend**: Laravel, Livewire (server-driven UI, no SPA build beyond Tailwind/Vite
|
- **Backend**: Laravel, Livewire (server-driven UI, no SPA build beyond Tailwind/Vite
|
||||||
for CSS), LdapRecord for directory auth, Sanctum for API tokens, L5-Swagger for
|
for CSS), LdapRecord for directory auth, Sanctum for API tokens, L5-Swagger for
|
||||||
API docs, Laravel Reverb for WebSocket broadcasting (real-time queue/chat
|
API docs, Laravel Reverb for WebSocket broadcasting (real-time queue/chat
|
||||||
updates — see [ARCHITECTURE.md](ARCHITECTURE.md)).
|
updates — see [ARCHITECTURE.md](ARCHITECTURE.md)), webklex/php-imap for the
|
||||||
|
optional e-mail intake fetcher (pure-PHP IMAP client, no `ext-imap` needed).
|
||||||
- **Frontend**: Blade + Livewire + a little Alpine.js for local UI state; Tailwind
|
- **Frontend**: Blade + Livewire + a little Alpine.js for local UI state; Tailwind
|
||||||
v4 via Vite for `resources/css/app.css`; Laravel Echo + Pusher-protocol client
|
v4 via Vite for `resources/css/app.css`; Laravel Echo + Pusher-protocol client
|
||||||
(`resources/js/echo.js`) for Reverb. No JS charting library — the statistics
|
(`resources/js/echo.js`) for Reverb. No JS charting library — the statistics
|
||||||
dashboard is hand-rolled inline-styled bar/column charts, so it needs no client
|
dashboard is hand-rolled inline-styled bar/column charts, so it needs no client
|
||||||
build step beyond the CSS bundle.
|
build step beyond the CSS bundle.
|
||||||
- **Database**: MariaDB.
|
- **Database**: MariaDB.
|
||||||
- **Deployment**: `compose.yaml` — `servicedesk` (source bind-mounted from `./src`,
|
- **Deployment**: `compose.yaml` — `app` (source bind-mounted from `./src`,
|
||||||
no image rebuild needed for PHP/Blade/route changes) + `mariadb` + `reverb`
|
no image rebuild needed for PHP/Blade/route changes) + `mariadb` + `reverb`
|
||||||
(same image, `php artisan reverb:start`), fronted by Traefik with a private-CA
|
(same image, `php artisan reverb:start`) + `cron` (same image, `php artisan
|
||||||
TLS cert (the websocket path is routed to `reverb` by a higher-priority
|
schedule:work` — runs the scheduled commands below without needing a host
|
||||||
Traefik rule; everything else goes to `servicedesk`). The `servicedesk` image
|
crontab), fronted by Traefik with a private-CA TLS cert (the websocket path
|
||||||
|
is routed to `reverb` by a higher-priority Traefik rule; everything else
|
||||||
|
goes to `app`). The `servicedesk` image
|
||||||
itself is built and pushed by Gitea Actions (`.gitea/workflows/build.yml`) to
|
itself is built and pushed by Gitea Actions (`.gitea/workflows/build.yml`) to
|
||||||
the Gitea container registry whenever `Dockerfile` changes — `compose.yaml`
|
the Gitea container registry whenever `Dockerfile` changes — `compose.yaml`
|
||||||
just pulls a tag, it never builds locally.
|
just pulls a tag, it never builds locally.
|
||||||
@@ -157,7 +256,7 @@ Compose-level and Laravel-level) and the LDAP/SMTP gotcha after a fresh seed.
|
|||||||
```
|
```
|
||||||
- Fresh install / reset:
|
- Fresh install / reset:
|
||||||
```bash
|
```bash
|
||||||
sudo docker exec servicedesk-servicedesk-1 php artisan migrate:fresh --seed
|
sudo docker exec servicedesk-app-1 php artisan migrate:fresh --seed
|
||||||
```
|
```
|
||||||
Seeds real reference data (categories, custom fields, statuses/priorities/SLA,
|
Seeds real reference data (categories, custom fields, statuses/priorities/SLA,
|
||||||
teams, quick actions, response/e-mail templates, branding/config with example
|
teams, quick actions, response/e-mail templates, branding/config with example
|
||||||
@@ -172,9 +271,15 @@ src/ Laravel application
|
|||||||
app/Livewire/ Client/Operator/Admin Livewire components
|
app/Livewire/ Client/Operator/Admin Livewire components
|
||||||
app/Models/ Eloquent models
|
app/Models/ Eloquent models
|
||||||
app/Events/ Broadcast events (TicketQueueChanged, TicketMessagePosted)
|
app/Events/ Broadcast events (TicketQueueChanged, TicketMessagePosted)
|
||||||
app/Console/Commands/ Scheduled commands (SLA breach check, automation rules)
|
app/Console/Commands/ Scheduled commands (SLA breach check, automation rules, IMAP fetch,
|
||||||
app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient
|
AI ticket triage/summary) + bookstack:tag-content + hesk:import
|
||||||
app/Ldap/ LDAP user model + sync handlers
|
(one-time historical migration, see scripts/hesk-import/)
|
||||||
|
app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient,
|
||||||
|
ImapMailboxFetcher (I/O) + ImapMessageClassifier (pure logic),
|
||||||
|
AiClient (generic LLM client), BookStackContentTagger,
|
||||||
|
TicketAiTriageService, TicketAiSummaryService, SnipeItClient
|
||||||
|
app/Ldap/ LDAP user models — LldapUser (LLDAP/OpenLDAP, default) and
|
||||||
|
AdUser (Active Directory) — plus sync handlers
|
||||||
database/migrations/ Schema (one file per table group, final shape)
|
database/migrations/ Schema (one file per table group, final shape)
|
||||||
database/seeders/ DatabaseSeeder — reference data, no ticket data
|
database/seeders/ DatabaseSeeder — reference data, no ticket data
|
||||||
resources/css/ Tailwind entrypoint (needs `npm run build` after edits)
|
resources/css/ Tailwind entrypoint (needs `npm run build` after edits)
|
||||||
@@ -187,4 +292,6 @@ wiki/
|
|||||||
client/ How-to guide for the Client role
|
client/ How-to guide for the Client role
|
||||||
operator/ How-to guide for the Operator role
|
operator/ How-to guide for the Operator role
|
||||||
admin/ How-to guide for the Admin role
|
admin/ How-to guide for the Admin role
|
||||||
|
scripts/
|
||||||
|
hesk-import/ One-time Hesk 3.x ticket history import — see its own README.md
|
||||||
```
|
```
|
||||||
|
|||||||
10
TESTING.md
10
TESTING.md
@@ -13,20 +13,20 @@ fast in-process fakes (`array`/`sync`) for the same reason.
|
|||||||
From inside the app container (or on a bare-metal install, from `src/`):
|
From inside the app container (or on a bare-metal install, from `src/`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec servicedesk php artisan test
|
docker compose exec app php artisan test
|
||||||
```
|
```
|
||||||
|
|
||||||
or directly with Pest:
|
or directly with Pest:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec servicedesk ./vendor/bin/pest
|
docker compose exec app ./vendor/bin/pest
|
||||||
```
|
```
|
||||||
|
|
||||||
Run a single file or filter by name:
|
Run a single file or filter by name:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec servicedesk php artisan test --filter=SlaBreachNotificationTest
|
docker compose exec app php artisan test --filter=SlaBreachNotificationTest
|
||||||
docker compose exec servicedesk ./vendor/bin/pest tests/Feature/TicketApiTest.php
|
docker compose exec app ./vendor/bin/pest tests/Feature/TicketApiTest.php
|
||||||
```
|
```
|
||||||
|
|
||||||
There is no CI pipeline configured for this repository — running the suite
|
There is no CI pipeline configured for this repository — running the suite
|
||||||
@@ -60,5 +60,5 @@ automatically without extra boilerplate.
|
|||||||
on Laravel's default preset). Run it before committing:
|
on Laravel's default preset). Run it before committing:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec servicedesk ./vendor/bin/pint
|
docker compose exec app ./vendor/bin/pint
|
||||||
```
|
```
|
||||||
|
|||||||
121
install.md
121
install.md
@@ -24,7 +24,7 @@ osobne pliki, w dwóch różnych miejscach.
|
|||||||
- Docker + wtyczka `docker compose`.
|
- Docker + wtyczka `docker compose`.
|
||||||
- Zewnętrzna sieć Docker `traefik_public`, jeśli używasz Traefika tak jak w
|
- Zewnętrzna sieć Docker `traefik_public`, jeśli używasz Traefika tak jak w
|
||||||
`compose.yaml` (`docker network create traefik_public`, jeśli jeszcze nie
|
`compose.yaml` (`docker network create traefik_public`, jeśli jeszcze nie
|
||||||
istnieje). Bez Traefika trzeba samodzielnie zmapować porty serwisu `servicedesk`
|
istnieje). Bez Traefika trzeba samodzielnie zmapować porty serwisu `app`
|
||||||
na hosta (`ports: ["8080:80"]`) i obsłużyć TLS inaczej (patrz sekcja 2 niżej, w
|
na hosta (`ports: ["8080:80"]`) i obsłużyć TLS inaczej (patrz sekcja 2 niżej, w
|
||||||
razie potrzeby reverse-proxy przed kontenerem).
|
razie potrzeby reverse-proxy przed kontenerem).
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ APP_LOCALE=pl
|
|||||||
APP_FALLBACK_LOCALE=pl
|
APP_FALLBACK_LOCALE=pl
|
||||||
|
|
||||||
AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji
|
AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji
|
||||||
VERSION=1.1.3 # widoczne w Admin > O aplikacji
|
VERSION=1.3.0 # widoczne w Admin > O aplikacji
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=mariadb # nazwa serwisu z compose.yaml, NIE 127.0.0.1
|
DB_HOST=mariadb # nazwa serwisu z compose.yaml, NIE 127.0.0.1
|
||||||
@@ -90,8 +90,8 @@ QUEUE_CONNECTION=database
|
|||||||
`APP_KEY` wygenerujesz komendą artisan (krok 1.4) — zostaw puste w pliku.
|
`APP_KEY` wygenerujesz komendą artisan (krok 1.4) — zostaw puste w pliku.
|
||||||
|
|
||||||
`LDAP_*` i `MAIL_*` w `src/.env` są tylko **wartościami startowymi/awaryjnymi**.
|
`LDAP_*` i `MAIL_*` w `src/.env` są tylko **wartościami startowymi/awaryjnymi**.
|
||||||
Docelowo LDAP i SMTP konfiguruje się wygodniej z poziomu **Admin > Konfiguracja**
|
Docelowo LDAP konfiguruje się wygodniej z poziomu **Admin > Integracje**, a SMTP
|
||||||
w samej aplikacji (patrz ramka ostrzegawcza w kroku 1.6) — ale jeśli chcesz mieć
|
z **Admin > Poczta** (patrz ramka ostrzegawcza w kroku 1.6) — ale jeśli chcesz mieć
|
||||||
sensowny fallback zanim ktokolwiek się zaloguje do panelu admina, warto je od razu
|
sensowny fallback zanim ktokolwiek się zaloguje do panelu admina, warto je od razu
|
||||||
uzupełnić:
|
uzupełnić:
|
||||||
|
|
||||||
@@ -184,7 +184,7 @@ w restartach z błędem `Undefined constant "...SIGINT"`; dodaj `pcntl posix` do
|
|||||||
listy w `docker-php-ext-install` i poczekaj na przebudowanie obrazu przez CI.
|
listy w `docker-php-ext-install` i poczekaj na przebudowanie obrazu przez CI.
|
||||||
|
|
||||||
Traefik musi kierować ścieżkę websocketu (`/app*`) do `reverb`, a resztę do
|
Traefik musi kierować ścieżkę websocketu (`/app*`) do `reverb`, a resztę do
|
||||||
`servicedesk` — na tej samej domenie, więc bez dodatkowego wpisu DNS/certyfikatu:
|
`app` — na tej samej domenie, więc bez dodatkowego wpisu DNS/certyfikatu:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
reverb:
|
reverb:
|
||||||
@@ -240,10 +240,10 @@ te wartości są wypiekane w zbudowany bundle JS, nie czytane w runtime.
|
|||||||
### 1.4. Instalacja aplikacji wewnątrz kontenera
|
### 1.4. Instalacja aplikacji wewnątrz kontenera
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec servicedesk composer install --no-dev --optimize-autoloader
|
docker compose exec app composer install --no-dev --optimize-autoloader
|
||||||
docker compose exec servicedesk php artisan key:generate
|
docker compose exec app php artisan key:generate
|
||||||
docker compose exec servicedesk php artisan migrate --seed
|
docker compose exec app php artisan migrate --seed
|
||||||
docker compose exec servicedesk php artisan storage:link
|
docker compose exec app php artisan storage:link
|
||||||
```
|
```
|
||||||
|
|
||||||
`migrate --seed` (bez `--fresh`) na pustej bazie utworzy wszystkie tabele i
|
`migrate --seed` (bez `--fresh`) na pustej bazie utworzy wszystkie tabele i
|
||||||
@@ -254,7 +254,7 @@ po pierwszym zalogowaniu (Admin > Użytkownicy).
|
|||||||
|
|
||||||
### 1.5. Zbudowanie zasobów front-endowych (CSS/Tailwind)
|
### 1.5. Zbudowanie zasobów front-endowych (CSS/Tailwind)
|
||||||
|
|
||||||
Ani host, ani kontener `servicedesk` nie mają zainstalowanego Node.js — buduj
|
Ani host, ani kontener `app` nie mają zainstalowanego Node.js — buduj
|
||||||
przez jednorazowy kontener `node:22` zamiast dorzucać Node do obrazu aplikacji:
|
przez jednorazowy kontener `node:22` zamiast dorzucać Node do obrazu aplikacji:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -265,16 +265,43 @@ docker run --rm -v "$(pwd)/src":/app -w /app node:22 npm run build
|
|||||||
|
|
||||||
Powtarzaj drugi krok po każdej zmianie w `resources/css/` lub `resources/js/`.
|
Powtarzaj drugi krok po każdej zmianie w `resources/css/` lub `resources/js/`.
|
||||||
|
|
||||||
### 1.6. Zadanie cykliczne (SLA) i kolejka
|
### 1.6. Zadanie cykliczne (SLA, automatyzacje, poczta IMAP, AI) i kolejka
|
||||||
|
|
||||||
`routes/console.php` planuje `tickets:check-sla-breaches` co 15 minut, ale **obraz
|
`routes/console.php` planuje `tickets:check-sla-breaches` i `automation:run-rules`
|
||||||
Dockera nie ma wbudowanego cron/supervisora** — bez dodatkowego kroku to zadanie
|
co 15 minut, oraz `emails:fetch-imap` (odbieranie zgłoszeń/odpowiedzi e-mailem —
|
||||||
nigdy się nie uruchomi. Najprościej dodać wpis crona **na hoście**:
|
patrz Admin > Poczta) i `ai:run-ticket-automation` (opcjonalna automatyczna
|
||||||
|
kategoryzacja/podsumowania AI zgłoszeń — patrz Admin > Integracje) co 5 minut,
|
||||||
|
ale **obraz Dockera nie ma wbudowanego cron/supervisora** — bez dodatkowego
|
||||||
|
kroku żadne z tych zadań nigdy się nie uruchomi (poczta IMAP nadal da się
|
||||||
|
sprawdzić ręcznie przyciskiem „Pobierz teraz”, ale bez tego nic nie dzieje się
|
||||||
|
samo). Wszystkie cztery interwały są też konfigurowalne z poziomu **Admin >
|
||||||
|
Konfiguracja** (bez potrzeby edycji kodu czy restartu — nowa wartość obowiązuje
|
||||||
|
od najbliższego tyknięcia harmonogramu).
|
||||||
|
|
||||||
```cron
|
`compose.yaml` rozwiązuje to czwartą usługą, `cron` — tego samego obrazu
|
||||||
* * * * * cd /ścieżka/do/repo && docker compose exec -T servicedesk php artisan schedule:run >> /dev/null 2>&1
|
`servicedesk`, tylko z innym poleceniem:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cron:
|
||||||
|
image: gitea.kzbikowski.pl/kzbkowski/servicedesk:${IMAGE_TAG:-latest}
|
||||||
|
command: php artisan schedule:work
|
||||||
|
volumes:
|
||||||
|
- ./src:/var/www/html
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
mariadb:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`schedule:work` to własna, pierwszoplanowa pętla harmonogramu Laravela —
|
||||||
|
odpowiednik odpalania `schedule:run` co minutę, ale bez potrzeby zewnętrznego
|
||||||
|
triggera. Ten kontener nie musi być widoczny w Traefiku (nie obsługuje ruchu
|
||||||
|
HTTP), stąd tylko sieć `internal`. Sprawdź, że działa: `docker compose ps cron`
|
||||||
|
oraz `docker compose logs -f cron` (loguje każde odpalenie zaplanowanego
|
||||||
|
zadania).
|
||||||
|
|
||||||
Powiadomienia e-mail wysyłają się synchronicznie (nie trafiają do kolejki), więc
|
Powiadomienia e-mail wysyłają się synchronicznie (nie trafiają do kolejki), więc
|
||||||
`php artisan queue:work` nie jest obowiązkowy — `QUEUE_CONNECTION=database` w
|
`php artisan queue:work` nie jest obowiązkowy — `QUEUE_CONNECTION=database` w
|
||||||
`.env` wystarcza jako bezpieczny domyślny driver, gdyby coś w przyszłości zaczęło
|
`.env` wystarcza jako bezpieczny domyślny driver, gdyby coś w przyszłości zaczęło
|
||||||
@@ -283,8 +310,9 @@ kolejkować zadania.
|
|||||||
### ⚠️ Ważne: LDAP/SMTP z panelu Admina nadpisują `.env` w locie
|
### ⚠️ Ważne: LDAP/SMTP z panelu Admina nadpisują `.env` w locie
|
||||||
|
|
||||||
`AppServiceProvider` na starcie żądania sprawdza tabelę `settings` — jeśli w
|
`AppServiceProvider` na starcie żądania sprawdza tabelę `settings` — jeśli w
|
||||||
Admin > Konfiguracja pole **host LDAP** albo **SMTP włączony + host** jest
|
Admin > Integracje pole **host LDAP** albo w Admin > Poczta **SMTP włączony +
|
||||||
ustawione, **te wartości wygrywają z `.env`**, bez potrzeby restartu czy redeployu.
|
host** jest ustawione, **te wartości wygrywają z `.env`**, bez potrzeby
|
||||||
|
restartu czy redeployu.
|
||||||
|
|
||||||
Po świeżym `migrate --seed` te pola zawierają **przykładowe placeholdery**
|
Po świeżym `migrate --seed` te pola zawierają **przykładowe placeholdery**
|
||||||
(`ldap.example.com`, `smtp.example.com`, `changeme-*-password`) — to znaczy, że
|
(`ldap.example.com`, `smtp.example.com`, `changeme-*-password`) — to znaczy, że
|
||||||
@@ -293,20 +321,43 @@ adresami**, nawet jeśli w `.env` wpisałeś prawdziwe dane! Zanim oddasz system
|
|||||||
użytku:
|
użytku:
|
||||||
|
|
||||||
1. Zaloguj się lokalnym kontem `admin@example.com` / `admin`.
|
1. Zaloguj się lokalnym kontem `admin@example.com` / `admin`.
|
||||||
2. Wejdź w **Admin > Konfiguracja** i wpisz prawdziwe dane LDAP/SMTP (albo wyczyść
|
2. Wejdź w **Admin > Integracje** i wpisz prawdziwe dane LDAP (wybierz też
|
||||||
pole hosta LDAP, żeby wrócić do wartości z `.env`).
|
właściwy **Typ katalogu** — LLDAP/OpenLDAP albo Active Directory — jeśli
|
||||||
3. Użyj przycisków **„Testuj połączenie”** przy obu sekcjach, zanim zaczniesz
|
katalog to nie LLDAP; albo wyczyść pole hosta LDAP, żeby wrócić do
|
||||||
polegać na logowaniu przez katalog.
|
wartości z `.env`), a w **Admin > Poczta** dane SMTP.
|
||||||
|
3. Użyj przycisku **„Testuj połączenie”** w Integracje i **„Wyślij testową
|
||||||
|
wiadomość”** w Poczta, zanim zaczniesz polegać na logowaniu przez katalog
|
||||||
|
albo na powiadomieniach e-mail.
|
||||||
|
|
||||||
### Integracje opcjonalne (BookStack)
|
### Integracje opcjonalne (BookStack, AI)
|
||||||
|
|
||||||
Podpowiedzi artykułów z bazy wiedzy BookStack (przy tworzeniu zgłoszenia i w
|
Podpowiedzi artykułów z bazy wiedzy BookStack (przy tworzeniu zgłoszenia i w
|
||||||
panelu operatora) są **domyślnie wyłączone** i nie wymagają żadnej zmiennej w
|
panelu operatora) są **domyślnie wyłączone** i nie wymagają żadnej zmiennej w
|
||||||
`.env` — całość konfiguruje się w **Admin > Konfiguracja**: adres instancji,
|
`.env` — całość konfiguruje się w **Admin > Integracje**: adres instancji,
|
||||||
Token ID/Secret (rola/użytkownik właściciela tokenu musi mieć w BookStacku
|
Token ID/Secret (rola/użytkownik właściciela tokenu musi mieć w BookStacku
|
||||||
uprawnienie „Access System API”), oraz osobne listy dozwolonych półek dla
|
uprawnienie „Access System API”), filtr typu treści (książki/strony/rozdziały,
|
||||||
podpowiedzi przy tworzeniu zgłoszenia i dla panelu operatora — dopóki żadna
|
niezależne checkboxy), tryb wyszukiwania (po nazwie / po tagach / oba), oraz
|
||||||
półka nie jest zaznaczona, wyszukiwanie nic nie zwraca.
|
osobne listy dozwolonych półek dla podpowiedzi przy tworzeniu zgłoszenia i dla
|
||||||
|
panelu operatora — dopóki żadna półka nie jest zaznaczona, wyszukiwanie nic
|
||||||
|
nie zwraca.
|
||||||
|
|
||||||
|
Integracja AI (Admin > Integracje > „Integracja AI”) jest **domyślnie
|
||||||
|
wyłączona** i tak samo nie wymaga żadnej zmiennej w `.env` — adres API
|
||||||
|
(dowolny dostawca kompatybilny z OpenAI: Groq, OpenAI, lokalny Ollama),
|
||||||
|
opcjonalny klucz API, nazwa modelu i przełącznik weryfikacji SSL. Sama w
|
||||||
|
sobie nic nie robi — dopiero po jej włączeniu można włączyć automatyczne
|
||||||
|
tagowanie treści BookStack (przyciski przy integracji BookStack) oraz
|
||||||
|
automatyczną kategoryzację/podsumowania AI zgłoszeń (Admin > Integracje >
|
||||||
|
„Automatyzacja AI dla zgłoszeń”, wymaga też wpisu crona z kroku 1.6/2.6
|
||||||
|
powyżej — to ten sam harmonogram co SLA/automatyzacje/IMAP).
|
||||||
|
|
||||||
|
### Import historycznych zgłoszeń z Heska (opcjonalnie)
|
||||||
|
|
||||||
|
Jeśli migrujesz z helpdesku Hesk 3.x, `scripts/hesk-import/` zawiera
|
||||||
|
jednorazowe (nie ciągłe) narzędzie migracyjne — importuje zgłoszenia wraz z
|
||||||
|
pełną historią odpowiedzi/notatek, ograniczone do jednej domeny e-mail, w
|
||||||
|
trybie dry-run domyślnie. Nie dotyka bazy Heska poza odczytem. Zobacz
|
||||||
|
`scripts/hesk-import/README.md` po pełną instrukcję.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -349,7 +400,7 @@ APP_LOCALE=pl
|
|||||||
APP_FALLBACK_LOCALE=pl
|
APP_FALLBACK_LOCALE=pl
|
||||||
|
|
||||||
AUTHOR_CONTACT=helpdesk@twoja-domena.pl
|
AUTHOR_CONTACT=helpdesk@twoja-domena.pl
|
||||||
VERSION=1.1.3
|
VERSION=1.3.0
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=127.0.0.1 # albo adres IP/hostname prawdziwego serwera DB
|
DB_HOST=127.0.0.1 # albo adres IP/hostname prawdziwego serwera DB
|
||||||
@@ -363,7 +414,7 @@ QUEUE_CONNECTION=database
|
|||||||
```
|
```
|
||||||
|
|
||||||
Uzupełnij też `LDAP_*`/`MAIL_*` jak w sekcji 1.2 (to samo ostrzeżenie o
|
Uzupełnij też `LDAP_*`/`MAIL_*` jak w sekcji 1.2 (to samo ostrzeżenie o
|
||||||
Admin > Konfiguracja nadpisującym te wartości w locie dotyczy tu identycznie).
|
Admin > Integracje/Poczta nadpisującym te wartości w locie dotyczy tu identycznie).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
php artisan key:generate
|
php artisan key:generate
|
||||||
@@ -457,9 +508,11 @@ server {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.6. Zadanie cykliczne (SLA) i kolejka
|
### 2.6. Zadanie cykliczne (SLA, automatyzacje, poczta IMAP, AI) i kolejka
|
||||||
|
|
||||||
Crontab użytkownika, pod którym stoi aplikacja (np. `www-data`):
|
Crontab użytkownika, pod którym stoi aplikacja (np. `www-data`) — obsługuje też
|
||||||
|
`automation:run-rules`, `emails:fetch-imap` i `ai:run-ticket-automation`
|
||||||
|
(patrz 1.6 wyżej, w tym konfigurowalne interwały w Admin > Konfiguracja):
|
||||||
|
|
||||||
```cron
|
```cron
|
||||||
* * * * * cd /var/www/servicedesk/src && php artisan schedule:run >> /dev/null 2>&1
|
* * * * * cd /var/www/servicedesk/src && php artisan schedule:run >> /dev/null 2>&1
|
||||||
@@ -502,9 +555,9 @@ jednej ścieżki, analogicznie do reguły Traefika w 1.3b).
|
|||||||
### 2.7. Pierwsze logowanie i dalsza konfiguracja
|
### 2.7. Pierwsze logowanie i dalsza konfiguracja
|
||||||
|
|
||||||
Identycznie jak w kroku 1.6 — zaloguj się `admin@example.com` / `admin`, zmień
|
Identycznie jak w kroku 1.6 — zaloguj się `admin@example.com` / `admin`, zmień
|
||||||
hasło, uzupełnij prawdziwe LDAP/SMTP w Admin > Konfiguracja (placeholdery z seeda
|
hasło, uzupełnij prawdziwe LDAP w Admin > Integracje i SMTP w Admin > Poczta
|
||||||
inaczej realnie próbują łączyć się z fałszywymi adresami), przetestuj oba
|
(placeholdery z seeda inaczej realnie próbują łączyć się z fałszywymi
|
||||||
połączenia przyciskiem „Testuj połączenie”.
|
adresami), przetestuj oba połączenia.
|
||||||
|
|
||||||
### 2.8. Aktualizacje (bez przestoju)
|
### 2.8. Aktualizacje (bez przestoju)
|
||||||
|
|
||||||
|
|||||||
5
scripts/hesk-import/.env.example
Normal file
5
scripts/hesk-import/.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
HESK_DB_HOST=
|
||||||
|
HESK_DB_PORT=3306
|
||||||
|
HESK_DB_DATABASE=
|
||||||
|
HESK_DB_USERNAME=
|
||||||
|
HESK_DB_PASSWORD=
|
||||||
127
scripts/hesk-import/README.md
Normal file
127
scripts/hesk-import/README.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# Hesk 3.x import
|
||||||
|
|
||||||
|
One-time historical migration of tickets (with full reply/note history) from a
|
||||||
|
Hesk 3.x helpdesk database into this app, restricted to a single e-mail
|
||||||
|
domain. Read-only against the Hesk database — never writes there.
|
||||||
|
|
||||||
|
This is **not** an ongoing sync. Run it once to backfill history from an
|
||||||
|
old Hesk install; it doesn't pick up edits made in Hesk afterward.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- Matches Hesk tickets by requester e-mail domain (`--domain=firma.pl`).
|
||||||
|
- Imports each ticket's subject/body, status, priority, and full reply +
|
||||||
|
internal-note history, converting Hesk's `<br />`-laden "plain" text into
|
||||||
|
real line breaks.
|
||||||
|
- Maps Hesk categories to this app's categories by exact (case/whitespace-
|
||||||
|
insensitive) name match. A Hesk category with no match is skipped by
|
||||||
|
default — pass `--include-unmapped-categories` to import those tickets
|
||||||
|
anyway, uncategorized.
|
||||||
|
- 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 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp scripts/hesk-import/.env.example scripts/hesk-import/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Fill in `scripts/hesk-import/.env` with the Hesk database's
|
||||||
|
host/port/database/username/password. That file is gitignored — it holds
|
||||||
|
real credentials for a database this app otherwise has no access to.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Always dry-run first — it reports what *would* happen without writing
|
||||||
|
anything:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/hesk-import/hesk-import.sh --domain=firma.pl
|
||||||
|
```
|
||||||
|
|
||||||
|
Try a small batch for real before committing to the whole thing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/hesk-import/hesk-import.sh --domain=firma.pl --limit=10 --commit
|
||||||
|
```
|
||||||
|
|
||||||
|
Then the full import:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/hesk-import/hesk-import.sh --domain=firma.pl --commit
|
||||||
|
```
|
||||||
|
|
||||||
|
Safe to re-run (including after an interrupted/crashed run): already-imported
|
||||||
|
Hesk ticket ids are tracked in `storage/app/hesk-import-state.json` inside the
|
||||||
|
app container and skipped on subsequent runs.
|
||||||
|
|
||||||
|
### Backfilling team assignment
|
||||||
|
|
||||||
|
If tickets were already imported before team-by-category mapping existed (or
|
||||||
|
teams/subcategories changed since), backfill `team_id` on existing imported
|
||||||
|
tickets without importing anything new:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
`php artisan hesk:import` inside the `app` container via `docker compose
|
||||||
|
exec`, passing the Hesk DB credentials as one-off environment variables (they
|
||||||
|
never touch the app's own `.env` or get persisted anywhere but the state
|
||||||
|
file). The actual import logic lives in
|
||||||
|
[`../../src/app/Console/Commands/ImportHeskTickets.php`](../../src/app/Console/Commands/ImportHeskTickets.php).
|
||||||
49
scripts/hesk-import/hesk-import.sh
Normal file
49
scripts/hesk-import/hesk-import.sh
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Imports tickets from a Hesk 3.x helpdesk database into this app, filtered
|
||||||
|
# to one e-mail domain. Thin wrapper around `php artisan hesk:import` (see
|
||||||
|
# ../../src/app/Console/Commands/ImportHeskTickets.php for the actual logic)
|
||||||
|
# — this script only wires up the Hesk DB credentials and runs it inside the
|
||||||
|
# app container. See README.md in this folder for full setup/usage docs.
|
||||||
|
#
|
||||||
|
# Setup: copy .env.example (this folder) to .env (gitignored) and fill in
|
||||||
|
# your Hesk database's host/port/database/username/password.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/hesk-import/hesk-import.sh --domain=firma.pl # dry run (default, writes nothing)
|
||||||
|
# scripts/hesk-import/hesk-import.sh --domain=firma.pl --limit=10 --commit # real run, first 10 tickets only
|
||||||
|
# scripts/hesk-import/hesk-import.sh --domain=firma.pl --commit # real run, everything
|
||||||
|
#
|
||||||
|
# Safe to re-run: already-imported Hesk tickets are tracked in
|
||||||
|
# storage/app/hesk-import-state.json inside the app container and skipped.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
ENV_FILE="$SCRIPT_DIR/.env"
|
||||||
|
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
echo "Brak $ENV_FILE — skopiuj scripts/hesk-import/.env.example i uzupełnij dane dostępowe do bazy Heska." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
: "${HESK_DB_HOST:?ustaw HESK_DB_HOST w $ENV_FILE}"
|
||||||
|
: "${HESK_DB_DATABASE:?ustaw HESK_DB_DATABASE w $ENV_FILE}"
|
||||||
|
: "${HESK_DB_USERNAME:?ustaw HESK_DB_USERNAME w $ENV_FILE}"
|
||||||
|
HESK_DB_PORT="${HESK_DB_PORT:-3306}"
|
||||||
|
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
exec sudo docker compose exec \
|
||||||
|
-e HESK_DB_HOST="$HESK_DB_HOST" \
|
||||||
|
-e HESK_DB_PORT="$HESK_DB_PORT" \
|
||||||
|
-e HESK_DB_DATABASE="$HESK_DB_DATABASE" \
|
||||||
|
-e HESK_DB_USERNAME="$HESK_DB_USERNAME" \
|
||||||
|
-e HESK_DB_PASSWORD="$HESK_DB_PASSWORD" \
|
||||||
|
app php artisan hesk:import "$@"
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
APP_NAME=Laravel
|
APP_NAME=Laravel
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=false
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
AUTHOR_CONTACT=helpdesk@kzbikowski.pl
|
AUTHOR_CONTACT=helpdesk@kzbikowski.pl
|
||||||
VERSION=1.1.3
|
VERSION=1.4.0
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
|
|||||||
35
src/app/Console/Commands/BookstackTagContent.php
Normal file
35
src/app/Console/Commands/BookstackTagContent.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\BookStackContentTagger;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class BookstackTagContent extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'bookstack:tag-content
|
||||||
|
{--dry-run : Klasyfikuj i pokaż wynik bez zapisu tagów do BookStack}
|
||||||
|
{--force : Klasyfikuj ponownie elementy, które już mają tag podkategorii}
|
||||||
|
{--limit= : Zatrzymaj się po przetworzeniu N elementów}';
|
||||||
|
|
||||||
|
protected $description = 'Otaguj każdą książkę/rozdział/stronę w BookStack pasującymi nazwami podkategorii helpdesku, z pomocą skonfigurowanego dostawcy AI';
|
||||||
|
|
||||||
|
public function handle(BookStackContentTagger $tagger): int
|
||||||
|
{
|
||||||
|
$totals = $tagger->run(
|
||||||
|
dryRun: (bool) $this->option('dry-run'),
|
||||||
|
force: (bool) $this->option('force'),
|
||||||
|
limit: $this->option('limit') !== null ? (int) $this->option('limit') : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'BookStack tagging: przeskanowano %d, otagowano %d, pominięto %d, nieudanych paczek %d.',
|
||||||
|
$totals['scanned'],
|
||||||
|
$totals['tagged'],
|
||||||
|
$totals['skipped'],
|
||||||
|
$totals['failed_batches'],
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/app/Console/Commands/FetchImapEmails.php
Normal file
33
src/app/Console/Commands/FetchImapEmails.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\ImapMailbox;
|
||||||
|
use App\Services\ImapMailboxFetcher;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class FetchImapEmails extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'emails:fetch-imap';
|
||||||
|
|
||||||
|
protected $description = 'Poll every enabled IMAP mailbox and turn new messages into tickets/replies';
|
||||||
|
|
||||||
|
public function handle(ImapMailboxFetcher $fetcher): int
|
||||||
|
{
|
||||||
|
if (! ImapMailbox::query()->where('enabled', true)->exists()) {
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totals = $fetcher->fetchAll();
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'IMAP fetch: %d nowych, %d odpowiedzi, %d odrzuconych, %d błędów.',
|
||||||
|
$totals['created'],
|
||||||
|
$totals['replied'],
|
||||||
|
$totals['rejected'],
|
||||||
|
$totals['errors'],
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
908
src/app/Console/Commands/ImportHeskTickets.php
Normal file
908
src/app/Console/Commands/ImportHeskTickets.php
Normal file
@@ -0,0 +1,908 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-off migration from a Hesk 3.x helpdesk database into this app's own
|
||||||
|
* tickets/users. Never writes to the Hesk database — read-only there.
|
||||||
|
*
|
||||||
|
* 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). 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
|
||||||
|
{
|
||||||
|
protected $signature = 'hesk:import
|
||||||
|
{--domain= : Only import tickets whose requester e-mail ends in @this-domain}
|
||||||
|
{--commit : Actually write to the database (default is a dry run)}
|
||||||
|
{--limit= : Only process this many Hesk tickets (after the domain filter), useful for a test run}
|
||||||
|
{--state= : Path to the resume-state JSON file (default storage/app/hesk-import-state.json)}
|
||||||
|
{--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}
|
||||||
|
{--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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hesk ticket.status -> our status_key. Hesk's built-in codes are
|
||||||
|
* 0=New, 1=Waiting reply (for staff), 2=Replied (waiting on customer),
|
||||||
|
* 3=Resolved. Any other value (seen: a handful of "5" rows, presumably
|
||||||
|
* a since-deleted custom status) falls back to 'open'.
|
||||||
|
*/
|
||||||
|
private const STATUS_MAP = [
|
||||||
|
0 => 'new',
|
||||||
|
1 => 'waiting_operator',
|
||||||
|
2 => 'waiting_customer',
|
||||||
|
3 => 'closed',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hesk ticket.priority -> our priority_key. Hesk's enum is 1=Critical,
|
||||||
|
* 2=High, 3=Medium (the near-universal default — every Hesk category
|
||||||
|
* here defaults new tickets to priority 3); 0 is an unused/rare edge
|
||||||
|
* value, treated as the closest thing Hesk has to "Low".
|
||||||
|
*/
|
||||||
|
private const PRIORITY_MAP = [
|
||||||
|
0 => 'low',
|
||||||
|
1 => 'critical',
|
||||||
|
2 => 'high',
|
||||||
|
3 => 'medium',
|
||||||
|
];
|
||||||
|
|
||||||
|
private array $categoryMap = [];
|
||||||
|
|
||||||
|
/** @var array<int, int> servicedesk category id => servicedesk team id, only when unambiguous */
|
||||||
|
private array $teamByCategory = [];
|
||||||
|
|
||||||
|
/** @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;
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
if ($this->option('assign-teams')) {
|
||||||
|
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 === '') {
|
||||||
|
$this->error('Podaj --domain=twoja-domena.pl (bez @).');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$commit = (bool) $this->option('commit');
|
||||||
|
$limit = $this->option('limit') !== null ? (int) $this->option('limit') : null;
|
||||||
|
$includeUnmapped = (bool) $this->option('include-unmapped-categories');
|
||||||
|
$this->statePath = $this->option('state') ?: storage_path('app/hesk-import-state.json');
|
||||||
|
|
||||||
|
if (! $this->configureHeskConnection()) {
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->loadState();
|
||||||
|
$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)
|
||||||
|
->orderBy('id')
|
||||||
|
->when($limit, fn ($q) => $q->limit($limit))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// 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->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, 'skipped_unknown_customer' => 0, 'failed' => 0, 'messages' => 0, 'operator_messages_linked' => 0];
|
||||||
|
$unmappedCategories = [];
|
||||||
|
$unknownCustomerEmails = [];
|
||||||
|
|
||||||
|
$bar = $this->output->createProgressBar($tickets->count());
|
||||||
|
$bar->start();
|
||||||
|
|
||||||
|
foreach ($tickets as $heskTicket) {
|
||||||
|
$bar->advance();
|
||||||
|
|
||||||
|
if ($this->alreadyImported($heskTicket->id)) {
|
||||||
|
$stats['skipped']++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$categoryMapped = array_key_exists($heskTicket->category, $this->categoryMap);
|
||||||
|
|
||||||
|
if (! $categoryMapped && ! in_array($heskTicket->category, $unmappedCategories, true)) {
|
||||||
|
$unmappedCategories[] = $heskTicket->category;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: a Hesk category with no servicedesk equivalent means
|
||||||
|
// this ticket is skipped entirely rather than imported without
|
||||||
|
// a category — --include-unmapped-categories opts back in.
|
||||||
|
if (! $categoryMapped && ! $includeUnmapped) {
|
||||||
|
$stats['skipped_unmapped_category']++;
|
||||||
|
|
||||||
|
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']++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DB::transaction(function () use ($heskTicket, &$stats) {
|
||||||
|
$this->importOneTicket($heskTicket, $stats);
|
||||||
|
});
|
||||||
|
$this->state['imported'][] = $heskTicket->id;
|
||||||
|
$this->saveState();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->newLine(2);
|
||||||
|
|
||||||
|
$this->table(['Miara', 'Wartość'], [
|
||||||
|
['Zgłoszenia utworzone', $stats['created']],
|
||||||
|
['Wiadomości/notatki utworzone', $stats['messages']],
|
||||||
|
['...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']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($unmappedCategories) {
|
||||||
|
$names = collect($unmappedCategories)
|
||||||
|
->map(fn ($id) => DB::connection('hesk')->table('help_categories')->where('id', $id)->value('name') ?? "id={$id}")
|
||||||
|
->implode(', ');
|
||||||
|
$action = $includeUnmapped ? 'zgłoszenia zaimportowane bez kategorii' : 'zgłoszenia POMINIĘTE — użyj --include-unmapped-categories, żeby jednak je zaimportować bez kategorii';
|
||||||
|
$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ć.');
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
$port = env('HESK_DB_PORT', 3306);
|
||||||
|
$database = env('HESK_DB_DATABASE');
|
||||||
|
$username = env('HESK_DB_USERNAME');
|
||||||
|
$password = env('HESK_DB_PASSWORD');
|
||||||
|
|
||||||
|
if (! $host || ! $database || ! $username) {
|
||||||
|
$this->error('Brakuje HESK_DB_HOST / HESK_DB_DATABASE / HESK_DB_USERNAME w środowisku (patrz scripts/hesk-import/hesk-import.sh).');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Config::set('database.connections.hesk', [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'host' => $host,
|
||||||
|
'port' => $port,
|
||||||
|
'database' => $database,
|
||||||
|
'username' => $username,
|
||||||
|
'password' => $password,
|
||||||
|
'charset' => 'utf8mb4',
|
||||||
|
'collation' => 'utf8mb4_unicode_ci',
|
||||||
|
'options' => [PDO::ATTR_TIMEOUT => 10],
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
DB::connection('hesk')->getPdo();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error('Nie udało się połączyć z bazą Heska: '.$e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hesk category name (normalized) -> servicedesk category id. Only
|
||||||
|
* exact (case/whitespace-insensitive) name matches are mapped; anything
|
||||||
|
* else is left uncategorized and reported at the end instead of guessed at.
|
||||||
|
*/
|
||||||
|
private function buildCategoryMap(): void
|
||||||
|
{
|
||||||
|
$ours = Category::query()->get()->keyBy(fn (Category $c) => $this->normalizeCategoryName($c->name));
|
||||||
|
|
||||||
|
foreach (DB::connection('hesk')->table('help_categories')->get() as $heskCategory) {
|
||||||
|
$match = $ours->get($this->normalizeCategoryName($heskCategory->name));
|
||||||
|
|
||||||
|
if ($match) {
|
||||||
|
$this->categoryMap[$heskCategory->id] = $match->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeCategoryName(string $name): string
|
||||||
|
{
|
||||||
|
return mb_strtolower(trim($name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* servicedesk category id -> servicedesk team id, only when every
|
||||||
|
* subcategory under that category belongs to the same single team (the
|
||||||
|
* existing team_subcategory routing — see TicketService::autoAssignTeam()
|
||||||
|
* for the same rule applied to normal in-app ticket creation, there scoped
|
||||||
|
* to a specific subcategory rather than a whole category). A category
|
||||||
|
* whose subcategories are split across more than one team is left
|
||||||
|
* unmapped rather than guessed at.
|
||||||
|
*/
|
||||||
|
private function buildTeamMap(): void
|
||||||
|
{
|
||||||
|
foreach (Category::query()->pluck('id') as $categoryId) {
|
||||||
|
$teamIds = Team::query()
|
||||||
|
->whereHas('subcategories', fn ($q) => $q->where('subcategories.category_id', $categoryId))
|
||||||
|
->pluck('id')
|
||||||
|
->unique();
|
||||||
|
|
||||||
|
if ($teamIds->count() === 1) {
|
||||||
|
$this->teamByCategory[$categoryId] = $teamIds->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfill mode (--assign-teams): sets team_id on tickets that already
|
||||||
|
* exist (from an earlier --commit run, before team assignment was added)
|
||||||
|
* and don't have one yet — doesn't import anything, doesn't touch the
|
||||||
|
* Hesk database at all.
|
||||||
|
*/
|
||||||
|
private function runAssignTeams(bool $commit): int
|
||||||
|
{
|
||||||
|
$this->buildTeamMap();
|
||||||
|
|
||||||
|
if (! $this->teamByCategory) {
|
||||||
|
$this->warn('Żadna kategoria nie ma jednoznacznie przypisanego zespołu (na podstawie podkategorii) — nie ma czego przypisać.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalUpdated = 0;
|
||||||
|
|
||||||
|
foreach ($this->teamByCategory as $categoryId => $teamId) {
|
||||||
|
$query = Ticket::query()->where('category_id', $categoryId)->whereNull('team_id');
|
||||||
|
$count = $query->count();
|
||||||
|
|
||||||
|
if ($count === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(sprintf(
|
||||||
|
'Kategoria "%s" -> zespół "%s": %d zgłoszeń%s',
|
||||||
|
Category::query()->find($categoryId)?->name ?? "id={$categoryId}",
|
||||||
|
Team::query()->find($teamId)?->name ?? "id={$teamId}",
|
||||||
|
$count,
|
||||||
|
$commit ? '' : ' (dry-run)',
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($commit) {
|
||||||
|
$query->update(['team_id' => $teamId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalUpdated += $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info(($commit ? 'Zaktualizowano' : 'Do zaktualizowania').': '.$totalUpdated.' zgłoszeń.');
|
||||||
|
|
||||||
|
if (! $commit) {
|
||||||
|
$this->comment('To był dry-run — uruchom ponownie z --assign-teams --commit, żeby faktycznie zapisać.');
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
$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);
|
||||||
|
|
||||||
|
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(),
|
||||||
|
'customer_id' => $customer->id,
|
||||||
|
'email' => $heskTicket->email,
|
||||||
|
'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' => $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' => $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' => $closedAt ?? $heskTicket->lastchange]);
|
||||||
|
|
||||||
|
$opening = $ticket->messages()->create([
|
||||||
|
'author_name' => $heskTicket->name ?: $heskTicket->email,
|
||||||
|
'body' => $this->cleanText($heskTicket->message),
|
||||||
|
'created_at' => $heskTicket->dt,
|
||||||
|
'updated_at' => $heskTicket->dt,
|
||||||
|
]);
|
||||||
|
$opening->attachAuthor($customer->id, 'client');
|
||||||
|
$stats['messages']++;
|
||||||
|
|
||||||
|
foreach ($this->heskReplies($heskTicket->id) as $reply) {
|
||||||
|
$this->importReply($ticket, $reply, $customer, $stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->heskNotes($heskTicket->id) as $note) {
|
||||||
|
$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 $this->repliesByTicket->get($heskTicketId, collect());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function heskNotes(int $heskTicketId): Collection
|
||||||
|
{
|
||||||
|
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
|
||||||
|
? ($operator->name ?? $this->heskStaffNames[$reply->staffid] ?? 'Personel')
|
||||||
|
: ($reply->name ?: $customer->name);
|
||||||
|
|
||||||
|
$message = $ticket->messages()->create([
|
||||||
|
'author_name' => $authorName,
|
||||||
|
'body' => $this->cleanText($reply->message),
|
||||||
|
'created_at' => $reply->dt,
|
||||||
|
'updated_at' => $reply->dt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 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' => $operator->name ?? $this->heskStaffNames[$note->who] ?? 'Personel',
|
||||||
|
'internal' => true,
|
||||||
|
'body' => $this->cleanText($note->message),
|
||||||
|
'created_at' => $note->dt,
|
||||||
|
'updated_at' => $note->dt,
|
||||||
|
]);
|
||||||
|
$message->attachAuthor($operator?->id, 'operator');
|
||||||
|
$stats['messages']++;
|
||||||
|
|
||||||
|
if ($operator) {
|
||||||
|
$stats['operator_messages_linked']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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): ?User
|
||||||
|
{
|
||||||
|
$user = User::query()->where('email', trim($email))->first();
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array('client', $user->roles, true)) {
|
||||||
|
$user->roles = [...$user->roles, 'client'];
|
||||||
|
$user->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hesk's "plain" message/subject columns still carry <br /> tags (and
|
||||||
|
* occasionally other inline HTML) from HTML-formatted source e-mails —
|
||||||
|
* this app renders ticket/message bodies as escaped plain text
|
||||||
|
* (white-space:pre-wrap), so raw tags would show up literally instead
|
||||||
|
* of as line breaks.
|
||||||
|
*/
|
||||||
|
private function cleanText(?string $value): string
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = preg_replace('/<br\s*\/?>/i', "\n", $value);
|
||||||
|
$text = strip_tags($text);
|
||||||
|
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
|
||||||
|
return trim($text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadState(): void
|
||||||
|
{
|
||||||
|
if (is_file($this->statePath)) {
|
||||||
|
$decoded = json_decode(file_get_contents($this->statePath), true);
|
||||||
|
$this->state = is_array($decoded) ? $decoded : $this->state;
|
||||||
|
$this->state['imported'] ??= [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function saveState(): void
|
||||||
|
{
|
||||||
|
$dir = dirname($this->statePath);
|
||||||
|
if (! is_dir($dir)) {
|
||||||
|
mkdir($dir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($this->statePath, json_encode($this->state));
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/app/Console/Commands/RunAiTicketAutomation.php
Normal file
35
src/app/Console/Commands/RunAiTicketAutomation.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\TicketAiSummaryService;
|
||||||
|
use App\Services\TicketAiTriageService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class RunAiTicketAutomation extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'ai:run-ticket-automation';
|
||||||
|
|
||||||
|
protected $description = 'Run AI-driven ticket triage (categorize/prioritize new tickets) and refresh AI ticket summaries for the operator view';
|
||||||
|
|
||||||
|
public function handle(TicketAiTriageService $triage, TicketAiSummaryService $summary): int
|
||||||
|
{
|
||||||
|
$triageTotals = $triage->run();
|
||||||
|
$summaryTotals = $summary->run();
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'AI triage: scanned %d, changed %d, failed %d.',
|
||||||
|
$triageTotals['scanned'],
|
||||||
|
$triageTotals['changed'],
|
||||||
|
$triageTotals['failed'],
|
||||||
|
));
|
||||||
|
$this->info(sprintf(
|
||||||
|
'AI summaries: scanned %d, updated %d, failed %d.',
|
||||||
|
$summaryTotals['scanned'],
|
||||||
|
$summaryTotals['updated'],
|
||||||
|
$summaryTotals['failed'],
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/app/Jobs/GenerateTicketAiSummaryJob.php
Normal file
30
src/app/Jobs/GenerateTicketAiSummaryJob.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Services\TicketAiSummaryService;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliberately NOT a queued job (no ShouldQueue) — this app's queue worker
|
||||||
|
* is optional infrastructure (see install.md), so anything pushed onto the
|
||||||
|
* `jobs` table has no guarantee of ever being picked up. Dispatched with
|
||||||
|
* ::dispatchAfterResponse() instead, which runs it in-process right after
|
||||||
|
* the triggering HTTP/console response is sent, needing no worker at all.
|
||||||
|
*/
|
||||||
|
class GenerateTicketAiSummaryJob
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
public function __construct(protected int $ticketId) {}
|
||||||
|
|
||||||
|
public function handle(TicketAiSummaryService $summary): void
|
||||||
|
{
|
||||||
|
$ticket = Ticket::find($this->ticketId);
|
||||||
|
|
||||||
|
if ($ticket) {
|
||||||
|
$summary->generateFor($ticket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/app/Ldap/AdUser.php
Normal file
18
src/app/Ldap/AdUser.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Ldap;
|
||||||
|
|
||||||
|
use LdapRecord\Models\ActiveDirectory\User as ActiveDirectoryUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active Directory counterpart to LldapUser — same role (the LdapRecord
|
||||||
|
* model backing the 'users' auth provider), but for AD's schema instead of
|
||||||
|
* LLDAP/OpenLDAP's. AD user objects carry objectClass top/person/
|
||||||
|
* organizationalPerson/user (no inetOrgPerson/posixAccount/mailAccount, so
|
||||||
|
* LldapUser's object-class scope matches zero AD entries) and expose a
|
||||||
|
* binary objectGUID rather than entryUUID — both already handled correctly
|
||||||
|
* by LdapRecord's stock ActiveDirectory\User, so no overrides are needed
|
||||||
|
* here, only the swap in AppServiceProvider::applyLdapSettingsOverride()
|
||||||
|
* (driven by the ldap_directory_type setting).
|
||||||
|
*/
|
||||||
|
class AdUser extends ActiveDirectoryUser {}
|
||||||
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
335
src/app/Livewire/Admin/MailSettings.php
Normal file
335
src/app/Livewire/Admin/MailSettings.php
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Admin;
|
||||||
|
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\ImapMailbox;
|
||||||
|
use App\Services\ImapMailboxFetcher;
|
||||||
|
use App\Support\Settings;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Config;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SMTP (outbound) + IMAP mailboxes (inbound — turns e-mails into tickets or
|
||||||
|
* replies) on their own dedicated admin page, split out of the generic
|
||||||
|
* "Integracje" grab-bag since IMAP is a repeatable list (N mailboxes) rather
|
||||||
|
* than a singleton config, and both halves of "reply by e-mail" belong
|
||||||
|
* together rather than split across tabs.
|
||||||
|
*/
|
||||||
|
class MailSettings extends Component
|
||||||
|
{
|
||||||
|
public array $mailConfig = [];
|
||||||
|
|
||||||
|
public ?string $mailTestResult = null;
|
||||||
|
|
||||||
|
public bool $mailboxFormOpen = false;
|
||||||
|
|
||||||
|
public array $mailboxForm = [
|
||||||
|
'id' => null,
|
||||||
|
'name' => '',
|
||||||
|
'enabled' => true,
|
||||||
|
'host' => '',
|
||||||
|
'port' => 993,
|
||||||
|
'encryption' => 'ssl',
|
||||||
|
'validateCert' => true,
|
||||||
|
'username' => '',
|
||||||
|
'password' => '',
|
||||||
|
'folder' => 'INBOX',
|
||||||
|
'processedFolder' => '',
|
||||||
|
'rejectedFolder' => '',
|
||||||
|
'target' => '',
|
||||||
|
'blocklistSenders' => 'mailer-daemon,postmaster,no-reply,noreply',
|
||||||
|
];
|
||||||
|
|
||||||
|
public ?int $mailboxTestResultId = null;
|
||||||
|
|
||||||
|
public ?string $mailboxTestResult = null;
|
||||||
|
|
||||||
|
public ?string $mailboxTestMessage = null;
|
||||||
|
|
||||||
|
public ?int $mailboxFetchResultId = null;
|
||||||
|
|
||||||
|
public ?string $mailboxFetchSummary = null;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->mailConfig = [
|
||||||
|
'smtpEnabled' => Settings::bool('mail_smtp_enabled'),
|
||||||
|
'smtpHost' => Settings::get('mail_smtp_host'),
|
||||||
|
'smtpPort' => Settings::get('mail_smtp_port'),
|
||||||
|
'smtpUsername' => Settings::get('mail_smtp_username'),
|
||||||
|
'smtpPassword' => Settings::get('mail_smtp_password'),
|
||||||
|
'smtpEncryption' => Settings::get('mail_smtp_encryption'),
|
||||||
|
'fromAddress' => Settings::get('mail_from_address'),
|
||||||
|
'fromName' => Settings::get('mail_from_name'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function mailboxes(): Collection
|
||||||
|
{
|
||||||
|
return ImapMailbox::query()->with(['defaultSubcategory.category', 'defaultCategory'])->orderBy('name')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categories with their subcategories nested, for the mailbox form's
|
||||||
|
* single combined "cała kategoria albo konkretna podkategoria" selector.
|
||||||
|
*/
|
||||||
|
#[Computed]
|
||||||
|
public function categoryOptions(): Collection
|
||||||
|
{
|
||||||
|
return Category::query()->with('subcategories')->orderBy('name')->get()
|
||||||
|
->map(fn (Category $c) => [
|
||||||
|
'id' => $c->id,
|
||||||
|
'name' => $c->name,
|
||||||
|
'subcategories' => $c->subcategories->map(fn ($s) => ['id' => $s->id, 'name' => $s->name])->values(),
|
||||||
|
])
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SMTP =====================
|
||||||
|
|
||||||
|
public function saveMailConfig(): void
|
||||||
|
{
|
||||||
|
Settings::set('mail_smtp_enabled', $this->mailConfig['smtpEnabled'] ? '1' : '0');
|
||||||
|
Settings::set('mail_smtp_host', $this->mailConfig['smtpHost']);
|
||||||
|
Settings::set('mail_smtp_port', (string) $this->mailConfig['smtpPort']);
|
||||||
|
Settings::set('mail_smtp_username', $this->mailConfig['smtpUsername']);
|
||||||
|
|
||||||
|
if ($this->mailConfig['smtpPassword']) {
|
||||||
|
Settings::set('mail_smtp_password', $this->mailConfig['smtpPassword']);
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings::set('mail_smtp_encryption', $this->mailConfig['smtpEncryption']);
|
||||||
|
Settings::set('mail_from_address', $this->mailConfig['fromAddress']);
|
||||||
|
Settings::set('mail_from_name', $this->mailConfig['fromName']);
|
||||||
|
|
||||||
|
$this->mailTestResult = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a real test e-mail to the logged-in admin using the form's
|
||||||
|
* current (unsaved) values, temporarily overriding the mail config the
|
||||||
|
* same way AppServiceProvider does for real once saved.
|
||||||
|
*/
|
||||||
|
public function testMailConnection(): void
|
||||||
|
{
|
||||||
|
$cfg = $this->mailConfig;
|
||||||
|
|
||||||
|
if (empty($cfg['smtpHost']) || empty($cfg['fromAddress'])) {
|
||||||
|
$this->mailTestResult = 'error';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$original = Config::get('mail');
|
||||||
|
|
||||||
|
try {
|
||||||
|
Config::set('mail.default', 'smtp');
|
||||||
|
Config::set('mail.mailers.smtp.host', $cfg['smtpHost']);
|
||||||
|
Config::set('mail.mailers.smtp.port', (int) $cfg['smtpPort']);
|
||||||
|
Config::set('mail.mailers.smtp.username', $cfg['smtpUsername'] ?: null);
|
||||||
|
Config::set('mail.mailers.smtp.password', $cfg['smtpPassword'] ?: Settings::get('mail_smtp_password'));
|
||||||
|
Config::set('mail.mailers.smtp.scheme', match ($cfg['smtpEncryption']) {
|
||||||
|
'ssl' => 'smtps',
|
||||||
|
'tls' => 'smtp',
|
||||||
|
default => null,
|
||||||
|
});
|
||||||
|
Config::set('mail.from.address', $cfg['fromAddress']);
|
||||||
|
Config::set('mail.from.name', $cfg['fromName'] ?: Settings::get('company_name'));
|
||||||
|
|
||||||
|
app()->forgetInstance('mail.manager');
|
||||||
|
app()->forgetInstance('mailer');
|
||||||
|
|
||||||
|
Mail::raw('To jest testowa wiadomość wysłana z panelu administratora Servicedesk.', function ($message) {
|
||||||
|
$message->to(Auth::user()->email)->subject('Test konfiguracji SMTP');
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->mailTestResult = 'ok';
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$this->mailTestResult = 'error';
|
||||||
|
} finally {
|
||||||
|
Config::set('mail', $original);
|
||||||
|
app()->forgetInstance('mail.manager');
|
||||||
|
app()->forgetInstance('mailer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== IMAP MAILBOXES =====================
|
||||||
|
|
||||||
|
public function openMailboxForm(): void
|
||||||
|
{
|
||||||
|
$this->reset('mailboxForm');
|
||||||
|
$this->mailboxForm = [
|
||||||
|
'id' => null,
|
||||||
|
'name' => '',
|
||||||
|
'enabled' => true,
|
||||||
|
'host' => '',
|
||||||
|
'port' => 993,
|
||||||
|
'encryption' => 'ssl',
|
||||||
|
'validateCert' => true,
|
||||||
|
'username' => '',
|
||||||
|
'password' => '',
|
||||||
|
'folder' => 'INBOX',
|
||||||
|
'processedFolder' => '',
|
||||||
|
'rejectedFolder' => '',
|
||||||
|
'target' => '',
|
||||||
|
'blocklistSenders' => 'mailer-daemon,postmaster,no-reply,noreply',
|
||||||
|
];
|
||||||
|
$this->mailboxTestResultId = null;
|
||||||
|
$this->resetErrorBag();
|
||||||
|
$this->mailboxFormOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function editMailbox(int $id): void
|
||||||
|
{
|
||||||
|
$mailbox = ImapMailbox::query()->findOrFail($id);
|
||||||
|
|
||||||
|
$target = match (true) {
|
||||||
|
(bool) $mailbox->default_subcategory_id => "subcategory:{$mailbox->default_subcategory_id}",
|
||||||
|
(bool) $mailbox->default_category_id => "category:{$mailbox->default_category_id}",
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->mailboxForm = [
|
||||||
|
'id' => $mailbox->id,
|
||||||
|
'name' => $mailbox->name,
|
||||||
|
'enabled' => $mailbox->enabled,
|
||||||
|
'host' => $mailbox->host,
|
||||||
|
'port' => $mailbox->port,
|
||||||
|
'encryption' => $mailbox->encryption,
|
||||||
|
'validateCert' => $mailbox->validate_cert,
|
||||||
|
'username' => $mailbox->username,
|
||||||
|
'password' => $mailbox->password,
|
||||||
|
'folder' => $mailbox->folder,
|
||||||
|
'processedFolder' => $mailbox->processed_folder,
|
||||||
|
'rejectedFolder' => $mailbox->rejected_folder,
|
||||||
|
'target' => $target,
|
||||||
|
'blocklistSenders' => $mailbox->blocklist_senders,
|
||||||
|
];
|
||||||
|
$this->mailboxTestResultId = null;
|
||||||
|
$this->resetErrorBag();
|
||||||
|
$this->mailboxFormOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeMailboxForm(): void
|
||||||
|
{
|
||||||
|
$this->mailboxFormOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function submitMailboxForm(): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'mailboxForm.name' => ['required', 'string', 'max:255'],
|
||||||
|
'mailboxForm.host' => ['required', 'string', 'max:255'],
|
||||||
|
'mailboxForm.port' => ['required', 'integer', 'min:1', 'max:65535'],
|
||||||
|
'mailboxForm.encryption' => ['required', 'in:ssl,tls,none'],
|
||||||
|
'mailboxForm.username' => ['required', 'string', 'max:255'],
|
||||||
|
'mailboxForm.folder' => ['required', 'string', 'max:255'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
[$targetType, $targetId] = str_contains((string) $this->mailboxForm['target'], ':')
|
||||||
|
? explode(':', $this->mailboxForm['target'], 2)
|
||||||
|
: [null, null];
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'name' => $this->mailboxForm['name'],
|
||||||
|
'enabled' => (bool) $this->mailboxForm['enabled'],
|
||||||
|
'host' => $this->mailboxForm['host'],
|
||||||
|
'port' => (int) $this->mailboxForm['port'],
|
||||||
|
'encryption' => $this->mailboxForm['encryption'],
|
||||||
|
'validate_cert' => (bool) $this->mailboxForm['validateCert'],
|
||||||
|
'username' => $this->mailboxForm['username'],
|
||||||
|
'folder' => $this->mailboxForm['folder'],
|
||||||
|
'processed_folder' => $this->mailboxForm['processedFolder'] ?: null,
|
||||||
|
'rejected_folder' => $this->mailboxForm['rejectedFolder'] ?: null,
|
||||||
|
// Exactly one of these (or neither) — never both — driven by the
|
||||||
|
// form's single "cała kategoria albo konkretna podkategoria" selector.
|
||||||
|
'default_subcategory_id' => $targetType === 'subcategory' ? $targetId : null,
|
||||||
|
'default_category_id' => $targetType === 'category' ? $targetId : null,
|
||||||
|
'blocklist_senders' => $this->mailboxForm['blocklistSenders'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$mailbox = ImapMailbox::query()->find($this->mailboxForm['id']);
|
||||||
|
|
||||||
|
if ($mailbox) {
|
||||||
|
if ($this->mailboxForm['password']) {
|
||||||
|
$data['password'] = $this->mailboxForm['password'];
|
||||||
|
}
|
||||||
|
$mailbox->update($data);
|
||||||
|
} else {
|
||||||
|
$data['password'] = $this->mailboxForm['password'];
|
||||||
|
ImapMailbox::query()->create($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->mailboxFormOpen = false;
|
||||||
|
unset($this->mailboxes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleMailboxEnabled(int $id): void
|
||||||
|
{
|
||||||
|
$mailbox = ImapMailbox::query()->findOrFail($id);
|
||||||
|
$mailbox->update(['enabled' => ! $mailbox->enabled]);
|
||||||
|
unset($this->mailboxes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeMailbox(int $id): void
|
||||||
|
{
|
||||||
|
ImapMailbox::query()->findOrFail($id)->delete();
|
||||||
|
unset($this->mailboxes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a real fetch against one mailbox right now, outside the 5-minute
|
||||||
|
* schedule — for checking a freshly-configured mailbox without waiting,
|
||||||
|
* and for diagnosing "why didn't my e-mail turn into a ticket" without
|
||||||
|
* needing shell access. Allowed even while the mailbox is disabled
|
||||||
|
* (fetchAll(), used by the scheduled command, is the one that respects
|
||||||
|
* the enabled flag — this is an explicit admin action).
|
||||||
|
*/
|
||||||
|
public function fetchMailboxNow(int $id): void
|
||||||
|
{
|
||||||
|
$mailbox = ImapMailbox::query()->findOrFail($id);
|
||||||
|
$result = app(ImapMailboxFetcher::class)->fetchMailbox($mailbox);
|
||||||
|
|
||||||
|
$this->mailboxFetchResultId = $id;
|
||||||
|
$this->mailboxFetchSummary = "Nowe: {$result['created']}, odpowiedzi: {$result['replied']}, odrzucone: {$result['rejected']}, błędy: {$result['errors']}.";
|
||||||
|
unset($this->mailboxes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the form's current (unsaved) values against a throwaway
|
||||||
|
* ImapMailbox instance — mirrors testMailConnection()'s "don't require a
|
||||||
|
* save first" behavior. Falls back to the stored password when editing
|
||||||
|
* an existing mailbox and the password field was left blank.
|
||||||
|
*/
|
||||||
|
public function testMailboxConnection(): void
|
||||||
|
{
|
||||||
|
$mailbox = new ImapMailbox([
|
||||||
|
'host' => $this->mailboxForm['host'],
|
||||||
|
'port' => (int) $this->mailboxForm['port'],
|
||||||
|
'encryption' => $this->mailboxForm['encryption'],
|
||||||
|
'validate_cert' => (bool) $this->mailboxForm['validateCert'],
|
||||||
|
'username' => $this->mailboxForm['username'],
|
||||||
|
'folder' => $this->mailboxForm['folder'] ?: 'INBOX',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$mailbox->password = $this->mailboxForm['password']
|
||||||
|
?: ($this->mailboxForm['id'] ? ImapMailbox::query()->find($this->mailboxForm['id'])?->password : null);
|
||||||
|
|
||||||
|
$error = app(ImapMailboxFetcher::class)->testConnection($mailbox);
|
||||||
|
|
||||||
|
$this->mailboxTestResultId = (int) ($this->mailboxForm['id'] ?? 0);
|
||||||
|
$this->mailboxTestResult = $error === null ? 'ok' : 'error';
|
||||||
|
$this->mailboxTestMessage = $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.admin.mail-settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,15 +14,17 @@ use App\Models\SlaRule;
|
|||||||
use App\Models\Status;
|
use App\Models\Status;
|
||||||
use App\Models\Subcategory;
|
use App\Models\Subcategory;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
|
use App\Models\Ticket;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserField;
|
use App\Models\UserField;
|
||||||
|
use App\Services\AiClient;
|
||||||
use App\Services\BookStackClient;
|
use App\Services\BookStackClient;
|
||||||
|
use App\Services\BookStackContentTagger;
|
||||||
use App\Services\LdapUserProvisioner;
|
use App\Services\LdapUserProvisioner;
|
||||||
|
use App\Services\SnipeItClient;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Config;
|
|
||||||
use Illuminate\Support\Facades\Mail;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use LdapRecord\Connection;
|
use LdapRecord\Connection;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
@@ -147,16 +149,38 @@ class Panel extends Component
|
|||||||
|
|
||||||
public ?string $ldapTestResult = null;
|
public ?string $ldapTestResult = null;
|
||||||
|
|
||||||
public array $mailConfig = [];
|
|
||||||
|
|
||||||
public ?string $mailTestResult = null;
|
|
||||||
|
|
||||||
public array $bookstackConfig = [];
|
public array $bookstackConfig = [];
|
||||||
|
|
||||||
public ?string $bookstackTestResult = null;
|
public ?string $bookstackTestResult = null;
|
||||||
|
|
||||||
public ?string $bookstackTestMessage = null;
|
public ?string $bookstackTestMessage = null;
|
||||||
|
|
||||||
|
public ?array $bookstackTagResult = null;
|
||||||
|
|
||||||
|
public ?string $bookstackTagError = null;
|
||||||
|
|
||||||
|
public array $snipeitConfig = [];
|
||||||
|
|
||||||
|
public ?string $snipeitTestResult = null;
|
||||||
|
|
||||||
|
public ?string $snipeitTestMessage = null;
|
||||||
|
|
||||||
|
public array $aiConfig = [];
|
||||||
|
|
||||||
|
public ?string $aiTestResult = null;
|
||||||
|
|
||||||
|
public ?string $aiTestMessage = null;
|
||||||
|
|
||||||
|
public array $aiTriageConfig = [];
|
||||||
|
|
||||||
|
public bool $aiSummaryEnabled = false;
|
||||||
|
|
||||||
|
public bool $aiSummaryRegenerateOnMessage = false;
|
||||||
|
|
||||||
|
public string $aiSummaryPrompt = '';
|
||||||
|
|
||||||
|
public int $aiSummaryPromptVersion = 0;
|
||||||
|
|
||||||
// ---- generic pending-delete confirm ----
|
// ---- generic pending-delete confirm ----
|
||||||
public ?string $pendingDeleteType = null;
|
public ?string $pendingDeleteType = null;
|
||||||
|
|
||||||
@@ -181,10 +205,21 @@ class Panel extends Component
|
|||||||
'attachmentAllowedTypes' => Settings::get('attachment_allowed_types'),
|
'attachmentAllowedTypes' => Settings::get('attachment_allowed_types'),
|
||||||
'sessionLifetimeMinutes' => Settings::get('session_lifetime_minutes'),
|
'sessionLifetimeMinutes' => Settings::get('session_lifetime_minutes'),
|
||||||
'timezone' => Settings::timezone(),
|
'timezone' => Settings::timezone(),
|
||||||
|
'ticketNumberPrefix' => Settings::get('ticket_number_prefix'),
|
||||||
|
'ticketNumberObfuscate' => Settings::bool('ticket_number_obfuscate'),
|
||||||
|
'ticketNumberMinLength' => Settings::get('ticket_number_min_length'),
|
||||||
|
'refreshTicketViewSeconds' => Settings::get('refresh_ticket_view_seconds'),
|
||||||
|
'refreshQueueSeconds' => Settings::get('refresh_queue_seconds'),
|
||||||
|
'refreshNotificationsSeconds' => Settings::get('refresh_notifications_seconds'),
|
||||||
|
'scheduleSlaCheckMinutes' => Settings::get('schedule_sla_check_minutes'),
|
||||||
|
'scheduleAutomationRulesMinutes' => Settings::get('schedule_automation_rules_minutes'),
|
||||||
|
'scheduleImapFetchMinutes' => Settings::get('schedule_imap_fetch_minutes'),
|
||||||
|
'scheduleAiAutomationMinutes' => Settings::get('schedule_ai_automation_minutes'),
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->ldapConfig = [
|
$this->ldapConfig = [
|
||||||
'enabled' => Settings::bool('ldap_enabled'),
|
'enabled' => Settings::bool('ldap_enabled'),
|
||||||
|
'directoryType' => Settings::get('ldap_directory_type', 'lldap'),
|
||||||
'host' => Settings::get('ldap_host'),
|
'host' => Settings::get('ldap_host'),
|
||||||
'port' => Settings::get('ldap_port'),
|
'port' => Settings::get('ldap_port'),
|
||||||
'baseDn' => Settings::get('ldap_base_dn'),
|
'baseDn' => Settings::get('ldap_base_dn'),
|
||||||
@@ -197,17 +232,6 @@ class Panel extends Component
|
|||||||
'restrictTicketsToLdap' => Settings::bool('restrict_tickets_to_ldap'),
|
'restrictTicketsToLdap' => Settings::bool('restrict_tickets_to_ldap'),
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->mailConfig = [
|
|
||||||
'smtpEnabled' => Settings::bool('mail_smtp_enabled'),
|
|
||||||
'smtpHost' => Settings::get('mail_smtp_host'),
|
|
||||||
'smtpPort' => Settings::get('mail_smtp_port'),
|
|
||||||
'smtpUsername' => Settings::get('mail_smtp_username'),
|
|
||||||
'smtpPassword' => Settings::get('mail_smtp_password'),
|
|
||||||
'smtpEncryption' => Settings::get('mail_smtp_encryption'),
|
|
||||||
'fromAddress' => Settings::get('mail_from_address'),
|
|
||||||
'fromName' => Settings::get('mail_from_name'),
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->bookstackConfig = [
|
$this->bookstackConfig = [
|
||||||
'enabled' => Settings::bool('bookstack_enabled'),
|
'enabled' => Settings::bool('bookstack_enabled'),
|
||||||
'baseUrl' => Settings::get('bookstack_base_url'),
|
'baseUrl' => Settings::get('bookstack_base_url'),
|
||||||
@@ -215,10 +239,42 @@ class Panel extends Component
|
|||||||
'tokenSecret' => Settings::get('bookstack_token_secret'),
|
'tokenSecret' => Settings::get('bookstack_token_secret'),
|
||||||
'verifySsl' => Settings::bool('bookstack_verify_ssl'),
|
'verifySsl' => Settings::bool('bookstack_verify_ssl'),
|
||||||
'showToGuests' => Settings::bool('bookstack_show_to_guests'),
|
'showToGuests' => Settings::bool('bookstack_show_to_guests'),
|
||||||
'searchTypes' => Settings::get('bookstack_search_types', 'both'),
|
'searchTypes' => BookStackClient::normalizeSearchTypes(Settings::get('bookstack_search_types', '')),
|
||||||
|
'searchBy' => in_array($searchBy = Settings::get('bookstack_search_by', 'both'), BookStackClient::SEARCH_BY_OPTIONS, true) ? $searchBy : 'both',
|
||||||
'allowedShelfIdsCreation' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_creation', '')),
|
'allowedShelfIdsCreation' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_creation', '')),
|
||||||
'allowedShelfIdsTicketView' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_ticket_view', '')),
|
'allowedShelfIdsTicketView' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_ticket_view', '')),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$this->snipeitConfig = [
|
||||||
|
'enabled' => Settings::bool('snipeit_enabled'),
|
||||||
|
'baseUrl' => Settings::get('snipeit_base_url'),
|
||||||
|
'apiToken' => Settings::get('snipeit_api_token'),
|
||||||
|
'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'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->aiConfig = [
|
||||||
|
'enabled' => Settings::bool('ai_enabled'),
|
||||||
|
'baseUrl' => Settings::get('ai_base_url'),
|
||||||
|
'apiKey' => Settings::get('ai_api_key'),
|
||||||
|
'model' => Settings::get('ai_model'),
|
||||||
|
'verifySsl' => Settings::bool('ai_verify_ssl'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->aiTriageConfig = [
|
||||||
|
'categoryWhenMissing' => Settings::bool('ai_triage_category_when_missing'),
|
||||||
|
'subcategoryWhenCategoryOnly' => Settings::bool('ai_triage_subcategory_when_category_only'),
|
||||||
|
'recheckCategorized' => Settings::bool('ai_triage_recheck_categorized'),
|
||||||
|
'fixSubject' => Settings::bool('ai_triage_fix_subject'),
|
||||||
|
'setPriority' => Settings::bool('ai_triage_set_priority'),
|
||||||
|
];
|
||||||
|
$this->aiSummaryEnabled = Settings::bool('ai_summary_enabled');
|
||||||
|
$this->aiSummaryRegenerateOnMessage = Settings::bool('ai_summary_regenerate_on_message');
|
||||||
|
$this->aiSummaryPrompt = Settings::get('ai_summary_prompt');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setTab(string $tab): void
|
public function setTab(string $tab): void
|
||||||
@@ -283,10 +339,55 @@ class Panel extends Component
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Category::query()->find($categoryId)?->subcategories()->create(['name' => $name]);
|
$category = Category::query()->find($categoryId);
|
||||||
|
|
||||||
|
if (! $category) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextPosition = ((int) $category->subcategories()->max('sort_order')) + 1;
|
||||||
|
$category->subcategories()->create(['name' => $name, 'sort_order' => $nextPosition]);
|
||||||
$this->newSubNames[$categoryId] = '';
|
$this->newSubNames[$categoryId] = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function moveSubcategoryUp(int $subcategoryId): void
|
||||||
|
{
|
||||||
|
$this->swapSubcategoryOrder($subcategoryId, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function moveSubcategoryDown(int $subcategoryId): void
|
||||||
|
{
|
||||||
|
$this->swapSubcategoryOrder($subcategoryId, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function swapSubcategoryOrder(int $subcategoryId, int $direction): void
|
||||||
|
{
|
||||||
|
$sub = Subcategory::query()->find($subcategoryId);
|
||||||
|
|
||||||
|
if (! $sub) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$siblings = Subcategory::query()
|
||||||
|
->where('category_id', $sub->category_id)
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$idx = $siblings->search(fn (Subcategory $s) => $s->id === $sub->id);
|
||||||
|
$swapIdx = $idx + $direction;
|
||||||
|
|
||||||
|
if ($idx === false || $swapIdx < 0 || $swapIdx >= $siblings->count()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$other = $siblings[$swapIdx];
|
||||||
|
[$order, $otherOrder] = [$sub->sort_order, $other->sort_order];
|
||||||
|
|
||||||
|
$sub->update(['sort_order' => $otherOrder]);
|
||||||
|
$other->update(['sort_order' => $order]);
|
||||||
|
}
|
||||||
|
|
||||||
public function openSubcategoryEditForm(int $subcategoryId): void
|
public function openSubcategoryEditForm(int $subcategoryId): void
|
||||||
{
|
{
|
||||||
$sub = Subcategory::query()->with('customFields')->findOrFail($subcategoryId);
|
$sub = Subcategory::query()->with('customFields')->findOrFail($subcategoryId);
|
||||||
@@ -679,6 +780,14 @@ class Panel extends Component
|
|||||||
->values();
|
->values();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function categoriesForSnipeitForm()
|
||||||
|
{
|
||||||
|
return Category::query()->orderBy('name')->get()
|
||||||
|
->map(fn (Category $c) => ['id' => $c->id, 'label' => $c->name])
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
public function openTeamForm(): void
|
public function openTeamForm(): void
|
||||||
{
|
{
|
||||||
$this->teamForm = ['id' => null, 'name' => '', 'memberIds' => [], 'subcategoryIds' => []];
|
$this->teamForm = ['id' => null, 'name' => '', 'memberIds' => [], 'subcategoryIds' => []];
|
||||||
@@ -1357,6 +1466,41 @@ class Panel extends Component
|
|||||||
if (in_array($this->systemConfig['timezone'], \DateTimeZone::listIdentifiers(), true)) {
|
if (in_array($this->systemConfig['timezone'], \DateTimeZone::listIdentifiers(), true)) {
|
||||||
Settings::set('timezone', $this->systemConfig['timezone']);
|
Settings::set('timezone', $this->systemConfig['timezone']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Settings::set('ticket_number_prefix', trim((string) $this->systemConfig['ticketNumberPrefix']));
|
||||||
|
Settings::set('ticket_number_obfuscate', $this->systemConfig['ticketNumberObfuscate'] ? '1' : '0');
|
||||||
|
Settings::set('ticket_number_min_length', (string) max(1, (int) $this->systemConfig['ticketNumberMinLength']));
|
||||||
|
|
||||||
|
Settings::set('refresh_ticket_view_seconds', (string) max(1, (int) $this->systemConfig['refreshTicketViewSeconds']));
|
||||||
|
Settings::set('refresh_queue_seconds', (string) max(1, (int) $this->systemConfig['refreshQueueSeconds']));
|
||||||
|
Settings::set('refresh_notifications_seconds', (string) max(1, (int) $this->systemConfig['refreshNotificationsSeconds']));
|
||||||
|
Settings::set('schedule_sla_check_minutes', (string) max(1, (int) $this->systemConfig['scheduleSlaCheckMinutes']));
|
||||||
|
Settings::set('schedule_automation_rules_minutes', (string) max(1, (int) $this->systemConfig['scheduleAutomationRulesMinutes']));
|
||||||
|
Settings::set('schedule_imap_fetch_minutes', (string) max(1, (int) $this->systemConfig['scheduleImapFetchMinutes']));
|
||||||
|
Settings::set('schedule_ai_automation_minutes', (string) max(1, (int) $this->systemConfig['scheduleAiAutomationMinutes']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live preview for the "Numeracja zgłoszeń" settings — renders a real
|
||||||
|
* ticket's id/number against the form's current (not-yet-saved) values,
|
||||||
|
* so the admin sees exactly how numbers will look before hitting Zapisz.
|
||||||
|
*/
|
||||||
|
#[Computed]
|
||||||
|
public function ticketNumberPreview(): array
|
||||||
|
{
|
||||||
|
$ticket = Ticket::query()->latest('id')->first();
|
||||||
|
$id = $ticket->id ?? 1;
|
||||||
|
$raw = $ticket->number ?? '1001';
|
||||||
|
$checksum = $ticket->checksum ?? Ticket::generateUniqueChecksum($id);
|
||||||
|
$obfuscate = (bool) ($this->systemConfig['ticketNumberObfuscate'] ?? false);
|
||||||
|
$minLength = max(1, (int) ($this->systemConfig['ticketNumberMinLength'] ?? 4));
|
||||||
|
|
||||||
|
$number = $obfuscate ? $checksum : str_pad($raw, $minLength, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $id,
|
||||||
|
'formatted' => trim((string) ($this->systemConfig['ticketNumberPrefix'] ?? '')).$number,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================== LDAP CONFIG =====================
|
// ===================== LDAP CONFIG =====================
|
||||||
@@ -1364,6 +1508,7 @@ class Panel extends Component
|
|||||||
public function saveLdapConfig(): void
|
public function saveLdapConfig(): void
|
||||||
{
|
{
|
||||||
Settings::set('ldap_enabled', $this->ldapConfig['enabled'] ? '1' : '0');
|
Settings::set('ldap_enabled', $this->ldapConfig['enabled'] ? '1' : '0');
|
||||||
|
Settings::set('ldap_directory_type', $this->ldapConfig['directoryType'] === 'ad' ? 'ad' : 'lldap');
|
||||||
Settings::set('ldap_host', $this->ldapConfig['host']);
|
Settings::set('ldap_host', $this->ldapConfig['host']);
|
||||||
Settings::set('ldap_port', (string) $this->ldapConfig['port']);
|
Settings::set('ldap_port', (string) $this->ldapConfig['port']);
|
||||||
Settings::set('ldap_base_dn', $this->ldapConfig['baseDn']);
|
Settings::set('ldap_base_dn', $this->ldapConfig['baseDn']);
|
||||||
@@ -1425,8 +1570,10 @@ class Panel extends Component
|
|||||||
Settings::set('bookstack_verify_ssl', $this->bookstackConfig['verifySsl'] ? '1' : '0');
|
Settings::set('bookstack_verify_ssl', $this->bookstackConfig['verifySsl'] ? '1' : '0');
|
||||||
Settings::set('bookstack_show_to_guests', $this->bookstackConfig['showToGuests'] ? '1' : '0');
|
Settings::set('bookstack_show_to_guests', $this->bookstackConfig['showToGuests'] ? '1' : '0');
|
||||||
|
|
||||||
if (in_array($this->bookstackConfig['searchTypes'], ['both', 'page', 'book'], true)) {
|
Settings::set('bookstack_search_types', implode(',', BookStackClient::normalizeSearchTypes($this->bookstackConfig['searchTypes'])));
|
||||||
Settings::set('bookstack_search_types', $this->bookstackConfig['searchTypes']);
|
|
||||||
|
if (in_array($this->bookstackConfig['searchBy'], BookStackClient::SEARCH_BY_OPTIONS, true)) {
|
||||||
|
Settings::set('bookstack_search_by', $this->bookstackConfig['searchBy']);
|
||||||
}
|
}
|
||||||
|
|
||||||
Settings::set('bookstack_allowed_shelf_ids_creation', implode(',', $this->bookstackConfig['allowedShelfIdsCreation']));
|
Settings::set('bookstack_allowed_shelf_ids_creation', implode(',', $this->bookstackConfig['allowedShelfIdsCreation']));
|
||||||
@@ -1476,6 +1623,15 @@ class Panel extends Component
|
|||||||
: [...$ids, $id];
|
: [...$ids, $id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function toggleBookstackSearchType(string $type): void
|
||||||
|
{
|
||||||
|
$types = $this->bookstackConfig['searchTypes'];
|
||||||
|
|
||||||
|
$this->bookstackConfig['searchTypes'] = in_array($type, $types, true)
|
||||||
|
? array_values(array_diff($types, [$type]))
|
||||||
|
: [...$types, $type];
|
||||||
|
}
|
||||||
|
|
||||||
public function testBookstackConnection(): void
|
public function testBookstackConnection(): void
|
||||||
{
|
{
|
||||||
$cfg = $this->bookstackConfig;
|
$cfg = $this->bookstackConfig;
|
||||||
@@ -1494,73 +1650,157 @@ class Panel extends Component
|
|||||||
$this->bookstackTestMessage = $result['message'];
|
$this->bookstackTestMessage = $result['message'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================== MAIL / SMTP CONFIG =====================
|
|
||||||
|
|
||||||
public function saveMailConfig(): void
|
|
||||||
{
|
|
||||||
Settings::set('mail_smtp_enabled', $this->mailConfig['smtpEnabled'] ? '1' : '0');
|
|
||||||
Settings::set('mail_smtp_host', $this->mailConfig['smtpHost']);
|
|
||||||
Settings::set('mail_smtp_port', (string) $this->mailConfig['smtpPort']);
|
|
||||||
Settings::set('mail_smtp_username', $this->mailConfig['smtpUsername']);
|
|
||||||
|
|
||||||
if ($this->mailConfig['smtpPassword']) {
|
|
||||||
Settings::set('mail_smtp_password', $this->mailConfig['smtpPassword']);
|
|
||||||
}
|
|
||||||
|
|
||||||
Settings::set('mail_smtp_encryption', $this->mailConfig['smtpEncryption']);
|
|
||||||
Settings::set('mail_from_address', $this->mailConfig['fromAddress']);
|
|
||||||
Settings::set('mail_from_name', $this->mailConfig['fromName']);
|
|
||||||
|
|
||||||
$this->mailTestResult = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends a real test e-mail to the logged-in admin using the form's
|
* Runs synchronously in the request (no queue worker runs in this
|
||||||
* current (unsaved) values, temporarily overriding the mail config the
|
* deployment — see CLAUDE.md — so a dispatched job would just sit in the
|
||||||
* same way AppServiceProvider does for real once saved — so this test
|
* `jobs` table). Safe to click again if it times out on a large wiki:
|
||||||
* exercises the exact path production notifications will use.
|
* every write is idempotent, so a re-run just skips whatever already got
|
||||||
|
* tagged (or, for the --force variant, re-classifies from scratch).
|
||||||
*/
|
*/
|
||||||
public function testMailConnection(): void
|
public function runBookstackTagging(bool $force = false): void
|
||||||
{
|
{
|
||||||
$cfg = $this->mailConfig;
|
if (! app(BookStackClient::class)->enabled() || ! app(AiClient::class)->enabled()) {
|
||||||
|
$this->bookstackTagResult = null;
|
||||||
if (empty($cfg['smtpHost']) || empty($cfg['fromAddress'])) {
|
$this->bookstackTagError = 'Włącz i skonfiguruj obie integracje — BookStack oraz AI — przed uruchomieniem tagowania.';
|
||||||
$this->mailTestResult = 'error';
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$original = Config::get('mail');
|
$this->bookstackTagError = null;
|
||||||
|
|
||||||
try {
|
set_time_limit(0);
|
||||||
Config::set('mail.default', 'smtp');
|
$this->bookstackTagResult = app(BookStackContentTagger::class)->run(force: $force);
|
||||||
Config::set('mail.mailers.smtp.host', $cfg['smtpHost']);
|
|
||||||
Config::set('mail.mailers.smtp.port', (int) $cfg['smtpPort']);
|
|
||||||
Config::set('mail.mailers.smtp.username', $cfg['smtpUsername'] ?: null);
|
|
||||||
Config::set('mail.mailers.smtp.password', $cfg['smtpPassword'] ?: Settings::get('mail_smtp_password'));
|
|
||||||
Config::set('mail.mailers.smtp.scheme', match ($cfg['smtpEncryption']) {
|
|
||||||
'ssl' => 'smtps',
|
|
||||||
'tls' => 'smtp',
|
|
||||||
default => null,
|
|
||||||
});
|
|
||||||
Config::set('mail.from.address', $cfg['fromAddress']);
|
|
||||||
Config::set('mail.from.name', $cfg['fromName'] ?: Settings::get('company_name'));
|
|
||||||
|
|
||||||
app()->forgetInstance('mail.manager');
|
|
||||||
app()->forgetInstance('mailer');
|
|
||||||
|
|
||||||
Mail::raw('To jest testowa wiadomość wysłana z panelu administratora Servicedesk.', function ($message) {
|
|
||||||
$message->to(Auth::user()->email)->subject('Test konfiguracji SMTP');
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->mailTestResult = 'ok';
|
|
||||||
} catch (\Throwable) {
|
|
||||||
$this->mailTestResult = 'error';
|
|
||||||
} finally {
|
|
||||||
Config::set('mail', $original);
|
|
||||||
app()->forgetInstance('mail.manager');
|
|
||||||
app()->forgetInstance('mailer');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function runBookstackTaggingForce(): void
|
||||||
|
{
|
||||||
|
$this->runBookstackTagging(force: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SNIPE-IT CONFIG =====================
|
||||||
|
|
||||||
|
public function saveSnipeitConfig(): void
|
||||||
|
{
|
||||||
|
Settings::set('snipeit_enabled', $this->snipeitConfig['enabled'] ? '1' : '0');
|
||||||
|
Settings::set('snipeit_base_url', $this->snipeitConfig['baseUrl']);
|
||||||
|
|
||||||
|
if ($this->snipeitConfig['apiToken']) {
|
||||||
|
Settings::set('snipeit_api_token', $this->snipeitConfig['apiToken']);
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
$this->snipeitTestResult = null;
|
||||||
|
$this->snipeitTestMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleSnipeitClientSubcategory(int $id): void
|
||||||
|
{
|
||||||
|
$ids = $this->snipeitConfig['clientAssetSubcategoryIds'];
|
||||||
|
|
||||||
|
$this->snipeitConfig['clientAssetSubcategoryIds'] = in_array($id, $ids, true)
|
||||||
|
? array_values(array_diff($ids, [$id]))
|
||||||
|
: [...$ids, $id];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The category-level counterpart to toggleSnipeitClientSubcategory() —
|
||||||
|
* a coarser allow-list for admins who want the picker on for every
|
||||||
|
* subcategory of a category at once, without ticking each one
|
||||||
|
* individually. NewTicket::snipeitAssets() allows a ticket through if
|
||||||
|
* its category OR its subcategory is on either list.
|
||||||
|
*/
|
||||||
|
public function toggleSnipeitClientCategory(int $id): void
|
||||||
|
{
|
||||||
|
$ids = $this->snipeitConfig['clientAssetCategoryIds'];
|
||||||
|
|
||||||
|
$this->snipeitConfig['clientAssetCategoryIds'] = in_array($id, $ids, true)
|
||||||
|
? array_values(array_diff($ids, [$id]))
|
||||||
|
: [...$ids, $id];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSnipeitConnection(): void
|
||||||
|
{
|
||||||
|
$cfg = $this->snipeitConfig;
|
||||||
|
|
||||||
|
if (empty($cfg['baseUrl'])) {
|
||||||
|
$this->snipeitTestResult = 'error';
|
||||||
|
$this->snipeitTestMessage = 'Uzupełnij adres API.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $cfg['apiToken'] ?: Settings::get('snipeit_api_token');
|
||||||
|
$result = app(SnipeItClient::class)->testConnection($cfg['baseUrl'], $token ?? '', ! $cfg['skipSslVerification']);
|
||||||
|
|
||||||
|
$this->snipeitTestResult = $result['ok'] ? 'ok' : 'error';
|
||||||
|
$this->snipeitTestMessage = $result['message'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== AI CONFIG =====================
|
||||||
|
|
||||||
|
public function saveAiConfig(): void
|
||||||
|
{
|
||||||
|
Settings::set('ai_enabled', $this->aiConfig['enabled'] ? '1' : '0');
|
||||||
|
Settings::set('ai_base_url', $this->aiConfig['baseUrl']);
|
||||||
|
|
||||||
|
if ($this->aiConfig['apiKey']) {
|
||||||
|
Settings::set('ai_api_key', $this->aiConfig['apiKey']);
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings::set('ai_model', $this->aiConfig['model']);
|
||||||
|
Settings::set('ai_verify_ssl', $this->aiConfig['verifySsl'] ? '1' : '0');
|
||||||
|
|
||||||
|
$this->aiTestResult = null;
|
||||||
|
$this->aiTestMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAiConnection(): void
|
||||||
|
{
|
||||||
|
$cfg = $this->aiConfig;
|
||||||
|
|
||||||
|
if (empty($cfg['baseUrl']) || empty($cfg['model'])) {
|
||||||
|
$this->aiTestResult = 'error';
|
||||||
|
$this->aiTestMessage = 'Uzupełnij adres API i nazwę modelu.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiKey = $cfg['apiKey'] ?: Settings::get('ai_api_key');
|
||||||
|
$result = app(AiClient::class)->testConnection($cfg['baseUrl'], $apiKey ?? '', $cfg['model'], (bool) $cfg['verifySsl']);
|
||||||
|
|
||||||
|
$this->aiTestResult = $result['ok'] ? 'ok' : 'error';
|
||||||
|
$this->aiTestMessage = $result['message'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveAiTriageConfig(): void
|
||||||
|
{
|
||||||
|
Settings::set('ai_triage_category_when_missing', $this->aiTriageConfig['categoryWhenMissing'] ? '1' : '0');
|
||||||
|
Settings::set('ai_triage_subcategory_when_category_only', $this->aiTriageConfig['subcategoryWhenCategoryOnly'] ? '1' : '0');
|
||||||
|
Settings::set('ai_triage_recheck_categorized', $this->aiTriageConfig['recheckCategorized'] ? '1' : '0');
|
||||||
|
Settings::set('ai_triage_fix_subject', $this->aiTriageConfig['fixSubject'] ? '1' : '0');
|
||||||
|
Settings::set('ai_triage_set_priority', $this->aiTriageConfig['setPriority'] ? '1' : '0');
|
||||||
|
Settings::set('ai_summary_enabled', $this->aiSummaryEnabled ? '1' : '0');
|
||||||
|
Settings::set('ai_summary_regenerate_on_message', $this->aiSummaryRegenerateOnMessage ? '1' : '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveAiSummaryPrompt(string $value): void
|
||||||
|
{
|
||||||
|
Settings::set('ai_summary_prompt', $value);
|
||||||
|
$this->aiSummaryPrompt = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetAiSummaryPrompt(): void
|
||||||
|
{
|
||||||
|
$default = Settings::default('ai_summary_prompt');
|
||||||
|
Settings::set('ai_summary_prompt', $default);
|
||||||
|
$this->aiSummaryPrompt = $default;
|
||||||
|
$this->aiSummaryPromptVersion++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================== GENERIC DELETE CONFIRM =====================
|
// ===================== GENERIC DELETE CONFIRM =====================
|
||||||
@@ -1602,8 +1842,38 @@ class Panel extends Component
|
|||||||
$this->cancelPendingDelete();
|
$this->cancelPendingDelete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the labels in the $tabGroups array built inline in
|
||||||
|
* admin/panel.blade.php (icons/grouping live only there — this is just
|
||||||
|
* the page-title-sized subset, not worth threading the whole structure
|
||||||
|
* through the PHP side for).
|
||||||
|
*/
|
||||||
|
private const TAB_LABELS = [
|
||||||
|
'categories' => 'Kategorie',
|
||||||
|
'fields' => 'Pola dodatkowe',
|
||||||
|
'statuses' => 'Statusy',
|
||||||
|
'priorities' => 'Priorytety i SLA',
|
||||||
|
'reply-quick-actions' => 'Szybkie akcje odpowiedzi',
|
||||||
|
'response-templates' => 'Szablony odpowiedzi',
|
||||||
|
'automation-rules' => 'Automatyzacja SLA',
|
||||||
|
'triggers' => 'Wyzwalacze',
|
||||||
|
'users' => 'Użytkownicy',
|
||||||
|
'teams' => 'Zespoły',
|
||||||
|
'user-fields' => 'Pola dodatkowe',
|
||||||
|
'templates' => 'Szablony e-mail',
|
||||||
|
'email' => 'Poczta',
|
||||||
|
'branding' => 'Wygląd i branding',
|
||||||
|
'config' => 'Konfiguracja',
|
||||||
|
'integrations' => 'Integracje',
|
||||||
|
'api-keys' => 'Klucze API',
|
||||||
|
'logs' => 'Logi',
|
||||||
|
'about' => 'O aplikacji',
|
||||||
|
];
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.admin.panel');
|
$tabLabel = self::TAB_LABELS[$this->tab] ?? 'Panel administratora';
|
||||||
|
|
||||||
|
return view('livewire.admin.panel')->title(Settings::pageTitle($tabLabel));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,6 @@ class Login extends Component
|
|||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.auth.login');
|
return view('livewire.auth.login')->title(Settings::pageTitle('Logowanie'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,45 +3,75 @@
|
|||||||
namespace App\Livewire\Client;
|
namespace App\Livewire\Client;
|
||||||
|
|
||||||
use App\Models\Status;
|
use App\Models\Status;
|
||||||
|
use App\Support\Settings;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
class Dashboard extends Component
|
class Dashboard extends Component
|
||||||
{
|
{
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
private const PER_PAGE = 20;
|
||||||
|
|
||||||
|
#[Url]
|
||||||
public string $tab = 'current';
|
public string $tab = 'current';
|
||||||
|
|
||||||
public string $search = '';
|
public string $search = '';
|
||||||
|
|
||||||
#[Computed]
|
/**
|
||||||
public function tickets()
|
* 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()
|
return Auth::user()->ticketsAsCustomer()
|
||||||
->search($this->search)
|
->search($this->search)
|
||||||
->with('subcategory.category')
|
->with('subcategory.category')
|
||||||
->orderByDesc('updated_at')
|
->orderByDesc('created_at');
|
||||||
->get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Separate named paginators (see pageName below) so switching tabs
|
||||||
|
* doesn't reset whichever page the other tab was on.
|
||||||
|
*/
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function currentTickets()
|
public function currentTickets()
|
||||||
{
|
{
|
||||||
return $this->tickets->whereNotIn('status_key', Status::closedKeys());
|
return $this->baseQuery()->whereNotIn('status_key', Status::closedKeys())
|
||||||
|
->paginate(self::PER_PAGE, pageName: 'currentPage');
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function archiveTickets()
|
public function archiveTickets()
|
||||||
{
|
{
|
||||||
return $this->tickets->whereIn('status_key', Status::closedKeys());
|
return $this->baseQuery()->whereIn('status_key', Status::closedKeys())
|
||||||
|
->paginate(self::PER_PAGE, pageName: 'archivePage');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedSearch(): void
|
||||||
|
{
|
||||||
|
$this->resetPage('currentPage');
|
||||||
|
$this->resetPage('archivePage');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setTab(string $tab): void
|
public function setTab(string $tab): void
|
||||||
{
|
{
|
||||||
$this->tab = $tab;
|
$this->tab = $tab;
|
||||||
|
session(['client_dashboard_tab' => $tab]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.client.dashboard');
|
return view('livewire.client.dashboard')->title(Settings::pageTitle('Moje zgłoszenia'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Livewire\Client;
|
|||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Subcategory;
|
use App\Models\Subcategory;
|
||||||
use App\Services\BookStackClient;
|
use App\Services\BookStackClient;
|
||||||
|
use App\Services\SnipeItClient;
|
||||||
use App\Services\TicketService;
|
use App\Services\TicketService;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -40,6 +41,82 @@ class NewTicket extends Component
|
|||||||
$this->suggestedArticlesLoaded = true;
|
$this->suggestedArticlesLoaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same wire:init-deferred pattern as suggestedArticlesLoaded above,
|
||||||
|
// for the Snipe-IT "Twój sprzęt" picker.
|
||||||
|
public bool $snipeitAssetsLoaded = false;
|
||||||
|
|
||||||
|
public ?int $selectedSnipeitAssetId = null;
|
||||||
|
|
||||||
|
public function loadSnipeitAssets(): void
|
||||||
|
{
|
||||||
|
$this->snipeitAssetsLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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}>
|
||||||
|
*/
|
||||||
|
#[Computed]
|
||||||
|
public function snipeitAssets(): array
|
||||||
|
{
|
||||||
|
if (! $this->snipeitAssetsLoaded || ! Settings::bool('snipeit_client_can_select_asset') || ! $this->subcategoryId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$subcategoryAllowed = in_array($this->subcategoryId, $this->snipeitAllowedSubcategoryIds(), true);
|
||||||
|
$categoryAllowed = $this->categoryId && in_array($this->categoryId, $this->snipeitAllowedCategoryIds(), true);
|
||||||
|
|
||||||
|
if (! $subcategoryAllowed && ! $categoryAllowed) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(SnipeItClient::class)->assetsForEmail(Auth::user()->email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
protected function snipeitAllowedSubcategoryIds(): array
|
||||||
|
{
|
||||||
|
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()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectSnipeitAsset(int $id): void
|
||||||
|
{
|
||||||
|
if (! Settings::bool('snipeit_client_can_select_asset')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->selectedSnipeitAssetId = $this->selectedSnipeitAssetId === $id ? null : $id;
|
||||||
|
}
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function categories()
|
public function categories()
|
||||||
{
|
{
|
||||||
@@ -62,12 +139,14 @@ class NewTicket extends Component
|
|||||||
{
|
{
|
||||||
$this->categoryId = $id;
|
$this->categoryId = $id;
|
||||||
$this->subcategoryId = null;
|
$this->subcategoryId = null;
|
||||||
|
$this->selectedSnipeitAssetId = null;
|
||||||
$this->step = 2;
|
$this->step = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function selectSubcategory(int $id): void
|
public function selectSubcategory(int $id): void
|
||||||
{
|
{
|
||||||
$this->subcategoryId = $id;
|
$this->subcategoryId = $id;
|
||||||
|
$this->selectedSnipeitAssetId = null;
|
||||||
$this->step = 3;
|
$this->step = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,8 +161,9 @@ class NewTicket extends Component
|
|||||||
}
|
}
|
||||||
|
|
||||||
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
||||||
|
$tagQuery = trim($this->selectedSubcategory?->name ?? '');
|
||||||
|
|
||||||
return app(BookStackClient::class)->search($query);
|
return app(BookStackClient::class)->search($query, tagQuery: $tagQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function backToCategory(): void
|
public function backToCategory(): void
|
||||||
@@ -129,12 +209,18 @@ class NewTicket extends Component
|
|||||||
|
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
|
$selectedAsset = $this->selectedSnipeitAssetId
|
||||||
|
? collect($this->snipeitAssets)->firstWhere('id', $this->selectedSnipeitAssetId)
|
||||||
|
: null;
|
||||||
|
|
||||||
$ticket = app(TicketService::class)->create([
|
$ticket = app(TicketService::class)->create([
|
||||||
'email' => $user->email,
|
'email' => $user->email,
|
||||||
'subcategory_id' => $this->subcategoryId,
|
'subcategory_id' => $this->subcategoryId,
|
||||||
'subject' => $this->subject,
|
'subject' => $this->subject,
|
||||||
'body' => $this->body,
|
'body' => $this->body,
|
||||||
'custom_values' => $this->customValues,
|
'custom_values' => $this->customValues,
|
||||||
|
'snipeit_asset_id' => $selectedAsset['id'] ?? null,
|
||||||
|
'snipeit_asset_name' => $selectedAsset['label'] ?? null,
|
||||||
], $user);
|
], $user);
|
||||||
|
|
||||||
app(TicketService::class)->attachFiles($ticket, $ticket->messages()->first(), $this->attachments);
|
app(TicketService::class)->attachFiles($ticket, $ticket->messages()->first(), $this->attachments);
|
||||||
@@ -144,6 +230,6 @@ class NewTicket extends Component
|
|||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.client.new-ticket');
|
return view('livewire.client.new-ticket')->title(Settings::pageTitle('Nowe zgłoszenie'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ class TicketShow extends Component
|
|||||||
|
|
||||||
public string $csatComment = '';
|
public string $csatComment = '';
|
||||||
|
|
||||||
|
public bool $showAllOtherTickets = false;
|
||||||
|
|
||||||
|
private const OTHER_TICKETS_PREVIEW_COUNT = 5;
|
||||||
|
|
||||||
// Set via wire:init (see the blade view) rather than on the initial
|
// Set via wire:init (see the blade view) rather than on the initial
|
||||||
// render, so the BookStack HTTP call in suggestedArticles() never
|
// render, so the BookStack HTTP call in suggestedArticles() never
|
||||||
// delays the ticket page's first paint — it loads in a beat later instead.
|
// delays the ticket page's first paint — it loads in a beat later instead.
|
||||||
@@ -103,7 +107,21 @@ class TicketShow extends Component
|
|||||||
#[Computed]
|
#[Computed]
|
||||||
public function otherTickets()
|
public function otherTickets()
|
||||||
{
|
{
|
||||||
return Auth::user()->ticketsAsCustomer()->where('id', '!=', $this->ticket->id)->get();
|
$query = Auth::user()->ticketsAsCustomer()->where('id', '!=', $this->ticket->id)->latest();
|
||||||
|
|
||||||
|
return $this->showAllOtherTickets ? $query->get() : $query->take(self::OTHER_TICKETS_PREVIEW_COUNT)->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function otherTicketsCount(): int
|
||||||
|
{
|
||||||
|
return Auth::user()->ticketsAsCustomer()->where('id', '!=', $this->ticket->id)->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revealAllOtherTickets(): void
|
||||||
|
{
|
||||||
|
$this->showAllOtherTickets = true;
|
||||||
|
unset($this->otherTickets);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -122,8 +140,9 @@ class TicketShow extends Component
|
|||||||
|
|
||||||
$subcategory = $this->ticket->subcategory;
|
$subcategory = $this->ticket->subcategory;
|
||||||
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
|
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
|
||||||
|
$tagQuery = trim($subcategory?->name ?? '');
|
||||||
|
|
||||||
return app(BookStackClient::class)->search($query);
|
return app(BookStackClient::class)->search($query, tagQuery: $tagQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatedAttachments(): void
|
public function updatedAttachments(): void
|
||||||
@@ -221,6 +240,6 @@ class TicketShow extends Component
|
|||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.client.ticket-show');
|
return view('livewire.client.ticket-show')->title(Settings::pageTitle($this->ticket->displayNumber().' — '.$this->ticket->subject));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
141
src/app/Livewire/GlobalSearch.php
Normal file
141
src/app/Livewire/GlobalSearch.php
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class GlobalSearch extends Component
|
||||||
|
{
|
||||||
|
public string $search = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gmail-style "field:value" operators — recognized keys (accent-
|
||||||
|
* insensitive, a couple of Polish synonyms each) narrow the match to
|
||||||
|
* that one field; anything left over after stripping them still runs
|
||||||
|
* through the broad Ticket::scopeSearch() default. Multiple operators
|
||||||
|
* combine with AND, same as Gmail's "from:x subject:y".
|
||||||
|
*/
|
||||||
|
private const FIELD_PREFIXES = [
|
||||||
|
'od' => 'from',
|
||||||
|
'nadawca' => 'from',
|
||||||
|
'temat' => 'subject',
|
||||||
|
'tytul' => 'subject',
|
||||||
|
'tytuł' => 'subject',
|
||||||
|
'tresc' => 'body',
|
||||||
|
'treść' => 'body',
|
||||||
|
'numer' => 'number',
|
||||||
|
'nr' => 'number',
|
||||||
|
];
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function results()
|
||||||
|
{
|
||||||
|
$term = trim($this->search);
|
||||||
|
|
||||||
|
if ($term === '' || ! ($user = Auth::user())) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = $this->baseQuery($user);
|
||||||
|
|
||||||
|
if (! $query) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
[$fields, $free] = $this->parseQuery($term);
|
||||||
|
|
||||||
|
foreach ($fields as $field => $values) {
|
||||||
|
foreach ($values as $value) {
|
||||||
|
$this->applyFieldFilter($query, $field, $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// search() itself no-ops on an empty term, so this is safe to call
|
||||||
|
// unconditionally — it only matters when there was no operator at
|
||||||
|
// all, or an operator left some free text behind.
|
||||||
|
$query->search($free);
|
||||||
|
|
||||||
|
return $query->orderByDesc('updated_at')->limit(8)->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits "od:kacper temat:drukarka reszta" into recognized field
|
||||||
|
* operators plus whatever free text is left over. An unrecognized
|
||||||
|
* "key:value" (e.g. a pasted URL) is left untouched in the free text
|
||||||
|
* rather than silently dropped.
|
||||||
|
*/
|
||||||
|
private function parseQuery(string $term): array
|
||||||
|
{
|
||||||
|
$fields = [];
|
||||||
|
|
||||||
|
$free = preg_replace_callback(
|
||||||
|
'/(\pL+):(\S+)/u',
|
||||||
|
function ($m) use (&$fields) {
|
||||||
|
$key = mb_strtolower($m[1]);
|
||||||
|
|
||||||
|
if (! isset(self::FIELD_PREFIXES[$key])) {
|
||||||
|
return $m[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields[self::FIELD_PREFIXES[$key]][] = $m[2];
|
||||||
|
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
$term
|
||||||
|
);
|
||||||
|
|
||||||
|
return [$fields, trim(preg_replace('/\s+/', ' ', $free))];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyFieldFilter($query, string $field, string $value): void
|
||||||
|
{
|
||||||
|
$like = '%'.$value.'%';
|
||||||
|
|
||||||
|
match ($field) {
|
||||||
|
'from' => $query->where(fn ($q) => $q->where('name', 'like', $like)->orWhere('email', 'like', $like)),
|
||||||
|
'subject' => $query->where('subject', 'like', $like),
|
||||||
|
'number' => $query->where('number', 'like', $like),
|
||||||
|
'body' => $query->where(fn ($q) => $q->where('body', 'like', $like)
|
||||||
|
->orWhereIn('id', DB::table('ticket_messages')->where('body', 'like', $like)->pluck('ticket_id'))),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function urlFor(Ticket $ticket): string
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
return $user->isOperator()
|
||||||
|
? route('operator.ticket', $ticket)
|
||||||
|
: route('client.ticket', $ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scoped (and routed, see urlFor()) by the operator role specifically
|
||||||
|
* rather than isAdmin() — admin alone doesn't grant the operator.*
|
||||||
|
* routes (see routes/web.php's role:operator middleware), so a result
|
||||||
|
* pointing there would 404 for an admin-only account.
|
||||||
|
*/
|
||||||
|
protected function baseQuery(User $user)
|
||||||
|
{
|
||||||
|
if ($user->isOperator()) {
|
||||||
|
return Ticket::query()->visibleToOperator($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->isClient()) {
|
||||||
|
return $user->ticketsAsCustomer();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.global-search');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ use App\Services\BookStackClient;
|
|||||||
use App\Services\LdapUserProvisioner;
|
use App\Services\LdapUserProvisioner;
|
||||||
use App\Services\TicketService;
|
use App\Services\TicketService;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithFileUploads;
|
use Livewire\WithFileUploads;
|
||||||
@@ -41,6 +42,13 @@ class Landing extends Component
|
|||||||
// delays the page's first paint — it loads in a beat later instead.
|
// delays the page's first paint — it loads in a beat later instead.
|
||||||
public bool $suggestedArticlesLoaded = false;
|
public bool $suggestedArticlesLoaded = false;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
if (Auth::check()) {
|
||||||
|
$this->redirect(route('client.dashboard'), navigate: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function loadSuggestedArticles(): void
|
public function loadSuggestedArticles(): void
|
||||||
{
|
{
|
||||||
$this->suggestedArticlesLoaded = true;
|
$this->suggestedArticlesLoaded = true;
|
||||||
@@ -100,8 +108,9 @@ class Landing extends Component
|
|||||||
}
|
}
|
||||||
|
|
||||||
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
||||||
|
$tagQuery = trim($this->selectedSubcategory?->name ?? '');
|
||||||
|
|
||||||
return app(BookStackClient::class)->search($query);
|
return app(BookStackClient::class)->search($query, tagQuery: $tagQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function backToCategory(): void
|
public function backToCategory(): void
|
||||||
@@ -186,6 +195,8 @@ class Landing extends Component
|
|||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.landing');
|
$context = $this->submittedTicketId ? 'Zgłoszenie utworzone' : 'Zgłoś problem';
|
||||||
|
|
||||||
|
return view('livewire.landing')->title(Settings::pageTitle($context));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
51
src/app/Livewire/Operator/ClientSearch.php
Normal file
51
src/app/Livewire/Operator/ClientSearch.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Operator;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Settings;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class ClientSearch extends Component
|
||||||
|
{
|
||||||
|
#[Url]
|
||||||
|
public string $search = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A search box, not a browse-everything list — capped rather than
|
||||||
|
* paginated, same reasoning as the debounced search inputs elsewhere
|
||||||
|
* (queue/dashboard): once there's a match count this large, the fix is a
|
||||||
|
* narrower search term, not another page to click through.
|
||||||
|
*/
|
||||||
|
private const MAX_RESULTS = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches by name or e-mail across every account, not just role=client —
|
||||||
|
* a ticket's customer_id can point at any user (e.g. an operator who
|
||||||
|
* also filed a ticket), so narrowing to clients only would hide valid
|
||||||
|
* results. Role badges in the view make it clear who's who.
|
||||||
|
*/
|
||||||
|
#[Computed]
|
||||||
|
public function results()
|
||||||
|
{
|
||||||
|
$term = trim($this->search);
|
||||||
|
|
||||||
|
if ($term === '') {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return User::query()
|
||||||
|
->where(fn ($q) => $q->where('name', 'like', "%{$term}%")->orWhere('email', 'like', "%{$term}%"))
|
||||||
|
->withCount('ticketsAsCustomer')
|
||||||
|
->orderBy('name')
|
||||||
|
->limit(self::MAX_RESULTS)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.operator.client-search')->title(Settings::pageTitle('Klienci'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,8 +91,9 @@ class NewTicket extends Component
|
|||||||
}
|
}
|
||||||
|
|
||||||
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
||||||
|
$tagQuery = trim($this->selectedSubcategory?->name ?? '');
|
||||||
|
|
||||||
return app(BookStackClient::class)->search($query);
|
return app(BookStackClient::class)->search($query, tagQuery: $tagQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function backToCategory(): void
|
public function backToCategory(): void
|
||||||
@@ -159,6 +160,6 @@ class NewTicket extends Component
|
|||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.operator.new-ticket');
|
return view('livewire.operator.new-ticket')->title(Settings::pageTitle('Nowe zgłoszenie'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,30 @@ use App\Models\Team;
|
|||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\TicketService;
|
use App\Services\TicketService;
|
||||||
|
use App\Support\Settings;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
use Livewire\Attributes\On;
|
use Livewire\Attributes\On;
|
||||||
use Livewire\Attributes\Url;
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
class Queue extends Component
|
class Queue extends Component
|
||||||
{
|
{
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rows per page for the ticket table below. Sorting (see sortTickets())
|
||||||
|
* still happens in PHP over the full filtered result — several sortable
|
||||||
|
* columns (kategoria, przypisany, zespół...) are derived labels with no
|
||||||
|
* single backing SQL column — so this slices the already-sorted
|
||||||
|
* collection rather than using a query-level ->paginate(). That still
|
||||||
|
* bounds how many rows ever hit the DOM at once, which is what actually
|
||||||
|
* mattered once the ticket count grew into the thousands.
|
||||||
|
*/
|
||||||
|
private const PER_PAGE = 50;
|
||||||
|
|
||||||
#[Url]
|
#[Url]
|
||||||
public string $queue = 'all';
|
public string $queue = 'all';
|
||||||
|
|
||||||
@@ -32,12 +48,12 @@ class Queue extends Component
|
|||||||
|
|
||||||
public string $search = '';
|
public string $search = '';
|
||||||
|
|
||||||
public string $sortBy = 'updated_at';
|
public string $sortBy = 'created';
|
||||||
|
|
||||||
public string $sortDir = 'desc';
|
public string $sortDir = 'desc';
|
||||||
|
|
||||||
/** @var string[] */
|
/** @var string[] */
|
||||||
public array $visibleColumns = ['number', 'subject', 'customer', 'category', 'priority', 'status', 'sla', 'assignee'];
|
public array $visibleColumns = ['id', 'number', 'subject', 'customer', 'category', 'priority', 'status', 'sla', 'assignee'];
|
||||||
|
|
||||||
/** @var int[] */
|
/** @var int[] */
|
||||||
public array $selectedIds = [];
|
public array $selectedIds = [];
|
||||||
@@ -56,7 +72,15 @@ class Queue extends Component
|
|||||||
*/
|
*/
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
|
$savedColumns = Auth::user()->operator_queue_columns;
|
||||||
|
|
||||||
|
if (is_array($savedColumns) && $savedColumns !== []) {
|
||||||
|
$this->visibleColumns = array_values(array_intersect($savedColumns, array_keys($this->columnDefs())));
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->savedViewId !== null) {
|
if ($this->savedViewId !== null) {
|
||||||
|
$this->rememberQueueTab();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +90,20 @@ class Queue extends Component
|
|||||||
$this->applyViewFilters($default->filters);
|
$this->applyViewFilters($default->filters);
|
||||||
$this->savedViewId = $default->id;
|
$this->savedViewId = $default->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->rememberQueueTab();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session-only (not the per-user `operator_queue_columns` column — a tab
|
||||||
|
* selection is transient, not a durable preference): lets "Wróć do
|
||||||
|
* listy" on the ticket-detail page return to whichever queue tab was
|
||||||
|
* actually active, instead of always resetting to the "Otwarte" default.
|
||||||
|
* See TicketShow's back-link, which reads this same key.
|
||||||
|
*/
|
||||||
|
private function rememberQueueTab(): void
|
||||||
|
{
|
||||||
|
session(['operator_queue_tab' => $this->queue]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -129,6 +167,33 @@ class Queue extends Component
|
|||||||
$this->sortDir = $filters['sortDir'] ?? $this->sortDir;
|
$this->sortDir = $filters['sortDir'] ?? $this->sortDir;
|
||||||
$this->visibleColumns = $filters['visibleColumns'] ?? $this->visibleColumns;
|
$this->visibleColumns = $filters['visibleColumns'] ?? $this->visibleColumns;
|
||||||
$this->selectedIds = [];
|
$this->selectedIds = [];
|
||||||
|
$this->resetPage();
|
||||||
|
$this->rememberQueueTab();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Any change to a filter/search/queue-tab input can shrink the result
|
||||||
|
* set out from under whatever page the operator was on — snap back to
|
||||||
|
* page 1 rather than showing an empty table.
|
||||||
|
*/
|
||||||
|
public function updatedSearch(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedFilterStatus(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedFilterPriority(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedFilterCategory(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function saveCurrentView(): void
|
public function saveCurrentView(): void
|
||||||
@@ -285,6 +350,21 @@ class Queue extends Component
|
|||||||
return $groups;
|
return $groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last few tickets this operator actually opened (see
|
||||||
|
* Ticket::recordViewBy(), called from TicketShow::mount()) — re-scoped
|
||||||
|
* through visibleToOperator() in case a team reassignment since the
|
||||||
|
* view happened would now hide it from them.
|
||||||
|
*/
|
||||||
|
#[Computed]
|
||||||
|
public function recentlyViewed()
|
||||||
|
{
|
||||||
|
return Auth::user()->recentlyViewedTickets()
|
||||||
|
->visibleToOperator(Auth::user())
|
||||||
|
->take(6)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function filteredTickets()
|
public function filteredTickets()
|
||||||
{
|
{
|
||||||
@@ -297,8 +377,16 @@ class Queue extends Component
|
|||||||
if ($this->filterPriority !== 'all') {
|
if ($this->filterPriority !== 'all') {
|
||||||
$query->where('priority_key', $this->filterPriority);
|
$query->where('priority_key', $this->filterPriority);
|
||||||
}
|
}
|
||||||
if ($this->filterCategory !== 'all') {
|
if ($this->filterCategory === 'none') {
|
||||||
$query->whereHas('subcategory', fn ($q) => $q->where('category_id', $this->filterCategory));
|
$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.
|
||||||
|
$query->where(function ($q) {
|
||||||
|
$q->whereHas('subcategory', fn ($sq) => $sq->where('category_id', $this->filterCategory))
|
||||||
|
->orWhere('category_id', $this->filterCategory);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if ($this->filterCustomerId) {
|
if ($this->filterCustomerId) {
|
||||||
$query->where('customer_id', $this->filterCustomerId);
|
$query->where('customer_id', $this->filterCustomerId);
|
||||||
@@ -307,9 +395,16 @@ class Queue extends Component
|
|||||||
$query->search($this->search);
|
$query->search($this->search);
|
||||||
}
|
}
|
||||||
|
|
||||||
$tickets = $query->with(['subcategory.category', 'assignee', 'priority', 'status', 'team'])->get();
|
$tickets = $query->with(['subcategory.category', 'category', 'assignee', 'priority', 'status', 'team'])->get();
|
||||||
|
$sorted = $this->sortTickets($tickets);
|
||||||
|
|
||||||
return $this->sortTickets($tickets);
|
return new LengthAwarePaginator(
|
||||||
|
$sorted->forPage($this->getPage(), self::PER_PAGE)->values(),
|
||||||
|
$sorted->count(),
|
||||||
|
self::PER_PAGE,
|
||||||
|
$this->getPage(),
|
||||||
|
['path' => request()->url()],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -324,17 +419,21 @@ class Queue extends Component
|
|||||||
$desc = $this->sortDir === 'desc';
|
$desc = $this->sortDir === 'desc';
|
||||||
|
|
||||||
$sorted = match ($this->sortBy) {
|
$sorted = match ($this->sortBy) {
|
||||||
|
'id' => $tickets->sortBy(fn (Ticket $t) => $t->id, SORT_REGULAR, $desc),
|
||||||
'number' => $tickets->sortBy(fn (Ticket $t) => (int) $t->number, SORT_REGULAR, $desc),
|
'number' => $tickets->sortBy(fn (Ticket $t) => (int) $t->number, SORT_REGULAR, $desc),
|
||||||
'subject' => $tickets->sortBy('subject', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
'subject' => $tickets->sortBy('subject', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||||
'customer' => $tickets->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
'customer' => $tickets->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||||
|
'email' => $tickets->sortBy('email', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||||
'category' => $tickets->sortBy(fn (Ticket $t) => $t->categoryLabel(), SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
'category' => $tickets->sortBy(fn (Ticket $t) => $t->categoryLabel(), SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||||
'priority' => $tickets->sortBy(fn (Ticket $t) => $t->priority?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc),
|
'priority' => $tickets->sortBy(fn (Ticket $t) => $t->priority?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc),
|
||||||
'status' => $tickets->sortBy(fn (Ticket $t) => $t->status?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc),
|
'status' => $tickets->sortBy(fn (Ticket $t) => $t->status?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc),
|
||||||
'assignee' => $tickets->sortBy(fn (Ticket $t) => $t->assignee?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
'assignee' => $tickets->sortBy(fn (Ticket $t) => $t->assignee?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||||
'team' => $tickets->sortBy(fn (Ticket $t) => $t->team?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
'team' => $tickets->sortBy(fn (Ticket $t) => $t->team?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||||
'subcategory' => $tickets->sortBy(fn (Ticket $t) => $t->subcategory?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
'subcategory' => $tickets->sortBy(fn (Ticket $t) => $t->subcategory?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||||
|
'source' => $tickets->sortBy(fn (Ticket $t) => $t->source ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||||
|
'updated' => $tickets->sortBy(fn (Ticket $t) => $t->updated_at, SORT_REGULAR, $desc),
|
||||||
'created' => $tickets->sortBy(fn (Ticket $t) => $t->created_at, SORT_REGULAR, $desc),
|
'created' => $tickets->sortBy(fn (Ticket $t) => $t->created_at, SORT_REGULAR, $desc),
|
||||||
default => $tickets->sortBy('updated_at', SORT_REGULAR, $desc),
|
default => $tickets->sortBy(fn (Ticket $t) => $t->created_at, SORT_REGULAR, $desc),
|
||||||
};
|
};
|
||||||
|
|
||||||
return $sorted->values();
|
return $sorted->values();
|
||||||
@@ -346,9 +445,11 @@ class Queue extends Component
|
|||||||
public function columnDefs(): array
|
public function columnDefs(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'id' => 'ID',
|
||||||
'number' => 'Numer',
|
'number' => 'Numer',
|
||||||
'subject' => 'Temat',
|
'subject' => 'Temat',
|
||||||
'customer' => 'Klient',
|
'customer' => 'Klient',
|
||||||
|
'email' => 'E-mail',
|
||||||
'category' => 'Kategoria',
|
'category' => 'Kategoria',
|
||||||
'subcategory' => 'Podkategoria',
|
'subcategory' => 'Podkategoria',
|
||||||
'priority' => 'Priorytet',
|
'priority' => 'Priorytet',
|
||||||
@@ -356,7 +457,9 @@ class Queue extends Component
|
|||||||
'sla' => 'SLA',
|
'sla' => 'SLA',
|
||||||
'assignee' => 'Przypisany',
|
'assignee' => 'Przypisany',
|
||||||
'team' => 'Zespół',
|
'team' => 'Zespół',
|
||||||
|
'source' => 'Źródło',
|
||||||
'created' => 'Utworzono',
|
'created' => 'Utworzono',
|
||||||
|
'updated' => 'Zaktualizowano',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,7 +470,7 @@ class Queue extends Component
|
|||||||
*/
|
*/
|
||||||
public function sortableColumns(): array
|
public function sortableColumns(): array
|
||||||
{
|
{
|
||||||
return ['number', 'subject', 'customer', 'category', 'subcategory', 'priority', 'status', 'assignee', 'team', 'created'];
|
return ['id', 'number', 'subject', 'customer', 'email', 'category', 'subcategory', 'priority', 'status', 'assignee', 'team', 'source', 'created', 'updated'];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sortByColumn(string $column): void
|
public function sortByColumn(string $column): void
|
||||||
@@ -395,12 +498,57 @@ class Queue extends Component
|
|||||||
} else {
|
} else {
|
||||||
$this->visibleColumns[] = $column;
|
$this->visibleColumns[] = $column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->persistVisibleColumns();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function moveColumnUp(string $column): void
|
||||||
|
{
|
||||||
|
$this->reorderColumn($column, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function moveColumnDown(string $column): void
|
||||||
|
{
|
||||||
|
$this->reorderColumn($column, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reorderColumn(string $column, int $delta): void
|
||||||
|
{
|
||||||
|
$from = array_search($column, $this->visibleColumns, true);
|
||||||
|
|
||||||
|
if ($from === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$to = $from + $delta;
|
||||||
|
|
||||||
|
if ($to < 0 || $to >= count($this->visibleColumns)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$columns = $this->visibleColumns;
|
||||||
|
[$columns[$from], $columns[$to]] = [$columns[$to], $columns[$from]];
|
||||||
|
$this->visibleColumns = $columns;
|
||||||
|
|
||||||
|
$this->persistVisibleColumns();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-remembers shown/hidden columns *and* their order per operator,
|
||||||
|
* independent of the named/default SavedQueueView mechanism above — a
|
||||||
|
* plain column toggle or drag shouldn't require explicitly "saving a
|
||||||
|
* view" for it to stick between visits.
|
||||||
|
*/
|
||||||
|
private function persistVisibleColumns(): void
|
||||||
|
{
|
||||||
|
Auth::user()->forceFill(['operator_queue_columns' => $this->visibleColumns])->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setQueue(string $key): void
|
public function setQueue(string $key): void
|
||||||
{
|
{
|
||||||
$this->queue = $key;
|
$this->queue = $key;
|
||||||
$this->selectedIds = [];
|
$this->selectedIds = [];
|
||||||
|
$this->rememberQueueTab();
|
||||||
|
|
||||||
// Every tab except "closed" now excludes closed-stage tickets (see
|
// Every tab except "closed" now excludes closed-stage tickets (see
|
||||||
// queueDefs()), so a stale closed-stage status filter would silently
|
// queueDefs()), so a stale closed-stage status filter would silently
|
||||||
@@ -409,11 +557,14 @@ class Queue extends Component
|
|||||||
if ($key !== 'closed' && $this->filterStatus !== 'all' && Status::stageFor($this->filterStatus) === 'closed') {
|
if ($key !== 'closed' && $this->filterStatus !== 'all' && Status::stageFor($this->filterStatus) === 'closed') {
|
||||||
$this->filterStatus = 'all';
|
$this->filterStatus = 'all';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->resetPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function clearCustomerFilter(): void
|
public function clearCustomerFilter(): void
|
||||||
{
|
{
|
||||||
$this->filterCustomerId = null;
|
$this->filterCustomerId = null;
|
||||||
|
$this->resetPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function toggleSelect(int $id): void
|
public function toggleSelect(int $id): void
|
||||||
@@ -425,6 +576,20 @@ class Queue extends Component
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects every ticket currently visible under the active filters/queue
|
||||||
|
* (not every ticket in the system) — toggles off if all of them are
|
||||||
|
* already selected, matching the usual "header checkbox" convention.
|
||||||
|
*/
|
||||||
|
public function toggleSelectAll(): void
|
||||||
|
{
|
||||||
|
$visibleIds = $this->filteredTickets->pluck('id')->all();
|
||||||
|
|
||||||
|
$this->selectedIds = empty(array_diff($visibleIds, $this->selectedIds))
|
||||||
|
? array_values(array_diff($this->selectedIds, $visibleIds))
|
||||||
|
: array_values(array_unique(array_merge($this->selectedIds, $visibleIds)));
|
||||||
|
}
|
||||||
|
|
||||||
public function mergeSelected(): void
|
public function mergeSelected(): void
|
||||||
{
|
{
|
||||||
$ids = $this->selectedIdsInScope();
|
$ids = $this->selectedIdsInScope();
|
||||||
@@ -476,6 +641,8 @@ class Queue extends Component
|
|||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.operator.queue');
|
$queueLabel = $this->queueDefs()[$this->queue]['label'] ?? 'Kolejka';
|
||||||
|
|
||||||
|
return view('livewire.operator.queue')->title(Settings::pageTitle($queueLabel));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Models\Status;
|
|||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\Settings;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -127,7 +128,17 @@ class Stats extends Component
|
|||||||
$query->where('tickets.priority_key', $this->filterPriority);
|
$query->where('tickets.priority_key', $this->filterPriority);
|
||||||
}
|
}
|
||||||
if ($this->filterCategory !== 'all') {
|
if ($this->filterCategory !== 'all') {
|
||||||
$query->whereHas('subcategory', fn ($q) => $q->where('category_id', $this->filterCategory));
|
// A ticket can carry its category two ways — via a subcategory
|
||||||
|
// (whose own category_id we check through) or, when routed to a
|
||||||
|
// bare category with no subcategory, via tickets.category_id
|
||||||
|
// directly (see Ticket::category()). Only checking the
|
||||||
|
// subcategory relation silently dropped every bare-category
|
||||||
|
// ticket from the filter.
|
||||||
|
$categoryId = $this->filterCategory;
|
||||||
|
$query->where(function ($q) use ($categoryId) {
|
||||||
|
$q->where('tickets.category_id', $categoryId)
|
||||||
|
->orWhereHas('subcategory', fn ($sq) => $sq->where('category_id', $categoryId));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if ($this->filterAssignee === 'unassigned') {
|
if ($this->filterAssignee === 'unassigned') {
|
||||||
$query->whereNull('tickets.assignee_id');
|
$query->whereNull('tickets.assignee_id');
|
||||||
@@ -298,37 +309,70 @@ class Stats extends Component
|
|||||||
])->values();
|
])->values();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A ticket carries its category two ways: via a subcategory (whose
|
||||||
|
* category_id we join through), or — when routed to a bare category with
|
||||||
|
* no subcategory — via tickets.category_id directly (see
|
||||||
|
* Ticket::category()). Counting only the subcategory join silently
|
||||||
|
* dropped every bare-category ticket, so this sums both paths per
|
||||||
|
* category id before joining to categories for the label.
|
||||||
|
*/
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function byCategory()
|
public function byCategory()
|
||||||
{
|
{
|
||||||
return (clone $this->baseQuery)
|
$viaSubcategory = (clone $this->baseQuery)
|
||||||
->whereNotNull('tickets.subcategory_id')
|
->whereNotNull('tickets.subcategory_id')
|
||||||
->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id')
|
->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id')
|
||||||
->join('categories', 'categories.id', '=', 'subcategories.category_id')
|
->select('subcategories.category_id', DB::raw('count(*) as count'))
|
||||||
->select('categories.name as label', DB::raw('count(*) as count'))
|
->groupBy('subcategories.category_id')
|
||||||
->groupBy('categories.id', 'categories.name')
|
->pluck('count', 'category_id');
|
||||||
->orderByDesc('count')
|
|
||||||
->get()
|
$viaCategory = (clone $this->baseQuery)
|
||||||
->map(fn ($row) => ['label' => $row->label, 'count' => (int) $row->count]);
|
->whereNull('tickets.subcategory_id')
|
||||||
|
->whereNotNull('tickets.category_id')
|
||||||
|
->select('tickets.category_id', DB::raw('count(*) as count'))
|
||||||
|
->groupBy('tickets.category_id')
|
||||||
|
->pluck('count', 'category_id');
|
||||||
|
|
||||||
|
$counts = $viaSubcategory->keys()->merge($viaCategory->keys())->unique()
|
||||||
|
->mapWithKeys(fn ($id) => [$id => ($viaSubcategory[$id] ?? 0) + ($viaCategory[$id] ?? 0)]);
|
||||||
|
|
||||||
|
return Category::query()->whereIn('id', $counts->keys())->get()
|
||||||
|
->map(fn (Category $c) => ['label' => $c->name, 'count' => (int) $counts[$c->id]])
|
||||||
|
->sortByDesc('count')
|
||||||
|
->values();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One level deeper than byCategory() — same shape, but grouped by the
|
* One level deeper than byCategory() — grouped by the actual subcategory,
|
||||||
* actual subcategory, labeled "Category / Subcategory" to disambiguate
|
* labeled "Category / Subcategory" to disambiguate subcategories that
|
||||||
* subcategories that share a name across different parent categories.
|
* share a name across different parent categories. Tickets routed to a
|
||||||
|
* bare category (no subcategory — see byCategory()'s docblock) have no
|
||||||
|
* subcategory to group by, so they get their own "Category (bez
|
||||||
|
* podkategorii)" row instead of being silently dropped.
|
||||||
*/
|
*/
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function bySubcategory()
|
public function bySubcategory()
|
||||||
{
|
{
|
||||||
return (clone $this->baseQuery)
|
$withSubcategory = (clone $this->baseQuery)
|
||||||
->whereNotNull('tickets.subcategory_id')
|
->whereNotNull('tickets.subcategory_id')
|
||||||
->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id')
|
->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id')
|
||||||
->join('categories', 'categories.id', '=', 'subcategories.category_id')
|
->join('categories', 'categories.id', '=', 'subcategories.category_id')
|
||||||
->select('subcategories.id', 'categories.name as category_name', 'subcategories.name as sub_name', DB::raw('count(*) as count'))
|
->select('subcategories.id', 'categories.name as category_name', 'subcategories.name as sub_name', DB::raw('count(*) as count'))
|
||||||
->groupBy('subcategories.id', 'categories.name', 'subcategories.name')
|
->groupBy('subcategories.id', 'categories.name', 'subcategories.name')
|
||||||
->orderByDesc('count')
|
|
||||||
->get()
|
->get()
|
||||||
->map(fn ($row) => ['label' => $row->category_name.' / '.$row->sub_name, 'count' => (int) $row->count]);
|
->map(fn ($row) => ['label' => $row->category_name.' / '.$row->sub_name, 'count' => (int) $row->count]);
|
||||||
|
|
||||||
|
$bareCategory = (clone $this->baseQuery)
|
||||||
|
->whereNull('tickets.subcategory_id')
|
||||||
|
->whereNotNull('tickets.category_id')
|
||||||
|
->join('categories', 'categories.id', '=', 'tickets.category_id')
|
||||||
|
->select('categories.name as category_name', DB::raw('count(*) as count'))
|
||||||
|
->groupBy('categories.id', 'categories.name')
|
||||||
|
->get()
|
||||||
|
->map(fn ($row) => ['label' => $row->category_name.' (bez podkategorii)', 'count' => (int) $row->count]);
|
||||||
|
|
||||||
|
return $withSubcategory->concat($bareCategory)->sortByDesc('count')->values();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
@@ -605,7 +649,7 @@ class Stats extends Component
|
|||||||
|
|
||||||
foreach ($tickets as $ticket) {
|
foreach ($tickets as $ticket) {
|
||||||
fputcsv($out, [
|
fputcsv($out, [
|
||||||
$ticket->number,
|
$ticket->displayNumber(),
|
||||||
$ticket->subject,
|
$ticket->subject,
|
||||||
$ticket->statusLabel(),
|
$ticket->statusLabel(),
|
||||||
$ticket->priorityLabel(),
|
$ticket->priorityLabel(),
|
||||||
@@ -624,6 +668,6 @@ class Stats extends Component
|
|||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.operator.stats');
|
return view('livewire.operator.stats')->title(Settings::pageTitle('Statystyki'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ use App\Models\Ticket;
|
|||||||
use App\Models\TicketMessage;
|
use App\Models\TicketMessage;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\BookStackClient;
|
use App\Services\BookStackClient;
|
||||||
|
use App\Services\SnipeItClient;
|
||||||
|
use App\Services\TicketAiSummaryService;
|
||||||
use App\Services\TicketService;
|
use App\Services\TicketService;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
use Livewire\Attributes\On;
|
use Livewire\Attributes\On;
|
||||||
@@ -78,6 +81,38 @@ class TicketShow extends Component
|
|||||||
$this->suggestedArticlesLoaded = true;
|
$this->suggestedArticlesLoaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same wire:init-deferred pattern — the AI summary card just displays
|
||||||
|
// whatever the scheduled ai:run-ticket-automation command last computed
|
||||||
|
// (no live AI call from the ticket page), but refreshing the ticket here
|
||||||
|
// picks up a summary the command generated after this page's initial load.
|
||||||
|
public bool $aiSummaryLoaded = false;
|
||||||
|
|
||||||
|
public function loadAiSummary(): void
|
||||||
|
{
|
||||||
|
$this->aiSummaryLoaded = true;
|
||||||
|
$this->ticket->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ?string $aiSummaryRegenerateError = null;
|
||||||
|
|
||||||
|
// Manual regeneration is an explicit operator action (unlike the
|
||||||
|
// wire:init-deferred load above), so it's fine to block on the AI call
|
||||||
|
// here rather than deferring it — the button's wire:loading state covers
|
||||||
|
// the wait.
|
||||||
|
public function regenerateAiSummary(): void
|
||||||
|
{
|
||||||
|
$this->aiSummaryRegenerateError = null;
|
||||||
|
set_time_limit(0);
|
||||||
|
|
||||||
|
if (! app(TicketAiSummaryService::class)->generateFor($this->ticket)) {
|
||||||
|
$this->aiSummaryRegenerateError = 'Nie udało się wygenerować podsumowania. Sprawdź konfigurację integracji AI.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->ticket->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
public function mount(Ticket $ticket): void
|
public function mount(Ticket $ticket): void
|
||||||
{
|
{
|
||||||
abort_unless($ticket->isVisibleToOperator(Auth::user()), 403);
|
abort_unless($ticket->isVisibleToOperator(Auth::user()), 403);
|
||||||
@@ -89,6 +124,26 @@ class TicketShow extends Component
|
|||||||
// ticket-show.blade.php) — so every open resumes it, not just the
|
// ticket-show.blade.php) — so every open resumes it, not just the
|
||||||
// very first one.
|
// very first one.
|
||||||
$this->ticket->resumeTimer();
|
$this->ticket->resumeTimer();
|
||||||
|
|
||||||
|
$this->ticket->recordViewBy(Auth::user());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Livewire lifecycle hook, called for any exception raised while
|
||||||
|
* handling a request for this component — including one thrown while
|
||||||
|
* re-hydrating the typed $ticket property itself (Livewire re-fetches
|
||||||
|
* it by id on every request), which happens before any of this
|
||||||
|
* component's own methods run and so can't be caught locally the way
|
||||||
|
* refreshOrRedirectAway() catches it during an explicit refresh().
|
||||||
|
* Covers a ticket deleted by someone else while an operator still has
|
||||||
|
* it open — sends them back to their queue instead of a hard error.
|
||||||
|
*/
|
||||||
|
public function exception(\Throwable $e, $stopPropagation): void
|
||||||
|
{
|
||||||
|
if ($e instanceof ModelNotFoundException) {
|
||||||
|
$this->redirect(route('operator.queue'), navigate: true);
|
||||||
|
$stopPropagation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
@@ -209,6 +264,36 @@ class TicketShow extends Component
|
|||||||
unset($this->publicMessages, $this->internalMessages);
|
unset($this->publicMessages, $this->internalMessages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-fetches the ticket and, for a non-admin operator, sends them back
|
||||||
|
* to their queue instead of leaving them stuck on a page that can no
|
||||||
|
* longer legitimately show anything — either because the ticket was
|
||||||
|
* deleted (refresh() throws ModelNotFoundException, same as
|
||||||
|
* Model::findOrFail() internally) or because a team/assignee change
|
||||||
|
* (by this operator or anyone else) moved it out of their visible
|
||||||
|
* scope. Returns false when it redirected, so callers can bail out of
|
||||||
|
* whatever they were doing instead of continuing to operate on a
|
||||||
|
* ticket that's about to disappear from under them.
|
||||||
|
*/
|
||||||
|
protected function refreshOrRedirectAway(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->ticket->refresh();
|
||||||
|
} catch (ModelNotFoundException) {
|
||||||
|
$this->redirect(route('operator.queue'), navigate: true);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->ticket->isVisibleToOperator(Auth::user())) {
|
||||||
|
$this->redirect(route('operator.queue'), navigate: true);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bridged from a TicketQueueChanged broadcast (see resources/js/echo.js
|
* Bridged from a TicketQueueChanged broadcast (see resources/js/echo.js
|
||||||
* and Queue::onQueueChanged()) — lets a status/priority/team/assignee
|
* and Queue::onQueueChanged()) — lets a status/priority/team/assignee
|
||||||
@@ -222,7 +307,7 @@ class TicketShow extends Component
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->ticket->refresh();
|
$this->refreshOrRedirectAway();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -232,8 +317,11 @@ class TicketShow extends Component
|
|||||||
*/
|
*/
|
||||||
public function refreshTicketData(): void
|
public function refreshTicketData(): void
|
||||||
{
|
{
|
||||||
|
if (! $this->refreshOrRedirectAway()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
unset($this->publicMessages, $this->internalMessages);
|
unset($this->publicMessages, $this->internalMessages);
|
||||||
$this->ticket->refresh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
@@ -266,24 +354,121 @@ class TicketShow extends Component
|
|||||||
|
|
||||||
$subcategory = $this->ticket->subcategory;
|
$subcategory = $this->ticket->subcategory;
|
||||||
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
|
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
|
||||||
|
$tagQuery = trim($subcategory?->name ?? '');
|
||||||
|
|
||||||
return app(BookStackClient::class)->search($query, 5, BookStackClient::CONTEXT_TICKET_VIEW);
|
return app(BookStackClient::class)->search($query, 5, BookStackClient::CONTEXT_TICKET_VIEW, $tagQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Snipe-IT --------
|
||||||
|
|
||||||
|
// Same wire:init-deferred pattern as suggestedArticlesLoaded above — the
|
||||||
|
// requester's asset list is a Snipe-IT HTTP call, deferred so it never
|
||||||
|
// delays the ticket page's first paint.
|
||||||
|
public bool $snipeitAssetsLoaded = false;
|
||||||
|
|
||||||
|
public function loadSnipeitAssets(): void
|
||||||
|
{
|
||||||
|
$this->snipeitAssetsLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string $snipeitSearchQuery = '';
|
||||||
|
|
||||||
|
public array $snipeitSearchResults = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, url: string}>
|
||||||
|
*/
|
||||||
|
#[Computed]
|
||||||
|
public function snipeitRequesterAssets(): array
|
||||||
|
{
|
||||||
|
if (! $this->snipeitAssetsLoaded || ! Settings::bool('snipeit_operator_view_requester_assets') || ! $this->ticket->email) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(SnipeItClient::class)->assetsForEmail($this->ticket->email);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A non-admin operator can only reassign a ticket to one of their own
|
* Live detail for the ticket's linked asset (if any) — always fetched
|
||||||
* teams (mirrors the visibility scoping in Operator\Queue).
|
* fresh so a status/assignment change made directly in Snipe-IT shows up
|
||||||
|
* without an operator having to re-link anything. Not gated behind
|
||||||
|
* snipeit_operator_view_requester_assets/snipeit_operator_search_inventory:
|
||||||
|
* showing what's already on the ticket isn't the same permission as
|
||||||
|
* browsing the rest of Snipe-IT. Falls back to the ticket's own cached
|
||||||
|
* snipeit_asset_name in the view when this comes back null (unreachable
|
||||||
|
* instance or the asset was deleted there).
|
||||||
|
*
|
||||||
|
* @return array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, assignedTo: ?string, url: string}|null
|
||||||
|
*/
|
||||||
|
#[Computed]
|
||||||
|
public function snipeitLinkedAsset(): ?array
|
||||||
|
{
|
||||||
|
return $this->ticket->snipeit_asset_id
|
||||||
|
? app(SnipeItClient::class)->asset($this->ticket->snipeit_asset_id)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function searchSnipeitAssets(): void
|
||||||
|
{
|
||||||
|
if (! Settings::bool('snipeit_operator_search_inventory')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->snipeitSearchResults = app(SnipeItClient::class)->searchAssets($this->snipeitSearchQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* $id must come from whichever list it was clicked from — the requester's
|
||||||
|
* assets (gated on snipeit_operator_view_requester_assets) or an
|
||||||
|
* inventory search result (gated on snipeit_operator_search_inventory) —
|
||||||
|
* rather than a direct Snipe-IT lookup by id, so an operator can't link
|
||||||
|
* an arbitrary asset via a source that's admin-disabled for them.
|
||||||
|
*/
|
||||||
|
public function linkSnipeitAsset(int $id): void
|
||||||
|
{
|
||||||
|
$fromRequesterAssets = Settings::bool('snipeit_operator_view_requester_assets')
|
||||||
|
? collect($this->snipeitRequesterAssets)->firstWhere('id', $id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$fromSearchResults = Settings::bool('snipeit_operator_search_inventory')
|
||||||
|
? collect($this->snipeitSearchResults)->firstWhere('id', $id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$asset = $fromRequesterAssets ?? $fromSearchResults;
|
||||||
|
|
||||||
|
if (! $asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app(TicketService::class)->setSnipeitAsset($this->ticket, ['id' => $asset['id'], 'label' => $asset['label']]);
|
||||||
|
$this->ticket->refresh();
|
||||||
|
unset($this->snipeitLinkedAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlike linkSnipeitAsset(), not gated behind either visibility setting
|
||||||
|
* — clearing a link a ticket already has is a correction, not a new way
|
||||||
|
* to browse Snipe-IT, so it stays available even if an admin later turns
|
||||||
|
* both of those off.
|
||||||
|
*/
|
||||||
|
public function unlinkSnipeitAsset(): void
|
||||||
|
{
|
||||||
|
app(TicketService::class)->setSnipeitAsset($this->ticket, null);
|
||||||
|
$this->ticket->refresh();
|
||||||
|
unset($this->snipeitLinkedAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every team, regardless of the viewing operator's own membership —
|
||||||
|
* unlike ticket *visibility* (Operator\Queue, scoped to an operator's
|
||||||
|
* own teams), reassignment isn't restricted: an operator working a
|
||||||
|
* ticket needs to be able to route it to whichever team actually owns
|
||||||
|
* the problem, even one they don't personally belong to.
|
||||||
*/
|
*/
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function teams()
|
public function teams()
|
||||||
{
|
{
|
||||||
$query = Team::query();
|
return Team::query()->get();
|
||||||
|
|
||||||
if (! Auth::user()->isAdmin()) {
|
|
||||||
$query->whereHas('members', fn ($q) => $q->where('users.id', Auth::id()));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query->get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
@@ -357,7 +542,12 @@ class TicketShow extends Component
|
|||||||
public function setTeam(string $id): void
|
public function setTeam(string $id): void
|
||||||
{
|
{
|
||||||
app(TicketService::class)->setTeam($this->ticket, $id ? Team::query()->find($id) : null);
|
app(TicketService::class)->setTeam($this->ticket, $id ? Team::query()->find($id) : null);
|
||||||
$this->ticket->refresh();
|
|
||||||
|
// Reassigning to a team the operator doesn't belong to can move the
|
||||||
|
// ticket out of their own visible scope (see Ticket::isVisibleToOperator())
|
||||||
|
// — send them back to their queue rather than leaving them on a
|
||||||
|
// ticket they can no longer legitimately keep viewing.
|
||||||
|
$this->refreshOrRedirectAway();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------- reporter --------
|
// -------- reporter --------
|
||||||
@@ -585,6 +775,6 @@ class TicketShow extends Component
|
|||||||
// progress is saved incrementally rather than only on explicit stop.
|
// progress is saved incrementally rather than only on explicit stop.
|
||||||
$this->ticket->flushTimer();
|
$this->ticket->flushTimer();
|
||||||
|
|
||||||
return view('livewire.operator.ticket-show');
|
return view('livewire.operator.ticket-show')->title(Settings::pageTitle($this->ticket->displayNumber().' — '.$this->ticket->subject));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Livewire\Settings;
|
namespace App\Livewire\Settings;
|
||||||
|
|
||||||
use App\Models\NotificationPreference;
|
use App\Models\NotificationPreference;
|
||||||
|
use App\Support\Settings;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ class NotificationPreferences extends Component
|
|||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.settings.notification-preferences', ['rows' => $this->rows()]);
|
return view('livewire.settings.notification-preferences', ['rows' => $this->rows()])
|
||||||
|
->title(Settings::pageTitle('Powiadomienia'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ class Category extends Model
|
|||||||
{
|
{
|
||||||
public function subcategories(): HasMany
|
public function subcategories(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Subcategory::class);
|
return $this->hasMany(Subcategory::class)->orderBy('sort_order');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace App\Models;
|
|||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
#[Fillable(['key', 'name', 'trigger_label', 'subject', 'body'])]
|
#[Fillable(['key', 'name', 'subject', 'body'])]
|
||||||
class EmailTemplate extends Model
|
class EmailTemplate extends Model
|
||||||
{
|
{
|
||||||
public function render(array $placeholders): array
|
public function render(array $placeholders): array
|
||||||
|
|||||||
65
src/app/Models/ImapMailbox.php
Normal file
65
src/app/Models/ImapMailbox.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One inbound mailbox polled by `emails:fetch-imap` — an admin can configure
|
||||||
|
* several (e.g. zgloszenia-it@ vs zgloszenia-delegacje@), each landing new
|
||||||
|
* tickets in its own default subcategory. Unlike LDAP/SMTP/BookStack, this is
|
||||||
|
* a list of N configs rather than a Settings singleton, so it's a real model
|
||||||
|
* rather than key/value rows.
|
||||||
|
*/
|
||||||
|
#[Fillable([
|
||||||
|
'name', 'enabled', 'host', 'port', 'encryption', 'validate_cert',
|
||||||
|
'username', 'password', 'folder', 'processed_folder', 'rejected_folder',
|
||||||
|
'default_subcategory_id', 'default_category_id', 'blocklist_senders', 'last_checked_at', 'last_error',
|
||||||
|
])]
|
||||||
|
class ImapMailbox extends Model
|
||||||
|
{
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'enabled' => 'boolean',
|
||||||
|
'validate_cert' => 'boolean',
|
||||||
|
'password' => 'encrypted',
|
||||||
|
'last_checked_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function defaultSubcategory(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Subcategory::class, 'default_subcategory_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only meaningful when default_subcategory_id is null — a mailbox is
|
||||||
|
* routed to either a specific subcategory or a whole category, never
|
||||||
|
* both (enforced by the admin form's single combined selector).
|
||||||
|
*/
|
||||||
|
public function defaultCategory(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Category::class, 'default_category_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function blocklistedSenders(): array
|
||||||
|
{
|
||||||
|
return array_filter(array_map('trim', explode(',', (string) $this->blocklist_senders)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function targetLabel(): string
|
||||||
|
{
|
||||||
|
if ($this->defaultSubcategory) {
|
||||||
|
return $this->defaultSubcategory->category->name.' / '.$this->defaultSubcategory->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->defaultCategory) {
|
||||||
|
return 'Cała kategoria: '.$this->defaultCategory->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable(['category_id', 'name', 'description', 'default_priority_key'])]
|
#[Fillable(['category_id', 'name', 'description', 'default_priority_key', 'sort_order'])]
|
||||||
class Subcategory extends Model
|
class Subcategory extends Model
|
||||||
{
|
{
|
||||||
public function category(): BelongsTo
|
public function category(): BelongsTo
|
||||||
|
|||||||
@@ -2,23 +2,159 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Support\Settings;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'number', 'customer_id', 'email', 'name', 'subcategory_id', 'subject', 'body',
|
'number', 'checksum', 'customer_id', 'email', 'name', 'subcategory_id', 'category_id', 'subject', 'body',
|
||||||
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id',
|
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id', 'source', 'hesk_ticket_id',
|
||||||
'sla_notified_at', 'last_customer_activity_at', 'time_spent_seconds', 'timer_started_at',
|
'sla_notified_at', 'last_customer_activity_at', 'time_spent_seconds', 'timer_started_at',
|
||||||
'created_at', 'updated_at', 'csat_rating', 'csat_comment', 'csat_rated_at',
|
'created_at', 'updated_at', 'csat_rating', 'csat_comment', 'csat_rated_at',
|
||||||
|
'ai_triaged_at', 'ai_summary', 'ai_suggested_action', 'ai_summary_generated_at',
|
||||||
|
'snipeit_asset_id', 'snipeit_asset_name',
|
||||||
])]
|
])]
|
||||||
class Ticket extends Model
|
class Ticket extends Model
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Every value ever written to tickets.source across the app — web
|
||||||
|
* submission (the default), IMAP-fetched e-mail, and the Hesk import
|
||||||
|
* command. Enforced on save (see booted() below) so a typo'd literal
|
||||||
|
* fails loudly instead of silently sticking in the column.
|
||||||
|
*/
|
||||||
|
public const SOURCES = ['web', 'email', 'hesk_import'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ai_* and snipeit_* fields are no longer real columns on `tickets` —
|
||||||
|
* they live in aiSummary()/snipeitAsset(), one-to-one extension tables (see the
|
||||||
|
* 2026_08_05_000164/000165 migrations for why: both blocks are wide and
|
||||||
|
* null on most tickets). These maps back the getAttribute()/
|
||||||
|
* setAttribute() overrides below, which keep every existing
|
||||||
|
* `$ticket->ai_summary`/`$ticket->snipeit_asset_id` read/write working
|
||||||
|
* unchanged against the new tables, so callers never had to change.
|
||||||
|
*/
|
||||||
|
private const AI_SUMMARY_FIELD_MAP = [
|
||||||
|
'ai_triaged_at' => 'triaged_at',
|
||||||
|
'ai_summary' => 'summary',
|
||||||
|
'ai_suggested_action' => 'suggested_action',
|
||||||
|
'ai_summary_generated_at' => 'summary_generated_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const SNIPEIT_FIELD_MAP = [
|
||||||
|
'snipeit_asset_id' => 'asset_id',
|
||||||
|
'snipeit_asset_name' => 'asset_name',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queued writes to the virtual ai_* and snipeit_* fields above, flushed into
|
||||||
|
* the related row once the ticket itself is saved (see booted()) rather
|
||||||
|
* than applied immediately — a brand-new ticket has no id yet to key the
|
||||||
|
* related row on.
|
||||||
|
*/
|
||||||
|
protected array $pendingVirtualAttributes = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every ticket gets a stable, unique checksum the moment its id is known
|
||||||
|
* — it never needs to change afterward, and having it always populated
|
||||||
|
* (regardless of whether obfuscation is currently on) means toggling the
|
||||||
|
* "Ukryj kolejność zgłoszeń" setting doesn't need a backfill pass.
|
||||||
|
*/
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::created(function (Ticket $ticket) {
|
||||||
|
$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
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -57,6 +193,26 @@ class Ticket extends Model
|
|||||||
return $this->belongsTo(Subcategory::class);
|
return $this->belongsTo(Subcategory::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only ever set when there's no subcategory to derive a category from
|
||||||
|
* (subcategory_id already implies one via Subcategory::category()) — see
|
||||||
|
* categoryLabel() and the migration that introduced this column.
|
||||||
|
*/
|
||||||
|
public function category(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Category::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queryable counterpart to the custom_fields JSON blob — see
|
||||||
|
* syncFieldValues(). Read-only from the app's perspective; write custom
|
||||||
|
* field values via the custom_fields attribute as before.
|
||||||
|
*/
|
||||||
|
public function fieldValues(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TicketFieldValue::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function watchers(): BelongsToMany
|
public function watchers(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(User::class, 'ticket_watchers');
|
return $this->belongsToMany(User::class, 'ticket_watchers');
|
||||||
@@ -67,6 +223,26 @@ class Ticket extends Model
|
|||||||
return $this->watchers()->where('users.id', $user->id)->exists();
|
return $this->watchers()->where('users.id', $user->id)->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function viewers(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(User::class, 'ticket_views')->withPivot('viewed_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bumps viewed_at for an existing view rather than duplicating it — sync()
|
||||||
|
* updates pivot columns on already-attached rows, not just new ones.
|
||||||
|
*
|
||||||
|
* Formatted explicitly with microseconds: a plain Carbon instance gets
|
||||||
|
* bound through the connection's default date format (whole seconds,
|
||||||
|
* regardless of the column's own declared precision), so two views in
|
||||||
|
* the same second would otherwise tie and silently fall back to sorting
|
||||||
|
* by row id instead of actual recency.
|
||||||
|
*/
|
||||||
|
public function recordViewBy(User $user): void
|
||||||
|
{
|
||||||
|
$this->viewers()->syncWithoutDetaching([$user->id => ['viewed_at' => now()->format('Y-m-d H:i:s.u')]]);
|
||||||
|
}
|
||||||
|
|
||||||
public function status(): BelongsTo
|
public function status(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Status::class, 'status_key');
|
return $this->belongsTo(Status::class, 'status_key');
|
||||||
@@ -107,16 +283,108 @@ class Ticket extends Model
|
|||||||
return $this->hasMany(AutomationRuleTicketLog::class);
|
return $this->hasMany(AutomationRuleTicketLog::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Numeric-safe "max + 1" without pulling every ticket's number into PHP
|
||||||
|
* memory (`number` is a plain string column, so a DB-level MAX() would
|
||||||
|
* sort lexicographically — "999" > "1000" — hence ordering by length
|
||||||
|
* first). LENGTH()/ORDER BY/LIMIT are portable across MySQL and the
|
||||||
|
* sqlite connection tests run against, unlike a driver-specific CAST.
|
||||||
|
*/
|
||||||
public static function nextNumber(): string
|
public static function nextNumber(): string
|
||||||
{
|
{
|
||||||
$max = static::query()->pluck('number')->map(fn ($n) => (int) $n)->max();
|
$max = (int) static::query()
|
||||||
|
->orderByRaw('LENGTH(number) DESC, number DESC')
|
||||||
|
->value('number');
|
||||||
|
|
||||||
return (string) (($max ?: 1000) + 1);
|
return (string) (($max ?: 1000) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number shown to users: the admin-configured prefix in front of
|
||||||
|
* formattedNumber(). Kept separate from formattedNumber() because the
|
||||||
|
* `{numer}` placeholder in admin-editable e-mail templates historically
|
||||||
|
* carries no prefix (templates hardcode their own, e.g. "Zgłoszenie
|
||||||
|
* #{numer}") — changing that would double up or mismatch a
|
||||||
|
* non-default prefix in every existing template.
|
||||||
|
*/
|
||||||
|
public function displayNumber(): string
|
||||||
|
{
|
||||||
|
return Settings::get('ticket_number_prefix', '#').$this->formattedNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ticket number without any prefix: either the raw sequential
|
||||||
|
* `number` (zero-padded to the admin-configured minimum length), or —
|
||||||
|
* when obfuscation is enabled — this ticket's stored checksum. The
|
||||||
|
* checksum is a fixed-width HMAC output, so minimum-length padding
|
||||||
|
* doesn't apply to it (padding a checksum has no real meaning — it's
|
||||||
|
* only meant to make a short *sequential* number look consistent).
|
||||||
|
* This is also the value getRouteKey()/resolveRouteBinding() use, so
|
||||||
|
* the number shown on the page and the one in the URL always match.
|
||||||
|
* The underlying `number` column itself is left alone, since it still
|
||||||
|
* backs the numeric sort in Operator/Queue.php.
|
||||||
|
*/
|
||||||
|
public function formattedNumber(): string
|
||||||
|
{
|
||||||
|
if (Settings::bool('ticket_number_obfuscate')) {
|
||||||
|
return $this->checksum ?? $this->number;
|
||||||
|
}
|
||||||
|
|
||||||
|
$minLength = max(1, (int) Settings::get('ticket_number_min_length', '4'));
|
||||||
|
|
||||||
|
return str_pad($this->number, $minLength, '0', STR_PAD_LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value used when generating a URL for this ticket (route($name,
|
||||||
|
* $ticket)) — mirrors formattedNumber() minus the prefix, so a link
|
||||||
|
* never shows the raw sequential number while the page itself shows an
|
||||||
|
* obfuscated one (or vice versa).
|
||||||
|
*/
|
||||||
|
public function getRouteKey()
|
||||||
|
{
|
||||||
|
return Settings::bool('ticket_number_obfuscate') ? ($this->checksum ?? $this->number) : $this->number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbound counterpart to getRouteKey() — resolves a URL segment back to
|
||||||
|
* a ticket via whichever column matches the current numbering mode.
|
||||||
|
*/
|
||||||
|
public function resolveRouteBinding($value, $field = null)
|
||||||
|
{
|
||||||
|
if ($field) {
|
||||||
|
return $this->where($field, $value)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
$column = Settings::bool('ticket_number_obfuscate') ? 'checksum' : 'number';
|
||||||
|
|
||||||
|
return $this->where($column, $value)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A short, HMAC-derived checksum for this ticket, carrying no relation
|
||||||
|
* to creation order — salted with the app key so it can't be predicted
|
||||||
|
* or reversed back into id/creation order without server-side secrets.
|
||||||
|
* Collisions are rare but not astronomically so at 6 digits, so this
|
||||||
|
* walks a nonce forward until it lands on a value no other ticket
|
||||||
|
* already has (enforced for real by the column's unique constraint).
|
||||||
|
*/
|
||||||
|
public static function generateUniqueChecksum(int $id): string
|
||||||
|
{
|
||||||
|
$nonce = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
$hash = hash_hmac('sha256', $id.'|'.$nonce, (string) config('app.key'));
|
||||||
|
$candidate = (string) (hexdec(substr($hash, 0, 8)) % 900000 + 100000);
|
||||||
|
$nonce++;
|
||||||
|
} while (static::query()->where('checksum', $candidate)->exists());
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
public function categoryLabel(): string
|
public function categoryLabel(): string
|
||||||
{
|
{
|
||||||
return $this->subcategory?->label() ?? '';
|
return $this->subcategory?->label() ?? $this->category?->name ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -184,6 +452,7 @@ class Ticket extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
$q->orWhere('number', 'like', $like)
|
$q->orWhere('number', 'like', $like)
|
||||||
|
->orWhere('checksum', 'like', $like)
|
||||||
->orWhere('name', 'like', $like)
|
->orWhere('name', 'like', $like)
|
||||||
->orWhere('email', 'like', $like)
|
->orWhere('email', 'like', $like)
|
||||||
->orWhereIn('id', $messageTicketIds);
|
->orWhereIn('id', $messageTicketIds);
|
||||||
@@ -342,7 +611,7 @@ class Ticket extends Model
|
|||||||
public function flushTimer(): void
|
public function flushTimer(): void
|
||||||
{
|
{
|
||||||
if ($this->timer_started_at) {
|
if ($this->timer_started_at) {
|
||||||
$this->update([
|
$this->updateTimerFields([
|
||||||
'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(),
|
'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(),
|
||||||
'timer_started_at' => now(),
|
'timer_started_at' => now(),
|
||||||
]);
|
]);
|
||||||
@@ -352,7 +621,7 @@ class Ticket extends Model
|
|||||||
public function stopTimer(): void
|
public function stopTimer(): void
|
||||||
{
|
{
|
||||||
if ($this->timer_started_at) {
|
if ($this->timer_started_at) {
|
||||||
$this->update([
|
$this->updateTimerFields([
|
||||||
'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(),
|
'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(),
|
||||||
'timer_started_at' => null,
|
'timer_started_at' => null,
|
||||||
]);
|
]);
|
||||||
@@ -371,13 +640,13 @@ class Ticket extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->timer_started_at) {
|
if (! $this->timer_started_at) {
|
||||||
$this->update(['timer_started_at' => now()]);
|
$this->updateTimerFields(['timer_started_at' => now()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resetTimer(): void
|
public function resetTimer(): void
|
||||||
{
|
{
|
||||||
$this->update(['time_spent_seconds' => 0, 'timer_started_at' => null]);
|
$this->updateTimerFields(['time_spent_seconds' => 0, 'timer_started_at' => null]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -388,12 +657,27 @@ class Ticket extends Model
|
|||||||
*/
|
*/
|
||||||
public function setTimeSpent(int $seconds): void
|
public function setTimeSpent(int $seconds): void
|
||||||
{
|
{
|
||||||
$this->update([
|
$this->updateTimerFields([
|
||||||
'time_spent_seconds' => max(0, $seconds),
|
'time_spent_seconds' => max(0, $seconds),
|
||||||
'timer_started_at' => $this->timer_started_at ? now() : null,
|
'timer_started_at' => $this->timer_started_at ? now() : null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timer bookkeeping alone is never a ticket "update" worth surfacing —
|
||||||
|
* merely opening a ticket (resumeTimer on mount, stopTimer on leaving)
|
||||||
|
* would otherwise bump `updated_at` on every single view, drowning out
|
||||||
|
* genuinely stale tickets in queues/lists sorted by that column. Real
|
||||||
|
* content changes (replies, status/priority/assignee edits, ...) go
|
||||||
|
* through their own ->update() calls elsewhere and still touch it.
|
||||||
|
*/
|
||||||
|
private function updateTimerFields(array $attributes): void
|
||||||
|
{
|
||||||
|
$this->timestamps = false;
|
||||||
|
$this->update($attributes);
|
||||||
|
$this->timestamps = true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom field values for this ticket's subcategory, in display order, skipping blanks.
|
* Custom field values for this ticket's subcategory, in display order, skipping blanks.
|
||||||
*
|
*
|
||||||
@@ -419,4 +703,33 @@ class Ticket extends Model
|
|||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the custom_fields JSON blob into ticket_field_values, one row
|
||||||
|
* per non-blank entry — called automatically on save (see booted()).
|
||||||
|
* Deleted/blanked entries are removed rather than left stale, and
|
||||||
|
* unrecognized field ids (e.g. a value left over after its custom_fields
|
||||||
|
* definition was deleted) are skipped, matching the migration's
|
||||||
|
* backfill.
|
||||||
|
*/
|
||||||
|
public function syncFieldValues(): void
|
||||||
|
{
|
||||||
|
$values = $this->custom_fields ?? [];
|
||||||
|
$validFieldIds = CustomField::query()->pluck('id')->all();
|
||||||
|
|
||||||
|
$this->fieldValues()->whereNotIn('custom_field_id', array_keys($values))->delete();
|
||||||
|
|
||||||
|
foreach ($values as $fieldId => $value) {
|
||||||
|
if ($value === '' || $value === null || ! in_array((int) $fieldId, $validFieldIds, true)) {
|
||||||
|
$this->fieldValues()->where('custom_field_id', $fieldId)->delete();
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->fieldValues()->updateOrCreate(
|
||||||
|
['custom_field_id' => $fieldId],
|
||||||
|
['value' => is_bool($value) ? ($value ? '1' : '0') : (string) $value],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,26 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
|
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
|
||||||
|
|
||||||
#[Fillable(['ticket_id', 'author_name', 'internal', 'body', 'edited', 'api_client_id', 'created_at', 'updated_at'])]
|
#[Fillable(['ticket_id', 'author_name', 'internal', 'body', 'edited', 'api_client_id', 'source', 'created_at', 'updated_at'])]
|
||||||
class TicketMessage extends Model
|
class TicketMessage extends Model
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Non-null values ever written to ticket_messages.source — null means
|
||||||
|
* "web" (see the migration that added this column); only IMAP-fetched
|
||||||
|
* replies set it to 'email'. Enforced on save (see booted() below) so a
|
||||||
|
* typo'd literal fails loudly instead of silently sticking.
|
||||||
|
*/
|
||||||
|
public const SOURCES = ['email'];
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (TicketMessage $message) {
|
||||||
|
if ($message->source !== null && ! in_array($message->source, self::SOURCES, true)) {
|
||||||
|
throw new \InvalidArgumentException("Invalid ticket message source: {$message->source}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
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;
|
namespace App\Models;
|
||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
|
||||||
use Database\Factories\UserFactory;
|
use Database\Factories\UserFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
@@ -15,8 +14,8 @@ use Illuminate\Notifications\Notifiable;
|
|||||||
use LdapRecord\Laravel\Auth\AuthenticatesWithLdap;
|
use LdapRecord\Laravel\Auth\AuthenticatesWithLdap;
|
||||||
use LdapRecord\Laravel\Auth\LdapAuthenticatable;
|
use LdapRecord\Laravel\Auth\LdapAuthenticatable;
|
||||||
|
|
||||||
#[Fillable(['name', 'email', 'password', 'roles', 'custom_field_values'])]
|
#[Fillable(['name', 'email', 'password', 'roles', 'custom_field_values', 'operator_queue_columns'])]
|
||||||
#[Hidden(['password', 'remember_token'])]
|
#[Hidden(['password'])]
|
||||||
class User extends Authenticatable implements LdapAuthenticatable
|
class User extends Authenticatable implements LdapAuthenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
@@ -109,8 +108,8 @@ class User extends Authenticatable implements LdapAuthenticatable
|
|||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'email_verified_at' => 'datetime',
|
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
|
'operator_queue_columns' => 'array',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +188,13 @@ class User extends Authenticatable implements LdapAuthenticatable
|
|||||||
return $this->belongsToMany(Ticket::class, 'ticket_watchers');
|
return $this->belongsToMany(Ticket::class, 'ticket_watchers');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function recentlyViewedTickets(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Ticket::class, 'ticket_views')
|
||||||
|
->withPivot('viewed_at')
|
||||||
|
->orderByPivot('viewed_at', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
public function ticketsAssigned(): HasMany
|
public function ticketsAssigned(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Ticket::class, 'assignee_id');
|
return $this->hasMany(Ticket::class, 'assignee_id');
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class TicketNotification extends Notification
|
|||||||
'ticket_id' => $this->ticket->id,
|
'ticket_id' => $this->ticket->id,
|
||||||
'number' => $this->ticket->number,
|
'number' => $this->ticket->number,
|
||||||
'subject' => $this->ticket->subject,
|
'subject' => $this->ticket->subject,
|
||||||
'message' => 'Zgłoszenie #'.$this->ticket->number.' — '.$this->ticket->subject,
|
'message' => 'Zgłoszenie '.$this->ticket->displayNumber().' — '.$this->ticket->subject,
|
||||||
'url' => $this->ticketUrl(),
|
'url' => $this->ticketUrl(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -76,7 +76,7 @@ class TicketNotification extends Notification
|
|||||||
$firstName = trim(explode(' ', $this->ticket->name)[0] ?? $this->ticket->name);
|
$firstName = trim(explode(' ', $this->ticket->name)[0] ?? $this->ticket->name);
|
||||||
|
|
||||||
$rendered = $template?->render([
|
$rendered = $template?->render([
|
||||||
'numer' => $this->ticket->number,
|
'numer' => $this->ticket->formattedNumber(),
|
||||||
'imie' => $firstName,
|
'imie' => $firstName,
|
||||||
'temat' => $this->ticket->subject,
|
'temat' => $this->ticket->subject,
|
||||||
'status' => $this->ticket->statusLabel(),
|
'status' => $this->ticket->statusLabel(),
|
||||||
@@ -87,7 +87,7 @@ class TicketNotification extends Notification
|
|||||||
'link' => $this->ticketUrl(),
|
'link' => $this->ticketUrl(),
|
||||||
'ocena' => route('client.ticket', $this->ticket).'#csat',
|
'ocena' => route('client.ticket', $this->ticket).'#csat',
|
||||||
]) ?? [
|
]) ?? [
|
||||||
'subject' => 'Zgłoszenie #'.$this->ticket->number,
|
'subject' => 'Zgłoszenie '.$this->ticket->displayNumber(),
|
||||||
'body' => $this->ticket->subject,
|
'body' => $this->ticket->subject,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Events\NotificationCreated;
|
use App\Events\NotificationCreated;
|
||||||
|
use App\Events\TicketMessagePosted;
|
||||||
|
use App\Jobs\GenerateTicketAiSummaryJob;
|
||||||
use App\Models\ApiClient;
|
use App\Models\ApiClient;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Notifications\TicketNotification;
|
use App\Notifications\TicketNotification;
|
||||||
@@ -40,6 +42,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
$this->applyTimezoneSettingsOverride();
|
$this->applyTimezoneSettingsOverride();
|
||||||
$this->configureApiRateLimiting();
|
$this->configureApiRateLimiting();
|
||||||
$this->broadcastBellNotifications();
|
$this->broadcastBellNotifications();
|
||||||
|
$this->regenerateAiSummaryOnNewMessage();
|
||||||
|
|
||||||
// 'user' backs the polymorphic notifiable_type column on the
|
// 'user' backs the polymorphic notifiable_type column on the
|
||||||
// database-notifications table (in-app notification bell).
|
// database-notifications table (in-app notification bell).
|
||||||
@@ -68,6 +71,25 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-optional: when enabled, every reply/note/API message re-runs the
|
||||||
|
* AI summary for its ticket right away instead of waiting for the next
|
||||||
|
* ai:run-ticket-automation sweep (up to schedule_ai_automation_minutes
|
||||||
|
* stale). dispatchAfterResponse() runs in-process after the triggering
|
||||||
|
* request finishes rather than going through the queue table — see
|
||||||
|
* GenerateTicketAiSummaryJob's docblock for why.
|
||||||
|
*/
|
||||||
|
protected function regenerateAiSummaryOnNewMessage(): void
|
||||||
|
{
|
||||||
|
Event::listen(TicketMessagePosted::class, function (TicketMessagePosted $event) {
|
||||||
|
if (! Settings::bool('ai_summary_enabled') || ! Settings::bool('ai_summary_regenerate_on_message')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GenerateTicketAiSummaryJob::dispatchAfterResponse($event->ticketId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API keys get a generous per-key budget; unauthenticated requests (which
|
* API keys get a generous per-key budget; unauthenticated requests (which
|
||||||
* only ever hit the guard before rejecting with 401) get a much smaller
|
* only ever hit the guard before rejecting with 401) get a much smaller
|
||||||
@@ -85,13 +107,21 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Avoid touching the DB during artisan commands that run before the
|
* Avoid touching the DB during the specific artisan commands that run
|
||||||
* `settings` table exists (e.g. `migrate` itself), or before it can be
|
* before the `settings` table exists or could be mid-schema-change (the
|
||||||
* queried at all — shared by every settings-driven config override below.
|
* migrate family) — shared by every settings-driven config override
|
||||||
|
* below. Deliberately scoped to just those commands rather than "any
|
||||||
|
* console command": scheduled commands (`schedule:run` → e.g.
|
||||||
|
* `emails:fetch-imap`, `tickets:check-sla-breaches`) also run in the
|
||||||
|
* console and need the real SMTP/LDAP/timezone overrides exactly like a
|
||||||
|
* web request does, or their notifications/lookups silently fall back
|
||||||
|
* to whatever's in `.env` (this was a real bug: scheduled-command
|
||||||
|
* notifications were always going out via the `.env` `log` mailer
|
||||||
|
* instead of the configured SMTP server).
|
||||||
*/
|
*/
|
||||||
protected function settingsTableUsable(): bool
|
protected function settingsTableUsable(): bool
|
||||||
{
|
{
|
||||||
if ($this->app->runningInConsole() && ! $this->app->runningUnitTests()) {
|
if ($this->app->runningConsoleCommand('migrate', 'migrate:fresh', 'migrate:refresh', 'migrate:reset', 'migrate:rollback', 'migrate:install')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +158,12 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
Config::set('ldap.connections.default', $config);
|
Config::set('ldap.connections.default', $config);
|
||||||
Container::addConnection(new Connection($config), 'default');
|
Container::addConnection(new Connection($config), 'default');
|
||||||
|
|
||||||
|
// Active Directory's objectClass chain (top/person/organizationalPerson/
|
||||||
|
// user) and login attribute (sAMAccountName) differ from the
|
||||||
|
// LLDAP/OpenLDAP schema LldapUser is scoped to — swap in AdUser so a
|
||||||
|
// directory switch doesn't leave every login matching zero entries.
|
||||||
|
Config::set('auth.providers.users.model', Settings::ldapUserModelClass());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
116
src/app/Services/AiClient.php
Normal file
116
src/app/Services/AiClient.php
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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,
|
||||||
|
* OpenAI itself, or a self-hosted Ollama instance's OpenAI-compat endpoint,
|
||||||
|
* whichever the admin points ai_base_url at. Not BookStack-specific; the
|
||||||
|
* BookStack content tagger is just the first consumer.
|
||||||
|
*/
|
||||||
|
class AiClient
|
||||||
|
{
|
||||||
|
public function enabled(): bool
|
||||||
|
{
|
||||||
|
// Deliberately no api-key requirement here — a self-hosted Ollama
|
||||||
|
// instance typically has no auth at all.
|
||||||
|
return Settings::bool('ai_enabled')
|
||||||
|
&& Settings::get('ai_base_url')
|
||||||
|
&& Settings::get('ai_model');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{role: string, content: string}> $messages
|
||||||
|
* @return string|null the assistant message content, or null on any failure
|
||||||
|
*/
|
||||||
|
public function chat(array $messages, array $options = []): ?string
|
||||||
|
{
|
||||||
|
if (! $this->enabled()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$model = Settings::get('ai_model');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->client()->post('/chat/completions', [
|
||||||
|
'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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests unsaved admin-form values directly, rather than whatever's
|
||||||
|
* currently stored — mirrors BookStackClient::testConnection().
|
||||||
|
*
|
||||||
|
* @return array{ok: bool, message: ?string}
|
||||||
|
*/
|
||||||
|
public function testConnection(string $baseUrl, string $apiKey, string $model, bool $verifySsl = true): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$http = Http::withOptions(['verify' => $verifySsl])
|
||||||
|
->timeout(10)
|
||||||
|
->baseUrl(rtrim($baseUrl, '/'));
|
||||||
|
|
||||||
|
if ($apiKey !== '') {
|
||||||
|
$http = $http->withToken($apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $http->post('/chat/completions', [
|
||||||
|
'model' => $model,
|
||||||
|
'messages' => [['role' => 'user', 'content' => 'ping']],
|
||||||
|
'max_tokens' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return ['ok' => true, 'message' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => false, 'message' => $response->json('error.message') ?? ('HTTP '.$response->status())];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['ok' => false, 'message' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function client()
|
||||||
|
{
|
||||||
|
$http = Http::withOptions(['verify' => Settings::bool('ai_verify_ssl')])
|
||||||
|
->timeout(60)
|
||||||
|
->baseUrl(rtrim(Settings::get('ai_base_url'), '/'));
|
||||||
|
|
||||||
|
$apiKey = Settings::get('ai_api_key');
|
||||||
|
|
||||||
|
return $apiKey ? $http->withToken($apiKey) : $http;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,31 @@ class BookStackClient
|
|||||||
self::CONTEXT_TICKET_VIEW => 'bookstack_allowed_shelf_ids_ticket_view',
|
self::CONTEXT_TICKET_VIEW => 'bookstack_allowed_shelf_ids_ticket_view',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content types selectable via the admin's "Przeszukuj" checkboxes.
|
||||||
|
* Deliberately excludes 'bookshelf' — shelves are only ever a filter
|
||||||
|
* (dozwolone półki), never a suggestion result in their own right.
|
||||||
|
*/
|
||||||
|
public const SEARCH_TYPES = ['book', 'page', 'chapter'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the query text is matched, via the admin's "Szukaj po" option —
|
||||||
|
* 'name' restricts to the title ({in_name:...}), 'tags' matches a tag
|
||||||
|
* whose name equals the query (expected to hold the helpdesk
|
||||||
|
* category/subcategory name, e.g. a "Drukarki" tag on the relevant
|
||||||
|
* BookStack pages), 'both' runs both and merges the results (BookStack's
|
||||||
|
* query syntax ANDs filters together, so there's no single-query way to
|
||||||
|
* express "name OR tag").
|
||||||
|
*/
|
||||||
|
public const SEARCH_BY_OPTIONS = ['name', 'tags', 'both'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content types the bulk-tagging command operates over — same set as
|
||||||
|
* SEARCH_TYPES (book/page/chapter, no bookshelf), named separately since
|
||||||
|
* the two consts serve different features that happen to share a domain.
|
||||||
|
*/
|
||||||
|
public const CONTENT_TYPES = self::SEARCH_TYPES;
|
||||||
|
|
||||||
public function enabled(): bool
|
public function enabled(): bool
|
||||||
{
|
{
|
||||||
return Settings::bool('bookstack_enabled')
|
return Settings::bool('bookstack_enabled')
|
||||||
@@ -39,44 +64,56 @@ class BookStackClient
|
|||||||
* before any content is ever suggested, independently per context).
|
* before any content is ever suggested, independently per context).
|
||||||
* Cached briefly since the same category/subcategory query repeats
|
* Cached briefly since the same category/subcategory query repeats
|
||||||
* across every ticket created/viewed with that combination. Respects the
|
* across every ticket created/viewed with that combination. Respects the
|
||||||
* admin-configured bookstack_search_types setting ('both'|'page'|'book')
|
* admin-configured bookstack_search_types (subset of SEARCH_TYPES, via
|
||||||
* via BookStack's own `{type:x}` query syntax. The cache key folds in the
|
* BookStack's `{type:a|b}` syntax) and bookstack_search_by ('name'|
|
||||||
* allowed-shelf list so changing it in Admin > Konfiguracja is reflected
|
* 'tags'|'both', via `{in_name:...}`/`[...]`) settings. The cache key
|
||||||
* immediately, instead of possibly serving a pre-change result for up to
|
* folds in the allowed-shelf list so changing it in Admin > Konfiguracja
|
||||||
* 10 minutes.
|
* is reflected immediately, instead of possibly serving a pre-change
|
||||||
|
* result for up to 10 minutes.
|
||||||
|
*
|
||||||
|
* $tagQuery is the text matched by the 'tags' variant, separate from
|
||||||
|
* $query (matched by the 'name' variant) — callers pass the bare
|
||||||
|
* subcategory name here (what bookstack:tag-content actually writes as
|
||||||
|
* a tag), while $query stays the fuller "Category Subcategory" text
|
||||||
|
* that's more useful for a plain title/body search. Defaults to $query
|
||||||
|
* so existing call sites that don't pass it keep working.
|
||||||
*
|
*
|
||||||
* @return array<int, array{name: string, url: ?string, type: string, book: ?string, shelf: ?string}>
|
* @return array<int, array{name: string, url: ?string, type: string, book: ?string, shelf: ?string}>
|
||||||
*/
|
*/
|
||||||
public function search(string $query, int $limit = 5, string $context = self::CONTEXT_CREATION): array
|
public function search(string $query, int $limit = 5, string $context = self::CONTEXT_CREATION, ?string $tagQuery = null): array
|
||||||
{
|
{
|
||||||
$query = trim($query);
|
$query = trim($query);
|
||||||
|
$tagQuery = trim($tagQuery ?? $query);
|
||||||
$allowedShelfIds = $this->allowedShelfIds($context);
|
$allowedShelfIds = $this->allowedShelfIds($context);
|
||||||
|
|
||||||
if (! $this->enabled() || $query === '' || ! $allowedShelfIds) {
|
if (! $this->enabled() || $query === '' || ! $allowedShelfIds) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$typeFilter = Settings::get('bookstack_search_types', 'both');
|
$searchBy = $this->searchBy();
|
||||||
|
$bookstackQueries = $searchBy === 'both'
|
||||||
|
? [$this->buildQuery($query, 'name'), $this->buildQuery($tagQuery, 'tags')]
|
||||||
|
: [$this->buildQuery($searchBy === 'tags' ? $tagQuery : $query, $searchBy)];
|
||||||
|
|
||||||
if (in_array($typeFilter, ['page', 'book'], true)) {
|
$cacheKey = 'bookstack:search:'.md5(implode('||', $bookstackQueries).'|'.$limit.'|'.implode(',', $allowedShelfIds));
|
||||||
$query .= " {type:{$typeFilter}}";
|
|
||||||
}
|
|
||||||
|
|
||||||
$cacheKey = 'bookstack:search:'.md5($query.'|'.$limit.'|'.implode(',', $allowedShelfIds));
|
return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($bookstackQueries, $limit, $allowedShelfIds) {
|
||||||
|
|
||||||
return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($query, $limit, $allowedShelfIds) {
|
|
||||||
try {
|
try {
|
||||||
$response = $this->client()->get('/api/search', ['query' => $query, 'count' => $limit]);
|
|
||||||
|
|
||||||
if (! $response->successful()) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$shelfMap = $this->shelfBookMap();
|
$shelfMap = $this->shelfBookMap();
|
||||||
$allowedBookIds = $this->bookIdsForShelves($shelfMap, $allowedShelfIds);
|
$allowedBookIds = $this->bookIdsForShelves($shelfMap, $allowedShelfIds);
|
||||||
$bookShelfNames = $this->bookShelfNames($shelfMap);
|
$bookShelfNames = $this->bookShelfNames($shelfMap);
|
||||||
|
|
||||||
return collect($response->json('data', []))
|
$items = collect();
|
||||||
|
|
||||||
|
foreach ($bookstackQueries as $bookstackQuery) {
|
||||||
|
$response = $this->client()->get('/api/search', ['query' => $bookstackQuery, 'count' => $limit]);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
$items = $items->concat($response->json('data', []));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $items
|
||||||
->filter(function (array $item) use ($allowedShelfIds, $allowedBookIds) {
|
->filter(function (array $item) use ($allowedShelfIds, $allowedBookIds) {
|
||||||
$type = $item['type'] ?? null;
|
$type = $item['type'] ?? null;
|
||||||
|
|
||||||
@@ -103,6 +140,8 @@ class BookStackClient
|
|||||||
];
|
];
|
||||||
})
|
})
|
||||||
->filter(fn (array $item) => $item['name'] !== '')
|
->filter(fn (array $item) => $item['name'] !== '')
|
||||||
|
->unique(fn (array $item) => $item['url'] ?? $item['name'])
|
||||||
|
->take($limit)
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
@@ -111,6 +150,68 @@ class BookStackClient
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds one BookStack search-syntax query string for $query, restricted
|
||||||
|
* to $by ('name' -> `{in_name:...}`, 'tags' -> `[...]`) and to the
|
||||||
|
* configured content types (`{type:a|b}`, omitted if all types are
|
||||||
|
* allowed since that's equivalent to no filter).
|
||||||
|
*/
|
||||||
|
protected function buildQuery(string $query, string $by): string
|
||||||
|
{
|
||||||
|
$parts = [$by === 'tags' ? "[{$query}]" : "{in_name:{$query}}"];
|
||||||
|
|
||||||
|
$types = $this->searchTypes();
|
||||||
|
|
||||||
|
if (array_diff(self::SEARCH_TYPES, $types)) {
|
||||||
|
$parts[] = '{type:'.implode('|', $types).'}';
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string[] non-empty subset of SEARCH_TYPES
|
||||||
|
*/
|
||||||
|
protected function searchTypes(): array
|
||||||
|
{
|
||||||
|
return self::normalizeSearchTypes(Settings::get('bookstack_search_types', ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes bookstack_search_types storage into a non-empty subset of
|
||||||
|
* SEARCH_TYPES — shared with Admin\Panel so the checkbox UI and the
|
||||||
|
* actual search agree on the same format. Also understands the legacy
|
||||||
|
* single-value 'both'/'page'/'book' storage from before the setting
|
||||||
|
* became a checkbox list, so existing configuration keeps working.
|
||||||
|
*
|
||||||
|
* @param string[]|string $raw
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public static function normalizeSearchTypes(array|string $raw): array
|
||||||
|
{
|
||||||
|
$legacy = ['both' => self::SEARCH_TYPES, 'page' => ['page'], 'book' => ['book']];
|
||||||
|
|
||||||
|
if (is_string($raw) && isset($legacy[$raw])) {
|
||||||
|
return $legacy[$raw];
|
||||||
|
}
|
||||||
|
|
||||||
|
$types = collect(is_array($raw) ? $raw : explode(',', $raw))
|
||||||
|
->map(fn ($v) => trim((string) $v))
|
||||||
|
->filter(fn ($v) => in_array($v, self::SEARCH_TYPES, true))
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return $types ?: self::SEARCH_TYPES;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function searchBy(): string
|
||||||
|
{
|
||||||
|
$raw = Settings::get('bookstack_search_by', 'both');
|
||||||
|
|
||||||
|
return in_array($raw, self::SEARCH_BY_OPTIONS, true) ? $raw : 'both';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drops the cached shelf list and shelf>book membership map — used by
|
* Drops the cached shelf list and shelf>book membership map — used by
|
||||||
* the admin's "Odśwież listę półek" button so a shelf renamed/added/
|
* the admin's "Odśwież listę półek" button so a shelf renamed/added/
|
||||||
@@ -155,6 +256,103 @@ class BookStackClient
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every book/chapter/page of $type across the whole BookStack instance —
|
||||||
|
* NOT filtered by the allowed-shelf settings, unlike search(). Those only
|
||||||
|
* gate which suggestions are ever shown to a client/operator; the bulk
|
||||||
|
* tagger is meant to cover every piece of content regardless. Paginates
|
||||||
|
* through BookStack's count/offset list endpoints (count capped at 500,
|
||||||
|
* the API's own per-page maximum). Uncached — this is a one-shot batch
|
||||||
|
* read, not a repeated request-path lookup.
|
||||||
|
*
|
||||||
|
* @return array<int, array{id: int, name: string}>
|
||||||
|
*/
|
||||||
|
public function listAll(string $type): array
|
||||||
|
{
|
||||||
|
if (! $this->enabled() || ! in_array($type, self::CONTENT_TYPES, true)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
$offset = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
$response = $this->client()->get("/api/{$type}s", ['count' => 500, 'offset' => $offset]);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$page = $response->json('data', []);
|
||||||
|
$items = [...$items, ...$page];
|
||||||
|
$offset += 500;
|
||||||
|
$total = $response->json('total', 0);
|
||||||
|
} while (count($page) > 0 && count($items) < $total);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full detail for a single book/chapter/page — its current tags (needed
|
||||||
|
* to merge rather than clobber when the tagger writes new ones) and the
|
||||||
|
* text used to classify it (a page's markdown source, or a book/
|
||||||
|
* chapter's description). Null if $type is invalid or the item can't be
|
||||||
|
* fetched.
|
||||||
|
*
|
||||||
|
* @return array{id: int, name: string, tags: array<int, array{name: string, value: string}>, content: string}|null
|
||||||
|
*/
|
||||||
|
public function detail(string $type, int $id): ?array
|
||||||
|
{
|
||||||
|
if (! $this->enabled() || ! in_array($type, self::CONTENT_TYPES, true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->client()->get("/api/{$type}s/{$id}");
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $data['id'],
|
||||||
|
'name' => $data['name'] ?? '',
|
||||||
|
'tags' => $data['tags'] ?? [],
|
||||||
|
'content' => $data['markdown'] ?? $data['description'] ?? '',
|
||||||
|
];
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overwrites just the tags field on a book/chapter/page — BookStack
|
||||||
|
* treats every field on its update endpoints as optional, so this never
|
||||||
|
* touches the item's name/content/other attributes. Callers are
|
||||||
|
* responsible for merging in any tags they want to keep (this replaces
|
||||||
|
* the whole array, it doesn't append).
|
||||||
|
*
|
||||||
|
* @param array<int, array{name: string, value: string}> $tags
|
||||||
|
*/
|
||||||
|
public function updateTags(string $type, int $id, array $tags): bool
|
||||||
|
{
|
||||||
|
if (! $this->enabled() || ! in_array($type, self::CONTENT_TYPES, true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->client()->put("/api/{$type}s/{$id}", ['tags' => $tags])->successful();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return int[]
|
* @return int[]
|
||||||
*/
|
*/
|
||||||
|
|||||||
260
src/app/Services/BookStackContentTagger.php
Normal file
260
src/app/Services/BookStackContentTagger.php
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Subcategory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-assigns BookStack tags to every book/chapter/page, using the
|
||||||
|
* configured AiClient to classify each item's title+content against the
|
||||||
|
* helpdesk's current subcategory vocabulary — so BookStackClient::search()'s
|
||||||
|
* 'tags'/'both' mode has something to actually match against. Idempotent by
|
||||||
|
* default: an item already carrying a tag matching a current subcategory
|
||||||
|
* name is skipped, so re-running after adding a handful of new pages is
|
||||||
|
* cheap; $force re-classifies everything.
|
||||||
|
*/
|
||||||
|
class BookStackContentTagger
|
||||||
|
{
|
||||||
|
protected const BATCH_SIZE = 20;
|
||||||
|
|
||||||
|
protected const CONTENT_EXCERPT_CHARS = 2000;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected BookStackClient $bookstack,
|
||||||
|
protected AiClient $ai,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{scanned: int, tagged: int, skipped: int, failed_batches: int}
|
||||||
|
*/
|
||||||
|
public function run(bool $dryRun = false, bool $force = false, ?int $limit = null): array
|
||||||
|
{
|
||||||
|
$totals = ['scanned' => 0, 'tagged' => 0, 'skipped' => 0, 'failed_batches' => 0];
|
||||||
|
|
||||||
|
if (! $this->bookstack->enabled() || ! $this->ai->enabled()) {
|
||||||
|
return $totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
$vocabulary = $this->subcategoryVocabulary();
|
||||||
|
|
||||||
|
if (! $vocabulary) {
|
||||||
|
return $totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (BookStackClient::CONTENT_TYPES as $type) {
|
||||||
|
foreach ($this->itemsToProcess($type, $vocabulary, $force, $limit, $totals) as $batch) {
|
||||||
|
$this->processBatch($type, $batch, $vocabulary, $dryRun, $totals);
|
||||||
|
|
||||||
|
if ($limit !== null && $totals['scanned'] >= $limit) {
|
||||||
|
break 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string[] current subcategory names, deduped case-insensitively
|
||||||
|
*/
|
||||||
|
protected function subcategoryVocabulary(): array
|
||||||
|
{
|
||||||
|
return Subcategory::query()
|
||||||
|
->pluck('name')
|
||||||
|
->filter()
|
||||||
|
->unique(fn (string $name) => Str::lower($name))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yields item detail records (id, name, tags, content) in chunks of
|
||||||
|
* BATCH_SIZE, skipping already-tagged items unless $force. Detail is
|
||||||
|
* fetched per item since BookStack's list endpoints don't include
|
||||||
|
* tags/content — acceptable here since this is an offline batch job, not
|
||||||
|
* a request-path call.
|
||||||
|
*
|
||||||
|
* @param string[] $vocabulary
|
||||||
|
* @param array{scanned: int, tagged: int, skipped: int, failed_batches: int} $totals
|
||||||
|
* @return \Generator<int, array<int, array{id: int, name: string, tags: array, content: string}>>
|
||||||
|
*/
|
||||||
|
protected function itemsToProcess(string $type, array $vocabulary, bool $force, ?int $limit, array &$totals): \Generator
|
||||||
|
{
|
||||||
|
$batch = [];
|
||||||
|
|
||||||
|
foreach ($this->bookstack->listAll($type) as $summary) {
|
||||||
|
if ($limit !== null && $totals['scanned'] >= $limit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$detail = $this->bookstack->detail($type, $summary['id']);
|
||||||
|
|
||||||
|
if (! $detail) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totals['scanned']++;
|
||||||
|
|
||||||
|
if (! $force && $this->alreadyTagged($detail['tags'], $vocabulary)) {
|
||||||
|
$totals['skipped']++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$batch[] = $detail;
|
||||||
|
|
||||||
|
if (count($batch) >= self::BATCH_SIZE) {
|
||||||
|
yield $batch;
|
||||||
|
$batch = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($batch) {
|
||||||
|
yield $batch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{name: string, value: string}> $tags
|
||||||
|
* @param string[] $vocabulary
|
||||||
|
*/
|
||||||
|
protected function alreadyTagged(array $tags, array $vocabulary): bool
|
||||||
|
{
|
||||||
|
$existing = collect($tags)->map(fn (array $t) => Str::lower($t['name'] ?? ''));
|
||||||
|
$vocabLower = collect($vocabulary)->map(fn (string $v) => Str::lower($v));
|
||||||
|
|
||||||
|
return $existing->intersect($vocabLower)->isNotEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{id: int, name: string, tags: array, content: string}> $batch
|
||||||
|
* @param string[] $vocabulary
|
||||||
|
* @param array{scanned: int, tagged: int, skipped: int, failed_batches: int} $totals
|
||||||
|
*/
|
||||||
|
protected function processBatch(string $type, array $batch, array $vocabulary, bool $dryRun, array &$totals): void
|
||||||
|
{
|
||||||
|
$prompt = $this->buildPrompt($batch, $vocabulary);
|
||||||
|
|
||||||
|
$raw = $this->ai->chat([
|
||||||
|
['role' => 'system', 'content' => $prompt['system']],
|
||||||
|
['role' => 'user', 'content' => $prompt['user']],
|
||||||
|
], ['temperature' => 0]);
|
||||||
|
|
||||||
|
$assignments = $this->parseAssignments($raw, $vocabulary);
|
||||||
|
|
||||||
|
if ($assignments === null) {
|
||||||
|
$totals['failed_batches']++;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($batch as $item) {
|
||||||
|
$labels = $assignments[(string) $item['id']] ?? [];
|
||||||
|
|
||||||
|
if (! $labels) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$totals['tagged']++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mergedTags = $this->mergeTags($item['tags'], $labels);
|
||||||
|
|
||||||
|
if ($this->bookstack->updateTags($type, $item['id'], $mergedTags)) {
|
||||||
|
$totals['tagged']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{id: int, name: string, tags: array, content: string}> $batch
|
||||||
|
* @param string[] $vocabulary
|
||||||
|
* @return array{system: string, user: string}
|
||||||
|
*/
|
||||||
|
protected function buildPrompt(array $batch, array $vocabulary): array
|
||||||
|
{
|
||||||
|
$system = 'Klasyfikujesz artykuły bazy wiedzy do kategorii zgłoszeń helpdesk. '
|
||||||
|
.'Dostępne kategorie (użyj DOKŁADNIE tej pisowni): '.implode(', ', $vocabulary).'. '
|
||||||
|
.'Dla każdego elementu przypisz 0, 1 lub więcej pasujących kategorii — nie zgaduj '
|
||||||
|
.'kategorii, jeśli żadna sensownie nie pasuje, zwróć pustą tablicę. '
|
||||||
|
.'Odpowiedz WYŁĄCZNIE obiektem JSON, bez żadnego innego tekstu ani formatowania, '
|
||||||
|
.'gdzie klucz to id elementu (jako string), a wartość to tablica dopasowanych nazw '
|
||||||
|
.'kategorii. Przykład: {"12": ["Drukarki i skanery"], "13": []}';
|
||||||
|
|
||||||
|
$user = collect($batch)->map(function (array $item) {
|
||||||
|
$excerpt = Str::limit(strip_tags($item['content']), self::CONTENT_EXCERPT_CHARS, '');
|
||||||
|
|
||||||
|
return "id={$item['id']} nazwa=\"{$item['name']}\"\n{$excerpt}";
|
||||||
|
})->implode("\n---\n");
|
||||||
|
|
||||||
|
return ['system' => $system, 'user' => $user];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defensively parses the LLM's JSON response — not every OpenAI-
|
||||||
|
* compatible provider (esp. self-hosted Ollama models) reliably honors a
|
||||||
|
* "respond with only JSON" instruction, so this first tries to pull out
|
||||||
|
* the first {...} block (in case the model wrapped it in prose or a
|
||||||
|
* markdown code fence) before decoding. A malformed/non-JSON response
|
||||||
|
* fails just this one batch (caller counts it in failed_batches) rather
|
||||||
|
* than aborting the whole run. Only vocabulary-matching labels survive
|
||||||
|
* (case-insensitive); anything else the model returns is discarded.
|
||||||
|
*
|
||||||
|
* @param string[] $vocabulary
|
||||||
|
* @return array<string, string[]>|null
|
||||||
|
*/
|
||||||
|
protected function parseAssignments(?string $raw, array $vocabulary): ?array
|
||||||
|
{
|
||||||
|
if (! $raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! preg_match('/\{.*\}/s', $raw, $matches)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($matches[0], true);
|
||||||
|
|
||||||
|
if (! is_array($decoded)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$vocabByLower = collect($vocabulary)->mapWithKeys(fn (string $v) => [Str::lower($v) => $v]);
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach ($decoded as $id => $labels) {
|
||||||
|
if (! is_array($labels)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[(string) $id] = collect($labels)
|
||||||
|
->map(fn ($l) => $vocabByLower[Str::lower((string) $l)] ?? null)
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{name: string, value: string}> $existing
|
||||||
|
* @param string[] $newLabels
|
||||||
|
* @return array<int, array{name: string, value: string}>
|
||||||
|
*/
|
||||||
|
protected function mergeTags(array $existing, array $newLabels): array
|
||||||
|
{
|
||||||
|
$existingLower = collect($existing)->map(fn (array $t) => Str::lower($t['name'] ?? ''));
|
||||||
|
|
||||||
|
$additions = collect($newLabels)
|
||||||
|
->reject(fn (string $label) => $existingLower->contains(Str::lower($label)))
|
||||||
|
->map(fn (string $label) => ['name' => $label, 'value' => '']);
|
||||||
|
|
||||||
|
return [...$existing, ...$additions->values()->all()];
|
||||||
|
}
|
||||||
|
}
|
||||||
282
src/app/Services/ImapMailboxFetcher.php
Normal file
282
src/app/Services/ImapMailboxFetcher.php
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\ImapMailbox;
|
||||||
|
use App\Support\Imap\InboundEmail;
|
||||||
|
use App\Support\Settings;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Throwable;
|
||||||
|
use Webklex\PHPIMAP\Client;
|
||||||
|
use Webklex\PHPIMAP\ClientManager;
|
||||||
|
use Webklex\PHPIMAP\Message;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I/O layer for the "reply/create ticket by e-mail" feature — connects to
|
||||||
|
* every enabled ImapMailbox, fetches unseen messages and delegates every
|
||||||
|
* decision to ImapMessageClassifier (pure logic) + TicketService (the
|
||||||
|
* existing ticket-mutation API). Kept thin and mostly untested directly;
|
||||||
|
* ImapMessageClassifier carries the actual test coverage.
|
||||||
|
*/
|
||||||
|
class ImapMailboxFetcher
|
||||||
|
{
|
||||||
|
private const HEADER_FIELDS = ['auto-submitted', 'x-autoreply', 'x-autorespond', 'precedence'];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ImapMessageClassifier $classifier,
|
||||||
|
private readonly TicketService $tickets,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{created: int, replied: int, rejected: int, errors: int}
|
||||||
|
*/
|
||||||
|
public function fetchAll(): array
|
||||||
|
{
|
||||||
|
$totals = ['created' => 0, 'replied' => 0, 'rejected' => 0, 'errors' => 0];
|
||||||
|
|
||||||
|
foreach (ImapMailbox::query()->where('enabled', true)->get() as $mailbox) {
|
||||||
|
foreach ($this->fetchMailbox($mailbox) as $key => $value) {
|
||||||
|
$totals[$key] += $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{created: int, replied: int, rejected: int, errors: int}
|
||||||
|
*/
|
||||||
|
public function fetchMailbox(ImapMailbox $mailbox): array
|
||||||
|
{
|
||||||
|
$result = ['created' => 0, 'replied' => 0, 'rejected' => 0, 'errors' => 0];
|
||||||
|
$log = Log::channel('imap');
|
||||||
|
|
||||||
|
$log->info("[{$mailbox->name}] łączenie z {$mailbox->host}:{$mailbox->port} (folder: {$mailbox->folder})");
|
||||||
|
|
||||||
|
try {
|
||||||
|
$client = $this->connect($mailbox);
|
||||||
|
$folder = $client->getFolder($mailbox->folder ?: 'INBOX');
|
||||||
|
$messages = $folder->messages()->whereUnseen()->get();
|
||||||
|
|
||||||
|
$log->info("[{$mailbox->name}] {$messages->count()} nieprzeczytanych wiadomości");
|
||||||
|
|
||||||
|
foreach ($messages as $message) {
|
||||||
|
try {
|
||||||
|
$this->processMessage($mailbox, $message, $result, $log);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$result['errors']++;
|
||||||
|
$log->error("[{$mailbox->name}] błąd przetwarzania wiadomości (uid={$message->getUid()}) — {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$client->disconnect();
|
||||||
|
$mailbox->update(['last_checked_at' => now(), 'last_error' => null]);
|
||||||
|
$log->info("[{$mailbox->name}] zakończono: {$result['created']} nowych, {$result['replied']} odpowiedzi, {$result['rejected']} odrzuconych, {$result['errors']} błędów");
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$result['errors']++;
|
||||||
|
$mailbox->update(['last_checked_at' => now(), 'last_error' => $e->getMessage()]);
|
||||||
|
$log->error("[{$mailbox->name}] połączenie nieudane — {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a connection and lists the configured folder, without fetching
|
||||||
|
* or touching any message — used by the admin "Testuj połączenie" button.
|
||||||
|
* Returns null on success, the exception message on failure.
|
||||||
|
*/
|
||||||
|
public function testConnection(ImapMailbox $mailbox): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$client = $this->connect($mailbox);
|
||||||
|
$client->getFolder($mailbox->folder ?: 'INBOX');
|
||||||
|
$client->disconnect();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function connect(ImapMailbox $mailbox): Client
|
||||||
|
{
|
||||||
|
$manager = new ClientManager;
|
||||||
|
$client = $manager->make([
|
||||||
|
'host' => $mailbox->host,
|
||||||
|
'port' => $mailbox->port,
|
||||||
|
'protocol' => 'imap',
|
||||||
|
'encryption' => $mailbox->encryption === 'none' ? false : $mailbox->encryption,
|
||||||
|
'validate_cert' => $mailbox->validate_cert,
|
||||||
|
'username' => $mailbox->username,
|
||||||
|
'password' => $mailbox->password,
|
||||||
|
]);
|
||||||
|
$client->connect();
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{created: int, replied: int, rejected: int, errors: int} $result
|
||||||
|
*/
|
||||||
|
private function processMessage(ImapMailbox $mailbox, Message $message, array &$result, LoggerInterface $log): void
|
||||||
|
{
|
||||||
|
$email = $this->toInboundEmail($message);
|
||||||
|
$uid = $message->getUid();
|
||||||
|
|
||||||
|
$log->debug("[{$mailbox->name}] uid={$uid} od={$email->fromEmail} temat=\"{$email->subject}\" nagłówki=".json_encode($email->headers, JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
$rejectReason = $this->classifier->rejectionReason($email, $mailbox->blocklistedSenders());
|
||||||
|
if ($rejectReason === null && ! $this->classifier->isSenderAllowed($email->fromEmail)) {
|
||||||
|
$rejectReason = "nadawca spoza LDAP ({$email->fromEmail}), a restrict_tickets_to_ldap jest włączone";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rejectReason !== null) {
|
||||||
|
$this->finish($message, $mailbox->rejected_folder);
|
||||||
|
$result['rejected']++;
|
||||||
|
$log->info("[{$mailbox->name}] uid={$uid} ODRZUCONO od {$email->fromEmail} \"{$email->subject}\" — {$rejectReason}");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oznacz/przenieś PRZED utworzeniem ticketu: awaria w tym miejscu
|
||||||
|
// zostawia co najwyżej "przetworzoną" wiadomość bez ticketu (widoczne,
|
||||||
|
// łatwe do naprawienia ręcznie) zamiast duplikatu ticketu przy
|
||||||
|
// ponownym uruchomieniu.
|
||||||
|
$this->finish($message, $mailbox->processed_folder);
|
||||||
|
|
||||||
|
$ticket = $this->classifier->matchTicket($email->subject);
|
||||||
|
$sender = $this->classifier->resolveSender($email->fromEmail);
|
||||||
|
$attachments = $this->buildAttachments($email, $mailbox, $log);
|
||||||
|
$authorName = $email->fromName !== '' ? $email->fromName : $email->fromEmail;
|
||||||
|
|
||||||
|
if ($ticket) {
|
||||||
|
if ($sender) {
|
||||||
|
$this->tickets->clientReply($ticket, $sender, $email->body(), $attachments, source: 'email');
|
||||||
|
} else {
|
||||||
|
$this->tickets->guestReply($ticket, $authorName, $email->body(), $attachments, source: 'email');
|
||||||
|
}
|
||||||
|
$result['replied']++;
|
||||||
|
$log->info("[{$mailbox->name}] uid={$uid} ODPOWIEDŹ od {$email->fromEmail} dopisana do zgłoszenia #{$ticket->id} ({$ticket->displayNumber()})");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$newTicket = $this->tickets->create([
|
||||||
|
'email' => $email->fromEmail,
|
||||||
|
'name' => $authorName,
|
||||||
|
'subcategory_id' => $mailbox->default_subcategory_id,
|
||||||
|
'category_id' => $mailbox->default_category_id,
|
||||||
|
'subject' => $email->subject !== '' ? $email->subject : '(bez tematu)',
|
||||||
|
'body' => $email->body(),
|
||||||
|
'source' => 'email',
|
||||||
|
], $sender, $authorName);
|
||||||
|
$result['created']++;
|
||||||
|
$log->info("[{$mailbox->name}] uid={$uid} NOWE zgłoszenie #{$newTicket->id} ({$newTicket->displayNumber()}) od {$email->fromEmail}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function finish(Message $message, ?string $moveToFolder): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$message->setFlag('Seen');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::channel('imap')->warning("IMAP: nie udało się oznaczyć wiadomości jako przeczytanej — {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($moveToFolder) {
|
||||||
|
$message->move($moveToFolder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toInboundEmail(Message $message): InboundEmail
|
||||||
|
{
|
||||||
|
$fromAddress = $message->getFrom()->first();
|
||||||
|
$header = $message->getHeader();
|
||||||
|
|
||||||
|
// Webklex's Header::get() returns an *empty* Attribute (not null)
|
||||||
|
// for a header that isn't present at all, and Attribute::first() on
|
||||||
|
// that empty instance comes back as '' rather than null — so a
|
||||||
|
// plain "!== null" check on the resulting value is always true,
|
||||||
|
// making every message look like it carries every one of these
|
||||||
|
// headers. Only keep a header that actually has content.
|
||||||
|
$headers = [];
|
||||||
|
foreach (self::HEADER_FIELDS as $name) {
|
||||||
|
$value = $header?->get($name)->first();
|
||||||
|
if ($value !== null && $value !== '') {
|
||||||
|
$headers[$name] = (string) $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InboundEmail(
|
||||||
|
fromEmail: $fromAddress?->mail ?? '',
|
||||||
|
fromName: $this->decodeHeaderText(trim((string) ($fromAddress?->personal ?? ''), '"')),
|
||||||
|
subject: $this->decodeHeaderText((string) $message->getSubject()),
|
||||||
|
textBody: (string) $message->getTextBody(),
|
||||||
|
htmlBody: (string) $message->getHTMLBody(),
|
||||||
|
headers: $headers,
|
||||||
|
attachments: $this->extractAttachments($message),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some senders' mail clients leave the Subject/From display-name as raw
|
||||||
|
* RFC 2047 encoded-words (e.g. "=?utf-8?Q?...?=") instead of the
|
||||||
|
* decoded UTF-8 webklex's own config claims to produce — decode
|
||||||
|
* defensively rather than showing garbled text on the ticket.
|
||||||
|
*/
|
||||||
|
private function decodeHeaderText(string $value): string
|
||||||
|
{
|
||||||
|
return $value !== '' ? mb_decode_mimeheader($value) : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{filename: string, mime: string, content: string}>
|
||||||
|
*/
|
||||||
|
private function extractAttachments(Message $message): array
|
||||||
|
{
|
||||||
|
$attachments = [];
|
||||||
|
|
||||||
|
foreach ($message->getAttachments() as $attachment) {
|
||||||
|
$attachments[] = [
|
||||||
|
'filename' => $attachment->getName() ?: 'attachment',
|
||||||
|
'mime' => $attachment->getMimeType() ?: 'application/octet-stream',
|
||||||
|
'content' => $attachment->getContent(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attachments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts raw attachment bytes into UploadedFile instances (via a temp
|
||||||
|
* file + the $test=true flag, which lets Symfony's UploadedFile skip the
|
||||||
|
* is_uploaded_file() check outside of a real HTTP request) so they flow
|
||||||
|
* through TicketService::attachFiles() unchanged. Validated the same way
|
||||||
|
* every other caller validates before calling attachFiles() — a mail
|
||||||
|
* carrying an oversized/disallowed attachment still creates the
|
||||||
|
* ticket/reply, just without that attachment, rather than being dropped
|
||||||
|
* entirely or silently bypassing the admin's attachment policy.
|
||||||
|
*
|
||||||
|
* @return UploadedFile[]
|
||||||
|
*/
|
||||||
|
private function buildAttachments(InboundEmail $email, ImapMailbox $mailbox, LoggerInterface $log): array
|
||||||
|
{
|
||||||
|
$files = [];
|
||||||
|
|
||||||
|
foreach ($email->attachments as $attachment) {
|
||||||
|
$path = tempnam(sys_get_temp_dir(), 'imap_');
|
||||||
|
file_put_contents($path, $attachment['content']);
|
||||||
|
$files[] = new UploadedFile($path, $attachment['filename'], $attachment['mime'], null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($files && ($error = Settings::validateAttachments($files))) {
|
||||||
|
$log->warning("[{$mailbox->name}] pominięto załączniki wiadomości od {$email->fromEmail} — {$error}");
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/app/Services/ImapMessageClassifier.php
Normal file
137
src/app/Services/ImapMessageClassifier.php
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Imap\InboundEmail;
|
||||||
|
use App\Support\Settings;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure decision logic for the IMAP fetcher — no IMAP connection, no
|
||||||
|
* side effects, so it's fully Pest-testable against hand-built
|
||||||
|
* InboundEmail instances. ImapMailboxFetcher does all the I/O and calls
|
||||||
|
* into this for every decision.
|
||||||
|
*/
|
||||||
|
class ImapMessageClassifier
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* RFC 3834 (Auto-Submitted) + common vendor headers, plus EN/PL subject
|
||||||
|
* phrasing for autoresponders/bounces that don't set those headers at
|
||||||
|
* all — the two layers catch most real-world autoresponders/mailer-daemons.
|
||||||
|
*/
|
||||||
|
private const AUTO_REPLY_SUBJECT_PATTERNS = [
|
||||||
|
'/\bout of office\b/i',
|
||||||
|
'/\bautomatic reply\b/i',
|
||||||
|
'/\bautomatyczna odpowiedz\b/iu',
|
||||||
|
'/\bautoresponder\b/i',
|
||||||
|
'/\bundeliverable\b/i',
|
||||||
|
'/\bundelivered\b/i',
|
||||||
|
'/\bmail delivery failed\b/i',
|
||||||
|
'/\bdelivery status notification\b/i',
|
||||||
|
'/\bnieobecnosc\b.*\bbiurze\b/iu',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a human-readable rejection reason, or null if the message
|
||||||
|
* should be processed as a genuine ticket/reply.
|
||||||
|
*
|
||||||
|
* @param string[] $extraBlocklist additional blocked sender local-parts/addresses (per-mailbox)
|
||||||
|
*/
|
||||||
|
public function rejectionReason(InboundEmail $email, array $extraBlocklist = []): ?string
|
||||||
|
{
|
||||||
|
$autoSubmitted = strtolower((string) $email->header('auto-submitted'));
|
||||||
|
if ($autoSubmitted !== '' && $autoSubmitted !== 'no') {
|
||||||
|
return "Auto-Submitted: {$autoSubmitted}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($email->header('x-autoreply') !== null || $email->header('x-autorespond') !== null) {
|
||||||
|
return 'X-Autoreply/X-Autorespond header present';
|
||||||
|
}
|
||||||
|
|
||||||
|
$precedence = strtolower((string) $email->header('precedence'));
|
||||||
|
if (in_array($precedence, ['bulk', 'junk', 'list'], true)) {
|
||||||
|
return "Precedence: {$precedence}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$senderLocalPart = strtolower(explode('@', $email->fromEmail)[0] ?? '');
|
||||||
|
$blocked = array_map('strtolower', $extraBlocklist);
|
||||||
|
if ($senderLocalPart !== '' && in_array($senderLocalPart, $blocked, true)) {
|
||||||
|
return "Blocked sender: {$email->fromEmail}";
|
||||||
|
}
|
||||||
|
if (in_array(strtolower($email->fromEmail), $blocked, true)) {
|
||||||
|
return "Blocked sender: {$email->fromEmail}";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::AUTO_REPLY_SUBJECT_PATTERNS as $pattern) {
|
||||||
|
if (preg_match($pattern, $email->subject) === 1) {
|
||||||
|
return "Subject matched auto-reply pattern ({$pattern})";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same gate Landing::submit() applies to web/guest ticket creation
|
||||||
|
* (Settings::bool('restrict_tickets_to_ldap')) — must apply identically
|
||||||
|
* to mail-originated tickets/replies, or the restriction has a hole.
|
||||||
|
*/
|
||||||
|
public function isSenderAllowed(string $email): bool
|
||||||
|
{
|
||||||
|
if (! Settings::bool('restrict_tickets_to_ldap')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return User::query()->where('email', $email)->exists()
|
||||||
|
|| app(LdapUserProvisioner::class)->existsInLdap($email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Existing local user, or an LDAP-provisioned one if enabled — mirrors
|
||||||
|
* TicketService::create()'s own guest-resolution branch. Returns null
|
||||||
|
* for a genuine, unprovisionable guest.
|
||||||
|
*/
|
||||||
|
public function resolveSender(string $email): ?User
|
||||||
|
{
|
||||||
|
if ($user = User::query()->where('email', $email)->first()) {
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Settings::bool('ldap_auto_provision_guests')) {
|
||||||
|
return app(LdapUserProvisioner::class)->findOrCreateByEmail($email);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips common reply/forward prefixes, then tries every digit run of
|
||||||
|
* length >= 4 (longest first) against Ticket::resolveRouteBinding() —
|
||||||
|
* covers both the plain sequential number and the obfuscated checksum,
|
||||||
|
* since both are plain digit strings and every outbound notification
|
||||||
|
* subject already carries one (see database/seeders/DatabaseSeeder.php).
|
||||||
|
* Prefix-aware matching was considered and rejected: {numer} email
|
||||||
|
* templates hardcode their own literal '#', independent of the
|
||||||
|
* admin-configurable ticket_number_prefix setting, and templates are
|
||||||
|
* themselves admin-editable.
|
||||||
|
*/
|
||||||
|
public function matchTicket(string $subject): ?Ticket
|
||||||
|
{
|
||||||
|
$cleaned = preg_replace('/^\s*(re|odp|fwd|fw|aw)\s*:\s*/i', '', $subject) ?? $subject;
|
||||||
|
$cleaned = preg_replace('/^\s*(re|odp|fwd|fw|aw)\s*:\s*/i', '', $cleaned) ?? $cleaned;
|
||||||
|
|
||||||
|
preg_match_all('/\d{4,}/', $cleaned, $matches);
|
||||||
|
$tokens = $matches[0] ?? [];
|
||||||
|
usort($tokens, fn ($a, $b) => strlen($b) <=> strlen($a));
|
||||||
|
|
||||||
|
foreach ($tokens as $token) {
|
||||||
|
$ticket = (new Ticket)->resolveRouteBinding($token);
|
||||||
|
if ($ticket) {
|
||||||
|
return $ticket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Ldap\LldapUser;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserField;
|
use App\Models\UserField;
|
||||||
|
use App\Support\Settings;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use LdapRecord\Models\Model as LdapModel;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,11 +81,26 @@ class LdapUserProvisioner
|
|||||||
return $matched;
|
return $matched;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function findLdapEntryForUser(User $user): ?LldapUser
|
/**
|
||||||
|
* The LdapRecord model class for whichever directory is currently
|
||||||
|
* configured (LLDAP vs Active Directory — see
|
||||||
|
* Settings::ldapUserModelClass()) — resolved fresh on every call rather
|
||||||
|
* than cached, since an admin can flip the directory type mid-session.
|
||||||
|
*/
|
||||||
|
protected function ldapUserModel(): string
|
||||||
|
{
|
||||||
|
return Settings::ldapUserModelClass();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function findLdapEntryForUser(User $user): ?LdapModel
|
||||||
{
|
{
|
||||||
if ($user->guid) {
|
if ($user->guid) {
|
||||||
try {
|
try {
|
||||||
$byGuid = LldapUser::query()->where('entryuuid', '=', $user->guid)->first();
|
// findByGuid() builds the right raw filter for either a
|
||||||
|
// binary objectGUID (AD) or a plain entryUUID string
|
||||||
|
// (LLDAP/OpenLDAP) — unlike a plain ->where(), it doesn't
|
||||||
|
// need to know which attribute that is.
|
||||||
|
$byGuid = $this->ldapUserModel()::query()->findByGuid($user->guid);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Log::warning('LDAP lookup by guid failed: '.$e->getMessage());
|
Log::warning('LDAP lookup by guid failed: '.$e->getMessage());
|
||||||
$byGuid = null;
|
$byGuid = null;
|
||||||
@@ -98,10 +114,10 @@ class LdapUserProvisioner
|
|||||||
return $this->findLdapEntryByEmail($user->email);
|
return $this->findLdapEntryByEmail($user->email);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function findLdapEntryByEmail(string $email): ?LldapUser
|
protected function findLdapEntryByEmail(string $email): ?LdapModel
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
return LldapUser::query()->where('mail', '=', $email)->first();
|
return $this->ldapUserModel()::query()->where('mail', '=', $email)->first();
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Log::warning('LDAP lookup by email failed: '.$e->getMessage());
|
Log::warning('LDAP lookup by email failed: '.$e->getMessage());
|
||||||
|
|
||||||
@@ -109,7 +125,7 @@ class LdapUserProvisioner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function applyFieldsFromLdap(User $user, LldapUser $ldapEntry): void
|
public function applyFieldsFromLdap(User $user, LdapModel $ldapEntry): void
|
||||||
{
|
{
|
||||||
$values = $user->custom_field_values ?? [];
|
$values = $user->custom_field_values ?? [];
|
||||||
|
|
||||||
|
|||||||
212
src/app/Services/SnipeItClient.php
Normal file
212
src/app/Services/SnipeItClient.php
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Support\Settings;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
class SnipeItClient
|
||||||
|
{
|
||||||
|
public function enabled(): bool
|
||||||
|
{
|
||||||
|
return Settings::bool('snipeit_enabled')
|
||||||
|
&& Settings::get('snipeit_base_url')
|
||||||
|
&& Settings::get('snipeit_api_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assets Snipe-IT has checked out to $email — feeds the "Sprzęt
|
||||||
|
* zgłaszającego" sidebar shown to a client creating a ticket and to an
|
||||||
|
* operator viewing one. Snipe-IT has no "assets by e-mail" endpoint, so
|
||||||
|
* this looks the requester up as a Snipe-IT user first, then lists what's
|
||||||
|
* assigned to them. Cached briefly per e-mail since the ticket-creation
|
||||||
|
* form and ticket-view page both re-render this on every interaction.
|
||||||
|
*
|
||||||
|
* @return array<int, array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, url: string}>
|
||||||
|
*/
|
||||||
|
public function assetsForEmail(string $email): array
|
||||||
|
{
|
||||||
|
$email = trim($email);
|
||||||
|
|
||||||
|
if (! $this->enabled() || $email === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Cache::remember('snipeit:user-assets:'.md5(strtolower($email)), now()->addMinutes(5), function () use ($email) {
|
||||||
|
try {
|
||||||
|
$user = $this->findUserByEmail($email);
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->client()->get("/users/{$user['id']}/assets");
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($response->json('rows', []))
|
||||||
|
->map(fn (array $a) => $this->normalizeAsset($a))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-inventory search behind the operator's "przeszukaj cały
|
||||||
|
* inwentarz" picker — unlike assetsForEmail() this isn't scoped to any
|
||||||
|
* one requester. Uncached: it's a live, as-you-type lookup.
|
||||||
|
*
|
||||||
|
* @return array<int, array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, url: string}>
|
||||||
|
*/
|
||||||
|
public function searchAssets(string $query, int $limit = 10): array
|
||||||
|
{
|
||||||
|
$query = trim($query);
|
||||||
|
|
||||||
|
if (! $this->enabled() || $query === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->client()->get('/hardware', ['search' => $query, 'limit' => $limit]);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($response->json('rows', []))
|
||||||
|
->map(fn (array $a) => $this->normalizeAsset($a))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live detail for a ticket's linked asset — fetched fresh rather than
|
||||||
|
* trusting the ticket's cached snipeit_asset_name, so a status/
|
||||||
|
* reassignment change in Snipe-IT is reflected immediately. Null if
|
||||||
|
* unreachable or the asset was deleted there; callers fall back to the
|
||||||
|
* cached label in that case.
|
||||||
|
*
|
||||||
|
* @return array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, assignedTo: ?string, url: string}|null
|
||||||
|
*/
|
||||||
|
public function asset(int $id): ?array
|
||||||
|
{
|
||||||
|
if (! $this->enabled()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->client()->get("/hardware/{$id}");
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
return [
|
||||||
|
...$this->normalizeAsset($data),
|
||||||
|
'assignedTo' => $data['assigned_to']['name'] ?? null,
|
||||||
|
];
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests unsaved admin-form values directly, rather than whatever's
|
||||||
|
* currently stored — mirrors BookStackClient::testConnection(). Hits a
|
||||||
|
* plain list endpoint (rather than e.g. /users/me, which isn't present
|
||||||
|
* on every Snipe-IT version) so this works as a version-agnostic
|
||||||
|
* auth+reachability check.
|
||||||
|
*
|
||||||
|
* @return array{ok: bool, message: ?string}
|
||||||
|
*/
|
||||||
|
public function testConnection(string $baseUrl, string $token, bool $verifySsl = true): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$response = Http::withToken($token)
|
||||||
|
->acceptJson()
|
||||||
|
->withOptions(['verify' => $verifySsl])
|
||||||
|
->timeout(6)
|
||||||
|
->get(rtrim($baseUrl, '/').'/api/v1/hardware', ['limit' => 1]);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return ['ok' => true, 'message' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = $response->json('messages') ?? $response->json('message');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'message' => is_string($message) ? $message : ($message ? json_encode($message) : ('HTTP '.$response->status())),
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['ok' => false, 'message' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function findUserByEmail(string $email): ?array
|
||||||
|
{
|
||||||
|
$response = $this->client()->get('/users', ['search' => $email, 'limit' => 5]);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($response->json('rows', []))
|
||||||
|
->first(fn (array $u) => isset($u['email']) && strcasecmp($u['email'], $email) === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* label is "numer środka - numer seryjny - producent model" — joining
|
||||||
|
* whichever of those three pieces is actually present (Snipe-IT doesn't
|
||||||
|
* guarantee any of them), falling back to the bare asset id if all three
|
||||||
|
* are blank. The display format requested for the "Sprzęt zgłaszającego"
|
||||||
|
* picker and sidebar, everywhere an asset is listed.
|
||||||
|
*
|
||||||
|
* @return array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, url: string}
|
||||||
|
*/
|
||||||
|
protected function normalizeAsset(array $a): array
|
||||||
|
{
|
||||||
|
$assetTag = $a['asset_tag'] ?? null;
|
||||||
|
$serial = $a['serial'] ?? null;
|
||||||
|
$manufacturer = $a['manufacturer']['name'] ?? null;
|
||||||
|
$model = $a['model']['name'] ?? null;
|
||||||
|
$modelDisplay = trim(($manufacturer ? "{$manufacturer} " : '').($model ?? ''));
|
||||||
|
|
||||||
|
$labelParts = collect([$assetTag, $serial, $modelDisplay])
|
||||||
|
->map(fn ($v) => trim((string) $v))
|
||||||
|
->filter(fn ($v) => $v !== '');
|
||||||
|
|
||||||
|
$label = $labelParts->isNotEmpty() ? $labelParts->implode(' - ') : 'Zasób #'.$a['id'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $a['id'],
|
||||||
|
'label' => $label,
|
||||||
|
'serial' => $serial,
|
||||||
|
'manufacturer' => $manufacturer,
|
||||||
|
'model' => $model,
|
||||||
|
'category' => $a['category']['name'] ?? null,
|
||||||
|
'status' => $a['status_label']['name'] ?? null,
|
||||||
|
'url' => rtrim(Settings::get('snipeit_base_url'), '/').'/hardware/'.$a['id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function client()
|
||||||
|
{
|
||||||
|
return Http::withToken(Settings::get('snipeit_api_token'))
|
||||||
|
->acceptJson()
|
||||||
|
->withOptions(['verify' => Settings::bool('snipeit_verify_ssl')])
|
||||||
|
->timeout(6)
|
||||||
|
->baseUrl(rtrim(Settings::get('snipeit_base_url'), '/').'/api/v1');
|
||||||
|
}
|
||||||
|
}
|
||||||
188
src/app/Services/TicketAiSummaryService.php
Normal file
188
src/app/Services/TicketAiSummaryService.php
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an AI summary + suggested next action for every ticket, cached
|
||||||
|
* on the ticket row and shown only in the operator view (see
|
||||||
|
* TicketAiTriageService's docblock for why this runs from the scheduled
|
||||||
|
* ai:run-ticket-automation command rather than live on page load). Stays
|
||||||
|
* reasonably fresh by regenerating whenever a ticket's latest message
|
||||||
|
* postdates its last summary, not on every scheduler tick for every ticket.
|
||||||
|
*/
|
||||||
|
class TicketAiSummaryService
|
||||||
|
{
|
||||||
|
protected const BATCH_LIMIT = 25;
|
||||||
|
|
||||||
|
protected const MESSAGE_EXCERPT_CHARS = 1500;
|
||||||
|
|
||||||
|
protected const BODY_EXCERPT_CHARS = 4000;
|
||||||
|
|
||||||
|
protected const TRANSCRIPT_MESSAGE_LIMIT = 30;
|
||||||
|
|
||||||
|
public function __construct(protected AiClient $ai) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{scanned: int, updated: int, failed: int}
|
||||||
|
*/
|
||||||
|
public function run(?int $limit = null): array
|
||||||
|
{
|
||||||
|
$totals = ['scanned' => 0, 'updated' => 0, 'failed' => 0];
|
||||||
|
|
||||||
|
if (! $this->ai->enabled() || ! Settings::bool('ai_summary_enabled')) {
|
||||||
|
return $totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->staleQuery()
|
||||||
|
->limit($limit ?? self::BATCH_LIMIT)
|
||||||
|
->get()
|
||||||
|
->each(function (Ticket $ticket) use (&$totals) {
|
||||||
|
$totals['scanned']++;
|
||||||
|
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tickets with no summary yet, or whose latest message postdates the
|
||||||
|
* last summary generation. Deliberately compares against
|
||||||
|
* ticket_messages.created_at rather than tickets.updated_at — the
|
||||||
|
* 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()
|
||||||
|
->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');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerates the summary for a single ticket right now, bypassing the
|
||||||
|
* staleness check — used by the manual "regenerate" button and the
|
||||||
|
* on-new-message hook, as opposed to run()'s scheduled batch sweep.
|
||||||
|
*/
|
||||||
|
public function generateFor(Ticket $ticket): bool
|
||||||
|
{
|
||||||
|
if (! $this->ai->enabled() || ! Settings::bool('ai_summary_enabled')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->summarizeOne($ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function summarizeOne(Ticket $ticket): bool
|
||||||
|
{
|
||||||
|
$raw = $this->ai->chat([
|
||||||
|
['role' => 'system', 'content' => Settings::get('ai_summary_prompt')],
|
||||||
|
['role' => 'user', 'content' => $this->buildTranscript($ticket)],
|
||||||
|
], ['temperature' => 0.2]);
|
||||||
|
|
||||||
|
$parsed = $this->parseResponse($raw);
|
||||||
|
|
||||||
|
if ($parsed === null) {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticket->update([
|
||||||
|
'ai_summary' => $parsed['summary'],
|
||||||
|
'ai_suggested_action' => $parsed['suggested_action'],
|
||||||
|
'ai_summary_generated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::channel('ai')->debug("Podsumowanie zgłoszenia #{$ticket->number}: zaktualizowane.");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Includes tickets.body explicitly (the opening description, separate
|
||||||
|
* from ticket_messages) rather than relying on it showing up as the
|
||||||
|
* thread's first message — that row falls outside the last-N transcript
|
||||||
|
* window on any ticket with more than TRANSCRIPT_MESSAGE_LIMIT messages,
|
||||||
|
* which would otherwise silently drop the original request from long
|
||||||
|
* threads. Mirrors TicketAiTriageService's own subject+body framing.
|
||||||
|
*/
|
||||||
|
protected function buildTranscript(Ticket $ticket): string
|
||||||
|
{
|
||||||
|
$lines = [
|
||||||
|
"Temat: {$ticket->subject}",
|
||||||
|
"Treść:\n".Str::limit(strip_tags($ticket->body), self::BODY_EXCERPT_CHARS),
|
||||||
|
];
|
||||||
|
|
||||||
|
$ticket->messages()->latest('created_at')->limit(self::TRANSCRIPT_MESSAGE_LIMIT)->get()
|
||||||
|
->sortBy('created_at')
|
||||||
|
->each(function ($message) use (&$lines) {
|
||||||
|
$role = $message->internal ? 'notatka wewnętrzna' : ($message->role === 'client' ? 'klient' : 'operator');
|
||||||
|
$body = Str::limit(strip_tags($message->body), self::MESSAGE_EXCERPT_CHARS, '');
|
||||||
|
$lines[] = "[{$role}] {$message->author_name}: {$body}";
|
||||||
|
});
|
||||||
|
|
||||||
|
return implode("\n\n", $lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same defensive-parsing shape used elsewhere in this app's AI services
|
||||||
|
* (BookStackContentTagger, TicketAiTriageService) — extracts the first
|
||||||
|
* {...} block before decoding.
|
||||||
|
*
|
||||||
|
* @return array{summary: string, suggested_action: ?string}|null
|
||||||
|
*/
|
||||||
|
protected function parseResponse(?string $raw): ?array
|
||||||
|
{
|
||||||
|
if (! $raw || ! preg_match('/\{.*\}/s', $raw, $matches)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($matches[0], true);
|
||||||
|
|
||||||
|
if (! is_array($decoded) || empty($decoded['summary']) || ! is_string($decoded['summary'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary' => trim($decoded['summary']),
|
||||||
|
'suggested_action' => ! empty($decoded['suggested_action']) && is_string($decoded['suggested_action'])
|
||||||
|
? trim($decoded['suggested_action'])
|
||||||
|
: null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
328
src/app/Services/TicketAiTriageService.php
Normal file
328
src/app/Services/TicketAiTriageService.php
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot AI classification of new tickets, gated by 5 independent
|
||||||
|
* settings toggles (ai_triage_category_when_missing/
|
||||||
|
* subcategory_when_category_only/recheck_categorized/fix_subject/
|
||||||
|
* set_priority). Runs from the scheduled ai:run-ticket-automation command,
|
||||||
|
* never synchronously at ticket creation, so it never adds LLM latency to a
|
||||||
|
* live customer submitting a ticket. Every scanned ticket gets
|
||||||
|
* ai_triaged_at stamped exactly once — this is a one-shot pass per ticket,
|
||||||
|
* not a continuous recheck, and there's no manual re-trigger by design.
|
||||||
|
*/
|
||||||
|
class TicketAiTriageService
|
||||||
|
{
|
||||||
|
protected const BATCH_LIMIT = 25;
|
||||||
|
|
||||||
|
protected const BODY_EXCERPT_CHARS = 4000;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected AiClient $ai,
|
||||||
|
protected TicketService $tickets,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{scanned: int, changed: int, failed: int}
|
||||||
|
*/
|
||||||
|
public function run(?int $limit = null): array
|
||||||
|
{
|
||||||
|
$totals = ['scanned' => 0, 'changed' => 0, 'failed' => 0];
|
||||||
|
|
||||||
|
if (! $this->ai->enabled() || ! $this->anyToggleEnabled()) {
|
||||||
|
return $totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
$vocabulary = $this->buildVocabulary();
|
||||||
|
$priorities = Priority::query()->orderBy('sort_order')->pluck('label', 'key')->all();
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
->each(function (Ticket $ticket) use ($vocabulary, $priorities, &$totals) {
|
||||||
|
$totals['scanned']++;
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function anyToggleEnabled(): bool
|
||||||
|
{
|
||||||
|
return Settings::bool('ai_triage_category_when_missing')
|
||||||
|
|| Settings::bool('ai_triage_subcategory_when_category_only')
|
||||||
|
|| Settings::bool('ai_triage_recheck_categorized')
|
||||||
|
|| Settings::bool('ai_triage_fix_subject')
|
||||||
|
|| Settings::bool('ai_triage_set_priority');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}> $vocabulary
|
||||||
|
* @param array<string, string> $priorities
|
||||||
|
* @param array{scanned: int, changed: int, failed: int} $totals
|
||||||
|
*/
|
||||||
|
protected function triageOne(Ticket $ticket, array $vocabulary, array $priorities, array &$totals): void
|
||||||
|
{
|
||||||
|
$prompt = $this->buildPrompt($ticket, $vocabulary, $priorities);
|
||||||
|
|
||||||
|
if ($prompt === null) {
|
||||||
|
$ticket->update(['ai_triaged_at' => now()]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = $this->ai->chat([
|
||||||
|
['role' => 'system', 'content' => $prompt['system']],
|
||||||
|
['role' => 'user', 'content' => $prompt['user']],
|
||||||
|
], ['temperature' => 0]);
|
||||||
|
|
||||||
|
$parsed = $this->parseResponse($raw);
|
||||||
|
|
||||||
|
// A response came back but couldn't be parsed — not fatal to the
|
||||||
|
// run, just means this ticket wasn't classified this time.
|
||||||
|
// ai_triaged_at is still stamped below so a persistently-bad
|
||||||
|
// 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
|
||||||
|
? $this->resolveChanges($ticket, $parsed, $vocabulary, $priorities, $prompt['scope'])
|
||||||
|
: [[], []];
|
||||||
|
|
||||||
|
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()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}>
|
||||||
|
*/
|
||||||
|
protected function buildVocabulary(): array
|
||||||
|
{
|
||||||
|
return Category::query()->with('subcategories')->get()
|
||||||
|
->map(fn (Category $c) => [
|
||||||
|
'id' => $c->id,
|
||||||
|
'name' => $c->name,
|
||||||
|
'subcategories' => $c->subcategories->map(fn ($s) => ['id' => $s->id, 'name' => $s->name])->all(),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines which of the 3 mutually-exclusive category scenarios (if
|
||||||
|
* any) applies to $ticket's current state, and whether subject/priority
|
||||||
|
* are also in scope, then builds the prompt around exactly that. Returns
|
||||||
|
* null when nothing is applicable/enabled for this ticket, so the caller
|
||||||
|
* can skip straight to stamping ai_triaged_at without an AI call.
|
||||||
|
*
|
||||||
|
* @param array<int, array{id: int, name: string, subcategories: array}> $vocabulary
|
||||||
|
* @param array<string, string> $priorities
|
||||||
|
* @return array{system: string, user: string, scope: ?string}|null
|
||||||
|
*/
|
||||||
|
protected function buildPrompt(Ticket $ticket, array $vocabulary, array $priorities): ?array
|
||||||
|
{
|
||||||
|
$scope = null;
|
||||||
|
|
||||||
|
if (! $ticket->category_id && ! $ticket->subcategory_id && Settings::bool('ai_triage_category_when_missing')) {
|
||||||
|
$scope = 'missing';
|
||||||
|
} elseif ($ticket->category_id && ! $ticket->subcategory_id && Settings::bool('ai_triage_subcategory_when_category_only')) {
|
||||||
|
$scope = 'category_only';
|
||||||
|
} elseif ($ticket->subcategory_id && Settings::bool('ai_triage_recheck_categorized')) {
|
||||||
|
$scope = 'recheck';
|
||||||
|
}
|
||||||
|
|
||||||
|
$wantSubject = Settings::bool('ai_triage_fix_subject');
|
||||||
|
$wantPriority = Settings::bool('ai_triage_set_priority');
|
||||||
|
|
||||||
|
if ($scope === null && ! $wantSubject && ! $wantPriority) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = ['Klasyfikujesz zgłoszenia helpdesku na podstawie tematu i treści.'];
|
||||||
|
|
||||||
|
if ($scope === 'missing') {
|
||||||
|
$parts[] = 'To zgłoszenie nie ma jeszcze przypisanej kategorii ani podkategorii. Wybierz najlepiej '
|
||||||
|
.'pasującą kategorię z listy poniżej (użyj DOKŁADNIE tej pisowni) i, jeśli to możliwe, także '
|
||||||
|
.'konkretną podkategorię w jej ramach. Jeśli żadna kategoria sensownie nie pasuje, zwróć null dla obu pól.';
|
||||||
|
} elseif ($scope === 'category_only') {
|
||||||
|
$parts[] = "To zgłoszenie ma już przypisaną kategorię \"{$ticket->category->name}\", ale brak konkretnej "
|
||||||
|
.'podkategorii. Wybierz najlepiej pasującą podkategorię z listy poniżej (należącą do tej kategorii, '
|
||||||
|
.'użyj DOKŁADNIE tej pisowni). Jeśli żadna nie pasuje dobrze, zwróć null.';
|
||||||
|
} elseif ($scope === 'recheck') {
|
||||||
|
$parts[] = "To zgłoszenie ma już przypisaną podkategorię \"{$ticket->subcategory->label()}\". Sprawdź, "
|
||||||
|
.'czy to nadal najlepsze dopasowanie na podstawie treści. Jeśli tak — zwróć null (nic nie zmieniaj). '
|
||||||
|
.'Jeśli lepiej pasuje inna kategoria/podkategoria z listy poniżej, zwróć ją.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scope !== null) {
|
||||||
|
$parts[] = "Dostępne kategorie i podkategorie:\n".$this->vocabularyText($vocabulary, $scope, $ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($wantSubject) {
|
||||||
|
$parts[] = 'Jeśli obecny temat zgłoszenia jest niejasny lub mylący, zaproponuj lepszy, zwięzły temat po '
|
||||||
|
.'polsku w polu "subject" (w przeciwnym razie null).';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($wantPriority) {
|
||||||
|
$priorityList = collect($priorities)->map(fn ($label, $key) => "{$key} ({$label})")->implode(', ');
|
||||||
|
$parts[] = 'Na podstawie treści oceń priorytet zgłoszenia i zwróć jego klucz w polu "priority" — '
|
||||||
|
."dostępne klucze: {$priorityList}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts[] = 'Odpowiedz WYŁĄCZNIE obiektem JSON, bez żadnego innego tekstu ani formatowania: '
|
||||||
|
.'{"category": "...", "subcategory": "...", "subject": "...", "priority": "..."} — pola, o które nie '
|
||||||
|
.'proszono powyżej, ustaw na null.';
|
||||||
|
|
||||||
|
$user = "Temat: {$ticket->subject}\n\nTreść:\n".Str::limit(strip_tags($ticket->body), self::BODY_EXCERPT_CHARS);
|
||||||
|
|
||||||
|
return ['system' => implode("\n\n", $parts), 'user' => $user, 'scope' => $scope];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}> $vocabulary
|
||||||
|
*/
|
||||||
|
protected function vocabularyText(array $vocabulary, string $scope, Ticket $ticket): string
|
||||||
|
{
|
||||||
|
$categories = $scope === 'category_only'
|
||||||
|
? collect($vocabulary)->filter(fn (array $c) => $c['id'] === $ticket->category_id)
|
||||||
|
: collect($vocabulary);
|
||||||
|
|
||||||
|
return $categories
|
||||||
|
->map(fn (array $c) => "- {$c['name']}: ".collect($c['subcategories'])->pluck('name')->implode(', '))
|
||||||
|
->implode("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same defensive-parsing shape as BookStackContentTagger::parseAssignments()
|
||||||
|
* — extracts the first {...} block before decoding, so prose-wrapped or
|
||||||
|
* malformed responses fail gracefully instead of crashing the run.
|
||||||
|
*/
|
||||||
|
protected function parseResponse(?string $raw): ?array
|
||||||
|
{
|
||||||
|
if (! $raw || ! preg_match('/\{.*\}/s', $raw, $matches)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($matches[0], true);
|
||||||
|
|
||||||
|
return is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fail-closed resolution: every value from the model is matched against
|
||||||
|
* the real vocabulary/priority list before being trusted — an
|
||||||
|
* unmatched/hallucinated category, a subcategory outside its claimed
|
||||||
|
* category, or an unknown priority key is silently dropped rather than
|
||||||
|
* written to the ticket. Only fields whose resolved value actually
|
||||||
|
* differs from the ticket's current value produce a change + history
|
||||||
|
* line, so e.g. a "recheck" that confirms the existing subcategory
|
||||||
|
* leaves no trace.
|
||||||
|
*
|
||||||
|
* @param array<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}> $vocabulary
|
||||||
|
* @param array<string, string> $priorities
|
||||||
|
* @return array{0: array<string, mixed>, 1: string[]}
|
||||||
|
*/
|
||||||
|
protected function resolveChanges(Ticket $ticket, array $parsed, array $vocabulary, array $priorities, ?string $scope): array
|
||||||
|
{
|
||||||
|
$changes = [];
|
||||||
|
$historyLines = [];
|
||||||
|
|
||||||
|
if ($scope !== null) {
|
||||||
|
$resolved = $this->resolveCategory($parsed, $vocabulary, $scope, $ticket);
|
||||||
|
|
||||||
|
if ($resolved) {
|
||||||
|
[$categoryId, $subcategoryId, $label] = $resolved;
|
||||||
|
|
||||||
|
if ($categoryId !== $ticket->category_id || $subcategoryId !== $ticket->subcategory_id) {
|
||||||
|
$changes['category_id'] = $categoryId;
|
||||||
|
$changes['subcategory_id'] = $subcategoryId;
|
||||||
|
$historyLines[] = "Kategoria zmieniona na: {$label}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Settings::bool('ai_triage_fix_subject') && ! empty($parsed['subject']) && is_string($parsed['subject'])) {
|
||||||
|
$newSubject = Str::limit(trim($parsed['subject']), 255, '');
|
||||||
|
|
||||||
|
if ($newSubject !== '' && $newSubject !== $ticket->subject) {
|
||||||
|
$changes['subject'] = $newSubject;
|
||||||
|
$historyLines[] = "Temat zmieniony na: „{$newSubject}”";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Settings::bool('ai_triage_set_priority') && ! empty($parsed['priority']) && is_string($parsed['priority'])) {
|
||||||
|
$key = collect($priorities)->keys()->first(fn ($k) => Str::lower($k) === Str::lower($parsed['priority']));
|
||||||
|
|
||||||
|
if ($key !== null && $key !== $ticket->priority_key) {
|
||||||
|
$changes['priority_key'] = $key;
|
||||||
|
$historyLines[] = 'Priorytet zmieniony na: '.Priority::labelFor($key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$changes, $historyLines];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}> $vocabulary
|
||||||
|
* @return array{0: ?int, 1: ?int, 2: string}|null [category_id, subcategory_id, display label]
|
||||||
|
*/
|
||||||
|
protected function resolveCategory(array $parsed, array $vocabulary, string $scope, Ticket $ticket): ?array
|
||||||
|
{
|
||||||
|
$categories = $scope === 'category_only'
|
||||||
|
? collect($vocabulary)->filter(fn (array $c) => $c['id'] === $ticket->category_id)
|
||||||
|
: collect($vocabulary);
|
||||||
|
|
||||||
|
$subName = $parsed['subcategory'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($subName) && $subName !== '') {
|
||||||
|
foreach ($categories as $category) {
|
||||||
|
foreach ($category['subcategories'] as $sub) {
|
||||||
|
if (Str::lower($sub['name']) === Str::lower($subName)) {
|
||||||
|
return [null, $sub['id'], "{$category['name']} / {$sub['name']}"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$catName = $parsed['category'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($catName) && $catName !== '') {
|
||||||
|
foreach ($categories as $category) {
|
||||||
|
if (Str::lower($category['name']) === Str::lower($catName)) {
|
||||||
|
return [$category['id'], null, $category['name']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,10 @@ class TicketService
|
|||||||
'email' => $customer?->email ?? $data['email'],
|
'email' => $customer?->email ?? $data['email'],
|
||||||
'name' => $customer?->name ?? ($data['name'] ?? $data['email']),
|
'name' => $customer?->name ?? ($data['name'] ?? $data['email']),
|
||||||
'subcategory_id' => $subcategory?->id,
|
'subcategory_id' => $subcategory?->id,
|
||||||
|
// category_id only ever carries a value when there's no
|
||||||
|
// subcategory to derive one from (e.g. an IMAP mailbox routed to
|
||||||
|
// a whole category rather than a specific subcategory).
|
||||||
|
'category_id' => $subcategory ? null : ($data['category_id'] ?? null),
|
||||||
'subject' => $data['subject'],
|
'subject' => $data['subject'],
|
||||||
'body' => $data['body'],
|
'body' => $data['body'],
|
||||||
'status_key' => Settings::get('default_status', 'new'),
|
'status_key' => Settings::get('default_status', 'new'),
|
||||||
@@ -50,6 +54,9 @@ class TicketService
|
|||||||
'assignee_id' => $data['assignee_id'] ?? null,
|
'assignee_id' => $data['assignee_id'] ?? null,
|
||||||
'custom_fields' => $data['custom_values'] ?? [],
|
'custom_fields' => $data['custom_values'] ?? [],
|
||||||
'last_customer_activity_at' => now(),
|
'last_customer_activity_at' => now(),
|
||||||
|
'source' => $data['source'] ?? 'web',
|
||||||
|
'snipeit_asset_id' => $data['snipeit_asset_id'] ?? null,
|
||||||
|
'snipeit_asset_name' => $data['snipeit_asset_name'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$message = $ticket->messages()->create([
|
$message = $ticket->messages()->create([
|
||||||
@@ -156,12 +163,79 @@ class TicketService
|
|||||||
TicketQueueChanged::dispatch($ticket->id, 'team_changed', Auth::id());
|
TicketQueueChanged::dispatch($ticket->id, 'team_changed', Auth::id());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies one AI-triage pass's changes (see TicketAiTriageService) in a
|
||||||
|
* single update, rather than composing setPriority()/updateDetails() —
|
||||||
|
* a pass can touch category, subcategory, subject and priority
|
||||||
|
* together, and those would each write their own generic history line
|
||||||
|
* and fire notify()/TriggerEngine::handle() per field instead of once
|
||||||
|
* per pass, fragmenting one semantic AI decision into several
|
||||||
|
* unrelated-looking edits. $historyLines carries one mechanical
|
||||||
|
* "X changed to: Y" line per changed field (built by the caller, since
|
||||||
|
* it already knows the human-readable labels); this always appends one
|
||||||
|
* more attribution line on top, mirroring how RunAutomationRules logs
|
||||||
|
* "Automatyzacja: {label}" after its own field-change lines.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $changes column => value, only the fields that actually changed
|
||||||
|
* @param string[] $historyLines
|
||||||
|
*/
|
||||||
|
public function applyAiTriage(Ticket $ticket, array $changes, array $historyLines): void
|
||||||
|
{
|
||||||
|
if (! $changes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$categoryChanged = array_key_exists('category_id', $changes) || array_key_exists('subcategory_id', $changes);
|
||||||
|
$priorityChanged = array_key_exists('priority_key', $changes);
|
||||||
|
|
||||||
|
$ticket->update($changes);
|
||||||
|
|
||||||
|
foreach ($historyLines as $line) {
|
||||||
|
$ticket->addHistory($line);
|
||||||
|
}
|
||||||
|
$ticket->addHistory('Automatyzacja: klasyfikacja AI');
|
||||||
|
|
||||||
|
if ($categoryChanged) {
|
||||||
|
$this->notify($ticket, 'category_changed');
|
||||||
|
app(TriggerEngine::class)->handle($ticket, 'category_changed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($priorityChanged) {
|
||||||
|
$this->notify($ticket, 'priority_changed');
|
||||||
|
app(TriggerEngine::class)->handle($ticket, 'priority_changed');
|
||||||
|
}
|
||||||
|
|
||||||
|
app(TriggerEngine::class)->handle($ticket, 'ticket_updated');
|
||||||
|
TicketQueueChanged::dispatch($ticket->id, 'ai_triage', Auth::id());
|
||||||
|
}
|
||||||
|
|
||||||
public function setReporter(Ticket $ticket, User $customer): void
|
public function setReporter(Ticket $ticket, User $customer): void
|
||||||
{
|
{
|
||||||
$ticket->update(['customer_id' => $customer->id, 'email' => $customer->email, 'name' => $customer->name]);
|
$ticket->update(['customer_id' => $customer->id, 'email' => $customer->email, 'name' => $customer->name]);
|
||||||
$ticket->addHistory('Zgłaszający zmieniony na: '.$customer->name);
|
$ticket->addHistory('Zgłaszający zmieniony na: '.$customer->name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Links/unlinks the Snipe-IT asset attached to a ticket — $asset null
|
||||||
|
* unlinks. Only the label (not live status/assignment) is cached on the
|
||||||
|
* ticket row, so it still shows something if Snipe-IT later becomes
|
||||||
|
* unreachable or the asset is deleted there, without a live API call on
|
||||||
|
* every ticket list render (see SnipeItClient::asset() for the live
|
||||||
|
* fetch used on the ticket-detail page itself).
|
||||||
|
*
|
||||||
|
* @param array{id: int, label: string}|null $asset
|
||||||
|
*/
|
||||||
|
public function setSnipeitAsset(Ticket $ticket, ?array $asset): void
|
||||||
|
{
|
||||||
|
$ticket->update([
|
||||||
|
'snipeit_asset_id' => $asset['id'] ?? null,
|
||||||
|
'snipeit_asset_name' => $asset['label'] ?? null,
|
||||||
|
]);
|
||||||
|
$ticket->addHistory($asset
|
||||||
|
? 'Powiązano sprzęt (inwentarz): '.$asset['label']
|
||||||
|
: 'Odpięto powiązany sprzęt (inwentarz)');
|
||||||
|
}
|
||||||
|
|
||||||
public function updateDetails(Ticket $ticket, array $data): void
|
public function updateDetails(Ticket $ticket, array $data): void
|
||||||
{
|
{
|
||||||
$categoryChanged = ($data['subcategory_id'] ?? null) !== $ticket->subcategory_id;
|
$categoryChanged = ($data['subcategory_id'] ?? null) !== $ticket->subcategory_id;
|
||||||
@@ -213,11 +287,12 @@ class TicketService
|
|||||||
TicketMessagePosted::dispatch($ticket->id, $message->id, true, $operator->id);
|
TicketMessagePosted::dispatch($ticket->id, $message->id, true, $operator->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function clientReply(Ticket $ticket, User $client, string $body, array $attachments = []): void
|
public function clientReply(Ticket $ticket, User $client, string $body, array $attachments = [], string $source = 'web'): void
|
||||||
{
|
{
|
||||||
$message = $ticket->messages()->create([
|
$message = $ticket->messages()->create([
|
||||||
'author_name' => $client->name,
|
'author_name' => $client->name,
|
||||||
'body' => $body,
|
'body' => $body,
|
||||||
|
'source' => $source === 'web' ? null : $source,
|
||||||
]);
|
]);
|
||||||
$message->attachAuthor($client->id, 'client');
|
$message->attachAuthor($client->id, 'client');
|
||||||
$ticket->touch();
|
$ticket->touch();
|
||||||
@@ -238,6 +313,36 @@ class TicketService
|
|||||||
TicketQueueChanged::dispatch($ticket->id, 'message_posted', $client->id);
|
TicketQueueChanged::dispatch($ticket->id, 'message_posted', $client->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reply from a customer with no User account — e.g. an e-mail reply
|
||||||
|
* from an address the IMAP fetcher couldn't resolve to a local/LDAP
|
||||||
|
* user. Mirrors clientReply() (real customer activity: resets SLA
|
||||||
|
* silence, fires comment_added so an admin-configured Trigger can reopen
|
||||||
|
* a closed ticket) rather than apiMessage() (attachAuthor(null, null) —
|
||||||
|
* a system/integration note, not client content). attachAuthor(null,
|
||||||
|
* 'client') matches how create() already tags a guest's opening message.
|
||||||
|
*/
|
||||||
|
public function guestReply(Ticket $ticket, string $authorName, string $body, array $attachments = [], string $source = 'web'): TicketMessage
|
||||||
|
{
|
||||||
|
$message = $ticket->messages()->create([
|
||||||
|
'author_name' => $authorName,
|
||||||
|
'body' => $body,
|
||||||
|
'source' => $source === 'web' ? null : $source,
|
||||||
|
]);
|
||||||
|
$message->attachAuthor(null, 'client');
|
||||||
|
$ticket->touch();
|
||||||
|
$this->attachFiles($ticket, $message, $attachments);
|
||||||
|
|
||||||
|
$ticket->update(['last_customer_activity_at' => now()]);
|
||||||
|
$ticket->automationRuleLogs()->delete();
|
||||||
|
|
||||||
|
app(TriggerEngine::class)->handle($ticket, 'comment_added');
|
||||||
|
TicketMessagePosted::dispatch($ticket->id, $message->id, false, null);
|
||||||
|
TicketQueueChanged::dispatch($ticket->id, 'message_posted', null);
|
||||||
|
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
|
||||||
public function toggleWatch(Ticket $ticket, User $user): bool
|
public function toggleWatch(Ticket $ticket, User $user): bool
|
||||||
{
|
{
|
||||||
if ($ticket->isWatchedBy($user)) {
|
if ($ticket->isWatchedBy($user)) {
|
||||||
@@ -328,7 +433,7 @@ class TicketService
|
|||||||
|
|
||||||
$primary->messages()->create([
|
$primary->messages()->create([
|
||||||
'author_name' => 'System',
|
'author_name' => 'System',
|
||||||
'body' => 'Scalono zgłoszenia: '.$others->map(fn (Ticket $o) => '#'.$o->number)->implode(', '),
|
'body' => 'Scalono zgłoszenia: '.$others->map(fn (Ticket $o) => $o->displayNumber())->implode(', '),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
foreach ($others as $other) {
|
foreach ($others as $other) {
|
||||||
@@ -346,7 +451,7 @@ class TicketService
|
|||||||
$note = $other->messages()->create([
|
$note = $other->messages()->create([
|
||||||
'author_name' => 'System',
|
'author_name' => 'System',
|
||||||
'internal' => true,
|
'internal' => true,
|
||||||
'body' => 'Scalone ze zgłoszeniem #'.$primary->number,
|
'body' => 'Scalone ze zgłoszeniem '.$primary->displayNumber(),
|
||||||
]);
|
]);
|
||||||
$note->attachAuthor(null, 'operator');
|
$note->attachAuthor(null, 'operator');
|
||||||
TicketQueueChanged::dispatch($other->id, 'merged', Auth::id());
|
TicketQueueChanged::dispatch($other->id, 'merged', Auth::id());
|
||||||
|
|||||||
49
src/app/Support/Imap/InboundEmail.php
Normal file
49
src/app/Support/Imap/InboundEmail.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Imap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized view of one inbound message, independent of the IMAP client
|
||||||
|
* library — the seam between ImapMailboxFetcher (I/O, effectively
|
||||||
|
* untestable without a real mailbox) and ImapMessageClassifier (pure
|
||||||
|
* decision logic, fully Pest-testable against hand-built instances).
|
||||||
|
*/
|
||||||
|
class InboundEmail
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $headers lower-cased header names
|
||||||
|
* @param array<int, array{filename: string, mime: string, content: string}> $attachments
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $fromEmail,
|
||||||
|
public readonly string $fromName,
|
||||||
|
public readonly string $subject,
|
||||||
|
public readonly string $textBody,
|
||||||
|
public readonly string $htmlBody,
|
||||||
|
public readonly array $headers,
|
||||||
|
public readonly array $attachments = [],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Treats an empty string the same as an absent header — the IMAP
|
||||||
|
* library backing ImapMailboxFetcher represents "header not present" as
|
||||||
|
* an empty value rather than a missing array key in some cases, so
|
||||||
|
* callers checking `header($x) !== null` alone would otherwise
|
||||||
|
* misdetect every message as carrying every header.
|
||||||
|
*/
|
||||||
|
public function header(string $name): ?string
|
||||||
|
{
|
||||||
|
$value = $this->headers[strtolower($name)] ?? null;
|
||||||
|
|
||||||
|
return $value !== null && $value !== '' ? $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function body(): string
|
||||||
|
{
|
||||||
|
if (trim($this->textBody) !== '') {
|
||||||
|
return $this->textBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim(html_entity_decode(strip_tags($this->htmlBody)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Support;
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Ldap\AdUser;
|
||||||
|
use App\Ldap\LldapUser;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Facades\Crypt;
|
use Illuminate\Support\Facades\Crypt;
|
||||||
@@ -22,6 +24,16 @@ class Settings
|
|||||||
'attachment_allowed_types' => 'jpg,jpeg,png,pdf,doc,docx,xls,xlsx,zip,txt',
|
'attachment_allowed_types' => 'jpg,jpeg,png,pdf,doc,docx,xls,xlsx,zip,txt',
|
||||||
'session_lifetime_minutes' => '120',
|
'session_lifetime_minutes' => '120',
|
||||||
'timezone' => 'UTC',
|
'timezone' => 'UTC',
|
||||||
|
'ticket_number_prefix' => '#',
|
||||||
|
'ticket_number_obfuscate' => '0',
|
||||||
|
'ticket_number_min_length' => '4',
|
||||||
|
'refresh_ticket_view_seconds' => '30',
|
||||||
|
'refresh_queue_seconds' => '60',
|
||||||
|
'refresh_notifications_seconds' => '30',
|
||||||
|
'schedule_sla_check_minutes' => '15',
|
||||||
|
'schedule_automation_rules_minutes' => '15',
|
||||||
|
'schedule_imap_fetch_minutes' => '5',
|
||||||
|
'schedule_ai_automation_minutes' => '5',
|
||||||
'ldap_enabled' => '1',
|
'ldap_enabled' => '1',
|
||||||
'ldap_host' => '',
|
'ldap_host' => '',
|
||||||
'ldap_port' => '389',
|
'ldap_port' => '389',
|
||||||
@@ -45,9 +57,38 @@ class Settings
|
|||||||
'bookstack_token_secret' => '',
|
'bookstack_token_secret' => '',
|
||||||
'bookstack_verify_ssl' => '1',
|
'bookstack_verify_ssl' => '1',
|
||||||
'bookstack_show_to_guests' => '0',
|
'bookstack_show_to_guests' => '0',
|
||||||
'bookstack_search_types' => 'both',
|
'bookstack_search_types' => 'book,page,chapter',
|
||||||
|
'bookstack_search_by' => 'both',
|
||||||
'bookstack_allowed_shelf_ids_creation' => '',
|
'bookstack_allowed_shelf_ids_creation' => '',
|
||||||
'bookstack_allowed_shelf_ids_ticket_view' => '',
|
'bookstack_allowed_shelf_ids_ticket_view' => '',
|
||||||
|
'snipeit_enabled' => '0',
|
||||||
|
'snipeit_base_url' => '',
|
||||||
|
'snipeit_api_token' => '',
|
||||||
|
'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',
|
||||||
|
'ai_base_url' => '',
|
||||||
|
'ai_api_key' => '',
|
||||||
|
'ai_model' => '',
|
||||||
|
'ai_verify_ssl' => '1',
|
||||||
|
'ai_triage_category_when_missing' => '0',
|
||||||
|
'ai_triage_subcategory_when_category_only' => '0',
|
||||||
|
'ai_triage_recheck_categorized' => '0',
|
||||||
|
'ai_triage_fix_subject' => '0',
|
||||||
|
'ai_triage_set_priority' => '0',
|
||||||
|
'ai_summary_enabled' => '0',
|
||||||
|
'ai_summary_regenerate_on_message' => '0',
|
||||||
|
'ai_summary_prompt' => 'Jesteś asystentem operatora helpdesku. Otrzymujesz temat, treść oraz historię '
|
||||||
|
.'wiadomości zgłoszenia. Podsumuj sprawę rzeczowo po polsku (2-3 zdania, czego dotyczy problem i na '
|
||||||
|
.'jakim jest etapie — np. czeka na odpowiedź klienta czy na działanie operatora) i zaproponuj krótką, '
|
||||||
|
.'konkretną kolejną akcję (jedno zdanie), np. "Poproś klienta o zrzut ekranu błędu" albo "Zamknij '
|
||||||
|
.'zgłoszenie — klient potwierdził rozwiązanie". Odpowiedz WYŁĄCZNIE obiektem JSON, bez innego tekstu: '
|
||||||
|
.'{"summary": "...", "suggested_action": "..."}. Jeśli nie da się ocenić kolejnego kroku, ustaw '
|
||||||
|
.'"suggested_action" na pusty string.',
|
||||||
'email_footer' => '<p>Ta wiadomość została wygenerowana automatycznie przez system {firma} — prosimy na nią nie odpowiadać.</p>',
|
'email_footer' => '<p>Ta wiadomość została wygenerowana automatycznie przez system {firma} — prosimy na nią nie odpowiadać.</p>',
|
||||||
'accent_color' => '#7c6fd6',
|
'accent_color' => '#7c6fd6',
|
||||||
'login_notice_type' => 'info',
|
'login_notice_type' => 'info',
|
||||||
@@ -60,7 +101,7 @@ class Settings
|
|||||||
.'</div>',
|
.'</div>',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static array $encrypted = ['ldap_bind_password', 'mail_smtp_password', 'bookstack_token_secret'];
|
protected static array $encrypted = ['ldap_bind_password', 'mail_smtp_password', 'bookstack_token_secret', 'ai_api_key', 'snipeit_api_token'];
|
||||||
|
|
||||||
public static function get(string $key, ?string $default = null): ?string
|
public static function get(string $key, ?string $default = null): ?string
|
||||||
{
|
{
|
||||||
@@ -191,6 +232,20 @@ class Settings
|
|||||||
return $path ? Storage::disk('public')->url($path) : asset('branding/default-mark.svg');
|
return $path ? Storage::disk('public')->url($path) : asset('branding/default-mark.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-page <title>: the context first (so it's still visible once the
|
||||||
|
* browser truncates a long tab title, and so tabs are distinguishable
|
||||||
|
* at a glance) with the company name as a trailing, always-present
|
||||||
|
* anchor. Falls back to just the company name for pages with no more
|
||||||
|
* specific context (landing, login).
|
||||||
|
*/
|
||||||
|
public static function pageTitle(?string $context = null): string
|
||||||
|
{
|
||||||
|
$company = static::get('company_name');
|
||||||
|
|
||||||
|
return $context ? "{$context} — {$company}" : $company;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The admin-configured timezone (IANA identifier, e.g. "Europe/Warsaw"),
|
* The admin-configured timezone (IANA identifier, e.g. "Europe/Warsaw"),
|
||||||
* applied at runtime by AppServiceProvider so every date/time displayed
|
* applied at runtime by AppServiceProvider so every date/time displayed
|
||||||
@@ -206,6 +261,24 @@ class Settings
|
|||||||
: static::$defaults['timezone'];
|
: static::$defaults['timezone'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an admin-configurable "every N minutes" scheduled command
|
||||||
|
* (schedule_*_minutes, routes/console.php) is due to run this minute.
|
||||||
|
* Used inside a Schedule::command(...)->when(...) closure rather than
|
||||||
|
* building a cron string up front — a closure is only evaluated when
|
||||||
|
* schedule:run actually processes due events, whereas eagerly reading
|
||||||
|
* Settings (which queries the DB) at routes/console.php's top level
|
||||||
|
* would run on every artisan boot (migrate, tinker, tests, ...), before
|
||||||
|
* the settings table necessarily even exists. Clamped to a minimum of 1
|
||||||
|
* so a blank/zero/negative stored value can never busy-loop.
|
||||||
|
*/
|
||||||
|
public static function dueEveryMinutes(string $key, int $default): bool
|
||||||
|
{
|
||||||
|
$minutes = max(1, (int) static::get($key, (string) $default));
|
||||||
|
|
||||||
|
return now()->minute % $minutes === 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps a single e-mail template's rendered HTML body in the fixed
|
* Wraps a single e-mail template's rendered HTML body in the fixed
|
||||||
* "box" layout — company name, ticket content and footer — so every
|
* "box" layout — company name, ticket content and footer — so every
|
||||||
@@ -230,19 +303,46 @@ class Settings
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the admin has pointed LDAP auth at Active Directory rather
|
||||||
|
* than an LLDAP/OpenLDAP-schema directory — changes which LdapRecord
|
||||||
|
* model class backs logins (see ldapUserModelClass()) and which
|
||||||
|
* attribute a bare username search defaults to (see
|
||||||
|
* ldapUsernameAttribute()), since AD's objectClass chain and login
|
||||||
|
* attribute (sAMAccountName, not uid) differ from LLDAP's.
|
||||||
|
*/
|
||||||
|
public static function isActiveDirectory(): bool
|
||||||
|
{
|
||||||
|
return static::get('ldap_directory_type') === 'ad';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The LdapRecord model class the 'users' auth provider should use —
|
||||||
|
* wired into config('auth.providers.users.model') at runtime by
|
||||||
|
* AppServiceProvider::applyLdapSettingsOverride(), same as the
|
||||||
|
* connection host/base DN below.
|
||||||
|
*/
|
||||||
|
public static function ldapUserModelClass(): string
|
||||||
|
{
|
||||||
|
return static::isActiveDirectory() ? AdUser::class : LldapUser::class;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the admin-configurable LDAP user filter (e.g. "(uid={0})") to
|
* Parses the admin-configurable LDAP user filter (e.g. "(uid={0})") to
|
||||||
* find which LDAP attribute logins are matched against.
|
* find which LDAP attribute logins are matched against. Defaults to
|
||||||
|
* Active Directory's sAMAccountName when no filter is set and the
|
||||||
|
* directory type is AD — uid is never populated on a stock AD user.
|
||||||
*/
|
*/
|
||||||
public static function ldapUsernameAttribute(): string
|
public static function ldapUsernameAttribute(): string
|
||||||
{
|
{
|
||||||
$filter = static::get('ldap_user_filter', '(uid={0})');
|
$default = static::isActiveDirectory() ? '(sAMAccountName={0})' : '(uid={0})';
|
||||||
|
$filter = static::get('ldap_user_filter') ?: $default;
|
||||||
|
|
||||||
if (preg_match('/\(([a-zA-Z0-9-]+)=\{0\}\)/', (string) $filter, $matches)) {
|
if (preg_match('/\(([a-zA-Z0-9-]+)=\{0\}\)/', (string) $filter, $matches)) {
|
||||||
return $matches[1];
|
return $matches[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'uid';
|
return static::isActiveDirectory() ? 'sAMAccountName' : 'uid';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Middleware\EnsureRole;
|
use App\Http\Middleware\EnsureRole;
|
||||||
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
|
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
|
||||||
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
|
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
@@ -44,4 +46,29 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
$exceptions->shouldRenderJsonWhen(
|
$exceptions->shouldRenderJsonWhen(
|
||||||
fn (Request $request) => $request->is('api/*'),
|
fn (Request $request) => $request->is('api/*'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A ticket deleted mid-session (typically by the operator/client
|
||||||
|
// currently viewing it) leaves any later request for that same
|
||||||
|
// {ticket} route binding 404ing — most commonly Livewire's own
|
||||||
|
// "model missing during hydration" recovery, which does a full
|
||||||
|
// window.location.reload() of the very page whose ticket just
|
||||||
|
// disappeared (e.g. the ticket-show view's periodic fallback
|
||||||
|
// refresh polling a few seconds after a delete+redirect). Land back
|
||||||
|
// on that area's own list page instead of a raw 404.
|
||||||
|
//
|
||||||
|
// Handler::prepareException() already converts ModelNotFoundException
|
||||||
|
// into NotFoundHttpException (wrapping the original as getPrevious())
|
||||||
|
// before any render() callback is dispatched — a callback typed
|
||||||
|
// against ModelNotFoundException itself would simply never match.
|
||||||
|
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
|
||||||
|
if (! $e->getPrevious() instanceof ModelNotFoundException || ! $request->user()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$request->is('operator/*') => redirect()->route('operator.queue'),
|
||||||
|
$request->is('client/*') => redirect()->route('client.dashboard'),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
});
|
||||||
})->create();
|
})->create();
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
"laravel/reverb": "*",
|
"laravel/reverb": "*",
|
||||||
"laravel/sanctum": "*",
|
"laravel/sanctum": "*",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"livewire/livewire": "*"
|
"livewire/livewire": "*",
|
||||||
|
"webklex/php-imap": "*"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
|||||||
83
src/composer.lock
generated
83
src/composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "321add40614eb8751e0c8dbda55016eb",
|
"content-hash": "abe8bd31e8d8849ae593e562f73a39df",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@@ -7593,6 +7593,87 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-04-26T05:33:54+00:00"
|
"time": "2026-04-26T05:33:54+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "webklex/php-imap",
|
||||||
|
"version": "6.2.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/Webklex/php-imap.git",
|
||||||
|
"reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/Webklex/php-imap/zipball/6b8ef85d621bbbaf52741b00cca8e9237e2b2e05",
|
||||||
|
"reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-fileinfo": "*",
|
||||||
|
"ext-iconv": "*",
|
||||||
|
"ext-json": "*",
|
||||||
|
"ext-libxml": "*",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-openssl": "*",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"illuminate/pagination": ">=5.0.0",
|
||||||
|
"nesbot/carbon": "^2.62.1|^3.2.4",
|
||||||
|
"php": "^8.0.2",
|
||||||
|
"symfony/http-foundation": ">=2.8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^9.5.10"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"symfony/mime": "Recomended for better extension support",
|
||||||
|
"symfony/var-dumper": "Usefull tool for debugging"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "6.0-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Webklex\\PHPIMAP\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Malte Goldenbaum",
|
||||||
|
"email": "github@webklex.com",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP IMAP client",
|
||||||
|
"homepage": "https://github.com/webklex/php-imap",
|
||||||
|
"keywords": [
|
||||||
|
"imap",
|
||||||
|
"mail",
|
||||||
|
"php-imap",
|
||||||
|
"pop3",
|
||||||
|
"webklex"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/Webklex/php-imap/issues",
|
||||||
|
"source": "https://github.com/Webklex/php-imap/tree/6.2.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://www.buymeacoffee.com/webklex",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://ko-fi.com/webklex",
|
||||||
|
"type": "ko_fi"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-04-25T06:02:37+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "zircote/swagger-php",
|
"name": "zircote/swagger-php",
|
||||||
"version": "6.4.0",
|
"version": "6.4.0",
|
||||||
|
|||||||
@@ -73,6 +73,42 @@ return [
|
|||||||
'replace_placeholders' => true,
|
'replace_placeholders' => true,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Dedicated, always-verbose channel for the IMAP fetcher
|
||||||
|
// (emails:fetch-imap) — kept separate from 'single'/LOG_LEVEL so a
|
||||||
|
// production app typically running at LOG_LEVEL=error still gets
|
||||||
|
// full visibility into what the fetcher did on every run, without
|
||||||
|
// that verbosity going into the main laravel.log.
|
||||||
|
'imap' => [
|
||||||
|
'driver' => 'daily',
|
||||||
|
'path' => storage_path('logs/imap.log'),
|
||||||
|
'level' => 'debug',
|
||||||
|
'days' => 14,
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// Dedicated, always-verbose channel for the scheduled AI ticket
|
||||||
|
// automation (ai:run-ticket-automation — triage + summaries) — same
|
||||||
|
// rationale as 'imap' below: full visibility into what the AI
|
||||||
|
// integration did on every run without depending on LOG_LEVEL.
|
||||||
|
'ai' => [
|
||||||
|
'driver' => 'daily',
|
||||||
|
'path' => storage_path('logs/ai.log'),
|
||||||
|
'level' => 'debug',
|
||||||
|
'days' => 14,
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// Dedicated channel for the one-off Hesk import command (hesk:import)
|
||||||
|
// — console output alone is lost once the terminal is closed, so
|
||||||
|
// failures/summary go here too.
|
||||||
|
'hesk_import' => [
|
||||||
|
'driver' => 'daily',
|
||||||
|
'path' => storage_path('logs/hesk-import.log'),
|
||||||
|
'level' => 'debug',
|
||||||
|
'days' => 14,
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
'slack' => [
|
'slack' => [
|
||||||
'driver' => 'slack',
|
'driver' => 'slack',
|
||||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ namespace Database\Factories;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends Factory<User>
|
* @extends Factory<User>
|
||||||
@@ -27,20 +26,8 @@ class UserFactory extends Factory
|
|||||||
return [
|
return [
|
||||||
'name' => fake()->name(),
|
'name' => fake()->name(),
|
||||||
'email' => fake()->unique()->safeEmail(),
|
'email' => fake()->unique()->safeEmail(),
|
||||||
'email_verified_at' => now(),
|
|
||||||
'password' => static::$password ??= Hash::make('password'),
|
'password' => static::$password ??= Hash::make('password'),
|
||||||
'roles' => ['client'],
|
'roles' => ['client'],
|
||||||
'remember_token' => Str::random(10),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Indicate that the model's email address should be unverified.
|
|
||||||
*/
|
|
||||||
public function unverified(): static
|
|
||||||
{
|
|
||||||
return $this->state(fn (array $attributes) => [
|
|
||||||
'email_verified_at' => null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('tickets', function (Blueprint $table) {
|
||||||
|
$table->string('checksum', 20)->nullable()->unique()->after('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Backfill: every existing ticket gets a stable, HMAC-derived
|
||||||
|
// checksum (mirrors Ticket::generateUniqueChecksum()) so the
|
||||||
|
// "hide ticket order" numbering mode has a real, unique, indexed
|
||||||
|
// column to resolve ticket URLs against instead of only being a
|
||||||
|
// display-time computation.
|
||||||
|
$assigned = [];
|
||||||
|
|
||||||
|
DB::table('tickets')->orderBy('id')->select('id')->chunkById(500, function ($tickets) use (&$assigned) {
|
||||||
|
foreach ($tickets as $ticket) {
|
||||||
|
$nonce = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
$hash = hash_hmac('sha256', $ticket->id.'|'.$nonce, (string) config('app.key'));
|
||||||
|
$candidate = (string) (hexdec(substr($hash, 0, 8)) % 900000 + 100000);
|
||||||
|
$nonce++;
|
||||||
|
} while (isset($assigned[$candidate]));
|
||||||
|
|
||||||
|
$assigned[$candidate] = true;
|
||||||
|
|
||||||
|
DB::table('tickets')->where('id', $ticket->id)->update(['checksum' => $candidate]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('tickets', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('checksum');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('imap_mailboxes', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->boolean('enabled')->default(false);
|
||||||
|
$table->string('host');
|
||||||
|
$table->unsignedSmallInteger('port')->default(993);
|
||||||
|
$table->string('encryption')->default('ssl');
|
||||||
|
$table->boolean('validate_cert')->default(true);
|
||||||
|
$table->string('username');
|
||||||
|
$table->text('password')->nullable();
|
||||||
|
$table->string('folder')->default('INBOX');
|
||||||
|
$table->string('processed_folder')->nullable();
|
||||||
|
$table->string('rejected_folder')->nullable();
|
||||||
|
$table->foreignId('default_subcategory_id')->nullable()->constrained('subcategories')->nullOnDelete();
|
||||||
|
$table->string('blocklist_senders')->default('mailer-daemon,postmaster,no-reply,noreply');
|
||||||
|
$table->timestamp('last_checked_at')->nullable();
|
||||||
|
$table->text('last_error')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('imap_mailboxes');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* category_id lets a ticket carry just a Category with no specific
|
||||||
|
* Subcategory (e.g. an IMAP mailbox routed to "całą kategorię" rather
|
||||||
|
* than one subcategory) — subcategory_id already implies a category via
|
||||||
|
* its own relation, so category_id is only ever populated when there's
|
||||||
|
* no subcategory to derive it from (see Ticket::categoryLabel()).
|
||||||
|
*
|
||||||
|
* source records how the ticket was created (web/e-mail/...), surfaced
|
||||||
|
* as a badge in the operator queue/ticket view.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('tickets', function (Blueprint $table) {
|
||||||
|
$table->foreignId('category_id')->nullable()->after('subcategory_id')->constrained('categories')->nullOnDelete();
|
||||||
|
$table->string('source')->default('web')->after('api_client_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('imap_mailboxes', function (Blueprint $table) {
|
||||||
|
$table->foreignId('default_category_id')->nullable()->after('default_subcategory_id')->constrained('categories')->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('imap_mailboxes', function (Blueprint $table) {
|
||||||
|
$table->dropConstrainedForeignId('default_category_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('tickets', function (Blueprint $table) {
|
||||||
|
$table->dropConstrainedForeignId('category_id');
|
||||||
|
$table->dropColumn('source');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Mirrors tickets.source at the individual-message level — a ticket
|
||||||
|
* created on the web can still later receive a reply by e-mail (or vice
|
||||||
|
* versa), so this needs tracking per message, not just per ticket.
|
||||||
|
* Null means "web" (the original/default channel); only IMAP-originated
|
||||||
|
* messages ever set it to 'email'.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('ticket_messages', function (Blueprint $table) {
|
||||||
|
$table->string('source')->nullable()->after('api_client_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('ticket_messages', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('source');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* ai_triaged_at marks that the AI auto-triage pass has run for this
|
||||||
|
* ticket (regardless of whether it changed anything) — never reset, so
|
||||||
|
* the scheduled command's query is just "tickets where this is null".
|
||||||
|
*
|
||||||
|
* ai_summary/ai_suggested_action/ai_summary_generated_at cache the AI
|
||||||
|
* ticket summary shown to operators; generated_at lets the summary
|
||||||
|
* command cheaply tell whether a ticket's summary is stale relative to
|
||||||
|
* its latest message, without re-summarizing every ticket every run.
|
||||||
|
*/
|
||||||
|
public function up(): 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('tickets', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['ai_triaged_at', 'ai_summary', 'ai_suggested_action', 'ai_summary_generated_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('subcategories', function (Blueprint $table) {
|
||||||
|
$table->unsignedInteger('sort_order')->default(0)->after('default_priority_key');
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach (DB::table('subcategories')->orderBy('category_id')->orderBy('id')->get() as $position => $sub) {
|
||||||
|
DB::table('subcategories')->where('id', $sub->id)->update(['sort_order' => $position]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('subcategories', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('sort_order');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* snipeit_asset_name is a cached label (asset tag + name/model) captured
|
||||||
|
* at link time — kept alongside the id so the ticket list/header still
|
||||||
|
* shows something meaningful if Snipe-IT is unreachable or the asset was
|
||||||
|
* later deleted there, without depending on a live API call.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('tickets', function (Blueprint $table) {
|
||||||
|
$table->unsignedInteger('snipeit_asset_id')->nullable()->after('ai_summary_generated_at');
|
||||||
|
$table->string('snipeit_asset_name')->nullable()->after('snipeit_asset_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('tickets', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['snipeit_asset_id', 'snipeit_asset_name']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('ticket_views', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
// Microsecond precision (not the plain-timestamp default) so two
|
||||||
|
// views landing in the same second — plausible with fast repeat
|
||||||
|
// clicks, not just test speed — still order correctly instead of
|
||||||
|
// tying and falling back to row id.
|
||||||
|
$table->timestamp('viewed_at', 6);
|
||||||
|
|
||||||
|
$table->unique(['ticket_id', 'user_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('ticket_views');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 = [
|
$templates = [
|
||||||
'tpl-new' => [
|
'tpl-new' => [
|
||||||
'name' => 'Nowe zgłoszenie przyjęte', 'trigger_label' => 'Zgłoszenie utworzone',
|
'name' => 'Nowe zgłoszenie przyjęte',
|
||||||
'subject' => 'Otrzymaliśmy Twoje zgłoszenie #{numer}',
|
'subject' => 'Otrzymaliśmy Twoje zgłoszenie #{numer}',
|
||||||
'body' => '<p>Cześć {imie},</p><p>Otrzymaliśmy Twoje zgłoszenie „{temat}”. Nasz zespół zajmie się nim najszybciej jak to możliwe.</p>'.$link.$footer,
|
'body' => '<p>Cześć {imie},</p><p>Otrzymaliśmy Twoje zgłoszenie „{temat}”. Nasz zespół zajmie się nim najszybciej jak to możliwe.</p>'.$link.$footer,
|
||||||
],
|
],
|
||||||
'tpl-status' => [
|
'tpl-status' => [
|
||||||
'name' => 'Zmiana statusu', 'trigger_label' => 'Status zgłoszenia zmieniony',
|
'name' => 'Zmiana statusu',
|
||||||
'subject' => 'Aktualizacja zgłoszenia #{numer}',
|
'subject' => 'Aktualizacja zgłoszenia #{numer}',
|
||||||
'body' => '<p>Cześć {imie},</p><p>Status Twojego zgłoszenia „{temat}” zmienił się na: {status}.</p>'.$link.$footer,
|
'body' => '<p>Cześć {imie},</p><p>Status Twojego zgłoszenia „{temat}” zmienił się na: {status}.</p>'.$link.$footer,
|
||||||
],
|
],
|
||||||
'tpl-category' => [
|
'tpl-category' => [
|
||||||
'name' => 'Zmiana kategorii', 'trigger_label' => 'Kategoria zgłoszenia zmieniona',
|
'name' => 'Zmiana kategorii',
|
||||||
'subject' => 'Zmieniono kategorię zgłoszenia #{numer}',
|
'subject' => 'Zmieniono kategorię zgłoszenia #{numer}',
|
||||||
'body' => '<p>Cześć {imie},</p><p>Kategoria Twojego zgłoszenia „{temat}” została zmieniona na: {kategoria}.</p>'.$link.$footer,
|
'body' => '<p>Cześć {imie},</p><p>Kategoria Twojego zgłoszenia „{temat}” została zmieniona na: {kategoria}.</p>'.$link.$footer,
|
||||||
],
|
],
|
||||||
'tpl-assignee' => [
|
'tpl-assignee' => [
|
||||||
'name' => 'Zmiana przypisanego operatora', 'trigger_label' => 'Przypisany operator zmieniony',
|
'name' => 'Zmiana przypisanego operatora',
|
||||||
'subject' => 'Zmieniono osobę obsługującą zgłoszenie #{numer}',
|
'subject' => 'Zmieniono osobę obsługującą zgłoszenie #{numer}',
|
||||||
'body' => '<p>Cześć {imie},</p><p>Twoim zgłoszeniem „{temat}” zajmie się teraz: {operator}.</p>'.$link.$footer,
|
'body' => '<p>Cześć {imie},</p><p>Twoim zgłoszeniem „{temat}” zajmie się teraz: {operator}.</p>'.$link.$footer,
|
||||||
],
|
],
|
||||||
'tpl-priority' => [
|
'tpl-priority' => [
|
||||||
'name' => 'Zmiana priorytetu', 'trigger_label' => 'Priorytet zgłoszenia zmieniony',
|
'name' => 'Zmiana priorytetu',
|
||||||
'subject' => 'Zmieniono priorytet zgłoszenia #{numer}',
|
'subject' => 'Zmieniono priorytet zgłoszenia #{numer}',
|
||||||
'body' => '<p>Cześć {imie},</p><p>Priorytet Twojego zgłoszenia „{temat}” zmienił się na: {priorytet}.</p>'.$link.$footer,
|
'body' => '<p>Cześć {imie},</p><p>Priorytet Twojego zgłoszenia „{temat}” zmienił się na: {priorytet}.</p>'.$link.$footer,
|
||||||
],
|
],
|
||||||
'tpl-team' => [
|
'tpl-team' => [
|
||||||
'name' => 'Zmiana zespołu', 'trigger_label' => 'Zespół obsługujący zmieniony',
|
'name' => 'Zmiana zespołu',
|
||||||
'subject' => 'Zmieniono zespół obsługujący zgłoszenie #{numer}',
|
'subject' => 'Zmieniono zespół obsługujący zgłoszenie #{numer}',
|
||||||
'body' => '<p>Cześć {imie},</p><p>Twoim zgłoszeniem „{temat}” zajmuje się teraz zespół: {zespol}.</p>'.$link.$footer,
|
'body' => '<p>Cześć {imie},</p><p>Twoim zgłoszeniem „{temat}” zajmuje się teraz zespół: {zespol}.</p>'.$link.$footer,
|
||||||
],
|
],
|
||||||
'tpl-closed' => [
|
'tpl-closed' => [
|
||||||
'name' => 'Zgłoszenie zamknięte', 'trigger_label' => 'Status = Zamknięte',
|
'name' => 'Zgłoszenie zamknięte',
|
||||||
'subject' => 'Zgłoszenie #{numer} zostało zamknięte',
|
'subject' => 'Zgłoszenie #{numer} zostało zamknięte',
|
||||||
'body' => '<p>Cześć {imie},</p><p>Twoje zgłoszenie „{temat}” zostało zamknięte. Jeśli temat nie został rozwiązany, odpowiedz na tego maila lub zgłoś sprawę ponownie.</p>'.$link.$csatLink.$footer,
|
'body' => '<p>Cześć {imie},</p><p>Twoje zgłoszenie „{temat}” zostało zamknięte. Jeśli temat nie został rozwiązany, odpowiedz na tego maila lub zgłoś sprawę ponownie.</p>'.$link.$csatLink.$footer,
|
||||||
],
|
],
|
||||||
'tpl-reply' => [
|
'tpl-reply' => [
|
||||||
'name' => 'Nowa odpowiedź operatora', 'trigger_label' => 'Operator odpowiedział',
|
'name' => 'Nowa odpowiedź operatora',
|
||||||
'subject' => 'Nowa odpowiedź w zgłoszeniu #{numer}',
|
'subject' => 'Nowa odpowiedź w zgłoszeniu #{numer}',
|
||||||
'body' => '<p>Cześć {imie},</p><p>Otrzymałeś/aś nową odpowiedź w zgłoszeniu „{temat}”.</p>'.$link.$footer,
|
'body' => '<p>Cześć {imie},</p><p>Otrzymałeś/aś nową odpowiedź w zgłoszeniu „{temat}”.</p>'.$link.$footer,
|
||||||
],
|
],
|
||||||
'tpl-sla-breach' => [
|
'tpl-sla-breach' => [
|
||||||
'name' => 'Przekroczenie SLA', 'trigger_label' => 'SLA przekroczone — operator',
|
'name' => 'Przekroczenie SLA',
|
||||||
'subject' => 'Przekroczono SLA zgłoszenia #{numer}',
|
'subject' => 'Przekroczono SLA zgłoszenia #{numer}',
|
||||||
'body' => '<p>Cześć {operator},</p><p>Zgłoszenie „{temat}” (#{numer}) przekroczyło ustalony czas rozwiązania SLA.</p>'.$link.$footer,
|
'body' => '<p>Cześć {operator},</p><p>Zgłoszenie „{temat}” (#{numer}) przekroczyło ustalony czas rozwiązania SLA.</p>'.$link.$footer,
|
||||||
],
|
],
|
||||||
'tpl-team-new-ticket' => [
|
'tpl-team-new-ticket' => [
|
||||||
'name' => 'Nowe zgłoszenie w zespole', 'trigger_label' => 'Nowe zgłoszenie w zespole — operator',
|
'name' => 'Nowe zgłoszenie w zespole',
|
||||||
'subject' => 'Nowe zgłoszenie w Twoim zespole (#{numer})',
|
'subject' => 'Nowe zgłoszenie w Twoim zespole (#{numer})',
|
||||||
'body' => '<p>Cześć,</p><p>Nowe zgłoszenie „{temat}” (#{numer}, kategoria: {kategoria}) trafiło do zespołu {zespol}.</p>'.$link.$footer,
|
'body' => '<p>Cześć,</p><p>Nowe zgłoszenie „{temat}” (#{numer}, kategoria: {kategoria}) trafiło do zespołu {zespol}.</p>'.$link.$footer,
|
||||||
],
|
],
|
||||||
@@ -352,7 +352,6 @@ class DatabaseSeeder extends Seeder
|
|||||||
foreach ($templates as $key => $tpl) {
|
foreach ($templates as $key => $tpl) {
|
||||||
$ids[$key] = EmailTemplate::query()->firstOrCreate(['key' => $key], [
|
$ids[$key] = EmailTemplate::query()->firstOrCreate(['key' => $key], [
|
||||||
'name' => $tpl['name'],
|
'name' => $tpl['name'],
|
||||||
'trigger_label' => $tpl['trigger_label'],
|
|
||||||
'subject' => $tpl['subject'],
|
'subject' => $tpl['subject'],
|
||||||
'body' => $tpl['body'],
|
'body' => $tpl['body'],
|
||||||
])->id;
|
])->id;
|
||||||
|
|||||||
8
src/lang/pl/pagination.php
Normal file
8
src/lang/pl/pagination.php
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'previous' => '« Poprzednia',
|
||||||
|
'next' => 'Następna »',
|
||||||
|
|
||||||
|
];
|
||||||
@@ -55,11 +55,6 @@
|
|||||||
[data-theme='light'] .tag-neutral { background: var(--color-neutral-200); color: var(--color-neutral-700); }
|
[data-theme='light'] .tag-neutral { background: var(--color-neutral-200); color: var(--color-neutral-700); }
|
||||||
[data-theme='light'] .dialog-backdrop { background: color-mix(in srgb, var(--color-neutral-900) 35%, transparent); }
|
[data-theme='light'] .dialog-backdrop { background: color-mix(in srgb, var(--color-neutral-900) 35%, transparent); }
|
||||||
|
|
||||||
[data-theme='light'] .login-notice-info { background: color-mix(in srgb, var(--color-accent) 14%, white); color: var(--color-accent-700); }
|
|
||||||
[data-theme='light'] .login-notice-warning { background: color-mix(in srgb, var(--color-warning) 20%, white); color: color-mix(in srgb, var(--color-warning) 80%, black); }
|
|
||||||
[data-theme='light'] .login-notice-success { background: color-mix(in srgb, var(--color-success) 18%, white); color: color-mix(in srgb, var(--color-success) 75%, black); }
|
|
||||||
[data-theme='light'] .login-notice-danger { background: color-mix(in srgb, var(--color-danger) 16%, white); color: color-mix(in srgb, var(--color-danger) 80%, black); }
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -211,6 +206,97 @@ body {
|
|||||||
.seg-opt:has(input:checked) { background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent); }
|
.seg-opt:has(input:checked) { background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent); }
|
||||||
.seg-opt input { position: absolute; opacity: 0; width: 0; height: 0; }
|
.seg-opt input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||||
|
|
||||||
|
.pagination-wrap { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; }
|
||||||
|
.pagination-summary { font-size: 12.5px; color: color-mix(in srgb, var(--color-text) 55%, transparent); }
|
||||||
|
.pagination-links { display: inline-flex; gap: 4px; flex-wrap: wrap; }
|
||||||
|
.pagination-links a,
|
||||||
|
.pagination-links button,
|
||||||
|
.pagination-links span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: none;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pagination-links > span { padding: 0; }
|
||||||
|
.pagination-links a:hover,
|
||||||
|
.pagination-links button:hover { background: color-mix(in srgb, var(--color-text) 6%, transparent); }
|
||||||
|
.pagination-links span[aria-disabled='true'] { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.pagination-links span.pagination-dots { background: transparent; }
|
||||||
|
.pagination-links button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.pagination-links [aria-current='page'] span {
|
||||||
|
background: var(--color-accent);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 3px;
|
||||||
|
border: 1px solid var(--color-divider);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--color-text) 4%, transparent);
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.panel-switch::-webkit-scrollbar { display: none; }
|
||||||
|
.panel-switch-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-accent);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.nav .panel-switch-option {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 128px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.nav .panel-switch-option:hover { color: #fff; text-decoration: none; }
|
||||||
|
.nav .panel-switch-option-active, .nav .panel-switch-option-active:hover { color: #fff; }
|
||||||
|
|
||||||
|
/* Preview the destination before the click actually navigates there:
|
||||||
|
the indicator follows whichever option is under the cursor (falling
|
||||||
|
back to the real active position, set inline per-request by
|
||||||
|
panel-switcher.blade.php, the moment the pointer leaves). Position is
|
||||||
|
purely by hovered index — independent of how many roles/options
|
||||||
|
exist — so these three rules cover the max of three areas regardless
|
||||||
|
of which subset a given user has. */
|
||||||
|
.panel-switch:has(> .panel-switch-option:nth-of-type(1):hover) .panel-switch-indicator { transform: translateX(0%) !important; }
|
||||||
|
.panel-switch:has(> .panel-switch-option:nth-of-type(2):hover) .panel-switch-indicator { transform: translateX(100%) !important; }
|
||||||
|
.panel-switch:has(> .panel-switch-option:nth-of-type(3):hover) .panel-switch-indicator { transform: translateX(200%) !important; }
|
||||||
|
|
||||||
|
/* The real active option's white text is only correct while the
|
||||||
|
indicator sits under it — once hover has pulled the indicator away to
|
||||||
|
a neighboring option, drop it back to normal text color so it doesn't
|
||||||
|
read as near-invisible white-on-track. */
|
||||||
|
.panel-switch:hover .panel-switch-option-active:not(:hover) { color: var(--color-text); }
|
||||||
|
|
||||||
.theme-toggle-option {
|
.theme-toggle-option {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -234,9 +320,18 @@ body {
|
|||||||
and Tailwind's @layer'd rules always lose to unlayered ones — including
|
and Tailwind's @layer'd rules always lose to unlayered ones — including
|
||||||
Quill's CDN stylesheet — regardless of source order, so anything meant to
|
Quill's CDN stylesheet — regardless of source order, so anything meant to
|
||||||
override .ql-editor here (like the padding reset) has to be unlayered too. */
|
override .ql-editor here (like the padding reset) has to be unlayered too. */
|
||||||
|
/* Fixed (not [data-theme]-dependent) colors on purpose — this box holds
|
||||||
|
admin-authored Quill HTML with its own inline text colors (including
|
||||||
|
plain "white"), so the container needs one stable dark background in
|
||||||
|
both app themes rather than a tint derived from --color-accent, which
|
||||||
|
changed hue/lightness between light and dark mode and could swallow
|
||||||
|
light-colored text the admin picked. */
|
||||||
.login-notice {
|
.login-notice {
|
||||||
padding: 0 14px;
|
padding: 14px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
background: #17262d;
|
||||||
|
color: #eef3f4;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
height: auto;
|
height: auto;
|
||||||
@@ -245,10 +340,10 @@ body {
|
|||||||
}
|
}
|
||||||
.login-notice > *:first-child { margin-top: 0; }
|
.login-notice > *:first-child { margin-top: 0; }
|
||||||
.login-notice > *:last-child { margin-bottom: 0; }
|
.login-notice > *:last-child { margin-bottom: 0; }
|
||||||
.login-notice-info { background: color-mix(in srgb, var(--color-accent) 20%, transparent); color: var(--color-accent); }
|
.login-notice hr { border-color: rgba(255, 255, 255, 0.14); }
|
||||||
.login-notice-warning { background: color-mix(in srgb, var(--color-warning) 20%, transparent); color: var(--color-warning); }
|
.login-notice-warning { border-color: color-mix(in srgb, var(--color-warning) 45%, rgba(255, 255, 255, 0.14)); }
|
||||||
.login-notice-success { background: color-mix(in srgb, var(--color-success) 20%, transparent); color: var(--color-success); }
|
.login-notice-success { border-color: color-mix(in srgb, var(--color-success) 45%, rgba(255, 255, 255, 0.14)); }
|
||||||
.login-notice-danger { background: color-mix(in srgb, var(--color-danger) 20%, transparent); color: var(--color-danger); }
|
.login-notice-danger { border-color: color-mix(in srgb, var(--color-danger) 45%, rgba(255, 255, 255, 0.14)); }
|
||||||
|
|
||||||
/* ---- Responsive layout (phones/tablets) ---- */
|
/* ---- Responsive layout (phones/tablets) ---- */
|
||||||
|
|
||||||
@@ -341,8 +436,27 @@ body {
|
|||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.page-pad { padding: 16px !important; }
|
.page-pad { padding: 16px !important; }
|
||||||
.nav { padding-left: 14px !important; padding-right: 14px !important; gap: 10px; }
|
.nav { padding-left: 14px !important; padding-right: 14px !important; gap: 10px; flex-wrap: wrap; }
|
||||||
.nav-panel-label { display: none; }
|
.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
|
||||||
|
shrink once labels are already at their minimum width) — same
|
||||||
|
"restructure instead of cram" fix as the mobile table pattern above:
|
||||||
|
drop it to its own full-width row below instead of fighting for
|
||||||
|
space with everything else in the bar. */
|
||||||
|
.panel-switch {
|
||||||
|
position: relative !important;
|
||||||
|
left: auto !important;
|
||||||
|
top: auto !important;
|
||||||
|
transform: none !important;
|
||||||
|
order: 10;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.nav .panel-switch-option { padding: 10px; font-size: 11.5px; min-width: 92px; }
|
||||||
|
|
||||||
/* Theme/notifications/profile dropdowns are anchored (position:absolute)
|
/* Theme/notifications/profile dropdowns are anchored (position:absolute)
|
||||||
to their own small trigger button by default, which overflows off the
|
to their own small trigger button by default, which overflows off the
|
||||||
|
|||||||
@@ -2,24 +2,12 @@
|
|||||||
|
|
||||||
@php
|
@php
|
||||||
$url = \Illuminate\Support\Facades\Storage::disk('public')->url($attachment->path);
|
$url = \Illuminate\Support\Facades\Storage::disk('public')->url($attachment->path);
|
||||||
$isImage = \Illuminate\Support\Str::startsWith($attachment->mime ?? '', 'image/');
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@if ($isImage)
|
<a
|
||||||
<a href="{{ $url }}" target="_blank" style="display:block;margin-top:8px">
|
|
||||||
<img
|
|
||||||
src="{{ $url }}"
|
|
||||||
alt="{{ $attachment->original_name }}"
|
|
||||||
loading="lazy"
|
|
||||||
style="max-width:220px;max-height:160px;border-radius:8px;border:1px solid var(--color-divider);object-fit:cover;cursor:zoom-in;display:block"
|
|
||||||
>
|
|
||||||
</a>
|
|
||||||
@else
|
|
||||||
<a
|
|
||||||
href="{{ $url }}"
|
href="{{ $url }}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
style="display:inline-flex;align-items:center;gap:6px;margin-top:8px;padding:5px 10px;border:1px solid var(--color-divider);border-radius:6px;font-size:12.5px;color:inherit;text-decoration:none;background:color-mix(in srgb, var(--color-text) 5%, transparent)"
|
style="display:inline-flex;align-items:center;gap:6px;margin-top:8px;padding:5px 10px;border:1px solid var(--color-divider);border-radius:6px;font-size:12.5px;color:inherit;text-decoration:none;background:color-mix(in srgb, var(--color-text) 5%, transparent)"
|
||||||
>
|
>
|
||||||
<span class="material-symbols-outlined" style="font-size:15px">attach_file</span>{{ $attachment->original_name }}
|
<span class="material-symbols-outlined" style="font-size:15px">attach_file</span>{{ $attachment->original_name }}
|
||||||
</a>
|
</a>
|
||||||
@endif
|
|
||||||
|
|||||||
50
src/resources/views/components/panel-switcher.blade.php
Normal file
50
src/resources/views/components/panel-switcher.blade.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
@props(['area' => null])
|
||||||
|
|
||||||
|
@php
|
||||||
|
// $area is passed explicitly by each page (e.g. <x-topbar area="operator" />)
|
||||||
|
// rather than inferred from request()->routeIs() here: this component is
|
||||||
|
// rendered as part of each top-level Livewire page's own template, so it
|
||||||
|
// re-renders on every wire:click/wire:model round-trip on that page (tab
|
||||||
|
// switches, pagination, search, ...) — and during that AJAX request,
|
||||||
|
// request()->route() is Livewire's own update route, not client./operator./
|
||||||
|
// admin.*, which silently broke the highlight on every in-page interaction
|
||||||
|
// when this used to key off the ambient request instead of an explicit prop.
|
||||||
|
$user = auth()->user();
|
||||||
|
$areas = [];
|
||||||
|
|
||||||
|
if ($user) {
|
||||||
|
if ($user->isClient()) {
|
||||||
|
$areas[] = ['label' => 'Klient', 'url' => route('client.dashboard'), 'active' => $area === 'client'];
|
||||||
|
}
|
||||||
|
if ($user->isOperator()) {
|
||||||
|
$areas[] = ['label' => 'Operator', 'url' => route('operator.queue'), 'active' => $area === 'operator'];
|
||||||
|
}
|
||||||
|
if ($user->isAdmin()) {
|
||||||
|
$areas[] = ['label' => 'Administrator', 'url' => route('admin.panel'), 'active' => $area === 'admin'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$activeIndex = collect($areas)->search(fn ($area) => $area['active']);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if (count($areas))
|
||||||
|
<div
|
||||||
|
class="panel-switch"
|
||||||
|
style="position:absolute;left:50%;top:50%;transform:translate(-50%, -50%)"
|
||||||
|
>
|
||||||
|
@if ($activeIndex !== false)
|
||||||
|
<span
|
||||||
|
class="panel-switch-indicator"
|
||||||
|
style="width:calc(100% / {{ count($areas) }});transform:translateX({{ $activeIndex * 100 }}%)"
|
||||||
|
></span>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@foreach ($areas as $item)
|
||||||
|
<a
|
||||||
|
href="{{ $item['url'] }}"
|
||||||
|
wire:navigate
|
||||||
|
class="panel-switch-option {{ $item['active'] ? 'panel-switch-option-active' : '' }}"
|
||||||
|
>{{ $item['label'] }}</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@@ -1,18 +1,5 @@
|
|||||||
@php
|
@php
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$areas = [];
|
|
||||||
|
|
||||||
if ($user) {
|
|
||||||
if ($user->isClient()) {
|
|
||||||
$areas[] = ['label' => 'Panel Klienta', 'url' => route('client.dashboard'), 'active' => request()->routeIs('client.*')];
|
|
||||||
}
|
|
||||||
if ($user->isOperator()) {
|
|
||||||
$areas[] = ['label' => 'Panel Operatora', 'url' => route('operator.queue'), 'active' => request()->routeIs('operator.*')];
|
|
||||||
}
|
|
||||||
if ($user->isAdmin()) {
|
|
||||||
$areas[] = ['label' => 'Panel Administratora', 'url' => route('admin.panel'), 'active' => request()->routeIs('admin.*')];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@if ($user)
|
@if ($user)
|
||||||
@@ -26,23 +13,27 @@
|
|||||||
x-show="open"
|
x-show="open"
|
||||||
x-cloak
|
x-cloak
|
||||||
class="nav-dropdown"
|
class="nav-dropdown"
|
||||||
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:200px;overflow:hidden;z-index:30"
|
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:220px;overflow:hidden;z-index:30"
|
||||||
>
|
>
|
||||||
@foreach ($areas as $area)
|
<div style="display:flex;flex-direction:column;gap:6px;padding:12px">
|
||||||
<a
|
<span style="font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $user->name }}</span>
|
||||||
href="{{ $area['url'] }}"
|
<span style="font-size:11.5px;color:color-mix(in srgb, var(--color-text) 60%, transparent);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $user->email }}</span>
|
||||||
wire:navigate
|
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:2px">
|
||||||
@click="open = false"
|
@if ($user->isClient())
|
||||||
class="theme-toggle-option"
|
<span class="tag tag-neutral">Klient</span>
|
||||||
style="text-decoration:none;color:{{ $area['active'] ? 'var(--color-accent)' : 'var(--color-text)' }};font-size:12.5px"
|
|
||||||
>{{ $area['label'] }}</a>
|
|
||||||
@endforeach
|
|
||||||
|
|
||||||
@if (count($areas))
|
|
||||||
<div style="border-top:1px solid var(--color-divider)"></div>
|
|
||||||
@endif
|
@endif
|
||||||
|
@if ($user->isOperator())
|
||||||
|
<span class="tag tag-accent-2">Operator</span>
|
||||||
|
@endif
|
||||||
|
@if ($user->isAdmin())
|
||||||
|
<span class="tag tag-accent">Administrator</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if ($user && ($user->isOperator() || $user->isAdmin()))
|
<div style="border-top:1px solid var(--color-divider)"></div>
|
||||||
|
|
||||||
|
@if ($user->isOperator() || $user->isAdmin())
|
||||||
<a
|
<a
|
||||||
href="{{ route('settings.notifications') }}"
|
href="{{ route('settings.notifications') }}"
|
||||||
wire:navigate
|
wire:navigate
|
||||||
|
|||||||
64
src/resources/views/components/snipeit-assets.blade.php
Normal file
64
src/resources/views/components/snipeit-assets.blade.php
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
@props([
|
||||||
|
'assets',
|
||||||
|
'variant' => 'banner',
|
||||||
|
'title' => 'Twój sprzęt (inwentarz)',
|
||||||
|
'selectable' => false,
|
||||||
|
'selectAction' => 'selectSnipeitAsset',
|
||||||
|
'selectedId' => null,
|
||||||
|
// false when embedded inside a caller-provided card (e.g. the operator's
|
||||||
|
// "Przeszukaj inwentarz" search box + results in one container) — skips
|
||||||
|
// this component's own wrapping card/title so the two don't nest.
|
||||||
|
'card' => true,
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$isSidebar = $variant === 'sidebar';
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if (count($assets))
|
||||||
|
@if ($card)
|
||||||
|
<div class="card" style="{{ $isSidebar ? 'padding:16px;gap:8px' : 'padding:14px;gap:10px;background:color-mix(in srgb, var(--color-accent) 6%, transparent);border-color:color-mix(in srgb, var(--color-accent) 25%, var(--color-divider))' }}">
|
||||||
|
@if ($isSidebar)
|
||||||
|
<div class="card-kicker">{{ $title }}</div>
|
||||||
|
@else
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;font-size:12.5px;font-weight:600">
|
||||||
|
<span class="material-symbols-outlined" style="font-size:16px">devices</span>
|
||||||
|
{{ $title }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
<div style="display:flex;flex-direction:column;gap:2px">
|
||||||
|
@foreach ($assets as $a)
|
||||||
|
@php $isSelected = $selectedId === $a['id']; @endphp
|
||||||
|
<div style="display:flex;gap:8px;align-items:center;padding:8px;border-radius:6px;{{ $isSelected ? 'background:color-mix(in srgb, var(--color-accent) 10%, transparent)' : '' }}">
|
||||||
|
<a
|
||||||
|
href="{{ $a['url'] }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style="display:flex;gap:10px;align-items:flex-start;flex:1;min-width:0;text-decoration:none;color:inherit"
|
||||||
|
>
|
||||||
|
<span class="material-symbols-outlined" style="font-size:18px;flex:none;margin-top:1px;color:var(--color-accent)">devices</span>
|
||||||
|
<span style="min-width:0;flex:1">
|
||||||
|
<span style="display:block;font-size:13px;font-weight:500;{{ $isSelected ? 'color:var(--color-accent)' : '' }}">{{ $a['label'] }}</span>
|
||||||
|
@if (! empty($a['category']))
|
||||||
|
<span style="display:block;font-size:11px;color:color-mix(in srgb, var(--color-text) 55%, transparent);margin-top:1px">{{ $a['category'] }}</span>
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
@if ($selectable)
|
||||||
|
@if ($isSelected)
|
||||||
|
<span style="flex:none;display:flex;align-items:center;gap:4px;font-size:11px;color:var(--color-accent);white-space:nowrap">
|
||||||
|
<span class="material-symbols-outlined" style="font-size:16px">check_circle</span>
|
||||||
|
Powiązano
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<button type="button" class="btn btn-secondary" style="flex:none;font-size:11px;padding:4px 8px;white-space:nowrap" wire:click="{{ $selectAction }}({{ $a['id'] }})">Powiąż</button>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@if ($card)
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
@@ -16,10 +16,10 @@
|
|||||||
style="position:relative;display:inline-block"
|
style="position:relative;display:inline-block"
|
||||||
>
|
>
|
||||||
<button type="button" class="btn btn-secondary btn-icon" @click="open = !open">
|
<button type="button" class="btn btn-secondary btn-icon" @click="open = !open">
|
||||||
<span class="material-symbols-outlined" x-text="icon()"></span>
|
<span class="material-symbols-outlined" x-text="typeof icon === 'function' ? icon() : ''"></span>
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
x-show="open"
|
x-show="typeof open !== 'undefined' && open"
|
||||||
x-cloak
|
x-cloak
|
||||||
class="nav-dropdown"
|
class="nav-dropdown"
|
||||||
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:150px;overflow:hidden;z-index:20"
|
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:150px;overflow:hidden;z-index:20"
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
@php
|
@props(['area' => null])
|
||||||
$panelLabel = match (true) {
|
|
||||||
request()->routeIs('client.*') => 'Panel Klienta',
|
|
||||||
request()->routeIs('operator.*') => 'Panel Operatora',
|
|
||||||
request()->routeIs('admin.*') => 'Panel Administratora',
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div class="nav" style="position:relative;padding:16px 28px;border-bottom:1px solid var(--color-divider)">
|
<div class="nav" style="position:relative;padding:16px 28px;border-bottom:1px solid var(--color-divider)">
|
||||||
<span class="nav-brand">{{ \App\Support\Settings::get('company_name') }}</span>
|
<span class="nav-brand">{{ \App\Support\Settings::get('company_name') }}</span>
|
||||||
|
|
||||||
@if ($panelLabel)
|
<x-panel-switcher :area="$area" />
|
||||||
<span class="nav-panel-label" style="position:absolute;left:50%;top:50%;transform:translate(-50%, -50%);font-weight:500;font-size:13.5px;white-space:nowrap">{{ $panelLabel }}</span>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<x-theme-toggle />
|
<x-theme-toggle />
|
||||||
|
|
||||||
@@ -20,6 +11,7 @@
|
|||||||
|
|
||||||
@auth
|
@auth
|
||||||
<livewire:notification-bell />
|
<livewire:notification-bell />
|
||||||
|
<livewire:global-search />
|
||||||
@endauth
|
@endauth
|
||||||
|
|
||||||
<x-profile-menu />
|
<x-profile-menu />
|
||||||
|
|||||||
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>
|
||||||
182
src/resources/views/livewire/admin/mail-settings.blade.php
Normal file
182
src/resources/views/livewire/admin/mail-settings.blade.php
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<div>
|
||||||
|
<h3 style="margin:0 0 14px">E-mail (SMTP)</h3>
|
||||||
|
<form wire:submit="saveMailConfig" class="card" style="padding:20px;gap:14px;max-width:480px;margin-bottom:32px">
|
||||||
|
<div class="field"><label>Adres nadawcy</label><input class="input" type="email" placeholder="wsparcie@firma.pl" wire:model="mailConfig.fromAddress"></div>
|
||||||
|
<div class="field"><label>Nazwa nadawcy</label><input class="input" placeholder="Zespół Wsparcia" wire:model="mailConfig.fromName"></div>
|
||||||
|
|
||||||
|
<div class="hr"></div>
|
||||||
|
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="mailConfig.smtpEnabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz wysyłkę przez własny serwer SMTP</strong></label>
|
||||||
|
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Bez włączenia aplikacja wysyła pocztę zgodnie z konfiguracją środowiska (.env).</span>
|
||||||
|
|
||||||
|
@if ($mailConfig['smtpEnabled'])
|
||||||
|
<div class="field"><label>Host SMTP</label><input class="input" placeholder="smtp.example.com" wire:model="mailConfig.smtpHost"></div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" placeholder="587" wire:model="mailConfig.smtpPort"></div>
|
||||||
|
<div class="field" style="flex:1">
|
||||||
|
<label>Szyfrowanie</label>
|
||||||
|
<select class="input" wire:model="mailConfig.smtpEncryption">
|
||||||
|
<option value="none">Brak</option>
|
||||||
|
<option value="tls">STARTTLS</option>
|
||||||
|
<option value="ssl">SSL/TLS</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Użytkownik</label><input class="input" wire:model="mailConfig.smtpUsername"></div>
|
||||||
|
<div class="field"><label>Hasło</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="mailConfig.smtpPassword"></div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
|
||||||
|
<button type="button" class="btn btn-secondary" wire:click="testMailConnection">Wyślij testową wiadomość</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||||
|
@if ($mailTestResult === 'ok')
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Wysłano na Twój adres</div>
|
||||||
|
@elseif ($mailTestResult === 'error')
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd wysyłki</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
|
||||||
|
@endif
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
||||||
|
<h3 style="margin:0">Skrzynki IMAP (zgłoszenia i odpowiedzi przez e-mail)</h3>
|
||||||
|
<button class="btn btn-primary" type="button" wire:click="openMailboxForm">+ Nowa skrzynka</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">
|
||||||
|
Każda skrzynka jest sprawdzana co kilka minut — nowa wiadomość zakłada zgłoszenie w wybranej podkategorii (np. zgloszenia-it@firma.pl → IT), a odpowiedź na powiadomienie e-mail (temat zawiera numer zgłoszenia) trafia jako odpowiedź do istniejącego zgłoszenia. Automatyczne odpowiedzi (autorespondery, „poza biurem”, bounce) są odrzucane.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if ($this->mailboxes->isNotEmpty())
|
||||||
|
<div class="table-wrap" style="margin-bottom:20px">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nazwa</th>
|
||||||
|
<th>Serwer</th>
|
||||||
|
<th>Użytkownik</th>
|
||||||
|
<th>Kategoria / podkategoria</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach ($this->mailboxes as $mailbox)
|
||||||
|
<tr>
|
||||||
|
<td>{{ $mailbox->name }}</td>
|
||||||
|
<td class="text-muted" style="white-space:nowrap">{{ $mailbox->host }}:{{ $mailbox->port }}</td>
|
||||||
|
<td class="text-muted">{{ $mailbox->username }}</td>
|
||||||
|
<td class="text-muted">{{ $mailbox->targetLabel() }}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="tag" style="border:none;cursor:pointer;background:color-mix(in srgb, var(--color-{{ $mailbox->enabled ? 'success' : 'danger' }}) 18%, transparent);color:var(--color-{{ $mailbox->enabled ? 'success' : 'danger' }})"
|
||||||
|
wire:click="toggleMailboxEnabled({{ $mailbox->id }})">
|
||||||
|
{{ $mailbox->enabled ? 'Włączona' : 'Wyłączona' }}
|
||||||
|
</button>
|
||||||
|
@if ($mailbox->last_error)
|
||||||
|
<div style="color:var(--color-danger);font-size:11px;margin-top:4px">{{ $mailbox->last_error }}</div>
|
||||||
|
@elseif ($mailbox->last_checked_at)
|
||||||
|
<div class="text-muted" style="font-size:11px;margin-top:4px">Sprawdzono: {{ $mailbox->last_checked_at->format('Y-m-d H:i') }}</div>
|
||||||
|
@endif
|
||||||
|
@if ($mailboxFetchResultId === $mailbox->id)
|
||||||
|
<div class="text-muted" style="font-size:11px;margin-top:4px">{{ $mailboxFetchSummary }}</div>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style="display:flex;gap:6px;justify-content:flex-end">
|
||||||
|
<button class="btn btn-ghost" type="button" wire:click="fetchMailboxNow({{ $mailbox->id }})" wire:loading.attr="disabled" wire:target="fetchMailboxNow({{ $mailbox->id }})">Pobierz teraz</button>
|
||||||
|
<button class="btn btn-ghost" type="button" wire:click="editMailbox({{ $mailbox->id }})">Edytuj</button>
|
||||||
|
<button class="btn btn-ghost" type="button" wire:click="removeMailbox({{ $mailbox->id }})" wire:confirm="Usunąć tę skrzynkę IMAP?">Usuń</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<p class="text-muted" style="font-size:13px">Brak skonfigurowanych skrzynek IMAP. Dodaj pierwszą używając przycisku wyżej.</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($mailboxFormOpen)
|
||||||
|
<div class="dialog-backdrop">
|
||||||
|
<form wire:submit="submitMailboxForm" class="dialog" style="max-width:520px;max-height:90vh;overflow-y:auto">
|
||||||
|
<div class="dialog-title">{{ $mailboxForm['id'] ? 'Edytuj skrzynkę IMAP' : 'Nowa skrzynka IMAP' }}</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Nazwa (etykieta)</label>
|
||||||
|
<input class="input" placeholder="np. Zgłoszenia IT" wire:model="mailboxForm.name">
|
||||||
|
</div>
|
||||||
|
@error('mailboxForm.name') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||||
|
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="mailboxForm.enabled" style="position:static;opacity:1;width:auto;height:auto">Włączona</label>
|
||||||
|
|
||||||
|
<div class="hr"></div>
|
||||||
|
|
||||||
|
<div class="field"><label>Host IMAP</label><input class="input" placeholder="imap.firma.pl" wire:model="mailboxForm.host"></div>
|
||||||
|
@error('mailboxForm.host') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||||
|
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" wire:model="mailboxForm.port"></div>
|
||||||
|
<div class="field" style="flex:1">
|
||||||
|
<label>Szyfrowanie</label>
|
||||||
|
<select class="input" wire:model="mailboxForm.encryption">
|
||||||
|
<option value="ssl">SSL/TLS</option>
|
||||||
|
<option value="tls">STARTTLS</option>
|
||||||
|
<option value="none">Brak</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="mailboxForm.validateCert" style="position:static;opacity:1;width:auto;height:auto">Weryfikuj certyfikat TLS</label>
|
||||||
|
|
||||||
|
<div class="field"><label>Adres skrzynki (login)</label><input class="input" placeholder="zgloszenia-it@firma.pl" wire:model="mailboxForm.username"></div>
|
||||||
|
@error('mailboxForm.username') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||||
|
<div class="field"><label>Hasło</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="mailboxForm.password"></div>
|
||||||
|
|
||||||
|
<div class="hr"></div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Kategoria / podkategoria nowych zgłoszeń</label>
|
||||||
|
<select class="input" wire:model="mailboxForm.target">
|
||||||
|
<option value="">Brak (zgłoszenie nieprzypisane)</option>
|
||||||
|
@foreach ($this->categoryOptions as $category)
|
||||||
|
<optgroup label="{{ $category['name'] }}">
|
||||||
|
<option value="category:{{ $category['id'] }}">Cała kategoria: {{ $category['name'] }}</option>
|
||||||
|
@foreach ($category['subcategories'] as $sub)
|
||||||
|
<option value="subcategory:{{ $sub['id'] }}">{{ $sub['name'] }}</option>
|
||||||
|
@endforeach
|
||||||
|
</optgroup>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<span class="text-muted" style="font-size:11.5px">Wybierz konkretną podkategorię (trafi też do jej zespołu) albo całą kategorię, jeśli nie chcesz przypisywać konkretnej podkategorii.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field"><label>Folder</label><input class="input" wire:model="mailboxForm.folder"></div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<div class="field" style="flex:1"><label>Folder po przetworzeniu (opcjonalnie)</label><input class="input" placeholder="pozostaw puste = oznacz jako przeczytane" wire:model="mailboxForm.processedFolder"></div>
|
||||||
|
<div class="field" style="flex:1"><label>Folder odrzuconych (opcjonalnie)</label><input class="input" placeholder="pozostaw puste = oznacz jako przeczytane" wire:model="mailboxForm.rejectedFolder"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Zablokowani nadawcy (dodatkowo do filtrów autoresponderów)</label>
|
||||||
|
<input class="input" wire:model="mailboxForm.blocklistSenders">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:4px">
|
||||||
|
<button type="button" class="btn btn-secondary" wire:click="testMailboxConnection">Testuj połączenie</button>
|
||||||
|
@if ($mailboxTestResult === 'ok')
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Połączono</div>
|
||||||
|
@elseif ($mailboxTestResult === 'error')
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger);font-size:12.5px"><span class="material-symbols-outlined" style="font-size:18px">error</span>{{ $mailboxTestMessage }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button class="btn btn-secondary" type="button" wire:click="closeMailboxForm">Anuluj</button>
|
||||||
|
<button class="btn btn-primary" type="submit">Zapisz</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@@ -17,20 +17,26 @@ $tabGroups = [
|
|||||||
],
|
],
|
||||||
'Ustawienia' => [
|
'Ustawienia' => [
|
||||||
['key' => 'templates', 'label' => 'Szablony e-mail', 'icon' => 'mail'],
|
['key' => 'templates', 'label' => 'Szablony e-mail', 'icon' => 'mail'],
|
||||||
['key' => 'email', 'label' => 'E-MAIL', 'icon' => 'forward_to_inbox'],
|
['key' => 'email', 'label' => 'Poczta', 'icon' => 'forward_to_inbox'],
|
||||||
['key' => 'branding', 'label' => 'Wygląd i branding', 'icon' => 'palette'],
|
['key' => 'branding', 'label' => 'Wygląd i branding', 'icon' => 'palette'],
|
||||||
['key' => 'config', 'label' => 'Konfiguracja', 'icon' => 'settings'],
|
['key' => 'config', 'label' => 'Konfiguracja', 'icon' => 'settings'],
|
||||||
['key' => 'integrations', 'label' => 'Integracje', 'icon' => 'hub'],
|
['key' => 'integrations', 'label' => 'Integracje', 'icon' => 'hub'],
|
||||||
['key' => 'api-keys', 'label' => 'Klucze API', 'icon' => 'vpn_key'],
|
['key' => 'api-keys', 'label' => 'Klucze API', 'icon' => 'vpn_key'],
|
||||||
|
['key' => 'logs', 'label' => 'Logi', 'icon' => 'terminal'],
|
||||||
['key' => 'about', 'label' => 'O aplikacji', 'icon' => 'info'],
|
['key' => 'about', 'label' => 'O aplikacji', 'icon' => 'info'],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
@endphp
|
@endphp
|
||||||
<div style="flex:1;display:flex;flex-direction:column">
|
<div style="flex:1;display:flex;flex-direction:column">
|
||||||
<x-topbar />
|
<x-topbar area="admin" />
|
||||||
|
|
||||||
<div class="app-split" style="flex:1;display:flex;min-height:0">
|
<div class="app-split" style="flex:1;display:flex;min-height:0">
|
||||||
<div class="side-rail" style="padding:16px 10px;gap:16px;overflow:auto;background:color-mix(in srgb, var(--color-text) 5%, var(--color-bg))">
|
<div class="side-rail" style="padding:16px 10px;gap:16px;overflow:auto;background:color-mix(in srgb, var(--color-text) 5%, var(--color-bg))">
|
||||||
|
<button type="button" @click="window.dispatchEvent(new CustomEvent('open-global-search'))" style="display:flex;align-items:center;gap:10px;width:100%;padding:8px 12px;border-radius:6px;border:none;cursor:pointer;font-size:13.5px;text-align:left;white-space:nowrap;background:transparent;color:var(--color-text)">
|
||||||
|
<span class="material-symbols-outlined" style="font-size:18px">search</span>
|
||||||
|
<span>Szukaj</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
@foreach ($tabGroups as $groupLabel => $items)
|
@foreach ($tabGroups as $groupLabel => $items)
|
||||||
<div style="display:flex;flex-direction:column;gap:1px">
|
<div style="display:flex;flex-direction:column;gap:1px">
|
||||||
<div style="font-size:10px;letter-spacing:0.09em;text-transform:uppercase;color:color-mix(in srgb, var(--color-text) 45%, transparent);padding:4px 12px 6px;white-space:nowrap">{{ $groupLabel }}</div>
|
<div style="font-size:10px;letter-spacing:0.09em;text-transform:uppercase;color:color-mix(in srgb, var(--color-text) 45%, transparent);padding:4px 12px 6px;white-space:nowrap">{{ $groupLabel }}</div>
|
||||||
@@ -85,7 +91,7 @@ $tabGroups = [
|
|||||||
<table class="table" style="margin:0;border-top:none">
|
<table class="table" style="margin:0;border-top:none">
|
||||||
<thead><tr><th>Podkategoria</th><th></th></tr></thead>
|
<thead><tr><th>Podkategoria</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach ($cat->subcategories as $sub)
|
@foreach ($cat->subcategories as $i => $sub)
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div style="display:flex;flex-direction:column;gap:2px">
|
<div style="display:flex;flex-direction:column;gap:2px">
|
||||||
@@ -97,7 +103,9 @@ $tabGroups = [
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div style="display:flex;gap:6px;justify-content:flex-end">
|
<div style="display:flex;gap:6px;justify-content:flex-end;align-items:center">
|
||||||
|
<button class="btn btn-icon" type="button" wire:click="moveSubcategoryUp({{ $sub->id }})" @disabled($i === 0) style="padding:2px;width:24px;height:24px" title="Przesuń wyżej"><span class="material-symbols-outlined" style="font-size:16px">arrow_upward</span></button>
|
||||||
|
<button class="btn btn-icon" type="button" wire:click="moveSubcategoryDown({{ $sub->id }})" @disabled($i === $cat->subcategories->count() - 1) style="padding:2px;width:24px;height:24px" title="Przesuń niżej"><span class="material-symbols-outlined" style="font-size:16px">arrow_downward</span></button>
|
||||||
<button class="btn btn-ghost" type="button" wire:click="openSubcategoryEditForm({{ $sub->id }})">Edytuj</button>
|
<button class="btn btn-ghost" type="button" wire:click="openSubcategoryEditForm({{ $sub->id }})">Edytuj</button>
|
||||||
<button class="btn btn-ghost" type="button" wire:click="removeSubcategory({{ $sub->id }})">Usuń</button>
|
<button class="btn btn-ghost" type="button" wire:click="removeSubcategory({{ $sub->id }})">Usuń</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -483,45 +491,7 @@ $tabGroups = [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 style="margin:0 0 14px">E-mail (SMTP)</h3>
|
<livewire:admin.mail-settings />
|
||||||
<form wire:submit="saveMailConfig" class="card" style="padding:20px;gap:14px;max-width:480px">
|
|
||||||
<div class="field"><label>Adres nadawcy</label><input class="input" type="email" placeholder="wsparcie@firma.pl" wire:model="mailConfig.fromAddress"></div>
|
|
||||||
<div class="field"><label>Nazwa nadawcy</label><input class="input" placeholder="Zespół Wsparcia" wire:model="mailConfig.fromName"></div>
|
|
||||||
|
|
||||||
<div class="hr"></div>
|
|
||||||
|
|
||||||
<label class="radio"><input type="checkbox" wire:model="mailConfig.smtpEnabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz wysyłkę przez własny serwer SMTP</strong></label>
|
|
||||||
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Bez włączenia aplikacja wysyła pocztę zgodnie z konfiguracją środowiska (.env).</span>
|
|
||||||
|
|
||||||
@if ($mailConfig['smtpEnabled'])
|
|
||||||
<div class="field"><label>Host SMTP</label><input class="input" placeholder="smtp.example.com" wire:model="mailConfig.smtpHost"></div>
|
|
||||||
<div style="display:flex;gap:10px">
|
|
||||||
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" placeholder="587" wire:model="mailConfig.smtpPort"></div>
|
|
||||||
<div class="field" style="flex:1">
|
|
||||||
<label>Szyfrowanie</label>
|
|
||||||
<select class="input" wire:model="mailConfig.smtpEncryption">
|
|
||||||
<option value="none">Brak</option>
|
|
||||||
<option value="tls">STARTTLS</option>
|
|
||||||
<option value="ssl">SSL/TLS</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field"><label>Użytkownik</label><input class="input" wire:model="mailConfig.smtpUsername"></div>
|
|
||||||
<div class="field"><label>Hasło</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="mailConfig.smtpPassword"></div>
|
|
||||||
|
|
||||||
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
|
|
||||||
<button type="button" class="btn btn-secondary" wire:click="testMailConnection">Wyślij testową wiadomość</button>
|
|
||||||
<button type="submit" class="btn btn-primary">Zapisz</button>
|
|
||||||
@if ($mailTestResult === 'ok')
|
|
||||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Wysłano na Twój adres</div>
|
|
||||||
@elseif ($mailTestResult === 'error')
|
|
||||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd wysyłki</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
|
|
||||||
@endif
|
|
||||||
</form>
|
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- ================= BRANDING ================= --}}
|
{{-- ================= BRANDING ================= --}}
|
||||||
@@ -617,6 +587,21 @@ $tabGroups = [
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<label class="radio"><input type="checkbox" wire:model="systemConfig.autoAssignByCategory" style="position:static;opacity:1;width:auto;height:auto">Automatyczne przypisywanie do zespołu wg kategorii</label>
|
<label class="radio"><input type="checkbox" wire:model="systemConfig.autoAssignByCategory" style="position:static;opacity:1;width:auto;height:auto">Automatyczne przypisywanie do zespołu wg kategorii</label>
|
||||||
|
|
||||||
|
<div class="hr"></div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Prefiks numeru zgłoszenia</label>
|
||||||
|
<input class="input" maxlength="20" placeholder="#" wire:model.live="systemConfig.ticketNumberPrefix">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Minimalna długość numeru (uzupełniana zerami z przodu)</label>
|
||||||
|
<input class="input" type="number" min="1" max="10" wire:model.live="systemConfig.ticketNumberMinLength">
|
||||||
|
</div>
|
||||||
|
<label class="radio"><input type="checkbox" wire:model.live="systemConfig.ticketNumberObfuscate" style="position:static;opacity:1;width:auto;height:auto">Ukryj kolejność zgłoszeń (numer wyświetlany jako suma kontrolna zamiast kolejnego numeru)</label>
|
||||||
|
<div class="text-muted" style="font-size:12px">
|
||||||
|
ID z bazy: {{ $this->ticketNumberPreview['id'] }} → podgląd numeru: {{ $this->ticketNumberPreview['formatted'] }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="padding:20px;gap:14px">
|
<div class="card" style="padding:20px;gap:14px">
|
||||||
@@ -636,9 +621,12 @@ $tabGroups = [
|
|||||||
<h4 style="margin:0">Sesja i strefa czasowa</h4>
|
<h4 style="margin:0">Sesja i strefa czasowa</h4>
|
||||||
<div class="field"><label>Czas wygaśnięcia sesji (minuty)</label><input class="input" type="number" wire:model="systemConfig.sessionLifetimeMinutes"></div>
|
<div class="field"><label>Czas wygaśnięcia sesji (minuty)</label><input class="input" type="number" wire:model="systemConfig.sessionLifetimeMinutes"></div>
|
||||||
<div class="field"
|
<div class="field"
|
||||||
|
wire:key="admin-timezone-clock"
|
||||||
x-data="{
|
x-data="{
|
||||||
tz: @js($systemConfig['timezone']),
|
tz: @js($systemConfig['timezone']),
|
||||||
now: '',
|
now: '',
|
||||||
|
tickHandle: null,
|
||||||
|
navigatingHandler: null,
|
||||||
tick() {
|
tick() {
|
||||||
try {
|
try {
|
||||||
this.now = new Intl.DateTimeFormat('pl-PL', { timeZone: this.tz, dateStyle: 'medium', timeStyle: 'medium' }).format(new Date());
|
this.now = new Intl.DateTimeFormat('pl-PL', { timeZone: this.tz, dateStyle: 'medium', timeStyle: 'medium' }).format(new Date());
|
||||||
@@ -646,8 +634,13 @@ $tabGroups = [
|
|||||||
this.now = '—';
|
this.now = '—';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}"
|
init() {
|
||||||
x-init="tick(); setInterval(() => tick(), 1000)">
|
this.tick();
|
||||||
|
this.tickHandle = setInterval(() => this.tick(), 1000);
|
||||||
|
this.navigatingHandler = () => clearInterval(this.tickHandle);
|
||||||
|
document.addEventListener('livewire:navigating', this.navigatingHandler);
|
||||||
|
},
|
||||||
|
}">
|
||||||
<label>Strefa czasowa</label>
|
<label>Strefa czasowa</label>
|
||||||
<select class="input" wire:model="systemConfig.timezone" x-on:change="tz = $event.target.value; tick()">
|
<select class="input" wire:model="systemConfig.timezone" x-on:change="tz = $event.target.value; tick()">
|
||||||
@foreach (\DateTimeZone::listIdentifiers() as $tzId)
|
@foreach (\DateTimeZone::listIdentifiers() as $tzId)
|
||||||
@@ -656,11 +649,36 @@ $tabGroups = [
|
|||||||
</select>
|
</select>
|
||||||
<div style="display:flex;gap:4px;font-size:12px">
|
<div style="display:flex;gap:4px;font-size:12px">
|
||||||
<span class="text-muted">Aktualny czas:</span>
|
<span class="text-muted">Aktualny czas:</span>
|
||||||
<strong x-text="now"></strong>
|
<strong x-text="typeof now !== 'undefined' ? now : ''"></strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="padding:20px;gap:14px">
|
||||||
|
<h4 style="margin:0">Częstotliwość odświeżania i harmonogramu</h4>
|
||||||
|
|
||||||
|
<div class="card-kicker">Odświeżanie w przeglądarce</div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<div class="field" style="flex:1"><label>Widok zgłoszenia (klient i operator)</label><input class="input" type="number" min="1" wire:model="systemConfig.refreshTicketViewSeconds"></div>
|
||||||
|
<div class="field" style="flex:1"><label>Lista zgłoszeń operatora</label><input class="input" type="number" min="1" wire:model="systemConfig.refreshQueueSeconds"></div>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Dzwonek powiadomień</label><input class="input" type="number" min="1" wire:model="systemConfig.refreshNotificationsSeconds"></div>
|
||||||
|
<p class="text-muted" style="font-size:12px;margin:0">Sekundy między automatycznymi odświeżeniami w przeglądarce (niezależnie od odświeżenia na żądanie po kliknięciu w licznik).</p>
|
||||||
|
|
||||||
|
<div class="hr"></div>
|
||||||
|
|
||||||
|
<div class="card-kicker">Zadania w tle</div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<div class="field" style="flex:1"><label>Sprawdzanie naruszeń SLA</label><input class="input" type="number" min="1" wire:model="systemConfig.scheduleSlaCheckMinutes"></div>
|
||||||
|
<div class="field" style="flex:1"><label>Reguły automatyzacji</label><input class="input" type="number" min="1" wire:model="systemConfig.scheduleAutomationRulesMinutes"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<div class="field" style="flex:1"><label>Pobieranie e-maili (IMAP)</label><input class="input" type="number" min="1" wire:model="systemConfig.scheduleImapFetchMinutes"></div>
|
||||||
|
<div class="field" style="flex:1"><label>Automatyczna kategoryzacja i podsumowania AI</label><input class="input" type="number" min="1" wire:model="systemConfig.scheduleAiAutomationMinutes"></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted" style="font-size:12px;margin:0">Minuty między uruchomieniami zadań w tle. Zmiana obowiązuje od najbliższego uruchomienia harmonogramu (co minutę), bez potrzeby restartu.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="grid-column:1/-1;display:flex">
|
<div style="grid-column:1/-1;display:flex">
|
||||||
<button type="submit" class="btn btn-primary">Zapisz</button>
|
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -680,6 +698,13 @@ $tabGroups = [
|
|||||||
<label class="radio"><input type="checkbox" wire:model="ldapConfig.enabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz autentykację LDAP/AD</strong></label>
|
<label class="radio"><input type="checkbox" wire:model="ldapConfig.enabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz autentykację LDAP/AD</strong></label>
|
||||||
|
|
||||||
@if ($ldapConfig['enabled'])
|
@if ($ldapConfig['enabled'])
|
||||||
|
<div class="field">
|
||||||
|
<label>Typ katalogu</label>
|
||||||
|
<select class="input" wire:model="ldapConfig.directoryType">
|
||||||
|
<option value="lldap">LLDAP / OpenLDAP</option>
|
||||||
|
<option value="ad">Active Directory</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="field"><label>Host serwera LDAP</label><input class="input" placeholder="ldap.example.com" wire:model="ldapConfig.host"></div>
|
<div class="field"><label>Host serwera LDAP</label><input class="input" placeholder="ldap.example.com" wire:model="ldapConfig.host"></div>
|
||||||
<div style="display:flex;gap:10px">
|
<div style="display:flex;gap:10px">
|
||||||
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" placeholder="389" wire:model="ldapConfig.port"></div>
|
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" placeholder="389" wire:model="ldapConfig.port"></div>
|
||||||
@@ -688,7 +713,13 @@ $tabGroups = [
|
|||||||
<div class="field"><label>Base DN</label><input class="input" placeholder="dc=example,dc=com" wire:model="ldapConfig.baseDn"></div>
|
<div class="field"><label>Base DN</label><input class="input" placeholder="dc=example,dc=com" wire:model="ldapConfig.baseDn"></div>
|
||||||
<div class="field"><label>Bind DN</label><input class="input" placeholder="cn=admin,dc=example,dc=com" wire:model="ldapConfig.bindDn"></div>
|
<div class="field"><label>Bind DN</label><input class="input" placeholder="cn=admin,dc=example,dc=com" wire:model="ldapConfig.bindDn"></div>
|
||||||
<div class="field"><label>Hasło Bind</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="ldapConfig.bindPassword"></div>
|
<div class="field"><label>Hasło Bind</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="ldapConfig.bindPassword"></div>
|
||||||
<div class="field"><label>User Filter</label><input class="input" placeholder="(uid={0})" wire:model="ldapConfig.userFilter"></div>
|
<div class="field">
|
||||||
|
<label>User Filter</label>
|
||||||
|
<input class="input" placeholder="{{ $ldapConfig['directoryType'] === 'ad' ? '(sAMAccountName={0})' : '(uid={0})' }}" wire:model="ldapConfig.userFilter">
|
||||||
|
@if ($ldapConfig['directoryType'] === 'ad')
|
||||||
|
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Active Directory nie ma atrybutu "uid" — puste pole domyślnie użyje sAMAccountName.</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
<label class="radio"><input type="checkbox" wire:model="ldapConfig.autoProvisionGuests" style="position:static;opacity:1;width:auto;height:auto">Automatycznie zakładaj konto dla gościa zgłaszającego, jeśli jego e-mail istnieje w LDAP</label>
|
<label class="radio"><input type="checkbox" wire:model="ldapConfig.autoProvisionGuests" style="position:static;opacity:1;width:auto;height:auto">Automatycznie zakładaj konto dla gościa zgłaszającego, jeśli jego e-mail istnieje w LDAP</label>
|
||||||
<label class="radio"><input type="checkbox" wire:model="ldapConfig.restrictUserCreationToLdap" style="position:static;opacity:1;width:auto;height:auto">Zezwalaj na ręczne zapraszanie użytkowników tylko, jeśli ich e-mail istnieje w LDAP</label>
|
<label class="radio"><input type="checkbox" wire:model="ldapConfig.restrictUserCreationToLdap" style="position:static;opacity:1;width:auto;height:auto">Zezwalaj na ręczne zapraszanie użytkowników tylko, jeśli ich e-mail istnieje w LDAP</label>
|
||||||
<label class="radio"><input type="checkbox" wire:model="ldapConfig.restrictTicketsToLdap" style="position:static;opacity:1;width:auto;height:auto">Zezwalaj na tworzenie zgłoszeń bez logowania tylko dla adresów e-mail istniejących w LDAP</label>
|
<label class="radio"><input type="checkbox" wire:model="ldapConfig.restrictTicketsToLdap" style="position:static;opacity:1;width:auto;height:auto">Zezwalaj na tworzenie zgłoszeń bez logowania tylko dla adresów e-mail istniejących w LDAP</label>
|
||||||
@@ -718,12 +749,35 @@ $tabGroups = [
|
|||||||
<div class="field"><label>Token Secret</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="bookstackConfig.tokenSecret"></div>
|
<div class="field"><label>Token Secret</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="bookstackConfig.tokenSecret"></div>
|
||||||
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Token API generuje się w BookStack: Profil → Ustawienia API. Użytkownik/rola właściciela tokenu musi mieć uprawnienie „Access System API”.</p>
|
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Token API generuje się w BookStack: Profil → Ustawienia API. Użytkownik/rola właściciela tokenu musi mieć uprawnienie „Access System API”.</p>
|
||||||
|
|
||||||
<div class="field"><label>Przeszukuj</label>
|
<div class="field">
|
||||||
<select class="input" style="width:auto" wire:model="bookstackConfig.searchTypes">
|
<label>Przeszukuj</label>
|
||||||
<option value="both">Strony i książki</option>
|
<div style="display:flex;gap:16px;flex-wrap:wrap">
|
||||||
<option value="page">Tylko strony</option>
|
<label style="display:flex;align-items:center;gap:6px;font-size:13px;font-weight:400">
|
||||||
<option value="book">Tylko książki</option>
|
<input type="checkbox" style="position:static;opacity:1;width:auto;height:auto" @checked(in_array('book', $bookstackConfig['searchTypes'])) wire:click="toggleBookstackSearchType('book')">
|
||||||
|
Książki
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;font-size:13px;font-weight:400">
|
||||||
|
<input type="checkbox" style="position:static;opacity:1;width:auto;height:auto" @checked(in_array('page', $bookstackConfig['searchTypes'])) wire:click="toggleBookstackSearchType('page')">
|
||||||
|
Strony
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;font-size:13px;font-weight:400">
|
||||||
|
<input type="checkbox" style="position:static;opacity:1;width:auto;height:auto" @checked(in_array('chapter', $bookstackConfig['searchTypes'])) wire:click="toggleBookstackSearchType('chapter')">
|
||||||
|
Rozdziały
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
@if (empty($bookstackConfig['searchTypes']))
|
||||||
|
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Nic nie zaznaczono — przy zapisie zostaną użyte wszystkie typy.</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Szukaj po</label>
|
||||||
|
<select class="input" style="width:auto" wire:model="bookstackConfig.searchBy">
|
||||||
|
<option value="both">Słowa kluczowe w nazwie lub tagi</option>
|
||||||
|
<option value="name">Tylko słowa kluczowe w nazwie</option>
|
||||||
|
<option value="tags">Tylko tagi</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">„Tagi” dopasowują artykuły oznaczone w BookStack tagiem o nazwie zgodnej z kategorią/podkategorią zgłoszenia (np. tag „Drukarki” dla podkategorii „Drukarki”).</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;justify-content:flex-end">
|
<div style="display:flex;justify-content:flex-end">
|
||||||
@@ -773,6 +827,26 @@ $tabGroups = [
|
|||||||
<label class="radio"><input type="checkbox" wire:model="bookstackConfig.verifySsl" style="position:static;opacity:1;width:auto;height:auto">Weryfikuj certyfikat SSL instancji BookStack</label>
|
<label class="radio"><input type="checkbox" wire:model="bookstackConfig.verifySsl" style="position:static;opacity:1;width:auto;height:auto">Weryfikuj certyfikat SSL instancji BookStack</label>
|
||||||
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Wyłącz tylko jeśli instancja BookStack korzysta z certyfikatu self-signed / z prywatnego CA.</span>
|
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Wyłącz tylko jeśli instancja BookStack korzysta z certyfikatu self-signed / z prywatnego CA.</span>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Automatyczne tagowanie treści (AI)</label>
|
||||||
|
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Używa integracji AI (skonfigurowanej w karcie „Integracja AI” obok) do otagowania książek/stron/rozdziałów nazwami pasujących podkategorii helpdesku — bez tego wyszukiwanie „po tagach” nic nie znajdzie. „Otaguj nową treść” pomija już otagowane pozycje; „Otaguj wszystko ponownie” klasyfikuje od nowa całą wiki (dłużej, więcej zapytań do AI).</p>
|
||||||
|
<div style="display:flex;gap:10px;margin-top:6px;align-items:center;flex-wrap:wrap">
|
||||||
|
<button type="button" class="btn btn-secondary" wire:click="runBookstackTagging" wire:loading.attr="disabled" wire:target="runBookstackTagging,runBookstackTaggingForce">
|
||||||
|
<span class="material-symbols-outlined" style="font-size:16px;vertical-align:middle" wire:loading.class="spin" wire:target="runBookstackTagging">sell</span>
|
||||||
|
Otaguj nową treść
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary" wire:click="runBookstackTaggingForce" wire:loading.attr="disabled" wire:target="runBookstackTagging,runBookstackTaggingForce">
|
||||||
|
<span class="material-symbols-outlined" style="font-size:16px;vertical-align:middle" wire:loading.class="spin" wire:target="runBookstackTaggingForce">refresh</span>
|
||||||
|
Otaguj wszystko ponownie (force)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if ($bookstackTagResult !== null)
|
||||||
|
<div class="text-muted" style="font-size:11.5px;margin-top:6px">Przeskanowano {{ $bookstackTagResult['scanned'] }}, otagowano {{ $bookstackTagResult['tagged'] }}, pominięto {{ $bookstackTagResult['skipped'] }}, nieudanych paczek {{ $bookstackTagResult['failed_batches'] }}.</div>
|
||||||
|
@elseif ($bookstackTagError)
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger);font-size:11.5px;margin-top:6px"><span class="material-symbols-outlined" style="font-size:16px">error</span>{{ $bookstackTagError }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
|
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
|
||||||
<button type="button" class="btn btn-secondary" wire:click="testBookstackConnection">Testuj połączenie</button>
|
<button type="button" class="btn btn-secondary" wire:click="testBookstackConnection">Testuj połączenie</button>
|
||||||
<button type="submit" class="btn btn-primary">Zapisz</button>
|
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||||
@@ -787,6 +861,139 @@ $tabGroups = [
|
|||||||
@endif
|
@endif
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<form wire:submit="saveSnipeitConfig" class="card" style="padding:20px;gap:14px">
|
||||||
|
<h4 style="margin:0">Snipe-IT (ewidencja sprzętu)</h4>
|
||||||
|
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Pokazuje sprzęt przypisany do zgłaszającego przy tworzeniu i przeglądaniu zgłoszenia oraz pozwala powiązać zgłoszenie z konkretnym urządzeniem z ewidencji Snipe-IT.</span>
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="snipeitConfig.enabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz integrację z Snipe-IT</strong></label>
|
||||||
|
|
||||||
|
@if ($snipeitConfig['enabled'])
|
||||||
|
<div class="field"><label>Adres API</label><input class="input" placeholder="https://assets.firma.pl" wire:model="snipeitConfig.baseUrl"></div>
|
||||||
|
<div class="field"><label>Klucz API</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="snipeitConfig.apiToken"></div>
|
||||||
|
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Osobisty token API generuje się w Snipe-IT: profil użytkownika → „Create New Token”.</p>
|
||||||
|
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="snipeitConfig.skipSslVerification" style="position:static;opacity:1;width:auto;height:auto">Nie sprawdzaj SSL</label>
|
||||||
|
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Zaznacz tylko, jeśli instancja Snipe-IT korzysta z certyfikatu self-signed / z prywatnego CA.</span>
|
||||||
|
|
||||||
|
<div style="border-top:1px solid var(--color-divider);margin:4px 0"></div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Klient</label>
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="snipeitConfig.clientCanSelectAsset" style="position:static;opacity:1;width:auto;height:auto">Klient może wybrać sprzęt, którego dotyczy zgłoszenie</label>
|
||||||
|
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Przy tworzeniu zgłoszenia klient zobaczy listę swojego sprzętu z Snipe-IT (dopasowanego po adresie e-mail) i będzie mógł je powiązać ze zgłoszeniem.</p>
|
||||||
|
</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
|
||||||
|
:options="$this->subcategoriesForTeamForm"
|
||||||
|
:selected-ids="$snipeitConfig['clientAssetSubcategoryIds']"
|
||||||
|
toggle-action="toggleSnipeitClientSubcategory"
|
||||||
|
placeholder="Brak wybranych podkategorii"
|
||||||
|
/>
|
||||||
|
<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
|
||||||
|
|
||||||
|
<div style="border-top:1px solid var(--color-divider);margin:4px 0"></div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Operator</label>
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="snipeitConfig.operatorViewRequesterAssets" style="position:static;opacity:1;width:auto;height:auto">Operator może zobaczyć sprzęt zgłaszającego w widoku zgłoszenia</label>
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="snipeitConfig.operatorSearchInventory" style="position:static;opacity:1;width:auto;height:auto">Zezwól operatorowi na przeszukiwanie całego inwentarza (nie tylko sprzętu zgłaszającego)</label>
|
||||||
|
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Obie funkcje pojawiają się w bocznym panelu widoku zgłoszenia operatora — przeszukiwanie inwentarza jako pole wyszukiwania z przyciskiem „Szukaj”, nie osobna podstrona. Odpięcie już powiązanego urządzenia jest zawsze dostępne dla operatora, niezależnie od tych dwóch ustawień.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
|
||||||
|
<button type="button" class="btn btn-secondary" wire:click="testSnipeitConnection">Testuj połączenie</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||||
|
@if ($snipeitTestResult === 'ok')
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Połączenie OK</div>
|
||||||
|
@elseif ($snipeitTestResult === 'error')
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd połączenia{{ $snipeitTestMessage ? ': '.$snipeitTestMessage : '' }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
|
||||||
|
@endif
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form wire:submit="saveAiConfig" class="card" style="padding:20px;gap:14px">
|
||||||
|
<h4 style="margin:0">Integracja AI</h4>
|
||||||
|
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Ogólne połączenie z dostawcą modelu językowego (API kompatybilne z OpenAI — Groq, OpenAI, lokalny Ollama itp.), wykorzystywane m.in. do automatycznego tagowania treści w BookStack.</span>
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="aiConfig.enabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz integrację AI</strong></label>
|
||||||
|
|
||||||
|
@if ($aiConfig['enabled'])
|
||||||
|
<div class="field"><label>Adres API (Base URL)</label><input class="input" placeholder="https://api.groq.com/openai/v1" wire:model="aiConfig.baseUrl"></div>
|
||||||
|
<div class="field"><label>Klucz API</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="aiConfig.apiKey"></div>
|
||||||
|
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Zostaw puste dla lokalnych instancji bez autoryzacji (np. Ollama).</p>
|
||||||
|
<div class="field"><label>Model</label><input class="input" placeholder="np. llama-3.3-70b-versatile" wire:model="aiConfig.model"></div>
|
||||||
|
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="aiConfig.verifySsl" style="position:static;opacity:1;width:auto;height:auto">Weryfikuj certyfikat SSL</label>
|
||||||
|
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Wyłącz tylko jeśli instancja (np. lokalny Ollama) korzysta z certyfikatu self-signed / z prywatnego CA.</span>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
|
||||||
|
<button type="button" class="btn btn-secondary" wire:click="testAiConnection">Testuj połączenie</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||||
|
@if ($aiTestResult === 'ok')
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Połączenie OK</div>
|
||||||
|
@elseif ($aiTestResult === 'error')
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd połączenia{{ $aiTestMessage ? ': '.$aiTestMessage : '' }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
|
||||||
|
@endif
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form wire:submit="saveAiTriageConfig" class="card" style="padding:20px;gap:14px">
|
||||||
|
<h4 style="margin:0">Automatyzacja AI dla zgłoszeń</h4>
|
||||||
|
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Wymaga włączonej integracji AI (karta obok). Zgłoszenia są przetwarzane w tle, cyklicznie co kilka minut — nie spowalnia to tworzenia zgłoszenia przez klienta.</span>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Automatyczna kategoryzacja nowych zgłoszeń</label>
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.categoryWhenMissing" style="position:static;opacity:1;width:auto;height:auto">Przypisz kategorię/podkategorię, gdy zgłoszenie nie ma żadnej</label>
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.subcategoryWhenCategoryOnly" style="position:static;opacity:1;width:auto;height:auto">Dobierz podkategorię, gdy zgłoszenie ma tylko kategorię</label>
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.recheckCategorized" style="position:static;opacity:1;width:auto;height:auto">Zweryfikuj i ewentualnie popraw już przypisaną podkategorię</label>
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.fixSubject" style="position:static;opacity:1;width:auto;height:auto">Popraw temat zgłoszenia, jeśli jest niejasny</label>
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.setPriority" style="position:static;opacity:1;width:auto;height:auto">Ustaw priorytet na podstawie treści</label>
|
||||||
|
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Każde zgłoszenie jest sprawdzane tylko raz — zastosowane zmiany trafiają do historii zgłoszenia z adnotacją „Automatyzacja: klasyfikacja AI”.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="border-top:1px solid var(--color-divider);margin:4px 0"></div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Podsumowanie AI dla operatora</label>
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="aiSummaryEnabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Generuj podsumowanie i sugerowaną akcję dla każdego zgłoszenia</strong></label>
|
||||||
|
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Widoczne wyłącznie w panelu operatora, w bocznym panelu zgłoszenia. Domyślnie odświeżane cyklicznie (co kilka minut, wraz z pozostałą automatyzacją AI powyżej).</p>
|
||||||
|
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="aiSummaryRegenerateOnMessage" style="position:static;opacity:1;width:auto;height:auto">Regeneruj podsumowanie od razu po każdej nowej wiadomości</label>
|
||||||
|
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Zamiast czekać na najbliższy cykl automatyzacji — dotyczy odpowiedzi operatora, klienta i notatek wewnętrznych.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||||
|
<label>Prompt systemowy podsumowania</label>
|
||||||
|
<button type="button" class="btn btn-ghost" style="padding:2px 8px;font-size:12px" wire:click="resetAiSummaryPrompt" wire:confirm="Przywrócić domyślny prompt? Obecna treść zostanie zastąpiona.">Resetuj</button>
|
||||||
|
</div>
|
||||||
|
<textarea wire:key="ai-summary-prompt-{{ $aiSummaryPromptVersion }}" class="input" rows="6" wire:change="saveAiSummaryPrompt($event.target.value)">{{ $aiSummaryPrompt }}</textarea>
|
||||||
|
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Model musi zwrócić obiekt JSON z kluczami "summary" i "suggested_action" — nie zmieniaj tego wymogu, chyba że wiadomo, że nowy dostawca/model obsłuży to inaczej.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@@ -796,6 +1003,11 @@ $tabGroups = [
|
|||||||
<livewire:admin.api-keys />
|
<livewire:admin.api-keys />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
{{-- ================= LOGS ================= --}}
|
||||||
|
@if ($tab === 'logs')
|
||||||
|
<livewire:admin.logs />
|
||||||
|
@endif
|
||||||
|
|
||||||
@if ($tab === 'about')
|
@if ($tab === 'about')
|
||||||
<h3 style="margin:0 0 14px">O aplikacji</h3>
|
<h3 style="margin:0 0 14px">O aplikacji</h3>
|
||||||
<div class="card" style="padding:18px;gap:10px;max-width:420px">
|
<div class="card" style="padding:18px;gap:10px;max-width:420px">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
</x-topbar>
|
</x-topbar>
|
||||||
|
|
||||||
<div class="page-pad" style="flex:1;display:flex;justify-content:center;align-items:center;padding:40px 20px">
|
<div class="page-pad" style="flex:1;display:flex;justify-content:center;align-items:center;padding:40px 20px">
|
||||||
<form wire:submit="submit" class="card elev-md" style="width:100%;max-width:380px;padding:28px;gap:16px">
|
<form wire:submit="submit" class="card elev-md" style="width:100%;max-width:480px;padding:28px;gap:16px">
|
||||||
<img src="{{ \App\Support\Settings::logoUrl() }}" alt="{{ \App\Support\Settings::get('company_name') }}" style="height:56px;width:auto;align-self:center">
|
<img src="{{ \App\Support\Settings::logoUrl() }}" alt="{{ \App\Support\Settings::get('company_name') }}" style="height:56px;width:auto;align-self:center">
|
||||||
<h2 style="margin:0;text-align:center">Zaloguj się</h2>
|
<h2 style="margin:0;text-align:center">Zaloguj się</h2>
|
||||||
<x-login-notice :html="\App\Support\Settings::get('login_notice_html')" :type="\App\Support\Settings::loginNoticeType()" />
|
<x-login-notice :html="\App\Support\Settings::get('login_notice_html')" :type="\App\Support\Settings::loginNoticeType()" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<div style="flex:1;display:flex;flex-direction:column">
|
<div style="flex:1;display:flex;flex-direction:column">
|
||||||
<x-topbar />
|
<x-topbar area="client" />
|
||||||
|
|
||||||
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:920px;width:100%;margin:0 auto;box-sizing:border-box">
|
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:920px;width:100%;margin:0 auto;box-sizing:border-box">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||||
@@ -9,20 +9,20 @@
|
|||||||
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||||
<div class="seg">
|
<div class="seg">
|
||||||
<label class="seg-opt"><input type="radio" name="ctab" @checked($tab === 'current') wire:click="setTab('current')">Aktualne ({{ $this->currentTickets->count() }})</label>
|
<label class="seg-opt"><input type="radio" name="ctab" @checked($tab === 'current') wire:click="setTab('current')">Aktualne ({{ $this->currentTickets->total() }})</label>
|
||||||
<label class="seg-opt"><input type="radio" name="ctab" @checked($tab === 'archive') wire:click="setTab('archive')">Archiwalne ({{ $this->archiveTickets->count() }})</label>
|
<label class="seg-opt"><input type="radio" name="ctab" @checked($tab === 'archive') wire:click="setTab('archive')">Archiwalne ({{ $this->archiveTickets->total() }})</label>
|
||||||
</div>
|
</div>
|
||||||
<input class="input" type="search" placeholder="Szukaj po numerze, temacie, treści…" wire:model.live.debounce.400ms="search" style="max-width:280px">
|
<input class="input" type="search" placeholder="Szukaj po numerze, temacie, treści…" wire:model.live.debounce.400ms="search" style="max-width:280px">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;flex-direction:column;gap:10px">
|
<div style="display:flex;flex-direction:column;gap:10px">
|
||||||
@foreach (($tab === 'current' ? $this->currentTickets : $this->archiveTickets) as $ticket)
|
@foreach (($tab === 'current' ? $this->currentTickets : $this->archiveTickets) as $ticket)
|
||||||
<a href="{{ route('client.ticket', $ticket) }}" wire:navigate class="card elev-sm" style="padding:16px;cursor:pointer;flex-direction:row;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;text-decoration:none;color:inherit">
|
<a href="{{ route('client.ticket', $ticket) }}" wire:navigate class="card elev-sm" style="padding:16px;cursor:pointer;flex-direction:row;align-items:center;justify-content:space-between;gap:12px;text-decoration:none;color:inherit">
|
||||||
<div>
|
<div style="flex:1;min-width:0">
|
||||||
<div style="font-weight:500">#{{ $ticket->number }} — {{ $ticket->subject }}</div>
|
<div style="font-weight:500">{{ $ticket->displayNumber() }} — {{ $ticket->subject }}</div>
|
||||||
<div class="card-meta">{{ $ticket->categoryLabel() }} · {{ \App\Support\Rel::format($ticket->updated_at) }}</div>
|
<div class="card-meta">{{ $ticket->categoryLabel() }} · {{ \App\Support\Rel::format($ticket->updated_at) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:6px">
|
<div style="display:flex;gap:6px;flex-shrink:0">
|
||||||
<span style="{{ $ticket->priorityStyle() }}">{{ $ticket->priorityLabel() }}</span>
|
<span style="{{ $ticket->priorityStyle() }}">{{ $ticket->priorityLabel() }}</span>
|
||||||
<span style="{{ $ticket->statusStyle() }}">{{ $ticket->statusLabel() }}</span>
|
<span style="{{ $ticket->statusStyle() }}">{{ $ticket->statusLabel() }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,6 +32,10 @@
|
|||||||
@if (($tab === 'current' ? $this->currentTickets : $this->archiveTickets)->isEmpty())
|
@if (($tab === 'current' ? $this->currentTickets : $this->archiveTickets)->isEmpty())
|
||||||
<p class="text-muted" style="font-size:13px">Brak zgłoszeń w tej zakładce.</p>
|
<p class="text-muted" style="font-size:13px">Brak zgłoszeń w tej zakładce.</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
<div style="margin-top:4px">
|
||||||
|
{{ ($tab === 'current' ? $this->currentTickets : $this->archiveTickets)->links() }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<div style="flex:1;display:flex;flex-direction:column">
|
<div style="flex:1;display:flex;flex-direction:column">
|
||||||
<x-topbar />
|
<x-topbar area="client" />
|
||||||
|
|
||||||
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:920px;width:100%;margin:0 auto;box-sizing:border-box">
|
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:920px;width:100%;margin:0 auto;box-sizing:border-box">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||||
@@ -64,6 +64,15 @@
|
|||||||
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
|
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div wire:init="loadSnipeitAssets">
|
||||||
|
<x-snipeit-assets
|
||||||
|
:assets="$this->snipeitAssets"
|
||||||
|
title="Twój sprzęt (inwentarz) — powiąż, jeśli zgłoszenie go dotyczy"
|
||||||
|
:selectable="\App\Support\Settings::bool('snipeit_client_can_select_asset')"
|
||||||
|
:selected-id="$selectedSnipeitAssetId"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Temat</label>
|
<label>Temat</label>
|
||||||
<input class="input" wire:model="subject">
|
<input class="input" wire:model="subject">
|
||||||
@@ -86,7 +95,7 @@
|
|||||||
@dragover.prevent="dragging = true"
|
@dragover.prevent="dragging = true"
|
||||||
@dragleave.prevent="dragging = false"
|
@dragleave.prevent="dragging = false"
|
||||||
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
|
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
|
||||||
:style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
|
:style="{ borderColor: (typeof dragging !== 'undefined' && dragging) ? 'var(--color-accent)' : undefined, background: (typeof dragging !== 'undefined' && dragging) ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
|
||||||
style="border:1px dashed var(--color-divider);border-radius:8px;padding:14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
|
style="border:1px dashed var(--color-divider);border-radius:8px;padding:14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
|
||||||
>
|
>
|
||||||
<label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>
|
<label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>
|
||||||
|
|||||||
@@ -1,22 +1,40 @@
|
|||||||
<div style="flex:1;display:flex;flex-direction:column">
|
<div style="flex:1;display:flex;flex-direction:column">
|
||||||
<x-topbar />
|
<x-topbar area="client" />
|
||||||
|
|
||||||
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:1180px;width:100%;margin:0 auto;box-sizing:border-box">
|
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:1180px;width:100%;margin:0 auto;box-sizing:border-box">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||||
<a href="{{ route('client.dashboard') }}" wire:navigate class="btn btn-ghost" style="padding:0">← Wróć do listy</a>
|
@php $backTab = session('client_dashboard_tab', 'current'); @endphp
|
||||||
|
<a href="{{ route('client.dashboard', $backTab !== 'current' ? ['tab' => $backTab] : []) }}" wire:navigate class="btn btn-ghost" style="padding:0">← Wróć do listy</a>
|
||||||
|
|
||||||
{{-- Live updates arrive via broadcasting, but websocket connections can
|
{{-- Live updates arrive via broadcasting, but websocket connections can
|
||||||
drop silently — this is a periodic fallback refresh, with a visible
|
drop silently — this is a periodic fallback refresh, with a visible
|
||||||
countdown so it's clear the thread is still refreshing on its own. --}}
|
countdown so it's clear the thread is still refreshing on its own.
|
||||||
|
Also clickable, to fetch immediately and reset the countdown. --}}
|
||||||
|
@php
|
||||||
|
$refreshTicketSeconds = max(1, (int) \App\Support\Settings::get('refresh_ticket_view_seconds'));
|
||||||
|
@endphp
|
||||||
<div
|
<div
|
||||||
|
wire:key="ticket-autorefresh-{{ $ticket->id }}"
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
style="cursor:default;gap:6px"
|
style="cursor:pointer;gap:6px"
|
||||||
x-data="{ remaining: 30, total: 30 }"
|
x-data="{
|
||||||
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)"
|
remaining: {{ $refreshTicketSeconds }}, total: {{ $refreshTicketSeconds }},
|
||||||
title="Zgłoszenie odświeża się automatycznie"
|
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 class="material-symbols-outlined" style="font-size:18px">schedule</span>
|
||||||
<span x-text="remaining + 's'"></span>
|
<span x-text="typeof remaining !== 'undefined' ? (remaining + 's') : ''"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -28,7 +46,7 @@
|
|||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="card" style="padding:22px;gap:10px">
|
<div class="card" style="padding:22px;gap:10px">
|
||||||
<div class="card-kicker">Zgłoszenie #{{ $ticket->number }}</div>
|
<div class="card-kicker">Zgłoszenie {{ $ticket->displayNumber() }}</div>
|
||||||
<h2 style="margin:2px 0 0">{{ $ticket->subject }}</h2>
|
<h2 style="margin:2px 0 0">{{ $ticket->subject }}</h2>
|
||||||
<div class="card-meta">{{ $ticket->categoryLabel() }} · utworzono {{ \App\Support\Rel::format($ticket->created_at) }}</div>
|
<div class="card-meta">{{ $ticket->categoryLabel() }} · utworzono {{ \App\Support\Rel::format($ticket->created_at) }}</div>
|
||||||
<div style="white-space:pre-wrap;font-size:14px;margin-top:4px">{{ $ticket->body }}</div>
|
<div style="white-space:pre-wrap;font-size:14px;margin-top:4px">{{ $ticket->body }}</div>
|
||||||
@@ -84,7 +102,7 @@
|
|||||||
@dragover.prevent="dragging = true"
|
@dragover.prevent="dragging = true"
|
||||||
@dragleave.prevent="dragging = false"
|
@dragleave.prevent="dragging = false"
|
||||||
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
|
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
|
||||||
:style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
|
:style="{ borderColor: (typeof dragging !== 'undefined' && dragging) ? 'var(--color-accent)' : undefined, background: (typeof dragging !== 'undefined' && dragging) ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
|
||||||
style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
|
style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
|
||||||
>
|
>
|
||||||
<label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>
|
<label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>
|
||||||
@@ -114,6 +132,16 @@
|
|||||||
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" />
|
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if ($ticket->snipeit_asset_name)
|
||||||
|
<div class="card" style="padding:16px;gap:6px">
|
||||||
|
<div class="card-kicker">Powiązany sprzęt</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:500">
|
||||||
|
<span class="material-symbols-outlined" style="font-size:18px;color:var(--color-accent)">devices</span>
|
||||||
|
{{ $ticket->snipeit_asset_name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="card" style="padding:16px;gap:10px">
|
<div class="card" style="padding:16px;gap:10px">
|
||||||
<div class="card-kicker">Status i priorytet</div>
|
<div class="card-kicker">Status i priorytet</div>
|
||||||
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||||||
@@ -167,13 +195,18 @@
|
|||||||
<div class="card" style="padding:16px;gap:8px">
|
<div class="card" style="padding:16px;gap:8px">
|
||||||
<div class="card-kicker">Inne Twoje zgłoszenia</div>
|
<div class="card-kicker">Inne Twoje zgłoszenia</div>
|
||||||
@forelse ($this->otherTickets as $ot)
|
@forelse ($this->otherTickets as $ot)
|
||||||
<a href="{{ route('client.ticket', $ot) }}" wire:navigate style="display:flex;justify-content:space-between;align-items:center;gap:8px;cursor:pointer;text-decoration:none;color:inherit">
|
<a wire:key="other-ticket-{{ $ot->id }}" href="{{ route('client.ticket', $ot) }}" wire:navigate style="display:flex;justify-content:space-between;align-items:center;gap:8px;cursor:pointer;text-decoration:none;color:inherit">
|
||||||
<span style="font-size:13px">#{{ $ot->number }} — {{ $ot->subject }}</span>
|
<span style="font-size:13px">{{ $ot->displayNumber() }} — {{ $ot->subject }}</span>
|
||||||
<span style="{{ $ot->statusStyle() }};flex:none">{{ $ot->statusLabel() }}</span>
|
<span style="{{ $ot->statusStyle() }};flex:none">{{ $ot->statusLabel() }}</span>
|
||||||
</a>
|
</a>
|
||||||
@empty
|
@empty
|
||||||
<p class="text-muted" style="font-size:12px;margin:0">Brak innych zgłoszeń.</p>
|
<p wire:key="other-tickets-empty" class="text-muted" style="font-size:12px;margin:0">Brak innych zgłoszeń.</p>
|
||||||
@endforelse
|
@endforelse
|
||||||
|
@if (! $showAllOtherTickets && $this->otherTicketsCount > count($this->otherTickets))
|
||||||
|
<button wire:key="show-all-other-tickets" type="button" wire:click="revealAllOtherTickets" class="btn btn-secondary" style="font-size:12px;padding:6px 10px;align-self:flex-start">
|
||||||
|
Pokaż wszystkie ({{ $this->otherTicketsCount }})
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
51
src/resources/views/livewire/global-search.blade.php
Normal file
51
src/resources/views/livewire/global-search.blade.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<div
|
||||||
|
x-data="{ open: false }"
|
||||||
|
x-on:open-global-search.window="open = true; $nextTick(() => $refs.searchInput.focus())"
|
||||||
|
x-on:keydown.cmd.k.window.prevent="open = true; $nextTick(() => $refs.searchInput.focus())"
|
||||||
|
x-on:keydown.ctrl.k.window.prevent="open = true; $nextTick(() => $refs.searchInput.focus())"
|
||||||
|
x-on:keydown.escape.window="open = false"
|
||||||
|
>
|
||||||
|
<div class="dialog-backdrop" x-show="open" x-cloak style="align-items:flex-start;padding-top:10vh">
|
||||||
|
<div class="dialog" style="max-width:720px;padding:0;gap:0;overflow:hidden" @click.outside="open = false">
|
||||||
|
<div style="display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:1px solid var(--color-divider)">
|
||||||
|
<span class="material-symbols-outlined" style="font-size:26px;color:color-mix(in srgb, var(--color-text) 55%, transparent)">search</span>
|
||||||
|
<input
|
||||||
|
x-ref="searchInput"
|
||||||
|
type="text"
|
||||||
|
wire:model.live.debounce.200ms="search"
|
||||||
|
placeholder="Szukaj zgłoszenia… (np. od:kacper temat:drukarka)"
|
||||||
|
class="input"
|
||||||
|
style="border:none;padding:10px 12px;box-shadow:none;font-size:18px"
|
||||||
|
x-on:keydown.enter="$refs.resultsList?.querySelector('a')?.click()"
|
||||||
|
>
|
||||||
|
<kbd style="font-size:11px;color:color-mix(in srgb, var(--color-text) 45%, transparent);border:1px solid var(--color-divider);border-radius:4px;padding:2px 7px;flex:none">Esc</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-ref="resultsList" style="max-height:460px;overflow-y:auto">
|
||||||
|
@forelse ($this->results as $ticket)
|
||||||
|
<a
|
||||||
|
href="{{ $this->urlFor($ticket) }}"
|
||||||
|
wire:navigate
|
||||||
|
@click="open = false"
|
||||||
|
style="display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 22px;text-decoration:none;color:inherit;border-bottom:1px solid var(--color-divider)"
|
||||||
|
>
|
||||||
|
<div style="min-width:0">
|
||||||
|
<div style="font-weight:500;font-size:14.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $ticket->displayNumber() }} — {{ $ticket->subject }}</div>
|
||||||
|
<div class="card-meta" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
||||||
|
{{ $ticket->categoryLabel() }} · {{ $ticket->name }} · {{ \App\Support\Rel::format($ticket->updated_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:6px;flex:none">
|
||||||
|
<span style="{{ $ticket->priorityStyle() }}">{{ $ticket->priorityLabel() }}</span>
|
||||||
|
<span style="{{ $ticket->statusStyle() }}">{{ $ticket->statusLabel() }}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@empty
|
||||||
|
<div style="padding:28px 22px;text-align:center;font-size:13.5px;color:color-mix(in srgb, var(--color-text) 55%, transparent)">
|
||||||
|
{{ trim($search) === '' ? 'Zacznij pisać, aby wyszukać zgłoszenie… (obsługuje też od:, temat:, treść:, numer:)' : 'Brak wyników.' }}
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -9,11 +9,11 @@
|
|||||||
@if ($this->submittedTicket)
|
@if ($this->submittedTicket)
|
||||||
<div class="card elev-md" style="padding:32px;gap:14px;text-align:left">
|
<div class="card elev-md" style="padding:32px;gap:14px;text-align:left">
|
||||||
<span class="tag tag-accent" style="align-self:flex-start">Zgłoszenie przyjęte</span>
|
<span class="tag tag-accent" style="align-self:flex-start">Zgłoszenie przyjęte</span>
|
||||||
<h2 style="margin:0">Zgłoszenie #{{ $this->submittedTicket->number }} zostało utworzone</h2>
|
<h2 style="margin:0">Zgłoszenie {{ $this->submittedTicket->displayNumber() }} zostało utworzone</h2>
|
||||||
<p class="text-muted" style="margin:0">Zapisz numer zgłoszenia i adres e-mail — będziesz mógł/mogła sprawdzić status, kontaktując się z zespołem wsparcia. Aktualizacje będziemy wysyłać na Twój adres e-mail.</p>
|
<p class="text-muted" style="margin:0">Zapisz numer zgłoszenia i adres e-mail — będziesz mógł/mogła sprawdzić status, kontaktując się z zespołem wsparcia. Aktualizacje będziemy wysyłać na Twój adres e-mail.</p>
|
||||||
<div class="hr"></div>
|
<div class="hr"></div>
|
||||||
<div style="display:flex;flex-direction:column;gap:4px;font-size:14px">
|
<div style="display:flex;flex-direction:column;gap:4px;font-size:14px">
|
||||||
<div><strong>Numer zgłoszenia:</strong> #{{ $this->submittedTicket->number }}</div>
|
<div><strong>Numer zgłoszenia:</strong> {{ $this->submittedTicket->displayNumber() }}</div>
|
||||||
<div><strong>Temat:</strong> {{ $this->submittedTicket->subject }}</div>
|
<div><strong>Temat:</strong> {{ $this->submittedTicket->subject }}</div>
|
||||||
<div><strong>Kategoria:</strong> {{ $this->submittedTicket->categoryLabel() }}</div>
|
<div><strong>Kategoria:</strong> {{ $this->submittedTicket->categoryLabel() }}</div>
|
||||||
<div><strong>Zgłaszający:</strong> {{ $this->submittedTicket->email }}</div>
|
<div><strong>Zgłaszający:</strong> {{ $this->submittedTicket->email }}</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div x-data="{ open: false }" @click.outside="open = false" class="nav-dropdown-wrap" style="position:relative;display:inline-block" wire:poll.30s="$refresh">
|
<div x-data="{ open: false }" @click.outside="open = false" class="nav-dropdown-wrap" style="position:relative;display:inline-block" wire:poll.{{ max(1, (int) \App\Support\Settings::get('refresh_notifications_seconds')) }}s="$refresh">
|
||||||
<button type="button" class="btn btn-secondary" @click="open = !open" style="position:relative;display:flex;align-items:center;gap:0;padding:8px">
|
<button type="button" class="btn btn-secondary" @click="open = !open" style="position:relative;display:flex;align-items:center;gap:0;padding:8px">
|
||||||
<span class="material-symbols-outlined" style="font-size:18px">notifications</span>
|
<span class="material-symbols-outlined" style="font-size:18px">notifications</span>
|
||||||
@if ($this->unreadCount)
|
@if ($this->unreadCount)
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<div style="flex:1;display:flex;flex-direction:column">
|
||||||
|
<x-topbar area="operator" />
|
||||||
|
|
||||||
|
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto;display:flex;flex-direction:column;gap:20px;max-width:820px;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">
|
||||||
|
<h2 style="margin:0">Klienci</h2>
|
||||||
|
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost">← Wróć do listy</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
type="search"
|
||||||
|
placeholder="Szukaj po imieniu, nazwisku lub adresie e-mail…"
|
||||||
|
wire:model.live.debounce.400ms="search"
|
||||||
|
autofocus
|
||||||
|
>
|
||||||
|
|
||||||
|
<div style="display:flex;flex-direction:column;gap:10px">
|
||||||
|
@if (trim($search) === '')
|
||||||
|
<p class="text-muted" style="font-size:13px">Zacznij pisać, aby wyszukać klienta.</p>
|
||||||
|
@else
|
||||||
|
@forelse ($this->results as $u)
|
||||||
|
<a
|
||||||
|
wire:key="client-{{ $u->id }}"
|
||||||
|
href="{{ route('operator.queue', ['filterCustomerId' => $u->id]) }}"
|
||||||
|
wire:navigate
|
||||||
|
class="card elev-sm"
|
||||||
|
style="padding:14px 16px;cursor:pointer;flex-direction:row;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;text-decoration:none;color:inherit"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:500">{{ $u->name }}</div>
|
||||||
|
<div class="card-meta">{{ $u->email }}</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end">
|
||||||
|
@if ($u->hasRole('client'))
|
||||||
|
<span class="tag tag-outline">Klient</span>
|
||||||
|
@endif
|
||||||
|
@if ($u->hasRole('operator'))
|
||||||
|
<span class="tag tag-outline">Operator</span>
|
||||||
|
@endif
|
||||||
|
@if ($u->hasRole('admin'))
|
||||||
|
<span class="tag tag-outline">Administrator</span>
|
||||||
|
@endif
|
||||||
|
<span class="tag tag-neutral">{{ $u->tickets_as_customer_count }} {{ $u->tickets_as_customer_count === 1 ? 'zgłoszenie' : 'zgłoszeń' }}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@empty
|
||||||
|
<p class="text-muted" style="font-size:13px">Brak wyników dla „{{ $search }}”.</p>
|
||||||
|
@endforelse
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<div style="flex:1;display:flex;flex-direction:column">
|
<div style="flex:1;display:flex;flex-direction:column">
|
||||||
<x-topbar />
|
<x-topbar area="operator" />
|
||||||
|
|
||||||
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:920px;width:100%;margin:0 auto;box-sizing:border-box">
|
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:920px;width:100%;margin:0 auto;box-sizing:border-box">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
@dragover.prevent="dragging = true"
|
@dragover.prevent="dragging = true"
|
||||||
@dragleave.prevent="dragging = false"
|
@dragleave.prevent="dragging = false"
|
||||||
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
|
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
|
||||||
:style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
|
:style="{ borderColor: (typeof dragging !== 'undefined' && dragging) ? 'var(--color-accent)' : undefined, background: (typeof dragging !== 'undefined' && dragging) ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
|
||||||
style="border:1px dashed var(--color-divider);border-radius:8px;padding:14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
|
style="border:1px dashed var(--color-divider);border-radius:8px;padding:14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
|
||||||
>
|
>
|
||||||
<label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>
|
<label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
<div style="flex:1;display:flex;flex-direction:column">
|
<div style="flex:1;display:flex;flex-direction:column">
|
||||||
<x-topbar />
|
<x-topbar area="operator" />
|
||||||
|
|
||||||
<div class="app-split" style="flex:1;display:flex;min-height:0">
|
<div class="app-split" style="flex:1;display:flex;min-height:0">
|
||||||
<div
|
<div
|
||||||
class="side-rail"
|
class="side-rail"
|
||||||
style="padding:10px;gap:6px;background:color-mix(in srgb, var(--color-text) 5%, var(--color-bg))"
|
style="padding:10px;gap:6px;background:color-mix(in srgb, var(--color-text) 5%, var(--color-bg))"
|
||||||
x-data="{ collapsed: localStorage.getItem('operatorSidebarCollapsed') === '1' }"
|
x-data="{ collapsed: localStorage.getItem('operatorSidebarCollapsed') === '1' }"
|
||||||
x-effect="localStorage.setItem('operatorSidebarCollapsed', collapsed ? '1' : '0')"
|
x-effect="typeof collapsed !== 'undefined' && localStorage.setItem('operatorSidebarCollapsed', collapsed ? '1' : '0')"
|
||||||
:class="collapsed ? 'side-rail-collapsed' : ''"
|
:class="(typeof collapsed !== 'undefined' && collapsed) ? 'side-rail-collapsed' : ''"
|
||||||
>
|
>
|
||||||
<button type="button" class="side-rail-toggle" @click="collapsed = ! collapsed" :title="collapsed ? 'Rozwiń panel' : 'Zwiń panel'">
|
<button type="button" class="side-rail-toggle" @click="collapsed = ! collapsed" :title="(typeof collapsed !== 'undefined' && collapsed) ? 'Rozwiń panel' : 'Zwiń panel'">
|
||||||
<span class="material-symbols-outlined" style="font-size:18px" x-text="collapsed ? 'left_panel_open' : 'left_panel_close'"></span>
|
<span class="material-symbols-outlined" style="font-size:18px" x-text="(typeof collapsed !== 'undefined' && collapsed) ? 'left_panel_open' : 'left_panel_close'"></span>
|
||||||
<span class="side-rail-toggle-label" x-text="collapsed ? 'Przegląd' : 'Zwiń panel'"></span>
|
<span class="side-rail-toggle-label" x-text="(typeof collapsed !== 'undefined' && collapsed) ? 'Przegląd' : 'Zwiń panel'"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" class="side-rail-toggle" title="Szukaj" @click="window.dispatchEvent(new CustomEvent('open-global-search'))">
|
||||||
|
<span class="material-symbols-outlined" style="font-size:18px">search</span>
|
||||||
|
<span class="side-rail-toggle-label">Szukaj</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="side-rail-scroll">
|
<div class="side-rail-scroll">
|
||||||
@@ -27,6 +32,18 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|
||||||
|
@if ($this->recentlyViewed->isNotEmpty())
|
||||||
|
<div style="display:flex;flex-direction:column;gap:1px">
|
||||||
|
<div class="side-rail-group-label" style="font-size:10px;letter-spacing:0.09em;text-transform:uppercase;color:color-mix(in srgb, var(--color-text) 45%, transparent);padding:4px 12px 6px;white-space:nowrap">Ostatnio przeglądane</div>
|
||||||
|
@foreach ($this->recentlyViewed as $ticket)
|
||||||
|
<a href="{{ route('operator.ticket', $ticket) }}" wire:navigate class="side-rail-item" title="{{ $ticket->displayNumber() }} — {{ $ticket->subject }}" style="display:flex;align-items:center;gap:10px;width:100%;padding:8px 12px;border-radius:6px;font-size:13.5px;text-align:left;white-space:nowrap;text-decoration:none;color:var(--color-text);overflow:hidden">
|
||||||
|
<span class="material-symbols-outlined" style="font-size:18px;flex:none">history</span>
|
||||||
|
<span class="side-rail-label" style="overflow:hidden;text-overflow:ellipsis">{{ $ticket->displayNumber() }} — {{ $ticket->subject }}</span>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -40,6 +57,10 @@
|
|||||||
<button type="button" class="btn btn-secondary" @disabled(count($selectedIds) < 2) wire:click="mergeSelected">Scal zgłoszenia ({{ count($selectedIds) }})</button>
|
<button type="button" class="btn btn-secondary" @disabled(count($selectedIds) < 2) wire:click="mergeSelected">Scal zgłoszenia ({{ count($selectedIds) }})</button>
|
||||||
<button type="button" class="btn btn-secondary" @disabled(count($selectedIds) < 1) wire:click="requestDeleteSelected" style="color:var(--color-danger)">Usuń zgłoszenia ({{ count($selectedIds) }})</button>
|
<button type="button" class="btn btn-secondary" @disabled(count($selectedIds) < 1) wire:click="requestDeleteSelected" style="color:var(--color-danger)">Usuń zgłoszenia ({{ count($selectedIds) }})</button>
|
||||||
</div>
|
</div>
|
||||||
|
<a href="{{ route('operator.clients') }}" wire:navigate class="btn btn-secondary" style="display:flex;align-items:center;gap:6px">
|
||||||
|
<span class="material-symbols-outlined" style="font-size:18px">person_search</span>
|
||||||
|
Klienci
|
||||||
|
</a>
|
||||||
<a href="{{ route('operator.stats') }}" wire:navigate class="btn btn-secondary" style="display:flex;align-items:center;gap:6px">
|
<a href="{{ route('operator.stats') }}" wire:navigate class="btn btn-secondary" style="display:flex;align-items:center;gap:6px">
|
||||||
<span class="material-symbols-outlined" style="font-size:18px">bar_chart</span>
|
<span class="material-symbols-outlined" style="font-size:18px">bar_chart</span>
|
||||||
Statystyki
|
Statystyki
|
||||||
@@ -69,6 +90,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<select class="input" style="width:auto" wire:model.live="filterCategory">
|
<select class="input" style="width:auto" wire:model.live="filterCategory">
|
||||||
<option value="all">Wszystkie kategorie</option>
|
<option value="all">Wszystkie kategorie</option>
|
||||||
|
<option value="none">Bez kategorii</option>
|
||||||
@foreach ($this->categories as $c)
|
@foreach ($this->categories as $c)
|
||||||
<option value="{{ $c->id }}">{{ $c->name }}</option>
|
<option value="{{ $c->id }}">{{ $c->name }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
@@ -79,7 +101,7 @@
|
|||||||
<span class="material-symbols-outlined" style="font-size:18px">bookmark</span>
|
<span class="material-symbols-outlined" style="font-size:18px">bookmark</span>
|
||||||
Zapisane widoki
|
Zapisane widoki
|
||||||
</button>
|
</button>
|
||||||
<div x-show="open" x-cloak @click.outside="open = false; adding = false" style="position:absolute;top:100%;left:0;margin-top:4px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);z-index:30;padding:6px;min-width:220px">
|
<div x-show="typeof open !== 'undefined' && open" x-cloak @click.outside="open = false; adding = false" style="position:absolute;top:100%;left:0;margin-top:4px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);z-index:30;padding:6px;min-width:220px">
|
||||||
@forelse ($this->savedViews as $view)
|
@forelse ($this->savedViews as $view)
|
||||||
<div style="display:flex;align-items:center;gap:4px;padding:2px 2px 2px 8px;border-radius:5px;{{ $savedViewId === $view->id ? 'background:color-mix(in srgb, var(--color-accent) 12%, transparent)' : '' }}">
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 2px 2px 8px;border-radius:5px;{{ $savedViewId === $view->id ? 'background:color-mix(in srgb, var(--color-accent) 12%, transparent)' : '' }}">
|
||||||
<button type="button" wire:click="applySavedView({{ $view->id }})" style="flex:1;min-width:0;text-align:left;background:none;border:none;cursor:pointer;padding:6px 0;font-size:13px;color:{{ $savedViewId === $view->id ? 'var(--color-accent)' : 'inherit' }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $view->name }}</button>
|
<button type="button" wire:click="applySavedView({{ $view->id }})" style="flex:1;min-width:0;text-align:left;background:none;border:none;cursor:pointer;padding:6px 0;font-size:13px;color:{{ $savedViewId === $view->id ? 'var(--color-accent)' : 'inherit' }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $view->name }}</button>
|
||||||
@@ -95,7 +117,7 @@
|
|||||||
<template x-if="! adding">
|
<template x-if="! adding">
|
||||||
<button type="button" class="btn btn-secondary btn-block" @click="adding = true" style="font-size:12.5px">+ Zapisz bieżące filtry…</button>
|
<button type="button" class="btn btn-secondary btn-block" @click="adding = true" style="font-size:12.5px">+ Zapisz bieżące filtry…</button>
|
||||||
</template>
|
</template>
|
||||||
<div x-show="adding" style="display:flex;gap:6px;padding:4px 2px">
|
<div x-show="typeof adding !== 'undefined' && adding" style="display:flex;gap:6px;padding:4px 2px">
|
||||||
<input class="input" style="flex:1;font-size:12.5px" placeholder="Nazwa widoku" wire:model="newViewName" @keydown.enter="$wire.saveCurrentView(); adding = false">
|
<input class="input" style="flex:1;font-size:12.5px" placeholder="Nazwa widoku" wire:model="newViewName" @keydown.enter="$wire.saveCurrentView(); adding = false">
|
||||||
<button type="button" class="btn btn-primary" style="flex:none;padding:6px 10px" @click="$wire.saveCurrentView(); adding = false">Zapisz</button>
|
<button type="button" class="btn btn-primary" style="flex:none;padding:6px 10px" @click="$wire.saveCurrentView(); adding = false">Zapisz</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,29 +129,61 @@
|
|||||||
<span class="material-symbols-outlined" style="font-size:18px">view_column</span>
|
<span class="material-symbols-outlined" style="font-size:18px">view_column</span>
|
||||||
Kolumny
|
Kolumny
|
||||||
</button>
|
</button>
|
||||||
<div x-show="open" x-cloak @click.outside="open = false" style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);z-index:30;padding:6px;min-width:180px">
|
<div x-show="typeof open !== 'undefined' && open" x-cloak @click.outside="open = false" style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);z-index:30;padding:6px;min-width:220px">
|
||||||
@foreach ($columnDefs as $key => $label)
|
@foreach ($visibleColumns as $i => $key)
|
||||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:400;padding:6px 8px;border-radius:5px;cursor:pointer">
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 2px 2px 8px;border-radius:5px">
|
||||||
<input type="checkbox" @checked(in_array($key, $visibleColumns)) wire:click="toggleColumn('{{ $key }}')">
|
<label style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:400;flex:1;min-width:0;cursor:pointer">
|
||||||
|
<input type="checkbox" checked wire:click="toggleColumn('{{ $key }}')">
|
||||||
|
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $columnDefs[$key] ?? $key }}</span>
|
||||||
|
</label>
|
||||||
|
<span class="material-symbols-outlined" style="font-size:16px;flex:none;cursor:{{ $i === 0 ? 'default' : 'pointer' }};opacity:{{ $i === 0 ? '0.25' : '0.7' }}" title="Przesuń wcześniej (w lewo w tabeli)" wire:click="moveColumnUp('{{ $key }}')">arrow_upward</span>
|
||||||
|
<span class="material-symbols-outlined" style="font-size:16px;flex:none;cursor:{{ $i === count($visibleColumns) - 1 ? 'default' : 'pointer' }};opacity:{{ $i === count($visibleColumns) - 1 ? '0.25' : '0.7' }}" title="Przesuń później (w prawo w tabeli)" wire:click="moveColumnDown('{{ $key }}')">arrow_downward</span>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
@php $hiddenColumnDefs = array_diff_key($columnDefs, array_flip($visibleColumns)); @endphp
|
||||||
|
@if (! empty($hiddenColumnDefs))
|
||||||
|
<div style="border-top:1px solid var(--color-divider);margin:4px 0"></div>
|
||||||
|
@foreach ($hiddenColumnDefs as $key => $label)
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:400;padding:6px 8px;border-radius:5px;cursor:pointer;opacity:0.75">
|
||||||
|
<input type="checkbox" wire:click="toggleColumn('{{ $key }}')">
|
||||||
{{ $label }}
|
{{ $label }}
|
||||||
</label>
|
</label>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Live updates arrive via broadcasting, but websocket connections can
|
{{-- Live updates arrive via broadcasting, but websocket connections can
|
||||||
drop silently (backgrounded tab, network blip) — this is a periodic
|
drop silently (backgrounded tab, network blip) — this is a periodic
|
||||||
fallback refresh, with a visible countdown so it's clear the queue
|
fallback refresh, with a visible countdown so it's clear the queue
|
||||||
is still refreshing itself rather than just stuck. --}}
|
is still refreshing itself rather than just stuck. Also clickable,
|
||||||
|
to fetch immediately and reset the countdown. --}}
|
||||||
|
@php
|
||||||
|
$refreshQueueSeconds = max(1, (int) \App\Support\Settings::get('refresh_queue_seconds'));
|
||||||
|
@endphp
|
||||||
<div
|
<div
|
||||||
|
wire:key="queue-autorefresh"
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
style="cursor:default;gap:6px"
|
style="cursor:pointer;gap:6px"
|
||||||
x-data="{ remaining: 60, total: 60 }"
|
x-data="{
|
||||||
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshQueue(); }, 1000)"
|
remaining: {{ $refreshQueueSeconds }}, total: {{ $refreshQueueSeconds }},
|
||||||
title="Kolejka odświeża się automatycznie co minutę"
|
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 class="material-symbols-outlined" style="font-size:18px">schedule</span>
|
||||||
<span x-text="remaining + 's'"></span>
|
<span x-text="typeof remaining !== 'undefined' ? (remaining + 's') : ''"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -137,9 +191,9 @@
|
|||||||
<table class="table table-cards-mobile">
|
<table class="table table-cards-mobile">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th>
|
<th><input type="checkbox" @checked($this->filteredTickets->isNotEmpty() && empty($this->filteredTickets->pluck('id')->diff($selectedIds)->all())) wire:click="toggleSelectAll" title="Zaznacz wszystkie"></th>
|
||||||
@foreach ($columnDefs as $key => $label)
|
@foreach ($visibleColumns as $key)
|
||||||
@continue(! in_array($key, $visibleColumns))
|
@php $label = $columnDefs[$key] ?? $key; @endphp
|
||||||
<th>
|
<th>
|
||||||
@if (in_array($key, $sortableColumns))
|
@if (in_array($key, $sortableColumns))
|
||||||
<button type="button" wire:click="sortByColumn('{{ $key }}')" style="display:inline-flex;align-items:center;gap:2px;background:none;border:none;cursor:pointer;padding:0;font:inherit;color:inherit;text-transform:inherit;letter-spacing:inherit">
|
<button type="button" wire:click="sortByColumn('{{ $key }}')" style="display:inline-flex;align-items:center;gap:2px;background:none;border:none;cursor:pointer;padding:0;font:inherit;color:inherit;text-transform:inherit;letter-spacing:inherit">
|
||||||
@@ -160,44 +214,68 @@
|
|||||||
@php $sla = $t->slaInfo(); @endphp
|
@php $sla = $t->slaInfo(); @endphp
|
||||||
<tr wire:key="ticket-{{ $t->id }}">
|
<tr wire:key="ticket-{{ $t->id }}">
|
||||||
<td class="td-select"><input type="checkbox" @checked(in_array($t->id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})"></td>
|
<td class="td-select"><input type="checkbox" @checked(in_array($t->id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})"></td>
|
||||||
@if (in_array('number', $visibleColumns))
|
@foreach ($visibleColumns as $key)
|
||||||
<td data-label="Numer" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->number }}</a></td>
|
@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
|
@endif
|
||||||
@if (in_array('subject', $visibleColumns))
|
</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>
|
<td data-label="Temat" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer;white-space:nowrap">{{ $t->subject }}</a></td>
|
||||||
@endif
|
@break
|
||||||
@if (in_array('customer', $visibleColumns))
|
@case('customer')
|
||||||
<td data-label="Klient" style="white-space:nowrap">{{ $t->name }}</td>
|
<td data-label="Klient" style="white-space:nowrap">{{ $t->name }}</td>
|
||||||
@endif
|
@break
|
||||||
@if (in_array('category', $visibleColumns))
|
@case('email')
|
||||||
|
<td data-label="E-mail" style="white-space:nowrap">{{ $t->email }}</td>
|
||||||
|
@break
|
||||||
|
@case('category')
|
||||||
<td data-label="Kategoria" style="white-space:nowrap">{{ $t->categoryLabel() }}</td>
|
<td data-label="Kategoria" style="white-space:nowrap">{{ $t->categoryLabel() }}</td>
|
||||||
@endif
|
@break
|
||||||
@if (in_array('subcategory', $visibleColumns))
|
@case('subcategory')
|
||||||
<td data-label="Podkategoria" style="white-space:nowrap">{{ $t->subcategory?->name ?? '—' }}</td>
|
<td data-label="Podkategoria" style="white-space:nowrap">{{ $t->subcategory?->name ?? '—' }}</td>
|
||||||
@endif
|
@break
|
||||||
@if (in_array('priority', $visibleColumns))
|
@case('priority')
|
||||||
<td data-label="Priorytet"><span style="{{ $t->priorityStyle() }}">{{ $t->priorityLabel() }}</span></td>
|
<td data-label="Priorytet"><span style="{{ $t->priorityStyle() }}">{{ $t->priorityLabel() }}</span></td>
|
||||||
@endif
|
@break
|
||||||
@if (in_array('status', $visibleColumns))
|
@case('status')
|
||||||
<td data-label="Status"><span style="{{ $t->statusStyle() }}">{{ $t->statusLabel() }}</span></td>
|
<td data-label="Status"><span style="{{ $t->statusStyle() }}">{{ $t->statusLabel() }}</span></td>
|
||||||
@endif
|
@break
|
||||||
@if (in_array('sla', $visibleColumns))
|
@case('sla')
|
||||||
<td data-label="SLA"><span class="{{ $sla['cls'] }}">{{ $sla['short'] }}</span></td>
|
<td data-label="SLA"><span class="{{ $sla['cls'] }}">{{ $sla['short'] }}</span></td>
|
||||||
@endif
|
@break
|
||||||
@if (in_array('assignee', $visibleColumns))
|
@case('assignee')
|
||||||
<td data-label="Przypisany" style="white-space:nowrap">{{ $t->assignee?->name ?? 'Nieprzypisane' }}</td>
|
<td data-label="Przypisany" style="white-space:nowrap">{{ $t->assignee?->name ?? 'Nieprzypisane' }}</td>
|
||||||
@endif
|
@break
|
||||||
@if (in_array('team', $visibleColumns))
|
@case('team')
|
||||||
<td data-label="Zespół" style="white-space:nowrap">{{ $t->team?->name ?? '—' }}</td>
|
<td data-label="Zespół" style="white-space:nowrap">{{ $t->team?->name ?? '—' }}</td>
|
||||||
@endif
|
@break
|
||||||
@if (in_array('created', $visibleColumns))
|
@case('source')
|
||||||
|
<td data-label="Źródło" style="white-space:nowrap">{{ match ($t->source) { 'email' => 'E-mail', 'hesk_import' => 'Import HESK', default => 'WWW' } }}</td>
|
||||||
|
@break
|
||||||
|
@case('created')
|
||||||
<td data-label="Utworzono" style="white-space:nowrap">{{ \App\Support\Rel::format($t->created_at) }}</td>
|
<td data-label="Utworzono" style="white-space:nowrap">{{ \App\Support\Rel::format($t->created_at) }}</td>
|
||||||
@endif
|
@break
|
||||||
|
@case('updated')
|
||||||
|
<td data-label="Zaktualizowano" style="white-space:nowrap">{{ \App\Support\Rel::format($t->updated_at) }}</td>
|
||||||
|
@break
|
||||||
|
@endswitch
|
||||||
|
@endforeach
|
||||||
</tr>
|
</tr>
|
||||||
@endforeach
|
@endforeach
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="margin-top:12px">
|
||||||
|
{{ $this->filteredTickets->links() }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<div style="flex:1;display:flex;flex-direction:column">
|
<div style="flex:1;display:flex;flex-direction:column">
|
||||||
<x-topbar />
|
<x-topbar area="operator" />
|
||||||
|
|
||||||
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto;display:flex;flex-direction:column;gap:20px">
|
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto;display:flex;flex-direction:column;gap:20px">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user