Files
itflow/functions/client_onboarding.php
johnnyq 2311d1f9d8 Speed up Admin Audit Logs, Sign-ins and Users, Transactions, Income, Invoices, Projects and the invoice page on installs with years of history: database 3.6.6 adds logs (log_user_id, log_type, log_action), logs (log_type, log_action, log_user_id, log_ip, log_created_at) and tickets (ticket_invoice_id), each only where missing and in db.sql, leaving out one it cannot add with a warning in App Logs; Audit Logs drops SQL_CALC_FOUND_ROWS and counts its views with one GROUP BY log_type, log_action in the new auditLogViewCounts, which also hands back the types and actions in the view so auditLogKindsSql finds a sparse view or action on the index, and builds its who, client, type and action lists with auditLogDistinctSql in place of EXISTS and DISTINCT over the log, as the export window now does; Sign-ins reads only the new log_type index, counts unknown emails for the page's addresses in their own query and ends every sort on log_ip or log_user_id so tied rows keep one order; the new listNewestFirst in functions/app.php pages a newest-first list from the last month, year and four years back before the whole of it, used by Transactions without the running balance and by Income, which both take their count from their pill in place of SQL_CALC_FOUND_ROWS, with incomeUnionSql(false) leaving out the name joins for Income's counts when there is no search; Invoices replaces the LEFT JOIN of every payment grouped by invoice with a paid lookup for the page's invoices and the unpaid ones in the totals; the project lookups, through the new PROJECT_TICKETS_SQL, the client onboarding ticket counts and the expense refund lookups also say the column is > 0, so the database stays on the index where nearly every row has 0 and its statistics say a lookup finds every row, Projects having read 33.6 million rows with no ticket on a project; checked on an eight-year test install against the old code over 103 filter, sort and page combinations and a crawl of all 233 pages; add the changes to the 26.11.8 changelog section
2026-11-13 12:58:04 -05:00

314 lines
18 KiB
PHP

<?php
/*
* Client onboarding - taking a client on, a step at a time
* (agent/client_onboarding.php, its steps in agent/client_onboarding/, saved
* by agent/post/client_onboarding.php): the company, its locations, its
* people and their portal access, the agreement (a contract from a template,
* sent to sign if you like), billing (who is billed, their tax, and the
* agreement's recurring invoice), IT basics (domains, and vendors and
* licenses from templates) and a kickoff project from a project template.
*
* Each step saves as it goes - the company step makes the client - so it can
* be left and carried on from the client's overview. A step is done once it
* is saved here, or, for most, once the client has what it is about however
* it was added: a location, a contact, a contract, a recurring invoice, a
* vendor or license, a project. A step can be put off (Skip for now) and done
* later. Steps the agent's role can't do, or whose module is off, aren't
* there for them.
*
* The client overview shows how far it has got until every step is done or
* skipped, Finish is pressed on the summary, or its card is closed.
*/
/** The steps, in order - key => nav label, title, icon and what it is for. */
const CLIENT_ONBOARDING_STEPS = [
'company' => ['label' => 'Company', 'title' => 'The company', 'icon' => 'fa-building',
'about' => 'Who they are - the name you know them by, what they do, their website and how they found you.'],
'locations' => ['label' => 'Locations', 'title' => 'Locations', 'icon' => 'fa-map-marker-alt',
'about' => 'Where they work - their main office first, then any other sites you look after.'],
'people' => ['label' => 'People', 'title' => 'People', 'icon' => 'fa-users',
'about' => 'Who you deal with - the primary contact, who gets the invoices and who to call about technical things - and client portal access for them.'],
'agreement' => ['label' => 'Agreement', 'title' => 'Agreement', 'icon' => 'fa-file-contract',
'about' => 'The contract you look after them on, from one of your templates - its rates, net terms and SLAs are theirs while it is in force. Send it to them to sign, if you like.'],
'billing' => ['label' => 'Billing', 'title' => 'Billing', 'icon' => 'fa-file-invoice-dollar',
'about' => 'Who gets the invoices, their tax, and the recurring invoice that bills the agreement.'],
'it' => ['label' => 'IT basics', 'title' => 'IT basics', 'icon' => 'fa-network-wired',
'about' => 'Their domains, to watch for renewals, and the vendors and licenses most of your clients have - from your templates.'],
'kickoff' => ['label' => 'Kickoff', 'title' => 'Kickoff', 'icon' => 'fa-flag',
'about' => 'The onboarding project - its tickets raised from a project template, for your team to work through.'],
];
/** Has the 3.5.2 update run? Until it has, nothing here is shown or done. */
function clientOnboardingReady() {
global $mysqli;
static $ready = null;
if ($ready === null) {
if (defined('CURRENT_DATABASE_VERSION')) {
$version = CURRENT_DATABASE_VERSION;
} else {
$result = mysqli_query($mysqli, "SELECT config_current_database_version FROM settings WHERE company_id = 1");
$version = $result && ($row = mysqli_fetch_row($result)) ? (string) $row[0] : '0';
}
$ready = version_compare($version, '3.5.2', '>=');
}
return $ready;
}
/** May this agent onboard a client - the update run, and Clients at Modify, as adding one needs? */
function clientOnboardingCan() {
return clientOnboardingReady() && lookupUserPermission('module_client') >= 2;
}
/** Where a step is - the wizard for a client (or a new one, with no client), at $step ('done' for the summary). */
function clientOnboardingUrl($client_id = 0, $step = '') {
$query = array_filter(['client_id' => intval($client_id) ?: null, 'step' => $step !== '' ? $step : null]);
return '/agent/client_onboarding.php' . ($query ? '?' . http_build_query($query) : '');
}
/** A client's onboarding - or null when it was never onboarded. */
function clientOnboardingRow($client_id) {
global $mysqli;
$client_id = intval($client_id);
if (!$client_id || !clientOnboardingReady()) {
return null;
}
return mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT * FROM client_onboardings WHERE onboarding_client_id = $client_id LIMIT 1")) ?: null;
}
/** Start onboarding a client - once; logged the first time. Its row. */
function clientOnboardingStart($client_id) {
global $mysqli, $session_user_id, $session_name;
$client_id = intval($client_id);
mysqli_query($mysqli, "INSERT IGNORE INTO client_onboardings SET onboarding_client_id = $client_id, onboarding_started_by = " . intval($session_user_id));
if (mysqli_affected_rows($mysqli) === 1) {
logAudit("Client", "Onboard", "$session_name started onboarding client " . escapeSql((string) getFieldById('clients', $client_id, 'client_name')), $client_id, $client_id);
}
return clientOnboardingRow($client_id);
}
/** The steps this agent has, in order - those whose module is off, or their role can't do, left out. */
function clientOnboardingStepKeys() {
global $config_module_enable_accounting, $config_module_enable_itdoc, $config_module_enable_ticketing;
$keys = [];
foreach (array_keys(CLIENT_ONBOARDING_STEPS) as $key) {
if ($key === 'agreement' && !contractClientPickReady()) {
continue;
}
if ($key === 'billing' && !intval($config_module_enable_accounting)) {
continue;
}
if ($key === 'it' && !intval($config_module_enable_itdoc)) {
continue;
}
if ($key === 'kickoff' && (!intval($config_module_enable_ticketing) || lookupUserPermission('module_support') < 2)) {
continue;
}
$keys[] = $key;
}
return $keys;
}
/** A step list column, as keys. */
function clientOnboardingList($value) {
return array_values(array_filter(array_map('trim', explode(',', (string) $value))));
}
/**
* The contract the Agreement step made, still there - else the client's most
* recent one that is active or out to sign. Its row, or null.
*/
function clientOnboardingContract($client_id, $row = null) {
global $mysqli;
$client_id = intval($client_id);
$row = $row ?? clientOnboardingRow($client_id);
if (!contractsReady()) {
return null;
}
$made = intval($row['onboarding_contract_id'] ?? 0);
$id = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT contract_id FROM contracts WHERE contract_client_id = $client_id AND contract_archived_at IS NULL
AND (contract_id = $made OR contract_status IN ('Active', 'Draft'))
ORDER BY contract_id = $made DESC, contract_status = 'Active' DESC, contract_id DESC LIMIT 1"))[0] ?? 0);
return $id ? contractRow($id) : null;
}
/**
* The steps for a client, in order, each with whether it is done (saved here,
* or the client has what it is about), skipped and a line on where it stands
* - key => CLIENT_ONBOARDING_STEPS' fields + done, skipped, saved, status.
* With no client, the company step alone - the client is made there.
*/
function clientOnboardingSteps($client_id, $row = null) {
global $mysqli;
$client_id = intval($client_id);
$row = $row ?? clientOnboardingRow($client_id);
$saved = clientOnboardingList($row['onboarding_saved'] ?? '');
$skipped = clientOnboardingList($row['onboarding_skipped'] ?? '');
$client = $client_id ? mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT client_name, client_type, client_lead, client_created_at FROM clients WHERE client_id = $client_id")) : null;
$count = function ($sql) use ($mysqli) {
$result = mysqli_query($mysqli, $sql);
return $result ? intval(mysqli_fetch_row($result)[0] ?? 0) : 0;
};
$plural = function ($n, $one, $many = null) {
return $n . ' ' . ($n === 1 ? $one : ($many ?? $one . 's'));
};
$steps = [];
foreach (clientOnboardingStepKeys() as $key) {
if (!$client && $key !== 'company') {
continue;
}
$done = in_array($key, $saved, true);
$status = '';
switch ($key) {
case 'company':
$done = $client && intval($client['client_lead']) === 0;
$status = !$client ? 'Not added yet' : (intval($client['client_lead']) ? 'A lead - saving makes them a client'
: (trim((string) $client['client_type']) !== '' ? $client['client_type'] . ', a' : 'A') . ' client since ' . date('M j, Y', strtotime($client['client_created_at'])));
break;
case 'locations':
$n = $count("SELECT COUNT(*) FROM locations WHERE location_client_id = $client_id AND location_archived_at IS NULL");
$primary = $n ? mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT location_name, location_address, location_city FROM locations
WHERE location_client_id = $client_id AND location_archived_at IS NULL ORDER BY location_primary DESC, location_id ASC LIMIT 1")) : null;
$done = $done || $n > 0;
$where = $primary ? trim(implode(', ', array_filter([trim((string) $primary['location_address']), trim((string) $primary['location_city'])]))) : '';
$where = $where !== '' ? $where : ($primary ? $primary['location_name'] : '');
$status = $n ? $plural($n, 'location') . ($primary ? ' - ' . ($n > 1 ? "the primary at $where" : $where) : '') : 'None yet';
break;
case 'people':
$n = $count("SELECT COUNT(*) FROM contacts WHERE contact_client_id = $client_id AND contact_archived_at IS NULL");
$primary = $n ? (string) (mysqli_fetch_row(mysqli_query($mysqli, "SELECT contact_name FROM contacts WHERE contact_client_id = $client_id AND contact_archived_at IS NULL AND contact_primary = 1 LIMIT 1"))[0] ?? '') : '';
$done = $done || $n > 0;
$status = $n ? $plural($n, 'person', 'people') . ($primary !== '' ? " - $primary is the primary contact" : ' - no primary contact yet') : 'None yet';
break;
case 'agreement':
$contract = clientOnboardingContract($client_id, $row);
$done = $done || ($contract !== null);
if ($contract) {
$pending = contractSignaturesReady() && contractSignaturePendingFor(intval($contract['contract_id']));
$status = $contract['contract_name'] . ' - ' . ($contract['contract_status'] === 'Draft' ? ($pending ? 'a draft, out to sign' : 'a draft')
: strtolower($contract['contract_status']) . (!empty($contract['contract_start_date']) ? ' from ' . date('M j, Y', strtotime($contract['contract_start_date'])) : ''));
} else {
$status = 'No contract - billed on the defaults';
}
break;
case 'billing':
$recurring = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT recurring_invoice_id, recurring_invoice_prefix, recurring_invoice_number, recurring_invoice_amount,
recurring_invoice_frequency, recurring_invoice_currency_code, recurring_invoice_status
FROM recurring_invoices WHERE recurring_invoice_client_id = $client_id AND recurring_invoice_archived_at IS NULL
ORDER BY recurring_invoice_id = " . intval($row['onboarding_recurring_invoice_id'] ?? 0) . " DESC, recurring_invoice_status DESC, recurring_invoice_id DESC LIMIT 1"));
$done = $done || $recurring !== null;
$status = $recurring ? $recurring['recurring_invoice_prefix'] . $recurring['recurring_invoice_number'] . ' bills ' . billingMoney($recurring['recurring_invoice_amount'], $recurring['recurring_invoice_currency_code'])
. ' ' . strtolower(recurringInvoiceFrequencies()[$recurring['recurring_invoice_frequency']] ?? '') . (intval($recurring['recurring_invoice_status']) ? '' : ' - paused')
: ($done ? 'Billing details saved - no recurring invoice' : 'No recurring invoice');
break;
case 'it':
$domains = $count("SELECT COUNT(*) FROM domains WHERE domain_client_id = $client_id AND domain_archived_at IS NULL");
$vendors = $count("SELECT COUNT(*) FROM vendors WHERE vendor_client_id = $client_id AND vendor_archived_at IS NULL");
$licenses = $count("SELECT COUNT(*) FROM software WHERE software_client_id = $client_id AND software_archived_at IS NULL");
$done = $done || $vendors + $licenses > 0;
$status = $domains + $vendors + $licenses ? implode(', ', array_filter([$domains ? $plural($domains, 'domain') : '', $vendors ? $plural($vendors, 'vendor') : '', $licenses ? $plural($licenses, 'license') : ''])) : 'Nothing yet';
break;
case 'kickoff':
$project = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT project_id, project_prefix, project_number, project_name,
(SELECT COUNT(*) FROM tickets WHERE ticket_project_id = project_id AND ticket_project_id > 0) AS tickets
FROM projects WHERE project_client_id = $client_id AND project_archived_at IS NULL
ORDER BY project_id = " . intval($row['onboarding_project_id'] ?? 0) . " DESC, project_id DESC LIMIT 1"));
$done = $done || $project !== null;
$status = $project ? $project['project_prefix'] . $project['project_number'] . ' ' . $project['project_name'] . ' - ' . $plural(intval($project['tickets']), 'ticket') : 'No project yet';
break;
}
$steps[$key] = CLIENT_ONBOARDING_STEPS[$key] + ['done' => $done, 'skipped' => !$done && in_array($key, $skipped, true), 'saved' => in_array($key, $saved, true), 'status' => $status];
}
return $steps;
}
/** How far it has got - done (or skipped), of how many, the next step to do ('' when none is left) and whether it is finished. */
function clientOnboardingProgress(array $steps) {
$done = 0;
$next = '';
foreach ($steps as $key => $step) {
if ($step['done'] || $step['skipped']) {
$done++;
} elseif ($next === '') {
$next = $key;
}
}
return ['done' => $done, 'total' => count($steps), 'next' => $next, 'finished' => $next === ''];
}
/** The step after this one, in order - 'done' (the summary) after the last. */
function clientOnboardingAfter($key, array $steps) {
$keys = array_keys($steps);
$at = array_search($key, $keys, true);
return $at === false || !isset($keys[$at + 1]) ? 'done' : $keys[$at + 1];
}
/** Note a step saved, or put off ($kind saved or skipped) - or take that back with $on false. Saving one takes it out of skipped. */
function clientOnboardingMark($client_id, $key, $kind = 'saved', $on = true) {
global $mysqli;
$row = clientOnboardingRow($client_id) ?? clientOnboardingStart($client_id);
if (!$row || !isset(CLIENT_ONBOARDING_STEPS[$key]) || !in_array($kind, ['saved', 'skipped'], true)) {
return;
}
$lists = ['saved' => clientOnboardingList($row['onboarding_saved']), 'skipped' => clientOnboardingList($row['onboarding_skipped'])];
$lists[$kind] = $on ? array_values(array_unique(array_merge($lists[$kind], [$key]))) : array_values(array_diff($lists[$kind], [$key]));
if ($kind === 'saved' && $on) {
$lists['skipped'] = array_values(array_diff($lists['skipped'], [$key]));
}
mysqli_query($mysqli, "UPDATE client_onboardings SET onboarding_saved = '" . escapeSql(implode(',', $lists['saved'])) . "',
onboarding_skipped = '" . escapeSql(implode(',', $lists['skipped'])) . "' WHERE onboarding_id = " . intval($row['onboarding_id']));
}
/** Remember what a step made - contract, recurring_invoice or project, by id. */
function clientOnboardingMade($client_id, $what, $id) {
global $mysqli;
if (!in_array($what, ['contract', 'recurring_invoice', 'project'], true)) {
return;
}
mysqli_query($mysqli, "UPDATE client_onboardings SET onboarding_{$what}_id = " . intval($id) . " WHERE onboarding_client_id = " . intval($client_id));
}
/** Finish - from the summary. The overview stops showing it; the wizard is still there from the client's menu. */
function clientOnboardingFinish($client_id) {
global $mysqli;
mysqli_query($mysqli, "UPDATE client_onboardings SET onboarding_finished_at = IFNULL(onboarding_finished_at, NOW()) WHERE onboarding_client_id = " . intval($client_id));
return mysqli_affected_rows($mysqli) === 1;
}
/** Stop showing it on the client's overview. */
function clientOnboardingDismiss($client_id) {
global $mysqli;
mysqli_query($mysqli, "UPDATE client_onboardings SET onboarding_dismissed_at = NOW() WHERE onboarding_client_id = " . intval($client_id) . " AND onboarding_dismissed_at IS NULL");
}
/**
* What the client overview's card shows - ['steps', 'progress'] while the
* client is being onboarded: begun, not finished or closed, and a step still
* to do. Null otherwise, and for an agent who can't onboard.
*/
function clientOnboardingCard($client_id) {
if (!clientOnboardingCan()) {
return null;
}
$row = clientOnboardingRow($client_id);
if (!$row || $row['onboarding_finished_at'] || $row['onboarding_dismissed_at']) {
return null;
}
$steps = clientOnboardingSteps($client_id, $row);
$progress = clientOnboardingProgress($steps);
return $progress['finished'] ? null : ['steps' => $steps, 'progress' => $progress];
}
/** Where a client's onboarding stands, for its menu - none (never begun), open, or finished (finished, closed, or every step done or skipped). */
function clientOnboardingState($client_id) {
$row = clientOnboardingRow($client_id);
if (!$row) {
return 'none';
}
if ($row['onboarding_finished_at'] || $row['onboarding_dismissed_at'] || clientOnboardingProgress(clientOnboardingSteps($client_id, $row))['finished']) {
return 'finished';
}
return 'open';
}