'fa-sync-alt', 'Device' => 'fa-desktop', 'User' => 'fa-user', 'Site' => 'fa-building', 'Concurrent' => 'fa-users', 'Perpetual' => 'fa-infinity', 'Trial' => 'fa-hourglass-half', 'Usage-based' => 'fa-tachometer-alt', 'Open Source' => 'fa-code-branch', ]; /** The icon for a license type, typed or from the list. */ function softwareLicenseTypeIcon($license_type) { $license_type = html_entity_decode((string) $license_type, ENT_QUOTES, 'UTF-8'); return SOFTWARE_LICENSE_TYPES[$license_type] ?? 'fa-certificate'; } /** * The icon and colour for a kind of software (Admin > Categories, software * types). An icon set on the category wins; otherwise it is read from the * name, so "Security Software" is a red shield and "Software as a Service" a * cloud. The colours are the asset tones (css: .itflow-asset-tone-*). */ function softwareTypeStyle($software_type) { global $mysqli; static $category_icons = null; if ($category_icons === null) { $category_icons = []; $sql = mysqli_query($mysqli, "SELECT category_name, category_icon FROM categories WHERE category_type = 'software_type' AND category_icon IS NOT NULL AND category_icon != ''"); while ($row = mysqli_fetch_assoc($sql)) { if (preg_match('/^fa-[a-z0-9-]+$/', $row['category_icon'])) { $category_icons[strtolower($row['category_name'])] = $row['category_icon']; } } } $type = strtolower(trim(html_entity_decode((string) $software_type, ENT_QUOTES, 'UTF-8'))); // First match wins - "Operating System" before "System Software" $styles = [ 'saas' => ['fa-cloud', 'wireless'], 'service' => ['fa-cloud', 'wireless'], 'cloud' => ['fa-cloud', 'wireless'], 'productivity' => ['fa-briefcase', 'workstation'], 'office' => ['fa-briefcase', 'workstation'], 'security' => ['fa-shield-alt', 'firewall'], 'antivirus' => ['fa-shield-alt', 'firewall'], 'backup' => ['fa-hdd', 'network'], 'web' => ['fa-globe', 'network'], 'mobile' => ['fa-mobile-alt', 'phone'], 'operating' => ['fa-layer-group', 'virtual'], 'system' => ['fa-cogs', 'server'], 'desktop' => ['fa-window-maximize', 'peripheral'], ]; $style = ['fa-cube', 'other']; foreach ($styles as $needle => $candidate) { if ($type !== '' && strpos($type, $needle) !== false) { $style = $candidate; break; } } if (isset($category_icons[$type])) { $style[0] = $category_icons[$type]; } return ['icon' => $style[0], 'tone' => $style[1]]; } /** The tile for a license - its software type's icon on that type's colour. $size '' | 'sm' | 'lg'. */ function softwareTile($software_type, $size = '') { $style = softwareTypeStyle($software_type); $size_class = in_array($size, ['sm', 'lg'], true) ? " itflow-asset-icon-$size" : ''; return ''; } /** * Where a renewal date stands - null without one. 'state' is expired, soon * (within $soon_days) or active; 'short' fits a pill, 'text' a sentence. */ function softwareRenewal($expire, $soon_days = 30) { $expire = trim((string) $expire); if ($expire === '' || strpos($expire, '0000-00-00') === 0) { return null; } $date = DateTime::createFromFormat('!Y-m-d', substr($expire, 0, 10)); if (!$date) { return null; } $days = (int) (new DateTime('today'))->diff($date)->format('%r%a'); $ymd = $date->format('Y-m-d'); if ($days < 0) { $ago = -$days; $text = $ago === 1 ? 'Expired yesterday' : ($ago < 60 ? "Expired $ago days ago" : 'Expired ' . timeAgo($ymd)); return ['state' => 'expired', 'tone' => 'danger', 'short' => 'Expired', 'text' => $text, 'days' => $days, 'date' => $ymd]; } if ($days <= $soon_days) { $text = $days === 0 ? 'Renews today' : ($days === 1 ? 'Renews tomorrow' : "Renews in $days days"); return ['state' => 'soon', 'tone' => $days <= 7 ? 'danger' : 'warning', 'short' => $text, 'text' => $text, 'days' => $days, 'date' => $ymd]; } return ['state' => 'active', 'tone' => 'success', 'short' => 'Active', 'text' => 'Renews ' . timeAgo($ymd), 'days' => $days, 'date' => $ymd]; } /** A renewal as a soft pill - expired red, soon amber, otherwise green. */ function softwareRenewalPill($renewal, $use_text = false) { if (!$renewal) { return ''; } $tone = $renewal['tone']; $icon = ['expired' => 'fa-calendar-times', 'soon' => 'fa-hourglass-half', 'active' => 'fa-calendar-check'][$renewal['state']]; return '' . '' . escapeHtml($use_text ? $renewal['text'] : $renewal['short']) . ''; } /** * Seats in use against seats bought. 'state' is over, full, unused, ok - or * unlimited when the license has no seat count. */ function softwareSeats($used, $total) { $used = intval($used); $total = intval($total); if ($total <= 0) { return ['used' => $used, 'total' => 0, 'free' => null, 'over' => 0, 'pct' => 0, 'state' => 'unlimited', 'tone' => 'secondary', 'text' => $used === 1 ? '1 in use' : "$used in use", 'title' => "$used in use, no seat limit"]; } $over = max(0, $used - $total); $free = max(0, $total - $used); $state = $over ? 'over' : ($free === 0 ? 'full' : ($used === 0 ? 'unused' : 'ok')); $text = ['over' => "$over over", 'full' => 'All in use', 'unused' => 'None in use', 'ok' => "$free free"][$state]; return [ 'used' => $used, 'total' => $total, 'free' => $free, 'over' => $over, 'pct' => min(100, (int) round($used / $total * 100)), 'state' => $state, 'tone' => ['over' => 'danger', 'full' => 'warning', 'unused' => 'secondary', 'ok' => 'primary'][$state], 'text' => $text, 'title' => "$used of $total seats in use" . ($over ? " - $over over" : ''), ]; } /** Seats as "3 / 5" over a thin bar, and what is left - or "4 in use" for a license without a limit. */ function softwareSeatBar($seats, $show_text = true) { $text = $show_text ? '
' . escapeHtml($seats['text']) . '
' : ''; if ($seats['state'] === 'unlimited') { return '
' . $seats['used'] . ' in use' . ($show_text ? '
No seat limit
' : '') . '
'; } return '
' . '
' . $seats['used'] . '/ ' . $seats['total'] . '
' . '
' . '
' . $text . '
'; } /** SQL for the seats in use on the license in $software_column - devices plus users, archived ones left out. */ function softwareSeatsUsedSql($software_column = 'software.software_id') { return "((SELECT COUNT(*) FROM software_assets AS seat_a JOIN assets AS seat_aa ON seat_aa.asset_id = seat_a.asset_id AND seat_aa.asset_archived_at IS NULL WHERE seat_a.software_id = $software_column) + (SELECT COUNT(*) FROM software_contacts AS seat_c JOIN contacts AS seat_cc ON seat_cc.contact_id = seat_c.contact_id AND seat_cc.contact_archived_at IS NULL WHERE seat_c.software_id = $software_column))"; } /** * The list's attention tabs - each a WHERE fragment on the software table. * Renewing is the same 30 days the dashboard counts. */ function softwareStatusFilters() { $used = softwareSeatsUsedSql('software.software_id'); return [ 'renewing' => ['label' => 'Renewing soon', 'icon' => 'fa-hourglass-half', 'title' => 'Renewing in the next 30 days', 'sql' => "software_expire IS NOT NULL AND software_expire BETWEEN CURDATE() AND CURDATE() + INTERVAL 30 DAY"], 'expired' => ['label' => 'Expired', 'icon' => 'fa-calendar-times', 'title' => 'Past their renewal date', 'sql' => "software_expire IS NOT NULL AND software_expire != '0000-00-00' AND software_expire < CURDATE()"], 'over' => ['label' => 'Over seats', 'icon' => 'fa-exclamation-triangle', 'title' => 'More devices and users than seats bought', 'sql' => "software_seats > 0 AND $used > software_seats"], 'unused' => ['label' => 'No seats assigned', 'icon' => 'fa-user-slash', 'title' => 'Nobody and nothing on them yet', 'sql' => "$used = 0"], ]; } /** A license key, monospace, with a copy button - long keys are cut short on screen but copy whole. */ function softwareKeyHtml($key, $class = '') { $key = escapeHtml($key); if ($key === '') { return ''; } return '' . $key . '' . ''; } /** One license by id with its client, vendor and seats in use - raw, or null. */ function softwareRow($software_id) { global $mysqli; $software_id = intval($software_id); if (!$software_id) { return null; } $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT software.*, client_id, client_name, vendor_id, vendor_name, vendor_description, vendor_account_number, vendor_phone, vendor_phone_country_code, vendor_extension, vendor_email, vendor_website, vendor_hours, vendor_sla, vendor_code, vendor_archived_at, " . softwareSeatsUsedSql('software.software_id') . " AS seats_used FROM software LEFT JOIN clients ON client_id = software_client_id LEFT JOIN vendors ON vendor_id = software_vendor_id WHERE software_id = $software_id LIMIT 1")); return $row ?: null; } /** * The devices and users on a license, each with the keys handed to them. * Archived ones are left out - they do not use a seat. */ function softwareSeatHolders($software_id) { global $mysqli; $software_id = intval($software_id); $holders = ['assets' => [], 'contacts' => []]; $sql = mysqli_query($mysqli, "SELECT assets.asset_id, asset_name, asset_type, asset_make, asset_model, asset_client_id, asset_contact_id, contacts.contact_name AS asset_contact_name, contacts.contact_photo AS asset_contact_photo, (SELECT GROUP_CONCAT(k.software_key ORDER BY k.software_key_id SEPARATOR '\n') FROM software_keys AS k JOIN software_key_asset_assignments AS ka ON ka.software_key_id = k.software_key_id WHERE k.software_key_software_id = $software_id AND ka.asset_id = assets.asset_id) AS held_keys FROM software_assets JOIN assets ON assets.asset_id = software_assets.asset_id LEFT JOIN contacts ON contacts.contact_id = asset_contact_id WHERE software_assets.software_id = $software_id AND asset_archived_at IS NULL ORDER BY asset_name ASC"); while ($row = mysqli_fetch_assoc($sql)) { $row['held_keys'] = $row['held_keys'] !== null ? explode("\n", $row['held_keys']) : []; $holders['assets'][] = $row; } $sql = mysqli_query($mysqli, "SELECT contacts.contact_id, contact_name, contact_title, contact_email, contact_photo, contact_client_id, (SELECT GROUP_CONCAT(k.software_key ORDER BY k.software_key_id SEPARATOR '\n') FROM software_keys AS k JOIN software_key_contact_assignments AS kc ON kc.software_key_id = k.software_key_id WHERE k.software_key_software_id = $software_id AND kc.contact_id = contacts.contact_id) AS held_keys FROM software_contacts JOIN contacts ON contacts.contact_id = software_contacts.contact_id WHERE software_contacts.software_id = $software_id AND contact_archived_at IS NULL ORDER BY contact_name ASC"); while ($row = mysqli_fetch_assoc($sql)) { $row['held_keys'] = $row['held_keys'] !== null ? explode("\n", $row['held_keys']) : []; $holders['contacts'][] = $row; } return $holders; } /** A license's individual keys, oldest first, each with the devices and users it is handed to. */ function softwareKeys($software_id) { global $mysqli; $software_id = intval($software_id); $keys = []; $sql = mysqli_query($mysqli, "SELECT software_key_id, software_key FROM software_keys WHERE software_key_software_id = $software_id ORDER BY software_key_id ASC"); while ($row = mysqli_fetch_assoc($sql)) { $keys[intval($row['software_key_id'])] = ['id' => intval($row['software_key_id']), 'key' => $row['software_key'], 'assets' => [], 'contacts' => []]; } if (!$keys) { return []; } $ids = implode(',', array_keys($keys)); $sql = mysqli_query($mysqli, "SELECT ka.software_key_id, assets.asset_id, asset_name, asset_type, asset_archived_at FROM software_key_asset_assignments AS ka JOIN assets ON assets.asset_id = ka.asset_id WHERE ka.software_key_id IN ($ids) ORDER BY asset_name ASC"); while ($row = mysqli_fetch_assoc($sql)) { $keys[intval($row['software_key_id'])]['assets'][] = $row; } $sql = mysqli_query($mysqli, "SELECT kc.software_key_id, contacts.contact_id, contact_name, contact_photo, contact_client_id, contact_archived_at FROM software_key_contact_assignments AS kc JOIN contacts ON contacts.contact_id = kc.contact_id WHERE kc.software_key_id IN ($ids) ORDER BY contact_name ASC"); while ($row = mysqli_fetch_assoc($sql)) { $keys[intval($row['software_key_id'])]['contacts'][] = $row; } return array_values($keys); } /** * The licenses one device ($kind 'asset') or user ('contact') holds a seat * on - not archived - with each license's seats in use and the keys handed to * this holder. For the Licenses tab on the asset and contact pages and quick looks. */ function softwareHeldBy($kind, $holder_id) { global $mysqli; $holder_id = intval($holder_id); if ($kind === 'asset') { [$seat_table, $holder_column, $assign_table] = ['software_assets', 'asset_id', 'software_key_asset_assignments']; } elseif ($kind === 'contact') { [$seat_table, $holder_column, $assign_table] = ['software_contacts', 'contact_id', 'software_key_contact_assignments']; } else { return []; } $sql = mysqli_query($mysqli, "SELECT software.software_id, software_name, software_version, software_type, software_license_type, software_seats, software_expire, software_key, software_client_id, vendor_name, " . softwareSeatsUsedSql('software.software_id') . " AS seats_used, (SELECT GROUP_CONCAT(k.software_key ORDER BY k.software_key_id SEPARATOR '\n') FROM software_keys AS k JOIN $assign_table AS a ON a.software_key_id = k.software_key_id WHERE k.software_key_software_id = software.software_id AND a.$holder_column = $holder_id) AS held_keys FROM $seat_table AS h JOIN software ON software.software_id = h.software_id LEFT JOIN vendors ON vendor_id = software_vendor_id WHERE h.$holder_column = $holder_id AND software_archived_at IS NULL ORDER BY software_name ASC"); $rows = []; while ($row = mysqli_fetch_assoc($sql)) { $row['held_keys'] = $row['held_keys'] !== null ? explode("\n", $row['held_keys']) : []; $rows[] = $row; } return $rows; } /** * The Licenses tab on the asset and contact pages: each license this device * or user holds, opening its quick look, with its seats, renewal and the key * to install - theirs if one was handed to them, otherwise the license key. * $rows from softwareHeldBy(). */ function softwareHeldTable($rows, $kind, $holder_id, $can_edit) { $holder_id = intval($holder_id); $html = '
' . ''; foreach ($rows as $row) { $software_id = intval($row['software_id']); $renewal = softwareRenewal($row['software_expire']); $sub = implode(' · ', array_filter([$row['software_license_type'], $row['software_version']])); $keys = $row['held_keys']; $key_html = ''; foreach ($keys as $key) { $key_html .= '
' . softwareKeyHtml($key) . '
'; } if (!$keys && $row['software_key']) { $key_html = softwareKeyHtml($row['software_key']) . '
License key
'; } $renew_html = $renewal ? escapeHtml($renewal['date']) . '
' . escapeHtml($renewal['text']) . '
' : '-'; $unlink = $can_edit ? '' : ''; $html .= '' . '' . '' . '' . '' . '' . ''; } return $html . '
LicenseSeatsRenewsKey
' . softwareTile($row['software_type'], 'sm') . '
' . '' . escapeHtml($row['software_name']) . '' . ($sub !== '' ? '
' . escapeHtml($sub) . '
' : '') . '
' . softwareSeatBar(softwareSeats($row['seats_used'], $row['software_seats'])) . '' . $renew_html . '' . ($key_html !== '' ? $key_html : '-') . '' . $unlink . '
'; } /** The same, as the short list in the asset and contact quick looks. */ function softwareHeldList($rows) { $html = ''; } /** * Of $ids, the devices ($kind 'asset') or users ('contact') that belong to * $client_id and are not archived - what a form may put on a license. */ function softwareClientIds($kind, $ids, $client_id) { global $mysqli; $ids = array_values(array_unique(array_filter(array_map('intval', (array) $ids)))); $client_id = intval($client_id); if (!$ids || !$client_id) { return []; } [$table, $id_column, $client_column, $archived_column] = $kind === 'asset' ? ['assets', 'asset_id', 'asset_client_id', 'asset_archived_at'] : ['contacts', 'contact_id', 'contact_client_id', 'contact_archived_at']; $sql = mysqli_query($mysqli, "SELECT $id_column FROM $table WHERE $id_column IN (" . implode(',', $ids) . ") AND $client_column = $client_id AND $archived_column IS NULL"); $valid = []; while ($row = mysqli_fetch_row($sql)) { $valid[] = intval($row[0]); } return $valid; } /** Take back any of this license's keys handed to a device or user that has lost its seat. */ function softwareFreeKeys($software_id, $kind, $holder_id) { global $mysqli; $software_id = intval($software_id); $holder_id = intval($holder_id); [$assign_table, $holder_column] = $kind === 'asset' ? ['software_key_asset_assignments', 'asset_id'] : ['software_key_contact_assignments', 'contact_id']; mysqli_query($mysqli, "DELETE a FROM $assign_table AS a JOIN software_keys AS k ON k.software_key_id = a.software_key_id WHERE k.software_key_software_id = $software_id AND a.$holder_column = $holder_id"); } /** * Make a license's devices and users exactly these - adding the new ones and * dropping the rest (their keys go back to the pool). Only the license's own * client's, unarchived records are taken; archived holders are left alone. * Returns the names added and removed, for the log. */ function softwareSyncSeats($software_id, $client_id, $asset_ids, $contact_ids) { global $mysqli; $software_id = intval($software_id); $changes = ['added' => [], 'removed' => []]; $kinds = [ 'asset' => ['software_assets', 'assets', 'asset_id', 'asset_name', 'asset_archived_at', $asset_ids], 'contact' => ['software_contacts', 'contacts', 'contact_id', 'contact_name', 'contact_archived_at', $contact_ids], ]; foreach ($kinds as $kind => [$seat_table, $table, $id_column, $name_column, $archived_column, $wanted]) { $wanted = softwareClientIds($kind, $wanted, $client_id); $current = []; $sql = mysqli_query($mysqli, "SELECT s.$id_column, $name_column FROM $seat_table AS s JOIN $table AS t ON t.$id_column = s.$id_column WHERE s.software_id = $software_id AND t.$archived_column IS NULL"); while ($row = mysqli_fetch_row($sql)) { $current[intval($row[0])] = $row[1]; } foreach (array_diff($wanted, array_keys($current)) as $id) { mysqli_query($mysqli, "INSERT IGNORE INTO $seat_table SET software_id = $software_id, $id_column = $id"); if (mysqli_affected_rows($mysqli) > 0) { $changes['added'][] = getFieldById($table, $id, $name_column); } } foreach (array_diff(array_keys($current), $wanted) as $id) { mysqli_query($mysqli, "DELETE FROM $seat_table WHERE software_id = $software_id AND $id_column = $id"); softwareFreeKeys($software_id, $kind, $id); $changes['removed'][] = $current[$id]; } } return $changes; } /** One line for the log about seats a save changed - '' when it changed none. */ function softwareSeatChangeText($changes) { $parts = []; if ($changes['added']) { $parts[] = 'added ' . implode(', ', $changes['added']); } if ($changes['removed']) { $parts[] = 'removed ' . implode(', ', $changes['removed']); } return $parts ? ' (seats: ' . implode('; ', $parts) . ')' : ''; } /** A warning to add to the alert when a license has gone over its seats, or ''. */ function softwareSeatWarning($software_id) { $row = softwareRow($software_id); if (!$row) { return ''; } $seats = softwareSeats($row['seats_used'], $row['software_seats']); if ($seats['state'] !== 'over') { return ''; } // Raw name - flash messages are escaped when shown (alertMessageHtml) return ' - ' . $row['software_name'] . ' is now over its seats, ' . $seats['used'] . ' of ' . $seats['total'] . ' in use'; } /** Add keys to a license, one per line of $text - blank lines and keys it already has are skipped. Returns how many were added. */ function softwareAddKeys($software_id, $text) { global $mysqli; $software_id = intval($software_id); $existing = []; $sql = mysqli_query($mysqli, "SELECT software_key FROM software_keys WHERE software_key_software_id = $software_id"); while ($row = mysqli_fetch_row($sql)) { $existing[strtolower($row[0])] = true; } $added = 0; foreach (preg_split('/\r\n|\r|\n/', (string) $text) as $line) { $key = mb_substr(trim($line), 0, 400); if ($key === '' || isset($existing[strtolower($key)])) { continue; } $existing[strtolower($key)] = true; mysqli_query($mysqli, "INSERT INTO software_keys SET software_key = '" . escapeSql($key) . "', software_key_software_id = $software_id"); $added++; } return $added; } /** Who did what to a license - its audit log entries, newest first. */ function softwareHistory($software_id, $limit = 50) { global $mysqli; $software_id = intval($software_id); $limit = max(1, intval($limit)); $sql = mysqli_query($mysqli, "SELECT log_action, log_description, log_created_at, user_name, user_avatar, user_id FROM logs LEFT JOIN users ON user_id = log_user_id WHERE log_type = 'Software' AND log_entity_id = $software_id ORDER BY log_id DESC LIMIT $limit"); $rows = []; while ($row = mysqli_fetch_assoc($sql)) { $rows[] = $row; } return $rows; } /** Date from a form - Y-m-d or null. */ function softwareFormDate($value) { $value = trim((string) $value); $date = DateTime::createFromFormat('!Y-m-d', $value); return $date && $date->format('Y-m-d') === $value ? $value : null; } /** * The license fields a New / Edit form posts, as a SQL SET list - only the * client's own vendor, real dates, a whole number of seats (blank is no * limit). Null when there is no name. Fields missing from the post are blank, * never a PHP warning. */ function softwareFormSet($post, $client_id) { $text = function ($field, $length = 200) use ($post) { $value = trim((string) ($post[$field] ?? '')); return "'" . escapeSql($length ? mb_substr($value, 0, $length) : $value) . "'"; }; $name = trim((string) ($post['name'] ?? '')); if ($name === '') { return null; } $seats = trim((string) ($post['seats'] ?? '')); $purchase = softwareFormDate($post['purchase'] ?? ''); $expire = softwareFormDate($post['expire'] ?? ''); $fields = [ 'software_name' => $text('name'), 'software_version' => $text('version'), 'software_description' => $text('description', 0), 'software_type' => $text('type'), 'software_license_type' => $text('license_type'), 'software_key' => $text('key'), 'software_seats' => $seats === '' ? 'NULL' : max(0, intval($seats)), 'software_purchase_reference' => $text('purchase_reference'), 'software_purchase' => $purchase ? "'$purchase'" : 'NULL', 'software_expire' => $expire ? "'$expire'" : 'NULL', 'software_notes' => $text('notes', 0), 'software_favorite' => empty($post['favorite']) ? 0 : 1, 'software_vendor_id' => assetClientOwnedId('vendor', $post['vendor'] ?? 0, $client_id), ]; $set = []; foreach ($fields as $column => $value) { $set[] = "$column = $value"; } return implode(', ', $set); } /** * A renewal date moved on by a term ('1m', '3m', '6m', '1y', '2y', '3y') - * from the current date, or from today if there is none or it has passed. * Null for a term it does not know. */ function softwareRenewalExtended($expire, $term) { $intervals = ['1m' => 'P1M', '3m' => 'P3M', '6m' => 'P6M', '1y' => 'P1Y', '2y' => 'P2Y', '3y' => 'P3Y']; if (!isset($intervals[$term])) { return null; } $today = new DateTime('today'); $from = softwareFormDate(substr((string) $expire, 0, 10)); $base = $from ? new DateTime($from) : $today; if ($base < $today) { $base = $today; } return $base->add(new DateInterval($intervals[$term]))->format('Y-m-d'); } /** The renewal terms the Renew modal offers. */ const SOFTWARE_RENEWAL_TERMS = ['1m' => '1 month', '3m' => '3 months', '6m' => '6 months', '1y' => '1 year', '2y' => '2 years', '3y' => '3 years'];