Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23529ba441 | ||
|
|
fe1414a7bf | ||
|
|
0f9cd52911 | ||
|
|
cf9e9ee854 | ||
|
|
120dd16845 | ||
|
|
62010dd09f | ||
|
|
0cbe968aa5 | ||
|
|
5cf2480602 | ||
|
|
f4ab0be347 | ||
|
|
dadf035627 | ||
|
|
023e345591 | ||
|
|
8458279e19 | ||
|
|
4230e2af6a | ||
|
|
e2c3eecc63 | ||
|
|
105b37feae | ||
|
|
86201ec1d7 | ||
|
|
c59a03fa31 | ||
|
|
3fd0e1ed99 | ||
|
|
b6f5288eed | ||
|
|
a2d22c34fd | ||
|
|
ba154f680a | ||
|
|
32886da33d | ||
|
|
ea38a25fa1 | ||
|
|
1e6d1d5f5b | ||
|
|
60bd8073ee |
@@ -162,6 +162,19 @@
|
||||
if(triggers.includes('leaveUpdated')){
|
||||
try{ console.debug('[DEBUG] htmx:afterRequest -> leaveUpdated'); if(window._calendars){ const cs = Object.values(window._calendars).filter(c=>c.refreshLeaves); console.debug('[DEBUG] afterRequest -> refreshing', cs.length, 'calendars'); cs.forEach(c => c.refreshLeaves && c.refreshLeaves()); } }catch(e){}
|
||||
}
|
||||
if(triggers.includes('runCompleted')){
|
||||
try{
|
||||
// Stop further HTMX polling by removing polling attributes from
|
||||
// the run status element and its container. The server will return
|
||||
// out-of-band (OOB) swaps to update the log/result areas in-place
|
||||
// and to insert a "View final output" button, so a full page
|
||||
// reload is no longer required.
|
||||
const el = document.querySelector('#run-status');
|
||||
if(el){ el.removeAttribute('hx-get'); el.removeAttribute('hx-trigger'); el.removeAttribute('hx-swap'); }
|
||||
const container = document.querySelector('#run-status-container');
|
||||
if(container){ container.removeAttribute('hx-get'); container.removeAttribute('hx-trigger'); }
|
||||
}catch(e){ console.error('runCompleted handler failed', e); }
|
||||
}
|
||||
}catch(e){ console.error('htmx:afterRequest robustness handler', e); }
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -11,14 +11,31 @@
|
||||
|
||||
{# Rota selection is handled by route scoping; no dropdown needed here #}
|
||||
|
||||
<div style="display:flex;gap:0.5rem;margin-bottom:0.5rem;align-items:center;">
|
||||
<div>
|
||||
<button id="view-calendar-btn" class="button is-small is-link">Calendar</button>
|
||||
<button id="view-list-btn" class="button is-small">List</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="calendar" class="calendar" data-mode="inline"></div>
|
||||
<div id="calendar-list-view" style="display:none; margin-top:0.5rem;"></div>
|
||||
<p class="help">View all leave requests across workers. Choose a rota to scope the calendar, or select "All rotas" to see everything. Click and drag to select dates to open the leave form (you'll need to choose workers in the modal).</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:0.5rem;">
|
||||
<strong>Legend:</strong>
|
||||
<span style="display:inline-block;width:12px;height:12px;background:#ffecec;border:1px solid #ddd;margin-left:0.5rem;vertical-align:middle"></span> Existing leave
|
||||
<span style="display:inline-block;width:12px;height:12px;background:#cdeffd;border:1px solid #2b8fd6;margin-left:0.75rem;vertical-align:middle"></span> Selected range
|
||||
<div id="global-legend" class="box" style="margin-top:0.5rem;">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;justify-content:space-between;">
|
||||
<div><strong>Legend:</strong></div>
|
||||
<div>
|
||||
{% if selected_rota_id %}
|
||||
<a class="button is-small is-light" href="{% url 'rota:rota_detail' selected_rota_id %}">Back to rota</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div id="global-legend-content" style="margin-top:0.5rem;">
|
||||
<span style="display:inline-block;width:12px;height:12px;background:#ffecec;border:1px solid #ddd;margin-left:0.5rem;vertical-align:middle"></span> Existing leave
|
||||
<span style="display:inline-block;width:12px;height:12px;background:#cdeffd;border:1px solid #2b8fd6;margin-left:0.75rem;vertical-align:middle"></span> Selected range
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,28 +63,139 @@
|
||||
const sel = document.getElementById('selected_rota_json');
|
||||
if(sel) initialRota = JSON.parse(sel.textContent || sel.innerText || 'null');
|
||||
}catch(e){ initialRota = null; }
|
||||
// expose initial rota id so other scripts (list view) can access it
|
||||
window._initialRota = initialRota;
|
||||
window.initCalendar('calendar', { leaves: leaves, leavesUrl: leavesUrlBase, rotaId: initialRota, requestUrl: '{% url "rota:request_leave" %}', mode: container.dataset.mode || 'inline' });
|
||||
function refreshLegendFor(id){
|
||||
const legend = document.getElementById('rota-worker-legend');
|
||||
if(!legend) return;
|
||||
if(!id){ legend.innerText = 'Showing leave for all rotas.'; return; }
|
||||
legend.innerText = 'Loading legend…';
|
||||
const globalLegendContent = document.getElementById('global-legend-content');
|
||||
if(!globalLegendContent) return;
|
||||
if(!id){
|
||||
globalLegendContent.innerHTML = '<span style="display:inline-block;width:12px;height:12px;background:#ffecec;border:1px solid #ddd;margin-left:0.5rem;vertical-align:middle"></span> Existing leave'
|
||||
+ '<span style="display:inline-block;width:12px;height:12px;background:#cdeffd;border:1px solid #2b8fd6;margin-left:0.75rem;vertical-align:middle"></span> Selected range';
|
||||
return;
|
||||
}
|
||||
globalLegendContent.innerText = 'Loading legend…';
|
||||
fetch(leavesUrlBase + '?rota_id=' + encodeURIComponent(id), {credentials:'same-origin'}).then(r=>r.json()).then(data=>{
|
||||
const byWorker = {};
|
||||
(data.leaves||[]).forEach(l=>{ if(l.worker_id){ byWorker[l.worker_id] = {name:l.reason, color:l.color}; } });
|
||||
if(Object.keys(byWorker).length===0){ legend.innerText = 'No workers with leaves in this rota.'; return; }
|
||||
legend.innerHTML = Object.entries(byWorker).map(([id,info])=> `<div style="display:inline-flex;align-items:center;gap:0.5rem;margin-right:0.5rem"><span style="width:14px;height:14px;background:${info.color};border:1px solid rgba(0,0,0,0.1);display:inline-block"></span><span>${info.name}</span></div>`).join('');
|
||||
}).catch(e=>{ legend.innerText = 'Unable to load legend'; console.debug(e); });
|
||||
if(Object.keys(byWorker).length===0){
|
||||
globalLegendContent.innerText = 'No workers with leaves in this rota.';
|
||||
return;
|
||||
}
|
||||
const workerHtml = Object.entries(byWorker).map(([id,info])=> `<div style="display:inline-flex;align-items:center;gap:0.5rem;margin-right:0.5rem"><span style="width:14px;height:14px;background:${info.color};border:1px solid rgba(0,0,0,0.1);display:inline-block"></span><span>${info.name}</span></div>`).join('');
|
||||
globalLegendContent.innerHTML = workerHtml + '<span style="display:inline-block;width:12px;height:12px;background:#cdeffd;border:1px solid #2b8fd6;margin-left:0.75rem;vertical-align:middle"></span> Selected range';
|
||||
}).catch(e=>{ globalLegendContent.innerText = 'Unable to load legend'; console.debug(e); });
|
||||
}
|
||||
// Refresh legend for the initially selected rota (or show all rotas)
|
||||
refreshLegendFor(initialRota);
|
||||
// Ensure the legend element exists (script is before the legend markup)
|
||||
if(document.readyState === 'loading'){
|
||||
document.addEventListener('DOMContentLoaded', function(){ refreshLegendFor(initialRota); });
|
||||
} else {
|
||||
refreshLegendFor(initialRota);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<div class="box" style="margin-top:0.75rem;">
|
||||
<h4 class="subtitle is-6">Workers on this rota</h4>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;">
|
||||
<div id="rota-worker-legend">Select a rota to show its workers.</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
const calendarEl = document.getElementById('calendar');
|
||||
const listEl = document.getElementById('calendar-list-view');
|
||||
const btnCal = document.getElementById('view-calendar-btn');
|
||||
const btnList = document.getElementById('view-list-btn');
|
||||
let isList = false;
|
||||
|
||||
function showCalendar(){
|
||||
isList = false;
|
||||
if(listEl) listEl.style.display = 'none';
|
||||
if(calendarEl) calendarEl.style.display = '';
|
||||
if(btnCal) btnCal.classList.add('is-link');
|
||||
if(btnList) btnList.classList.remove('is-link');
|
||||
}
|
||||
|
||||
async function renderList(){
|
||||
if(!listEl) return;
|
||||
listEl.innerHTML = '<p>Loading…</p>';
|
||||
try{
|
||||
let url = '{% url "rota:leaves_global_json" %}';
|
||||
const rotaForList = (typeof window !== 'undefined' && window._initialRota) ? window._initialRota : null;
|
||||
if(rotaForList) url += (url.includes('?') ? '&' : '?') + 'rota_id=' + encodeURIComponent(rotaForList);
|
||||
const res = await fetch(url, {credentials:'same-origin'});
|
||||
if(!res.ok) throw new Error('Failed to fetch leaves');
|
||||
const data = await res.json();
|
||||
const leavesArr = (data.leaves||[]).map(l => ({ start: l.start, end: l.end, reason: l.reason, worker_id: l.worker_id, color: l.color }));
|
||||
|
||||
// find min/max date across all leaves
|
||||
if(leavesArr.length===0){ listEl.innerHTML = '<p>No leaves found.</p>'; return; }
|
||||
function toDate(s){ const p = s.split('-'); return new Date(parseInt(p[0]), parseInt(p[1],10)-1, parseInt(p[2],10)); }
|
||||
function isoDate(d){ return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0'); }
|
||||
let minD = null, maxD = null;
|
||||
leavesArr.forEach(l=>{
|
||||
try{
|
||||
const s = toDate(l.start);
|
||||
const e = toDate(l.end || l.start);
|
||||
if(!minD || s < minD) minD = s;
|
||||
if(!maxD || e > maxD) maxD = e;
|
||||
}catch(e){}
|
||||
});
|
||||
if(!minD || !maxD){ listEl.innerHTML = '<p>No valid leave dates found.</p>'; return; }
|
||||
|
||||
// build a map of dates -> leaves that cover that date
|
||||
const dateMap = {};
|
||||
for(let d = new Date(minD); d <= maxD; d.setDate(d.getDate()+1)){
|
||||
dateMap[isoDate(new Date(d))] = [];
|
||||
}
|
||||
leavesArr.forEach(l=>{
|
||||
const s = toDate(l.start);
|
||||
const e = toDate(l.end || l.start);
|
||||
for(let d = new Date(s); d <= e; d.setDate(d.getDate()+1)){
|
||||
const key = isoDate(new Date(d));
|
||||
if(key in dateMap) dateMap[key].push(l);
|
||||
else dateMap[key] = [l];
|
||||
}
|
||||
});
|
||||
|
||||
// Render dates in ascending order
|
||||
const parts = [];
|
||||
const keys = Object.keys(dateMap).sort();
|
||||
for(const dateKey of keys){
|
||||
const items = dateMap[dateKey];
|
||||
const header = `<div style="margin-top:0.5rem; font-weight:bold">${dateKey}</div>`;
|
||||
if(!items || items.length===0){
|
||||
parts.push(header + `<div style="margin-left:1.5rem;margin-top:0.25rem;color:#666">No leaves</div>`);
|
||||
continue;
|
||||
}
|
||||
const rows = items.map(l=>{
|
||||
const name = l.reason || 'Unknown';
|
||||
const color = l.color || '#ddd';
|
||||
return `<div style="margin-left:1.5rem;margin-top:0.25rem;display:flex;align-items:center;gap:0.5rem"><span style="width:12px;height:12px;background:${color};display:inline-block;border:1px solid rgba(0,0,0,0.1)"></span><span>${name}</span></div>`;
|
||||
}).join('');
|
||||
parts.push(header + rows);
|
||||
}
|
||||
listEl.innerHTML = parts.join('');
|
||||
}catch(err){ console.error('renderList failed', err); listEl.innerHTML = '<p>Unable to load list view.</p>'; }
|
||||
}
|
||||
|
||||
function showList(){
|
||||
isList = true;
|
||||
if(calendarEl) calendarEl.style.display = 'none';
|
||||
if(listEl) listEl.style.display = '';
|
||||
if(btnList) btnList.classList.add('is-link');
|
||||
if(btnCal) btnCal.classList.remove('is-link');
|
||||
renderList();
|
||||
}
|
||||
|
||||
if(btnCal) btnCal.addEventListener('click', showCalendar);
|
||||
if(btnList) btnList.addEventListener('click', showList);
|
||||
// default view
|
||||
showCalendar();
|
||||
|
||||
// Refresh views when leaves change
|
||||
window.addEventListener('leaveUpdated', function(){
|
||||
if(isList) renderList();
|
||||
else try{ if(window._calendars && window._calendars['calendar'] && window._calendars['calendar'].refreshLeaves) window._calendars['calendar'].refreshLeaves(); }catch(e){}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{# Consolidated legend above replaces the separate 'Workers on this rota' box #}
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{% load crispy_forms_tags %}
|
||||
<div class="modal is-active" id="leave-modal">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Add leave for {{ worker.name }}</p>
|
||||
<button class="delete" aria-label="close" onclick="document.getElementById('modal').innerHTML='' "></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<form method="post" hx-post="{{ form_action }}" hx-target="#modal" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<div class="field" style="margin-top:0.75rem;">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
<button type="button" class="button" onclick="document.getElementById('modal').innerHTML=''">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -8,3 +8,34 @@
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// Mark required fields with a visual asterisk and set ARIA attributes
|
||||
(function(){
|
||||
try{
|
||||
const form = document.currentScript.previousElementSibling;
|
||||
if(!form) return;
|
||||
const requiredEls = form.querySelectorAll('input[required], select[required], textarea[required]');
|
||||
requiredEls.forEach(el=>{
|
||||
el.setAttribute('aria-required','true');
|
||||
// try to find a label: either <label for="id_x"> or a sibling .label
|
||||
const id = el.id;
|
||||
let lbl = id ? form.querySelector('label[for="'+id+'"]') : null;
|
||||
if(!lbl){
|
||||
// look for nearest label element in the same .field
|
||||
const field = el.closest('.field');
|
||||
if(field) lbl = field.querySelector('label');
|
||||
}
|
||||
if(lbl){
|
||||
const already = lbl.querySelector('.required-indicator') || lbl.querySelector('.has-text-danger') || /\*\s*$/.test(lbl.textContent);
|
||||
if(!already){
|
||||
const span = document.createElement('span');
|
||||
span.className = 'required-indicator has-text-danger';
|
||||
span.textContent = ' *';
|
||||
lbl.appendChild(span);
|
||||
}
|
||||
}
|
||||
});
|
||||
}catch(e){ /* ignore */ }
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -22,12 +22,38 @@ function _close_shift_modal_targets(){
|
||||
<div class="box" id="constraints-editor">
|
||||
<h4 class="title is-6">Shift constraints</h4>
|
||||
<p class="help">Add constraint entries. The form stores a JSON list; this editor helps build that list.</p>
|
||||
<div id="constraint-errors" class="notification is-danger" style="display:none; margin-bottom:0.5em;"></div>
|
||||
<div id="constraint-list"></div>
|
||||
<div style="margin-top:0.5em;">
|
||||
<button type="button" class="button is-small" id="add-constraint">Add constraint</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if focus %}
|
||||
<script>
|
||||
(function(){
|
||||
try{
|
||||
const focusName = '{{ focus }}';
|
||||
const listEl = document.getElementById('constraint-list');
|
||||
const addBtn = document.getElementById('add-constraint');
|
||||
if(listEl){
|
||||
// Ensure at least one row exists
|
||||
if(listEl.children.length === 0){ if(addBtn) addBtn.click(); }
|
||||
setTimeout(()=>{
|
||||
// Try to find a select matching the constraint name
|
||||
const selects = listEl.querySelectorAll('select');
|
||||
let matched = null;
|
||||
selects.forEach(s=>{ if(s.value === focusName) matched = s; });
|
||||
if(matched){ try{ matched.scrollIntoView({behavior:'smooth', block:'center'}); matched.focus(); matched.dispatchEvent(new Event('change',{bubbles:true})); return; }catch(e){} }
|
||||
// otherwise, focus first row and set its type to the requested focus
|
||||
const firstSel = selects[0];
|
||||
if(firstSel){ try{ firstSel.value = focusName; firstSel.dispatchEvent(new Event('change',{bubbles:true})); firstSel.focus(); firstSel.scrollIntoView({behavior:'smooth', block:'center'}); }catch(e){} }
|
||||
}, 120);
|
||||
}
|
||||
}catch(e){}
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
<script>
|
||||
(function(){
|
||||
// Constraint type schemas: name -> fields metadata
|
||||
@@ -45,7 +71,7 @@ function _close_shift_modal_targets(){
|
||||
{name: "weeks", label: "Weeks (comma-separated integers)", type: "list_int"},
|
||||
{name: "start_date", label: "Start date", type: "date"},
|
||||
{name: "end_date", label: "End date", type: "date"},
|
||||
{name: "days", label: "Days (integer)", type: "int"},
|
||||
{name: "days", label: "Duration (days)", type: "int", required: true},
|
||||
{name: "ignore_shifts", label: "Ignore shifts (comma-separated)", type: "list"},
|
||||
{name: "exclude_days", label: "Exclude days (comma-separated)", type: "list"},
|
||||
{name: "allow_self", label: "Allow self", type: "bool"}
|
||||
@@ -55,23 +81,46 @@ function _close_shift_modal_targets(){
|
||||
{name: "weeks", label: "Weeks (comma-separated integers)", type: "list_int"},
|
||||
{name: "start_date", label: "Start date", type: "date"},
|
||||
{name: "end_date", label: "End date", type: "date"},
|
||||
{name: "days", label: "Days (integer)", type: "int"},
|
||||
{name: "days", label: "Duration (days)", type: "int", required: true},
|
||||
{name: "ignore_shifts", label: "Ignore shifts (comma-separated)", type: "list"},
|
||||
{name: "exclude_days", label: "Exclude days (comma-separated)", type: "list"},
|
||||
{name: "allow_self", label: "Allow self", type: "bool"}
|
||||
]},
|
||||
"min_summed_grade_by_shifts_per_day": {label: "Min summed grade by shifts per day", fields: [ {name: "shifts", label: "Shifts (comma-separated)", type: "list"}, {name: "min_grade_sum", label: "Minimum grade sum", type: "int"}, {name: "days", label: "Days (comma-separated)", type: "list"} ]},
|
||||
"limit_grade_number": {label: "Limit grade number", fields: [ {name: "grade", label: "Grade", type: "int"}, {name: "max_number", label: "Max number", type: "int"} ]},
|
||||
"minimum_grade_number": {label: "Minimum grade number", fields: [ {name: "grade", label: "Grade", type: "int"}, {name: "min_number", label: "Min number", type: "int"} ]},
|
||||
"require_remote_site_presence": {label: "Require remote site presence", fields: [ {name: "site", label: "Site", type: "text"}, {name: "required_number", label: "Required number", type: "int"} ]},
|
||||
"max_shifts_per_week": {label: "Max shifts per week", fields: [ {name: "max_shifts", label: "Max shifts", type: "int"} ]},
|
||||
"max_shifts_per_week_block": {label: "Max shifts per week block", fields: [ {name: "week_block", label: "Week block", type: "int"}, {name: "max_shifts", label: "Max shifts", type: "int"} ]}
|
||||
"min_summed_grade_by_shifts_per_day": {label: "Min summed grade by shifts per day", fields: [ {name: "shifts", label: "Shifts (comma-separated)", type: "list", required: true}, {name: "min_grade_sum", label: "Minimum grade sum", type: "int", required: true}, {name: "days", label: "Days (comma-separated)", type: "list", required: true} ]},
|
||||
"limit_grade_number": {label: "Limit grade number", fields: [ {name: "grade", label: "Grade", type: "int", required: true}, {name: "max_number", label: "Max number", type: "int", required: true} ]},
|
||||
"minimum_grade_number": {label: "Minimum grade number", fields: [ {name: "grade", label: "Grade", type: "int", required: true}, {name: "min_number", label: "Min number", type: "int", required: true} ]},
|
||||
"require_remote_site_presence": {label: "Require remote site presence", fields: [ {name: "site", label: "Site", type: "text", required: true}, {name: "required_number", label: "Required number", type: "int", required: true} ]},
|
||||
"max_shifts_per_week": {label: "Max shifts per week", fields: [ {name: "max_shifts", label: "Max shifts", type: "int", required: true} ]},
|
||||
"max_shifts_per_week_block": {label: "Max shifts per week block", fields: [ {name: "week_block", label: "Week block", type: "int", required: true}, {name: "max_shifts", label: "Max shifts", type: "int", required: true} ]}
|
||||
};
|
||||
|
||||
const textarea = document.getElementById('id_constraints');
|
||||
const listEl = document.getElementById('constraint-list');
|
||||
const errorsEl = document.getElementById('constraint-errors');
|
||||
const addBtn = document.getElementById('add-constraint');
|
||||
|
||||
function validateConstraintsArray(data){
|
||||
const msgs = [];
|
||||
if(!Array.isArray(data)) return ["Constraints must be a JSON list"];
|
||||
data.forEach((c, idx)=>{
|
||||
if(!c || typeof c !== 'object'){
|
||||
msgs.push(`Constraint #${idx+1} must be an object`);
|
||||
return;
|
||||
}
|
||||
const name = c.name || c.type || null;
|
||||
if(!name){ msgs.push(`Constraint #${idx+1} missing 'name'`); return; }
|
||||
if(name==='pre' || name==='post'){
|
||||
const opts = c.options || {};
|
||||
// days (duration) is required
|
||||
const daysVal = (c.days!==undefined && c.days!==null) ? c.days : (opts.days!==undefined && opts.days!==null ? opts.days : null);
|
||||
if(daysVal===null){
|
||||
msgs.push(`Constraint #${idx+1} (pre/post) requires 'Duration (days)' to be set`);
|
||||
}
|
||||
}
|
||||
});
|
||||
return msgs;
|
||||
}
|
||||
|
||||
function parseInitial(){
|
||||
let val = textarea.value || '';
|
||||
if(!val.trim()) return [];
|
||||
@@ -106,6 +155,12 @@ function _close_shift_modal_targets(){
|
||||
}catch(e){ /* ignore */ }
|
||||
}
|
||||
syncTextarea();
|
||||
// validate and show errors
|
||||
try{
|
||||
const v = validateConstraintsArray(data);
|
||||
if(v && v.length){ errorsEl.style.display='block'; errorsEl.innerHTML = v.map(m=>`<div>${m}</div>`).join(''); }
|
||||
else { errorsEl.style.display='none'; errorsEl.innerHTML=''; }
|
||||
}catch(e){ /* ignore */ }
|
||||
}
|
||||
|
||||
function renderConstraintRow(c, idx){
|
||||
@@ -148,6 +203,13 @@ function _close_shift_modal_targets(){
|
||||
SCHEMAS[t].fields.forEach(f=>{
|
||||
const fieldWrap = document.createElement('div'); fieldWrap.className='field';
|
||||
const label = document.createElement('label'); label.className='label'; label.textContent = f.label;
|
||||
// append required indicator if schema marks this field required
|
||||
if(f.required){
|
||||
if(!label.querySelector('.required-indicator')){
|
||||
const span = document.createElement('span'); span.className='required-indicator has-text-danger'; span.textContent=' *';
|
||||
label.appendChild(span);
|
||||
}
|
||||
}
|
||||
fieldWrap.appendChild(label);
|
||||
const control = document.createElement('div'); control.className='control';
|
||||
let input;
|
||||
@@ -168,6 +230,7 @@ function _close_shift_modal_targets(){
|
||||
cb.checked = !!curb;
|
||||
cb.setAttribute('data-field-name', f.name);
|
||||
cb.onchange = syncToData;
|
||||
if(f.required){ cb.required = true; cb.setAttribute('aria-required','true'); }
|
||||
control.appendChild(cb);
|
||||
fieldWrap.appendChild(control);
|
||||
fieldsContainer.appendChild(fieldWrap);
|
||||
@@ -181,6 +244,7 @@ function _close_shift_modal_targets(){
|
||||
input = document.createElement('input'); input.className='input'; input.type='text'; input.oninput = syncToData;
|
||||
}
|
||||
input.setAttribute('data-field-name', f.name);
|
||||
if(f.required){ input.required = true; input.setAttribute('aria-required','true'); }
|
||||
control.appendChild(input);
|
||||
fieldWrap.appendChild(control);
|
||||
fieldsContainer.appendChild(fieldWrap);
|
||||
@@ -248,6 +312,34 @@ function _close_shift_modal_targets(){
|
||||
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Mark required fields in the modal shift form with a visible asterisk and add ARIA attributes
|
||||
(function(){
|
||||
try{
|
||||
const modalForm = document.querySelector('#shift-modal form');
|
||||
if(!modalForm) return;
|
||||
const requiredEls = modalForm.querySelectorAll('input[required], select[required], textarea[required]');
|
||||
requiredEls.forEach(el=>{
|
||||
el.setAttribute('aria-required','true');
|
||||
const id = el.id;
|
||||
let lbl = id ? modalForm.querySelector('label[for="'+id+'"]') : null;
|
||||
if(!lbl){
|
||||
const field = el.closest('.field');
|
||||
if(field) lbl = field.querySelector('label');
|
||||
}
|
||||
if(lbl){
|
||||
const already = lbl.querySelector('.required-indicator') || lbl.querySelector('.has-text-danger') || /\*\s*$/.test(lbl.textContent);
|
||||
if(!already){
|
||||
const span = document.createElement('span');
|
||||
span.className = 'required-indicator has-text-danger';
|
||||
span.textContent = ' *';
|
||||
lbl.appendChild(span);
|
||||
}
|
||||
}
|
||||
});
|
||||
}catch(e){ /* ignore */ }
|
||||
})();
|
||||
</script>
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
|
||||
@@ -5,6 +5,16 @@
|
||||
{% for s in rota.shifts %}
|
||||
<li>
|
||||
<strong>{{ s.name }}</strong> — sites: {{ s.sites|join:", " }} — days: {{ s.days|join:", " }} — workers: {{ s.workers_required }}
|
||||
{% if s.constraints %}
|
||||
<div style="margin-top:0.25rem;">
|
||||
<span class="tags">
|
||||
{% for c in s.constraints %}
|
||||
{# Build a simple tooltip showing key: value pairs for the constraint on hover; make clickable to open constraint editor #}
|
||||
<a href="javascript:void(0)" class="tag is-light" title="{% for k,v in c.items %}{{ k }}: {{ v }}{% if not forloop.last %}; {% endif %}{% endfor %}" hx-get="{% url 'rota:shift_edit' rota.id forloop.parentloop.counter0 %}?focus={{ c.name }}" hx-target="#modal" hx-swap="innerHTML">{{ c.name }}</a>
|
||||
{% endfor %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if s.balance_offset is defined and s.balance_offset %}
|
||||
— balance offset: {{ s.balance_offset }}
|
||||
{% endif %}
|
||||
|
||||
@@ -20,6 +20,19 @@
|
||||
{% include 'rota/partials/worker_token_area.html' with worker=worker token_link=token_link %}
|
||||
{% endif %}
|
||||
|
||||
{% if worker %}
|
||||
<div class="box" id="worker-leaves">
|
||||
<h4 class="title is-6">Leaves</h4>
|
||||
<div>
|
||||
{% include 'rota/partials/leave_list.html' with leaves=worker.leaves.all token=None %}
|
||||
</div>
|
||||
<div style="margin-top:0.5rem; display:flex; gap:0.5rem;">
|
||||
<a class="button is-small" href="{% url 'rota:worker_detail' worker.id %}">Open full page</a>
|
||||
<button class="button is-small" hx-get="{% url 'rota:worker_leave_add' worker.id %}" hx-target="#modal" hx-swap="innerHTML">Add leave</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Structured Non-working days editor (keeps #id_nwds in sync) -->
|
||||
<div class="box" id="nwds-editor">
|
||||
<h4 class="title is-6">Non-working days</h4>
|
||||
@@ -30,6 +43,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if focus %}
|
||||
<script>
|
||||
(function(){
|
||||
try{
|
||||
const f = '{{ focus }}';
|
||||
const listId = f + '-list';
|
||||
const addBtnId = 'add-' + f;
|
||||
const listEl = document.getElementById(listId);
|
||||
if(listEl){
|
||||
// if no rows exist, trigger the Add button to create one
|
||||
if(listEl.children.length === 0){
|
||||
const btn = document.getElementById(addBtnId);
|
||||
if(btn) btn.click();
|
||||
}
|
||||
// after render, scroll into view and focus the first input
|
||||
setTimeout(()=>{
|
||||
const el = document.getElementById(listId);
|
||||
if(el){
|
||||
try{ el.scrollIntoView({behavior:'smooth', block:'center'}); }catch(e){}
|
||||
const firstRow = el.querySelector('.box') || el.firstElementChild;
|
||||
if(firstRow){
|
||||
const input = firstRow.querySelector('input, select, textarea, [data-field]');
|
||||
if(input){ try{ input.focus(); }catch(e){} }
|
||||
}
|
||||
}
|
||||
}, 120);
|
||||
}
|
||||
}catch(e){}
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
<script>
|
||||
(function(){
|
||||
const textarea = document.getElementById('id_nwds');
|
||||
@@ -198,64 +242,84 @@
|
||||
<p class="help">Quick editors for common lists so you don't need to edit JSON manually.</p>
|
||||
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-half">
|
||||
<h5 class="subtitle is-6">Out of programme</h5>
|
||||
<div id="oop-list"></div>
|
||||
<button type="button" class="button is-small" id="add-oop">Add OOP</button>
|
||||
<div class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Out of programme</h5>
|
||||
<button type="button" class="button is-small" id="add-oop">Add</button>
|
||||
</div>
|
||||
<div id="oop-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-half">
|
||||
<h5 class="subtitle is-6">Not available to work</h5>
|
||||
<div id="nav-list"></div>
|
||||
<button type="button" class="button is-small" id="add-nav">Add unavailability</button>
|
||||
<div class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Not available to work</h5>
|
||||
<button type="button" class="button is-small" id="add-nav">Add</button>
|
||||
</div>
|
||||
<div id="nav-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-half">
|
||||
<h5 class="subtitle is-6">Preferences not to work</h5>
|
||||
<div id="pref-list"></div>
|
||||
<button type="button" class="button is-small" id="add-pref">Add preference</button>
|
||||
<div class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Preferences not to work</h5>
|
||||
<button type="button" class="button is-small" id="add-pref">Add</button>
|
||||
</div>
|
||||
<div id="pref-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-half">
|
||||
<h5 class="subtitle is-6">Work requests</h5>
|
||||
<div id="wr-list"></div>
|
||||
<button type="button" class="button is-small" id="add-wr">Add request</button>
|
||||
<div class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Work requests</h5>
|
||||
<button type="button" class="button is-small" id="add-wr">Add</button>
|
||||
</div>
|
||||
<div id="wr-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-half">
|
||||
<h5 class="subtitle is-6">Locum availability</h5>
|
||||
<div id="loc-list"></div>
|
||||
<button type="button" class="button is-small" id="add-loc">Add locum availability</button>
|
||||
<div class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Locum availability</h5>
|
||||
<button type="button" class="button is-small" id="add-loc">Add</button>
|
||||
</div>
|
||||
<div id="loc-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-half">
|
||||
<h5 class="subtitle is-6">Forced assignments (week,day,shift)</h5>
|
||||
<div id="fa-list"></div>
|
||||
<button type="button" class="button is-small" id="add-fa">Add forced assignment</button>
|
||||
<div class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Forced assignments (week,day,shift)</h5>
|
||||
<button type="button" class="button is-small" id="add-fa">Add</button>
|
||||
</div>
|
||||
<div id="fa-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-half">
|
||||
<h5 class="subtitle is-6">Forced assignments by date</h5>
|
||||
<div id="fabd-list"></div>
|
||||
<button type="button" class="button is-small" id="add-fabd">Add forced assignment by date</button>
|
||||
<div class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Forced assignments by date</h5>
|
||||
<button type="button" class="button is-small" id="add-fabd">Add</button>
|
||||
</div>
|
||||
<div id="fabd-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-half">
|
||||
<h5 class="subtitle is-6">Hard day dependencies</h5>
|
||||
<div id="hdd-list"></div>
|
||||
<button type="button" class="button is-small" id="add-hdd">Add dependency</button>
|
||||
<div class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Hard day dependencies</h5>
|
||||
<button type="button" class="button is-small" id="add-hdd">Add</button>
|
||||
</div>
|
||||
<div id="hdd-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-half">
|
||||
<h5 class="subtitle is-6">Hard day exclusions</h5>
|
||||
<div id="hde-list"></div>
|
||||
<button type="button" class="button is-small" id="add-hde">Add exclusion</button>
|
||||
<div class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Hard day exclusions</h5>
|
||||
<button type="button" class="button is-small" id="add-hde">Add</button>
|
||||
</div>
|
||||
<div id="hde-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-half">
|
||||
<h5 class="subtitle is-6">Allowed multi-shift sets</h5>
|
||||
<div id="ams-list"></div>
|
||||
<button type="button" class="button is-small" id="add-ams">Add set</button>
|
||||
<div class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Allowed multi-shift sets</h5>
|
||||
<button type="button" class="button is-small" id="add-ams">Add</button>
|
||||
</div>
|
||||
<div id="ams-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,6 +332,7 @@
|
||||
if(textarea) textarea.style.display='none';
|
||||
const listEl = document.getElementById(cfg.container);
|
||||
const addBtn = document.getElementById(cfg.addButton);
|
||||
const WEEKDAYS = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
|
||||
|
||||
function parse(){
|
||||
let v = textarea ? textarea.value : ''; if(!v) return [];
|
||||
@@ -284,16 +349,70 @@
|
||||
const field = document.createElement('div'); field.className='field';
|
||||
const label = document.createElement('label'); label.className='label'; label.textContent=f.label; field.appendChild(label);
|
||||
const control = document.createElement('div'); control.className='control';
|
||||
let input;
|
||||
if(f.type==='date'){
|
||||
input = document.createElement('input'); input.type='date'; input.className='datepicker input'; input.setAttribute('autocomplete','off'); input.value = item[f.name] || '';
|
||||
} else if(f.type==='int'){
|
||||
input = document.createElement('input'); input.type='number'; input.className='input'; input.value = item[f.name] || '';
|
||||
} else {
|
||||
input = document.createElement('input'); input.type='text'; input.className='input'; input.value = item[f.name] || '';
|
||||
|
||||
// Special-case: render weekday checkboxes when the field name is 'days'
|
||||
if(f.name === 'days'){
|
||||
const box = document.createElement('div'); box.className='buttons'; box.setAttribute('data-field', f.name);
|
||||
WEEKDAYS.forEach(d => {
|
||||
const btn = document.createElement('label'); btn.className='button is-small is-light'; btn.style.marginRight='0.25rem';
|
||||
const chk = document.createElement('input'); chk.type='checkbox'; chk.value = d; chk.style.marginRight='0.35rem';
|
||||
// mark checked if item[f.name] contains this day (accept set/list/string)
|
||||
try{
|
||||
const cur = item[f.name];
|
||||
if(cur){
|
||||
if(Array.isArray(cur)){
|
||||
if(cur.indexOf(d)!==-1) chk.checked = true;
|
||||
} else if(typeof cur === 'string'){
|
||||
const parts = cur.split(',').map(s=>s.trim()); if(parts.indexOf(d)!==-1) chk.checked = true;
|
||||
} else if(typeof cur === 'object'){
|
||||
// set-like
|
||||
if(cur.hasOwnProperty(d) || (Array.from(Object.values(cur)).indexOf(d)!==-1)) chk.checked = true;
|
||||
}
|
||||
}
|
||||
}catch(e){}
|
||||
chk.onchange = ()=>{ sync(idx); };
|
||||
btn.appendChild(chk);
|
||||
const span = document.createElement('span'); span.textContent = d; btn.appendChild(span);
|
||||
box.appendChild(btn);
|
||||
});
|
||||
control.appendChild(box);
|
||||
field.appendChild(control); col.appendChild(field); row.appendChild(col);
|
||||
} else {
|
||||
let input;
|
||||
if(f.type==='date'){
|
||||
input = document.createElement('input'); input.type='date'; input.className='datepicker input'; input.setAttribute('autocomplete','off'); input.value = item[f.name] || '';
|
||||
} else if(f.type==='int'){
|
||||
input = document.createElement('input'); input.type='number'; input.className='input'; input.value = item[f.name] || '';
|
||||
} else if(f.type==='shift'){
|
||||
// render a select populated with shift options passed via cfg
|
||||
input = document.createElement('select'); input.className='select';
|
||||
// wrap select in a div.select to match Bulma styling
|
||||
const selWrap = document.createElement('div'); selWrap.className = 'select';
|
||||
const sel = document.createElement('select');
|
||||
const emptyOpt = document.createElement('option'); emptyOpt.value = ''; emptyOpt.text = '(any)'; sel.appendChild(emptyOpt);
|
||||
const opts = (cfg && cfg.shiftOptions) ? cfg.shiftOptions : (typeof _shiftOptions !== 'undefined' ? _shiftOptions : []);
|
||||
if(Array.isArray(opts)){
|
||||
opts.forEach(o=>{ const op = document.createElement('option'); op.value = o; op.text = o; if(item[f.name] === o) op.selected = true; sel.appendChild(op); });
|
||||
}
|
||||
sel.value = item[f.name] || '';
|
||||
sel.onchange = ()=>{ sync(idx); };
|
||||
sel.setAttribute('data-field', f.name);
|
||||
selWrap.appendChild(sel);
|
||||
// set 'input' variable to the wrapper so downstream code appends it
|
||||
input = selWrap;
|
||||
} else {
|
||||
input = document.createElement('input'); input.type='text'; input.className='input'; input.value = item[f.name] || '';
|
||||
}
|
||||
// tag this control with the field name so sync() can find it reliably
|
||||
if(input.tagName === 'DIV' && input.querySelector('select')){
|
||||
// wrapper created for select; the inner select already has data-field
|
||||
// nothing to do here
|
||||
} else {
|
||||
input.setAttribute('data-field', f.name);
|
||||
input.onchange = ()=>{ sync(idx); };
|
||||
}
|
||||
control.appendChild(input); field.appendChild(control); col.appendChild(field); row.appendChild(col);
|
||||
}
|
||||
input.onchange = ()=>{ sync(idx); };
|
||||
control.appendChild(input); field.appendChild(control); col.appendChild(field); row.appendChild(col);
|
||||
});
|
||||
// remove
|
||||
const colRm = document.createElement('div'); colRm.className='column is-narrow'; const fr = document.createElement('div'); fr.className='field'; const ctr = document.createElement('div'); ctr.className='control'; const rm = document.createElement('button'); rm.type='button'; rm.className='button is-danger is-light is-small'; rm.textContent='Remove'; rm.onclick = ()=>{ removeAt(idx); }; ctr.appendChild(rm); fr.appendChild(ctr); colRm.appendChild(fr); row.appendChild(colRm);
|
||||
@@ -302,7 +421,38 @@
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function sync(i){ const data = parse(); const el = listEl.children[i]; if(!el) return; const inputs = el.querySelectorAll('input'); const obj = {}; cfg.fields.forEach((f,j)=>{ const val = inputs[j].value; if(val!=='') obj[f.name]= f.type==='int'?parseInt(val,10):val; }); data[i]=obj; if(textarea) textarea.value = JSON.stringify(data); }
|
||||
function sync(i){
|
||||
const data = parse();
|
||||
const el = listEl.children[i]; if(!el) return;
|
||||
const obj = {};
|
||||
cfg.fields.forEach((f)=>{
|
||||
const fld = el.querySelector('[data-field="'+f.name+'"]');
|
||||
if(!fld){
|
||||
// fallback: try to find an input by label text
|
||||
const inp = el.querySelector('input');
|
||||
if(inp && inp.value!=='') obj[f.name] = f.type==='int'?parseInt(inp.value,10):inp.value;
|
||||
return;
|
||||
}
|
||||
// if this is a checkbox group (for days), fld will be a container
|
||||
if(f.name === 'days'){
|
||||
const checks = fld.querySelectorAll('input[type=checkbox]');
|
||||
const vals = Array.from(checks).filter(c=>c.checked).map(c=>c.value);
|
||||
if(vals.length) obj[f.name] = vals;
|
||||
} else {
|
||||
// single input control
|
||||
if(fld.tagName === 'INPUT' || fld.tagName === 'SELECT'){
|
||||
const v = fld.value;
|
||||
if(v !== '') obj[f.name] = f.type==='int'?parseInt(v,10):v;
|
||||
} else {
|
||||
// container wrapping an input (e.g. div.select wrapper or custom control)
|
||||
const inp = fld.querySelector('input') || fld.querySelector('select');
|
||||
if(inp && inp.value !== '') obj[f.name] = f.type==='int'?parseInt(inp.value,10):inp.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
data[i]=obj;
|
||||
if(textarea) textarea.value = JSON.stringify(data);
|
||||
}
|
||||
|
||||
function removeAt(i){ const data = parse(); data.splice(i,1); if(textarea) textarea.value = JSON.stringify(data); render(); }
|
||||
|
||||
@@ -314,15 +464,21 @@
|
||||
makeSimpleListEditor({textarea:'id_oop', container:'oop-list', addButton:'add-oop', fields:[{name:'start_date',label:'Start date',type:'date'},{name:'end_date',label:'End date',type:'date'},{name:'reason',label:'Reason',type:'text'}]});
|
||||
makeSimpleListEditor({textarea:'id_not_available_to_work', container:'nav-list', addButton:'add-nav', fields:[{name:'date',label:'Date',type:'date'},{name:'reason',label:'Reason',type:'text'}]});
|
||||
makeSimpleListEditor({textarea:'id_pref_not_to_work', container:'pref-list', addButton:'add-pref', fields:[{name:'date',label:'Date',type:'date'},{name:'reason',label:'Reason',type:'text'}]});
|
||||
makeSimpleListEditor({textarea:'id_work_requests', container:'wr-list', addButton:'add-wr', fields:[{name:'date',label:'Date',type:'date'},{name:'shift',label:'Shift',type:'text'}]});
|
||||
makeSimpleListEditor({textarea:'id_locum_availability', container:'loc-list', addButton:'add-loc', fields:[{name:'date',label:'Date',type:'date'},{name:'shift',label:'Shift',type:'text'}]});
|
||||
makeSimpleListEditor({textarea:'id_forced_assignments', container:'fa-list', addButton:'add-fa', fields:[{name:'week',label:'Week',type:'int'},{name:'day',label:'Day',type:'text'},{name:'shift',label:'Shift',type:'text'}]});
|
||||
makeSimpleListEditor({textarea:'id_forced_assignments_by_date', container:'fabd-list', addButton:'add-fabd', fields:[{name:'date',label:'Date',type:'date'},{name:'shift',label:'Shift',type:'text'}]});
|
||||
// If the server provided shift names for the current rota, use them
|
||||
var _shiftOptions = [];
|
||||
try{
|
||||
_shiftOptions = {{ shift_names_json|default:'[]'|safe }};
|
||||
}catch(e){ _shiftOptions = []; }
|
||||
|
||||
makeSimpleListEditor({textarea:'id_work_requests', container:'wr-list', addButton:'add-wr', fields:[{name:'date',label:'Date',type:'date'},{name:'shift',label:'Shift',type:'shift'}], shiftOptions:_shiftOptions});
|
||||
makeSimpleListEditor({textarea:'id_locum_availability', container:'loc-list', addButton:'add-loc', fields:[{name:'date',label:'Date',type:'date'},{name:'shift',label:'Shift',type:'shift'}], shiftOptions:_shiftOptions});
|
||||
makeSimpleListEditor({textarea:'id_forced_assignments', container:'fa-list', addButton:'add-fa', fields:[{name:'week',label:'Week',type:'int'},{name:'day',label:'Day',type:'text'},{name:'shift',label:'Shift',type:'shift'}], shiftOptions:_shiftOptions});
|
||||
makeSimpleListEditor({textarea:'id_forced_assignments_by_date', container:'fabd-list', addButton:'add-fabd', fields:[{name:'date',label:'Date',type:'date'},{name:'shift',label:'Shift',type:'shift'}], shiftOptions:_shiftOptions});
|
||||
makeSimpleListEditor({textarea:'id_allowed_multi_shift_sets', container:'ams-list', addButton:'add-ams', fields:[{name:'shifts',label:'Shifts (comma-separated)',type:'text'}]});
|
||||
|
||||
// Hard day deps/exclusions need days/weeks/start/end
|
||||
makeSimpleListEditor({textarea:'id_hard_day_dependencies', container:'hdd-list', addButton:'add-hdd', fields:[{name:'days',label:'Days (comma-separated)',type:'text'},{name:'weeks',label:'Weeks (comma-separated ints)',type:'text'},{name:'start_date',label:'Start date',type:'date'},{name:'end_date',label:'End date',type:'date'}]});
|
||||
makeSimpleListEditor({textarea:'id_hard_day_exclusions', container:'hde-list', addButton:'add-hde', fields:[{name:'days',label:'Days (comma-separated)',type:'text'},{name:'weeks',label:'Weeks (comma-separated ints)',type:'text'},{name:'start_date',label:'Start date',type:'date'},{name:'end_date',label:'End date',type:'date'}]});
|
||||
makeSimpleListEditor({textarea:'id_hard_day_dependencies', container:'hdd-list', addButton:'add-hdd', fields:[{name:'days',label:'Days (weekdays)',type:'text'},{name:'weeks',label:'Weeks (comma-separated ints)',type:'text'},{name:'start_date',label:'Start date',type:'date'},{name:'end_date',label:'End date',type:'date'}]});
|
||||
makeSimpleListEditor({textarea:'id_hard_day_exclusions', container:'hde-list', addButton:'add-hde', fields:[{name:'days',label:'Days (weekdays)',type:'text'},{name:'weeks',label:'Weeks (comma-separated ints)',type:'text'},{name:'start_date',label:'Start date',type:'date'},{name:'end_date',label:'End date',type:'date'}]});
|
||||
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Import workers</p>
|
||||
<button class="delete" aria-label="close" hx-trigger="closeModal"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<form id="worker-import-form" method="post" enctype="multipart/form-data" hx-post="{% url 'rota:worker_import' rota.id %}" hx-target="#modal" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="label">CSV file</label>
|
||||
<div class="control">
|
||||
<input class="input" type="file" name="file" accept="text/csv,application/vnd.ms-excel,text/plain" />
|
||||
</div>
|
||||
<p class="help">Upload a CSV file with columns name,email,site,grade,fte (headers optional).</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Or paste CSV / lines</label>
|
||||
<div class="control">
|
||||
<textarea class="textarea" name="workers_text" rows="6" placeholder="name,email,site,grade,fte\nAlice,alice@example.com,exeter,2,1.0"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="assign_to_rota" value="1" checked /> Assign created workers to this rota
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field is-grouped">
|
||||
<div class="control"><button class="button is-link" type="submit">Import</button></div>
|
||||
<div class="control"><button class="button is-light" type="button" hx-trigger="closeModal">Cancel</button></div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="content" style="margin-top:1rem;">
|
||||
<p class="help">Example CSV lines:</p>
|
||||
<pre>name,email,site,grade,fte
|
||||
Alice,alice@example.com,exeter,2,1.0
|
||||
Bob,bob@example.com,plymouth,1,0.8</pre>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -6,7 +6,7 @@
|
||||
<li>
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:0.75rem;">
|
||||
<div style="display:flex; align-items:center; gap:0.5rem;">
|
||||
<a href="{% url 'rota:worker_detail' w.id %}">{{ w.name }}</a>
|
||||
<a href="javascript:void(0)" hx-get="{% url 'rota:worker_overview' rota.id w.id %}" hx-target="#modal" hx-swap="innerHTML">{{ w.name }}</a>
|
||||
<small style="color:#666">— {{ w.site }}{% if w.grade %} · grade {{ w.grade }}{% endif %} · FTE {{ w.fte }}</small>
|
||||
{# show non-working days stored in options.nwds if present; render JSON safely and let JS format it #}
|
||||
{% if w.options.nwds %}
|
||||
@@ -15,10 +15,33 @@
|
||||
{{ w.options.nwds|default:"[]"|json_script:script_id }}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{# Show worker-specific constraint snippets (compact tags with hover details) #}
|
||||
{% if w.options.hard_day_exclusions or w.options.hard_day_dependencies or w.options.forced_assignments or w.options.forced_assignments_by_date or w.options.allowed_multi_shift_sets %}
|
||||
<div style="margin-top:0.25rem;">
|
||||
<span class="tags">
|
||||
{% for it in w.options.hard_day_exclusions %}
|
||||
<a href="javascript:void(0)" class="tag is-light" title="{{ it|escape }}" hx-get="{% url 'rota:worker_edit' rota.id w.id %}?focus=hde" hx-target="#modal" hx-swap="innerHTML">HDE</a>
|
||||
{% endfor %}
|
||||
{% for it in w.options.hard_day_dependencies %}
|
||||
<a href="javascript:void(0)" class="tag is-light" title="{{ it|escape }}" hx-get="{% url 'rota:worker_edit' rota.id w.id %}?focus=hdd" hx-target="#modal" hx-swap="innerHTML">HDD</a>
|
||||
{% endfor %}
|
||||
{% for it in w.options.forced_assignments %}
|
||||
<a href="javascript:void(0)" class="tag is-light" title="{{ it|escape }}" hx-get="{% url 'rota:worker_edit' rota.id w.id %}?focus=fa" hx-target="#modal" hx-swap="innerHTML">FA</a>
|
||||
{% endfor %}
|
||||
{% for it in w.options.forced_assignments_by_date %}
|
||||
<a href="javascript:void(0)" class="tag is-light" title="{{ it|escape }}" hx-get="{% url 'rota:worker_edit' rota.id w.id %}?focus=fad" hx-target="#modal" hx-swap="innerHTML">FAD</a>
|
||||
{% endfor %}
|
||||
{% for it in w.options.allowed_multi_shift_sets %}
|
||||
<a href="javascript:void(0)" class="tag is-light" title="{{ it|escape }}" hx-get="{% url 'rota:worker_edit' rota.id w.id %}?focus=ams" hx-target="#modal" hx-swap="innerHTML">AMS</a>
|
||||
{% endfor %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="flex:0 0 auto;">
|
||||
<button class="button is-small is-light ml-2" hx-get="{% url 'rota:worker_edit' rota.id w.id %}" hx-target="#modal" hx-swap="innerHTML">Edit</button>
|
||||
<button class="button is-small is-danger ml-2" hx-get="{% url 'rota:worker_delete' rota.id w.id %}" hx-target="#modal" hx-swap="innerHTML">Delete</button>
|
||||
<div style="flex:0 0 auto; display:flex; gap:0.5rem;">
|
||||
<button class="button is-small is-light" hx-get="{% url 'rota:worker_edit' rota.id w.id %}" hx-target="#modal" hx-swap="innerHTML">Edit</button>
|
||||
<a class="button is-small" href="{% url 'rota:worker_detail' w.id %}">Leave</a>
|
||||
<button class="button is-small is-danger" hx-get="{% url 'rota:worker_delete' rota.id w.id %}" hx-target="#modal" hx-swap="innerHTML">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
{% load crispy_forms_tags %}
|
||||
<div class="modal is-active" id="worker-overview-modal">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Worker: {{ worker.name }}</p>
|
||||
<button class="delete" aria-label="close" onclick="document.getElementById('modal').innerHTML='' "></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<div class="content">
|
||||
<h4 class="title is-6">Basic info</h4>
|
||||
<p><strong>Site:</strong> {{ worker.site }}</p>
|
||||
<p><strong>Grade:</strong> {{ worker.grade }}</p>
|
||||
<p><strong>FTE:</strong> {{ worker.fte }}</p>
|
||||
<p><strong>Email:</strong> {{ worker.email }}</p>
|
||||
|
||||
<h4 class="title is-6">Leaves</h4>
|
||||
<div>
|
||||
{% include 'rota/partials/leave_list.html' with leaves=worker.leaves.all token=None %}
|
||||
</div>
|
||||
|
||||
<h4 class="title is-6">Configured constraints & options</h4>
|
||||
<p class="help">Below are the worker's configured options. Values are shown read-only.</p>
|
||||
|
||||
<div class="box" style="white-space:pre-wrap; font-family:monospace; font-size:0.9rem;">
|
||||
{{ pretty_options }}
|
||||
</div>
|
||||
|
||||
<h4 class="title is-6">Quick summary</h4>
|
||||
<div class="tags">
|
||||
{% for it in worker.options.hard_day_exclusions %}
|
||||
<span class="tag is-light" title="{{ it|escape }}">HDE</span>
|
||||
{% empty %}{% endfor %}
|
||||
{% for it in worker.options.hard_day_dependencies %}
|
||||
<span class="tag is-light" title="{{ it|escape }}">HDD</span>
|
||||
{% empty %}{% endfor %}
|
||||
{% for it in worker.options.forced_assignments %}
|
||||
<span class="tag is-light" title="{{ it|escape }}">FA</span>
|
||||
{% empty %}{% endfor %}
|
||||
{% for it in worker.options.forced_assignments_by_date %}
|
||||
<span class="tag is-light" title="{{ it|escape }}">FAD</span>
|
||||
{% empty %}{% endfor %}
|
||||
{% for it in worker.options.allowed_multi_shift_sets %}
|
||||
<span class="tag is-light" title="{{ it|escape }}">AMS</span>
|
||||
{% empty %}{% endfor %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div style="margin-top:1rem; display:flex; gap:0.5rem;">
|
||||
<button class="button is-small is-light" hx-get="{% url 'rota:worker_edit' rota.id worker.id %}" hx-target="#modal" hx-swap="innerHTML">Edit</button>
|
||||
<a class="button is-small" href="{% url 'rota:worker_detail' worker.id %}">Open full page</a>
|
||||
<button type="button" class="button" onclick="document.getElementById('modal').innerHTML=''">Close</button>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -6,65 +6,121 @@
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h1 class="title">{{ rota.name }}</h1>
|
||||
<p><a class="button is-small" href="{% url 'rota:rota_edit' rota.id %}">Edit rota</a></p>
|
||||
<p class="subtitle">{{ rota.description }}</p>
|
||||
<p>Period: {{ rota.start_date }} → {{ rota.end_date }}</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<p>
|
||||
<button class="button is-link" hx-get="{% url 'rota:worker_add' %}?rota_id={{ rota.id }}" hx-target="#modal" hx-swap="innerHTML">Add worker</button>
|
||||
<a class="button is-info is-light is-small" href="{% url 'rota:leaves_global_rota' rota.id %}" style="margin-left:0.5rem;">All leave requests</a>
|
||||
</p>
|
||||
{% include 'rota/partials/worker_list.html' %}
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<div>
|
||||
<h1 class="title is-3" style="margin-bottom:0.2rem;">{{ rota.name }}</h1>
|
||||
<p class="subtitle is-6" style="margin-top:0;">{{ rota.description }}</p>
|
||||
<p class="is-size-7 has-text-grey">Period: {{ rota.start_date }} → {{ rota.end_date }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<div class="buttons">
|
||||
<a class="button is-small" href="{% url 'rota:rota_edit' rota.id %}">Edit rota</a>
|
||||
<a class="button is-small is-light" hx-get="{% url 'rota:worker_import' rota.id %}" hx-target="#modal" hx-swap="innerHTML">Import</a>
|
||||
<a class="button is-small" href="{% url 'rota:rota_detail' rota.id %}">Refresh</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<details class="box">
|
||||
<summary class="subtitle" style="cursor:pointer; outline:none;">Historic runs</summary>
|
||||
<div style="margin-bottom:0.5em; margin-top:0.5em;">
|
||||
<form method="post" action="{% url 'rota:rota_runs_clear' rota.id %}" style="display:inline" onsubmit="return confirm('Delete all historic runs for this rota? This cannot be undone.')">
|
||||
{% csrf_token %}
|
||||
<button class="button is-danger is-light is-small" type="submit">Clear all runs</button>
|
||||
</form>
|
||||
<div class="columns is-variable is-6" style="margin-top:0.75rem;">
|
||||
<div class="column is-half">
|
||||
<div class="box">
|
||||
<h2 class="subtitle is-6">Solver settings</h2>
|
||||
<div class="columns is-mobile is-multiline" style="margin-bottom:0.5rem;">
|
||||
<div class="column is-narrow"><strong>Solver</strong></div>
|
||||
<div class="column">{% if solver %}{{ solver }}{% else %}<span class="has-text-grey">(default)</span>{% endif %}</div>
|
||||
<div class="column is-narrow"><strong>Ratio</strong></div>
|
||||
<div class="column">{% if solver_ratio is not None %}{{ solver_ratio }}{% else %}<span class="has-text-grey">—</span>{% endif %}</div>
|
||||
</div>
|
||||
{% if solver_options %}
|
||||
<div style="margin-top:0.5rem;">
|
||||
<h3 class="is-size-7">Solver options</h3>
|
||||
<pre style="white-space:pre-wrap; margin:0;">{{ solver_options }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<ul>
|
||||
{% for run in rota.runs.all %}
|
||||
<li>
|
||||
<a href="{% url 'rota:rota_run_detail' run.id %}">Run {{ run.id }}</a> — {{ run.get_status_display }}
|
||||
{% if run.finished_at %} — finished {{ run.finished_at }}{% endif %}
|
||||
<span class="ml-2"><a class="button is-small is-light" href="{% url 'rota:rota_run_export_html' run.id %}" target="_blank">Export</a></span>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li>No previous runs</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<div class="box">
|
||||
<h2 class="subtitle is-6">RotaBuilder summary</h2>
|
||||
<div class="columns is-mobile is-multiline" style="margin-bottom:0.5rem;">
|
||||
<div class="column is-narrow"><strong>Weeks</strong></div>
|
||||
<div class="column">{{ weeks_to_rota|default:'—' }}</div>
|
||||
<div class="column is-narrow"><strong>Shifts</strong></div>
|
||||
<div class="column">{{ shift_count|default:'—' }}</div>
|
||||
<div class="column is-narrow"><strong>Workers</strong></div>
|
||||
<div class="column">{{ worker_count|default:'—' }}</div>
|
||||
</div>
|
||||
<p class="help is-size-7">This summary reflects the current rota options and configured shifts/workers.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box mb-4">
|
||||
<div class="level is-mobile">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
{% include 'rota/partials/worker_list.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<div class="buttons">
|
||||
<button class="button is-link" hx-get="{% url 'rota:worker_add' %}?rota_id={{ rota.id }}" hx-target="#modal" hx-swap="innerHTML">Add worker</button>
|
||||
<button class="button is-light" hx-get="{% url 'rota:worker_import' rota.id %}" hx-target="#modal" hx-swap="innerHTML">Import workers</button>
|
||||
<a class="button is-info is-light is-small" href="{% url 'rota:leaves_global_rota' rota.id %}">All leave requests</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h2 class="subtitle">Constraint options</h2>
|
||||
<div class="box">
|
||||
<p class="help">Configure typed rota constraint options. Click Edit to open the form showing typed fields and descriptions.</p>
|
||||
<p><button class="button is-link" hx-get="{% url 'rota:rota_options' rota.id %}" hx-target="#modal" hx-swap="innerHTML">Edit constraint options</button></p>
|
||||
<div class="columns is-vcentered">
|
||||
<div class="column">
|
||||
<p class="help">Configure typed rota constraint options. Click Edit to open the form showing typed fields and descriptions.</p>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<button class="button is-link" hx-get="{% url 'rota:rota_options' rota.id %}" hx-target="#modal" hx-swap="innerHTML">Edit constraint options</button>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<details class="constraints-details" style="margin-top:0.5rem;">
|
||||
<summary class="subtitle is-6" style="cursor:pointer; outline:none;">Configured constraint values{% if configured_constraints %} ({{ configured_constraints|length }}){% endif %}</summary>
|
||||
<div style="margin-top:0.5rem;">
|
||||
<div style="display:flex; gap:0.5rem; align-items:center; margin-bottom:0.5rem;">
|
||||
<input id="constraint-filter-{{ rota.id }}" class="input is-small" type="search" placeholder="Filter constraints (key or value)..." aria-label="Filter constraints" />
|
||||
<button id="constraint-clear-{{ rota.id }}" class="button is-small is-light" type="button">Clear</button>
|
||||
<div class="field has-addons" style="margin-bottom:0.5rem;">
|
||||
<div class="control is-expanded"><input id="constraint-filter-{{ rota.id }}" class="input is-small" type="search" placeholder="Filter constraints (key or value)..." aria-label="Filter constraints" /></div>
|
||||
<div class="control"><button id="constraint-clear-{{ rota.id }}" class="button is-small is-light" type="button">Clear</button></div>
|
||||
</div>
|
||||
<div class="constraints-scroll" style="max-height:600px; overflow:auto; margin-bottom:0.75em;">
|
||||
{% if configured_constraints %}
|
||||
<table class="table is-fullwidth is-striped is-narrow" id="constraint-table-{{ rota.id }}">
|
||||
<thead><tr><th>Key</th><th>Value</th><th></th></tr></thead>
|
||||
<thead><tr><th>Key</th><th>Value</th><th class="has-text-right">Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{% for key, info in configured_constraints.items %}
|
||||
<tr id="constraint-row-{{ key }}">
|
||||
<td style="vertical-align:middle;"><code title="{{ info.description|default:'' }}">{{ key }}</code></td>
|
||||
<td style="vertical-align:middle;"><pre style="white-space:pre-wrap; margin:0;">{{ info.pretty }}</pre></td>
|
||||
<td style="vertical-align:middle; min-width:180px;"><code title="{{ info.description|default:'' }}">{{ key }}</code></td>
|
||||
<td style="vertical-align:middle;">
|
||||
{% if info.typed and info.default is not None %}
|
||||
{% if info.value == info.default %}
|
||||
<pre class="has-text-grey" style="white-space:pre-wrap; margin:0;">{{ info.pretty }}</pre>
|
||||
<span class="tag is-light is-small" title="Using typed default">default</span>
|
||||
{% else %}
|
||||
<pre style="white-space:pre-wrap; margin:0;">{{ info.pretty }}</pre>
|
||||
<div class="help is-size-7" style="margin-top:0.25rem;color:#666;">Default: <code style="white-space:pre-wrap">{{ info.default }}</code></div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<pre style="white-space:pre-wrap; margin:0;">{{ info.pretty }}</pre>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="vertical-align:middle; text-align:right;">
|
||||
<a href="javascript:void(0)" class="button is-small is-light" role="button" hx-get="{% url 'rota:rota_option_edit' rota.id %}?key={{ key }}" hx-target="#modal" hx-swap="innerHTML">Edit</a>
|
||||
<form method="post" hx-post="{% url 'rota:rota_option_delete' rota.id %}" hx-target="#constraint-row-{{ key }}" hx-swap="outerHTML" style="display:inline">
|
||||
{% csrf_token %}
|
||||
@@ -108,22 +164,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="field is-grouped is-grouped-multiline">
|
||||
<div class="control"><label class="checkbox"><input type="checkbox" name="generate_builder" value="1"> Generate HTML export</label></div>
|
||||
<div class="control"><label class="checkbox"><input type="checkbox" name="builder_solve" value="1"> Solve when exporting (may be slow)</label></div>
|
||||
<div class="control"><button class="button is-primary" type="submit">Run scheduler (background)</button></div>
|
||||
</div>
|
||||
</form>
|
||||
{# Run controls and historic runs moved to the bottom of the page. #}
|
||||
|
||||
<div>
|
||||
{% include 'rota/partials/shift_list.html' %}
|
||||
<p><button class="button is-link" hx-get="{% url 'rota:shift_add' rota.id %}" hx-target="#shift-form" hx-swap="innerHTML">Add shift</button></p>
|
||||
<div id="shift-form">{# Load form via HTMX when user clicks 'Add shift' - initial empty state #}</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<h2 class="subtitle">Edit shifts</h2>
|
||||
<p><button class="button is-link" hx-get="{% url 'rota:shift_add' rota.id %}" hx-target="#shift-form" hx-swap="innerHTML">Add shift</button></p>
|
||||
<div id="shift-form">{# Load form via HTMX when user clicks 'Add shift' - initial empty state #}</div>
|
||||
{% include 'rota/partials/shift_list.html' %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="field is-grouped is-grouped-multiline">
|
||||
<div class="control"><label class="checkbox"><input type="checkbox" name="generate_builder" value="1" checked> Generate HTML export</label></div>
|
||||
<div class="control"><label class="checkbox"><input type="checkbox" name="builder_solve" value="1" checked> Solve when exporting (may be slow)</label></div>
|
||||
<div class="control"><button class="button is-primary" type="submit">Run rota</button></div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mb-4" style="margin-top:0.75rem;">
|
||||
<details class="box">
|
||||
<summary class="subtitle" style="cursor:pointer; outline:none;">Historic runs</summary>
|
||||
<div style="margin-bottom:0.5em; margin-top:0.5em;">
|
||||
<form method="post" action="{% url 'rota:rota_runs_clear' rota.id %}" style="display:inline" onsubmit="return confirm('Delete all historic runs for this rota? This cannot be undone.')">
|
||||
{% csrf_token %}
|
||||
<button class="button is-danger is-light is-small" type="submit">Clear all runs</button>
|
||||
</form>
|
||||
</div>
|
||||
<ul>
|
||||
{% for run in rota.runs.all %}
|
||||
<li>
|
||||
<a href="{% url 'rota:rota_run_detail' run.id %}">Run {{ run.id }}</a> — {{ run.get_status_display }}
|
||||
{% if run.finished_at %} — finished {{ run.finished_at }}{% endif %}
|
||||
<span class="ml-2"><a class="button is-small is-light" href="{% url 'rota:rota_run_export_html' run.id %}" target="_blank">Export</a></span>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li>No previous runs</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta charset="utf-8">
|
||||
<title>Rota run {{ run.id }}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" rel="stylesheet">
|
||||
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
|
||||
<style>pre { white-space: pre-wrap; }</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -12,17 +13,23 @@
|
||||
<div class="container">
|
||||
<h1 class="title">Rota run for {{ run.rota.name }}</h1>
|
||||
<p class="subtitle">Status: {{ run.get_status_display }} ({{ run.status }})</p>
|
||||
<div style="margin-bottom:0.5rem;" id="run-status-container">
|
||||
<div id="run-status" hx-get="{% url 'rota:rota_run_status' run.id %}" hx-trigger="every 3s" hx-swap="outerHTML">
|
||||
<span class="tag">Status: {{ run.get_status_display }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p>Created: {{ run.created_at|localtime }} started: {{ run.started_at|localtime }} finished: {{ run.finished_at|localtime }}</p>
|
||||
<p class="mt-2">
|
||||
<a class="button" href="{% url 'rota:rota_detail' run.rota.id %}">Back to rota</a>
|
||||
<a class="button is-light" href="{% url 'rota:rota_run_export_html' run.id %}" target="_blank">Open export</a>
|
||||
<span id="view-final-output" style="margin-left:0.5rem;"></span>
|
||||
</p>
|
||||
|
||||
<div class="box">
|
||||
<details>
|
||||
<summary><strong>Log</strong></summary>
|
||||
<div style="margin-top:0.5rem; max-height:40vh; overflow:auto; border-top:1px solid #eee; padding-top:0.5rem;">
|
||||
<pre>{{ run.log }}</pre>
|
||||
<div id="run-log"><pre id="run-log-pre">{{ run.log }}</pre></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@@ -31,7 +38,7 @@
|
||||
<details>
|
||||
<summary><strong>Result (raw)</strong></summary>
|
||||
<div style="margin-top:0.5rem; max-height:40vh; overflow:auto; border-top:1px solid #eee; padding-top:0.5rem;">
|
||||
<pre>{{ result_pretty|default:run.result }}</pre>
|
||||
<div id="run-result"><pre id="run-result-pre">{{ result_pretty|default:run.result }}</pre></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
+172
-4
@@ -181,22 +181,27 @@ class WorkerForm(forms.ModelForm):
|
||||
parsed = val
|
||||
|
||||
def _normalize(obj):
|
||||
# Normalize pydantic BaseModel instances (v2:v1)
|
||||
# Normalize pydantic BaseModel instances (v2:v1) and recurse
|
||||
if hasattr(obj, "model_dump"):
|
||||
try:
|
||||
return obj.model_dump()
|
||||
res = obj.model_dump()
|
||||
return _normalize(res)
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(obj, "dict") and not isinstance(obj, dict):
|
||||
try:
|
||||
return obj.dict()
|
||||
res = obj.dict()
|
||||
return _normalize(res)
|
||||
except Exception:
|
||||
pass
|
||||
# Recurse lists/tuples
|
||||
# Recurse lists/tuples/sets
|
||||
if isinstance(obj, list):
|
||||
return [_normalize(i) for i in obj]
|
||||
if isinstance(obj, tuple):
|
||||
return [_normalize(i) for i in obj]
|
||||
if isinstance(obj, set):
|
||||
# JSON can't represent sets; convert to list
|
||||
return [_normalize(i) for i in obj]
|
||||
if isinstance(obj, dict):
|
||||
return {k: _normalize(v) for k, v in obj.items()}
|
||||
return obj
|
||||
@@ -410,6 +415,35 @@ class RotaScheduleForm(forms.ModelForm):
|
||||
else:
|
||||
self.fields[field_name] = forms.CharField(required=False, initial=initial, label=k.replace("_", " "), help_text=meta.get("help"))
|
||||
|
||||
# Expose solver selection and solver options (JSON) so users can tune
|
||||
# solver behaviour per-rota. Persisted into `RotaSchedule.options` as
|
||||
# keys `solver` (string) and `solver_options` (dict).
|
||||
solver_initial = existing_opts.get("solver", "appsi_highs")
|
||||
solver_opts_initial = existing_opts.get("solver_options", {})
|
||||
try:
|
||||
solver_opts_str = json.dumps(solver_opts_initial, indent=2)
|
||||
except Exception:
|
||||
solver_opts_str = ""
|
||||
|
||||
self.fields["solver"] = forms.CharField(required=False, initial=solver_initial, label="Solver", help_text="Solver plugin name to use (e.g. appsi_highs, scip, gurobi)")
|
||||
# Common solver ratio is surfaced as a dedicated field for convenience
|
||||
# (many workflows tune this). It's stored inside the solver_options
|
||||
# dict under the key 'ratio'. The free-form JSON textarea remains for
|
||||
# advanced options.
|
||||
solver_ratio_initial = None
|
||||
try:
|
||||
if isinstance(solver_opts_initial, dict) and "ratio" in solver_opts_initial:
|
||||
solver_ratio_initial = solver_opts_initial.get("ratio")
|
||||
else:
|
||||
# also accept legacy top-level 'ratio' key in existing_opts
|
||||
solver_ratio_initial = existing_opts.get("ratio")
|
||||
except Exception:
|
||||
solver_ratio_initial = None
|
||||
|
||||
self.fields["solver"] = forms.CharField(required=False, initial=solver_initial, label="Solver", help_text="Solver plugin name to use (e.g. appsi_highs, scip, gurobi)")
|
||||
self.fields["solver_ratio"] = forms.FloatField(required=False, initial=solver_ratio_initial, label="Solver ratio (relative gap)", help_text="Relative MIP gap / ratio (e.g. 0.01 for 1%)")
|
||||
self.fields["solver_options"] = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows": 6}), initial=solver_opts_str, label="Solver options (JSON)", help_text="JSON object of solver-specific options; e.g. {\"seconds\": 60, \"threads\": 4}")
|
||||
|
||||
def clean_start_date(self):
|
||||
start = self.cleaned_data.get("start_date")
|
||||
if start is None:
|
||||
@@ -436,6 +470,120 @@ class RotaScheduleForm(forms.ModelForm):
|
||||
self.fields["end_date"].initial = end_date
|
||||
return cleaned
|
||||
|
||||
def save(self, commit=True):
|
||||
"""Save the RotaSchedule and persist dynamic `opt__*` fields into
|
||||
`instance.options`.
|
||||
|
||||
This persists both typed constraint option fields (created from
|
||||
RotaConstraintOptions) and the small set of builder-argument fields
|
||||
(created in __init__). For structured fields we attempt to parse JSON
|
||||
textareas into Python objects.
|
||||
"""
|
||||
instance = super().save(commit=False)
|
||||
|
||||
opts = instance.options or {}
|
||||
|
||||
# Persist typed constraint option fields using inferred defaults map
|
||||
try:
|
||||
defaults = getattr(self, "_constraint_defaults", {}) or {}
|
||||
except Exception:
|
||||
defaults = {}
|
||||
|
||||
for key, default in defaults.items():
|
||||
field_name = f"opt__{key}"
|
||||
if field_name in self.cleaned_data:
|
||||
val = self.cleaned_data[field_name]
|
||||
if isinstance(default, (list, dict)):
|
||||
# JSON textarea
|
||||
try:
|
||||
parsed = json.loads(val) if val is not None and val != "" else []
|
||||
except Exception:
|
||||
parsed = val
|
||||
opts[key] = parsed
|
||||
else:
|
||||
if val == "" or val is None:
|
||||
opts[key] = None
|
||||
else:
|
||||
opts[key] = val
|
||||
|
||||
# Persist any remaining opt__ fields (builder args and others)
|
||||
for fname, val in self.cleaned_data.items():
|
||||
if not fname.startswith("opt__"):
|
||||
continue
|
||||
key = fname[5:]
|
||||
if key in defaults:
|
||||
# already handled
|
||||
continue
|
||||
v = val
|
||||
# Try to parse JSON-like strings into structures
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
if s.startswith("[") or s.startswith("{"):
|
||||
try:
|
||||
v = json.loads(v)
|
||||
except Exception:
|
||||
pass
|
||||
opts[key] = v
|
||||
|
||||
# Persist the top-level 'weeks' value from the form into options so
|
||||
# the builder can prefer this explicit value instead of deriving it
|
||||
# from end_date. Do not persist end_date itself here.
|
||||
try:
|
||||
weeks_val = self.cleaned_data.get("weeks")
|
||||
if weeks_val is not None:
|
||||
opts["weeks"] = int(weeks_val)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Persist solver selection and options (if provided). Solver options are
|
||||
# stored as a dict under opts['solver_options']. If the user supplied
|
||||
# a JSON string, attempt to parse it; otherwise keep raw value.
|
||||
try:
|
||||
solver_name = self.cleaned_data.get("solver")
|
||||
if solver_name:
|
||||
opts["solver"] = solver_name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
so = self.cleaned_data.get("solver_options")
|
||||
if so is not None and so != "":
|
||||
if isinstance(so, str):
|
||||
try:
|
||||
parsed = json.loads(so)
|
||||
except Exception:
|
||||
# keep as raw string if parsing fails (non-fatal)
|
||||
parsed = so
|
||||
else:
|
||||
parsed = so
|
||||
opts["solver_options"] = parsed
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If a dedicated solver_ratio field was provided, inject/override it
|
||||
# into the solver_options dict so it is honoured at runtime. This
|
||||
# ensures the friendly ratio field wins over any value in the JSON
|
||||
# textarea.
|
||||
try:
|
||||
ratio_val = self.cleaned_data.get("solver_ratio")
|
||||
if ratio_val is not None and ratio_val != "":
|
||||
so = opts.get("solver_options")
|
||||
if so is None or not isinstance(so, dict):
|
||||
so = {}
|
||||
try:
|
||||
so["ratio"] = float(ratio_val)
|
||||
except Exception:
|
||||
so["ratio"] = ratio_val
|
||||
opts["solver_options"] = so
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
instance.options = opts
|
||||
|
||||
if commit:
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
|
||||
class WorkerSelfServiceForm(forms.ModelForm):
|
||||
"""Small form exposed to workers via tokenized links so they can set
|
||||
@@ -663,6 +811,26 @@ class ShiftForm(forms.Form):
|
||||
parsed = json.loads(val)
|
||||
if not isinstance(parsed, list):
|
||||
raise forms.ValidationError("Constraints must be a JSON list of constraint objects")
|
||||
# Validate each constraint object for basic correctness
|
||||
errors = []
|
||||
for i, c in enumerate(parsed):
|
||||
if not isinstance(c, dict):
|
||||
errors.append(f"Constraint #{i+1} must be an object")
|
||||
continue
|
||||
name = c.get('name') or c.get('type')
|
||||
if not name:
|
||||
errors.append(f"Constraint #{i+1} missing 'name'")
|
||||
continue
|
||||
if name in ('pre', 'post'):
|
||||
opts = c.get('options', {}) or {}
|
||||
# days is now required: check top-level or under options
|
||||
days_val = c.get('days') if c.get('days') is not None else opts.get('days')
|
||||
if days_val is None:
|
||||
errors.append(
|
||||
f"Constraint #{i+1} ('{name}') requires a 'days' duration (number of days the constraint applies for)."
|
||||
)
|
||||
if errors:
|
||||
raise forms.ValidationError(errors)
|
||||
return parsed
|
||||
except forms.ValidationError:
|
||||
raise
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Count
|
||||
|
||||
from rota.models import Worker
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "List workers assigned to more than one rota (useful before migrating to OneToOne Assignment)"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
qs = Worker.objects.annotate(num_rotas=Count('rotas')).filter(num_rotas__gt=1)
|
||||
if not qs.exists():
|
||||
self.stdout.write(self.style.SUCCESS('No workers assigned to more than one rota.'))
|
||||
return
|
||||
for w in qs:
|
||||
self.stdout.write(f"Worker id={w.id} name={w.name!r} assigned_rotas={w.num_rotas}")
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 6.0 on 2025-12-19 21:11
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rota", "0007_make_request_token_unique"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="assignment",
|
||||
options={"ordering": ("-created_at",)},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="assignment",
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="assignment",
|
||||
name="worker",
|
||||
field=models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="rota.worker"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="worker",
|
||||
name="request_token",
|
||||
field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
|
||||
),
|
||||
]
|
||||
@@ -55,14 +55,34 @@ class RotaSchedule(models.Model):
|
||||
)
|
||||
|
||||
# compute weeks_to_rota (integer number of weeks)
|
||||
delta = self.end_date - self.start_date
|
||||
weeks_to_rota = max(1, int(delta.days // 7))
|
||||
# Prefer an explicit value stored in `self.options['weeks']` (set by
|
||||
# the rota edit form). If absent, fall back to deriving from the
|
||||
# stored end_date (backwards compatible).
|
||||
opts_preview = dict(self.options or {})
|
||||
weeks_opt = None
|
||||
if "weeks" in opts_preview:
|
||||
try:
|
||||
weeks_opt = int(opts_preview.get("weeks"))
|
||||
except Exception:
|
||||
weeks_opt = None
|
||||
if weeks_opt is not None:
|
||||
weeks_to_rota = max(1, weeks_opt)
|
||||
else:
|
||||
delta = self.end_date - self.start_date
|
||||
weeks_to_rota = max(1, int(delta.days // 7))
|
||||
|
||||
# Build kwargs for RotaBuilder from any explicit builder args stored in
|
||||
# `self.options` (kept for backward compatibility), and treat the
|
||||
# remaining keys as constraint options which should map to
|
||||
# `RotaConstraintOptions`.
|
||||
opts = dict(self.options or {})
|
||||
# Remove 'weeks' from opts before handing to constraint options so
|
||||
# it doesn't cause validation failures in RotaConstraintOptions.
|
||||
if "weeks" in opts:
|
||||
try:
|
||||
opts.pop("weeks")
|
||||
except Exception:
|
||||
pass
|
||||
builder_keys = [
|
||||
"balance_offset_modifier",
|
||||
"ltft_balance_offset",
|
||||
@@ -277,13 +297,18 @@ class Worker(models.Model):
|
||||
class Assignment(models.Model):
|
||||
"""Join model assigning a worker to a rota (can hold role / metadata)."""
|
||||
|
||||
worker = models.ForeignKey(Worker, on_delete=models.CASCADE)
|
||||
# Enforce that a Worker may only be assigned to a single RotaSchedule by
|
||||
# using a OneToOneField here. This ensures at the DB level that each
|
||||
# Worker has at most one Assignment (and therefore at most one rota).
|
||||
worker = models.OneToOneField(Worker, on_delete=models.CASCADE)
|
||||
rota = models.ForeignKey(RotaSchedule, on_delete=models.CASCADE)
|
||||
role = models.CharField(max_length=100, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("worker", "rota")
|
||||
# With OneToOneField on worker, uniqueness for (worker, rota) is
|
||||
# implied by the worker uniqueness. Keep ordering for convenience.
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.worker} -> {self.rota}"
|
||||
|
||||
@@ -90,8 +90,21 @@ def _run_rota_job(run_id: int, use_external_generator: bool = False, generate_bu
|
||||
err_buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(out_buf), contextlib.redirect_stderr(err_buf):
|
||||
rb = rota.to_rota_builder()
|
||||
# build the model and optionally solve
|
||||
rb.build_and_solve(solve=builder_solve)
|
||||
# If the rota has solver options saved in rota.options, prefer
|
||||
# those when invoking the builder. `solver_options` is expected
|
||||
# to be a dict of solver-specific options; `solver` may be a
|
||||
# string naming the solver plugin.
|
||||
opts = rota.options or {}
|
||||
solver_options = opts.get("solver_options") or {}
|
||||
solver_name = opts.get("solver") or None
|
||||
|
||||
# build the model and optionally solve. If solver_name is
|
||||
# provided, pass it through; otherwise let builder default.
|
||||
if solver_name:
|
||||
rb.build_and_solve(options=solver_options, solve=builder_solve, solver=solver_name)
|
||||
else:
|
||||
rb.build_and_solve(options=solver_options, solve=builder_solve)
|
||||
|
||||
html = rb.get_worker_timetable_html(include_html_tag=True)
|
||||
|
||||
# store under result so it's accessible from UI
|
||||
|
||||
@@ -13,8 +13,11 @@ urlpatterns = [
|
||||
path("rota/<int:rota_id>/shift/<int:idx>/delete/", views.shift_delete, name="shift_delete"),
|
||||
path("rota/<int:rota_id>/edit/", views.rota_edit, name="rota_edit"),
|
||||
path("worker/add/", views.worker_create, name="worker_add"),
|
||||
path("rota/<int:rota_id>/workers/import/", views.worker_import, name="worker_import"),
|
||||
path("worker/<int:rota_id>/<int:worker_id>/edit/", views.worker_edit, name="worker_edit"),
|
||||
path("worker/<int:rota_id>/<int:worker_id>/overview/", views.worker_overview, name="worker_overview"),
|
||||
path("worker/<int:rota_id>/<int:worker_id>/delete/", views.worker_delete, name="worker_delete"),
|
||||
path("worker/<int:worker_id>/leave/add/", views.worker_leave_add, name="worker_leave_add"),
|
||||
path("worker/<int:worker_id>/", views.worker_detail, name="worker_detail"),
|
||||
path("worker/<int:worker_id>/leaves.json", views.leaves_json, name="worker_leaves_json"),
|
||||
path("leave/request/", views.request_leave, name="request_leave"),
|
||||
@@ -32,6 +35,7 @@ urlpatterns = [
|
||||
path("worker/<int:worker_id>/leaves/delete_all/", views.leave_delete_all, name="leave_delete_all"),
|
||||
path("worker/<int:worker_id>/regenerate_token/", views.regenerate_worker_token, name="regenerate_worker_token"),
|
||||
path("run/<int:run_id>/", views.rota_run_detail, name="rota_run_detail"),
|
||||
path("run/<int:run_id>/status/", views.rota_run_status, name="rota_run_status"),
|
||||
path("run/<int:run_id>/export/html/", views.rota_run_export_html, name="rota_run_export_html"),
|
||||
path("run/<int:run_id>/export/download/", views.rota_run_export_download, name="rota_run_export_download"),
|
||||
path("rota/<int:rota_id>/export/builder/", views.rota_export_builder, name="rota_export_builder"),
|
||||
|
||||
+381
-10
@@ -1,4 +1,5 @@
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.utils import timezone
|
||||
from django.urls import reverse
|
||||
|
||||
from .models import RotaSchedule, Worker
|
||||
@@ -14,6 +15,7 @@ from django.http import JsonResponse
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.shortcuts import HttpResponseRedirect
|
||||
import traceback
|
||||
|
||||
|
||||
|
||||
def _get_constraint_description(key: str):
|
||||
@@ -163,6 +165,10 @@ def rota_detail(request, rota_id):
|
||||
try:
|
||||
opts = rota.options or {}
|
||||
for k, v in opts.items():
|
||||
# Only surface keys that correspond to the typed RotaConstraintOptions
|
||||
# (exclude builder/constructor args and other non-constraint keys).
|
||||
if k not in available_constraints_rich:
|
||||
continue
|
||||
# pretty-print complex values where appropriate
|
||||
try:
|
||||
pretty = json.dumps(v, indent=2, default=str)
|
||||
@@ -175,10 +181,76 @@ def rota_detail(request, rota_id):
|
||||
desc = available_constraints_rich.get(k, {}).get("description")
|
||||
except Exception:
|
||||
desc = None
|
||||
configured_constraints[k] = {"value": v, "pretty": pretty, "typed": (k in available_constraints_rich), "description": desc}
|
||||
default_val = available_constraints_rich.get(k, {}).get("default")
|
||||
configured_constraints[k] = {
|
||||
"value": v,
|
||||
"pretty": pretty,
|
||||
"typed": (k in available_constraints_rich),
|
||||
"description": desc,
|
||||
"default": default_val,
|
||||
}
|
||||
except Exception:
|
||||
configured_constraints = {}
|
||||
|
||||
# Compute a small rota-builder summary for display in the template
|
||||
try:
|
||||
# Prefer explicit value stored in rota.options['weeks'] when present.
|
||||
opts = rota.options or {}
|
||||
if "weeks" in opts:
|
||||
try:
|
||||
weeks_to_rota = max(1, int(opts.get("weeks")))
|
||||
except Exception:
|
||||
weeks_to_rota = None
|
||||
else:
|
||||
delta = rota.end_date - rota.start_date
|
||||
weeks_to_rota = max(1, int(delta.days // 7))
|
||||
except Exception:
|
||||
weeks_to_rota = None
|
||||
|
||||
try:
|
||||
shift_count = len(rota.shifts or [])
|
||||
except Exception:
|
||||
shift_count = None
|
||||
|
||||
try:
|
||||
worker_count = rota.workers.count()
|
||||
except Exception:
|
||||
worker_count = None
|
||||
|
||||
# Extract solver settings saved on the rota (if any) for display.
|
||||
try:
|
||||
opts = rota.options or {}
|
||||
solver = opts.get("solver")
|
||||
so = opts.get("solver_options")
|
||||
# Normalize solver_options to a dict when possible and extract ratio
|
||||
solver_options = None
|
||||
solver_ratio = None
|
||||
if isinstance(so, dict):
|
||||
solver_options = so
|
||||
solver_ratio = so.get("ratio")
|
||||
elif isinstance(so, str):
|
||||
try:
|
||||
solver_options = json.loads(so)
|
||||
except Exception:
|
||||
solver_options = so
|
||||
if isinstance(solver_options, dict):
|
||||
solver_ratio = solver_options.get("ratio")
|
||||
# fallback to legacy top-level 'ratio' key
|
||||
if solver_ratio is None:
|
||||
solver_ratio = opts.get("ratio")
|
||||
# Pretty JSON for display when dict
|
||||
try:
|
||||
if isinstance(solver_options, dict):
|
||||
solver_options_pretty = json.dumps(solver_options, indent=2)
|
||||
else:
|
||||
solver_options_pretty = str(solver_options) if solver_options is not None else None
|
||||
except Exception:
|
||||
solver_options_pretty = None
|
||||
except Exception:
|
||||
solver = None
|
||||
solver_options_pretty = None
|
||||
solver_ratio = None
|
||||
|
||||
# legacy RotaOptionsForm not used here; we provide a typed RotaScheduleForm below
|
||||
|
||||
# Provide a typed RotaScheduleForm instance so the template can render
|
||||
@@ -193,7 +265,18 @@ def rota_detail(request, rota_id):
|
||||
return render(
|
||||
request,
|
||||
"rota/rota_detail.html",
|
||||
{"rota": rota, "options_form": typed_form, "available_constraints": available_constraints_rich, "configured_constraints": configured_constraints},
|
||||
{
|
||||
"rota": rota,
|
||||
"options_form": typed_form,
|
||||
"available_constraints": available_constraints_rich,
|
||||
"configured_constraints": configured_constraints,
|
||||
"weeks_to_rota": weeks_to_rota,
|
||||
"shift_count": shift_count,
|
||||
"worker_count": worker_count,
|
||||
"solver": solver,
|
||||
"solver_options": solver_options_pretty,
|
||||
"solver_ratio": solver_ratio,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -252,12 +335,19 @@ def worker_create(request):
|
||||
# If we created a worker and a rota_id was provided, create Assignment
|
||||
if rota is not None:
|
||||
from .models import Assignment
|
||||
# Only assign if the worker is not already assigned to any rota.
|
||||
try:
|
||||
Assignment.objects.create(worker=worker, rota=rota)
|
||||
if not worker.rotas.exists():
|
||||
Assignment.objects.create(worker=worker, rota=rota)
|
||||
else:
|
||||
# skip assigning; worker already assigned elsewhere
|
||||
pass
|
||||
except Exception:
|
||||
# best-effort: try using the M2M add as fallback
|
||||
# best-effort: try using the M2M add as fallback but still
|
||||
# respect single-rota constraint
|
||||
try:
|
||||
rota.workers.add(worker)
|
||||
if not worker.rotas.exists():
|
||||
rota.workers.add(worker)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -283,9 +373,35 @@ def worker_create(request):
|
||||
|
||||
# HTMX GET: return modal partial
|
||||
if is_hx:
|
||||
# If a rota_id query param is provided, include the rota in the
|
||||
# modal context so the template can offer rota-specific choices
|
||||
rota = None
|
||||
rota_id = request.GET.get("rota_id") or request.POST.get("rota_id")
|
||||
if rota_id:
|
||||
try:
|
||||
rota = get_object_or_404(RotaSchedule, pk=int(rota_id))
|
||||
except Exception:
|
||||
rota = None
|
||||
|
||||
# Prepare a JSON-encoded list of shift names for client-side use
|
||||
shift_names_json = '[]'
|
||||
try:
|
||||
import json as _json
|
||||
if rota is not None and getattr(rota, 'shifts', None):
|
||||
# rota.shifts is stored as list of dicts
|
||||
names = []
|
||||
for s in (rota.shifts or []):
|
||||
try:
|
||||
names.append(s.get('name'))
|
||||
except Exception:
|
||||
continue
|
||||
shift_names_json = _json.dumps(names)
|
||||
except Exception:
|
||||
shift_names_json = '[]'
|
||||
|
||||
modal_html = render_to_string(
|
||||
"rota/partials/worker_form_modal.html",
|
||||
{"form": form, "form_action": request.get_full_path()},
|
||||
{"form": form, "form_action": request.get_full_path(), "rota": rota, "shift_names_json": shift_names_json},
|
||||
request=request,
|
||||
)
|
||||
return HttpResponse(modal_html)
|
||||
@@ -294,6 +410,139 @@ def worker_create(request):
|
||||
return render(request, "rota/worker_form.html", {"form": form})
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def worker_import(request, rota_id=None):
|
||||
"""HTMX endpoint: import workers from CSV or pasted text.
|
||||
|
||||
Accepts a file upload named 'file' (CSV) or a textarea 'workers_text'.
|
||||
CSV columns supported: name,email,site,grade,fte (headers optional).
|
||||
When `rota_id` is provided, the created workers will be assigned to
|
||||
that rota.
|
||||
"""
|
||||
is_hx = request.headers.get("HX-Request", "false").lower() == "true"
|
||||
|
||||
rota = None
|
||||
if rota_id is not None:
|
||||
try:
|
||||
rota = get_object_or_404(RotaSchedule, pk=rota_id)
|
||||
except Exception:
|
||||
rota = None
|
||||
|
||||
if request.method == "GET":
|
||||
# render modal partial
|
||||
return render(request, "rota/partials/worker_import_modal.html", {"rota": rota})
|
||||
|
||||
# POST: parse uploaded file or textarea
|
||||
created = []
|
||||
errors = []
|
||||
assign_to_rota = request.POST.get("assign_to_rota") == "1"
|
||||
|
||||
import csv
|
||||
try:
|
||||
upload = request.FILES.get("file")
|
||||
if upload:
|
||||
# decode bytes to text
|
||||
try:
|
||||
text = upload.read().decode("utf-8")
|
||||
except Exception:
|
||||
text = upload.read().decode("latin-1")
|
||||
else:
|
||||
text = request.POST.get("workers_text", "")
|
||||
|
||||
# Normalize lines: if simple newline-separated names/emails, make a CSV
|
||||
reader = csv.DictReader(text.splitlines())
|
||||
rows = list(reader)
|
||||
if not rows:
|
||||
# Try parsing as simple CSV without headers
|
||||
reader2 = csv.reader([line for line in text.splitlines() if line.strip()])
|
||||
for r in reader2:
|
||||
# Map positional columns: name,email,site,grade,fte
|
||||
while len(r) < 5:
|
||||
r.append("")
|
||||
rows.append({"name": r[0].strip(), "email": r[1].strip(), "site": r[2].strip(), "grade": r[3].strip(), "fte": r[4].strip()})
|
||||
|
||||
from .models import Assignment
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
try:
|
||||
# normalize keys lower-case
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
r = {k.strip().lower(): (v if v is not None else "") for k, v in row.items()}
|
||||
name = r.get("name") or r.get("full_name") or r.get("username")
|
||||
if not name or str(name).strip() == "":
|
||||
errors.append(f"Row {i+1}: missing name")
|
||||
continue
|
||||
email = r.get("email") or ""
|
||||
site = r.get("site") or ""
|
||||
try:
|
||||
grade = int(r.get("grade")) if r.get("grade") not in (None, "") else None
|
||||
except Exception:
|
||||
grade = None
|
||||
try:
|
||||
fte_raw = r.get("fte")
|
||||
if fte_raw in (None, ""):
|
||||
fte = 1.0
|
||||
else:
|
||||
fte = float(fte_raw)
|
||||
except Exception:
|
||||
fte = 1.0
|
||||
|
||||
# If an email is provided, try to find an existing worker to
|
||||
# avoid duplicates; otherwise create a new record.
|
||||
existing = None
|
||||
if email:
|
||||
try:
|
||||
existing = Worker.objects.filter(email__iexact=email.strip()).first()
|
||||
except Exception:
|
||||
existing = None
|
||||
|
||||
if existing is not None:
|
||||
w = existing
|
||||
else:
|
||||
w = Worker.objects.create(name=name.strip(), email=email.strip(), site=site.strip(), grade=grade, fte=fte)
|
||||
created.append(w)
|
||||
|
||||
# Assignment: only assign if the worker is not already assigned
|
||||
# to another rota. If already assigned, record an error and
|
||||
# skip assignment.
|
||||
if assign_to_rota and rota is not None:
|
||||
try:
|
||||
if not w.rotas.exists():
|
||||
Assignment.objects.create(worker=w, rota=rota)
|
||||
else:
|
||||
# If already assigned to this rota, nothing to do.
|
||||
assigned_rota = w.rotas.first()
|
||||
if assigned_rota and assigned_rota.pk != rota.pk:
|
||||
errors.append(f"Row {i+1}: worker '{w.name}' already assigned to rota '{assigned_rota.name}'")
|
||||
except Exception:
|
||||
try:
|
||||
if not w.rotas.exists():
|
||||
rota.workers.add(w)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
errors.append(f"Row {i+1}: {exc}")
|
||||
except Exception as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
# If HTMX, return updated worker list partial and close modal
|
||||
if is_hx and rota is not None:
|
||||
worker_list_html = render_to_string(
|
||||
"rota/partials/worker_list.html",
|
||||
{"rota": rota},
|
||||
request=request,
|
||||
)
|
||||
# Return both new worker list and a trigger to close modal
|
||||
resp = f'<div id="worker-list" hx-swap-oob="true">{worker_list_html}</div>'
|
||||
response = HttpResponse(resp)
|
||||
response["HX-Trigger"] = "closeModal"
|
||||
return response
|
||||
|
||||
# Non-HTMX: redirect back to rota detail with message (flash messages not added here)
|
||||
return redirect("rota:rota_detail", rota_id=rota.id if rota is not None else None)
|
||||
|
||||
|
||||
def worker_detail(request, worker_id):
|
||||
worker = get_object_or_404(Worker, pk=worker_id)
|
||||
if request.method == "POST":
|
||||
@@ -345,6 +594,58 @@ def worker_detail(request, worker_id):
|
||||
return render(request, "rota/worker_detail.html", {"worker": worker, "form": form, "leaves": leaves, "leaves_json": leaves_json})
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def worker_leave_add(request, worker_id):
|
||||
"""HTMX endpoint: show a leave form modal for a worker, or create the leave on POST."""
|
||||
worker = get_object_or_404(Worker, pk=worker_id)
|
||||
is_hx = request.headers.get("HX-Request", "false").lower() == "true"
|
||||
|
||||
if request.method == "POST":
|
||||
form = LeaveForm(request.POST)
|
||||
if form.is_valid():
|
||||
leave = form.save(commit=False)
|
||||
leave.worker = worker
|
||||
leave.save()
|
||||
if is_hx:
|
||||
# Close modal and optionally trigger an update; leave list can be refreshed separately
|
||||
response = HttpResponse("")
|
||||
response["HX-Trigger"] = "closeModal"
|
||||
return response
|
||||
return redirect("rota:worker_detail", worker_id=worker.id)
|
||||
|
||||
|
||||
|
||||
# invalid: return modal with errors
|
||||
if is_hx:
|
||||
modal_html = render_to_string("rota/partials/leave_form_modal.html", {"form": form, "worker": worker, "form_action": request.path}, request=request)
|
||||
return HttpResponse(modal_html)
|
||||
# non-HTMX fallthrough
|
||||
# GET: render modal partial
|
||||
form = LeaveForm()
|
||||
if is_hx:
|
||||
modal_html = render_to_string("rota/partials/leave_form_modal.html", {"form": form, "worker": worker, "form_action": request.path}, request=request)
|
||||
return HttpResponse(modal_html)
|
||||
return redirect("rota:worker_detail", worker_id=worker.id)
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
def worker_overview(request, rota_id, worker_id):
|
||||
"""Return a read-only modal overview of the worker (HTMX GET)."""
|
||||
rota = get_object_or_404(RotaSchedule, pk=rota_id)
|
||||
worker = get_object_or_404(Worker, pk=worker_id)
|
||||
try:
|
||||
pretty_options = json.dumps(worker.options or {}, indent=2, default=str)
|
||||
except Exception:
|
||||
pretty_options = str(worker.options or {})
|
||||
|
||||
modal_html = render_to_string(
|
||||
"rota/partials/worker_overview_modal.html",
|
||||
{"worker": worker, "rota": rota, "pretty_options": pretty_options},
|
||||
request=request,
|
||||
)
|
||||
return HttpResponse(modal_html)
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def worker_edit(request, rota_id, worker_id):
|
||||
rota = get_object_or_404(RotaSchedule, pk=rota_id)
|
||||
@@ -364,7 +665,7 @@ def worker_edit(request, rota_id, worker_id):
|
||||
# invalid: return form modal with errors
|
||||
modal_html = render_to_string(
|
||||
"rota/partials/worker_form_modal.html",
|
||||
{"form": form, "form_action": request.get_full_path(), "rota": rota},
|
||||
{"form": form, "form_action": request.get_full_path(), "rota": rota, "focus": request.GET.get('focus') or request.POST.get('focus')},
|
||||
request=request,
|
||||
)
|
||||
return HttpResponse(modal_html)
|
||||
@@ -373,7 +674,7 @@ def worker_edit(request, rota_id, worker_id):
|
||||
form = WorkerForm(instance=worker)
|
||||
modal_html = render_to_string(
|
||||
"rota/partials/worker_form_modal.html",
|
||||
{"form": form, "form_action": request.path, "rota": rota, "worker": worker, "token_link": request.build_absolute_uri(reverse('rota:request_leave_token', args=[worker.request_token])) if worker and getattr(worker, 'request_token', None) else ''},
|
||||
{"form": form, "form_action": request.path, "rota": rota, "worker": worker, "token_link": request.build_absolute_uri(reverse('rota:request_leave_token', args=[worker.request_token])) if worker and getattr(worker, 'request_token', None) else '', "focus": request.GET.get('focus')},
|
||||
request=request,
|
||||
)
|
||||
return HttpResponse(modal_html)
|
||||
@@ -464,6 +765,76 @@ def rota_run_detail(request, run_id):
|
||||
return render(request, "rota/rota_run_detail.html", {"run": run, "result_pretty": result_pretty, "builder_html": builder_html})
|
||||
|
||||
|
||||
def rota_run_status(request, run_id):
|
||||
"""Return a small HTML fragment describing the run status.
|
||||
|
||||
This endpoint is intended to be polled by HTMX. While the run is
|
||||
still running we return a fragment that contains the polling HTMX
|
||||
attributes so the client will continue to poll. Once the run is
|
||||
finished we return a final fragment (no polling attributes) and
|
||||
include a small script to reload the page so the UI updates.
|
||||
"""
|
||||
from .models import RotaRun
|
||||
|
||||
run = get_object_or_404(RotaRun, pk=run_id)
|
||||
|
||||
# If the run is still pending/running, return a self-updating fragment
|
||||
if run.status in (RotaRun.STATUS_PENDING, RotaRun.STATUS_RUNNING):
|
||||
html = (
|
||||
f'<div id="run-status" hx-get="{reverse("rota:rota_run_status", args=[run.id])}" '
|
||||
'hx-trigger="every 3s" hx-swap="outerHTML">'
|
||||
f'<span class="tag is-info">Status: {run.get_status_display()}</span>'
|
||||
f' <small>started: {run.started_at}</small>'
|
||||
f' <small style="margin-left:0.5rem; color:#666;">server_time: {timezone.now().isoformat()}</small>'
|
||||
"</div>"
|
||||
)
|
||||
resp = HttpResponse(html)
|
||||
# Prevent aggressive caching so polling always fetches fresh state
|
||||
resp['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||
resp['Pragma'] = 'no-cache'
|
||||
return resp
|
||||
|
||||
# Finished/failed: return final fragment and reload the page so the
|
||||
# user sees final logs/export without further polling.
|
||||
# Build out-of-band swaps to update the log and result sections in-place
|
||||
from django.utils.html import escape
|
||||
|
||||
# Prepare pretty result if present
|
||||
try:
|
||||
if run.result is not None:
|
||||
import json as _json
|
||||
|
||||
result_pretty = _json.dumps(run.result, indent=2, default=str)
|
||||
else:
|
||||
result_pretty = ""
|
||||
except Exception:
|
||||
result_pretty = str(run.result)
|
||||
|
||||
status_fragment = (
|
||||
f'<div id="run-status"><span class="tag is-success">Status: {escape(run.get_status_display())}</span>'
|
||||
f' <small>finished: {escape(str(run.finished_at))}</small></div>'
|
||||
)
|
||||
|
||||
# OOB swap for the log block
|
||||
log_fragment = f'<div id="run-log" hx-swap-oob="true"><pre>{escape(run.log or "")}</pre></div>'
|
||||
|
||||
# OOB swap for the result block
|
||||
result_fragment = f'<div id="run-result" hx-swap-oob="true"><pre>{escape(result_pretty or "")}</pre></div>'
|
||||
|
||||
# OOB swap for a 'View final output' button
|
||||
view_button = (
|
||||
f'<div id="view-final-output" hx-swap-oob="true">'
|
||||
f'<a class="button is-link" href="{reverse("rota:rota_run_export_html", args=[run.id])}" target="_blank">View final output</a>'
|
||||
"</div>"
|
||||
)
|
||||
|
||||
body = status_fragment + log_fragment + result_fragment + view_button
|
||||
resp = HttpResponse(body)
|
||||
resp['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||
resp['Pragma'] = 'no-cache'
|
||||
return resp
|
||||
|
||||
|
||||
def rota_run_export_html(request, run_id):
|
||||
from .models import RotaRun
|
||||
|
||||
@@ -1339,7 +1710,7 @@ def shift_add(request, rota_id):
|
||||
return response
|
||||
|
||||
# invalid: return modal with form and errors
|
||||
modal_html = render_to_string("rota/partials/shift_form_modal.html", {"form": form, "rota": rota, "form_action": request.path}, request=request)
|
||||
modal_html = render_to_string("rota/partials/shift_form_modal.html", {"form": form, "rota": rota, "form_action": request.path, "focus": request.GET.get('focus') or request.POST.get('focus')}, request=request)
|
||||
return HttpResponse(modal_html)
|
||||
|
||||
else:
|
||||
@@ -1398,7 +1769,7 @@ def shift_edit(request, rota_id, idx):
|
||||
"constraints": json.dumps(shift.get("constraints", [])) if shift.get("constraints") is not None else "",
|
||||
}
|
||||
form = ShiftForm(initial=initial)
|
||||
modal_html = render_to_string("rota/partials/shift_form_modal.html", {"form": form, "rota": rota, "form_action": request.path}, request=request)
|
||||
modal_html = render_to_string("rota/partials/shift_form_modal.html", {"form": form, "rota": rota, "form_action": request.path, "focus": request.GET.get('focus')}, request=request)
|
||||
return HttpResponse(modal_html)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
html {
|
||||
--bg-color: white;
|
||||
--text-color: black;
|
||||
--table-bg: #f9f9f9;
|
||||
--border-color: #ddd;
|
||||
--header-bg: #e0e0e0;
|
||||
--highlight-color: #ffff99;
|
||||
--worker-bg: #f0f0f0;
|
||||
}
|
||||
|
||||
/* color: red; */
|
||||
html.dark-mode {
|
||||
--bg-color: #121212;
|
||||
--text-color: #e0e0e0;
|
||||
--table-bg: #1e1e1e;
|
||||
--border-color: #333;
|
||||
--header-bg: #2a2a2a;
|
||||
--highlight-color: #333300;
|
||||
--worker-bg: #2a2a2a;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
table {
|
||||
@@ -14,7 +34,8 @@ table {
|
||||
position: relative; */
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
|
||||
background-color: var(--table-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
#main-table {
|
||||
@@ -72,12 +93,12 @@ th {
|
||||
}
|
||||
|
||||
.table-div tr:hover {
|
||||
background-color: lightblue;
|
||||
background-color: var(--highlight-color);
|
||||
}
|
||||
|
||||
.table-div tr:hover .unavailable {
|
||||
|
||||
background-color: lightgray;
|
||||
background-color: var(--worker-bg);
|
||||
}
|
||||
|
||||
.Sat {
|
||||
|
||||
@@ -513,6 +513,311 @@ $("#gen-table").each(function () {
|
||||
});
|
||||
});
|
||||
|
||||
// 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 = [];
|
||||
|
||||
+25
-4
@@ -1,6 +1,26 @@
|
||||
html {
|
||||
--bg-color: white;
|
||||
--text-color: black;
|
||||
--table-bg: #f9f9f9;
|
||||
--border-color: #ddd;
|
||||
--header-bg: #e0e0e0;
|
||||
--highlight-color: #ffff99;
|
||||
--worker-bg: #f0f0f0;
|
||||
}
|
||||
|
||||
/* color: red; */
|
||||
html.dark-mode {
|
||||
--bg-color: #121212;
|
||||
--text-color: #e0e0e0;
|
||||
--table-bg: #1e1e1e;
|
||||
--border-color: #333;
|
||||
--header-bg: #2a2a2a;
|
||||
--highlight-color: #333300;
|
||||
--worker-bg: #2a2a2a;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
table {
|
||||
@@ -14,7 +34,8 @@ table {
|
||||
position: relative; */
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
|
||||
background-color: var(--table-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
#main-table {
|
||||
@@ -72,12 +93,12 @@ th {
|
||||
}
|
||||
|
||||
#main-table tr:hover {
|
||||
background-color: lightblue;
|
||||
background-color: var(--highlight-color);
|
||||
}
|
||||
|
||||
#main-table tr:hover .unavailable {
|
||||
|
||||
background-color: lightgray;
|
||||
background-color: var(--worker-bg);
|
||||
}
|
||||
|
||||
.Sat {
|
||||
|
||||
@@ -1162,6 +1162,311 @@ $("#gen-table").each(function () {
|
||||
});
|
||||
});
|
||||
|
||||
// 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 = [];
|
||||
@@ -1398,4 +1703,26 @@ daysOfWeek.forEach(day => {
|
||||
$(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');
|
||||
});
|
||||
});
|
||||
+244
-23
@@ -38,6 +38,131 @@ from loguru import logger
|
||||
|
||||
logger.add("rota.log", rotation="1 MB", level="DEBUG")
|
||||
|
||||
|
||||
def _safe_constraint_info(obj):
|
||||
"""Return a tuple (type_name, safe_dump) for logging without calling repr()
|
||||
|
||||
Avoids triggering pydantic __repr__ which can call __getattr__ and raise
|
||||
when models come from mixed pydantic versions. Prefer model_dump() when
|
||||
available; fall back to __dict__ or the class name. Any failure returns a
|
||||
short error string.
|
||||
"""
|
||||
tname = type(obj).__name__
|
||||
try:
|
||||
# Only call model_dump on real pydantic BaseModel instances. Some
|
||||
# objects may expose a model_dump attribute or similar but aren't
|
||||
# proper BaseModel instances in this runtime; calling model_dump on
|
||||
# those can raise PydanticUserError. Use isinstance check to be safe.
|
||||
if isinstance(obj, BaseModel) and hasattr(obj, "model_dump"):
|
||||
try:
|
||||
return tname, obj.model_dump()
|
||||
except Exception:
|
||||
# fall through to other strategies
|
||||
pass
|
||||
if hasattr(obj, "__dict__"):
|
||||
return tname, dict(obj.__dict__)
|
||||
return tname, str(type(obj))
|
||||
except Exception as e:
|
||||
return tname, f"<dump-failed: {e!r}>"
|
||||
|
||||
|
||||
def _safe_as_dict(obj):
|
||||
"""Return a plain dict view of obj without triggering pydantic __getattr__.
|
||||
|
||||
Prefer model_dump(), then __dict__, then if obj is a mapping return it.
|
||||
Returns None if we couldn't obtain a dict safely.
|
||||
"""
|
||||
try:
|
||||
# Only call model_dump on actual pydantic BaseModel instances. This
|
||||
# avoids raising PydanticUserError for objects that merely expose a
|
||||
# model_dump attribute but aren't proper BaseModel instances.
|
||||
if isinstance(obj, BaseModel) and hasattr(obj, "model_dump"):
|
||||
try:
|
||||
return obj.model_dump()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if isinstance(obj, dict):
|
||||
return obj
|
||||
|
||||
if hasattr(obj, "__dict__"):
|
||||
try:
|
||||
return dict(obj.__dict__)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception:
|
||||
# Defensive: anything going wrong, return None so caller can fall back
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_constraint_weeks(constraint, rota_builder):
|
||||
"""Ensure constraint has a `weeks` value, computing from start/end if needed.
|
||||
|
||||
This avoids directly reading attributes on pydantic models which may trigger
|
||||
problematic __getattr__ behavior in mixed-version environments. If we can
|
||||
extract a dict view, use that; otherwise fall back to guarded getattr/setattr.
|
||||
"""
|
||||
# Don't mutate unknown constraint objects (some can be pydantic objects
|
||||
# from different versions that break __setattr__). Instead compute the
|
||||
# weeks and cache them on the rota_builder to be used by callers.
|
||||
try:
|
||||
if not hasattr(rota_builder, "_constraint_weeks_cache"):
|
||||
rota_builder._constraint_weeks_cache = {}
|
||||
|
||||
key = id(constraint)
|
||||
if key in rota_builder._constraint_weeks_cache:
|
||||
return
|
||||
|
||||
cdict = _safe_as_dict(constraint)
|
||||
if cdict is not None:
|
||||
weeks = cdict.get("weeks", None)
|
||||
if weeks is None:
|
||||
start = cdict.get("start_date", None)
|
||||
end = cdict.get("end_date", None)
|
||||
computed = rota_builder.get_weeks_by_date_range(start, end)
|
||||
rota_builder._constraint_weeks_cache[key] = computed
|
||||
else:
|
||||
rota_builder._constraint_weeks_cache[key] = weeks
|
||||
return
|
||||
|
||||
# Fallback: try getattr but don't attempt to setattr on the object
|
||||
try:
|
||||
weeks = getattr(constraint, "weeks")
|
||||
rota_builder._constraint_weeks_cache[key] = weeks
|
||||
return
|
||||
except Exception:
|
||||
start = getattr(constraint, "start_date", None)
|
||||
end = getattr(constraint, "end_date", None)
|
||||
computed = rota_builder.get_weeks_by_date_range(start, end)
|
||||
rota_builder._constraint_weeks_cache[key] = computed
|
||||
return
|
||||
except Exception:
|
||||
tname, dump = _safe_constraint_info(constraint)
|
||||
logger.exception("Error accessing/setting 'weeks' on constraint type=%s dump=%s", tname, dump)
|
||||
raise
|
||||
|
||||
|
||||
def _get_constraint_weeks(constraint, rota_builder):
|
||||
"""Return the weeks list for a constraint using the cache or safe accessors.
|
||||
|
||||
Never calls repr()/__repr__ on the constraint.
|
||||
"""
|
||||
if hasattr(rota_builder, "_constraint_weeks_cache"):
|
||||
wk = rota_builder._constraint_weeks_cache.get(id(constraint), None)
|
||||
if wk is not None:
|
||||
return wk
|
||||
|
||||
cdict = _safe_as_dict(constraint)
|
||||
if cdict is not None:
|
||||
return cdict.get("weeks", None)
|
||||
|
||||
try:
|
||||
return getattr(constraint, "weeks")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
ShiftName = str
|
||||
DayStr = str
|
||||
WeekInt = int
|
||||
@@ -68,9 +193,21 @@ SHIFT_BOUNDS = {
|
||||
#]
|
||||
|
||||
class BaseShiftConstraint(BaseModel):
|
||||
weeks: Optional[List[int]] = None
|
||||
start_date: Optional[datetime.date] = None
|
||||
end_date: Optional[datetime.date] = None
|
||||
weeks: Optional[List[int]] = Field(
|
||||
None,
|
||||
description=(
|
||||
"List of week numbers when this constraint is active. "
|
||||
"These are selectors that limit WHEN the constraint applies; they must not be used together with start_date/end_date."
|
||||
),
|
||||
)
|
||||
start_date: Optional[datetime.date] = Field(
|
||||
None,
|
||||
description="Inclusive start date for the period when this constraint is active (cannot be used with `weeks`).",
|
||||
)
|
||||
end_date: Optional[datetime.date] = Field(
|
||||
None,
|
||||
description="Inclusive end date for the period when this constraint is active (cannot be used with `weeks`).",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
|
||||
|
||||
@@ -82,26 +219,56 @@ class BaseShiftConstraint(BaseModel):
|
||||
return weeks
|
||||
|
||||
|
||||
#class ShiftConstraint(BaseShiftConstraint):
|
||||
# name: str
|
||||
# options: Any = None
|
||||
class ShiftConstraint(BaseShiftConstraint):
|
||||
"""Compatibility wrapper for legacy constraint dicts of the form
|
||||
{'name': <constraint_name>, 'options': {...}}.
|
||||
|
||||
The DB historically stored constraints as name/options pairs. When we
|
||||
encounter that shape we convert into this wrapper so validation passes
|
||||
and the builder code can still inspect `name`/`options` if needed.
|
||||
"""
|
||||
name: str
|
||||
options: Any = None
|
||||
|
||||
class NightConstraint(BaseShiftConstraint):
|
||||
options: Any = None
|
||||
|
||||
class PreShiftConstraint(BaseShiftConstraint):
|
||||
days: Optional[int] = None
|
||||
ignore_shifts: List[str] = []
|
||||
exclude_days: List[str] = []
|
||||
exclude_dates: List[datetime.date] = []
|
||||
allow_self: bool = True
|
||||
"""Pre-shift constraint.
|
||||
|
||||
Semantics:
|
||||
- `weeks`, `start_date`, `end_date` (inherited) are selectors that determine WHEN the constraint is active (which weeks or date window).
|
||||
- `days` is a DURATION: the number of days the constraint applies for (e.g. 3 means apply the constraint for 3 days relative to the shift). This field is required.
|
||||
- `ignore_shifts`, `exclude_days`, `exclude_dates` further refine which shifts/dates are considered.
|
||||
"""
|
||||
days: int = Field(
|
||||
...,
|
||||
description=(
|
||||
"Duration in days: the number of days the constraint applies for. "
|
||||
"This is different from selector fields (weeks/start_date/end_date) which select WHEN the constraint is active."
|
||||
),
|
||||
)
|
||||
ignore_shifts: List[str] = Field(default_factory=list, description="List of shift names to ignore when applying this constraint")
|
||||
exclude_days: List[str] = Field(default_factory=list, description="Weekday names to exclude (e.g. ['Mon','Tue'])")
|
||||
exclude_dates: List[datetime.date] = Field(default_factory=list, description="Specific dates to exclude from the constraint")
|
||||
allow_self: bool = Field(True, description="Allow the same worker to satisfy this constraint (default: True)")
|
||||
|
||||
class PostShiftConstraint(BaseShiftConstraint):
|
||||
days: Optional[int] = None
|
||||
ignore_shifts: List[str] = []
|
||||
exclude_days: List[str] = []
|
||||
exclude_dates: List[datetime.date] = []
|
||||
allow_self: bool = True
|
||||
"""Post-shift constraint.
|
||||
|
||||
See PreShiftConstraint docs: selectors (weeks/start_date/end_date) choose WHEN the constraint is active; `days` is a duration and is required.
|
||||
"""
|
||||
days: int = Field(
|
||||
...,
|
||||
description=(
|
||||
"Duration in days: the number of days the constraint applies for after the shift. "
|
||||
"Different from selector fields which choose which weeks/dates the constraint is active on."
|
||||
),
|
||||
)
|
||||
ignore_shifts: List[str] = Field(default_factory=list, description="List of shift names to ignore when applying this constraint")
|
||||
exclude_days: List[str] = Field(default_factory=list, description="Weekday names to exclude (e.g. ['Mon','Tue'])")
|
||||
exclude_dates: List[datetime.date] = Field(default_factory=list, description="Specific dates to exclude from the constraint")
|
||||
allow_self: bool = Field(True, description="Allow the same worker to satisfy this constraint (default: True)")
|
||||
|
||||
class BalanceAcrossGroupsConstraint(BaseShiftConstraint):
|
||||
""""""
|
||||
@@ -203,7 +370,12 @@ class SingleShift(BaseModel):
|
||||
hard_constrain_shift: bool = True
|
||||
bank_holidays_only: bool = False
|
||||
#constraint: list[ShiftConstraint] = []
|
||||
constraints: List[BaseModel] = Field(default_factory=list)
|
||||
# NOTE: avoid typing this as List[BaseModel] — instructing pydantic to
|
||||
# validate items as `BaseModel` can cause it to attempt to instantiate
|
||||
# `BaseModel` directly which raises a PydanticUserError. Use the
|
||||
# concrete BaseShiftConstraint base type so pydantic will validate into
|
||||
# actual constraint subclasses instead of BaseModel itself.
|
||||
constraints: List["BaseShiftConstraint"] = Field(default_factory=list)
|
||||
|
||||
start_date: datetime.date | None = None
|
||||
end_date: datetime.date | None = None
|
||||
@@ -217,6 +389,42 @@ class SingleShift(BaseModel):
|
||||
)
|
||||
|
||||
def __init__(self, **data: Any):
|
||||
# Backwards-compat: accept legacy `constraints` list entries in the
|
||||
# form {'name': ..., 'options': {...}} and convert them to concrete
|
||||
# pydantic constraint models before validation. This avoids errors
|
||||
# where the DB stored legacy name/options pairs.
|
||||
if "constraints" in data and isinstance(data["constraints"], (list, tuple)):
|
||||
converted = []
|
||||
_name_map = {
|
||||
"pre": PreShiftConstraint,
|
||||
"post": PostShiftConstraint,
|
||||
"night": NightConstraint,
|
||||
"balance_across_groups": BalanceAcrossGroupsConstraint,
|
||||
"min_summed_grade_by_shifts_per_day": MinSummedGradeByShiftsPerDayConstraint,
|
||||
"limit_grade_number": LimitGradeNumberConstraint,
|
||||
"minimum_grade_number": MinimumGradeNumberConstraint,
|
||||
"require_remote_site_presence": RequireRemoteSitePresenceConstraint,
|
||||
"max_shifts_per_week": MaxShiftsPerWeekConstraint,
|
||||
"max_shifts_per_week_block": MaxShiftsPerWeekBlockConstraint,
|
||||
}
|
||||
for item in data["constraints"]:
|
||||
if isinstance(item, dict) and "name" in item:
|
||||
cname = item.get("name")
|
||||
opts = item.get("options") or {}
|
||||
cls = _name_map.get(cname)
|
||||
if cls is not None:
|
||||
try:
|
||||
inst = cls.model_validate(opts) if hasattr(cls, "model_validate") else cls(**(opts or {}))
|
||||
except Exception:
|
||||
# fallback to generic wrapper
|
||||
inst = ShiftConstraint(name=cname, options=opts)
|
||||
else:
|
||||
inst = ShiftConstraint(name=cname, options=opts)
|
||||
converted.append(inst)
|
||||
else:
|
||||
converted.append(item)
|
||||
data["constraints"] = converted
|
||||
|
||||
if "constraint" in data:
|
||||
raise ValueError(
|
||||
"The 'constraint' field on SingleShift has been deprecated and removed. "
|
||||
@@ -1253,8 +1461,19 @@ class RotaBuilder(object):
|
||||
|
||||
|
||||
for constraint in self.constraint_options_model.min_summed_grade_by_shifts_per_day:
|
||||
weeks = constraint.weeks if constraint.weeks is not None else self.weeks
|
||||
days_list = constraint.days if constraint.days is not None else self.days
|
||||
# Safely extract weeks/days without invoking pydantic __getattr__
|
||||
cdict = _safe_as_dict(constraint)
|
||||
if cdict is not None:
|
||||
weeks = cdict.get("weeks") if cdict.get("weeks") is not None else self.weeks
|
||||
days_list = cdict.get("days") if cdict.get("days") is not None else self.days
|
||||
else:
|
||||
try:
|
||||
weeks = constraint.weeks if constraint.weeks is not None else self.weeks
|
||||
days_list = constraint.days if constraint.days is not None else self.days
|
||||
except Exception:
|
||||
tname, dump = _safe_constraint_info(constraint)
|
||||
logger.exception("Error accessing 'weeks' or 'days' on constraint type=%s dump=%s", tname, dump)
|
||||
raise
|
||||
for week in weeks:
|
||||
for day in days_list:
|
||||
# Only consider if all shifts are present that day
|
||||
@@ -3317,7 +3536,8 @@ class RotaBuilder(object):
|
||||
ignore_shifts = constraint.ignore_shifts + [constraint_shift.name]
|
||||
else:
|
||||
ignore_shifts = constraint.ignore_shifts
|
||||
if constraint.weeks is not None and week not in constraint.weeks:
|
||||
weeks = _get_constraint_weeks(constraint, self)
|
||||
if weeks is not None and week not in weeks:
|
||||
continue
|
||||
date_main = self.get_date_by_week_day(week, day)
|
||||
for n in range(0, constraint.days):
|
||||
@@ -3367,7 +3587,8 @@ class RotaBuilder(object):
|
||||
ignore_shifts = constraint.ignore_shifts + [constraint_shift.name]
|
||||
else:
|
||||
ignore_shifts = constraint.ignore_shifts
|
||||
if constraint.weeks is not None and week not in constraint.weeks:
|
||||
weeks = _get_constraint_weeks(constraint, self)
|
||||
if weeks is not None and week not in weeks:
|
||||
continue
|
||||
date_main = self.get_date_by_week_day(week, day)
|
||||
for n in range(0, constraint.days):
|
||||
@@ -4114,8 +4335,8 @@ class RotaBuilder(object):
|
||||
self.sites.add(site)
|
||||
|
||||
for constraint in s.constraints:
|
||||
if constraint.weeks is None:
|
||||
constraint.weeks = self.get_weeks_by_date_range(constraint.start_date, constraint.end_date)
|
||||
# Use a safe accessor to avoid triggering pydantic __getattr__ bugs
|
||||
_ensure_constraint_weeks(constraint, self)
|
||||
|
||||
self.shift_counts = defaultdict(int)
|
||||
self.shift_worker_counts = defaultdict(int)
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to generate a small rota for testing ICS export functionality.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add the project root to Python path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from rota_generator.shifts import RotaBuilder, SingleShift, WorkerRequirement, days, NightConstraint, PreShiftConstraint
|
||||
from rota_generator.workers import Worker, NotAvailableToWork, NonWorkingDays
|
||||
|
||||
def create_test_rota():
|
||||
"""Create a small test rota with a few shifts and workers."""
|
||||
|
||||
# Define sites
|
||||
sites = ("truro", "exeter", "plymouth")
|
||||
|
||||
# Create rota builder
|
||||
rota_start_date = datetime.date(2025, 12, 29) # Start from today-ish
|
||||
Rota = RotaBuilder(
|
||||
rota_start_date,
|
||||
weeks_to_rota=4, # Small rota for testing
|
||||
balance_offset_modifier=2,
|
||||
name="test_rota",
|
||||
)
|
||||
|
||||
# Add some shifts
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("truro", "exeter", "plymouth"),
|
||||
name="night_weekday",
|
||||
length=12.25,
|
||||
days=days[:5], # Mon-Fri
|
||||
balance_offset=3.9,
|
||||
workers_required=1,
|
||||
force_as_block=True,
|
||||
constraints=[NightConstraint(), PreShiftConstraint(days=1)],
|
||||
),
|
||||
SingleShift(
|
||||
sites=("truro", "exeter", "plymouth"),
|
||||
name="weekend_truro",
|
||||
length=12.5,
|
||||
days=days[5:], # Sat-Sun
|
||||
balance_offset=3,
|
||||
workers_required=1,
|
||||
force_as_block=True,
|
||||
constraints=[PreShiftConstraint(days=1)],
|
||||
),
|
||||
SingleShift(
|
||||
sites=("plymouth",),
|
||||
name="plymouth_twilight",
|
||||
length=12.5,
|
||||
days=days[:5],
|
||||
balance_offset=4,
|
||||
workers_required=1,
|
||||
),
|
||||
)
|
||||
|
||||
# Add some test workers
|
||||
workers = [
|
||||
Worker(
|
||||
name="Alice",
|
||||
site="truro",
|
||||
fte=100, # 1.0 FTE
|
||||
start_date=rota_start_date,
|
||||
end_date=None,
|
||||
grade=1,
|
||||
initials="AL",
|
||||
),
|
||||
Worker(
|
||||
name="Bob",
|
||||
site="exeter",
|
||||
fte=100, # 1.0 FTE
|
||||
start_date=rota_start_date,
|
||||
end_date=None,
|
||||
grade=1,
|
||||
initials="BO",
|
||||
),
|
||||
Worker(
|
||||
name="Charlie",
|
||||
site="plymouth",
|
||||
fte=100, # 1.0 FTE
|
||||
start_date=rota_start_date,
|
||||
end_date=None,
|
||||
grade=1,
|
||||
initials="CH",
|
||||
),
|
||||
Worker(
|
||||
name="Chester",
|
||||
site="plymouth",
|
||||
fte=100, # 1.0 FTE
|
||||
start_date=rota_start_date,
|
||||
end_date=None,
|
||||
grade=1,
|
||||
initials="CH",
|
||||
),
|
||||
Worker(
|
||||
name="Dino",
|
||||
site="truro",
|
||||
fte=100, # 0.8 FTE
|
||||
start_date=rota_start_date,
|
||||
end_date=None,
|
||||
grade=2,
|
||||
initials="DI",
|
||||
),
|
||||
Worker(
|
||||
name="Diana",
|
||||
site="truro",
|
||||
fte=80, # 0.8 FTE
|
||||
start_date=rota_start_date,
|
||||
end_date=None,
|
||||
grade=2,
|
||||
initials="DI",
|
||||
),
|
||||
]
|
||||
|
||||
Rota.add_workers(workers)
|
||||
|
||||
# Build and solve
|
||||
Rota.build_and_solve(options={"ratio": 0.1, "seconds": 30, "threads": 10})
|
||||
|
||||
return Rota
|
||||
|
||||
def generate_html_output(rota, output_dir="output/test"):
|
||||
"""Generate HTML output for the rota."""
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# Use the built-in export method
|
||||
rota.export_rota_to_html(filename="index")
|
||||
|
||||
html_path = os.path.join("output", "test", "index.html")
|
||||
|
||||
print(f"Test rota HTML generated at: {html_path}")
|
||||
print("Open this file in a web browser to test the ICS export functionality.")
|
||||
|
||||
return html_path
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Generating test rota...")
|
||||
|
||||
try:
|
||||
rota = create_test_rota()
|
||||
html_path = generate_html_output(rota)
|
||||
|
||||
print("\nTest rota generation complete!")
|
||||
print(f"To test ICS export, open: file://{os.path.abspath(html_path)}")
|
||||
print("Then use the 'Set Shift Times' button to configure times and download ICS files.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error generating test rota: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
Reference in New Issue
Block a user