= 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 "" . escapeHtml($none_label) . ""; } $user_avatar = escapeHtml($user_avatar); if ($user_avatar) { $avatar = ""; } else { $avatar = ""; } return "$avatar" . escapeHtml($user_name) . ""; } 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 * * 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" - nothing logged (NULL, '') is 0s [$h, $m, $s] = array_map('intval', array_pad(explode(':', (string) $time), 3, 0)); $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 values the UI offers - * month, quarter and year, each also a MySQL interval unit. A whitelist is the * only safe handling for a value that could be spliced into SQL unquoted * (INTERVAL 1 ). 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', 'quarter', '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); }