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>
<script>
$(document).ready(function(){
document.addEventListener('DOMContentLoaded', function () {
$('#generateAIContent').on('click', function(){
var prompt = $('#aiPrompt').val().trim();
if(prompt === '') {
var button = document.getElementById('generateAIContent');
var promptField = document.getElementById('aiPrompt');
if (!button || !promptField) {
return;
}
button.addEventListener('click', function () {
var prompt = promptField.value.trim();
if (prompt === '') {
alert('Please enter a prompt.');
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({
url: '/agent/ajax.php?ai_create_document_template', // The PHP script that calls the OpenAI API
fetch('/agent/ajax.php?ai_create_document_template', {
method: 'POST',
data: { prompt: prompt },
dataType: 'html',
success: function(response) {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
credentials: 'same-origin',
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
// and it's targeting the .tinymce textarea:
tinymce.activeEditor.setContent(response);
},
error: function() {
})
.catch(function () {
alert('Error generating content. Please try again.');
},
complete: function() {
$('#generateAIContent').prop('disabled', false).html('<i class="fa fa-fw fa-magic me-1"></i>Generate with AI');
}
});
})
.finally(function () {
button.disabled = false;
button.innerHTML = '<i class="fa fa-fw fa-magic me-1"></i>Generate with AI';
});
});
});
</script>

View File

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

View File

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

View File

@@ -1352,16 +1352,24 @@ if (isset($_GET['asset_id'])) {
<!-- JavaScript to Show/Hide Password Form Group -->
<script>
$(document).ready(function() {
$('.authMethod').on('change', function() {
var $form = $(this).closest('.authForm');
if ($(this).val() === 'local') {
$form.find('.passwordGroup').show();
} else {
$form.find('.passwordGroup').hide();
document.addEventListener('DOMContentLoaded', function () {
function syncAuthForm(select) {
var form = select.closest('.authForm');
if (!form) {
return;
}
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>

View File

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

View File

@@ -1228,16 +1228,24 @@ if (isset($_GET['contact_id'])) {
);
}
$(document).ready(function() {
$('.authMethod').on('change', function() {
var $form = $(this).closest('.authForm');
if ($(this).val() === 'local') {
$form.find('.passwordGroup').show();
} else {
$form.find('.passwordGroup').hide();
document.addEventListener('DOMContentLoaded', function () {
function syncAuthForm(select) {
var form = select.closest('.authForm');
if (!form) {
return;
}
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>

View File

@@ -747,78 +747,64 @@ require_once "../includes/footer.php";
?>
<!-- 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>
$(function() {
document.addEventListener('DOMContentLoaded', function () {
var availableProducts = <?= $json_products ?? '[]' ?>;
$("#name").autocomplete({
var nameInput = document.getElementById('name');
if (!nameInput) {
return;
}
itflowAutocomplete(nameInput, {
minLength: 1,
delay: 0,
source: function(request, response) {
var term = $.ui.autocomplete.escapeRegex(request.term.toLowerCase());
var matcher = new RegExp(term, "i");
var matches = $.grep(availableProducts, function(item) {
return matcher.test(item.label || "") || matcher.test(item.product_name || "") || matcher.test(item.product_code || "");
});
response(matches);
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;
},
select: function (event, ui) {
$("#name").val(ui.item.product_name);
$("#desc").val(ui.item.description);
$("#qty").val(1);
$("#price").val(ui.item.price);
$("#tax").val(ui.item.tax).trigger('change');
$("#product_id").val(ui.item.prod_id);
return false;
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
$("#name").on("input", function() {
$("#product_id").val(0);
nameInput.addEventListener('input', function () {
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>
@@ -835,7 +821,7 @@ new Sortable(document.querySelector('table#items tbody'), {
order: index
}));
$.post('ajax.php', {
itflowPostForm('ajax.php', {
update_invoice_items_order: true,
csrf_token: '<?= $_SESSION['csrf_token'] ?>',
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 () {
// -------------------------------
// Drag: Kanban Columns (Statuses)

View File

@@ -460,18 +460,24 @@ ob_start();
</form>
<!-- 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>
$(function() {
document.addEventListener('DOMContentLoaded', function () {
var operatingSystems = <?= $json_os ?>;
$("#os").autocomplete({
source: operatingSystems, // Should be an array of objects with 'label' and 'value'
select: function(event, ui) {
$("#os").val(ui.item.label); // Set the input field value to the selected label
return false;
var osInput = document.getElementById('os');
if (!osInput) {
return;
}
itflowAutocomplete(osInput, {
minLength: 1,
source: operatingSystems,
onSelect: function (item) {
osInput.value = item.label;
}
});
});
</script>

View File

@@ -296,17 +296,23 @@ function generatePassword() {
);
}
$(document).ready(function() {
$('.authMethod').on('change', function() {
var $form = $(this).closest('.authForm');
if ($(this).val() === 'local') {
$form.find('.passwordGroup').show();
} else {
$form.find('.passwordGroup').hide();
document.addEventListener('DOMContentLoaded', function () {
function syncAuthForm(select) {
var form = select.closest('.authForm');
if (!form) {
return;
}
});
$('.authMethod').trigger('change');
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);
});
});
</script>

View File

@@ -336,17 +336,23 @@ function generatePassword() {
);
}
$(document).ready(function() {
$('.authMethod').on('change', function() {
var $form = $(this).closest('.authForm');
if ($(this).val() === 'local') {
$form.find('.passwordGroup').show();
} else {
$form.find('.passwordGroup').hide();
document.addEventListener('DOMContentLoaded', function () {
function syncAuthForm(select) {
var form = select.closest('.authForm');
if (!form) {
return;
}
});
$('.authMethod').trigger('change');
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);
});
});
</script>

View File

@@ -248,8 +248,6 @@ ob_start();
<!-- 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>

View File

@@ -285,8 +285,6 @@ ob_start();
<!-- 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>

View File

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

View File

@@ -81,57 +81,93 @@ ob_start();
<!-- JS to make the correct boxes appear depending on if internal/client approval) -->
<script>
$('#approval_scope').on('change', function() {
const scope = $(this).val();
const typeSelect = $('#approval_type');
const wrapper = $('#approval_type_wrapper');
(function () {
var scopeSelect = document.getElementById('approval_scope');
var typeSelect = document.getElementById('approval_type');
var typeWrapper = document.getElementById('approval_type_wrapper');
var userWrapper = document.getElementById('specific_user_wrapper');
var userSelect = document.getElementById('specific_user_select');
typeSelect.empty();
$('#specific_user_wrapper').addClass('d-none');
if (!scope) {
wrapper.addClass('d-none');
if (!scopeSelect || !typeSelect) {
return;
}
wrapper.removeClass('d-none');
if (scope === 'internal') {
typeSelect.append('<option value="">Select...</option>');
typeSelect.append('<option value="any">Any internal reviewer</option>');
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>`);
function setOptions(select, pairs) {
select.innerHTML = '';
pairs.forEach(function (pair) {
// new Option() assigns text, so nothing here is parsed as markup
select.appendChild(new Option(pair[1], pair[0]));
});
});
});
// 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>
<?php

View File

@@ -588,24 +588,66 @@ require_once "../includes/footer.php";
<!-- JSON Autocomplete / type ahead -->
<!-- //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>
$(function() {
var availableProducts = <?= $json_products ?? '[]' ?>;
$("#name").autocomplete({
source: availableProducts,
select: function(event, ui) {
$("#name").val(ui.item.label); // Product name field - this seemingly has to referenced as label
$("#desc").val(ui.item.description); // Product description field
$("#qty").val(1); // Product quantity field automatically make it a 1
$("#price").val(ui.item.price); // Product price field
setTomSelectValue(document.getElementById("tax"), ui.item.tax); // Tax field - setValue repaints the Tom Select widget
return false;
}
});
document.addEventListener('DOMContentLoaded', function () {
var availableProducts = <?= $json_products ?? '[]' ?>;
var nameInput = document.getElementById('name');
if (!nameInput) {
return;
}
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 src="../libs/SortableJS/Sortable.min.js"></script>
@@ -620,7 +662,7 @@ new Sortable(document.querySelector('table#items tbody'), {
order: index
}));
$.post('ajax.php', {
itflowPostForm('ajax.php', {
update_quote_items_order: true,
csrf_token: '<?= $_SESSION['csrf_token'] ?>',
quote_id: <?= $quote_id ?>,

View File

@@ -495,24 +495,66 @@ require_once "../includes/footer.php";
?>
<!-- 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>
$(function() {
var availableProducts = <?= $json_products ?? '[]' ?>;
$("#name").autocomplete({
source: availableProducts,
select: function (event, ui) {
$("#name").val(ui.item.label); // Product name field - this seemingly has to referenced as label
$("#desc").val(ui.item.description); // Product description field
$("#qty").val(1); // Product quantity field automatically make it a 1
$("#price").val(ui.item.price); // Product price field
setTomSelectValue(document.getElementById("tax"), ui.item.tax); // Tax field - setValue repaints the Tom Select widget
return false;
}
});
document.addEventListener('DOMContentLoaded', function () {
var availableProducts = <?= $json_products ?? '[]' ?>;
var nameInput = document.getElementById('name');
if (!nameInput) {
return;
}
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 src="../libs/SortableJS/Sortable.min.js"></script>
@@ -527,7 +569,7 @@ new Sortable(document.querySelector('table#items tbody'), {
order: index
}));
$.post('ajax.php', {
itflowPostForm('ajax.php', {
update_recurring_invoice_items_order: true,
csrf_token: '<?= $_SESSION['csrf_token'] ?>',
recurring_invoice_id: <?= $recurring_invoice_id ?>,

View File

@@ -1414,7 +1414,7 @@ require_once "../includes/footer.php";
order: index
}));
$.post('ajax.php', {
itflowPostForm('ajax.php', {
update_ticket_tasks_order: true,
csrf_token: '<?= $_SESSION['csrf_token'] ?>',
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">
<!-- jQuery -->
<script src="../../libs/jquery/jquery.min.js"></script>
</head>
<body class="hold-transition login-page">
@@ -103,10 +102,20 @@ $data = "otpauth://totp/ITFlow:$session_email?secret=$token";
<script>
// Slide alert up after 4 secs
$("#alert").fadeTo(5000, 500).slideUp(500, function(){
$("#alert").slideUp(500);
});
// Fade the alert out after 5s, then collapse it
(function () {
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
@@ -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
// carries its own copy of the helper.
function flashTooltip(button, message) {
const el = button instanceof Element ? button : $(button)[0];
const el = button instanceof Element ? button : document.querySelector(button);
if (!el) {
return;
}
@@ -144,10 +153,8 @@ $data = "otpauth://totp/ITFlow:$session_email?secret=$token";
});
// Enable Popovers
$(function () {
document.querySelectorAll('[data-bs-toggle="popover"]').forEach(function (el) {
bootstrap.Popover.getOrCreateInstance(el);
})
document.querySelectorAll('[data-bs-toggle="popover"]').forEach(function (el) {
bootstrap.Popover.getOrCreateInstance(el);
});
</script>

View File

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

View File

@@ -276,7 +276,6 @@ if ($_SERVER['REQUEST_METHOD'] == "POST") {
<!-- /.login-box -->
<!-- jQuery -->
<script src="../libs/jquery/jquery.min.js"></script>
<!-- Bootstrap 4 -->
<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 -->
<script src="https://js.stripe.com/v3/"></script>
<script src="../libs/jquery/jquery.min.js"></script>
<div class="row pt-5">
<div class="col-sm">

View File

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

View File

@@ -45,6 +45,7 @@ if (basename(dirname($_SERVER['REQUEST_URI'])) === 'guest') { ?>
<!-- AdminLTE App -->
<script src="/libs/adminlte/js/adminlte.min.js"></script>
<script src="/js/autocomplete.js"></script>
<script src="/js/app.js"></script>
<script src="/js/ajax_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">
<!-- Scripts -->
<script src="/libs/jquery/jquery.min.js"></script>
</head>
<body class="layout-fixed sidebar-expand-lg app-loaded theme-<?= escapeHtml($config_theme) ?>">
<div class="app-wrapper text-sm">

View File

@@ -1,3 +1,4 @@
<script src="/js/autocomplete.js"></script>
<script src="/js/app.js"></script>
<?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
if (window.history.replaceState) {
window.history.replaceState(null, null, window.location.href);
}
// Slide alert up after 4 secs
$("#alert").fadeTo(5000, 500).slideUp(500, function() {
$("#alert").slideUp(500);
});
// Fade the legacy #alert box out after 5s, then collapse it
(function () {
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
// afterwards as element.tomselect, which is how the helpers below reach it.
@@ -446,8 +525,11 @@ $(document).ready(function() {
});
});
// ClipboardJS fix for Bootstrap modals
$.fn.modal.Constructor.prototype._enforceFocus = function() {};
// Bootstrap 4 needed _enforceFocus patched out so ClipboardJS could reach
// 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
var clipboard = new ClipboardJS('.clipboardjs');
@@ -461,15 +543,21 @@ $(document).ready(function() {
});
// Enable Popovers
$(function() {
document.querySelectorAll('[data-bs-toggle="popover"]').forEach(function (el) {
bootstrap.Popover.getOrCreateInstance(el);
});
document.querySelectorAll('[data-bs-toggle="popover"]').forEach(function (el) {
bootstrap.Popover.getOrCreateInstance(el);
});
// Data Tables
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.
@@ -487,7 +575,7 @@ $(document).ready(function() {
* namespaced .off() keeps this to a single handler - modal_footer.php re-loads this
* 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 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
* 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'));
@@ -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
* 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 endField = document.getElementById(prefix + '_end_time');
@@ -575,7 +663,7 @@ $(document).off('change.itflowEventTime').on('change.itflowEventTime', '.event-s
* until the next page load.
*/
function flashTooltip(button, message) {
const el = button instanceof Element ? button : $(button)[0];
const el = button instanceof Element ? button : document.querySelector(button);
if (!el) {
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/adminlte/js/adminlte.min.js"></script>
<script src="js/login_prevent_resubmit.js"></script>

View File

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

View File

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