mirror of
https://github.com/itflow-org/itflow
synced 2026-11-11 15:07:24 +00:00
464 lines
28 KiB
PHP
464 lines
28 KiB
PHP
<?php
|
|
|
|
/*
|
|
* Links between documents, files and the things they are about - the assets,
|
|
* contacts, licenses and vendors a document covers, the files attached to a
|
|
* document, the assets and contacts a file belongs to - and the seats on a
|
|
* license (the devices and users on it) and the credentials and files that
|
|
* go with it, the documents and files about a location (a floor plan, a
|
|
* site runbook), and a vendor's portal logins, documents and files (the
|
|
* contract, the escalation sheet), and what a service depends on - its
|
|
* assets, logins, domains, certificates, people, vendors and documents.
|
|
*
|
|
* Every link is a row in a two-column table (asset_documents, document_files,
|
|
* contact_files...) and they all work the same way, so they are described once
|
|
* here and linked, unlinked, listed and offered by the same few functions -
|
|
* one link modal (agent/modals/link/link.php), one handler
|
|
* (agent/post/link.php) and one list (linkGroupsHtml()) on the document page
|
|
* and the file and document quick looks.
|
|
*
|
|
* A type with a 'view' module (credentials) is only offered, listed or
|
|
* linked for an agent who may see that module.
|
|
*
|
|
* A link only ever joins two records of the same client, and never to an
|
|
* archived one; linkAdd() checks both rather than trusting the form.
|
|
*/
|
|
|
|
/** The record types a link can join. */
|
|
const LINK_ENTITIES = [
|
|
'asset' => ['table' => 'assets', 'id' => 'asset_id', 'name' => 'asset_name', 'client' => 'asset_client_id', 'archived' => 'asset_archived_at', 'label' => 'asset', 'plural' => 'Assets', 'icon' => 'fa-desktop'],
|
|
'contact' => ['table' => 'contacts', 'id' => 'contact_id', 'name' => 'contact_name', 'client' => 'contact_client_id', 'archived' => 'contact_archived_at', 'label' => 'contact', 'plural' => 'Contacts', 'icon' => 'fa-user'],
|
|
'document' => ['table' => 'documents', 'id' => 'document_id', 'name' => 'document_name', 'client' => 'document_client_id', 'archived' => 'document_archived_at', 'label' => 'document', 'plural' => 'Documents', 'icon' => 'fa-file-alt'],
|
|
'file' => ['table' => 'files', 'id' => 'file_id', 'name' => 'file_name', 'client' => 'file_client_id', 'archived' => 'file_archived_at', 'label' => 'file', 'plural' => 'Files', 'icon' => 'fa-paperclip'],
|
|
'software' => ['table' => 'software', 'id' => 'software_id', 'name' => 'software_name', 'client' => 'software_client_id', 'archived' => 'software_archived_at', 'label' => 'license', 'plural' => 'Licenses', 'icon' => 'fa-cube'],
|
|
'location' => ['table' => 'locations', 'id' => 'location_id', 'name' => 'location_name', 'client' => 'location_client_id', 'archived' => 'location_archived_at', 'label' => 'location', 'plural' => 'Locations', 'icon' => 'fa-map-marker-alt', 'view' => 'module_client'],
|
|
'credential' => ['table' => 'credentials', 'id' => 'credential_id', 'name' => 'credential_name', 'client' => 'credential_client_id', 'archived' => 'credential_archived_at', 'label' => 'credential', 'plural' => 'Credentials', 'icon' => 'fa-key', 'view' => 'module_credential'],
|
|
'vendor' => ['table' => 'vendors', 'id' => 'vendor_id', 'name' => 'vendor_name', 'client' => 'vendor_client_id', 'archived' => 'vendor_archived_at', 'label' => 'vendor', 'plural' => 'Vendors', 'icon' => 'fa-building'],
|
|
'service' => ['table' => 'services', 'id' => 'service_id', 'name' => 'service_name', 'client' => 'service_client_id', 'archived' => 'service_archived_at', 'label' => 'service', 'plural' => 'Services', 'icon' => 'fa-stream'],
|
|
'domain' => ['table' => 'domains', 'id' => 'domain_id', 'name' => 'domain_name', 'client' => 'domain_client_id', 'archived' => 'domain_archived_at', 'label' => 'domain', 'plural' => 'Domains', 'icon' => 'fa-globe'],
|
|
'certificate' => ['table' => 'certificates', 'id' => 'certificate_id', 'name' => 'certificate_name', 'client' => 'certificate_client_id', 'archived' => 'certificate_archived_at', 'label' => 'certificate', 'plural' => 'Certificates', 'icon' => 'fa-lock'],
|
|
];
|
|
|
|
/**
|
|
* Which pairs can be linked, keyed by the two types in alphabetical order, and
|
|
* the permission it takes. Linking a contact to a file has always been a
|
|
* contacts permission, a credential to a license a credentials one;
|
|
* everything else is documentation, a support one. A device or user linked
|
|
* to a license holds a seat on it.
|
|
*/
|
|
const LINK_RELATIONS = [
|
|
'asset|document' => ['table' => 'asset_documents', 'module' => 'module_support'],
|
|
'contact|document' => ['table' => 'contact_documents', 'module' => 'module_support'],
|
|
'document|file' => ['table' => 'document_files', 'module' => 'module_support'],
|
|
'document|software' => ['table' => 'software_documents', 'module' => 'module_support'],
|
|
'document|vendor' => ['table' => 'vendor_documents', 'module' => 'module_support'],
|
|
'asset|file' => ['table' => 'asset_files', 'module' => 'module_support'],
|
|
'contact|file' => ['table' => 'contact_files', 'module' => 'module_client'],
|
|
'asset|software' => ['table' => 'software_assets', 'module' => 'module_support'],
|
|
'contact|software' => ['table' => 'software_contacts', 'module' => 'module_support'],
|
|
'credential|software' => ['table' => 'software_credentials', 'module' => 'module_credential'],
|
|
'file|software' => ['table' => 'software_files', 'module' => 'module_support'],
|
|
'document|location' => ['table' => 'location_documents', 'module' => 'module_support'],
|
|
'file|location' => ['table' => 'location_files', 'module' => 'module_support'],
|
|
'credential|vendor' => ['table' => 'vendor_credentials', 'module' => 'module_credential'],
|
|
'file|vendor' => ['table' => 'vendor_files', 'module' => 'module_support'],
|
|
// What a service depends on - support documentation, its logins a credentials permission
|
|
'asset|service' => ['table' => 'service_assets', 'module' => 'module_support'],
|
|
'certificate|service' => ['table' => 'service_certificates', 'module' => 'module_support'],
|
|
'contact|service' => ['table' => 'service_contacts', 'module' => 'module_support'],
|
|
'credential|service' => ['table' => 'service_credentials', 'module' => 'module_credential'],
|
|
'document|service' => ['table' => 'service_documents', 'module' => 'module_support'],
|
|
'domain|service' => ['table' => 'service_domains', 'module' => 'module_support'],
|
|
'service|vendor' => ['table' => 'service_vendors', 'module' => 'module_support'],
|
|
];
|
|
|
|
/** The relation between two types, or null when they cannot be linked. */
|
|
function linkRelation($a, $b) {
|
|
$pair = [(string) $a, (string) $b];
|
|
sort($pair);
|
|
return LINK_RELATIONS[implode('|', $pair)] ?? null;
|
|
}
|
|
|
|
/**
|
|
* What a $from record can be linked to, in the order the pages list them -
|
|
* a license's seats first - leaving out types this agent may not see.
|
|
*/
|
|
function linkTypesFor($from) {
|
|
$orders = [
|
|
'software' => ['asset', 'contact', 'credential', 'document', 'file'],
|
|
// A vendor's portal logins first - what is wanted on the phone to them
|
|
'vendor' => ['credential', 'document', 'file', 'service'],
|
|
// What it runs on first, then what it needs to work, then who to call
|
|
'service' => SERVICE_LINK_TYPES,
|
|
];
|
|
$order = $orders[$from] ?? ['file', 'document', 'asset', 'contact', 'location', 'software', 'credential', 'vendor', 'service'];
|
|
return array_values(array_filter($order, function ($to) use ($from) {
|
|
$view = LINK_ENTITIES[$to]['view'] ?? null;
|
|
return $to !== $from && linkRelation($from, $to) && (!$view || lookupUserPermission($view) >= 1);
|
|
}));
|
|
}
|
|
|
|
/** May this agent change links between these two types? */
|
|
function linkCanEdit($from, $to) {
|
|
$relation = linkRelation($from, $to);
|
|
return $relation && lookupUserPermission($relation['module']) >= 2;
|
|
}
|
|
|
|
/** One record's id, name, client and archived date - or null if it does not exist. */
|
|
function linkRecord($type, $id) {
|
|
global $mysqli;
|
|
$entity = LINK_ENTITIES[$type] ?? null;
|
|
$id = intval($id);
|
|
if (!$entity || !$id) {
|
|
return null;
|
|
}
|
|
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT {$entity['id']} AS id, {$entity['name']} AS name,
|
|
{$entity['client']} AS client_id, {$entity['archived']} AS archived_at
|
|
FROM {$entity['table']} WHERE {$entity['id']} = $id LIMIT 1"));
|
|
return $row ?: null;
|
|
}
|
|
|
|
/** Extra columns each type brings to a linked row, beyond id and name. */
|
|
function linkDisplayColumns($type) {
|
|
return [
|
|
'asset' => 'asset_type, asset_make, asset_model',
|
|
'contact' => 'contact_title, contact_photo, contact_client_id',
|
|
'document' => 'document_description, document_updated_at, document_created_at',
|
|
'file' => 'file_ext, file_size, file_mime_type, file_description',
|
|
'software' => 'software_type, software_version, software_license_type, software_seats, software_expire, ' . softwareSeatsUsedSql('t.software_id') . ' AS seats_used',
|
|
'credential' => 'credential_description, credential_uri, credential_username, credential_otp_secret, credential_favorite, credential_password_changed_at',
|
|
'vendor' => 'vendor_description, vendor_account_number, vendor_phone, vendor_phone_country_code, vendor_extension',
|
|
'location' => 'location_address, location_city, location_state, location_zip, location_primary, location_photo, location_client_id',
|
|
'service' => 'service_description, service_category, service_importance, service_client_id',
|
|
'domain' => 'domain_description, domain_expire, domain_auto_renew',
|
|
'certificate' => 'certificate_domain, certificate_expire, certificate_issued_at, certificate_check_status, certificate_checked_at',
|
|
][$type] ?? '';
|
|
}
|
|
|
|
/**
|
|
* The $to records linked to one $from record, ready to draw: each row has id
|
|
* and name plus linkDisplayColumns(). Archived records are left out - they
|
|
* come back when they are restored.
|
|
*/
|
|
function linkedRecords($from, $id, $to) {
|
|
global $mysqli;
|
|
$relation = linkRelation($from, $to);
|
|
$from_entity = LINK_ENTITIES[$from] ?? null;
|
|
$to_entity = LINK_ENTITIES[$to] ?? null;
|
|
if (!$relation || !$from_entity || !$to_entity) {
|
|
return [];
|
|
}
|
|
$id = intval($id);
|
|
$extra = linkDisplayColumns($to);
|
|
$sql = mysqli_query($mysqli, "SELECT t.{$to_entity['id']} AS id, t.{$to_entity['name']} AS name" . ($extra ? ", $extra" : '') . "
|
|
FROM {$relation['table']} l
|
|
JOIN {$to_entity['table']} t ON t.{$to_entity['id']} = l.{$to_entity['id']}
|
|
WHERE l.{$from_entity['id']} = $id AND t.{$to_entity['archived']} IS NULL
|
|
ORDER BY t.{$to_entity['name']} ASC");
|
|
$rows = [];
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$rows[] = $row;
|
|
}
|
|
return $rows;
|
|
}
|
|
|
|
/** The client's $to records not yet linked to this $from record, and not archived. */
|
|
function linkCandidates($from, $id, $to, $client_id) {
|
|
global $mysqli;
|
|
$relation = linkRelation($from, $to);
|
|
$from_entity = LINK_ENTITIES[$from] ?? null;
|
|
$to_entity = LINK_ENTITIES[$to] ?? null;
|
|
if (!$relation || !$from_entity || !$to_entity) {
|
|
return [];
|
|
}
|
|
$id = intval($id);
|
|
$client_id = intval($client_id);
|
|
$extra = linkDisplayColumns($to);
|
|
// Files show their folder, so two files with the same name can be told apart
|
|
$folder_join = $to === 'file' ? 'LEFT JOIN folders ON folder_id = t.file_folder_id' : '';
|
|
$folder_column = $to === 'file' ? ', folder_name' : '';
|
|
$sql = mysqli_query($mysqli, "SELECT t.{$to_entity['id']} AS id, t.{$to_entity['name']} AS name" . ($extra ? ", $extra" : '') . "$folder_column
|
|
FROM {$to_entity['table']} t
|
|
$folder_join
|
|
LEFT JOIN {$relation['table']} l ON l.{$to_entity['id']} = t.{$to_entity['id']} AND l.{$from_entity['id']} = $id
|
|
WHERE t.{$to_entity['client']} = $client_id AND t.{$to_entity['archived']} IS NULL AND l.{$to_entity['id']} IS NULL
|
|
ORDER BY t.{$to_entity['name']} ASC");
|
|
$rows = [];
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$rows[] = $row;
|
|
}
|
|
return $rows;
|
|
}
|
|
|
|
/** How many $to records each type has linked to this $from record - for tab and section counts. */
|
|
function linkCounts($from, $id) {
|
|
$counts = [];
|
|
foreach (linkTypesFor($from) as $to) {
|
|
$counts[$to] = count(linkedRecords($from, $id, $to));
|
|
}
|
|
return $counts;
|
|
}
|
|
|
|
/** The audit log entry a link change is filed under - the service, document, file or license side of it. */
|
|
function linkLogSide($from, $id, $to, $other_id) {
|
|
foreach (['service', 'document', 'file', 'software'] as $side) {
|
|
if ($from === $side) {
|
|
return [ucfirst($side), $id];
|
|
}
|
|
if ($to === $side) {
|
|
return [ucfirst($side), $other_id];
|
|
}
|
|
}
|
|
return [ucfirst($from), $id];
|
|
}
|
|
|
|
/**
|
|
* Link $ids ($to records) to one $from record. Only records of the same
|
|
* client that are not archived are linked; anything else in $ids is skipped,
|
|
* as is anything already linked. The caller has checked the permission and
|
|
* the client access of $from. Returns the names that were linked.
|
|
*/
|
|
function linkAdd($from, $id, $to, $ids) {
|
|
global $mysqli, $session_name;
|
|
$relation = linkRelation($from, $to);
|
|
$source = linkRecord($from, $id);
|
|
if (!$relation || !$source) {
|
|
return [];
|
|
}
|
|
$from_entity = LINK_ENTITIES[$from];
|
|
$to_entity = LINK_ENTITIES[$to];
|
|
$client_id = intval($source['client_id']);
|
|
$added = [];
|
|
foreach (array_unique(array_map('intval', (array) $ids)) as $other_id) {
|
|
$other = linkRecord($to, $other_id);
|
|
if (!$other || intval($other['client_id']) !== $client_id || $other['archived_at'] !== null) {
|
|
continue;
|
|
}
|
|
// The pair is the table's primary key, so a second link of the same two is a no-op rather than an error
|
|
mysqli_query($mysqli, "INSERT IGNORE INTO {$relation['table']} SET {$from_entity['id']} = " . intval($source['id']) . ", {$to_entity['id']} = $other_id");
|
|
if (mysqli_affected_rows($mysqli) < 1) {
|
|
continue;
|
|
}
|
|
$added[] = $other['name'];
|
|
[$log_type, $log_id] = linkLogSide($from, intval($source['id']), $to, $other_id);
|
|
$log_from = $log_type === ucfirst($from) ? $source['name'] : $other['name'];
|
|
$log_other = $log_type === ucfirst($from) ? $other['name'] : $source['name'];
|
|
$log_other_label = $log_type === ucfirst($from) ? $to_entity['label'] : $from_entity['label'];
|
|
logAudit($log_type, "Link", "$session_name linked $log_other_label " . escapeSql($log_other) . " to " . strtolower($log_type) . " " . escapeSql($log_from), $client_id, $log_id);
|
|
}
|
|
return $added;
|
|
}
|
|
|
|
/** Unlink one $to record from a $from record. Returns its name, or '' if there was no such link. */
|
|
function linkRemove($from, $id, $to, $other_id) {
|
|
global $mysqli, $session_name;
|
|
$relation = linkRelation($from, $to);
|
|
$source = linkRecord($from, $id);
|
|
$other = linkRecord($to, $other_id);
|
|
if (!$relation || !$source || !$other) {
|
|
return '';
|
|
}
|
|
$from_entity = LINK_ENTITIES[$from];
|
|
$to_entity = LINK_ENTITIES[$to];
|
|
mysqli_query($mysqli, "DELETE FROM {$relation['table']} WHERE {$from_entity['id']} = " . intval($source['id']) . " AND {$to_entity['id']} = " . intval($other['id']));
|
|
if (mysqli_affected_rows($mysqli) < 1) {
|
|
return '';
|
|
}
|
|
// A device or user off a license gives back any of its keys they had
|
|
foreach (['asset', 'contact'] as $holder) {
|
|
if ([$from, $to] === ['software', $holder]) {
|
|
softwareFreeKeys($source['id'], $holder, $other['id']);
|
|
} elseif ([$from, $to] === [$holder, 'software']) {
|
|
softwareFreeKeys($other['id'], $holder, $source['id']);
|
|
}
|
|
}
|
|
[$log_type, $log_id] = linkLogSide($from, intval($source['id']), $to, intval($other['id']));
|
|
$log_from = $log_type === ucfirst($from) ? $source['name'] : $other['name'];
|
|
$log_other = $log_type === ucfirst($from) ? $other['name'] : $source['name'];
|
|
$log_other_label = $log_type === ucfirst($from) ? $to_entity['label'] : $from_entity['label'];
|
|
logAudit($log_type, "Unlink", "$session_name unlinked $log_other_label " . escapeSql($log_other) . " from " . strtolower($log_type) . " " . escapeSql($log_from), intval($source['client_id']), $log_id);
|
|
return $other['name'];
|
|
}
|
|
|
|
/** Drop every link a record has - for when it is deleted for good. */
|
|
function linkRemoveAll($type, $id) {
|
|
global $mysqli;
|
|
$entity = LINK_ENTITIES[$type] ?? null;
|
|
if (!$entity) {
|
|
return;
|
|
}
|
|
$id = intval($id);
|
|
foreach (LINK_RELATIONS as $pair => $relation) {
|
|
if (in_array($type, explode('|', $pair), true)) {
|
|
mysqli_query($mysqli, "DELETE FROM {$relation['table']} WHERE {$entity['id']} = $id");
|
|
}
|
|
}
|
|
}
|
|
|
|
/** A candidate as an option in a picker (itflow_select.js) - its icon or avatar, and a line under it. Rows as linkCandidates() gives them. */
|
|
function linkOptionData($to, $row, $client_id) {
|
|
switch ($to) {
|
|
case 'asset':
|
|
return selectOptionAsset($row['asset_type'], trim($row['asset_make'] . ' ' . $row['asset_model']));
|
|
case 'contact':
|
|
return selectOptionContact($row['contact_client_id'], $row['name'], $row['contact_photo'], $row['contact_title'], $row['id']);
|
|
case 'file':
|
|
$info = fileTypeInfo($row['file_ext']);
|
|
return selectOptionData(['icon' => 'fas fa-' . $info['icon'], 'description' => ($row['folder_name'] ?: 'Top level') . ' · ' . formatBytes(intval($row['file_size'])), 'keywords' => $row['file_description']]);
|
|
case 'document':
|
|
return selectOptionData(['icon' => 'fas fa-file-alt', 'description' => $row['document_description'] ?: 'Updated ' . timeAgo($row['document_updated_at'] ?: $row['document_created_at'])]);
|
|
case 'software':
|
|
// How many seats are left, so a full license is seen before it is picked
|
|
$seats = softwareSeats($row['seats_used'], $row['software_seats']);
|
|
$seat_text = $seats['state'] === 'unlimited' ? '' : ($seats['state'] === 'over' || $seats['state'] === 'full'
|
|
? 'Full - ' . $seats['used'] . ' of ' . $seats['total'] . ' seats'
|
|
: $seats['free'] . ' of ' . $seats['total'] . ' seats free');
|
|
return selectOptionData(['icon' => 'fas ' . softwareTypeStyle($row['software_type'])['icon'], 'description' => implode(' · ', array_filter([$row['software_license_type'], $seat_text, $row['software_version']]))]);
|
|
case 'credential':
|
|
return selectOptionCredential(['credential_name' => $row['name'], 'credential_description' => $row['credential_description'], 'credential_uri' => $row['credential_uri']], $row['credential_description'] ?: $row['credential_uri']);
|
|
case 'location':
|
|
return selectOptionData(['icon' => 'fas fa-' . (intval($row['location_primary']) ? 'building' : 'map-marker-alt'), 'description' => implode(' · ', array_filter([intval($row['location_primary']) ? 'Primary' : '', locationAddressText($row)]))]);
|
|
case 'vendor':
|
|
return selectOptionVendor($row['name'], $row['vendor_description']);
|
|
case 'service':
|
|
return selectOptionService($row);
|
|
case 'domain':
|
|
$expiry = domainExpiry($row['domain_expire'], $row['domain_auto_renew']);
|
|
return selectOptionData(['icon' => 'fas fa-globe', 'description' => implode(' · ', array_filter([$row['domain_description'], $expiry ? $expiry['text'] : '']))]);
|
|
case 'certificate':
|
|
return selectOptionCertificate(['certificate_name' => $row['name']] + $row);
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* How one linked record is drawn: its icon or avatar, its name linked to where
|
|
* it opens, and a quieter detail. Raw row in, HTML out.
|
|
*/
|
|
function linkRowHtml($to, $row, $client_id) {
|
|
$id = intval($row['id']);
|
|
$name = escapeHtml($row['name']);
|
|
$extra = '';
|
|
switch ($to) {
|
|
case 'asset':
|
|
$lead = '<span class="itflow-asset-icon itflow-asset-icon-sm itflow-asset-tone-' . assetTypeTone($row['asset_type']) . '" aria-hidden="true"><i class="fas fa-fw fa-' . getAssetIcon($row['asset_type']) . '"></i></span>';
|
|
$link = "<a class=\"ajax-modal text-truncate\" href=\"#\" data-modal-size=\"lg\" data-modal-url=\"modals/asset/asset.php?id=$id\">$name</a>";
|
|
$detail = trim($row['asset_make'] . ' ' . $row['asset_model']);
|
|
break;
|
|
case 'contact':
|
|
$lead = contactAvatar($row['contact_client_id'], $id, $row['name'], $row['contact_photo'], 'sm');
|
|
$link = "<a class=\"ajax-modal text-truncate\" href=\"#\" data-modal-size=\"lg\" data-modal-url=\"modals/contact/contact.php?id=$id\">$name</a>";
|
|
$detail = $row['contact_title'];
|
|
break;
|
|
case 'file':
|
|
$lead = fileTypeTile($row['file_ext'], 'sm');
|
|
$link = "<a class=\"ajax-modal text-truncate\" href=\"#\" data-modal-size=\"lg\" data-modal-url=\"modals/file/file.php?id=$id\">$name</a>";
|
|
$detail = fileTypeInfo($row['file_ext'])['label'] . ($row['file_size'] ? ' · ' . formatBytes(intval($row['file_size'])) : '');
|
|
break;
|
|
case 'document':
|
|
$lead = documentTile('sm');
|
|
$link = "<a class=\"text-truncate\" href=\"document.php?client_id=" . intval($client_id) . "&document_id=$id\">$name</a>";
|
|
$detail = 'Updated ' . timeAgo($row['document_updated_at'] ?: $row['document_created_at']);
|
|
break;
|
|
case 'software':
|
|
$lead = softwareTile($row['software_type'], 'sm');
|
|
$link = "<a class=\"ajax-modal text-truncate\" href=\"#\" data-modal-size=\"lg\" data-modal-url=\"modals/software/software.php?id=$id\">$name</a>";
|
|
$seats = softwareSeats($row['seats_used'] ?? 0, $row['software_seats'] ?? 0);
|
|
$renewal = softwareRenewal($row['software_expire'] ?? '');
|
|
$detail = implode(' · ', array_filter([
|
|
$row['software_license_type'] ?? '',
|
|
$seats['state'] === 'unlimited' ? '' : $seats['used'] . ' / ' . $seats['total'] . ' seats',
|
|
$renewal && $renewal['state'] !== 'active' ? $renewal['text'] : '',
|
|
]));
|
|
break;
|
|
case 'credential':
|
|
$credential = ['credential_name' => $row['name'], 'credential_description' => $row['credential_description'] ?? '', 'credential_uri' => $row['credential_uri'] ?? '', 'credential_username' => $row['credential_username'] ?? ''];
|
|
$lead = credentialTile($credential, 'sm');
|
|
$link = "<a class=\"ajax-modal text-truncate\" href=\"#\" data-modal-url=\"modals/credential/credential.php?id=$id\">$name</a>"
|
|
. (intval($row['credential_favorite'] ?? 0) ? '<i class="fas fa-star text-warning small ms-1 align-self-center" title="Starred"></i>' : '');
|
|
$detail = $row['credential_description'] ?: $row['credential_uri'];
|
|
// The login itself - username to copy, the password and 2FA code on asking
|
|
$username = credentialUsername($credential);
|
|
$extra = '<div class="small d-flex flex-wrap align-items-center column-gap-3 row-gap-1 mt-1">'
|
|
. ($username !== '' ? '<span class="d-inline-flex align-items-center min-w-0"><i class="fas fa-fw fa-user text-body-secondary me-1"></i>' . credentialUsernameHtml($username) . '</span>' : '')
|
|
. credentialPasswordHtml($id) . credentialOtpHtml($id, trim((string) ($row['credential_otp_secret'] ?? '')) !== '')
|
|
. '</div>';
|
|
break;
|
|
case 'location':
|
|
$lead = locationTile($row, 'sm');
|
|
$link = "<a class=\"ajax-modal text-truncate\" href=\"#\" data-modal-size=\"lg\" data-modal-url=\"modals/location/location.php?id=$id\">$name</a>";
|
|
$detail = locationAddressText($row);
|
|
break;
|
|
case 'vendor':
|
|
$lead = vendorTile($row['name'], 'sm');
|
|
$link = "<a class=\"ajax-modal text-truncate\" href=\"#\" data-modal-size=\"lg\" data-modal-url=\"modals/vendor/vendor.php?id=$id\">$name</a>";
|
|
$detail = implode(' · ', array_filter([$row['vendor_description'], $row['vendor_phone'] ? formatPhoneNumber($row['vendor_phone'], $row['vendor_phone_country_code']) : '']));
|
|
break;
|
|
case 'service':
|
|
$lead = serviceTile($row['service_category'] ?? '', 'sm');
|
|
$link = '<a class="text-truncate" href="' . escapeHtml(serviceUrl($id, $row['service_client_id'] ?? $client_id)) . "\">$name</a>";
|
|
$detail = implode(' · ', array_filter([$row['service_category'] ?? '', ($row['service_importance'] ?? '') ? $row['service_importance'] . ' importance' : '']));
|
|
break;
|
|
case 'domain':
|
|
$lead = domainTile('sm');
|
|
$link = "<a class=\"ajax-modal text-truncate\" href=\"#\" data-modal-size=\"lg\" data-modal-url=\"modals/domain/domain.php?id=$id\">$name</a>";
|
|
$expiry = domainExpiry($row['domain_expire'] ?? '', $row['domain_auto_renew'] ?? 0);
|
|
$detail = '';
|
|
$extra = '<div class="mt-1">' . domainExpiryPill($expiry, true) . '</div>';
|
|
break;
|
|
case 'certificate':
|
|
$lead = certificateTile('sm');
|
|
$link = "<a class=\"ajax-modal text-truncate\" href=\"#\" data-modal-size=\"lg\" data-modal-url=\"modals/certificate/certificate.php?id=$id\">$name</a>";
|
|
$detail = ($row['certificate_domain'] ?? '') !== $row['name'] ? ($row['certificate_domain'] ?? '') : '';
|
|
$problem = !in_array($row['certificate_check_status'] ?? '', [null, '', 'ok'], true);
|
|
$extra = '<div class="mt-1 d-flex flex-wrap gap-1">' . certificateExpiryPill(certificateExpiry($row), true) . ($problem ? certificateStatusPill($row) : '') . '</div>';
|
|
break;
|
|
default:
|
|
return '';
|
|
}
|
|
$detail = (string) $detail !== '' ? '<div class="small text-body-secondary text-truncate">' . escapeHtml($detail) . '</div>' : '';
|
|
return "$lead<div class=\"min-w-0 flex-grow-1\"><div class=\"d-flex\">$link</div>$detail$extra</div>";
|
|
}
|
|
|
|
/**
|
|
* Everything linked to one document or file, grouped by type - the "Linked"
|
|
* card on the document page and the section in the file and document quick
|
|
* looks. Each group has its count and, for an agent who may change it, a
|
|
* Link button and an unlink button on each row (shown on hover, like the copy
|
|
* buttons). Empty groups collapse to a single "Link a ..." line, so the card
|
|
* says what can be linked without a wall of empty headings.
|
|
*/
|
|
function linkGroupsHtml($from, $id, $client_id, $archived = false) {
|
|
$id = intval($id);
|
|
$groups = '';
|
|
$empty = [];
|
|
foreach (linkTypesFor($from) as $to) {
|
|
$entity = LINK_ENTITIES[$to];
|
|
$rows = linkedRecords($from, $id, $to);
|
|
$can_edit = !$archived && linkCanEdit($from, $to);
|
|
$link_url = "modals/link/link.php?from=$from&id=$id&to=$to";
|
|
if (!$rows) {
|
|
if ($can_edit) {
|
|
$empty[] = "<a class=\"ajax-modal btn btn-xs btn-outline-secondary\" href=\"#\" data-modal-url=\"$link_url\"><i class=\"fas {$entity['icon']} me-1\"></i>{$entity['plural']}</a>";
|
|
}
|
|
continue;
|
|
}
|
|
$items = '';
|
|
foreach ($rows as $row) {
|
|
$unlink = $can_edit
|
|
? '<a class="itflow-link-remove confirm-link" href="post.php?unlink_item&from=' . $from . '&id=' . $id . '&to=' . $to . '&other=' . intval($row['id']) . '&csrf_token=' . escapeHtml($_SESSION['csrf_token'] ?? '') . '" title="Unlink ' . $entity['label'] . '"><i class="fas fa-unlink"></i></a>'
|
|
: '';
|
|
$items .= '<li class="itflow-link-row">' . linkRowHtml($to, $row, $client_id) . $unlink . '</li>';
|
|
}
|
|
$add = $can_edit ? "<a class=\"ajax-modal ms-auto small\" href=\"#\" data-modal-url=\"$link_url\" title=\"Link {$entity['label']}\"><i class=\"fas fa-plus me-1\"></i>Link</a>" : '';
|
|
$groups .= "<div class=\"itflow-link-group\"><div class=\"itflow-link-group-head\"><i class=\"fas fa-fw {$entity['icon']} me-1\"></i>{$entity['plural']}<span class=\"badge rounded-pill ms-1\">" . count($rows) . "</span>$add</div><ul class=\"list-unstyled mb-0\">$items</ul></div>";
|
|
}
|
|
if ($empty) {
|
|
$groups .= '<div class="itflow-link-empty"><span class="small text-body-secondary me-1">Link</span>' . implode(' ', $empty) . '</div>';
|
|
}
|
|
if ($groups === '') {
|
|
$groups = '<div class="small text-body-secondary">Nothing linked.</div>';
|
|
}
|
|
return "<div class=\"itflow-links\">$groups</div>";
|
|
}
|