diff --git a/djangorota/djangorota/templates/base.html b/djangorota/djangorota/templates/base.html index 55428fe..a1a02bd 100644 --- a/djangorota/djangorota/templates/base.html +++ b/djangorota/djangorota/templates/base.html @@ -138,7 +138,7 @@ // When leave list updated server-side, HTMX may trigger 'leaveUpdated'. document.body.addEventListener('leaveUpdated', function(){ 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); } }); @@ -155,11 +155,12 @@ // HX-Trigger may be a JSON string or comma-separated names; handle both let triggers = []; 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')){ - 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')){ - 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); } }); diff --git a/djangorota/djangorota/templates/rota/leave_request.html b/djangorota/djangorota/templates/rota/leave_request.html index 85c7a30..dbbfd3f 100644 --- a/djangorota/djangorota/templates/rota/leave_request.html +++ b/djangorota/djangorota/templates/rota/leave_request.html @@ -10,6 +10,9 @@

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

+ {% if token %} +

Update your settings

+ {% endif %}
diff --git a/djangorota/djangorota/templates/rota/partials/worker_selfservice_modal.html b/djangorota/djangorota/templates/rota/partials/worker_selfservice_modal.html new file mode 100644 index 0000000..667a748 --- /dev/null +++ b/djangorota/djangorota/templates/rota/partials/worker_selfservice_modal.html @@ -0,0 +1,22 @@ +{% load crispy_forms_tags %} +
diff --git a/djangorota/djangorota/templates/rota/partials/worker_settings_saved.html b/djangorota/djangorota/templates/rota/partials/worker_settings_saved.html new file mode 100644 index 0000000..1f7c1b2 --- /dev/null +++ b/djangorota/djangorota/templates/rota/partials/worker_settings_saved.html @@ -0,0 +1 @@ +
Settings saved.
\ No newline at end of file diff --git a/djangorota/djangorota/templates/rota/rota_detail.html b/djangorota/djangorota/templates/rota/rota_detail.html index 97cce78..473bdf2 100644 --- a/djangorota/djangorota/templates/rota/rota_detail.html +++ b/djangorota/djangorota/templates/rota/rota_detail.html @@ -1,3 +1,4 @@ +{% extends 'base.html' %} Rota: {{ rota.name }} @@ -12,122 +13,132 @@

Edit rota

-

{{ rota.description }}

Period: {{ rota.start_date }} → {{ rota.end_date }}

- {% include 'rota/partials/worker_list.html' %} -
+ {% block head %} + + {% include 'rota/partials/flatpickr_includes.html' %} + {% endblock %} + {% block content %} +
+
+

{{ rota.name }}

+

+ Edit rota +

+

{{ rota.description }}

+

Period: {{ rota.start_date }} → {{ rota.end_date }}

-
-

Historic runs

-
-
-
- {% csrf_token %} - -
-
-
    - {% for run in rota.runs.all %} -
  • - Run {{ run.id }} — - {{ run.get_status_display }} - {% if run.finished_at %} — finished {{ run.finished_at }}{% endif %} - - Export - -
  • - {% empty %} -
  • No previous runs
  • - {% endfor %} -
-
-
+
+

+ +

+ {% include 'rota/partials/worker_list.html' %} +
-
-

Constraint options

-
-

Configure typed rota constraint options. Click Edit to open the form showing typed fields and descriptions.

-

- -

-
-

Configured constraint values

-
- {% if configured_constraints %} - - - - - - {% for key, info in configured_constraints.items %} - - - - - - {% endfor %} - -
KeyValue
{{ key }}
{{ info.pretty }}
- Edit -
+
+

Historic runs

+
+
+ {% csrf_token %} - - - + -
- {% else %} -

No typed constraint options have been configured for this rota.

- {% endif %} -
+
+
    + {% for run in rota.runs.all %} +
  • + Run {{ run.id }} — + {{ run.get_status_display }} + {% if run.finished_at %} — finished {{ run.finished_at }}{% endif %} + + Export + +
  • + {% empty %} +
  • No previous runs
  • + {% endfor %} +
+
+
- {# Available constraint keys section removed per UX request #} - - +
+

Constraint options

+
+

Configure typed rota constraint options. Click Edit to open the form showing typed fields and descriptions.

+

+ +

+
+

Configured constraint values

+
+ {% if configured_constraints %} + + + + + + {% for key, info in configured_constraints.items %} + + + + + + {% endfor %} + +
KeyValue
{{ key }}
{{ info.pretty }}
+ Edit +
+ {% csrf_token %} + + + +
+
+ {% else %} +

No typed constraint options have been configured for this rota.

+ {% endif %} +
- +
+
-
- {% csrf_token %} -
-
- -
-
- -
-
- -
-
-
+
+ {% csrf_token %} +
+
+ +
+
+ +
+
+ +
+
+
-
+
-
-

Edit shifts

-

- -

+
+

Edit shifts

+

+ +

-
- {# Load form via HTMX when user clicks 'Add shift' - initial empty state #} -
+
+ {# Load form via HTMX when user clicks 'Add shift' - initial empty state #} +
- - - {% include 'rota/partials/shift_list.html' %} -
-
-
- - + {% include 'rota/partials/shift_list.html' %} + + + {% endblock %} diff --git a/djangorota/djangorota/templates/rota/worker_settings.html b/djangorota/djangorota/templates/rota/worker_settings.html new file mode 100644 index 0000000..e330adf --- /dev/null +++ b/djangorota/djangorota/templates/rota/worker_settings.html @@ -0,0 +1,19 @@ +{% extends 'base.html' %} +{% load crispy_forms_tags %} +{% block content %} +
+

Worker settings for {{ worker.name }}

+
+
+ {% csrf_token %} + {{ form|crispy }} +
+
+ +
+
+
+
+

Back to leave request

+
+{% endblock %} diff --git a/djangorota/rota/forms.py b/djangorota/rota/forms.py index ead17c3..68240ec 100644 --- a/djangorota/rota/forms.py +++ b/djangorota/rota/forms.py @@ -407,6 +407,56 @@ class RotaScheduleForm(forms.ModelForm): self.fields["end_date"].initial = end_date 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): instance = super().save(commit=False) end_date = self.cleaned_data.get("end_date") diff --git a/djangorota/rota/urls.py b/djangorota/rota/urls.py index d844929..5284e10 100644 --- a/djangorota/rota/urls.py +++ b/djangorota/rota/urls.py @@ -19,6 +19,7 @@ urlpatterns = [ 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/settings/token//", views.worker_settings_token, name="worker_settings_token"), path("leave//delete/", views.leave_delete, name="leave_delete"), path("worker//regenerate_token/", views.regenerate_worker_token, name="regenerate_worker_token"), path("run//", views.rota_run_detail, name="rota_run_detail"), diff --git a/djangorota/rota/views.py b/djangorota/rota/views.py index eb11d0c..bd8a768 100644 --- a/djangorota/rota/views.py +++ b/djangorota/rota/views.py @@ -3,6 +3,7 @@ from django.urls import reverse from .models import RotaSchedule, Worker from .forms import WorkerForm, LeaveForm, RotaScheduleForm +from .forms import WorkerSelfServiceForm from .services import run_rota_async from .forms import ShiftForm 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}) +@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): """HTMX-aware modal edit for rota.options JSON.