1
0

refactor(admin-lists): slim tenant + user grids, Stripe-style pagination

Tenant list:
- Drop the avatar/logo column — the list now shows only Tenant name
  and user count. Also drop the logo-hasLogo server lookup and the
  initials/placeholder markup.

User list (13 → 4 columns, Stripe pattern):
- User (compound: avatar + Name + email subtitle, click opens drawer)
- Tenants (badges, primary tooltip)
- Last login (relative badge)
- State (active/inactive badge)
- UUID hidden at the last column for cells[uuidIndex].data
- Everything else (departments, roles, phone, mobile, short_dial,
  created, modified) lives in the detail drawer.
- Document the off-by-one gotcha: with row selection enabled gridjs
  prepends a checkbox cell, so runtime cells[] are shifted by +1;
  uuidIndex is 5 (column position 4 + selection offset).
- New .grid-user-profile css mirrors .grid-tenant-profile (avatar +
  stacked name/email) with ellipsis and primary-color hover affordance.

Pagination (all Grid.js lists):
- Non-current page buttons now use the neutral-chip secondary-outline
  tokens (--app-button-neutral-*) plus the raised box-shadow — matches
  the rest of the button system in both light and dark themes.
- Current page stays distinctly primary-filled with the filled-shadow;
  focus-visible combines the chip shadow with the primary focus ring.
- Disabled pager buttons remain neutral but lose the lift shadow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 21:52:44 +02:00
parent fa86009e3f
commit 874a90d8f8
6 changed files with 117 additions and 129 deletions

View File

@@ -19,11 +19,10 @@ $order = $filters['order'];
$dir = $filters['dir'];
$computedOrderKeys = ['users'];
$userTenantRepository = app(\MintyPHP\Repository\Tenant\UserTenantRepository::class);
$tenantLogoService = app(\MintyPHP\Service\Tenant\TenantLogoService::class);
$settingsDefaultsGateway = app(\MintyPHP\Service\Settings\SettingsDefaultsGateway::class);
$gridUserCountEnricher = app(\MintyPHP\Service\Data\GridUserCountEnricher::class);
$fetchRows = static function (array $tenantRows) use ($userTenantRepository, $tenantLogoService, $settingsDefaultsGateway, $gridUserCountEnricher): array {
$fetchRows = static function (array $tenantRows) use ($userTenantRepository, $settingsDefaultsGateway, $gridUserCountEnricher): array {
$userCounts = $gridUserCountEnricher->computeCounts(
$tenantRows,
$userTenantRepository->countUsersByTenantIds(...),
@@ -47,10 +46,6 @@ $fetchRows = static function (array $tenantRows) use ($userTenantRepository, $te
'status_badge' => $tenantStatus->badgeVariant(),
'status_label' => t($tenantStatus->labelToken()),
'total_users' => $counts['active_users'] + $counts['inactive_users'],
'has_logo' => $tenantUuid !== '' && (
$tenantLogoService->hasLogo($tenantUuid, \MintyPHP\Service\Tenant\TenantLogoService::THEME_LIGHT)
|| $tenantLogoService->hasLogo($tenantUuid, \MintyPHP\Service\Tenant\TenantLogoService::THEME_DARK)
),
];
}

View File

@@ -43,7 +43,6 @@ $pageConfig = [
'filterChipMeta' => $filterChipMeta,
'gridLang' => $gridLang,
'labels' => [
'logo' => t('Logo'),
'tenant' => t('Tenant'),
'users' => t('Users'),
],

View File

@@ -117,19 +117,9 @@ $pageConfig = [
'csrf' => $gridCsrf,
'gridLang' => $gridLang,
'labels' => [
'avatar' => t('Avatar'),
'firstName' => t('First name'),
'lastName' => t('Last name'),
'email' => t('Email'),
'user' => t('User'),
'state' => t('State'),
'tenants' => t('Tenants'),
'departments' => t('Departments'),
'roles' => t('Roles'),
'phone' => t('Phone'),
'mobile' => t('Mobile'),
'shortDial' => t('Short dial'),
'created' => t('Created'),
'modified' => t('Modified'),
'lastLogin' => t('Last login'),
'selectAll' => t('Select all'),
'active' => t('Active'),

View File

@@ -248,12 +248,15 @@
same custom properties, not by setting `color`/`background-color`
directly, and we must use a token that isn't shadowed inside the button
scope (`--app-form-element-color` fits). */
/* Align pagination buttons with the Stripe-style neutral-chip secondary
outline: theme-aware surface, high-contrast text, subtle border,
raised shadow. Current page stays distinct via primary fill. */
.gridjs-pagination .gridjs-pages button,
.gridjs-pagination .gridjs-pages button[type="button"] {
--app-background-color: var(--app-form-element-background-color);
--app-color: var(--app-form-element-color);
--app-border-color: var(--app-table-border-color);
--app-box-shadow: none;
--app-background-color: var(--app-button-neutral-bg);
--app-color: var(--app-button-neutral-color);
--app-border-color: var(--app-button-neutral-border);
box-shadow: var(--app-button-neutral-shadow);
margin: 0;
display: inline-flex;
align-items: center;
@@ -263,21 +266,24 @@
line-height: 1.3;
min-width: 2rem;
white-space: nowrap;
font-weight: 500;
font-weight: var(--font-medium);
}
.gridjs-pagination .gridjs-pages button:hover:not(:disabled),
.gridjs-pagination .gridjs-pages button[type="button"]:hover:not(:disabled),
.gridjs-pagination .gridjs-pages button:focus:not(:disabled),
.gridjs-pagination .gridjs-pages button[type="button"]:focus:not(:disabled) {
--app-background-color: var(--app-primary-hover-background);
--app-border-color: var(--app-primary-hover-border);
--app-color: var(--app-primary-inverse);
--app-background-color: var(--app-button-neutral-bg-hover);
--app-color: var(--app-button-neutral-color-hover);
--app-border-color: var(--app-button-neutral-border-hover);
box-shadow: var(--app-button-neutral-shadow-hover);
}
.gridjs-pagination .gridjs-pages button:focus-visible {
outline: none;
box-shadow: 0 0 0 var(--app-outline-width) var(--app-primary-focus);
box-shadow:
var(--app-button-neutral-shadow),
0 0 0 var(--app-outline-width) var(--app-primary-focus);
}
.gridjs-pagination .gridjs-pages button.gridjs-currentPage,
@@ -287,16 +293,18 @@
--app-background-color: var(--app-primary);
--app-border-color: var(--app-primary);
--app-color: var(--app-primary-inverse);
font-weight: 600;
box-shadow: var(--app-button-filled-shadow);
font-weight: var(--font-semibold);
}
.gridjs-pagination .gridjs-pages button:disabled,
.gridjs-pagination .gridjs-pages button[disabled] {
--app-background-color: var(--app-background-color);
--app-background-color: var(--app-button-neutral-bg);
--app-color: var(--app-muted-color);
--app-border-color: var(--app-table-border-color);
--app-border-color: var(--app-button-neutral-border);
box-shadow: none;
cursor: default;
opacity: 0.7;
opacity: 0.55;
}
.gridjs-pagination .gridjs-pages button.gridjs-spread,
@@ -634,6 +642,42 @@
font-weight: 500;
}
/* Compound user cell: avatar + name (strong) + email subtitle (small, muted). */
.grid-user-profile {
display: inline-flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.grid-user-profile-text {
display: inline-flex;
flex-direction: column;
min-width: 0;
line-height: var(--leading-tight);
}
.grid-user-profile-text strong {
font-weight: var(--font-medium);
color: var(--app-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.grid-user-profile-text small {
font-size: var(--text-xs);
color: var(--app-muted-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
a.grid-user-profile.app-grid-link-cell:hover .grid-user-profile-text strong {
color: var(--app-primary);
text-decoration: underline;
}
.gridjs-container .grid-actions {
display: inline-flex;
align-items: center;
@@ -664,6 +708,7 @@
.gridjs-td .badge,
.gridjs-td .grid-name-cell,
.gridjs-td .grid-tenant-profile,
.gridjs-td .grid-user-profile,
.gridjs-td .app-inline-actions {
white-space: nowrap;
}

View File

@@ -12,14 +12,6 @@ createListPageModule({
const labels = config.labels ?? {};
const tenantUuidIndex = 2;
const initialsForRow = (row) => {
const descriptionCell = row?.cells?.[0]?.data;
const label = typeof descriptionCell === 'object' && descriptionCell !== null
? String(descriptionCell.label ?? '')
: String(descriptionCell ?? '');
const initial = label.trim().charAt(0).toUpperCase();
return initial || '?';
};
const { gridConfig } = initListPage({
grid: {
@@ -32,27 +24,7 @@ createListPageModule({
name: labels.tenant || 'Tenant',
sort: true,
width: '75%',
formatter: (cell, row) => {
const hasLogo = cell?.has_logo ? 1 : 0;
const uuid = encodeURIComponent(String(cell?.uuid ?? ''));
let avatarHtml = '';
if (hasLogo && uuid) {
const src = new URL(`admin/tenants/logo-file?uuid=${uuid}&theme=light&size=64`, appBase).toString();
avatarHtml = `<img class="grid-avatar grid-avatar-tenant" src="${src}" alt="" loading="lazy">`;
} else {
const initials = escapeHtml(initialsForRow(row));
avatarHtml = `<span class="grid-avatar grid-avatar-tenant grid-avatar-placeholder">${initials}</span>`;
}
const nameText = escapeHtml(String(cell?.label ?? ''));
return gridjs.html(
`<div class="grid-tenant-profile">` +
`${avatarHtml}` +
`<span class="grid-tenant-profile-name-text">${nameText}</span>` +
`</div>`
);
},
formatter: (cell) => gridjs.html(escapeHtml(String(cell ?? ''))),
},
{
name: labels.users || 'Users',
@@ -72,11 +44,7 @@ createListPageModule({
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [
{
uuid: row.uuid ?? '',
label: row.description ?? '',
has_logo: row.has_logo ? 1 : 0,
},
row.description ?? '',
row.total_users ?? 0,
row.uuid,
]),

View File

@@ -26,10 +26,20 @@ createListPageModule({
if (!rowSelection) {
throw new Error('Grid.js RowSelection plugin is not available.');
}
const activeIndex = 6;
const uuidIndex = 2;
// Layout:
// col 0: User compound (display)
// col 1: Tenants
// col 2: Last login
// col 3: State
// col 4: UUID (hidden scalar)
// Selection plugin prepends a checkbox cell, so runtime cells[] are
// shifted by +1. Cell accessors therefore use indices 1..5 (1-based
// from the user-visible column order + 1 for the selection col).
const uuidIndex = 5;
const activeIndex = 4;
const canEditRow = (row) => {
const uuid = row?.cells?.[uuidIndex]?.data;
const uuid = String(row?.cells?.[uuidIndex]?.data ?? '');
if (!uuid) { return false; }
if (uuid === currentUserUuid) {
return canUpdateUsers || canUpdateSelf;
@@ -37,10 +47,8 @@ createListPageModule({
return canUpdateUsers;
};
const initialsForRow = (row) => {
const first = row?.cells?.[3]?.data ?? '';
const last = row?.cells?.[4]?.data ?? '';
const parts = [first, last]
const initialsFromUser = (user) => {
const parts = [user?.firstName, user?.lastName]
.map((value) => String(value || '').trim())
.filter(Boolean);
const chars = parts.map((value) => value[0] || '').join('').toUpperCase();
@@ -49,32 +57,38 @@ createListPageModule({
const columns = [
{
name: labels.avatar || 'Avatar',
sort: false,
formatter: (cell, row) => {
if (!cell) {
const initials = escapeHtml(initialsForRow(row));
return gridjs.html(`<span class="grid-avatar grid-avatar-placeholder">${initials}</span>`);
}
const src = new URL(`admin/users/avatar-file?uuid=${cell}&size=64`, appBase).toString();
const full = new URL(`admin/users/avatar-file?uuid=${cell}&size=256`, appBase).toString();
return gridjs.html(`<a data-fslightbox="user-avatars" href="${full}"><img class="grid-avatar" src="${src}" alt="" loading="lazy"></a>`);
},
},
{ name: 'UUID', hidden: true },
{
name: labels.firstName || 'First name',
name: labels.user || 'User',
sort: true,
formatter: (cell, row) => {
const name = escapeHtml(String(cell || '').trim() || '-');
const uuid = row?.cells?.[uuidIndex]?.data;
if (!uuid) { return gridjs.html(`<span>${name}</span>`); }
const uuid = String(row?.cells?.[uuidIndex]?.data ?? '');
const firstName = String(cell?.firstName ?? '').trim();
const lastName = String(cell?.lastName ?? '').trim();
const email = String(cell?.email ?? '').trim();
const fullName = [firstName, lastName].filter(Boolean).join(' ') || email || '-';
const avatarHtml = cell?.hasAvatar && uuid
? `<img class="grid-avatar" src="${new URL(`admin/users/avatar-file?uuid=${encodeURIComponent(uuid)}&size=64`, appBase).toString()}" alt="" loading="lazy">`
: `<span class="grid-avatar grid-avatar-placeholder">${escapeHtml(initialsFromUser(cell))}</span>`;
const nameEsc = escapeHtml(fullName);
const emailEsc = escapeHtml(email);
const subtitle = email && email !== fullName ? `<small>${emailEsc}</small>` : '';
const inner = `${avatarHtml}<span class="grid-user-profile-text"><strong>${nameEsc}</strong>${subtitle}</span>`;
if (!uuid) {
return gridjs.html(`<div class="grid-user-profile">${inner}</div>`);
}
const editUrl = escapeHtml(withCurrentListReturn(new URL(`admin/users/edit/${uuid}`, appBase).toString()));
return gridjs.html(`<a class="app-grid-link-cell" href="${editUrl}" data-drawer-trigger>${name}</a>`);
return gridjs.html(`<a class="grid-user-profile app-grid-link-cell" href="${editUrl}" data-drawer-trigger>${inner}</a>`);
},
},
{ name: labels.lastName || 'Last name', sort: true },
{ name: labels.email || 'Email', sort: true },
{
name: labels.tenants || 'Tenants',
sort: false,
formatter: (cell) => gridjs.html(buildBadgeList(cell, { primaryTooltip: labels.primaryTenant || '' })),
},
{
name: labels.lastLogin || 'Last login',
sort: true,
formatter: (cell) => badgeHtml(gridjs, cell || labels.never || 'Never'),
},
{
name: labels.state || 'State',
sort: true,
@@ -82,24 +96,7 @@ createListPageModule({
? gridjs.html(`<span class="badge" data-variant="success">${labels.active || 'Active'}</span>`)
: gridjs.html(`<span class="badge" data-variant="danger">${labels.inactive || 'Inactive'}</span>`),
},
{
name: labels.tenants || 'Tenants',
sort: false,
formatter: (cell) => gridjs.html(buildBadgeList(cell, { primaryTooltip: labels.primaryTenant || '' })),
},
{ name: labels.departments || 'Departments', sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) },
{ name: labels.roles || 'Roles', sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) },
{ name: labels.phone || 'Phone', sort: false },
{ name: labels.mobile || 'Mobile', sort: false },
{ name: labels.shortDial || 'Short dial', sort: false },
{ name: labels.created || 'Created', sort: true, formatter: (cell) => badgeHtml(gridjs, cell) },
{ name: labels.modified || 'Modified', sort: true, formatter: (cell) => badgeHtml(gridjs, cell) },
{
name: labels.lastLogin || 'Last login',
sort: true,
formatter: (cell) => badgeHtml(gridjs, cell || labels.never || 'Never'),
},
{ name: 'ID', hidden: true },
{ name: 'UUID', hidden: true },
];
const { gridConfig } = initListPage({
@@ -109,26 +106,20 @@ createListPageModule({
dataUrl: 'admin/users/data',
appBase,
columns,
sortColumns: [null, null, 'first_name', 'last_name', 'email', 'active', null, null, null, null, null, null, 'created', 'modified', 'last_login_at', null],
sortColumns: ['first_name', null, 'last_login_at', 'active', null],
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [
row.has_avatar ? row.uuid : '',
row.uuid,
row.first_name,
row.last_name,
row.email,
row.active,
{
firstName: row.first_name ?? '',
lastName: row.last_name ?? '',
email: row.email ?? '',
hasAvatar: !!row.has_avatar,
},
row.tenants,
row.departments,
row.roles,
row.phone,
row.mobile,
row.short_dial,
row.created,
row.modified,
row.last_login,
row.id,
row.active,
row.uuid ?? '',
]),
selection: {
enabled: true,
@@ -136,7 +127,7 @@ createListPageModule({
component: rowSelection,
selectAllLabel: labels.selectAll || 'Select all',
props: {
id: (row) => row.cell(uuidIndex).data,
id: (row) => String(row.cell(uuidIndex).data ?? ''),
},
getSelectedIds: ({ container }) => {
if (!container) { return []; }
@@ -149,7 +140,7 @@ createListPageModule({
filterSchema,
urlSync: true,
rowDataset: (row) => ({
uuid: row?.cells?.[uuidIndex]?.data,
uuid: String(row?.cells?.[uuidIndex]?.data ?? ''),
}),
rowInteraction: { linkColumn: false },
rowDblClick: {
@@ -157,7 +148,7 @@ createListPageModule({
if (!canEditRow(rowData)) {
return '';
}
const uuid = rowData?.cells?.[uuidIndex]?.data;
const uuid = String(rowData?.cells?.[uuidIndex]?.data ?? '');
return uuid ? new URL(`admin/users/edit/${uuid}`, appBase).toString() : '';
},
},