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>
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>
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>
- Edit page: managers see 'Delete handover' button in aside with confirm dialog
- Delete handler: POST action=delete → deleteByIds → redirect to list
- Flash scope set to null (global) so toast shows reliably after redirect
regardless of locale prefix or query string in URL
- i18n: added Delete handover / Delete this handover? / Handover deleted (de+en)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tabs: replace open/closed with active (draft+in_progress+under_review)
and done (completed+archived) — records now appear in the correct tab
Create wizard assign mode: redirect to list with flash message after
assigning instead of opening edit page
i18n: add In progress / Done tab labels and wizard strings (de + en)
Nikita Soldatov note: user does not exist in DB — needs to be created
via admin user management
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
HandoverServiceTest: 30 new cases covering:
- assign(): happy path, draft→in_progress transition, permission check,
zero assignedTo, not found, email sent, due date passed to repo
- submitForReview(): assignee and manager paths, non-assignee denied,
wrong status (under_review/completed), revision created, email sent
- resendNotification(): happy path, permission denied, no assignee
- statusLabel/Variant for under_review
HandoverNotificationServiceTest: 9 new cases covering:
- notifyAssigned: sends correct template+vars, skips on empty/null email,
fallback dash for empty debitor and due_date
- notifyReviewRequested: sends to manager, skips on no email,
fallback dash for empty assignee name
48 tests total, all passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MintyPHP DB returns rows as $row[$table][$field], so tenant users
come back as $u['u']['id'] not $u['id']. Added fallback for both.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New statuses: under_review
- New DB fields: assigned_to, assigned_by, assigned_at, due_date (migration 012)
- HandoverNotificationService: emails on assign and review-request
- HandoverService: assign(), submitForReview(), resendNotification()
- Assign page: manager selects employee + due date, resend notification
- Create wizard: manager can assign during creation (step 1)
- Edit page: "Submit for review" button for assignee, assign link for manager
- List page: open/closed tabs, non-managers see only their assigned handovers
- Email templates: handover_assigned + handover_review_requested (de/en)
- i18n keys added for de and en
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds helpdesk/dashboard route, empty page/view, Overview nav group in the
sidebar, and Dashboard i18n keys (de + en). No content yet.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fresh installs now come up with working dev test connections for
Microsoft SSO and Business Central without manual configuration:
- init.sql appends the live breadcrumb + icoreon tenants (idempotent
by uuid), Felix as a 6th user with all five roles, the live
departments, plus tenant_auth_microsoft (SSO enabled for
breadcrumb), tenant_auth_ldap stubs, and the global Microsoft
shared-app credentials. The MusterMandant demo seed is preserved
as tenant 1.
- A new helpdesk migration 011 seeds the per-tenant BC connection
rows in helpdesk_tenant_settings and the helpdesk.bc_* fallback
settings — module-owned data stays in module migrations.
- .env.example sets APP_CRYPTO_KEY so the AES-256-GCM blobs in the
seed (Microsoft client secret, BC basic auth passwords) actually
decrypt out-of-the-box. Verified by decrypting all four blobs
end-to-end through Crypto::decryptString.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Commit 4890a28 folded templates/partials/app-flash.phtml into
app-toast-stack.phtml, which the base layout now mounts itself. 14
helpdesk views still required the deleted partial and crashed every
helpdesk page with a 500 (Failed opening required app-flash.phtml).
Remove the redundant requires; the unified toast stack continues to
render flash messages via the layout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seven translation keys were orphaned by the recent user-lifecycle
cockpit work (commits 144d841, cc2cf3a, 157eb18) and now have zero
callers in pages/, modules/, or core/:
* "Run lifecycle now" — was the <details>-summary title
* "Run user lifecycle now" — was the <details>-button label
* "This runs the lifecycle policy immediately and may deactivate or
delete users." — was the <details>-blockquote warning
* "User lifecycle logs" — was the embedded panel's title
* "Purge user lifecycle logs" — was the panel's purge-button label
* "Purge entries older than 365 days?" — was the panel's purge confirm
* "Lifecycle audit entry not found" — was the deleted view($id).php
flash key
The aside-actions partial uses different, still-active keys
("Run policy now", "Purge logs", "Run user lifecycle now?") so no
visible string lost. Removed from all four i18n files
(core + audit-module, de + en) so translation parity stays balanced
and the architecture-test "i18n parity" check stays green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "Last run" KPI tile stayed empty after a manual policy run, even
though the run completed successfully. Two distinct bugs were
involved:
1. The dashboard read latestSystemRun() from the audit log filtered by
trigger_type='system'. UserLifecycleService::run() never sets that
value — it uses 'manual' for actor-triggered runs and 'cron' for
scheduled ones. The query never matched anything.
2. Even with the right trigger_type, the audit log only writes per-user
entries (logDeactivate / logDelete / logDeleteFailure). A run that
processes zero users — including most cron ticks on a healthy
tenant — leaves no trace, so the tile would still show "—" after a
correct execution.
Both bugs share one root cause: run-trigger state was being inferred
from audit-log details, but those are two semantically different
things. Audit log answers "what did the run do?". A "last run" tile
answers "did the run happen?".
This commit moves run-trigger state to the core settings table and
keeps the audit log strictly for per-user events:
* Two new keys in core/Service/Settings/SettingKeys —
USER_LIFECYCLE_LAST_RUN_AT_KEY and USER_LIFECYCLE_LAST_RUN_STATUS_KEY.
* SettingsUserLifecycleGateway gains recordLastRun() and getLastRun().
UserSettingsGateway exposes them as recordLifecycleLastRun() /
getLifecycleLastRun() so UserLifecycleService can call through its
existing dependency without growing its constructor.
* UserLifecycleService::run() writes both keys in finally — every time
the lock was acquired, regardless of whether any user was processed
and regardless of whether the run finished cleanly. Status reflects
$result['ok'] ('success' / 'failed').
* UserLifecyclePolicyDashboardService gains a lastRun() reader. Action
page now sources the KPI tile from this core service instead of the
audit interface — so the tile works even when the audit module is
disabled.
* The audit-side lastRun() / latestSystemRun() / their tests are
removed (YAGNI). Phase 4 (activity feed) can rebuild from the audit
filter grid without a special method.
Behaviorally: a no-op run now records "Last run: just now · ✓ Success"
in the cockpit, exactly as expected.
All six quality gates green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cleans up two leftover redundancies after the Phase-1 cockpit
foundation landed in cc2cf3a:
* The audit-list panel embedded inside the settings page rendered
its own "User lifecycle logs" titlebar — a redundant heading
below the page-level titlebar that already says exactly that.
The embedded panel now renders the filter toolbar + grid
directly. Page-level title carries the context.
* The Run-Now action existed twice — inline in a <details>-card
inside the form and again in the aside Quick-Actions list. The
inline version is gone; aside is the single discoverable home
for policy-level danger actions, consistent with how Phase 1
introduced Purge logs there too.
* The orphan $lastRunSummary string in the action and view stays
removed accordingly. KPI tile "Last run" still carries the
relative-time + status hint, so no information is lost — just
surfaced once instead of twice.
Three architecture-test lists updated to match the panel's new
shape, each with an inline comment so future readers see why the
panel is intentionally absent:
* DetailActionPolicyContractFiles.migratedConfirmFiles drops the
user-lifecycle settings view (its danger action delegates to the
aside-actions partial, already in this list).
* ListUiSharedPartialsContractTest.purgeTitlebarTemplateFiles drops
the panel (no titlebar of its own anymore).
* ListTitlebarContractFiles.titlebarTemplateFiles drops the panel
for the same reason.
All six quality gates green; behaviour-identical to a user with the
required permission (purge + run-now both still available, just
sourced from the aside).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 of the Stripe-style policy-cockpit redesign for the user-lifecycle
settings page. Pure server-rendering — no async, no JS components, no
sparklines yet (those land in later phases).
Adds a four-tile KPI row above the configuration form (Last run,
Deactivated/30d, Deleted/30d, Pending deletion/7d), populates the
previously empty aside with three quick actions (Run policy now,
Purge logs, Policy reference link), and surfaces a relative-time +
status hint under the existing Run-Now collapsible.
Module-isolation is preserved through a new read-side contract:
* core/Service/Audit/UserLifecycleAuditDashboardInterface — read-only
pendant to the existing write-side UserLifecycleAuditInterface.
Methods: lastRun(), summaryByAction(int days), countActionInWindow(...).
* core/Service/Audit/NullUserLifecycleAuditDashboard — fail-closed
default when the audit module is disabled. KPI tiles 1-3 then
render "—"; tile 4 (pending deletion) keeps working because it
lives in the core domain.
* modules/audit/.../Service/UserLifecycleAuditDashboardService — the
module's implementation; reads through the existing
UserLifecycleAuditRepository (extended with three new aggregation
queries: lastRun, countByActionStatusSinceTimestamp, countSinceTimestamp).
* AuditContainerRegistrar binds the interface to the module impl;
registerContainer.php registers the Null fallback before module
bindings, mirroring how the write-side audit interface is wired.
The new core service UserLifecyclePolicyDashboardService computes
the pending-deletion-window count from the users table directly
(no audit dependency) — defensive when both policy days are 0
(returns 0 rather than running an unbounded query).
New shared template partial templates/partials/app-kpi-row.phtml is
generic — accepts a $kpiTiles array of {label, count, icon, iconTone,
href, tooltip} and reuses the existing app-tile primitive. Other
settings pages can pick it up without ceremony.
Includes:
* PHPUnit tests for both new services (happy path + Null-fallback +
policy-disabled edge cases).
* AuditModuleIsolationContractTest allowlist extended for the new
interface and module service.
* 14 new translation keys in both default_de.json and default_en.json
(i18n parity verified).
All six quality gates green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to commits 144d841 (Embed user lifecycle audit into settings
page) + 8c07c2c (Revert "fix(user-lifecycle-panel): restore
filter-options population").
The audit page move turned the user-lifecycle list-page into a slot
template included by pages/admin/settings/user-lifecycle. Slot
templates run during the view phase, where MintyPHP forbids DB calls
("Database can only be used in MintyPHP action", DB.php). The
populated actor / action / status / trigger filter dropdowns —
previously fed by UserLifecycleAuditService::filterOptions() during
the action phase of the now-deleted index().php — therefore had to
go away and were not replaced. The service method and its
underlying UserLifecycleAuditRepository::listFilterOptions() became
dead code in the same commit but were kept; this cleans them up.
Removed:
* UserLifecycleAuditService::filterOptions(int) — single caller was
the deleted index() action.
* UserLifecycleAuditRepository::listFilterOptions(int) — single
caller was the service method above.
* Stale phpstan-baseline.neon entry for the service method.
* Now-unused RepositoryArrayHelper import in the repository.
Sister services (Api / System / Import) still use their own
filterOptions() — their list-pages remain action-driven and can
populate dropdowns from the DB. The constant FILTER_OPTIONS_LIMIT_MAX
in UserLifecycleAuditRepository is kept; listPaged() reuses it as a
generic upper bound for multi-value filter inputs.
The reduced-fidelity actor filter in the settings panel (URL-pinned
IDs only, no DB-side enumeration) is the architecturally correct
trade-off for the slot-based isolation. A future provider mechanism
on the slot manifest could restore richer filter population without
breaking module isolation, but that is out of scope here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to commit 144d841 (Embed user lifecycle audit into settings
page). The original move from a standalone audit page to a settings
panel silently dropped UserLifecycleAuditService::filterOptions()
from the panel template. The service method was kept (still defined
in UserLifecycleAuditService.php) but had zero callers — dead code,
and three concrete UX/data regressions:
* Actor filter dropdown is empty: previously populated with
display_name + email + (deleted)-marker for every actor that
appeared in lifecycle events; after the move only actor IDs
already pinned in the URL via ?actor_user_ids= are listed, with
bare "User #N" labels instead of human-readable names.
* DB-only enum values are no longer surfaced: action/status/trigger
filter dropdowns are populated only from the PHP enum cases.
Migration drift values present in the DB but absent from the enum
silently disappear from the filter UI.
* Active-actor-fallback lost the (deleted) suffix. An actor ID in
the URL with no matching DB row used to be labeled "User #N
(deleted)" — now just "User #N", losing the lifecycle hint.
Restores the three filter-options merge loops verbatim from the
pre-144d841 implementation: defensive enum-merge for actions /
statuses / triggers, full actor enumeration from filterOptions['actors']
with display_name/email/exists handling, and the (deleted) suffix
fallback. UserLifecycleAuditService::filterOptions() is once again
consumed; sister services (Api/System/Import) keep their existing
usage unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The drawer chrome used to be built in JS via a 50-line innerHTML string
and needed every caller to plumb through a labels object sourced from
per-page PHP config. That diverged from the codebase convention — the
confirm dialog, search dialog and session warning are all PHP partials
mounted once in templates/default.phtml. The drawer now follows the
same pattern: templates/partials/app-detail-drawer.phtml renders the
markup with t() labels, default.phtml mounts it inside the logged-in
shell, and the JS only attaches behavior via the [data-detail-drawer]
selector.
Knock-on cleanup: ensureDrawerElement and resolveLabels disappear from
the JS, all three initDetailDrawer callers (admin/users, address book,
helpdesk debitor) drop their labels parameter, and the matching dead
'drawerClose'/'drawerPrev'/… entries leave the per-page PHP grid configs.
The drawer also gains a clean fallback when the partial is missing
(console warn + null return), so logged-out edges can't crash.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the toIntIds() rollout to service and repository layers:
- Drops a third private duplicate (UserCustomFieldValueService::normalizeTenantIds)
- Collapses the two-line intval+filter pattern inside UserAssignmentService,
UserAuthorizationPolicy, SsoUserLinkService, DepartmentService, and
UserCustomFieldValueService
- Replaces inline patterns in 4 repositories (RolePermissionRepository,
RoleAssignableRoleRepository, UserWriteRepository, DepartmentRepository,
UserCustomFieldValueRepository, UserCustomFieldValueOptionRepository)
- Simplifies the notifications sanitizeTenantIds trait body
Tenant-area files deliberately untouched per parallel ongoing work on
the tenant-logo refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Consolidates the scattered `array_values(array_unique(array_map('intval',
$x)))` + manual positive-filter pattern behind a single lenient helper
`toIntIds(mixed $value): array` in core/Support/helpers/array.php.
- `RepositoryArrayHelper::sanitizePositiveIds()` now delegates (keeps
strict array-input contract + existing tests intact).
- Drops two private duplicates: `UserProfileViewService::normalizeIds()`
and `AddressBookService::normalizeIds()`.
- Replaces 12 inline occurrences across admin action pages with the
helper, cutting boilerplate by 3-5 lines per site.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reduce admin/system-info to a single focused status view: overall
status banner, components list from existing health checks, and an
environment meta table. Drops the Modules and Permissions tabs —
those were dev-diagnostics already available via CLI.
- Remove SystemInfoService + its test; wire the action directly to
SystemHealthService and build meta inline
- Extend SystemHealthService with per-check label + description so
the UI speaks in customer terms while the CLI doctor path stays
compatible
- Rebuild the view on existing app-stats-table + app-empty-state
primitives; add a small app-status-banner component for the
headline signal
- Align help-center nav label and i18n keys (de/en)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrates the event-details section to the .app-details-card summary +
container pattern with outcome badge in the summary meta slot, and
truncates request ID / IP hash / user-agent hash for readable inline
display. Same content, consistent with the rest of the detail-page
design language.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Unifies the breakpoint system and fixes cross-layer CSS bugs that kept
the old mobile hide rules from ever taking effect.
Breakpoints:
- Document canonical xs/sm/md/lg/xl/2xl scale in variables.base.css
- Replace 968px one-offs with 1024px (app-details, app-page-editor)
- Make details tab-panel max-width responsive (min(70ch, 100%))
Topbar (< md):
- Hide breadcrumb in components layer so cross-layer overrides actually win
- Dock search icon alongside right-column icons via flex layout
- Add xs (400px) tightening for hit-area preservation
- Hide brand divider alongside hidden brand name
- Switch hover states to theme-aware --app-dropdown-hover-background-color
Mobile drawer:
- Icon-bar now vertical like desktop (was horizontal top strip, effectively invisible)
- Sidebar shifted 52px right so icon-bar and sidebar slide in as one 280px unit
- Add breathing room above first nav item (20px top padding)
List tabs:
- Rewrite .app-list-tabs to horizontal scroll with hidden scrollbar, snap,
and mask-image gradient fades; add initListTabs to scroll active into view
- Register as runtime component in app-init.js
List toolbar:
- Stack wide controls (multi-select, search/text inputs) full-width below sm,
keep compact controls at natural width
Footer:
- New dedicated app-footer.css with mobile-first stacking, border-top separator,
theme-aware hover; remove redundant rules from app-shell.css
Notification dropdown:
- Width with min(360px, 100vw - 2rem) and min-width: 0 override global
details > ul rule; switch to position: fixed below md so it hugs the
viewport right edge instead of the non-rightmost bell button
Content spacing:
- Add padding-block-start to .app-main-content on mobile (was 0)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refactors the address-book detail partial (templates/partials/app-user-profile.phtml)
toward a Stripe/Linear aesthetic: the banner + avatar still carry identity,
but the header now also carries meta chips (primary tenant, department,
hire date) and primary CTAs (Send email, Call). Email/phone/mobile move
out of a tab into an always-visible contact stack directly under the
identity block, with icon-led rows and a hover-revealed copy button.
Tabs reduce to Address / About / Organization, with Organization only
appearing for multi-tenant users — single-tenant assignments are fully
communicated through chips. The detail aside is gone; main content is
single-column and more focused.
Introduces web/js/components/app-copy-field.js — a generic, server-
markup-driven copy-to-clipboard button component wired from app-init.js.
The markup is rendered by the PHP partial (role-compatible, SSR-safe);
the component only attaches the click handler and drives icon-swap
feedback via an is-copied class.
Address-book index page now loads the 'address-book' CSS group alongside
'address-book-index' so the detail drawer (which mounts the same profile
partial inline) gets the correct styles.
Contact rows use inline-block + vertical-align centering inside the
clickable link; the field-label tooltip lives on a <span> wrapper rather
than the <i> itself (Bootstrap icons render their glyph via ::before, and
the tooltip rule would otherwise cap it). Hover reveals the copy button
on pointer devices; @media (hover: none) shows it permanently on touch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the global app_theme, app_theme_user and app_primary_color settings
from the admin/settings area and the underlying service/cache/API/i18n/docs
layers. The tenant columns (tenants.primary_color, default_theme,
allow_user_theme) become the single source of truth for appearance.
Branding helpers are tenant-only and fall back to a hardcoded 'light' theme
with no brand color when no tenant context is available (login, error,
pre-session pages). The APP_THEME env var is removed.
Scope:
- UI: Appearance tab gone from /admin/settings; tenant edit form drops the
global-fallback color preview.
- Service: SettingsAppGateway no longer exposes setting-backed accessors
for theme/user-theme/primary-color. Theme catalog utilities (listThemes,
isAllowedTheme, normalizeTheme, resolveDefaultTheme) stay and are used
across Tenant / Auth / User flows; resolveDefaultTheme now hardcodes
'light' with a catalog fallback (no global/env lookup).
- AdminSettingsService: buildPageData, sanitization, audit snapshot, apply
step and cache payload are all stripped of the three keys. Dead helper
applyAppPrimaryColor + normalizePrimaryColor removed.
- Cache: SettingCacheService HOT_PATH_KEYS drops the three keys.
- API: /settings (GET/PUT) and /settings/public (GET) no longer expose
appearance fields; OpenAPI schemas pruned accordingly.
- Audit redaction list shortened.
- DB: db/init/init.sql seed rows removed. No migration for existing
installs — legacy rows stay inert.
- i18n + docs: setting.app_theme, setting.app_theme_user,
setting.app_primary_color removed from de+en locales; reference and
explanation docs updated.
- Tests: covering tests for the removed methods pruned; ThemeResolutionTest
rewritten for tenant-only semantics. One unrelated PHP-CS-Fixer issue in
UserRegistrar.php cleaned up to keep QG-006 green.
Workflow: .agents/runs/SETTINGS-APPEARANCE-TENANT-ONLY/ (gitignored).
Gates: QG-001..003, QG-006, QG-008 all pass (1985 tests, 28764 assertions).
Reviews: code, security, acceptance all pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Align CustomField-Schicht mit core/Repository/Access-Muster. Vier
Repositories (Tenant-Definition, Tenant-Option, User-Value, User-Value-Option)
bekommen je ein Interface, werden von static auf instance umgestellt und
ueber CustomFieldRepositoryFactory injiziert.
TenantCustomFieldService und UserCustomFieldValueService injizieren die
Repository-Interfaces statt statisch aufzurufen. CustomFieldServicesFactory
reicht Instanzen durch. AddressBookService (Modul) bekommt
TenantCustomFieldOptionRepositoryInterface per Container — Interface-Kopplung
verbessert Modul-Isolation gegenueber dem vorherigen statischen Aufruf.
Kein Verhaltenswechsel, keine SQL-Aenderung, keine Service-Signatur-Aenderung.
Alle tenant_id-Parameter erhalten (GR-SEC-009, Security-Review SR-002).
Oeffnet den Weg fuer Cluster B1 (TenantCustomFieldService-Tests) und B2
(UserCustomFieldValueService-Tests), die nun mit createMock() moeglich sind.
Nebenbei zwei PHPStan-Folge-Findings korrigiert:
- Veralteter Ignore-Eintrag fuer findById aus phpstan-baseline.neon entfernt
- Redundanter is_array-Check in TenantCustomFieldOptionRepository entfernt
(durch typisierte Interface-Signatur abgedeckt)
Gates: QG-001 (1945 Tests, 28581 Assertions) / QG-002 / QG-003 / QG-006 pass.
Workflow: .agents/runs/CUSTOMFIELD-DI-REFACTOR-001/
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Loose ends surfaced by comparing the three drawer consumers side by side:
- Core i18n was missing `Open full page`, `Loading`, `Failed to load` —
admin/users showed English strings in the German UI because those labels
resolve from core, not module catalogs.
- Helpdesk tickets page config did not pass drawer labels; the JS fell back
to its hard-coded English defaults. Labels now wired via `t(...)`.
- CLAUDE.md notes the `fetchUrl` single-expression convention (required so
the architecture test can statically locate the fragment path) and
documents the `onContentLoaded` hook for module-specific runtime
(helpdesk's async communication feed uses it).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrates the ticket detail view (communication feed + ticket metadata aside)
into the core detail drawer pattern. Click a ticket number in the customer's
support tab → drawer slides in with the full ticket thread; arrow icons step
through the current filter page; ↗ opens the full page.
- `modules/helpdesk/templates/helpdesk-ticket-detail.phtml` extracts the
body (communication feed container + metadata aside + i18n JSON) so the
full page and the fragment render from the same source.
- `ticket-fragment($id).php` / `(none).phtml` reuse BcODataGateway::getTicket
and enforce the same ABILITY_ACCESS guard as the full page. Connection
errors render inline without aborting the fragment.
- Route `helpdesk/ticket-fragment/{id}` declared in module.php.
- `helpdesk-detail.js` wires the tickets grid: ticket-no formatter emits
`data-drawer-trigger`, `linkColumn` disabled, `initDetailDrawer` called
with `onContentLoaded → initHelpdeskTicket(contentEl)` so the async
communication feed loads inside the drawer just like on the full page.
- `fetchUrl`/`fullUrl` written as single-expression arrows so the
DetailDrawerFragmentContractTest can discover the consumer and validate
the fragment endpoint files exist — the test now covers all three drawer
consumers (address book, admin users, helpdesk tickets).
The full-page ticket view is unchanged for users who deep-link directly;
only the router-to-body wiring moved through the partial.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extracts the user profile read-view into a core service + shared partial so
modules no longer duplicate the assignment/scope logic, and migrates the
admin/users list as the drawer's second consumer.
Consolidation:
- `core/Service/User/UserProfileViewService` owns the single read-path that
resolves tenants/departments/roles for a user UUID, enforces scope-aware
department visibility, and returns a consistent `{status, user}` shape.
- `templates/partials/app-user-profile.phtml` is the shared render template;
takes `$profileKey` for storage namespace + lightbox grouping.
- `AddressBookService::buildViewContext()` shrinks to a one-line delegation
and drops its `UserAssignmentService` dependency. The old
`modules/addressbook/templates/address-book-profile.phtml` is removed.
admin/users migration:
- `pages/admin/users/view-fragment($id).php` + `(none).phtml` use the core
service and enforce `ABILITY_ADMIN_USERS_VIEW`.
- `admin-users-index.js` replaces the grid-native `linkColumn` on the first
name with a formatter that emits `data-drawer-trigger`; `linkColumn` is
disabled so the drawer click handler wins. `fullUrl` points at the edit
form — drawer → edit is one click.
- Drawer labels wired via page config.
The new `DetailDrawerFragmentContractTest` validated the users fragment
endpoint automatically — no manual test additions were needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>