854 lines
30 KiB
JavaScript
854 lines
30 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");
|
|
|
|
|
|
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) => {
|
|
shifts.push($(td).attr("data-shift"));
|
|
})
|
|
|
|
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;
|
|
|
|
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);
|
|
|
|
|
|
worker_td.after(`<div class='worker-summary auto-generated'>
|
|
<span>Total shifts: ${total_shifts}, </span>
|
|
<span>Weekends: ${weekends_worked}, </span>
|
|
<span>Bank holidays: ${bank_holidays_worked}, </span>
|
|
<span class='shift-diff-span'>Summed shift diff: ${summed_shift_diff.toPrecision(2)}, </span>
|
|
|
|
</div>`)
|
|
|
|
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(() => {
|
|
$(table).find(".rota-day").toggleClass("hidden");
|
|
$(table).find(".worker-summary").toggle();
|
|
|
|
});
|
|
|
|
|
|
$(table).before($(`<span>Total summed shift diff: ${total_summed_shift_diffs}</span><br/>`));
|
|
$(table).before($(`<span>Total summed shift diff: ${total_summed_shift_diffs_abs}</span><br/>`));
|
|
$(table).before(summary_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><table class='tsummary'></table></div>")
|
|
$("#show-colour-diff").on("click", (evt) => {
|
|
$("table.tsummary").find(`td.target-assigned`).toggleClass("no-colour");
|
|
});
|
|
|
|
$("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>`);
|
|
|
|
});
|
|
|
|
|
|
|
|
$(".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")
|
|
shift_targets = jtr.data("worker-targets")
|
|
|
|
oshifts.forEach((s) => {
|
|
if (s in shift_targets && shift_targets[s] > 0) {
|
|
if (s in shift_counts) {
|
|
c = shift_counts[s];
|
|
} else {
|
|
c = 0;
|
|
}
|
|
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} (${shift_targets[s].toFixed(2)})</td>`)
|
|
|
|
} else {
|
|
row.append(`<td>-</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)
|
|
})
|
|
|
|
});
|
|
|
|
oshifts.forEach((shift) => {
|
|
button = $(`<button data-shift='${shift}'>${shift}</button>`)
|
|
|
|
button.on("click", (evt) => {
|
|
console.log(evt.target.dataset.shift)
|
|
$("#shift-timetable-div").empty();
|
|
table = $("<table>");
|
|
$("table#main-table th.date").each((n, th) => {
|
|
row = $("<tr>")
|
|
row.append(`<td>${th.dataset.date}</td>`);
|
|
$(`table#main-table td[data-date='${th.dataset.date}'][data-shift='${evt.target.dataset.shift}']`).each((n, td) => {
|
|
|
|
//row.append(`<td>${}</td>`)
|
|
row.append($(td).closest("tr").children("td:first").clone())
|
|
|
|
});
|
|
|
|
table.append(row)
|
|
});
|
|
$("#shift-timetable-div").append(table);
|
|
|
|
})
|
|
|
|
$("#shift-timetable-options").append(button)
|
|
});
|
|
|
|
//$("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")
|
|
})
|
|
}
|
|
if (e.target.dataset.shift != undefined && e.target.dataset.shift.length > 0) {
|
|
$(`td[data-shift=${e.target.dataset.shift}]`).addClass("shift-highlight")
|
|
|
|
|
|
$(`td[data-remote-site='${e.target.dataset.shiftRemoteSite}']`).closest('tr').find(`td[data-shift=${e.target.dataset.shift}]`).addClass("remote-site-match")
|
|
// if (e.target.dataset.shiftRemoteSite == worker_td.dataset.remoteSite) {
|
|
// $(`td[data-shift-remote-site=${e.target.dataset.shiftRemoteSite}]`).addClass("remote-site-match")
|
|
// }
|
|
}
|
|
|
|
}, (e) => {
|
|
|
|
//end hover
|
|
$("td.pair-match").removeClass("pair-match")
|
|
$("td.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('');
|
|
}; |