This commit is contained in:
Ross
2025-12-15 22:07:50 +00:00
parent e99ab2c1fa
commit 2482f6a04a
5 changed files with 153 additions and 4 deletions
@@ -0,0 +1,70 @@
{% extends 'base.html' %}
{% load crispy_forms_tags static %}
{% block content %}
<div class="container">
<h2 class="title">All leave requests</h2>
<div class="box">
<h3 class="subtitle">Global leave calendar</h3>
{% include 'rota/partials/flatpickr_includes.html' %}
<link rel="stylesheet" href="{% static 'css/calendar.css' %}">
<div id="calendar" class="calendar" data-mode="inline"></div>
<p class="help">View all leave requests across workers. Click and drag to select dates to open the leave form (you'll need to choose a worker in the modal).</p>
{% if request.GET.rota_id %}
<p class="help">Showing leaves for rota id {{ request.GET.rota_id }}.</p>
{% endif %}
</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>
</div>
<script id="worker_leaves_json" type="application/json">{{ leaves_json|safe }}</script>
<script src="{% static 'js/calendar.js' %}"></script>
<script>
(function(){
const container = document.getElementById('calendar');
if(!container) return;
let leaves = [];
try {
const raw = document.getElementById('worker_leaves_json');
if (raw) {
leaves = JSON.parse(raw.textContent || raw.innerText || '[]');
}
} catch (e) {
console.debug('Unable to parse embedded leaves JSON', e);
}
// Provide a leavesUrl so the calendar will fetch the authoritative all-leaves list
// Pass rota_id if present in the querystring so the server filters to rota workers.
const rotaParam = new URLSearchParams(window.location.search).get('rota_id');
let leavesUrl = '{% url "rota:leaves_global_json" %}';
if(rotaParam) leavesUrl += '?rota_id=' + encodeURIComponent(rotaParam);
window.initCalendar('calendar', { leaves: leaves, leavesUrl: leavesUrl, requestUrl: '{% url "rota:request_leave" %}', mode: container.dataset.mode || 'inline' });
})();
</script>
{% if request.GET.rota_id %}
<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">Loading legend…</div>
</div>
</div>
<script>
(function(){
const legend = document.getElementById('rota-worker-legend');
const rotaParam = new URLSearchParams(window.location.search).get('rota_id');
if(!rotaParam) return;
fetch('{% url "rota:leaves_global_json" %}?rota_id=' + encodeURIComponent(rotaParam), {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); });
})();
</script>
{% endif %}
{% endblock %}
@@ -12,7 +12,10 @@
<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></p>
<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_id={{ rota.id }}" style="margin-left:0.5rem;">All leave requests</a>
</p>
{% include 'rota/partials/worker_list.html' %}
</div>
+2
View File
@@ -20,6 +20,8 @@ urlpatterns = [
path("leave/request/", views.request_leave, name="request_leave"),
path("leave/request/token/<uuid:token>/", views.request_leave, name="request_leave_token"),
path("worker/settings/token/<uuid:token>/", views.worker_settings_token, name="worker_settings_token"),
path("leaves/all/", views.leaves_global, name="leaves_global"),
path("leaves/all.json", views.leaves_global_json, name="leaves_global_json"),
path("leave/<int:leave_id>/delete/", views.leave_delete, name="leave_delete"),
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"),
+46
View File
@@ -1039,6 +1039,52 @@ def leaves_json(request, worker_id):
return JsonResponse({'leaves': data})
def leaves_global_json(request):
"""Return JSON list of all leave ranges across all workers."""
from .models import Leave
# Optionally filter by rota_id (provided as query param) so the global
# calendar can be scoped to a particular rota's workers.
rota_id = request.GET.get('rota_id')
qs = Leave.objects.select_related('worker')
if rota_id:
try:
rota_id_i = int(rota_id)
qs = qs.filter(worker__rotas=rota_id_i)
except Exception:
pass
# Build a worker->color map for workers present in the query so the
# client can color-code leaves by worker.
worker_colors = {}
def color_for_worker(pk):
# deterministic hue based on id
h = (int(pk) * 37) % 360
return f'hsl({h} 65% 85%)'
data = []
for leave in qs.all().order_by('start_date'):
worker = getattr(leave, 'worker', None)
wid = getattr(worker, 'id', None)
wname = getattr(worker, 'name', '') if worker else ''
if wid not in worker_colors and wid is not None:
worker_colors[wid] = color_for_worker(wid)
data.append({
'start': leave.start_date.isoformat(),
'end': leave.end_date.isoformat(),
'reason': wname or (leave.reason or ''),
'worker_id': wid,
'color': worker_colors.get(wid),
})
return JsonResponse({'leaves': data})
def leaves_global(request):
"""Page showing a global calendar with all leave requests."""
# Provide an empty JSON payload initially; the calendar will fetch the authoritative list
leaves_json = '[]'
return render(request, 'rota/leaves_global.html', {'leaves_json': leaves_json})
@require_POST
def leave_delete(request, leave_id):
"""Delete a Leave record. Allowed if the requester is staff, or if a valid token matches the worker, or the authenticated user's email matches the worker email."""
+31 -3
View File
@@ -29,7 +29,11 @@
let viewMonth = new Date().getMonth();
let selStart = null; let selEnd = null; let previewEnd = null;
function inLeaves(isoStr){ for(const L of leaves){ if(!L.start) continue; if(L.start<=isoStr && isoStr<=L.end) return true } return false }
function inLeaves(isoStr){
const matches = [];
for(const L of leaves){ if(!L.start) continue; if(L.start<=isoStr && isoStr<=L.end) matches.push(L); }
return matches;
}
function buildControls(){
let controls = document.getElementById('calendar-controls');
@@ -84,7 +88,31 @@
else{
const isoStr = iso(viewYear, viewMonth, cur);
dayDiv.textContent = cur;
if(inLeaves(isoStr)) dayDiv.classList.add('leave');
const matches = inLeaves(isoStr);
if(matches && matches.length){
dayDiv.classList.add('leave');
try{
// collect distinct colors for this day's matches
const colors = Array.from(new Set(matches.map(x=>x.color).filter(Boolean)));
if(colors.length===1){
dayDiv.style.backgroundImage = ''; // clear any previous gradient
dayDiv.style.backgroundColor = colors[0];
} else if(colors.length>1){
// build an equal-split horizontal gradient (cap to 6 colours)
const n = Math.min(colors.length, 6);
const seg = 100 / n;
const stops = colors.slice(0,n).map((c,i)=>{
const start = Math.round(i*seg);
const end = Math.round((i+1)*seg);
return `${c} ${start}% ${end}%`;
}).join(', ');
dayDiv.style.backgroundColor = '';
dayDiv.style.backgroundImage = `linear-gradient(90deg, ${stops})`;
}
}catch(e){ /* defensive: leave default styling */ }
// set tooltip to show reasons / worker names for overlapping leaves
try{ dayDiv.title = matches.map(x => x.reason || '').filter(Boolean).join(', '); }catch(e){}
}
const rangeEnd = selEnd || previewEnd;
if(selStart && rangeEnd){ if(selStart<=isoStr && isoStr<=rangeEnd) dayDiv.classList.add('in-range'); }
else if(selStart && selStart===isoStr) dayDiv.classList.add('selected');
@@ -113,7 +141,7 @@
if(res.ok){
const data = await res.json();
if(data && Array.isArray(data.leaves)){
setLeaves(data.leaves.map(l=>({start:l.start, end:l.end, reason:l.reason}))); return;
setLeaves(data.leaves.map(l=>({start:l.start, end:l.end, reason:l.reason, color:l.color, worker_id:l.worker_id}))); return;
}
}
}catch(e){ console.error('refreshLeaves failed', e); }