mirror of
https://github.com/itflow-org/itflow
synced 2026-11-11 07:01:16 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
137
js/client_switcher.js
Normal 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();
|
||||
}
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user