.
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user