Files
itflow/functions/client_overview.php
johnnyq 9ba21cf352 Assets: add NAS, UPS, PDU, Modem/ONT and NVR/DVR types from a new registry in functions/asset_type.php that sets each type's icon, tone, group, fields and details; the asset form shows only the fields a type uses (fields that already hold a value stay shown) and adds a Details section per type (CPU/RAM/storage, VM host/vCPUs, switch ports/SFP/PoE/management, NAS drives/capacity/RAID, UPS capacity/output/runtime/outlets/battery dates, PDU kind/outlets/input, modem circuit ID/ISP account, printer, phone, mobile, display, camera and NVR details) stored in a new asset_specs table (database 3.4.9); new or changed port and SFP counts add the missing interfaces, continuing the asset's numbering, skipping Wi-Fi, VLAN and management ports and never deleting any; the asset page and quick look show the details, a server or NAS lists the VMs that run on it, and Assigned and Warranty show only on types that use them; the list gets Servers & NAS and Power tabs, an optional Details column, a UPS battery badge, an Assigned picker only on assigned types, and New buttons named for what they add; nightly UPS Battery Due notifications at 45, 7 and 1 days as a Support notification; copy leaves out per-unit details, Transfer to Client carries details and interface types, export adds per-detail columns, import reads detail columns after the standard twelve (VM hosts by name, resolved after every row is in) and reports values it could not keep, and the API reads and writes asset_specs; racks, client portal, client export, overview, document templates and AI lists group the new types; fix asset import carrying physical location and notes into the next row, saving 0000-00-00 for a missing purchase date and warning on an unknown contact or location, save API and import types in the list's spelling, and keep interface types on transfer; changelog updated
2026-10-27 15:59:03 -04:00

693 lines
36 KiB
PHP

<?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",
];
// The same rule as projects.php?show=overdue
$items[] = [
'count' => dashboardCount("SELECT COUNT(project_id) FROM projects WHERE project_client_id = $client_id AND " . projectViews()['overdue']['where']),
'label' => 'Overdue projects', 'icon' => 'project-diagram', 'tone' => 'danger',
'url' => "/agent/projects.php?client_id=$client_id&show=overdue",
];
}
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",
];
// A contract coming up to its notice date - the same rule as contracts.php?show=soon
if (contractsReady()) {
$soon = contractViews()['soon']['where'];
$items[] = [
'count' => dashboardCount("SELECT COUNT(contract_id) FROM contracts WHERE contract_client_id = $client_id AND contract_archived_at IS NULL AND $soon AND contract_auto_renew = 0"),
'label' => 'Contracts ending', 'icon' => 'file-contract', 'tone' => 'warning',
'detail' => "They don't renew themselves",
'url' => "/agent/contracts.php?client_id=$client_id&show=soon",
];
$items[] = [
'count' => dashboardCount("SELECT COUNT(contract_id) FROM contracts WHERE contract_client_id = $client_id AND contract_archived_at IS NULL AND $soon AND contract_auto_renew = 1"),
'label' => 'Contracts renewing', 'icon' => 'sync-alt', 'tone' => 'info',
'detail' => 'Time to review them with the client',
'url' => "/agent/contracts.php?client_id=$client_id&show=soon",
];
}
if ($access['tickets']) {
$items[] = [
'count' => dashboardCount("SELECT COUNT(ticket_id) FROM tickets WHERE " . billingUnbilledSql() . " AND ticket_client_id = $client_id"),
'label' => 'Resolved, not invoiced', 'icon' => 'receipt', 'tone' => 'info',
'url' => "/agent/unbilled_time.php?client_id=$client_id",
];
}
if ($access['financial']) {
$to_bill = expenseUnbilledCount($client_id);
$items[] = [
'count' => $to_bill['count'],
'label' => 'Expenses to bill', 'icon' => 'shopping-cart', 'tone' => 'info',
'detail' => $to_bill['count'] ? dashboardMoney($to_bill['value']) . ' to bill on' : null,
'url' => "/agent/unbilled_time.php?client_id=$client_id",
];
$trips_to_bill = tripUnbilledCount($client_id);
$items[] = [
'count' => $trips_to_bill['count'],
'label' => 'Trips to bill', 'icon' => 'route', 'tone' => 'info',
'detail' => $trips_to_bill['count'] ? dashboardMoney($trips_to_bill['value']) . ' of travel' : null,
'url' => "/agent/unbilled_time.php?client_id=$client_id",
];
}
}
if ($access['docs']) {
// More devices and users on a license than seats bought - a true-up to bill, or seats to free
$items[] = [
'count' => dashboardCount("SELECT COUNT(software_id) FROM software WHERE software_client_id = $client_id AND software_archived_at IS NULL
AND " . softwareStatusFilters()['over']['sql']),
'label' => 'Licenses over seats', 'icon' => 'cube', 'tone' => 'danger',
'url' => "/agent/software.php?client_id=$client_id&status=over",
];
// What the last check of a host found wrong - the same rule as certificates.php?show=problems
$items[] = [
'count' => dashboardCount("SELECT COUNT(certificate_id) FROM certificates WHERE certificate_client_id = $client_id AND certificate_archived_at IS NULL
AND " . certificateProblemSql()),
'label' => 'Certificate problems', 'icon' => 'lock', 'tone' => 'warning',
'detail' => 'Name mismatch, untrusted, expired or unreachable',
'url' => "/agent/certificates.php?client_id=$client_id&show=problems",
];
// Something a service depends on is expiring, out of action or archived - the same rule as services.php?show=attention
$service_views = serviceViews();
$items[] = [
'count' => dashboardCount("SELECT COUNT(service_id) FROM services WHERE service_client_id = $client_id AND " . $service_views['attention']['where']),
'label' => 'Services needing attention', 'icon' => 'stream', 'tone' => 'danger',
'detail' => 'What they run on needs a look',
'url' => "/agent/services.php?client_id=$client_id&show=attention",
];
$items[] = [
'count' => dashboardCount("SELECT COUNT(service_id) FROM services WHERE service_client_id = $client_id AND " . $service_views['review']['where']),
'label' => 'Service reviews due', 'icon' => 'clipboard-check', 'tone' => 'warning',
'detail' => 'Next ' . SERVICE_REVIEW_SOON_DAYS . ' days or overdue',
'url' => "/agent/services.php?client_id=$client_id&show=review",
];
// Document templates it should have a document from - the strip across the top of its Files
$items[] = [
'count' => count(documentTemplatesMissingFor($client_id)),
'label' => 'Documents missing', 'icon' => 'file-medical', 'tone' => 'warning',
'detail' => 'Ones every client like it should have',
'url' => "/agent/files.php?client_id=$client_id",
];
// The same rule as files.php?type=due
$items[] = [
'count' => count(documentsReviewDue("documents.document_client_id = $client_id")),
'label' => 'Document reviews due', 'icon' => 'calendar-check', 'tone' => 'warning',
'detail' => 'As their templates ask',
'url' => "/agent/files.php?client_id=$client_id&type=due",
];
// License templates it should have a license from - the strip across the top of its Licenses
$items[] = [
'count' => count(softwareTemplatesMissingFor($client_id)),
'label' => 'Licenses missing', 'icon' => 'cube', 'tone' => 'warning',
'detail' => 'Ones every client like it should have',
'url' => "/agent/software.php?client_id=$client_id",
];
}
if ($access['credentials']) {
// Logins nobody has changed in a while - the same rule as credentials.php?show=due
$items[] = [
'count' => dashboardCount("SELECT COUNT(credential_id) FROM credentials WHERE credential_client_id = $client_id AND credential_archived_at IS NULL
AND (credential_password_changed_at IS NULL OR credential_password_changed_at < NOW() - INTERVAL " . CREDENTIAL_ROTATE_DAYS . " DAY)"),
'label' => 'Passwords due for a change', 'icon' => 'history', 'tone' => 'warning',
'detail' => 'Not changed in ' . CREDENTIAL_ROTATE_DAYS . ' days',
'url' => "/agent/credentials.php?client_id=$client_id&show=due&sort=credential_password_changed_at&order=ASC",
];
}
if (lookupUserPermission('module_client') >= 1) {
// Vendor templates it should have a vendor from - the strip across the top of its Vendors
$items[] = [
'count' => count(vendorTemplatesMissingFor($client_id)),
'label' => 'Vendors missing', 'icon' => 'building', 'tone' => 'warning',
'detail' => 'Ones every client like it should have',
'url' => "/agent/vendors.php?client_id=$client_id",
];
}
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, licenses and credentials. */
function clientOverviewFavorites($client_id, $access) {
global $mysqli;
$favorites = ['assets' => [], 'licenses' => [], '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;
}
$sql = mysqli_query($mysqli, "SELECT software_id, software_name, software_type, software_license_type, software_key, software_expire
FROM software
WHERE software_client_id = $client_id AND software_favorite = 1 AND software_archived_at IS NULL
ORDER BY software_name");
while ($row = mysqli_fetch_assoc($sql)) {
$favorites['licenses'][] = $row;
}
}
if ($access['credentials']) {
$sql = mysqli_query($mysqli, "SELECT credential_id, credential_name, credential_description, credential_username, credential_otp_secret, credential_uri, credential_password_changed_at
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;
}
/**
* What the client runs on, most important first and the ones needing a look
* before the rest - [services, issues per service, total].
*/
function clientOverviewServices($client_id, $limit = 6) {
global $mysqli;
$client_id = intval($client_id);
$where = "service_client_id = $client_id AND services.service_archived_at IS NULL";
$sql = mysqli_query($mysqli, "SELECT services.* FROM services WHERE $where
ORDER BY " . serviceAttentionSql() . " DESC, FIELD(service_importance, 'High', 'Medium', 'Low'), service_name LIMIT " . intval($limit));
$rows = [];
while ($row = mysqli_fetch_assoc($sql)) {
$rows[intval($row['service_id'])] = $row;
}
return ['services' => $rows, 'issues' => serviceIssues(array_keys($rows)), 'total' => dashboardCount("SELECT COUNT(service_id) FROM services WHERE $where")];
}
/**
* The client's open projects, the ones needing a look first (overdue, then
* soonest due), with their numbers - [rows, stats, total].
*/
function clientOverviewProjects($client_id, $limit = 5) {
global $mysqli;
$client_id = intval($client_id);
$where = "project_client_id = $client_id AND " . PROJECT_OPEN_SQL;
$sql = mysqli_query($mysqli, "SELECT projects.*, user_name, user_avatar FROM projects LEFT JOIN users ON user_id = project_manager
WHERE $where ORDER BY project_due IS NULL, project_due ASC, project_id DESC LIMIT " . intval($limit));
$rows = [];
while ($row = mysqli_fetch_assoc($sql)) {
$rows[intval($row['project_id'])] = $row;
}
return ['projects' => $rows, 'stats' => projectStats(array_keys($rows)), 'total' => dashboardCount("SELECT COUNT(project_id) FROM projects WHERE $where")];
}
/**
* The next $days days for this client: scheduled tickets, calendar events
* (repeating ones expanded the way the calendar does it), recurring
* tickets due to be raised, projects starting or due, and services due for
* review. 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']),
];
}
// Projects starting or due - all day
$sql = mysqli_query($mysqli, "SELECT project_id, project_prefix, project_number, project_name, project_start, project_due
FROM projects
WHERE project_client_id = $client_id AND " . PROJECT_OPEN_SQL . "
AND (project_due BETWEEN '" . substr($from, 0, 10) . "' AND '" . substr($to, 0, 10) . "' OR project_start BETWEEN '" . substr($from, 0, 10) . "' AND '" . substr($to, 0, 10) . "')");
while ($row = mysqli_fetch_assoc($sql)) {
foreach (['project_start' => 'Project starts', 'project_due' => 'Project due'] as $column => $what) {
if ($row[$column] && $row[$column] >= substr($from, 0, 10) && $row[$column] <= substr($to, 0, 10)) {
$items[] = [
'when' => $row[$column] . ' 00:00:00', 'all_day' => true, 'kind' => 'project',
'title' => $row['project_name'], 'ref' => $row['project_prefix'] . $row['project_number'],
'detail' => $what,
'url' => '/agent/' . projectUrl($row['project_id'], $client_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",
];
}
}
// Services due for review - all day
if ($access['docs']) {
$sql = mysqli_query($mysqli, "SELECT service_id, service_name, service_review_due FROM services
WHERE service_client_id = $client_id AND service_archived_at IS NULL
AND service_review_due BETWEEN '" . substr($from, 0, 10) . "' AND '" . substr($to, 0, 10) . "'");
while ($row = mysqli_fetch_assoc($sql)) {
$items[] = [
'when' => $row['service_review_due'] . ' 00:00:00', 'all_day' => true, 'kind' => 'service',
'title' => $row['service_name'], 'ref' => '', 'detail' => 'Service review due',
'url' => '/agent/' . serviceUrl($row['service_id'], $client_id),
];
}
}
$sql = mysqli_query($mysqli, "SELECT event_id, event_title, event_start, event_end, event_all_day, event_repeat, event_repeat_interval, event_repeat_until, event_repeat_skip,
event_location, location_name
FROM calendar_events
JOIN calendars ON calendar_id = event_calendar_id AND calendar_archived_at IS NULL
LEFT JOIN locations ON location_id = event_location_id AND event_location_id > 0
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['location_name'] ?: $row['event_location']),
'url' => "/agent/calendar.php?client_id=$client_id",
'modal' => 'modals/calendar/calendar_event.php?id=' . intval($row['event_id']) . '&occurrence=' . substr($start, 0, 10),
];
}
}
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&open="],
['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&open="],
['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/license.php?client_id=$client_id&software_id="],
['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, ['domain', 'certificate', 'warranty', 'retire', 'software'], 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(" . assetTypeGroupSql('workstation') . "), 0) AS workstation,
COALESCE(SUM(" . assetTypeGroupSql('server') . "), 0) AS server,
COALESCE(SUM(" . assetTypeGroupSql('virtual') . "), 0) AS virtual,
COALESCE(SUM(" . assetTypeGroupSql('network') . "), 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']),
];
}
/**
* This client's shared links still to do something about - open, or a
* request answered and waiting to be saved - newest first, only the kinds
* this agent may see (functions/share.php).
*/
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, item_received_at,
item_end_reason, item_ended_at, item_active,
COALESCE(credential_name, document_name, file_name, item_name) AS item_name, file_ext, credential_uri, credential_description
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_type IN (" . shareVisibleTypesSql() . ") AND " . shareLiveSql() . "
ORDER BY item_received_at IS NULL, 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 - the ones this
* agent's role may see (auditLogRoleSql()). Views (opening a credential, a
* page) are left out - they would push every change off the card - and stay
* in the client's Audit Log.
*/
function clientOverviewActivity($client_id, $limit = 6) {
global $mysqli;
$client_id = intval($client_id);
$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' AND " . auditLogRoleSql() . "
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 what Unbilled Time has for the client - billable tickets
* done and on no invoice, the time logged on them and what it comes to - and,
* for agents who can see expenses, the expenses to bill on to them and what
* they come to with their markup.
*/
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 " . dashboardRevenueSql() . " 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 = billingUnbilledByClient($client_id)[intval($client_id)] ?? null;
$to_bill = lookupUserPermission('module_financial') >= 1 ? expenseUnbilledCount($client_id) : ['count' => 0, 'value' => 0.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' => $unbilled ? count($unbilled['tickets']) : 0,
'unbilled_seconds' => $unbilled ? intval($unbilled['seconds']) : 0,
'unbilled_value' => $unbilled ? floatval($unbilled['value']) : 0.0,
'unbilled_expenses' => $to_bill['count'],
'unbilled_expense_value' => $to_bill['value'],
];
}
/** 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 " . dashboardRevenueSql() . " 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,
];
}