Client overview redesign with technical and financial sections; client header on all client pages gets initials, industry, tags, open tickets and balance due, and an expandable panel matching the overview

This commit is contained in:
johnnyq
2026-10-10 15:51:07 -04:00
parent 2b2a6c8c42
commit 81062aea0e
10 changed files with 1430 additions and 1041 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
<?php
/*
* The client actions menu - New Ticket, Edit, Export, Archive/Restore, Delete.
* Shared by the client header (inc_client_top_head.php) and the client
* overview, so both offer exactly the same actions behind the same
* permissions. Include it inside a .dropdown, after its toggle button.
*/
?>
<div class="dropdown-menu">
<?php if (lookupUserPermission("module_support") >= 2) { ?>
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/ticket/ticket_add.php?client_id=<?= $client_id ?>" data-modal-size="lg">
<i class="fas fa-fw fa-life-ring me-2"></i>New Ticket
</a>
<?php } ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item ajax-modal" href="#"
data-modal-url="modals/client/client_edit.php?id=<?= $client_id ?>">
<i class="fas fa-fw fa-edit me-2"></i>Edit Client
</a>
<?php if (lookupUserPermission("module_billing") >= 2) { ?>
<?php if ($show_add_credit) { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#addCreditModal">
<i class="fas fa-fw fa-wallet me-2"></i>Add Credit
</a>
<?php } ?>
<?php } ?>
<?php if (lookupUserPermission("module_client") >= 1) { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#exportClientPDFModal">
<i class="fas fa-fw fa-file-pdf me-2"></i>Export Data
</a>
<?php } ?>
<?php if (empty($client_archived_at)) { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger confirm-link" href="post.php?archive_client=<?= $client_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-archive me-2"></i>Archive Client
</a>
<?php } else { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-primary confirm-link" href="post.php?restore_client=<?= $client_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-archive me-2"></i>Restore Client
</a>
<?php } ?>
<?php if (lookupUserPermission("module_client") >= 3 && $client_archived_at) { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger text-bold" href="#" data-bs-toggle="modal" data-bs-target="#deleteClientModal<?= $client_id ?>">
<i class="fas fa-fw fa-trash me-2"></i>Delete Client
</a>
<?php } ?>
</div>

View File

@@ -0,0 +1,76 @@
<?php
/*
* The primary contact, primary location and details columns under the
* client's name. Shared by the client overview's header and the client header
* on every other client page (inc_client_top_head.php), so both show the same.
* Expects the client variables from inc_all_client.php; the caller supplies
* the surrounding .row.
*/
$client_details_edit = lookupUserPermission('module_client') >= 2;
$client_since = $client_created_at ? date('M Y', strtotime($client_created_at)) : '';
$client_website_url = $client_website ? (preg_match('#^https?://#i', $client_website) ? $client_website : "https://$client_website") : '';
$client_website_label = preg_replace('#^https?://#i', '', rtrim($client_website, '/'));
$location_map_query = rawurlencode(html_entity_decode(trim("$location_address $location_city $location_state $location_zip"), ENT_QUOTES));
?>
<div class="col-md-4">
<div class="itflow-ov-label">Primary contact</div>
<?php if ($contact_id) { ?>
<a class="ajax-modal fw-semibold" href="#" data-modal-url="modals/contact/contact.php?client_id=<?= $client_id ?>&id=<?= $contact_id ?>" data-modal-size="lg"><?= $contact_name ?: 'Unnamed contact' ?></a>
<?php if ($contact_title) { ?><span class="text-body-secondary small ms-1"><?= $contact_title ?></span><?php } ?>
<div class="small mt-1 d-flex flex-column gap-1">
<?php if ($contact_phone) { ?>
<a class="text-reset text-decoration-none" href="tel:<?= escapeHtml(preg_replace('/[^0-9+]/', '', html_entity_decode($contact_phone))) ?>"><i class="fas fa-fw fa-phone text-body-secondary me-2" aria-hidden="true"></i><?= $contact_phone ?><?= $contact_extension ? " x$contact_extension" : '' ?></a>
<?php } ?>
<?php if ($contact_mobile) { ?>
<a class="text-reset text-decoration-none" href="tel:<?= escapeHtml(preg_replace('/[^0-9+]/', '', html_entity_decode($contact_mobile))) ?>"><i class="fas fa-fw fa-mobile-alt text-body-secondary me-2" aria-hidden="true"></i><?= $contact_mobile ?></a>
<?php } ?>
<?php if ($contact_email) { ?>
<span class="d-flex align-items-center">
<a class="text-reset text-decoration-none text-truncate" href="mailto:<?= $contact_email ?>"><i class="far fa-fw fa-envelope text-body-secondary me-2" aria-hidden="true"></i><?= $contact_email ?></a>
<button class="btn btn-link btn-sm p-0 ms-1 text-body-secondary clipboardjs" type="button" title="Copy email" data-clipboard-text="<?= $contact_email ?>"><i class="far fa-fw fa-copy" aria-hidden="true"></i></button>
</span>
<?php } ?>
</div>
<?php } elseif ($client_details_edit) { ?>
<a class="ajax-modal small" href="#" data-modal-url="modals/contact/contact_add.php?client_id=<?= $client_id ?>&primary=1"><i class="fas fa-fw fa-plus me-1"></i>Add primary contact</a>
<?php } else { ?>
<span class="text-body-tertiary">None</span>
<?php } ?>
</div>
<div class="col-md-4">
<div class="itflow-ov-label">Primary location</div>
<?php if ($location_id) { ?>
<?php if ($location_address || $location_city) { ?>
<a class="text-reset text-decoration-none" href="//maps.<?= $session_map_source ?>.com/?q=<?= $location_map_query ?>" target="_blank" rel="noopener" title="Open in maps">
<?php if ($location_address) { ?><div><?= $location_address ?></div><?php } ?>
<div class="<?= $location_address ? 'small text-body-secondary' : '' ?>"><?= formatAddress('', $location_city, $location_state, $location_zip, $location_country, ', ') ?></div>
</a>
<?php } else { ?>
<div><?= $location_name ?></div>
<?php } ?>
<?php if ($location_phone) { ?>
<a class="small text-reset text-decoration-none d-block mt-1" href="tel:<?= escapeHtml(preg_replace('/[^0-9+]/', '', html_entity_decode($location_phone))) ?>"><i class="fas fa-fw fa-phone text-body-secondary me-2" aria-hidden="true"></i><?= $location_phone ?></a>
<?php } ?>
<?php } elseif ($client_details_edit) { ?>
<a class="ajax-modal small" href="#" data-modal-url="modals/location/location_add.php?client_id=<?= $client_id ?>&primary=1"><i class="fas fa-fw fa-plus me-1"></i>Add primary location</a>
<?php } else { ?>
<span class="text-body-tertiary">None</span>
<?php } ?>
</div>
<div class="col-md-4">
<div class="itflow-ov-label">Details</div>
<div class="small d-flex flex-column gap-1">
<?php if ($client_website) { ?>
<a class="text-truncate" href="<?= $client_website_url ?>" target="_blank" rel="noopener"><i class="fas fa-fw fa-globe text-body-secondary me-2" aria-hidden="true"></i><?= $client_website_label ?></a>
<?php } ?>
<?php if ($client_since) { ?>
<span><i class="far fa-fw fa-calendar text-body-secondary me-2" aria-hidden="true"></i><?= $client_is_lead ? 'Lead' : 'Client' ?> since <?= $client_since ?></span>
<?php } ?>
<?php if ($client_referral) { ?>
<span><i class="fas fa-fw fa-handshake text-body-secondary me-2" aria-hidden="true"></i>Referred by <?= $client_referral ?></span>
<?php } ?>
<span class="text-body-secondary"><i class="fas fa-fw fa-hashtag me-2" aria-hidden="true"></i>Client ID <?= $client_id ?></span>
</div>
</div>

View File

@@ -0,0 +1,22 @@
<?php
/*
* The client's tag pills, each opening the clients list filtered to that tag,
* then a "+" pill that opens Add Tags for this client. Used by the client
* header (inc_client_top_head.php) and the client overview. Expects
* $client_tags (from inc_all_client.php) and $client_id.
*/
foreach ($client_tags as $client_tag) {
$client_tag_id = intval($client_tag['tag_id']);
$client_tag_name = escapeHtml($client_tag['tag_name']);
$client_tag_color = preg_match('/^#[0-9a-f]{3,8}$/i', (string) $client_tag['tag_color']) ? $client_tag['tag_color'] : 'var(--bs-secondary)';
$client_tag_icon = preg_replace('/[^a-z0-9-]/', '', strtolower((string) $client_tag['tag_icon'])) ?: 'tag';
?>
<a href="clients.php?tags[]=<?= $client_tag_id ?>" class="itflow-client-tag" style="--itflow-tag-color: <?= $client_tag_color ?>" title="Clients tagged <?= $client_tag_name ?>">
<i class="fas fa-fw fa-<?= $client_tag_icon ?>" aria-hidden="true"></i><?= $client_tag_name ?>
</a>
<?php }
if (lookupUserPermission("module_client") >= 2) { ?>
<a href="#" class="itflow-client-tag itflow-client-tag-add ajax-modal" data-modal-url="modals/client/client_bulk_assign_tags.php?client_ids[]=<?= $client_id ?>" title="Add tags">
<i class="fas fa-fw fa-plus" aria-hidden="true"></i><?= $client_tags ? '<span class="visually-hidden">Add tags</span>' : 'Tag' ?>
</a>
<?php }

View File

@@ -78,28 +78,12 @@ if (isset($_GET['client_id'])) {
// Tab Title // No Sanitizing needed
$tab_title = $row['client_name'];
// Client Tags
$client_tag_name_display_array = array();
$client_tag_id_array = array();
$sql_client_tags = mysqli_query($mysqli, "SELECT tag_color, tag_icon, client_tags.tag_id, tag_name FROM client_tags LEFT JOIN tags ON client_tags.tag_id = tags.tag_id WHERE client_id = $client_id ORDER BY tag_name ASC");
// Client Tags - raw rows, drawn by includes/client_tags.php
$client_tags = [];
$sql_client_tags = mysqli_query($mysqli, "SELECT tags.tag_id, tag_name, tag_color, tag_icon FROM client_tags JOIN tags ON client_tags.tag_id = tags.tag_id WHERE client_id = $client_id ORDER BY tag_name ASC");
while ($row = mysqli_fetch_assoc($sql_client_tags)) {
$client_tag_id = intval($row['tag_id']);
$client_tag_name = escapeHtml($row['tag_name']);
$client_tag_color = escapeHtml($row['tag_color']);
if (empty($client_tag_color)) {
$client_tag_color = "dark";
}
$client_tag_icon = escapeHtml($row['tag_icon']);
if (empty($client_tag_icon)) {
$client_tag_icon = "tag";
}
$client_tag_id_array[] = $client_tag_id;
$client_tag_name_display_array[] = "<span class='badge text-light p-1 me-1' style='background-color: $client_tag_color;'><i class='fa fa-fw fa-$client_tag_icon me-2'></i>$client_tag_name</span>";
$client_tags[] = $row;
}
$client_tags_display = implode('', $client_tag_name_display_array);
/*
* Client badge counts and money totals.

View File

@@ -1,228 +1,86 @@
<?php $show_add_credit = 0; // Remove once credits is added hides the button ?>
<?php
/*
* Whether #clientHeader starts open. Remembered in a cookie rather than
* localStorage precisely so it can be answered HERE, server side - the panel
* and its chevron then render in the right state on the first paint instead
* of flashing shut and springing open once JS has run.
*
* With no cookie yet, fall back to the old behaviour: open on the client
* overview, closed on every other client page.
*/
if (isset($_COOKIE['client_header_open'])) {
$client_header_open = $_COOKIE['client_header_open'] === '1';
} else {
$client_header_open = basename($_SERVER["PHP_SELF"]) == "client_overview.php";
}
?>
/*
* The client header at the top of every client page except the overview,
* which draws its own (same tags, same actions menu).
*
* One card: the client's initials, name with its type under it, tags, open
* tickets and any balance due, and the actions menu. Clicking the name opens
* a panel with the same primary contact, location and details as the
* overview's header (includes/client_details.php).
*
* Whether the panel starts open is remembered in a cookie rather than
* localStorage precisely so it can be answered HERE, server side - the panel
* and its chevron then render in the right state on the first paint instead of
* flashing shut and springing open once JS has run. Closed until opened.
*/
<div class="card mb-3 d-print-none">
<div class="card-header pb-1 pt-2 px-3">
<div class="card-title">
<a href="#" class="client-header-toggle<?= $client_header_open ? '' : ' collapsed' ?>" data-bs-toggle="collapse" data-bs-target="#clientHeader" aria-controls="clientHeader" aria-expanded="<?= $client_header_open ? 'true' : 'false' ?>"><h4 class="text-dark" data-bs-toggle="tooltip" data-bs-placement="right" title="Client ID: <?= $client_id ?>"><i class="fas fa-fw fa-chevron-<?= $client_header_open ? 'down' : 'right' ?> client-header-chevron" aria-hidden="true"></i><strong><?= $client_name ?></strong> <?php if ($client_archived_at) { echo "(archived)"; } ?></h4></a>
$show_add_credit = 0; // Remove once credits is added hides the button
$client_header_open = ($_COOKIE['client_header_open'] ?? '0') === '1';
if (empty($client_top_head_skip)) {
$client_head_edit = lookupUserPermission("module_client") >= 2;
$client_head_billing = lookupUserPermission("module_financial") >= 1 && $config_module_enable_accounting == 1;
$client_head_support = lookupUserPermission("module_support") >= 1 && $config_module_enable_ticketing == 1;
$client_head_invoices = lookupUserPermission("module_sales") >= 1 && $config_module_enable_accounting == 1;
$client_head_glance_tickets = $client_head_support && $num_active_tickets > 0;
$client_head_glance_due = $client_head_billing && $balance > 0;
$client_head_money = function ($amount) use ($currency_format, $client_currency_code, $session_company_currency) {
return numfmt_format_currency($currency_format, floatval($amount), $client_currency_code ?: $session_company_currency);
};
?>
<div class="card mb-3 d-print-none itflow-client-head">
<div class="card-body d-flex align-items-start align-items-md-center gap-2 px-3 py-2">
<div class="d-flex flex-wrap align-items-center column-gap-2 row-gap-2 row-gap-md-1 flex-grow-1 itflow-client-head-main">
<a href="#" class="itflow-client-head-toggle client-header-toggle<?= $client_header_open ? '' : ' collapsed' ?>" data-bs-toggle="collapse" data-bs-target="#clientHeader" aria-controls="clientHeader" aria-expanded="<?= $client_header_open ? 'true' : 'false' ?>" title="Client ID: <?= $client_id ?>">
<i class="fas fa-fw fa-chevron-<?= $client_header_open ? 'down' : 'right' ?> client-header-chevron" aria-hidden="true"></i>
<span class="itflow-client-avatar itflow-client-avatar-sm text-bg-<?= $client_initials_tone ?>" aria-hidden="true"><?= $client_initials ?></span>
<span class="itflow-client-head-title">
<span class="itflow-client-head-name"><?= $client_name ?></span>
<?php if ($client_type) { ?><span class="itflow-client-head-type"><?= $client_type ?></span><?php } ?>
</span>
</a>
<?php if ($client_is_lead) { ?><span class="badge rounded-pill text-bg-info fw-normal">Lead</span><?php } ?>
<?php if ($client_archived_at) { ?><span class="badge rounded-pill text-bg-secondary fw-normal">Archived</span><?php } ?>
<div class="d-flex flex-wrap align-items-center gap-1">
<?php require 'includes/client_tags.php'; ?>
</div>
<?php /* Open tickets and balance due, always in sight */ ?>
<?php if ($client_head_glance_tickets || $client_head_glance_due) { ?>
<div class="itflow-client-head-glance d-flex align-items-center gap-3 ms-md-auto">
<?php if ($client_head_glance_tickets) { ?>
<a class="text-body-secondary" href="tickets.php?client_id=<?= $client_id ?>"><i class="fas fa-fw fa-life-ring me-1" aria-hidden="true"></i><?= number_format($num_active_tickets) ?> open</a>
<?php } ?>
<?php if ($client_head_glance_due) { ?>
<?php if ($client_head_invoices) { ?>
<a class="text-danger-emphasis fw-semibold" href="invoices.php?client_id=<?= $client_id ?>&status=Unpaid"><?= $client_head_money($balance) ?> due</a>
<?php } else { ?>
<span class="text-danger-emphasis fw-semibold"><?= $client_head_money($balance) ?> due</span>
<?php } ?>
<?php } ?>
</div>
<?php } ?>
</div>
<?php if (!empty($client_tag_name_display_array)) { ?><div class="card-title ms-2"><?= $client_tags_display ?></div> <?php } ?>
<?php if (lookupUserPermission("module_client") >= 2) { ?>
<div class="card-tools">
<div class="dropdown dropstart text-center">
<button class="btn btn-dark btn-sm" title="More actions" type="button" data-bs-toggle="dropdown">
<?php if ($client_head_edit) { ?>
<div class="dropdown dropstart flex-shrink-0">
<button class="btn btn-outline-secondary btn-sm" title="More actions" type="button" data-bs-toggle="dropdown">
<i class="fas fa-fw fa-ellipsis-v"></i>
</button>
<div class="dropdown-menu">
<?php if (lookupUserPermission("module_support") >= 2) { ?>
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/ticket/ticket_add.php?client_id=<?= $client_id ?>" data-modal-size="lg">
<i class="fas fa-fw fa-life-ring me-2"></i>New Ticket
</a>
<?php } ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item ajax-modal" href="#"
data-modal-url="modals/client/client_edit.php?id=<?= $client_id ?>">
<i class="fas fa-fw fa-edit me-2"></i>Edit Client
</a>
<?php if (lookupUserPermission("module_billing") >= 2) { ?>
<?php if ($show_add_credit) { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#addCreditModal">
<i class="fas fa-fw fa-wallet me-2"></i>Add Credit
</a>
<?php } ?>
<?php } ?>
<?php if (lookupUserPermission("module_client") >= 1) { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#exportClientPDFModal">
<i class="fas fa-fw fa-file-pdf me-2"></i>Export Data
</a>
<?php } ?>
<?php if (empty($client_archived_at)) { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger confirm-link" href="post.php?archive_client=<?= $client_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-archive me-2"></i>Archive Client
</a>
<?php } else { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-primary confirm-link" href="post.php?restore_client=<?= $client_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-archive me-2"></i>Restore Client
</a>
<?php } ?>
<?php if (lookupUserPermission("module_client") >= 3 && $client_archived_at) { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger text-bold" href="#" data-bs-toggle="modal" data-bs-target="#deleteClientModal<?= $client_id ?>">
<i class="fas fa-fw fa-trash me-2"></i>Delete Client
</a>
<?php } ?>
</div>
<?php require 'includes/client_actions_menu.php'; ?>
</div>
</div>
<?php } ?>
</div>
</div>
<div class="collapse<?= $client_header_open ? ' show' : '' ?>" id="clientHeader">
<div class="card-group mb-3">
<div class="card card-body px-3 py-2">
<h5>Primary Location</h5>
<?php /* location_id comes from inc_all_client.php's LEFT JOIN on
location_primary = 1, so empty means there is genuinely no
primary location - not merely a primary location with no
street address filled in. Gated at module_client >= 2 to
match enforceUserPermission() in agent/post/location.php;
without that a read-only user would get a link that 403s. */ ?>
<?php if (empty($location_id) && lookupUserPermission("module_client") >= 2) { ?>
<div class="mb-2">
<a class="ajax-modal small" href="#" data-modal-url="modals/location/location_add.php?client_id=<?= $client_id ?>&primary=1">
Add Primary Location
</a>
</div>
<?php }
if (!empty($location_address)) { ?>
<div>
<a href="//maps.<?= $session_map_source ?>.com/?q=<?= "$location_address $location_zip" ?>" target="_blank">
<i class="fa fa-fw fa-map-marker-alt text-secondary ms-1 me-2"></i><?= $location_address ?>
<div>
<i class="fa fa-fw ms-1 me-2"></i><?= formatAddress('', $location_city, $location_state, $location_zip, '', ' ') ?>
</div>
<div>
<i class="fa fa-fw ms-1 me-2"></i><small><?= $location_country ?></small>
</div>
</a>
</div>
<?php }
if (!empty($location_phone)) { ?>
<div>
<i class="fa fa-fw fa-phone text-secondary ms-1 me-2"></i><a href="tel:<?= $location_phone ?>"><?= $location_phone ?></a>
</div>
<hr class="my-2">
<?php }
if (!empty($client_website)) { ?>
<div class="mt-1">
<i class="fa fa-fw fa-globe text-secondary ms-1 me-2"></i><a target="_blank" href="//<?= $client_website ?>"><?= $client_website ?></a>
</div>
<?php } ?>
</div>
<div class="card card-body px-3 py-2">
<h5>Primary Contact</h5>
<?php
if (empty($contact_id) && lookupUserPermission("module_client") >= 2) { ?>
<div>
<a class="ajax-modal small" href="#" data-modal-url="modals/contact/contact_add.php?client_id=<?= $client_id ?>&primary=1">
Add Primary Contact
</a>
</div>
<?php }
if (!empty($contact_name)) { ?>
<div>
<i class="fa fa-fw fa-user text-secondary ms-1 me-2"></i><?= $contact_name ?>
</div>
<?php }
if (!empty($contact_email)) { ?>
<div class="mt-1">
<i class="fa fa-fw fa-envelope text-secondary ms-1 me-2"></i><a href="mailto:<?= $contact_email ?>"><?= $contact_email ?></a>
</div>
<?php
}
if (!empty($contact_phone)) { ?>
<div class="mt-1">
<i class="fa fa-fw fa-phone text-secondary ms-1 me-2"></i><a href="tel:<?= $contact_phone ?>"><?= $contact_phone ?></a>
<?php
if (!empty($contact_extension)) {
echo "<small>x$contact_extension</small>";
}
?>
</div>
<?php
}
if (!empty($contact_mobile)) { ?>
<div class="mt-1">
<i class="fa fa-fw fa-mobile-alt text-secondary ms-1 me-2"></i><a href="tel:<?= $contact_mobile ?>"><?= $contact_mobile ?></a>
</div>
<?php } ?>
</div>
<?php if (lookupUserPermission("module_financial") >= 1 && $config_module_enable_accounting == 1) { ?>
<div class="card card-body px-3 py-2">
<h5>Billing</h5>
<div class="ms-1 text-secondary">Hourly Rate
<span class="text-dark float-end"> <?= numfmt_format_currency($currency_format, $client_rate, $client_currency_code) ?></span>
</div>
<div class="ms-1 mt-1 text-secondary">Paid
<span class="text-dark float-end"> <?= numfmt_format_currency($currency_format, $amount_paid, $client_currency_code) ?></span>
</div>
<div class="ms-1 mt-1 text-secondary">Balance
<span class="<?php if ($balance > 0) { echo "text-danger"; }else{ echo "text-dark"; } ?> float-end"> <?= numfmt_format_currency($currency_format, $balance, $client_currency_code) ?></span>
</div>
<?php /* Credit Not Ready 2025-08-27 JQ
if ($credit_balance) { ?>
<div class="ms-1 mt-1 text-secondary">Credit
<span class="text-success float-end"><?php echo numfmt_format_currency($currency_format, $credit_balance, $client_currency_code); ?></span>
</div>
<?php } */?>
<div class="ms-1 mt-1 text-secondary">Monthly Recurring
<span class="text-dark float-end"> <?= numfmt_format_currency($currency_format, $recurring_monthly, $client_currency_code) ?></span>
</div>
<div class="ms-1 mt-1 text-secondary">Net Terms
<span class="text-dark float-end">
<?php if ($client_net_terms) { ?>
<?= $client_net_terms; ?><small class="text-secondary ms-1">Days</small>
<?php } else { ?>
On Receipt
<?php } ?>
</span>
</div>
<?php if(!empty($client_tax_id_number)) { ?>
<div class="ms-1 mt-1 text-secondary">Tax ID
<span class="text-dark float-end font-monospace"><?= $client_tax_id_number ?></span>
</div>
<?php } ?>
</div>
<?php } ?>
<?php if (lookupUserPermission("module_support") >= 1 && $config_module_enable_ticketing == 1) { ?>
<div class="card card-body px-3 py-2">
<h5>Support</h5>
<div class="ms-1 text-secondary">Open Tickets
<span class="text-dark float-end"><?= $num_active_tickets ?></span>
</div>
<div class="ms-1 text-secondary mt-1">Closed Tickets
<span class="text-dark float-end"><?= $num_closed_tickets ?></span>
<div class="collapse<?= $client_header_open ? ' show' : '' ?>" id="clientHeader">
<div class="card-body border-top px-3 py-3">
<div class="row g-3">
<?php require 'includes/client_details.php'; ?>
</div>
</div>
<?php } ?>
</div>
</div>
@@ -262,6 +120,8 @@
})();
</script>
<?php } ?>
<?php
// require_once "modals/client/client_credit_add.php"; --Credit Not Ready 2025-08-27
require_once "modals/client/client_delete.php";

View File

@@ -1552,26 +1552,71 @@ html {
max-width: 100%;
}
/* Client header disclosure - agent/includes/inc_client_top_head.php
/* Client header - agent/includes/inc_client_top_head.php
The client name is the collapse trigger for #clientHeader, but links carry no
underline in this app and the h4 is text-dark, so nothing said the heading was
clickable or that a panel was folded behind it.
Size and colour only. Which way it points is carried by the icon class itself
(fa-chevron-right / fa-chevron-down) - emitted by PHP for the first paint, swapped
by the include's own script after that. Not a CSS rotation, so the direction cannot
go stale behind a cached stylesheet. */
.client-header-chevron {
font-size: .7em;
margin-right: .25rem;
vertical-align: middle;
Initials, name and tags in one row; the name opens the panel underneath
(#clientHeader), which holds the same columns as the overview's header. Which way the chevron points is carried by the icon class
itself (fa-chevron-right / fa-chevron-down) - emitted by PHP for the first
paint, swapped by the include's own script after that. Not a CSS rotation,
so the direction cannot go stale behind a cached stylesheet. */
.itflow-client-head-main,
.itflow-client-head-toggle {
min-width: 0;
}
.itflow-client-head-toggle {
display: inline-flex;
align-items: center;
gap: .5rem;
color: var(--bs-body-color);
text-decoration: none;
}
.itflow-client-head-toggle:hover,
.itflow-client-head-toggle:focus-visible {
color: var(--bs-body-color);
}
/* Name with the client type under it, together no taller than the 2rem
initials tile beside them, so the row stays the same height */
.itflow-client-head-title {
display: flex;
flex-direction: column;
min-width: 0;
}
.itflow-client-head-name {
font-size: 1.125rem;
font-weight: 600;
line-height: 1.25rem;
overflow-wrap: anywhere;
}
.itflow-client-head-type {
font-size: .75rem;
line-height: .75rem;
color: var(--bs-secondary-color);
}
.client-header-chevron {
font-size: .7rem;
color: var(--bs-secondary-color);
}
.client-header-toggle:hover .client-header-chevron {
color: var(--bs-body-color);
}
/* Open tickets and balance due beside the menu */
.itflow-client-head-glance {
font-size: .875rem;
white-space: nowrap;
}
.itflow-client-head-glance a {
text-decoration: none;
}
/* Phone: a smaller name so most fit on one line beside the menu button, the
tags under it, then open tickets and balance due on their own line */
@media (max-width: 767.98px) {
.itflow-client-head-name {
font-size: 1rem;
}
.itflow-client-head-glance {
flex-basis: 100%;
}
}
/* Search palette - js/global_search.js
@@ -2487,3 +2532,49 @@ button.itflow-client-nav-identity:focus-visible {
color: #fff;
overflow-wrap: anywhere;
}
/* Client overview - agent/client_overview.php
Reuses the dashboard's pieces (quiet card headers, the attention rail, the
schedule list, money figures); this adds the large initials tile, the small
labels in the header, contact avatars, the notes box and the money strip. */
.itflow-client-avatar-lg {
width: 3.5rem;
height: 3.5rem;
border-radius: var(--bs-border-radius-lg);
font-size: 1.1rem;
}
/* Phone: the name stays beside the initials and the buttons drop under them */
@media (max-width: 767.98px) {
.itflow-ov-hero-name {
flex: 1 1 0;
}
.itflow-ov-hero-actions {
flex-basis: 100%;
}
}
.itflow-ov-label {
margin-bottom: .25rem;
font-size: .8rem;
color: var(--bs-secondary-color);
}
.itflow-ov-person {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
font-size: .8rem;
font-weight: 600;
object-fit: cover;
}
.itflow-ov-notes {
min-height: 12rem;
resize: vertical;
}
/* The money strip is the attention rail's layout without the coloured bar */
.itflow-ov-stats .itflow-dash-rail-item {
--itflow-dash-tone: transparent;
flex-basis: 12rem;
}

View File

@@ -30,3 +30,4 @@ require_once __DIR__ . '/functions/backup.php';
require_once __DIR__ . '/functions/search.php';
require_once __DIR__ . '/functions/notifications.php';
require_once __DIR__ . '/functions/dashboard.php';
require_once __DIR__ . '/functions/client_overview.php';

View File

@@ -0,0 +1,500 @@
<?php
/*
* Client overview data - agent/client_overview.php.
*
* Everything the overview shows is worked out here, one function per card,
* all for a single client. Where a figure links to a list, it is counted the
* way that list filters, so "3 unassigned" opens a list of 3 tickets.
*
* The page has already run enforceClientAccess() for $client_id (via
* inc_all_client.php), so nothing here re-checks client scope.
*/
/** Which parts of the overview this agent can see. */
function clientOverviewAccess() {
global $config_module_enable_ticketing, $config_module_enable_accounting, $config_module_enable_itdoc;
$accounting = intval($config_module_enable_accounting) === 1;
return [
'tickets' => intval($config_module_enable_ticketing) === 1 && lookupUserPermission('module_support') >= 1,
'docs' => intval($config_module_enable_itdoc) === 1 && lookupUserPermission('module_support') >= 1,
'credentials' => intval($config_module_enable_itdoc) === 1 && lookupUserPermission('module_credential') >= 1,
'sales' => $accounting && lookupUserPermission('module_sales') >= 1,
'financial' => $accounting && lookupUserPermission('module_financial') >= 1,
'edit' => lookupUserPermission('module_client') >= 2,
'add_ticket' => intval($config_module_enable_ticketing) === 1 && lookupUserPermission('module_support') >= 2,
];
}
/** Open tickets that are past SLA - the rule tickets.php?sla=breached uses. */
function clientOverviewSlaBreachedSql() {
return "(ticket_sla_id > 0 AND (ticket_response_sla_alert_stage = 2 OR ticket_resolution_sla_alert_stage = 2
OR ticket_response_sla_met = 0 OR ticket_resolution_sla_met = 0))";
}
/**
* Things to act on for this client. Only items with a count come back, so a
* client in good shape shows nothing here.
*/
function clientOverviewAttention($client_id, $access, $renewals) {
$items = [];
$open = "ticket_client_id = $client_id AND ticket_resolved_at IS NULL AND ticket_closed_at IS NULL";
if ($access['tickets']) {
$items[] = [
'count' => dashboardCount("SELECT COUNT(ticket_id) FROM tickets WHERE $open AND " . clientOverviewSlaBreachedSql()),
'label' => 'Past SLA', 'icon' => 'stopwatch', 'tone' => 'danger',
'url' => "/agent/tickets.php?client_id=$client_id&sla=breached",
];
$items[] = [
'count' => dashboardCount("SELECT COUNT(ticket_id) FROM tickets WHERE $open AND ticket_assigned_to = 0"),
'label' => 'Unassigned tickets', 'icon' => 'user-slash', 'tone' => 'warning',
'url' => "/agent/tickets.php?client_id=$client_id&assigned=unassigned",
];
$items[] = [
'count' => dashboardCount("SELECT COUNT(ticket_id) FROM tickets WHERE $open AND ticket_updated_at < NOW() - INTERVAL 7 DAY"),
'label' => 'No update in 7 days', 'icon' => 'hourglass-half', 'tone' => 'warning',
'url' => "/agent/tickets.php?client_id=$client_id&sort=ticket_updated_at&order=ASC",
];
}
if ($access['sales']) {
$overdue_where = "invoice_client_id = $client_id AND invoice_status IN ('Sent', 'Viewed', 'Partial') AND invoice_due < CURDATE()";
$overdue_count = dashboardCount("SELECT COUNT(invoice_id) FROM invoices WHERE $overdue_where");
$overdue_amount = dashboardSum("SELECT SUM(invoice_amount) FROM invoices WHERE $overdue_where")
- dashboardSum("SELECT SUM(payment_amount) FROM payments JOIN invoices ON payment_invoice_id = invoice_id WHERE $overdue_where");
$items[] = [
'count' => $overdue_count,
'label' => 'Overdue invoices', 'icon' => 'file-invoice-dollar', 'tone' => 'danger',
'detail' => $overdue_count ? dashboardMoney($overdue_amount) . ' outstanding' : null,
'url' => "/agent/invoices.php?client_id=$client_id&status=Overdue",
];
if ($access['tickets']) {
$items[] = [
'count' => dashboardCount("SELECT COUNT(ticket_id) FROM tickets WHERE ticket_client_id = $client_id
AND ticket_resolved_at IS NOT NULL AND ticket_billable = 1 AND ticket_invoice_id = 0"),
'label' => 'Resolved, not invoiced', 'icon' => 'receipt', 'tone' => 'info',
'url' => "/agent/tickets.php?client_id=$client_id&billing=unbilled&state=closed",
];
}
}
if ($access['docs'] && $renewals) {
$expired = count(array_filter($renewals['items'], function ($r) { return $r['days'] < 0; }));
$soon = count($renewals['items']) - $expired;
$items[] = ['count' => $expired, 'label' => 'Expired', 'icon' => 'calendar-times', 'tone' => 'danger',
'detail' => 'Warranties, licenses, domains, certificates', 'url' => '#clientRenewals'];
$items[] = ['count' => $soon, 'label' => 'Expiring soon', 'icon' => 'calendar-alt', 'tone' => 'warning',
'detail' => 'Next ' . $renewals['days'] . ' days', 'url' => '#clientRenewals'];
}
return array_values(array_filter($items, function ($item) {
return $item['count'] > 0;
}));
}
/** Open tickets, the ones most in need of attention first: past SLA, then priority, then waiting longest. */
function clientOverviewOpenTickets($client_id, $limit = 8) {
global $mysqli;
$where = "ticket_client_id = $client_id AND ticket_resolved_at IS NULL AND ticket_closed_at IS NULL";
$sql = mysqli_query($mysqli, "
SELECT ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_priority, ticket_created_at, ticket_updated_at,
ticket_status_name, ticket_status_color, user_name, contact_name,
" . clientOverviewSlaBreachedSql() . " AS sla_breached
FROM tickets
LEFT JOIN ticket_statuses ON ticket_status = ticket_status_id
LEFT JOIN users ON ticket_assigned_to = user_id
LEFT JOIN contacts ON ticket_contact_id = contact_id
WHERE $where
ORDER BY sla_breached DESC, FIELD(ticket_priority, 'Urgent', 'High', 'Medium', 'Low'), COALESCE(ticket_updated_at, ticket_created_at) ASC
LIMIT " . intval($limit));
$tickets = [];
while ($row = mysqli_fetch_assoc($sql)) {
$tickets[] = $row;
}
return [
'tickets' => $tickets,
'total' => dashboardCount("SELECT COUNT(ticket_id) FROM tickets WHERE $where"),
'resolved_30' => dashboardCount("SELECT COUNT(ticket_id) FROM tickets WHERE ticket_client_id = $client_id AND ticket_resolved_at >= NOW() - INTERVAL 30 DAY"),
'opened_30' => dashboardCount("SELECT COUNT(ticket_id) FROM tickets WHERE ticket_client_id = $client_id AND ticket_created_at >= NOW() - INTERVAL 30 DAY"),
];
}
/** The contacts worth having to hand: primary first, then anyone flagged important, billing or technical. */
function clientOverviewContacts($client_id, $limit = 6) {
global $mysqli;
$sql = mysqli_query($mysqli, "
SELECT contact_id, contact_name, contact_title, contact_email, contact_phone, contact_phone_country_code, contact_extension,
contact_mobile, contact_mobile_country_code, contact_photo, contact_primary, contact_important, contact_billing, contact_technical
FROM contacts
WHERE contact_client_id = $client_id AND contact_archived_at IS NULL
AND (contact_primary = 1 OR contact_important = 1 OR contact_billing = 1 OR contact_technical = 1)
ORDER BY contact_primary DESC, contact_important DESC, contact_name ASC
LIMIT " . intval($limit));
$contacts = [];
while ($row = mysqli_fetch_assoc($sql)) {
$contacts[] = $row;
}
return $contacts;
}
/** Starred assets and credentials. */
function clientOverviewFavorites($client_id, $access) {
global $mysqli;
$favorites = ['assets' => [], 'credentials' => []];
if ($access['docs']) {
$sql = mysqli_query($mysqli, "SELECT asset_id, asset_name, asset_type, asset_make, asset_model, interface_ip
FROM assets
LEFT JOIN asset_interfaces ON interface_asset_id = asset_id AND interface_primary = 1
WHERE asset_client_id = $client_id AND asset_favorite = 1 AND asset_archived_at IS NULL
ORDER BY asset_type, asset_name");
while ($row = mysqli_fetch_assoc($sql)) {
$favorites['assets'][] = $row;
}
}
if ($access['credentials']) {
$sql = mysqli_query($mysqli, "SELECT credential_id, credential_name, credential_username, credential_otp_secret, credential_uri
FROM credentials
WHERE credential_client_id = $client_id AND credential_favorite = 1 AND credential_archived_at IS NULL
ORDER BY credential_name");
while ($row = mysqli_fetch_assoc($sql)) {
$favorites['credentials'][] = $row;
}
}
return $favorites;
}
/**
* The next $days days for this client: scheduled tickets, calendar events
* (repeating ones expanded the way the calendar does it) and recurring
* tickets due to be raised. Soonest first.
*/
function clientOverviewUpcoming($client_id, $access, $days = 14) {
global $mysqli;
$from = date('Y-m-d 00:00:00');
$to = date('Y-m-d 23:59:59', strtotime('+' . ($days - 1) . ' days'));
$items = [];
if ($access['tickets']) {
$sql = mysqli_query($mysqli, "SELECT ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_schedule, ticket_onsite, user_name
FROM tickets LEFT JOIN users ON ticket_assigned_to = user_id
WHERE ticket_client_id = $client_id AND ticket_resolved_at IS NULL AND ticket_closed_at IS NULL
AND ticket_schedule BETWEEN '$from' AND '$to'");
while ($row = mysqli_fetch_assoc($sql)) {
$items[] = [
'when' => $row['ticket_schedule'], 'all_day' => false, 'kind' => 'ticket',
'title' => $row['ticket_subject'], 'ref' => $row['ticket_prefix'] . $row['ticket_number'],
'detail' => trim(($row['ticket_onsite'] ? 'Onsite' : '') . ($row['user_name'] ? ($row['ticket_onsite'] ? ', ' : '') . $row['user_name'] : '')),
'url' => "/agent/ticket.php?client_id=$client_id&ticket_id=" . intval($row['ticket_id']),
];
}
$sql = mysqli_query($mysqli, "SELECT recurring_ticket_id, recurring_ticket_subject, recurring_ticket_next_run, recurring_ticket_frequency
FROM recurring_tickets
WHERE recurring_ticket_client_id = $client_id AND recurring_ticket_next_run BETWEEN '" . substr($from, 0, 10) . "' AND '" . substr($to, 0, 10) . "'");
while ($row = mysqli_fetch_assoc($sql)) {
$items[] = [
'when' => $row['recurring_ticket_next_run'] . ' 00:00:00', 'all_day' => true, 'kind' => 'recurring',
'title' => $row['recurring_ticket_subject'], 'ref' => '',
'detail' => 'Recurring ticket, ' . strtolower($row['recurring_ticket_frequency']),
'url' => "/agent/recurring_tickets.php?client_id=$client_id",
];
}
}
$sql = mysqli_query($mysqli, "SELECT event_id, event_title, event_start, event_end, event_all_day, event_repeat, event_location
FROM calendar_events
WHERE event_client_id = $client_id AND event_archived_at IS NULL AND event_start <= '$to'
AND ((event_repeat IS NOT NULL AND event_repeat != '') OR event_start >= '$from' OR event_end >= '$from')");
while ($row = mysqli_fetch_assoc($sql)) {
foreach (expandRecurringEvent($row, $from, $to) as $occurrence) {
$start = $occurrence['start'];
$end = $occurrence['end'] ?: $start;
if ($start > $to || max($start, $end) < $from) {
continue;
}
$items[] = [
'when' => max($start, $from), 'all_day' => !empty($row['event_all_day']) || $start < $from, 'kind' => 'event',
'title' => $row['event_title'], 'ref' => '', 'detail' => (string) $row['event_location'],
'url' => "/agent/calendar.php?client_id=$client_id",
];
}
}
usort($items, function ($a, $b) {
return strcmp(substr($a['when'], 0, 10) . ($a['all_day'] ? '0' : '1') . substr($a['when'], 11),
substr($b['when'], 0, 10) . ($b['all_day'] ? '0' : '1') . substr($b['when'], 11));
});
return $items;
}
/**
* Renewals: domains, certificates, licenses and asset warranties that have
* expired or expire in the next $days days, plus assets reaching seven years
* since install (the old overview's retirement rule). Expiring soonest first,
* then the expired ones, most recent first.
*/
function clientOverviewRenewals($client_id, $days = 45) {
global $mysqli;
$window = "CURRENT_DATE + INTERVAL $days DAY";
$sources = [
['domain', 'globe', "SELECT domain_id AS id, domain_name AS name, domain_expire AS d FROM domains
WHERE domain_client_id = $client_id AND domain_archived_at IS NULL AND domain_expire IS NOT NULL AND domain_expire <= $window",
'Domain', "/agent/domains.php?client_id=$client_id&q="],
['certificate', 'lock', "SELECT certificate_id AS id, certificate_name AS name, certificate_expire AS d FROM certificates
WHERE certificate_client_id = $client_id AND certificate_archived_at IS NULL AND certificate_expire IS NOT NULL AND certificate_expire <= $window",
'Certificate', "/agent/certificates.php?client_id=$client_id&q="],
['software', 'cube', "SELECT software_id AS id, software_name AS name, software_expire AS d FROM software
WHERE software_client_id = $client_id AND software_archived_at IS NULL AND software_expire IS NOT NULL AND software_expire <= $window",
'License', "/agent/software.php?client_id=$client_id&q="],
['warranty', 'shield-alt', "SELECT asset_id AS id, asset_name AS name, asset_warranty_expire AS d FROM assets
WHERE asset_client_id = $client_id AND asset_archived_at IS NULL AND asset_warranty_expire IS NOT NULL AND asset_warranty_expire <= $window",
'Warranty', "/agent/asset.php?client_id=$client_id&asset_id="],
['retire', 'recycle', "SELECT asset_id AS id, asset_name AS name, asset_install_date + INTERVAL 7 YEAR AS d FROM assets
WHERE asset_client_id = $client_id AND asset_archived_at IS NULL AND asset_install_date IS NOT NULL AND asset_install_date + INTERVAL 7 YEAR <= $window",
'7 years old', "/agent/asset.php?client_id=$client_id&asset_id="],
];
$items = [];
$today = new DateTime('today');
foreach ($sources as [$kind, $icon, $sql, $label, $url]) {
$result = mysqli_query($mysqli, $sql);
while ($row = mysqli_fetch_assoc($result)) {
$date = new DateTime($row['d']);
$items[] = [
'kind' => $kind, 'icon' => $icon, 'label' => $label,
'name' => $row['name'], 'date' => $row['d'],
'days' => (int) $today->diff($date)->format('%r%a'),
'url' => in_array($kind, ['warranty', 'retire'], true) ? $url . intval($row['id']) : $url . rawurlencode($row['name']),
];
}
}
usort($items, function ($a, $b) {
$a_soon = $a['days'] >= 0;
$b_soon = $b['days'] >= 0;
if ($a_soon !== $b_soon) {
return $a_soon ? -1 : 1;
}
return $a_soon ? $a['days'] <=> $b['days'] : $b['days'] <=> $a['days'];
});
return ['items' => $items, 'days' => $days];
}
/** Assets by kind - the same groups as the assets page's type filter - and warranty state. */
function clientOverviewAssets($client_id) {
global $mysqli;
$groups = [
'workstation' => ['label' => 'Workstations', 'icon' => 'desktop', 'count' => 0],
'server' => ['label' => 'Servers', 'icon' => 'server', 'count' => 0],
'virtual' => ['label' => 'Virtual machines', 'icon' => 'cloud', 'count' => 0],
'network' => ['label' => 'Network', 'icon' => 'network-wired', 'count' => 0],
'other' => ['label' => 'Other', 'icon' => 'cube', 'count' => 0],
];
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT
COUNT(asset_id) AS total,
COALESCE(SUM(asset_type IN ('Desktop', 'Laptop')), 0) AS workstation,
COALESCE(SUM(asset_type = 'Server'), 0) AS server,
COALESCE(SUM(asset_type = 'Virtual Machine'), 0) AS virtual,
COALESCE(SUM(asset_type IN ('Firewall/Router', 'Switch', 'Access Point')), 0) AS network,
COALESCE(SUM(asset_warranty_expire IS NOT NULL AND asset_warranty_expire < CURRENT_DATE), 0) AS warranty_expired,
COALESCE(SUM(asset_warranty_expire IS NOT NULL AND asset_warranty_expire >= CURRENT_DATE), 0) AS warranty_active
FROM assets WHERE asset_client_id = $client_id AND asset_archived_at IS NULL"));
$total = intval($row['total']);
$grouped = 0;
foreach (['workstation', 'server', 'virtual', 'network'] as $key) {
$groups[$key]['count'] = intval($row[$key]);
$grouped += intval($row[$key]);
}
$groups['other']['count'] = $total - $grouped;
return [
'total' => $total,
'groups' => $groups,
'warranty_expired' => intval($row['warranty_expired']),
'warranty_active' => intval($row['warranty_active']),
'warranty_unknown' => $total - intval($row['warranty_expired']) - intval($row['warranty_active']),
];
}
/** Active share links for this client's items. */
function clientOverviewSharedItems($client_id) {
global $mysqli;
$sql = mysqli_query($mysqli, "SELECT item_id, item_type, item_related_id, item_recipient, item_views, item_view_limit, item_expire_at,
COALESCE(credential_name, document_name, file_name) AS item_name
FROM shared_items
LEFT JOIN credentials ON item_type = 'Credential' AND credential_id = item_related_id AND credential_client_id = $client_id
LEFT JOIN documents ON item_type = 'Document' AND document_id = item_related_id AND document_client_id = $client_id
LEFT JOIN files ON item_type = 'File' AND file_id = item_related_id AND file_client_id = $client_id
WHERE item_client_id = $client_id AND item_active = 1
ORDER BY item_created_at DESC
LIMIT 10");
$items = [];
while ($row = mysqli_fetch_assoc($sql)) {
$items[] = $row;
}
return $items;
}
/**
* The latest changes for this client from the audit log. Views (opening a
* credential, a page) are left out - they would push every change off the
* card - and stay in the full audit log.
*/
function clientOverviewActivity($client_id, $limit = 6) {
global $mysqli;
$sql = mysqli_query($mysqli, "SELECT log_created_at, log_description, user_name FROM logs
LEFT JOIN users ON log_user_id = user_id
WHERE log_client_id = $client_id AND log_action != 'View'
ORDER BY log_id DESC
LIMIT " . intval($limit));
$items = [];
while ($row = mysqli_fetch_assoc($sql)) {
$items[] = $row;
}
return $items;
}
/**
* The money figures. Balance is invoiced minus paid, all time - the same as
* the clients list and the old client header. Paid this year is cash in
* (invoice payments plus client revenues), compared with the same days last
* year. Unbilled is resolved billable tickets with no invoice, and the time
* logged on them.
*/
function clientOverviewMoney($client_id, $balance, $recurring_monthly) {
$year_start = date('Y-01-01');
$today = date('Y-m-d');
$last_year_start = (intval(date('Y')) - 1) . '-01-01';
$last_year_today = dashboardShiftMonths($today, -12);
$paid = function ($from, $to) use ($client_id) {
return dashboardSum("SELECT SUM(payment_amount) FROM payments JOIN invoices ON payment_invoice_id = invoice_id
WHERE invoice_client_id = $client_id AND payment_date BETWEEN '$from' AND '$to'")
+ dashboardSum("SELECT SUM(revenue_amount) FROM revenues WHERE revenue_client_id = $client_id
AND revenue_category_id > 0 AND revenue_date BETWEEN '$from' AND '$to'");
};
$paid_ytd = $paid($year_start, $today);
$paid_last_ytd = $paid($last_year_start, $last_year_today);
$overdue_where = "invoice_client_id = $client_id AND invoice_status IN ('Sent', 'Viewed', 'Partial') AND invoice_due < CURDATE()";
$overdue = dashboardSum("SELECT SUM(invoice_amount) FROM invoices WHERE $overdue_where")
- dashboardSum("SELECT SUM(payment_amount) FROM payments JOIN invoices ON payment_invoice_id = invoice_id WHERE $overdue_where");
$unbilled_where = "ticket_client_id = $client_id AND ticket_resolved_at IS NOT NULL AND ticket_billable = 1 AND ticket_invoice_id = 0";
return [
'balance' => $balance,
'overdue' => max(0, $overdue),
'paid_ytd' => $paid_ytd,
'paid_change' => dashboardChange($paid_ytd, $paid_last_ytd),
'recurring' => $recurring_monthly,
'unbilled_tickets' => dashboardCount("SELECT COUNT(ticket_id) FROM tickets WHERE $unbilled_where"),
'unbilled_seconds' => intval(dashboardSum("SELECT SUM(TIME_TO_SEC(ticket_reply_time_worked)) FROM ticket_replies
JOIN tickets ON ticket_reply_ticket_id = ticket_id
WHERE ticket_reply_archived_at IS NULL AND $unbilled_where")),
];
}
/** Unpaid invoices with what is still owed on each, oldest due first. */
function clientOverviewOpenInvoices($client_id, $limit = 6) {
global $mysqli;
$sql = mysqli_query($mysqli, "SELECT invoice_id, invoice_prefix, invoice_number, invoice_scope, invoice_date, invoice_due, invoice_status, invoice_amount,
invoice_amount - COALESCE((SELECT SUM(payment_amount) FROM payments WHERE payment_invoice_id = invoice_id), 0) AS balance
FROM invoices
WHERE invoice_client_id = $client_id AND invoice_status IN ('Sent', 'Viewed', 'Partial')
ORDER BY invoice_due ASC, invoice_id ASC
LIMIT " . intval($limit));
$invoices = [];
while ($row = mysqli_fetch_assoc($sql)) {
$invoices[] = $row;
}
return [
'invoices' => $invoices,
'total' => dashboardCount("SELECT COUNT(invoice_id) FROM invoices WHERE invoice_client_id = $client_id AND invoice_status IN ('Sent', 'Viewed', 'Partial')"),
];
}
/** Active recurring invoices, next to bill first. */
function clientOverviewRecurringInvoices($client_id) {
global $mysqli;
$sql = mysqli_query($mysqli, "SELECT recurring_invoice_id, recurring_invoice_prefix, recurring_invoice_number, recurring_invoice_scope,
recurring_invoice_frequency, recurring_invoice_next_date, recurring_invoice_amount
FROM recurring_invoices
WHERE recurring_invoice_client_id = $client_id AND recurring_invoice_status = 1 AND recurring_invoice_archived_at IS NULL
ORDER BY recurring_invoice_next_date ASC");
$items = [];
while ($row = mysqli_fetch_assoc($sql)) {
$items[] = $row;
}
return $items;
}
/** Invoiced (by invoice date, drafts and cancelled left out) and paid, per month for the last 12 months. */
function clientOverviewMonthly($client_id) {
$months = [];
for ($i = 11; $i >= 0; $i--) {
$months[] = substr(dashboardShiftMonths(date('Y-m-01'), -$i), 0, 7);
}
$from = $months[0] . '-01';
$to = date('Y-m-t');
$paid = array_map(function ($a, $b) {
return round($a + $b, 2);
},
dashboardMonthly("SELECT DATE_FORMAT(payment_date, '%Y-%m') AS m, SUM(payment_amount) AS v FROM payments
JOIN invoices ON payment_invoice_id = invoice_id
WHERE invoice_client_id = $client_id AND payment_date BETWEEN '$from' AND '$to' GROUP BY m", $months),
dashboardMonthly("SELECT DATE_FORMAT(revenue_date, '%Y-%m') AS m, SUM(revenue_amount) AS v FROM revenues
WHERE revenue_client_id = $client_id AND revenue_category_id > 0 AND revenue_date BETWEEN '$from' AND '$to' GROUP BY m", $months)
);
return [
'labels' => array_map(function ($m) {
return date('M y', strtotime("$m-01"));
}, $months),
'invoiced' => array_map(function ($v) {
return round($v, 2);
}, dashboardMonthly("SELECT DATE_FORMAT(invoice_date, '%Y-%m') AS m, SUM(invoice_amount) AS v FROM invoices
WHERE invoice_client_id = $client_id AND invoice_status NOT IN ('Draft', 'Cancelled', 'Non-Billable')
AND invoice_date BETWEEN '$from' AND '$to' GROUP BY m", $months)),
'paid' => $paid,
];
}
/** Up to two initials for a person's name, for a contact without a photo. */
function clientOverviewPersonInitials($name) {
$words = preg_split('/\s+/u', trim((string) $name), -1, PREG_SPLIT_NO_EMPTY);
$initials = '';
foreach (array_slice($words, 0, 2) as $word) {
$initials .= mb_strtoupper(mb_substr($word, 0, 1));
}
return $initials ?: '?';
}

90
js/client_overview.js Normal file
View File

@@ -0,0 +1,90 @@
/*
* ITFlow - client overview (agent/client_overview.php).
*
* Saves the quick notes when you click away, but only if they changed, and
* says so; draws the invoiced-and-paid chart from window.itflowClientOverviewData.
*/
(function () {
'use strict';
/* Quick notes */
const notes = document.getElementById('clientNotes');
const status = document.querySelector('[data-notes-status]');
if (notes && !notes.readOnly && typeof itflowPost === 'function') {
let saved = notes.value;
notes.addEventListener('blur', function () {
if (notes.value === saved) {
return;
}
const value = notes.value;
if (status) {
status.textContent = 'Saving...';
}
itflowPost('ajax.php', {
client_set_notes: 'TRUE',
csrf_token: notes.dataset.csrf,
client_id: notes.dataset.clientId,
notes: value
}, function () {
saved = value;
if (status) {
status.textContent = 'Saved';
}
}, function () {
if (status) {
status.textContent = 'Not saved - try again';
}
});
});
}
/* Invoiced and paid, last 12 months */
const data = window.itflowClientOverviewData;
const canvas = document.getElementById('clientMonthly');
if (!data || !data.monthly || !canvas || typeof Chart === 'undefined') {
return;
}
const base = typeof itflowChartDefaults === 'function' ? itflowChartDefaults() : { grid: 'rgba(0,0,0,.1)' };
const root = getComputedStyle(document.body);
const cssVar = function (name, fallback) {
return (root.getPropertyValue(name) || '').trim() || fallback;
};
const alpha = function (color, a) {
const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(color);
return m ? 'rgba(' + parseInt(m[1], 16) + ',' + parseInt(m[2], 16) + ',' + parseInt(m[3], 16) + ',' + a + ')' : color;
};
let money;
try {
money = new Intl.NumberFormat(undefined, { style: 'currency', currency: data.currency || 'USD', maximumFractionDigits: 0 });
} catch (e) {
money = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 });
}
const short = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 });
new Chart(canvas, {
type: 'bar',
data: {
labels: data.monthly.labels,
datasets: [
{ label: 'Invoiced', data: data.monthly.invoiced, backgroundColor: alpha(cssVar('--itflow-accent', '#0d6efd'), 0.75), borderRadius: 3, maxBarThickness: 22 },
{ label: 'Paid', data: data.monthly.paid, backgroundColor: alpha(cssVar('--bs-success', '#198754'), 0.8), borderRadius: 3, maxBarThickness: 22 }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: 'bottom', labels: { boxWidth: 10, boxHeight: 10, usePointStyle: true, padding: 14 } },
tooltip: { callbacks: { label: function (c) { return c.dataset.label + ': ' + money.format(c.parsed.y); } } }
},
scales: {
x: { grid: { display: false }, ticks: { maxRotation: 0, autoSkipPadding: 10 } },
y: { beginAtZero: true, grid: { color: base.grid }, border: { display: false }, ticks: { maxTicksLimit: 5, callback: function (v) { return short.format(v); } } }
}
}
});
})();