Files
itflow/agent/includes/inc_all_client.php
johnnyq a2a0823163 Trips stage 2: mileage money - Trips and Mileage in Admin > Settings > Invoice with miles or kilometers, the rate an agent is paid back for their own vehicle and the expense category it goes under, and what a trip bills a client as a trip charge, a rate a mile beyond the distance the charge covers and the invoice line name; the trip form has In their own vehicle starting as the driver's latest trip and Bill the client starting as the ticket is billable, each showing what it pays back or bills; the list gets To pay back and To bill pills, an owed card (Owed to you for agents without financial access), who is owed what and marks on each row; Pay Mileage pays one driver for the ticked trips at the usual or a past rate as a mileage expense per client paid personally by them under the mileage category to a Mileage vendor made on first use, paid back there and then from an account or later with Reimburse; Unbilled Time has Travel to bill beside the expenses at an editable price going on the client's invoice with their tickets and expenses, billing every client includes trips and Not billable writes them off; the invoice gets a Travel card with Add Trips and Take off Invoice, and deleting the line or invoice makes a trip to bill again; paid back and billed trips keep their distance, driver, vehicle, client and billable and can't be deleted, and deleting the mileage expense puts its trips back to pay; bulk Set Vehicle, Set Billable and Pay Mileage; the trip quick look shows what it pays back and bills and the mileage expense lists its trips; dashboard Trips to bill and Mileage to pay back, client overview Trips to bill and the Unbilled Time count include trips; export Vehicle, Paid Back and Billing columns; the Mileage report and dashboard chart follow the unit and the report rate starts at the mileage rate; demo data with recent trips in own vehicles and billable when trips bill; database 3.0.4 with trip_own_vehicle, trip_expense_id, trip_billable, trip_invoice_id, trip_invoice_item_id and the mileage and trip billing settings; CHANGELOG updated
2026-10-16 20:35:51 -04:00

288 lines
18 KiB
PHP

<?php
require_once $_SERVER['DOCUMENT_ROOT'] . '/config.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/functions.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/includes/check_login.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/includes/page_title.php';
// Perms
enforceUserPermission('module_client');
if (isset($_GET['client_id'])) {
$client_id = intval($_GET['client_id']);
// Client Access Check
enforceClientAccess();
$sql = mysqli_query($mysqli, "UPDATE clients SET client_accessed_at = NOW() WHERE client_id = $client_id");
$sql = mysqli_query(
$mysqli,
"SELECT client_abbreviation, client_archived_at, client_created_at, client_currency_code,
client_lead, client_name, client_net_terms, client_notes, client_rate, client_referral,
client_tax_id_number, client_type, client_website, contact_email, contact_extension,
contact_id, contact_mobile, contact_mobile_country_code, contact_name, contact_phone,
contact_phone_country_code, contact_photo, contact_primary, contact_title, location_address,
location_city, location_country, location_id, location_name, location_phone,
location_phone_country_code, location_primary, location_state, location_zip FROM clients
LEFT JOIN locations ON client_id = location_client_id AND location_primary = 1
LEFT JOIN contacts ON client_id = contact_client_id AND contact_primary = 1
WHERE client_id = $client_id"
);
if (mysqli_num_rows($sql) == 0) {
require_once $_SERVER['DOCUMENT_ROOT'] . '/includes/header.php';
echo "<center><h1 class='text-secondary mt-5'>Nothing to see here</h1><a class='btn btn-lg btn-secondary mt-3' href='javascript:history.back()'><i class='fa fa-fw fa-arrow-left'></i> Go Back</a></center>";
exit;
} else {
$row = mysqli_fetch_assoc($sql);
$client_name = escapeHtml($row['client_name']);
$client_is_lead = intval($row['client_lead']);
$client_type = escapeHtml($row['client_type']);
$client_website = escapeHtml($row['client_website']);
$client_referral = escapeHtml($row['client_referral']);
$client_currency_code = escapeHtml($row['client_currency_code']);
$client_net_terms = intval($row['client_net_terms']);
$client_tax_id_number = escapeHtml($row['client_tax_id_number']);
$client_abbreviation = escapeHtml($row['client_abbreviation']);
$client_initials = escapeHtml(clientInitials($row['client_abbreviation'], $row['client_name']));
$client_initials_tone = clientInitialsTone($client_id);
$client_rate = floatval($row['client_rate']);
$client_notes = escapeHtml($row['client_notes']);
$client_created_at = escapeHtml($row['client_created_at']);
$client_archived_at = escapeHtml($row['client_archived_at']);
$contact_id = intval($row['contact_id']);
$contact_name = escapeHtml($row['contact_name']);
$contact_photo = escapeHtml($row['contact_photo']);
$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_primary = intval($row['contact_primary']);
$location_id = intval($row['location_id']);
$location_name = escapeHtml($row['location_name']);
$location_address = escapeHtml($row['location_address']);
$location_city = escapeHtml($row['location_city']);
$location_state = escapeHtml($row['location_state']);
$location_zip = escapeHtml($row['location_zip']);
$location_country = escapeHtml($row['location_country']);
$location_full_address = formatAddress($location_address, $location_city, $location_state, $location_zip, $location_country, '<br>') ?: '-';
$location_phone_country_code = escapeHtml($row['location_phone_country_code']);
$location_phone = escapeHtml(formatPhoneNumber($row['location_phone'], $location_phone_country_code));
$location_primary = intval($row['location_primary']);
// Tab Title // No Sanitizing needed
$tab_title = $row['client_name'];
// Client Tags - raw rows, drawn by tagPills() (functions/tags.php)
$client_tags = [];
$sql_client_tags = mysqli_query($mysqli, "SELECT tags.tag_id, tag_name, tag_color, tag_icon FROM client_tags JOIN tags ON client_tags.tag_id = tags.tag_id WHERE client_id = $client_id ORDER BY tag_name ASC");
while ($row = mysqli_fetch_assoc($sql_client_tags)) {
$client_tags[] = $row;
}
/*
* Client badge counts and money totals.
*
* These feed the client side nav badges and the client header card, so
* every page in the client context pays for them before it renders a
* byte. They used to be forty separate queries, and none of the
* *_client_id columns they filter on carries an index, so each one was
* a full table scan - invoices alone was scanned eight times per page
* load, tickets twice, domains / certificates / software three times
* each.
*
* Same numbers, one query per table. Conditions that used to sit in a
* WHERE clause are conditional aggregates instead, so a table is read
* once and every figure taken from it comes out of that single pass.
* COALESCE is needed because SUM() over no rows is NULL where COUNT()
* was 0, and these are printed straight into the nav.
*
* Deliberately NOT merged any further: folding the remaining
* single-table counts into one SELECT of scalar subqueries would save
* round trips but not scans, and the scans are what costs. Indexes on
* (<entity>_client_id, <entity>_archived_at) are the other half of
* this and are a schema change, not a code one.
*/
// Invoices - money total plus every status count in one pass.
// The money total ignores invoice_archived_at, matching the original
// query; every count filters on it. Keeping both in one scan means the
// archived test has to move out of the WHERE and into the aggregates.
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT
COALESCE(SUM(CASE WHEN invoice_status NOT IN ('Draft', 'Cancelled', 'Non-Billable') THEN invoice_amount END), 0) AS invoice_amounts,
COALESCE(SUM(invoice_archived_at IS NULL), 0) AS num_invoices,
COALESCE(SUM(invoice_archived_at IS NULL AND invoice_status = 'Draft'), 0) AS num_invoices_draft,
COALESCE(SUM(invoice_archived_at IS NULL AND invoice_status = 'Sent'), 0) AS num_invoices_sent,
COALESCE(SUM(invoice_archived_at IS NULL AND invoice_status = 'Viewed'), 0) AS num_invoices_viewed,
COALESCE(SUM(invoice_archived_at IS NULL AND invoice_status = 'Partial'), 0) AS num_invoices_partial,
COALESCE(SUM(invoice_archived_at IS NULL AND invoice_status = 'Paid'), 0) AS num_invoices_paid,
COALESCE(SUM(invoice_archived_at IS NULL AND invoice_status IN ('Sent', 'Viewed', 'Partial')), 0) AS num_invoices_open
FROM invoices WHERE invoice_client_id = $client_id"));
$invoice_amounts = floatval($row['invoice_amounts']);
$num_invoices = intval($row['num_invoices']);
$num_invoices_draft = intval($row['num_invoices_draft']);
$num_invoices_sent = intval($row['num_invoices_sent']);
$num_invoices_viewed = intval($row['num_invoices_viewed']);
$num_invoices_partial = intval($row['num_invoices_partial']);
$num_invoices_paid = intval($row['num_invoices_paid']);
$num_invoices_open = intval($row['num_invoices_open']);
// Payments - amount paid and the payment half of the income count.
// The sum ignores payment_archived_at, the count does not, same as before.
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT
COALESCE(SUM(payment_amount), 0) AS amount_paid,
COALESCE(SUM(payment_archived_at IS NULL), 0) AS num_payments
FROM payments, invoices
WHERE payment_invoice_id = invoice_id AND invoice_client_id = $client_id"));
$amount_paid = floatval($row['amount_paid']);
$num_payments = intval($row['num_payments']);
$balance = $invoice_amounts - $amount_paid;
// Revenues that are not the far side of a transfer.
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(revenue_id) AS num
FROM revenues LEFT JOIN transfers ON transfer_revenue_id = revenue_id
WHERE transfer_id IS NULL AND revenue_archived_at IS NULL AND revenue_client_id = $client_id"));
$num_income = $num_payments + intval($row['num']);
// Recurring invoices - what the running ones bring in a month, and the
// count. The total keys off recurring_invoice_status, the count off
// recurring_invoice_archived_at, so neither test can sit in the WHERE.
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT
COALESCE(SUM(CASE WHEN recurring_invoice_status = 1 THEN " . recurringInvoiceMonthlySql() . " END), 0) AS recurring_monthly,
COALESCE(SUM(recurring_invoice_archived_at IS NULL), 0) AS num_recurring_invoices
FROM recurring_invoices WHERE recurring_invoice_client_id = $client_id"));
$recurring_monthly = floatval($row['recurring_monthly']);
$num_recurring_invoices = intval($row['num_recurring_invoices']);
// Billable tickets done and not invoiced yet, for Unbilled Time
$num_unbilled_tickets = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM tickets WHERE " . billingUnbilledSql() . " AND ticket_client_id = $client_id"))[0]);
// ...and their expenses to bill on, for those who can see expenses
$num_unbilled_expenses = lookupUserPermission('module_financial') >= 1 ? expenseUnbilledCount($client_id)['count'] : 0;
$num_unbilled_trips = lookupUserPermission('module_financial') >= 1 ? tripUnbilledCount($client_id)['count'] : 0;
// Get Credit Balance
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COALESCE(SUM(credit_amount), 0) AS credit_balance
FROM credits WHERE credit_client_id = $client_id"));
$credit_balance = floatval($row['credit_balance']);
// Tickets - active and closed in one pass.
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT
COALESCE(SUM(ticket_closed_at IS NULL AND ticket_status != 4), 0) AS num_active_tickets,
COALESCE(SUM(ticket_closed_at IS NOT NULL), 0) AS num_closed_tickets
FROM tickets WHERE ticket_archived_at IS NULL AND ticket_client_id = $client_id"));
$num_active_tickets = intval($row['num_active_tickets']);
$num_closed_tickets = intval($row['num_closed_tickets']);
// Domains - total plus both expiry buckets in one pass.
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT
COUNT(domain_id) AS num_domains,
COALESCE(SUM(domain_expire IS NOT NULL AND domain_expire < CURRENT_DATE + INTERVAL 45 DAY), 0) AS num_domains_expiring_warning,
COALESCE(SUM(domain_expire IS NOT NULL AND (domain_expire < CURRENT_DATE OR domain_expire < CURRENT_DATE + INTERVAL 7 DAY)), 0) AS num_domains_urgent
FROM domains WHERE domain_archived_at IS NULL AND domain_client_id = $client_id"));
$num_domains = intval($row['num_domains']);
$num_domains_expiring_warning = intval($row['num_domains_expiring_warning']);
$num_domains_urgent = intval($row['num_domains_urgent']);
// Certificates - total plus both expiry buckets in one pass.
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT
COUNT(certificate_id) AS num_certificates,
COALESCE(SUM(certificate_expire IS NOT NULL AND certificate_expire < CURRENT_DATE + INTERVAL 7 DAY), 0) AS num_certificates_expiring,
COALESCE(SUM(certificate_expire IS NOT NULL AND (certificate_expire < CURRENT_DATE OR certificate_expire < CURRENT_DATE + INTERVAL 1 DAY)), 0) AS num_certificates_expired
FROM certificates WHERE certificate_archived_at IS NULL AND certificate_client_id = $client_id"));
$num_certificates = intval($row['num_certificates']);
$num_certificates_expiring = intval($row['num_certificates_expiring']);
$num_certificates_expired = intval($row['num_certificates_expired']);
// Software - total plus both expiry buckets in one pass.
// The 45-day window is what the original query used; the comment above
// it said 90 days and was wrong.
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT
COUNT(software_id) AS num_software,
COALESCE(SUM(software_expire IS NOT NULL AND software_expire < CURRENT_DATE + INTERVAL 45 DAY), 0) AS num_software_expiring,
COALESCE(SUM(software_expire IS NOT NULL AND (software_expire < CURRENT_DATE OR software_expire < CURRENT_DATE + INTERVAL 7 DAY)), 0) AS num_software_expired
FROM software WHERE software_archived_at IS NULL AND software_client_id = $client_id"));
$num_software = intval($row['num_software']);
$num_software_expiring = intval($row['num_software_expiring']);
$num_software_expired = intval($row['num_software_expired']);
// One count each, one table each - nothing to fold together.
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(contact_id) AS num FROM contacts WHERE contact_archived_at IS NULL AND contact_client_id = $client_id"));
$num_contacts = intval($row['num']);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(location_id) AS num FROM locations WHERE location_archived_at IS NULL AND location_client_id = $client_id"));
$num_locations = intval($row['num']);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(asset_id) AS num FROM assets WHERE asset_archived_at IS NULL AND asset_client_id = $client_id"));
$num_assets = intval($row['num']);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(recurring_ticket_id) AS num FROM recurring_tickets WHERE recurring_ticket_client_id = $client_id AND recurring_ticket_paused_at IS NULL"));
$num_recurring_tickets = intval($row['num']);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(project_id) AS num FROM projects WHERE project_archived_at IS NULL AND project_completed_at IS NULL AND project_client_id = $client_id"));
$num_active_projects = intval($row['num']);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(service_id) AS num FROM services WHERE service_client_id = $client_id AND service_archived_at IS NULL"));
$num_services = intval($row['num']);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(vendor_id) AS num FROM vendors WHERE vendor_archived_at IS NULL AND vendor_client_id = $client_id"));
$num_vendors = intval($row['num']);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(credential_id) AS num FROM credentials WHERE credential_archived_at IS NULL AND credential_client_id = $client_id"));
$num_credentials = intval($row['num']);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(network_id) AS num FROM networks WHERE network_archived_at IS NULL AND network_client_id = $client_id"));
$num_networks = intval($row['num']);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(rack_id) AS num FROM racks WHERE rack_archived_at IS NULL AND rack_client_id = $client_id"));
$num_racks = intval($row['num']);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(quote_id) AS num FROM quotes WHERE quote_archived_at IS NULL AND quote_client_id = $client_id"));
$num_quotes = intval($row['num']);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(file_id) AS num FROM files WHERE file_archived_at IS NULL AND file_client_id = $client_id"));
$num_files = intval($row['num']);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(document_id) AS num FROM documents WHERE document_archived_at IS NULL AND document_client_id = $client_id"));
$num_documents = intval($row['num']);
// Shared links still to do something about - open, or a login sent back and not saved yet
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(*) AS num, COALESCE(SUM(item_type = 'Request' AND item_received_at IS NOT NULL), 0) AS received
FROM shared_items WHERE item_client_id = $client_id AND item_type IN (" . shareVisibleTypesSql() . ") AND " . shareLiveSql()));
$num_shares = intval($row['num']);
$num_shares_received = intval($row['received']);
// What is coming up - events still ahead, and series still running
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(event_id) AS num FROM calendar_events WHERE event_client_id = $client_id AND event_archived_at IS NULL
AND (COALESCE(event_end, event_start) >= CURDATE() OR (event_repeat <> '' AND (event_repeat_until IS NULL OR event_repeat_until >= CURDATE())))"));
$num_calendar_events = intval($row['num']);
// The trips this agent may see - all of them with Financial access, else their own
[$num_trips] = tripTotalsFor('trip_client_id', $client_id);
}
}
require_once $_SERVER['DOCUMENT_ROOT'] . '/includes/header.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/includes/top_nav.php';
require_once 'includes/client_side_nav.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/includes/inc_wrapper.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/includes/inc_alert_feedback.php';
require_once 'includes/inc_client_top_head.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/includes/filter_header.php';