feat(core): detail drawer + address book list redesign

Introduces a reusable core detail-drawer primitive that slides in from the
right and loads any view via a `*-fragment(none).phtml` endpoint. Bundles the
address book list overhaul that is its first consumer.

Core additions:
- `app-detail-drawer.js` — generic drawer with stepper, focus trap, body
  scroll-lock, URL-hash deep-linking, session-expiry detection
- `app-fragment-init.js` — auto-wires tabs/lookups/confirm/file-upload/
  fslightbox inside injected HTML; consumers do not re-initialize components
- `app-focus-trap.js` — shared focus-trap + refcounted scroll-lock, used by
  both filter-drawer and detail-drawer
- `getHtml()` in `app-http.js` + `SessionExpiredError`; drawer reloads the
  page on auth redirect instead of rendering the login form in the panel
- `DetailDrawerFragmentContractTest` enforces that every `initDetailDrawer`
  consumer ships matching `*-fragment($id).php` + `*-fragment(none).phtml`

Address book list:
- Grid collapses from 9 columns to 4 (identity / context / phone / actions)
  with a two-line identity cell (avatar + name + email)
- Tenant register tabs above the grid using the `app-list-tabs` partial;
  tenant filter wired via hidden toolbar field so grid.js forwards it on
  every data fetch
- Profile body extracted to a shared partial so the full-page view and the
  new drawer fragment share the same markup
- New i18n keys for the drawer/list labels

Also refactors `app-filter-drawer` to reuse the shared focus-trap and
scroll-lock instead of maintaining its own copy, and documents the
detail-drawer convention in CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 15:22:26 +02:00
parent c14ff27c37
commit 811588290c
24 changed files with 1573 additions and 371 deletions

View File

@@ -1,12 +1,20 @@
{
"About": "Über",
"Address book": "Adressbuch",
"Call": "Anrufen",
"Call mobile": "Mobil anrufen",
"Call phone": "Telefon anrufen",
"Failed to load": "Laden fehlgeschlagen",
"From": "Von",
"Loading": "Lädt",
"More": "Mehr",
"No departments": "Keine Abteilungen",
"Open details": "Details öffnen",
"Open full page": "Vollseite öffnen",
"Quick actions": "Schnellaktionen",
"Send email": "E-Mail senden",
"Tenant / Department": "Mandant / Abteilung",
"To": "Bis",
"View in address book": "Im Adressbuch ansehen"
"View in address book": "Im Adressbuch ansehen",
"further assignments": "weitere Zuordnungen"
}

View File

@@ -1,12 +1,20 @@
{
"About": "About",
"Address book": "Address book",
"Call": "Call",
"Call mobile": "Call mobile",
"Call phone": "Call phone",
"Failed to load": "Failed to load",
"From": "From",
"Loading": "Loading",
"More": "More",
"No departments": "No departments",
"Open details": "Open details",
"Open full page": "Open full page",
"Quick actions": "Quick actions",
"Send email": "Send email",
"Tenant / Department": "Tenant / Department",
"To": "To",
"View in address book": "View in address book"
"View in address book": "View in address book",
"further assignments": "further assignments"
}

View File

@@ -135,11 +135,29 @@ class AddressBookService
);
}
$activeTenant = trim((string) ($query['tenant'] ?? ''));
$tenantTabs = array_values(array_filter(array_map(
static function (array $tenant): array {
return [
'uuid' => (string) ($tenant['uuid'] ?? ''),
'description' => (string) ($tenant['description'] ?? ''),
];
},
$tenants
), static fn (array $tab): bool => $tab['uuid'] !== ''));
usort($tenantTabs, static fn (array $a, array $b): int => strnatcasecmp(
(string) $a['description'],
(string) $b['description']
));
return [
'activeTenants' => $activeTenants,
'activeRoles' => $activeRoles,
'activeDepartments' => $activeDepartments,
'activeTenant' => $activeTenant,
'tenantItems' => $tenantItems,
'tenantTabs' => $tenantTabs,
'departments' => $departments,
'roles' => $roles,
'customFieldFilterDefinitions' => $customFieldFilterDefinitions,
@@ -191,6 +209,10 @@ class AddressBookService
$displayName = (string) ($row['email'] ?? '');
}
$primaryTenantLabel = $tenantList[0] ?? '';
$primaryDepartmentLabel = $departmentList[0] ?? '';
$extraAssignmentsCount = max(0, count($tenantList) - 1) + max(0, count($departmentList) - 1);
$rows[] = [
'uuid' => $uuid,
'display_name' => $displayName,
@@ -203,6 +225,9 @@ class AddressBookService
'tenants' => $tenantList,
'departments' => $departmentList,
'roles' => $roleList,
'primary_tenant_label' => $primaryTenantLabel,
'primary_department_label' => $primaryDepartmentLabel,
'extra_assignments_count' => $extraAssignmentsCount,
'has_avatar' => $uuid !== '' && $this->avatarService->hasAvatar($uuid),
];
}

View File

@@ -17,6 +17,7 @@ return [
// Keep canonical route stable without forcing a /index target to avoid Router redirect loops.
['path' => 'address-book', 'target' => 'address-book'],
['path' => 'address-book/data', 'target' => 'address-book/data'],
['path' => 'address-book/view-fragment', 'target' => 'address-book/view-fragment'],
// Legacy aliases for existing bookmarks/links.
['path' => 'addressbook', 'target' => 'address-book'],
['path' => 'adressbook', 'target' => 'address-book'],
@@ -70,6 +71,9 @@ return [
'modules/addressbook/css/pages/address-book-view.css',
'modules/addressbook/css/pages/address-book-banner.css',
],
'address-book-index' => [
'modules/addressbook/css/pages/address-book-index.css',
],
],
'scheduler_jobs' => [],

View File

@@ -13,6 +13,12 @@ return gridFilterSchema([
'roles' => ['type' => 'csv_strings', 'max' => 200],
],
'toolbar' => [
[
'key' => 'tenant',
'type' => 'hidden',
'input_id' => 'address-book-tenant-filter-single',
'default' => '',
],
[
'key' => 'search',
'type' => 'text',

View File

@@ -19,6 +19,8 @@ $activeTenants = $context['activeTenants'] ?? [];
$activeRoles = $context['activeRoles'] ?? [];
$activeDepartments = $context['activeDepartments'] ?? [];
$tenantItems = $context['tenantItems'] ?? [];
$tenantTabs = $context['tenantTabs'] ?? [];
$activeTenant = (string) ($context['activeTenant'] ?? '');
$departments = $context['departments'] ?? [];
$roles = $context['roles'] ?? [];
$customFieldFilterDefinitions = $context['customFieldFilterDefinitions'] ?? [];
@@ -30,6 +32,7 @@ $toolbarOptionSets = [
'role_items' => (array) $roles,
];
$toolbarFilterStateOverrides = [
'tenant' => $activeTenant,
'search' => (string) gridQueryString($query, 'search', ''),
'tenants' => array_map('strval', (array) $activeTenants),
'departments' => array_map('strval', (array) $activeDepartments),
@@ -81,6 +84,7 @@ $breadcrumbs = [
['label' => t('Address book')],
];
Buffer::set('grid_lang', json_encode(gridLang(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
Buffer::set('style_groups', json_encode(['address-book-index']));
Buffer::set(
'grid_csrf',
json_encode(['key' => $csrfKey, 'token' => $csrfToken], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)

View File

@@ -14,12 +14,39 @@ $toolbarOptionSets = is_array($toolbarOptionSets ?? null) ? $toolbarOptionSets :
$clientFilterSchema = is_array($clientFilterSchema ?? null) ? $clientFilterSchema : [];
$filterChipMeta = is_array($filterChipMeta ?? null) ? $filterChipMeta : [];
$searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
$tenantTabs = is_array($tenantTabs ?? null) ? $tenantTabs : [];
$activeTenant = (string) ($activeTenant ?? '');
$showTenantTabs = count($tenantTabs) > 1;
?>
<?php
$listTitle = t('Address book');
$listTitleActionsHtml = '';
require templatePath('partials/app-list-titlebar.phtml');
?>
<?php if ($showTenantTabs): ?>
<?php
$listTabsId = 'address-book-tabs';
$listTabsItems = [
[
'href' => 'address-book',
'label' => t('All'),
'active' => $activeTenant === '',
],
];
foreach ($tenantTabs as $tenantTab) {
$tenantUuid = (string) ($tenantTab['uuid'] ?? '');
if ($tenantUuid === '') {
continue;
}
$listTabsItems[] = [
'href' => 'address-book?tenant=' . rawurlencode($tenantUuid),
'label' => (string) ($tenantTab['description'] ?? ''),
'active' => $activeTenant === $tenantUuid,
];
}
require templatePath('partials/app-list-tabs.phtml');
?>
<?php endif; ?>
<div
class="app-list-toolbar"
id="address-book-toolbar"
@@ -220,13 +247,20 @@ $pageConfig = [
'gridLang' => $gridLang,
'labels' => [
'name' => t('Name'),
'email' => t('Email'),
'context' => t('Tenant / Department'),
'phone' => t('Phone'),
'mobile' => t('Mobile'),
'shortDial' => t('Short dial'),
'tenants' => t('Tenants'),
'departments' => t('Departments'),
'roles' => t('Roles'),
'actions' => t('Actions'),
'more' => t('More'),
'openDetails' => t('Open details'),
'sendEmail' => t('Send email'),
'call' => t('Call'),
'extraAssignments' => t('further assignments'),
'drawerClose' => t('Close'),
'drawerPrev' => t('Previous'),
'drawerNext' => t('Next'),
'drawerOpenFull' => t('Open full page'),
'drawerLoading' => t('Loading'),
'drawerError' => t('Failed to load'),
],
];
?>

View File

@@ -1,239 +1,2 @@
<?php
use MintyPHP\I18n;
$values = $user ?? [];
$name = trim(($values['first_name'] ?? '') . ' ' . ($values['last_name'] ?? ''));
$displayName = $name !== '' ? $name : ($values['email'] ?? t('Address book'));
$email = trim((string) ($values['email'] ?? ''));
$phone = trim((string) ($values['phone'] ?? ''));
$mobile = trim((string) ($values['mobile'] ?? ''));
$shortDial = trim((string) ($values['short_dial'] ?? ''));
$address = trim((string) ($values['address'] ?? ''));
$postalCode = trim((string) ($values['postal_code'] ?? ''));
$city = trim((string) ($values['city'] ?? ''));
$region = trim((string) ($values['region'] ?? ''));
$country = trim((string) ($values['country'] ?? ''));
$profileDescription = trim((string) ($values['profile_description'] ?? ''));
$jobTitle = trim((string) ($values['job_title'] ?? ''));
$hireDate = trim((string) ($values['hire_date'] ?? ''));
$hireDateLabel = '';
if ($hireDate !== '') {
$format = (strpos((string) (I18n::$locale ?? ''), 'de') === 0) ? 'd.m.Y' : 'Y-m-d';
try {
$hireDateLabel = (new \DateTimeImmutable($hireDate))->format($format);
} catch (\Exception $e) {
$hireDateLabel = $hireDate;
}
}
$avatarUuid = (string) ($values['uuid'] ?? '');
$hasAvatar = !empty($values['has_avatar']) && $avatarUuid !== '';
$initials = '';
if ($displayName !== '') {
$parts = array_filter(array_map('trim', preg_split('/\s+/', $displayName) ?: []));
$chars = '';
foreach ($parts as $part) {
if (function_exists('mb_substr')) {
$chars .= mb_substr($part, 0, 1);
} else {
$chars .= substr($part, 0, 1);
}
}
$initials = strtoupper($chars !== '' ? $chars : '?');
}
$tenantGroups = $values['tenant_groups'] ?? [];
$hasContact = ($email !== '' || $phone !== '' || $mobile !== '' || $shortDial !== '');
$hasAddress = ($address !== '' || $postalCode !== '' || $city !== '' || $region !== '' || $country !== '');
$hasOrganization = (!empty($tenantGroups));
$aboutSummary = '';
if ($profileDescription !== '') {
foreach (preg_split('/\\r\\n|\\r|\\n/', $profileDescription) as $line) {
$line = trim((string) $line);
if ($line !== '') {
$aboutSummary = $line;
break;
}
}
}
?>
<div class="app-details-container">
<section>
<div class="app-profile-card-container">
<div class="app-profile-card">
<div class="app-profile-banner"></div>
<div class="app-profile-header">
<div class="user-avatar-block avatar-round app-profile-avatar">
<?php if ($hasAvatar): ?>
<a data-fslightbox="address-book-avatar"
href="admin/users/avatar-file?uuid=<?php e($avatarUuid); ?>&size=256">
<img class="user-avatar-image" src="admin/users/avatar-file?uuid=<?php e($avatarUuid); ?>&size=160"
alt="">
</a>
<?php else: ?>
<span class="user-avatar-placeholder"><?php e($initials ?: '?'); ?></span>
<?php endif; ?>
</div>
<div class="app-profile-meta">
<hgroup>
<h2><?php e($displayName); ?></h2>
<?php if ($jobTitle !== ''): ?>
<p><small><?php e($jobTitle); ?></small></p>
<?php else: ?>
<p><a href="mailto:<?php e($email); ?>"><small><?php e($email); ?></small></a></p>
<?php endif; ?>
</hgroup>
</div>
</div>
</div>
</div>
<div class="app-profile-body-container">
<div class="app-profile-body">
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="address-book-profile">
<div class="app-tabs-nav">
<?php if ($hasContact): ?>
<button type="button" data-tab="contact" data-tab-default><small><?php e(t('Contact')); ?></small></button>
<?php endif; ?>
<?php if ($hasAddress): ?>
<button type="button" data-tab="address"><small><?php e(t('Address')); ?></small></button>
<?php endif; ?>
<?php if ($hasOrganization): ?>
<button type="button" data-tab="organization"><small><?php e(t('Organization')); ?></small></button>
<?php endif; ?>
<button type="button" data-tab="about"><small><?php e(t('About')); ?></small></button>
</div>
<?php if ($hasContact): ?>
<div data-tab-panel="contact">
<div class="grid">
<?php if ($email !== ''): ?>
<div>
<small><?php e(t('Email')); ?></small>
<p><a href="mailto:<?php e($email); ?>"><?php e($email); ?></a></p>
</div>
<?php endif; ?>
<?php if ($phone !== ''): ?>
<div>
<small><?php e(t('Phone')); ?></small>
<p><a href="tel:<?php e($phone); ?>"><?php e($phone); ?></a></p>
</div>
<?php endif; ?>
</div>
<div class="grid">
<?php if ($mobile !== ''): ?>
<div>
<small><?php e(t('Mobile')); ?></small>
<p><a href="tel:<?php e($mobile); ?>"><?php e($mobile); ?></a></p>
</div>
<?php endif; ?>
<?php if ($shortDial !== ''): ?>
<div>
<small><?php e(t('Short dial')); ?></small>
<p><?php e($shortDial); ?></p>
</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<?php if ($hasAddress): ?>
<div data-tab-panel="address">
<div>
<?php if ($address !== ''): ?>
<p><?php e($address); ?></p>
<?php endif; ?>
<?php $line2 = trim($postalCode . ' ' . $city); ?>
<?php if ($line2 !== ''): ?>
<p><?php e($line2); ?></p>
<?php endif; ?>
<?php if ($region !== ''): ?>
<p><?php e($region); ?></p>
<?php endif; ?>
<?php if ($country !== ''): ?>
<p><?php e($country); ?></p>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<?php if ($hasOrganization): ?>
<div data-tab-panel="organization">
<?php if (!empty($tenantGroups)): ?>
<table class="app-profile-table">
<thead>
<tr>
<th><?php e(t('Tenant')); ?></th>
<th><?php e(t('Departments')); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($tenantGroups as $group): ?>
<?php
$departments = $group['departments'] ?? [];
$departments = is_array($departments) ? $departments : [];
?>
<tr>
<td>
<?php e($group['label'] ?? ''); ?>
<?php if (!empty($group['is_primary'])): ?>
<small>(<?php e(t('Primary tenant')); ?>)</small>
<?php endif; ?>
</td>
<td>
<?php if (!empty($departments)): ?>
<?php e(implode(', ', $departments)); ?>
<?php else: ?>
-
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<p>-</p>
<?php endif; ?>
</div>
<?php endif; ?>
<div data-tab-panel="about">
<?php if ($hireDateLabel !== ''): ?>
<div>
<small><?php e(t('Hire date')); ?></small>
<p><?php e($hireDateLabel); ?></p>
</div>
<?php endif; ?>
<?php if ($profileDescription !== ''): ?>
<?php foreach (preg_split('/\\r\\n|\\r|\\n/', $profileDescription) as $line): ?>
<?php if (trim($line) !== ''): ?>
<p><?php e($line); ?></p>
<?php endif; ?>
<?php endforeach; ?>
<?php elseif ($hireDateLabel === ''): ?>
<p>-</p>
<?php endif; ?>
</div>
</div>
</div>
</div>
</section>
<aside id="app-details-aside-section">
<div class="app-details-aside-section">
<hgroup>
<h2><?php e(t('Contact')); ?></h2>
<p><?php e(t('Quick actions')); ?></p>
</hgroup>
<hr>
<?php if ($email !== ''): ?>
<p><a href="mailto:<?php e($email); ?>"><?php e(t('Send email')); ?></a></p>
<?php endif; ?>
<?php if ($phone !== ''): ?>
<p><a href="tel:<?php e($phone); ?>"><?php e(t('Call phone')); ?></a></p>
<?php endif; ?>
<?php if ($mobile !== ''): ?>
<p><a href="tel:<?php e($mobile); ?>"><?php e(t('Call mobile')); ?></a></p>
<?php endif; ?>
<?php if (!$hasContact): ?>
<p>-</p>
<?php endif; ?>
</div>
</aside>
</div>
require __DIR__ . '/../../templates/address-book-profile.phtml';

View File

@@ -0,0 +1,35 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Support\Guard;
$session = app(SessionStoreInterface::class)->all();
Guard::requireLogin();
Guard::requireAbilityOrForbidden(\MintyPHP\Module\AddressBook\AddressBookAuthorizationPolicy::ABILITY_VIEW);
$currentUserId = (int) ($session['user']['id'] ?? 0);
$uuid = trim((string) ($id ?? ''));
if ($uuid === '') {
http_response_code(400);
return;
}
$addressBookService = app(\MintyPHP\Module\AddressBook\Service\AddressBookService::class);
$viewContext = $addressBookService->buildViewContext($currentUserId, $uuid);
$status = (string) ($viewContext['status'] ?? '');
if ($status === 'not_found') {
http_response_code(404);
$user = null;
return;
}
if ($status === 'forbidden') {
http_response_code(403);
$user = null;
return;
}
$user = $viewContext['user'] ?? null;
if (!is_array($user)) {
http_response_code(404);
$user = null;
return;
}

View File

@@ -0,0 +1,5 @@
<?php
if (!isset($user) || !is_array($user)) {
return;
}
require __DIR__ . '/../../templates/address-book-profile.phtml';

View File

@@ -0,0 +1,239 @@
<?php
use MintyPHP\I18n;
$values = $user ?? [];
$name = trim(($values['first_name'] ?? '') . ' ' . ($values['last_name'] ?? ''));
$displayName = $name !== '' ? $name : ($values['email'] ?? t('Address book'));
$email = trim((string) ($values['email'] ?? ''));
$phone = trim((string) ($values['phone'] ?? ''));
$mobile = trim((string) ($values['mobile'] ?? ''));
$shortDial = trim((string) ($values['short_dial'] ?? ''));
$address = trim((string) ($values['address'] ?? ''));
$postalCode = trim((string) ($values['postal_code'] ?? ''));
$city = trim((string) ($values['city'] ?? ''));
$region = trim((string) ($values['region'] ?? ''));
$country = trim((string) ($values['country'] ?? ''));
$profileDescription = trim((string) ($values['profile_description'] ?? ''));
$jobTitle = trim((string) ($values['job_title'] ?? ''));
$hireDate = trim((string) ($values['hire_date'] ?? ''));
$hireDateLabel = '';
if ($hireDate !== '') {
$format = (strpos((string) (I18n::$locale ?? ''), 'de') === 0) ? 'd.m.Y' : 'Y-m-d';
try {
$hireDateLabel = (new \DateTimeImmutable($hireDate))->format($format);
} catch (\Exception $e) {
$hireDateLabel = $hireDate;
}
}
$avatarUuid = (string) ($values['uuid'] ?? '');
$hasAvatar = !empty($values['has_avatar']) && $avatarUuid !== '';
$initials = '';
if ($displayName !== '') {
$parts = array_filter(array_map('trim', preg_split('/\s+/', $displayName) ?: []));
$chars = '';
foreach ($parts as $part) {
if (function_exists('mb_substr')) {
$chars .= mb_substr($part, 0, 1);
} else {
$chars .= substr($part, 0, 1);
}
}
$initials = strtoupper($chars !== '' ? $chars : '?');
}
$tenantGroups = $values['tenant_groups'] ?? [];
$hasContact = ($email !== '' || $phone !== '' || $mobile !== '' || $shortDial !== '');
$hasAddress = ($address !== '' || $postalCode !== '' || $city !== '' || $region !== '' || $country !== '');
$hasOrganization = (!empty($tenantGroups));
$aboutSummary = '';
if ($profileDescription !== '') {
foreach (preg_split('/\\r\\n|\\r|\\n/', $profileDescription) as $line) {
$line = trim((string) $line);
if ($line !== '') {
$aboutSummary = $line;
break;
}
}
}
?>
<div class="app-details-container">
<section>
<div class="app-profile-card-container">
<div class="app-profile-card">
<div class="app-profile-banner"></div>
<div class="app-profile-header">
<div class="user-avatar-block avatar-round app-profile-avatar">
<?php if ($hasAvatar): ?>
<a data-fslightbox="address-book-avatar"
href="admin/users/avatar-file?uuid=<?php e($avatarUuid); ?>&size=256">
<img class="user-avatar-image" src="admin/users/avatar-file?uuid=<?php e($avatarUuid); ?>&size=160"
alt="">
</a>
<?php else: ?>
<span class="user-avatar-placeholder"><?php e($initials ?: '?'); ?></span>
<?php endif; ?>
</div>
<div class="app-profile-meta">
<hgroup>
<h2><?php e($displayName); ?></h2>
<?php if ($jobTitle !== ''): ?>
<p><small><?php e($jobTitle); ?></small></p>
<?php else: ?>
<p><a href="mailto:<?php e($email); ?>"><small><?php e($email); ?></small></a></p>
<?php endif; ?>
</hgroup>
</div>
</div>
</div>
</div>
<div class="app-profile-body-container">
<div class="app-profile-body">
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="address-book-profile">
<div class="app-tabs-nav">
<?php if ($hasContact): ?>
<button type="button" data-tab="contact" data-tab-default><small><?php e(t('Contact')); ?></small></button>
<?php endif; ?>
<?php if ($hasAddress): ?>
<button type="button" data-tab="address"><small><?php e(t('Address')); ?></small></button>
<?php endif; ?>
<?php if ($hasOrganization): ?>
<button type="button" data-tab="organization"><small><?php e(t('Organization')); ?></small></button>
<?php endif; ?>
<button type="button" data-tab="about"><small><?php e(t('About')); ?></small></button>
</div>
<?php if ($hasContact): ?>
<div data-tab-panel="contact">
<div class="grid">
<?php if ($email !== ''): ?>
<div>
<small><?php e(t('Email')); ?></small>
<p><a href="mailto:<?php e($email); ?>"><?php e($email); ?></a></p>
</div>
<?php endif; ?>
<?php if ($phone !== ''): ?>
<div>
<small><?php e(t('Phone')); ?></small>
<p><a href="tel:<?php e($phone); ?>"><?php e($phone); ?></a></p>
</div>
<?php endif; ?>
</div>
<div class="grid">
<?php if ($mobile !== ''): ?>
<div>
<small><?php e(t('Mobile')); ?></small>
<p><a href="tel:<?php e($mobile); ?>"><?php e($mobile); ?></a></p>
</div>
<?php endif; ?>
<?php if ($shortDial !== ''): ?>
<div>
<small><?php e(t('Short dial')); ?></small>
<p><?php e($shortDial); ?></p>
</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<?php if ($hasAddress): ?>
<div data-tab-panel="address">
<div>
<?php if ($address !== ''): ?>
<p><?php e($address); ?></p>
<?php endif; ?>
<?php $line2 = trim($postalCode . ' ' . $city); ?>
<?php if ($line2 !== ''): ?>
<p><?php e($line2); ?></p>
<?php endif; ?>
<?php if ($region !== ''): ?>
<p><?php e($region); ?></p>
<?php endif; ?>
<?php if ($country !== ''): ?>
<p><?php e($country); ?></p>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<?php if ($hasOrganization): ?>
<div data-tab-panel="organization">
<?php if (!empty($tenantGroups)): ?>
<table class="app-profile-table">
<thead>
<tr>
<th><?php e(t('Tenant')); ?></th>
<th><?php e(t('Departments')); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($tenantGroups as $group): ?>
<?php
$departments = $group['departments'] ?? [];
$departments = is_array($departments) ? $departments : [];
?>
<tr>
<td>
<?php e($group['label'] ?? ''); ?>
<?php if (!empty($group['is_primary'])): ?>
<small>(<?php e(t('Primary tenant')); ?>)</small>
<?php endif; ?>
</td>
<td>
<?php if (!empty($departments)): ?>
<?php e(implode(', ', $departments)); ?>
<?php else: ?>
-
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<p>-</p>
<?php endif; ?>
</div>
<?php endif; ?>
<div data-tab-panel="about">
<?php if ($hireDateLabel !== ''): ?>
<div>
<small><?php e(t('Hire date')); ?></small>
<p><?php e($hireDateLabel); ?></p>
</div>
<?php endif; ?>
<?php if ($profileDescription !== ''): ?>
<?php foreach (preg_split('/\\r\\n|\\r|\\n/', $profileDescription) as $line): ?>
<?php if (trim($line) !== ''): ?>
<p><?php e($line); ?></p>
<?php endif; ?>
<?php endforeach; ?>
<?php elseif ($hireDateLabel === ''): ?>
<p>-</p>
<?php endif; ?>
</div>
</div>
</div>
</div>
</section>
<aside id="app-details-aside-section">
<div class="app-details-aside-section">
<hgroup>
<h2><?php e(t('Contact')); ?></h2>
<p><?php e(t('Quick actions')); ?></p>
</hgroup>
<hr>
<?php if ($email !== ''): ?>
<p><a href="mailto:<?php e($email); ?>"><?php e(t('Send email')); ?></a></p>
<?php endif; ?>
<?php if ($phone !== ''): ?>
<p><a href="tel:<?php e($phone); ?>"><?php e(t('Call phone')); ?></a></p>
<?php endif; ?>
<?php if ($mobile !== ''): ?>
<p><a href="tel:<?php e($mobile); ?>"><?php e(t('Call mobile')); ?></a></p>
<?php endif; ?>
<?php if (!$hasContact): ?>
<p>-</p>
<?php endif; ?>
</div>
</aside>
</div>

View File

@@ -0,0 +1,143 @@
/* Address book list — dense identity layout */
#address-book-grid .gridjs-tr {
vertical-align: middle;
}
.addr-identity-cell {
display: inline-flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.addr-identity-avatar {
width: 40px;
height: 40px;
flex-shrink: 0;
}
.addr-identity-text {
display: inline-flex;
flex-direction: column;
min-width: 0;
line-height: 1.25;
}
.addr-identity-name {
font-weight: 600;
font-size: 0.95rem;
color: var(--app-color-text, inherit);
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 28ch;
}
.addr-identity-name:hover {
text-decoration: underline;
}
.addr-identity-email {
color: var(--app-color-muted, #6b7280);
font-size: 0.82rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 28ch;
}
.addr-context-cell {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
color: var(--app-color-text, inherit);
font-size: 0.88rem;
}
.addr-context-primary {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 20ch;
}
.addr-context-secondary {
color: var(--app-color-muted, #6b7280);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 20ch;
}
.addr-context-separator {
color: var(--app-color-muted, #9ca3af);
flex-shrink: 0;
}
.addr-context-extra {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.75rem;
height: 1.25rem;
padding: 0 0.375rem;
border-radius: 9999px;
background: var(--app-color-subtle, #f3f4f6);
color: var(--app-color-muted, #6b7280);
font-size: 0.72rem;
font-weight: 600;
margin-left: 0.25rem;
flex-shrink: 0;
}
.addr-context-empty,
.addr-phone-empty {
color: var(--app-color-muted, #9ca3af);
}
.addr-phone-cell {
color: var(--app-color-text, inherit);
text-decoration: none;
font-variant-numeric: tabular-nums;
font-size: 0.88rem;
}
.addr-phone-cell:hover {
text-decoration: underline;
}
.addr-action-cell {
display: inline-flex;
gap: 0.25rem;
justify-content: flex-end;
}
.addr-action-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.375rem;
color: var(--app-color-muted, #6b7280);
text-decoration: none;
transition: background 0.12s, color 0.12s;
}
.addr-action-button:hover {
background: var(--app-color-subtle, #f3f4f6);
color: var(--app-color-text, #111827);
}
@media (max-width: 900px) {
.addr-identity-name,
.addr-identity-email,
.addr-context-primary,
.addr-context-secondary {
max-width: 100%;
}
}

View File

@@ -1,7 +1,8 @@
import { initMultiSelect } from '/js/components/app-multiselect-init.js';
import { initMultiSelectCascade } from '/js/components/app-multiselect-cascade.js';
import { initDetailDrawer } from '/js/components/app-detail-drawer.js';
import { createListPageModule } from '/js/core/app-list-page-module.js';
import { escapeHtml, buildBadgeList, gridFilterMultiCsv, gridFilterText, withCurrentListReturn } from '/js/pages/app-list-utils.js';
import { escapeHtml, gridFilterMultiCsv, gridFilterText, withCurrentListReturn } from '/js/pages/app-list-utils.js';
createListPageModule({
configId: 'address-book-index',
@@ -53,52 +54,111 @@ createListPageModule({
name: labels.name || 'Name',
sort: true,
formatter: (cell, row) => {
const nameValue = typeof cell === 'object' && cell !== null
? (cell.name ?? '')
: cell;
const avatarUuid = typeof cell === 'object' && cell !== null
? (cell.avatar_uuid ?? '')
: '';
const nameValue = cell?.name ?? '';
const email = cell?.email ?? '';
const avatarUuid = cell?.avatar_uuid ?? '';
const name = escapeHtml(nameValue || '-');
const uuid = row?.cells?.[uuidIndex]?.data;
const editUrl = uuid ? withCurrentListReturn(new URL(`address-book/view/${uuid}`, appBase).toString()) : '';
const nameHtml = editUrl
? `<a class="app-grid-link-cell" href="${escapeHtml(editUrl)}">${name}</a>`
: `<span>${name}</span>`;
const nameNode = editUrl
? `<a class="app-grid-link-cell addr-identity-name" href="${escapeHtml(editUrl)}" data-drawer-trigger>${name}</a>`
: `<span class="addr-identity-name">${name}</span>`;
const emailNode = email
? `<span class="addr-identity-email">${escapeHtml(email)}</span>`
: '';
let avatarNode;
if (!avatarUuid) {
const initials = escapeHtml(initialsForName(nameValue));
return gridjs.html(`<span class="grid-name-cell"><span class="grid-avatar grid-avatar-placeholder">${initials}</span>${nameHtml}</span>`);
avatarNode = `<span class="grid-avatar grid-avatar-placeholder addr-identity-avatar">${initials}</span>`;
} else {
const src = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=64`, appBase).toString();
const full = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=256`, appBase).toString();
avatarNode = `<a data-fslightbox="address-book-avatars" href="${full}"><img class="grid-avatar addr-identity-avatar" src="${src}" alt="" loading="lazy"></a>`;
}
const src = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=64`, appBase).toString();
const full = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=256`, appBase).toString();
return gridjs.html(`<span class="grid-name-cell"><a data-fslightbox="address-book-avatars" href="${full}"><img class="grid-avatar" src="${src}" alt="" loading="lazy"></a>${nameHtml}</span>`);
return gridjs.html(`<span class="addr-identity-cell">${avatarNode}<span class="addr-identity-text">${nameNode}${emailNode}</span></span>`);
},
},
{
name: labels.context || 'Tenant / Department',
sort: false,
formatter: (cell) => {
const tenant = escapeHtml(String(cell?.tenant || ''));
const department = escapeHtml(String(cell?.department || ''));
const extra = Number(cell?.extra || 0);
if (!tenant && !department && !extra) {
return gridjs.html('<span class="addr-context-cell addr-context-empty"></span>');
}
const parts = [];
if (tenant) {
parts.push(`<span class="addr-context-primary">${tenant}</span>`);
}
if (department) {
parts.push(`<span class="addr-context-separator" aria-hidden="true">·</span><span class="addr-context-secondary">${department}</span>`);
}
if (extra > 0) {
const extraLabel = escapeHtml(labels.extraAssignments || 'further assignments');
parts.push(`<span class="addr-context-extra" title="+${extra} ${extraLabel}">+${extra}</span>`);
}
return gridjs.html(`<span class="addr-context-cell">${parts.join('')}</span>`);
},
},
{
name: labels.phone || 'Phone',
sort: false,
formatter: (cell) => {
const number = String(cell || '').trim();
if (!number) {
return gridjs.html('<span class="addr-phone-empty"></span>');
}
const tel = number.replace(/[^+0-9]/g, '');
const safe = escapeHtml(number);
return gridjs.html(`<a class="addr-phone-cell" href="tel:${escapeHtml(tel)}">${safe}</a>`);
},
},
{
name: labels.actions || 'Actions',
sort: false,
formatter: (cell, row) => {
const uuid = row?.cells?.[uuidIndex]?.data;
if (!uuid) {
return gridjs.html('');
}
const viewUrl = escapeHtml(withCurrentListReturn(new URL(`address-book/view/${uuid}`, appBase).toString()));
const email = String(cell?.email || '').trim();
const openLabel = escapeHtml(labels.openDetails || 'Open details');
const mailLabel = escapeHtml(labels.sendEmail || 'Send email');
const actions = [
`<a class="addr-action-button" href="${viewUrl}" data-tooltip="${openLabel}" data-tooltip-pos="left" aria-label="${openLabel}"><i class="bi bi-box-arrow-up-right"></i></a>`,
];
if (email) {
const safeMail = escapeHtml(email);
actions.push(`<a class="addr-action-button" href="mailto:${safeMail}" data-tooltip="${mailLabel}" data-tooltip-pos="left" aria-label="${mailLabel}"><i class="bi bi-envelope"></i></a>`);
}
return gridjs.html(`<span class="addr-action-cell">${actions.join('')}</span>`);
},
},
{ name: labels.email || 'Email', sort: true },
{ name: labels.phone || 'Phone', sort: false },
{ name: labels.mobile || 'Mobile', sort: false },
{ name: labels.shortDial || 'Short dial', sort: false },
{ name: labels.tenants || 'Tenants', sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) },
{ name: labels.departments || 'Departments', sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) },
{ name: labels.roles || 'Roles', sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) },
],
sortColumns: [null, 'display_name', 'email', null, null, null, null, null, null],
sortColumns: [null, 'display_name', null, null, null],
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [
row.uuid,
{
name: row.display_name,
avatar_uuid: row.has_avatar ? row.uuid : '',
},
row.email,
row.phone,
row.mobile,
row.short_dial,
row.tenants,
row.departments,
row.roles,
]),
mapData: (data) => data.data.map((row) => {
const phone = (row.phone && String(row.phone).trim()) || (row.mobile && String(row.mobile).trim()) || '';
return [
row.uuid,
{
name: row.display_name,
email: row.email || '',
avatar_uuid: row.has_avatar ? row.uuid : '',
},
{
tenant: row.primary_tenant_label || '',
department: row.primary_department_label || '',
extra: row.extra_assignments_count || 0,
},
phone,
{ email: row.email || '' },
];
}),
search: gridSearch,
filterSchema,
extraFilters: customFilters,
@@ -118,6 +178,13 @@ createListPageModule({
mode: 'drawer',
chipMeta: filterChipMeta,
watchInputs: ['#address-book-search'],
preserveFilterParamsOnReset: ['tenant'],
drawer: {
countExcludeParams: ['tenant'],
},
clearMetaOptions: {
preserveFilterParams: ['tenant'],
},
},
}) || {};
@@ -131,6 +198,23 @@ createListPageModule({
});
});
initDetailDrawer({
gridConfig,
triggerSelector: '[data-drawer-trigger]',
rowUuidAttr: 'uuid',
fetchUrl: (uuid) => new URL(`address-book/view-fragment/${uuid}`, appBase).toString(),
fullUrl: (uuid) => withCurrentListReturn(new URL(`address-book/view/${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 gridConfig;
},
});