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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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 !== ''));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
30
modules/addressbook/pages/address-book/data().php
Normal file
30
modules/addressbook/pages/address-book/data().php
Normal 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)
|
||||
);
|
||||
56
modules/addressbook/pages/address-book/filter-schema.php
Normal file
56
modules/addressbook/pages/address-book/filter-schema.php
Normal 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' => [],
|
||||
],
|
||||
],
|
||||
]);
|
||||
15
modules/addressbook/pages/address-book/index($slug).php
Normal file
15
modules/addressbook/pages/address-book/index($slug).php
Normal 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()));
|
||||
125
modules/addressbook/pages/address-book/index().php
Normal file
125
modules/addressbook/pages/address-book/index().php
Normal 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)
|
||||
);
|
||||
242
modules/addressbook/pages/address-book/index(default).phtml
Normal file
242
modules/addressbook/pages/address-book/index(default).phtml
Normal 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>
|
||||
38
modules/addressbook/pages/address-book/view($id).php
Normal file
38
modules/addressbook/pages/address-book/view($id).php
Normal 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);
|
||||
239
modules/addressbook/pages/address-book/view(default).phtml
Normal file
239
modules/addressbook/pages/address-book/view(default).phtml
Normal 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>
|
||||
@@ -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'] : [];
|
||||
|
||||
@@ -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']
|
||||
);
|
||||
}
|
||||
}
|
||||
576
modules/addressbook/web/css/pages/address-book-view.css
Normal file
576
modules/addressbook/web/css/pages/address-book-view.css
Normal 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;
|
||||
}
|
||||
}
|
||||
133
modules/addressbook/web/js/pages/address-book-index.js
Normal file
133
modules/addressbook/web/js/pages/address-book-index.js
Normal 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,
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user