feat(core): UserProfileViewService + admin/users detail drawer

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>
This commit is contained in:
2026-04-22 15:49:10 +02:00
parent 21c331cd06
commit c1cf9f94bb
13 changed files with 254 additions and 113 deletions

View File

@@ -1,7 +1,8 @@
import { createListPageModule } from '../core/app-list-page-module.js';
import { initUsersListPage } from './app-users-list.js';
import { bindBulkVisibility } from '../components/app-bulk-selection.js';
import { buildUrl, badgeHtml, escapeHtml, buildBadgeList } from './app-list-utils.js';
import { initDetailDrawer } from '../components/app-detail-drawer.js';
import { buildUrl, badgeHtml, escapeHtml, buildBadgeList, withCurrentListReturn } from './app-list-utils.js';
import { showAsyncFlash, withLoading } from '../components/app-async-flash.js';
createListPageModule({
@@ -61,7 +62,17 @@ createListPageModule({
},
},
{ name: 'UUID', hidden: true },
{ name: labels.firstName || 'First name', sort: true },
{
name: labels.firstName || 'First name',
sort: true,
formatter: (cell, row) => {
const name = escapeHtml(String(cell || '').trim() || '-');
const uuid = row?.cells?.[uuidIndex]?.data;
if (!uuid) { return gridjs.html(`<span>${name}</span>`); }
const editUrl = escapeHtml(withCurrentListReturn(new URL(`admin/users/edit/${uuid}`, appBase).toString()));
return gridjs.html(`<a class="app-grid-link-cell" href="${editUrl}" data-drawer-trigger>${name}</a>`);
},
},
{ name: labels.lastName || 'Last name', sort: true },
{ name: labels.email || 'Email', sort: true },
{
@@ -140,7 +151,7 @@ createListPageModule({
rowDataset: (row) => ({
uuid: row?.cells?.[uuidIndex]?.data,
}),
rowInteraction: { linkColumn: 2 },
rowInteraction: { linkColumn: false },
rowDblClick: {
getUrl: (rowData) => {
if (!canEditRow(rowData)) {
@@ -190,6 +201,23 @@ createListPageModule({
buttonSelector: '[data-users-bulk]',
});
initDetailDrawer({
gridConfig,
triggerSelector: '[data-drawer-trigger]',
rowUuidAttr: 'uuid',
fetchUrl: (uuid) => new URL(`admin/users/view-fragment/${uuid}`, appBase).toString(),
fullUrl: (uuid) => withCurrentListReturn(new URL(`admin/users/edit/${uuid}`, appBase).toString()),
hashPrefix: 'user',
labels: {
close: labels.drawerClose || 'Close',
previous: labels.drawerPrev || 'Previous',
next: labels.drawerNext || 'Next',
openFull: labels.drawerOpenFull || 'Open full page',
loading: labels.drawerLoading || 'Loading',
error: labels.drawerError || 'Failed to load',
},
});
return usersGridConfig;
},
});