All Jquery removed replaced with stand JS and non jqery dependent libs

This commit is contained in:
johnnyq
2026-08-14 17:28:06 -04:00
parent 2354dd1511
commit bbbfff7413
35 changed files with 753 additions and 328 deletions

View File

@@ -49,34 +49,48 @@ ob_start();
</form> </form>
<script> <script>
$(document).ready(function(){ document.addEventListener('DOMContentLoaded', function () {
$('#generateAIContent').on('click', function(){ var button = document.getElementById('generateAIContent');
var prompt = $('#aiPrompt').val().trim(); var promptField = document.getElementById('aiPrompt');
if(prompt === '') { if (!button || !promptField) {
return;
}
button.addEventListener('click', function () {
var prompt = promptField.value.trim();
if (prompt === '') {
alert('Please enter a prompt.'); alert('Please enter a prompt.');
return; return;
} }
$('#generateAIContent').prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> Generating...'); button.disabled = true;
button.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Generating...';
$.ajax({ fetch('/agent/ajax.php?ai_create_document_template', {
url: '/agent/ajax.php?ai_create_document_template', // The PHP script that calls the OpenAI API
method: 'POST', method: 'POST',
data: { prompt: prompt }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
dataType: 'html', credentials: 'same-origin',
success: function(response) { body: new URLSearchParams({ prompt: prompt }).toString()
})
.then(function (res) {
if (!res.ok) {
throw new Error('HTTP ' + res.status);
}
return res.text();
})
.then(function (response) {
// Assuming you have exactly one TinyMCE instance on the page // Assuming you have exactly one TinyMCE instance on the page
// and it's targeting the .tinymce textarea: // and it's targeting the .tinymce textarea:
tinymce.activeEditor.setContent(response); tinymce.activeEditor.setContent(response);
}, })
error: function() { .catch(function () {
alert('Error generating content. Please try again.'); alert('Error generating content. Please try again.');
}, })
complete: function() { .finally(function () {
$('#generateAIContent').prop('disabled', false).html('<i class="fa fa-fw fa-magic me-1"></i>Generate with AI'); button.disabled = false;
} button.innerHTML = '<i class="fa fa-fw fa-magic me-1"></i>Generate with AI';
}); });
}); });
}); });
</script> </script>

View File

@@ -227,7 +227,7 @@ new Sortable(document.querySelector('table#ticket_templates tbody'), {
order: index order: index
})); }));
$.post('/agent/ajax.php', { itflowPostForm('/agent/ajax.php', {
update_project_template_ticket_order: true, update_project_template_ticket_order: true,
csrf_token: '<?= $_SESSION['csrf_token'] ?>', csrf_token: '<?= $_SESSION['csrf_token'] ?>',
project_template_id: <?= $project_template_id ?>, project_template_id: <?= $project_template_id ?>,

View File

@@ -145,7 +145,7 @@ new Sortable(document.querySelector('table#tasks tbody'), {
order: index order: index
})); }));
$.post('/agent/ajax.php', { itflowPostForm('/agent/ajax.php', {
update_task_templates_order: true, update_task_templates_order: true,
csrf_token: '<?= $_SESSION['csrf_token'] ?>', csrf_token: '<?= $_SESSION['csrf_token'] ?>',
ticket_template_id: <?= $ticket_template_id ?>, ticket_template_id: <?= $ticket_template_id ?>,

View File

@@ -1352,16 +1352,24 @@ if (isset($_GET['asset_id'])) {
<!-- JavaScript to Show/Hide Password Form Group --> <!-- JavaScript to Show/Hide Password Form Group -->
<script> <script>
$(document).ready(function() { document.addEventListener('DOMContentLoaded', function () {
$('.authMethod').on('change', function() { function syncAuthForm(select) {
var $form = $(this).closest('.authForm'); var form = select.closest('.authForm');
if ($(this).val() === 'local') { if (!form) {
$form.find('.passwordGroup').show(); return;
} else {
$form.find('.passwordGroup').hide();
} }
form.querySelectorAll('.passwordGroup').forEach(function (group) {
group.style.display = select.value === 'local' ? '' : 'none';
});
}
document.querySelectorAll('.authMethod').forEach(function (select) {
select.addEventListener('change', function () {
syncAuthForm(this);
});
syncAuthForm(select);
}); });
$('.authMethod').trigger('change'); });
}); });
</script> </script>

View File

@@ -192,7 +192,7 @@ while ($row = mysqli_fetch_assoc($sql)) {
const allDayToggle = document.getElementById("event_add_all_day"); const allDayToggle = document.getElementById("event_add_all_day");
if (allDayToggle) { if (allDayToggle) {
allDayToggle.checked = true; allDayToggle.checked = true;
$(allDayToggle).trigger("change"); allDayToggle.dispatchEvent(new Event('change', { bubbles: true }));
} }
bootstrap.Modal.getOrCreateInstance(document.getElementById('addCalendarEventModal')).show(); bootstrap.Modal.getOrCreateInstance(document.getElementById('addCalendarEventModal')).show();
} }
@@ -270,7 +270,7 @@ while ($row = mysqli_fetch_assoc($sql)) {
// Last, so the handler in app.js shows or hides the time row to match // Last, so the handler in app.js shows or hides the time row to match
if (allDayToggle) { if (allDayToggle) {
allDayToggle.checked = selectionInfo.allDay; allDayToggle.checked = selectionInfo.allDay;
$(allDayToggle).trigger("change"); allDayToggle.dispatchEvent(new Event('change', { bubbles: true }));
} }
calendar.unselect(); calendar.unselect();
@@ -278,15 +278,14 @@ while ($row = mysqli_fetch_assoc($sql)) {
}, },
eventClick: function(editEvent) { eventClick: function(editEvent) {
var eventId = editEvent.event.id; var eventId = editEvent.event.id;
var $link = $('<a>', { var link = document.createElement('a');
href: '#', link.href = '#';
'class': 'ajax-modal', link.className = 'ajax-modal';
'data-modal-url': 'modals/calendar/calendar_event_edit.php?<?= $client_url ?>&id=' + eventId link.dataset.modalUrl = 'modals/calendar/calendar_event_edit.php?<?= $client_url ?>&id=' + eventId;
});
$('body').append($link); // Append to the body document.body.appendChild(link); // Append to the body
$link.trigger('click'); // Trigger the modal link.click(); // Trigger the modal
$link.remove(); // Cleanup link.remove(); // Cleanup
}, },
dayMaxEvents: true, // allow "more" link when too many events dayMaxEvents: true, // allow "more" link when too many events
views: { views: {

View File

@@ -1228,16 +1228,24 @@ if (isset($_GET['contact_id'])) {
); );
} }
$(document).ready(function() { document.addEventListener('DOMContentLoaded', function () {
$('.authMethod').on('change', function() { function syncAuthForm(select) {
var $form = $(this).closest('.authForm'); var form = select.closest('.authForm');
if ($(this).val() === 'local') { if (!form) {
$form.find('.passwordGroup').show(); return;
} else {
$form.find('.passwordGroup').hide();
} }
form.querySelectorAll('.passwordGroup').forEach(function (group) {
group.style.display = select.value === 'local' ? '' : 'none';
});
}
document.querySelectorAll('.authMethod').forEach(function (select) {
select.addEventListener('change', function () {
syncAuthForm(this);
});
syncAuthForm(select);
}); });
$('.authMethod').trigger('change'); });
}); });
</script> </script>

View File

@@ -747,78 +747,64 @@ require_once "../includes/footer.php";
?> ?>
<!-- JSON Autocomplete / type ahead --> <!-- JSON Autocomplete / type ahead -->
<link rel="stylesheet" href="../libs/jquery-ui/jquery-ui.min.css">
<script src="../libs/jquery-ui/jquery-ui.min.js"></script>
<script> <script>
$(function() { document.addEventListener('DOMContentLoaded', function () {
var availableProducts = <?= $json_products ?? '[]' ?>; var availableProducts = <?= $json_products ?? '[]' ?>;
$("#name").autocomplete({ var nameInput = document.getElementById('name');
if (!nameInput) {
return;
}
itflowAutocomplete(nameInput, {
minLength: 1, minLength: 1,
delay: 0, source: availableProducts,
source: function(request, response) { match: function (item, term) {
var term = $.ui.autocomplete.escapeRegex(request.term.toLowerCase()); return String(item.label || '').toLowerCase().indexOf(term) !== -1
var matcher = new RegExp(term, "i"); || String(item.product_name || '').toLowerCase().indexOf(term) !== -1
var matches = $.grep(availableProducts, function(item) { || String(item.product_code || '').toLowerCase().indexOf(term) !== -1;
return matcher.test(item.label || "") || matcher.test(item.product_name || "") || matcher.test(item.product_code || "");
});
response(matches);
}, },
select: function (event, ui) { render: function (item) {
$("#name").val(ui.item.product_name); var esc = itflowEscapeHtml;
$("#desc").val(ui.item.description); var typeText = item.type ? item.type.charAt(0).toUpperCase() + item.type.slice(1).toLowerCase() : "";
$("#qty").val(1); var showStock = (typeText.toLowerCase() !== "service");
$("#price").val(ui.item.price); var taxText = (item.tax_percent != null) ? (parseFloat(item.tax_percent) + "%") : "No tax";
$("#tax").val(ui.item.tax).trigger('change'); var priceText = (item.price != null && item.price !== "") ? String(item.price) : "";
$("#product_id").val(ui.item.prod_id); var stockText = (item.available_stock ?? 0);
return false;
return "<div class='d-flex justify-content-between align-items-start'>" +
"<div class='flex-fill pe-2'>" +
"<div class='fw-bold'>" + esc(item.label) +
(typeText ? " <small class='text-muted'>(" + esc(typeText) + ")</small>" : "") +
"</div>" +
"<div class='small text-muted'>" + esc(item.description) + "</div>" +
"<div class='mt-1'>" +
"<span class='badge bg-secondary me-1'>Tax: " + esc(taxText) + "</span>" +
(showStock ? "<span class='badge " + (stockText > 0 ? "bg-success" : "bg-danger") + "'>Stock: " + esc(stockText) + "</span>" : "") +
"</div>" +
"</div>" +
"<div class='text-end'>" +
"<div class='fw-bold'>" + esc(priceText) + "</div>" +
"</div>" +
"</div>";
},
onSelect: function (item) {
document.getElementById('name').value = item.product_name;
document.getElementById('desc').value = item.description;
document.getElementById('qty').value = 1;
document.getElementById('price').value = item.price;
setTomSelectValue(document.getElementById('tax'), item.tax);
document.getElementById('product_id').value = item.prod_id;
} }
}); });
// Typing over the name by hand breaks the link to the product // Typing over the name by hand breaks the link to the product
$("#name").on("input", function() { nameInput.addEventListener('input', function () {
$("#product_id").val(0); document.getElementById('product_id').value = 0;
}); });
// Product names and descriptions are user supplied - escape before
// building markup, the default renderer uses .text() for this reason
function esc(value) {
return $("<div>").text(value == null ? "" : value).html();
}
// Keep it simple: default jQuery UI look, just richer content
$("#name").autocomplete("instance")._renderItem = function(ul, item) {
var typeText = item.type ? item.type.charAt(0).toUpperCase() + item.type.slice(1).toLowerCase() : "";
var showStock = (typeText.toLowerCase() !== "service");
var taxText = (item.tax_percent != null) ? (parseFloat(item.tax_percent) + "%") : "No tax";
var priceText = (item.price != null && item.price !== "") ? String(item.price) : "";
var stockText = (item.available_stock ?? 0);
var infoLeft =
"<div class='d-flex justify-content-between align-items-start'>" +
"<div class='flex-fill pe-2'>" +
"<div class='fw-bold'>" + esc(item.label) +
(typeText ? " <small class='text-muted'>(" + esc(typeText) + ")</small>" : "") +
"</div>" +
"<div class='small text-muted'>" + esc(item.description) + "</div>" +
"<div class='mt-1'>" +
"<span class='badge bg-secondary me-1'>Tax: " + esc(taxText) + "</span>" +
(showStock ? "<span class='badge " + (stockText > 0 ? "bg-success" : "bg-danger") + "'>Stock: " + esc(stockText) + "</span>" : "") +
"</div>" +
"</div>" +
"<div class='text-end'>" +
"<div class='fw-bold'>" + esc(priceText) + "</div>" +
"</div>" +
"</div>";
// Use the jQuery UI wrapper so default hover/focus styles apply
return $("<li>")
.append($("<div class='ui-menu-item-wrapper'>").append(infoLeft))
.appendTo(ul);
};
}); });
</script> </script>
@@ -835,7 +821,7 @@ new Sortable(document.querySelector('table#items tbody'), {
order: index order: index
})); }));
$.post('ajax.php', { itflowPostForm('ajax.php', {
update_invoice_items_order: true, update_invoice_items_order: true,
csrf_token: '<?= $_SESSION['csrf_token'] ?>', csrf_token: '<?= $_SESSION['csrf_token'] ?>',
invoice_id: <?= $invoice_id ?>, invoice_id: <?= $invoice_id ?>,

View File

@@ -1,38 +1,3 @@
/**
* $.post replacement. jQuery serialised nested arrays/objects into PHP-style
* bracket params (positions[0][status_id]=...), which is what ajax.php parses,
* so that encoding is reproduced here rather than sending JSON.
*/
function itflowPostForm(url, data) {
const params = new URLSearchParams();
(function add(prefix, value) {
if (Array.isArray(value)) {
value.forEach(function (v, i) {
add(prefix + '[' + i + ']', v);
});
} else if (value !== null && typeof value === 'object') {
Object.keys(value).forEach(function (k) {
add(prefix ? prefix + '[' + k + ']' : k, value[k]);
});
} else {
params.append(prefix, value === true ? 'true' : String(value));
}
})('', data);
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
credentials: 'same-origin',
body: params.toString()
}).then(function (res) {
if (!res.ok) {
throw new Error('HTTP ' + res.status);
}
return res.text();
});
}
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
// ------------------------------- // -------------------------------
// Drag: Kanban Columns (Statuses) // Drag: Kanban Columns (Statuses)

View File

@@ -460,18 +460,24 @@ ob_start();
</form> </form>
<!-- JSON Autocomplete / type ahead --> <!-- JSON Autocomplete / type ahead -->
<link rel="stylesheet" href="/libs/jquery-ui/jquery-ui.min.css">
<script src="/libs/jquery-ui/jquery-ui.min.js"></script>
<script> <script>
$(function() { document.addEventListener('DOMContentLoaded', function () {
var operatingSystems = <?= $json_os ?>; var operatingSystems = <?= $json_os ?>;
$("#os").autocomplete({
source: operatingSystems, // Should be an array of objects with 'label' and 'value' var osInput = document.getElementById('os');
select: function(event, ui) { if (!osInput) {
$("#os").val(ui.item.label); // Set the input field value to the selected label return;
return false; }
itflowAutocomplete(osInput, {
minLength: 1,
source: operatingSystems,
onSelect: function (item) {
osInput.value = item.label;
} }
}); });
}); });
</script> </script>

View File

@@ -296,17 +296,23 @@ function generatePassword() {
); );
} }
$(document).ready(function() { document.addEventListener('DOMContentLoaded', function () {
$('.authMethod').on('change', function() { function syncAuthForm(select) {
var $form = $(this).closest('.authForm'); var form = select.closest('.authForm');
if ($(this).val() === 'local') { if (!form) {
$form.find('.passwordGroup').show(); return;
} else {
$form.find('.passwordGroup').hide();
} }
}); form.querySelectorAll('.passwordGroup').forEach(function (group) {
$('.authMethod').trigger('change'); group.style.display = select.value === 'local' ? '' : 'none';
});
}
document.querySelectorAll('.authMethod').forEach(function (select) {
select.addEventListener('change', function () {
syncAuthForm(this);
});
syncAuthForm(select);
});
}); });
</script> </script>

View File

@@ -336,17 +336,23 @@ function generatePassword() {
); );
} }
$(document).ready(function() { document.addEventListener('DOMContentLoaded', function () {
$('.authMethod').on('change', function() { function syncAuthForm(select) {
var $form = $(this).closest('.authForm'); var form = select.closest('.authForm');
if ($(this).val() === 'local') { if (!form) {
$form.find('.passwordGroup').show(); return;
} else {
$form.find('.passwordGroup').hide();
} }
}); form.querySelectorAll('.passwordGroup').forEach(function (group) {
$('.authMethod').trigger('change'); group.style.display = select.value === 'local' ? '' : 'none';
});
}
document.querySelectorAll('.authMethod').forEach(function (select) {
select.addEventListener('change', function () {
syncAuthForm(this);
});
syncAuthForm(select);
});
}); });
</script> </script>

View File

@@ -248,8 +248,6 @@ ob_start();
<!-- Recurring Ticket Client/Contact JS --> <!-- 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/tickets_add_modal.js"></script>
<script src="/agent/js/ticket_tasks_modal.js"></script> <script src="/agent/js/ticket_tasks_modal.js"></script>

View File

@@ -285,8 +285,6 @@ ob_start();
<!-- Ticket Client/Contact JS --> <!-- 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/tickets_add_modal.js"></script>
<script src="/agent/js/ticket_tasks_modal.js"></script> <script src="/agent/js/ticket_tasks_modal.js"></script>

View File

@@ -19,18 +19,30 @@ ob_start();
</div> </div>
<script> <script>
$(function() { document.addEventListener('DOMContentLoaded', function () {
$.ajax({ var target = document.getElementById('summaryContent');
url: 'ajax.php?ai_ticket_summary', if (!target) {
return;
}
fetch('ajax.php?ai_ticket_summary', {
method: 'POST', method: 'POST',
data: { ticket_id: <?= $ticket_id ?> }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
success: function(response) { credentials: 'same-origin',
$('#summaryContent').html(response); body: new URLSearchParams({ ticket_id: '<?= $ticket_id ?>' }).toString()
}, })
error: function() { .then(function (res) {
$('#summaryContent').html('Error generating summary.'); if (!res.ok) {
} throw new Error('HTTP ' + res.status);
}); }
return res.text();
})
.then(function (html) {
target.innerHTML = html;
})
.catch(function () {
target.textContent = 'Error generating summary.';
});
}); });
</script> </script>

View File

@@ -81,57 +81,93 @@ ob_start();
<!-- JS to make the correct boxes appear depending on if internal/client approval) --> <!-- JS to make the correct boxes appear depending on if internal/client approval) -->
<script> <script>
$('#approval_scope').on('change', function() { (function () {
const scope = $(this).val(); var scopeSelect = document.getElementById('approval_scope');
const typeSelect = $('#approval_type'); var typeSelect = document.getElementById('approval_type');
const wrapper = $('#approval_type_wrapper'); var typeWrapper = document.getElementById('approval_type_wrapper');
var userWrapper = document.getElementById('specific_user_wrapper');
var userSelect = document.getElementById('specific_user_select');
typeSelect.empty(); if (!scopeSelect || !typeSelect) {
$('#specific_user_wrapper').addClass('d-none');
if (!scope) {
wrapper.addClass('d-none');
return; return;
} }
wrapper.removeClass('d-none'); function setOptions(select, pairs) {
select.innerHTML = '';
if (scope === 'internal') { pairs.forEach(function (pair) {
typeSelect.append('<option value="">Select...</option>'); // new Option() assigns text, so nothing here is parsed as markup
typeSelect.append('<option value="any">Any internal reviewer</option>'); select.appendChild(new Option(pair[1], pair[0]));
typeSelect.append('<option value="specific">Specific agent</option>');
}
if (scope === 'client') {
typeSelect.append('<option value="">Select...</option>');
typeSelect.append('<option value="any">Ticket contact</option>');
typeSelect.append('<option value="technical">Technical contacts</option>');
typeSelect.append('<option value="billing">Billing contacts</option>');
}
});
// Specific user (internal only for now)
$('#approval_type').on('change', function() {
const type = $(this).val();
const scope = $('#approval_scope').val();
const userSelect = $('#specific_user_select');
if (type !== 'specific' || scope !== 'internal') {
$('#specific_user_wrapper').addClass('d-none');
return;
}
$('#specific_user_wrapper').removeClass('d-none');
userSelect.empty().append('<option value="">Loading...</option>');
$.getJSON('ajax.php?get_internal_users=true', function(data) {
userSelect.empty().append('<option value="">Select user...</option>');
data.users.forEach(function(u) {
userSelect.append(`<option value="${u.user_id}">${u.user_name}</option>`);
}); });
}); // the selects are Tom Select enhanced, so it has to re-read them
}); refreshTomSelect(select);
}
scopeSelect.addEventListener('change', function () {
var scope = this.value;
setOptions(typeSelect, []);
userWrapper.classList.add('d-none');
if (!scope) {
typeWrapper.classList.add('d-none');
return;
}
typeWrapper.classList.remove('d-none');
if (scope === 'internal') {
setOptions(typeSelect, [
['', 'Select...'],
['any', 'Any internal reviewer'],
['specific', 'Specific agent']
]);
}
if (scope === 'client') {
setOptions(typeSelect, [
['', 'Select...'],
['any', 'Ticket contact'],
['technical', 'Technical contacts'],
['billing', 'Billing contacts']
]);
}
});
// Specific user (internal only for now)
typeSelect.addEventListener('change', function () {
var type = this.value;
var scope = scopeSelect.value;
if (type !== 'specific' || scope !== 'internal') {
userWrapper.classList.add('d-none');
return;
}
userWrapper.classList.remove('d-none');
setOptions(userSelect, [['', 'Loading...']]);
fetch('ajax.php?get_internal_users=true', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
})
.then(function (res) {
if (!res.ok) {
throw new Error('HTTP ' + res.status);
}
return res.json();
})
.then(function (data) {
var pairs = [['', 'Select user...']];
data.users.forEach(function (u) {
pairs.push([u.user_id, u.user_name]);
});
setOptions(userSelect, pairs);
})
.catch(function () {
setOptions(userSelect, [['', 'Failed to load users']]);
});
});
})();
</script> </script>
<?php <?php

View File

@@ -588,24 +588,66 @@ require_once "../includes/footer.php";
<!-- JSON Autocomplete / type ahead --> <!-- JSON Autocomplete / type ahead -->
<!-- //TODO: Move to js/ --> <!-- //TODO: Move to js/ -->
<link rel="stylesheet" href="../libs/jquery-ui/jquery-ui.min.css">
<script src="../libs/jquery-ui/jquery-ui.min.js"></script>
<script> <script>
$(function() {
var availableProducts = <?= $json_products ?? '[]' ?>;
$("#name").autocomplete({ document.addEventListener('DOMContentLoaded', function () {
source: availableProducts,
select: function(event, ui) { var availableProducts = <?= $json_products ?? '[]' ?>;
$("#name").val(ui.item.label); // Product name field - this seemingly has to referenced as label
$("#desc").val(ui.item.description); // Product description field var nameInput = document.getElementById('name');
$("#qty").val(1); // Product quantity field automatically make it a 1 if (!nameInput) {
$("#price").val(ui.item.price); // Product price field return;
setTomSelectValue(document.getElementById("tax"), ui.item.tax); // Tax field - setValue repaints the Tom Select widget }
return false;
} itflowAutocomplete(nameInput, {
}); minLength: 1,
source: availableProducts,
match: function (item, term) {
return String(item.label || '').toLowerCase().indexOf(term) !== -1
|| String(item.product_name || '').toLowerCase().indexOf(term) !== -1
|| String(item.product_code || '').toLowerCase().indexOf(term) !== -1;
},
render: function (item) {
var esc = itflowEscapeHtml;
var typeText = item.type ? item.type.charAt(0).toUpperCase() + item.type.slice(1).toLowerCase() : "";
var showStock = (typeText.toLowerCase() !== "service");
var taxText = (item.tax_percent != null) ? (parseFloat(item.tax_percent) + "%") : "No tax";
var priceText = (item.price != null && item.price !== "") ? String(item.price) : "";
var stockText = (item.available_stock ?? 0);
return "<div class='d-flex justify-content-between align-items-start'>" +
"<div class='flex-fill pe-2'>" +
"<div class='fw-bold'>" + esc(item.label) +
(typeText ? " <small class='text-muted'>(" + esc(typeText) + ")</small>" : "") +
"</div>" +
"<div class='small text-muted'>" + esc(item.description) + "</div>" +
"<div class='mt-1'>" +
"<span class='badge bg-secondary me-1'>Tax: " + esc(taxText) + "</span>" +
(showStock ? "<span class='badge " + (stockText > 0 ? "bg-success" : "bg-danger") + "'>Stock: " + esc(stockText) + "</span>" : "") +
"</div>" +
"</div>" +
"<div class='text-end'>" +
"<div class='fw-bold'>" + esc(priceText) + "</div>" +
"</div>" +
"</div>";
},
onSelect: function (item) {
document.getElementById('name').value = item.product_name;
document.getElementById('desc').value = item.description;
document.getElementById('qty').value = 1;
document.getElementById('price').value = item.price;
setTomSelectValue(document.getElementById('tax'), item.tax);
document.getElementById('product_id').value = item.prod_id;
}
}); });
// Typing over the name by hand breaks the link to the product
nameInput.addEventListener('input', function () {
document.getElementById('product_id').value = 0;
});
});
</script> </script>
<script src="../libs/SortableJS/Sortable.min.js"></script> <script src="../libs/SortableJS/Sortable.min.js"></script>
@@ -620,7 +662,7 @@ new Sortable(document.querySelector('table#items tbody'), {
order: index order: index
})); }));
$.post('ajax.php', { itflowPostForm('ajax.php', {
update_quote_items_order: true, update_quote_items_order: true,
csrf_token: '<?= $_SESSION['csrf_token'] ?>', csrf_token: '<?= $_SESSION['csrf_token'] ?>',
quote_id: <?= $quote_id ?>, quote_id: <?= $quote_id ?>,

View File

@@ -495,24 +495,66 @@ require_once "../includes/footer.php";
?> ?>
<!-- JSON Autocomplete / type ahead --> <!-- JSON Autocomplete / type ahead -->
<link rel="stylesheet" href="../libs/jquery-ui/jquery-ui.min.css">
<script src="../libs/jquery-ui/jquery-ui.min.js"></script>
<script> <script>
$(function() {
var availableProducts = <?= $json_products ?? '[]' ?>;
$("#name").autocomplete({ document.addEventListener('DOMContentLoaded', function () {
source: availableProducts,
select: function (event, ui) { var availableProducts = <?= $json_products ?? '[]' ?>;
$("#name").val(ui.item.label); // Product name field - this seemingly has to referenced as label
$("#desc").val(ui.item.description); // Product description field var nameInput = document.getElementById('name');
$("#qty").val(1); // Product quantity field automatically make it a 1 if (!nameInput) {
$("#price").val(ui.item.price); // Product price field return;
setTomSelectValue(document.getElementById("tax"), ui.item.tax); // Tax field - setValue repaints the Tom Select widget }
return false;
} itflowAutocomplete(nameInput, {
}); minLength: 1,
source: availableProducts,
match: function (item, term) {
return String(item.label || '').toLowerCase().indexOf(term) !== -1
|| String(item.product_name || '').toLowerCase().indexOf(term) !== -1
|| String(item.product_code || '').toLowerCase().indexOf(term) !== -1;
},
render: function (item) {
var esc = itflowEscapeHtml;
var typeText = item.type ? item.type.charAt(0).toUpperCase() + item.type.slice(1).toLowerCase() : "";
var showStock = (typeText.toLowerCase() !== "service");
var taxText = (item.tax_percent != null) ? (parseFloat(item.tax_percent) + "%") : "No tax";
var priceText = (item.price != null && item.price !== "") ? String(item.price) : "";
var stockText = (item.available_stock ?? 0);
return "<div class='d-flex justify-content-between align-items-start'>" +
"<div class='flex-fill pe-2'>" +
"<div class='fw-bold'>" + esc(item.label) +
(typeText ? " <small class='text-muted'>(" + esc(typeText) + ")</small>" : "") +
"</div>" +
"<div class='small text-muted'>" + esc(item.description) + "</div>" +
"<div class='mt-1'>" +
"<span class='badge bg-secondary me-1'>Tax: " + esc(taxText) + "</span>" +
(showStock ? "<span class='badge " + (stockText > 0 ? "bg-success" : "bg-danger") + "'>Stock: " + esc(stockText) + "</span>" : "") +
"</div>" +
"</div>" +
"<div class='text-end'>" +
"<div class='fw-bold'>" + esc(priceText) + "</div>" +
"</div>" +
"</div>";
},
onSelect: function (item) {
document.getElementById('name').value = item.product_name;
document.getElementById('desc').value = item.description;
document.getElementById('qty').value = 1;
document.getElementById('price').value = item.price;
setTomSelectValue(document.getElementById('tax'), item.tax);
document.getElementById('product_id').value = item.prod_id;
}
}); });
// Typing over the name by hand breaks the link to the product
nameInput.addEventListener('input', function () {
document.getElementById('product_id').value = 0;
});
});
</script> </script>
<script src="../libs/SortableJS/Sortable.min.js"></script> <script src="../libs/SortableJS/Sortable.min.js"></script>
@@ -527,7 +569,7 @@ new Sortable(document.querySelector('table#items tbody'), {
order: index order: index
})); }));
$.post('ajax.php', { itflowPostForm('ajax.php', {
update_recurring_invoice_items_order: true, update_recurring_invoice_items_order: true,
csrf_token: '<?= $_SESSION['csrf_token'] ?>', csrf_token: '<?= $_SESSION['csrf_token'] ?>',
recurring_invoice_id: <?= $recurring_invoice_id ?>, recurring_invoice_id: <?= $recurring_invoice_id ?>,

View File

@@ -1414,7 +1414,7 @@ require_once "../includes/footer.php";
order: index order: index
})); }));
$.post('ajax.php', { itflowPostForm('ajax.php', {
update_ticket_tasks_order: true, update_ticket_tasks_order: true,
csrf_token: '<?= $_SESSION['csrf_token'] ?>', csrf_token: '<?= $_SESSION['csrf_token'] ?>',
ticket_id: <?= $ticket_id ?>, ticket_id: <?= $ticket_id ?>,

View File

@@ -47,7 +47,6 @@ $data = "otpauth://totp/ITFlow:$session_email?secret=$token";
<link rel="stylesheet" href="../../libs/adminlte/css/adminlte.min.css"> <link rel="stylesheet" href="../../libs/adminlte/css/adminlte.min.css">
<!-- jQuery --> <!-- jQuery -->
<script src="../../libs/jquery/jquery.min.js"></script>
</head> </head>
<body class="hold-transition login-page"> <body class="hold-transition login-page">
@@ -103,10 +102,20 @@ $data = "otpauth://totp/ITFlow:$session_email?secret=$token";
<script> <script>
// Slide alert up after 4 secs // Fade the alert out after 5s, then collapse it
$("#alert").fadeTo(5000, 500).slideUp(500, function(){ (function () {
$("#alert").slideUp(500); const alertEl = document.getElementById('alert');
}); if (!alertEl) {
return;
}
setTimeout(function () {
alertEl.style.transition = 'opacity .5s linear';
alertEl.style.opacity = '0';
setTimeout(function () {
alertEl.style.display = 'none';
}, 500);
}, 5000);
})();
// ClipboardJS // ClipboardJS
@@ -114,7 +123,7 @@ $data = "otpauth://totp/ITFlow:$session_email?secret=$token";
// on screen. This page is standalone and does not load js/app.js, so it // on screen. This page is standalone and does not load js/app.js, so it
// carries its own copy of the helper. // carries its own copy of the helper.
function flashTooltip(button, message) { function flashTooltip(button, message) {
const el = button instanceof Element ? button : $(button)[0]; const el = button instanceof Element ? button : document.querySelector(button);
if (!el) { if (!el) {
return; return;
} }
@@ -144,10 +153,8 @@ $data = "otpauth://totp/ITFlow:$session_email?secret=$token";
}); });
// Enable Popovers // Enable Popovers
$(function () { document.querySelectorAll('[data-bs-toggle="popover"]').forEach(function (el) {
document.querySelectorAll('[data-bs-toggle="popover"]').forEach(function (el) { bootstrap.Popover.getOrCreateInstance(el);
bootstrap.Popover.getOrCreateInstance(el);
})
}); });
</script> </script>

View File

@@ -24,7 +24,6 @@
<?php require_once $_SERVER['DOCUMENT_ROOT'] . '/includes/inc_confirm_modal.php'; ?> <?php require_once $_SERVER['DOCUMENT_ROOT'] . '/includes/inc_confirm_modal.php'; ?>
<!-- jQuery --> <!-- jQuery -->
<script src="/libs/jquery/jquery.min.js"></script>
<!-- Bootstrap 4 --> <!-- Bootstrap 4 -->
<script src="/libs/bootstrap/js/bootstrap.bundle.min.js"></script> <script src="/libs/bootstrap/js/bootstrap.bundle.min.js"></script>

View File

@@ -276,7 +276,6 @@ if ($_SERVER['REQUEST_METHOD'] == "POST") {
<!-- /.login-box --> <!-- /.login-box -->
<!-- jQuery --> <!-- jQuery -->
<script src="../libs/jquery/jquery.min.js"></script>
<!-- Bootstrap 4 --> <!-- Bootstrap 4 -->
<script src="../libs/bootstrap/js/bootstrap.bundle.min.js"></script> <script src="../libs/bootstrap/js/bootstrap.bundle.min.js"></script>

View File

@@ -72,7 +72,6 @@ if (isset($_GET['invoice_id'], $_GET['url_key']) && !isset($_GET['payment_intent
<!-- Stripe & jQuery --> <!-- Stripe & jQuery -->
<script src="https://js.stripe.com/v3/"></script> <script src="https://js.stripe.com/v3/"></script>
<script src="../libs/jquery/jquery.min.js"></script>
<div class="row pt-5"> <div class="row pt-5">
<div class="col-sm"> <div class="col-sm">

View File

@@ -26,7 +26,6 @@
<link rel="stylesheet" href="/libs/tom-select/css/tom-select.bootstrap5.min.css"> <link rel="stylesheet" href="/libs/tom-select/css/tom-select.bootstrap5.min.css">
<!-- Scripts --> <!-- Scripts -->
<script src="/libs/jquery/jquery.min.js"></script>
</head> </head>
<body class="layout-fixed theme-<?= escapeHtml($config_theme) ?>"> <body class="layout-fixed theme-<?= escapeHtml($config_theme) ?>">

View File

@@ -45,6 +45,7 @@ if (basename(dirname($_SERVER['REQUEST_URI'])) === 'guest') { ?>
<!-- AdminLTE App --> <!-- AdminLTE App -->
<script src="/libs/adminlte/js/adminlte.min.js"></script> <script src="/libs/adminlte/js/adminlte.min.js"></script>
<script src="/js/autocomplete.js"></script>
<script src="/js/app.js"></script> <script src="/js/app.js"></script>
<script src="/js/ajax_modal.js"></script> <script src="/js/ajax_modal.js"></script>
<script src="/js/confirm_modal.js"></script> <script src="/js/confirm_modal.js"></script>

View File

@@ -35,7 +35,6 @@ header("X-Frame-Options: DENY");
<link rel="stylesheet" href="/css/itflow_custom.css"> <link rel="stylesheet" href="/css/itflow_custom.css">
<!-- Scripts --> <!-- Scripts -->
<script src="/libs/jquery/jquery.min.js"></script>
</head> </head>
<body class="layout-fixed sidebar-expand-lg app-loaded theme-<?= escapeHtml($config_theme) ?>"> <body class="layout-fixed sidebar-expand-lg app-loaded theme-<?= escapeHtml($config_theme) ?>">
<div class="app-wrapper text-sm"> <div class="app-wrapper text-sm">

View File

@@ -1,3 +1,4 @@
<script src="/js/autocomplete.js"></script>
<script src="/js/app.js"></script> <script src="/js/app.js"></script>
<?php <?php

120
js/app.js
View File

@@ -1,13 +1,92 @@
$(document).ready(function() { /**
* Delegated listener that binds exactly once.
*
* modal_footer.php re-loads this file every time an ajax modal opens, which is
* why the jQuery originals used a namespaced .off().on() - without it the
* handlers stacked up and fired once per modal ever opened. The named flag on
* window is the vanilla equivalent. `this` is the matched element, matching
* jQuery's delegation contract so the handler bodies are unchanged.
*/
/**
* $.post replacement. jQuery serialised nested arrays/objects into PHP-style
* bracket params (positions[0][status_id]=...), which is what ajax.php parses,
* so that encoding is reproduced here rather than sending JSON.
*/
function itflowPostForm(url, data) {
const params = new URLSearchParams();
(function add(prefix, value) {
if (Array.isArray(value)) {
value.forEach(function (v, i) {
add(prefix + '[' + i + ']', v);
});
} else if (value !== null && typeof value === 'object') {
Object.keys(value).forEach(function (k) {
add(prefix ? prefix + '[' + k + ']' : k, value[k]);
});
} else {
params.append(prefix, value === true ? 'true' : String(value));
}
})('', data);
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
credentials: 'same-origin',
body: params.toString()
}).then(function (res) {
if (!res.ok) {
throw new Error('HTTP ' + res.status);
}
return res.text();
});
}
function itflowBindOnce(name, type, selector, handler) {
window.itflowBound = window.itflowBound || {};
if (window.itflowBound[name]) {
return;
}
window.itflowBound[name] = true;
document.addEventListener(type, function (e) {
const match = e.target.closest(selector);
if (match) {
handler.call(match, e);
}
});
}
function itflowInit() {
// Prevents resubmit on forms // Prevents resubmit on forms
if (window.history.replaceState) { if (window.history.replaceState) {
window.history.replaceState(null, null, window.location.href); window.history.replaceState(null, null, window.location.href);
} }
// Slide alert up after 4 secs // Fade the legacy #alert box out after 5s, then collapse it
$("#alert").fadeTo(5000, 500).slideUp(500, function() { (function () {
$("#alert").slideUp(500); const alertEl = document.getElementById('alert');
}); if (!alertEl) {
return;
}
setTimeout(function () {
alertEl.style.transition = 'opacity .5s linear';
alertEl.style.opacity = '0';
setTimeout(function () {
alertEl.style.overflow = 'hidden';
alertEl.style.transition = 'height .5s ease, margin .5s ease, padding .5s ease';
alertEl.style.height = alertEl.offsetHeight + 'px';
void alertEl.offsetHeight;
alertEl.style.height = '0px';
alertEl.style.marginTop = '0';
alertEl.style.marginBottom = '0';
alertEl.style.paddingTop = '0';
alertEl.style.paddingBottom = '0';
setTimeout(function () {
alertEl.style.display = 'none';
}, 500);
}, 500);
}, 5000);
})();
// Initialize Tom Select (replaces Select2). Every instance is reachable // Initialize Tom Select (replaces Select2). Every instance is reachable
// afterwards as element.tomselect, which is how the helpers below reach it. // afterwards as element.tomselect, which is how the helpers below reach it.
@@ -446,8 +525,11 @@ $(document).ready(function() {
}); });
}); });
// ClipboardJS fix for Bootstrap modals // Bootstrap 4 needed _enforceFocus patched out so ClipboardJS could reach
$.fn.modal.Constructor.prototype._enforceFocus = function() {}; // its textarea inside a modal. Bootstrap 5 registers no jQuery plugin, so
// the old $.fn.modal line threw and killed everything below it. If copying
// from inside a modal ever misbehaves, ClipboardJS's `container` option is
// the lever, not a Bootstrap patch.
// Clipboard // Clipboard
var clipboard = new ClipboardJS('.clipboardjs'); var clipboard = new ClipboardJS('.clipboardjs');
@@ -461,15 +543,21 @@ $(document).ready(function() {
}); });
// Enable Popovers // Enable Popovers
$(function() { document.querySelectorAll('[data-bs-toggle="popover"]').forEach(function (el) {
document.querySelectorAll('[data-bs-toggle="popover"]').forEach(function (el) { bootstrap.Popover.getOrCreateInstance(el);
bootstrap.Popover.getOrCreateInstance(el);
});
}); });
// Data Tables // Data Tables
new DataTable('.dataTables'); new DataTable('.dataTables');
}); }
// modal_footer.php re-loads this file on every ajax modal open, so run now if
// the document is already parsed, otherwise wait for it.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', itflowInit);
} else {
itflowInit();
}
/* /*
* Calendar event modals - the All day switch shows or hides the time row. * Calendar event modals - the All day switch shows or hides the time row.
@@ -487,7 +575,7 @@ $(document).ready(function() {
* namespaced .off() keeps this to a single handler - modal_footer.php re-loads this * namespaced .off() keeps this to a single handler - modal_footer.php re-loads this
* file on every ajax modal open. * file on every ajax modal open.
*/ */
$(document).off('change.itflowAllDay').on('change.itflowAllDay', '.event-all-day-toggle', function () { itflowBindOnce('itflowAllDay', 'change', '.event-all-day-toggle', function () {
const allDay = this.checked; const allDay = this.checked;
const timeFields = document.getElementById(this.id.replace(/_all_day$/, '_time_fields')); const timeFields = document.getElementById(this.id.replace(/_all_day$/, '_time_fields'));
@@ -511,7 +599,7 @@ $(document).off('change.itflowAllDay').on('change.itflowAllDay', '.event-all-day
* Keep the end date at or after the start date, without shortening a longer span * Keep the end date at or after the start date, without shortening a longer span
* the user has already chosen. * the user has already chosen.
*/ */
$(document).off('change.itflowEventDate').on('change.itflowEventDate', '.event-start-date', function () { itflowBindOnce('itflowEventDate', 'change', '.event-start-date', function () {
const endField = document.getElementById(this.id.replace(/_start_date$/, '_end_date')); const endField = document.getElementById(this.id.replace(/_start_date$/, '_end_date'));
@@ -529,7 +617,7 @@ $(document).off('change.itflowEventDate').on('change.itflowEventDate', '.event-s
* A start late in the evening rolls the end onto the following day rather than * A start late in the evening rolls the end onto the following day rather than
* wrapping round to an end that precedes the start. * wrapping round to an end that precedes the start.
*/ */
$(document).off('change.itflowEventTime').on('change.itflowEventTime', '.event-start-time', function () { itflowBindOnce('itflowEventTime', 'change', '.event-start-time', function () {
const prefix = this.id.replace(/_start_time$/, ''); const prefix = this.id.replace(/_start_time$/, '');
const endField = document.getElementById(prefix + '_end_time'); const endField = document.getElementById(prefix + '_end_time');
@@ -575,7 +663,7 @@ $(document).off('change.itflowEventTime').on('change.itflowEventTime', '.event-s
* until the next page load. * until the next page load.
*/ */
function flashTooltip(button, message) { function flashTooltip(button, message) {
const el = button instanceof Element ? button : $(button)[0]; const el = button instanceof Element ? button : document.querySelector(button);
if (!el) { if (!el) {
return; return;
} }

204
js/autocomplete.js Normal file
View File

@@ -0,0 +1,204 @@
/**
* Minimal autocomplete, replacing jQuery UI's.
*
* itflowAutocomplete(input, {
* source: array of items, or fn(term) -> array
* minLength: default 1
* maxItems: cap on rendered results, default 50
* match: fn(item, term) -> bool (default: substring over item.label)
* render: fn(item) -> HTML string (default: escaped item.label)
* onSelect: fn(item)
* })
*
* Keyboard: up/down to move, Enter to pick, Escape to dismiss.
* The menu is capped to the space actually available in the viewport and
* scrolls internally, and flips above the input when there is more room there.
*/
function itflowEscapeHtml(value) {
var div = document.createElement('div');
div.textContent = value == null ? '' : String(value);
return div.innerHTML;
}
function itflowAutocomplete(input, options) {
if (!input || input.itflowAutocomplete) {
return;
}
var opts = options || {};
var minLength = opts.minLength == null ? 1 : opts.minLength;
var maxItems = opts.maxItems == null ? 50 : opts.maxItems;
var GAP = 4; // breathing room against the input
var MARGIN = 8; // never touch the viewport edge
var MIN_HEIGHT = 120;
var items = [];
var active = -1;
var menu = document.createElement('div');
menu.className = 'itflow-ac-menu dropdown-menu p-0';
menu.setAttribute('role', 'listbox');
document.body.appendChild(menu);
function defaultMatch(item, term) {
return String(item.label || '').toLowerCase().indexOf(term) !== -1;
}
function close() {
menu.classList.remove('show');
menu.innerHTML = '';
active = -1;
input.setAttribute('aria-expanded', 'false');
}
/**
* Size and place the menu against whatever room the viewport actually has.
* Without this a long product list runs off the bottom of the page.
*/
function position() {
var r = input.getBoundingClientRect();
var below = window.innerHeight - r.bottom - GAP - MARGIN;
var above = r.top - GAP - MARGIN;
var flip = below < MIN_HEIGHT && above > below;
var room = Math.max(flip ? above : below, MIN_HEIGHT);
menu.style.position = 'fixed';
menu.style.left = r.left + 'px';
menu.style.minWidth = r.width + 'px';
menu.style.maxWidth = Math.max(r.width, Math.min(520, window.innerWidth - (MARGIN * 2))) + 'px';
menu.style.maxHeight = room + 'px';
menu.style.overflowY = 'auto';
menu.style.overflowX = 'hidden';
menu.style.zIndex = '2000';
if (flip) {
menu.style.top = '';
menu.style.bottom = (window.innerHeight - r.top + GAP) + 'px';
} else {
menu.style.bottom = '';
menu.style.top = (r.bottom + GAP) + 'px';
}
// Keep it on screen horizontally too
var width = menu.offsetWidth || r.width;
if (r.left + width > window.innerWidth - MARGIN) {
menu.style.left = Math.max(MARGIN, window.innerWidth - width - MARGIN) + 'px';
}
}
function highlight(next) {
var nodes = menu.querySelectorAll('.itflow-ac-item');
if (!nodes.length) {
return;
}
if (active >= 0 && nodes[active]) {
nodes[active].classList.remove('active');
}
active = next < 0 ? nodes.length - 1 : (next >= nodes.length ? 0 : next);
nodes[active].classList.add('active');
nodes[active].scrollIntoView({ block: 'nearest' });
}
function choose(index) {
var item = items[index];
if (!item) {
return;
}
close();
if (typeof opts.onSelect === 'function') {
opts.onSelect(item);
}
}
function open(term) {
var source = typeof opts.source === 'function' ? opts.source(term) : (opts.source || []);
var matcher = typeof opts.match === 'function' ? opts.match : defaultMatch;
var all = source.filter(function (item) {
return matcher(item, term);
});
items = all.slice(0, maxItems);
if (!items.length) {
close();
return;
}
menu.innerHTML = '';
items.forEach(function (item, i) {
var el = document.createElement('button');
el.type = 'button';
el.className = 'itflow-ac-item dropdown-item text-wrap';
el.setAttribute('role', 'option');
el.innerHTML = typeof opts.render === 'function'
? opts.render(item)
: itflowEscapeHtml(item.label);
el.addEventListener('mousedown', function (e) {
// mousedown, not click - blur would close the menu first
e.preventDefault();
choose(i);
});
menu.appendChild(el);
});
if (all.length > items.length) {
var more = document.createElement('div');
more.className = 'itflow-ac-more small text-muted px-3 py-2 border-top';
more.textContent = 'Showing ' + items.length + ' of ' + all.length + ' - keep typing to narrow';
menu.appendChild(more);
}
menu.classList.add('show');
input.setAttribute('aria-expanded', 'true');
active = -1;
position();
menu.scrollTop = 0;
}
input.setAttribute('autocomplete', 'off');
input.setAttribute('aria-autocomplete', 'list');
input.addEventListener('input', function () {
var term = input.value.trim().toLowerCase();
if (term.length < minLength) {
close();
return;
}
open(term);
});
input.addEventListener('keydown', function (e) {
if (!menu.classList.contains('show')) {
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
highlight(active + 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlight(active - 1);
} else if (e.key === 'Enter') {
if (active >= 0) {
e.preventDefault();
choose(active);
}
} else if (e.key === 'Escape') {
close();
}
});
input.addEventListener('blur', function () {
setTimeout(close, 150);
});
function reposition() {
if (menu.classList.contains('show')) {
position();
}
}
window.addEventListener('resize', reposition);
// position: fixed does not follow the page, and these menus open inside
// scrollable modal bodies - so track scroll on the way up the tree too
window.addEventListener('scroll', reposition, true);
document.addEventListener('click', function (e) {
if (e.target !== input && !menu.contains(e.target)) {
close();
}
});
input.itflowAutocomplete = { close: close, reposition: reposition };
}

View File

@@ -1 +0,0 @@
JQuery UI 1.13.0

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -810,7 +810,6 @@ if (!$config_whitelabel_enabled) {
} }
?> ?>
<script src="libs/jquery/jquery.min.js"></script>
<script src="libs/bootstrap/js/bootstrap.bundle.min.js"></script> <script src="libs/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="libs/adminlte/js/adminlte.min.js"></script> <script src="libs/adminlte/js/adminlte.min.js"></script>
<script src="js/login_prevent_resubmit.js"></script> <script src="js/login_prevent_resubmit.js"></script>

View File

@@ -94,36 +94,57 @@ ob_start();
</div> </div>
<script> <script>
$(document).ready(function () { document.addEventListener('DOMContentLoaded', function () {
var perPage = 8; var perPage = 8;
var $items = $(".notification-item"); var items = Array.from(document.querySelectorAll('.notification-item'));
var totalItems = $items.length; var totalItems = items.length;
var totalPages = Math.ceil(totalItems / perPage); var totalPages = Math.ceil(totalItems / perPage);
var currentPage = 0; var currentPage = 0;
var prevBtn = document.getElementById('prev-btn');
var nextBtn = document.getElementById('next-btn');
var indicator = document.getElementById('page-indicator');
function showPage(page) { function showPage(page) {
$items.hide().slice(page * perPage, (page + 1) * perPage).show(); items.forEach(function (item, i) {
$("#prev-btn").prop("disabled", page === 0); var visible = i >= page * perPage && i < (page + 1) * perPage;
$("#next-btn").prop("disabled", page >= totalPages - 1); item.style.display = visible ? '' : 'none';
$("#page-indicator").text(`Page ${page + 1} of ${totalPages} (${totalItems} total)`); });
if (prevBtn) {
prevBtn.disabled = page === 0;
}
if (nextBtn) {
nextBtn.disabled = page >= totalPages - 1;
}
if (indicator) {
indicator.textContent = `Page ${page + 1} of ${totalPages} (${totalItems} total)`;
}
} }
$("#prev-btn").on("click", function () { if (prevBtn) {
if (currentPage > 0) { prevBtn.addEventListener('click', function () {
currentPage--; if (currentPage > 0) {
showPage(currentPage); currentPage--;
} showPage(currentPage);
}); }
});
}
$("#next-btn").on("click", function () { if (nextBtn) {
if (currentPage < totalPages - 1) { nextBtn.addEventListener('click', function () {
currentPage++; if (currentPage < totalPages - 1) {
showPage(currentPage); currentPage++;
} showPage(currentPage);
}); }
});
}
if (totalItems <= perPage) { if (totalItems <= perPage) {
$("#prev-btn, #next-btn, #page-indicator").hide(); [prevBtn, nextBtn, indicator].forEach(function (el) {
if (el) {
el.style.display = 'none';
}
});
} }
showPage(currentPage); showPage(currentPage);

View File

@@ -1457,7 +1457,6 @@ if (isset($_POST['add_telemetry'])) {
<!-- REQUIRED SCRIPTS --> <!-- REQUIRED SCRIPTS -->
<!-- jQuery --> <!-- jQuery -->
<script src="/libs/jquery/jquery.min.js"></script>
<!-- Bootstrap 5 --> <!-- Bootstrap 5 -->
<script src="/libs/bootstrap/js/bootstrap.bundle.min.js"></script> <script src="/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
<!-- Custom js--> <!-- Custom js-->