From 2482f6a04a5bf61343d4aff6cdffcdc091f4422b Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 15 Dec 2025 22:07:50 +0000 Subject: [PATCH] . --- .../templates/rota/leaves_global.html | 70 +++++++++++++++++++ .../templates/rota/rota_detail.html | 5 +- djangorota/rota/urls.py | 2 + djangorota/rota/views.py | 46 ++++++++++++ static/js/calendar.js | 34 ++++++++- 5 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 djangorota/djangorota/templates/rota/leaves_global.html diff --git a/djangorota/djangorota/templates/rota/leaves_global.html b/djangorota/djangorota/templates/rota/leaves_global.html new file mode 100644 index 0000000..f9ab44b --- /dev/null +++ b/djangorota/djangorota/templates/rota/leaves_global.html @@ -0,0 +1,70 @@ +{% extends 'base.html' %} +{% load crispy_forms_tags static %} +{% block content %} +
+

All leave requests

+ +
+

Global leave calendar

+ {% include 'rota/partials/flatpickr_includes.html' %} + +
+

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).

+ {% if request.GET.rota_id %} +

Showing leaves for rota id {{ request.GET.rota_id }}.

+ {% endif %} +
+ +
+ Legend: + Existing leave + Selected range +
+
+ + + + + + {% if request.GET.rota_id %} +
+

Workers on this rota

+
+
Loading legend…
+
+
+ + {% endif %} +{% endblock %} diff --git a/djangorota/djangorota/templates/rota/rota_detail.html b/djangorota/djangorota/templates/rota/rota_detail.html index beed00a..e13454c 100644 --- a/djangorota/djangorota/templates/rota/rota_detail.html +++ b/djangorota/djangorota/templates/rota/rota_detail.html @@ -12,7 +12,10 @@

Period: {{ rota.start_date }} → {{ rota.end_date }}

-

+

+ + All leave requests +

{% include 'rota/partials/worker_list.html' %}
diff --git a/djangorota/rota/urls.py b/djangorota/rota/urls.py index e6fec72..0797bb8 100644 --- a/djangorota/rota/urls.py +++ b/djangorota/rota/urls.py @@ -20,6 +20,8 @@ urlpatterns = [ path("leave/request/", views.request_leave, name="request_leave"), path("leave/request/token//", views.request_leave, name="request_leave_token"), path("worker/settings/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//delete/", views.leave_delete, name="leave_delete"), path("worker//leaves/delete_all/", views.leave_delete_all, name="leave_delete_all"), path("worker//regenerate_token/", views.regenerate_worker_token, name="regenerate_worker_token"), diff --git a/djangorota/rota/views.py b/djangorota/rota/views.py index b9eb57f..defb184 100644 --- a/djangorota/rota/views.py +++ b/djangorota/rota/views.py @@ -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.""" diff --git a/static/js/calendar.js b/static/js/calendar.js index 7651ec4..45fc27f 100644 --- a/static/js/calendar.js +++ b/static/js/calendar.js @@ -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); }