The drawer chrome used to be built in JS via a 50-line innerHTML string and needed every caller to plumb through a labels object sourced from per-page PHP config. That diverged from the codebase convention — the confirm dialog, search dialog and session warning are all PHP partials mounted once in templates/default.phtml. The drawer now follows the same pattern: templates/partials/app-detail-drawer.phtml renders the markup with t() labels, default.phtml mounts it inside the logged-in shell, and the JS only attaches behavior via the [data-detail-drawer] selector. Knock-on cleanup: ensureDrawerElement and resolveLabels disappear from the JS, all three initDetailDrawer callers (admin/users, address book, helpdesk debitor) drop their labels parameter, and the matching dead 'drawerClose'/'drawerPrev'/… entries leave the per-page PHP grid configs. The drawer also gains a clean fallback when the partial is missing (console warn + null return), so logged-out edges can't crash. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
213 lines
9.1 KiB
JavaScript
213 lines
9.1 KiB
JavaScript
import { initMultiSelect } from '/js/components/app-multiselect-init.js';
|
||
import { initMultiSelectCascade } from '/js/components/app-multiselect-cascade.js';
|
||
import { initDetailDrawer } from '/js/components/app-detail-drawer.js';
|
||
import { createListPageModule } from '/js/core/app-list-page-module.js';
|
||
import { escapeHtml, gridFilterMultiCsv, gridFilterText, withCurrentListReturn } from '/js/pages/app-list-utils.js';
|
||
|
||
createListPageModule({
|
||
configId: 'address-book-index',
|
||
moduleId: 'address-book-index',
|
||
missingGridMessage: 'Address book grid init failed: Grid.js missing',
|
||
initErrorMessage: 'Address book grid init failed',
|
||
setup: ({ config, gridjs, appBase, initListPage }) => {
|
||
const labels = config.labels ?? {};
|
||
const gridSearch = config.gridSearch ?? null;
|
||
const filterSchema = config.filterSchema ?? [];
|
||
const filterChipMeta = config.filterChipMeta ?? [];
|
||
|
||
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 { gridConfig } = initListPage({
|
||
grid: {
|
||
gridjs,
|
||
container: '#address-book-grid',
|
||
dataUrl: 'address-book/data',
|
||
appBase,
|
||
columns: [
|
||
{ name: 'UUID', hidden: true },
|
||
{
|
||
name: labels.name || 'Name',
|
||
sort: true,
|
||
formatter: (cell, row) => {
|
||
const nameValue = cell?.name ?? '';
|
||
const email = cell?.email ?? '';
|
||
const avatarUuid = cell?.avatar_uuid ?? '';
|
||
const name = escapeHtml(nameValue || '-');
|
||
const uuid = row?.cells?.[uuidIndex]?.data;
|
||
const editUrl = uuid ? withCurrentListReturn(new URL(`address-book/view/${uuid}`, appBase).toString()) : '';
|
||
const nameNode = editUrl
|
||
? `<a class="app-grid-link-cell addr-identity-name" href="${escapeHtml(editUrl)}" data-drawer-trigger>${name}</a>`
|
||
: `<span class="addr-identity-name">${name}</span>`;
|
||
const emailNode = email
|
||
? `<span class="addr-identity-email">${escapeHtml(email)}</span>`
|
||
: '';
|
||
let avatarNode;
|
||
if (!avatarUuid) {
|
||
const initials = escapeHtml(initialsForName(nameValue));
|
||
avatarNode = `<span class="grid-avatar grid-avatar-placeholder addr-identity-avatar">${initials}</span>`;
|
||
} else {
|
||
const src = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=64`, appBase).toString();
|
||
const full = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=256`, appBase).toString();
|
||
avatarNode = `<a data-fslightbox="address-book-avatars" href="${full}"><img class="grid-avatar addr-identity-avatar" src="${src}" alt="" loading="lazy"></a>`;
|
||
}
|
||
return gridjs.html(`<span class="addr-identity-cell">${avatarNode}<span class="addr-identity-text">${nameNode}${emailNode}</span></span>`);
|
||
},
|
||
},
|
||
{
|
||
name: labels.context || 'Tenant / Department',
|
||
sort: false,
|
||
formatter: (cell) => {
|
||
const tenant = escapeHtml(String(cell?.tenant || ''));
|
||
const department = escapeHtml(String(cell?.department || ''));
|
||
const extra = Number(cell?.extra || 0);
|
||
if (!tenant && !department && !extra) {
|
||
return gridjs.html('<span class="addr-context-cell addr-context-empty">–</span>');
|
||
}
|
||
const parts = [];
|
||
if (tenant) {
|
||
parts.push(`<span class="addr-context-primary">${tenant}</span>`);
|
||
}
|
||
if (department) {
|
||
parts.push(`<span class="addr-context-separator" aria-hidden="true">·</span><span class="addr-context-secondary">${department}</span>`);
|
||
}
|
||
if (extra > 0) {
|
||
const extraLabel = escapeHtml(labels.extraAssignments || 'further assignments');
|
||
parts.push(`<span class="addr-context-extra" title="+${extra} ${extraLabel}">+${extra}</span>`);
|
||
}
|
||
return gridjs.html(`<span class="addr-context-cell">${parts.join('')}</span>`);
|
||
},
|
||
},
|
||
{
|
||
name: labels.phone || 'Phone',
|
||
sort: false,
|
||
formatter: (cell) => {
|
||
const number = String(cell || '').trim();
|
||
if (!number) {
|
||
return gridjs.html('<span class="addr-phone-empty">–</span>');
|
||
}
|
||
const tel = number.replace(/[^+0-9]/g, '');
|
||
const safe = escapeHtml(number);
|
||
return gridjs.html(`<a class="addr-phone-cell" href="tel:${escapeHtml(tel)}">${safe}</a>`);
|
||
},
|
||
},
|
||
{
|
||
name: labels.actions || 'Actions',
|
||
sort: false,
|
||
formatter: (cell, row) => {
|
||
const uuid = row?.cells?.[uuidIndex]?.data;
|
||
if (!uuid) {
|
||
return gridjs.html('');
|
||
}
|
||
const viewUrl = escapeHtml(withCurrentListReturn(new URL(`address-book/view/${uuid}`, appBase).toString()));
|
||
const email = String(cell?.email || '').trim();
|
||
const openLabel = escapeHtml(labels.openDetails || 'Open details');
|
||
const mailLabel = escapeHtml(labels.sendEmail || 'Send email');
|
||
const actions = [
|
||
`<a class="addr-action-button" href="${viewUrl}" data-tooltip="${openLabel}" data-tooltip-pos="left" aria-label="${openLabel}"><i class="bi bi-box-arrow-up-right"></i></a>`,
|
||
];
|
||
if (email) {
|
||
const safeMail = escapeHtml(email);
|
||
actions.push(`<a class="addr-action-button" href="mailto:${safeMail}" data-tooltip="${mailLabel}" data-tooltip-pos="left" aria-label="${mailLabel}"><i class="bi bi-envelope"></i></a>`);
|
||
}
|
||
return gridjs.html(`<span class="addr-action-cell">${actions.join('')}</span>`);
|
||
},
|
||
},
|
||
],
|
||
sortColumns: [null, 'display_name', null, null, null],
|
||
paginationLimit: 10,
|
||
language: config.gridLang ?? {},
|
||
mapData: (data) => data.data.map((row) => {
|
||
const phone = (row.phone && String(row.phone).trim()) || (row.mobile && String(row.mobile).trim()) || '';
|
||
return [
|
||
row.uuid,
|
||
{
|
||
name: row.display_name,
|
||
email: row.email || '',
|
||
avatar_uuid: row.has_avatar ? row.uuid : '',
|
||
},
|
||
{
|
||
tenant: row.primary_tenant_label || '',
|
||
department: row.primary_department_label || '',
|
||
extra: row.extra_assignments_count || 0,
|
||
},
|
||
phone,
|
||
{ email: row.email || '' },
|
||
];
|
||
}),
|
||
search: gridSearch,
|
||
filterSchema,
|
||
extraFilters: customFilters,
|
||
urlSync: true,
|
||
rowDataset: (row) => ({
|
||
uuid: row?.cells?.[uuidIndex]?.data,
|
||
}),
|
||
rowInteraction: { linkColumn: false },
|
||
rowDblClick: {
|
||
getUrl: (rowData) => {
|
||
const uuid = rowData?.cells?.[uuidIndex]?.data;
|
||
return uuid ? new URL(`address-book/view/${uuid}`, appBase).toString() : '';
|
||
},
|
||
},
|
||
},
|
||
filters: {
|
||
mode: 'drawer',
|
||
chipMeta: filterChipMeta,
|
||
watchInputs: ['#address-book-search'],
|
||
preserveFilterParamsOnReset: ['tenant'],
|
||
drawer: {
|
||
countExcludeParams: ['tenant'],
|
||
},
|
||
clearMetaOptions: {
|
||
preserveFilterParams: ['tenant'],
|
||
},
|
||
},
|
||
}) || {};
|
||
|
||
initMultiSelect().then(() => {
|
||
initMultiSelectCascade({
|
||
parent: '#address-book-tenant-filter',
|
||
child: '#address-book-department-filter',
|
||
map: tenantDepartmentMap,
|
||
mode: 'disable',
|
||
clearInvalid: true,
|
||
});
|
||
});
|
||
|
||
initDetailDrawer({
|
||
gridConfig,
|
||
triggerSelector: '[data-drawer-trigger]',
|
||
rowUuidAttr: 'uuid',
|
||
fetchUrl: (uuid) => new URL(`address-book/view-fragment/${uuid}`, appBase).toString(),
|
||
fullUrl: (uuid) => withCurrentListReturn(new URL(`address-book/view/${uuid}`, appBase).toString()),
|
||
hashPrefix: 'user',
|
||
});
|
||
|
||
return gridConfig;
|
||
},
|
||
});
|