Files
itflow/agent/client_overview.php
johnnyq 1a51fc8dd6 Invoices, quotes and recurring invoices makeover stage 3: Unbilled Time page under Billing and in each client's menu with a count, listing every client's billable resolved or closed tickets not yet invoiced with time logged, hours to bill, rate, amount and oldest waiting, invoicing many clients at once onto a new draft each or each one's newest draft, and per client reviewing each ticket's hours before billing onto a new or existing draft with a line per ticket or one for all, live totals, and Not billable to write tickets off, Billing Time settings under Admin Settings Invoice for rounding to 6, 12, 15 or 30 minutes or the hour, nearest or always up, a per-ticket minimum and the line name, defaulting to the nearest 15 minutes, rebuilt ticket Invoice window showing time by agent, hours to bill and the client or default rate with add-to-draft and an open ticket warning, Add Tickets on an invoice now adds lines for the tickets' time with an option to attach without lines, client overview, dashboard and Unbilled Work report linking to it and counting unbilled time the same way, ticket billing written to ticket history instead of an internal note, invoices from tickets and copies due on the client's terms including On Receipt. Fixes ticket invoice line placed near the top of existing invoices, a ticket billable twice from two windows, no rate when the client had none set, and no hours filled in for tickets with 100 hours or more. Adds DB 2.9.2, billingTimeInvoice() as the one billing path with ticket claiming, billingUnbilledSql() and related helpers, unbilled_invoice, unbilled_invoice_clients and unbilled_write_off handlers, js/unbilled_time.js, and updates the 26.11 changelog
2026-10-16 13:42:06 -04:00

615 lines
45 KiB
PHP

<?php
/*
* Client overview.
*
* Who the client is and how to reach them, what needs attention, then the
* technical side (tickets, notes, contacts, favourites, schedule, projects,
* services, renewals, assets) and the financial side (balance, payments,
* recurring, unbilled work, open invoices). Each part shows to agents whose
* role can read it.
*
* The figures come from functions/client_overview.php; the chart is drawn by
* js/client_overview.js, which also saves the quick notes.
*/
// This page draws its own client header, with the same actions menu
$client_top_head_skip = true;
require_once "includes/inc_all_client.php";
$access = clientOverviewAccess();
$renewals = $access['docs'] ? clientOverviewRenewals($client_id) : null;
$attention = clientOverviewAttention($client_id, $access, $renewals);
$open_tickets = $access['tickets'] ? clientOverviewOpenTickets($client_id) : null;
$open_projects = $access['tickets'] ? clientOverviewProjects($client_id) : null;
$client_services = $access['docs'] ? clientOverviewServices($client_id) : null;
$overview_contacts = clientOverviewContacts($client_id);
$favorites = clientOverviewFavorites($client_id, $access);
$upcoming = clientOverviewUpcoming($client_id, $access);
$asset_summary = $access['docs'] ? clientOverviewAssets($client_id) : null;
$shared_items = clientOverviewSharedItems($client_id);
$activity = clientOverviewActivity($client_id);
$money = $access['financial'] ? clientOverviewMoney($client_id, $balance, $recurring_monthly) : null;
$open_invoices = $access['sales'] ? clientOverviewOpenInvoices($client_id) : null;
$recurring_invoices = $access['sales'] ? clientOverviewRecurringInvoices($client_id) : null;
$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);
};
?>
<div class="itflow-dash itflow-ov">
<!-- Who this is -->
<div class="card mb-4">
<div class="card-body">
<div class="d-flex flex-wrap align-items-start gap-3">
<span class="itflow-client-avatar itflow-client-avatar-lg text-bg-<?= $client_initials_tone ?>" aria-hidden="true"><?= $client_initials ?></span>
<div class="flex-grow-1 overflow-hidden itflow-ov-hero-name">
<h1 class="itflow-dash-greeting mb-1">
<?= $client_name ?>
<?php if ($client_is_lead) { ?><span class="badge rounded-pill text-bg-info align-middle fs-6 fw-normal ms-1">Lead</span><?php } ?>
<?php if ($client_archived_at) { ?><span class="badge rounded-pill text-bg-secondary align-middle fs-6 fw-normal ms-1">Archived</span><?php } ?>
</h1>
<div class="d-flex flex-wrap align-items-center gap-1">
<?php if ($client_type) { ?>
<span class="text-body-secondary me-2"><?= $client_type ?></span>
<?php } ?>
<?= tagPills('client', $client_id, $client_tags) ?>
</div>
</div>
<div class="d-flex gap-2 ms-auto itflow-ov-hero-actions">
<?php if ($access['add_ticket'] && !$client_archived_at) { ?>
<button type="button" class="btn btn-primary ajax-modal" data-modal-url="modals/ticket/ticket_add.php?client_id=<?= $client_id ?>" data-modal-size="lg">
<i class="fas fa-plus me-2"></i>New Ticket
</button>
<?php } ?>
<?php if ($access['edit']) { ?>
<div class="dropdown dropstart">
<button class="btn btn-outline-secondary" title="More actions" type="button" data-bs-toggle="dropdown">
<i class="fas fa-fw fa-ellipsis-v"></i>
</button>
<?php require 'includes/client_actions_menu.php'; ?>
</div>
<?php } ?>
</div>
</div>
</div>
<div class="card-body border-top">
<div class="row g-3">
<?php require 'includes/client_details.php'; ?>
</div>
</div>
</div>
<?php if ($attention) { ?>
<section class="mb-4" aria-labelledby="ovAttention">
<h2 id="ovAttention" class="itflow-dash-section mb-2">Needs attention</h2>
<div class="card mb-0 overflow-hidden">
<div class="itflow-dash-rail">
<?php foreach ($attention as $item) {
$tone = $item['tone']; ?>
<a href="<?= escapeHtml($item['url']) ?>" class="itflow-dash-rail-item" style="--itflow-dash-tone: var(--bs-<?= $tone ?>)">
<span class="itflow-dash-rail-count <?= $tone_text[$tone] ?? 'text-body' ?>"><?= number_format(intval($item['count'])) ?></span>
<span class="itflow-dash-rail-label"><i class="fas fa-fw fa-<?= $item['icon'] ?> <?= $tone_text[$tone] ?? '' ?>" aria-hidden="true"></i><?= escapeHtml($item['label']) ?></span>
<?php if (!empty($item['detail'])) { ?><span class="itflow-dash-rail-detail"><?= escapeHtml($item['detail']) ?></span><?php } ?>
</a>
<?php } ?>
</div>
</div>
</section>
<?php } ?>
<h2 class="itflow-dash-section mb-2">Technical</h2>
<div class="row g-3 mb-3">
<?php if ($open_tickets !== null) { ?>
<div class="col-xl-8">
<div class="card h-100 mb-0">
<div class="card-header d-flex flex-wrap align-items-center gap-2">
<h3 class="card-title mb-0 me-auto">Open tickets</h3>
<span class="small text-body-secondary"><?= $open_tickets['opened_30'] ?> opened, <?= $open_tickets['resolved_30'] ?> resolved in the last 30 days</span>
<?php if ($open_tickets['total']) { ?><a class="small ms-2" href="tickets.php?client_id=<?= $client_id ?>">View all <?= $open_tickets['total'] ?></a><?php } ?>
</div>
<?php if ($open_tickets['tickets']) { ?>
<div class="list-group list-group-flush">
<?php foreach ($open_tickets['tickets'] as $ticket) {
$status_color = preg_match('/^#[0-9a-f]{3,8}$/i', (string) $ticket['ticket_status_color']) ? $ticket['ticket_status_color'] : '#6c757d';
$last_activity = $ticket['ticket_updated_at'] ?: $ticket['ticket_created_at'];
$ticket_who = trim(implode(', ', array_filter([$ticket['contact_name'], $ticket['user_name'] ? 'assigned to ' . $ticket['user_name'] : 'unassigned'])));
?>
<a href="ticket.php?client_id=<?= $client_id ?>&ticket_id=<?= intval($ticket['ticket_id']) ?>" class="list-group-item list-group-item-action d-flex align-items-center gap-3 py-2">
<span class="itflow-dash-dot bg-<?= $priority_colors[$ticket['ticket_priority']] ?? 'secondary' ?>" title="<?= escapeHtml($ticket['ticket_priority']) ?> priority"></span>
<span class="flex-grow-1 overflow-hidden">
<span class="d-block text-truncate"><span class="small text-body-secondary me-2"><?= escapeHtml($ticket['ticket_prefix'] . $ticket['ticket_number']) ?></span><?= escapeHtml($ticket['ticket_subject']) ?></span>
<span class="d-block small text-body-secondary text-truncate"><?= escapeHtml(ucfirst($ticket_who)) ?></span>
</span>
<?php if ($ticket['sla_breached']) { ?>
<span class="badge rounded-pill text-bg-danger" title="Past SLA"><i class="fas fa-stopwatch me-1" aria-hidden="true"></i>SLA</span>
<?php } ?>
<span class="badge rounded-pill text-white d-none d-sm-inline" style="background-color: <?= $status_color ?>"><?= escapeHtml($ticket['ticket_status_name']) ?></span>
<span class="small text-body-secondary text-nowrap d-none d-md-inline" title="Last activity <?= escapeHtml($last_activity) ?>"><?= escapeHtml(timeAgo($last_activity)) ?></span>
</a>
<?php } ?>
</div>
<?php } else { ?>
<div class="card-body text-center text-body-secondary py-5">
<i class="fas fa-check-circle fa-2x mb-2 d-block text-success" aria-hidden="true"></i>
No open tickets.
</div>
<?php } ?>
</div>
</div>
<?php } ?>
<div class="<?= $open_tickets !== null ? 'col-xl-4' : 'col-md-6 col-xl-4' ?>">
<div class="card h-100 mb-0">
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0 me-auto">Quick notes</h3>
<span class="small text-body-secondary" data-notes-status><?= $access['edit'] ? 'Saves when you click away' : '' ?></span>
</div>
<div class="card-body pt-2 d-flex">
<textarea class="form-control itflow-ov-notes" id="clientNotes" rows="8" placeholder="<?= $access['edit'] ? 'Gate codes, quirks, who to ask for - anything the next person should know' : 'No notes' ?>"
data-client-id="<?= $client_id ?>" data-csrf="<?= $_SESSION['csrf_token'] ?>" aria-label="Quick notes" <?= $access['edit'] ? '' : 'readonly' ?>><?= $client_notes ?></textarea>
</div>
</div>
</div>
<div class="col-md-6 col-xl-4">
<div class="card h-100 mb-0">
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0 me-auto">Key contacts</h3>
<a class="small" href="contacts.php?client_id=<?= $client_id ?>">All contacts</a>
</div>
<?php if ($overview_contacts) { ?>
<div class="list-group list-group-flush">
<?php foreach ($overview_contacts as $person) {
$person_id = intval($person['contact_id']);
$person_name = escapeHtml($person['contact_name']);
$person_phone = escapeHtml(formatPhoneNumber($person['contact_phone'], $person['contact_phone_country_code']));
$person_mobile = escapeHtml(formatPhoneNumber($person['contact_mobile'], $person['contact_mobile_country_code']));
$person_email = escapeHtml($person['contact_email']);
?>
<div class="list-group-item d-flex gap-3 py-2">
<?= contactAvatar($client_id, $person_id, $person['contact_name'], $person['contact_photo']) ?>
<div class="overflow-hidden flex-grow-1">
<a class="ajax-modal fw-semibold" href="#" data-modal-url="modals/contact/contact.php?client_id=<?= $client_id ?>&id=<?= $person_id ?>" data-modal-size="lg"><?= $person_name ?></a>
<span class="ms-1"><?= contactFlagPills($person, false) ?></span>
<?php if ($person['contact_title']) { ?><div class="small text-body-secondary text-truncate"><?= escapeHtml($person['contact_title']) ?></div><?php } ?>
<div class="small d-flex flex-wrap column-gap-3">
<?php if ($person_phone) { ?><a class="text-reset text-decoration-none text-nowrap" href="tel:<?= escapeHtml(preg_replace('/[^0-9+]/', '', (string) $person['contact_phone'])) ?>"><i class="fas fa-fw fa-phone text-body-secondary me-1" aria-hidden="true"></i><?= $person_phone ?><?= $person['contact_extension'] ? ' x' . escapeHtml($person['contact_extension']) : '' ?></a><?php } ?>
<?php if ($person_mobile) { ?><a class="text-reset text-decoration-none text-nowrap" href="tel:<?= escapeHtml(preg_replace('/[^0-9+]/', '', (string) $person['contact_mobile'])) ?>"><i class="fas fa-fw fa-mobile-alt text-body-secondary me-1" aria-hidden="true"></i><?= $person_mobile ?></a><?php } ?>
</div>
<?php if ($person_email) { ?>
<div class="small d-flex align-items-center">
<a class="text-reset text-decoration-none text-truncate" href="mailto:<?= $person_email ?>"><i class="far fa-fw fa-envelope text-body-secondary me-1" aria-hidden="true"></i><?= $person_email ?></a>
<button type="button" class="itflow-copy clipboardjs" title="Copy email" data-clipboard-text="<?= $person_email ?>"><i class="far fa-copy"></i></button>
</div>
<?php } ?>
</div>
</div>
<?php } ?>
</div>
<?php } else { ?>
<div class="card-body text-center text-body-secondary py-4 small">
Mark contacts as primary, billing, technical or important and they show here.
</div>
<?php } ?>
</div>
</div>
<?php if ($favorites['assets'] || $favorites['licenses'] || $favorites['credentials']) { ?>
<div class="col-md-6 col-xl-4">
<div class="card h-100 mb-0">
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0 me-auto">Favorites</h3>
<i class="fas fa-star text-warning" aria-hidden="true"></i>
</div>
<div class="list-group list-group-flush">
<?php foreach ($favorites['assets'] as $asset) {
$asset_ip = escapeHtml($asset['interface_ip']); ?>
<div class="list-group-item d-flex align-items-center gap-2 py-2">
<i class="fas fa-fw fa-<?= getAssetIcon($asset['asset_type']) ?> text-body-secondary" title="<?= escapeHtml($asset['asset_type']) ?>"></i>
<span class="flex-grow-1 overflow-hidden">
<a class="ajax-modal d-block text-truncate" href="#" data-modal-url="modals/asset/asset.php?id=<?= intval($asset['asset_id']) ?>" data-modal-size="lg"><?= escapeHtml($asset['asset_name']) ?></a>
<span class="d-block small text-body-secondary text-truncate"><?= escapeHtml(trim($asset['asset_make'] . ' ' . $asset['asset_model'])) ?></span>
</span>
<?php if ($asset_ip) { ?>
<span class="small text-nowrap"><?= $asset_ip ?><button type="button" class="itflow-copy clipboardjs" title="Copy IP" data-clipboard-text="<?= $asset_ip ?>"><i class="far fa-copy"></i></button></span>
<?php } ?>
</div>
<?php } ?>
<?php foreach ($favorites['licenses'] as $favorite_license) {
$favorite_renewal = softwareRenewal($favorite_license['software_expire']); ?>
<div class="list-group-item d-flex align-items-center gap-2 py-2">
<i class="fas fa-fw <?= softwareTypeStyle($favorite_license['software_type'])['icon'] ?> text-body-secondary" title="License"></i>
<span class="flex-grow-1 overflow-hidden">
<a class="ajax-modal d-block text-truncate" href="#" data-modal-url="modals/software/software.php?id=<?= intval($favorite_license['software_id']) ?>" data-modal-size="lg"><?= escapeHtml($favorite_license['software_name']) ?></a>
<span class="d-block small text-truncate text-<?= $favorite_renewal && $favorite_renewal['state'] !== 'active' ? $favorite_renewal['tone'] . '-emphasis' : 'body-secondary' ?>"><?= escapeHtml(implode(' · ', array_filter([$favorite_license['software_license_type'], $favorite_renewal ? $favorite_renewal['text'] : '']))) ?></span>
</span>
<?php if ($favorite_license['software_key']) { ?>
<span class="small text-nowrap"><?= softwareKeyHtml($favorite_license['software_key']) ?></span>
<?php } ?>
</div>
<?php } ?>
<?php foreach ($favorites['credentials'] as $credential) {
$credential_id = intval($credential['credential_id']);
$credential_username = credentialUsername($credential);
?>
<div class="list-group-item d-flex align-items-center gap-2 py-2">
<?= credentialTile($credential, 'sm') ?>
<span class="flex-grow-1 min-w-0">
<a class="ajax-modal d-block text-truncate" href="#" data-modal-url="modals/credential/credential.php?id=<?= $credential_id ?>"><?= escapeHtml($credential['credential_name']) ?></a>
<span class="d-flex flex-wrap align-items-center column-gap-3 row-gap-1 small text-body-secondary">
<?php if ($credential_username !== '') { ?>
<span class="d-inline-flex align-items-center min-w-0"><?= credentialUsernameHtml($credential_username) ?></span>
<?php } ?>
<?= credentialPasswordHtml($credential_id) ?>
<?= credentialOtpHtml($credential_id, trim((string) $credential['credential_otp_secret']) !== '') ?>
</span>
</span>
</div>
<?php } ?>
</div>
</div>
</div>
<?php } ?>
<?php if ($upcoming) { ?>
<div class="col-md-6 col-xl-4">
<div class="card h-100 mb-0">
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0 me-auto">Coming up <span class="small text-body-secondary fw-normal ms-1">14 days</span></h3>
<a class="small" href="calendar.php?client_id=<?= $client_id ?>">Calendar</a>
</div>
<div class="itflow-dash-schedule">
<?php
$upcoming_day = null;
foreach (array_slice($upcoming, 0, 10) as $item) {
$item_day = dashboardDayLabel($item['when']);
if ($item_day !== $upcoming_day) {
$upcoming_day = $item_day; ?>
<div class="itflow-dash-schedule-day"><?= escapeHtml($item_day) ?></div>
<?php } ?>
<a class="itflow-dash-schedule-item<?= !empty($item['modal']) ? ' ajax-modal' : '' ?>" href="<?= escapeHtml($item['url']) ?>"<?php if (!empty($item['modal'])) { ?> data-modal-size="lg" data-modal-url="<?= escapeHtml($item['modal']) ?>"<?php } ?>>
<span class="itflow-dash-schedule-time"><?= $item['all_day'] ? 'All day' : escapeHtml(date('g:i A', strtotime($item['when']))) ?></span>
<span class="itflow-dash-schedule-mark bg-<?= ['ticket' => 'primary', 'recurring' => 'info', 'event' => 'success', 'project' => 'warning', 'service' => 'secondary'][$item['kind']] ?>"></span>
<span class="flex-grow-1 overflow-hidden">
<span class="d-block text-truncate"><?php if ($item['ref']) { ?><span class="small text-body-secondary me-2"><?= escapeHtml($item['ref']) ?></span><?php } ?><?= escapeHtml($item['title']) ?></span>
<?php if ($item['detail']) { ?><span class="d-block small text-body-secondary text-truncate"><?= escapeHtml($item['detail']) ?></span><?php } ?>
</span>
</a>
<?php } ?>
</div>
</div>
</div>
<?php } ?>
<?php if ($open_projects && $open_projects['total']) { ?>
<div class="col-md-6 col-xl-4">
<div class="card h-100 mb-0">
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0 me-auto">Projects</h3>
<a class="small" href="projects.php?client_id=<?= $client_id ?>">View all <?= $open_projects['total'] ?></a>
</div>
<div class="list-group list-group-flush">
<?php foreach ($open_projects['projects'] as $project_id => $project) {
$project_stats = $open_projects['stats'][$project_id]; ?>
<a href="<?= escapeHtml(projectUrl($project_id, $client_id)) ?>" class="list-group-item list-group-item-action py-2">
<span class="d-flex align-items-center gap-2">
<span class="flex-grow-1 text-truncate"><span class="small text-body-secondary me-2"><?= escapeHtml(projectReference($project)) ?></span><?= escapeHtml($project['project_name']) ?></span>
<?= projectDuePill($project) ?>
</span>
<div class="mt-1 small text-body-secondary"><?= projectProgressHtml($project_stats) ?></div>
</a>
<?php } ?>
</div>
</div>
</div>
<?php } ?>
<?php if ($client_services && $client_services['total']) { ?>
<div class="col-md-6 col-xl-4">
<div class="card h-100 mb-0">
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0 me-auto">Services</h3>
<a class="small" href="services.php?client_id=<?= $client_id ?>">View all <?= $client_services['total'] ?></a>
</div>
<div class="list-group list-group-flush">
<?php foreach ($client_services['services'] as $service_id => $service) {
$service_issues = $client_services['issues'][$service_id];
$service_review = serviceReview($service);
$service_danger = in_array('danger', array_column($service_issues, 'tone'), true); ?>
<a href="<?= escapeHtml(serviceUrl($service_id, $client_id)) ?>" class="list-group-item list-group-item-action d-flex align-items-center gap-2 py-2">
<?= serviceTile($service['service_category'], 'sm') ?>
<span class="flex-grow-1 overflow-hidden">
<span class="d-block text-truncate"><?= escapeHtml($service['service_name']) ?></span>
<span class="d-block small text-truncate <?= $service_issues ? ($service_danger ? 'text-danger-emphasis' : 'text-warning-emphasis') : 'text-body-secondary' ?>">
<?php if ($service_issues) { ?>
<i class="fas fa-exclamation-triangle me-1"></i><?= escapeHtml($service_issues[0]['text']) ?>
<?php } else { ?>
<?= escapeHtml($service['service_category'] ?: $service['service_importance'] . ' importance') ?><?= in_array($service_review['state'], ['overdue', 'soon'], true) ? ' · ' . escapeHtml(lcfirst($service_review['text'])) : '' ?>
<?php } ?>
</span>
</span>
<?php if ($service['service_importance'] === 'High') { ?><?= serviceImportancePill('High') ?><?php } ?>
</a>
<?php } ?>
</div>
</div>
</div>
<?php } ?>
<?php if ($renewals !== null) { ?>
<div class="col-md-6 col-xl-4" id="clientRenewals">
<div class="card h-100 mb-0">
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0 me-auto">Renewals</h3>
<span class="small text-body-secondary">Expired or due in <?= $renewals['days'] ?> days</span>
</div>
<?php if ($renewals['items']) { ?>
<div class="list-group list-group-flush">
<?php foreach (array_slice($renewals['items'], 0, 8) as $item) {
$expired = $item['days'] < 0;
$when = $item['days'] === 0 ? 'Today' : ($expired ? 'Expired ' . timeAgo($item['date']) : 'In ' . preg_replace('/^in /', '', timeAgo($item['date'])));
?>
<a href="<?= escapeHtml($item['url']) ?>" class="list-group-item list-group-item-action d-flex align-items-center gap-2 py-2">
<i class="fas fa-fw fa-<?= $item['icon'] ?> text-body-secondary" aria-hidden="true"></i>
<span class="flex-grow-1 overflow-hidden">
<span class="d-block text-truncate"><?= escapeHtml($item['name']) ?></span>
<span class="d-block small text-body-secondary"><?= $item['label'] ?></span>
</span>
<span class="small text-nowrap text-end <?= $expired ? 'text-danger-emphasis' : 'text-warning-emphasis' ?>" title="<?= escapeHtml(date('M j, Y', strtotime($item['date']))) ?>"><?= escapeHtml($when) ?></span>
</a>
<?php } ?>
</div>
<?php if (count($renewals['items']) > 8) { ?>
<div class="card-footer small text-body-secondary py-2"><?= count($renewals['items']) - 8 ?> more not shown</div>
<?php } ?>
<?php } else { ?>
<div class="card-body text-center text-body-secondary py-4">
<i class="fas fa-check-circle fa-2x mb-2 d-block text-success" aria-hidden="true"></i>
Nothing expired or due in the next <?= $renewals['days'] ?> days.
</div>
<?php } ?>
</div>
</div>
<?php } ?>
<?php if ($asset_summary !== null && $asset_summary['total']) { ?>
<div class="col-md-6 col-xl-4">
<div class="card h-100 mb-0">
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0 me-auto">Assets</h3>
<a class="small" href="assets.php?client_id=<?= $client_id ?>">View all <?= $asset_summary['total'] ?></a>
</div>
<div class="list-group list-group-flush">
<?php foreach ($asset_summary['groups'] as $group_key => $group) {
if (!$group['count']) {
continue;
} ?>
<a href="assets.php?client_id=<?= $client_id ?>&type=<?= $group_key ?>" class="list-group-item list-group-item-action d-flex align-items-center gap-2 py-2">
<i class="fas fa-fw fa-<?= $group['icon'] ?> text-body-secondary" aria-hidden="true"></i>
<span class="flex-grow-1"><?= $group['label'] ?></span>
<span class="fw-semibold"><?= $group['count'] ?></span>
</a>
<?php } ?>
</div>
<div class="card-footer small text-body-secondary py-2">
Warranty: <?= $asset_summary['warranty_active'] ?> covered<?php if ($asset_summary['warranty_expired']) { ?>, <span class="text-danger-emphasis"><?= $asset_summary['warranty_expired'] ?> expired</span><?php } ?><?php if ($asset_summary['warranty_unknown']) { ?>, <?= $asset_summary['warranty_unknown'] ?> not recorded<?php } ?>
</div>
</div>
</div>
<?php } ?>
<?php if ($shared_items) { ?>
<div class="col-md-6 col-xl-4">
<div class="card h-100 mb-0">
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0 me-auto">Shared links</h3>
<a class="small" href="shares.php?client_id=<?= $client_id ?>">All shared links</a>
</div>
<div class="list-group list-group-flush">
<?php foreach ($shared_items as $item) {
$item_related_id = intval($item['item_related_id']);
$item_label = escapeHtml(shareName($item));
$item_type_info = shareTypes()[$item['item_type']] ?? ['label' => $item['item_type'], 'icon' => 'fa-share-alt'];
// Documents and files look and open as they do everywhere else
if ($item['item_type'] === 'Document' && $item['item_name']) {
$item_tile = documentTile('sm');
} elseif ($item['item_type'] === 'File' && $item['file_ext']) {
$item_tile = fileTypeTile($item['file_ext'], 'sm');
} elseif ($item['item_type'] === 'Credential' && $item['credential_uri'] !== null) {
$item_tile = credentialTile(['credential_name' => $item['item_name'], 'credential_uri' => $item['credential_uri'], 'credential_description' => $item['credential_description']], 'sm');
} else {
$item_tile = '<span class="itflow-link-icon" title="' . escapeHtml($item_type_info['label']) . '" aria-hidden="true"><i class="fas fa-fw ' . $item_type_info['icon'] . '"></i></span>';
}
$item_received = $item['item_type'] === 'Request' && $item['item_received_at'];
?>
<div class="list-group-item d-flex align-items-center gap-2 py-2">
<?= $item_tile ?>
<span class="flex-grow-1 overflow-hidden">
<a class="d-block text-truncate ajax-modal" href="#" data-modal-url="modals/share/share.php?id=<?= intval($item['item_id']) ?>"><?= $item_label ?></a>
<span class="d-block small text-body-secondary text-truncate">
<?php if ($item_received) { ?>
<span class="text-warning-emphasis"><i class="fas fa-inbox me-1"></i>Sent back <?= escapeHtml(timeAgo($item['item_received_at'])) ?> - save it</span>
<?php } else { ?>
<?= escapeHtml(ucfirst(($item['item_recipient'] ? $item['item_recipient'] . ', ' : '') . strtolower(shareViewsText($item)) . ', expires ' . timeAgo($item['item_expire_at']))) ?>
<?php } ?>
</span>
</span>
<?php if (!$item_received && shareCanMake($item['item_type'])) { ?><a class="btn btn-sm btn-link text-danger confirm-link" href="post.php?share_revoke=<?= intval($item['item_id']) ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>" title="Turn off this link"><i class="fas fa-fw fa-unlink" aria-hidden="true"></i></a><?php } ?>
</div>
<?php } ?>
</div>
</div>
</div>
<?php } ?>
<div class="col-md-6 col-xl-4">
<div class="card h-100 mb-0">
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0 me-auto">Recent activity</h3>
<?php if ($session_is_admin) { ?><a class="small" href="../admin/audit_logs.php?client=<?= $client_id ?>">Audit log</a><?php } ?>
</div>
<?php if ($activity) { ?>
<div class="list-group list-group-flush">
<?php foreach ($activity as $log) { ?>
<div class="list-group-item py-2">
<div class="small"><?= escapeHtml($log['log_description']) ?></div>
<div class="small text-body-secondary" title="<?= escapeHtml($log['log_created_at']) ?>"><?= escapeHtml(timeAgo($log['log_created_at'])) ?></div>
</div>
<?php } ?>
</div>
<?php } else { ?>
<div class="card-body text-center text-body-secondary py-4 small">Nothing recorded yet.</div>
<?php } ?>
</div>
</div>
</div>
<?php if ($money !== null || $open_invoices !== null) { ?>
<h2 class="itflow-dash-section mt-4 mb-2">Financial</h2>
<?php if ($money !== null) { ?>
<div class="card mb-3 overflow-hidden">
<div class="itflow-dash-rail itflow-ov-stats">
<a href="invoices.php?client_id=<?= $client_id ?>&status=Unpaid" class="itflow-dash-rail-item">
<span class="small text-body-secondary">Balance</span>
<span class="itflow-dash-money-value <?= $money['overdue'] > 0.004 ? 'text-danger-emphasis' : '' ?>"><?= $money_format($money['balance']) ?></span>
<span class="small <?= $money['overdue'] > 0.004 ? 'text-danger-emphasis' : 'text-body-tertiary' ?>"><?= $money['overdue'] > 0.004 ? $money_format($money['overdue']) . ' overdue' : 'Nothing overdue' ?></span>
</a>
<a href="income.php?client_id=<?= $client_id ?>" class="itflow-dash-rail-item">
<span class="small text-body-secondary">Paid this year</span>
<span class="itflow-dash-money-value"><?= $money_format($money['paid_ytd']) ?></span>
<?php if ($money['paid_change'] !== null) { ?>
<span class="small"><span class="fw-semibold <?= $money['paid_change'] >= 0 ? 'text-success-emphasis' : 'text-danger-emphasis' ?>"><i class="fas fa-arrow-<?= $money['paid_change'] >= 0 ? 'up' : 'down' ?> me-1" aria-hidden="true"></i><?= abs($money['paid_change']) ?>%</span> <span class="text-body-tertiary">vs last year</span></span>
<?php } else { ?>
<span class="small text-body-tertiary">Nothing last year to compare</span>
<?php } ?>
</a>
<a href="recurring_invoices.php?client_id=<?= $client_id ?>" class="itflow-dash-rail-item">
<span class="small text-body-secondary">Recurring</span>
<span class="itflow-dash-money-value"><?= $money_format($money['recurring']) ?><span class="fs-6 fw-normal text-body-secondary">/mo</span></span>
<span class="small text-body-tertiary"><?= $num_recurring_invoices ?> recurring invoice<?= $num_recurring_invoices === 1 ? '' : 's' ?></span>
</a>
<?php if ($access['tickets']) { ?>
<a href="unbilled_time.php?client_id=<?= $client_id ?>" class="itflow-dash-rail-item">
<span class="small text-body-secondary">Not yet invoiced</span>
<span class="itflow-dash-money-value <?= $money['unbilled_value'] > 0 ? 'text-info-emphasis' : '' ?>"><?= $money_format($money['unbilled_value']) ?></span>
<span class="small text-body-tertiary"><?= $money['unbilled_tickets'] ?> ticket<?= $money['unbilled_tickets'] === 1 ? '' : 's' ?><?= $money['unbilled_seconds'] ? ', ' . dashboardDuration($money['unbilled_seconds']) . ' logged' : '' ?></span>
</a>
<?php } ?>
<div class="itflow-dash-rail-item">
<span class="small text-body-secondary">Terms</span>
<span class="itflow-dash-money-value"><?= $money_format($client_rate) ?><span class="fs-6 fw-normal text-body-secondary">/hr</span></span>
<span class="small text-body-tertiary"><?= $client_net_terms ? "Net $client_net_terms" : 'Due on receipt' ?><?= $client_tax_id_number ? ", tax ID $client_tax_id_number" : '' ?></span>
</div>
</div>
</div>
<?php } ?>
<div class="row g-3 mb-3">
<?php if ($open_invoices !== null) { ?>
<div class="col-xl-8">
<div class="card h-100 mb-0">
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0 me-auto">Unpaid invoices</h3>
<a class="small" href="invoices.php?client_id=<?= $client_id ?>">All invoices</a>
</div>
<?php if ($open_invoices['invoices']) { ?>
<div class="list-group list-group-flush">
<?php foreach ($open_invoices['invoices'] as $invoice) {
$days_over = (int) (new DateTime('today'))->diff(new DateTime($invoice['invoice_due']))->format('%r%a') * -1;
?>
<a href="invoice.php?client_id=<?= $client_id ?>&invoice_id=<?= intval($invoice['invoice_id']) ?>" class="list-group-item list-group-item-action d-flex align-items-center gap-3 py-2">
<span class="flex-grow-1 overflow-hidden">
<span class="d-block text-truncate"><span class="fw-semibold me-2"><?= escapeHtml($invoice['invoice_prefix'] . $invoice['invoice_number']) ?></span><span class="text-body-secondary"><?= escapeHtml($invoice['invoice_scope']) ?></span></span>
<span class="d-block small <?= $days_over > 0 ? 'text-danger-emphasis' : 'text-body-secondary' ?>">
<?= $days_over > 0 ? "$days_over day" . ($days_over === 1 ? '' : 's') . ' overdue' : 'Due ' . escapeHtml(date('M j', strtotime($invoice['invoice_due']))) ?>
<?= $invoice['invoice_status'] === 'Partial' ? ', part paid' : '' ?>
</span>
</span>
<span class="fw-semibold text-nowrap"><?= $money_format($invoice['balance']) ?></span>
</a>
<?php } ?>
</div>
<?php if ($open_invoices['total'] > count($open_invoices['invoices'])) { ?>
<a class="card-footer small text-decoration-none py-2" href="invoices.php?client_id=<?= $client_id ?>&status=Unpaid"><?= $open_invoices['total'] - count($open_invoices['invoices']) ?> more unpaid</a>
<?php } ?>
<?php } else { ?>
<div class="card-body text-center text-body-secondary py-4">
<i class="fas fa-check-circle fa-2x mb-2 d-block text-success" aria-hidden="true"></i>
All invoices are paid.
</div>
<?php } ?>
</div>
</div>
<div class="col-xl-4">
<div class="card h-100 mb-0">
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0 me-auto">Recurring invoices</h3>
<a class="small" href="recurring_invoices.php?client_id=<?= $client_id ?>">View all</a>
</div>
<?php if ($recurring_invoices) { ?>
<div class="list-group list-group-flush">
<?php foreach ($recurring_invoices as $recurring) { ?>
<a href="recurring_invoice.php?client_id=<?= $client_id ?>&recurring_invoice_id=<?= intval($recurring['recurring_invoice_id']) ?>" class="list-group-item list-group-item-action d-flex align-items-center gap-3 py-2">
<span class="flex-grow-1 overflow-hidden">
<span class="d-block text-truncate"><?= escapeHtml($recurring['recurring_invoice_scope'] ?: $recurring['recurring_invoice_prefix'] . $recurring['recurring_invoice_number']) ?></span>
<span class="d-block small text-body-secondary"><?= recurringInvoiceFrequencies()[$recurring['recurring_invoice_frequency']] ?? 'Monthly' ?>, next <?= escapeHtml(date('M j', strtotime($recurring['recurring_invoice_next_date']))) ?></span>
</span>
<span class="fw-semibold text-nowrap"><?= $money_format($recurring['recurring_invoice_amount']) ?></span>
</a>
<?php } ?>
</div>
<?php } else { ?>
<div class="card-body text-center text-body-secondary py-4 small">No recurring invoices.</div>
<?php } ?>
</div>
</div>
<?php } ?>
<?php if ($chart_data) { ?>
<div class="col-12">
<div class="card mb-0">
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0 me-auto">Invoiced and paid</h3>
<span class="small text-body-secondary">Last 12 months</span>
</div>
<?php if (array_sum($chart_data['monthly']['invoiced']) + array_sum($chart_data['monthly']['paid']) > 0) { ?>
<div class="card-body">
<div class="itflow-dash-chart"><canvas id="clientMonthly" aria-label="Invoiced and paid per month"></canvas></div>
</div>
<?php } else { ?>
<div class="card-body text-center text-body-secondary py-4 small">Nothing invoiced or paid in the last 12 months.</div>
<?php } ?>
</div>
</div>
<?php } ?>
</div>
<?php } ?>
</div>
<?php require_once "../includes/footer.php"; ?>
<?php if ($chart_data) { ?>
<script>window.itflowClientOverviewData = <?= json_encode($chart_data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_INVALID_UTF8_SUBSTITUTE) ?>;</script>
<?php } ?>
<script src="/js/client_overview.js"></script>