From 67e511a914cb0faf1a833c60c09e45ed470506c3 Mon Sep 17 00:00:00 2001 From: johnnyq Date: Tue, 13 Oct 2026 13:22:58 -0400 Subject: [PATCH] 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 --- agent/ajax.php | 139 +++++ agent/asset.php | 43 +- agent/assets.php | 51 +- agent/client_overview.php | 2 +- agent/clients.php | 22 +- agent/contact.php | 66 +- agent/contacts.php | 74 +-- agent/credentials.php | 54 +- agent/includes/client_tags.php | 22 - agent/includes/inc_all_client.php | 2 +- agent/includes/inc_client_top_head.php | 4 +- agent/locations.php | 56 +- agent/modals/asset/asset.php | 43 +- agent/modals/asset/asset_bulk_assign_tags.php | 57 -- .../modals/client/client_bulk_assign_tags.php | 79 --- agent/modals/contact/contact.php | 46 +- .../contact/contact_bulk_assign_tags.php | 57 -- .../credential_bulk_assign_tags.php | 61 -- .../location/location_bulk_assign_tags.php | 56 -- agent/post/asset.php | 48 -- agent/post/client.php | 54 -- agent/post/contact.php | 53 -- agent/post/credential.php | 54 -- agent/post/location.php | 54 -- css/itflow_custom.css | 63 +- functions.php | 1 + functions/tags.php | 168 ++++++ includes/footer.php | 1 + js/tag_picker.js | 565 ++++++++++++++++++ 29 files changed, 1009 insertions(+), 986 deletions(-) delete mode 100644 agent/includes/client_tags.php delete mode 100644 agent/modals/asset/asset_bulk_assign_tags.php delete mode 100644 agent/modals/client/client_bulk_assign_tags.php delete mode 100644 agent/modals/contact/contact_bulk_assign_tags.php delete mode 100644 agent/modals/credential/credential_bulk_assign_tags.php delete mode 100644 agent/modals/location/location_bulk_assign_tags.php create mode 100644 functions/tags.php create mode 100644 js/tag_picker.js diff --git a/agent/ajax.php b/agent/ajax.php index 67cfd38ac..1d2a0499a 100644 --- a/agent/ajax.php +++ b/agent/ajax.php @@ -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(); diff --git a/agent/asset.php b/agent/asset.php index cb6e8b647..1ba5498b8 100644 --- a/agent/asset.php +++ b/agent/asset.php @@ -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[] = "$asset_tag_name"; - } - $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[] = "$credential_tag_name"; - } - $credential_tags_display = implode('', $credential_tag_name_display_array); ?> diff --git a/agent/assets.php b/agent/assets.php index 80c98e602..7bea2d150 100644 --- a/agent/assets.php +++ b/agent/assets.php @@ -400,10 +400,8 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); - - Assign Tags + + Tags @@ -666,20 +645,18 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); - - -
- -
-
- - - "; } ?>
-
- -
+ + +
+ +
+ "; } ?> +
+ = 2) { ?> +
+
- +
diff --git a/agent/client_overview.php b/agent/client_overview.php index 77d662dc3..5c87ab2b9 100644 --- a/agent/client_overview.php +++ b/agent/client_overview.php @@ -62,7 +62,7 @@ $money_format = function ($amount) use ($currency_format, $client_currency_code, - +
diff --git a/agent/clients.php b/agent/clients.php index a6f330e4c..f7acda9c0 100644 --- a/agent/clients.php +++ b/agent/clients.php @@ -331,8 +331,8 @@ $total_leads = intval($row['leads']); Send Email - - Assign Tags + + Tags Set Industry @@ -516,23 +516,7 @@ $total_leads = intval($row['leads']);
-
+
diff --git a/agent/contact.php b/agent/contact.php index e32cd499a..06039c7bc 100644 --- a/agent/contact.php +++ b/agent/contact.php @@ -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[] = "$contact_tag_name"; - } - $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[] = "$asset_tag_name"; - } - $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&"); ?> @@ -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[] = "$credential_tag_name"; - } - $credential_tags_display = implode('', $credential_tag_name_display_array); ?> diff --git a/agent/contacts.php b/agent/contacts.php index 6eaf1803f..e85426713 100644 --- a/agent/contacts.php +++ b/agent/contacts.php @@ -248,10 +248,8 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); Set Roles - - Assign Tags + + Tags @@ -471,34 +450,29 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); - - -
- - - "> - - - - - - + + + diff --git a/agent/credentials.php b/agent/credentials.php index 17b9d702a..6c0c064aa 100644 --- a/agent/credentials.php +++ b/agent/credentials.php @@ -252,10 +252,8 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); - - Assign Tags + + Tags
- - -
- -
-
"; } ?>
-
- -
- -
- -
+ + +
+ +
+ "; } ?> +
+ = 2) { ?> +
+
- +
diff --git a/agent/includes/client_tags.php b/agent/includes/client_tags.php deleted file mode 100644 index 3422cc43d..000000000 --- a/agent/includes/client_tags.php +++ /dev/null @@ -1,22 +0,0 @@ - - - - -= 2) { ?> - - Add tags' : 'Tag' ?> - - Lead Archived -
- -
+ diff --git a/agent/locations.php b/agent/locations.php index 68e8bffd5..47ce49fc4 100644 --- a/agent/locations.php +++ b/agent/locations.php @@ -183,10 +183,8 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); Bulk Action (0) - - -
- -
-
>
-
-
- -
- -
- -
+ + +
" diff --git a/agent/modals/asset/asset.php b/agent/modals/asset/asset.php index c4ad86918..09ab6d2dd 100644 --- a/agent/modals/asset/asset.php +++ b/agent/modals/asset/asset.php @@ -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[] = "$asset_tag_name"; -} -$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[] = "$credential_tag_name"; - } - $credential_tags_display = implode('', $credential_tag_name_display_array); ?> diff --git a/agent/modals/asset/asset_bulk_assign_tags.php b/agent/modals/asset/asset_bulk_assign_tags.php deleted file mode 100644 index 539681897..000000000 --- a/agent/modals/asset/asset_bulk_assign_tags.php +++ /dev/null @@ -1,57 +0,0 @@ - - - -
- - - - - - -
- - - - - -
- - - - - - - -
- -$contact_tag_name"; -} -$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[] = "$asset_tag_name"; - } - $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']); ?> diff --git a/agent/modals/contact/contact_bulk_assign_tags.php b/agent/modals/contact/contact_bulk_assign_tags.php deleted file mode 100644 index c6d4cf4c7..000000000 --- a/agent/modals/contact/contact_bulk_assign_tags.php +++ /dev/null @@ -1,57 +0,0 @@ - - - -
- - - - - - -
- - - - -
- - - - - -
- - - - -
- - - - - - -
- -$count assets"); - } - - redirect(); - -} - if (isset($_POST['bulk_assign_asset_location'])) { validateCSRFToken(); diff --git a/agent/post/client.php b/agent/post/client.php index ba1a016d1..6ec360dd9 100644 --- a/agent/post/client.php +++ b/agent/post/client.php @@ -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 " . escapeHtml($row['client_name']) . ""); - } else { - logAudit("Client", "Bulk Edit", "$session_name added tags for $count clients", $client_id); - - flashAlert("Assigned tags for $count clients"); - } - } - - redirect(); - -} - if (isset($_POST['bulk_send_client_email']) && isset($_POST['client_ids'])) { validateCSRFToken(); diff --git a/agent/post/contact.php b/agent/post/contact.php index 5b3b3e3dc..08cc5ecdb 100644 --- a/agent/post/contact.php +++ b/agent/post/contact.php @@ -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 $count contacts"); - } - - redirect(); - -} - if (isset($_POST['send_bulk_mail_now'])) { validateCSRFToken(); diff --git a/agent/post/credential.php b/agent/post/credential.php index a884a43e6..c4a2d2874 100644 --- a/agent/post/credential.php +++ b/agent/post/credential.php @@ -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 $count 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(); diff --git a/agent/post/location.php b/agent/post/location.php index 01619678c..3cd4a19d0 100644 --- a/agent/post/location.php +++ b/agent/post/location.php @@ -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 $count locations"); - - } - - redirect(); - -} - if (isset($_POST['bulk_archive_locations'])) { validateCSRFToken(); diff --git a/css/itflow_custom.css b/css/itflow_custom.css index c5e6fbd9a..72ea34ddf 100644 --- a/css/itflow_custom.css +++ b/css/itflow_custom.css @@ -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; } diff --git a/functions.php b/functions.php index ec04db7dd..f0c4c1793 100644 --- a/functions.php +++ b/functions.php @@ -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'; diff --git a/functions/tags.php b/functions/tags.php new file mode 100644 index 000000000..bcda2ef76 --- /dev/null +++ b/functions/tags.php @@ -0,0 +1,168 @@ + [ + '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 = ''; + + 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 .= '' + . '' . $name . ''; + } + + if ($can_edit) { + $html .= ''; + } + + return $html . ''; +} + +/* 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)]; +} diff --git a/includes/footer.php b/includes/footer.php index 57310473a..56b958f20 100644 --- a/includes/footer.php +++ b/includes/footer.php @@ -77,6 +77,7 @@ + diff --git a/js/tag_picker.js b/js/tag_picker.js new file mode 100644 index 000000000..0ab8e0b97 --- /dev/null +++ b/js/tag_picker.js @@ -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: + *