v1.4.0
Co nowego: - Wsparcie Active Directory dla LDAP (obok LLDAP/OpenLDAP), przełącznik typu katalogu w Admin > Integracje. - Wyszukiwarka klientów dla operatora (Operator > Klienci). - Stronicowanie kolejki operatora (50/stronę) i dashboardu klienta (20/stronę). - Globalna wyszukiwarka zgłoszeń (Ctrl+K/Cmd+K) z operatorami w stylu Gmaila (od:, temat:, treść:, numer:), plus przycisk "Szukaj" w panelu bocznym. - Ostatnio przeglądane zgłoszenia w panelu bocznym operatora. - Przeprojektowany pasek nawigacji: suwak Klient/Operator/Administrator zamiast rozwijanego menu, bogatsze menu profilu (nazwa/e-mail/role), dynamiczne tytuły kart przeglądarki na każdej podstronie. - Narzędzie do jednorazowego importu historii zgłoszeń z Heska 3.x (scripts/hesk-import/). - Poprawka: paginacja pokazywała surowe klucze tłumaczeń zamiast tekstu (brakujący lang/pl/pagination.php). Zaktualizowana dokumentacja: README, CLAUDE.md, install.md, ARCHITECTURE.md, CHANGELOG.md, wiki/*. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
/mysql/
|
||||
.env
|
||||
compose.yaml
|
||||
compose.yaml
|
||||
scripts/hesk-import/.env
|
||||
@@ -167,6 +167,16 @@ the account used for first login after a fresh install (see
|
||||
`SyncUserFieldsFromLdap` keeps `UserFieldValue` rows in sync with directory
|
||||
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")
|
||||
|
||||
`App\Support\Settings` (`app/Support/Settings.php`) is a cached key/value reader
|
||||
|
||||
60
CHANGELOG.md
60
CHANGELOG.md
@@ -3,6 +3,66 @@
|
||||
All notable changes to this project are documented in this file. Format loosely
|
||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [1.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
|
||||
|
||||
46
CLAUDE.md
46
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
|
||||
|
||||
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
|
||||
`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 up -d`, see [install.md](install.md) 1.3/1.3a) — building locally would
|
||||
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
|
||||
with no rebuild or restart:
|
||||
|
||||
@@ -56,33 +61,32 @@ with no rebuild or restart:
|
||||
surfaces in production as a 500 with `touch(): Utime failed: Operation not
|
||||
permitted`. If you ran `php artisan test`/`tinker`/any artisan command via
|
||||
`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
|
||||
session — don't wait for a report of a broken page to catch it.
|
||||
|
||||
## Scheduled commands need a host crontab entry
|
||||
## 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 outside the container
|
||||
calls `php artisan schedule:run` on a timer. **As of 2026-07-23 this is
|
||||
configured** — root's crontab on the host runs, every minute:
|
||||
`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.
|
||||
|
||||
```cron
|
||||
* * * * * cd /mnt/rabbit-containers/servicedesk && docker compose exec -T servicedesk php artisan schedule:run >> /dev/null 2>&1
|
||||
```
|
||||
|
||||
(`sudo crontab -l -u root` to inspect/edit — it previously did not exist at all,
|
||||
which meant none of the four scheduled commands above had ever run
|
||||
automatically; ask before changing this again, since removing it silently
|
||||
breaks SLA checks, automation rules, IMAP fetching and AI ticket automation,
|
||||
and confusingly not the IMAP feature alone if you're only debugging that one.)
|
||||
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.
|
||||
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.
|
||||
|
||||
All four commands' intervals are admin-configurable (Admin > Konfiguracja —
|
||||
`schedule_sla_check_minutes`/`schedule_automation_rules_minutes`/
|
||||
|
||||
@@ -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:
|
||||
```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):
|
||||
```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
|
||||
commit the result if `public/build/` is tracked, or confirm the deploy step
|
||||
|
||||
46
README.md
46
README.md
@@ -23,9 +23,22 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
|
||||
|
||||
## 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,
|
||||
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.
|
||||
- **SLA** — per-priority response/resolution time targets; a scheduled command
|
||||
(`tickets:check-sla-breaches`, every 15 min) flags overdue tickets and can notify
|
||||
the assigned operator.
|
||||
@@ -53,6 +66,10 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
|
||||
team's queue (plus unrouted tickets and anything assigned to them) unless they're
|
||||
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
|
||||
"quick actions" (send + transition status in one click), and HTML e-mail
|
||||
templates for every ticket lifecycle event (created, status/priority/category/
|
||||
@@ -69,9 +86,12 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
|
||||
e-mail layout/footer, SMTP connection (Admin > E-MAIL), attachment limits,
|
||||
session lifetime, timezone (Admin > Konfiguracja), and LDAP connection + user
|
||||
sync + BookStack (Admin > Integracje).
|
||||
- **LDAP auth** — logins bind against an LDAP/LLDAP directory (`config/auth.php`,
|
||||
- **LDAP auth** — logins bind against a directory (`config/auth.php`,
|
||||
`config/ldap.php`); local accounts (e.g. the emergency `admin` account) fall back
|
||||
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
|
||||
immediately on a ticket lifecycle event (created, any field updated, status/
|
||||
priority/assignee/team/category changed, new public reply): AND-combined
|
||||
@@ -201,11 +221,13 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
|
||||
dashboard is hand-rolled inline-styled bar/column charts, so it needs no client
|
||||
build step beyond the CSS bundle.
|
||||
- **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`
|
||||
(same image, `php artisan reverb:start`), 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 `servicedesk`). The `servicedesk` image
|
||||
(same image, `php artisan reverb:start`) + `cron` (same image, `php artisan
|
||||
schedule:work` — runs the scheduled commands below without needing a host
|
||||
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
|
||||
the Gitea container registry whenever `Dockerfile` changes — `compose.yaml`
|
||||
just pulls a tag, it never builds locally.
|
||||
@@ -227,7 +249,7 @@ Compose-level and Laravel-level) and the LDAP/SMTP gotcha after a fresh seed.
|
||||
```
|
||||
- Fresh install / reset:
|
||||
```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,
|
||||
teams, quick actions, response/e-mail templates, branding/config with example
|
||||
@@ -243,12 +265,14 @@ src/ Laravel application
|
||||
app/Models/ Eloquent models
|
||||
app/Events/ Broadcast events (TicketQueueChanged, TicketMessagePosted)
|
||||
app/Console/Commands/ Scheduled commands (SLA breach check, automation rules, IMAP fetch,
|
||||
AI ticket triage/summary) + bookstack:tag-content
|
||||
AI ticket triage/summary) + bookstack:tag-content + hesk:import
|
||||
(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 model + sync handlers
|
||||
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/seeders/ DatabaseSeeder — reference data, no ticket data
|
||||
resources/css/ Tailwind entrypoint (needs `npm run build` after edits)
|
||||
@@ -261,4 +285,6 @@ wiki/
|
||||
client/ How-to guide for the Client role
|
||||
operator/ How-to guide for the Operator 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/`):
|
||||
|
||||
```bash
|
||||
docker compose exec servicedesk php artisan test
|
||||
docker compose exec app php artisan test
|
||||
```
|
||||
|
||||
or directly with Pest:
|
||||
|
||||
```bash
|
||||
docker compose exec servicedesk ./vendor/bin/pest
|
||||
docker compose exec app ./vendor/bin/pest
|
||||
```
|
||||
|
||||
Run a single file or filter by name:
|
||||
|
||||
```bash
|
||||
docker compose exec servicedesk php artisan test --filter=SlaBreachNotificationTest
|
||||
docker compose exec servicedesk ./vendor/bin/pest tests/Feature/TicketApiTest.php
|
||||
docker compose exec app php artisan test --filter=SlaBreachNotificationTest
|
||||
docker compose exec app ./vendor/bin/pest tests/Feature/TicketApiTest.php
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
docker compose exec servicedesk ./vendor/bin/pint
|
||||
docker compose exec app ./vendor/bin/pint
|
||||
```
|
||||
|
||||
79
install.md
79
install.md
@@ -24,7 +24,7 @@ osobne pliki, w dwóch różnych miejscach.
|
||||
- Docker + wtyczka `docker compose`.
|
||||
- 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
|
||||
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
|
||||
razie potrzeby reverse-proxy przed kontenerem).
|
||||
|
||||
@@ -90,8 +90,8 @@ QUEUE_CONNECTION=database
|
||||
`APP_KEY` wygenerujesz komendą artisan (krok 1.4) — zostaw puste w pliku.
|
||||
|
||||
`LDAP_*` i `MAIL_*` w `src/.env` są tylko **wartościami startowymi/awaryjnymi**.
|
||||
Docelowo LDAP i SMTP konfiguruje się wygodniej z poziomu **Admin > Konfiguracja**
|
||||
w samej aplikacji (patrz ramka ostrzegawcza w kroku 1.6) — ale jeśli chcesz mieć
|
||||
Docelowo LDAP konfiguruje się wygodniej z poziomu **Admin > Integracje**, a SMTP
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
```bash
|
||||
docker compose exec servicedesk composer install --no-dev --optimize-autoloader
|
||||
docker compose exec servicedesk php artisan key:generate
|
||||
docker compose exec servicedesk php artisan migrate --seed
|
||||
docker compose exec servicedesk php artisan storage:link
|
||||
docker compose exec app composer install --no-dev --optimize-autoloader
|
||||
docker compose exec app php artisan key:generate
|
||||
docker compose exec app php artisan migrate --seed
|
||||
docker compose exec app php artisan storage:link
|
||||
```
|
||||
|
||||
`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)
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
@@ -273,16 +273,35 @@ 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 crona nic nie dzieje 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). Najprościej dodać wpis crona **na
|
||||
hoście**:
|
||||
od najbliższego tyknięcia harmonogramu).
|
||||
|
||||
```cron
|
||||
* * * * * cd /ścieżka/do/repo && docker compose exec -T servicedesk php artisan schedule:run >> /dev/null 2>&1
|
||||
`compose.yaml` rozwiązuje to czwartą usługą, `cron` — tego samego obrazu
|
||||
`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
|
||||
`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
|
||||
@@ -291,8 +310,9 @@ kolejkować zadania.
|
||||
### ⚠️ Ważne: LDAP/SMTP z panelu Admina nadpisują `.env` w locie
|
||||
|
||||
`AppServiceProvider` na starcie żądania sprawdza tabelę `settings` — jeśli w
|
||||
Admin > Konfiguracja pole **host LDAP** albo **SMTP włączony + host** jest
|
||||
ustawione, **te wartości wygrywają z `.env`**, bez potrzeby restartu czy redeployu.
|
||||
Admin > Integracje pole **host LDAP** albo w Admin > Poczta **SMTP włączony +
|
||||
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**
|
||||
(`ldap.example.com`, `smtp.example.com`, `changeme-*-password`) — to znaczy, że
|
||||
@@ -301,10 +321,13 @@ adresami**, nawet jeśli w `.env` wpisałeś prawdziwe dane! Zanim oddasz system
|
||||
użytku:
|
||||
|
||||
1. Zaloguj się lokalnym kontem `admin@example.com` / `admin`.
|
||||
2. Wejdź w **Admin > Konfiguracja** i wpisz prawdziwe dane LDAP/SMTP (albo wyczyść
|
||||
pole hosta LDAP, żeby wrócić do wartości z `.env`).
|
||||
3. Użyj przycisków **„Testuj połączenie”** przy obu sekcjach, zanim zaczniesz
|
||||
polegać na logowaniu przez katalog.
|
||||
2. Wejdź w **Admin > Integracje** i wpisz prawdziwe dane LDAP (wybierz też
|
||||
właściwy **Typ katalogu** — LLDAP/OpenLDAP albo Active Directory — jeśli
|
||||
katalog to nie LLDAP; albo wyczyść pole hosta LDAP, żeby wrócić do
|
||||
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, AI)
|
||||
|
||||
@@ -328,6 +351,14 @@ 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ę.
|
||||
|
||||
---
|
||||
|
||||
## 2. Wdrożenie bezpośrednio na serwerze (Apache/Nginx, bez Dockera)
|
||||
@@ -383,7 +414,7 @@ QUEUE_CONNECTION=database
|
||||
```
|
||||
|
||||
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
|
||||
php artisan key:generate
|
||||
@@ -524,9 +555,9 @@ jednej ścieżki, analogicznie do reguły Traefika w 1.3b).
|
||||
### 2.7. Pierwsze logowanie i dalsza konfiguracja
|
||||
|
||||
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
|
||||
inaczej realnie próbują łączyć się z fałszywymi adresami), przetestuj oba
|
||||
połączenia przyciskiem „Testuj połączenie”.
|
||||
hasło, uzupełnij prawdziwe LDAP w Admin > Integracje i SMTP w Admin > Poczta
|
||||
(placeholdery z seeda inaczej realnie próbują łączyć się z fałszywymi
|
||||
adresami), przetestuj oba połączenia.
|
||||
|
||||
### 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=
|
||||
83
scripts/hesk-import/README.md
Normal file
83
scripts/hesk-import/README.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# 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 or creates a client account per requester e-mail, reusing an existing
|
||||
account (adding the `client` role if it doesn't have it yet) rather than
|
||||
duplicating.
|
||||
- Hesk staff replies are **not** linked to a real operator account (this
|
||||
script never creates operator accounts) — the reply still shows the
|
||||
correct staff name and "operator" badge via `author_name`, just without a
|
||||
clickable user behind it.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## 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 "$@"
|
||||
@@ -5,7 +5,7 @@ APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
|
||||
AUTHOR_CONTACT=helpdesk@kzbikowski.pl
|
||||
VERSION=1.3.0
|
||||
VERSION=1.4.0
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
|
||||
504
src/app/Console/Commands/ImportHeskTickets.php
Normal file
504
src/app/Console/Commands/ImportHeskTickets.php
Normal file
@@ -0,0 +1,504 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Role;
|
||||
use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
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), so a re-run (interrupted connection,
|
||||
* crashed midway, etc.) skips tickets already imported instead of
|
||||
* duplicating them. Not idempotent across *edits* on the Hesk side — this is
|
||||
* a one-time historical import, not an ongoing sync.
|
||||
*/
|
||||
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}';
|
||||
|
||||
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 = [];
|
||||
|
||||
private array $state = ['imported' => []];
|
||||
|
||||
private string $statePath;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if ($this->option('assign-teams')) {
|
||||
return $this->runAssignTeams((bool) $this->option('commit'));
|
||||
}
|
||||
|
||||
$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();
|
||||
|
||||
$tickets = DB::connection('hesk')->table('help_tickets')
|
||||
->where('email', 'like', '%@'.$domain)
|
||||
->orderBy('id')
|
||||
->when($limit, fn ($q) => $q->limit($limit))
|
||||
->get();
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s tryb: %d zgłoszeń z Heska pasuje do domeny @%s (%d już zaimportowanych wcześniej, zostaną pominięte).',
|
||||
$commit ? 'KOMMIT' : 'DRY-RUN',
|
||||
$tickets->count(),
|
||||
$domain,
|
||||
$tickets->whereIn('id', $this->state['imported'])->count(),
|
||||
));
|
||||
|
||||
$stats = ['created' => 0, 'skipped' => 0, 'skipped_unmapped_category' => 0, 'failed' => 0, 'messages' => 0, 'customers_created' => 0];
|
||||
$unmappedCategories = [];
|
||||
|
||||
$bar = $this->output->createProgressBar($tickets->count());
|
||||
$bar->start();
|
||||
|
||||
foreach ($tickets as $heskTicket) {
|
||||
$bar->advance();
|
||||
|
||||
if (in_array($heskTicket->id, $this->state['imported'], true)) {
|
||||
$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;
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
$this->table(['Miara', 'Wartość'], [
|
||||
['Zgłoszenia utworzone', $stats['created']],
|
||||
['Wiadomości/notatki utworzone', $stats['messages']],
|
||||
['Nowe konta klientów', $stats['customers_created']],
|
||||
['Pominięte (już zaimportowane)', $stats['skipped']],
|
||||
['Pominięte (kategoria bez odpowiednika)', $stats['skipped_unmapped_category']],
|
||||
['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 (! $commit) {
|
||||
$this->newLine();
|
||||
$this->comment('To był dry-run — nic nie zostało zapisane. Uruchom ponownie z --commit, żeby faktycznie zaimportować.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private function loadHeskStaffNames(): void
|
||||
{
|
||||
$this->heskStaffNames = DB::connection('hesk')->table('help_users')->pluck('name', 'id')->all();
|
||||
}
|
||||
|
||||
private function importOneTicket(object $heskTicket, array &$stats): void
|
||||
{
|
||||
$customer = $this->resolveCustomer($heskTicket->email, $heskTicket->name, $stats);
|
||||
$categoryId = $this->categoryMap[$heskTicket->category] ?? 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,
|
||||
'subject' => $this->cleanText($heskTicket->subject) ?: '(bez tematu)',
|
||||
'body' => $this->cleanText($heskTicket->message),
|
||||
'status_key' => self::STATUS_MAP[(int) $heskTicket->status] ?? 'open',
|
||||
'priority_key' => self::PRIORITY_MAP[(int) $heskTicket->priority] ?? 'medium',
|
||||
'source' => 'hesk_import',
|
||||
'last_customer_activity_at' => $heskTicket->lastchange,
|
||||
'created_at' => $heskTicket->dt,
|
||||
'updated_at' => $heskTicket->lastchange,
|
||||
]);
|
||||
|
||||
// Ticket::booted() re-saves the row right after create() to stamp a
|
||||
// checksum, which — being a normal Eloquent save() — stomps
|
||||
// updated_at back to "now". Restore the historical value via the
|
||||
// query builder so it bypasses Eloquent's timestamp handling.
|
||||
DB::table('tickets')->where('id', $ticket->id)->update(['updated_at' => $heskTicket->lastchange]);
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
$stats['created']++;
|
||||
}
|
||||
|
||||
private function heskReplies(int $heskTicketId): Collection
|
||||
{
|
||||
return DB::connection('hesk')->table('help_replies')
|
||||
->where('replyto', $heskTicketId)
|
||||
->orderBy('dt')
|
||||
->get();
|
||||
}
|
||||
|
||||
private function heskNotes(int $heskTicketId): Collection
|
||||
{
|
||||
return DB::connection('hesk')->table('help_notes')
|
||||
->where('ticket', $heskTicketId)
|
||||
->orderBy('dt')
|
||||
->get();
|
||||
}
|
||||
|
||||
private function importReply(Ticket $ticket, object $reply, User $customer, array &$stats): void
|
||||
{
|
||||
$isStaff = (int) $reply->staffid > 0;
|
||||
$authorName = $isStaff
|
||||
? ($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,
|
||||
]);
|
||||
|
||||
// Staff replies aren't linked to a real User (we deliberately don't
|
||||
// create operator accounts for imported Hesk staff — see the
|
||||
// migration script's design questions) — attachAuthor(null,
|
||||
// 'operator') still tags the role/badge correctly via author_name.
|
||||
$message->attachAuthor($isStaff ? null : $customer->id, $isStaff ? 'operator' : 'client');
|
||||
$stats['messages']++;
|
||||
}
|
||||
|
||||
private function importNote(Ticket $ticket, object $note, array &$stats): void
|
||||
{
|
||||
$message = $ticket->messages()->create([
|
||||
'author_name' => $this->heskStaffNames[$note->who] ?? 'Personel',
|
||||
'internal' => true,
|
||||
'body' => $this->cleanText($note->message),
|
||||
'created_at' => $note->dt,
|
||||
'updated_at' => $note->dt,
|
||||
]);
|
||||
$message->attachAuthor(null, 'operator');
|
||||
$stats['messages']++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds or creates the local client account for a Hesk requester e-mail.
|
||||
* Reuses an existing account (e.g. the one real admin account, or one
|
||||
* already created by an earlier ticket from the same person) rather than
|
||||
* duplicating, and only ever adds the 'client' role — never removes
|
||||
* whatever roles the account already had.
|
||||
*/
|
||||
private function resolveCustomer(string $email, ?string $name, array &$stats): User
|
||||
{
|
||||
$email = trim($email);
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
|
||||
if (! $user) {
|
||||
$user = User::query()->create([
|
||||
'name' => $name ?: $email,
|
||||
'email' => $email,
|
||||
'roles' => ['client'],
|
||||
]);
|
||||
$stats['customers_created']++;
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
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 {}
|
||||
@@ -219,6 +219,7 @@ class Panel extends Component
|
||||
|
||||
$this->ldapConfig = [
|
||||
'enabled' => Settings::bool('ldap_enabled'),
|
||||
'directoryType' => Settings::get('ldap_directory_type', 'lldap'),
|
||||
'host' => Settings::get('ldap_host'),
|
||||
'port' => Settings::get('ldap_port'),
|
||||
'baseDn' => Settings::get('ldap_base_dn'),
|
||||
@@ -1498,6 +1499,7 @@ class Panel extends Component
|
||||
public function saveLdapConfig(): void
|
||||
{
|
||||
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_port', (string) $this->ldapConfig['port']);
|
||||
Settings::set('ldap_base_dn', $this->ldapConfig['baseDn']);
|
||||
@@ -1814,8 +1816,37 @@ class Panel extends Component
|
||||
$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',
|
||||
'about' => 'O aplikacji',
|
||||
];
|
||||
|
||||
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()
|
||||
{
|
||||
return view('livewire.auth.login');
|
||||
return view('livewire.auth.login')->title(Settings::pageTitle('Logowanie'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,36 +3,52 @@
|
||||
namespace App\Livewire\Client;
|
||||
|
||||
use App\Models\Status;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
class Dashboard extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
private const PER_PAGE = 20;
|
||||
|
||||
public string $tab = 'current';
|
||||
|
||||
public string $search = '';
|
||||
|
||||
#[Computed]
|
||||
public function tickets()
|
||||
protected function baseQuery()
|
||||
{
|
||||
return Auth::user()->ticketsAsCustomer()
|
||||
->search($this->search)
|
||||
->with('subcategory.category')
|
||||
->orderByDesc('updated_at')
|
||||
->get();
|
||||
->orderByDesc('updated_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Separate named paginators (see pageName below) so switching tabs
|
||||
* doesn't reset whichever page the other tab was on.
|
||||
*/
|
||||
#[Computed]
|
||||
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]
|
||||
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
|
||||
@@ -42,6 +58,6 @@ class Dashboard extends Component
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.client.dashboard');
|
||||
return view('livewire.client.dashboard')->title(Settings::pageTitle('Moje zgłoszenia'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +209,6 @@ class NewTicket extends Component
|
||||
|
||||
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 bool $showAllOtherTickets = false;
|
||||
|
||||
private const OTHER_TICKETS_PREVIEW_COUNT = 5;
|
||||
|
||||
// Set via wire:init (see the blade view) rather than on the initial
|
||||
// render, so the BookStack HTTP call in suggestedArticles() never
|
||||
// delays the ticket page's first paint — it loads in a beat later instead.
|
||||
@@ -103,7 +107,21 @@ class TicketShow extends Component
|
||||
#[Computed]
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,6 +240,6 @@ class TicketShow extends Component
|
||||
|
||||
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\TicketService;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
@@ -41,6 +42,13 @@ class Landing extends Component
|
||||
// delays the page's first paint — it loads in a beat later instead.
|
||||
public bool $suggestedArticlesLoaded = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
if (Auth::check()) {
|
||||
$this->redirect(route('client.dashboard'), navigate: true);
|
||||
}
|
||||
}
|
||||
|
||||
public function loadSuggestedArticles(): void
|
||||
{
|
||||
$this->suggestedArticlesLoaded = true;
|
||||
@@ -187,6 +195,8 @@ class Landing extends Component
|
||||
|
||||
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'));
|
||||
}
|
||||
}
|
||||
@@ -160,6 +160,6 @@ class NewTicket extends Component
|
||||
|
||||
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\User;
|
||||
use App\Services\TicketService;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
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]
|
||||
public string $queue = 'all';
|
||||
|
||||
@@ -129,6 +145,32 @@ class Queue extends Component
|
||||
$this->sortDir = $filters['sortDir'] ?? $this->sortDir;
|
||||
$this->visibleColumns = $filters['visibleColumns'] ?? $this->visibleColumns;
|
||||
$this->selectedIds = [];
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -285,6 +327,21 @@ class Queue extends Component
|
||||
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]
|
||||
public function filteredTickets()
|
||||
{
|
||||
@@ -314,8 +371,15 @@ class Queue extends Component
|
||||
}
|
||||
|
||||
$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()],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -415,11 +479,14 @@ class Queue extends Component
|
||||
if ($key !== 'closed' && $this->filterStatus !== 'all' && Status::stageFor($this->filterStatus) === 'closed') {
|
||||
$this->filterStatus = 'all';
|
||||
}
|
||||
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function clearCustomerFilter(): void
|
||||
{
|
||||
$this->filterCustomerId = null;
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function toggleSelect(int $id): void
|
||||
@@ -496,6 +563,8 @@ class Queue extends Component
|
||||
|
||||
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\Ticket;
|
||||
use App\Models\User;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -624,6 +625,6 @@ class Stats extends Component
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.operator.stats');
|
||||
return view('livewire.operator.stats')->title(Settings::pageTitle('Statystyki'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,8 @@ class TicketShow extends Component
|
||||
// ticket-show.blade.php) — so every open resumes it, not just the
|
||||
// very first one.
|
||||
$this->ticket->resumeTimer();
|
||||
|
||||
$this->ticket->recordViewBy(Auth::user());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -773,6 +775,6 @@ class TicketShow extends Component
|
||||
// progress is saved incrementally rather than only on explicit stop.
|
||||
$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;
|
||||
|
||||
use App\Models\NotificationPreference;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -39,6 +40,7 @@ class NotificationPreferences extends Component
|
||||
|
||||
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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,26 @@ class Ticket extends Model
|
||||
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
|
||||
{
|
||||
return $this->belongsTo(Status::class, 'status_key');
|
||||
|
||||
@@ -189,6 +189,13 @@ class User extends Authenticatable implements LdapAuthenticatable
|
||||
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
|
||||
{
|
||||
return $this->hasMany(Ticket::class, 'assignee_id');
|
||||
|
||||
@@ -158,6 +158,12 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
Config::set('ldap.connections.default', $config);
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Ldap\LldapUser;
|
||||
use App\Models\User;
|
||||
use App\Models\UserField;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use LdapRecord\Models\Model as LdapModel;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
@@ -80,11 +81,26 @@ class LdapUserProvisioner
|
||||
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) {
|
||||
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) {
|
||||
Log::warning('LDAP lookup by guid failed: '.$e->getMessage());
|
||||
$byGuid = null;
|
||||
@@ -98,10 +114,10 @@ class LdapUserProvisioner
|
||||
return $this->findLdapEntryByEmail($user->email);
|
||||
}
|
||||
|
||||
protected function findLdapEntryByEmail(string $email): ?LldapUser
|
||||
protected function findLdapEntryByEmail(string $email): ?LdapModel
|
||||
{
|
||||
try {
|
||||
return LldapUser::query()->where('mail', '=', $email)->first();
|
||||
return $this->ldapUserModel()::query()->where('mail', '=', $email)->first();
|
||||
} catch (Throwable $e) {
|
||||
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 ?? [];
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Ldap\AdUser;
|
||||
use App\Ldap\LldapUser;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
@@ -229,6 +231,20 @@ class Settings
|
||||
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"),
|
||||
* applied at runtime by AppServiceProvider so every date/time displayed
|
||||
@@ -286,19 +302,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
|
||||
* 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
|
||||
{
|
||||
$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)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return 'uid';
|
||||
return static::isActiveDirectory() ? 'sAMAccountName' : 'uid';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
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 »',
|
||||
|
||||
];
|
||||
@@ -211,6 +211,63 @@ body {
|
||||
.seg-opt:has(input:checked) { background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent); }
|
||||
.seg-opt input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -341,8 +398,26 @@ body {
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page-pad { padding: 16px !important; }
|
||||
.nav { padding-left: 14px !important; padding-right: 14px !important; gap: 10px; }
|
||||
.nav-panel-label { display: none; }
|
||||
.nav { padding-left: 14px !important; padding-right: 14px !important; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
/* 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)
|
||||
to their own small trigger button by default, which overflows off the
|
||||
|
||||
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
|
||||
$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
|
||||
|
||||
@if ($user)
|
||||
@@ -26,23 +13,27 @@
|
||||
x-show="open"
|
||||
x-cloak
|
||||
class="nav-dropdown"
|
||||
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width: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)
|
||||
<a
|
||||
href="{{ $area['url'] }}"
|
||||
wire:navigate
|
||||
@click="open = false"
|
||||
class="theme-toggle-option"
|
||||
style="text-decoration:none;color:{{ $area['active'] ? 'var(--color-accent)' : 'var(--color-text)' }};font-size:12.5px"
|
||||
>{{ $area['label'] }}</a>
|
||||
@endforeach
|
||||
<div style="display:flex;flex-direction:column;gap:6px;padding:12px">
|
||||
<span style="font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $user->name }}</span>
|
||||
<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>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:2px">
|
||||
@if ($user->isClient())
|
||||
<span class="tag tag-neutral">Klient</span>
|
||||
@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 (count($areas))
|
||||
<div style="border-top:1px solid var(--color-divider)"></div>
|
||||
@endif
|
||||
<div style="border-top:1px solid var(--color-divider)"></div>
|
||||
|
||||
@if ($user && ($user->isOperator() || $user->isAdmin()))
|
||||
@if ($user->isOperator() || $user->isAdmin())
|
||||
<a
|
||||
href="{{ route('settings.notifications') }}"
|
||||
wire:navigate
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
@php
|
||||
$panelLabel = match (true) {
|
||||
request()->routeIs('client.*') => 'Panel Klienta',
|
||||
request()->routeIs('operator.*') => 'Panel Operatora',
|
||||
request()->routeIs('admin.*') => 'Panel Administratora',
|
||||
default => null,
|
||||
};
|
||||
@endphp
|
||||
@props(['area' => null])
|
||||
|
||||
<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>
|
||||
|
||||
@if ($panelLabel)
|
||||
<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-panel-switcher :area="$area" />
|
||||
|
||||
<x-theme-toggle />
|
||||
|
||||
@@ -20,6 +11,7 @@
|
||||
|
||||
@auth
|
||||
<livewire:notification-bell />
|
||||
<livewire:global-search />
|
||||
@endauth
|
||||
|
||||
<x-profile-menu />
|
||||
|
||||
@@ -27,10 +27,15 @@ $tabGroups = [
|
||||
];
|
||||
@endphp
|
||||
<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="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)
|
||||
<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>
|
||||
@@ -684,6 +689,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>
|
||||
|
||||
@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 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>
|
||||
@@ -692,7 +704,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>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>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.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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
@@ -9,8 +9,8 @@
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
<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 === 'archive') wire:click="setTab('archive')">Archiwalne ({{ $this->archiveTickets->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->total() }})</label>
|
||||
</div>
|
||||
<input class="input" type="search" placeholder="Szukaj po numerze, temacie, treści…" wire:model.live.debounce.400ms="search" style="max-width:280px">
|
||||
</div>
|
||||
@@ -32,6 +32,10 @@
|
||||
@if (($tab === 'current' ? $this->currentTickets : $this->archiveTickets)->isEmpty())
|
||||
<p class="text-muted" style="font-size:13px">Brak zgłoszeń w tej zakładce.</p>
|
||||
@endif
|
||||
|
||||
<div style="margin-top:4px">
|
||||
{{ ($tab === 'current' ? $this->currentTickets : $this->archiveTickets)->links() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
@@ -182,13 +182,18 @@
|
||||
<div class="card" style="padding:16px;gap:8px">
|
||||
<div class="card-kicker">Inne Twoje zgłoszenia</div>
|
||||
@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->displayNumber() }} — {{ $ot->subject }}</span>
|
||||
<span style="{{ $ot->statusStyle() }};flex:none">{{ $ot->statusLabel() }}</span>
|
||||
</a>
|
||||
@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
|
||||
@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>
|
||||
|
||||
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>
|
||||
@@ -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">
|
||||
<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 style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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
|
||||
@@ -14,6 +14,11 @@
|
||||
<span class="side-rail-toggle-label" x-text="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>
|
||||
|
||||
<div class="side-rail-scroll">
|
||||
@foreach ($this->queueGroups as $groupLabel => $items)
|
||||
<div style="display:flex;flex-direction:column;gap:1px">
|
||||
@@ -27,6 +32,18 @@
|
||||
@endforeach
|
||||
</div>
|
||||
@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>
|
||||
|
||||
@@ -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) < 1) wire:click="requestDeleteSelected" style="color:var(--color-danger)">Usuń zgłoszenia ({{ count($selectedIds) }})</button>
|
||||
</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">
|
||||
<span class="material-symbols-outlined" style="font-size:18px">bar_chart</span>
|
||||
Statystyki
|
||||
@@ -208,6 +229,9 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="margin-top:12px">
|
||||
{{ $this->filteredTickets->links() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<div style="display:flex;flex-direction:column;gap:16px;max-width:1180px;margin:0 auto">
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Livewire\Client\Dashboard as ClientDashboard;
|
||||
use App\Livewire\Client\NewTicket as ClientNewTicket;
|
||||
use App\Livewire\Client\TicketShow as ClientTicketShow;
|
||||
use App\Livewire\Landing;
|
||||
use App\Livewire\Operator\ClientSearch as OperatorClientSearch;
|
||||
use App\Livewire\Operator\NewTicket as OperatorNewTicket;
|
||||
use App\Livewire\Operator\Queue as OperatorQueue;
|
||||
use App\Livewire\Operator\Stats as OperatorStats;
|
||||
@@ -56,6 +57,7 @@ Route::middleware(['auth', 'role:operator'])->prefix('operator')->name('operator
|
||||
Route::get('/', OperatorQueue::class)->name('queue');
|
||||
Route::get('/new', OperatorNewTicket::class)->name('new');
|
||||
Route::get('/stats', OperatorStats::class)->name('stats');
|
||||
Route::get('/clients', OperatorClientSearch::class)->name('clients');
|
||||
Route::get('/tickets/{ticket}', OperatorTicketShow::class)->name('ticket');
|
||||
|
||||
// Fired via navigator.sendBeacon on tab/browser close (see the
|
||||
|
||||
147
src/tests/Feature/GlobalSearchAndRecentlyViewedTest.php
Normal file
147
src/tests/Feature/GlobalSearchAndRecentlyViewedTest.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\GlobalSearch;
|
||||
use App\Livewire\Operator\Queue;
|
||||
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('opening a ticket records a view that surfaces in the operator sidebar, most recent first', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('recent-view@example.com');
|
||||
$ticketA = makeTicket(['number' => '1001', 'subject' => 'First one']);
|
||||
$ticketB = makeTicket(['number' => '1002', 'subject' => 'Second one']);
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticketA]);
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticketB]);
|
||||
|
||||
$queue = Livewire::actingAs($operator)->test(Queue::class);
|
||||
$recent = $queue->instance()->recentlyViewed;
|
||||
|
||||
expect($recent->pluck('id')->all())->toBe([$ticketB->id, $ticketA->id]);
|
||||
});
|
||||
|
||||
test('re-opening the same ticket bumps it to the top instead of duplicating it', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('recent-view-bump@example.com');
|
||||
$ticketA = makeTicket(['number' => '1001']);
|
||||
$ticketB = makeTicket(['number' => '1002']);
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticketA]);
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticketB]);
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticketA]);
|
||||
|
||||
$recent = Livewire::actingAs($operator)->test(Queue::class)->instance()->recentlyViewed;
|
||||
|
||||
expect($recent->pluck('id')->all())->toBe([$ticketA->id, $ticketB->id]);
|
||||
});
|
||||
|
||||
test('global search only returns tickets the searching user is allowed to see', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('search-operator@example.com');
|
||||
$clientA = User::query()->create(['name' => 'Client A', 'email' => 'client-a@example.com', 'roles' => ['client']]);
|
||||
$clientB = User::query()->create(['name' => 'Client B', 'email' => 'client-b@example.com', 'roles' => ['client']]);
|
||||
|
||||
$ticketA = makeTicket(['number' => '2001', 'subject' => 'Unikalny temat A', 'customer_id' => $clientA->id, 'email' => $clientA->email]);
|
||||
makeTicket(['number' => '2002', 'subject' => 'Unikalny temat B', 'customer_id' => $clientB->id, 'email' => $clientB->email]);
|
||||
|
||||
// Operator sees both, scoped only by the search term.
|
||||
$operatorResults = Livewire::actingAs($operator)->test(GlobalSearch::class)
|
||||
->set('search', 'Unikalny')
|
||||
->instance()->results;
|
||||
expect($operatorResults)->toHaveCount(2);
|
||||
|
||||
// Client A only ever sees their own ticket, even when the term matches both.
|
||||
$clientResults = Livewire::actingAs($clientA)->test(GlobalSearch::class)
|
||||
->set('search', 'Unikalny')
|
||||
->instance()->results;
|
||||
expect($clientResults->pluck('id')->all())->toBe([$ticketA->id]);
|
||||
});
|
||||
|
||||
test('global search returns nothing for a blank query', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('search-blank@example.com');
|
||||
makeTicket(['number' => '3001']);
|
||||
|
||||
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)->instance()->results;
|
||||
|
||||
expect($results)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('the od: operator matches the reporter, not ticket content', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('search-od@example.com');
|
||||
$byKacper = makeTicket(['number' => '4001', 'name' => 'Kacper Zbikowski', 'email' => 'kacper@example.com', 'subject' => 'Nieistotny temat']);
|
||||
makeTicket(['number' => '4002', 'name' => 'Ktos Inny', 'email' => 'ktos@example.com', 'subject' => 'Kacper w temacie']);
|
||||
|
||||
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
|
||||
->set('search', 'od:Kacper')
|
||||
->instance()->results;
|
||||
|
||||
expect($results->pluck('id')->all())->toBe([$byKacper->id]);
|
||||
});
|
||||
|
||||
test('the temat: operator matches only the subject', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('search-temat@example.com');
|
||||
$matching = makeTicket(['number' => '4101', 'subject' => 'Awaria drukarki', 'body' => 'coś innego']);
|
||||
makeTicket(['number' => '4102', 'subject' => 'Coś innego', 'body' => 'Awaria drukarki w opisie']);
|
||||
|
||||
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
|
||||
->set('search', 'temat:drukarki')
|
||||
->instance()->results;
|
||||
|
||||
expect($results->pluck('id')->all())->toBe([$matching->id]);
|
||||
});
|
||||
|
||||
test('the treść: operator matches only ticket/message body, not the subject', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('search-tresc@example.com');
|
||||
$matching = makeTicket(['number' => '4201', 'subject' => 'Coś innego', 'body' => 'Drukarka nie działa od rana']);
|
||||
makeTicket(['number' => '4202', 'subject' => 'Drukarka znów nie działa', 'body' => 'coś innego']);
|
||||
|
||||
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
|
||||
->set('search', 'treść:drukarka')
|
||||
->instance()->results;
|
||||
|
||||
expect($results->pluck('id')->all())->toBe([$matching->id]);
|
||||
});
|
||||
|
||||
test('the numer: operator matches the ticket number', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('search-numer@example.com');
|
||||
$matching = makeTicket(['number' => '9999']);
|
||||
makeTicket(['number' => '1111']);
|
||||
|
||||
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
|
||||
->set('search', 'numer:9999')
|
||||
->instance()->results;
|
||||
|
||||
expect($results->pluck('id')->all())->toBe([$matching->id]);
|
||||
});
|
||||
|
||||
test('operators combine with AND, matching Gmail-style multi-field search', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('search-combo@example.com');
|
||||
$matching = makeTicket(['number' => '4301', 'name' => 'Kacper Zbikowski', 'subject' => 'Awaria drukarki']);
|
||||
makeTicket(['number' => '4302', 'name' => 'Kacper Zbikowski', 'subject' => 'Inny temat']);
|
||||
makeTicket(['number' => '4303', 'name' => 'Ktos Inny', 'subject' => 'Awaria drukarki']);
|
||||
|
||||
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
|
||||
->set('search', 'od:Kacper temat:drukarki')
|
||||
->instance()->results;
|
||||
|
||||
expect($results->pluck('id')->all())->toBe([$matching->id]);
|
||||
});
|
||||
|
||||
test('an unrecognized "key:value" token is treated as free text instead of being silently dropped', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('search-unrecognized@example.com');
|
||||
$matching = makeTicket(['number' => '4401', 'subject' => 'status:pilne coś']);
|
||||
|
||||
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
|
||||
->set('search', 'status:pilne')
|
||||
->instance()->results;
|
||||
|
||||
expect($results->pluck('id')->all())->toBe([$matching->id]);
|
||||
});
|
||||
47
src/tests/Feature/OperatorClientSearchTest.php
Normal file
47
src/tests/Feature/OperatorClientSearchTest.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Operator\ClientSearch;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('searching by name or email finds matching accounts, empty search shows nothing', function () {
|
||||
$operator = operatorUser('client-search-op@example.com');
|
||||
$anna = User::query()->create(['name' => 'Anna Kowalska', 'email' => 'anna.kowalska@example.com', 'roles' => ['client']]);
|
||||
User::query()->create(['name' => 'Jan Nowak', 'email' => 'jan.nowak@example.com', 'roles' => ['client']]);
|
||||
|
||||
$component = Livewire::actingAs($operator)->test(ClientSearch::class);
|
||||
expect($component->instance()->results)->toHaveCount(0);
|
||||
|
||||
$component->set('search', 'Kowalska');
|
||||
expect($component->instance()->results->pluck('id')->all())->toBe([$anna->id]);
|
||||
|
||||
$component->set('search', 'jan.nowak@example.com');
|
||||
expect($component->instance()->results)->toHaveCount(1);
|
||||
|
||||
$component->set('search', 'nobody-matches-this');
|
||||
expect($component->instance()->results)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('results include the ticket count and are capped at 20', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('client-search-op2@example.com');
|
||||
$client = User::query()->create(['name' => 'Piotr Testowy', 'email' => 'piotr@example.com', 'roles' => ['client']]);
|
||||
makeTicket(['number' => '2001', 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]);
|
||||
makeTicket(['number' => '2002', 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]);
|
||||
|
||||
foreach (range(1, 25) as $i) {
|
||||
User::query()->create(['name' => "Testowy Kandydat {$i}", 'email' => "cand{$i}@example.com", 'roles' => ['client']]);
|
||||
}
|
||||
|
||||
$component = Livewire::actingAs($operator)->test(ClientSearch::class)->set('search', 'Testowy');
|
||||
expect($component->instance()->results)->toHaveCount(20);
|
||||
|
||||
$component->set('search', 'Piotr');
|
||||
expect($component->instance()->results->first()->tickets_as_customer_count)->toBe(2);
|
||||
});
|
||||
|
||||
test('a client cannot reach the operator client-search page', function () {
|
||||
$client = User::query()->create(['name' => 'Client', 'email' => 'not-operator@example.com', 'roles' => ['client']]);
|
||||
|
||||
$this->actingAs($client)->get(route('operator.clients'))->assertForbidden();
|
||||
});
|
||||
46
src/tests/Feature/PageTitleTest.php
Normal file
46
src/tests/Feature/PageTitleTest.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
|
||||
function assertTitle(TestResponse $response, string $expectedContext): void
|
||||
{
|
||||
$response->assertOk();
|
||||
preg_match('#<title>(.*?)</title>#s', $response->getContent(), $m);
|
||||
expect($m[1] ?? null)->toBe($expectedContext.' — Servicedesk');
|
||||
}
|
||||
|
||||
test('login and landing pages get a page-specific title', function () {
|
||||
assertTitle($this->get('/login'), 'Logowanie');
|
||||
assertTitle($this->get('/'), 'Zgłoś problem');
|
||||
});
|
||||
|
||||
test('client pages carry a distinct, ticket-specific title', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$client = User::query()->create(['name' => 'Client', 'email' => 'title-client@example.com', 'roles' => ['client']]);
|
||||
$ticket = makeTicket(['number' => '6001', 'subject' => 'Drukarka nie działa', 'customer_id' => $client->id, 'email' => $client->email]);
|
||||
|
||||
assertTitle($this->actingAs($client)->get('/client'), 'Moje zgłoszenia');
|
||||
assertTitle($this->actingAs($client)->get('/client/new'), 'Nowe zgłoszenie');
|
||||
assertTitle($this->actingAs($client)->get(route('client.ticket', $ticket)), '#6001 — Drukarka nie działa');
|
||||
});
|
||||
|
||||
test('operator queue title reflects the currently selected queue', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('title-operator@example.com');
|
||||
$ticket = makeTicket(['number' => '6101', 'subject' => 'Awaria monitora']);
|
||||
|
||||
assertTitle($this->actingAs($operator)->get('/operator'), 'Otwarte');
|
||||
assertTitle($this->actingAs($operator)->get('/operator?queue=mine'), 'Moje zgłoszenia');
|
||||
assertTitle($this->actingAs($operator)->get('/operator/new'), 'Nowe zgłoszenie');
|
||||
assertTitle($this->actingAs($operator)->get('/operator/stats'), 'Statystyki');
|
||||
assertTitle($this->actingAs($operator)->get('/operator/clients'), 'Klienci');
|
||||
assertTitle($this->actingAs($operator)->get(route('operator.ticket', $ticket)), '#6101 — Awaria monitora');
|
||||
});
|
||||
|
||||
test('admin panel title reflects the currently selected tab', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
assertTitle($this->actingAs($admin)->get('/admin'), 'Kategorie');
|
||||
assertTitle($this->actingAs($admin)->get('/admin?tab=users'), 'Użytkownicy');
|
||||
});
|
||||
@@ -31,16 +31,16 @@ test('every page renders without error against fully seeded data', function () {
|
||||
$this->get('/')->assertOk()->assertSee('Jak możemy pomóc');
|
||||
$this->get('/login')->assertOk()->assertSee('Nowe zgłoszenie bez logowania');
|
||||
|
||||
$this->actingAs($client)->get('/client')->assertOk()->assertSee('Moje zgłoszenia')->assertSee('Panel Klienta');
|
||||
$this->actingAs($client)->get('/client/new')->assertOk()->assertSee('Panel Klienta');
|
||||
$this->actingAs($client)->get(route('client.ticket', $clientTicket))->assertOk()->assertSee($clientTicket->subject)->assertSee('Panel Klienta');
|
||||
$this->actingAs($client)->get('/client')->assertOk()->assertSee('Moje zgłoszenia')->assertSee('Klient');
|
||||
$this->actingAs($client)->get('/client/new')->assertOk()->assertSee('Klient');
|
||||
$this->actingAs($client)->get(route('client.ticket', $clientTicket))->assertOk()->assertSee($clientTicket->subject)->assertSee('Klient');
|
||||
|
||||
$this->actingAs($operator)->get('/operator')->assertOk()->assertSee('Otwarte')->assertSee('Panel Operatora');
|
||||
$this->actingAs($operator)->get('/operator/new')->assertOk()->assertSee($client->email)->assertSee('Panel Operatora');
|
||||
$this->actingAs($operator)->get(route('operator.ticket', $anyTicket))->assertOk()->assertSee($anyTicket->subject)->assertSee('Panel Operatora');
|
||||
$this->actingAs($operator)->get('/operator')->assertOk()->assertSee('Otwarte')->assertSee('Operator');
|
||||
$this->actingAs($operator)->get('/operator/new')->assertOk()->assertSee($client->email)->assertSee('Operator');
|
||||
$this->actingAs($operator)->get(route('operator.ticket', $anyTicket))->assertOk()->assertSee($anyTicket->subject)->assertSee('Operator');
|
||||
|
||||
foreach (['categories', 'fields', 'users', 'teams', 'user-fields', 'reply-quick-actions', 'response-templates', 'statuses', 'priorities', 'templates', 'branding', 'config', 'about'] as $tab) {
|
||||
$this->actingAs($admin)->get('/admin')->assertOk()->assertSee('Panel Administratora');
|
||||
$this->actingAs($admin)->get('/admin')->assertOk()->assertSee('Administrator');
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', $tab)
|
||||
->assertOk();
|
||||
|
||||
80
src/tests/Feature/TicketListPaginationTest.php
Normal file
80
src/tests/Feature/TicketListPaginationTest.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Client\Dashboard;
|
||||
use App\Livewire\Client\TicketShow;
|
||||
use App\Livewire\Operator\Queue;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('the operator queue paginates instead of rendering every matching ticket at once', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('queue-page@example.com');
|
||||
|
||||
foreach (range(1, 55) as $i) {
|
||||
makeTicket(['number' => str_pad($i, 4, '0', STR_PAD_LEFT)]);
|
||||
}
|
||||
|
||||
$component = Livewire::actingAs($operator)->test(Queue::class);
|
||||
|
||||
expect($component->instance()->filteredTickets)->toHaveCount(50)
|
||||
->and($component->instance()->filteredTickets->total())->toBe(55);
|
||||
|
||||
$component->call('gotoPage', 2);
|
||||
|
||||
expect($component->instance()->filteredTickets)->toHaveCount(5);
|
||||
});
|
||||
|
||||
test('changing the queue search resets the operator to page 1', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('queue-page-reset@example.com');
|
||||
|
||||
foreach (range(1, 55) as $i) {
|
||||
makeTicket(['number' => str_pad($i, 4, '0', STR_PAD_LEFT), 'subject' => 'Zgłoszenie']);
|
||||
}
|
||||
makeTicket(['number' => '9999', 'subject' => 'Unikalny temat']);
|
||||
|
||||
$component = Livewire::actingAs($operator)->test(Queue::class)->call('gotoPage', 2);
|
||||
expect($component->instance()->filteredTickets->currentPage())->toBe(2);
|
||||
|
||||
$component->set('search', 'Unikalny');
|
||||
|
||||
expect($component->instance()->filteredTickets->currentPage())->toBe(1)
|
||||
->and($component->instance()->filteredTickets)->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('the client dashboard paginates current/archive tickets separately', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$client = User::query()->create(['name' => 'Client', 'email' => 'dash-page@example.com', 'roles' => ['client']]);
|
||||
|
||||
foreach (range(1, 25) as $i) {
|
||||
makeTicket(['number' => str_pad($i, 4, '0', STR_PAD_LEFT), 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name, 'status_key' => 'new']);
|
||||
}
|
||||
foreach (range(1, 3) as $i) {
|
||||
makeTicket(['number' => '90'.$i, 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name, 'status_key' => 'closed']);
|
||||
}
|
||||
|
||||
$component = Livewire::actingAs($client)->test(Dashboard::class);
|
||||
|
||||
expect($component->instance()->currentTickets)->toHaveCount(20)
|
||||
->and($component->instance()->currentTickets->total())->toBe(25)
|
||||
->and($component->instance()->archiveTickets)->toHaveCount(3);
|
||||
});
|
||||
|
||||
test('"Inne Twoje zgłoszenia" shows only a handful until "Pokaż wszystkie" is clicked', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$client = User::query()->create(['name' => 'Client', 'email' => 'other-tickets@example.com', 'roles' => ['client']]);
|
||||
$viewed = makeTicket(['number' => '5000', 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]);
|
||||
|
||||
foreach (range(1, 8) as $i) {
|
||||
makeTicket(['number' => '60'.$i, 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]);
|
||||
}
|
||||
|
||||
$component = Livewire::actingAs($client)->test(TicketShow::class, ['ticket' => $viewed]);
|
||||
|
||||
expect($component->instance()->otherTickets)->toHaveCount(5)
|
||||
->and($component->instance()->otherTicketsCount)->toBe(8);
|
||||
|
||||
$component->call('revealAllOtherTickets');
|
||||
|
||||
expect($component->instance()->otherTickets)->toHaveCount(8);
|
||||
});
|
||||
@@ -7,7 +7,8 @@ treści (szablony, szybkie akcje, e-maile), wygląd/branding oraz integracje
|
||||
(LDAP, poczta SMTP/IMAP, BookStack, AI, API).
|
||||
|
||||
Domyślnie każde konto ląduje po zalogowaniu w panelu Klienta; przełącz się do
|
||||
panelu Administratora przez menu profilu (prawy górny róg).
|
||||
panelu Administratora suwakiem Klient/Operator/Administrator na środku
|
||||
górnego paska.
|
||||
|
||||
## Kategorie i pola dodatkowe
|
||||
|
||||
@@ -238,10 +239,12 @@ razem, zamiast być rozrzucone po różnych zakładkach.
|
||||
|
||||
## Integracje
|
||||
|
||||
- **LDAP** — host, port, base DN, bind DN + hasło, SSL, filtr użytkownika
|
||||
(`(uid={0})` domyślnie), auto-provisioning gości, ograniczenie tworzenia
|
||||
kont/zgłaszania tylko przez LDAP. Przycisk **„Testuj połączenie”** sprawdza
|
||||
bind bez zapisywania zmian.
|
||||
- **LDAP** — **Typ katalogu** (LLDAP/OpenLDAP domyślnie, albo Active
|
||||
Directory — zmienia oczekiwany schemat i domyślny atrybut logowania), host,
|
||||
port, base DN, bind DN + hasło, SSL, filtr użytkownika (`(uid={0})` dla
|
||||
LLDAP, `(sAMAccountName={0})` dla AD), auto-provisioning gości, ograniczenie
|
||||
tworzenia kont/zgłaszania tylko przez LDAP. Przycisk **„Testuj połączenie”**
|
||||
sprawdza bind bez zapisywania zmian.
|
||||
|
||||
> Po świeżej instalacji (`migrate:fresh --seed`) LDAP i SMTP zawierają
|
||||
> **przykładowe wartości** (`ldap.example.com`, `smtp.example.com`,
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
Panel klienta (`/client`) służy do zgłaszania problemów/próśb i śledzenia ich
|
||||
rozwiązania. Po zalogowaniu każde konto domyślnie ląduje właśnie tutaj — nawet jeśli
|
||||
posiada też uprawnienia operatora lub administratora (przełączysz się przez menu
|
||||
profilu w prawym górnym rogu). Dzwoneczek powiadomień w górnym pasku pokazuje
|
||||
posiada też uprawnienia operatora lub administratora (przełączysz się suwakiem
|
||||
Klient/Operator/Administrator na środku górnego paska — pokazuje tylko te panele,
|
||||
do których masz dostęp). Dzwoneczek powiadomień w górnym pasku pokazuje
|
||||
tylko **nieprzeczytane** powiadomienia — kliknięcie usuwa je z listy.
|
||||
|
||||
## Zgłaszanie nowej sprawy
|
||||
@@ -43,7 +44,10 @@ Dashboard klienta dzieli zgłoszenia na dwie zakładki:
|
||||
- **Archiwum** — zgłoszenia zamknięte.
|
||||
|
||||
Pole wyszukiwania nad listą przeszukuje numer, temat i treść zgłoszenia (oraz
|
||||
odpowiedzi w wątku).
|
||||
odpowiedzi w wątku). Obie zakładki są stronicowane (20 zgłoszeń na stronę),
|
||||
niezależnie od siebie — przełączenie zakładki nie cofa Cię na pierwszą stronę
|
||||
drugiej. **Ctrl+K**/**Cmd+K** otwiera też globalną wyszukiwarkę zgłoszeń z
|
||||
dowolnego miejsca w aplikacji (tylko Twoich zgłoszeń).
|
||||
|
||||
Otwórz dowolne zgłoszenie, by zobaczyć:
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ Panel operatora (`/operator`) to miejsce pracy z kolejką zgłoszeń: przegląd,
|
||||
odpowiadanie, zmiana statusu/priorytetu/przypisania oraz statystyki zespołu.
|
||||
|
||||
Domyślnie każde konto ląduje po zalogowaniu w panelu Klienta; przełącz się do
|
||||
panelu Operatora przez menu profilu (prawy górny róg), jeśli konto ma tę rolę.
|
||||
panelu Operatora suwakiem Klient/Operator/Administrator na środku górnego
|
||||
paska, jeśli konto ma tę rolę.
|
||||
Dzwoneczek powiadomień w górnym pasku (widoczny we wszystkich panelach) pokazuje
|
||||
Twoje **nieprzeczytane** powiadomienia, aktualizowane **na żywo** w chwili ich
|
||||
utworzenia (niezależny od tego 30-sekundowy fallback dogrywa to, co ominęłoby
|
||||
@@ -31,7 +32,20 @@ Panel główny (`/operator`) pokazuje listę zgłoszeń z zakładkami po lewej s
|
||||
widzi wszystkie zespoły; też bez zamkniętych).
|
||||
|
||||
Nie-administratorzy widzą tylko zgłoszenia swoich zespołów, zgłoszenia bez
|
||||
przypisanego zespołu, oraz wszystko przypisane bezpośrednio do nich.
|
||||
przypisanego zespołu, oraz wszystko przypisane bezpośrednio do nich. Lista
|
||||
jest stronicowana (50 zgłoszeń na stronę) — zmiana filtra/wyszukiwania/
|
||||
zakładki wraca automatycznie na pierwszą stronę.
|
||||
|
||||
Na pasku bocznym, nad zakładkami: przycisk **„Szukaj”** otwiera globalną
|
||||
wyszukiwarkę zgłoszeń (dostępną z każdej strony też pod **Ctrl+K**/**Cmd+K**)
|
||||
— oprócz zwykłego szukania po treści rozumie też operatory `od:`, `temat:`,
|
||||
`treść:` i `numer:` (np. `od:kowalski temat:drukarka`), które można łączyć.
|
||||
Niżej — **„Ostatnio przeglądane”**: ostatnie 6 zgłoszeń, które faktycznie
|
||||
otworzyłeś, najnowsze na górze.
|
||||
|
||||
**Klienci** (przycisk nad tabelą) — osobna wyszukiwarka po imieniu/nazwisku/
|
||||
e-mailu dowolnego konta (nie tylko klientów), pokazuje liczbę jego zgłoszeń i
|
||||
prowadzi od razu do kolejki przefiltrowanej do tego klienta.
|
||||
|
||||
**Filtry** nad tabelą: status, priorytet, kategoria, wyszukiwanie po numerze/
|
||||
temacie/kliencie/treści zgłoszenia i odpowiedzi w wątku. **Kolumny** można dowolnie
|
||||
|
||||
Reference in New Issue
Block a user