This commit is contained in:
Ross
2025-12-14 21:52:31 +00:00
parent ff2b2ebb95
commit f0ebc0d01a
9 changed files with 259 additions and 105 deletions
+4 -3
View File
@@ -138,7 +138,7 @@
// When leave list updated server-side, HTMX may trigger 'leaveUpdated'. // When leave list updated server-side, HTMX may trigger 'leaveUpdated'.
document.body.addEventListener('leaveUpdated', function(){ document.body.addEventListener('leaveUpdated', function(){
try{ try{
if(window._calendars){ Object.values(window._calendars).forEach(c => c.refreshLeaves && c.refreshLeaves()); } if(window._calendars){ const cs = Object.values(window._calendars).filter(c=>c.refreshLeaves); console.debug('[DEBUG] leaveUpdated -> refreshing', cs.length, 'calendars'); cs.forEach(c => c.refreshLeaves && c.refreshLeaves()); }
}catch(e){ console.error('leaveUpdated handler', e); } }catch(e){ console.error('leaveUpdated handler', e); }
}); });
@@ -155,11 +155,12 @@
// HX-Trigger may be a JSON string or comma-separated names; handle both // HX-Trigger may be a JSON string or comma-separated names; handle both
let triggers = []; let triggers = [];
try{ triggers = JSON.parse(trig); }catch(e){ triggers = String(trig).split(/\s*,\s*/); } try{ triggers = JSON.parse(trig); }catch(e){ triggers = String(trig).split(/\s*,\s*/); }
try{ console.debug('[DEBUG] htmx:afterRequest HX-Trigger parsed ->', triggers); }catch(e){}
if(triggers.includes('closeModal')){ if(triggers.includes('closeModal')){
try{ if(window.closeProcRotaModal) window.closeProcRotaModal(); }catch(e){} try{ console.debug('[DEBUG] htmx:afterRequest -> closeModal'); if(window.closeProcRotaModal) window.closeProcRotaModal(); }catch(e){}
} }
if(triggers.includes('leaveUpdated')){ if(triggers.includes('leaveUpdated')){
try{ if(window._calendars){ Object.values(window._calendars).forEach(c => c.refreshLeaves && c.refreshLeaves()); } }catch(e){} try{ console.debug('[DEBUG] htmx:afterRequest -> leaveUpdated'); if(window._calendars){ const cs = Object.values(window._calendars).filter(c=>c.refreshLeaves); console.debug('[DEBUG] afterRequest -> refreshing', cs.length, 'calendars'); cs.forEach(c => c.refreshLeaves && c.refreshLeaves()); } }catch(e){}
} }
}catch(e){ console.error('htmx:afterRequest robustness handler', e); } }catch(e){ console.error('htmx:afterRequest robustness handler', e); }
}); });
@@ -10,6 +10,9 @@
<link rel="stylesheet" href="{% static 'css/calendar.css' %}"> <link rel="stylesheet" href="{% static 'css/calendar.css' %}">
<div id="calendar" class="calendar" data-worker-id="{% if worker %}{{ worker.id }}{% endif %}" data-mode="inline"></div> <div id="calendar" class="calendar" data-worker-id="{% if worker %}{{ worker.id }}{% endif %}" data-mode="inline"></div>
<p class="help">Click a start date then a second date to select a range and prefill the leave form.</p> <p class="help">Click a start date then a second date to select a range and prefill the leave form.</p>
{% if token %}
<p style="margin-top:0.5rem"><a class="button is-light" hx-get="{% url 'rota:worker_settings_token' token %}" hx-target="#modal" hx-swap="innerHTML">Update your settings</a></p>
{% endif %}
</div> </div>
<form method="post"> <form method="post">
@@ -0,0 +1,22 @@
{% load crispy_forms_tags %}
<div class="modal is-active" id="worker-settings-modal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Update your settings</p>
<button class="delete" aria-label="close" onclick="(window.closeProcRotaModal || (()=>{document.getElementById('modal').innerHTML=''}))()"></button>
</header>
<section class="modal-card-body">
<form method="post" hx-post="{{ form_action }}" hx-swap="none">
{% csrf_token %}
{{ form|crispy }}
<div class="field">
<div class="control">
<button class="button is-primary" type="submit">Save</button>
<button type="button" class="button" onclick="(window.closeProcRotaModal || (()=>{document.getElementById('modal').innerHTML=''}))()">Cancel</button>
</div>
</div>
</form>
</section>
</div>
</div>
@@ -0,0 +1 @@
<div class="notification is-success">Settings saved.</div>
@@ -1,3 +1,4 @@
{% extends 'base.html' %}
<html> <html>
<head> <head>
<title>Rota: {{ rota.name }}</title> <title>Rota: {{ rota.name }}</title>
@@ -12,122 +13,132 @@
<p> <p>
<a class="button is-small" href="{% url 'rota:rota_edit' rota.id %}">Edit rota</a> <a class="button is-small" href="{% url 'rota:rota_edit' rota.id %}">Edit rota</a>
</p> </p>
<p class="subtitle">{{ rota.description }}</p>
<p>Period: {{ rota.start_date }} → {{ rota.end_date }}</p> <p>Period: {{ rota.start_date }} → {{ rota.end_date }}</p>
<div class="mb-4"> <div class="mb-4">
<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> <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>
{% include 'rota/partials/worker_list.html' %} {% block head %}
</div> <link href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" rel="stylesheet">
{% include 'rota/partials/flatpickr_includes.html' %}
{% endblock %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title">{{ rota.name }}</h1>
<p>
<a class="button is-small" href="{% url 'rota:rota_edit' rota.id %}">Edit rota</a>
</p>
<p class="subtitle">{{ rota.description }}</p>
<p>Period: {{ rota.start_date }} → {{ rota.end_date }}</p>
<div class="mb-4"> <div class="mb-4">
<h2 class="subtitle">Historic runs</h2> <p>
<div class="box"> <button class="button is-link" hx-get="{% url 'rota:worker_add' %}?rota_id={{ rota.id }}" hx-target="#modal" hx-swap="innerHTML">Add worker</button>
<div style="margin-bottom:0.5em;"> </p>
<form method="post" action="{% url 'rota:rota_runs_clear' rota.id %}" style="display:inline" onsubmit="return confirm('Delete all historic runs for this rota? This cannot be undone.')"> {% include 'rota/partials/worker_list.html' %}
{% csrf_token %} </div>
<button class="button is-danger is-light is-small" type="submit">Clear all runs</button>
</form>
</div>
<ul>
{% for run in rota.runs.all %}
<li>
<a href="{% url 'rota:rota_run_detail' run.id %}">Run {{ run.id }}</a>
{{ run.get_status_display }}
{% if run.finished_at %} — finished {{ run.finished_at }}{% endif %}
<span class="ml-2">
<a class="button is-small is-light" href="{% url 'rota:rota_run_export_html' run.id %}" target="_blank">Export</a>
</span>
</li>
{% empty %}
<li>No previous runs</li>
{% endfor %}
</ul>
</div>
</div>
<div class="mb-4"> <div class="mb-4">
<h2 class="subtitle">Constraint options</h2> <h2 class="subtitle">Historic runs</h2>
<div class="box"> <div class="box">
<p class="help">Configure typed rota constraint options. Click Edit to open the form showing typed fields and descriptions.</p> <div style="margin-bottom:0.5em;">
<p> <form method="post" action="{% url 'rota:rota_runs_clear' rota.id %}" style="display:inline" onsubmit="return confirm('Delete all historic runs for this rota? This cannot be undone.')">
<button class="button is-link" hx-get="{% url 'rota:rota_options' rota.id %}" hx-target="#modal" hx-swap="innerHTML">Edit constraint options</button>
</p>
<hr />
<h3 class="subtitle is-6">Configured constraint values</h3>
<div style="max-height:200px; overflow:auto; margin-bottom:0.75em;">
{% if configured_constraints %}
<table class="table is-fullwidth is-striped is-narrow">
<thead>
<tr><th>Key</th><th>Value</th><th></th></tr>
</thead>
<tbody>
{% for key, info in configured_constraints.items %}
<tr id="constraint-row-{{ key }}">
<td style="vertical-align:middle;"><code title="{{ info.description|default:'' }}">{{ key }}</code></td>
<td style="vertical-align:middle;"><pre style="white-space:pre-wrap; margin:0;">{{ info.pretty }}</pre></td>
<td style="vertical-align:middle;">
<a href="javascript:void(0)" class="button is-small is-light" role="button" hx-get="{% url 'rota:rota_option_edit' rota.id %}?key={{ key }}" hx-target="#modal" hx-swap="innerHTML">Edit</a>
<form method="post" hx-post="{% url 'rota:rota_option_delete' rota.id %}" hx-target="#constraint-row-{{ key }}" hx-swap="outerHTML" style="display:inline">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="key" value="{{ key }}" /> <button class="button is-danger is-light is-small" type="submit">Clear all runs</button>
<input type="hidden" name="inline" value="1" />
<button class="button is-small is-danger is-light" type="submit">Delete</button>
</form> </form>
</td> </div>
</tr> <ul>
{% endfor %} {% for run in rota.runs.all %}
</tbody> <li>
</table> <a href="{% url 'rota:rota_run_detail' run.id %}">Run {{ run.id }}</a>
{% else %} {{ run.get_status_display }}
<p class="help">No typed constraint options have been configured for this rota.</p> {% if run.finished_at %} — finished {{ run.finished_at }}{% endif %}
{% endif %} <span class="ml-2">
</div> <a class="button is-small is-light" href="{% url 'rota:rota_run_export_html' run.id %}" target="_blank">Export</a>
</span>
</li>
{% empty %}
<li>No previous runs</li>
{% endfor %}
</ul>
</div>
</div>
{# Available constraint keys section removed per UX request #} <div class="mb-4">
</div> <h2 class="subtitle">Constraint options</h2>
</div> <div class="box">
<p class="help">Configure typed rota constraint options. Click Edit to open the form showing typed fields and descriptions.</p>
<p>
<button class="button is-link" hx-get="{% url 'rota:rota_options' rota.id %}" hx-target="#modal" hx-swap="innerHTML">Edit constraint options</button>
</p>
<hr />
<h3 class="subtitle is-6">Configured constraint values</h3>
<div style="max-height:200px; overflow:auto; margin-bottom:0.75em;">
{% if configured_constraints %}
<table class="table is-fullwidth is-striped is-narrow">
<thead>
<tr><th>Key</th><th>Value</th><th></th></tr>
</thead>
<tbody>
{% for key, info in configured_constraints.items %}
<tr id="constraint-row-{{ key }}">
<td style="vertical-align:middle;"><code title="{{ info.description|default:'' }}">{{ key }}</code></td>
<td style="vertical-align:middle;"><pre style="white-space:pre-wrap; margin:0;">{{ info.pretty }}</pre></td>
<td style="vertical-align:middle;">
<a href="javascript:void(0)" class="button is-small is-light" role="button" hx-get="{% url 'rota:rota_option_edit' rota.id %}?key={{ key }}" hx-target="#modal" hx-swap="innerHTML">Edit</a>
<form method="post" hx-post="{% url 'rota:rota_option_delete' rota.id %}" hx-target="#constraint-row-{{ key }}" hx-swap="outerHTML" style="display:inline">
{% csrf_token %}
<input type="hidden" name="key" value="{{ key }}" />
<input type="hidden" name="inline" value="1" />
<button class="button is-small is-danger is-light" type="submit">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="help">No typed constraint options have been configured for this rota.</p>
{% endif %}
</div>
</div>
</div>
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<div class="field is-grouped is-grouped-multiline"> <div class="field is-grouped is-grouped-multiline">
<div class="control"> <div class="control">
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" name="generate_builder" value="1"> Generate HTML export <input type="checkbox" name="generate_builder" value="1"> Generate HTML export
</label> </label>
</div> </div>
<div class="control"> <div class="control">
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" name="builder_solve" value="1"> Solve when exporting (may be slow) <input type="checkbox" name="builder_solve" value="1"> Solve when exporting (may be slow)
</label> </label>
</div> </div>
<div class="control"> <div class="control">
<button class="button is-primary" type="submit">Run scheduler (background)</button> <button class="button is-primary" type="submit">Run scheduler (background)</button>
</div> </div>
</div> </div>
</form> </form>
<hr> <hr>
<div> <div>
<h2 class="subtitle">Edit shifts</h2> <h2 class="subtitle">Edit shifts</h2>
<p> <p>
<button class="button is-link" hx-get="{% url 'rota:shift_add' rota.id %}" hx-target="#shift-form" hx-swap="innerHTML">Add shift</button> <button class="button is-link" hx-get="{% url 'rota:shift_add' rota.id %}" hx-target="#shift-form" hx-swap="innerHTML">Add shift</button>
</p> </p>
<div id="shift-form"> <div id="shift-form">
{# Load form via HTMX when user clicks 'Add shift' - initial empty state #} {# Load form via HTMX when user clicks 'Add shift' - initial empty state #}
</div> </div>
<!-- modal container is provided by base.html; avoid duplicate IDs which confuse HTMX --> {% include 'rota/partials/shift_list.html' %}
</div>
{% include 'rota/partials/shift_list.html' %} </div>
</div> {% endblock %}
</div>
</section>
</body>
</html>
@@ -0,0 +1,19 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container">
<h2 class="title">Worker settings for {{ worker.name }}</h2>
<div class="box">
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<div class="field">
<div class="control">
<button class="button is-primary" type="submit">Save</button>
</div>
</div>
</form>
</div>
<p><a href="{% url 'rota:request_leave_token' token %}">Back to leave request</a></p>
</div>
{% endblock %}
+50
View File
@@ -407,6 +407,56 @@ class RotaScheduleForm(forms.ModelForm):
self.fields["end_date"].initial = end_date self.fields["end_date"].initial = end_date
return cleaned return cleaned
class WorkerSelfServiceForm(forms.ModelForm):
"""Small form exposed to workers via tokenized links so they can set
their FTE and pick non-working days.
"""
WEEKDAYS = [
('0', 'Monday'),
('1', 'Tuesday'),
('2', 'Wednesday'),
('3', 'Thursday'),
('4', 'Friday'),
('5', 'Saturday'),
('6', 'Sunday'),
]
nwds = forms.MultipleChoiceField(choices=WEEKDAYS, required=False, widget=forms.CheckboxSelectMultiple, label="Non-working days")
class Meta:
model = Worker
fields = ["fte"]
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
initial = kwargs.get('initial', {})
if instance is not None:
try:
opts = instance.options or {}
nwds = opts.get('nwds', [])
# store as strings for the MultipleChoiceField
initial['nwds'] = [str(x) for x in (nwds or [])]
initial['fte'] = instance.fte
except Exception:
pass
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
def save(self, commit=True):
instance = super().save(commit=False)
# persist non-working days into instance.options['nwds'] as list of ints
opts = instance.options or {}
nwds = self.cleaned_data.get('nwds') or []
try:
opts['nwds'] = [int(x) for x in nwds]
except Exception:
opts['nwds'] = nwds
instance.options = opts
if commit:
instance.save()
return instance
def save(self, commit=True): def save(self, commit=True):
instance = super().save(commit=False) instance = super().save(commit=False)
end_date = self.cleaned_data.get("end_date") end_date = self.cleaned_data.get("end_date")
+1
View File
@@ -19,6 +19,7 @@ urlpatterns = [
path("worker/<int:worker_id>/leaves.json", views.leaves_json, name="worker_leaves_json"), path("worker/<int:worker_id>/leaves.json", views.leaves_json, name="worker_leaves_json"),
path("leave/request/", views.request_leave, name="request_leave"), path("leave/request/", views.request_leave, name="request_leave"),
path("leave/request/token/<uuid:token>/", views.request_leave, name="request_leave_token"), 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("leave/<int:leave_id>/delete/", views.leave_delete, name="leave_delete"), path("leave/<int:leave_id>/delete/", views.leave_delete, name="leave_delete"),
path("worker/<int:worker_id>/regenerate_token/", views.regenerate_worker_token, name="regenerate_worker_token"), path("worker/<int:worker_id>/regenerate_token/", views.regenerate_worker_token, name="regenerate_worker_token"),
path("run/<int:run_id>/", views.rota_run_detail, name="rota_run_detail"), path("run/<int:run_id>/", views.rota_run_detail, name="rota_run_detail"),
+46
View File
@@ -3,6 +3,7 @@ from django.urls import reverse
from .models import RotaSchedule, Worker from .models import RotaSchedule, Worker
from .forms import WorkerForm, LeaveForm, RotaScheduleForm from .forms import WorkerForm, LeaveForm, RotaScheduleForm
from .forms import WorkerSelfServiceForm
from .services import run_rota_async from .services import run_rota_async
from .forms import ShiftForm from .forms import ShiftForm
import json import json
@@ -568,6 +569,51 @@ def request_leave(request, token=None):
return render(request, "rota/leave_request.html", {"form": form, "worker": worker, "token": token}) return render(request, "rota/leave_request.html", {"form": form, "worker": worker, "token": token})
@require_http_methods(["GET", "POST"])
def worker_settings_token(request, token):
"""Allow a worker identified by a request token to update simple settings
such as FTE and non-working days without requiring authentication.
"""
worker = None
if token:
try:
worker = get_object_or_404(Worker, request_token=token)
except Exception:
worker = None
if worker is None:
return HttpResponseBadRequest("Invalid token")
is_hx = request.headers.get("HX-Request", "false").lower() == "true"
if request.method == "POST":
form = WorkerSelfServiceForm(request.POST, instance=worker)
if form.is_valid():
form.save()
if is_hx:
# Return minimal fragment confirming success and trigger a leaveUpdated
# so any calendars can refresh (if they rely on worker settings).
resp = render_to_string("rota/partials/worker_settings_saved.html", {"worker": worker}, request=request)
response = HttpResponse(resp)
response["HX-Trigger"] = "closeModal,leaveUpdated"
return response
# Full page: redirect back to tokenized request page
return redirect(reverse('rota:request_leave_token', args=[token]))
else:
form = WorkerSelfServiceForm(instance=worker)
# HTMX modal request: return partial modal
if is_hx:
modal_html = render_to_string(
"rota/partials/worker_selfservice_modal.html",
{"form": form, "form_action": request.get_full_path(), "worker": worker},
request=request,
)
return HttpResponse(modal_html)
return render(request, "rota/worker_settings.html", {"form": form, "worker": worker, "token": token})
def rota_options(request, rota_id): def rota_options(request, rota_id):
"""HTMX-aware modal edit for rota.options JSON. """HTMX-aware modal edit for rota.options JSON.