diff --git a/agent/client_overview.php b/agent/client_overview.php index 062df3196..77d662dc3 100644 --- a/agent/client_overview.php +++ b/agent/client_overview.php @@ -1,830 +1,542 @@ CURRENT_DATE - AND domain_expire < CURRENT_DATE + INTERVAL 45 DAY - ORDER BY domain_expire ASC" -); - -// Get Certificates Expiring -$sql_certificates_expiring = mysqli_query( - $mysqli, - "SELECT certificate_expire, certificate_id, certificate_name FROM certificates - WHERE certificate_client_id = $client_id - AND certificate_expire IS NOT NULL - AND certificate_archived_at IS NULL - AND certificate_expire > CURRENT_DATE - AND certificate_expire < CURRENT_DATE + INTERVAL 45 DAY - ORDER BY certificate_expire ASC" -); - -// Get Licenses Expiring -$sql_licenses_expiring = mysqli_query( - $mysqli, - "SELECT software_expire, software_id, software_name FROM software - WHERE software_client_id = $client_id - AND software_expire IS NOT NULL - AND software_archived_at IS NULL - AND software_expire > CURRENT_DATE - AND software_expire < CURRENT_DATE + INTERVAL 45 DAY - ORDER BY software_expire ASC" -); - -// Get Asset Warranties Expiring -$sql_asset_warranties_expiring = mysqli_query( - $mysqli, - "SELECT asset_id, asset_name, asset_warranty_expire FROM assets - WHERE asset_client_id = $client_id - AND asset_warranty_expire IS NOT NULL - AND asset_archived_at IS NULL - AND asset_warranty_expire > CURRENT_DATE - AND asset_warranty_expire < CURRENT_DATE + INTERVAL 45 DAY - ORDER BY asset_warranty_expire ASC" -); - -// Get Assets Retiring 7 Year -$sql_asset_retire = mysqli_query( - $mysqli, - "SELECT asset_id, asset_install_date, asset_name FROM assets - WHERE asset_client_id = $client_id - AND asset_install_date IS NOT NULL - AND asset_archived_at IS NULL - AND asset_install_date + INTERVAL 7 YEAR > CURRENT_DATE - AND asset_install_date + INTERVAL 7 YEAR <= CURRENT_DATE + INTERVAL 45 DAY - ORDER BY asset_install_date ASC" -); - -/* - * EXPIRED ITEMS - */ - -// Get Domains Expired -$sql_domains_expired = mysqli_query( - $mysqli, - "SELECT domain_expire, domain_id, domain_name FROM domains - WHERE domain_client_id = $client_id - AND domain_expire IS NOT NULL - AND domain_archived_at IS NULL - AND domain_expire < CURRENT_DATE - ORDER BY domain_expire ASC" -); - -// Get Certificates Expired -$sql_certificates_expired = mysqli_query( - $mysqli, - "SELECT certificate_expire, certificate_id, certificate_name FROM certificates - WHERE certificate_client_id = $client_id - AND certificate_expire IS NOT NULL - AND certificate_archived_at IS NULL - AND certificate_expire < CURRENT_DATE - ORDER BY certificate_expire ASC" -); - -// Get Licenses Expired -$sql_licenses_expired = mysqli_query( - $mysqli, - "SELECT software_expire, software_id, software_name FROM software - WHERE software_client_id = $client_id - AND software_expire IS NOT NULL - AND software_archived_at IS NULL - AND software_expire < CURRENT_DATE - ORDER BY software_expire ASC" -); - -// Get Asset Warranties Expired -$sql_asset_warranties_expired = mysqli_query( - $mysqli, - "SELECT asset_id, asset_name, asset_warranty_expire FROM assets - WHERE asset_client_id = $client_id - AND asset_warranty_expire IS NOT NULL - AND asset_archived_at IS NULL - AND asset_warranty_expire < CURRENT_DATE - ORDER BY asset_warranty_expire ASC" -); - -// Get Retired Assets -$sql_asset_retired = mysqli_query( - $mysqli, - "SELECT asset_id, asset_install_date, asset_name FROM assets - WHERE asset_client_id = $client_id - AND asset_install_date IS NOT NULL - AND asset_archived_at IS NULL - AND asset_install_date + INTERVAL 7 YEAR < CURRENT_DATE -- Assets retired (installed more than 7 years ago) - ORDER BY asset_install_date ASC" -); +$chart_data = []; +if ($access['financial']) { + $chart_data['monthly'] = clientOverviewMonthly($client_id); + $chart_data['currency'] = $client_currency_code ?: $session_company_currency; +} +$priority_colors = ['Urgent' => 'dark', 'High' => 'danger', 'Medium' => 'warning', 'Low' => 'info']; +$tone_text = ['danger' => 'text-danger-emphasis', 'warning' => 'text-warning-emphasis', 'info' => 'text-info-emphasis', 'secondary' => 'text-body']; +$money_format = 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); +}; ?> -
+
- - -
- -
-
-
Quick Notes
+ +
+
+
+ +
+

+ + Lead + Archived +

+
+ + + + +
+
+
+ + + + + + +
-
- +
+
+
+
-
- - 0) { ?> -
-
-
Important Contacts
+ +
+

Needs attention

+
+
+ + + + + + + +
-
- - + - while ($row = mysqli_fetch_assoc($sql_important_contacts)) { - $contact_id = intval($row['contact_id']); - $contact_name = escapeHtml($row['contact_name']); - $contact_title = escapeHtml($row['contact_title']); - $contact_email = escapeHtml($row['contact_email']); - $contact_phone_country_code = escapeHtml($row['contact_phone_country_code']); - $contact_phone = escapeHtml(formatPhoneNumber($row['contact_phone'], $contact_phone_country_code)); - $contact_extension = escapeHtml($row['contact_extension']); - $contact_mobile_country_code = escapeHtml($row['contact_mobile_country_code']); - $contact_mobile = escapeHtml(formatPhoneNumber($row['contact_mobile'], $contact_mobile_country_code)); - $contact_photo = escapeHtml($row['contact_photo']); - $contact_primary = intval($row['contact_primary']); - $contact_initials = initials($contact_name); - if ($contact_primary == 1) { - $contact_primary_display = "Primary Contact"; - } else { - $contact_primary_display = false; - } +

Technical

- ?> - - - - - - -
- -
- - - "> - - - - - - - - -
-
- -
- -
- -
- -
-
+
- - $contact_phone $contact_extension" ?> - - -
-
- -
+ +
+ +
+ + No open tickets. +
+ +
-
-
- - 0) { ?> - -
- -
-
-
Favorite Assets
-
- - - - - - - - -
- - - - -
-
-
- -
- - - - 0) && (lookupUserPermission('module_credential'))) { ?> - -
- -
-
-
Favorite Credentials
-
- - - " . truncate($credential_uri,40) . ""; - } - $credential_uri_2 = escapeUrl($row['credential_uri_2']); - $credential_username = escapeHtml(decryptCredentialEntry($row['credential_username'])); - if (empty($credential_username)) { - $credential_username_display = "-"; - } else { - $credential_username_display = "$credential_username"; - } - $credential_otp_secret = escapeHtml($row['credential_otp_secret']); - if (empty($credential_otp_secret)) { - $otp_display = ""; - } else { - $otp_display = " Hover.."; - } - - ?> - - - - - - - - -
- - - - - -
-
-
- -
- - - - 0) { ?> - -
- -
-
-
Shared Items
+
+
+
+

Quick notes

+
-
- - - + + + + - while ($row = mysqli_fetch_assoc($sql_shared_items)) { - $item_id = intval($row['item_id']); - $item_active = escapeHtml($row['item_active']); - $item_key = escapeHtml($row['item_key']); - $item_type = escapeHtml($row['item_type']); - $item_related_id = intval($row['item_related_id']); - $item_note = escapeHtml($row['item_note']); - $item_recipient = escapeHtml($row['item_recipient']); - $item_views = escapeHtml($row['item_views']); - $item_view_limit = escapeHtml($row['item_view_limit']); - $item_created_at = escapeHtml($row['item_created_at']); - $item_expire_at = escapeHtml($row['item_expire_at']); - $item_expire_at_human = timeAgo($row['item_expire_at']); - - if ($item_type == 'Credential') { - $share_item_sql = mysqli_query($mysqli, "SELECT credential_name FROM credentials WHERE credential_id = $item_related_id AND credential_client_id = $client_id"); - $share_item = mysqli_fetch_assoc($share_item_sql); - $item_name = escapeHtml($share_item['credential_name']); - $item_icon = "fas fa-key"; - } elseif ($item_type == 'Document') { - $share_item_sql = mysqli_query($mysqli, "SELECT document_name FROM documents WHERE document_id = $item_related_id AND document_client_id = $client_id"); - $share_item = mysqli_fetch_assoc($share_item_sql); - $item_name = escapeHtml($share_item['document_name']); - $item_icon = "fas fa-folder"; - } elseif ($item_type == 'File') { - $share_item_sql = mysqli_query($mysqli, "SELECT file_name FROM files WHERE file_id = $item_related_id AND file_client_id = $client_id"); - $share_item = mysqli_fetch_assoc($share_item_sql); - $item_name = escapeHtml($share_item['file_name']); - $item_icon = "fas fa-paperclip"; - } +
+
+
+

Key contacts

+ All contacts +
+ +
+ $person['contact_primary'], 'Billing' => $person['contact_billing'], 'Technical' => $person['contact_technical'], 'Important' => $person['contact_important']])); ?> -
- - - - - - - - - - -
- - -
Views:
-
-
Expires - - - -
-
-
-
- - - - 0 - || mysqli_num_rows($sql_certificates_expiring) > 0 - || mysqli_num_rows($sql_asset_warranties_expiring) > 0 - || mysqli_num_rows($sql_asset_retire) > 0 - || mysqli_num_rows($sql_licenses_expiring) > 0 - ) { ?> - -
- -
-
-
Expiring in the Next 45 Days
-
-
- - -

- - Domain: - -- () -

- - - -

- - Certificate: - -- () -

- - - -

- - Asset Warranty: - -- () -

- - - - - -

- - Asset Retire: - -- () -

- - - - -

- - License: - -- () -

- - - -
-
-
- - - - - 0 - || mysqli_num_rows($sql_certificates_expired) > 0 - || mysqli_num_rows($sql_asset_warranties_expired) > 0 - || mysqli_num_rows($sql_asset_retired) > 0 - || mysqli_num_rows($sql_licenses_expired) > 0 - ) - { ?> - -
- -
-
-
Expired
-
-
- - -

- - Domain: - -- () -

- - - -

- - Certificate: - -- () -

- - - -

- Asset Warranty: - - -- () -

- - - - - -

- - Asset Retire: - -- () -

- - - - -

- - Software: - -- () -

- - - -
-
-
- - - - 0) { ?> - - - -
- -
-
-
Stale Tickets (Not updated within 3 days)
-
- - - - - - - - - - - - -
-
-
- - - - 0) { ?> - - - -
- -
-
-
Recent Activities (Last 10 tasks)
-
- - - - - - - - - - - -
- - +
+ + + + + +
+ + + + +
+
+ + +
+ +
+ + +
+ +
+
+ +
+ +
+ Mark contacts as primary, billing, technical or important and they show here. +
+ +
+
+
+

Favorites

+ +
+
+ +
+ + + + + + + + +
+ + +
+ + + + + + + + + + + + OTP + + + + +
+ +
+
+
+ + + +
+
+
+

Coming up

+ Next 14 days +
+
+ +
+ + + + + + + + + + +
+
+
+ + + +
+
+
+

Renewals

+ Expired or due in days +
+ + + 8) { ?> + + + +
+ + Nothing expired or due in the next days. +
+ +
+
+ + + +
+
+
+

Assets

+ View all +
+
+ $group) { + if (!$group['count']) { + continue; + } ?> + + + + + + +
+ +
+
+ + + +
+
+
+

Active share links

+
+
+ 'key', 'Document' => 'file-alt', 'File' => 'paperclip'][$item['item_type']] ?? 'share-square'; + $item_views = intval($item['item_views']); + $item_limit = intval($item['item_view_limit']); + ?> +
+ + + + + view, expires + + + +
+ +
+
+
+ + +
+
+
+

Recent activity

+ Audit log +
+ +
+ +
+
+
+
+ +
+ +
Nothing recorded yet.
+ +
+
+
+ + + +

Financial

+ + + + + +
+ +
+
+
+

Unpaid invoices

+ All invoices +
+ + + count($open_invoices['invoices'])) { ?> + more unpaid + + +
+ + All invoices are paid. +
+ +
+
+ +
+
+
+

Recurring invoices

+ View all +
+ + + +
No recurring invoices.
+ +
+
+ + + +
+
+
+

Invoiced and paid

+ Last 12 months +
+ 0) { ?> +
+
+
+ +
Nothing invoiced or paid in the last 12 months.
+ +
+
+ +
+
- - - - + + + diff --git a/agent/includes/client_actions_menu.php b/agent/includes/client_actions_menu.php new file mode 100644 index 000000000..52b41157b --- /dev/null +++ b/agent/includes/client_actions_menu.php @@ -0,0 +1,53 @@ + + diff --git a/agent/includes/client_details.php b/agent/includes/client_details.php new file mode 100644 index 000000000..b56f0994b --- /dev/null +++ b/agent/includes/client_details.php @@ -0,0 +1,76 @@ += 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)); +?> +
+
Primary contact
+ + + +
+ + + + + + + + + + + + +
+ + Add primary contact + + None + +
+ +
+
Primary location
+ + + +
+
+
+ +
+ + + + + + Add primary location + + None + +
+ +
+
Details
+
+ + + + + since + + + Referred by + + Client ID +
+
diff --git a/agent/includes/client_tags.php b/agent/includes/client_tags.php new file mode 100644 index 000000000..3422cc43d --- /dev/null +++ b/agent/includes/client_tags.php @@ -0,0 +1,22 @@ + + + + += 2) { ?> + + Add tags' : 'Tag' ?> + +$client_tag_name"; + $client_tags[] = $row; } - $client_tags_display = implode('', $client_tag_name_display_array); /* * Client badge counts and money totals. diff --git a/agent/includes/inc_client_top_head.php b/agent/includes/inc_client_top_head.php index d89122d45..761050aa4 100644 --- a/agent/includes/inc_client_top_head.php +++ b/agent/includes/inc_client_top_head.php @@ -1,228 +1,86 @@ - +/* + * 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. + */ -
-
-
-

+$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); + }; + ?> + +
+
+
+ + + + + + + + + Lead + Archived +
+ +
+ + + +
+ + open + + + + due + + due + + +
+
-
- = 2) { ?> -
- -
-
-
- -
-
-
Primary Location
- = 2 to - match enforceUserPermission() in agent/post/location.php; - without that a read-only user would get a link that 403s. */ ?> - = 2) { ?> - - - - -
- -
-
- -
- -
- -
- -
-
Primary Contact
- = 2) { ?> - - -
- -
- -
- -
- -
- - - x$contact_extension"; - } - ?> -
- -
- -
- -
- - = 1 && $config_module_enable_accounting == 1) { ?> - -
-
Billing
-
Hourly Rate - -
-
Paid - -
-
Balance - float-end"> -
- -
Credit - -
- -
Monthly Recurring - -
-
Net Terms - - - Days - - On Receipt - - -
- -
Tax ID - -
- -
- - - = 1 && $config_module_enable_ticketing == 1) { ?> -
-
Support
-
Open Tickets - -
-
Closed Tickets - +
+
+
+
- -
@@ -262,6 +120,8 @@ })(); + + 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 ?: '?'; +} diff --git a/js/client_overview.js b/js/client_overview.js new file mode 100644 index 000000000..1386009ce --- /dev/null +++ b/js/client_overview.js @@ -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); } } } + } + } + }); +})();