.
This commit is contained in:
@@ -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); }
|
||||
});
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<head>
|
||||
<title>Rota: {{ rota.name }}</title>
|
||||
@@ -12,122 +13,132 @@
|
||||
<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">
|
||||
<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>
|
||||
{% include 'rota/partials/worker_list.html' %}
|
||||
</div>
|
||||
{% 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">
|
||||
<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">
|
||||
<h2 class="subtitle">Historic runs</h2>
|
||||
<div class="box">
|
||||
<div style="margin-bottom:0.5em;">
|
||||
<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.')">
|
||||
{% csrf_token %}
|
||||
<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">
|
||||
<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>
|
||||
{% include 'rota/partials/worker_list.html' %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h2 class="subtitle">Constraint options</h2>
|
||||
<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">
|
||||
<div class="mb-4">
|
||||
<h2 class="subtitle">Historic runs</h2>
|
||||
<div class="box">
|
||||
<div style="margin-bottom:0.5em;">
|
||||
<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.')">
|
||||
{% 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>
|
||||
<button class="button is-danger is-light is-small" type="submit">Clear all runs</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>
|
||||
<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>
|
||||
|
||||
{# Available constraint keys section removed per UX request #}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<h2 class="subtitle">Constraint options</h2>
|
||||
<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">
|
||||
{% csrf_token %}
|
||||
<div class="field is-grouped is-grouped-multiline">
|
||||
<div class="control">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="generate_builder" value="1"> Generate HTML export
|
||||
</label>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="builder_solve" value="1"> Solve when exporting (may be slow)
|
||||
</label>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Run scheduler (background)</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="field is-grouped is-grouped-multiline">
|
||||
<div class="control">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="generate_builder" value="1"> Generate HTML export
|
||||
</label>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="builder_solve" value="1"> Solve when exporting (may be slow)
|
||||
</label>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Run scheduler (background)</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr>
|
||||
<hr>
|
||||
|
||||
<div>
|
||||
<h2 class="subtitle">Edit shifts</h2>
|
||||
<p>
|
||||
<button class="button is-link" hx-get="{% url 'rota:shift_add' rota.id %}" hx-target="#shift-form" hx-swap="innerHTML">Add shift</button>
|
||||
</p>
|
||||
<div>
|
||||
<h2 class="subtitle">Edit shifts</h2>
|
||||
<p>
|
||||
<button class="button is-link" hx-get="{% url 'rota:shift_add' rota.id %}" hx-target="#shift-form" hx-swap="innerHTML">Add shift</button>
|
||||
</p>
|
||||
|
||||
<div id="shift-form">
|
||||
{# Load form via HTMX when user clicks 'Add shift' - initial empty state #}
|
||||
</div>
|
||||
<div id="shift-form">
|
||||
{# Load form via HTMX when user clicks 'Add shift' - initial empty state #}
|
||||
</div>
|
||||
|
||||
<!-- modal container is provided by base.html; avoid duplicate IDs which confuse HTMX -->
|
||||
|
||||
{% include 'rota/partials/shift_list.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
{% include 'rota/partials/shift_list.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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")
|
||||
|
||||
@@ -19,6 +19,7 @@ urlpatterns = [
|
||||
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/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("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"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user