Categories wins; otherwise the built-in statuses get a sensible * default and anything else is grey. * * Returns ['class' => bootstrap tone or '', 'style' => inline background or ''] */ function assetStatusBadge($status, $category_color = '') { $category_color = trim((string) $category_color); if (preg_match('/^(#[0-9a-f]{3,8}|[a-z]{3,24})$/i', $category_color)) { return ['class' => '', 'style' => "background-color: $category_color"]; } $defaults = [ 'Deployed' => 'success', 'Ready to Deploy' => 'info', 'Out for Repair' => 'warning', 'Lost' => 'danger', 'Stolen' => 'danger', 'Retired' => 'secondary', ]; return ['class' => $defaults[(string) $status] ?? 'secondary', 'style' => '']; } /** Status colours from Admin > Categories, keyed by status name (raw). One query per page. */ function assetStatusColors() { global $mysqli; static $colors = null; if ($colors === null) { $colors = []; $sql = mysqli_query($mysqli, "SELECT category_name, category_color FROM categories WHERE category_type = 'asset_status' AND category_archived_at IS NULL"); while ($row = mysqli_fetch_assoc($sql)) { $colors[$row['category_name']] = (string) $row['category_color']; } } return $colors; } /** The colour a status is drawn in, as a CSS value - the same as its badge. */ function assetStatusColor($status, $category_color = '') { $badge = assetStatusBadge($status, $category_color); return $badge['style'] ? trim(substr($badge['style'], strlen('background-color:'))) : "var(--bs-{$badge['class']})"; } /** The status badge, ready to echo. */ function assetStatusPill($status) { $status = (string) $status; if ($status === '') { return ''; } $badge = assetStatusBadge($status, assetStatusColors()[$status] ?? ''); $class = $badge['class'] ? "text-bg-{$badge['class']}" : 'text-white'; $style = $badge['style'] ? " style=\"{$badge['style']}\"" : ''; return "" . escapeHtml($status) . ""; } /** OS name fragments and their brand icons (Font Awesome 5), first match wins. Also drives the asset form's OS icon. */ function assetOsIconMap() { return [ 'windows' => 'fab fa-windows', 'macos' => 'fab fa-apple', 'mac os' => 'fab fa-apple', 'ios' => 'fab fa-apple', 'ipados' => 'fab fa-apple', 'android' => 'fab fa-android', 'ubuntu' => 'fab fa-ubuntu', 'red hat' => 'fab fa-redhat', 'rhel' => 'fab fa-redhat', 'centos' => 'fab fa-centos', 'fedora' => 'fab fa-fedora', 'suse' => 'fab fa-suse', 'freebsd' => 'fab fa-freebsd', 'pfsense' => 'fab fa-freebsd', 'opnsense' => 'fab fa-freebsd', 'truenas' => 'fab fa-freebsd', 'raspberry' => 'fab fa-raspberry-pi', 'linux' => 'fab fa-linux', 'debian' => 'fab fa-linux', 'mint' => 'fab fa-linux', 'proxmox' => 'fab fa-linux', 'pop!_os' => 'fab fa-linux', 'firmware' => 'fas fa-microchip', 'routeros' => 'fas fa-microchip', ]; } /** A brand icon for the operating system, where one is recognisable (Font Awesome 5 class). */ function assetOsIcon($os) { $os = strtolower((string) $os); foreach (assetOsIconMap() as $needle => $icon) { if (strpos($os, $needle) !== false) { return $icon; } } return 'fas fa-compact-disc'; } /** * Where the warranty stands, or null when there's no date. * * Returns ['tone' => danger|warning|success, 'short' => "Expired" / "23 days left" / "Covered", * 'text' => "Expired 3 months ago" / "Expires in 23 days" / "Until 2028-11-08", * 'days' => days left (negative once expired)] */ function assetWarranty($warranty_expire, $soon_days = 90) { $warranty_expire = trim((string) $warranty_expire); if ($warranty_expire === '' || strpos($warranty_expire, '0000-00-00') === 0) { return null; } $expires = DateTime::createFromFormat('!Y-m-d', substr($warranty_expire, 0, 10)); if (!$expires) { return null; } $today = new DateTime('today'); $days = (int) $today->diff($expires)->format('%r%a'); $date = $expires->format('Y-m-d'); if ($days < 0) { return ['tone' => 'danger', 'short' => 'Expired', 'text' => 'Expired ' . timeAgo($date), 'days' => $days, 'date' => $date]; } if ($days <= $soon_days) { $left = $days === 0 ? 'today' : ($days === 1 ? '1 day left' : "$days days left"); return ['tone' => 'warning', 'short' => $left, 'text' => $days === 0 ? 'Expires today' : "Expires in $days day" . ($days === 1 ? '' : 's'), 'days' => $days, 'date' => $date]; } return ['tone' => 'success', 'short' => 'Covered', 'text' => "Until $date", 'days' => $days, 'date' => $date]; } /** "3 yrs 2 mos" since a date, or '' without one. For purchase / install dates. */ function assetAge($date) { $date = trim((string) $date); if ($date === '' || strpos($date, '0000-00-00') === 0) { return ''; } $from = DateTime::createFromFormat('!Y-m-d', substr($date, 0, 10)); if (!$from) { return ''; } $diff = $from->diff(new DateTime('today')); if ($diff->invert) { return ''; } $parts = []; if ($diff->y) { $parts[] = $diff->y . ' yr' . ($diff->y === 1 ? '' : 's'); } if ($diff->m) { $parts[] = $diff->m . ' mo' . ($diff->m === 1 ? '' : 's'); } if (!$parts) { return $diff->d <= 1 ? 'new' : $diff->d . ' days'; } return implode(' ', $parts); } /** * The asset's links (URI, URI 2, client URI) as [label, url] pairs, skipping * empty ones. Raw - escapeUrl() each url before it goes in an href. */ function assetLinks($uri, $uri_2, $uri_client) { $links = []; foreach ([['Open', $uri], ['Open 2', $uri_2], ['Client link', $uri_client]] as [$label, $url]) { $url = trim((string) $url); if ($url !== '') { $links[] = [$label, $url]; } } return $links; } /** A short name for a link, by what it opens: "Web", "SSH", "RDP"... The full URL goes in its title. */ function assetLinkLabel($url) { $scheme = strtolower((string) parse_url((string) $url, PHP_URL_SCHEME)); $labels = ['http' => 'Web', 'https' => 'Web', 'ssh' => 'SSH', 'telnet' => 'Telnet', 'rdp' => 'RDP', 'vnc' => 'VNC', 'ftp' => 'FTP', 'sftp' => 'SFTP', 'smb' => 'SMB']; return $labels[$scheme] ?? ($scheme !== '' ? strtoupper($scheme) : 'Link'); } /** Link schemes and their icons. Also drives the asset form's link icons. */ function assetLinkIconMap() { return [ 'ssh' => 'fas fa-terminal', 'telnet' => 'fas fa-terminal', 'rdp' => 'fas fa-desktop', 'vnc' => 'fas fa-desktop', 'ftp' => 'fas fa-folder-open', 'sftp' => 'fas fa-folder-open', 'smb' => 'fas fa-folder-open', 'http' => 'fas fa-globe', 'https' => 'fas fa-globe', ]; } /** Icon for a link by its scheme. */ function assetLinkIcon($url) { $scheme = strtolower((string) parse_url((string) $url, PHP_URL_SCHEME)); return assetLinkIconMap()[$scheme] ?? 'fas fa-external-link-alt'; } /** * $id if that location / contact / vendor / network / asset belongs to the * client, else 0. The handlers run their picks through this so a hand-edited * POST can't hang another client's contact or location off an asset. */ function assetClientOwnedId($kind, $id, $client_id) { global $mysqli; $tables = [ 'location' => ['locations', 'location_id', 'location_client_id'], 'contact' => ['contacts', 'contact_id', 'contact_client_id'], 'vendor' => ['vendors', 'vendor_id', 'vendor_client_id'], 'network' => ['networks', 'network_id', 'network_client_id'], 'asset' => ['assets', 'asset_id', 'asset_client_id'], ]; $id = intval($id); $client_id = intval($client_id); if (!$id || !isset($tables[$kind])) { return 0; } [$table, $id_column, $client_column] = $tables[$kind]; $sql = mysqli_query($mysqli, "SELECT 1 FROM $table WHERE $id_column = $id AND $client_column = $client_id LIMIT 1"); return mysqli_num_rows($sql) ? $id : 0; } /* * Quick pickers (js/quick_pick.js) for an asset's status, location and who it * is assigned to - changed where they show on the assets list and the asset * page instead of through Edit. Wrapped in quickPick() (functions/ticket_fields.php) * with the asset's client as the scope, since locations and contacts are per client. */ /** The asset fields a quick picker can change. */ const ASSET_QUICK_FIELDS = ['asset_status', 'asset_location', 'asset_contact']; /** The asset and what its pickers show, by id - the row assetQuickHtml() and assetContactDetails() draw from. */ function assetQuickRow($asset_id) { global $mysqli; return mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT asset_id, asset_name, asset_status, asset_client_id, asset_location_id, location_name, location_archived_at, asset_contact_id, contact_name, contact_photo, contact_archived_at, contact_email, contact_phone, contact_phone_country_code, contact_extension, contact_mobile, contact_mobile_country_code FROM assets LEFT JOIN locations ON location_id = asset_location_id LEFT JOIN contacts ON contact_id = asset_contact_id WHERE asset_id = " . intval($asset_id))) ?: null; } /** * What a picker shows for the value it holds now. $style is 'list' (a pill, * on the assets list and in the asset page header) or 'detail' (a line in the * asset page's Where & who card). $row is raw, as assetQuickRow() returns it. */ function assetQuickHtml($field, $row, $style = 'list') { $client_id = intval($row['asset_client_id'] ?? 0); $pill = 'badge rounded-pill fw-normal d-inline-flex align-items-center gap-1 itflow-asset-pill'; $empty = "$pill itflow-asset-pill-empty"; if ($field === 'asset_status') { return assetStatusPill($row['asset_status'] ?? '') ?: "No status"; } if ($field === 'asset_location') { if (!intval($row['asset_location_id'] ?? 0) || (string) ($row['location_name'] ?? '') === '') { return $style === 'detail' ? 'No location' : "No location"; } $archived = !empty($row['location_archived_at']); $name = '' . escapeHtml($row['location_name']) . ''; return $style === 'detail' ? '$name" : "$name"; } if ($field === 'asset_contact') { if (!intval($row['asset_contact_id'] ?? 0) || (string) ($row['contact_name'] ?? '') === '') { return $style === 'detail' ? 'Nobody' : "Nobody"; } $archived = !empty($row['contact_archived_at']); $avatar = contactAvatar($client_id, $row['asset_contact_id'], $row['contact_name'], $row['contact_photo'] ?? '', 'xs'); $name = '' . escapeHtml($row['contact_name']) . ''; return $style === 'detail' ? "$avatar$name" : "$avatar$name"; } return ''; } /** Under the asset page's Assigned picker: how to reach that person, and a link to them. Redrawn when the contact changes. */ function assetContactDetails($row) { $contact_id = intval($row['asset_contact_id'] ?? 0); if (!$contact_id || (string) ($row['contact_name'] ?? '') === '') { return ''; } $client_id = intval($row['asset_client_id']); $email = escapeHtml($row['contact_email'] ?? ''); $phone = escapeHtml(formatPhoneNumber($row['contact_phone'] ?? '', $row['contact_phone_country_code'] ?? '')); $extension = escapeHtml($row['contact_extension'] ?? ''); $mobile = escapeHtml(formatPhoneNumber($row['contact_mobile'] ?? '', $row['contact_mobile_country_code'] ?? '')); $html = ''; if ($email) { $html .= "
$email
"; } if ($phone) { $html .= "
$phone" . ($extension ? " x$extension" : '') . '
'; } if ($mobile) { $html .= "
$mobile
"; } $html .= "
Open contact
"; return $html; } /** * The choices a picker offers, in js/quick_pick.js's option shape. Locations * and contacts are the client's own (null if the agent can't reach that client). */ function assetQuickOptions($field, $client_id) { global $mysqli; $client_id = intval($client_id); if ($field === 'asset_status') { $options = [['value' => '', 'label' => 'No status', 'icon' => 'fas fa-minus', 'none' => true]]; $sql = mysqli_query($mysqli, "SELECT category_name, category_color FROM categories WHERE category_type = 'asset_status' AND category_archived_at IS NULL ORDER BY category_order ASC, category_name ASC"); while ($row = mysqli_fetch_assoc($sql)) { $options[] = ['value' => $row['category_name'], 'label' => $row['category_name'], 'color' => assetStatusColor($row['category_name'], $row['category_color'])]; } return $options; } if (!$client_id || !hasClientAccess($client_id)) { return null; } if ($field === 'asset_location') { $options = [['value' => '0', 'label' => 'No location', 'icon' => 'fas fa-ban', 'none' => true]]; $sql = mysqli_query($mysqli, "SELECT location_id, location_name, location_primary, location_city, location_state FROM locations WHERE location_client_id = $client_id AND location_archived_at IS NULL ORDER BY location_primary DESC, location_name ASC"); while ($row = mysqli_fetch_assoc($sql)) { $place = implode(', ', array_filter([trim((string) $row['location_city']), trim((string) $row['location_state'])])); $description = implode(' ยท ', array_filter([intval($row['location_primary']) ? 'Primary' : '', $place])); $options[] = ['value' => (string) intval($row['location_id']), 'label' => $row['location_name'], 'icon' => 'fas fa-map-marker-alt', 'description' => $description]; } return $options; } if ($field === 'asset_contact') { $options = [['value' => '0', 'label' => 'Nobody', 'icon' => 'fas fa-user-slash', 'none' => true]]; $sql = mysqli_query($mysqli, "SELECT contact_id, contact_name, contact_title, contact_photo FROM contacts WHERE contact_client_id = $client_id AND contact_archived_at IS NULL ORDER BY contact_name ASC"); while ($row = mysqli_fetch_assoc($sql)) { $options[] = ['value' => (string) intval($row['contact_id']), 'label' => $row['contact_name'], 'description' => (string) $row['contact_title']] + contactAvatarData($client_id, $row['contact_id'], $row['contact_name'], $row['contact_photo']); } return $options; } return null; } /** * Change one field on one asset, as Edit Asset would - audit log and asset * history included. The caller checks the agent may edit assets of its * client. Returns [ok, plain-text message]. */ function assetQuickSet($asset_id, $field, $value) { global $mysqli, $session_name; $asset_id = intval($asset_id); $row = assetQuickRow($asset_id); if (!$row) { return [false, 'That asset no longer exists.']; } $client_id = intval($row['asset_client_id']); $asset_name = escapeSql($row['asset_name']); $value = trim((string) $value); if ($field === 'asset_status') { if ($value !== '' && !isset(assetStatusColors()[$value])) { return [false, 'Pick one of the asset statuses.']; } if ($value === (string) $row['asset_status']) { return [true, '']; } $status = escapeSql($value); mysqli_query($mysqli, "UPDATE assets SET asset_status = '$status' WHERE asset_id = $asset_id"); $what = $value === '' ? "cleared the status on $asset_name" : "set status to $status on $asset_name"; $message = $value === '' ? 'Status cleared' : "Status set to $value"; } elseif ($field === 'asset_location') { $location_id = assetClientOwnedId('location', $value, $client_id); if (intval($value) && !$location_id) { return [false, 'That location is not one of this client\'s.']; } if ($location_id === intval($row['asset_location_id'])) { return [true, '']; } $location_name = $location_id ? (string) mysqli_fetch_row(mysqli_query($mysqli, "SELECT location_name FROM locations WHERE location_id = $location_id"))[0] : ''; $status = escapeSql($row['asset_status']); mysqli_query($mysqli, "UPDATE assets SET asset_location_id = $location_id WHERE asset_id = $asset_id"); $what = $location_id ? "assigned asset $asset_name to location " . escapeSql($location_name) : "removed asset $asset_name from its location"; $message = $location_id ? "Moved to $location_name" : 'Location removed'; } elseif ($field === 'asset_contact') { $contact_id = assetClientOwnedId('contact', $value, $client_id); if (intval($value) && !$contact_id) { return [false, 'That contact is not one of this client\'s.']; } if ($contact_id === intval($row['asset_contact_id'])) { return [true, '']; } $contact_name = $contact_id ? (string) mysqli_fetch_row(mysqli_query($mysqli, "SELECT contact_name FROM contacts WHERE contact_id = $contact_id"))[0] : ''; $status = escapeSql($row['asset_status']); mysqli_query($mysqli, "UPDATE assets SET asset_contact_id = $contact_id WHERE asset_id = $asset_id"); $what = $contact_id ? "assigned asset $asset_name to contact " . escapeSql($contact_name) : "unassigned asset $asset_name"; $message = $contact_id ? "Assigned to $contact_name" : 'Unassigned'; } else { return [false, 'Unknown field.']; } logAudit("Asset", "Edit", "$session_name $what", $client_id, $asset_id); mysqli_query($mysqli, "INSERT INTO asset_history SET asset_history_status = '$status', asset_history_description = '$session_name $what', asset_history_asset_id = $asset_id"); return [true, $message]; } /** What quick_pick.js redraws after a change: the value in both looks, plus the contact details on the asset page. */ function assetQuickRecord($asset_id, $field) { $row = assetQuickRow($asset_id); $current = [ 'asset_status' => (string) $row['asset_status'], 'asset_location' => (string) intval($row['asset_location_id']), 'asset_contact' => (string) intval($row['asset_contact_id']), ][$field]; $record = ['current' => $current, 'html' => ['list' => assetQuickHtml($field, $row, 'list'), 'detail' => assetQuickHtml($field, $row, 'detail')]]; if ($field === 'asset_contact') { $record['related'] = ['asset-contact-' . intval($asset_id) => assetContactDetails($row)]; } return $record; }