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>
@@ -6,6 +7,23 @@
{% include 'rota/partials/flatpickr_includes.html' %} {% include 'rota/partials/flatpickr_includes.html' %}
</head> </head>
<body> <body>
<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>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>
{% block head %}
<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"> <section class="section">
<div class="container"> <div class="container">
<h1 class="title">{{ rota.name }}</h1> <h1 class="title">{{ rota.name }}</h1>
@@ -86,12 +104,9 @@
{% endif %} {% endif %}
</div> </div>
{# Available constraint keys section removed per UX request #}
</div> </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">
@@ -123,11 +138,7 @@
{# 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' %} {% include 'rota/partials/shift_list.html' %}
</div> </div>
</div> </div>
</section> {% endblock %}
</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.