diff --git a/djangorota/djangorota/settings.py b/djangorota/djangorota/settings.py index 1163d21..0e403a5 100644 --- a/djangorota/djangorota/settings.py +++ b/djangorota/djangorota/settings.py @@ -129,6 +129,8 @@ STATICFILES_DIRS = [ BASE_DIR / "output", # also look for output/ at the repository root (BASE_DIR.parent) BASE_DIR.parent / "output", + # repository-level static folder (workspace root) + BASE_DIR.parent / "static", ] # Default primary key field type diff --git a/djangorota/djangorota/templates/base.html b/djangorota/djangorota/templates/base.html index 2e515dd..5e7b005 100644 --- a/djangorota/djangorota/templates/base.html +++ b/djangorota/djangorota/templates/base.html @@ -31,6 +31,69 @@ + + + + {% block scripts %}{% endblock %} diff --git a/djangorota/djangorota/templates/rota/leave_request.html b/djangorota/djangorota/templates/rota/leave_request.html index 17bbef9..54a8e0c 100644 --- a/djangorota/djangorota/templates/rota/leave_request.html +++ b/djangorota/djangorota/templates/rota/leave_request.html @@ -1,5 +1,5 @@ {% extends 'base.html' %} -{% load crispy_forms_tags %} +{% load crispy_forms_tags static %} {% block content %}

Request leave{% if worker %} for {{ worker.name }}{% endif %}

@@ -7,6 +7,7 @@

Request leave on calendar

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

Click a start date then a second date to select a range and prefill the leave form.

@@ -20,108 +21,35 @@
+ {% if worker %} +

Existing leave for {{ worker.name }}

+
+ {% include 'rota/partials/leave_list.html' with leaves=worker.leaves.all %} +
+ {% else %} +

If you are requesting leave for a specific worker, open this page using the worker link so existing requests will be shown.

+ {% endif %} + +
+ Legend: + Existing leave + Selected range +
- - + {% endblock %} diff --git a/djangorota/djangorota/templates/rota/partials/leave_list.html b/djangorota/djangorota/templates/rota/partials/leave_list.html new file mode 100644 index 0000000..c5959d7 --- /dev/null +++ b/djangorota/djangorota/templates/rota/partials/leave_list.html @@ -0,0 +1,10 @@ +{% comment %}Render a simple leave list with data attributes so client JS can read them{% endcomment %} +
+ +
diff --git a/djangorota/djangorota/templates/rota/partials/leave_request_modal.html b/djangorota/djangorota/templates/rota/partials/leave_request_modal.html index 0f7bd10..4bb497e 100644 --- a/djangorota/djangorota/templates/rota/partials/leave_request_modal.html +++ b/djangorota/djangorota/templates/rota/partials/leave_request_modal.html @@ -4,7 +4,7 @@ + diff --git a/djangorota/rota/urls.py b/djangorota/rota/urls.py index eb98f68..c1e5b79 100644 --- a/djangorota/rota/urls.py +++ b/djangorota/rota/urls.py @@ -16,6 +16,7 @@ urlpatterns = [ path("worker///edit/", views.worker_edit, name="worker_edit"), path("worker///delete/", views.worker_delete, name="worker_delete"), path("worker//", views.worker_detail, name="worker_detail"), + path("worker//leaves.json", views.leaves_json, name="worker_leaves_json"), path("leave/request/", views.request_leave, name="request_leave"), path("leave/request/token//", views.request_leave, name="request_leave_token"), 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 9d6aae6..1036d52 100644 --- a/djangorota/rota/views.py +++ b/djangorota/rota/views.py @@ -9,6 +9,7 @@ import json from django.views.decorators.http import require_http_methods from django.template.loader import render_to_string from django.http import HttpResponse, HttpResponseBadRequest +from django.http import JsonResponse from django.views.decorators.http import require_POST from django.shortcuts import HttpResponseRedirect import traceback @@ -508,10 +509,19 @@ def request_leave(request, token=None): # On HTMX modal post, return an OOB swap to close modal and optionally # update worker detail partial if present. if is_hx: - resp = '' - # optionally include trigger + # Render updated leave list and return OOB swaps: update leave-list and clear modal. + leave_list_html = render_to_string( + "rota/partials/leave_list.html", + {"leaves": worker.leaves.all()}, + request=request, + ) + resp = ( + f'
{leave_list_html}
' + + '' + ) response = HttpResponse(resp) - response["HX-Trigger"] = "closeModal" + # Trigger modal close and a leaveUpdated event so client can refresh calendar highlights + response["HX-Trigger"] = "closeModal,leaveUpdated" return response return redirect("rota:worker_detail", worker_id=worker.id) else: @@ -929,6 +939,19 @@ def rota_run_export_download(request, run_id): return response +def leaves_json(request, worker_id): + """Return JSON list of leave ranges for a given worker.""" + worker = get_object_or_404(Worker, pk=worker_id) + data = [] + for l in worker.leaves.all().order_by('start_date'): + data.append({ + 'start': l.start_date.isoformat(), + 'end': l.end_date.isoformat(), + 'reason': l.reason, + }) + return JsonResponse({'leaves': data}) + + @require_POST def rota_run_export_regenerate(request, run_id): """Regenerate the HTML export for a completed run and persist it on the RotaRun. diff --git a/static/css/calendar.css b/static/css/calendar.css new file mode 100644 index 0000000..818bfc5 --- /dev/null +++ b/static/css/calendar.css @@ -0,0 +1,12 @@ +/* Shared calendar styles */ +.calendar { display:flex; flex-direction:column; gap:0.5rem; margin-bottom:1rem; max-width:900px; margin-left:auto; margin-right:auto } +.calendar .week { display:flex; gap:0.5rem; } +.calendar .day { flex:1; padding:0.5rem; border:1px solid #eee; min-height:3rem; cursor:pointer; text-align:center } +.calendar .day.other-month { background:#f9f9f9; color:#999; } +.calendar .day.leave { background:#ffecec; } +.calendar .day.selected { color:#2b8fd6; font-weight:700 } +.calendar .day.in-range { color:#2b8fd6; font-weight:600 } +.calendar .day:hover { box-shadow: 0 1px 2px rgba(0,0,0,0.05); } +.calendar .day.day-header { background:transparent; border:0; padding:0.25rem; min-height:unset } +#calendar-controls { margin-top:0.5rem } +#calendar-controls .button { margin-left:0.5rem } diff --git a/static/js/calendar.js b/static/js/calendar.js new file mode 100644 index 0000000..15dd4d9 --- /dev/null +++ b/static/js/calendar.js @@ -0,0 +1,149 @@ +/* Shared calendar JS + Usage: call initCalendar(containerId, opts) + opts: + workerId: optional worker id + leaves: array of {start:'YYYY-MM-DD', end:'YYYY-MM-DD'} + requestUrl: url to use for requesting leave (GET will be used with query params) + mode: 'inline' or other +*/ +(function(){ + // Keep a registry of calendar instances so external code (eg. modal handlers) + // can clear selections when needed. + window._calendars = window._calendars || {}; + + function iso(y,m,d){ return `${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}` } + + function initCalendar(containerId, opts){ + const container = document.getElementById(containerId); + if(!container) return; + const workerId = opts.workerId || container.dataset.workerId || ''; + let leaves = opts.leaves || []; + const leavesUrl = opts.leavesUrl || null; + const requestUrl = opts.requestUrl || (window.location.origin + '/leave/request/'); + + let viewYear = new Date().getFullYear(); + 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 buildControls(){ + let controls = document.getElementById('calendar-controls'); + if(!controls){ + controls = document.createElement('div'); controls.id='calendar-controls'; + controls.innerHTML = `No dates selected `; + container.parentNode.insertBefore(controls, container.nextSibling); + document.getElementById('sel-clear').onclick = function(){ selStart = null; selEnd = null; previewEnd = null; render(); }; + document.getElementById('sel-confirm').onclick = function(){ + if(!selStart) return; + if(!selEnd) selEnd = selStart; + const startInput = document.querySelector('input[name="start_date"]'); + const endInput = document.querySelector('input[name="end_date"]'); + if(startInput) startInput.value = selStart; + if(endInput) endInput.value = selEnd; + if(typeof initFlatpickr === 'function') initFlatpickr(); + const form = document.querySelector('form'); if(form) form.scrollIntoView({behavior:'smooth'}); + }; + } + const label = document.getElementById('sel-label'); + if(selStart && selEnd) label.textContent = `Selected: ${selStart} → ${selEnd}`; + else if(selStart) label.textContent = `Start: ${selStart}`; + else label.textContent = 'No dates selected'; + document.getElementById('sel-confirm').disabled = !selStart; + } + + function render(){ + container.innerHTML=''; + const first = new Date(viewYear, viewMonth, 1); + const startDay = first.getDay(); + const days = new Date(viewYear, viewMonth+1, 0).getDate(); + let cur = 1 - startDay; + + const header = document.createElement('div'); header.style.display='flex'; header.style.justifyContent='space-between'; header.style.alignItems='center'; header.style.marginBottom='0.5rem'; + const title = document.createElement('div'); title.textContent = first.toLocaleString(undefined,{month:'long', year:'numeric'}); + const prev = document.createElement('button'); prev.textContent='◀'; prev.className='button'; prev.onclick=()=>{ viewMonth--; if(viewMonth<0){ viewMonth=11; viewYear--; } render(); }; + const next = document.createElement('button'); next.textContent='▶'; next.className='button'; next.onclick=()=>{ viewMonth++; if(viewMonth>11){ viewMonth=0; viewYear++; } render(); }; + header.appendChild(prev); header.appendChild(title); header.appendChild(next); + container.appendChild(header); + + // Day-of-week header row + const dow = document.createElement('div'); dow.className='week'; dow.style.fontWeight='bold'; + const dayNames = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; + for(const dn of dayNames){ const dh = document.createElement('div'); dh.className='day day-header'; dh.style.textAlign='center'; dh.style.padding='0.25rem'; dh.textContent = dn; dow.appendChild(dh); } + container.appendChild(dow); + + while(cur<=days){ + const week = document.createElement('div'); week.className='week'; + for(let i=0;i<7;i++){ + const dayDiv = document.createElement('div'); dayDiv.className='day'; + if(cur<1 || cur>days){ dayDiv.classList.add('other-month'); dayDiv.textContent=''; } + else{ + const isoStr = iso(viewYear, viewMonth, cur); + dayDiv.textContent = cur; + if(inLeaves(isoStr)) dayDiv.classList.add('leave'); + 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'); + dayDiv.dataset.iso = isoStr; + dayDiv.onclick = function(){ + const clicked = this.dataset.iso; + if(!selStart){ selStart = clicked; selEnd = null; } + else if(selStart && !selEnd){ if(clicked < selStart){ selEnd = selStart; selStart = clicked } else selEnd = clicked } + else { selStart = clicked; selEnd = null } + previewEnd = null; + render(); + // Auto-confirm on range completion: open request URL with params + if(selStart && selEnd){ + const url = requestUrl + '?worker_id=' + encodeURIComponent(workerId) + '&start_date=' + selStart + '&end_date=' + selEnd; + try{ htmx.ajax('GET', url, {target:'#modal', swap:'innerHTML'}); }catch(e){ window.location = url; } + } + }; + dayDiv.onmouseover = function(){ if(selStart && !selEnd){ previewEnd = this.dataset.iso; render(); } }; + dayDiv.onmouseout = function(){ if(selStart && !selEnd){ previewEnd = null; render(); } }; + } + week.appendChild(dayDiv); + cur++; + } + container.appendChild(week); + } + buildControls(); + } + + // expose a simple API to clear the current selection from outside. + function clearSelection(){ selStart = null; selEnd = null; previewEnd = null; render(); } + function setLeaves(newLeaves){ leaves = newLeaves || []; render(); } + async function refreshLeaves(){ + if(leavesUrl){ + try{ + const res = await fetch(leavesUrl, { credentials: 'same-origin' }); + 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; + } + } + }catch(e){ console.error('refreshLeaves failed', e); } + } + // fallback: do nothing + } + + window._calendars[containerId] = { clearSelection, setLeaves, refreshLeaves }; + + render(); + // If a server leavesUrl is provided, refresh from server to ensure authoritative data + if(leavesUrl){ + // don't await here — fire-and-forget refresh + refreshLeaves(); + } + } + + // provide a global helper to clear selections across all calendars + window.clearAllCalendarSelections = function(){ + try{ + if(window._calendars){ Object.values(window._calendars).forEach(c => c.clearSelection && c.clearSelection()); } + }catch(e){ console.error('clearAllCalendarSelections', e); } + }; + + // expose + window.initCalendar = initCalendar; +})();