2439 lines
93 KiB
JavaScript
2439 lines
93 KiB
JavaScript
Object.defineProperties(Array.prototype, {
|
|
count: {
|
|
value: function (query) {
|
|
/*
|
|
Counts number of occurrences of query in array, an integer >= 0
|
|
Uses the javascript == notion of equality.
|
|
*/
|
|
var count = 0;
|
|
for (let i = 0; i < this.length; i++)
|
|
if (this[i] == query)
|
|
count++;
|
|
return count;
|
|
}
|
|
}
|
|
});
|
|
|
|
const mean = arr => arr.reduce((p, c) => p + c, 0) / arr.length;
|
|
|
|
tables = $(".table-div");
|
|
|
|
$("#extra-div").append("<div id='shift-diffs'></div>");
|
|
|
|
|
|
function generateExtra() {
|
|
$(".auto-generated").remove();
|
|
global_shifts = new Set();
|
|
|
|
total_summed_shift_diffs = 0;
|
|
total_summed_shift_diffs_abs = 0;
|
|
|
|
tables.each((n, table) => {
|
|
|
|
rows = $(table).find(".worker-row");
|
|
|
|
workers = {};
|
|
|
|
rows.each((n, tr) => {
|
|
|
|
|
|
worker_td = $(tr).find("td:first");
|
|
|
|
worker = worker_td.attr("data-worker");
|
|
|
|
shift_tds = $(tr).find(".rota-day");
|
|
shifts = []
|
|
|
|
shift_tds.each((n, td) => {
|
|
let data_shift = $(td).attr("data-shift");
|
|
if (data_shift && data_shift !== "") {
|
|
// Split by comma for multiple shifts per day
|
|
let split_shifts = data_shift.split(",");
|
|
split_shifts.forEach(s => {
|
|
if (s && s.trim() !== "") {
|
|
shifts.push(s.trim());
|
|
}
|
|
});
|
|
}
|
|
})
|
|
|
|
shifts_unique = new Set(shifts);
|
|
shifts_unique.delete("");
|
|
global_shifts = new Set([...global_shifts, ...shifts_unique]);
|
|
|
|
weekends_worked = 0;
|
|
|
|
weeks = shift_tds.length / 7;
|
|
|
|
whole_weekends_worked = 0;
|
|
|
|
for (var i = 1; i <= weeks; i++) {
|
|
if ($(tr).find(`[data-week='${i}'][data-day='Sat']`).attr("data-shift") != "" || $(tr).find(`[data-week='${i}'][data-day='Sun']`).attr("data-shift") != "") {
|
|
weekends_worked = weekends_worked + 1;
|
|
}
|
|
if ($(tr).find(`[data-week='${i}'][data-day='Sat']`).attr("data-shift") != "" && $(tr).find(`[data-week='${i}'][data-day='Sun']`).attr("data-shift") != "") {
|
|
whole_weekends_worked = whole_weekends_worked + 1;
|
|
}
|
|
}
|
|
|
|
shift_counts = {};
|
|
|
|
//total_shifts = 0;
|
|
days_lost = 0;
|
|
shifts_unique.forEach(el => {
|
|
shift_counts[el] = shifts.count(el);
|
|
//total_shifts = total_shifts + shifts.count(el);
|
|
|
|
// why like this?
|
|
if (el == "night_weekend") {
|
|
whole_weekends_worked = whole_weekends_worked - shifts.count(el) / 3
|
|
}
|
|
|
|
if ($("#global-settings").length == 1) {
|
|
|
|
n = $(`#${el}-tdl`).val();
|
|
if (!isNaN(n)) {
|
|
days_lost = days_lost + shifts.count(el) * n;
|
|
}
|
|
|
|
|
|
}
|
|
|
|
// // 5 days for weekday nights
|
|
// if(el == "night_weekday") { days_lost = days_lost + shifts.count(el) / 4 * 5 }
|
|
// // 4 days for weekend nights
|
|
// if(el == "night_weekend") {
|
|
// days_lost = days_lost + shifts.count(el) / 3 * 4
|
|
// //whole_weekends_worked = whole_weekends_worked - shifts.count(el) / 3
|
|
// }
|
|
// // 0.5 for twilights
|
|
// if(el.includes("twilight")) {
|
|
// twilight_days_lost = shifts.count(el) / 2
|
|
// days_lost = days_lost + twilight_days_lost
|
|
|
|
// }
|
|
|
|
});
|
|
|
|
// Also 1 day for a whole weekend
|
|
days_lost = days_lost + whole_weekends_worked
|
|
|
|
// Highlight non working days
|
|
nwds = JSON.parse(worker_td.attr("data-nwds"));
|
|
nwds.forEach(element => {
|
|
// Restrict to date ranges
|
|
start_date = new Date(Date.parse(element[1]))
|
|
end_date = new Date(Date.parse(element[2]))
|
|
$(shift_tds).filter(`.${element[0]}`).each(
|
|
(n, td_el) => {
|
|
td_date = new Date(Date.parse(td_el.dataset.date));
|
|
|
|
if (td_date >= start_date && td_date < end_date) {
|
|
$(td_el).addClass("nwd");
|
|
}
|
|
|
|
}
|
|
|
|
)
|
|
});
|
|
|
|
workers[worker] = {
|
|
"site": worker_td.attr("data-site"),
|
|
"nwds": worker_td.attr("data-nwds"),
|
|
"fte": worker_td.attr("data-fte_adj"),
|
|
"end_date": worker_td.attr("data-end_date"),
|
|
"shift_diff": worker_td.attr("data-shift-diff"),
|
|
"shifts": shifts,
|
|
"shift_types": shifts_unique,
|
|
"weekends_worked": weekends_worked,
|
|
"shift_counts": shift_counts,
|
|
"days_lost": days_lost,
|
|
//"shifts"
|
|
}
|
|
|
|
sc = JSON.stringify(shift_counts);
|
|
|
|
|
|
bank_holidays = shift_tds.filter(".bank-holiday").filter(function (i) {
|
|
return $(this).attr('data-shift') != "";
|
|
});
|
|
|
|
bank_holidays_worked = bank_holidays.length;
|
|
|
|
nights_worked = $(tr).find(".night-shift").length;
|
|
|
|
shift_diff = JSON.parse(worker_td.attr("data-shift-diff"));
|
|
summed_shift_diff = Object.values(shift_diff).reduce((a, b) => a + b);
|
|
|
|
total_summed_shift_diffs = total_summed_shift_diffs + summed_shift_diff;
|
|
total_summed_shift_diffs_abs = total_summed_shift_diffs_abs + Math.abs(summed_shift_diff);
|
|
|
|
|
|
|
|
worker_td.get(0).dataset.shiftDiffSummed = summed_shift_diff.toPrecision(2);
|
|
|
|
|
|
// compute separate Sat / Sun counts for this worker
|
|
let sat_count = 0;
|
|
let sun_count = 0;
|
|
for (let i = 1; i <= weeks; i++) {
|
|
let sat = $(tr).find(`[data-week='${i}'][data-day='Sat']`).attr("data-shift");
|
|
let sun = $(tr).find(`[data-week='${i}'][data-day='Sun']`).attr("data-shift");
|
|
if (sat && sat !== "") sat_count += 1;
|
|
if (sun && sun !== "") sun_count += 1;
|
|
}
|
|
|
|
// worker-summary will be inserted after we compute prior-adjust badges (sat/sun)
|
|
|
|
if (summed_shift_diff >= 1) {
|
|
worker_td.addClass("large-shift-diff-pos")
|
|
$(tr).find(".shift-diff-span").addClass("large-shift-diff-pos")
|
|
} else if (summed_shift_diff <= -1) {
|
|
worker_td.addClass("large-shift-diff-neg")
|
|
$(tr).find(".shift-diff-span").addClass("large-shift-diff-neg")
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
|
sites = {};
|
|
sites_days_lost = {}
|
|
for (const worker in workers) {
|
|
w = workers[worker];
|
|
if (w.fte == 100) {
|
|
for (let shift of w.shift_types) {
|
|
if (!(w.site in sites)) {
|
|
sites[w.site] = {};
|
|
|
|
}
|
|
if (!(shift in sites[w.site])) {
|
|
sites[w.site][shift] = [w.shift_counts[shift]];
|
|
} else {
|
|
sites[w.site][shift].push(w.shift_counts[shift]);
|
|
}
|
|
}
|
|
if (!(w.site in sites_days_lost)) {
|
|
sites_days_lost[w.site] = [w.days_lost];
|
|
|
|
} else {
|
|
sites_days_lost[w.site].push(w.days_lost);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate site shift summaries
|
|
// $(table).append("<div class='site-summary auto-generated'>Average shifts for a 100% FTE trainee</div>");
|
|
|
|
// for(const site in sites) {
|
|
// $(table).find(".site-summary").append(`<div class='site site-${site}'><span class='site-name'>${site}</span></div>`);
|
|
|
|
// for(const shift in sites[site]) {
|
|
// average = mean(sites[site][shift])
|
|
// $(table).find(`.site-${site}`).append(`<div>${shift}: ${average.toFixed(2)}</div>`);
|
|
// }
|
|
|
|
// days_lost_avg = mean(sites_days_lost[site]);
|
|
// $(table).find(`.site-${site}`).append(`<div class='training-days-lost'>Training days lost: ${days_lost_avg.toFixed(2)}</div>`);
|
|
// }
|
|
|
|
summary_button = $("<button class='auto-generated'>Toggle Summary</button>").click(() => {
|
|
let $rotaDays = $(table).find(".rota-day");
|
|
let $workerSummaries = $(table).find(".worker-summary");
|
|
// determine current visibility of rota days
|
|
let rotaVisible = $rotaDays.length ? !$rotaDays.first().hasClass('hidden') : true;
|
|
if (rotaVisible) {
|
|
$rotaDays.addClass('hidden');
|
|
$workerSummaries.show();
|
|
} else {
|
|
$rotaDays.removeClass('hidden');
|
|
$workerSummaries.hide();
|
|
}
|
|
|
|
});
|
|
|
|
|
|
$("#shift-diffs").before($(`<span>Total summed shift diff: ${total_summed_shift_diffs}</span><br/>`));
|
|
$("#shift-diffs").before($(`<span>Total summed shift diff (abs): ${total_summed_shift_diffs_abs}</span><br/>`));
|
|
|
|
|
|
$(table).before(summary_button);
|
|
|
|
// Add per-table avoid-toggle button (near the summary button)
|
|
avoid_button = $("<button class='auto-generated'>Toggle Avoids</button>").click((evt) => {
|
|
// ensure style exists once
|
|
if ($('#avoid-shift-style').length === 0) {
|
|
$('head').append(`
|
|
<style id='avoid-shift-style'>
|
|
td.rota-day.avoid-shift.avoid-highlight { border: 2px solid rgba(255,140,0,0.9) !important; box-shadow: 0 0 6px rgba(255,140,0,0.25); }
|
|
</style>
|
|
`);
|
|
}
|
|
|
|
let $btn = $(evt.target);
|
|
let enabled = $btn.data('enabled') || false;
|
|
enabled = !enabled;
|
|
$btn.data('enabled', enabled);
|
|
|
|
let $table = $(table);
|
|
$table.find("td.rota-day.avoid-shift").each((i, el) => {
|
|
let $el = $(el);
|
|
if (enabled) {
|
|
$el.addClass('avoid-highlight');
|
|
// preserve original title
|
|
if (!$el.data('orig-title')) $el.data('orig-title', $el.attr('title') || '');
|
|
let avoid_shifts = $el.attr('data-avoid-shifts') || '';
|
|
let avoid_reason = $el.attr('data-avoid-reason') || '';
|
|
let suffix = [];
|
|
// make the constraint type explicit in the tooltip
|
|
suffix.push(`constraint: avoid_shifts_by_worker_dates`);
|
|
if (avoid_shifts) suffix.push(`shifts: ${avoid_shifts}`);
|
|
if (avoid_reason) suffix.push(`reason: ${avoid_reason}`);
|
|
if (suffix.length) {
|
|
let newTitle = $el.data('orig-title') + ' [' + suffix.join('; ') + ']';
|
|
$el.attr('title', newTitle);
|
|
}
|
|
} else {
|
|
$el.removeClass('avoid-highlight');
|
|
// restore original title
|
|
if ($el.data('orig-title') !== undefined) $el.attr('title', $el.data('orig-title'));
|
|
}
|
|
});
|
|
|
|
$btn.text(enabled ? 'Hide Avoids' : 'Toggle Avoids');
|
|
});
|
|
|
|
$(table).before(avoid_button);
|
|
|
|
|
|
|
|
});
|
|
return global_shifts
|
|
}
|
|
|
|
shifts = generateExtra();
|
|
|
|
if (shifts.size < 1) {
|
|
shifts = $("#shifts-container").data("shifts");
|
|
}
|
|
|
|
oshifts = Array.from(shifts).sort()
|
|
|
|
$("body").append("<div><button id='show-colour-diff'>Show colour diff</button><button id='toggle-prior-adjust' style='margin-left:10px;'>Toggle Prior Adjust</button><table class='tsummary'></table></div>")
|
|
$("#show-colour-diff").on("click", (evt) => {
|
|
$("table.tsummary").find(`td.target-assigned`).toggleClass("no-colour");
|
|
});
|
|
$("#toggle-prior-adjust").on("click", (evt) => {
|
|
$("table.tsummary").find(`td.prior-adjust, th.prior-adjust-col`).toggle();
|
|
});
|
|
|
|
// Note: per-table avoid-toggle buttons are added next to each table's summary button inside generateExtra().
|
|
|
|
$("table.tsummary").append("<tr class='header'></tr>");
|
|
h = $("table.tsummary tr.header");
|
|
h.append(`<th>Worker</th>`);
|
|
h.append(`<th>Site</th>`);
|
|
h.append(`<th>FTE</th>`);
|
|
h.append(`<th>FTE (adj)</th>`);
|
|
|
|
|
|
oshifts.forEach((s) => {
|
|
h.append(`<th>${s}</th>`);
|
|
|
|
});
|
|
|
|
// Render formatted Shifts settings and Worker details with filtering
|
|
function renderShiftSettings() {
|
|
const $container = $("#shifts-container");
|
|
if (!$container.length) return;
|
|
let shiftsJson = $container.data("shifts-json");
|
|
try {
|
|
if (typeof shiftsJson === 'string') shiftsJson = JSON.parse(shiftsJson);
|
|
} catch (e) {
|
|
// fallback: no structured data
|
|
shiftsJson = null;
|
|
}
|
|
|
|
const $table = $("<table class='shifts-table' style='width:100%; border-collapse:collapse; margin-bottom:8px;'></table>");
|
|
$table.append(`<tr><th style='text-align:left'>Name</th><th style='text-align:left'>Sites</th><th style='text-align:left'>Days</th><th style='text-align:left'>Balance offset</th></tr>`);
|
|
if (shiftsJson && Array.isArray(shiftsJson)) {
|
|
shiftsJson.forEach(s => {
|
|
const sites = (s.sites || []).join(", ");
|
|
const days = (s.days || []).join(", ");
|
|
$table.append(`<tr class='shift-row' data-shift-name='${s.name}'><td>${s.name}</td><td>${sites}</td><td>${days}</td><td>${s.balance_offset ?? ''}</td></tr>`);
|
|
});
|
|
} else {
|
|
// fallback to existing content
|
|
const html = $("#shifts-table").html();
|
|
$("#shifts-table").empty().append(html);
|
|
}
|
|
$("#shifts-table").empty().append($table);
|
|
|
|
// filter input
|
|
$("#shift-filter").on("input", (e) => {
|
|
const v = $(e.target).val().toLowerCase();
|
|
$(".shift-row").each((i, el) => {
|
|
const name = $(el).data('shift-name') + '';
|
|
$(el).toggle(name.toLowerCase().indexOf(v) !== -1);
|
|
});
|
|
// update shift select in worker panel if present
|
|
renderWorkerFilterOptions();
|
|
});
|
|
}
|
|
|
|
function renderWorkerFilterOptions() {
|
|
const $wd = $("#worker_details");
|
|
if (!$wd.length) return;
|
|
let workers = $wd.data("workers");
|
|
try { if (typeof workers === 'string') workers = JSON.parse(workers); } catch (e) { workers = null; }
|
|
// populate shift select with shifts present in current shifts table
|
|
const shiftNames = [];
|
|
$(".shift-row:visible").each((i, el) => shiftNames.push($(el).data('shift-name')));
|
|
const $select = $("#worker-shift-select");
|
|
if ($select.length === 0) {
|
|
$("#worker_details").prepend('<div style="margin-bottom:8px;"><input type="text" id="worker-filter" placeholder="Filter workers by name/site" style="margin-right:8px;"/> <select id="worker-shift-select"><option value="">(All shifts)</option></select></div>');
|
|
}
|
|
const $sel = $("#worker-shift-select");
|
|
$sel.empty().append('<option value="">(All shifts)</option>');
|
|
shiftNames.forEach(s => $sel.append(`<option value="${s}">${s}</option>`));
|
|
|
|
// attach events
|
|
$("#worker-filter").off('input').on('input', () => renderWorkerList());
|
|
$sel.off('change').on('change', () => renderWorkerList());
|
|
}
|
|
|
|
function renderWorkerList() {
|
|
const $wd = $("#worker_details");
|
|
if (!$wd.length) return;
|
|
let workers = $wd.data("workers");
|
|
try { if (typeof workers === 'string') workers = JSON.parse(workers); } catch (e) { workers = null; }
|
|
const filter = ($("#worker-filter").val() || '').toLowerCase();
|
|
const shiftFilter = ($("#worker-shift-select").val() || '');
|
|
|
|
// Parse the original pre block into per-worker text blocks so rich details are preserved.
|
|
const detailsByName = {};
|
|
const preText = (($wd.find("pre").first().text() || '') + '').trim();
|
|
const escapeHtml = (text) => String(text)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
if (preText.length > 0) {
|
|
preText.split(/\n\s*\n(?=Name:\s*)/g).forEach(block => {
|
|
const trimmed = (block || '').trim();
|
|
if (!trimmed) return;
|
|
const nameMatch = trimmed.match(/^Name:\s*(.+)$/m);
|
|
if (!nameMatch) return;
|
|
detailsByName[nameMatch[1].trim()] = trimmed;
|
|
});
|
|
}
|
|
|
|
let $list = $("#worker-list");
|
|
if ($list.length === 0) {
|
|
$list = $("<div id='worker-list' style='margin-top:8px;'></div>");
|
|
$("#worker_details").append($list);
|
|
}
|
|
// Hide the static pre block since we render interactive cards
|
|
$("#worker_details pre").hide();
|
|
$list.show();
|
|
$list.empty();
|
|
if (!workers) return;
|
|
workers.forEach(w => {
|
|
const name = (w.name || '') + '';
|
|
const site = (w.site || '') + '';
|
|
if (filter.length > 0) {
|
|
const matchesName = name.toLowerCase().indexOf(filter) !== -1;
|
|
const matchesSite = site.toLowerCase().indexOf(filter) !== -1;
|
|
if (!matchesName && !matchesSite) return;
|
|
}
|
|
// if shiftFilter, ensure worker has target or assigned for that shift
|
|
// attempt to read per-worker targets and counts from DOM if not present in JSON
|
|
let targets = w.shift_target_number || {};
|
|
let counts = w.shift_counts || {};
|
|
const domRow = $(`td.worker[data-worker='${name}']`).first();
|
|
if (domRow && domRow.length) {
|
|
try {
|
|
const domTargets = domRow.data('worker-targets');
|
|
if (domTargets && Object.keys(targets).length === 0) targets = domTargets;
|
|
} catch (e) {}
|
|
try {
|
|
const domCounts = domRow.data('shift-counts');
|
|
if (domCounts && Object.keys(counts).length === 0) counts = domCounts;
|
|
} catch (e) {}
|
|
}
|
|
if (shiftFilter) {
|
|
const assigned = (counts && counts[shiftFilter]) ? counts[shiftFilter] : 0;
|
|
const hasTarget = Object.keys(targets || {}).indexOf(shiftFilter) !== -1 && (targets[shiftFilter] || 0) > 0;
|
|
const hasAssigned = assigned > 0;
|
|
if (!hasTarget && !hasAssigned) return;
|
|
}
|
|
const fte = w.fte || '';
|
|
const grade = w.grade || '';
|
|
const id = w.id ? String(w.id).substring(0, 8) : '';
|
|
const card = $(`<div class='worker-card'><div class='worker-header'><strong>${name}</strong> <span class='worker-id'>${id}</span></div><div class='worker-meta'><span class='worker-site'>${site}</span> • <span class='worker-grade'>Grade: ${grade}</span> • <span class='worker-fte'>FTE: ${fte}%</span></div></div>`);
|
|
// show shift targets summary
|
|
if (targets && Object.keys(targets).length > 0) {
|
|
const tbl = $("<table class='worker-shifts-table'></table>");
|
|
const header = $("<thead><tr><th>Shift</th><th class='text-right'>Assigned</th><th class='text-right'>Target</th></tr></thead>");
|
|
tbl.append(header);
|
|
const tbody = $("<tbody></tbody>");
|
|
Object.keys(targets).forEach(s => {
|
|
const t = targets[s];
|
|
const c = (counts && counts[s]) ? counts[s] : 0;
|
|
tbody.append(`<tr><td>${s}</td><td class='text-right'>${c}</td><td class='text-right'>${parseFloat(t).toFixed(2)}</td></tr>`);
|
|
});
|
|
tbl.append(tbody);
|
|
card.append(tbl);
|
|
}
|
|
|
|
// Include the original full worker detail text block so no information is lost.
|
|
const fullDetails = detailsByName[name] || '';
|
|
if (fullDetails) {
|
|
card.append(`<details class='worker-full-details'><summary>Full details</summary><pre>${escapeHtml(fullDetails)}</pre></details>`);
|
|
}
|
|
$list.append(card);
|
|
});
|
|
}
|
|
|
|
// Add CSS for worker details styling and comprehensive dark mode support
|
|
if ($('#worker-details-style').length === 0) {
|
|
$('head').append(`<style id='worker-details-style'>
|
|
/* ===== Light mode defaults ===== */
|
|
body {
|
|
background-color: #fff;
|
|
color: #000;
|
|
}
|
|
#worker_details {
|
|
background: #fff;
|
|
color: #000;
|
|
}
|
|
#worker-filter, #worker-shift-select {
|
|
background: #fff;
|
|
color: #000;
|
|
border: 1px solid #ccc;
|
|
}
|
|
#worker-filter:focus, #worker-shift-select:focus {
|
|
outline: none;
|
|
border-color: #0066cc;
|
|
box-shadow: 0 0 4px rgba(0,102,204,0.3);
|
|
}
|
|
.worker-card {
|
|
border: 1px solid #ccc;
|
|
border-radius: 4px;
|
|
padding: 8px;
|
|
margin-bottom: 8px;
|
|
background: #fff;
|
|
color: #000;
|
|
transition: background-color 0.2s, border-color 0.2s;
|
|
}
|
|
.worker-card:hover {
|
|
background: #f5f5f5;
|
|
border-color: #999;
|
|
}
|
|
.worker-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 6px;
|
|
}
|
|
.worker-header strong {
|
|
flex: 1;
|
|
margin: 0;
|
|
color: #000;
|
|
}
|
|
.worker-id {
|
|
font-size: 0.8em;
|
|
color: #999;
|
|
font-family: monospace;
|
|
}
|
|
.worker-meta {
|
|
font-size: 0.9em;
|
|
color: #666;
|
|
margin-bottom: 6px;
|
|
}
|
|
.worker-site, .worker-grade, .worker-fte {
|
|
display: inline;
|
|
}
|
|
.worker-shifts-table {
|
|
margin-top: 6px;
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 0.9em;
|
|
background: #fff;
|
|
color: #000;
|
|
}
|
|
.worker-shifts-table thead {
|
|
background: #f0f0f0;
|
|
font-weight: bold;
|
|
color: #000;
|
|
}
|
|
.worker-shifts-table th, .worker-shifts-table td {
|
|
padding: 4px 6px;
|
|
border: 1px solid #ddd;
|
|
color: #000;
|
|
}
|
|
.worker-shifts-table tr:nth-child(even) {
|
|
background: #fafafa;
|
|
}
|
|
.worker-full-details {
|
|
margin-top: 8px;
|
|
}
|
|
.worker-full-details summary {
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
}
|
|
.worker-full-details pre {
|
|
margin-top: 6px;
|
|
white-space: pre-wrap;
|
|
}
|
|
.text-right {
|
|
text-align: right;
|
|
}
|
|
pre {
|
|
background: #f5f5f5;
|
|
color: #000;
|
|
border: 1px solid #ddd;
|
|
}
|
|
input, select, textarea {
|
|
background: #fff;
|
|
color: #000;
|
|
border: 1px solid #ccc;
|
|
}
|
|
table {
|
|
background: #fff;
|
|
color: #000;
|
|
}
|
|
table th {
|
|
background: #f9f9f9;
|
|
color: #000;
|
|
}
|
|
table td {
|
|
border-color: #ddd;
|
|
}
|
|
|
|
/* ===== Dark mode overrides (toggled via html.dark-mode class) ===== */
|
|
html.dark-mode body {
|
|
background-color: #1e1e1e;
|
|
color: #e0e0e0;
|
|
}
|
|
html.dark-mode #worker_details {
|
|
background: #2d2d2d;
|
|
color: #e0e0e0;
|
|
}
|
|
html.dark-mode #worker-filter, html.dark-mode #worker-shift-select {
|
|
background: #3d3d3d;
|
|
color: #e0e0e0;
|
|
border: 1px solid #555;
|
|
}
|
|
html.dark-mode #worker-filter:focus, html.dark-mode #worker-shift-select:focus {
|
|
border-color: #0088ff;
|
|
box-shadow: 0 0 4px rgba(0,136,255,0.5);
|
|
}
|
|
html.dark-mode .worker-card {
|
|
background: #2d2d2d;
|
|
border-color: #444;
|
|
color: #e0e0e0;
|
|
}
|
|
html.dark-mode .worker-card:hover {
|
|
background: #333;
|
|
border-color: #666;
|
|
}
|
|
html.dark-mode .worker-header strong {
|
|
color: #e0e0e0;
|
|
}
|
|
html.dark-mode .worker-meta, html.dark-mode .worker-id {
|
|
color: #aaa;
|
|
}
|
|
html.dark-mode .worker-shifts-table {
|
|
background: #2d2d2d;
|
|
color: #e0e0e0;
|
|
}
|
|
html.dark-mode .worker-shifts-table thead {
|
|
background: #1e1e1e;
|
|
color: #e0e0e0;
|
|
}
|
|
html.dark-mode .worker-shifts-table th, html.dark-mode .worker-shifts-table td {
|
|
border-color: #444;
|
|
color: #e0e0e0;
|
|
}
|
|
html.dark-mode .worker-shifts-table tr:nth-child(even) {
|
|
background: #252525;
|
|
}
|
|
html.dark-mode .worker-full-details summary {
|
|
color: #cfd8e3;
|
|
}
|
|
html.dark-mode pre {
|
|
background: #1e1e1e;
|
|
color: #e0e0e0;
|
|
border: 1px solid #444;
|
|
}
|
|
html.dark-mode input, html.dark-mode select, html.dark-mode textarea {
|
|
background: #3d3d3d;
|
|
color: #e0e0e0;
|
|
border: 1px solid #555;
|
|
}
|
|
html.dark-mode table {
|
|
background: #2d2d2d;
|
|
color: #e0e0e0;
|
|
}
|
|
html.dark-mode table th {
|
|
background: #1e1e1e;
|
|
color: #e0e0e0;
|
|
}
|
|
html.dark-mode table td {
|
|
border-color: #444;
|
|
color: #e0e0e0;
|
|
}
|
|
html.dark-mode h1, html.dark-mode h2, html.dark-mode h3, html.dark-mode h4, html.dark-mode h5, html.dark-mode h6 {
|
|
color: #e0e0e0;
|
|
}
|
|
html.dark-mode a {
|
|
color: #4db8ff;
|
|
}
|
|
html.dark-mode a:visited {
|
|
color: #8866ff;
|
|
}
|
|
html.dark-mode button {
|
|
background: #3d3d3d;
|
|
color: #e0e0e0;
|
|
border: 1px solid #555;
|
|
}
|
|
html.dark-mode button:hover {
|
|
background: #4d4d4d;
|
|
}
|
|
</style>`);
|
|
}
|
|
|
|
// initialize panels
|
|
renderShiftSettings();
|
|
renderWorkerFilterOptions();
|
|
renderWorkerList();
|
|
|
|
// Column for prior-adjustment (hidden by default)
|
|
h.append(`<th class='prior-adjust-col' style='display:none'>Prior Adjust</th>`);
|
|
|
|
// Ensure header styles for visibility and stickiness are present
|
|
if ($('#tsummary-style').length === 0) {
|
|
$('head').append(`
|
|
<style id='tsummary-style'>
|
|
/* Compact timetable styles */
|
|
table.tsummary { border-collapse: collapse; width: 100%; overflow: auto; font-size: 12px; }
|
|
table.tsummary th, table.tsummary td { border: 1px solid #eee; padding: 2px 4px; }
|
|
table.tsummary th { background: #fafafa; position: sticky; top: 0; z-index: 5; white-space: nowrap; }
|
|
/* Vertical headers for compact columns */
|
|
table.tsummary th { writing-mode: vertical-rl; text-orientation: mixed; transform: rotate(180deg); padding: 4px 2px; height: 88px; font-size: 10px; }
|
|
/* Keep prior-adjust column horizontal */
|
|
table.tsummary th.prior-adjust-col { writing-mode: horizontal-tb; transform: none; height: auto; }
|
|
/* Make header text readable */
|
|
table.tsummary th > span { display: inline-block; transform: rotate(180deg); padding: 0 2px; }
|
|
/* Ensure cells align under rotated headers */
|
|
table.tsummary td { vertical-align: middle; white-space: nowrap; max-width: 6ch; overflow: hidden; text-overflow: ellipsis; }
|
|
|
|
/* Reduce worker summary and card sizes */
|
|
.worker-summary { font-size: 11px; padding: 0px 6px; margin-bottom: 4px; }
|
|
.worker-card { padding: 6px; margin-bottom: 4px; }
|
|
|
|
/* Compact shifts/settings table */
|
|
table.shifts-table th, table.shifts-table td { padding: 4px 6px; font-size: 12px; }
|
|
|
|
/* Slightly thinner avoid highlight for compact layout */
|
|
td.rota-day.avoid-shift.avoid-highlight { border-width: 1.5px !important; box-shadow: 0 0 4px rgba(255,140,0,0.18); }
|
|
|
|
/* Higher-contrast dark mode for timetable readability */
|
|
html.dark-mode table.tsummary { background: #111922; color: #e8eef5; }
|
|
html.dark-mode table.tsummary th,
|
|
html.dark-mode table.tsummary td { border-color: #516274; }
|
|
html.dark-mode table.tsummary th { background: #253241; color: #f5f8fc; }
|
|
html.dark-mode table.tsummary td { background: #16212d; color: #e8eef5; }
|
|
html.dark-mode table.tsummary tr:nth-child(even) td { background: #1a2734; }
|
|
html.dark-mode table.tsummary td.target-assigned { color: #f2f6fb; }
|
|
</style>
|
|
`);
|
|
}
|
|
|
|
|
|
|
|
$(".table-div .worker-row .worker").each((n, tr) => {
|
|
//worker_td = $(tr).find("td").first();
|
|
jtr = $(tr);
|
|
row = $("<tr></tr>");
|
|
row.append(`<td>${jtr.data("worker")}</td>`)
|
|
row.append(`<td>${jtr.data("site")}</td>`)
|
|
row.append(`<td>${jtr.data("fte")}</td>`)
|
|
row.append(`<td>${parseFloat(jtr.data("fte_adj")).toFixed(2)}</td>`)
|
|
|
|
if (jtr.data("shift-diff-summed") >= 1) {
|
|
row.addClass("large-shift-diff-pos")
|
|
} else if (jtr.data("shift-diff-summed") <= -1) {
|
|
row.addClass("large-shift-diff-neg")
|
|
|
|
}
|
|
|
|
shift_counts = jtr.data("shift-counts")
|
|
locum_shift_counts = jtr.data("locum-shift-counts")
|
|
shift_targets = jtr.data("worker-targets")
|
|
|
|
// Compute total shifts from shift_counts (sum of assigned counts)
|
|
let total_shifts = 0;
|
|
try {
|
|
if (shift_counts && typeof shift_counts === 'object') {
|
|
total_shifts = Object.values(shift_counts).reduce((a, b) => a + (parseFloat(b) || 0), 0);
|
|
}
|
|
} catch (e) {
|
|
total_shifts = 0;
|
|
}
|
|
|
|
|
|
oshifts.forEach((s) => {
|
|
if (s in shift_targets && shift_targets[s] > 0) {
|
|
if (s in shift_counts) {
|
|
c = shift_counts[s];
|
|
} else {
|
|
c = 0;
|
|
}
|
|
locum_shifts = ""
|
|
if (s in locum_shift_counts) {
|
|
locum_shifts = `+${locum_shift_counts[s]}`
|
|
}
|
|
row.append(`<td class="target-assigned ${s}" data-assigned=${c} data-target=${shift_targets[s].toFixed(2)} data-diff=${shift_targets[s].toFixed(2) - c}>${c}${locum_shifts} (${shift_targets[s].toFixed(2)})</td>`)
|
|
|
|
} else {
|
|
row.append(`<td>-</td>`)
|
|
}
|
|
})
|
|
|
|
// Build per-shift prior-adjust spans from data-previous-shifts
|
|
// Try both the raw attribute and jQuery-parsed data for robustness
|
|
let prev_attr = jtr.attr("data-previous-shifts");
|
|
if (!prev_attr) prev_attr = jtr.data("previous-shifts");
|
|
let partsHtml = "";
|
|
let sat_adj = null;
|
|
let sun_adj = null;
|
|
if (prev_attr) {
|
|
try {
|
|
let prev_map = (typeof prev_attr === 'object') ? prev_attr : JSON.parse(prev_attr);
|
|
let parts = [];
|
|
// Build parts for non-weekend shifts first; we'll always render weekend per-day below.
|
|
for (let k in prev_map) {
|
|
if (!prev_map.hasOwnProperty(k)) continue;
|
|
if (k === 'weekend') continue;
|
|
let entry = prev_map[k];
|
|
let adj = null;
|
|
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
|
if ('adjustment' in entry) adj = parseFloat(entry.adjustment);
|
|
else if ('allocated' in entry && 'worked' in entry) adj = parseFloat(entry.allocated) - parseFloat(entry.worked);
|
|
} else if (Array.isArray(entry) && entry.length >= 2) {
|
|
adj = parseFloat(entry[1]) - parseFloat(entry[0]);
|
|
}
|
|
if (adj && Math.abs(adj) > 0.0001) {
|
|
let s = (adj > 0 ? '+' : '') + Number(adj).toFixed(2);
|
|
// span includes data attributes for per-shift colouring later
|
|
partsHtml += `<span class='prior-adjust-part' data-shift='${k}' data-adj='${adj}' style='display:inline-block;padding:0 6px;margin-right:6px;border-radius:3px'>${k}:${s}</span>`;
|
|
}
|
|
}
|
|
|
|
// compute per-day weekend adjustments if present under 'weekend'
|
|
if ('weekend' in prev_map) {
|
|
let w = prev_map['weekend'];
|
|
// Prefer explicit per-day entries when present
|
|
if (w && typeof w === 'object' && !Array.isArray(w)) {
|
|
if ('Sat' in w) {
|
|
let e = w['Sat'];
|
|
if (e && typeof e === 'object' && 'allocated' in e && 'worked' in e) sat_adj = parseFloat(e.allocated) - parseFloat(e.worked);
|
|
else if (Array.isArray(e) && e.length >= 2) sat_adj = parseFloat(e[1]) - parseFloat(e[0]);
|
|
}
|
|
if ('Sun' in w) {
|
|
let e = w['Sun'];
|
|
if (e && typeof e === 'object' && 'allocated' in e && 'worked' in e) sun_adj = parseFloat(e.allocated) - parseFloat(e.worked);
|
|
else if (Array.isArray(e) && e.length >= 2) sun_adj = parseFloat(e[1]) - parseFloat(e[0]);
|
|
}
|
|
|
|
// If no per-day entries but there is an overall weekend value, split it evenly
|
|
if (sat_adj === null && sun_adj === null && 'allocated' in w && 'worked' in w) {
|
|
try {
|
|
let total = parseFloat(w.allocated) - parseFloat(w.worked);
|
|
if (isFinite(total)) {
|
|
sat_adj = sun_adj = total / 2.0;
|
|
}
|
|
} catch (err) {
|
|
// leave as null on parse error
|
|
}
|
|
}
|
|
|
|
// Handle a '__all__' bucket (tuple or object) as fallback
|
|
if (sat_adj === null && sun_adj === null && '__all__' in w) {
|
|
let e = w['__all__'];
|
|
if (Array.isArray(e) && e.length >= 2) {
|
|
let total = parseFloat(e[1]) - parseFloat(e[0]);
|
|
sat_adj = sun_adj = total / 2.0;
|
|
} else if (e && typeof e === 'object' && 'allocated' in e && 'worked' in e) {
|
|
let total = parseFloat(e.allocated) - parseFloat(e.worked);
|
|
sat_adj = sun_adj = total / 2.0;
|
|
}
|
|
}
|
|
} else if (Array.isArray(w) && w.length >= 2) {
|
|
// tuple-style: split evenly
|
|
let total = parseFloat(w[1]) - parseFloat(w[0]);
|
|
sat_adj = sun_adj = total / 2.0;
|
|
}
|
|
|
|
// Always append per-day prior-adjust parts for weekend so the section is per-day
|
|
if (sat_adj !== null && Math.abs(sat_adj) > 0.0001) {
|
|
let s = (sat_adj > 0 ? '+' : '') + Number(sat_adj).toFixed(2);
|
|
partsHtml += `<span class='prior-adjust-part' data-shift='weekend' data-day='Sat' data-adj='${sat_adj}' style='display:inline-block;padding:0 6px;margin-right:6px;border-radius:3px'>Sat:${s}</span>`;
|
|
}
|
|
if (sun_adj !== null && Math.abs(sun_adj) > 0.0001) {
|
|
let s = (sun_adj > 0 ? '+' : '') + Number(sun_adj).toFixed(2);
|
|
partsHtml += `<span class='prior-adjust-part' data-shift='weekend' data-day='Sun' data-adj='${sun_adj}' style='display:inline-block;padding:0 6px;margin-right:6px;border-radius:3px'>Sun:${s}</span>`;
|
|
}
|
|
}
|
|
|
|
} catch (e) {
|
|
// ignore parse errors
|
|
}
|
|
}
|
|
|
|
// helper to render small coloured adjustment badge
|
|
function _adjBadge(adj) {
|
|
if (adj === null || typeof adj === 'undefined' || isNaN(adj)) return '';
|
|
let adjf = parseFloat(adj);
|
|
if (!isFinite(adjf)) return '';
|
|
let sign = (adjf > 0) ? '+' : '';
|
|
let text = `${sign}${adjf.toFixed(2)}`;
|
|
// scale alpha by magnitude but ensure a minimum so small adjustments remain visible
|
|
let mag = Math.min(1.0, Math.abs(adjf) / 3.0);
|
|
if (mag < 0.12) mag = 0.12;
|
|
let bg, color;
|
|
if (adjf > 0) { bg = `rgba(0,128,0,${mag.toFixed(2)})`; color = '#fff'; }
|
|
else if (adjf < 0) { bg = `rgba(196,0,0,${mag.toFixed(2)})`; color = '#fff'; }
|
|
else { bg = 'rgba(128,128,128,0.15)'; color = '#000'; }
|
|
return ` <span class='weekend-day-adjust' style='margin-left:6px; padding:1px 6px; border-radius:4px; background:${bg}; color:${color}; font-weight:600; font-size:0.85em;'>(${text})</span>`;
|
|
}
|
|
|
|
let sat_adj_html = _adjBadge(sat_adj);
|
|
let sun_adj_html = _adjBadge(sun_adj);
|
|
|
|
// compute Sat / Sun counts for this worker row (ensure variables are defined)
|
|
let sat_count = 0;
|
|
let sun_count = 0;
|
|
try {
|
|
let $row = jtr.closest('tr');
|
|
$row.find("td[data-day='Sat']").each((i, td) => {
|
|
let s = $(td).attr('data-shift');
|
|
if (s && s !== '') sat_count += 1;
|
|
});
|
|
$row.find("td[data-day='Sun']").each((i, td) => {
|
|
let s = $(td).attr('data-shift');
|
|
if (s && s !== '') sun_count += 1;
|
|
});
|
|
} catch (e) {
|
|
// fallback: leave counts at 0
|
|
}
|
|
|
|
console.log("Computed weekend counts and adjustments for worker:", jtr.data("worker"));
|
|
console.log(`sat_count: ${sat_count}, sun_count: ${sun_count}, sat_adj: ${sat_adj}, sun_adj: ${sun_adj}`);
|
|
|
|
// Insert worker summary now that we have per-day adjustment badges
|
|
// Hidden by default so summaries only appear when rota days are hidden
|
|
let workerSummaryHtml = `<div class='worker-summary auto-generated' style='display: none;'>
|
|
<span>Total shifts: ${total_shifts}, </span>
|
|
<span>Weekends: ${weekends_worked}, </span>
|
|
<span>Sat: ${sat_count}${sat_adj_html}, </span>
|
|
<span>Sun: ${sun_count}${sun_adj_html}, </span>
|
|
<span>Nights: ${nights_worked}, </span>
|
|
<span>Bank holidays: ${bank_holidays_worked}, </span>
|
|
<span class='shift-diff-span'>Summed shift diff: ${summed_shift_diff.toPrecision(2)}, </span>
|
|
</div>`;
|
|
|
|
jtr.after(workerSummaryHtml);
|
|
|
|
row.append(`<td class='prior-adjust' style='display:none'>${partsHtml}</td>`)
|
|
|
|
$("table.tsummary").append(row)
|
|
})
|
|
|
|
shift_max_diff_map = {};
|
|
shift_min_diff_map = {};
|
|
|
|
oshifts.forEach((s) => {
|
|
shift_max_diff_map[s] = 0;
|
|
shift_min_diff_map[s] = 0;
|
|
$("table.tsummary").find(`td.target-assigned.${s}`).each((n, td) => {
|
|
shift_max_diff_map[s] = Math.max(shift_max_diff_map[s], td.dataset.diff)
|
|
shift_min_diff_map[s] = Math.min(shift_min_diff_map[s], td.dataset.diff)
|
|
})
|
|
|
|
});
|
|
|
|
oshifts.forEach((shift) => {
|
|
$("table.tsummary").find(`td.target-assigned.${shift}`).each((n, td) => {
|
|
diff = td.dataset.diff;
|
|
|
|
if (diff > 0) {
|
|
max_diff = shift_max_diff_map[shift];
|
|
var h = 120 - Math.floor((max_diff - diff) * 120 / max_diff);
|
|
var s = diff / max_diff;
|
|
//var s = 0.5;
|
|
var v = 1;
|
|
$(td).css({
|
|
backgroundColor: hsv2rgb(h, s, 1)
|
|
})
|
|
} else {
|
|
min_diff = shift_min_diff_map[shift]
|
|
|
|
var h = Math.floor((min_diff - diff) * 120 / min_diff);
|
|
var s = diff / min_diff;
|
|
//var s = 0.5;
|
|
var v = 1;
|
|
$(td).css({
|
|
backgroundColor: hsv2rgb(h, s, 1)
|
|
})
|
|
|
|
}
|
|
$(td).addClass("no-colour")
|
|
//s = Math.max(shift_max_diff_map[s], td.dataset.diff)
|
|
//shift_min_diff_map[s] = Math.min(shift_max_diff_map[s], td.dataset.diff)
|
|
})
|
|
|
|
});
|
|
|
|
// Colour each per-shift prior-adjust span independently.
|
|
// For each shift, find the maximum absolute adjustment across rows and scale saturation accordingly.
|
|
let priorShiftMax = {};
|
|
$("table.tsummary").find("td.prior-adjust .prior-adjust-part").each((n, span) => {
|
|
let $span = $(span);
|
|
let shift = $span.attr('data-shift');
|
|
let adj = parseFloat($span.attr('data-adj')) || 0;
|
|
let absadj = Math.abs(adj);
|
|
if (!(shift in priorShiftMax)) priorShiftMax[shift] = 0;
|
|
priorShiftMax[shift] = Math.max(priorShiftMax[shift], absadj);
|
|
});
|
|
|
|
// Apply colouring to each span using hsv2rgb (green for positive, red for negative)
|
|
$("table.tsummary").find("td.prior-adjust .prior-adjust-part").each((n, span) => {
|
|
let $span = $(span);
|
|
let shift = $span.attr('data-shift');
|
|
let adj = parseFloat($span.attr('data-adj')) || 0;
|
|
if (Math.abs(adj) < 1e-9) return;
|
|
let max = priorShiftMax[shift] || Math.abs(adj) || 1;
|
|
let sat = (max === 0) ? 1 : Math.min(1, Math.abs(adj) / max);
|
|
let hue = (adj > 0) ? 120 : 0; // green or red
|
|
$span.css({ backgroundColor: hsv2rgb(hue, sat, 1), color: '#000' });
|
|
$span.addClass('no-colour');
|
|
});
|
|
|
|
let selectedShifts = new Set();
|
|
|
|
let shiftColors = {};
|
|
let rotaExportViewEnabled = false;
|
|
let shiftDisplayOrder = [...oshifts];
|
|
|
|
oshifts.forEach((shift, i) => {
|
|
selectedShifts.add(shift);
|
|
// Assign a unique color for each shift using HSL
|
|
shiftColors[shift] = `hsl(${(i * 360 / oshifts.length)}, 70%, 80%)`;
|
|
});
|
|
|
|
// Add a button to toggle grid view
|
|
$("#shift-timetable-options").append(
|
|
`<button id="toggle-grid-view" style="margin-left:10px;">Toggle Linear/Grid View</button><br/>`
|
|
);
|
|
|
|
// Add a button to toggle rota export view (spreadsheet-style day-by-day shifts)
|
|
$("#shift-timetable-options").append(
|
|
`<button id="toggle-rota-export-view" style="margin-left:10px;">Show Rota Export View</button>`
|
|
);
|
|
|
|
// Add optional control to show worker names in rota export view
|
|
$("#shift-timetable-options").append(`
|
|
<span id="rota-export-show-workers-wrap" style="margin-left:12px;">
|
|
<label>
|
|
<input type="checkbox" id="rota-export-show-workers" checked>
|
|
Show workers
|
|
</label>
|
|
</span><br/>
|
|
`);
|
|
|
|
let gridViewEnabled = false;
|
|
|
|
$("#toggle-grid-view").on("click", function () {
|
|
gridViewEnabled = !gridViewEnabled;
|
|
renderShiftTimetable();
|
|
});
|
|
|
|
$("#toggle-rota-export-view").on("click", function () {
|
|
rotaExportViewEnabled = !rotaExportViewEnabled;
|
|
if (rotaExportViewEnabled) {
|
|
$(this).text("Show Linear/Grid View");
|
|
} else {
|
|
$(this).text("Show Rota Export View");
|
|
}
|
|
renderShiftTimetable();
|
|
});
|
|
|
|
$(document).on("change", "#rota-export-show-workers", function () {
|
|
if (rotaExportViewEnabled) {
|
|
renderShiftTimetable();
|
|
}
|
|
});
|
|
|
|
|
|
const shiftColourControls = $(`
|
|
<details><summary>Shift Timetable Colours</summary>
|
|
<div id="shift-colour-controls" style="margin:16px 0;">
|
|
<b>Shift timetable colours:</b>
|
|
<span id="shift-colour-buttons"></span>
|
|
</div>
|
|
</details>
|
|
`);
|
|
$("#shift-timetable-options").append(shiftColourControls);
|
|
|
|
// Add colour pickers for each shift
|
|
oshifts.forEach(shift => {
|
|
// Colour picker for the shift
|
|
let colorInput = $(`<input type="color" class="shift-colour-picker" data-shift="${shift}" value="${rgb2hex(shiftColors[shift])}" style="margin-left:2px; vertical-align:middle;" title="Pick colour for ${shift}">`);
|
|
// Label for the shift
|
|
let label = $(`<label style="margin-right:10px;">${shift}</label>`);
|
|
// On change, update the shiftColors map, button color, and re-render the timetable
|
|
colorInput.on("input", function () {
|
|
shiftColors[shift] = $(this).val();
|
|
// Update the corresponding shift button color
|
|
$(`#shift-timetable-options button[data-shift='${shift}']`).css("background", selectedShifts.has(shift) ? shiftColors[shift] : "");
|
|
renderShiftTimetable();
|
|
});
|
|
$("#shift-colour-buttons").append(label.append(colorInput));
|
|
});
|
|
// Add the toggle button if not already present
|
|
if ($("#show-all-shifts").length === 0) {
|
|
$("#shift-timetable-options").append(
|
|
`<button id="show-all-shifts" style="margin-left:10px;">Show All Shifts</button>`
|
|
);
|
|
}
|
|
|
|
let allShiftsShown = false; // Start with all shifts shown by default
|
|
|
|
function updateShowAllShiftsButton() {
|
|
if (allShiftsShown) {
|
|
$("#show-all-shifts").addClass("active").text("Hide All Shifts");
|
|
} else {
|
|
$("#show-all-shifts").removeClass("active").text("Show All Shifts");
|
|
}
|
|
}
|
|
|
|
// Toggle logic
|
|
$("#show-all-shifts").off("click").on("click", function () {
|
|
allShiftsShown = !allShiftsShown;
|
|
if (allShiftsShown) {
|
|
// Select all shifts
|
|
oshifts.forEach((shift) => {
|
|
selectedShifts.add(shift);
|
|
$(`#shift-timetable-options button[data-shift='${shift}']`)
|
|
.addClass("selected")
|
|
.css("background", shiftColors[shift]);
|
|
});
|
|
} else {
|
|
// Deselect all shifts
|
|
oshifts.forEach((shift) => {
|
|
selectedShifts.delete(shift);
|
|
$(`#shift-timetable-options button[data-shift='${shift}']`)
|
|
.removeClass("selected")
|
|
.css("background", "");
|
|
});
|
|
}
|
|
updateShowAllShiftsButton();
|
|
renderShiftTimetable();
|
|
});
|
|
|
|
// Ensure the button state is correct on page load and after timetable render
|
|
updateShowAllShiftsButton();
|
|
// Utility to convert hsl to hex for initial value
|
|
function rgb2hex(rgb) {
|
|
// Accepts hsl() or rgb() or hex
|
|
if (rgb.startsWith("#")) return rgb;
|
|
if (rgb.startsWith("hsl")) {
|
|
// Convert hsl to rgb
|
|
let hsl = rgb.match(/hsl\(([\d.]+),\s*([\d.]+)%,\s*([\d.]+)%\)/);
|
|
if (!hsl) return "#ffe066";
|
|
let h = Number(hsl[1]), s = Number(hsl[2]) / 100, l = Number(hsl[3]) / 100;
|
|
let a = s * Math.min(l, 1 - l);
|
|
function f(n) {
|
|
let k = (n + h / 30) % 12;
|
|
let color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
|
return Math.round(255 * color);
|
|
}
|
|
return "#" + [f(0), f(8), f(4)].map(x => x.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
if (rgb.startsWith("rgb")) {
|
|
let rgbArr = rgb.match(/\d+/g).map(Number);
|
|
return "#" + rgbArr.map(x => x.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
return "#ffe066";
|
|
}
|
|
|
|
// --- Add worker checkboxes to the shift timetable section ---
|
|
function renderWorkerCheckboxes() {
|
|
// Get all unique worker names and IDs from the main table
|
|
let workerList = [];
|
|
$("table#main-table td.worker").each(function () {
|
|
let workerId = $(this).attr("data-worker-id");
|
|
let workerName = $(this).find("span.name").text().trim();
|
|
if (workerId && !workerList.some(w => w.id === workerId)) {
|
|
workerList.push({ id: workerId, name: workerName });
|
|
}
|
|
});
|
|
|
|
// Build checkboxes
|
|
let html = `<details id="shift-timetable-worker-filter" style="margin-bottom:8px;">
|
|
<summary><b>Show workers:</b></summary>`;
|
|
// Add a "Toggle All" button
|
|
html += `<button type="button" id="toggle-all-workers" style="margin-right:10px;">Toggle All</button>`;
|
|
|
|
workerList.forEach(w => {
|
|
html += `<label style="margin-right:8px;">
|
|
<input type="checkbox" name="worker-filter-checkbox" value="${w.id}" checked> ${w.name}
|
|
</label>`;
|
|
});
|
|
|
|
// Attach toggle logic after DOM insertion
|
|
setTimeout(() => {
|
|
$("#toggle-all-workers").off("click").on("click", function() {
|
|
const $checkboxes = $("input[name='worker-filter-checkbox']");
|
|
const allChecked = $checkboxes.length === $checkboxes.filter(":checked").length;
|
|
$checkboxes.prop("checked", !allChecked);
|
|
// Update the filter set and re-render once, instead of triggering "change" for each
|
|
workerFilterSet = new Set(
|
|
Array.from(document.querySelectorAll("input[name='worker-filter-checkbox']:checked"))
|
|
.map(cb => cb.value)
|
|
);
|
|
// Call your function here after the map has finished
|
|
renderShiftTimetable();
|
|
});
|
|
}, 0);
|
|
html += `</details>`;
|
|
|
|
// Prepend to the shift timetable options
|
|
$("#shift-timetable-options").prepend(html);
|
|
}
|
|
|
|
// Store the current filter value (set of workerIds)
|
|
let workerFilterSet = new Set();
|
|
// Store the current setting
|
|
let showShiftNames = false;
|
|
// Controls for rendering
|
|
let showWeekStart = false;
|
|
let showGrades = false;
|
|
|
|
// Call this after the DOM is ready and after timetable is rendered
|
|
renderWorkerCheckboxes();
|
|
|
|
// On first render, select all workers by default
|
|
$("input[name='worker-filter-checkbox']").each(function() {
|
|
workerFilterSet.add($(this).val());
|
|
});
|
|
renderShiftTimetable();
|
|
|
|
// Update the filter set on checkbox change
|
|
$(document).on("change", "input[name='worker-filter-checkbox']", function() {
|
|
workerFilterSet = new Set(
|
|
$("input[name='worker-filter-checkbox']:checked").map(function() { return $(this).val(); }).get()
|
|
);
|
|
renderShiftTimetable();
|
|
});
|
|
// Add a checkbox to show/hide worker grades in the shift timetable
|
|
$("#shift-timetable-options").prepend(`
|
|
<div id="shift-timetable-show-grades" style="margin-bottom:8px;">
|
|
<label>
|
|
<input type="checkbox" id="show-grades-checkbox">
|
|
Show worker grades in timetable
|
|
</label>
|
|
</div>
|
|
`);
|
|
|
|
$(document).on("change", "#show-grades-checkbox", function() {
|
|
showGrades = $(this).is(":checked");
|
|
renderShiftTimetable();
|
|
});
|
|
// Add a checkbox to show/hide shift names in the shift timetable section
|
|
$("#shift-timetable-options").prepend(`
|
|
<div id="shift-timetable-show-shift-names" style="margin-bottom:8px;">
|
|
<label>
|
|
<input type="checkbox" id="show-shift-names-checkbox">
|
|
Show shift names in timetable
|
|
</label>
|
|
</div>
|
|
`).prepend(`
|
|
<div id="week-start-control" style="margin:8px 0;">
|
|
<label>
|
|
<input type="checkbox" id="show-week-start-checkbox">
|
|
Show week start dates in grid view
|
|
</label>
|
|
</div>
|
|
`);
|
|
|
|
// Add a button to remove all shift timetable colours (set to white)
|
|
$("#shift-colour-controls").append(
|
|
`<button id="remove-shift-colours" style="margin-left:10px;">Remove Shift Colours</button>`
|
|
);
|
|
|
|
$("#remove-shift-colours").on("click", function () {
|
|
// Set all shift colours to white
|
|
oshifts.forEach(shift => {
|
|
shiftColors[shift] = "#ffffff";
|
|
// Update the colour picker if present
|
|
$(`.shift-colour-picker[data-shift='${shift}']`).val("#ffffff");
|
|
// Update the corresponding shift button color if selected
|
|
$(`#shift-timetable-options button[data-shift='${shift}']`).css("background", selectedShifts.has(shift) ? "#ffffff" : "");
|
|
});
|
|
renderShiftTimetable();
|
|
});
|
|
|
|
$("#show-shift-names-checkbox").on("change", function() {
|
|
showShiftNames = $(this).is(":checked");
|
|
renderShiftTimetable();
|
|
});
|
|
|
|
|
|
$("#show-week-start-checkbox").on("change", function () {
|
|
showWeekStart = $(this).is(":checked");
|
|
renderShiftTimetable();
|
|
});
|
|
|
|
// Patch renderShiftTimetable to show shift names if enabled
|
|
function renderShiftTimetable() {
|
|
console.log("Render shift timetable");
|
|
console.log(workerFilterSet)
|
|
$("#shift-timetable-div").empty();
|
|
if (selectedShifts.size === 0) return;
|
|
|
|
if (rotaExportViewEnabled) {
|
|
renderRotaExportTimetable();
|
|
return;
|
|
}
|
|
|
|
if (workerFilterSet.size === 0) return;
|
|
|
|
// Helper: returns true if worker id matches filter set or set is empty (all)
|
|
function workerMatchesFilter(workerId) {
|
|
if (workerFilterSet.size === 0) return true;
|
|
return workerFilterSet.has(workerId);
|
|
}
|
|
|
|
if (!gridViewEnabled) {
|
|
// Linear view
|
|
let table = $("<table id='linear-shift-timetable'>");
|
|
let headerRow = $("<tr>");
|
|
headerRow.append("<th>Date</th><th>Week</th><th>Day</th><th>Worker(s)</th>");
|
|
table.append(headerRow);
|
|
|
|
$("table#main-table th.date").each((n, th) => {
|
|
let date = th.dataset.date;
|
|
let week = "";
|
|
let day = "";
|
|
let firstTd = $(`table#main-table td[data-date='${date}']`).first();
|
|
if (firstTd.length) {
|
|
week = firstTd.attr("data-week") || "";
|
|
day = firstTd.attr("data-day") || "";
|
|
}
|
|
|
|
let row = $("<tr>");
|
|
row.append(`<td>${date}</td>`);
|
|
row.append(`<td>${week}</td>`);
|
|
row.append(`<td>${day}</td>`);
|
|
|
|
let workerCells = [];
|
|
$(`table#main-table td[data-date='${date}']`).filter(function () {
|
|
let shifts = $(this).attr("data-shift");
|
|
if (!shifts) return false;
|
|
let cell_shifts = shifts.split(",").map(s => s.trim());
|
|
return Array.from(selectedShifts).some(s => cell_shifts.includes(s));
|
|
}).each((n, td) => {
|
|
let workerTd = $(td).closest("tr").children("td:first");
|
|
let workerName = workerTd.find("span.name").text().trim();
|
|
// grade may be provided as a data attribute; if not, try to parse from visible text
|
|
let workerGrade = workerTd.attr('data-grade') || '';
|
|
if (!workerGrade) {
|
|
// attempt to parse e.g. "Name (4) [100]" -> capture 4
|
|
let txt = workerTd.text();
|
|
let m = txt.match(/\(([^)]+)\)/);
|
|
if (m) workerGrade = m[1].trim();
|
|
}
|
|
let workerId = workerTd.attr("data-worker-id");
|
|
if (!workerMatchesFilter(workerId)) return;
|
|
let cell_shifts = $(td).attr("data-shift").split(",").map(s => s.trim());
|
|
let matchedShifts = Array.from(selectedShifts).filter(s => cell_shifts.includes(s));
|
|
let color = "#fff";
|
|
let shiftLabel = "";
|
|
if (matchedShifts.length === 1) {
|
|
color = shiftColors[matchedShifts[0]];
|
|
if (showShiftNames) shiftLabel = `<span style="font-size:0.9em; color:#333;"> (${matchedShifts[0]})</span>`;
|
|
} else if (matchedShifts.length > 1) {
|
|
color = `linear-gradient(90deg, ${shiftColors[matchedShifts[0]]} 0 50%, ${shiftColors[matchedShifts[1]]} 50% 100%)`;
|
|
if (showShiftNames) shiftLabel = `<span style="font-size:0.9em; color:#333;"> (${matchedShifts.join(", ")})</span>`;
|
|
}
|
|
let gradeTextLinear = showGrades ? ` <span style="font-size:0.85em;color:#444;">(${workerGrade})</span>` : '';
|
|
workerCells.push(`<td style="background:${color}">${workerName}${gradeTextLinear}${shiftLabel}</td>`);
|
|
});
|
|
|
|
if (workerCells.length === 0) {
|
|
row.append("<td></td>");
|
|
} else {
|
|
row.append(workerCells.join(""));
|
|
}
|
|
|
|
table.append(row);
|
|
});
|
|
$("#shift-timetable-div").append(table);
|
|
return;
|
|
}
|
|
|
|
// Grid view
|
|
let weeks = [];
|
|
let daysOfWeek = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
$("table#main-table td[data-week]").each(function () {
|
|
let week = $(this).attr("data-week");
|
|
if (week && !weeks.includes(week)) weeks.push(week);
|
|
});
|
|
weeks = weeks.sort((a, b) => parseInt(a) - parseInt(b));
|
|
|
|
|
|
// Build week -> start date map (earliest date in that week)
|
|
let weekStartDates = {};
|
|
$("table#main-table td[data-week][data-date]").each(function () {
|
|
let wk = $(this).attr("data-week");
|
|
let d = $(this).attr("data-date");
|
|
if (!wk || !d) return;
|
|
if (!weekStartDates[wk] || new Date(d) < new Date(weekStartDates[wk])) {
|
|
weekStartDates[wk] = d;
|
|
}
|
|
});
|
|
|
|
let workerIds = [];
|
|
let workerIdToName = {};
|
|
$("table#main-table td.worker").each(function () {
|
|
let workerId = $(this).attr("data-worker-id");
|
|
let workerName = $(this).find("span.name").text().trim();
|
|
let workerGrade = $(this).attr('data-grade') || '';
|
|
if (workerId && !workerIds.includes(workerId)) {
|
|
if (workerMatchesFilter(workerId)) {
|
|
workerIds.push(workerId);
|
|
workerIdToName[workerId] = workerName;
|
|
workerIdToName[workerId + '_grade'] = workerGrade;
|
|
}
|
|
}
|
|
});
|
|
|
|
let cellMap = {};
|
|
$("table#main-table td.worker").each(function () {
|
|
let workerId = $(this).attr("data-worker-id");
|
|
let workerName = $(this).find("span.name").text().trim();
|
|
let workerGrade = $(this).attr('data-grade') || '';
|
|
if (!cellMap[workerId]) cellMap[workerId] = { name: workerName, grade: workerGrade, cells: {} };
|
|
});
|
|
$("table#main-table td[data-week][data-day]").each(function () {
|
|
let td = $(this);
|
|
let tr = td.closest("tr");
|
|
let workerTd = tr.find("td.worker");
|
|
let workerId = workerTd.attr("data-worker-id");
|
|
let week = td.attr("data-week");
|
|
let day = td.attr("data-day");
|
|
let shifts = (td.attr("data-shift") || "").split(",").map(s => s.trim()).filter(Boolean);
|
|
if (workerId && week && day) {
|
|
if (!cellMap[workerId].cells[week]) cellMap[workerId].cells[week] = {};
|
|
cellMap[workerId].cells[week][day] = shifts;
|
|
}
|
|
});
|
|
|
|
let gridTable = $(`<table class="shift-grid-table" style="margin-bottom:20px; border-collapse:collapse; width:auto;"><caption>Shift Grid</caption></table>`);
|
|
let header = $("<tr><th style='border:1px solid #888; min-width:60px; width:80px;'>Week</th></tr>");
|
|
daysOfWeek.forEach(day => header.append(`<th style='border:1px solid #888;'>${day}</th>`));
|
|
gridTable.append(header);
|
|
|
|
weeks.forEach(week => {
|
|
let row = $(`<tr></tr>`);
|
|
// Show week number and (optionally) the week's start date
|
|
let weekLabel = [week];
|
|
if (showWeekStart && weekStartDates[week]) {
|
|
weekLabel += `<div style="font-size:0.85em; color:#444;">${weekStartDates[week]}</div>`;
|
|
}
|
|
row.append(`<td style="border:1px solid #888; min-width:60px; width:120px; font-weight:bold; vertical-align:middle;">${weekLabel}</td>`);
|
|
daysOfWeek.forEach(day => {
|
|
let cellContent = [];
|
|
for (const workerId of workerIds) {
|
|
let worker = cellMap[workerId];
|
|
let shifts = (worker.cells[week] && worker.cells[week][day]) || [];
|
|
let matchedShifts = Array.from(selectedShifts).filter(s => shifts.includes(s));
|
|
if (matchedShifts.length > 0) {
|
|
let color = "#fff";
|
|
let shiftLabel = "";
|
|
if (matchedShifts.length === 1) {
|
|
color = shiftColors[matchedShifts[0]];
|
|
if (showShiftNames) shiftLabel = `<span style="font-size:0.9em; color:#333;"> (${matchedShifts[0]})</span>`;
|
|
} else if (matchedShifts.length > 1) {
|
|
color = `linear-gradient(90deg, ${shiftColors[matchedShifts[0]]} 0 50%, ${shiftColors[matchedShifts[1]]} 50% 100%)`;
|
|
if (showShiftNames) shiftLabel = `<span style="font-size:0.9em; color:#333;"> (${matchedShifts.join(", ")})</span>`;
|
|
}
|
|
let gradeText = showGrades ? ` <span style="font-size:0.85em;color:#444;">(${worker.grade||''})</span>` : '';
|
|
cellContent.push(`<div style="background:${color};margin:1px 0;padding:0 2px;border-radius:3px;display:inline-block;">${worker.name}${gradeText}${shiftLabel}</div>`);
|
|
}
|
|
}
|
|
row.append(`<td style="border:1px solid #888; min-width:40px; text-align:center;">${cellContent.join(" / ")}</td>`);
|
|
});
|
|
gridTable.append(row);
|
|
});
|
|
$("#shift-timetable-div").append(gridTable);
|
|
}
|
|
|
|
function formatDateForExport(dateStr) {
|
|
if (!dateStr) return "";
|
|
let d = new Date(dateStr);
|
|
if (Number.isNaN(d.getTime())) return dateStr;
|
|
let dd = String(d.getDate()).padStart(2, "0");
|
|
let mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
let yyyy = d.getFullYear();
|
|
return `${dd}/${mm}/${yyyy}`;
|
|
}
|
|
|
|
function getShiftHeaderLabel(shift) {
|
|
let s = (shift || "").toLowerCase();
|
|
if (s.includes("twil")) return "TWIL";
|
|
if (s.includes("night") || s.includes("oncall")) return "NIGHT";
|
|
if (s.includes("weekend")) return "WEEKEND";
|
|
return (shift || "").toUpperCase();
|
|
}
|
|
|
|
function renderRotaExportTimetable() {
|
|
let weeks = [];
|
|
let daysOfWeek = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
let weekStartDates = {};
|
|
let showWorkers = $("#rota-export-show-workers").is(":checked");
|
|
|
|
// Build week index and week start dates from existing table data
|
|
$("table#main-table td[data-week][data-date]").each(function () {
|
|
let week = $(this).attr("data-week");
|
|
let date = $(this).attr("data-date");
|
|
if (!week || !date) return;
|
|
if (!weeks.includes(week)) weeks.push(week);
|
|
if (!weekStartDates[week] || new Date(date) < new Date(weekStartDates[week])) {
|
|
weekStartDates[week] = date;
|
|
}
|
|
});
|
|
|
|
weeks = weeks.sort((a, b) => parseInt(a) - parseInt(b));
|
|
|
|
const orderedSelectedShifts = shiftDisplayOrder.filter(s => selectedShifts.has(s));
|
|
const dayShiftOrder = {};
|
|
daysOfWeek.forEach(day => {
|
|
dayShiftOrder[day] = orderedSelectedShifts.filter(shift => {
|
|
let found = false;
|
|
$(`table#main-table td[data-day='${day}']`).each(function () {
|
|
let shifts = ($(this).attr("data-shift") || "").split(",").map(x => x.trim()).filter(Boolean);
|
|
if (shifts.includes(shift)) {
|
|
found = true;
|
|
return false;
|
|
}
|
|
});
|
|
return found;
|
|
});
|
|
});
|
|
|
|
let table = $(`<table id="rota-export-table" class="shift-grid-table" style="margin-bottom:20px; border-collapse:collapse; width:auto; table-layout:fixed;"><caption>Rota Export (Shifts by Day)</caption></table>`);
|
|
let headerTop = $("<tr></tr>");
|
|
headerTop.append("<th style='border:1px solid #888; min-width:130px;'>Week Start</th>");
|
|
headerTop.append("<th style='border:1px solid #888; min-width:70px;'>Week</th>");
|
|
daysOfWeek.forEach(day => {
|
|
let span = Math.max(1, dayShiftOrder[day].length);
|
|
headerTop.append(`<th style='border:1px solid #888; min-width:120px;' colspan='${span}'>${day.toUpperCase()}</th>`);
|
|
});
|
|
table.append(headerTop);
|
|
|
|
let headerBottom = $("<tr></tr>");
|
|
headerBottom.append("<th style='border:1px solid #888; min-width:130px;'></th>");
|
|
headerBottom.append("<th style='border:1px solid #888; min-width:70px;'>No</th>");
|
|
daysOfWeek.forEach(day => {
|
|
if (dayShiftOrder[day].length === 0) {
|
|
headerBottom.append("<th style='border:1px solid #888; min-width:120px;'></th>");
|
|
return;
|
|
}
|
|
dayShiftOrder[day].forEach(shift => {
|
|
headerBottom.append(`<th style='border:1px solid #888; min-width:120px;'>${getShiftHeaderLabel(shift)}</th>`);
|
|
});
|
|
});
|
|
table.append(headerBottom);
|
|
|
|
weeks.forEach(week => {
|
|
let row = $("<tr></tr>");
|
|
let startDateFormatted = formatDateForExport(weekStartDates[week]);
|
|
row.append(`<td style='border:1px solid #888; font-weight:bold; vertical-align:top;'>${startDateFormatted}</td>`);
|
|
row.append(`<td style='border:1px solid #888; text-align:center; font-weight:bold; vertical-align:top;'>${week}</td>`);
|
|
|
|
daysOfWeek.forEach(day => {
|
|
const dayShifts = dayShiftOrder[day];
|
|
if (dayShifts.length === 0) {
|
|
row.append(`<td style='border:1px solid #888; color:#999; text-align:center;'>-</td>`);
|
|
return;
|
|
}
|
|
|
|
dayShifts.forEach(shift => {
|
|
let workersForShift = new Set();
|
|
$(`table#main-table td[data-week='${week}'][data-day='${day}']`).each(function () {
|
|
let shifts = ($(this).attr("data-shift") || "").split(",").map(s => s.trim()).filter(Boolean);
|
|
if (!shifts.includes(shift)) return;
|
|
let workerName = $(this).closest("tr").find("td.worker span.name").text().trim();
|
|
if (workerName) workersForShift.add(workerName);
|
|
});
|
|
|
|
let workerList = Array.from(workersForShift).sort();
|
|
let text = "";
|
|
if (showWorkers) {
|
|
text = workerList.join("/");
|
|
} else {
|
|
text = workerList.length > 0 ? "X" : "";
|
|
}
|
|
|
|
row.append(`<td style='border:1px solid #888; vertical-align:top; text-align:center;'>${text}</td>`);
|
|
});
|
|
});
|
|
|
|
table.append(row);
|
|
});
|
|
|
|
if (weeks.length === 0) {
|
|
table.append(`<tr><td colspan="16" style="border:1px solid #888; text-align:center; color:#666;">No rota data found.</td></tr>`);
|
|
}
|
|
|
|
$("#shift-timetable-div").append(table);
|
|
}
|
|
|
|
// Re-render worker checkboxes after timetable is updated (in case workers change)
|
|
function rerenderWorkerCheckboxesIfNeeded() {
|
|
$("#shift-timetable-worker-filter").remove();
|
|
renderWorkerCheckboxes();
|
|
// Restore checked state
|
|
$("input[name='worker-filter-checkbox']").each(function() {
|
|
if (workerFilterSet.has($(this).val())) {
|
|
$(this).prop("checked", true);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Call this after timetable is (re)rendered if needed
|
|
rerenderWorkerCheckboxesIfNeeded();
|
|
|
|
// Patch the timetable button logic to use renderShiftTimetable
|
|
oshifts.forEach((shift) => {
|
|
let button = $(`<button data-shift='${shift}' draggable='true' title='Drag to reorder shifts'>${shift}</button>`);
|
|
button.css({
|
|
background: "",
|
|
border: "1px solid #888",
|
|
margin: "2px",
|
|
color: "#222"
|
|
});
|
|
|
|
button.on("click", function (evt) {
|
|
let s = evt.target.dataset.shift;
|
|
if (selectedShifts.has(s)) {
|
|
selectedShifts.delete(s);
|
|
$(this).removeClass("selected");
|
|
$(this).css("background", "");
|
|
} else {
|
|
selectedShifts.add(s);
|
|
$(this).addClass("selected");
|
|
$(this).css("background", shiftColors[shift]);
|
|
}
|
|
renderShiftTimetable();
|
|
});
|
|
|
|
$("#shift-timetable-options").append(button);
|
|
});
|
|
|
|
let draggedShiftButton = null;
|
|
|
|
$(document).on("dragstart", "#shift-timetable-options button[data-shift]", function (e) {
|
|
draggedShiftButton = this;
|
|
if (e.originalEvent && e.originalEvent.dataTransfer) {
|
|
e.originalEvent.dataTransfer.effectAllowed = "move";
|
|
}
|
|
});
|
|
|
|
$(document).on("dragover", "#shift-timetable-options button[data-shift]", function (e) {
|
|
e.preventDefault();
|
|
});
|
|
|
|
$(document).on("drop", "#shift-timetable-options button[data-shift]", function (e) {
|
|
e.preventDefault();
|
|
if (!draggedShiftButton || draggedShiftButton === this) return;
|
|
|
|
const $dragged = $(draggedShiftButton);
|
|
const $target = $(this);
|
|
const draggedIndex = $dragged.index();
|
|
const targetIndex = $target.index();
|
|
|
|
if (draggedIndex < targetIndex) {
|
|
$target.after($dragged);
|
|
} else {
|
|
$target.before($dragged);
|
|
}
|
|
|
|
shiftDisplayOrder = $("#shift-timetable-options button[data-shift]").map(function () {
|
|
return $(this).attr("data-shift");
|
|
}).get();
|
|
|
|
renderShiftTimetable();
|
|
draggedShiftButton = null;
|
|
});
|
|
|
|
|
|
// Add help text to the shift timetable section
|
|
$("#shift-timetable-options").prepend(`
|
|
<details id="shift-timetable-help" style="margin-bottom:8px; font-size: 0.95em; color: #444;">
|
|
<summary><b>Shift Timetables Help:</b></summary>
|
|
<ul style="margin: 4px 0 0 18px; padding: 0;">
|
|
<li>Select one or more shifts below to view which workers are assigned on each day.</li>
|
|
<li>Use the colour pickers to customise shift colours in the timetable and main table.</li>
|
|
<li>Toggle between linear and grid view using the "Toggle Linear/Grid View" button.</li>
|
|
</ul>
|
|
</details>
|
|
`);
|
|
//$("table.summary")
|
|
|
|
// $("body").prepend("<div id='global-settings'>Settings:<br />Customise training days lost per shift (you need to press recalculate for changes to take effect)<form></form></div>")
|
|
|
|
// shifts.forEach((s) => {
|
|
|
|
// disable = false;
|
|
// if(s.includes("twilight")) {
|
|
// value = 0.5;
|
|
// } else if(s == "night_weekend") {
|
|
// value = 4 / 3;
|
|
// } else if(s == "night_weekday") {
|
|
// value = 5 / 4;
|
|
// } else {
|
|
// disable = true;
|
|
// value = 0;
|
|
// }
|
|
|
|
// if(!disable) {
|
|
// $("#global-settings form").append(`<span class='tdl-settings'><label for="${s}-tdl">${s}</label><input type="number" id="${s}-tdl" name="${s}-tdl" value='${value}'></span>`);
|
|
// }
|
|
|
|
|
|
// })
|
|
// $("#global-settings").append($("<button >Recalculate</button>").click(() => {
|
|
// generateExtra();
|
|
// }))
|
|
|
|
// generateExtra();
|
|
|
|
$(".table-div + pre").each((n, pre) => {
|
|
|
|
$(pre).before($("<button class='shift-breakdown-button'>Show shift breakdown</button>").click(() => {
|
|
$(pre).toggle();
|
|
}));
|
|
|
|
})
|
|
|
|
|
|
$("td").hover((e) => {
|
|
// start hover
|
|
worker_td = $(e.target).closest('tr').find('td:first')[0]
|
|
pair = worker_td.dataset.pair;
|
|
index = $(e.target).index() - 1;
|
|
if (pair > 0) {
|
|
$(`td[data-pair=${pair}]`).each((n, el) => {
|
|
$($(el).closest('tr').find('td').get(index)).addClass("pair-match")
|
|
})
|
|
}
|
|
|
|
// Find the span under the mouse, if any
|
|
let targetSpan = $(e.target).closest("span.multi-shift-shift").get(0) ||
|
|
($(e.target).hasClass("multi-shift-shift") ? e.target : null);
|
|
|
|
if (targetSpan) {
|
|
// Only highlight spans with the same text
|
|
let shiftDisplay = $(targetSpan).text().trim();
|
|
$("span.multi-shift-shift").filter(function () {
|
|
return $(this).text().trim() === shiftDisplay;
|
|
}).addClass("shift-highlight");
|
|
} else if (e.target.dataset.shift != undefined && e.target.dataset.shift.length > 0) {
|
|
// Fallback: highlight all cells containing ANY of the hovered shifts (legacy)
|
|
let hovered_shifts = e.target.dataset.shift.split(",").map(s => s.trim());
|
|
$("td").filter(function () {
|
|
let shifts = $(this).attr("data-shift");
|
|
if (!shifts) return false;
|
|
let cell_shifts = shifts.split(",").map(s => s.trim());
|
|
return hovered_shifts.some(s => cell_shifts.includes(s));
|
|
}).addClass("shift-highlight");
|
|
}
|
|
|
|
}, (e) => {
|
|
// end hover
|
|
$("td.pair-match").removeClass("pair-match")
|
|
$("td.shift-highlight").removeClass("shift-highlight")
|
|
$("span.shift-highlight").removeClass("shift-highlight")
|
|
$("td.remote-site-match").removeClass("remote-site-match")
|
|
});
|
|
|
|
|
|
$("#rota-table tr td:first-child").each((n, td) => {
|
|
$(td).click(() => {
|
|
viewWorker(td);
|
|
})
|
|
})
|
|
|
|
function viewWorker(worker) {
|
|
ds = worker.dataset;
|
|
dlg = $(`<div class='dialog' title='${ds.worker}'>
|
|
Site: ${ds.site}</br>
|
|
FTE: ${ds.fte} (${ds.fte_adj})</br>
|
|
Start date: ${ds.start_date}</br>
|
|
End date: ${ds.end_date}</br>
|
|
Non working days: ${ds.nwds}</br>
|
|
Remote site: ${ds.remoteSite}</br>
|
|
Summed shift diff: ${ds.shiftDiffSummed}
|
|
</div>`)
|
|
$("body").append(dlg)
|
|
|
|
shifts = JSON.parse(ds.shiftCounts)
|
|
shift_targets = JSON.parse(ds.workerTargets)
|
|
shift_diffs = JSON.parse(ds.shiftDiff)
|
|
|
|
let shift_count_table = document.createElement("table")
|
|
shift_count_table.classList.add("shift-table")
|
|
let header_row = shift_count_table.createTHead().insertRow();
|
|
header_row.insertCell().appendChild(document.createTextNode("Shift"))
|
|
header_row.insertCell().appendChild(document.createTextNode("Count"))
|
|
header_row.insertCell().appendChild(document.createTextNode("Target"))
|
|
header_row.insertCell().appendChild(document.createTextNode("Diffs"))
|
|
|
|
|
|
table_body = shift_count_table.createTBody()
|
|
|
|
for (const shift in shifts) {
|
|
let row = table_body.insertRow();
|
|
row.insertCell().appendChild(document.createTextNode(shift))
|
|
row.insertCell().appendChild(document.createTextNode(shifts[shift]))
|
|
row.insertCell().appendChild(document.createTextNode(shift_targets[shift].toPrecision(2)))
|
|
row.insertCell().appendChild(document.createTextNode(shift_diffs[shift].toPrecision(2)))
|
|
}
|
|
|
|
dlg.get(0).appendChild(shift_count_table)
|
|
dlg.dialog()
|
|
|
|
|
|
|
|
}
|
|
|
|
$("#export-div").append($("#main-table").clone().attr("id", "gen-table").addClass("transposed"))
|
|
|
|
$("#gen-table").each(function () {
|
|
var $this = $(this);
|
|
var newrows = [];
|
|
//$this.find("th.site-title").parent().remove();
|
|
$this.find("tr").each(function (rowToColIndex) {
|
|
$(this).find("td, th").each(function (colToRowIndex) {
|
|
if (newrows[colToRowIndex] === undefined) {
|
|
newrows[colToRowIndex] = $("<tr></tr>");
|
|
}
|
|
while (newrows[colToRowIndex].find("td, th").length < rowToColIndex) {
|
|
newrows[colToRowIndex].append($("<td></td>"));
|
|
}
|
|
newrows[colToRowIndex].append($(this));
|
|
});
|
|
});
|
|
$this.find("tr").remove();
|
|
$.each(newrows, function () {
|
|
$this.append(this);
|
|
});
|
|
|
|
$this.find("td.rota-day").each((n, el) => {
|
|
$(el).text(el.dataset.shift)
|
|
});
|
|
});
|
|
|
|
// ICS Export functionality
|
|
let customShiftTimes = {}; // Will store user-defined times
|
|
|
|
function getShiftTimes() {
|
|
// Merge default with custom
|
|
const defaultTimes = {
|
|
'night_weekday': { start: '20:00', end: '08:15', nextDay: true },
|
|
'night_weekend': { start: '20:00', end: '08:15', nextDay: true },
|
|
'exeter_twilight': { start: '14:00', end: '02:30', nextDay: true },
|
|
'truro_twilight': { start: '14:00', end: '02:30', nextDay: true },
|
|
'torbay_twilight': { start: '14:00', end: '02:30', nextDay: true },
|
|
'plymouth_twilight': { start: '14:00', end: '02:30', nextDay: true },
|
|
'weekend_exeter': { start: '08:00', end: '20:30', nextDay: false },
|
|
'weekend_truro': { start: '08:00', end: '20:30', nextDay: false },
|
|
'weekend_torbay': { start: '08:00', end: '20:30', nextDay: false },
|
|
'weekend_plymouth1': { start: '08:00', end: '16:00', nextDay: false },
|
|
'weekend_plymouth2': { start: '16:00', end: '00:00', nextDay: false },
|
|
'plymouth_bank_holidays': { start: '08:00', end: '16:00', nextDay: false },
|
|
};
|
|
return { ...defaultTimes, ...customShiftTimes };
|
|
}
|
|
|
|
function generateICS(shiftType, shiftData) {
|
|
let icsContent = `BEGIN:VCALENDAR
|
|
VERSION:2.0
|
|
PRODID:-//Rota Generator//EN
|
|
`;
|
|
|
|
const shiftTimes = getShiftTimes();
|
|
const time = shiftTimes[shiftType] || { start: '09:00', end: '17:00', nextDay: false };
|
|
|
|
shiftData.forEach(entry => {
|
|
const date = entry.date;
|
|
const workerName = entry.worker;
|
|
|
|
const startDate = new Date(date + 'T' + time.start + ':00');
|
|
let endDate = new Date(date + 'T' + time.end + ':00');
|
|
if (time.nextDay) {
|
|
endDate.setDate(endDate.getDate() + 1);
|
|
}
|
|
|
|
const formatDate = (d) => d.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
|
|
|
const shiftTitle = shiftType.replace(/_/g, ' ').toUpperCase();
|
|
const summary = workerName ? `${shiftTitle} - ${workerName}` : shiftTitle;
|
|
const description = workerName ?
|
|
`Shift: ${shiftType}\nWorker: ${workerName}` :
|
|
`Shift: ${shiftType}`;
|
|
|
|
icsContent += `BEGIN:VEVENT
|
|
UID:${workerName ? workerName + '-' : ''}${shiftType}-${date}@rota
|
|
DTSTAMP:${formatDate(new Date())}
|
|
DTSTART:${formatDate(startDate)}
|
|
DTEND:${formatDate(endDate)}
|
|
SUMMARY:${summary}
|
|
DESCRIPTION:${description}
|
|
END:VEVENT
|
|
`;
|
|
});
|
|
|
|
icsContent += 'END:VCALENDAR';
|
|
return icsContent;
|
|
}
|
|
|
|
function downloadICS(filename, content) {
|
|
const blob = new Blob([content], { type: 'text/calendar;charset=utf-8' });
|
|
const link = document.createElement('a');
|
|
link.href = URL.createObjectURL(blob);
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
}
|
|
|
|
// Collect shift dates and worker assignments
|
|
const shiftDates = {};
|
|
const workerShifts = {};
|
|
|
|
$('#main-table tr.worker-row').each(function() {
|
|
const workerName = $(this).find('td.worker').attr('data-worker');
|
|
if (workerName) {
|
|
workerShifts[workerName] = {};
|
|
|
|
$(this).find('td.rota-day').each(function() {
|
|
const shift = $(this).attr('data-shift');
|
|
const date = $(this).attr('data-date');
|
|
if (shift && shift !== '' && date) {
|
|
const shifts = shift.split(',').map(s => s.trim()).filter(s => s);
|
|
shifts.forEach(s => {
|
|
// Collect by shift type with worker info
|
|
if (!shiftDates[s]) {
|
|
shiftDates[s] = [];
|
|
}
|
|
// Check if we already have this date for this shift type
|
|
const existingEntry = shiftDates[s].find(entry => entry.date === date);
|
|
if (!existingEntry) {
|
|
shiftDates[s].push({ date: date, worker: workerName });
|
|
}
|
|
|
|
// Collect by worker
|
|
if (!workerShifts[workerName][s]) {
|
|
workerShifts[workerName][s] = [];
|
|
}
|
|
if (!workerShifts[workerName][s].includes(date)) {
|
|
workerShifts[workerName][s].push(date);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Add ICS export buttons
|
|
const icsButtonsDiv = $('<div id="ics-export-buttons" style="margin-top: 20px;"><h3>Export ICS Files</h3></div>');
|
|
const setTimesButton = $(`<button id="set-shift-times" style="margin: 5px;">Set Shift Times</button>`);
|
|
icsButtonsDiv.append(setTimesButton);
|
|
|
|
// Shift type exports (with worker info)
|
|
const shiftExportsDiv = $('<div style="margin-bottom: 20px;"><h4>Export by Shift Type</h4></div>');
|
|
Object.keys(shiftDates).sort().forEach(shiftType => {
|
|
const button = $(`<button style="margin: 5px;">Download ${shiftType.replace(/_/g, ' ')} ICS</button>`);
|
|
button.on('click', () => {
|
|
const ics = generateICS(shiftType, shiftDates[shiftType]);
|
|
downloadICS(`${shiftType}.ics`, ics);
|
|
});
|
|
shiftExportsDiv.append(button);
|
|
});
|
|
icsButtonsDiv.append(shiftExportsDiv);
|
|
|
|
// Worker exports
|
|
const workerExportsDiv = $('<div style="margin-bottom: 20px;"><h4>Export by Worker</h4></div>');
|
|
Object.keys(workerShifts).sort().forEach(workerName => {
|
|
const workerShiftTypes = Object.keys(workerShifts[workerName]);
|
|
if (workerShiftTypes.length > 0) {
|
|
const button = $(`<button style="margin: 5px;">Download ${workerName} ICS</button>`);
|
|
button.on('click', () => {
|
|
showWorkerShiftSelectionModal(workerName, workerShifts[workerName]);
|
|
});
|
|
workerExportsDiv.append(button);
|
|
}
|
|
});
|
|
icsButtonsDiv.append(workerExportsDiv);
|
|
|
|
$('#export-div').append(icsButtonsDiv);
|
|
|
|
// Function to show worker shift selection modal
|
|
function showWorkerShiftSelectionModal(workerName, workerShiftData) {
|
|
const checkboxesDiv = $('#worker-shift-checkboxes');
|
|
checkboxesDiv.empty();
|
|
|
|
$('#worker-shift-modal-title').text(`Select Shifts for ${workerName}`);
|
|
|
|
// Create checkboxes for each shift type
|
|
Object.keys(workerShiftData).sort().forEach(shiftType => {
|
|
const dates = workerShiftData[shiftType];
|
|
const checkboxId = `shift-${shiftType}`;
|
|
const checkbox = $(`
|
|
<div style="margin: 8px 0;">
|
|
<input type="checkbox" id="${checkboxId}" checked>
|
|
<label for="${checkboxId}" style="margin-left: 8px;">
|
|
${shiftType.replace(/_/g, ' ').toUpperCase()} (${dates.length} shift${dates.length !== 1 ? 's' : ''})
|
|
</label>
|
|
</div>
|
|
`);
|
|
checkboxesDiv.append(checkbox);
|
|
});
|
|
|
|
// Store worker data for export
|
|
$('#worker-shift-export').data('workerName', workerName);
|
|
$('#worker-shift-export').data('workerShiftData', workerShiftData);
|
|
|
|
// Show modal
|
|
workerShiftModal.show();
|
|
}
|
|
|
|
// Modal for worker shift selection
|
|
const workerShiftModal = $(`
|
|
<div id="worker-shift-modal" style="display:none; position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:10000; background:#fff; border:2px solid #888; border-radius:8px; box-shadow:0 4px 24px #0002; padding:24px; min-width:400px; max-height:80vh; overflow-y:auto;">
|
|
<h3 style="margin-top:0;" id="worker-shift-modal-title">Select Shifts for Export</h3>
|
|
<div id="worker-shift-checkboxes" style="margin: 16px 0;"></div>
|
|
<div style="text-align:right; margin-top:16px;">
|
|
<button id="worker-shift-cancel" style="margin-right:8px;">Cancel</button>
|
|
<button id="worker-shift-export">Export Selected</button>
|
|
</div>
|
|
</div>
|
|
`);
|
|
$('body').append(workerShiftModal);
|
|
|
|
// Modal for setting shift times
|
|
const shiftTimesModal = $(`
|
|
<div id="shift-times-modal" style="display:none; position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:10000; background:#fff; border:2px solid #888; border-radius:8px; box-shadow:0 4px 24px #0002; padding:24px; min-width:400px; max-height:80vh; overflow-y:auto;">
|
|
<h3 style="margin-top:0;">Set Shift Times</h3>
|
|
<div id="shift-times-controls"></div>
|
|
<div style="text-align:right; margin-top:16px;">
|
|
<button id="save-shift-times">Save</button>
|
|
<button id="close-shift-times">Close</button>
|
|
</div>
|
|
</div>
|
|
`);
|
|
$("body").append(shiftTimesModal);
|
|
|
|
$('#set-shift-times').on('click', function() {
|
|
$('#shift-times-modal').show();
|
|
populateShiftTimesModal();
|
|
});
|
|
|
|
$('#close-shift-times').on('click', function() {
|
|
$('#shift-times-modal').hide();
|
|
});
|
|
|
|
$('#save-shift-times').on('click', function() {
|
|
saveShiftTimes();
|
|
$('#shift-times-modal').hide();
|
|
});
|
|
|
|
// Worker shift modal event handlers
|
|
$('#worker-shift-cancel').on('click', function() {
|
|
workerShiftModal.hide();
|
|
});
|
|
|
|
$('#worker-shift-export').on('click', function() {
|
|
const workerName = $(this).data('workerName');
|
|
const workerShiftData = $(this).data('workerShiftData');
|
|
|
|
// Get selected shift types
|
|
const selectedShifts = [];
|
|
$('#worker-shift-checkboxes input[type="checkbox"]:checked').each(function() {
|
|
const shiftType = $(this).attr('id').replace('shift-', '');
|
|
selectedShifts.push(shiftType);
|
|
});
|
|
|
|
if (selectedShifts.length === 0) {
|
|
alert('Please select at least one shift type to export.');
|
|
return;
|
|
}
|
|
|
|
// Generate ICS for selected shifts
|
|
let icsContent = `BEGIN:VCALENDAR
|
|
VERSION:2.0
|
|
PRODID:-//Rota Generator//EN
|
|
`;
|
|
|
|
selectedShifts.forEach(shiftType => {
|
|
const dates = workerShiftData[shiftType];
|
|
const shiftTimes = getShiftTimes();
|
|
const time = shiftTimes[shiftType] || { start: '09:00', end: '17:00', nextDay: false };
|
|
|
|
dates.forEach(date => {
|
|
const startDate = new Date(date + 'T' + time.start + ':00');
|
|
let endDate = new Date(date + 'T' + time.end + ':00');
|
|
if (time.nextDay) {
|
|
endDate.setDate(endDate.getDate() + 1);
|
|
}
|
|
|
|
const formatDate = (d) => d.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
|
|
|
const shiftTitle = shiftType.replace(/_/g, ' ').toUpperCase();
|
|
|
|
icsContent += `BEGIN:VEVENT
|
|
UID:${workerName}-${shiftType}-${date}@rota
|
|
DTSTAMP:${formatDate(new Date())}
|
|
DTSTART:${formatDate(startDate)}
|
|
DTEND:${formatDate(endDate)}
|
|
SUMMARY:${shiftTitle} - ${workerName}
|
|
DESCRIPTION:Shift: ${shiftType}\nWorker: ${workerName}
|
|
END:VEVENT
|
|
`;
|
|
});
|
|
});
|
|
|
|
icsContent += 'END:VCALENDAR';
|
|
downloadICS(`${workerName.replace(/[^a-zA-Z0-9]/g, '_')}.ics`, icsContent);
|
|
|
|
workerShiftModal.hide();
|
|
});
|
|
|
|
function populateShiftTimesModal() {
|
|
const controls = $('#shift-times-controls');
|
|
controls.empty();
|
|
const shiftTimes = getShiftTimes();
|
|
Object.keys(shiftDates).sort().forEach(shiftType => {
|
|
const time = shiftTimes[shiftType] || { start: '09:00', end: '17:00', nextDay: false };
|
|
const div = $(`
|
|
<div style="margin: 10px 0; padding: 10px; border: 1px solid #ccc; border-radius: 4px;">
|
|
<label><strong>${shiftType.replace(/_/g, ' ')}</strong></label><br>
|
|
<label>Start: <input type="time" class="shift-start" data-shift="${shiftType}" value="${time.start}"></label>
|
|
<label>End: <input type="time" class="shift-end" data-shift="${shiftType}" value="${time.end}"></label>
|
|
<label><input type="checkbox" class="shift-nextday" data-shift="${shiftType}" ${time.nextDay ? 'checked' : ''}> Ends next day</label>
|
|
</div>
|
|
`);
|
|
controls.append(div);
|
|
});
|
|
}
|
|
|
|
function saveShiftTimes() {
|
|
customShiftTimes = {};
|
|
$('#shift-times-controls .shift-start').each(function() {
|
|
const shift = $(this).data('shift');
|
|
const start = $(this).val();
|
|
const end = $(`input.shift-end[data-shift="${shift}"]`).val();
|
|
const nextDay = $(`input.shift-nextday[data-shift="${shift}"]`).is(':checked');
|
|
customShiftTimes[shift] = { start, end, nextDay };
|
|
});
|
|
}
|
|
|
|
function hsv2rgb(h, s, v) {
|
|
// adapted from http://schinckel.net/2012/01/10/hsv-to-rgb-in-javascript/
|
|
var rgb, i, data = [];
|
|
if (s === 0) {
|
|
rgb = [v, v, v];
|
|
} else {
|
|
h = h / 60;
|
|
i = Math.floor(h);
|
|
data = [v * (1 - s), v * (1 - s * (h - i)), v * (1 - s * (1 - (h - i)))];
|
|
switch (i) {
|
|
case 0:
|
|
rgb = [v, data[2], data[0]];
|
|
break;
|
|
case 1:
|
|
rgb = [data[1], v, data[0]];
|
|
break;
|
|
case 2:
|
|
rgb = [data[0], v, data[2]];
|
|
break;
|
|
case 3:
|
|
rgb = [data[0], data[1], v];
|
|
break;
|
|
case 4:
|
|
rgb = [data[2], data[0], v];
|
|
break;
|
|
default:
|
|
rgb = [v, data[0], data[1]];
|
|
break;
|
|
}
|
|
}
|
|
return '#' + rgb.map(function (x) {
|
|
return ("0" + Math.round(x * 255).toString(16)).slice(-2);
|
|
}).join('');
|
|
};
|
|
|
|
//$("#workers-json-target").append(syntaxHighlight(JSON.stringify(JSON.parse(($("#worker_details").data("workers"))))))
|
|
|
|
function syntaxHighlight(json) {
|
|
if (typeof json != 'string') {
|
|
json = JSON.stringify(json, undefined, 2);
|
|
}
|
|
json = json.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
|
|
var cls = 'number';
|
|
if (/^"/.test(match)) {
|
|
if (/:$/.test(match)) {
|
|
cls = 'key';
|
|
} else {
|
|
cls = 'string';
|
|
}
|
|
} else if (/true|false/.test(match)) {
|
|
cls = 'boolean';
|
|
} else if (/null/.test(match)) {
|
|
cls = 'null';
|
|
}
|
|
return '<span class="' + cls + '">' + match + '</span>';
|
|
});
|
|
}
|
|
|
|
(function() {
|
|
const mainTable = document.getElementById("main-table");
|
|
if (!mainTable) return;
|
|
|
|
// Ensure the header row is inside a proper <thead> element.
|
|
// Some exports use a bare <tr> as the first row; move it into a thead so CSS/sticky headers work.
|
|
if (!mainTable.tHead) {
|
|
const firstRow = mainTable.querySelector('tr');
|
|
if (firstRow) {
|
|
const thead = mainTable.createTHead();
|
|
thead.appendChild(firstRow);
|
|
}
|
|
}
|
|
|
|
// Helper to remove highlight from all headers
|
|
function clearColHover() {
|
|
const ths = mainTable.querySelectorAll("th.col-hover");
|
|
ths.forEach(th => th.classList.remove("col-hover"));
|
|
}
|
|
|
|
// Mouseover handler for all cells
|
|
mainTable.addEventListener("mouseover", function(e) {
|
|
let cell = e.target.closest("td,th");
|
|
if (!cell) return;
|
|
// Find the column index
|
|
let colIdx = cell.cellIndex;
|
|
if (colIdx === undefined) return;
|
|
clearColHover();
|
|
// Highlight the header in this column (first row)
|
|
let headerRow = mainTable.tHead ? mainTable.tHead.rows[0] : mainTable.rows[0];
|
|
if (headerRow && headerRow.cells[colIdx]) {
|
|
headerRow.cells[colIdx].classList.add("col-hover");
|
|
}
|
|
});
|
|
|
|
// Mouseout handler to clear highlight
|
|
mainTable.addEventListener("mouseout", function(e) {
|
|
clearColHover();
|
|
});
|
|
|
|
|
|
})();
|
|
|
|
// --- Add a button to show the Display Settings modal ---
|
|
$("#main-table").before(
|
|
`<button id="show-display-settings" style="margin:8px 0 8px 0; float:right;">Display Settings</button>`
|
|
);
|
|
|
|
// Ensure main table header is sticky and compact
|
|
if ($('#main-table-style').length === 0) {
|
|
$('head').append(`
|
|
<style id='main-table-style'>
|
|
/* Make main timetable header sticky */
|
|
#main-table thead th, #main-table thead td { position: sticky; top: 0; z-index: 60; background: #fff; font-size: 12px; }
|
|
/* Slight shadow to separate header from body */
|
|
#main-table thead th { box-shadow: 0 2px 6px rgba(0,0,0,0.06); }
|
|
</style>
|
|
`);
|
|
}
|
|
|
|
// --- Modal show/hide logic ---
|
|
$("#show-display-settings").on("click", function() {
|
|
$("#display-settings-modal").show();
|
|
});
|
|
// Attach the event handler after the modal is added to the DOM
|
|
$(document).on("click", "#close-display-settings", function() {
|
|
$("#display-settings-modal").hide();
|
|
});
|
|
|
|
// --- Modal HTML (replace the highlight-days-controls div with this) ---
|
|
const displaySettingsModal = $(`
|
|
<div id="display-settings-modal" style="display:none; position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:10000; background:#fff; border:2px solid #888; border-radius:8px; box-shadow:0 4px 24px #0002; padding:24px; min-width:320px;">
|
|
<h3 style="margin-top:0;">Display Settings</h3>
|
|
<div id="highlight-days-controls" style="margin:8px 0;">
|
|
<b>Highlight days of week:</b>
|
|
<span id="highlight-days-buttons"></span>
|
|
</div>
|
|
<div style="text-align:right; margin-top:16px;">
|
|
<button id="close-display-settings">Close</button>
|
|
</div>
|
|
</div>
|
|
`);
|
|
$("body").append(displaySettingsModal);
|
|
|
|
// --- Highlight day buttons in modal with color pickers ---
|
|
const daysOfWeek = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
let highlightedDays = new Set();
|
|
let highlightDayColors = {}; // day -> color
|
|
|
|
// Default highlight color
|
|
const defaultHighlightColor = "#ffe066";
|
|
|
|
// --- Add force assigned highlight controls to the Display Settings modal ---
|
|
const forceAssignedControls = $(`
|
|
<div id="force-assigned-controls" style="margin:16px 0;">
|
|
<label>
|
|
<input type="checkbox" id="highlight-force-assigned" checked>
|
|
Highlight force assigned shifts
|
|
</label>
|
|
</div>
|
|
`);
|
|
$("#display-settings-modal #highlight-days-controls").after(forceAssignedControls);
|
|
|
|
// --- Highlighting logic ---
|
|
function updateDayHighlights() {
|
|
// Remove previous highlights and reset borders/backgrounds
|
|
$("#main-table td, #main-table th").removeClass("day-highlighted")
|
|
.css("border-left", "").css("border-right", "").css("background", "");
|
|
|
|
// For each selected day, highlight all cells in that column
|
|
highlightedDays.forEach(day => {
|
|
let color = highlightDayColors[day] || defaultHighlightColor;
|
|
// Highlight th: only change border color, not background
|
|
$(`#main-table th[data-day='${day}']`)
|
|
.addClass("day-highlighted")
|
|
.css("border-left", `3px solid ${color}`)
|
|
.css("border-right", `3px solid ${color}`)
|
|
.css("background", "");
|
|
// Highlight td: only change border color, not background
|
|
$(`#main-table td[data-day='${day}']`)
|
|
.addClass("day-highlighted")
|
|
.css("border-left", `3px solid ${color}`)
|
|
.css("border-right", `3px solid ${color}`)
|
|
.css("background", "");
|
|
});
|
|
// Update modal button states
|
|
$("#highlight-days-buttons .highlight-day-btn").each(function() {
|
|
let day = $(this).data("day");
|
|
if (highlightedDays.has(day)) {
|
|
$(this).addClass("highlighted");
|
|
} else {
|
|
$(this).removeClass("highlighted");
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Highlighting logic for force assigned shifts ---
|
|
function updateForceAssignedHighlights() {
|
|
if ($("#highlight-force-assigned").prop("checked")) {
|
|
// Add highlight to all force assigned cells
|
|
$("#main-table td.force-assigned").addClass("force-assigned-highlight");
|
|
} else {
|
|
// Remove highlight
|
|
$("#main-table td.force-assigned").removeClass("force-assigned-highlight");
|
|
}
|
|
}
|
|
|
|
// --- Update both day and force assigned highlights together ---
|
|
function updateAllHighlights() {
|
|
updateDayHighlights();
|
|
updateForceAssignedHighlights();
|
|
}
|
|
|
|
// --- Update force assigned highlights when checkbox changes ---
|
|
$("#display-settings-modal").on("change", "#highlight-force-assigned", function() {
|
|
updateForceAssignedHighlights();
|
|
});
|
|
|
|
|
|
|
|
|
|
// --- Also call updateAllHighlights on modal open ---
|
|
$("#show-display-settings").on("click", function() {
|
|
$("#display-settings-modal").show();
|
|
updateAllHighlights();
|
|
});
|
|
|
|
// --- Highlight day buttons in modal with color pickers ---
|
|
daysOfWeek.forEach(day => {
|
|
// Button to toggle highlight
|
|
let btn = $(`<button type="button" class="highlight-day-btn" data-day="${day}" style="margin:0 2px;">${day}</button>`);
|
|
// Color picker
|
|
let colorInput = $(`<input type="color" class="highlight-day-color" data-day="${day}" value="${defaultHighlightColor}" style="margin-left:2px; vertical-align:middle;" title="Pick highlight color for ${day}">`);
|
|
highlightDayColors[day] = defaultHighlightColor;
|
|
|
|
btn.on("click", function () {
|
|
if (highlightedDays.has(day)) {
|
|
highlightedDays.delete(day);
|
|
$(this).removeClass("highlighted");
|
|
} else {
|
|
highlightedDays.add(day);
|
|
$(this).addClass("highlighted");
|
|
}
|
|
updateAllHighlights();
|
|
});
|
|
|
|
colorInput.on("input", function () {
|
|
highlightDayColors[day] = $(this).val();
|
|
updateAllHighlights();
|
|
});
|
|
|
|
$("#highlight-days-buttons").append(btn).append(colorInput);
|
|
});
|
|
|
|
|
|
// --- Initial highlight on page load ---
|
|
$(function() {
|
|
updateAllHighlights();
|
|
|
|
});
|
|
|
|
// --- Dark Mode Toggle ---
|
|
$(function() {
|
|
// Add dark mode toggle button to the page
|
|
const darkModeButton = $('<button id="dark-mode-toggle" style="position: fixed; top: 10px; right: 10px; z-index: 1000; padding: 8px 12px; background: #333; color: white; border: none; border-radius: 4px; cursor: pointer;">🌙 Dark Mode</button>');
|
|
$('body').append(darkModeButton);
|
|
|
|
// Check for saved theme preference or default to light mode
|
|
const currentTheme = localStorage.getItem('theme') || 'light';
|
|
if (currentTheme === 'dark') {
|
|
$('html').addClass('dark-mode');
|
|
darkModeButton.text('☀️ Light Mode');
|
|
}
|
|
|
|
// Toggle dark mode
|
|
darkModeButton.on('click', function() {
|
|
$('html').toggleClass('dark-mode');
|
|
const isDark = $('html').hasClass('dark-mode');
|
|
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
|
$(this).text(isDark ? '☀️ Light Mode' : '🌙 Dark Mode');
|
|
});
|
|
}); |