This commit is contained in:
Ross
2025-12-13 22:18:42 +00:00
parent a89aa0a35b
commit d509922450
5 changed files with 238 additions and 0 deletions
@@ -0,0 +1,16 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container">
<h2 class="title">Request leave{% if worker %} for {{ worker.name }}{% endif %}</h2>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<div class="field">
<div class="control">
<button class="button is-primary" type="submit">Request</button>
</div>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,25 @@
{% 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">Request leave{% if worker %} for {{ worker.name }}{% endif %}</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 %}
{% if worker %}
<input type="hidden" name="worker_id" value="{{ worker.id }}" />
{% endif %}
{{ form|crispy }}
<div class="field">
<div class="control">
<button class="button is-primary" type="submit">Request</button>
<button type="button" class="button" onclick="document.getElementById('modal').innerHTML=''">Cancel</button>
</div>
</div>
</form>
</section>
</div>
</div>
@@ -124,6 +124,140 @@
// initial render
try{ render(); }catch(e){ console && console.error && console.error(e); }
})();
</script>
<!-- Additional structured editors for other worker fields -->
<div class="box" id="other-editors">
<h4 class="title is-6">Other structured fields</h4>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
</div>
</div>
<script>
(function(){
// Helper to create a simple list editor bound to a textarea id
function makeSimpleListEditor(cfg){
const textarea = document.getElementById(cfg.textarea);
if(textarea) textarea.style.display='none';
const listEl = document.getElementById(cfg.container);
const addBtn = document.getElementById(cfg.addButton);
function parse(){
let v = textarea ? textarea.value : ''; if(!v) return [];
try{ const p = JSON.parse(v); return Array.isArray(p)?p:[];}catch(e){return [];}
}
function render(){ listEl.innerHTML=''; const data = parse(); data.forEach((it,i)=> listEl.appendChild(rowFor(it,i))); }
function rowFor(item, idx){
const wrap = document.createElement('div'); wrap.className='box';
const row = document.createElement('div'); row.className='columns';
cfg.fields.forEach(f=>{
const col = document.createElement('div'); col.className='column';
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] || '';
}
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);
wrap.appendChild(row);
try{ if(window.initFlatpickr) initFlatpickr(wrap); }catch(e){}
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 removeAt(i){ const data = parse(); data.splice(i,1); if(textarea) textarea.value = JSON.stringify(data); render(); }
addBtn.addEventListener('click', ()=>{ const data = parse(); data.push({}); if(textarea) textarea.value=JSON.stringify(data); render(); });
try{ render(); }catch(e){ console && console.error && console.error(e); }
}
// Instantiate editors for many worker fields
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'}]});
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'}]});
})();
</script>
<div class="field">
+1
View File
@@ -16,6 +16,7 @@ urlpatterns = [
path("worker/<int:rota_id>/<int:worker_id>/edit/", views.worker_edit, name="worker_edit"),
path("worker/<int:rota_id>/<int:worker_id>/delete/", views.worker_delete, name="worker_delete"),
path("worker/<int:worker_id>/", views.worker_detail, name="worker_detail"),
path("leave/request/", views.request_leave, name="request_leave"),
path("run/<int:run_id>/", views.rota_run_detail, name="rota_run_detail"),
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"),
+62
View File
@@ -420,6 +420,68 @@ def rota_options_update(request, rota_id):
return redirect("rota:rota_detail", rota_id=rota.id)
@require_http_methods(["GET", "POST"])
def request_leave(request):
"""Allow a worker to request leave for themselves.
Resolution strategy:
- If the user is authenticated, try to find a `Worker` with matching
email (first match). If found, use that worker.
- Otherwise, fall back to `worker_id` provided via GET/POST.
Supports HTMX modal flow when `HX-Request` header is present.
"""
is_hx = request.headers.get("HX-Request", "false").lower() == "true"
worker = None
# try authenticated user -> worker by email
try:
if request.user and request.user.is_authenticated and getattr(request.user, "email", None):
worker = Worker.objects.filter(email=request.user.email).first()
except Exception:
worker = None
# fallback to explicit worker_id
if worker is None:
worker_id = request.GET.get("worker_id") or request.POST.get("worker_id")
if worker_id:
try:
worker = get_object_or_404(Worker, pk=int(worker_id))
except Exception:
worker = None
if request.method == "POST":
form = LeaveForm(request.POST)
if form.is_valid():
leave = form.save(commit=False)
if worker is None:
return HttpResponseBadRequest("Worker not specified or not found")
leave.worker = worker
leave.save()
# On HTMX modal post, return an OOB swap to close modal and optionally
# update worker detail partial if present.
if is_hx:
resp = '<div id="modal" hx-swap-oob="true"></div>'
# optionally include trigger
response = HttpResponse(resp)
response["HX-Trigger"] = "closeModal"
return response
return redirect("rota:worker_detail", worker_id=worker.id)
else:
form = LeaveForm()
# Render modal or full page
if is_hx:
modal_html = render_to_string(
"rota/partials/leave_request_modal.html",
{"form": form, "form_action": request.get_full_path(), "worker": worker},
request=request,
)
return HttpResponse(modal_html)
return render(request, "rota/leave_request.html", {"form": form, "worker": worker})
def rota_options(request, rota_id):
"""HTMX-aware modal edit for rota.options JSON.