Files
itflow/functions/contact.php
johnnyq 234afd25fb Contacts overhaul: rebuilt contacts list with role tabs and counts, avatars, flag pills, hover copy on phone/mobile/email, inline location picker, linked asset/credential/license/ticket chips and tag filter; contact page rebuilt to match the asset page with header actions, Reach/Details/Quick notes cards and tabbed Assets, Credentials, Tickets, Recurring, Licenses, Documents, Files, Services and Notes; add and edit contact share one tabbed form (Details, Roles, Access, Notes) with field icons, role switches, password generator, duplicate email check and photo removal; contact quick look redone to match the asset quick look; contact avatars now used everywhere a contact appears - contact pickers, ticket header, contact card, client replies, ticket list, kanban, assets, clients list, client header, overview key contacts, credentials, documents, services, invoice/quote/statement recipients, license users and global search - with each contact keeping the same colour throughout; ticket contact pickers show the name with title and role underneath; fixes: permission checks on the add contact modal and contact page/list actions, contact location limited to the contact's own client on add/edit/bulk assign, contacts list no longer selects user account columns, archived assets and credentials left off the contact page and quick look, saving a contact with the client portal disabled no longer clears their portal login method, replacing a photo no longer tries to delete a directory, sort column whitelisted, list counts and tags batched instead of per row, recurring ticket menu markup, undefined index notices, contacts list no longer scrolls sideways on phones, Edit Service shows avatars on unselected contacts and assets, license user labels toggle their checkbox; new functions/contact.php helpers (contactAvatar, contactLink, contactFlagPills, contactPhoneLink, contactAvatarData, contactOptionDescription), shared contact_form.php, contact_location quick pick, removed unused assetContactAvatar and clientOverviewPersonInitials
2026-10-14 18:33:18 -04:00

191 lines
9.0 KiB
PHP

<?php
/*
* Contacts - how a contact is shown: their picture or initials, their flags
* (Primary, Important, Billing, Technical, portal access), their phone
* numbers, and the quick picker for their location. The contacts list, the
* contact page and the quick look are built from these, and so is every
* other place a contact turns up - a ticket, an asset, a client, the pickers
* (selectOptionContact() in functions/format.php) and the search - so a
* person looks the same everywhere.
*
* Everything here takes raw values from the database and escapes what it
* prints.
*/
/** The colour behind a contact's initials - picked by id, so it is the same on every page and in every picker. */
function contactAvatarTone($contact_id) {
return clientInitialsTone($contact_id);
}
/**
* Their photo, or their initials on a soft colour that stays the same for
* them (like the client avatars on the clients list). $size is '' (2.5rem),
* 'xs' (in front of a name in a line of text or a pill), 'sm', 'md' (beside
* a ticket reply, the size of the agents' pictures there) or 'lg'.
* Takes values raw or already escaped.
*/
function contactAvatar($client_id, $contact_id, $name, $photo, $size = '') {
$class = 'itflow-contact-avatar' . ($size ? " itflow-contact-avatar-$size" : '');
$photo = html_entity_decode((string) $photo, ENT_QUOTES, 'UTF-8');
if ($photo !== '') {
return '<img class="' . $class . '" src="../uploads/clients/' . intval($client_id) . '/' . escapeHtml(rawurlencode($photo)) . '" alt="" loading="lazy">';
}
$tone = contactAvatarTone($contact_id);
return '<span class="' . $class . ' bg-' . $tone . '-subtle text-' . $tone . '-emphasis" aria-hidden="true">' . escapeHtml(personInitials($name) ?: '?') . '</span>';
}
/**
* A contact wherever a page mentions one: their avatar, then their name,
* linked to their quick look - or to their page with 'page' => true.
* Other options: 'size' of the avatar ('xs' unless given), 'class' on the
* link, 'archived' to strike the name through.
* Takes values raw or already escaped.
*/
function contactLink($client_id, $contact_id, $name, $photo = '', $options = []) {
$client_id = intval($client_id);
$contact_id = intval($contact_id);
$name = html_entity_decode((string) $name, ENT_QUOTES, 'UTF-8');
$label = escapeHtml($name !== '' ? $name : 'Unnamed contact');
$archived = !empty($options['archived']);
$class = trim('text-truncate ' . ($options['class'] ?? '') . ($archived ? ' text-decoration-line-through text-body-secondary' : ''));
$title = $archived ? ' title="Archived contact"' : '';
$link = !empty($options['page'])
? "<a class=\"$class\" href=\"contact.php?client_id=$client_id&amp;contact_id=$contact_id\"$title>$label</a>"
: "<a class=\"ajax-modal $class\" href=\"#\" data-modal-size=\"lg\" data-modal-url=\"modals/contact/contact.php?id=$contact_id\"$title>$label</a>";
return '<span class="itflow-contact-ref">' . contactAvatar($client_id, $contact_id, $name, $photo, $options['size'] ?? 'xs') . $link . '</span>';
}
/** The quieter line under a contact in a picker: their title, and Primary or Technical. */
function contactOptionDescription($row) {
$role = !empty($row['contact_primary']) ? 'Primary' : (!empty($row['contact_technical']) ? 'Technical' : '');
return implode(' · ', array_filter([trim(html_entity_decode((string) ($row['contact_title'] ?? ''), ENT_QUOTES, 'UTF-8')), $role]));
}
/** For JSON (pickers, search): what contactAvatar() draws - the photo's URL, or their initials and colour. */
function contactAvatarData($client_id, $contact_id, $name, $photo) {
$photo = html_entity_decode((string) $photo, ENT_QUOTES, 'UTF-8');
if ($photo !== '') {
return ['image' => '/uploads/clients/' . intval($client_id) . '/' . rawurlencode($photo)];
}
return ['initials' => personInitials($name) ?: '?', 'tone' => contactAvatarTone($contact_id)];
}
/**
* How they log in to the client portal, or '' with no access: 'Password'
* or 'Microsoft'.
*/
function contactPortalAccess($contact_user_id, $auth_method) {
if (!intval($contact_user_id) || (string) $auth_method === '') {
return '';
}
return $auth_method === 'azure' ? 'Microsoft' : 'Password';
}
/**
* Their flags as pills: Primary (or Important when not primary - a primary
* contact is always important), Billing, Technical, and Portal when they can
* log in to the client portal.
*/
function contactFlagPills($row, $with_portal = true) {
$pills = [];
if (!empty($row['contact_primary'])) {
$pills[] = ['Primary', 'text-bg-success', 'The client\'s main contact'];
} elseif (!empty($row['contact_important'])) {
$pills[] = ['Important', 'bg-warning-subtle text-warning-emphasis', 'Pinned to the top of the list'];
}
if (!empty($row['contact_billing'])) {
$pills[] = ['Billing', 'bg-secondary-subtle text-secondary-emphasis', 'Receives invoices'];
}
if (!empty($row['contact_technical'])) {
$pills[] = ['Technical', 'bg-secondary-subtle text-secondary-emphasis', 'Technical contact'];
}
if ($with_portal) {
$portal = contactPortalAccess($row['contact_user_id'] ?? 0, $row['user_auth_method'] ?? '');
if ($portal) {
$pills[] = ['Portal', 'bg-info-subtle text-info-emphasis', "Client portal login ($portal)"];
}
}
$html = '';
foreach ($pills as [$label, $class, $title]) {
$html .= '<span class="badge rounded-pill fw-normal ' . $class . '" title="' . escapeHtml($title) . '">' . $label . '</span> ';
}
return rtrim($html);
}
/** A phone number as a tel: link, with its extension. '' when there is no number. */
function contactPhoneLink($phone, $country_code, $extension = '', $class = '') {
$phone = (string) $phone;
if ($phone === '') {
return '';
}
$display = escapeHtml(formatPhoneNumber($phone, $country_code));
$country_digits = preg_replace('/[^0-9]/', '', (string) $country_code);
$dial = escapeHtml(($country_digits !== '' ? "+$country_digits" : '') . preg_replace('/[^0-9]/', '', $phone));
$extension = escapeHtml($extension);
return '<a class="' . $class . '" href="tel:' . $dial . ($extension ? ",$extension" : '') . '">' . $display . '</a>'
. ($extension ? ' <span class="text-body-secondary">x' . $extension . '</span>' : '');
}
/*
* Quick picker (js/quick_pick.js) for a contact's location - the same picker
* the assets use, with the contact's client as the scope.
*/
/** The contact and their location, by id. */
function contactQuickRow($contact_id) {
global $mysqli;
return mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT contact_id, contact_name, contact_client_id, contact_location_id,
location_name, location_archived_at
FROM contacts
LEFT JOIN locations ON location_id = contact_location_id
WHERE contact_id = " . intval($contact_id))) ?: null;
}
/** What the location picker shows - the asset location pill, fed the contact's row. */
function contactLocationHtml($row, $style = 'list') {
return assetQuickHtml('asset_location', [
'asset_client_id' => $row['contact_client_id'] ?? 0,
'asset_location_id' => $row['contact_location_id'] ?? 0,
'location_name' => $row['location_name'] ?? '',
'location_archived_at' => $row['location_archived_at'] ?? null,
], $style);
}
/** Move a contact to one of their client's locations (0 for none), as Edit Contact would. Returns [ok, plain-text message]. */
function contactQuickSetLocation($contact_id, $value) {
global $mysqli, $session_name;
$row = contactQuickRow($contact_id);
if (!$row) {
return [false, 'That contact no longer exists.'];
}
$contact_id = intval($row['contact_id']);
$client_id = intval($row['contact_client_id']);
$location_id = assetClientOwnedId('location', $value, $client_id);
if (intval($value) && !$location_id) {
return [false, 'That location is not one of this client\'s.'];
}
if ($location_id === intval($row['contact_location_id'])) {
return [true, ''];
}
$location_name = $location_id ? (string) mysqli_fetch_row(mysqli_query($mysqli, "SELECT location_name FROM locations WHERE location_id = $location_id"))[0] : '';
mysqli_query($mysqli, "UPDATE contacts SET contact_location_id = $location_id WHERE contact_id = $contact_id");
$contact_name = escapeSql($row['contact_name']);
logAudit("Contact", "Edit", $location_id
? "$session_name set the location of contact $contact_name to " . escapeSql($location_name)
: "$session_name removed the location of contact $contact_name", $client_id, $contact_id);
return [true, $location_id ? "Moved to $location_name" : 'Location removed'];
}
/** What quick_pick.js redraws after a change. */
function contactQuickRecord($contact_id) {
$row = contactQuickRow($contact_id);
return [
'current' => (string) intval($row['contact_location_id']),
'html' => ['list' => contactLocationHtml($row, 'list'), 'detail' => contactLocationHtml($row, 'detail')],
];
}