refactor: align addressbook module to MintyPHP\Module\AddressBook namespace

Migrates addressbook service classes from core namespace
(MintyPHP\Service\AddressBook\*) to proper module namespace
(MintyPHP\Module\AddressBook\Service\*), consistent with the
bookmarks module pattern.

Moved to module:
- AddressBookService → modules/addressbook/lib/Module/AddressBook/Service/
- AddressBookServicesFactory → modules/addressbook/lib/Module/AddressBook/Service/
- AddressBookServiceTest → modules/addressbook/tests/Module/AddressBook/Service/
- Pages, templates, CSS, JS all in module directory

All module PHP classes now live under MintyPHP\Module\<Name>\*,
enforced by ModuleStructureContractTest::testModuleClassesUseModuleNamespace.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 22:20:54 +01:00
parent 4871c6032e
commit c328067aa6
24 changed files with 373 additions and 114 deletions

View File

@@ -0,0 +1,42 @@
<?php
namespace MintyPHP\Module\AddressBook;
use MintyPHP\Service\Access\AuthorizationDecision;
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
use MintyPHP\Service\Access\PermissionService;
/**
* Authorization policy for address book abilities.
*
* Owns the 'addressbook.view' ability and delegates to the
* 'address_book.view' permission in the Core PermissionService.
*/
final class AddressBookAuthorizationPolicy implements AuthorizationPolicyInterface
{
public const ABILITY_VIEW = 'addressbook.view';
public function __construct(
private readonly PermissionService $permissionService
) {
}
public function supports(string $ability): bool
{
return $ability === self::ABILITY_VIEW;
}
public function authorize(string $ability, array $context = []): AuthorizationDecision
{
$actorUserId = (int) ($context['actor_user_id'] ?? 0);
if ($actorUserId <= 0) {
return AuthorizationDecision::deny(403, 'forbidden');
}
if (!$this->permissionService->userHas($actorUserId, PermissionService::ADDRESS_BOOK_VIEW)) {
return AuthorizationDecision::deny(403, 'forbidden');
}
return AuthorizationDecision::allow();
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace MintyPHP\Module\AddressBook;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Container\ContainerRegistrar;
use MintyPHP\Module\AddressBook\Service\AddressBookService;
use MintyPHP\Module\AddressBook\Service\AddressBookServicesFactory;
use MintyPHP\Service\CustomField\CustomFieldServicesFactory;
use MintyPHP\Service\Directory\DirectoryServicesFactory;
use MintyPHP\Service\Tenant\TenantScopeService;
use MintyPHP\Service\User\UserServicesFactory;
final class AddressBookContainerRegistrar implements ContainerRegistrar
{
public function register(AppContainer $container): void
{
$container->set(AddressBookServicesFactory::class, static fn (AppContainer $c): AddressBookServicesFactory => new AddressBookServicesFactory(
$c->get(UserServicesFactory::class),
$c->get(DirectoryServicesFactory::class),
$c->get(CustomFieldServicesFactory::class),
$c->get(TenantScopeService::class)
));
$container->set(AddressBookService::class, static fn (AppContainer $c): AddressBookService => $c
->get(AddressBookServicesFactory::class)
->createAddressBookService());
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace MintyPHP\Module\AddressBook\Providers;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
/**
* Provides address book data for the layout navigation context.
*
* Contributes the 'addressbook.nav' key to $layoutNav with URL, active filters,
* and people groups from the session.
*/
final class AddressBookLayoutProvider implements LayoutContextProvider
{
public function provide(array $session, AppContainer $container): array
{
// Read query params for active filter state
$query = [];
try {
$query = requestInput()->queryAll();
} catch (\Throwable) {
// fail-open: may not be available in CLI context
}
return [
'addressbook.nav' => [
'url' => lurl('address-book'),
'activeSearch' => trim((string) ($query['search'] ?? '')),
'activeTenants' => appNormalizeStringList($query['tenants'] ?? ''),
'activeDepartments' => appNormalizePositiveIntList($query['departments'] ?? ''),
'activeRoles' => appNormalizePositiveIntList($query['roles'] ?? ''),
'activeCustomFilters' => self::normalizeCustomFilterQuery($query),
'peopleGroups' => is_array($session['available_departments_by_tenant'] ?? null)
? $session['available_departments_by_tenant']
: [],
],
];
}
/**
* Normalize address-book custom field query keys/values.
*
* @param array<string, mixed> $rawQuery
* @return array<string, string|list<int>>
*/
public static function normalizeCustomFilterQuery(array $rawQuery): array
{
$normalized = [];
foreach ($rawQuery as $rawKey => $rawValue) {
$key = strtolower(trim((string) $rawKey));
if ($key === '') {
continue;
}
if (preg_match('/^cf_[a-f0-9-]{36}$/', $key)) {
$value = trim((string) $rawValue);
if ($value !== '') {
$normalized[$key] = $value;
}
continue;
}
if (preg_match('/^cfm_[a-f0-9-]{36}$/', $key)) {
$ids = appNormalizePositiveIntList($rawValue);
if ($ids) {
$normalized[$key] = $ids;
}
continue;
}
if (preg_match('/^cfd_[a-f0-9-]{36}_(from|to)$/', $key)) {
$value = trim((string) $rawValue);
$dt = \DateTimeImmutable::createFromFormat('Y-m-d', $value);
if ($dt && $dt->format('Y-m-d') === $value) {
$normalized[$key] = $value;
}
}
}
ksort($normalized, SORT_STRING);
return $normalized;
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace MintyPHP\Module\AddressBook\Providers;
use MintyPHP\App\Module\Contracts\SearchResourceProvider;
/**
* Provides the address-book search resource for the global search.
*/
final class AddressBookSearchProvider implements SearchResourceProvider
{
public function resources(): array
{
return [
'address-book' => [
'label' => t('Address book'),
'permission' => 'address_book.view',
'count_sql' => "select count(*) from users where active = 1 and (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{tenantFilter}}",
'preview_sql' => "select uuid, first_name, last_name, email from users where active = 1 and (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{tenantFilter}} order by last_name, first_name limit ?",
'result_sql' => "select uuid, first_name, last_name, email from users where active = 1 and (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{tenantFilter}} order by last_name, first_name",
'tenant_filter' => 'and exists (select 1 from user_tenants ut where ut.user_id = users.id and ut.tenant_id in (???))',
],
];
}
public function mapResultItem(string $resourceKey, array $row): ?array
{
if ($resourceKey !== 'address-book') {
return null;
}
$title = trim(($row['first_name'] ?? '') . ' ' . ($row['last_name'] ?? ''));
$subtitle = (string) ($row['email'] ?? '');
$uuid = (string) ($row['uuid'] ?? '');
if ($title === '' && $subtitle === '') {
return null;
}
return [
'title' => $title !== '' ? $title : $subtitle,
'subtitle' => $subtitle,
'url' => lurl('address-book/view/' . $uuid),
'icon' => 'bi-people',
];
}
public function listUrl(string $resourceKey, string $encodedSearch): string
{
if ($resourceKey !== 'address-book') {
return '';
}
return lurl('address-book') . '?search=' . $encodedSearch;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace MintyPHP\Module\AddressBook\Providers;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\Contracts\SessionProvider;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Service\User\UserTenantContextService;
/**
* Populates session with department-by-tenant hierarchy data used by the
* address book aside panel.
*/
final class AddressBookSessionProvider implements SessionProvider
{
public function populate(array $user, AppContainer $container): void
{
$userId = (int) ($user['id'] ?? 0);
if ($userId <= 0) {
unset($_SESSION['available_departments_by_tenant']);
return;
}
$tenantContext = $container->get(UserTenantContextService::class);
$sessionStore = $container->get(SessionStoreInterface::class);
if (!$tenantContext instanceof UserTenantContextService || !$sessionStore instanceof SessionStoreInterface) {
return;
}
$sessionStore->set(
'available_departments_by_tenant',
$tenantContext->getAvailableDepartmentsByTenant($userId)
);
}
public function clear(): void
{
unset($_SESSION['available_departments_by_tenant']);
}
}

View File

@@ -0,0 +1,359 @@
<?php
namespace MintyPHP\Module\AddressBook\Service;
use MintyPHP\Repository\CustomField\TenantCustomFieldOptionRepository;
use MintyPHP\Service\Access\RoleService;
use MintyPHP\Service\CustomField\UserCustomFieldValueService;
use MintyPHP\Service\Org\DepartmentService;
use MintyPHP\Service\Tenant\TenantScopeService;
use MintyPHP\Service\Tenant\TenantService;
use MintyPHP\Service\User\UserAccountService;
use MintyPHP\Service\User\UserAssignmentService;
use MintyPHP\Service\User\UserAvatarService;
class AddressBookService
{
public function __construct(
private readonly UserAccountService $userAccountService,
private readonly UserAssignmentService $userAssignmentService,
private readonly TenantService $tenantService,
private readonly DepartmentService $departmentService,
private readonly RoleService $roleService,
private readonly TenantScopeService $scopeGateway,
private readonly UserCustomFieldValueService $customFieldValueService,
private readonly UserAvatarService $avatarService
) {
}
public function buildIndexContext(int $currentUserId, array $query): array
{
$activeTenants = $this->splitCommaValues($query['tenants'] ?? '');
$activeRoles = $this->splitCommaValues($query['roles'] ?? '');
$activeDepartments = $this->splitCommaValues($query['departments'] ?? '');
$tenantIds = $this->normalizeIds($this->scopeGateway->getUserTenantIds($currentUserId));
$tenants = $this->tenantService->list();
if ($tenantIds) {
$allowedTenantMap = array_fill_keys($tenantIds, true);
$tenants = array_values(array_filter(
$tenants,
static function (array $tenant) use ($allowedTenantMap): bool {
$tenantId = (int) ($tenant['id'] ?? 0);
return $tenantId > 0 && isset($allowedTenantMap[$tenantId]);
}
));
} elseif ($this->scopeGateway->isStrict()) {
$tenants = [];
}
$tenantItems = array_map(
static fn (array $tenant): array => [
'id' => (string) ($tenant['uuid'] ?? ''),
'description' => (string) ($tenant['description'] ?? ''),
],
$tenants
);
$departments = $tenantIds
? $this->departmentService->listByTenantIds($tenantIds)
: ($this->scopeGateway->isStrict() ? [] : $this->departmentService->list());
$roles = $this->roleService->listActive();
$customFieldFilterSpec = $this->customFieldValueService->extractCustomFieldFilterSpec($query, $currentUserId);
$customFieldFilterDefinitions = $this->normalizeDefinitions(
$customFieldFilterSpec['definitions']
);
$customFieldFilterQuery = $customFieldFilterSpec['query'];
$customFieldDefinitionIds = [];
foreach ($customFieldFilterDefinitions as $definition) {
$definitionId = (int) ($definition['id'] ?? 0);
if ($definitionId > 0) {
$customFieldDefinitionIds[] = $definitionId;
}
}
$customFieldDefinitionIds = array_values(array_unique($customFieldDefinitionIds));
$customFieldOptions = TenantCustomFieldOptionRepository::listByDefinitionIds(
$customFieldDefinitionIds,
true
);
$customFieldOptionsByDefinition = [];
foreach ($customFieldOptions as $option) {
$definitionId = (int) ($option['definition_id'] ?? 0);
if ($definitionId <= 0) {
continue;
}
$customFieldOptionsByDefinition[$definitionId] ??= [];
$customFieldOptionsByDefinition[$definitionId][] = [
'id' => (string) ((int) ($option['id'] ?? 0)),
'description' => (string) ($option['label'] ?? ''),
];
}
$tenantLabelById = [];
foreach ($tenants as $tenant) {
$tenantId = (int) ($tenant['id'] ?? 0);
if ($tenantId <= 0) {
continue;
}
$tenantLabelById[$tenantId] = (string) ($tenant['description'] ?? '');
}
foreach ($customFieldFilterDefinitions as &$definition) {
$definitionId = (int) ($definition['id'] ?? 0);
$tenantId = (int) ($definition['tenant_id'] ?? 0);
$definition['options'] = $customFieldOptionsByDefinition[$definitionId] ?? [];
$definition['tenant_label'] = $tenantLabelById[$tenantId] ?? '';
}
unset($definition);
$tenantDepartmentMap = [];
$departmentMapByTenantId = [];
foreach ($departments as $department) {
$departmentId = (int) ($department['id'] ?? 0);
$departmentTenantId = (int) ($department['tenant_id'] ?? 0);
if ($departmentId <= 0 || $departmentTenantId <= 0) {
continue;
}
$departmentMapByTenantId[$departmentTenantId] ??= [];
$departmentMapByTenantId[$departmentTenantId][] = $departmentId;
}
foreach ($tenants as $tenant) {
$tenantId = (int) ($tenant['id'] ?? 0);
$tenantUuid = (string) ($tenant['uuid'] ?? '');
if ($tenantId <= 0 || $tenantUuid === '') {
continue;
}
$tenantDepartmentMap[$tenantUuid] = array_map(
'strval',
$departmentMapByTenantId[$tenantId] ?? []
);
}
return [
'activeTenants' => $activeTenants,
'activeRoles' => $activeRoles,
'activeDepartments' => $activeDepartments,
'tenantItems' => $tenantItems,
'departments' => $departments,
'roles' => $roles,
'customFieldFilterDefinitions' => $customFieldFilterDefinitions,
'customFieldFilterQuery' => $customFieldFilterQuery,
'tenantDepartmentMap' => $tenantDepartmentMap,
];
}
public function buildGridResult(int $currentUserId, array $query): array
{
$limit = (int) ($query['limit'] ?? 10);
$offset = (int) ($query['offset'] ?? 0);
$search = trim((string) ($query['search'] ?? ''));
$order = (string) ($query['order'] ?? 'last_name');
$dir = (string) ($query['dir'] ?? 'asc');
$tenant = trim((string) ($query['tenant'] ?? ''));
$tenants = $query['tenants'] ?? '';
$departments = $query['departments'] ?? '';
$roles = $query['roles'] ?? '';
$customFieldFilterSpec = $this->customFieldValueService->extractCustomFieldFilterSpec($query, $currentUserId);
$result = $this->userAccountService->listPaged([
'limit' => $limit,
'offset' => $offset,
'search' => $search,
'order' => $order,
'dir' => $dir,
'tenant' => $tenant,
'tenants' => $tenants,
'departments' => $departments,
'roles' => $roles,
'customFieldFilterSpec' => $customFieldFilterSpec,
'tenantUserId' => $currentUserId,
'active' => 'active',
]);
$rows = [];
foreach (($result['rows'] ?? []) as $row) {
$tenantList = $this->normalizeLabelList($row['tenant_labels'] ?? []);
$departmentList = $this->normalizeLabelList($row['department_labels'] ?? []);
$roleList = $this->normalizeLabelList($row['role_labels'] ?? []);
$uuid = (string) ($row['uuid'] ?? '');
$displayName = trim((string) ($row['display_name'] ?? ''));
if ($displayName === '') {
$displayName = trim((string) ($row['first_name'] ?? '') . ' ' . (string) ($row['last_name'] ?? ''));
}
if ($displayName === '') {
$displayName = (string) ($row['email'] ?? '');
}
$rows[] = [
'uuid' => $uuid,
'display_name' => $displayName,
'first_name' => (string) ($row['first_name'] ?? ''),
'last_name' => (string) ($row['last_name'] ?? ''),
'email' => (string) ($row['email'] ?? ''),
'phone' => (string) ($row['phone'] ?? ''),
'mobile' => (string) ($row['mobile'] ?? ''),
'short_dial' => (string) ($row['short_dial'] ?? ''),
'tenants' => $tenantList,
'departments' => $departmentList,
'roles' => $roleList,
'has_avatar' => $uuid !== '' && $this->avatarService->hasAvatar($uuid),
];
}
return [
'data' => $rows,
'total' => (int) ($result['total'] ?? 0),
];
}
public function buildViewContext(int $currentUserId, string $uuid): array
{
$uuid = trim($uuid);
if ($uuid === '') {
return ['status' => 'invalid_uuid'];
}
$user = $this->userAccountService->findByUuid($uuid);
if (!$user || !isset($user['id'])) {
return ['status' => 'not_found'];
}
$targetUserId = (int) $user['id'];
if ($targetUserId <= 0) {
return ['status' => 'not_found'];
}
// Users can always view their own profile; others require scope access.
if (
$currentUserId !== $targetUserId
&& !$this->scopeGateway->canAccess('users', $targetUserId, $currentUserId)
) {
return ['status' => 'forbidden'];
}
$viewerTenantIds = $this->normalizeIds($this->scopeGateway->getUserTenantIds($currentUserId));
$assignments = $this->userAssignmentService->buildAssignmentsForUser($targetUserId);
$departmentMap = [];
foreach (($assignments['departments'] ?? []) as $department) {
if (empty($department['active'])) {
continue;
}
$tenantId = (int) ($department['tenant_id'] ?? 0);
$label = trim((string) ($department['description'] ?? ''));
// Only show departments in tenants the viewer also belongs to — prevents leaking org structure.
if ($tenantId <= 0 || $label === '' || !in_array($tenantId, $viewerTenantIds, true)) {
continue;
}
$departmentMap[$tenantId] ??= [];
$departmentMap[$tenantId][] = $label;
}
foreach ($departmentMap as $tenantId => $labels) {
$labels = array_values(array_unique($labels));
natcasesort($labels);
$departmentMap[$tenantId] = array_values($labels);
}
$primaryTenantId = (int) ($user['primary_tenant_id'] ?? 0);
$tenantGroups = [];
foreach (($assignments['tenants'] ?? []) as $tenant) {
$tenantId = (int) ($tenant['id'] ?? 0);
$tenantLabel = trim((string) ($tenant['description'] ?? ''));
$status = strtolower(trim((string) ($tenant['status'] ?? '')));
if (
$tenantId <= 0
|| $tenantLabel === ''
|| $status !== 'active'
|| !in_array($tenantId, $viewerTenantIds, true)
) {
continue;
}
$tenantGroups[] = [
'label' => $tenantLabel,
'is_primary' => $primaryTenantId > 0 && $tenantId === $primaryTenantId,
'departments' => $departmentMap[$tenantId] ?? [],
];
}
usort($tenantGroups, static fn (array $a, array $b): int => strnatcasecmp(
(string) $a['label'],
(string) $b['label']
));
$roleLabels = [];
foreach (($assignments['roles'] ?? []) as $role) {
if (empty($role['active'])) {
continue;
}
$label = trim((string) ($role['description'] ?? ''));
if ($label !== '') {
$roleLabels[] = $label;
}
}
$roleLabels = array_values(array_unique($roleLabels));
natcasesort($roleLabels);
$roleLabels = array_values($roleLabels);
$avatarUuid = (string) ($user['uuid'] ?? '');
$user['has_avatar'] = $avatarUuid !== '' && $this->avatarService->hasAvatar($avatarUuid);
$user['tenant_groups'] = $tenantGroups;
$user['role_labels'] = $roleLabels;
return [
'status' => 'ok',
'user' => $user,
];
}
private function splitCommaValues($value): array
{
return array_values(array_filter(
array_map('trim', explode(',', (string) $value)),
static fn (string $item): bool => $item !== ''
));
}
private function normalizeDefinitions($definitions): array
{
if (!is_array($definitions)) {
return [];
}
return array_values(array_filter(
$definitions,
static fn ($definition): bool => is_array($definition)
));
}
private function normalizeIds(array $values): array
{
$ids = array_values(array_unique(array_map('intval', $values)));
return array_values(array_filter($ids, static fn (int $id): bool => $id > 0));
}
// Accepts either a '||'-delimited DB aggregate string (from GROUP_CONCAT) or a plain array.
private function normalizeLabelList($value): array
{
if (is_string($value)) {
if ($value === '') {
return [];
}
$items = explode('||', $value);
} elseif (is_array($value)) {
$items = $value;
} else {
return [];
}
return array_values(array_filter(array_map(
static fn ($item): string => trim((string) $item),
$items
), static fn (string $item): bool => $item !== ''));
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace MintyPHP\Module\AddressBook\Service;
use MintyPHP\Service\CustomField\CustomFieldServicesFactory;
use MintyPHP\Service\Directory\DirectoryServicesFactory;
use MintyPHP\Service\Tenant\TenantScopeService;
use MintyPHP\Service\User\UserServicesFactory;
class AddressBookServicesFactory
{
private ?AddressBookService $addressBookService = null;
public function __construct(
private readonly UserServicesFactory $userServicesFactory,
private readonly DirectoryServicesFactory $directoryServicesFactory,
private readonly CustomFieldServicesFactory $customFieldServicesFactory,
private readonly TenantScopeService $tenantScopeService
) {
}
public function createAddressBookService(): AddressBookService
{
if ($this->addressBookService !== null) {
return $this->addressBookService;
}
$userFactory = $this->userServicesFactory;
$directoryFactory = $this->directoryServicesFactory;
return $this->addressBookService = new AddressBookService(
$userFactory->createUserAccountService(),
$userFactory->createUserAssignmentService(),
$directoryFactory->createTenantService(),
$directoryFactory->createDepartmentService(),
$directoryFactory->createRoleService(),
$this->tenantScopeService,
$this->customFieldServicesFactory->createUserCustomFieldValueService(),
$userFactory->createUserAvatarService()
);
}
}

View File

@@ -3,13 +3,8 @@
/**
* Address Book module manifest.
*
* Contributes the "People" aside tab/panel, global search section,
* Contributes routes, sidebar slots, search entry, user-edit aside action,
* layout context data, and session lifecycle.
*
* Permissions (address_book.view) remain in Core PermissionService because
* they gate general user-visibility in UserAuthorizationPolicy, not just
* address-book access. Asset groups remain in config/assets.php because the
* asset pipeline does not yet consume ModuleRegistry::getAssetGroups().
*/
return [
'id' => 'addressbook',
@@ -17,9 +12,18 @@ return [
'enabled_by_default' => false,
'load_order' => 10,
'routes' => [],
'routes' => [
// 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'],
// Legacy aliases for existing bookmarks/links.
['path' => 'addressbook', 'target' => 'address-book'],
['path' => 'adressbook', 'target' => 'address-book'],
],
'public_paths' => [],
'container_registrars' => [],
'container_registrars' => [
\MintyPHP\Module\AddressBook\AddressBookContainerRegistrar::class,
],
'ui_slots' => [
'aside.tab_panel' => [
@@ -32,15 +36,47 @@ return [
'panel_template' => __DIR__ . '/templates/aside-people-panel.phtml',
'details_storage' => 'aside-people-tenant',
'details_open_active' => true,
'order' => 110,
],
],
'search.resource_item' => [
[
'key' => 'address-book',
'label' => 'Address book',
'base_url' => 'address-book',
'permission' => 'can_view_address_book',
'order' => 110,
],
],
'user.edit.aside_action' => [
[
'key' => 'address-book-view-user',
'type' => 'link',
'label' => 'View in address book',
'href' => 'address-book/view/{user_uuid}',
'permission' => 'can_view_address_book',
'order' => 110,
],
],
],
'authorization_policies' => [
\MintyPHP\Module\AddressBook\AddressBookAuthorizationPolicy::class,
],
'layout_capabilities' => [
'can_view_address_book' => 'addressbook.view',
],
'search_resources' => [
\MintyPHP\Module\AddressBook\Providers\AddressBookSearchProvider::class,
],
'asset_groups' => [],
'asset_groups' => [
'address-book' => [
'modules/addressbook/css/pages/address-book-view.css',
],
],
'scheduler_jobs' => [],
'layout_context_providers' => [
@@ -51,7 +87,14 @@ return [
\MintyPHP\Module\AddressBook\Providers\AddressBookSessionProvider::class,
],
'permissions' => [],
'permissions' => [
[
'key' => 'address_book.view',
'description' => 'Can view address book',
'active' => true,
'is_system' => true,
],
],
'migrations_path' => 'db/updates',
];

View File

@@ -0,0 +1,30 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\AddressBook\AddressBookAuthorizationPolicy;
use MintyPHP\Module\AddressBook\Service\AddressBookServicesFactory;
use MintyPHP\Support\Guard;
$session = app(SessionStoreInterface::class)->all();
Guard::requireLogin();
Guard::requireAbilityOrForbidden(AddressBookAuthorizationPolicy::ABILITY_VIEW);
gridRequireGetRequest();
$query = requestInput()->queryAll();
$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php', $query);
foreach ($query as $rawKey => $rawValue) {
$key = strtolower(trim((string) $rawKey));
if (preg_match('/^(cf_|cfm_|cfd_)/', $key) !== 1) {
continue;
}
$filters[$key] = $rawValue;
}
$currentUserId = (int) ($session['user']['id'] ?? 0);
$addressBookService = (app(AddressBookServicesFactory::class))->createAddressBookService();
$result = $addressBookService->buildGridResult($currentUserId, $filters);
gridJsonDataResult(
(array) ($result['data'] ?? []),
(int) ($result['total'] ?? 0)
);

View File

@@ -0,0 +1,56 @@
<?php
return gridFilterSchema([
'query' => [
'limit' => ['type' => 'int', 'default' => 10, 'min' => 1, 'max' => 100],
'offset' => ['type' => 'int', 'default' => 0, 'min' => 0],
'search' => ['type' => 'string'],
'order' => ['type' => 'order', 'allowed' => ['display_name', 'email', 'first_name', 'last_name'], 'default' => 'last_name'],
'dir' => ['type' => 'dir', 'default' => 'asc'],
'tenant' => ['type' => 'string'],
'tenants' => ['type' => 'csv_strings', 'max' => 200],
'departments' => ['type' => 'csv_strings', 'max' => 200],
'roles' => ['type' => 'csv_strings', 'max' => 200],
],
'toolbar' => [
[
'key' => 'search',
'type' => 'text',
'label' => 'Search',
'placeholder' => 'Search...',
'input_id' => 'address-book-search',
'search' => true,
'query' => ['type' => 'string'],
],
[
'key' => 'tenants',
'type' => 'multi_csv',
'label' => 'Tenants',
'placeholder' => 'Select tenants',
'input_id' => 'address-book-tenant-filter',
'options_key' => 'tenant_items',
'query' => ['type' => 'csv_strings', 'max' => 200, 'return' => 'array'],
'default' => [],
],
[
'key' => 'departments',
'type' => 'multi_csv',
'label' => 'Departments',
'placeholder' => 'Select departments',
'input_id' => 'address-book-department-filter',
'options_key' => 'department_items',
'query' => ['type' => 'csv_strings', 'max' => 200, 'return' => 'array'],
'default' => [],
],
[
'key' => 'roles',
'type' => 'multi_csv',
'label' => 'Roles',
'placeholder' => 'Select roles',
'input_id' => 'address-book-role-filter',
'options_key' => 'role_items',
'query' => ['type' => 'csv_strings', 'max' => 200, 'return' => 'array'],
'default' => [],
],
],
]);

View File

@@ -0,0 +1,15 @@
<?php
use MintyPHP\Http\Request;
use MintyPHP\Router;
use MintyPHP\Support\Guard;
Guard::requireLogin();
$slug = trim((string) ($slug ?? ''));
if ($slug === '') {
require __DIR__ . '/index().php';
return;
}
Router::redirect('error/not_found?url=' . urlencode(Request::pathWithQuery()));

View File

@@ -0,0 +1,125 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\AddressBook\Service\AddressBookServicesFactory;
use MintyPHP\Session;
use MintyPHP\Support\Guard;
$session = app(SessionStoreInterface::class)->all();
Guard::requireLogin();
Guard::requireAbilityOrForbidden(\MintyPHP\Module\AddressBook\AddressBookAuthorizationPolicy::ABILITY_VIEW);
$filterSchema = require __DIR__ . '/filter-schema.php';
$query = requestInput()->queryAll();
$currentUserId = (int) ($session['user']['id'] ?? 0);
$addressBookService = (app(AddressBookServicesFactory::class))->createAddressBookService();
$context = $addressBookService->buildIndexContext($currentUserId, $query);
$activeTenants = $context['activeTenants'] ?? [];
$activeRoles = $context['activeRoles'] ?? [];
$activeDepartments = $context['activeDepartments'] ?? [];
$tenantItems = $context['tenantItems'] ?? [];
$departments = $context['departments'] ?? [];
$roles = $context['roles'] ?? [];
$customFieldFilterDefinitions = $context['customFieldFilterDefinitions'] ?? [];
$customFieldFilterQuery = $context['customFieldFilterQuery'] ?? [];
$tenantDepartmentMap = $context['tenantDepartmentMap'] ?? [];
$toolbarOptionSets = [
'tenant_items' => (array) $tenantItems,
'department_items' => (array) $departments,
'role_items' => (array) $roles,
];
$toolbarFilterStateOverrides = [
'search' => (string) gridQueryString($query, 'search', ''),
'tenants' => array_map('strval', (array) $activeTenants),
'departments' => array_map('strval', (array) $activeDepartments),
'roles' => array_map('strval', (array) $activeRoles),
];
$listFilterContext = gridBuildListFilterContext($filterSchema, [
'query' => $query,
'search_keys' => ['search'],
'toolbar_state_overrides' => $toolbarFilterStateOverrides,
'toolbar_option_sets' => $toolbarOptionSets,
]);
$toolbarFilterSchema = $listFilterContext['toolbarFilterSchema'];
$toolbarFilterState = $listFilterContext['toolbarFilterState'];
$searchToolbarFilterSchema = $listFilterContext['searchToolbarFilterSchema'];
$drawerToolbarFilterSchema = $listFilterContext['drawerToolbarFilterSchema'];
$toolbarOptionSets = $listFilterContext['toolbarOptionSets'];
$clientFilterSchema = $listFilterContext['clientFilterSchema'];
$searchConfig = $listFilterContext['searchConfig'];
$customFieldChipMeta = [];
foreach ($customFieldFilterDefinitions as $definition) {
$definitionUuid = strtolower(trim((string) ($definition['uuid'] ?? '')));
$definitionType = strtolower(trim((string) ($definition['type'] ?? '')));
$definitionLabel = trim((string) ($definition['label'] ?? ''));
$definitionOptions = is_array($definition['options'] ?? null) ? $definition['options'] : [];
if ($definitionUuid === '' || $definitionType === '' || $definitionLabel === '') {
continue;
}
if (in_array($definitionType, ['select', 'boolean'], true)) {
$options = gridOptionMapFromItems($definitionOptions);
if ($definitionType === 'boolean') {
$options = ['1' => t('Yes'), '0' => t('No')];
}
$customFieldChipMeta[] = [
'type' => 'scalar',
'param' => 'cf_' . $definitionUuid,
'label' => $definitionLabel,
'options' => $options,
];
continue;
}
if ($definitionType === 'multiselect') {
$customFieldChipMeta[] = [
'type' => 'multi_csv',
'param' => 'cfm_' . $definitionUuid,
'label' => $definitionLabel,
'options' => gridOptionMapFromItems($definitionOptions),
];
continue;
}
if ($definitionType === 'date') {
$customFieldChipMeta[] = [
'type' => 'date_range',
'label' => $definitionLabel,
'from_param' => 'cfd_' . $definitionUuid . '_from',
'to_param' => 'cfd_' . $definitionUuid . '_to',
];
}
}
$filterChipMeta = [
'search' => [
'label' => t('Search'),
'type' => 'text',
],
'tenants' => [
'label' => t('Tenants'),
'type' => 'multi_csv',
'options' => gridOptionMapFromItems((array) $tenantItems),
],
'departments' => [
'label' => t('Departments'),
'type' => 'multi_csv',
'options' => gridOptionMapFromItems((array) $departments),
],
'roles' => [
'label' => t('Roles'),
'type' => 'multi_csv',
'options' => gridOptionMapFromItems((array) $roles),
],
'custom_fields' => $customFieldChipMeta,
];
$csrfKey = Session::$csrfSessionKey;
$csrfToken = $session[$csrfKey] ?? '';
Buffer::set('title', t('Address book'));
Buffer::set('grid_lang', json_encode(gridLang(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
Buffer::set(
'grid_csrf',
json_encode(['key' => $csrfKey, 'token' => $csrfToken], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
);

View File

@@ -0,0 +1,242 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Session;
$csrfKey = $csrfKey ?? Session::$csrfSessionKey;
$csrfToken = $csrfToken ?? ($_SESSION[$csrfKey] ?? '');
$customFieldFilterDefinitions = is_array($customFieldFilterDefinitions ?? null) ? $customFieldFilterDefinitions : [];
$activeCustomFieldQuery = is_array($customFieldFilterQuery ?? null) ? $customFieldFilterQuery : [];
$toolbarFilterSchema = is_array($toolbarFilterSchema ?? null) ? $toolbarFilterSchema : [];
$searchToolbarFilterSchema = is_array($searchToolbarFilterSchema ?? null) ? $searchToolbarFilterSchema : [];
$drawerToolbarFilterSchema = is_array($drawerToolbarFilterSchema ?? null) ? $drawerToolbarFilterSchema : [];
$toolbarFilterState = is_array($toolbarFilterState ?? null) ? $toolbarFilterState : [];
$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;
?>
<?php
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('Address book')],
];
require templatePath('partials/app-breadcrumb.phtml');
?>
<?php
$listTitle = t('Address book');
$listTitleActionsHtml = '';
require templatePath('partials/app-list-titlebar.phtml');
?>
<div
class="app-list-toolbar"
id="address-book-toolbar"
>
<?php renderGridFilterToolbar($searchToolbarFilterSchema, $toolbarFilterState, $toolbarOptionSets); ?>
<button
type="button"
class="outline secondary icon-button"
data-filter-drawer-open
data-tooltip="<?php e(t('Filter')); ?>"
data-tooltip-pos="top"
aria-label="<?php e(t('Filter')); ?>"
aria-expanded="false"
>
<i class="bi bi-funnel"></i> <?php e(t('Filter')); ?>
</button>
</div>
<div
class="app-active-filters"
data-active-filter-chips
data-filter-chips-clear-all-label="<?php e(t('Clear all filters')); ?>"
data-filter-chips-remove-label="<?php e(t('Remove filter')); ?>"
></div>
<p
class="app-visually-hidden"
data-filter-live-region
data-filter-live-applied-message="<?php e(t('Filters applied')); ?>"
data-filter-live-removed-message="<?php e(t('Filter removed')); ?>"
data-filter-live-cleared-message="<?php e(t('All filters cleared')); ?>"
aria-live="polite"
aria-atomic="true"
></p>
<div
class="app-filter-drawer"
data-filter-drawer
role="dialog"
aria-modal="true"
aria-hidden="true"
aria-labelledby="address-book-filter-drawer-title"
hidden
>
<aside class="app-filter-drawer-panel" data-filter-drawer-panel>
<header class="app-filter-drawer-header">
<h2 id="address-book-filter-drawer-title"><?php e(t('Filter')); ?></h2>
<button
type="button"
class="transparent icon-button"
data-filter-drawer-close
aria-label="<?php e(t('Close filters')); ?>"
>
<i class="bi bi-x-lg"></i>
</button>
</header>
<div class="app-filter-drawer-body">
<div
class="app-list-toolbar"
id="address-book-drawer-toolbar"
data-tenant-dept-map="<?php e(json_encode($tenantDepartmentMap ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); ?>"
>
<?php renderGridFilterToolbar($drawerToolbarFilterSchema, $toolbarFilterState, $toolbarOptionSets); ?>
</div>
<?php if ($customFieldFilterDefinitions): ?>
<div class="app-list-toolbar" id="address-book-custom-filters">
<?php foreach ($customFieldFilterDefinitions as $definition): ?>
<?php
$definitionUuid = strtolower(trim((string) ($definition['uuid'] ?? '')));
$definitionType = strtolower(trim((string) ($definition['type'] ?? '')));
$definitionLabel = trim((string) ($definition['label'] ?? ''));
$definitionOptions = is_array($definition['options'] ?? null) ? $definition['options'] : [];
if ($definitionUuid === '' || $definitionLabel === '' || $definitionType === '') {
continue;
}
$label = $definitionLabel;
$idSuffix = preg_replace('/[^a-z0-9]+/', '-', $definitionUuid);
$paramScalar = 'cf_' . $definitionUuid;
$paramMulti = 'cfm_' . $definitionUuid;
$paramDateFrom = 'cfd_' . $definitionUuid . '_from';
$paramDateTo = 'cfd_' . $definitionUuid . '_to';
?>
<?php if ($definitionType === 'select'): ?>
<?php $currentValue = (string) ($activeCustomFieldQuery[$paramScalar] ?? ''); ?>
<?php
$selectItems = [['id' => '', 'description' => 'Please select']];
foreach ($definitionOptions as $option) {
$optionId = (string) ($option['id'] ?? '');
if ($optionId === '') {
continue;
}
$selectItems[] = [
'id' => $optionId,
'description' => (string) ($option['description'] ?? ''),
'translate' => false,
];
}
selectFilter(
'address-book-custom-filter-' . $idSuffix,
$label,
$selectItems,
$currentValue,
['for' => 'address-book-custom-filter-' . $idSuffix],
[
'data-address-book-custom-filter' => true,
'data-address-book-custom-filter-param' => $paramScalar,
]
);
?>
<?php elseif ($definitionType === 'boolean'): ?>
<?php $currentValue = strtolower(trim((string) ($activeCustomFieldQuery[$paramScalar] ?? ''))); ?>
<?php
$selectedBoolean = '';
if (in_array($currentValue, ['1', 'true'], true)) {
$selectedBoolean = '1';
} elseif (in_array($currentValue, ['0', 'false'], true)) {
$selectedBoolean = '0';
}
selectFilter(
'address-book-custom-filter-' . $idSuffix,
$label,
[
['id' => '', 'description' => 'Please select'],
['id' => '1', 'description' => 'Yes'],
['id' => '0', 'description' => 'No'],
],
$selectedBoolean,
['for' => 'address-book-custom-filter-' . $idSuffix],
[
'data-address-book-custom-filter' => true,
'data-address-book-custom-filter-param' => $paramScalar,
]
);
?>
<?php elseif ($definitionType === 'multiselect'): ?>
<?php
$currentValues = array_values(array_filter(array_map(
'trim',
explode(',', (string) ($activeCustomFieldQuery[$paramMulti] ?? ''))
), static fn ($item): bool => $item !== ''));
multiSelectFilter(
'address-book-custom-filter-' . $idSuffix,
$label,
'Select options',
$definitionOptions,
$currentValues
);
?>
<input type="hidden"
data-address-book-custom-filter
data-address-book-custom-filter-type="multiselect"
data-address-book-custom-filter-param="<?php e($paramMulti); ?>"
data-address-book-custom-filter-input="#address-book-custom-filter-<?php e($idSuffix); ?>"
>
<?php elseif ($definitionType === 'date'): ?>
<?php
$currentFrom = (string) ($activeCustomFieldQuery[$paramDateFrom] ?? '');
$currentTo = (string) ($activeCustomFieldQuery[$paramDateTo] ?? '');
?>
<label class="app-field" for="address-book-custom-filter-<?php e($idSuffix); ?>-from">
<span><?php e($label . ' ' . t('From')); ?></span>
<input type="date"
id="address-book-custom-filter-<?php e($idSuffix); ?>-from"
value="<?php e($currentFrom); ?>"
data-address-book-custom-filter
data-address-book-custom-filter-param="<?php e($paramDateFrom); ?>">
</label>
<label class="app-field" for="address-book-custom-filter-<?php e($idSuffix); ?>-to">
<span><?php e($label . ' ' . t('To')); ?></span>
<input type="date"
id="address-book-custom-filter-<?php e($idSuffix); ?>-to"
value="<?php e($currentTo); ?>"
data-address-book-custom-filter
data-address-book-custom-filter-param="<?php e($paramDateTo); ?>">
</label>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<footer class="app-filter-drawer-footer">
<button type="button" class="primary" data-filter-drawer-apply>
<?php e(t('Apply filters')); ?>
</button>
</footer>
</aside>
</div>
<div class="app-list-table">
<div id="address-book-grid"></div>
</div>
<?php
$gridLang = json_decode(appBufferValue('grid_lang'), true);
if (!is_array($gridLang)) {
$gridLang = [];
}
$pageConfig = [
'gridSearch' => $searchConfig,
'filterSchema' => $clientFilterSchema,
'filterChipMeta' => $filterChipMeta,
'gridLang' => $gridLang,
'labels' => [
'name' => t('Name'),
'email' => t('Email'),
'phone' => t('Phone'),
'mobile' => t('Mobile'),
'shortDial' => t('Short dial'),
'tenants' => t('Tenants'),
'departments' => t('Departments'),
'roles' => t('Roles'),
],
];
?>
<script src="<?php e(assetVersion('vendor/gridjs/gridjs.umd.js')); ?>"></script>
<script type="application/json" id="page-config-address-book-index"><?php gridJsonForJs($pageConfig); ?></script>
<script type="module" src="<?php e(assetVersion('modules/addressbook/js/pages/address-book-index.js')); ?>"></script>

View File

@@ -0,0 +1,38 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Router;
use MintyPHP\Support\Flash;
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 === '') {
Router::redirect('address-book');
}
$addressBookService = app(\MintyPHP\Module\AddressBook\Service\AddressBookService::class);
$viewContext = $addressBookService->buildViewContext($currentUserId, $uuid);
$status = (string) ($viewContext['status'] ?? '');
if ($status === 'not_found') {
Flash::error('User not found', 'address-book', 'user_not_found');
Router::redirect('address-book');
}
if ($status === 'forbidden') {
Router::redirect('error/forbidden');
}
$user = $viewContext['user'] ?? null;
if (!is_array($user)) {
Router::redirect('address-book');
}
Buffer::set('style_groups', json_encode(['address-book']));
$name = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? ''));
$title = $name !== '' ? $name : ($user['email'] ?? t('Address book'));
Buffer::set('title', $title);

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

@@ -8,7 +8,7 @@
*
* Available in scope via include: $layoutNav, $layoutAuth, $viewAuth
*/
$addressBook = is_array($layoutNav['addressBook'] ?? null) ? $layoutNav['addressBook'] : [];
$addressBook = is_array($layoutNav['addressbook.nav'] ?? null) ? $layoutNav['addressbook.nav'] : [];
$addressBookUrl = trim((string) ($addressBook['url'] ?? lurl('address-book')));
$activeAddressSearch = trim((string) ($addressBook['activeSearch'] ?? ''));
$activeAddressTenants = is_array($addressBook['activeTenants'] ?? null) ? $addressBook['activeTenants'] : [];

View File

@@ -0,0 +1,89 @@
<?php
namespace MintyPHP\Tests\Module\AddressBook\Service;
use MintyPHP\Service\Access\RoleService;
use MintyPHP\Module\AddressBook\Service\AddressBookService;
use MintyPHP\Service\CustomField\UserCustomFieldValueService;
use MintyPHP\Service\Org\DepartmentService;
use MintyPHP\Service\Tenant\TenantScopeService;
use MintyPHP\Service\Tenant\TenantService;
use MintyPHP\Service\User\UserAccountService;
use MintyPHP\Service\User\UserAssignmentService;
use MintyPHP\Service\User\UserAvatarService;
use PHPUnit\Framework\TestCase;
class AddressBookServiceTest extends TestCase
{
public function testBuildIndexContextBuildsTenantDepartmentMapForGlobalScope(): void
{
$userAccountService = $this->createMock(UserAccountService::class);
$userAssignmentService = $this->createMock(UserAssignmentService::class);
$tenantService = $this->createMock(TenantService::class);
$departmentService = $this->createMock(DepartmentService::class);
$roleService = $this->createMock(RoleService::class);
$scopeGateway = $this->createMock(TenantScopeService::class);
$customFieldGateway = $this->createMock(UserCustomFieldValueService::class);
$avatarService = $this->createMock(UserAvatarService::class);
$scopeGateway
->expects($this->once())
->method('getUserTenantIds')
->with(7)
->willReturn([]);
$tenantService
->expects($this->once())
->method('list')
->willReturn([
['id' => 1, 'uuid' => 'tenant-a', 'description' => 'Tenant A'],
['id' => 2, 'uuid' => 'tenant-b', 'description' => 'Tenant B'],
]);
$scopeGateway
->expects($this->exactly(2))
->method('isStrict')
->willReturn(false);
$departmentService
->expects($this->once())
->method('list')
->willReturn([
['id' => 10, 'tenant_id' => 1, 'description' => 'Sales'],
['id' => 20, 'tenant_id' => 2, 'description' => 'Support'],
]);
$departmentService
->expects($this->never())
->method('listByTenantIds');
$roleService
->expects($this->once())
->method('listActive')
->willReturn([]);
$customFieldGateway
->expects($this->once())
->method('extractCustomFieldFilterSpec')
->with([], 7)
->willReturn([
'definitions' => [],
'query' => [],
]);
$service = new AddressBookService(
$userAccountService,
$userAssignmentService,
$tenantService,
$departmentService,
$roleService,
$scopeGateway,
$customFieldGateway,
$avatarService
);
$context = $service->buildIndexContext(7, []);
$this->assertSame(
[
'tenant-a' => ['10'],
'tenant-b' => ['20'],
],
$context['tenantDepartmentMap']
);
}
}

View File

@@ -0,0 +1,576 @@
@layer pages {
.app-profile-card-container {
margin-top: calc(var(--app-spacing) * 2);
}
.app-profile-card {
border: 1px solid var(--app-border);
background: var(--app-card-background-color);
overflow: hidden;
border-radius: var(--app-border-radius);
margin-bottom: var(--app-spacing);
}
.app-profile-header {
display: flex;
align-items: flex-start;
padding: 0 calc(var(--app-spacing) * 1) 0 calc(var(--app-spacing) * 1);
flex-direction: column;
justify-content: center;
margin-top: -42px;
z-index: 1;
position: relative;
}
.app-profile-meta h2 {
margin: 0;
}
.app-profile-meta p {
margin: 0.25rem 0 0;
color: var(--app-muted-color);
}
.app-profile-body {
border: 1px solid var(--app-border);
background: var(--app-card-background-color);
overflow: hidden;
border-radius: var(--app-border-radius);
margin-bottom: var(--app-spacing);
}
.app-profile-body [data-tab-panel] {
padding-inline: var(--app-spacing);
}
.app-profile-body [data-tab-panel]:not(:has(p)) {
padding-block-end: var(--app-spacing);
}
.app-profile-table {
width: 100%;
border: 1px solid var(--app-border);
border-radius: var(--app-border-radius);
border-collapse: separate;
border-spacing: 0;
background: transparent;
overflow: hidden;
}
.app-profile-table th,
.app-profile-table td {
padding: 0.55rem 0.75rem;
background: transparent;
border-bottom: 1px solid var(--app-border);
text-align: left;
vertical-align: top;
}
.app-profile-table thead th {
font-weight: 600;
}
.app-profile-table tr:last-child td {
border-bottom: 0;
}
@keyframes ani-app-profile-banner {
0% {
--c-0: hsla(212, 0%, 0%, 1);
--s-start-0: 14.489998991212337%;
--s-end-0: 72%;
--y-0: 93%;
--x-0: 93%;
--c-1: hsla(212, 0%, 0%, 1);
--s-start-1: 0%;
--s-end-1: 45%;
--y-1: 9%;
--x-1: 26%;
--x-2: 15%;
--s-start-2: 2.9253667596993065%;
--s-end-2: 22.388851682060018%;
--c-2: hsla(257, 91%, 27%, 0.35);
--y-2: 79%;
--x-3: 40%;
--s-start-3: 3.985353824694249%;
--s-end-3: 47.580278608924694%;
--y-3: 104%;
--c-3: hsla(212, 100%, 50%, 0.5);
--y-4: 60%;
--x-4: 0%;
--c-4: hsla(224, 72%, 36%, 1);
--s-start-4: 2.391200382592061%;
--s-end-4: 29.307684556768592%;
--s-start-5: 2.9253667596993065%;
--s-end-5: 22.388851682060018%;
--y-5: 37%;
--c-5: hsla(248, 52%, 24%, 1);
--x-5: 92%;
--x-6: 101%;
--y-6: 16%;
--s-start-6: 13.173642363290591%;
--s-end-6: 31.747336520355095%;
--c-6: hsla(212, 100%, 50%, 0.19);
--y-7: 13%;
--s-start-7: 1%;
--s-end-7: 31%;
--x-7: 90%;
--c-7: hsla(227, 98%, 53%, 1);
--y-8: 56%;
--s-start-8: 3.985353824694249%;
--s-end-8: 13.103042116379756%;
--c-8: hsla(166, 71%, 60%, 0.32);
--x-8: 104%;
--c-9: hsla(219, 83%, 23%, 0.18);
--s-start-9: 18.597054544690312%;
--s-end-9: 31%;
--x-9: 97%;
--y-9: 19%;
}
100% {
--c-0: hsla(306, 0%, 0%, 1);
--s-start-0: 2.391200382592061%;
--s-end-0: 43.902064173373226%;
--y-0: 9%;
--x-0: 7%;
--c-1: hsla(306, 0%, 0%, 1);
--s-start-1: 9%;
--s-end-1: 54.805582404585024%;
--y-1: 93%;
--x-1: 96%;
--x-2: -2%;
--s-start-2: 3%;
--s-end-2: 26.722813338714598%;
--c-2: hsla(166, 72%, 60%, 1);
--y-2: 103%;
--x-3: 33%;
--s-start-3: 2.391200382592061%;
--s-end-3: 32.0689540200964%;
--y-3: 82%;
--c-3: hsla(180, 100%, 50%, 0.26);
--y-4: 81%;
--x-4: 37%;
--c-4: hsla(212, 88%, 26%, 0.58);
--s-start-4: 4.40642490323111%;
--s-end-4: 37.23528104246256%;
--s-start-5: 3%;
--s-end-5: 32.537089799783296%;
--y-5: 99%;
--c-5: hsla(271, 98%, 53%, 0.31);
--x-5: 54%;
--x-6: 104%;
--y-6: 43%;
--s-start-6: 6%;
--s-end-6: 42.501105312974815%;
--c-6: hsla(262, 100%, 50%, 0.15);
--y-7: -16%;
--s-start-7: 5%;
--s-end-7: 13.10107024898374%;
--x-7: 104%;
--c-7: hsla(298, 36%, 23%, 1);
--y-8: 30%;
--s-start-8: 2.391200382592061%;
--s-end-8: 27.141813016850573%;
--c-8: hsla(180, 100%, 50%, 0.11);
--x-8: 97%;
--c-9: hsla(219, 83%, 23%, 0.59);
--s-start-9: 5%;
--s-end-9: 21.32164536610654%;
--x-9: 78%;
--y-9: 4%;
}
}
@property --c-0 {
syntax: "<color>";
inherits: false;
initial-value: hsla(212, 0%, 0%, 1);
}
@property --s-start-0 {
syntax: "<percentage>";
inherits: false;
initial-value: 14.489998991212337%;
}
@property --s-end-0 {
syntax: "<percentage>";
inherits: false;
initial-value: 72%;
}
@property --y-0 {
syntax: "<percentage>";
inherits: false;
initial-value: 93%;
}
@property --x-0 {
syntax: "<percentage>";
inherits: false;
initial-value: 93%;
}
@property --c-1 {
syntax: "<color>";
inherits: false;
initial-value: hsla(212, 0%, 0%, 1);
}
@property --s-start-1 {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
@property --s-end-1 {
syntax: "<percentage>";
inherits: false;
initial-value: 45%;
}
@property --y-1 {
syntax: "<percentage>";
inherits: false;
initial-value: 9%;
}
@property --x-1 {
syntax: "<percentage>";
inherits: false;
initial-value: 26%;
}
@property --x-2 {
syntax: "<percentage>";
inherits: false;
initial-value: 15%;
}
@property --s-start-2 {
syntax: "<percentage>";
inherits: false;
initial-value: 2.9253667596993065%;
}
@property --s-end-2 {
syntax: "<percentage>";
inherits: false;
initial-value: 22.388851682060018%;
}
@property --c-2 {
syntax: "<color>";
inherits: false;
initial-value: hsla(257, 91%, 27%, 0.35);
}
@property --y-2 {
syntax: "<percentage>";
inherits: false;
initial-value: 79%;
}
@property --x-3 {
syntax: "<percentage>";
inherits: false;
initial-value: 40%;
}
@property --s-start-3 {
syntax: "<percentage>";
inherits: false;
initial-value: 3.985353824694249%;
}
@property --s-end-3 {
syntax: "<percentage>";
inherits: false;
initial-value: 47.580278608924694%;
}
@property --y-3 {
syntax: "<percentage>";
inherits: false;
initial-value: 104%;
}
@property --c-3 {
syntax: "<color>";
inherits: false;
initial-value: hsla(212, 100%, 50%, 0.5);
}
@property --y-4 {
syntax: "<percentage>";
inherits: false;
initial-value: 60%;
}
@property --x-4 {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
@property --c-4 {
syntax: "<color>";
inherits: false;
initial-value: hsla(224, 72%, 36%, 1);
}
@property --s-start-4 {
syntax: "<percentage>";
inherits: false;
initial-value: 2.391200382592061%;
}
@property --s-end-4 {
syntax: "<percentage>";
inherits: false;
initial-value: 29.307684556768592%;
}
@property --s-start-5 {
syntax: "<percentage>";
inherits: false;
initial-value: 2.9253667596993065%;
}
@property --s-end-5 {
syntax: "<percentage>";
inherits: false;
initial-value: 22.388851682060018%;
}
@property --y-5 {
syntax: "<percentage>";
inherits: false;
initial-value: 37%;
}
@property --c-5 {
syntax: "<color>";
inherits: false;
initial-value: hsla(248, 52%, 24%, 1);
}
@property --x-5 {
syntax: "<percentage>";
inherits: false;
initial-value: 92%;
}
@property --x-6 {
syntax: "<percentage>";
inherits: false;
initial-value: 101%;
}
@property --y-6 {
syntax: "<percentage>";
inherits: false;
initial-value: 16%;
}
@property --s-start-6 {
syntax: "<percentage>";
inherits: false;
initial-value: 13.173642363290591%;
}
@property --s-end-6 {
syntax: "<percentage>";
inherits: false;
initial-value: 31.747336520355095%;
}
@property --c-6 {
syntax: "<color>";
inherits: false;
initial-value: hsla(212, 100%, 50%, 0.19);
}
@property --y-7 {
syntax: "<percentage>";
inherits: false;
initial-value: 13%;
}
@property --s-start-7 {
syntax: "<percentage>";
inherits: false;
initial-value: 1%;
}
@property --s-end-7 {
syntax: "<percentage>";
inherits: false;
initial-value: 31%;
}
@property --x-7 {
syntax: "<percentage>";
inherits: false;
initial-value: 90%;
}
@property --c-7 {
syntax: "<color>";
inherits: false;
initial-value: hsla(227, 98%, 53%, 1);
}
@property --y-8 {
syntax: "<percentage>";
inherits: false;
initial-value: 56%;
}
@property --s-start-8 {
syntax: "<percentage>";
inherits: false;
initial-value: 3.985353824694249%;
}
@property --s-end-8 {
syntax: "<percentage>";
inherits: false;
initial-value: 13.103042116379756%;
}
@property --c-8 {
syntax: "<color>";
inherits: false;
initial-value: hsla(166, 71%, 60%, 0.32);
}
@property --x-8 {
syntax: "<percentage>";
inherits: false;
initial-value: 104%;
}
@property --c-9 {
syntax: "<color>";
inherits: false;
initial-value: hsla(219, 83%, 23%, 0.18);
}
@property --s-start-9 {
syntax: "<percentage>";
inherits: false;
initial-value: 18.597054544690312%;
}
@property --s-end-9 {
syntax: "<percentage>";
inherits: false;
initial-value: 31%;
}
@property --x-9 {
syntax: "<percentage>";
inherits: false;
initial-value: 97%;
}
@property --y-9 {
syntax: "<percentage>";
inherits: false;
initial-value: 19%;
}
.app-profile-banner {
--c-0: hsla(212, 0%, 0%, 1);
--y-0: 93%;
--x-0: 93%;
--c-1: hsla(212, 0%, 0%, 1);
--y-1: 9%;
--x-1: 26%;
--x-2: 15%;
--c-2: hsla(257, 91%, 27%, 0.35);
--y-2: 79%;
--x-3: 40%;
--y-3: 104%;
--c-3: hsla(212, 100%, 50%, 0.5);
--y-4: 60%;
--x-4: 0%;
--c-4: hsla(224, 72%, 36%, 1);
--y-5: 37%;
--c-5: hsla(248, 52%, 24%, 1);
--x-5: 92%;
--x-6: 101%;
--y-6: 16%;
--c-6: hsla(212, 100%, 50%, 0.19);
--y-7: 13%;
--x-7: 90%;
--c-7: hsla(227, 98%, 53%, 1);
--y-8: 56%;
--c-8: hsla(166, 71%, 60%, 0.32);
--x-8: 104%;
--c-9: hsla(219, 83%, 23%, 0.18);
--x-9: 97%;
--y-9: 19%;
background-color: hsla(305, 0%, 0%, 1);
background-image:
radial-gradient(
circle at var(--x-0) var(--y-0),
var(--c-0) var(--s-start-0),
transparent var(--s-end-0)
),
radial-gradient(
circle at var(--x-1) var(--y-1),
var(--c-1) var(--s-start-1),
transparent var(--s-end-1)
),
radial-gradient(
circle at var(--x-2) var(--y-2),
var(--c-2) var(--s-start-2),
transparent var(--s-end-2)
),
radial-gradient(
circle at var(--x-3) var(--y-3),
var(--c-3) var(--s-start-3),
transparent var(--s-end-3)
),
radial-gradient(
circle at var(--x-4) var(--y-4),
var(--c-4) var(--s-start-4),
transparent var(--s-end-4)
),
radial-gradient(
circle at var(--x-5) var(--y-5),
var(--c-5) var(--s-start-5),
transparent var(--s-end-5)
),
radial-gradient(
circle at var(--x-6) var(--y-6),
var(--c-6) var(--s-start-6),
transparent var(--s-end-6)
),
radial-gradient(
circle at var(--x-7) var(--y-7),
var(--c-7) var(--s-start-7),
transparent var(--s-end-7)
),
radial-gradient(
circle at var(--x-8) var(--y-8),
var(--c-8) var(--s-start-8),
transparent var(--s-end-8)
),
radial-gradient(
circle at var(--x-9) var(--y-9),
var(--c-9) var(--s-start-9),
transparent var(--s-end-9)
);
animation: ani-app-profile-banner 10s linear infinite alternate;
background-blend-mode:
normal, normal, normal, normal, normal, normal, normal, normal, normal,
normal;
will-change: transform, opacity;
contain: paint;
height: 160px;
}
}

View File

@@ -0,0 +1,133 @@
import { initMultiSelect } from '/js/components/app-multiselect-init.js';
import { initMultiSelectCascade } from '/js/components/app-multiselect-cascade.js';
import { initStandardListPage } from '/js/core/app-grid-factory.js';
import { readPageConfig } from '/js/core/app-page-config.js';
import { warnOnce } from '/js/core/app-dom.js';
import { getAppBase, escapeHtml, buildBadgeList, gridFilterMultiCsv, gridFilterText } from '/js/pages/app-list-utils.js';
const config = readPageConfig('address-book-index');
if (config) {
const appBase = getAppBase();
const gridSearch = config.gridSearch ?? null;
const filterSchema = config.filterSchema ?? [];
const filterChipMeta = config.filterChipMeta ?? [];
const labels = config.labels ?? {};
const toolbar = document.querySelector('#address-book-drawer-toolbar');
const customFilterInputs = Array.from(document.querySelectorAll('[data-address-book-custom-filter]'));
const tenantDepartmentMap = toolbar?.dataset?.tenantDeptMap
? JSON.parse(toolbar.dataset.tenantDeptMap)
: {};
const uuidIndex = 0;
const initialsForName = (name) => {
const parts = String(name || '')
.trim()
.split(/\s+/)
.filter(Boolean);
const chars = parts.slice(0, 2).map((value) => value[0] || '').join('').toUpperCase();
return chars || '?';
};
const customFilters = customFilterInputs.map((input) => {
const param = String(input?.dataset?.addressBookCustomFilterParam || '').trim();
if (!param) {return null;}
if (input.dataset.addressBookCustomFilterType === 'multiselect') {
const targetSelector = input.dataset.addressBookCustomFilterInput || '';
return gridFilterMultiCsv(targetSelector, param, { default: [] });
}
return gridFilterText(input, param, { default: '' });
}).filter(Boolean);
const gridOptions = {
gridjs: window.gridjs,
container: '#address-book-grid',
dataUrl: 'address-book/data',
appBase,
columns: [
{ name: 'UUID', hidden: true },
{
name: labels.name || 'Name',
sort: true,
formatter: (cell) => {
const nameValue = typeof cell === 'object' && cell !== null
? (cell.name ?? '')
: cell;
const avatarUuid = typeof cell === 'object' && cell !== null
? (cell.avatar_uuid ?? '')
: '';
const name = escapeHtml(nameValue || '-');
if (!avatarUuid) {
const initials = escapeHtml(initialsForName(nameValue));
return gridjs.html(`<span class="grid-name-cell"><span class="grid-avatar grid-avatar-placeholder">${initials}</span><span>${name}</span></span>`);
}
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><span>${name}</span></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],
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,
]),
search: gridSearch,
filterSchema,
extraFilters: customFilters,
urlSync: true,
rowDataset: (row) => ({
uuid: row?.cells?.[uuidIndex]?.data,
}),
rowDblClick: {
getUrl: (rowData) => {
const uuid = rowData?.cells?.[uuidIndex]?.data;
return uuid ? new URL(`address-book/view/${uuid}`, appBase).toString() : '';
},
},
};
const { gridConfig } = initStandardListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: filterChipMeta,
watchInputs: ['#address-book-search'],
},
});
if (!gridConfig || !gridConfig.grid) {
warnOnce('UI_INIT_FAIL', 'Address book grid init failed', { module: 'address-book-index', component: 'grid' });
}
initMultiSelect().then(() => {
initMultiSelectCascade({
parent: '#address-book-tenant-filter',
child: '#address-book-department-filter',
map: tenantDepartmentMap,
mode: 'disable',
clearInvalid: true,
});
});
}