Files
itflow/functions/format.php
johnnyq 422088fb60 Invoices, quotes and recurring invoices makeover stage 2: per user, per device and per license recurring lines that take their quantity from the client's contacts (all or tagged), devices (ticked types or tagged, lost/stolen/retired left out) or a license's seats (in use or bought), counted again for every invoice with an optional contract minimum, set from a Counted choice on the add row or the full Quantity from section in Edit Line with a live count, marked on each line and listed in a new Seats card with Update now when the client has changed, quarterly frequency alongside monthly and yearly counted as a third in MRR on the dashboard, client pages, client portal and reports, period words {month} {quarter} {year} {period} filled into scope, notes and lines, schedule card showing the period the next invoice covers, recurring_invoice_anchor_day so month-end schedules return to the 31st after a short month, nightly run and Invoice Now consolidated into recurringInvoiceGenerate() which claims the run so it is billed once, refreshes seat counts and takes taxes at current rates, nightly run now bills runs missed by up to 7 days, Invoice Now bills the next run early and moves the schedule on from that run instead of restarting from today, recurring email goes to non-archived primary and billing contacts once per address and stays a draft when outgoing mail is not set up. Fixes portal recurring card ignoring yearly-only clients and counting archived ones, edit recurring invoice resetting the billing day. Adds DB 2.9.1, recurring_invoice_sync and recurring_seat_count handlers, itflowBillingSeatForm(), and updates the 26.11 changelog
2026-10-16 13:09:59 -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" - 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 <frequency>). 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);
}