Files
itflow/functions/format.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

559 lines
19 KiB
PHP

<?php
// String, number, date & time formatting helpers
// Split from the former monolithic functions.php
function initials($string) {
if (!empty($string)) {
$return = '';
foreach (explode(' ', $string) as $word) {
$return .= mb_strtoupper($word[0], 'UTF-8'); // Use mb_strtoupper for UTF-8 support
}
$return = substr($return, 0, 2);
return $return;
}
}
function truncate($text, $chars) {
if (strlen($text) <= $chars) {
return $text;
}
$text = $text . " ";
$text = substr($text, 0, $chars);
$lastSpacePos = strrpos($text, ' ');
if ($lastSpacePos !== false) {
$text = substr($text, 0, $lastSpacePos);
}
return $text . "...";
}
/**
* A number for reading.
*
* $show_country_code defaults to TRUE: a phone number on screen, in a PDF, in
* an email or in an export is being read by a human who may need to dial it,
* and a bare national number is ambiguous the moment a client is abroad. The
* dial code is only prepended when one is actually stored, so records without
* one look exactly as they did.
*
* Pass false where the output feeds a form INPUT - intl-tel-input runs in
* separateDialCode mode, so its dropdown owns the code and the field must hold
* the national number alone. Those are the only call sites that should.
*/
function formatPhoneNumber($phoneNumber, $country_code = '', $show_country_code = true) {
// Remove all non-digit characters
$digits = preg_replace('/\D/', '', $phoneNumber ?? '');
$formatted = '';
// If no digits at all, fallback early
if (strlen($digits) === 0) {
return $phoneNumber;
}
// Helper function to safely check the first digit
$startsWith = function($str, $char) {
return isset($str[0]) && $str[0] === $char;
};
switch ($country_code) {
case '1': // USA/Canada
if (strlen($digits) === 10) {
$formatted = '(' . substr($digits, 0, 3) . ') ' . substr($digits, 3, 3) . '-' . substr($digits, 6);
}
break;
case '44': // UK
if ($startsWith($digits, '0')) {
$digits = substr($digits, 1);
}
if (strlen($digits) === 10) {
$formatted = '0' . substr($digits, 0, 4) . ' ' . substr($digits, 4, 3) . ' ' . substr($digits, 7);
}
break;
case '61': // Australia
if ($startsWith($digits, '0')) {
$digits = substr($digits, 1);
}
if (strlen($digits) === 9) {
$formatted = '0' . substr($digits, 0, 4) . ' ' . substr($digits, 4, 3) . ' ' . substr($digits, 7);
}
break;
case '91': // India
if (strlen($digits) === 10) {
$formatted = substr($digits, 0, 5) . ' ' . substr($digits, 5);
}
break;
case '81': // Japan
if ($startsWith($digits, '0')) {
$digits = substr($digits, 1);
}
if (strlen($digits) >= 9 && strlen($digits) <= 10) {
$formatted = '0' . substr($digits, 0, 2) . '-' . substr($digits, 2, 4) . '-' . substr($digits, 6);
}
break;
case '49': // Germany
if ($startsWith($digits, '0')) {
$digits = substr($digits, 1);
}
if (strlen($digits) >= 10) {
$formatted = '0' . substr($digits, 0, 3) . ' ' . substr($digits, 3);
}
break;
case '33': // France
if ($startsWith($digits, '0')) {
$digits = substr($digits, 1);
}
if (strlen($digits) === 9) {
$formatted = '0' . implode(' ', str_split($digits, 2));
}
break;
case '34': // Spain
if (strlen($digits) === 9) {
$formatted = substr($digits, 0, 3) . ' ' . substr($digits, 3, 3) . ' ' . substr($digits, 6);
}
break;
case '39': // Italy
if ($startsWith($digits, '0')) {
$digits = substr($digits, 1);
}
$formatted = '0' . implode(' ', str_split($digits, 3));
break;
case '55': // Brazil
if (strlen($digits) === 11) {
$formatted = '(' . substr($digits, 0, 2) . ') ' . substr($digits, 2, 5) . '-' . substr($digits, 7);
}
break;
case '7': // Russia
if ($startsWith($digits, '8')) {
$digits = substr($digits, 1);
}
if (strlen($digits) === 10) {
$formatted = '8 (' . substr($digits, 0, 3) . ') ' . substr($digits, 3, 3) . '-' . substr($digits, 6, 2) . '-' . substr($digits, 8);
}
break;
case '86': // China
if (strlen($digits) === 11) {
$formatted = substr($digits, 0, 3) . ' ' . substr($digits, 3, 4) . ' ' . substr($digits, 7);
}
break;
case '82': // South Korea
if (strlen($digits) === 11) {
$formatted = substr($digits, 0, 3) . '-' . substr($digits, 3, 4) . '-' . substr($digits, 7);
}
break;
case '62': // Indonesia
if (!$startsWith($digits, '0')) {
$digits = '0' . $digits;
}
if (strlen($digits) === 12) {
$formatted = substr($digits, 0, 4) . ' ' . substr($digits, 4, 4) . ' ' . substr($digits, 8);
}
break;
case '63': // Philippines
if (strlen($digits) === 11) {
$formatted = substr($digits, 0, 4) . ' ' . substr($digits, 4, 3) . ' ' . substr($digits, 7);
}
break;
case '234': // Nigeria
if (!$startsWith($digits, '0')) {
$digits = '0' . $digits;
}
if (strlen($digits) === 11) {
$formatted = substr($digits, 0, 4) . ' ' . substr($digits, 4, 3) . ' ' . substr($digits, 7);
}
break;
case '27': // South Africa
if (strlen($digits) >= 9 && strlen($digits) <= 10) {
$formatted = substr($digits, 0, 3) . ' ' . substr($digits, 3, 3) . ' ' . substr($digits, 6);
}
break;
case '971': // UAE
if (strlen($digits) === 9) {
$formatted = substr($digits, 0, 3) . ' ' . substr($digits, 3, 3) . ' ' . substr($digits, 6);
}
break;
default:
// fallback — do nothing, use raw digits later
break;
}
if (!$formatted) {
$formatted = $digits ?: $phoneNumber;
}
return $show_country_code && $country_code ? "+$country_code $formatted" : $formatted;
}
function formatAddress($address, $city, $state, $zip, $country = '', $separator = "\n") {
$address = trim((string)$address);
$city = trim((string)$city);
$state = trim((string)$state);
$zip = trim((string)$zip);
$country = trim((string)$country);
// "City, ST 15212" - the comma only appears when something follows the city
$region = trim(implode(' ', array_filter([$state, $zip], 'strlen')));
if ($city !== '' && $region !== '') {
$region = "$city, $region";
} elseif ($city !== '') {
$region = $city;
}
// Empty parts drop out entirely rather than leaving stray separators
return implode($separator, array_filter([$address, $region, $country], 'strlen'));
}
/**
* The agent a ticket is assigned to, as a pill: their avatar (or their initials
* on a grey circle) then their name. Unassigned keeps the same shape with a
* user-slash circle - red while the ticket still needs someone, muted once it
* doesn't. Takes the RAW user columns and escapes them itself.
* Bootstrap 5.3 utilities and Font Awesome only - no custom CSS.
*/
function formatAssignee($user_id, $user_name, $user_avatar, $highlight_unassigned = true, $none_label = 'Unassigned') {
$pill = "badge rounded-pill d-inline-flex align-items-center align-middle gap-1 p-1 pe-2";
$user_id = intval($user_id);
if (!$user_id) {
$tone = $highlight_unassigned ? 'danger' : 'secondary';
$colors = $highlight_unassigned ? 'text-danger-emphasis bg-danger-subtle' : 'text-body-secondary bg-body-secondary';
return "<span class='$pill $colors'><span class='position-relative d-inline-flex' aria-hidden='true'><i class='fas fa-circle fs-5 text-$tone'></i><i class='fas fa-user-slash position-absolute top-50 start-50 translate-middle small text-white'></i></span>" . escapeHtml($none_label) . "</span>";
}
$user_avatar = escapeHtml($user_avatar);
if ($user_avatar) {
$avatar = "<img src='/uploads/users/$user_id/$user_avatar' alt='' width='20' height='20' class='rounded-circle object-fit-cover'>";
} else {
$avatar = "<span class='position-relative d-inline-flex' aria-hidden='true'><i class='fas fa-circle fs-5 text-secondary'></i><span class='position-absolute top-50 start-50 translate-middle small text-white'><small>" . escapeHtml(initials($user_name)) . "</small></span></span>";
}
return "<span class='$pill text-body-emphasis bg-body-secondary'>$avatar" . escapeHtml($user_name) . "</span>";
}
function timeAgo($datetime) {
if (is_null($datetime)) {
return "-";
}
$time = strtotime($datetime);
$difference = $time - time(); // Changed to handle future dates
if ($difference == 0) {
return 'right now';
}
$isFuture = $difference > 0; // Check if the date is in the future
$difference = abs($difference); // Absolute value for calculation
$timeRules = array(
31536000 => 'year',
2592000 => 'month',
604800 => 'week',
86400 => 'day',
3600 => 'hour',
60 => 'minute',
1 => 'second'
);
foreach ($timeRules as $secs => $str) {
$div = $difference / $secs;
if ($div >= 1) {
$t = round($div);
$timeStr = $t . ' ' . $str . ($t > 1 ? 's' : '');
return $isFuture ? 'in ' . $timeStr : $timeStr . ' ago';
}
}
}
function shortenClientName($client) {
// Pre-process by removing any non-alphanumeric characters except for certain punctuations.
$client = html_entity_decode($client); // Decode any HTML entities
$client = str_replace("'", "", $client); // Removing all occurrences of '
$cleaned = preg_replace('/[^a-zA-Z0-9&]+/', ' ', $client);
// Break into words.
$words = explode(' ', trim($cleaned));
$shortened = '';
// If there's only one word.
if (count($words) == 1) {
$word = $words[0];
if (strlen($word) <= 3) {
return strtoupper($word);
}
// Prefer starting and ending characters.
$shortened = $word[0] . substr($word, -2);
} else {
// Less weightage to common words.
$commonWords = ['the', 'of', 'and'];
foreach ($words as $word) {
if (!in_array(strtolower($word), $commonWords) || strlen($shortened) < 2) {
$shortened .= $word[0];
}
}
// If there are still not enough characters, take from the last word.
while (strlen($shortened) < 3 && !empty($word)) {
$shortened .= substr($word, 1, 1);
$word = substr($word, 1);
}
}
return strtoupper(substr($shortened, 0, 3));
}
/**
* Up to three letters for a client's initials tile - their abbreviation when
* they have one, otherwise one made from the name. Raw: escape at output.
*/
function clientInitials($abbreviation, $name) {
$source = trim((string) $abbreviation) ?: shortenClientName((string) $name);
return mb_strtoupper(mb_substr($source, 0, 3));
}
/** The colour of a client's initials tile, picked by id so it is the same on every page. */
function clientInitialsTone($client_id) {
$tones = ['primary', 'success', 'info', 'warning', 'secondary'];
return $tones[intval($client_id) % count($tones)];
}
/*
* Option extras for the select picker (js/itflow_select.js): data-*
* attributes that put a picture, initials, a colour or an icon in front of
* an <option>, and an optional second line under it.
*
* <option value="<?= $id ?>"<?= selectOptionClient($id, $name, $abbr) ?>><?= $name ?></option>
*
* Takes values raw or already escaped - they are decoded and escaped again
* here, so either way the attribute comes out right.
*/
function selectOptionData(array $data) {
$html = '';
foreach ($data as $name => $value) {
$value = html_entity_decode((string) $value, ENT_QUOTES, 'UTF-8');
if ($value !== '') {
$html .= ' data-' . $name . '="' . escapeHtml($value) . '"';
}
}
return $html;
}
/** Up to two initials for a person's name - "Marla Whitfield" is MW. */
function personInitials($name) {
$words = preg_split('/\s+/u', trim(html_entity_decode((string) $name, ENT_QUOTES, 'UTF-8')), -1, PREG_SPLIT_NO_EMPTY);
$initials = '';
foreach (array_slice($words, 0, 2) as $word) {
$initials .= mb_strtoupper(mb_substr($word, 0, 1));
}
return $initials;
}
/** A client - its coloured initials tile, the same as the client list and side nav. */
function selectOptionClient($client_id, $client_name, $client_abbreviation = '') {
return selectOptionData([
'initials' => clientInitials(html_entity_decode((string) $client_abbreviation, ENT_QUOTES, 'UTF-8'), html_entity_decode((string) $client_name, ENT_QUOTES, 'UTF-8')),
'tone' => clientInitialsTone($client_id)
]);
}
/** An agent - their avatar, or their initials in a circle. */
function selectOptionUser($user_id, $user_name, $user_avatar = '') {
$user_avatar = html_entity_decode((string) $user_avatar, ENT_QUOTES, 'UTF-8');
if ($user_avatar !== '') {
return selectOptionData(['image' => '/uploads/users/' . intval($user_id) . '/' . rawurlencode($user_avatar)]);
}
return selectOptionData(['initials' => personInitials($user_name), 'shape' => 'circle', 'tone' => 'secondary']);
}
/**
* A contact - their photo, or their initials on the same soft colour as their
* avatar everywhere else (contactAvatar() in functions/contact.php), with
* their title underneath. Without $contact_id the initials are grey.
*/
function selectOptionContact($client_id, $contact_name, $contact_photo = '', $contact_title = '', $contact_id = 0) {
$contact_photo = html_entity_decode((string) $contact_photo, ENT_QUOTES, 'UTF-8');
$data = $contact_photo !== ''
? ['image' => '/uploads/clients/' . intval($client_id) . '/' . rawurlencode($contact_photo)]
: ['initials' => personInitials($contact_name), 'shape' => 'circle', 'tone' => intval($contact_id) ? contactAvatarTone($contact_id) : 'secondary', 'soft' => '1'];
$data['description'] = $contact_title;
return selectOptionData($data);
}
/** A tag, category or status - its colour, and its icon if it has one. */
function selectOptionColor($color, $icon = '') {
// A hex value, or a CSS colour name - categories store names like "lightblue"
$color = preg_match('/^(#[0-9a-f]{3,8}|[a-z]{3,24})$/i', trim((string) $color)) ? trim($color) : '';
$icon = trim(preg_replace('/[^a-z0-9 -]/', '', strtolower((string) $icon)));
if ($icon !== '' && strpos($icon, 'fa-') === false) {
$icon = "fas fa-$icon";
}
return selectOptionData(['color' => $color, 'icon' => $icon]);
}
/** A ticket priority - the colour its badge has in the ticket lists. */
function selectOptionPriority($priority) {
$colors = ['Urgent' => 'var(--bs-emphasis-color)', 'High' => 'var(--bs-danger)', 'Medium' => 'var(--bs-warning)', 'Low' => 'var(--bs-info)'];
return selectOptionData(['color' => $colors[html_entity_decode((string) $priority)] ?? '']);
}
/** An asset - the icon for its type, with make and model underneath. */
function selectOptionAsset($asset_type, $description = '') {
return selectOptionData(['icon' => 'fas fa-' . getAssetIcon(html_entity_decode((string) $asset_type, ENT_QUOTES, 'UTF-8')), 'description' => $description]);
}
function roundUpToNearestMultiple($n, $increment = 1000) {
return (int) ($increment * ceil($n / $increment));
}
function roundToNearest15Min($time) {
// Validate the input time format
if (!preg_match('/^(\d{2}):(\d{2}):(\d{2})$/', $time, $matches)) {
return false; // or throw an exception
}
// Extract hours, minutes, and seconds from the matched time string
list(, $hours, $minutes, $seconds) = $matches;
// Convert everything to seconds for easier calculation
$totalSeconds = ($hours * 3600) + ($minutes * 60) + $seconds;
// Calculate the remainder when divided by 900 seconds (15 minutes)
$remainder = $totalSeconds % 900;
if ($remainder > 450) { // If remainder is more than 7.5 minutes (450 seconds), round up
$totalSeconds += (900 - $remainder);
} else { // Else round down
$totalSeconds -= $remainder;
}
// Convert total seconds to decimal hours
$decimalHours = $totalSeconds / 3600;
// Return the decimal hours
return number_format($decimalHours, 2);
}
function formatDuration($time) {
// expects "HH:MM:SS"
[$h, $m, $s] = array_map('intval', explode(':', $time));
$parts = [];
if ($h > 0) $parts[] = $h . 'h';
if ($m > 0) $parts[] = $m . 'm';
// show seconds only if under 1 minute total OR if nothing else exists
if ($h == 0 && $m == 0) {
$parts[] = $s . 's';
}
return implode(' ', $parts);
}
function validateDate($date) {
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
return $date;
}
return date('Y-m-d'); // Fallback
}
/*
* Validates a recurring invoice frequency against the only two values the UI
* offers. This value is spliced into SQL *unquoted* (INTERVAL 1 <frequency>),
* where escaping does nothing - a whitelist is the only safe handling. Apply it
* at every write path AND at the read side before use, so a row planted before
* this fix can't fire.
*/
function validateRecurringFrequency($frequency) {
if (in_array($frequency, ['month', 'year'], true)) {
return $frequency;
}
return 'month'; // Fallback
}
/*
* Formats bytes into human readable file sizes
*/
function formatBytes($bytes, $precision = 2) {
$units = array('B', 'KB', 'MB', 'GB', 'TB');
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
$bytes /= 1024;
}
return round($bytes, $precision) . ' ' . $units[$i];
}
function secondsToTime($inputSeconds) {
$inputSeconds = floor($inputSeconds);
$secondsInAMinute = 60;
$secondsInAnHour = 60 * $secondsInAMinute;
$secondsInADay = 24 * $secondsInAnHour;
// Extract days
$days = floor($inputSeconds / $secondsInADay);
// Extract hours
$hourSeconds = $inputSeconds % $secondsInADay;
$hours = floor($hourSeconds / $secondsInAnHour);
// Extract minutes
$minuteSeconds = $hourSeconds % $secondsInAnHour;
$minutes = floor($minuteSeconds / $secondsInAMinute);
// Extract the remaining seconds
$remainingSeconds = $minuteSeconds % $secondsInAMinute;
$seconds = ceil($remainingSeconds);
// Format and return
$timeParts = [];
$sections = [
'day' => (int)$days,
'hour' => (int)$hours,
'minute' => (int)$minutes,
'second' => (int)$seconds,
];
foreach ($sections as $name => $value) {
if ($value > 0) {
$timeParts[] = $value. ' '.$name.($value == 1 ? '' : 's');
}
}
return implode(', ', $timeParts);
}