Client side nav: coloured initials and client name under an All clients link, aligned with the menu, click to switch clients

This commit is contained in:
johnnyq
2026-10-10 13:49:47 -04:00
parent 4728797432
commit 362a6dcfd2
7 changed files with 315 additions and 16 deletions

View File

@@ -364,22 +364,33 @@ if (isset($_GET['share_generate_link'])) {
/*
* Returns sorted list of active clients
*/
/*
* Every active client this agent can open, most recently opened first - the
* client switcher in the client side nav (js/client_switcher.js) loads it once
* and filters it as you type. Names are raw; the script renders them as text.
*/
if (isset($_GET['get_active_clients'])) {
enforceUserPermission('module_client');
$client_sql = mysqli_query(
$mysqli,
"SELECT client_id, client_name FROM clients
"SELECT client_id, client_name, client_abbreviation FROM clients
WHERE client_archived_at IS NULL
" . clientScopeSql('clients.client_id') . "
ORDER BY client_accessed_at DESC"
);
$response = ['clients' => []];
while ($row = mysqli_fetch_assoc($client_sql)) {
$response['clients'][] = $row;
$response['clients'][] = [
'client_id' => intval($row['client_id']),
'client_name' => $row['client_name'],
'client_initials' => clientInitials($row['client_abbreviation'], $row['client_name']),
'client_tone' => clientInitialsTone($row['client_id']),
];
}
echo json_encode($response);
echo json_encode($response, JSON_INVALID_UTF8_SUBSTITUTE);
}
/*

View File

@@ -268,9 +268,6 @@ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COALESCE(SUM(client_lead
$total_clients = intval($row['clients']);
$total_leads = intval($row['leads']);
// Initials tile colours - picked by id, so a client keeps its colour from page to page
$avatar_tones = ['primary', 'success', 'info', 'warning', 'secondary'];
?>
<div class="card mb-3">
@@ -479,10 +476,8 @@ $avatar_tones = ['primary', 'success', 'info', 'warning', 'secondary'];
$client_currency_code = escapeHtml($row['client_currency_code'] ?: $session_company_currency);
$client_rate = floatval($row['client_rate']);
// Initials tile: the client's own abbreviation when it has one
$client_abbreviation = trim($row['client_abbreviation']) ?: shortenClientName($row['client_name']);
$client_initials = escapeHtml(mb_strtoupper(mb_substr($client_abbreviation, 0, 3)));
$client_tone = $avatar_tones[$client_id % count($avatar_tones)];
$client_initials = escapeHtml(clientInitials($row['client_abbreviation'], $row['client_name']));
$client_tone = clientInitialsTone($client_id);
$contact_id = intval($row['contact_id']);
$contact_name = escapeHtml($row['contact_name']);

View File

@@ -2,21 +2,67 @@
<aside class="app-sidebar shadow d-print-none" data-bs-theme="dark">
<div class="sidebar-brand">
<a class="brand-link" href="/agent/clients.php">
<a class="brand-link" href="/agent/clients.php<?= $client_is_lead ? '?leads=1' : '' ?>">
<i class="fas fa-arrow-left me-2"></i>
<span class="brand-text h5 mb-0">Back | <strong><?= $client_name_truncated ?></strong></span>
<span class="brand-text fs-6 text-body-secondary"><?= $client_is_lead ? 'All leads' : 'All clients' ?></span>
</a>
</div>
<!-- Sidebar -->
<div class="sidebar-wrapper">
<?php
/*
* Which client this is - same initials and colour as on the clients
* list - and a click opens a switcher to jump to another one
* (js/client_switcher.js). Switching keeps you on the same kind of
* page: a list stays that list, and a single record (a ticket, an
* invoice) goes to its list, since that record belongs to this client.
*/
$client_switch_pages = [
'asset.php' => 'assets.php',
'contact.php' => 'contacts.php',
'document.php' => 'files.php',
'invoice.php' => 'invoices.php',
'network.php' => 'networks.php',
'project.php' => 'projects.php',
'quote.php' => 'quotes.php',
'recurring_invoice.php' => 'recurring_invoices.php',
'ticket.php' => 'tickets.php',
];
$client_switch_page = basename($_SERVER['PHP_SELF']);
$client_switch_page = $client_switch_pages[$client_switch_page] ?? $client_switch_page;
if (!preg_match('/^[a-z_]+\.php$/', $client_switch_page) || !is_file(__DIR__ . "/../$client_switch_page")) {
$client_switch_page = 'client_overview.php';
}
?>
<div class="dropdown itflow-client-switcher" data-client-switcher data-client-id="<?= $client_id ?>" data-switch-url="/agent/<?= $client_switch_page ?>?client_id=">
<button type="button" class="itflow-client-nav-identity" data-bs-toggle="dropdown" data-bs-auto-close="outside" data-bs-display="static" aria-expanded="false" title="<?= $client_name ?>">
<span class="itflow-client-avatar itflow-client-avatar-sm text-bg-<?= $client_initials_tone ?>" aria-hidden="true"><?= $client_initials ?></span>
<span class="overflow-hidden flex-grow-1">
<?php if ($client_is_lead || $client_archived_at) { ?>
<span class="d-block itflow-client-nav-identity-label"><?= ($client_archived_at ? 'Archived ' : '') . ($client_is_lead ? 'lead' : 'client') ?></span>
<?php } ?>
<span class="d-block itflow-client-nav-identity-name"><?= $client_name ?></span>
</span>
<i class="fas fa-fw fa-sort itflow-client-switcher-caret" aria-hidden="true"></i>
<span class="visually-hidden">Switch client</span>
</button>
<div class="dropdown-menu itflow-client-switcher-menu">
<div class="px-2 pb-2">
<input type="search" class="form-control form-control-sm" placeholder="Switch to client" aria-label="Find a client" autocomplete="off" data-client-switcher-search>
</div>
<div class="itflow-client-switcher-list" data-client-switcher-list></div>
</div>
</div>
<script src="/js/client_switcher.js" defer></script>
<!-- Sidebar Menu -->
<nav>
<ul class="nav nav-pills sidebar-menu flex-column" data-lte-toggle="treeview" role="menu" data-accordion="false">
<li class="nav-item mt-3">
<li class="nav-item">
<a href="/agent/client_overview.php?client_id=<?= $client_id ?>" class="nav-link <?php if (basename($_SERVER["PHP_SELF"]) == "client_overview.php") { echo "active"; } ?>">
<i class="nav-icon fas fa-tachometer-alt"></i>
<p>Overview</p>

View File

@@ -39,7 +39,6 @@ if (isset($_GET['client_id'])) {
$row = mysqli_fetch_assoc($sql);
$client_name = escapeHtml($row['client_name']);
$client_name_truncated = escapeHtml(truncate($row['client_name'], 7));
$client_is_lead = intval($row['client_lead']);
$client_type = escapeHtml($row['client_type']);
$client_website = escapeHtml($row['client_website']);
@@ -48,6 +47,8 @@ if (isset($_GET['client_id'])) {
$client_net_terms = intval($row['client_net_terms']);
$client_tax_id_number = escapeHtml($row['client_tax_id_number']);
$client_abbreviation = escapeHtml($row['client_abbreviation']);
$client_initials = escapeHtml(clientInitials($row['client_abbreviation'], $row['client_name']));
$client_initials_tone = clientInitialsTone($client_id);
$client_rate = floatval($row['client_rate']);
$client_notes = escapeHtml($row['client_notes']);
$client_created_at = escapeHtml($row['client_created_at']);

View File

@@ -2338,8 +2338,8 @@ a.itflow-dash-rail-item:focus-visible {
/* Clients list - agent/clients.php
Rows are a table with Bootstrap's own utilities; this is only the initials
tile beside each name, the small tag pills under it, and a tint on rows
ticked for a bulk action. */
tile beside each name (also used at the top of the client side nav), the
small tag pills under it, and a tint on rows ticked for a bulk action. */
.itflow-client-avatar {
display: inline-flex;
flex: 0 0 auto;
@@ -2376,3 +2376,97 @@ a.itflow-dash-rail-item:focus-visible {
.itflow-clients tbody tr:has(.bulk-select:checked) > * {
--bs-table-bg-state: var(--bs-primary-bg-subtle);
}
/* Client side nav - agent/includes/client_side_nav.php: the client's initials
and name at the top, under the "All clients" link */
/* Same width, corners and left padding as the menu links below it, so the
tile lines up with their icons; sits right under the "All clients" bar */
.itflow-client-nav-identity {
display: flex;
align-items: center;
gap: .6rem;
margin: 0;
padding: .35rem .8rem;
border: 1px solid rgba(255, 255, 255, .08);
border-radius: var(--bs-border-radius);
background-color: rgba(255, 255, 255, .05);
}
.itflow-client-avatar-sm {
width: 2rem;
height: 2rem;
border-radius: var(--bs-border-radius);
font-size: .7rem;
}
.itflow-client-nav-identity-label {
font-size: .65rem;
font-weight: 600;
letter-spacing: .05em;
line-height: 1.2;
text-transform: uppercase;
color: rgba(255, 255, 255, .55);
}
/* The card is the client switcher's button (js/client_switcher.js) */
button.itflow-client-nav-identity {
width: 100%;
text-align: left;
color: inherit;
font: inherit;
cursor: pointer;
}
button.itflow-client-nav-identity:hover,
button.itflow-client-nav-identity[aria-expanded="true"] {
background-color: rgba(255, 255, 255, .1);
}
button.itflow-client-nav-identity:focus-visible {
outline: 2px solid var(--itflow-accent, var(--bs-primary));
outline-offset: 1px;
}
.itflow-client-switcher-caret {
flex: 0 0 auto;
margin-left: auto;
font-size: .75rem;
color: rgba(255, 255, 255, .45);
}
.itflow-client-switcher {
margin-bottom: .5rem;
}
.itflow-client-switcher-menu {
width: 100%;
margin-top: .25rem !important;
padding: .5rem 0 .25rem;
}
.itflow-client-switcher-list {
max-height: min(22rem, 60vh);
overflow-y: auto;
}
.itflow-client-switcher-item {
display: flex;
align-items: center;
gap: .5rem;
padding-top: .3rem;
padding-bottom: .3rem;
font-size: .875rem;
}
.itflow-client-avatar-xs {
width: 1.5rem;
height: 1.5rem;
border-radius: var(--bs-border-radius-sm);
font-size: .6rem;
}
.itflow-client-switcher-empty {
padding: .4rem 1rem;
font-size: .8rem;
color: var(--bs-secondary-color);
}
/* Long names get two lines, then an ellipsis - the full name is in the tooltip */
.itflow-client-nav-identity-name {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
font-size: .875rem;
font-weight: 600;
line-height: 1.25;
color: #fff;
overflow-wrap: anywhere;
}

View File

@@ -330,6 +330,21 @@ function shortenClientName($client) {
return strtoupper(substr($shortened, 0, 3));
}
/**
* Up to three letters for a client's initials tile - their abbreviation when
* they have one, otherwise one made from the name. Raw: escape at output.
*/
function clientInitials($abbreviation, $name) {
$source = trim((string) $abbreviation) ?: shortenClientName((string) $name);
return mb_strtoupper(mb_substr($source, 0, 3));
}
/** The colour of a client's initials tile, picked by id so it is the same on every page. */
function clientInitialsTone($client_id) {
$tones = ['primary', 'success', 'info', 'warning', 'secondary'];
return $tones[intval($client_id) % count($tones)];
}
function roundUpToNearestMultiple($n, $increment = 1000) {
return (int) ($increment * ceil($n / $increment));

137
js/client_switcher.js Normal file
View File

@@ -0,0 +1,137 @@
/*
* ITFlow - client switcher.
*
* The client card at the top of the client side nav opens a dropdown listing
* the agent's clients, most recently opened first, with a box to filter them.
* The list comes from ajax.php?get_active_clients the first time it opens and
* is filtered here as you type. Picking a client goes to the same page for
* that client - the side nav works out which page that is (data-switch-url).
*
* Keys: type to filter, Down to move into the list, Enter to open the first
* match, Esc to close.
*/
(function () {
'use strict';
const root = document.querySelector('[data-client-switcher]');
if (!root) {
return;
}
const input = root.querySelector('[data-client-switcher-search]');
const list = root.querySelector('[data-client-switcher-list]');
const currentId = parseInt(root.dataset.clientId, 10);
const switchUrl = root.dataset.switchUrl;
const tones = ['primary', 'success', 'info', 'warning', 'secondary'];
const limit = 50;
let clients = null;
let loading = null;
function message(text) {
list.textContent = '';
const div = document.createElement('div');
div.className = 'itflow-client-switcher-empty';
div.textContent = text;
list.appendChild(div);
}
function item(c) {
const id = parseInt(c.client_id, 10);
const link = document.createElement('a');
link.className = 'dropdown-item itflow-client-switcher-item';
link.href = switchUrl + id;
if (id === currentId) {
link.classList.add('active');
link.setAttribute('aria-current', 'true');
}
const tile = document.createElement('span');
const tone = tones.indexOf(c.client_tone) !== -1 ? c.client_tone : 'secondary';
tile.className = 'itflow-client-avatar itflow-client-avatar-xs text-bg-' + tone;
tile.setAttribute('aria-hidden', 'true');
tile.textContent = c.client_initials;
const name = document.createElement('span');
name.className = 'text-truncate';
name.textContent = c.client_name;
link.append(tile, name);
return link;
}
function render() {
if (clients === null) {
message('Loading clients...');
return;
}
const typed = input.value.trim();
const term = typed.toLowerCase();
const matches = clients.filter(function (c) {
return !term || c.client_name.toLowerCase().indexOf(term) !== -1 || c.client_initials.toLowerCase() === term;
});
if (!matches.length) {
message(term ? 'No clients match "' + typed + '"' : 'No clients');
return;
}
list.textContent = '';
matches.slice(0, limit).forEach(function (c) {
list.appendChild(item(c));
});
if (matches.length > limit) {
const more = document.createElement('div');
more.className = 'itflow-client-switcher-empty';
more.textContent = 'Keep typing to narrow down ' + (matches.length - limit) + ' more';
list.appendChild(more);
}
}
function load() {
if (clients !== null || loading) {
return;
}
loading = fetch('/agent/ajax.php?get_active_clients', { credentials: 'same-origin', headers: { Accept: 'application/json' } })
.then(function (response) {
if (!response.ok) {
throw new Error(response.status);
}
return response.json();
})
.then(function (data) {
clients = (data && Array.isArray(data.clients)) ? data.clients : [];
render();
})
.catch(function () {
loading = null;
message('Could not load clients - close and try again');
});
}
root.addEventListener('show.bs.dropdown', function () {
input.value = '';
render();
load();
});
root.addEventListener('shown.bs.dropdown', function () {
input.focus();
});
input.addEventListener('input', render);
input.addEventListener('keydown', function (event) {
const first = list.querySelector('.itflow-client-switcher-item');
if (event.key === 'Enter') {
event.preventDefault();
if (first) {
window.location.href = first.href;
}
} else if (event.key === 'ArrowDown' && first) {
event.preventDefault();
first.focus();
}
});
})();