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