mirror of
https://github.com/itflow-org/itflow
synced 2026-11-11 07:01:16 +00:00
559 lines
19 KiB
PHP
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);
|
|
}
|