Files
itflow/agent/js/calendar.js
johnnyq be002b6f29 Roles stage 4: the money on invoices goes by Financial with Sales at Read through billingMoneyCan and enforceBillingMoney, so taking payments one at a time, by saved card or as a batch, changing them and emailing receipts, reminders and statements need write access to Financial, and deleting or refunding a payment needs full access, with the old Sales rules for reminders, statements and receipts still accepted; invoice, invoice list, Income and payment quick looks offer only what the role can do, the reminder window offers only the reminder to a role that cannot send the invoice, a reminder for a draft or with nothing owed still needs Sales, and Income bulk Set Category on payments needs Sales as it changes the invoice; database 3.1.8 gives the built-in Accountant full access to Financial when it still has its shipped levels, and new installs seed it that way; calendar events for a client follow the role's Clients level through calendarEventCan, Read to see, Modify to add, change, move, skip or send, Full to delete or Modify for the agent who added it, events an agent is on always show, events with no client stay open to everyone, and the calendar, dashboard and calendar links leave out other agents' client events for roles without Clients, with New event, the client picker and dragging following the same rule; the API now sets PHP and MySQL to ITFlow's timezone so what it creates, API ticket SLA times and key expiry go by local time, and inc_set_timezone.php only writes the session when one is open and no longer leaves its result and row variables behind; changelog updated
2026-10-17 21:52:10 -04:00

514 lines
20 KiB
JavaScript

// The calendar page (agent/calendar.php).
//
// Items are fetched for the range on screen from ajax.php?calendar_items -
// the events on the calendars ticked, and the built-in sources ticked - for
// one client or all, everyone's or one agent's. What is ticked, whose, and
// the view are remembered in this browser.
//
// Picking a day or a time range opens New Event for it. A click opens the
// item's quick look. Scheduled tickets and events that do not repeat can be
// dragged to a new time (ajax.php?calendar_move), and the open tickets in To
// schedule dragged onto the calendar to book them - a ticket is booked exactly
// as from the ticket, so the agent and contact are emailed the time.
(function () {
const root = document.getElementById('itflowCalendar');
const el = document.getElementById('calendar');
if (!root || !el || typeof FullCalendar === 'undefined') {
return;
}
const csrf = root.dataset.csrf;
const clientId = Number(root.dataset.clientId) || 0;
const myId = Number(root.dataset.userId) || 0;
const canSchedule = root.dataset.canSchedule === '1';
// A client's calendar takes Clients at Modify to add to (calendarEventCan())
const canAdd = root.dataset.canAdd !== '0';
const mobile = root.dataset.mobile === '1' || window.innerWidth < 768;
const sourceDefaults = JSON.parse(root.dataset.sources || '{}');
const agentSelect = document.getElementById('calendarAgent');
const unscheduledList = document.getElementById('calendarUnscheduled');
// ---------- what this browser remembers ----------
const storeKey = 'itflow.calendar.' + (clientId ? 'client' : 'all');
let state = {};
try {
state = JSON.parse(window.localStorage.getItem(storeKey) || '{}') || {};
} catch (e) {
state = {};
}
function save() {
try {
window.localStorage.setItem(storeKey, JSON.stringify(state));
} catch (e) {
// Private window or storage blocked - it just won't be remembered
}
}
state.hidden = Array.isArray(state.hidden) ? state.hidden : [];
state.sources = state.sources && typeof state.sources === 'object' ? state.sources : {};
root.querySelectorAll('.itflow-cal-check[data-calendar]').forEach(function (box) {
box.checked = state.hidden.indexOf(box.dataset.calendar) === -1;
});
root.querySelectorAll('.itflow-cal-check[data-source]').forEach(function (box) {
const key = box.dataset.source;
box.checked = key in state.sources ? !!state.sources[key] : !!(sourceDefaults[key] || {}).default;
});
if (agentSelect && state.agent && agentSelect.querySelector('option[value="' + Number(state.agent) + '"]')) {
agentSelect.value = String(Number(state.agent));
}
function shownCalendars() {
return Array.from(root.querySelectorAll('.itflow-cal-check[data-calendar]:checked')).map(function (box) {
return box.dataset.calendar;
});
}
function shownSources() {
return Array.from(root.querySelectorAll('.itflow-cal-check[data-source]:checked')).map(function (box) {
return box.dataset.source;
});
}
function agentId() {
return agentSelect ? Number(agentSelect.value) || 0 : 0;
}
// ---------- helpers ----------
const pad = function (n) {
return String(n).padStart(2, '0');
};
// Local wall clock - toISOString() would shift by the browser's offset
function ymd(date) {
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate());
}
function ymdhm(date) {
return ymd(date) + ' ' + pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':00';
}
function words(date, allDay) {
const options = {weekday: 'short', month: 'short', day: 'numeric'};
if (!allDay) {
options.hour = 'numeric';
options.minute = '2-digit';
}
return date.toLocaleString(undefined, options);
}
function openModal(url, size) {
const link = document.createElement('a');
link.href = '#';
link.className = 'ajax-modal d-none';
link.dataset.modalUrl = url;
if (size) {
link.dataset.modalSize = size;
}
document.body.appendChild(link);
link.click();
link.remove();
}
function newEvent(params) {
const query = new URLSearchParams(params || {});
if (clientId) {
query.set('client_id', clientId);
query.set('lock_client', '1');
}
openModal('modals/calendar/calendar_event_add.php?' + query.toString(), 'lg');
}
function confirmDialog(options) {
if (typeof Swal === 'undefined') {
return Promise.resolve({isConfirmed: window.confirm(options.title), isDenied: false});
}
return Swal.fire(Object.assign({
theme: document.documentElement.getAttribute('data-bs-theme') === 'dark' ? 'dark' : 'light',
icon: 'question',
showCancelButton: true,
cancelButtonText: 'Cancel',
reverseButtons: true,
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-primary mx-1',
denyButton: 'btn btn-outline-secondary mx-1',
cancelButton: 'btn btn-secondary mx-1',
input: 'itflow-cal-swal-input'
}
}, options));
}
function post(data, done, fail) {
data.calendar_move = 1;
data.csrf_token = csrf;
fetch('ajax.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
credentials: 'same-origin',
body: new URLSearchParams(data).toString()
}).then(function (res) {
return res.json().catch(function () {
return {error: 'Something went wrong - reload and try again.'};
});
}).then(function (json) {
if (json && json.ok) {
done(json);
} else {
fail(json && json.error ? json.error : 'That could not be saved.');
}
}).catch(function () {
fail('That could not be saved - check the connection.');
});
}
function saved(json) {
itflowToast(json.message, 'success');
if (json.conflicts && json.conflicts.length) {
itflowToast('Same agent, within two hours: ' + json.conflicts.join('; '), 'warning');
}
}
// ---------- the calendar ----------
const calendar = new FullCalendar.Calendar(el, {
initialView: state.view || (mobile ? 'listWeek' : 'dayGridMonth'),
initialDate: root.dataset.initialDate || undefined,
headerToolbar: mobile
? {left: 'prev,next', center: 'title', right: 'today'}
: {left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'},
footerToolbar: mobile ? {center: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'} : false,
noEventsText: 'Nothing on the calendar for these days',
firstDay: Number(root.dataset.firstDay) || 0,
height: mobile ? 'auto' : 'calc(100vh - 11rem)',
navLinks: true,
nowIndicator: true,
dayMaxEvents: true,
selectable: canAdd,
selectMirror: true,
editable: true,
droppable: canSchedule,
scrollTime: '07:30:00',
businessHours: {daysOfWeek: [1, 2, 3, 4, 5], startTime: '08:00', endTime: '18:00'},
eventOrder: 'allDay,start,-duration,title',
views: {
// Side by side past three, the rest go behind "+N more"
timeGrid: {eventMaxStack: 3}
},
events: function (info, success, failure) {
const end = new Date(info.end.getTime());
end.setDate(end.getDate() - 1);
itflowGet('ajax.php', {
calendar_items: 1,
from: ymd(info.start),
to: ymd(end),
calendars: shownCalendars().join(','),
sources: shownSources().join(','),
client_id: clientId,
user_id: agentId()
}, function (text) {
let items = [];
try {
items = JSON.parse(text);
} catch (e) {
items = [];
}
success(Array.isArray(items) ? items : []);
}, function (error) {
failure(error);
});
},
loading: function (on) {
root.classList.toggle('is-loading', on);
},
datesSet: function (info) {
state.view = info.view.type;
save();
},
// An icon for what it is, the title, and where / who underneath when there is room
eventContent: function (arg) {
const props = arg.event.extendedProps || {};
const view = arg.view.type;
const wrap = document.createElement('div');
wrap.className = 'itflow-cal-event ' + (view.indexOf('list') === 0 ? 'is-list' : (view.indexOf('timeGrid') === 0 && !arg.event.allDay ? 'is-timegrid' : 'is-daygrid'));
if ((arg.timeText && !arg.event.allDay) || (arg.event.allDay && view.indexOf('list') === 0)) {
const time = document.createElement('span');
time.className = 'itflow-cal-time';
time.textContent = arg.event.allDay ? 'All day' : arg.timeText;
wrap.appendChild(time);
}
if (props.icon) {
const icon = document.createElement('i');
icon.className = 'fas fa-fw ' + props.icon + ' itflow-cal-icon';
wrap.appendChild(icon);
}
const title = document.createElement('span');
title.className = 'itflow-cal-title';
title.textContent = arg.event.title;
wrap.appendChild(title);
if (props.detail && (view.indexOf('timeGrid') === 0 || view.indexOf('list') === 0)) {
const detail = document.createElement('div');
detail.className = 'itflow-cal-detail';
detail.textContent = props.detail;
wrap.appendChild(detail);
}
return {domNodes: [wrap]};
},
eventDidMount: function (info) {
const props = info.event.extendedProps || {};
info.el.setAttribute('title', info.event.title + (props.detail ? '\n' + props.detail : ''));
info.el.dataset.kind = props.kind || '';
},
eventClick: function (info) {
info.jsEvent.preventDefault();
const props = info.event.extendedProps || {};
if (props.modal) {
openModal(props.modal, 'lg');
} else if (props.url) {
window.location.href = props.url;
}
},
// A day, or a run of days or times, picked on empty calendar
select: function (info) {
calendar.unselect();
newEvent({
start: info.allDay ? ymd(info.start) : ymdhm(info.start),
end: info.allDay ? ymd(info.end) : ymdhm(info.end),
all_day: info.allDay ? 1 : 0
});
},
eventDrop: function (info) {
moved(info);
},
eventResize: function (info) {
moved(info);
},
// A ticket from To schedule
drop: function (info) {
booked(info);
}
});
calendar.render();
// ---------- moving things ----------
function moved(info) {
const event = info.event;
const props = event.extendedProps || {};
const start = event.start;
const end = event.end;
if (props.kind === 'ticket') {
confirmDialog({
title: 'Move ' + event.title.split(' ')[0] + ' to ' + words(start, false) + '?',
text: 'The agent and the contact are emailed the new time, as when it is rescheduled on the ticket.',
confirmButtonText: 'Move it'
}).then(function (result) {
if (!result.isConfirmed) {
info.revert();
return;
}
post({kind: 'ticket', id: props.ticket_id, start: ymdhm(start), all_day: event.allDay ? 1 : 0}, function (json) {
saved(json);
calendar.refetchEvents();
}, function (error) {
itflowToast(error, 'error');
info.revert();
});
});
return;
}
if (props.kind !== 'event') {
info.revert();
return;
}
const send = function (notify) {
post({
kind: 'event',
id: props.event_id,
start: event.allDay ? ymd(start) + ' 00:00:00' : ymdhm(start),
end: end ? (event.allDay ? ymd(end) + ' 00:00:00' : ymdhm(end)) : '',
all_day: event.allDay ? 1 : 0,
notify: notify ? 1 : 0
}, function (json) {
saved(json);
calendar.refetchEvents();
}, function (error) {
itflowToast(error, 'error');
info.revert();
});
};
// The people who have it in their calendar can be sent the change
if (props.invited > 0) {
confirmDialog({
title: 'Tell the ' + props.invited + ' invited?',
text: event.title + ' is moving to ' + words(start, event.allDay) + '. Send the update to the ' + (props.invited === 1 ? 'person who has' : 'people who have') + ' it in their calendar?',
showDenyButton: true,
confirmButtonText: 'Move and send',
denyButtonText: 'Just move it'
}).then(function (result) {
if (result.isConfirmed) {
send(true);
} else if (result.isDenied) {
send(false);
} else {
info.revert();
}
});
} else {
send(false);
}
}
function booked(info) {
const item = info.draggedEl;
const start = new Date(info.date.getTime());
if (info.allDay) {
start.setHours(9, 0, 0, 0);
}
confirmDialog({
title: 'Book ' + item.dataset.reference + ' for ' + words(start, false) + '?',
text: 'The agent and the contact are emailed the time, as when it is scheduled on the ticket.',
input: 'radio',
inputOptions: {1: 'Onsite', 0: 'Remote'},
inputValue: item.dataset.onsite === '1' ? '1' : '0',
confirmButtonText: 'Book it'
}).then(function (result) {
if (!result.isConfirmed) {
return;
}
post({kind: 'ticket', id: item.dataset.ticket, start: ymdhm(start), onsite: result.value === '0' ? 0 : 1}, function (json) {
saved(json);
item.remove();
countUnscheduled();
calendar.refetchEvents();
}, function (error) {
itflowToast(error, 'error');
});
});
}
// ---------- the sidebar ----------
root.querySelectorAll('.itflow-cal-check').forEach(function (box) {
box.addEventListener('change', function () {
if (box.dataset.calendar) {
state.hidden = state.hidden.filter(function (id) {
return id !== box.dataset.calendar;
});
if (!box.checked) {
state.hidden.push(box.dataset.calendar);
}
} else {
state.sources[box.dataset.source] = box.checked;
}
save();
calendar.refetchEvents();
});
});
if (agentSelect) {
agentSelect.addEventListener('change', function () {
state.agent = agentId();
save();
calendar.refetchEvents();
loadUnscheduled();
});
}
const newButton = document.getElementById('calendarNewEvent');
if (newButton) {
newButton.addEventListener('click', function () {
newEvent({});
});
}
// ---------- To schedule ----------
const priorityTones = {Urgent: 'danger', High: 'warning', Medium: 'primary', Low: 'secondary'};
function countUnscheduled() {
const count = document.getElementById('calendarUnscheduledCount');
const hint = document.getElementById('calendarUnscheduledHint');
const n = unscheduledList ? unscheduledList.children.length : 0;
if (count) {
count.textContent = n ? String(n) : '';
}
if (hint) {
hint.textContent = n
? 'Open tickets with no time booked - drag one onto the calendar to book it.'
: 'Nothing waiting - every open ticket ' + (agentId() && agentId() !== myId ? 'they have' : 'you have') + ' is booked or needs no visit.';
}
}
function loadUnscheduled() {
if (!unscheduledList) {
return;
}
itflowGet('ajax.php', {calendar_unscheduled: 1, user_id: agentId() || myId, client_id: clientId}, function (text) {
let tickets = [];
try {
tickets = JSON.parse(text).tickets || [];
} catch (e) {
tickets = [];
}
unscheduledList.innerHTML = '';
tickets.forEach(function (ticket) {
const item = document.createElement('div');
item.className = 'itflow-cal-unscheduled';
item.dataset.ticket = ticket.id;
item.dataset.reference = ticket.reference;
item.dataset.title = ticket.reference + ' ' + ticket.subject;
item.dataset.onsite = ticket.onsite;
item.title = 'Drag onto the calendar to book it - click for a quick look';
const dot = document.createElement('span');
dot.className = 'itflow-cal-priority bg-' + (priorityTones[ticket.priority] || 'secondary');
dot.title = ticket.priority;
item.appendChild(dot);
const text = document.createElement('div');
text.className = 'min-w-0';
const line = document.createElement('div');
line.className = 'text-truncate small fw-semibold';
line.textContent = ticket.reference + ' ' + ticket.subject;
const sub = document.createElement('div');
sub.className = 'text-truncate small text-body-secondary';
sub.textContent = (clientId ? '' : ticket.client + ' · ') + ticket.priority + (ticket.onsite ? ' · Onsite' : '');
text.appendChild(line);
text.appendChild(sub);
item.appendChild(text);
item.addEventListener('click', function () {
openModal('modals/ticket/ticket.php?id=' + ticket.id, 'lg');
});
unscheduledList.appendChild(item);
});
countUnscheduled();
});
}
if (unscheduledList && canSchedule && FullCalendar.Interaction && FullCalendar.Interaction.Draggable) {
new FullCalendar.Interaction.Draggable(unscheduledList, {
itemSelector: '.itflow-cal-unscheduled',
eventData: function (item) {
return {title: item.dataset.title, duration: '01:00', create: false, color: '#0d6efd'};
}
});
loadUnscheduled();
}
})();