New "Customer analytics" page under Monitoring: surfaces who is actively in
contact vs. who has gone quiet, so the team can reach out before a customer
slips away.
- KPI tiles: total / active / no-contact / never-in-contact
- "Customers without recent contact" — longest silence first
- "Top customers by communication" — most ticket activity in the period
- Configurable no-contact threshold (helpdesk settings, default 60 days)
CustomerEngagementService aggregates the global ticket snapshot per customer
(same single-pull model as RiskRadarService — no N+1 to BC). Last contact is
the most recent ticket activity; silence beyond the threshold flags the
customer. Revenue ranking deferred (BC OData exposes no per-customer ledger).
All within modules/helpdesk/. Tests + PHPStan green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Security module now calls Flash::add() directly (for 'warning' type),
so PHPStan no longer reports it as unused — the baseline entry became unmatched.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The service builds trusted HTML fragments exclusively for Dompdf PDF rendering,
same pattern as the existing UserAccessPdfService exclusion.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Schema snapshot on existing handovers may predate the field type change,
so the conditional check was false and tenantUsers was empty.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- select multiple, no placeholder option (was causing two empty entries)
- value stored as JSON array string e.g. ["1","3"]
- on render decode JSON back to array for selected check
- both create and edit actions read field as array
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds 'user-select' to FIELD_TYPES so it appears in the schema editor
dropdown as 'Benutzerauswahl' / 'User selection'. Preview renders a
placeholder select. Translations added to both i18n files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New schema field type 'user-select' renders a dropdown of active tenant
users. Action files (create + edit) load tenant users when the schema
contains at least one user-select field and pass them to _form.phtml.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Select had name="domain_no" but JS was looking for input[name="domain_no"].
Moved name to a hidden input #wizard-domain-no; select syncs into it on
change; manual text input syncs into it on input event.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the domain API returns an empty list for a selected customer, the
domain select is replaced with two text inputs: domain name (required)
and URL (optional). JS syncs the manual values into the existing hidden
domain_no/domain_url fields so the server-side validation and save logic
is unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Using resolver 127.0.0.11 (Docker internal DNS) with set $php_upstream
forces nginx to re-resolve the php hostname dynamically instead of caching
the IP at startup — eliminates 502 after php container is recreated.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
QG-009 was failing on GitHub Actions runner because ~/.codex does not
exist on ubuntu-latest and CODEX_HOME is not set. The check is a
local-developer tool only — CI runners do not have Codex installed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
css-contract-check.sh expects @layer pages { (block syntax),
not @layer pages; (statement syntax) — regex captures up to
the first whitespace or { so the semicolon was included in the
detected layer name.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Required by css-contract-check.sh — all files under pages/ must declare
their layer near the top of the file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Same fix as qa-required.yaml — css-contract-check.sh calls rg on
the host runner, not inside Docker.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CSS/docs/JS contract checks call rg directly on the host runner,
not inside Docker, so ripgrep must be present on the ubuntu runner.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>