= 2; $show_tickets = $config_module_enable_ticketing == 1 && lookupUserPermission("module_support") >= 1; $can_add_ticket = $config_module_enable_ticketing == 1 && lookupUserPermission("module_support") >= 2; // Same gate as the Billing card on a client's own pages (inc_client_top_head.php) $show_billing = $config_module_enable_accounting == 1 && lookupUserPermission("module_financial") >= 1; // The contract in force for each client - the Contracts pages' gate (agent/contracts.php), with accounting on $show_contracts = $config_module_enable_accounting == 1 && contractsReady() && lookupUserPermission("module_sales") >= 1; // Starting one - New Contract for a client without one, or from a template for several at once $can_start_contract = $show_contracts && lookupUserPermission("module_sales") >= 2; $can_add_contract = $can_edit && $can_start_contract && contractTemplates(); /** * Builds a link to this page with filter overrides applied. Pass null to drop * a filter. Paging always resets - a page number from the previous filter set * means nothing once the result set changes. */ function clientsFilterUrl($overrides = []) { $params = $_GET; foreach ($overrides as $name => $value) { if ($value === null) { unset($params[$name]); } else { $params[$name] = $value; } } unset($params['page']); return escapeHtml('?' . http_build_query($params)); } // Chips describing what is currently filtering the list, built as each // filter is parsed. Each one renders with an x that drops just that filter. $active_filters = []; // Leads Filter if (isset($_GET['leads']) && $_GET['leads'] == 1) { $leads_filter = 1; $leads_query = "AND client_lead = 1"; } else { $leads_filter = 0; $leads_query = "AND client_lead = 0"; } // Archived - filter_header.php has already turned ?archived into $archive_query if ($archived) { $active_filters[] = ['label' => 'Showing', 'value' => 'Archived', 'drop' => 'archived']; } // Tags Filter if (isset($_GET['tags']) && is_array($_GET['tags']) && !empty($_GET['tags'])) { $tag_filter = implode(",", array_map('intval', $_GET['tags'])); $tag_query = "AND clients.client_id IN (SELECT client_tags.client_id FROM client_tags WHERE client_tags.tag_id IN ($tag_filter))"; $tag_names = []; $sql_tag_names = mysqli_query($mysqli, "SELECT tag_name FROM tags WHERE tag_id IN ($tag_filter) ORDER BY tag_name"); while ($row = mysqli_fetch_assoc($sql_tag_names)) { $tag_names[] = escapeHtml($row['tag_name']); } $active_filters[] = ['label' => 'Tags', 'value' => implode(', ', $tag_names), 'drop' => 'tags']; } else { $tag_filter = 0; $tag_query = ''; } // Industry Filter if (!empty($_GET['industry'])) { $industry_query = "AND clients.client_type = '" . escapeSql($_GET['industry']) . "'"; $industry_filter = escapeHtml($_GET['industry']); $active_filters[] = ['label' => 'Industry', 'value' => $industry_filter, 'drop' => 'industry']; } else { $industry_query = ''; $industry_filter = ''; } // Referral Filter if (!empty($_GET['referral'])) { $referral_query = "AND clients.client_referral = '" . escapeSql($_GET['referral']) . "'"; $referral_filter = escapeHtml($_GET['referral']); $active_filters[] = ['label' => 'Referral', 'value' => $referral_filter, 'drop' => 'referral']; } else { $referral_query = ''; $referral_filter = ''; } // Contract Filter - in force today: any, none, or one started from a template $contract_query = ''; $contract_filter = $show_contracts ? (string) ($_GET['contract'] ?? '') : ''; if ($contract_filter !== '') { $contract_in_force = contractInForceSql('cf'); if ($contract_filter === 'none') { $contract_query = "AND NOT EXISTS (SELECT 1 FROM contracts cf WHERE cf.contract_client_id = clients.client_id AND $contract_in_force)"; $contract_filter_label = 'None in force'; } elseif ($contract_filter === 'any') { $contract_query = "AND EXISTS (SELECT 1 FROM contracts cf WHERE cf.contract_client_id = clients.client_id AND $contract_in_force)"; $contract_filter_label = 'One in force'; } elseif (ctype_digit($contract_filter) && ($contract_filter_template = contractTemplateRow(intval($contract_filter)))) { $contract_query = "AND EXISTS (SELECT 1 FROM contracts cf WHERE cf.contract_client_id = clients.client_id AND cf.contract_template_id = " . intval($contract_filter) . " AND $contract_in_force)"; $contract_filter_label = 'From ' . $contract_filter_template['contract_template_name']; } else { $contract_filter = ''; } if ($contract_query !== '') { $active_filters[] = ['label' => 'Contract', 'value' => escapeHtml($contract_filter_label), 'drop' => 'contract']; } } // Created date $date_filter_active = $dtf !== '1970-01-01' || $dtt !== '2099-12-31'; if ($date_filter_active) { $active_filters[] = ['label' => 'Created', 'value' => escapeHtml("$dtf to $dtt"), 'drop' => ['canned_date', 'dtf', 'dtt']]; } if ($q !== '') { $active_filters[] = ['label' => 'Search', 'value' => stripslashes(escapeHtml($q)), 'drop' => 'q']; } // How many of the collapsed panel's filters are set - shown on the filter button $hidden_filter_count = count(array_filter([$tag_query, $industry_query, $referral_query, $contract_query, $date_filter_active])); /* * Sorting. Only these keys are accepted; anything else falls back to the * page default instead of reaching ORDER BY as an unknown column. Balance and * open tickets are worked out per client, so they sort on an expression. */ $balance_sql = "(SELECT COALESCE(SUM(invoice_amount), 0) FROM invoices WHERE invoice_client_id = clients.client_id AND invoice_status NOT IN ('Draft', 'Cancelled', 'Non-Billable')) - (SELECT COALESCE(SUM(payment_amount), 0) FROM payments JOIN invoices ON payment_invoice_id = invoice_id WHERE invoice_client_id = clients.client_id)"; $open_tickets_sql = "(SELECT COUNT(ticket_id) FROM tickets WHERE ticket_client_id = clients.client_id AND ticket_resolved_at IS NULL)"; $sortable = [ 'client_name' => 'client_name', 'contact_name' => 'contact_name', 'location_city' => 'location_city', 'client_accessed_at' => 'client_accessed_at', 'client_created_at' => 'client_created_at', ]; if ($show_billing) { $sortable['balance'] = $balance_sql; } if ($show_tickets) { $sortable['open_tickets'] = $open_tickets_sql; } if ($show_contracts) { // The name of the one in force that started last $sortable['contract'] = "(SELECT cs.contract_name FROM contracts cs WHERE cs.contract_client_id = clients.client_id AND " . contractInForceSql('cs') . " ORDER BY cs.contract_start_date DESC, cs.contract_id DESC LIMIT 1)"; } if (!isset($sortable[$sort])) { $sort = 'client_accessed_at'; } $order_by = $sortable[$sort]; // Sort link for a column header, plus the arrow when it is the active sort function clientsSortLink($column, $label) { global $sort, $disp, $order_icon; $url = clientsFilterUrl(['sort' => $column, 'order' => $sort === $column ? $disp : (in_array($column, ['client_name', 'contact_name', 'location_city', 'contract'], true) ? 'ASC' : 'DESC')]); return '' . $label . ($sort === $column ? " $order_icon" : '') . ''; } $client_scope = clientScopeSql('clients.client_id'); /* * One row per client. The primary contact and location are picked by id * rather than joined on the primary flag, so a client with two "primary" * contacts still lists once - which used to need a GROUP BY over every * column of three tables. */ $sql = mysqli_query( $mysqli, " SELECT SQL_CALC_FOUND_ROWS clients.client_id, client_name, client_type, client_abbreviation, client_currency_code, client_lead, client_archived_at, contact_id, contact_name, contact_phone, contact_phone_country_code, contact_extension, contact_mobile, contact_mobile_country_code, contact_email, contact_photo, location_id, location_address, location_city, location_state, location_zip, location_country FROM clients LEFT JOIN contacts ON contact_id = ( SELECT c.contact_id FROM contacts c WHERE c.contact_client_id = clients.client_id AND c.contact_primary = 1 ORDER BY c.contact_id LIMIT 1 ) LEFT JOIN locations ON location_id = ( SELECT l.location_id FROM locations l WHERE l.location_client_id = clients.client_id AND l.location_primary = 1 ORDER BY l.location_id LIMIT 1 ) WHERE (client_name LIKE '%$q%' OR client_abbreviation LIKE '%$q%' OR client_type LIKE '%$q%' OR client_referral LIKE '%$q%' OR contact_email LIKE '%$q%' OR contact_name LIKE '%$q%' OR contact_phone LIKE '%$phone_query%' OR contact_mobile LIKE '%$phone_query%' OR location_address LIKE '%$q%' OR location_city LIKE '%$q%' OR location_state LIKE '%$q%' OR location_zip LIKE '%$q%' OR location_country LIKE '%$q%' OR client_tax_id_number LIKE '%$q%' OR clients.client_id IN (SELECT client_tags.client_id FROM client_tags JOIN tags ON tags.tag_id = client_tags.tag_id WHERE tag_name LIKE '%$q%')) AND client_$archive_query AND DATE(client_created_at) BETWEEN '$dtf' AND '$dtt' $leads_query $client_scope $tag_query $industry_query $referral_query $contract_query ORDER BY $order_by $order, client_name ASC LIMIT $record_from, $record_to "); $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); $clients = []; while ($row = mysqli_fetch_assoc($sql)) { $clients[intval($row['client_id'])] = $row; } /* * Everything else a row shows, fetched for the whole page at once - a query * per lookup, not a query per lookup per row. */ $client_tags = []; $client_money = []; $client_tickets = []; $client_rates = []; $client_contracts = []; $client_onboarding_open = []; $can_onboard = $can_edit && clientOnboardingCan(); if ($clients) { $page_ids = implode(',', array_keys($clients)); // Onboarding begun and not finished - Carry on onboarding in the row's menu (functions/client_onboarding.php) if ($can_onboard) { $sql_onboarding = mysqli_query($mysqli, "SELECT onboarding_client_id FROM client_onboardings WHERE onboarding_client_id IN ($page_ids) AND onboarding_finished_at IS NULL AND onboarding_dismissed_at IS NULL"); while ($row = mysqli_fetch_row($sql_onboarding)) { $client_onboarding_open[intval($row[0])] = true; } } $sql_tags = mysqli_query($mysqli, "SELECT client_tags.client_id, tags.tag_id, tag_name, tag_description, tag_color, tag_icon FROM client_tags JOIN tags ON tags.tag_id = client_tags.tag_id WHERE client_tags.client_id IN ($page_ids) ORDER BY tag_name"); while ($row = mysqli_fetch_assoc($sql_tags)) { $client_tags[intval($row['client_id'])][] = $row; } if ($show_billing) { foreach (array_keys($clients) as $id) { $client_money[$id] = ['invoiced' => 0, 'paid' => 0, 'overdue' => 0, 'recurring' => 0]; } // Balance the way the client's own Billing card works it out: invoiced minus paid $sql_money = mysqli_query($mysqli, "SELECT invoice_client_id AS id, SUM(invoice_amount) AS v FROM invoices WHERE invoice_client_id IN ($page_ids) AND invoice_status NOT IN ('Draft', 'Cancelled', 'Non-Billable') GROUP BY invoice_client_id"); while ($row = mysqli_fetch_assoc($sql_money)) { $client_money[intval($row['id'])]['invoiced'] = floatval($row['v']); } $sql_money = mysqli_query($mysqli, "SELECT invoice_client_id AS id, SUM(payment_amount) AS v FROM payments JOIN invoices ON payment_invoice_id = invoice_id WHERE invoice_client_id IN ($page_ids) GROUP BY invoice_client_id"); while ($row = mysqli_fetch_assoc($sql_money)) { $client_money[intval($row['id'])]['paid'] = floatval($row['v']); } // What is still owed on unpaid invoices past their due date $sql_money = mysqli_query($mysqli, "SELECT invoice_client_id AS id, SUM(invoice_amount - COALESCE((SELECT SUM(payment_amount) FROM payments WHERE payment_invoice_id = invoice_id), 0)) AS v FROM invoices WHERE invoice_client_id IN ($page_ids) AND invoice_status IN ('Sent', 'Viewed', 'Partial') AND invoice_due < CURDATE() GROUP BY invoice_client_id"); while ($row = mysqli_fetch_assoc($sql_money)) { $client_money[intval($row['id'])]['overdue'] = floatval($row['v']); } // Monthly recurring - quarterly ones count as a third, yearly as a twelfth $sql_money = mysqli_query($mysqli, "SELECT recurring_invoice_client_id AS id, SUM(" . recurringInvoiceMonthlySql() . ") AS v FROM recurring_invoices WHERE recurring_invoice_client_id IN ($page_ids) AND recurring_invoice_status = 1 AND recurring_invoice_frequency IN ('month', 'quarter', 'year') GROUP BY recurring_invoice_client_id"); while ($row = mysqli_fetch_assoc($sql_money)) { $client_money[intval($row['id'])]['recurring'] = floatval($row['v']); } // The hourly rate the contract in force sets - a client has none of its own (functions/contract_billing.php) if (contractsReady()) { $rate_today = date('Y-m-d'); $sql_rates = mysqli_query($mysqli, "SELECT contract_client_id AS id, contract_rate_standard AS rate, contract_name FROM contracts WHERE contract_client_id IN ($page_ids) AND contract_status IN ('Active', 'Ended', 'Cancelled') AND contract_archived_at IS NULL AND contract_rate_standard > 0 AND contract_start_date IS NOT NULL AND contract_start_date <= '$rate_today' AND (contract_end_date IS NULL OR contract_end_date >= '$rate_today' OR (contract_status = 'Active' AND contract_auto_renew = 1)) ORDER BY contract_start_date DESC, contract_id DESC"); while ($row = mysqli_fetch_assoc($sql_rates)) { $client_rates[intval($row['id'])] = $client_rates[intval($row['id'])] ?? [floatval($row['rate']), contractPossessive($row['contract_name']) . ' rate']; } } // Else the client's own, before the 3.5.0 update - else the default rate $default_rate = billingDefaults()['rate']; foreach (array_keys($clients) as $id) { if (isset($client_rates[$id])) { continue; } $legacy = billingClientLegacyTerms($id); if ($legacy && $legacy['rate'] !== null) { $client_rates[$id] = [$legacy['rate'], "The client's rate"]; } elseif ($default_rate > 0) { $client_rates[$id] = [$default_rate, 'The default rate, from Admin > Settings > Defaults - no contract in force sets one', true]; } } } // Each client's contracts - the one in force that started last, how many more are, and failing that the next to start or a draft if ($show_contracts) { $contract_today = date('Y-m-d'); $sql_contracts = mysqli_query($mysqli, "SELECT contract_id, contract_client_id, contract_name, contract_type, contract_status, contract_start_date, contract_end_date, contract_auto_renew, contract_notice_days, contract_cancelled_at FROM contracts WHERE contract_client_id IN ($page_ids) AND contract_archived_at IS NULL AND contract_status IN ('Active', 'Draft', 'Ended', 'Cancelled') ORDER BY contract_start_date DESC, contract_id DESC"); while ($row = mysqli_fetch_assoc($sql_contracts)) { $id = intval($row['contract_client_id']); $client_contracts[$id] = $client_contracts[$id] ?? ['current' => null, 'more' => 0, 'next' => null]; if ($row['contract_status'] !== 'Draft' && contractInForceOn($row, $contract_today)) { if ($client_contracts[$id]['current']) { $client_contracts[$id]['more']++; } else { $client_contracts[$id]['current'] = $row; } } elseif ($row['contract_status'] === 'Draft' || ($row['contract_status'] === 'Active' && (string) $row['contract_start_date'] > $contract_today)) { // The soonest to start, else a draft - rows come latest start first $next = $client_contracts[$id]['next']; if (!$next || $row['contract_status'] === 'Active' || $next['contract_status'] === 'Draft') { $client_contracts[$id]['next'] = $row; } } } } if ($show_tickets) { $sql_tickets = mysqli_query($mysqli, "SELECT ticket_client_id AS id, COUNT(ticket_id) AS open, SUM(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)) AS breached FROM tickets WHERE ticket_client_id IN ($page_ids) AND ticket_resolved_at IS NULL GROUP BY ticket_client_id"); while ($row = mysqli_fetch_assoc($sql_tickets)) { $client_tickets[intval($row['id'])] = ['open' => intval($row['open']), 'breached' => intval($row['breached'])]; } } } // Clients / Leads counts for the header, in the archive state being shown $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COALESCE(SUM(client_lead = 0), 0) AS clients, COALESCE(SUM(client_lead = 1), 0) AS leads FROM clients WHERE client_$archive_query $client_scope")); $total_clients = intval($row['clients']); $total_leads = intval($row['leads']); ?>

Clients Client | Lead

" id="advancedFilter">
null]; ?> : Clear all
table-light"> $row) { $client_name = escapeHtml($row['client_name']); $client_type = escapeHtml($row['client_type']); $client_archived_at = escapeHtml($row['client_archived_at']); $client_currency_code = escapeHtml($row['client_currency_code'] ?: $session_company_currency); $client_initials = escapeHtml(clientInitials($row['client_abbreviation'], $row['client_name'])); $client_tone = clientInitialsTone($client_id); $contact_id = intval($row['contact_id']); $contact_name = escapeHtml($row['contact_name']); $contact_phone = escapeHtml(formatPhoneNumber($row['contact_phone'], $row['contact_phone_country_code'])); $contact_phone_digits = preg_replace('/[^0-9+]/', '', (string) $row['contact_phone']); $contact_extension = escapeHtml($row['contact_extension']); $contact_mobile = escapeHtml(formatPhoneNumber($row['contact_mobile'], $row['contact_mobile_country_code'])); $contact_mobile_digits = preg_replace('/[^0-9+]/', '', (string) $row['contact_mobile']); $contact_email = escapeHtml($row['contact_email']); $location_id = intval($row['location_id']); $location_street = escapeHtml($row['location_address']); $location_place = escapeHtml(formatAddress('', $row['location_city'], $row['location_state'], '', '')); $location_full = escapeHtml(formatAddress($row['location_address'], $row['location_city'], $row['location_state'], $row['location_zip'], $row['location_country'], ', ')); $tickets_open = $client_tickets[$client_id]['open'] ?? 0; $tickets_breached = $client_tickets[$client_id]['breached'] ?? 0; if ($show_billing) { $money = $client_money[$client_id]; $balance = $money['invoiced'] - $money['paid']; } ?> null, 'more' => 0, 'next' => null]; $contract_current = $contract_info['current']; $contract_next = $contract_info['next']; ?>
Recurring Actions
'text-reset']) ?> Add primary contact -
Add primary location -
"> -
ยท + more
None
-
0.004) { ?> 0.004) { ?>
overdue
0.004) { ?> /mo
/hr(default)' : '' ?>
-
$leads_filter ? 'leads' : 'clients', 'icon' => 'fa-user-friends', 'views' => ['leads'], 'text' => $leads_filter ? 'Prospects you are talking to, kept apart from your clients.' : 'Add the companies you look after, or import them all at once.', 'actions' => $can_edit ? array_merge( [['label' => $leads_filter ? 'New Lead' : 'New Client', 'modal' => 'modals/client/client_add.php' . ($leads_filter ? '?lead=1' : '')]], $leads_filter || !$can_onboard ? [] : [['label' => 'Onboard a client', 'href' => clientOnboardingUrl(), 'icon' => 'fa-route']], $leads_filter ? [] : [['label' => 'Import', 'modal' => 'modals/client/client_import.php', 'icon' => 'fa-upload']] ) : [], ]; ?>