Tags everywhere: the same tag pills on every list and detail page, and an inline tag picker that saves as you tick (single records and bulk, with make-a-tag for admins) in place of the Assign Tags modals

This commit is contained in:
johnnyq
2026-10-13 13:22:58 -04:00
parent 57b8b626c5
commit 67e511a914
29 changed files with 1009 additions and 986 deletions

View File

@@ -40,6 +40,145 @@ if (isset($_GET['certificate_fetch_parse_json_details'])) {
}
/*
* Tag picker (js/tag_picker.js) - replaces the "Assign Tags" modals.
*
* GET lists the tags one record, or a bulk selection of records, can have and
* how many of them have each. POST ticks a tag on or off for all of them -
* or, for an admin, makes a new tag and ticks it on - and sends back the
* redrawn pills for every record, so the page updates in place.
*
* Same checks the modals' post handlers made: edit rights on the module and
* access to each record's client. tagRecordsEditable() does both.
*/
if (isset($_GET['tag_picker'])) {
header('Content-Type: application/json');
$entity = (string) ($_GET['entity'] ?? '');
$config = tagEntity($entity);
$records = $config ? tagRecordsEditable($entity, (array) ($_GET['ids'] ?? [])) : false;
if ($records === false) {
http_response_code(403);
echo json_encode(['error' => 'You cannot change the tags on these.']);
exit();
}
$ids = implode(',', array_keys($records));
$response = [
'records' => count($records),
'type' => $config['type'],
'create' => (bool) $session_is_admin,
'manage' => $session_is_admin ? "/admin/tags.php?type={$config['type']}" : '',
'tags' => []
];
$sql = mysqli_query($mysqli,
"SELECT tag_id, tag_name, tag_color, tag_icon,
(SELECT COUNT(*) FROM {$config['link_table']}
WHERE {$config['link_tag']} = tags.tag_id AND {$config['link_record']} IN ($ids)) AS chosen
FROM tags
WHERE tag_type = {$config['type']}
ORDER BY tag_name ASC"
);
while ($row = mysqli_fetch_assoc($sql)) {
$response['tags'][] = [
'id' => intval($row['tag_id']),
'name' => $row['tag_name'],
'color' => preg_match('/^(#[0-9a-f]{3,8}|[a-z]{3,24})$/i', trim((string) $row['tag_color'])) ? trim($row['tag_color']) : '',
'icon' => preg_replace('/[^a-z0-9-]/', '', strtolower((string) $row['tag_icon'])) ?: 'tag',
'chosen' => intval($row['chosen'])
];
}
echo json_encode($response);
exit();
}
if (isset($_POST['tag_picker_set']) || isset($_POST['tag_picker_create'])) {
validateCSRFToken();
header('Content-Type: application/json');
$entity = (string) ($_POST['entity'] ?? '');
$config = tagEntity($entity);
$records = $config ? tagRecordsEditable($entity, (array) ($_POST['ids'] ?? [])) : false;
if ($records === false) {
http_response_code(403);
echo json_encode(['error' => 'You cannot change the tags on these.']);
exit();
}
$type = $config['type'];
$link = $config['link_table'];
$link_record = $config['link_record'];
$link_tag = $config['link_tag'];
if (isset($_POST['tag_picker_create'])) {
// Making tags is an admin job everywhere else (Admin > Tags), so it is here too
$name = trim((string) ($_POST['name'] ?? ''));
if (!$session_is_admin || $name === '' || mb_strlen($name) > 200) {
http_response_code(403);
echo json_encode(['error' => 'That tag cannot be made here.']);
exit();
}
$name_sql = escapeSql($name);
// Typing the name of a tag that already exists just picks it
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT tag_id FROM tags WHERE tag_type = $type AND tag_name = '$name_sql' LIMIT 1"));
if ($row) {
$tag_id = intval($row['tag_id']);
} else {
$color = tagNextColor($type);
mysqli_query($mysqli, "INSERT INTO tags SET tag_name = '$name_sql', tag_type = $type, tag_color = '$color', tag_icon = 'tag'");
$tag_id = mysqli_insert_id($mysqli);
logAudit("Tag", "Create", "$session_name created tag $name_sql", 0, $tag_id);
}
$on = true;
} else {
$tag_id = intval($_POST['tag_id'] ?? 0);
$on = !empty($_POST['on']);
}
// The tag has to be one of this kind of record's tags
$tag = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT tag_name FROM tags WHERE tag_id = $tag_id AND tag_type = $type"));
if (!$tag) {
http_response_code(404);
echo json_encode(['error' => 'That tag no longer exists.']);
exit();
}
$tag_name = escapeSql($tag['tag_name']);
$changed = 0;
foreach ($records as $record_id => $record) {
$has = mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM $link WHERE $link_record = $record_id AND $link_tag = $tag_id")) > 0;
$record_name = escapeSql($record['name']);
if ($on && !$has) {
mysqli_query($mysqli, "INSERT INTO $link SET $link_record = $record_id, $link_tag = $tag_id");
logAudit($config['log'], "Edit", "$session_name added tag $tag_name to $record_name", $record['client_id'], $record_id);
$changed++;
} elseif (!$on && $has) {
mysqli_query($mysqli, "DELETE FROM $link WHERE $link_record = $record_id AND $link_tag = $tag_id");
logAudit($config['log'], "Edit", "$session_name removed tag $tag_name from $record_name", $record['client_id'], $record_id);
$changed++;
}
}
$filter = (string) ($_POST['filter'] ?? '');
$pills = [];
foreach (tagsForRecords($entity, array_keys($records)) as $record_id => $tags) {
$pills[$record_id] = tagPills($entity, $record_id, $tags, $filter);
}
echo json_encode(['tag_id' => $tag_id, 'changed' => $changed, 'pills' => $pills]);
exit();
}
if (isset($_POST['client_set_notes'])) {
validateCSRFToken();

View File

@@ -135,26 +135,8 @@ if (isset($_GET['asset_id'])) {
$document_count = mysqli_num_rows($sql_related_documents);
// Tags - many to many relationship
$asset_tag_name_display_array = array();
$asset_tag_id_array = array();
$sql_asset_tags = mysqli_query($mysqli, "SELECT tag_color, tag_icon, tag_id, tag_name FROM asset_tags LEFT JOIN tags ON asset_tag_tag_id = tag_id WHERE asset_tag_asset_id = $asset_id ORDER BY tag_name ASC");
while ($row = mysqli_fetch_assoc($sql_asset_tags)) {
$asset_tag_id = intval($row['tag_id']);
$asset_tag_name = escapeHtml($row['tag_name']);
$asset_tag_color = escapeHtml($row['tag_color']);
if (empty($asset_tag_color)) {
$asset_tag_color = "dark";
}
$asset_tag_icon = escapeHtml($row['tag_icon']);
if (empty($asset_tag_icon)) {
$asset_tag_icon = "tag";
}
$asset_tag_id_array[] = $asset_tag_id;
$asset_tag_name_display_array[] = "<a href='client_assets.php?client_id=$client_id&q=$asset_tag_name'><span class='badge text-light p-1 me-1' style='background-color: $asset_tag_color;'><i class='fa fa-fw fa-$asset_tag_icon me-2'></i>$asset_tag_name</span></a>";
}
$asset_tags_display = implode('', $asset_tag_name_display_array);
// Tags - functions/tags.php
$asset_tags_display = tagPills('asset', $asset_id, tagsForRecord('asset', $asset_id), "assets.php?client_id=$client_id&");
// Network Interfaces
$sql_related_interfaces = mysqli_query($mysqli, "
@@ -703,27 +685,6 @@ if (isset($_GET['asset_id'])) {
$credential_contact_id = intval($row['credential_contact_id']);
$credential_asset_id = intval($row['credential_asset_id']);
// Tags
$credential_tag_name_display_array = array();
$credential_tag_id_array = array();
$sql_credential_tags = mysqli_query($mysqli, "SELECT tag_color, tag_icon, credential_tags.tag_id, tag_name FROM credential_tags LEFT JOIN tags ON credential_tags.tag_id = tags.tag_id WHERE credential_id = $credential_id ORDER BY tag_name ASC");
while ($row = mysqli_fetch_assoc($sql_credential_tags)) {
$credential_tag_id = intval($row['tag_id']);
$credential_tag_name = escapeHtml($row['tag_name']);
$credential_tag_color = escapeHtml($row['tag_color']);
if (empty($credential_tag_color)) {
$credential_tag_color = "dark";
}
$credential_tag_icon = escapeHtml($row['tag_icon']);
if (empty($credential_tag_icon)) {
$credential_tag_icon = "tag";
}
$credential_tag_id_array[] = $credential_tag_id;
$credential_tag_name_display_array[] = "<a href='credentials.php?client_id=$client_id&tags[]=$credential_tag_id'><span class='badge text-light p-1 me-1' style='background-color: $credential_tag_color;'><i class='fa fa-fw fa-$credential_tag_icon me-2'></i>$credential_tag_name</span></a>";
}
$credential_tags_display = implode('', $credential_tag_name_display_array);
?>
<tr>

View File

@@ -400,10 +400,8 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
</a>
<div class="dropdown-divider"></div>
<?php } ?>
<a class="dropdown-item ajax-modal" href="#"
data-modal-url="modals/asset/asset_bulk_assign_tags.php"
data-bulk="true">
<i class="fas fa-fw fa-tags me-2"></i>Assign Tags
<a class="dropdown-item" href="#" data-tag-picker data-tag-entity="asset" data-tag-bulk data-csrf="<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-tags me-2"></i>Tags
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item ajax-modal" href="#"
@@ -638,26 +636,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
$credential_count = mysqli_num_rows($sql_credentials);
// Tags
$asset_tag_name_display_array = array();
$asset_tag_id_array = array();
$sql_asset_tags = mysqli_query($mysqli, "SELECT tag_color, tag_icon, tag_id, tag_name FROM asset_tags LEFT JOIN tags ON asset_tag_tag_id = tag_id WHERE asset_tag_asset_id = $asset_id ORDER BY tag_name ASC");
while ($row = mysqli_fetch_assoc($sql_asset_tags)) {
$asset_tag_id = intval($row['tag_id']);
$asset_tag_name = escapeHtml($row['tag_name']);
$asset_tag_color = escapeHtml($row['tag_color']);
if (empty($asset_tag_color)) {
$asset_tag_color = "dark";
}
$asset_tag_icon = escapeHtml($row['tag_icon']);
if (empty($asset_tag_icon)) {
$asset_tag_icon = "tag";
}
$asset_tag_id_array[] = $asset_tag_id;
$asset_tag_name_display_array[] = "<a href='assets.php?$client_url tags[]=$asset_tag_id'><span class='badge text-light p-1 me-1' style='background-color: $asset_tag_color;'><i class='fa fa-fw fa-$asset_tag_icon me-1'></i>$asset_tag_name</span></a>";
}
$asset_tags_display = implode('', $asset_tag_name_display_array);
$asset_tags = tagsForRecord('asset', $asset_id);
?>
<tr>
@@ -666,20 +645,18 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<input class="form-check-input bulk-select" type="checkbox" name="asset_ids[]" value="<?= $asset_id ?>">
</div>
</td>
<td>
<a class="text-dark" href="asset.php?client_id=<?= $client_id ?>&asset_id=<?= $asset_id ?>">
<div class="d-flex">
<i class="fa fa-fw fa-2x fa-<?= $device_icon ?> me-3 mt-1"></i>
<div class="flex-grow-1">
<div>
<?= $asset_name ?>
<?php if ($asset_favorite) { echo "<i class='fas fa-fw fa-star text-warning' title='Favorite'></i>"; } ?></div>
<div><small class="text-secondary"><?= $asset_description ?></small></div>
<?php if ($asset_tags_display) { echo $asset_tags_display; } ?>
</div>
<!-- The whole cell opens the asset (stretched-link); the tag pills sit above it -->
<td class="position-relative">
<div class="d-flex">
<i class="fa fa-fw fa-2x fa-<?= $device_icon ?> me-3 mt-1"></i>
<div class="flex-grow-1">
<a class="text-dark stretched-link" href="asset.php?client_id=<?= $client_id ?>&asset_id=<?= $asset_id ?>"><?= $asset_name ?> <?php if ($asset_favorite) { echo "<i class='fas fa-fw fa-star text-warning' title='Favorite'></i>"; } ?></a>
<div><small class="text-secondary"><?= $asset_description ?></small></div>
<?php if ($asset_tags || lookupUserPermission('module_client') >= 2) { ?>
<div class="mt-1"><?= tagPills('asset', $asset_id, $asset_tags, "assets.php?$client_url") ?></div>
<?php } ?>
</div>
</a>
</div>
</td>
<td>

View File

@@ -62,7 +62,7 @@ $money_format = function ($amount) use ($currency_format, $client_currency_code,
<?php if ($client_type) { ?>
<span class="text-body-secondary me-2"><?= $client_type ?></span>
<?php } ?>
<?php require 'includes/client_tags.php'; ?>
<?= tagPills('client', $client_id, $client_tags) ?>
</div>
</div>
<div class="d-flex gap-2 ms-auto itflow-ov-hero-actions">

View File

@@ -331,8 +331,8 @@ $total_leads = intval($row['leads']);
<i class="fas fa-fw fa-paper-plane me-2"></i>Send Email
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/client/client_bulk_assign_tags.php" data-bulk="true">
<i class="fas fa-fw fa-tags me-2"></i>Assign Tags
<a class="dropdown-item" href="#" data-tag-picker data-tag-entity="client" data-tag-bulk data-csrf="<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-tags me-2"></i>Tags
</a>
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/client/client_bulk_edit_industry.php" data-bulk="true">
<i class="fas fa-fw fa-briefcase me-2"></i>Set Industry
@@ -516,23 +516,7 @@ $total_leads = intval($row['leads']);
<div class="small text-body-secondary"><?= $client_type ?></div>
<?php } ?>
<?php if (!empty($client_tags[$client_id]) || $can_edit) { ?>
<div class="d-flex flex-wrap align-items-center gap-1 mt-1">
<?php foreach ($client_tags[$client_id] ?? [] as $tag) {
$tag_id = intval($tag['tag_id']);
$tag_name = escapeHtml($tag['tag_name']);
$tag_color = preg_match('/^#[0-9a-f]{3,8}$/i', (string) $tag['tag_color']) ? $tag['tag_color'] : 'var(--bs-secondary)';
$tag_icon = preg_replace('/[^a-z0-9-]/', '', strtolower((string) $tag['tag_icon'])) ?: 'tag';
?>
<a href="<?= clientsFilterUrl(['tags' => [$tag_id]]) ?>" class="itflow-client-tag" style="--itflow-tag-color: <?= $tag_color ?>" title="Show clients tagged <?= $tag_name ?>">
<i class="fas fa-fw fa-<?= $tag_icon ?>" aria-hidden="true"></i><?= $tag_name ?>
</a>
<?php } ?>
<?php if ($can_edit) { ?>
<a href="#" class="itflow-client-tag itflow-client-tag-add ajax-modal" data-modal-url="modals/client/client_bulk_assign_tags.php?client_ids[]=<?= $client_id ?>" title="Add tags to <?= $client_name ?>">
<i class="fas fa-fw fa-plus" aria-hidden="true"></i><?php if (empty($client_tags[$client_id])) { ?>Tag<?php } else { ?><span class="visually-hidden">Add tags</span><?php } ?>
</a>
<?php } ?>
</div>
<div class="mt-1"><?= tagPills('client', $client_id, $client_tags[$client_id] ?? [], $leads_filter ? 'clients.php?leads=1&' : 'clients.php?') ?></div>
<?php } ?>
</div>
</div>

View File

@@ -134,26 +134,8 @@ if (isset($_GET['contact_id'])) {
// Tags - many to many relationship
$contact_tag_name_display_array = array();
$contact_tag_id_array = array();
$sql_contact_tags = mysqli_query($mysqli, "SELECT tag_color, tag_icon, contact_tags.tag_id, tag_name FROM contact_tags LEFT JOIN tags ON contact_tags.tag_id = tags.tag_id WHERE contact_id = $contact_id ORDER BY tag_name ASC");
while ($row = mysqli_fetch_assoc($sql_contact_tags)) {
$contact_tag_id = intval($row['tag_id']);
$contact_tag_name = escapeHtml($row['tag_name']);
$contact_tag_color = escapeHtml($row['tag_color']);
if (empty($contact_tag_color)) {
$contact_tag_color = "dark";
}
$contact_tag_icon = escapeHtml($row['tag_icon']);
if (empty($contact_tag_icon)) {
$contact_tag_icon = "tag";
}
$contact_tag_id_array[] = $contact_tag_id;
$contact_tag_name_display_array[] = "<a href='client_contacts.php?client_id=$client_id&q=$contact_tag_name'><span class='badge text-light p-1 me-1' style='background-color: $contact_tag_color;'><i class='fa fa-fw fa-$contact_tag_icon me-2'></i>$contact_tag_name</span></a>";
}
$contact_tags_display = implode('', $contact_tag_name_display_array);
// Tags - functions/tags.php
$contact_tags_display = tagPills('contact', $contact_id, tagsForRecord('contact', $contact_id), "contacts.php?client_id=$client_id&");
// Notes - 1 to 1 relationship
$sql_related_notes = mysqli_query($mysqli, "SELECT contact_note, contact_note_created_at, contact_note_id, contact_note_type, user_name FROM contact_notes LEFT JOIN users ON contact_note_created_by = user_id WHERE contact_note_contact_id = $contact_id AND contact_note_archived_at IS NULL ORDER BY contact_note_created_at DESC");
@@ -436,27 +418,8 @@ if (isset($_GET['contact_id'])) {
$asset_created_at = escapeHtml($row['asset_created_at']);
$device_icon = getAssetIcon($asset_type);
// Tags
$asset_tag_name_display_array = array();
$asset_tag_id_array = array();
$sql_asset_tags = mysqli_query($mysqli, "SELECT tag_color, tag_icon, tag_id, tag_name FROM asset_tags LEFT JOIN tags ON asset_tag_tag_id = tag_id WHERE asset_tag_asset_id = $asset_id ORDER BY tag_name ASC");
while ($row = mysqli_fetch_assoc($sql_asset_tags)) {
$asset_tag_id = intval($row['tag_id']);
$asset_tag_name = escapeHtml($row['tag_name']);
$asset_tag_color = escapeHtml($row['tag_color']);
if (empty($asset_tag_color)) {
$asset_tag_color = "dark";
}
$asset_tag_icon = escapeHtml($row['tag_icon']);
if (empty($asset_tag_icon)) {
$asset_tag_icon = "tag";
}
$asset_tag_id_array[] = $asset_tag_id;
$asset_tag_name_display_array[] = "<a href='assets.php?$client_url tags[]=$asset_tag_id'><span class='badge text-light p-1 me-1' style='background-color: $asset_tag_color;'><i class='fa fa-fw fa-$asset_tag_icon me-2'></i>$asset_tag_name</span></a>";
}
$asset_tags_display = implode('', $asset_tag_name_display_array);
// Tags - functions/tags.php
$asset_tags_display = tagPills('asset', $asset_id, tagsForRecord('asset', $asset_id), "assets.php?client_id=$client_id&");
?>
<tr>
@@ -590,27 +553,6 @@ if (isset($_GET['contact_id'])) {
$credential_contact_id = intval($row['credential_contact_id']);
$credential_asset_id = intval($row['credential_asset_id']);
// Tags
$credential_tag_name_display_array = array();
$credential_tag_id_array = array();
$sql_credential_tags = mysqli_query($mysqli, "SELECT tag_color, tag_icon, credential_tags.tag_id, tag_name FROM credential_tags LEFT JOIN tags ON credential_tags.tag_id = tags.tag_id WHERE credential_id = $credential_id ORDER BY tag_name ASC");
while ($row = mysqli_fetch_assoc($sql_credential_tags)) {
$credential_tag_id = intval($row['tag_id']);
$credential_tag_name = escapeHtml($row['tag_name']);
$credential_tag_color = escapeHtml($row['tag_color']);
if (empty($credential_tag_color)) {
$credential_tag_color = "dark";
}
$credential_tag_icon = escapeHtml($row['tag_icon']);
if (empty($credential_tag_icon)) {
$credential_tag_icon = "tag";
}
$credential_tag_id_array[] = $credential_tag_id;
$credential_tag_name_display_array[] = "<a href='credentials.php?client_id=$client_id&tags[]=$credential_tag_id'><span class='badge text-light p-1 me-1' style='background-color: $credential_tag_color;'><i class='fa fa-fw fa-$credential_tag_icon me-2'></i>$credential_tag_name</span></a>";
}
$credential_tags_display = implode('', $credential_tag_name_display_array);
?>
<tr>

View File

@@ -248,10 +248,8 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<i class="fas fa-fw fa-user-shield me-2"></i>Set Roles
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item ajax-modal" href="#"
data-modal-url="modals/contact/contact_bulk_assign_tags.php"
data-bulk="true">
<i class="fas fa-fw fa-tags me-2"></i>Assign Tags
<a class="dropdown-item" href="#" data-tag-picker data-tag-entity="contact" data-tag-bulk data-csrf="<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-tags me-2"></i>Tags
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item ajax-modal" href="#"
@@ -443,26 +441,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
}
// Tags
$contact_tag_name_display_array = array();
$contact_tag_id_array = array();
$sql_contact_tags = mysqli_query($mysqli, "SELECT tag_color, tag_icon, contact_tags.tag_id, tag_name FROM contact_tags LEFT JOIN tags ON contact_tags.tag_id = tags.tag_id WHERE contact_id = $contact_id ORDER BY tag_name ASC");
while ($row = mysqli_fetch_assoc($sql_contact_tags)) {
$contact_tag_id = intval($row['tag_id']);
$contact_tag_name = escapeHtml($row['tag_name']);
$contact_tag_color = escapeHtml($row['tag_color']);
if (empty($contact_tag_color)) {
$contact_tag_color = "dark";
}
$contact_tag_icon = escapeHtml($row['tag_icon']);
if (empty($contact_tag_icon)) {
$contact_tag_icon = "tag";
}
$contact_tag_id_array[] = $contact_tag_id;
$contact_tag_name_display_array[] = "<a href='contacts.php?$client_url tags[]=$contact_tag_id'><span class='badge text-light p-1 me-1' style='background-color: $contact_tag_color;'><i class='fa fa-fw fa-$contact_tag_icon me-2'></i>$contact_tag_name</span></a>";
}
$contact_tags_display = implode('', $contact_tag_name_display_array);
$contact_tags = tagsForRecord('contact', $contact_id);
?>
<tr>
@@ -471,34 +450,29 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<input class="form-check-input bulk-select" type="checkbox" name="contact_ids[]" value="<?= $contact_id ?>">
</div>
</td>
<td>
<a class="text-dark" href="contact.php?client_id=<?= $client_id ?>&contact_id=<?= $contact_id ?>">
<div class="d-flex">
<?php if ($contact_photo) { ?>
<span class="fa-stack fa-2x me-3 text-center">
<img class="img-size-50 rounded-circle" src="<?= "../uploads/clients/$client_id/$contact_photo" ?>">
</span>
<?php } else { ?>
<span class="fa-stack fa-2x me-3">
<i class="fa fa-circle fa-stack-2x text-secondary"></i>
<span class="fa fa-stack-1x text-white"><?= $contact_initials ?></span>
</span>
<!-- The whole cell opens the contact (stretched-link); the tag pills sit above it -->
<td class="position-relative">
<div class="d-flex">
<?php if ($contact_photo) { ?>
<span class="fa-stack fa-2x me-3 text-center">
<img class="img-size-50 rounded-circle" src="<?= "../uploads/clients/$client_id/$contact_photo" ?>">
</span>
<?php } else { ?>
<span class="fa-stack fa-2x me-3">
<i class="fa fa-circle fa-stack-2x text-secondary"></i>
<span class="fa fa-stack-1x text-white"><?= $contact_initials ?></span>
</span>
<?php } ?>
<div class="flex-grow-1">
<a class="text-dark stretched-link <?php if($contact_important) { echo "text-bold"; } ?>" href="contact.php?client_id=<?= $client_id ?>&contact_id=<?= $contact_id ?>"><?= $contact_name ?> <?= $user_exists_display ?></a>
<?= $contact_title_display ?>
<div><?= $contact_primary_display ?></div>
<?php if ($contact_tags || lookupUserPermission('module_client') >= 2) { ?>
<div class="mt-1"><?= tagPills('contact', $contact_id, $contact_tags, "contacts.php?$client_url") ?></div>
<?php } ?>
<div class="flex-grow-1">
<div class="<?php if($contact_important) { echo "text-bold"; } ?>"><?= $contact_name ?> <?= $user_exists_display ?></div>
<?= $contact_title_display ?>
<div><?= $contact_primary_display ?></div>
<?php
if (!empty($contact_tags_display)) { ?>
<div class="mt-1">
<?= $contact_tags_display ?>
</div>
<?php } ?>
</div>
</div>
</a>
</div>
</td>
<td><?= $contact_department ?></td>
<td><?= $contact_info_display ?></td>

View File

@@ -252,10 +252,8 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
</button>
<?php } else { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item ajax-modal" href="#"
data-modal-url="modals/credential/credential_bulk_assign_tags.php"
data-bulk="true">
<i class="fas fa-fw fa-tags me-2"></i>Assign Tags
<a class="dropdown-item" href="#" data-tag-picker data-tag-entity="credential" data-tag-bulk data-csrf="<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-tags me-2"></i>Tags
</a>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger confirm-link"
@@ -345,26 +343,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
$asset_name = escapeHtml($row['asset_name']);
// Tags
$credential_tag_name_display_array = array();
$credential_tag_id_array = array();
$sql_credential_tags = mysqli_query($mysqli, "SELECT tag_color, tag_icon, credential_tags.tag_id, tag_name FROM credential_tags LEFT JOIN tags ON credential_tags.tag_id = tags.tag_id WHERE credential_id = $credential_id ORDER BY tag_name ASC");
while ($row = mysqli_fetch_assoc($sql_credential_tags)) {
$credential_tag_id = intval($row['tag_id']);
$credential_tag_name = escapeHtml($row['tag_name']);
$credential_tag_color = escapeHtml($row['tag_color']);
if (empty($credential_tag_color)) {
$credential_tag_color = "dark";
}
$credential_tag_icon = escapeHtml($row['tag_icon']);
if (empty($credential_tag_icon)) {
$credential_tag_icon = "tag";
}
$credential_tag_id_array[] = $credential_tag_id;
$credential_tag_name_display_array[] = "<a href='credentials.php?$client_url tags[]=$credential_tag_id'><span class='badge text-light p-1 me-1' style='background-color: $credential_tag_color;'><i class='fa fa-fw fa-$credential_tag_icon me-2'></i>$credential_tag_name</span></a>";
}
$credential_tags_display = implode('', $credential_tag_name_display_array);
$credential_tags = tagsForRecord('credential', $credential_id);
if ($credential_contact_id) {
$credential_contact_display = "<a href='#' class='me-2 mb-1 badge rounded-pill bg-dark p-2 ajax-modal' title='$contact_name'
@@ -421,23 +400,18 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<input class="form-check-input bulk-select" type="checkbox" name="credential_ids[]" value="<?= $credential_id ?>">
</div>
</td>
<td>
<a class="text-dark ajax-modal" href="#"
data-modal-url="modals/credential/credential_edit.php?id=<?= $credential_id ?>">
<div class="d-flex">
<i class="fa fa-fw fa-2x fa-key me-3"></i>
<div class="flex-grow-1">
<div><?= $credential_name ?> <?php if ($credential_favorite) { echo "<i class='fas fa-fw fa-star text-warning' title='Favorite'></i>"; } ?></div>
<div><small class="text-secondary"><?= $credential_description ?></small></div>
<?php
if (!empty($credential_tags_display)) { ?>
<div class="mt-1">
<?= $credential_tags_display ?>
</div>
<?php } ?>
</div>
<!-- The whole cell opens the credential (stretched-link); the tag pills sit above it -->
<td class="position-relative">
<div class="d-flex">
<i class="fa fa-fw fa-2x fa-key me-3"></i>
<div class="flex-grow-1">
<a class="text-dark stretched-link ajax-modal" href="#" data-modal-url="modals/credential/credential_edit.php?id=<?= $credential_id ?>"><?= $credential_name ?> <?php if ($credential_favorite) { echo "<i class='fas fa-fw fa-star text-warning' title='Favorite'></i>"; } ?></a>
<div><small class="text-secondary"><?= $credential_description ?></small></div>
<?php if ($credential_tags || lookupUserPermission('module_credential') >= 2) { ?>
<div class="mt-1"><?= tagPills('credential', $credential_id, $credential_tags, "credentials.php?$client_url") ?></div>
<?php } ?>
</div>
</a>
</div>
</td>
<td class="text-nowrap"><?= $credential_username_display ?></td>
<td class="text-nowrap">

View File

@@ -1,22 +0,0 @@
<?php
/*
* The client's tag pills, each opening the clients list filtered to that tag,
* then a "+" pill that opens Add Tags for this client. Used by the client
* header (inc_client_top_head.php) and the client overview. Expects
* $client_tags (from inc_all_client.php) and $client_id.
*/
foreach ($client_tags as $client_tag) {
$client_tag_id = intval($client_tag['tag_id']);
$client_tag_name = escapeHtml($client_tag['tag_name']);
$client_tag_color = preg_match('/^#[0-9a-f]{3,8}$/i', (string) $client_tag['tag_color']) ? $client_tag['tag_color'] : 'var(--bs-secondary)';
$client_tag_icon = preg_replace('/[^a-z0-9-]/', '', strtolower((string) $client_tag['tag_icon'])) ?: 'tag';
?>
<a href="clients.php?tags[]=<?= $client_tag_id ?>" class="itflow-client-tag" style="--itflow-tag-color: <?= $client_tag_color ?>" title="Clients tagged <?= $client_tag_name ?>">
<i class="fas fa-fw fa-<?= $client_tag_icon ?>" aria-hidden="true"></i><?= $client_tag_name ?>
</a>
<?php }
if (lookupUserPermission("module_client") >= 2) { ?>
<a href="#" class="itflow-client-tag itflow-client-tag-add ajax-modal" data-modal-url="modals/client/client_bulk_assign_tags.php?client_ids[]=<?= $client_id ?>" title="Add tags">
<i class="fas fa-fw fa-plus" aria-hidden="true"></i><?= $client_tags ? '<span class="visually-hidden">Add tags</span>' : 'Tag' ?>
</a>
<?php }

View File

@@ -78,7 +78,7 @@ if (isset($_GET['client_id'])) {
// Tab Title // No Sanitizing needed
$tab_title = $row['client_name'];
// Client Tags - raw rows, drawn by includes/client_tags.php
// Client Tags - raw rows, drawn by tagPills() (functions/tags.php)
$client_tags = [];
$sql_client_tags = mysqli_query($mysqli, "SELECT tags.tag_id, tag_name, tag_color, tag_icon FROM client_tags JOIN tags ON client_tags.tag_id = tags.tag_id WHERE client_id = $client_id ORDER BY tag_name ASC");
while ($row = mysqli_fetch_assoc($sql_client_tags)) {

View File

@@ -44,9 +44,7 @@ if (empty($client_top_head_skip)) {
</a>
<?php if ($client_is_lead) { ?><span class="badge rounded-pill text-bg-info fw-normal">Lead</span><?php } ?>
<?php if ($client_archived_at) { ?><span class="badge rounded-pill text-bg-secondary fw-normal">Archived</span><?php } ?>
<div class="d-flex flex-wrap align-items-center gap-1">
<?php require 'includes/client_tags.php'; ?>
</div>
<?= tagPills('client', $client_id, $client_tags) ?>
<?php /* Open tickets and balance due, always in sight */ ?>
<?php if ($client_head_glance_tickets || $client_head_glance_due) { ?>

View File

@@ -183,10 +183,8 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<i class="fas fa-fw fa-layer-group me-2"></i>Bulk Action (<span id="selectedCount">0</span>)
</button>
<div class="dropdown-menu">
<a class="dropdown-item ajax-modal" href="#"
data-modal-url="modals/location/location_bulk_assign_tags.php"
data-bulk="true">
<i class="fas fa-fw fa-tags me-2"></i>Assign Tags
<a class="dropdown-item" href="#" data-tag-picker data-tag-entity="location" data-tag-bulk data-csrf="<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-tags me-2"></i>Tags
</a>
<?php if ($archived) { ?>
<div class="dropdown-divider"></div>
@@ -304,27 +302,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
}
// Tags
$location_tag_name_display_array = array();
$location_tag_id_array = array();
$sql_location_tags = mysqli_query($mysqli, "SELECT tag_color, tag_icon, location_tags.tag_id, tag_name FROM location_tags LEFT JOIN tags ON location_tags.tag_id = tags.tag_id WHERE location_tags.location_id = $location_id ORDER BY tag_name ASC");
while ($row = mysqli_fetch_assoc($sql_location_tags)) {
$location_tag_id = intval($row['tag_id']);
$location_tag_name = escapeHtml($row['tag_name']);
$location_tag_color = escapeHtml($row['tag_color']);
if (empty($location_tag_color)) {
$location_tag_color = "dark";
}
$location_tag_icon = escapeHtml($row['tag_icon']);
if (empty($location_tag_icon)) {
$location_tag_icon = "tag";
}
$location_tag_id_array[] = $location_tag_id;
$location_tag_name_display_array[] = "<a href='locations.php?$client_url tags[]=$location_tag_id'><span class='badge text-light p-1 me-1' style='background-color: $location_tag_color;'><i class='fa fa-fw fa-$location_tag_icon me-2'></i>$location_tag_name</span></a>";
}
$location_tags_display = implode('', $location_tag_name_display_array);
$location_tags = tagsForRecord('location', $location_id);
?>
<tr>
@@ -333,23 +311,19 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<input class="form-check-input bulk-select" type="checkbox" name="location_ids[]" value="<?= $location_id ?>">
</div>
</td>
<td>
<a class="text-dark ajax-modal" href="#" data-modal-url="modals/location/location_edit.php?id=<?= $location_id ?>">
<div class="d-flex">
<i class="fa fa-fw fa-2x fa-map-marker-alt me-2"></i>
<div class="flex-grow-1">
<div <?php if($location_primary) { echo "class='text-bold'"; } ?>><?= $location_name ?></div>
<div><small class="text-secondary"><?= $location_description ?></small></div>
<div><?= $location_primary_display ?></div>
<?php
if (!empty($location_tags_display)) { ?>
<div class="mt-1">
<?= $location_tags_display ?>
</div>
<?php } ?>
</div>
<!-- The whole cell opens the location (stretched-link); the tag pills sit above it -->
<td class="position-relative">
<div class="d-flex">
<i class="fa fa-fw fa-2x fa-map-marker-alt me-2"></i>
<div class="flex-grow-1">
<a class="text-dark stretched-link ajax-modal <?php if($location_primary) { echo "text-bold"; } ?>" href="#" data-modal-url="modals/location/location_edit.php?id=<?= $location_id ?>"><?= $location_name ?></a>
<div><small class="text-secondary"><?= $location_description ?></small></div>
<div><?= $location_primary_display ?></div>
<?php if ($location_tags || lookupUserPermission('module_client') >= 2) { ?>
<div class="mt-1"><?= tagPills('location', $location_id, $location_tags, "locations.php?$client_url") ?></div>
<?php } ?>
</div>
</a>
</div>
</td>
<td>
<a href="//maps.<?= $session_map_source ?>.com?q=<?= "$location_address $location_zip" ?>"

View File

@@ -88,26 +88,8 @@ if ($location_archived_at) {
}
// Tags - many to many relationship
$asset_tag_name_display_array = array();
$asset_tag_id_array = array();
$sql_asset_tags = mysqli_query($mysqli, "SELECT tag_color, tag_icon, tag_id, tag_name FROM asset_tags LEFT JOIN tags ON asset_tag_tag_id = tag_id WHERE asset_tag_asset_id = $asset_id ORDER BY tag_name ASC");
while ($row = mysqli_fetch_assoc($sql_asset_tags)) {
$asset_tag_id = intval($row['tag_id']);
$asset_tag_name = escapeHtml($row['tag_name']);
$asset_tag_color = escapeHtml($row['tag_color']);
if (empty($asset_tag_color)) {
$asset_tag_color = "dark";
}
$asset_tag_icon = escapeHtml($row['tag_icon']);
if (empty($asset_tag_icon)) {
$asset_tag_icon = "tag";
}
$asset_tag_id_array[] = $asset_tag_id;
$asset_tag_name_display_array[] = "<a href='client_assets.php?client_id=$client_id&q=$asset_tag_name'><span class='badge text-light p-1 me-1' style='background-color: $asset_tag_color;'><i class='fa fa-fw fa-$asset_tag_icon me-2'></i>$asset_tag_name</span></a>";
}
$asset_tags_display = implode('', $asset_tag_name_display_array);
// Tags - functions/tags.php
$asset_tags_display = tagPills('asset', $asset_id, tagsForRecord('asset', $asset_id), "assets.php?client_id=$client_id&");
// Network Interfaces
$sql_related_interfaces = mysqli_query($mysqli, "
@@ -578,27 +560,6 @@ ob_start();
$credential_contact_id = intval($row['credential_contact_id']);
$credential_asset_id = intval($row['credential_asset_id']);
// Tags
$credential_tag_name_display_array = array();
$credential_tag_id_array = array();
$sql_credential_tags = mysqli_query($mysqli, "SELECT tag_color, tag_icon, credential_tags.tag_id, tag_name FROM credential_tags LEFT JOIN tags ON credential_tags.tag_id = tags.tag_id WHERE credential_id = $credential_id ORDER BY tag_name ASC");
while ($row = mysqli_fetch_assoc($sql_credential_tags)) {
$credential_tag_id = intval($row['tag_id']);
$credential_tag_name = escapeHtml($row['tag_name']);
$credential_tag_color = escapeHtml($row['tag_color']);
if (empty($credential_tag_color)) {
$credential_tag_color = "dark";
}
$credential_tag_icon = escapeHtml($row['tag_icon']);
if (empty($credential_tag_icon)) {
$credential_tag_icon = "tag";
}
$credential_tag_id_array[] = $credential_tag_id;
$credential_tag_name_display_array[] = "<a href='credentials.php?client_id=$client_id&tags[]=$credential_tag_id'><span class='badge text-light p-1 me-1' style='background-color: $credential_tag_color;'><i class='fa fa-fw fa-$credential_tag_icon me-2'></i>$credential_tag_name</span></a>";
}
$credential_tags_display = implode('', $credential_tag_name_display_array);
?>
<tr>

View File

@@ -1,57 +0,0 @@
<?php
require_once '../../../includes/modal_header.php';
$asset_ids = array_map('intval', $_GET['asset_ids'] ?? []);
$count = count($asset_ids);
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fa fa-fw fa-tags me-2"></i>Assign Tags for <strong><?= $count ?></strong> Assets</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<?php foreach ($asset_ids as $asset_id) { ?><input type="hidden" name="asset_ids[]" value="<?= $asset_id ?>"><?php } ?>
<div class="modal-body">
<input type="hidden" name="remove_tags" value="0">
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" name="remove_tags" value="1">
<label class="form-check-label text-danger">Remove Existing Tags</label>
</div>
<div class="mb-3">
<label>Tags</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-tags"></i></span>
<select class="form-select select2" name="tags[]" data-placeholder="Add some tags" multiple>
<?php
$sql_tags_select = mysqli_query($mysqli, "SELECT tag_id, tag_name, tag_color, tag_icon FROM tags WHERE tag_type = 5 ORDER BY tag_name ASC");
while ($row = mysqli_fetch_assoc($sql_tags_select)) {
$tag_id_select = intval($row['tag_id']);
$tag_name_select = escapeHtml($row['tag_name']);
?>
<option value="<?= $tag_id_select ?>"<?= selectOptionColor($row['tag_color'] ?? '', $row['tag_icon'] ?? '') ?>><?= $tag_name_select ?></option>
<?php } ?>
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="bulk_assign_asset_tags" class="btn btn-primary text-bold"><i class="fas fa-check me-2"></i>Assign Tags</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fa fa-times me-2"></i>Cancel</button>
</div>
</form>
<?php
require_once '../../../includes/modal_footer.php';

View File

@@ -1,79 +0,0 @@
<?php
require_once '../../../includes/modal_header.php';
enforceUserPermission('module_client', 2);
$client_ids = array_map('intval', $_GET['client_ids'] ?? []);
$count = count($client_ids);
// Opened from the + beside one client's tags on the clients list: name the
// client, and offer only the tags it doesn't already have
$single_client_id = $count === 1 ? $client_ids[0] : 0;
$existing_tags_query = '';
if ($single_client_id) {
enforceClientAccess($single_client_id);
$single_client_name = escapeHtml(getFieldById('clients', $single_client_id, 'client_name'));
$existing_tags_query = "AND tag_id NOT IN (SELECT tag_id FROM client_tags WHERE client_id = $single_client_id)";
}
ob_start();
?>
<div class="modal-header bg-dark">
<?php if ($single_client_id) { ?>
<h5 class="modal-title text-truncate"><i class="fa fa-fw fa-tags me-2"></i>Add Tags to <strong><?= $single_client_name ?></strong></h5>
<?php } else { ?>
<h5 class="modal-title"><i class="fa fa-fw fa-tags me-2"></i>Assign Tags for <strong><?= $count ?></strong> Clients</h5>
<?php } ?>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<?php foreach ($client_ids as $client_id) { ?><input type="hidden" name="client_ids[]" value="<?= $client_id ?>"><?php } ?>
<input type="hidden" name="bulk_remove_tags" value="0">
<div class="modal-body">
<?php if (!$single_client_id) { ?>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" name="bulk_remove_tags" value="1">
<label class="form-check-label text-danger">Remove Existing Tags</label>
</div>
<?php } ?>
<div class="mb-3">
<label>Tags</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-tags"></i></span>
<select class="form-select select2" name="bulk_tags[]" data-placeholder="Add some tags" multiple>
<?php
$sql_tags_select = mysqli_query($mysqli, "SELECT tag_id, tag_name, tag_color, tag_icon FROM tags WHERE tag_type = 1 $existing_tags_query ORDER BY tag_name ASC");
while ($row = mysqli_fetch_assoc($sql_tags_select)) {
$tag_id_select = intval($row['tag_id']);
$tag_name_select = escapeHtml($row['tag_name']);
?>
<option value="<?= $tag_id_select ?>"<?= selectOptionColor($row['tag_color'] ?? '', $row['tag_icon'] ?? '') ?>><?= $tag_name_select ?></option>
<?php } ?>
</select>
</div>
<?php if ($single_client_id && mysqli_num_rows($sql_tags_select) === 0) { ?>
<div class="form-text">This client already has every client tag.</div>
<?php } ?>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="bulk_assign_client_tags" class="btn btn-primary text-bold"><i class="fas fa-check me-2"></i><?= $single_client_id ? 'Add Tags' : 'Assign' ?></button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fa fa-times me-2"></i>Cancel</button>
</div>
</form>
<?php
require_once '../../../includes/modal_footer.php';

View File

@@ -116,30 +116,8 @@ $sql_related_recurring_tickets = mysqli_query($mysqli, "SELECT recurring_ticket_
$recurring_ticket_count = mysqli_num_rows($sql_related_recurring_tickets);
// Tags - many to many relationship
$contact_tag_name_display_array = array();
$contact_tag_id_array = array();
$sql_contact_tags = mysqli_query($mysqli, "SELECT tag_color, tag_icon, contact_tags.tag_id, tag_name FROM contact_tags
LEFT JOIN tags ON contact_tags.tag_id = tags.tag_id
WHERE contact_id = $contact_id
ORDER BY tag_name ASC
");
while ($row = mysqli_fetch_assoc($sql_contact_tags)) {
$contact_tag_id = intval($row['tag_id']);
$contact_tag_name = escapeHtml($row['tag_name']);
$contact_tag_color = escapeHtml($row['tag_color']);
if (empty($contact_tag_color)) {
$contact_tag_color = "dark";
}
$contact_tag_icon = escapeHtml($row['tag_icon']);
if (empty($contact_tag_icon)) {
$contact_tag_icon = "tag";
}
$contact_tag_id_array[] = $contact_tag_id;
$contact_tag_name_display_array[] = "<a href='client_contacts.php?client_id=$client_id&q=$contact_tag_name'><span class='badge text-light p-1 me-1' style='background-color: $contact_tag_color;'><i class='fa fa-fw fa-$contact_tag_icon me-2'></i>$contact_tag_name</span></a>";
}
$contact_tags_display = implode('', $contact_tag_name_display_array);
// Tags - functions/tags.php
$contact_tags_display = tagPills('contact', $contact_id, tagsForRecord('contact', $contact_id), "contacts.php?client_id=$client_id&");
// Notes - 1 to 1 relationship
$sql_related_notes = mysqli_query($mysqli, "SELECT contact_note, contact_note_created_at, contact_note_type, user_name FROM contact_notes
@@ -483,24 +461,8 @@ ob_start();
$device_icon = getAssetIcon($asset_type);
// Tags
$asset_tag_name_display_array = array();
$sql_asset_tags = mysqli_query($mysqli, "SELECT tag_color, tag_icon, tag_id, tag_name FROM asset_tags LEFT JOIN tags ON asset_tag_tag_id = tag_id WHERE asset_tag_asset_id = $asset_id ORDER BY tag_name ASC");
while ($row2 = mysqli_fetch_assoc($sql_asset_tags)) {
$asset_tag_id = intval($row2['tag_id']);
$asset_tag_name = escapeHtml($row2['tag_name']);
$asset_tag_color = escapeHtml($row2['tag_color']);
if (empty($asset_tag_color)) {
$asset_tag_color = "dark";
}
$asset_tag_icon = escapeHtml($row2['tag_icon']);
if (empty($asset_tag_icon)) {
$asset_tag_icon = "tag";
}
$asset_tag_name_display_array[] = "<a href='assets.php?$client_url tags[]=$asset_tag_id'><span class='badge text-light p-1 me-1' style='background-color: $asset_tag_color;'><i class='fa fa-fw fa-$asset_tag_icon me-2'></i>$asset_tag_name</span></a>";
}
$asset_tags_display = implode('', $asset_tag_name_display_array);
// Tags - functions/tags.php
$asset_tags_display = tagPills('asset', $asset_id, tagsForRecord('asset', $asset_id), "assets.php?client_id=$client_id&");
$asset_favorite = intval($row['asset_favorite']);
?>

View File

@@ -1,57 +0,0 @@
<?php
require_once '../../../includes/modal_header.php';
$contact_ids = array_map('intval', $_GET['contact_ids'] ?? []);
$count = count($contact_ids);
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fa fa-fw fa-tags me-2"></i>Assign Tags for <strong><?= $count ?></strong> Contacts</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<?php foreach ($contact_ids as $contact_id) { ?><input type="hidden" name="contact_ids[]" value="<?= $contact_id ?>"><?php } ?>
<div class="modal-body">
<input type="hidden" name="bulk_remove_tags" value="0">
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" name="bulk_remove_tags" value="1">
<label class="form-check-label text-danger">Remove Existing Tags</label>
</div>
<div class="mb-3">
<label>Tags</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-tags"></i></span>
<select class="form-select select2" name="bulk_tags[]" data-placeholder="Add some tags" multiple>
<?php
$sql_tags_select = mysqli_query($mysqli, "SELECT tag_id, tag_name, tag_color, tag_icon FROM tags WHERE tag_type = 3 ORDER BY tag_name ASC");
while ($row = mysqli_fetch_assoc($sql_tags_select)) {
$tag_id_select = intval($row['tag_id']);
$tag_name_select = escapeHtml($row['tag_name']);
?>
<option value="<?= $tag_id_select ?>"<?= selectOptionColor($row['tag_color'] ?? '', $row['tag_icon'] ?? '') ?>><?= $tag_name_select ?></option>
<?php } ?>
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="bulk_assign_contact_tags" class="btn btn-primary text-bold"><i class="fas fa-check me-2"></i>Assign Tags</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fa fa-times me-2"></i>Cancel</button>
</div>
</form>
<?php
require_once '../../../includes/modal_footer.php';

View File

@@ -1,61 +0,0 @@
<?php
require_once '../../../includes/modal_header.php';
$credential_ids = array_map('intval', $_GET['credential_ids'] ?? []);
$count = count($credential_ids);
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fa fa-fw fa-tags me-2"></i>Assign Tags to <strong><?= $count ?></strong> Credentials</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<?php foreach ($credential_ids as $credential_id) { ?><input type="hidden" name="credential_ids[]" value="<?= $credential_id ?>"><?php } ?>
<div class="modal-body">
<input type="hidden" name="bulk_remove_tags" value="0">
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" name="bulk_remove_tags" value="1">
<label class="form-check-label text-danger">Remove Existing Tags</label>
</div>
<div class="mb-3">
<label>Tags</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-tags"></i></span>
<select class="form-select select2" name="bulk_tags[]" data-placeholder="Add some tags" multiple>
<?php
$sql_tags_select = mysqli_query($mysqli, "SELECT tag_id, tag_name, tag_color, tag_icon FROM tags WHERE tag_type = 4 ORDER BY tag_name ASC");
while ($row = mysqli_fetch_assoc($sql_tags_select)) {
$tag_id_select = intval($row['tag_id']);
$tag_name_select = escapeHtml($row['tag_name']);
?>
<option value="<?= $tag_id_select ?>"<?= selectOptionColor($row['tag_color'] ?? '', $row['tag_icon'] ?? '') ?>><?= $tag_name_select ?></option>
<?php } ?>
</select>
<button class="btn btn-secondary ajax-modal" title="New tag" type="button"
data-modal-size="sm"
data-modal-url="../admin/modals/tag/tag_add.php?type=4">
<i class="fas fa-plus"></i>
</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="bulk_assign_credential_tags" class="btn btn-primary text-bold"><i class="fas fa-check me-2"></i>Assign Tags</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fa fa-times me-2"></i>Cancel</button>
</div>
</form>
<?php
require_once '../../../includes/modal_footer.php';

View File

@@ -1,56 +0,0 @@
<?php
require_once '../../../includes/modal_header.php';
$location_ids = array_map('intval', $_GET['location_ids'] ?? []);
$count = count($location_ids);
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fa fa-fw fa-tags me-2"></i>Assign Tags to <strong><?= $count ?></strong> Locations</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<?php foreach ($location_ids as $location_id) { ?><input type="hidden" name="location_ids[]" value="<?= $location_id ?>"><?php } ?>
<input type="hidden" name="bulk_remove_tags" value="0">
<div class="modal-body">
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" name="bulk_remove_tags" value="1">
<label class="form-check-label text-danger">Remove Existing Tags</label>
</div>
<div class="mb-3">
<label>Tags</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-tags"></i></span>
<select class="form-select select2" name="bulk_tags[]" data-placeholder="Add some tags" multiple>
<?php
$sql_tags_select = mysqli_query($mysqli, "SELECT tag_id, tag_name, tag_color, tag_icon FROM tags WHERE tag_type = 2 ORDER BY tag_name ASC");
while ($row = mysqli_fetch_assoc($sql_tags_select)) {
$tag_id_select = intval($row['tag_id']);
$tag_name_select = escapeHtml($row['tag_name']);
?>
<option value="<?= $tag_id_select ?>"<?= selectOptionColor($row['tag_color'] ?? '', $row['tag_icon'] ?? '') ?>><?= $tag_name_select ?></option>
<?php } ?>
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="bulk_assign_location_tags" class="btn btn-primary text-bold"><i class="fas fa-check me-2"></i>Assign Tags</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fa fa-times me-2"></i>Cancel</button>
</div>
</form>
<?php
require_once '../../../includes/modal_footer.php';

View File

@@ -359,54 +359,6 @@ if (isset($_GET['delete_asset_note'])) {
}
if (isset($_POST['bulk_assign_asset_tags'])) {
validateCSRFToken();
enforceUserPermission('module_client', 2);
if (isset($_POST['asset_ids'])) {
$count = count($_POST['asset_ids']);
foreach($_POST['asset_ids'] as $asset_id) {
$asset_id = intval($asset_id);
$sql = mysqli_query($mysqli,"SELECT asset_name, asset_client_id FROM assets WHERE asset_id = $asset_id");
$row = mysqli_fetch_assoc($sql);
$asset_name = escapeSql($row['asset_name']);
$client_id = intval($row['asset_client_id']);
enforceClientAccess();
if($_POST['remove_tags']) {
mysqli_query($mysqli, "DELETE FROM asset_tags WHERE asset_tag_asset_id = $asset_id");
}
if (isset($_POST['tags'])) {
foreach($_POST['tags'] as $tag) {
$tag = intval($tag);
$sql = mysqli_query($mysqli,"SELECT 1 FROM asset_tags WHERE asset_tag_asset_id = $asset_id AND asset_tag_tag_id = $tag");
if (mysqli_num_rows($sql) == 0) {
mysqli_query($mysqli, "INSERT INTO asset_tags SET asset_tag_asset_id = $asset_id, asset_tag_tag_id = $tag");
}
}
}
logAudit("Asset", "Edit", "$session_name added tags to asset $asset_name", $client_id, $asset_id);
}
logAudit("Asset", "Bulk Edit", "$session_name added tags for $asset_count assets", $client_id);
flashAlert("Assigned tags for <strong>$count</strong> assets");
}
redirect();
}
if (isset($_POST['bulk_assign_asset_location'])) {
validateCSRFToken();

View File

@@ -1003,60 +1003,6 @@ if (isset($_POST['bulk_edit_client_net_terms'])) {
}
if (isset($_POST['bulk_assign_client_tags'])) {
validateCSRFToken();
enforceUserPermission('module_client', 2);
if (isset($_POST['client_ids'])) {
// Check every selected client before changing any of them
foreach ($_POST['client_ids'] as $client_id) {
enforceClientAccess(intval($client_id));
}
$count = count($_POST['client_ids']);
foreach($_POST['client_ids'] as $client_id) {
$client_id = intval($client_id);
$sql = mysqli_query($mysqli,"SELECT client_name FROM clients WHERE client_id = $client_id");
$row = mysqli_fetch_assoc($sql);
$client_name = escapeSql($row['client_name']);
if ($_POST['bulk_remove_tags']) {
mysqli_query($mysqli, "DELETE FROM client_tags WHERE client_id = $client_id");
}
if (isset($_POST['bulk_tags'])) {
foreach($_POST['bulk_tags'] as $tag) {
$tag = intval($tag);
$sql = mysqli_query($mysqli,"SELECT 1 FROM client_tags WHERE client_id = $client_id AND tag_id = $tag");
if (mysqli_num_rows($sql) == 0) {
mysqli_query($mysqli, "INSERT INTO client_tags SET client_id = $client_id, tag_id = $tag");
}
}
}
logAudit("Client", "Edit", "$session_name added tags to $client_name", $client_id, $client_id);
}
if ($count === 1) {
flashAlert("Tags added to <strong>" . escapeHtml($row['client_name']) . "</strong>");
} else {
logAudit("Client", "Bulk Edit", "$session_name added tags for $count clients", $client_id);
flashAlert("Assigned tags for <strong>$count</strong> clients");
}
}
redirect();
}
if (isset($_POST['bulk_send_client_email']) && isset($_POST['client_ids'])) {
validateCSRFToken();

View File

@@ -511,59 +511,6 @@ if (isset($_POST['bulk_edit_contact_role'])) {
}
if (isset($_POST['bulk_assign_contact_tags'])) {
validateCSRFToken();
enforceUserPermission('module_client', 2);
// Assign Location to Selected Contacts
if (isset($_POST['contact_ids'])) {
// Get Selected Contacts Count
$count = count($_POST['contact_ids']);
foreach($_POST['contact_ids'] as $contact_id) {
$contact_id = intval($contact_id);
// Get Contact Details for Logging
$sql = mysqli_query($mysqli,"SELECT contact_name, contact_client_id FROM contacts WHERE contact_id = $contact_id");
$row = mysqli_fetch_assoc($sql);
$contact_name = escapeSql($row['contact_name']);
$client_id = intval($row['contact_client_id']);
enforceClientAccess();
if($_POST['bulk_remove_tags']) {
// Delete tags if chosed to do so
mysqli_query($mysqli, "DELETE FROM contact_tags WHERE contact_id = $contact_id");
}
// Add new tags
if (isset($_POST['bulk_tags'])) {
foreach($_POST['bulk_tags'] as $tag) {
$tag = intval($tag);
$sql = mysqli_query($mysqli,"SELECT 1 FROM contact_tags WHERE contact_id = $contact_id AND tag_id = $tag");
if (mysqli_num_rows($sql) == 0) {
mysqli_query($mysqli, "INSERT INTO contact_tags SET contact_id = $contact_id, tag_id = $tag");
}
}
}
logAudit("Contact", "Edit", "$session_name added tags to $contact_name", $client_id, $contact_id);
} // End Assign Location Loop
logAudit("Contact", "Bulk Edit", "$session_name added tags for $contact_count contacts", $client_id);
flashAlert("You assigned tags for <strong>$count</strong> contacts");
}
redirect();
}
if (isset($_POST['send_bulk_mail_now'])) {
validateCSRFToken();

View File

@@ -161,60 +161,6 @@ if (isset($_GET['delete_credential'])) {
}
if (isset($_POST['bulk_assign_credential_tags'])) {
validateCSRFToken();
enforceUserPermission('module_credential', 2);
// Assign tags to Selected Credentials
if (isset($_POST['credential_ids'])) {
// Get Selected Credential Count
$count = count($_POST['credential_ids']);
foreach($_POST['credential_ids'] as $credential_id) {
$credential_id = intval($credential_id);
// Get Contact Details for Logging
$sql = mysqli_query($mysqli,"SELECT credential_name, credential_client_id FROM credentials WHERE credential_id = $credential_id");
$row = mysqli_fetch_assoc($sql);
$credential_name = escapeSql($row['credential_name']);
$client_id = intval($row['credential_client_id']);
enforceClientAccess();
if($_POST['bulk_remove_tags']) {
// Delete tags if chosed to do so
mysqli_query($mysqli, "DELETE FROM credential_tags WHERE credential_id = $credential_id");
}
// Add new tags
if (isset($_POST['bulk_tags'])) {
foreach($_POST['bulk_tags'] as $tag) {
$tag = intval($tag);
$sql = mysqli_query($mysqli,"SELECT 1 FROM credential_tags WHERE credential_id = $credential_id AND tag_id = $tag");
if (mysqli_num_rows($sql) == 0) {
mysqli_query($mysqli, "INSERT INTO credential_tags SET credential_id = $credential_id, tag_id = $tag");
}
}
}
logAudit("Credential", "Edit", "$session_name added tags to $credential_name", $client_id, $credential_id);
flashAlert("Assigned tags for <strong>$count</strong> credentials");
} // End Assign Loop
logAudit("Credential", "Bulk Edit", "$session_name added tags to $count credentials", $client_id);
}
redirect();
}
if (isset($_POST['bulk_favorite_credentials'])) {
validateCSRFToken();

View File

@@ -209,60 +209,6 @@ if(isset($_GET['delete_location'])){
}
if (isset($_POST['bulk_assign_location_tags'])) {
validateCSRFToken();
enforceUserPermission('module_client', 2);
// Assign Tags to Selected
if (isset($_POST['location_ids'])) {
// Get Selected Count
$count = count($_POST['location_ids']);
foreach($_POST['location_ids'] as $location_id) {
$location_id = intval($location_id);
// Get Contact Details for Logging
$sql = mysqli_query($mysqli,"SELECT location_name, location_client_id FROM locations WHERE location_id = $location_id");
$row = mysqli_fetch_assoc($sql);
$location_name = escapeSql($row['location_name']);
$client_id = intval($row['location_client_id']);
enforceClientAccess();
if($_POST['bulk_remove_tags']) {
// Delete tags if chosed to do so
mysqli_query($mysqli, "DELETE FROM location_tags WHERE location_id = $location_id");
}
// Add new tags
if (isset($_POST['bulk_tags'])) {
foreach($_POST['bulk_tags'] as $tag) {
$tag = intval($tag);
$sql = mysqli_query($mysqli,"SELECT 1 FROM location_tags WHERE location_id = $location_id AND tag_id = $tag");
if (mysqli_num_rows($sql) == 0) {
mysqli_query($mysqli, "INSERT INTO location_tags SET location_id = $location_id, tag_id = $tag");
}
}
}
logAudit("Location", "Edit", "$session_name assigned tags to location $location_name", $client_id, $location_id);
} // End Assign Location Loop
logAudit("Location", "Bulk Edit", "$session_name assigned tags to $count location(s)", $client_id);
flashAlert("Assigned tags for <strong>$count</strong> locations");
}
redirect();
}
if (isset($_POST['bulk_archive_locations'])) {
validateCSRFToken();

View File

@@ -2785,41 +2785,78 @@ a.itflow-dash-rail-item:focus-visible {
font-weight: 600;
letter-spacing: .02em;
}
.itflow-client-tag {
/* Tags - functions/tags.php tagPills(), on every list and detail page that has
them. A pill opens the list filtered to that tag; the dashed "+" at the end
opens the tag picker (js/tag_picker.js). The group sits above stretched-link
row and cell links so the pills stay clickable on top of them. */
.itflow-tags {
position: relative;
z-index: 2;
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: .25rem;
max-width: 100%;
vertical-align: middle;
}
.itflow-tag {
display: inline-flex;
align-items: center;
gap: .2rem;
padding: .05rem .45rem;
border: 1px solid transparent;
border-radius: 1rem;
font-size: .75rem;
font-weight: normal;
line-height: 1.4;
color: #fff;
background-color: var(--itflow-tag-color, var(--bs-secondary));
text-decoration: none;
white-space: nowrap;
}
.itflow-client-tag:hover,
.itflow-client-tag:focus-visible {
.itflow-tag:hover,
.itflow-tag:focus-visible {
color: #fff;
filter: brightness(1.1);
}
/* The whole client cell is a link (stretched-link); the pills stay clickable on top of it */
.itflow-clients-client .itflow-client-tag {
position: relative;
z-index: 2;
}
/* "+ Tag" / "+" at the end of the tags - opens Add Tags for that client */
.itflow-client-tag-add {
.itflow-tag-add {
color: var(--bs-secondary-color);
background-color: transparent;
border: 1px dashed var(--bs-border-color);
border-color: var(--bs-border-color);
border-style: dashed;
cursor: pointer;
}
.itflow-client-tag-add:hover,
.itflow-client-tag-add:focus-visible {
.itflow-tag-add:hover,
.itflow-tag-add:focus-visible,
.itflow-tag-add[aria-expanded="true"] {
color: var(--itflow-accent, var(--bs-primary));
border-color: currentColor;
filter: none;
}
/* The picker - the select picker's panel, plus the bulk note and the dash for
a tag only some of the selected records have */
.itflow-tag-panel-note {
padding: .5rem .75rem;
border-bottom: 1px solid var(--bs-border-color-translucent);
font-size: .8125rem;
color: var(--bs-secondary-color);
}
.itflow-tag-panel .itflow-select-footer {
gap: .25rem;
padding-top: .375rem;
padding-bottom: .375rem;
}
.itflow-tag-done {
--bs-btn-padding-x: .75rem;
}
/* On a phone the sheet's own header has the Done */
.itflow-select-panel.is-sheet .itflow-tag-done {
display: none;
}
.itflow-select-option.is-mixed .itflow-select-checkbox {
border-color: var(--itflow-accent, var(--bs-primary));
color: var(--itflow-accent, var(--bs-primary));
}
.itflow-clients-street {
max-width: 16rem;
}

View File

@@ -31,3 +31,4 @@ require_once __DIR__ . '/functions/search.php';
require_once __DIR__ . '/functions/notifications.php';
require_once __DIR__ . '/functions/dashboard.php';
require_once __DIR__ . '/functions/client_overview.php';
require_once __DIR__ . '/functions/tags.php';

168
functions/tags.php Normal file
View File

@@ -0,0 +1,168 @@
<?php
/*
* Tags on clients, locations, contacts, credentials and assets.
*
* Every list and detail page draws a record's tags with tagPills(): coloured
* pills that open the list filtered to that tag, then a "+" pill that opens
* the tag picker (js/tag_picker.js). The picker ticks tags on and off in
* place through agent/ajax.php and redraws the pills from the HTML this file
* returns, so the page never reloads and nothing goes through a modal.
*/
/* What differs between the five kinds of tagged record. */
function tagEntity($entity) {
$entities = [
'client' => [
'type' => 1, 'label' => 'client', 'module' => 'module_client',
'link_table' => 'client_tags', 'link_record' => 'client_id', 'link_tag' => 'tag_id',
'table' => 'clients', 'id' => 'client_id', 'client' => 'client_id', 'name' => 'client_name',
'log' => 'Client', 'list' => 'clients.php'
],
'location' => [
'type' => 2, 'label' => 'location', 'module' => 'module_client',
'link_table' => 'location_tags', 'link_record' => 'location_id', 'link_tag' => 'tag_id',
'table' => 'locations', 'id' => 'location_id', 'client' => 'location_client_id', 'name' => 'location_name',
'log' => 'Location', 'list' => 'locations.php'
],
'contact' => [
'type' => 3, 'label' => 'contact', 'module' => 'module_client',
'link_table' => 'contact_tags', 'link_record' => 'contact_id', 'link_tag' => 'tag_id',
'table' => 'contacts', 'id' => 'contact_id', 'client' => 'contact_client_id', 'name' => 'contact_name',
'log' => 'Contact', 'list' => 'contacts.php'
],
'credential' => [
'type' => 4, 'label' => 'credential', 'module' => 'module_credential',
'link_table' => 'credential_tags', 'link_record' => 'credential_id', 'link_tag' => 'tag_id',
'table' => 'credentials', 'id' => 'credential_id', 'client' => 'credential_client_id', 'name' => 'credential_name',
'log' => 'Credential', 'list' => 'credentials.php'
],
'asset' => [
'type' => 5, 'label' => 'asset', 'module' => 'module_client',
'link_table' => 'asset_tags', 'link_record' => 'asset_tag_asset_id', 'link_tag' => 'asset_tag_tag_id',
'table' => 'assets', 'id' => 'asset_id', 'client' => 'asset_client_id', 'name' => 'asset_name',
'log' => 'Asset', 'list' => 'assets.php'
],
];
return $entities[$entity] ?? null;
}
/* Tag rows (tag_id, tag_name, tag_color, tag_icon) for each record, in one query. */
function tagsForRecords($entity, array $record_ids) {
global $mysqli;
$config = tagEntity($entity);
$record_ids = array_values(array_unique(array_filter(array_map('intval', $record_ids))));
$tags = array_fill_keys($record_ids, []);
if (!$config || !$record_ids) {
return $tags;
}
$sql = mysqli_query($mysqli,
"SELECT {$config['link_record']} AS record_id, tags.tag_id, tag_name, tag_color, tag_icon
FROM {$config['link_table']}
JOIN tags ON tags.tag_id = {$config['link_table']}.{$config['link_tag']}
WHERE {$config['link_record']} IN (" . implode(',', $record_ids) . ")
ORDER BY tag_name ASC"
);
while ($row = mysqli_fetch_assoc($sql)) {
$tags[intval($row['record_id'])][] = $row;
}
return $tags;
}
/* The tags of one record. */
function tagsForRecord($entity, $record_id) {
return tagsForRecords($entity, [$record_id])[intval($record_id)] ?? [];
}
/*
* The pills for one record, plus the "+" that opens the picker when the
* agent may edit this kind of record.
*
* $filter is where a pill goes, up to the tag: "contacts.php?client_id=4&"
* becomes contacts.php?client_id=4&tags[]=9. Empty means the list page for
* this kind of record across all clients. Nothing at all comes back for a
* record with no tags when the agent cannot add any.
*/
function tagPills($entity, $record_id, array $tags, $filter = '') {
$config = tagEntity($entity);
if (!$config) {
return '';
}
$record_id = intval($record_id);
$filter = tagFilterBase($entity, $filter);
$can_edit = lookupUserPermission($config['module']) >= 2;
if (!$tags && !$can_edit) {
return '';
}
$html = '<span class="itflow-tags" data-tag-entity="' . $entity . '" data-tag-record="' . $record_id . '" data-tag-filter="' . escapeHtml($filter) . '">';
foreach ($tags as $tag) {
$tag_id = intval($tag['tag_id']);
$name = escapeHtml($tag['tag_name']);
$color = preg_match('/^(#[0-9a-f]{3,8}|[a-z]{3,24})$/i', trim((string) $tag['tag_color'])) ? trim($tag['tag_color']) : 'var(--bs-secondary)';
$icon = preg_replace('/[^a-z0-9-]/', '', strtolower((string) $tag['tag_icon'])) ?: 'tag';
$html .= '<a href="' . escapeHtml($filter) . 'tags[]=' . $tag_id . '" class="itflow-tag" style="--itflow-tag-color: ' . $color . '" title="Show ' . $config['label'] . 's tagged ' . $name . '">'
. '<i class="fas fa-fw fa-' . $icon . '" aria-hidden="true"></i>' . $name . '</a>';
}
if ($can_edit) {
$html .= '<button type="button" class="itflow-tag itflow-tag-add" data-tag-picker data-tag-entity="' . $entity . '" data-tag-ids="' . $record_id . '" data-csrf="' . escapeHtml($_SESSION['csrf_token'] ?? '') . '" title="' . ($tags ? 'Change tags' : 'Add tags') . '">'
. '<i class="fas fa-fw fa-plus" aria-hidden="true"></i>' . ($tags ? '<span class="visually-hidden">Change tags</span>' : 'Tag') . '</button>';
}
return $html . '</span>';
}
/* A pill link base - a page of ours with a query string, or the default list. */
function tagFilterBase($entity, $filter) {
$config = tagEntity($entity);
$filter = (string) $filter;
if ($filter === '' || !preg_match('/^[a-z_]+\.php\?([a-z_]+=[0-9a-z_]*&)*$/i', $filter)) {
return $config['list'] . '?';
}
return $filter;
}
/*
* Check the agent may tag these records: edit rights on the module, and
* access to the client each record belongs to. Returns [record_id => name]
* for the ones that exist, or false if any is out of bounds.
*/
function tagRecordsEditable($entity, array $record_ids) {
global $mysqli;
$config = tagEntity($entity);
$record_ids = array_values(array_unique(array_filter(array_map('intval', $record_ids))));
if (!$config || !$record_ids || lookupUserPermission($config['module']) < 2) {
return false;
}
$records = [];
$sql = mysqli_query($mysqli,
"SELECT {$config['id']} AS record_id, {$config['client']} AS client_id, {$config['name']} AS record_name
FROM {$config['table']} WHERE {$config['id']} IN (" . implode(',', $record_ids) . ")"
);
while ($row = mysqli_fetch_assoc($sql)) {
if (!hasClientAccess(intval($row['client_id']))) {
return false;
}
$records[intval($row['record_id'])] = ['name' => $row['record_name'], 'client_id' => intval($row['client_id'])];
}
return count($records) === count($record_ids) ? $records : false;
}
/* A colour for a tag made from the picker - the next one round the palette. */
function tagNextColor($type) {
global $mysqli;
$palette = ['#0d6efd', '#198754', '#6f42c1', '#fd7e14', '#d63384', '#20c997', '#dc3545', '#0dcaf0', '#6610f2', '#ffc107'];
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(tag_id) FROM tags WHERE tag_type = " . intval($type)));
return $palette[intval($row[0]) % count($palette)];
}

View File

@@ -77,6 +77,7 @@
<script src="/libs/sweetalert2/js/sweetalert2.min.js"></script>
<script src="/js/autocomplete.js"></script>
<script src="/js/app.js"></script>
<script src="/js/tag_picker.js"></script>
<script src="/js/ajax_modal.js"></script>
<script src="/js/confirm_modal.js"></script>
<script src="/js/date_filter.js"></script>

565
js/tag_picker.js Normal file
View File

@@ -0,0 +1,565 @@
/*
* Tag picker - the "+" at the end of a record's tags, and "Assign Tags" in
* a list's bulk Action menu. Replaces the Assign Tags modals.
*
* Opens a panel under the button (a sheet from the bottom on a phone) with
* every tag this kind of record can have, ticked if it has it. Ticking or
* unticking saves straight away through agent/ajax.php and the pills on the
* page redraw from what the server sends back, so the list never reloads and
* you can tick several in a row. Admins can type a new tag name and make it
* on the spot; "Manage tags" goes to Admin > Tags.
*
* In bulk, a tag some of the selected records have shows a dash: ticking it
* gives it to all of them, unticking takes it off all of them.
*
* Markup comes from tagPills() in functions/tags.php:
* <button data-tag-picker data-tag-entity="contact" data-tag-ids="12" data-csrf="...">
* or for bulk, data-tag-bulk instead of data-tag-ids (the ticked .bulk-select
* boxes on the page are the records).
*
* Styled with the select picker's panel classes (css: "Select picker"), so
* the two look and behave alike.
*/
(function () {
'use strict';
if (window.itflowTagPicker) {
return; // re-run by an ajax modal - the listeners are already in place
}
var sheetMedia = window.matchMedia('(max-width: 575.98px)');
var finePointer = window.matchMedia('(pointer: fine)');
var open = null; // the one open picker
function make(tag, className, text) {
var node = document.createElement(tag);
if (className) {
node.className = className;
}
if (text) {
node.textContent = text;
}
return node;
}
function fold(text) {
return String(text || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
}
function csrfFor(button) {
if (button.dataset.csrf) {
return button.dataset.csrf;
}
var input = document.querySelector('input[name="csrf_token"]');
return input ? input.value : '';
}
function bulkIds() {
return Array.prototype.map.call(document.querySelectorAll('input.bulk-select:checked'), function (box) {
return box.value;
});
}
/* Swap in the pills the server sent, wherever that record is on the page */
function redraw(entity, pills) {
Object.keys(pills).forEach(function (id) {
document.querySelectorAll('.itflow-tags[data-tag-entity="' + entity + '"][data-tag-record="' + id + '"]').forEach(function (old) {
var holder = document.createElement('div');
holder.innerHTML = pills[id];
var fresh = holder.firstElementChild;
if (fresh) {
// Keep this spot's own filter links
fresh.setAttribute('data-tag-filter', old.getAttribute('data-tag-filter') || fresh.getAttribute('data-tag-filter'));
// The open picker hangs off the + in the pills being replaced -
// move it to the new +, or the next redraw finds it detached
// and closes the panel
if (open && old.contains(open.anchor)) {
var plus = fresh.querySelector('[data-tag-picker]');
if (plus) {
plus.setAttribute('aria-expanded', 'true');
open.anchor = plus;
open.button = plus;
}
}
old.replaceWith(fresh);
}
});
});
}
function filterFor(entity, ids) {
var spot = document.querySelector('.itflow-tags[data-tag-entity="' + entity + '"][data-tag-record="' + ids[0] + '"]');
return spot ? spot.getAttribute('data-tag-filter') || '' : '';
}
function Picker(button) {
this.button = button;
this.entity = button.dataset.tagEntity;
this.bulk = button.hasAttribute('data-tag-bulk');
this.ids = this.bulk ? bulkIds() : String(button.dataset.tagIds || '').split(',').filter(Boolean);
this.csrf = csrfFor(button);
// Bulk opens from a menu item that closes - hang the panel off the Action button
this.anchor = this.bulk ? (document.querySelector('#bulkActionButton > button') || button) : button;
this.query = '';
this.rows = [];
this.active = -1;
this.busy = 0;
}
Picker.prototype.build = function () {
var self = this;
var panel = this.panel = make('div', 'itflow-select-panel is-multiple itflow-tag-panel');
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-label', 'Tags');
var head = make('div', 'itflow-select-sheet-head');
head.appendChild(make('span', 'itflow-select-sheet-title', 'Tags'));
var done = make('button', 'btn btn-sm btn-link itflow-select-done', 'Done');
done.type = 'button';
done.addEventListener('click', function () {
self.close(false);
});
head.appendChild(done);
panel.appendChild(head);
if (this.bulk) {
panel.appendChild(make('div', 'itflow-tag-panel-note', this.ids.length + ' selected - ticked tags go on all of them, unticked come off'));
}
var search = make('div', 'itflow-select-search');
search.appendChild(make('i', 'fas fa-search'));
var input = this.input = make('input');
input.type = 'text';
input.autocomplete = 'off';
input.spellcheck = false;
input.setAttribute('aria-label', 'Search tags');
input.addEventListener('input', function () {
self.query = input.value;
self.draw(false);
});
input.addEventListener('keydown', function (e) {
self.key(e);
});
search.appendChild(input);
panel.appendChild(search);
var list = this.list = make('div', 'itflow-select-list');
list.setAttribute('role', 'listbox');
list.setAttribute('aria-multiselectable', 'true');
list.setAttribute('aria-label', 'Tags');
list.addEventListener('click', function (e) {
var node = e.target.closest('[data-row]');
if (node) {
self.pick(parseInt(node.getAttribute('data-row'), 10));
}
});
list.addEventListener('mousemove', function (e) {
var node = e.target.closest('[data-row]');
if (node) {
self.setActive(parseInt(node.getAttribute('data-row'), 10), false);
}
});
panel.appendChild(list);
var footer = this.footer = make('div', 'itflow-select-footer');
panel.appendChild(footer);
panel.addEventListener('mousedown', function (e) {
if (e.target !== input && !e.target.closest('a, button')) {
e.preventDefault();
}
});
this.backdrop = make('div', 'itflow-select-backdrop');
};
Picker.prototype.show = function () {
var self = this;
if (!this.entity || !this.ids.length) {
return;
}
this.build();
open = this;
this.sheet = sheetMedia.matches;
this.panel.classList.toggle('is-sheet', this.sheet);
this.input.placeholder = 'Loading...';
this.list.appendChild(make('div', 'itflow-select-empty', 'Loading tags...'));
var host = this.anchor.closest('.modal, .offcanvas') || document.body;
if (this.sheet) {
host.appendChild(this.backdrop);
}
host.appendChild(this.panel);
this.anchor.setAttribute('aria-expanded', 'true');
this.place();
itflowGet('ajax.php', { tag_picker: 1, entity: this.entity, ids: this.ids }, function (text) {
var data;
try {
data = JSON.parse(text);
} catch (e) {
data = null;
}
if (open !== self) {
return;
}
if (!data || !data.tags) {
self.fail('Could not load the tags.');
self.close(false);
return;
}
self.data = data;
self.input.placeholder = data.create ? 'Search or make a tag' : 'Search tags';
self.draw(false);
if (finePointer.matches) {
self.input.focus({ preventScroll: true });
}
}, function () {
self.fail('Could not load the tags.');
self.close(false);
});
};
Picker.prototype.close = function (refocus) {
if (open !== this) {
return;
}
open = null;
this.panel.remove();
this.backdrop.remove();
this.anchor.setAttribute('aria-expanded', 'false');
if (refocus && this.anchor.isConnected) {
this.anchor.focus({ preventScroll: true });
}
};
Picker.prototype.fail = function (message) {
if (typeof itflowToast === 'function') {
itflowToast(message, 'error');
}
};
Picker.prototype.place = function () {
if (this.sheet || open !== this) {
return;
}
var anchor = this.anchor.isConnected ? this.anchor : null;
if (!anchor) {
this.close(false);
return;
}
var rect = anchor.getBoundingClientRect();
var viewWidth = document.documentElement.clientWidth;
var viewHeight = window.innerHeight;
var width = Math.min(288, viewWidth - 16);
var panel = this.panel;
panel.style.width = width + 'px';
panel.style.left = Math.min(Math.max(8, rect.left), viewWidth - width - 8) + 'px';
this.list.style.maxHeight = '';
var height = panel.offsetHeight;
var below = viewHeight - rect.bottom - 12;
var above = rect.top - 12;
var up = height > below && above > below;
var room = up ? above : below;
if (height > room) {
this.list.style.maxHeight = Math.max(96, this.list.offsetHeight - (height - room)) + 'px';
height = panel.offsetHeight;
}
panel.style.top = (up ? rect.top - 4 - height : rect.bottom + 4) + 'px';
panel.classList.toggle('is-above', up);
};
/* The checklist, filtered by the search, and the "make it" row. */
Picker.prototype.draw = function (keep) {
var self = this;
var data = this.data;
if (!data) {
return;
}
var list = this.list;
var scroll = list.scrollTop;
var activeId = keep && this.rows[this.active] ? this.rows[this.active].id : null;
var terms = fold(this.query.trim()).split(/\s+/).filter(Boolean);
var exact = false;
list.textContent = '';
this.rows = [];
data.tags.forEach(function (tag) {
var name = fold(tag.name);
if (terms.length && !terms.every(function (t) { return name.indexOf(t) !== -1; })) {
return;
}
if (name === fold(self.query.trim())) {
exact = true;
}
var all = tag.chosen >= data.records;
var some = tag.chosen > 0 && !all;
var index = self.rows.length;
var node = make('div', 'itflow-select-option' + (all ? ' is-selected' : '') + (some ? ' is-mixed' : ''));
node.setAttribute('role', 'option');
node.setAttribute('data-row', index);
node.setAttribute('aria-selected', all ? 'true' : 'false');
node.id = 'itflow-tag-option-' + tag.id;
var box = make('span', 'itflow-select-checkbox');
box.appendChild(make('i', some ? 'fas fa-minus' : 'fas fa-check'));
node.appendChild(box);
var badge = make('span', 'itflow-select-badge');
badge.style.setProperty('--itflow-select-color', tag.color || 'var(--bs-secondary)');
badge.appendChild(make('i', 'fas fa-' + tag.icon));
badge.setAttribute('aria-hidden', 'true');
node.appendChild(badge);
var text = make('span', 'itflow-select-text');
text.appendChild(make('span', 'itflow-select-label', tag.name));
if (some) {
text.appendChild(make('span', 'itflow-select-description', tag.chosen + ' of ' + data.records));
}
node.appendChild(text);
list.appendChild(node);
self.rows.push({ node: node, id: tag.id, tag: tag });
});
var typed = this.query.trim();
if (data.create && typed && !exact) {
var index = this.rows.length;
var create = make('div', 'itflow-select-option itflow-select-create');
create.setAttribute('role', 'option');
create.setAttribute('data-row', index);
create.id = 'itflow-tag-option-new';
var plus = make('span', 'itflow-select-icon');
plus.appendChild(make('i', 'fas fa-plus'));
create.appendChild(plus);
var createText = make('span', 'itflow-select-text');
createText.appendChild(make('span', 'itflow-select-label', 'Make tag "' + typed + '"'));
create.appendChild(createText);
list.appendChild(create);
this.rows.push({ node: create, create: typed });
}
if (!this.rows.length) {
list.appendChild(make('div', 'itflow-select-empty', typed ? 'No tags match' : 'No tags yet' + (data.create ? ' - type a name to make one' : '')));
}
// Footer: how many the record has, and where tags are managed
this.footer.textContent = '';
var chosen = data.tags.filter(function (tag) { return tag.chosen >= data.records; }).length;
this.footer.appendChild(make('span', 'me-auto', chosen ? chosen + ' tagged' : 'No tags'));
if (data.manage) {
var manage = make('a', 'btn btn-sm btn-link', 'Manage tags');
manage.href = data.manage;
this.footer.appendChild(manage);
}
// Saving happens on every tick, so this only closes - no reaching
// past the panel to click off it
var done = make('button', 'btn btn-sm btn-primary itflow-tag-done', 'Done');
done.type = 'button';
done.addEventListener('click', function () {
self.close(true);
});
this.footer.appendChild(done);
var active = -1;
if (activeId !== null) {
this.rows.forEach(function (row, i) {
if (row.id === activeId) {
active = i;
}
});
}
if (keep) {
list.scrollTop = scroll;
}
this.setActive(active === -1 ? (this.rows.length ? 0 : -1) : active, false);
this.place();
};
Picker.prototype.setActive = function (index, scroll) {
var old = this.rows[this.active];
if (old) {
old.node.classList.remove('is-active');
}
this.active = index;
var row = this.rows[index];
if (!row) {
this.input.removeAttribute('aria-activedescendant');
return;
}
row.node.classList.add('is-active');
this.input.setAttribute('aria-activedescendant', row.node.id);
if (scroll) {
row.node.scrollIntoView({ block: 'nearest' });
}
};
Picker.prototype.key = function (e) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
this.setActive(Math.min(this.active + 1, this.rows.length - 1), true);
break;
case 'ArrowUp':
e.preventDefault();
this.setActive(Math.max(this.active - 1, 0), true);
break;
case 'Enter':
e.preventDefault();
this.pick(this.active);
break;
case 'Escape':
// Stop here, or a modal behind the picker closes too
e.preventDefault();
e.stopPropagation();
this.close(true);
break;
case 'Tab':
this.close(true);
break;
}
};
/* Tick or untick a tag - or make the typed one - and save it. */
Picker.prototype.pick = function (index) {
var self = this;
var row = this.rows[index];
if (!row) {
return;
}
var data = this.data;
var post = { csrf_token: this.csrf, entity: this.entity, ids: this.ids, filter: filterFor(this.entity, this.ids) };
var before = null;
if (row.create) {
post.tag_picker_create = 1;
post.name = row.create;
} else {
// A mixed tag goes on everywhere; a full one comes off everywhere
var on = row.tag.chosen < data.records;
post.tag_picker_set = 1;
post.tag_id = row.id;
post.on = on ? 1 : 0;
before = row.tag.chosen;
row.tag.chosen = on ? data.records : 0;
this.draw(true);
}
this.busy++;
itflowPost('ajax.php', post, function (text) {
self.busy--;
var result;
try {
result = JSON.parse(text);
} catch (e) {
result = null;
}
if (!result || !result.pills) {
failed();
return;
}
redraw(self.entity, result.pills);
if (row.create && open === self) {
// Load the list again so the new tag sits in its place, ticked
self.query = '';
self.input.value = '';
itflowGet('ajax.php', { tag_picker: 1, entity: self.entity, ids: self.ids }, function (fresh) {
try {
self.data = JSON.parse(fresh);
} catch (e) {
return;
}
if (open === self) {
self.draw(false);
}
});
}
}, failed);
function failed() {
self.busy--;
if (before !== null) {
row.tag.chosen = before;
if (open === self) {
self.draw(true);
}
}
self.fail('The tag change was not saved - try again.');
}
};
/* ---------- wiring ---------- */
document.addEventListener('click', function (e) {
var button = e.target.closest('[data-tag-picker]');
if (!button) {
return;
}
e.preventDefault();
e.stopPropagation();
var wasOpen = open && open.button === button;
if (open) {
open.close(false);
}
if (!wasOpen) {
new Picker(button).show();
}
});
/*
* Clicking off the panel closes it - and that click does nothing else.
* Otherwise closing the picker by clicking beside it could land on a
* row link, a pill or a button and take you somewhere.
*/
var swallow = false;
function outside(e) {
if (open && !open.panel.contains(e.target) && !e.target.closest('[data-tag-picker]')) {
open.close(false);
swallow = true;
}
}
document.addEventListener('mousedown', outside, true);
document.addEventListener('touchstart', outside, { capture: true, passive: true });
document.addEventListener('click', function (e) {
if (swallow) {
swallow = false;
e.preventDefault();
e.stopPropagation();
}
}, true);
document.addEventListener('pointerdown', function (e) {
// A fresh press that did not close a picker clears any leftover flag
// (a mousedown whose click never came, e.g. a drag off the page)
if (!open && !e.target.closest('[data-tag-picker]')) {
swallow = false;
}
}, true);
var placing = false;
function replace() {
if (!open || placing) {
return;
}
placing = true;
requestAnimationFrame(function () {
placing = false;
if (open) {
open.place();
}
});
}
window.addEventListener('resize', replace);
document.addEventListener('scroll', function (e) {
if (open && open.panel.contains(e.target)) {
return;
}
replace();
}, true);
document.addEventListener('hide.bs.modal', function (e) {
if (open && e.target.contains(open.anchor)) {
open.close(false);
}
});
window.itflowTagPicker = true;
})();