Files
itflow/agent/modals/link/link.php
johnnyq 9e55d52432 Vendors overhaul for daily MSP work: new vendor page built around calling them, with Call support (dials the extension), Email, Support site, a star, and a New menu (case/ticket with them, license from them, asset bought from them, portal login, document, upload, or expense and recurring expense for our own vendors), Support card (phone, hours, SLA, email, site), Account card (account number, PIN shown as dots with show and copy, account manager name, direct line and email), blur-to-save notes, an Also used by list of the agent's other clients with the same vendor by name or template, and tabs for Cases (open first, with their ticket number), Licenses (soonest renewal first), Assets (serial, order ref, warranty status), Domains (registrar, web host, DNS, mail), Services, Logins, Documents, Files and History; our own vendors show spend this year, last year and in all, recent expenses and recurring expenses; new vendor quick look; rebuilt vendors list with starred first, initials tiles coloured by vendor name, support line, hours and SLA, account number and PIN, chips for open cases, licenses, assets, domains, services and logins, an Ours / Clients' switch with an all-clients view in All Client Docs, wider search (account manager, emails, tags), tag filter and bulk Tags/Archive/Restore/Delete; shared tabbed vendor form (Details, Support, Account, Notes) with star, tags and start from template that fills every field; Add a Template to Clients to roll one vendor out to many clients, skipping those that have it; Admin Vendor Templates shows how many clients use each template and has Add to clients; vendor tags (tag type 6) with vendor tags in the Tags starter pack; ticket vendor block shows their ticket number, support line, hours, SLA, account number and PIN with copy buttons; vendor tiles in every vendor picker and quick look links across tickets, licenses, assets, domains, services and expense lists; search opens the vendor page; CSV export adds tags, open cases, account manager and PIN; logins, documents and files can be linked to a vendor and ones made from a vendor's page are linked to it; demo vendors get tags, a star and an account manager; database update 2.8.2 adds vendor_tags and vendor_contact_phone_country_code, vendor_contact_phone, vendor_contact_email (db.sql updated) and clears references to vendors that no longer exist; ticket vendor now validated against the ticket's client and removing a vendor from a ticket is logged as such; deleting a vendor from a template no longer unlinks every other vendor from that template; deleting a vendor clears it from assets, licenses, tickets and domains, and vendors with expenses, recurring expenses or payment provider fees are archived rather than deleted; vendor websites without a scheme open correctly; domain form vendor names escaped; new vendor templates keep the phone country code and pushing a template update with nothing ticked no longer errors; vendors API accepts the account manager fields; new functions/vendor.php (VENDOR_USES, vendorCan/vendorEnforce with client vendors on module_client and ours on module_financial, tile, link, website, PIN, form, tags, counts, delete check, reference clearing, also used by, spend, history), credential|vendor and file|vendor link relations, tag module_ours for our own vendors, handlers add_vendor_template_to_clients, favorite_vendor, unfavorite_vendor and ajax vendor_set_notes, vendor_model.php removed
2026-10-14 22:49:05 -04:00

169 lines
8.6 KiB
PHP

<?php
/*
* Link things to a document or a file - or a document or file to an asset or
* contact, devices, users and credentials to a license. One modal for every
* pair in LINK_RELATIONS (functions/links.php):
* ?from=document&id=81&to=asset opens on Assets; the pills along the top
* switch to anything else this record can be linked to. Pick as many as you
* like; only the client's own, unarchived, not-yet-linked records are offered.
*
* The old one-pair modals (document_link_asset.php, asset_link_file.php...)
* are thin wrappers that set from/id/to and include this.
*/
require_once '../../../includes/modal_header.php';
$link_from = (string) ($_GET['from'] ?? '');
$link_id = intval($_GET['id'] ?? 0);
$link_source = linkRecord($link_from, $link_id);
if (!$link_source) {
echo json_encode(['error' => 'That record no longer exists.']);
exit;
}
$client_id = intval($link_source['client_id']);
enforceClientAccess();
// Only the kinds this agent may change
$link_types = array_values(array_filter(linkTypesFor($link_from), function ($to) use ($link_from) {
return linkCanEdit($link_from, $to);
}));
if (!$link_types) {
echo json_encode(['error' => 'You do not have permission to change these links.']);
exit;
}
$link_to = in_array($_GET['to'] ?? '', $link_types, true) ? $_GET['to'] : $link_types[0];
$from_entity = LINK_ENTITIES[$link_from];
$link_name = escapeHtml($link_source['name']);
/** The option extras for one candidate - its icon or avatar, and a line under it. */
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 selectOptionData(['icon' => 'fas fa-key', 'description' => $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']);
}
return '';
}
$panes = [];
foreach ($link_types as $to) {
$panes[$to] = [
'entity' => LINK_ENTITIES[$to],
'linked' => count(linkedRecords($link_from, $link_id, $to)),
'candidates' => linkCandidates($link_from, $link_id, $to, $client_id),
];
}
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title text-truncate"><i class="fas fa-fw fa-link me-2"></i>Link to <strong><?= $link_name ?></strong></h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" autocomplete="off" data-link-form>
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="from" value="<?= escapeHtml($link_from) ?>">
<input type="hidden" name="id" value="<?= $link_id ?>">
<input type="hidden" name="to" value="<?= escapeHtml($link_to) ?>" data-link-to>
<div class="modal-body">
<?php if (count($panes) > 1) { ?>
<nav class="nav nav-pills itflow-asset-pills itflow-link-types mb-3" role="tablist">
<?php foreach ($panes as $to => $pane) { ?>
<button type="button" class="nav-link <?= $to === $link_to ? 'active' : '' ?>" data-bs-toggle="pill" data-bs-target="#linkPane-<?= $to ?>" data-link-type="<?= $to ?>" role="tab">
<i class="fas fa-fw <?= $pane['entity']['icon'] ?> me-1"></i><?= $pane['entity']['plural'] ?>
<?php if ($pane['linked']) { ?><span class="badge rounded-pill ms-1" title="Already linked"><?= $pane['linked'] ?></span><?php } ?>
</button>
<?php } ?>
</nav>
<?php } ?>
<div class="tab-content">
<?php foreach ($panes as $to => $pane) {
$entity = $pane['entity']; ?>
<div class="tab-pane fade <?= $to === $link_to ? 'show active' : '' ?>" id="linkPane-<?= $to ?>" role="tabpanel">
<?php if ($pane['candidates']) { ?>
<label class="form-label">Which <?= strtolower($entity['plural']) ?>?</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw <?= $entity['icon'] ?>"></i></span>
<select class="form-select select2" name="ids[]" multiple data-placeholder="Pick one or more <?= strtolower($entity['plural']) ?>" <?= $to === $link_to ? '' : 'disabled' ?> data-link-select="<?= $to ?>">
<?php foreach ($pane['candidates'] as $row) { ?>
<option value="<?= intval($row['id']) ?>"<?= linkOptionData($to, $row, $client_id) ?>><?= escapeHtml($row['name']) ?></option>
<?php } ?>
</select>
</div>
<div class="form-text"><?= count($pane['candidates']) ?> <?= count($pane['candidates']) === 1 ? $entity['label'] : strtolower($entity['plural']) ?> not linked yet<?= $pane['linked'] ? ', ' . $pane['linked'] . ' already linked' : '' ?>.</div>
<?php } else { ?>
<div class="itflow-asset-empty py-4">
<i class="fas <?= $entity['icon'] ?> fa-2x mb-2"></i>
<div><?= $pane['linked'] ? 'Every ' . $entity['label'] . ' this client has is already linked.' : 'This client has no ' . strtolower($entity['plural']) . ' to link.' ?></div>
</div>
<?php } ?>
</div>
<?php } ?>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="link_items" class="btn btn-primary text-bold"><i class="fas fa-link me-2"></i>Link</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fa fa-times me-2"></i>Cancel</button>
</div>
</form>
<script>
(function () {
// Only the pane on show posts its picks
var form = document.querySelector('form[data-link-form]:not([data-wired])');
if (!form) {
return;
}
form.dataset.wired = '1';
var to = form.querySelector('[data-link-to]');
form.querySelectorAll('[data-link-type]').forEach(function (pill) {
pill.addEventListener('shown.bs.tab', function () {
to.value = pill.dataset.linkType;
form.querySelectorAll('[data-link-select]').forEach(function (select) {
select.disabled = select.dataset.linkSelect !== to.value;
});
});
});
form.addEventListener('submit', function (e) {
var select = form.querySelector('[data-link-select="' + to.value + '"]');
if (!select || !select.selectedOptions.length) {
e.preventDefault();
if (select && select.itflowSelect) {
select.itflowSelect.open();
}
}
});
})();
</script>
<?php
require_once '../../../includes/modal_footer.php';