mirror of
https://github.com/itflow-org/itflow
synced 2026-08-04 22:57:14 +00:00
Feature: Add Dynamic Task editing in add ticket and add recurring ticket, recurring tickets now have their own task table as well
This commit is contained in:
34
admin/database_updates/2.5.6.php
Normal file
34
admin/database_updates/2.5.6.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* ITFlow - Database update to version 2.5.6 (from 2.5.5)
|
||||
* Included by admin/database_updates.php - do not access directly
|
||||
*/
|
||||
|
||||
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
|
||||
|
||||
// Recurring tickets now own their task list rather than reading the linked
|
||||
// ticket template at every run. The template still fills the list in when it
|
||||
// is picked, but the list can then be edited per schedule - which is only
|
||||
// meaningful if the edits are what the run actually reads.
|
||||
mysqli_query($mysqli, "CREATE TABLE `recurring_ticket_tasks` (
|
||||
`recurring_ticket_task_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`recurring_ticket_task_name` varchar(255) NOT NULL,
|
||||
`recurring_ticket_task_order` int(11) NOT NULL DEFAULT 0,
|
||||
`recurring_ticket_task_completion_estimate` int(11) NOT NULL DEFAULT 0,
|
||||
`recurring_ticket_task_recurring_ticket_id` int(11) NOT NULL,
|
||||
PRIMARY KEY (`recurring_ticket_task_id`),
|
||||
KEY `recurring_ticket_task_recurring_ticket_id` (`recurring_ticket_task_recurring_ticket_id`),
|
||||
CONSTRAINT `recurring_ticket_tasks_ibfk_1` FOREIGN KEY (`recurring_ticket_task_recurring_ticket_id`) REFERENCES `recurring_tickets` (`recurring_ticket_id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci");
|
||||
|
||||
// Copy each linked template's current tasks onto the schedule that links it, so
|
||||
// every existing recurring ticket keeps raising exactly the tasks it raises
|
||||
// today. Without this the switch to an owned list would silently produce
|
||||
// taskless tickets on the next run.
|
||||
mysqli_query($mysqli, "INSERT INTO recurring_ticket_tasks
|
||||
(recurring_ticket_task_name, recurring_ticket_task_order, recurring_ticket_task_completion_estimate, recurring_ticket_task_recurring_ticket_id)
|
||||
SELECT task_template_name, task_template_order, task_template_completion_estimate, recurring_ticket_id
|
||||
FROM recurring_tickets
|
||||
INNER JOIN task_templates ON task_template_ticket_template_id = recurring_ticket_ticket_template_id
|
||||
WHERE recurring_ticket_ticket_template_id > 0");
|
||||
53
agent/includes/inc_ticket_tasks_section.php
Normal file
53
agent/includes/inc_ticket_tasks_section.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* ITFlow - Editable task rows
|
||||
*
|
||||
* Shared by the ticket add modal and the recurring ticket add/edit modals. Rows
|
||||
* are added and removed in the browser by agent/js/ticket_tasks_modal.js and
|
||||
* submit as parallel tasks[] / task_estimates[] arrays, read back by
|
||||
* parseSubmittedTasks().
|
||||
*
|
||||
* Set $existing_tasks before including to pre-fill rows - a list of
|
||||
* ['name' => string, 'estimate' => int]. Defaults to none.
|
||||
*
|
||||
* The tasks_submitted marker distinguishes "the user cleared every row" from
|
||||
* "this form has no task section", which an empty tasks[] cannot express because
|
||||
* a form with no inputs of that name posts nothing at all.
|
||||
*/
|
||||
|
||||
$existing_tasks = $existing_tasks ?? [];
|
||||
|
||||
?>
|
||||
|
||||
<input type="hidden" name="tasks_submitted" value="1">
|
||||
|
||||
<div class="form-group">
|
||||
<label>Tasks</label>
|
||||
|
||||
<div class="form-row mb-1 text-muted small">
|
||||
<div class="col-7">Task</div>
|
||||
<div class="col-3">Estimate (mins)</div>
|
||||
<div class="col-2"></div>
|
||||
</div>
|
||||
|
||||
<div id="ticketTasksContainer">
|
||||
<?php foreach ($existing_tasks as $existing_task) { ?>
|
||||
<div class="form-row mb-2 ticket-task-row">
|
||||
<div class="col-7">
|
||||
<input type="text" class="form-control" name="tasks[]" placeholder="Task name" maxlength="255" value="<?= escapeHtml($existing_task['name']) ?>">
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<input type="number" class="form-control" name="task_estimates[]" placeholder="Mins" min="0" value="<?= intval($existing_task['estimate']) ?: '' ?>">
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<button type="button" class="btn btn-secondary btn-block ticket-task-remove" title="Remove task"><i class="fa fa-fw fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<?php } ?>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-secondary" id="ticketTaskAdd"><i class="fas fa-plus mr-2"></i>Add Task</button>
|
||||
|
||||
<small class="form-text text-muted">Leave the estimate blank if you don't track one. Blank task names are ignored.</small>
|
||||
</div>
|
||||
73
agent/includes/inc_ticket_template_select.php
Normal file
73
agent/includes/inc_ticket_template_select.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* ITFlow - Ticket template picker
|
||||
*
|
||||
* Shared by the ticket add modal and the recurring ticket add/edit modals. Each
|
||||
* option carries the template's subject, details and task list as data
|
||||
* attributes, which agent/js/ticket_tasks_modal.js applies to the form when the
|
||||
* template is picked.
|
||||
*
|
||||
* Set $selected_ticket_template_id before including to pre-select a template (an
|
||||
* archived one stays listed while it is the one selected, so an existing link
|
||||
* remains visible). Defaults to none.
|
||||
*/
|
||||
|
||||
$selected_ticket_template_id = intval($selected_ticket_template_id ?? 0);
|
||||
|
||||
// Every template's tasks in one pass, rather than a query per option
|
||||
$ticket_template_tasks = [];
|
||||
|
||||
$sql_ticket_template_tasks = mysqli_query(
|
||||
$mysqli,
|
||||
"SELECT task_template_ticket_template_id, task_template_name, task_template_completion_estimate
|
||||
FROM task_templates
|
||||
ORDER BY task_template_order ASC, task_template_id ASC"
|
||||
);
|
||||
|
||||
while ($row = mysqli_fetch_assoc($sql_ticket_template_tasks)) {
|
||||
$ticket_template_tasks[intval($row['task_template_ticket_template_id'])][] = [
|
||||
'name' => $row['task_template_name'],
|
||||
'estimate' => intval($row['task_template_completion_estimate'])
|
||||
];
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Template</label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-cube"></i></span>
|
||||
</div>
|
||||
<select class="form-control select2" id="ticket_template_select" name="ticket_template_id">
|
||||
<option value="0">- No Template -</option>
|
||||
<?php
|
||||
$sql_ticket_templates = mysqli_query(
|
||||
$mysqli,
|
||||
"SELECT ticket_template_id, ticket_template_name, ticket_template_subject, ticket_template_details
|
||||
FROM ticket_templates
|
||||
WHERE ticket_template_archived_at IS NULL OR ticket_template_id = $selected_ticket_template_id
|
||||
ORDER BY ticket_template_name ASC"
|
||||
);
|
||||
|
||||
while ($row = mysqli_fetch_assoc($sql_ticket_templates)) {
|
||||
$ticket_template_id_select = intval($row['ticket_template_id']);
|
||||
$ticket_template_name_select = escapeHtml($row['ticket_template_name']);
|
||||
$ticket_template_subject_select = escapeHtml($row['ticket_template_subject']);
|
||||
$ticket_template_details_select = escapeHtml($row['ticket_template_details']);
|
||||
$ticket_template_task_list = $ticket_template_tasks[$ticket_template_id_select] ?? [];
|
||||
$task_count = count($ticket_template_task_list);
|
||||
?>
|
||||
<option value="<?= $ticket_template_id_select ?>"
|
||||
data-subject="<?= $ticket_template_subject_select ?>"
|
||||
data-details="<?= $ticket_template_details_select ?>"
|
||||
data-tasks="<?= escapeHtml(json_encode($ticket_template_task_list)) ?>"
|
||||
<?php if ($selected_ticket_template_id == $ticket_template_id_select) { echo "selected"; } ?>>
|
||||
<?= $ticket_template_name_select ?> (<?= $task_count ?> tasks)
|
||||
</option>
|
||||
<?php } ?>
|
||||
</select>
|
||||
</div>
|
||||
<small class="form-text text-muted">Picking a template fills in the subject, details and tasks below. You can edit them afterwards.</small>
|
||||
</div>
|
||||
148
agent/js/ticket_tasks_modal.js
Normal file
148
agent/js/ticket_tasks_modal.js
Normal file
@@ -0,0 +1,148 @@
|
||||
// Editable task rows for the ticket and recurring ticket add/edit modals, and the
|
||||
// ticket template picker that pre-fills them.
|
||||
//
|
||||
// Rows submit as parallel tasks[] and task_estimates[] arrays, aligned by their
|
||||
// order in the form, so removing a row removes both of its inputs together.
|
||||
//
|
||||
// Wrapped in an IIFE because a modal can be opened, closed and opened again in one
|
||||
// page load, which re-runs this file - top-level const/let would throw on the
|
||||
// second run. Delegated handlers are namespaced and unbound first for the same
|
||||
// reason, otherwise "Add Task" would add one row per time the modal was opened.
|
||||
|
||||
(function () {
|
||||
|
||||
// Builds one task row. Values are assigned as properties rather than built into
|
||||
// markup, so a task name containing quotes or angle brackets needs no escaping.
|
||||
function buildTaskRow(taskName, taskEstimate) {
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "form-row mb-2 ticket-task-row";
|
||||
|
||||
const nameColumn = document.createElement("div");
|
||||
nameColumn.className = "col-7";
|
||||
|
||||
const nameInput = document.createElement("input");
|
||||
nameInput.type = "text";
|
||||
nameInput.className = "form-control";
|
||||
nameInput.name = "tasks[]";
|
||||
nameInput.placeholder = "Task name";
|
||||
nameInput.maxLength = 255;
|
||||
nameInput.value = taskName || '';
|
||||
nameColumn.appendChild(nameInput);
|
||||
|
||||
const estimateColumn = document.createElement("div");
|
||||
estimateColumn.className = "col-3";
|
||||
|
||||
const estimateInput = document.createElement("input");
|
||||
estimateInput.type = "number";
|
||||
estimateInput.className = "form-control";
|
||||
estimateInput.name = "task_estimates[]";
|
||||
estimateInput.placeholder = "Mins";
|
||||
estimateInput.min = 0;
|
||||
estimateInput.value = taskEstimate || '';
|
||||
estimateColumn.appendChild(estimateInput);
|
||||
|
||||
const removeColumn = document.createElement("div");
|
||||
removeColumn.className = "col-2";
|
||||
|
||||
const removeButton = document.createElement("button");
|
||||
removeButton.type = "button";
|
||||
removeButton.className = "btn btn-secondary btn-block ticket-task-remove";
|
||||
removeButton.title = "Remove task";
|
||||
removeButton.innerHTML = '<i class="fa fa-fw fa-trash"></i>';
|
||||
removeColumn.appendChild(removeButton);
|
||||
|
||||
row.appendChild(nameColumn);
|
||||
row.appendChild(estimateColumn);
|
||||
row.appendChild(removeColumn);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function addTaskRow(taskName, taskEstimate) {
|
||||
|
||||
const container = document.getElementById("ticketTasksContainer");
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.appendChild(buildTaskRow(taskName, taskEstimate));
|
||||
}
|
||||
|
||||
// Replaces the whole list - used when a template is picked
|
||||
function setTaskRows(tasks) {
|
||||
|
||||
const container = document.getElementById("ticketTasksContainer");
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
(tasks || []).forEach(task => {
|
||||
addTaskRow(task.name, task.estimate);
|
||||
});
|
||||
}
|
||||
|
||||
// jQuery parses a data-tasks attribute holding JSON into an array on its own,
|
||||
// but hand it a string and it stays a string - so handle both
|
||||
function readTemplateTasks($option) {
|
||||
|
||||
const tasks = $option.data('tasks');
|
||||
|
||||
if (!tasks) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (typeof tasks === 'string') {
|
||||
try {
|
||||
return JSON.parse(tasks);
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
$(document).off('click.ticketTasks').on('click.ticketTasks', '#ticketTaskAdd', function () {
|
||||
addTaskRow('', '');
|
||||
});
|
||||
|
||||
$(document).off('click.ticketTaskRemove').on('click.ticketTaskRemove', '.ticket-task-remove', function () {
|
||||
$(this).closest('.ticket-task-row').remove();
|
||||
});
|
||||
|
||||
// Ticket template picker - fills in the subject, details and task rows
|
||||
$(document).off('change.ticketTemplate').on('change.ticketTemplate', '#ticket_template_select', function () {
|
||||
|
||||
const $option = $(this).find(':selected');
|
||||
|
||||
// Selecting "- No Template -" only unlinks the template - it must not wipe
|
||||
// whatever the user has already written or added
|
||||
if (!parseInt($option.val(), 10)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const templateSubject = $option.data('subject') || '';
|
||||
const templateDetails = $option.data('details') || '';
|
||||
|
||||
$('#subjectInput').val(templateSubject);
|
||||
|
||||
if (window.tinymce) {
|
||||
const editor = tinymce.get('detailsInput');
|
||||
if (editor) {
|
||||
editor.setContent(templateDetails);
|
||||
} else {
|
||||
$('#detailsInput').val(templateDetails);
|
||||
}
|
||||
} else {
|
||||
$('#detailsInput').val(templateDetails);
|
||||
}
|
||||
|
||||
setTaskRows(readTemplateTasks($option));
|
||||
});
|
||||
|
||||
})();
|
||||
@@ -1,4 +1,4 @@
|
||||
// Used to populate dynamic content in recurring_ticket_add_modal and ticket_add_modal_v2 based on selected client
|
||||
// Used to populate dynamic content in the ticket and recurring ticket add modals based on selected client
|
||||
|
||||
// Not every modal that loads this script has every dropdown, and a modal opened
|
||||
// from a contact page has no client selector at all - the client arrives as a
|
||||
@@ -8,362 +8,370 @@
|
||||
// Client selected listener
|
||||
// We seem to have to use jQuery to listen for events, as the client input is a select2 component?
|
||||
|
||||
const clientSelectDropdown = document.getElementById("changeClientSelect"); // Define client selector
|
||||
// Wrapped in an IIFE because a modal can be opened, closed and opened again in one
|
||||
// page load, which re-runs this file - a top-level const would throw on the second
|
||||
// run and take the whole script with it.
|
||||
|
||||
if (clientSelectDropdown) {
|
||||
(function () {
|
||||
|
||||
// If the client selector is disabled, we must be on a client-specific page instead. Trigger the lists to update.
|
||||
if (clientSelectDropdown.disabled) {
|
||||
const clientSelectDropdown = document.getElementById("changeClientSelect"); // Define client selector
|
||||
|
||||
let client_id = $(clientSelectDropdown).find(':selected').val();
|
||||
if (clientSelectDropdown) {
|
||||
|
||||
// If the client selector is disabled, we must be on a client-specific page instead. Trigger the lists to update.
|
||||
if (clientSelectDropdown.disabled) {
|
||||
|
||||
let client_id = $(clientSelectDropdown).find(':selected').val();
|
||||
|
||||
populateLists(client_id);
|
||||
}
|
||||
|
||||
// Listener for client selection. Populate select lists when a client is selected
|
||||
$(clientSelectDropdown).on('select2:select', function (e) {
|
||||
let client_id = $(this).find(':selected').val();
|
||||
|
||||
// Update the dependent dropdown lists
|
||||
populateLists(client_id);
|
||||
|
||||
});
|
||||
|
||||
} else {
|
||||
|
||||
// No client selector - the modal was opened from a contact page, where the
|
||||
// client is fixed and arrives as a hidden field instead
|
||||
const clientIdHiddenField = document.getElementById("clientIdHidden");
|
||||
|
||||
if (clientIdHiddenField && clientIdHiddenField.value) {
|
||||
populateLists(clientIdHiddenField.value);
|
||||
}
|
||||
|
||||
populateLists(client_id);
|
||||
}
|
||||
|
||||
// Listener for client selection. Populate select lists when a client is selected
|
||||
$(clientSelectDropdown).on('select2:select', function (e) {
|
||||
let client_id = $(this).find(':selected').val();
|
||||
// Populates dropdowns with dynamic content based on the client ID
|
||||
// Called when the client select dropdown is used or if the client select is disabled
|
||||
function populateLists(client_id) {
|
||||
|
||||
// Update the dependent dropdown lists
|
||||
populateLists(client_id);
|
||||
populateContactsDropdown(client_id);
|
||||
|
||||
});
|
||||
populateAssetsDropdowns(client_id);
|
||||
|
||||
} else {
|
||||
populateLocationsDropdown(client_id);
|
||||
|
||||
// No client selector - the modal was opened from a contact page, where the
|
||||
// client is fixed and arrives as a hidden field instead
|
||||
const clientIdHiddenField = document.getElementById("clientIdHidden");
|
||||
populateVendorsDropdown(client_id);
|
||||
|
||||
if (clientIdHiddenField && clientIdHiddenField.value) {
|
||||
populateLists(clientIdHiddenField.value);
|
||||
populateProjectsDropdown(client_id);
|
||||
}
|
||||
|
||||
}
|
||||
// Empties a dropdown and adds its placeholder, returning the element - or null if
|
||||
// this modal doesn't have it. Pass null as the label for a multi-select, which has
|
||||
// no placeholder option of its own.
|
||||
function resetDropdown(id, placeholderLabel, placeholderValue) {
|
||||
|
||||
// Populates dropdowns with dynamic content based on the client ID
|
||||
// Called when the client select dropdown is used or if the client select is disabled
|
||||
function populateLists(client_id) {
|
||||
const dropdown = document.getElementById(id);
|
||||
|
||||
populateContactsDropdown(client_id);
|
||||
if (!dropdown) {
|
||||
return null;
|
||||
}
|
||||
|
||||
populateAssetsDropdowns(client_id);
|
||||
// innerHTML rather than removing options one by one, which leaves empty optgroups behind
|
||||
dropdown.innerHTML = '';
|
||||
|
||||
populateLocationsDropdown(client_id);
|
||||
// A multi-select keeps showing its old selections until select2 is told the value changed
|
||||
$(dropdown).val(null).trigger('change.select2');
|
||||
|
||||
populateVendorsDropdown(client_id);
|
||||
if (placeholderLabel !== null) {
|
||||
dropdown[dropdown.length] = new Option(placeholderLabel, placeholderValue);
|
||||
}
|
||||
|
||||
populateProjectsDropdown(client_id);
|
||||
}
|
||||
|
||||
// Empties a dropdown and adds its placeholder, returning the element - or null if
|
||||
// this modal doesn't have it. Pass null as the label for a multi-select, which has
|
||||
// no placeholder option of its own.
|
||||
function resetDropdown(id, placeholderLabel, placeholderValue) {
|
||||
|
||||
const dropdown = document.getElementById(id);
|
||||
|
||||
if (!dropdown) {
|
||||
return null;
|
||||
return dropdown;
|
||||
}
|
||||
|
||||
// innerHTML rather than removing options one by one, which leaves empty optgroups behind
|
||||
dropdown.innerHTML = '';
|
||||
|
||||
// A multi-select keeps showing its old selections until select2 is told the value changed
|
||||
$(dropdown).val(null).trigger('change.select2');
|
||||
|
||||
if (placeholderLabel !== null) {
|
||||
dropdown[dropdown.length] = new Option(placeholderLabel, placeholderValue);
|
||||
}
|
||||
|
||||
return dropdown;
|
||||
}
|
||||
|
||||
// Redraws a select2 component after its options have been replaced
|
||||
function refreshDropdown(dropdown) {
|
||||
if (dropdown) {
|
||||
$(dropdown).trigger('change.select2');
|
||||
}
|
||||
}
|
||||
|
||||
// Re-applies the value the modal was opened with (e.g. ticket_add.php?project_id=4),
|
||||
// which can only be selected once the options it refers to exist
|
||||
function applyPreselection(dropdown) {
|
||||
|
||||
if (!hasPreselection(dropdown)) {
|
||||
return;
|
||||
}
|
||||
|
||||
dropdown.value = dropdown.dataset.selected;
|
||||
}
|
||||
|
||||
// True when the modal was opened with a specific value for this dropdown.
|
||||
// '0' is the "none selected" value every one of these dropdowns uses, and is
|
||||
// truthy as a string - so it has to be excluded explicitly.
|
||||
function hasPreselection(dropdown) {
|
||||
return Boolean(dropdown && dropdown.dataset.selected && dropdown.dataset.selected !== '0');
|
||||
}
|
||||
|
||||
// Adds an optgroup to a dropdown and returns it, so options can be appended into it
|
||||
function appendOptionGroup(dropdown, label) {
|
||||
|
||||
if (!dropdown) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const group = document.createElement("optgroup");
|
||||
group.label = label;
|
||||
dropdown.appendChild(group);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
// Adds an option to an optgroup
|
||||
function appendGroupedOption(group, label, value) {
|
||||
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
|
||||
group.appendChild(new Option(label, value));
|
||||
}
|
||||
|
||||
// Builds the asset label as "Name - Make Model - (Contact)", matching how assets read elsewhere
|
||||
function buildAssetLabel(asset) {
|
||||
|
||||
let label = asset.asset_name;
|
||||
|
||||
if (asset.asset_make) {
|
||||
label = label + " - " + asset.asset_make;
|
||||
|
||||
if (asset.asset_model) {
|
||||
label = label + " " + asset.asset_model;
|
||||
// Redraws a select2 component after its options have been replaced
|
||||
function refreshDropdown(dropdown) {
|
||||
if (dropdown) {
|
||||
$(dropdown).trigger('change.select2');
|
||||
}
|
||||
}
|
||||
|
||||
if (asset.contact_name) {
|
||||
label = label + " - (" + asset.contact_name + ")";
|
||||
// Re-applies the value the modal was opened with (e.g. ticket_add.php?project_id=4),
|
||||
// which can only be selected once the options it refers to exist
|
||||
function applyPreselection(dropdown) {
|
||||
|
||||
if (!hasPreselection(dropdown)) {
|
||||
return;
|
||||
}
|
||||
|
||||
dropdown.value = dropdown.dataset.selected;
|
||||
}
|
||||
|
||||
return label;
|
||||
}
|
||||
|
||||
// Populate client contacts - one request feeds both the contact picker and the
|
||||
// watchers list, as both are built from the same set of people
|
||||
function populateContactsDropdown(client_id) {
|
||||
|
||||
if (!document.getElementById("contactSelect") && !document.getElementById("watchersSelect")) {
|
||||
return;
|
||||
// True when the modal was opened with a specific value for this dropdown.
|
||||
// '0' is the "none selected" value every one of these dropdowns uses, and is
|
||||
// truthy as a string - so it has to be excluded explicitly.
|
||||
function hasPreselection(dropdown) {
|
||||
return Boolean(dropdown && dropdown.dataset.selected && dropdown.dataset.selected !== '0');
|
||||
}
|
||||
|
||||
// Send a GET request to ajax.php as ajax.php?get_client_contacts=true&client_id=NUM
|
||||
jQuery.get(
|
||||
"ajax.php",
|
||||
{get_client_contacts: 'true', client_id: client_id},
|
||||
function(data) {
|
||||
// Adds an optgroup to a dropdown and returns it, so options can be appended into it
|
||||
function appendOptionGroup(dropdown, label) {
|
||||
|
||||
// If we get a response from ajax.php, parse it as JSON
|
||||
const response = JSON.parse(data);
|
||||
if (!dropdown) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Access the data for contacts (multiple)
|
||||
const contacts = response.contacts || [];
|
||||
const group = document.createElement("optgroup");
|
||||
group.label = label;
|
||||
dropdown.appendChild(group);
|
||||
|
||||
// Contacts dropdown
|
||||
const contactSelectDropdown = resetDropdown("contactSelect", '- No One -', '0');
|
||||
return group;
|
||||
}
|
||||
|
||||
// Watchers is a tags field - any address can be typed in, these are just the handy ones
|
||||
const watchersDropdown = resetDropdown("watchersSelect", null, null);
|
||||
// Adds an option to an optgroup
|
||||
function appendGroupedOption(group, label, value) {
|
||||
|
||||
// Populate dropdown
|
||||
contacts.forEach(contact => {
|
||||
var appendText = "";
|
||||
if (contact.contact_title) {
|
||||
appendText = " - " + contact.contact_title;
|
||||
}
|
||||
if (contact.contact_primary == "1") {
|
||||
appendText = appendText + " (Primary)";
|
||||
} else if (contact.contact_technical == "1") {
|
||||
appendText = appendText + " (Technical)";
|
||||
}
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (contactSelectDropdown) {
|
||||
contactSelectDropdown[contactSelectDropdown.length] = new Option(contact.contact_name + appendText, contact.contact_id);
|
||||
}
|
||||
group.appendChild(new Option(label, value));
|
||||
}
|
||||
|
||||
if (watchersDropdown && contact.contact_email) {
|
||||
watchersDropdown[watchersDropdown.length] = new Option(contact.contact_email, contact.contact_email);
|
||||
}
|
||||
});
|
||||
// Builds the asset label as "Name - Make Model - (Contact)", matching how assets read elsewhere
|
||||
function buildAssetLabel(asset) {
|
||||
|
||||
// Default to the client's primary contact unless the modal was opened for a
|
||||
// specific one. Contacts arrive primary-first.
|
||||
if (contactSelectDropdown && !hasPreselection(contactSelectDropdown)) {
|
||||
const primaryContact = contacts.find(contact => contact.contact_primary == "1");
|
||||
let label = asset.asset_name;
|
||||
|
||||
if (primaryContact) {
|
||||
contactSelectDropdown.value = primaryContact.contact_id;
|
||||
}
|
||||
if (asset.asset_make) {
|
||||
label = label + " - " + asset.asset_make;
|
||||
|
||||
if (asset.asset_model) {
|
||||
label = label + " " + asset.asset_model;
|
||||
}
|
||||
|
||||
applyPreselection(contactSelectDropdown);
|
||||
|
||||
refreshDropdown(contactSelectDropdown);
|
||||
refreshDropdown(watchersDropdown);
|
||||
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Populate client assets - feeds both the single asset picker and the additional assets
|
||||
// multi-select from one request, as both need the same list
|
||||
function populateAssetsDropdowns(client_id) {
|
||||
if (asset.contact_name) {
|
||||
label = label + " - (" + asset.contact_name + ")";
|
||||
}
|
||||
|
||||
if (!document.getElementById("assetSelect") && !document.getElementById("additionalAssetsSelect")) {
|
||||
return;
|
||||
return label;
|
||||
}
|
||||
|
||||
jQuery.get(
|
||||
"ajax.php",
|
||||
{get_client_assets: 'true', client_id: client_id},
|
||||
function(data) {
|
||||
// Populate client contacts - one request feeds both the contact picker and the
|
||||
// watchers list, as both are built from the same set of people
|
||||
function populateContactsDropdown(client_id) {
|
||||
|
||||
// If we get a response from ajax.php, parse it as JSON
|
||||
const response = JSON.parse(data);
|
||||
if (!document.getElementById("contactSelect") && !document.getElementById("watchersSelect")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Access the data for assets (multiple)
|
||||
const assets = response.assets || [];
|
||||
// Send a GET request to ajax.php as ajax.php?get_client_contacts=true&client_id=NUM
|
||||
jQuery.get(
|
||||
"ajax.php",
|
||||
{get_client_contacts: 'true', client_id: client_id},
|
||||
function(data) {
|
||||
|
||||
const assetSelectDropdown = resetDropdown("assetSelect", '- None -', '0');
|
||||
const additionalAssetsDropdown = resetDropdown("additionalAssetsSelect", null, null);
|
||||
// If we get a response from ajax.php, parse it as JSON
|
||||
const response = JSON.parse(data);
|
||||
|
||||
// Assets arrive ordered by type, so a change of type starts a new group
|
||||
let currentType = null;
|
||||
let assetGroup = null;
|
||||
let additionalAssetGroup = null;
|
||||
// Access the data for contacts (multiple)
|
||||
const contacts = response.contacts || [];
|
||||
|
||||
assets.forEach(asset => {
|
||||
const assetType = asset.asset_type || 'Uncategorized';
|
||||
// Contacts dropdown
|
||||
const contactSelectDropdown = resetDropdown("contactSelect", '- No One -', '0');
|
||||
|
||||
if (assetType !== currentType) {
|
||||
currentType = assetType;
|
||||
assetGroup = appendOptionGroup(assetSelectDropdown, assetType);
|
||||
additionalAssetGroup = appendOptionGroup(additionalAssetsDropdown, assetType);
|
||||
// Watchers is a tags field - any address can be typed in, these are just the handy ones
|
||||
const watchersDropdown = resetDropdown("watchersSelect", null, null);
|
||||
|
||||
// Populate dropdown
|
||||
contacts.forEach(contact => {
|
||||
var appendText = "";
|
||||
if (contact.contact_title) {
|
||||
appendText = " - " + contact.contact_title;
|
||||
}
|
||||
if (contact.contact_primary == "1") {
|
||||
appendText = appendText + " (Primary)";
|
||||
} else if (contact.contact_technical == "1") {
|
||||
appendText = appendText + " (Technical)";
|
||||
}
|
||||
|
||||
if (contactSelectDropdown) {
|
||||
contactSelectDropdown[contactSelectDropdown.length] = new Option(contact.contact_name + appendText, contact.contact_id);
|
||||
}
|
||||
|
||||
if (watchersDropdown && contact.contact_email) {
|
||||
watchersDropdown[watchersDropdown.length] = new Option(contact.contact_email, contact.contact_email);
|
||||
}
|
||||
});
|
||||
|
||||
// Default to the client's primary contact unless the modal was opened for a
|
||||
// specific one. Contacts arrive primary-first.
|
||||
if (contactSelectDropdown && !hasPreselection(contactSelectDropdown)) {
|
||||
const primaryContact = contacts.find(contact => contact.contact_primary == "1");
|
||||
|
||||
if (primaryContact) {
|
||||
contactSelectDropdown.value = primaryContact.contact_id;
|
||||
}
|
||||
}
|
||||
|
||||
const assetLabel = buildAssetLabel(asset);
|
||||
applyPreselection(contactSelectDropdown);
|
||||
|
||||
appendGroupedOption(assetGroup, assetLabel, asset.asset_id);
|
||||
appendGroupedOption(additionalAssetGroup, assetLabel, asset.asset_id);
|
||||
});
|
||||
refreshDropdown(contactSelectDropdown);
|
||||
refreshDropdown(watchersDropdown);
|
||||
|
||||
applyPreselection(assetSelectDropdown);
|
||||
|
||||
refreshDropdown(assetSelectDropdown);
|
||||
refreshDropdown(additionalAssetsDropdown);
|
||||
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Populate client locations
|
||||
function populateLocationsDropdown(client_id) {
|
||||
|
||||
if (!document.getElementById("locationSelect")) {
|
||||
return;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
jQuery.get(
|
||||
"ajax.php",
|
||||
{get_client_locations: 'true', client_id: client_id},
|
||||
function(data) {
|
||||
|
||||
// If we get a response from ajax.php, parse it as JSON
|
||||
const response = JSON.parse(data);
|
||||
|
||||
// Access the data for locations (multiple)
|
||||
const locations = response.locations || [];
|
||||
|
||||
// Locations dropdown
|
||||
const locationSelectDropdown = resetDropdown("locationSelect", '- Location -', '0');
|
||||
|
||||
// Populate dropdown
|
||||
locations.forEach(location => {
|
||||
locationSelectDropdown[locationSelectDropdown.length] = new Option(location.location_name, location.location_id);
|
||||
});
|
||||
|
||||
applyPreselection(locationSelectDropdown);
|
||||
|
||||
refreshDropdown(locationSelectDropdown);
|
||||
// Populate client assets - feeds both the single asset picker and the additional assets
|
||||
// multi-select from one request, as both need the same list
|
||||
function populateAssetsDropdowns(client_id) {
|
||||
|
||||
if (!document.getElementById("assetSelect") && !document.getElementById("additionalAssetsSelect")) {
|
||||
return;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Populate client vendors
|
||||
function populateVendorsDropdown(client_id) {
|
||||
jQuery.get(
|
||||
"ajax.php",
|
||||
{get_client_assets: 'true', client_id: client_id},
|
||||
function(data) {
|
||||
|
||||
if (!document.getElementById("vendorSelect")) {
|
||||
return;
|
||||
// If we get a response from ajax.php, parse it as JSON
|
||||
const response = JSON.parse(data);
|
||||
|
||||
// Access the data for assets (multiple)
|
||||
const assets = response.assets || [];
|
||||
|
||||
const assetSelectDropdown = resetDropdown("assetSelect", '- None -', '0');
|
||||
const additionalAssetsDropdown = resetDropdown("additionalAssetsSelect", null, null);
|
||||
|
||||
// Assets arrive ordered by type, so a change of type starts a new group
|
||||
let currentType = null;
|
||||
let assetGroup = null;
|
||||
let additionalAssetGroup = null;
|
||||
|
||||
assets.forEach(asset => {
|
||||
const assetType = asset.asset_type || 'Uncategorized';
|
||||
|
||||
if (assetType !== currentType) {
|
||||
currentType = assetType;
|
||||
assetGroup = appendOptionGroup(assetSelectDropdown, assetType);
|
||||
additionalAssetGroup = appendOptionGroup(additionalAssetsDropdown, assetType);
|
||||
}
|
||||
|
||||
const assetLabel = buildAssetLabel(asset);
|
||||
|
||||
appendGroupedOption(assetGroup, assetLabel, asset.asset_id);
|
||||
appendGroupedOption(additionalAssetGroup, assetLabel, asset.asset_id);
|
||||
});
|
||||
|
||||
applyPreselection(assetSelectDropdown);
|
||||
|
||||
refreshDropdown(assetSelectDropdown);
|
||||
refreshDropdown(additionalAssetsDropdown);
|
||||
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
jQuery.get(
|
||||
"ajax.php",
|
||||
{get_client_vendors: 'true', client_id: client_id},
|
||||
function(data) {
|
||||
|
||||
// If we get a response from ajax.php, parse it as JSON
|
||||
const response = JSON.parse(data);
|
||||
|
||||
// Access the data for vendors (multiple)
|
||||
const vendors = response.vendors || [];
|
||||
|
||||
// Vendors dropdown
|
||||
const vendorSelectDropdown = resetDropdown("vendorSelect", '- Vendor -', '0');
|
||||
|
||||
// Populate dropdown
|
||||
vendors.forEach(vendor => {
|
||||
vendorSelectDropdown[vendorSelectDropdown.length] = new Option(vendor.vendor_name, vendor.vendor_id);
|
||||
});
|
||||
|
||||
applyPreselection(vendorSelectDropdown);
|
||||
|
||||
refreshDropdown(vendorSelectDropdown);
|
||||
// Populate client locations
|
||||
function populateLocationsDropdown(client_id) {
|
||||
|
||||
if (!document.getElementById("locationSelect")) {
|
||||
return;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Populate client projects
|
||||
function populateProjectsDropdown(client_id) {
|
||||
jQuery.get(
|
||||
"ajax.php",
|
||||
{get_client_locations: 'true', client_id: client_id},
|
||||
function(data) {
|
||||
|
||||
if (!document.getElementById("projectSelect")) {
|
||||
return;
|
||||
// If we get a response from ajax.php, parse it as JSON
|
||||
const response = JSON.parse(data);
|
||||
|
||||
// Access the data for locations (multiple)
|
||||
const locations = response.locations || [];
|
||||
|
||||
// Locations dropdown
|
||||
const locationSelectDropdown = resetDropdown("locationSelect", '- Location -', '0');
|
||||
|
||||
// Populate dropdown
|
||||
locations.forEach(location => {
|
||||
locationSelectDropdown[locationSelectDropdown.length] = new Option(location.location_name, location.location_id);
|
||||
});
|
||||
|
||||
applyPreselection(locationSelectDropdown);
|
||||
|
||||
refreshDropdown(locationSelectDropdown);
|
||||
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
jQuery.get(
|
||||
"ajax.php",
|
||||
{get_client_projects: 'true', client_id: client_id},
|
||||
function(data) {
|
||||
|
||||
// If we get a response from ajax.php, parse it as JSON
|
||||
const response = JSON.parse(data);
|
||||
|
||||
// Access the data for projects (multiple)
|
||||
const projects = response.projects || [];
|
||||
|
||||
// Projects dropdown
|
||||
const projectSelectDropdown = resetDropdown("projectSelect", '- Select Project -', '0');
|
||||
|
||||
// Populate dropdown
|
||||
projects.forEach(project => {
|
||||
projectSelectDropdown[projectSelectDropdown.length] = new Option(project.project_name, project.project_id);
|
||||
});
|
||||
|
||||
applyPreselection(projectSelectDropdown);
|
||||
|
||||
refreshDropdown(projectSelectDropdown);
|
||||
// Populate client vendors
|
||||
function populateVendorsDropdown(client_id) {
|
||||
|
||||
if (!document.getElementById("vendorSelect")) {
|
||||
return;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
jQuery.get(
|
||||
"ajax.php",
|
||||
{get_client_vendors: 'true', client_id: client_id},
|
||||
function(data) {
|
||||
|
||||
// If we get a response from ajax.php, parse it as JSON
|
||||
const response = JSON.parse(data);
|
||||
|
||||
// Access the data for vendors (multiple)
|
||||
const vendors = response.vendors || [];
|
||||
|
||||
// Vendors dropdown
|
||||
const vendorSelectDropdown = resetDropdown("vendorSelect", '- Vendor -', '0');
|
||||
|
||||
// Populate dropdown
|
||||
vendors.forEach(vendor => {
|
||||
vendorSelectDropdown[vendorSelectDropdown.length] = new Option(vendor.vendor_name, vendor.vendor_id);
|
||||
});
|
||||
|
||||
applyPreselection(vendorSelectDropdown);
|
||||
|
||||
refreshDropdown(vendorSelectDropdown);
|
||||
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Populate client projects
|
||||
function populateProjectsDropdown(client_id) {
|
||||
|
||||
if (!document.getElementById("projectSelect")) {
|
||||
return;
|
||||
}
|
||||
|
||||
jQuery.get(
|
||||
"ajax.php",
|
||||
{get_client_projects: 'true', client_id: client_id},
|
||||
function(data) {
|
||||
|
||||
// If we get a response from ajax.php, parse it as JSON
|
||||
const response = JSON.parse(data);
|
||||
|
||||
// Access the data for projects (multiple)
|
||||
const projects = response.projects || [];
|
||||
|
||||
// Projects dropdown
|
||||
const projectSelectDropdown = resetDropdown("projectSelect", '- Select Project -', '0');
|
||||
|
||||
// Populate dropdown
|
||||
projects.forEach(project => {
|
||||
projectSelectDropdown[projectSelectDropdown.length] = new Option(project.project_name, project.project_id);
|
||||
});
|
||||
|
||||
applyPreselection(projectSelectDropdown);
|
||||
|
||||
refreshDropdown(projectSelectDropdown);
|
||||
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
@@ -28,6 +28,9 @@ ob_start();
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" data-toggle="pill" href="#pills-add-details"><i class="fa fa-fw fa-life-ring mr-2"></i>Details</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-toggle="pill" href="#pills-add-tasks"><i class="fa fa-fw fa-tasks mr-2"></i>Tasks</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-toggle="pill" href="#pills-add-schedule"><i class="fa fa-fw fa-building mr-2"></i>Schedule</a>
|
||||
</li>
|
||||
@@ -83,46 +86,7 @@ ob_start();
|
||||
|
||||
<?php } ?>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Template</label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-cube"></i></span>
|
||||
</div>
|
||||
<select class="form-control select2" id="ticket_template_select" name="ticket_template_id">
|
||||
<option value="0">- No Template -</option>
|
||||
<?php
|
||||
$sql_ticket_templates = mysqli_query($mysqli, "
|
||||
SELECT tt.ticket_template_id,
|
||||
tt.ticket_template_name,
|
||||
tt.ticket_template_subject,
|
||||
tt.ticket_template_details,
|
||||
COUNT(ttt.task_template_id) as task_count
|
||||
FROM ticket_templates tt
|
||||
LEFT JOIN task_templates ttt
|
||||
ON tt.ticket_template_id = ttt.task_template_ticket_template_id
|
||||
WHERE tt.ticket_template_archived_at IS NULL
|
||||
GROUP BY tt.ticket_template_id
|
||||
ORDER BY tt.ticket_template_name ASC
|
||||
");
|
||||
|
||||
while ($row = mysqli_fetch_assoc($sql_ticket_templates)) {
|
||||
$ticket_template_id_select = intval($row['ticket_template_id']);
|
||||
$ticket_template_name_select = escapeHtml($row['ticket_template_name']);
|
||||
$ticket_template_subject_select = escapeHtml($row['ticket_template_subject']);
|
||||
$ticket_template_details_select = escapeHtml($row['ticket_template_details']);
|
||||
$task_count = intval($row['task_count']);
|
||||
?>
|
||||
<option value="<?= $ticket_template_id_select ?>"
|
||||
data-subject="<?= $ticket_template_subject_select ?>"
|
||||
data-details="<?= $ticket_template_details_select ?>">
|
||||
<?= $ticket_template_name_select ?> (<?= $task_count ?> tasks)
|
||||
</option>
|
||||
<?php } ?>
|
||||
</select>
|
||||
</div>
|
||||
<small class="form-text text-muted">The template's tasks are added to every ticket this schedule raises.</small>
|
||||
</div>
|
||||
<?php require_once '../../includes/inc_ticket_template_select.php'; ?>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Subject <strong class="text-danger">*</strong></label>
|
||||
@@ -224,6 +188,12 @@ ob_start();
|
||||
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="pills-add-tasks">
|
||||
|
||||
<?php require_once '../../includes/inc_ticket_tasks_section.php'; ?>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="pills-add-schedule">
|
||||
|
||||
<div class="form-group">
|
||||
@@ -300,40 +270,14 @@ ob_start();
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Ticket Templates -->
|
||||
<script>
|
||||
$(document).on('change', '#ticket_template_select', function () {
|
||||
const $opt = $(this).find(':selected');
|
||||
|
||||
// Selecting "- No Template -" only unlinks the template - it must not wipe
|
||||
// whatever subject/details the user has already written
|
||||
if (!parseInt($opt.val(), 10)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const templateSubject = $opt.data('subject') || '';
|
||||
const templateDetails = $opt.data('details') || '';
|
||||
|
||||
$('#subjectInput').val(templateSubject);
|
||||
|
||||
if (window.tinymce) {
|
||||
const editor = tinymce.get('detailsInput');
|
||||
if (editor) {
|
||||
editor.setContent(templateDetails);
|
||||
} else {
|
||||
$('#detailsInput').val(templateDetails);
|
||||
}
|
||||
} else {
|
||||
$('#detailsInput').val(templateDetails);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Recurring Ticket Client/Contact JS -->
|
||||
<link rel="stylesheet" href="/libs/jquery-ui/jquery-ui.min.css">
|
||||
<script src="/libs/jquery-ui/jquery-ui.min.js"></script>
|
||||
<script src="/agent/js/tickets_add_modal.js"></script>
|
||||
|
||||
<script src="/agent/js/ticket_tasks_modal.js"></script>
|
||||
|
||||
<?php
|
||||
|
||||
require_once '../../../includes/modal_footer.php';
|
||||
|
||||
@@ -22,6 +22,22 @@ $recurring_ticket_category = intval($row['recurring_ticket_category']);
|
||||
$recurring_ticket_billable = intval($row['recurring_ticket_billable']);
|
||||
$recurring_ticket_ticket_template_id = intval($row['recurring_ticket_ticket_template_id']);
|
||||
|
||||
// Tasks already on this schedule, pre-filling the editable rows
|
||||
$existing_tasks = array();
|
||||
$sql_recurring_ticket_tasks = mysqli_query(
|
||||
$mysqli,
|
||||
"SELECT recurring_ticket_task_name, recurring_ticket_task_completion_estimate
|
||||
FROM recurring_ticket_tasks
|
||||
WHERE recurring_ticket_task_recurring_ticket_id = $recurring_ticket_id
|
||||
ORDER BY recurring_ticket_task_order ASC, recurring_ticket_task_id ASC"
|
||||
);
|
||||
while ($row = mysqli_fetch_assoc($sql_recurring_ticket_tasks)) {
|
||||
$existing_tasks[] = [
|
||||
'name' => $row['recurring_ticket_task_name'],
|
||||
'estimate' => intval($row['recurring_ticket_task_completion_estimate'])
|
||||
];
|
||||
}
|
||||
|
||||
// Additional Assets Selected
|
||||
$additional_assets_array = array();
|
||||
$sql_additional_assets = mysqli_query($mysqli, "SELECT asset_id FROM recurring_ticket_assets WHERE recurring_ticket_id = $recurring_ticket_id");
|
||||
@@ -55,6 +71,9 @@ ob_start();
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" data-toggle="pill" href="#pills-edit-details"><i class="fa fa-fw fa-life-ring mr-2"></i>Details</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-toggle="pill" href="#pills-edit-tasks"><i class="fa fa-fw fa-tasks mr-2"></i>Tasks</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-toggle="pill" href="#pills-edit-contacts"><i class="fa fa-fw fa-users mr-2"></i>Contact</a>
|
||||
</li>
|
||||
@@ -70,47 +89,10 @@ ob_start();
|
||||
|
||||
<div class="tab-pane fade show active" id="pills-edit-details">
|
||||
|
||||
<div class="form-group">
|
||||
<label>Template</label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-cube"></i></span>
|
||||
</div>
|
||||
<select class="form-control select2" id="ticket_template_select" name="ticket_template_id">
|
||||
<option value="0">- No Template -</option>
|
||||
<?php
|
||||
$sql_ticket_templates = mysqli_query($mysqli, "
|
||||
SELECT tt.ticket_template_id,
|
||||
tt.ticket_template_name,
|
||||
tt.ticket_template_subject,
|
||||
tt.ticket_template_details,
|
||||
COUNT(ttt.task_template_id) as task_count
|
||||
FROM ticket_templates tt
|
||||
LEFT JOIN task_templates ttt
|
||||
ON tt.ticket_template_id = ttt.task_template_ticket_template_id
|
||||
WHERE (tt.ticket_template_archived_at IS NULL OR tt.ticket_template_id = $recurring_ticket_ticket_template_id)
|
||||
GROUP BY tt.ticket_template_id
|
||||
ORDER BY tt.ticket_template_name ASC
|
||||
");
|
||||
|
||||
while ($row = mysqli_fetch_assoc($sql_ticket_templates)) {
|
||||
$ticket_template_id_select = intval($row['ticket_template_id']);
|
||||
$ticket_template_name_select = escapeHtml($row['ticket_template_name']);
|
||||
$ticket_template_subject_select = escapeHtml($row['ticket_template_subject']);
|
||||
$ticket_template_details_select = escapeHtml($row['ticket_template_details']);
|
||||
$task_count = intval($row['task_count']);
|
||||
?>
|
||||
<option value="<?= $ticket_template_id_select ?>"
|
||||
data-subject="<?= $ticket_template_subject_select ?>"
|
||||
data-details="<?= $ticket_template_details_select ?>"
|
||||
<?php if ($recurring_ticket_ticket_template_id == $ticket_template_id_select) { echo "selected"; } ?>>
|
||||
<?= $ticket_template_name_select ?> (<?= $task_count ?> tasks)
|
||||
</option>
|
||||
<?php } ?>
|
||||
</select>
|
||||
</div>
|
||||
<small class="form-text text-muted">The template's tasks are added to every ticket this schedule raises. Changing it rewrites the subject and details below.</small>
|
||||
</div>
|
||||
<?php
|
||||
$selected_ticket_template_id = $recurring_ticket_ticket_template_id;
|
||||
require_once '../../includes/inc_ticket_template_select.php';
|
||||
?>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Subject <strong class="text-danger">*</strong></label>
|
||||
@@ -211,6 +193,12 @@ ob_start();
|
||||
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="pills-edit-tasks">
|
||||
|
||||
<?php require_once '../../includes/inc_ticket_tasks_section.php'; ?>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="pills-edit-contacts">
|
||||
|
||||
<div class="form-group">
|
||||
@@ -349,34 +337,8 @@ ob_start();
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Ticket Templates -->
|
||||
<script>
|
||||
$(document).on('change', '#ticket_template_select', function () {
|
||||
const $opt = $(this).find(':selected');
|
||||
|
||||
// Selecting "- No Template -" only unlinks the template - it must not wipe
|
||||
// whatever subject/details the user has already written
|
||||
if (!parseInt($opt.val(), 10)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const templateSubject = $opt.data('subject') || '';
|
||||
const templateDetails = $opt.data('details') || '';
|
||||
|
||||
$('#subjectInput').val(templateSubject);
|
||||
|
||||
if (window.tinymce) {
|
||||
const editor = tinymce.get('detailsInput');
|
||||
if (editor) {
|
||||
editor.setContent(templateDetails);
|
||||
} else {
|
||||
$('#detailsInput').val(templateDetails);
|
||||
}
|
||||
} else {
|
||||
$('#detailsInput').val(templateDetails);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="/agent/js/ticket_tasks_modal.js"></script>
|
||||
|
||||
<?php
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ ob_start();
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" data-toggle="pill" href="#pills-add-details"><i class="fa fa-fw fa-life-ring mr-2"></i>Details</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-toggle="pill" href="#pills-add-tasks"><i class="fa fa-fw fa-tasks mr-2"></i>Tasks</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-toggle="pill" href="#pills-add-relationships"><i class="fa fa-fw fa-desktop mr-2"></i>Assignment</a>
|
||||
</li>
|
||||
@@ -86,45 +89,7 @@ ob_start();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Template</label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-cube"></i></span>
|
||||
</div>
|
||||
<select class="form-control select2" id="ticket_template_select" name="ticket_template_id" required>
|
||||
<option value="0">- Choose a Template -</option>
|
||||
<?php
|
||||
$sql_ticket_templates = mysqli_query($mysqli, "
|
||||
SELECT tt.ticket_template_id,
|
||||
tt.ticket_template_name,
|
||||
tt.ticket_template_subject,
|
||||
tt.ticket_template_details,
|
||||
COUNT(ttt.task_template_id) as task_count
|
||||
FROM ticket_templates tt
|
||||
LEFT JOIN task_templates ttt
|
||||
ON tt.ticket_template_id = ttt.task_template_ticket_template_id
|
||||
WHERE tt.ticket_template_archived_at IS NULL
|
||||
GROUP BY tt.ticket_template_id
|
||||
ORDER BY tt.ticket_template_name ASC
|
||||
");
|
||||
|
||||
while ($row = mysqli_fetch_assoc($sql_ticket_templates)) {
|
||||
$ticket_template_id_select = intval($row['ticket_template_id']);
|
||||
$ticket_template_name_select = escapeHtml($row['ticket_template_name']);
|
||||
$ticket_template_subject_select = escapeHtml($row['ticket_template_subject']);
|
||||
$ticket_template_details_select = escapeHtml($row['ticket_template_details']);
|
||||
$task_count = intval($row['task_count']);
|
||||
?>
|
||||
<option value="<?= $ticket_template_id_select ?>"
|
||||
data-subject="<?= $ticket_template_subject_select ?>"
|
||||
data-details="<?= $ticket_template_details_select ?>">
|
||||
<?= $ticket_template_name_select ?> (<?= $task_count ?> tasks)
|
||||
</option>
|
||||
<?php } ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<?php require_once '../../includes/inc_ticket_template_select.php'; ?>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Subject <strong class="text-danger">*</strong></label>
|
||||
@@ -240,6 +205,12 @@ ob_start();
|
||||
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="pills-add-tasks">
|
||||
|
||||
<?php require_once '../../includes/inc_ticket_tasks_section.php'; ?>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="pills-add-relationships">
|
||||
|
||||
<div class="form-group">
|
||||
@@ -339,33 +310,14 @@ ob_start();
|
||||
|
||||
</form>
|
||||
|
||||
<!-- Ticket Templates -->
|
||||
<script>
|
||||
$(document).on('change', '#ticket_template_select', function () {
|
||||
const $opt = $(this).find(':selected');
|
||||
const templateSubject = $opt.data('subject') || '';
|
||||
const templateDetails = $opt.data('details') || '';
|
||||
|
||||
$('#subjectInput').val(templateSubject);
|
||||
|
||||
if (window.tinymce) {
|
||||
const editor = tinymce.get('detailsInput');
|
||||
if (editor) {
|
||||
editor.setContent(templateDetails);
|
||||
} else {
|
||||
$('#detailsInput').val(templateDetails);
|
||||
}
|
||||
} else {
|
||||
$('#detailsInput').val(templateDetails);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Ticket Client/Contact JS -->
|
||||
<link rel="stylesheet" href="/libs/jquery-ui/jquery-ui.min.css">
|
||||
<script src="/libs/jquery-ui/jquery-ui.min.js"></script>
|
||||
<script src="/agent/js/tickets_add_modal.js"></script>
|
||||
|
||||
<script src="/agent/js/ticket_tasks_modal.js"></script>
|
||||
|
||||
<?php
|
||||
|
||||
require_once '../../../includes/modal_footer.php';
|
||||
|
||||
@@ -32,6 +32,11 @@ if (isset($_POST['add_recurring_ticket'])) {
|
||||
}
|
||||
}
|
||||
|
||||
// Add Tasks - stamped onto every ticket this schedule raises
|
||||
foreach (parseSubmittedTasks() as $task) {
|
||||
mysqli_query($mysqli, "INSERT INTO recurring_ticket_tasks SET recurring_ticket_task_name = '{$task['name']}', recurring_ticket_task_order = {$task['order']}, recurring_ticket_task_completion_estimate = {$task['estimate']}, recurring_ticket_task_recurring_ticket_id = $recurring_ticket_id");
|
||||
}
|
||||
|
||||
logAudit("Recurring Ticket", "Create", "$session_name created recurring ticket for $subject - $frequency", $client_id, $recurring_ticket_id);
|
||||
|
||||
flashAlert("Recurring ticket <strong>$subject - $frequency</strong> created");
|
||||
@@ -66,6 +71,15 @@ if (isset($_POST['edit_recurring_ticket'])) {
|
||||
}
|
||||
}
|
||||
|
||||
// Replace Tasks with whatever the modal submitted
|
||||
if (isset($_POST['tasks_submitted'])) {
|
||||
mysqli_query($mysqli, "DELETE FROM recurring_ticket_tasks WHERE recurring_ticket_task_recurring_ticket_id = $recurring_ticket_id");
|
||||
|
||||
foreach (parseSubmittedTasks() as $task) {
|
||||
mysqli_query($mysqli, "INSERT INTO recurring_ticket_tasks SET recurring_ticket_task_name = '{$task['name']}', recurring_ticket_task_order = {$task['order']}, recurring_ticket_task_completion_estimate = {$task['estimate']}, recurring_ticket_task_recurring_ticket_id = $recurring_ticket_id");
|
||||
}
|
||||
}
|
||||
|
||||
logAudit("Recurring Ticket", "Edit", "$session_name edited recurring ticket $subject", $client_id, $recurring_ticket_id);
|
||||
|
||||
flashAlert("Recurring ticket <strong>$subject - $frequency</strong> updated");
|
||||
@@ -102,7 +116,6 @@ if (isset($_POST['bulk_force_recurring_tickets'])) {
|
||||
$client_id = intval($row['recurring_ticket_client_id']);
|
||||
$asset_id = intval($row['recurring_ticket_asset_id']);
|
||||
$category = intval($row['recurring_ticket_category']);
|
||||
$ticket_template_id = intval($row['recurring_ticket_ticket_template_id']);
|
||||
$url_key = randomString(32);
|
||||
|
||||
enforceClientAccess();
|
||||
@@ -140,8 +153,8 @@ if (isset($_POST['bulk_force_recurring_tickets'])) {
|
||||
FROM recurring_ticket_assets
|
||||
WHERE recurring_ticket_id = $recurring_ticket_id");
|
||||
|
||||
// Copy Tasks from the linked ticket template, if one is set
|
||||
addTasksFromTicketTemplate($id, $ticket_template_id);
|
||||
// Copy Tasks from the schedule's own task list
|
||||
addTasksFromRecurringTicket($id, $recurring_ticket_id);
|
||||
|
||||
// Notifications
|
||||
|
||||
@@ -247,7 +260,6 @@ if (isset($_GET['force_recurring_ticket'])) {
|
||||
$client_id = intval($row['recurring_ticket_client_id']);
|
||||
$asset_id = intval($row['recurring_ticket_asset_id']);
|
||||
$category = intval($row['recurring_ticket_category']);
|
||||
$ticket_template_id = intval($row['recurring_ticket_ticket_template_id']);
|
||||
$url_key = randomString(32);
|
||||
|
||||
enforceClientAccess();
|
||||
@@ -285,8 +297,8 @@ if (isset($_GET['force_recurring_ticket'])) {
|
||||
FROM recurring_ticket_assets
|
||||
WHERE recurring_ticket_id = $recurring_ticket_id");
|
||||
|
||||
// Copy Tasks from the linked ticket template, if one is set
|
||||
addTasksFromTicketTemplate($id, $ticket_template_id);
|
||||
// Copy Tasks from the schedule's own task list
|
||||
addTasksFromRecurringTicket($id, $recurring_ticket_id);
|
||||
|
||||
// Notifications
|
||||
|
||||
|
||||
@@ -79,8 +79,15 @@ if (isset($_POST['add_ticket'])) {
|
||||
$ticket_id = mysqli_insert_id($mysqli);
|
||||
applyTicketSla($ticket_id);
|
||||
|
||||
// Add Tasks from Template if Template was selected
|
||||
addTasksFromTicketTemplate($ticket_id, $ticket_template_id);
|
||||
// Tasks come from the editable rows in the modal, which the template pre-fills.
|
||||
// Fall back to copying the template directly for a form without that section.
|
||||
if (isset($_POST['tasks_submitted'])) {
|
||||
foreach (parseSubmittedTasks() as $task) {
|
||||
mysqli_query($mysqli, "INSERT INTO tasks SET task_name = '{$task['name']}', task_order = {$task['order']}, task_completion_estimate = {$task['estimate']}, task_ticket_id = $ticket_id");
|
||||
}
|
||||
} else {
|
||||
addTasksFromTicketTemplate($ticket_id, $ticket_template_id);
|
||||
}
|
||||
|
||||
// Add Watchers
|
||||
if (isset($_POST['watchers'])) {
|
||||
|
||||
@@ -55,7 +55,7 @@ if (isset($_GET['billable']) && $_GET['billable'] == 1) {
|
||||
$sql = mysqli_query(
|
||||
$mysqli,
|
||||
"SELECT SQL_CALC_FOUND_ROWS *,
|
||||
(SELECT COUNT(task_template_id) FROM task_templates WHERE task_template_ticket_template_id = recurring_ticket_ticket_template_id) AS template_task_count
|
||||
(SELECT COUNT(recurring_ticket_task_id) FROM recurring_ticket_tasks WHERE recurring_ticket_task_recurring_ticket_id = recurring_ticket_id) AS recurring_ticket_task_count
|
||||
FROM recurring_tickets
|
||||
LEFT JOIN clients ON recurring_ticket_client_id = client_id
|
||||
LEFT JOIN categories ON category_id = recurring_ticket_category
|
||||
@@ -299,7 +299,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
|
||||
$recurring_ticket_client_name = escapeHtml($row['client_name']);
|
||||
$assigned_to = escapeHtml($row['user_name']) ?: '-';
|
||||
$recurring_ticket_template_name = escapeHtml($row['ticket_template_name']);
|
||||
$recurring_ticket_template_task_count = intval($row['template_task_count']);
|
||||
$recurring_ticket_task_count = intval($row['recurring_ticket_task_count']);
|
||||
?>
|
||||
|
||||
<tr>
|
||||
@@ -315,12 +315,12 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
|
||||
data-modal-url="modals/recurring_ticket/recurring_ticket_edit.php?id=<?= $recurring_ticket_id ?>">
|
||||
<?= $recurring_ticket_subject ?>
|
||||
</a>
|
||||
<?php if ($recurring_ticket_template_name) { ?>
|
||||
<?php if ($recurring_ticket_task_count) { ?>
|
||||
<span class="badge badge-secondary"
|
||||
data-toggle="tooltip"
|
||||
data-placement="top"
|
||||
title="Template: <?= $recurring_ticket_template_name ?> - adds <?= $recurring_ticket_template_task_count ?> task<?php if ($recurring_ticket_template_task_count != 1) { echo "s"; } ?> to each ticket raised">
|
||||
<i class="fa fa-fw fa-cube"></i><?= $recurring_ticket_template_task_count ?>
|
||||
title="Adds <?= $recurring_ticket_task_count ?> task<?php if ($recurring_ticket_task_count != 1) { echo "s"; } ?> to each ticket raised<?php if ($recurring_ticket_template_name) { ?>, from template: <?= $recurring_ticket_template_name ?><?php } ?>">
|
||||
<i class="fa fa-fw fa-tasks"></i><?= $recurring_ticket_task_count ?>
|
||||
</span>
|
||||
<?php } ?>
|
||||
</td>
|
||||
|
||||
@@ -317,7 +317,6 @@ if (mysqli_num_rows($sql_recurring_tickets) > 0) {
|
||||
$contact_id = intval($row['recurring_ticket_contact_id']);
|
||||
$asset_id = intval($row['recurring_ticket_asset_id']);
|
||||
$category = intval($row['recurring_ticket_category']);
|
||||
$ticket_template_id = intval($row['recurring_ticket_ticket_template_id']);
|
||||
$url_key = randomString(32);
|
||||
|
||||
$ticket_status = 1; // Default
|
||||
@@ -353,8 +352,8 @@ if (mysqli_num_rows($sql_recurring_tickets) > 0) {
|
||||
FROM recurring_ticket_assets
|
||||
WHERE recurring_ticket_id = $recurring_ticket_id");
|
||||
|
||||
// Copy Tasks from the linked ticket template, if one is set
|
||||
addTasksFromTicketTemplate($id, $ticket_template_id);
|
||||
// Copy Tasks from the schedule's own task list
|
||||
addTasksFromRecurringTicket($id, $recurring_ticket_id);
|
||||
|
||||
// Logging
|
||||
logAudit("Ticket", "Create", "Cron created recurring scheduled $frequency ticket - $subject", $client_id, $id);
|
||||
|
||||
21
db.sql
21
db.sql
@@ -1913,6 +1913,25 @@ CREATE TABLE `recurring_ticket_assets` (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `recurring_ticket_tasks`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `recurring_ticket_tasks`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `recurring_ticket_tasks` (
|
||||
`recurring_ticket_task_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`recurring_ticket_task_name` varchar(255) NOT NULL,
|
||||
`recurring_ticket_task_order` int(11) NOT NULL DEFAULT 0,
|
||||
`recurring_ticket_task_completion_estimate` int(11) NOT NULL DEFAULT 0,
|
||||
`recurring_ticket_task_recurring_ticket_id` int(11) NOT NULL,
|
||||
PRIMARY KEY (`recurring_ticket_task_id`),
|
||||
KEY `recurring_ticket_task_recurring_ticket_id` (`recurring_ticket_task_recurring_ticket_id`),
|
||||
CONSTRAINT `recurring_ticket_tasks_ibfk_1` FOREIGN KEY (`recurring_ticket_task_recurring_ticket_id`) REFERENCES `recurring_tickets` (`recurring_ticket_id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `recurring_tickets`
|
||||
--
|
||||
@@ -3067,4 +3086,4 @@ CREATE TABLE `vendors` (
|
||||
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
|
||||
|
||||
-- Dump completed on 2026-07-29 16:11:10
|
||||
-- Dump completed on 2026-07-29 17:47:46
|
||||
|
||||
@@ -113,6 +113,78 @@ function addTasksFromTicketTemplate($ticket_id, $ticket_template_id) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a recurring ticket's task list onto a ticket it has just raised.
|
||||
*
|
||||
* Recurring tickets own their tasks (see recurring_ticket_tasks) rather than
|
||||
* reading the linked ticket template at run time, so that a schedule's task
|
||||
* list can be edited without touching the template or any other schedule.
|
||||
*
|
||||
* @param int $ticket_id The ticket to attach the tasks to.
|
||||
* @param int $recurring_ticket_id The schedule to copy tasks from. 0 = no-op.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
function addTasksFromRecurringTicket($ticket_id, $recurring_ticket_id) {
|
||||
|
||||
global $mysqli;
|
||||
|
||||
$ticket_id = intval($ticket_id);
|
||||
$recurring_ticket_id = intval($recurring_ticket_id);
|
||||
|
||||
if (!$ticket_id || !$recurring_ticket_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
mysqli_query($mysqli, "INSERT INTO tasks (task_name, task_order, task_completion_estimate, task_ticket_id)
|
||||
SELECT recurring_ticket_task_name, recurring_ticket_task_order, recurring_ticket_task_completion_estimate, $ticket_id
|
||||
FROM recurring_ticket_tasks
|
||||
WHERE recurring_ticket_task_recurring_ticket_id = $recurring_ticket_id
|
||||
ORDER BY recurring_ticket_task_order ASC");
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the editable task rows posted by the ticket and recurring ticket modals.
|
||||
*
|
||||
* The rows submit as parallel tasks[] and task_estimates[] arrays, aligned by
|
||||
* their order in the form. Rows left blank are dropped, and the order is taken
|
||||
* from the surviving rows rather than the raw array index.
|
||||
*
|
||||
* Names come back already escaped for SQL, as every caller inserts them.
|
||||
*
|
||||
* @return array List of ['name' => string, 'order' => int, 'estimate' => int]
|
||||
*/
|
||||
function parseSubmittedTasks() {
|
||||
|
||||
$tasks = [];
|
||||
|
||||
if (empty($_POST['tasks']) || !is_array($_POST['tasks'])) {
|
||||
return $tasks;
|
||||
}
|
||||
|
||||
$estimates = $_POST['task_estimates'] ?? [];
|
||||
$task_order = 0;
|
||||
|
||||
foreach ($_POST['tasks'] as $index => $task_name) {
|
||||
$task_name = trim($task_name);
|
||||
|
||||
if ($task_name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tasks[] = [
|
||||
'name' => escapeSql($task_name),
|
||||
'order' => $task_order,
|
||||
'estimate' => intval($estimates[$index] ?? 0)
|
||||
];
|
||||
|
||||
$task_order++;
|
||||
}
|
||||
|
||||
return $tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a specified field's value from a table based on the record's id.
|
||||
* It validates the table and field names, automatically determines the primary key (or uses the first column as fallback),
|
||||
|
||||
Reference in New Issue
Block a user