Compare commits

..
18 Commits
Author SHA1 Message Date
Ross f019d2f565 . 2025-12-15 22:25:17 +00:00
Ross d2e128006a . 2025-12-15 22:24:11 +00:00
Ross 761f52eafa . 2025-12-15 22:16:35 +00:00
Ross 2482f6a04a . 2025-12-15 22:07:50 +00:00
Ross e99ab2c1fa . 2025-12-15 21:58:11 +00:00
Ross cf966573de . 2025-12-15 21:54:55 +00:00
Ross 6674306306 . 2025-12-15 21:51:40 +00:00
Ross a7e898f0a7 . 2025-12-15 21:48:06 +00:00
Ross bc7036859e . 2025-12-15 21:46:00 +00:00
Ross 5b4838e67d . 2025-12-15 21:45:33 +00:00
Ross b48382e990 . 2025-12-15 21:44:57 +00:00
Ross 9fc2ba3210 Enhance worker list display with site, grade, and FTE information; add non-working days summary rendering 2025-12-15 21:41:10 +00:00
Ross c5850eb1b2 Add functionality to delete all leave requests for a worker with permission checks 2025-12-15 21:25:21 +00:00
Ross 638fe17c60 . 2025-12-15 21:10:33 +00:00
Ross a783ce1f3e Refactor fte mapping in Worker model to convert fractional values to percentage for pydantic compatibility 2025-12-15 20:54:05 +00:00
Ross 00c737becf Add guard against division by zero in target_shifts calculation with warning logging 2025-12-15 20:51:19 +00:00
Ross b0af2daca4 Improve error handling in _run_rota_job to persist logs and results for better UI diagnostics 2025-12-15 20:45:06 +00:00
Ross 9b5f52035d Enhance JSON parsing and normalization in WorkerForm to handle various input types 2025-12-15 20:41:26 +00:00
17 changed files with 717 additions and 88 deletions
+11
View File
@@ -43,10 +43,14 @@ INSTALLED_APPS += [
"rota", "rota",
"crispy_forms", "crispy_forms",
"crispy_bulma", "crispy_bulma",
# Debug toolbar for local development
"debug_toolbar",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
# Debug toolbar middleware should run as early as possible
"debug_toolbar.middleware.DebugToolbarMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
@@ -133,6 +137,13 @@ STATICFILES_DIRS = [
BASE_DIR.parent / "static", BASE_DIR.parent / "static",
] ]
# django-debug-toolbar settings (development only)
INTERNAL_IPS = ["127.0.0.1", "::1"]
# Simple debug toolbar config: show when DEBUG is True and request comes from INTERNAL_IPS
DEBUG_TOOLBAR_CONFIG = {
"SHOW_TOOLBAR_CALLBACK": lambda request: DEBUG,
}
# Default primary key field type # Default primary key field type
DEFAULT_AUTO_FIELD = "django.db.models.AutoField" DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
@@ -14,7 +14,14 @@
<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> <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 %} {% endif %}
</div> </div>
{# Navigation back to the first rota this worker belongs to (if available) #}
{% if worker %}
{% with back_rota=worker.rotas.all.0 %}
{% if back_rota %}
<p style="margin-top:0.5rem"><a class="button" href="{% url 'rota:rota_detail' back_rota.id %}">&larr; Back to rota: {{ back_rota.name }}</a></p>
{% endif %}
{% endwith %}
{% endif %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% if token %} {% if token %}
@@ -0,0 +1,73 @@
{% extends 'base.html' %}
{% load crispy_forms_tags static %}
{% block content %}
<div class="container">
<h2 class="title">All leave requests</h2>
<div class="box">
<h3 class="subtitle">Global leave calendar</h3>
{% include 'rota/partials/flatpickr_includes.html' %}
<link rel="stylesheet" href="{% static 'css/calendar.css' %}">
{# Rota selection is handled by route scoping; no dropdown needed here #}
<div id="calendar" class="calendar" data-mode="inline"></div>
<p class="help">View all leave requests across workers. Choose a rota to scope the calendar, or select "All rotas" to see everything. Click and drag to select dates to open the leave form (you'll need to choose workers in the modal).</p>
</div>
<div style="margin-top:0.5rem;">
<strong>Legend:</strong>
<span style="display:inline-block;width:12px;height:12px;background:#ffecec;border:1px solid #ddd;margin-left:0.5rem;vertical-align:middle"></span> Existing leave
<span style="display:inline-block;width:12px;height:12px;background:#cdeffd;border:1px solid #2b8fd6;margin-left:0.75rem;vertical-align:middle"></span> Selected range
</div>
</div>
<script id="worker_leaves_json" type="application/json">{{ leaves_json|safe }}</script>
<script id="selected_rota_json" type="application/json">{{ selected_rota_id|default:"null" }}</script>
<script src="{% static 'js/calendar.js' %}"></script>
<script>
(function(){
const container = document.getElementById('calendar');
if(!container) return;
let leaves = [];
try {
const raw = document.getElementById('worker_leaves_json');
if (raw) {
leaves = JSON.parse(raw.textContent || raw.innerText || '[]');
}
} catch (e) {
console.debug('Unable to parse embedded leaves JSON', e);
}
// Base URL for leaves JSON
let leavesUrlBase = '{% url "rota:leaves_global_json" %}';
// Read initial rota selection provided by the view (if any)
let initialRota = null;
try{
const sel = document.getElementById('selected_rota_json');
if(sel) initialRota = JSON.parse(sel.textContent || sel.innerText || 'null');
}catch(e){ initialRota = null; }
window.initCalendar('calendar', { leaves: leaves, leavesUrl: leavesUrlBase, rotaId: initialRota, requestUrl: '{% url "rota:request_leave" %}', mode: container.dataset.mode || 'inline' });
function refreshLegendFor(id){
const legend = document.getElementById('rota-worker-legend');
if(!legend) return;
if(!id){ legend.innerText = 'Showing leave for all rotas.'; return; }
legend.innerText = 'Loading legend…';
fetch(leavesUrlBase + '?rota_id=' + encodeURIComponent(id), {credentials:'same-origin'}).then(r=>r.json()).then(data=>{
const byWorker = {};
(data.leaves||[]).forEach(l=>{ if(l.worker_id){ byWorker[l.worker_id] = {name:l.reason, color:l.color}; } });
if(Object.keys(byWorker).length===0){ legend.innerText = 'No workers with leaves in this rota.'; return; }
legend.innerHTML = Object.entries(byWorker).map(([id,info])=> `<div style="display:inline-flex;align-items:center;gap:0.5rem;margin-right:0.5rem"><span style="width:14px;height:14px;background:${info.color};border:1px solid rgba(0,0,0,0.1);display:inline-block"></span><span>${info.name}</span></div>`).join('');
}).catch(e=>{ legend.innerText = 'Unable to load legend'; console.debug(e); });
}
// Refresh legend for the initially selected rota (or show all rotas)
refreshLegendFor(initialRota);
})();
</script>
<div class="box" style="margin-top:0.75rem;">
<h4 class="subtitle is-6">Workers on this rota</h4>
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;">
<div id="rota-worker-legend">Select a rota to show its workers.</div>
</div>
</div>
{% endblock %}
@@ -7,11 +7,14 @@
<!-- Rely on the server to return an out-of-band (OOB) swap that updates <!-- Rely on the server to return an out-of-band (OOB) swap that updates
the #leave-list. Prevent htmx from attempting the main swap by the #leave-list. Prevent htmx from attempting the main swap by
disabling the default swap for this request. --> disabling the default swap for this request. -->
<form method="post" hx-post="{% url 'rota:leave_delete' l.id %}" hx-swap="none" style="display:inline;margin-left:0.5rem"> {% comment %}Only show delete control when the current request is allowed to delete:{% endcomment %}
{% if request.user.is_staff or request.user.is_authenticated and request.user.email and request.user.email == l.worker.email or token %}
<form method="post" action="{% url 'rota:leave_delete' l.id %}" hx-post="{% url 'rota:leave_delete' l.id %}" hx-swap="none" style="display:inline;margin-left:0.5rem">
{% csrf_token %} {% csrf_token %}
{% if token %}<input type="hidden" name="token" value="{{ token }}">{% endif %} {% if token %}<input type="hidden" name="token" value="{{ token }}">{% endif %}
<button class="button is-small is-danger" type="submit" onclick="return confirm('Delete this leave request?');">Delete</button> <button class="button is-small is-danger" type="submit" onclick="return confirm('Delete this leave request?');">Delete</button>
</form> </form>
{% endif %}
</li> </li>
{% empty %} {% empty %}
<li>No leave recorded</li> <li>No leave recorded</li>
@@ -0,0 +1,93 @@
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Apply leave to workers</p>
<button class="delete" aria-label="close" onclick="(function(){ if(window.closeProcRotaModal) window.closeProcRotaModal(); })()"></button>
</header>
<section class="modal-card-body">
<p class="help">Select one or more workers to apply the selected leave range to.</p>
<form id="apply-multi-form" method="post" action="{% url 'rota:apply_leave_multi' %}" hx-post="{% url 'rota:apply_leave_multi' %}" hx-swap="none">
{% csrf_token %}
<input type="hidden" name="start_date" value="{{ start_date }}" />
<input type="hidden" name="end_date" value="{{ end_date }}" />
<div style="max-height:300px; overflow:auto; margin-top:0.5rem;">
{% for w in workers %}
<div class="field">
<label class="checkbox"><input type="checkbox" name="worker_id" value="{{ w.id }}"> {{ w.name }}{% if w.site %} — {{ w.site }}{% endif %}</label>
</div>
{% empty %}
<p class="help">No workers available.</p>
{% endfor %}
</div>
</form>
</section>
<footer class="modal-card-foot">
<button id="apply-multi-submit" class="button is-success" type="submit" disabled>Apply leave</button>
<button class="button" type="button" onclick="(function(){ if(window.closeProcRotaModal) window.closeProcRotaModal(); })()">Cancel</button>
</footer>
<script>
(function(){
const form = document.getElementById('apply-multi-form');
const submitBtn = document.getElementById('apply-multi-submit');
if(!form) return;
function updateBtn(){
const any = Array.from(form.querySelectorAll('input[type=checkbox][name="worker_id"]')).some(cb=>cb.checked);
submitBtn.disabled = !any;
}
// Attach change handlers to checkboxes
Array.from(form.querySelectorAll('input[type=checkbox][name="worker_id"]')).forEach(cb=> cb.addEventListener('change', updateBtn));
updateBtn();
// Make the external Apply button trigger the form submit (button is outside the <form>)
submitBtn.addEventListener('click', function(ev){
ev.preventDefault();
if(typeof form.requestSubmit === 'function'){
form.requestSubmit();
} else {
// older browsers
const submitEvt = new Event('submit', {bubbles: true, cancelable: true});
form.dispatchEvent(submitEvt);
}
});
// Fallback submit handler using fetch so the modal doesn't navigate away
form.addEventListener('submit', function(ev){
ev.preventDefault();
// collect form data
const fd = new FormData(form);
const url = form.action;
// fetch with CSRF from cookie if present
function getCookie(name){
const v = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
return v ? v.pop() : '';
}
fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'HX-Request': 'true'
},
body: fd
}).then(resp=>{
// Respect HX-Trigger header if present
const trig = resp.headers.get('HX-Trigger') || resp.headers.get('Hx-Trigger');
if(trig){
const parts = trig.split(',').map(s=>s.trim()).filter(Boolean);
if(parts.includes('closeModal')){
if(window.closeProcRotaModal) window.closeProcRotaModal();
}
if(parts.includes('leaveUpdated')){
// dispatch a custom event that other code listens to
try{ window.dispatchEvent(new Event('leaveUpdated')); }catch(e){}
}
}
if(!resp.ok){
resp.text().then(t=>{ alert('Failed to apply leave: ' + (t||resp.statusText)); });
}
}).catch(err=>{ console.error('apply-multi submit failed', err); alert('Failed to apply leave'); });
});
})();
</script>
</div>
@@ -4,9 +4,23 @@
<ul> <ul>
{% for w in rota.workers.all %} {% for w in rota.workers.all %}
<li> <li>
<a href="{% url 'rota:worker_detail' w.id %}">{{ w.name }}</a> <div style="display:flex; align-items:center; justify-content:space-between; gap:0.75rem;">
<button class="button is-small is-light ml-2" hx-get="{% url 'rota:worker_edit' rota.id w.id %}" hx-target="#modal" hx-swap="innerHTML">Edit</button> <div style="display:flex; align-items:center; gap:0.5rem;">
<button class="button is-small is-danger ml-2" hx-get="{% url 'rota:worker_delete' rota.id w.id %}" hx-target="#modal" hx-swap="innerHTML">Delete</button> <a href="{% url 'rota:worker_detail' w.id %}">{{ w.name }}</a>
<small style="color:#666">&mdash; {{ w.site }}{% if w.grade %} · grade {{ w.grade }}{% endif %} · FTE {{ w.fte }}</small>
{# show non-working days stored in options.nwds if present; render JSON safely and let JS format it #}
{% if w.options.nwds %}
<span id="nwds-summary-{{ w.id }}" class="nwds-summary" data-nwds-id="nwds-data-{{ w.id }}" style="margin-left:0.5rem;color:#666"></span>
{% with script_id='nwds-data-'|add:w.id %}
{{ w.options.nwds|default:"[]"|json_script:script_id }}
{% endwith %}
{% endif %}
</div>
<div style="flex:0 0 auto;">
<button class="button is-small is-light ml-2" hx-get="{% url 'rota:worker_edit' rota.id w.id %}" hx-target="#modal" hx-swap="innerHTML">Edit</button>
<button class="button is-small is-danger ml-2" hx-get="{% url 'rota:worker_delete' rota.id w.id %}" hx-target="#modal" hx-swap="innerHTML">Delete</button>
</div>
</div>
</li> </li>
{% empty %} {% empty %}
<li>No workers assigned</li> <li>No workers assigned</li>
@@ -14,3 +28,69 @@
</ul> </ul>
</div> </div>
</div> </div>
<script>
// Render NWDS summaries: find script tags with id starting 'nwds-data-' and populate the
// corresponding summary span. Clicking a day label toggles display of date details.
(function(){
const DAYS = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
function dayLabelFromItem(item){
if(item === null || item === undefined) return '(any)';
if(typeof item === 'number') return DAYS[item] || String(item);
if(typeof item === 'string'){
const n = parseInt(item,10);
if(!Number.isNaN(n)) return DAYS[n] || item;
// normalise to first 3 letters
return item.trim().slice(0,3);
}
if(typeof item === 'object'){
if(item.day !== undefined){
if(typeof item.day === 'number') return DAYS[item.day] || String(item.day);
return String(item.day).slice(0,3);
}
}
return String(item);
}
document.querySelectorAll('.nwds-summary').forEach(function(target){
try{
let arr = [];
const dataAttr = target.getAttribute('data-nwds');
if(dataAttr){
try{ arr = JSON.parse(dataAttr); }catch(e){ arr = []; }
} else {
const scriptId = target.getAttribute('data-nwds-id');
if(scriptId){
let script = document.getElementById(scriptId);
// Some template/tooling may emit the JSON <script> without an id (observed in output).
// Fallback: look for the next sibling <script type="application/json"> after the span.
if(!script){
let sib = target.nextElementSibling;
while(sib){
if(sib.tagName === 'SCRIPT' && (sib.type === 'application/json' || sib.getAttribute('type') === 'application/json')){ script = sib; break; }
sib = sib.nextElementSibling;
}
}
if(script){
try{ arr = JSON.parse(script.textContent || script.innerText || '[]'); }catch(e){ arr = []; }
}
}
}
if(!Array.isArray(arr) || arr.length===0){ target.textContent = ''; return; }
arr.forEach(function(item, idx){
const a = document.createElement('a');
a.href = '#'; a.className = 'nwds-day'; a.style.marginLeft = (idx? '0.4rem':'0'); a.style.color = '#666'; a.textContent = dayLabelFromItem(item);
const details = document.createElement('span'); details.className='nwds-dates'; details.style.display='none'; details.style.marginLeft='0.25rem'; details.style.color='#444';
if(item && typeof item === 'object'){
const sd = item.start_date || item.from || '';
const ed = item.end_date || item.to || '';
if(sd || ed){ details.textContent = (sd||'') + (sd && ed ? ' → ' : '') + (ed||''); }
}
a.addEventListener('click', function(ev){ ev.preventDefault(); if(details.textContent){ details.style.display = details.style.display === 'none' ? 'inline' : 'none'; } });
target.appendChild(a);
target.appendChild(details);
});
}catch(e){ console.error('nwds render failed', e); }
});
})();
</script>
@@ -12,14 +12,17 @@
<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><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>
<button class="button is-link" hx-get="{% url 'rota:worker_add' %}?rota_id={{ rota.id }}" hx-target="#modal" hx-swap="innerHTML">Add worker</button>
<a class="button is-info is-light is-small" href="{% url 'rota:leaves_global_rota' rota.id %}" style="margin-left:0.5rem;">All leave requests</a>
</p>
{% include 'rota/partials/worker_list.html' %} {% include 'rota/partials/worker_list.html' %}
</div> </div>
<div class="mb-4"> <div class="mb-4">
<h2 class="subtitle">Historic runs</h2> <details class="box">
<div class="box"> <summary class="subtitle" style="cursor:pointer; outline:none;">Historic runs</summary>
<div style="margin-bottom:0.5em;"> <div style="margin-bottom:0.5em; margin-top: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.')"> <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 %} {% csrf_token %}
<button class="button is-danger is-light is-small" type="submit">Clear all runs</button> <button class="button is-danger is-light is-small" type="submit">Clear all runs</button>
@@ -36,7 +39,7 @@
<li>No previous runs</li> <li>No previous runs</li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </details>
</div> </div>
<div class="mb-4"> <div class="mb-4">
@@ -45,10 +48,16 @@
<p class="help">Configure typed rota constraint options. Click Edit to open the form showing typed fields and descriptions.</p> <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> <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 /> <hr />
<h3 class="subtitle is-6">Configured constraint values</h3> <details class="constraints-details" style="margin-top:0.5rem;">
<div style="max-height:200px; overflow:auto; margin-bottom:0.75em;"> <summary class="subtitle is-6" style="cursor:pointer; outline:none;">Configured constraint values{% if configured_constraints %} ({{ configured_constraints|length }}){% endif %}</summary>
{% if configured_constraints %} <div style="margin-top:0.5rem;">
<table class="table is-fullwidth is-striped is-narrow"> <div style="display:flex; gap:0.5rem; align-items:center; margin-bottom:0.5rem;">
<input id="constraint-filter-{{ rota.id }}" class="input is-small" type="search" placeholder="Filter constraints (key or value)..." aria-label="Filter constraints" />
<button id="constraint-clear-{{ rota.id }}" class="button is-small is-light" type="button">Clear</button>
</div>
<div class="constraints-scroll" style="max-height:600px; overflow:auto; margin-bottom:0.75em;">
{% if configured_constraints %}
<table class="table is-fullwidth is-striped is-narrow" id="constraint-table-{{ rota.id }}">
<thead><tr><th>Key</th><th>Value</th><th></th></tr></thead> <thead><tr><th>Key</th><th>Value</th><th></th></tr></thead>
<tbody> <tbody>
{% for key, info in configured_constraints.items %} {% for key, info in configured_constraints.items %}
@@ -71,7 +80,31 @@
{% else %} {% else %}
<p class="help">No typed constraint options have been configured for this rota.</p> <p class="help">No typed constraint options have been configured for this rota.</p>
{% endif %} {% endif %}
</div> </div>
</div>
<script>
(function(){
const input = document.getElementById('constraint-filter-{{ rota.id }}');
const clearBtn = document.getElementById('constraint-clear-{{ rota.id }}');
const table = document.getElementById('constraint-table-{{ rota.id }}');
if(!input || !table) return;
const rows = Array.from(table.tBodies[0].rows);
function normalize(s){ return (s||'').toString().toLowerCase(); }
function applyFilter(){
const q = normalize(input.value.trim());
if(!q){ rows.forEach(r => r.style.display=''); return; }
rows.forEach(r => {
const key = normalize(r.cells[0].innerText);
const val = normalize(r.cells[1].innerText);
r.style.display = (key.indexOf(q) !== -1 || val.indexOf(q) !== -1) ? '' : 'none';
});
}
let debounce;
input.addEventListener('input', ()=>{ clearTimeout(debounce); debounce = setTimeout(applyFilter, 150); });
clearBtn.addEventListener('click', ()=>{ input.value=''; applyFilter(); input.focus(); });
})();
</script>
</details>
</div> </div>
</div> </div>
@@ -1,54 +1,67 @@
{% extends 'base.html' %}
{% load crispy_forms_tags static %} {% load crispy_forms_tags static %}
<html> {% block content %}
<head> <div class="container">
<title>{{ worker.name }}</title> <h2 class="title">Request leave for {{ worker.name }}</h2>
<link href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
{% include 'rota/partials/flatpickr_includes.html' %}
<link rel="stylesheet" href="{% static 'css/calendar.css' %}">
</head>
<body>
<section class="section">
<div class="container">
<div class="box">
<h2 class="subtitle">Request leave on calendar</h2>
<div id="calendar" data-worker-id="{{ worker.id }}"></div>
<p class="help">Select a date or drag to select a range to request leave.</p>
</div>
<h1 class="title">{{ worker.name }}</h1>
<p class="subtitle">{{ worker.email }} - {{ worker.site }}</p>
<div class="box"> <div class="box">
<h2 class="subtitle">Request leave</h2> <h3 class="subtitle">Request leave on calendar</h3>
<form method="post"> {% include 'rota/partials/flatpickr_includes.html' %}
{% csrf_token %} <link rel="stylesheet" href="{% static 'css/calendar.css' %}">
{{ form|crispy }} <div id="calendar" class="calendar" data-worker-id="{{ worker.id }}" data-mode="inline"></div>
<div class="field"> <p class="help">Click a start date then a second date to select a range and prefill the leave form.</p>
<div class="control">
<button class="button is-primary" type="submit">Add leave</button>
</div>
</div>
</form>
</div>
<h2 class="subtitle">Existing leave</h2>
<div class="content">
{% include 'rota/partials/leave_list.html' with leaves=worker.leaves.all %}
</div>
</div> </div>
</section>
<script src="{% static 'js/calendar.js' %}"></script> {% with back_rota=worker.rotas.all.0 %}
<script> {% if back_rota %}
(function(){ <p style="margin-top:0.5rem"><a class="button" href="{% url 'rota:rota_detail' back_rota.id %}">&larr; Back to rota: {{ back_rota.name }}</a></p>
const container = document.getElementById('calendar'); {% endif %}
if(!container) return; {% endwith %}
const leaves = [
{% for l in worker.leaves.all %} <form method="post">
{start:'{{ l.start_date|date:"Y-m-d" }}', end:'{{ l.end_date|date:"Y-m-d" }}'}, {% csrf_token %}
{% endfor %} {{ form|crispy }}
]; <div class="field">
window.initCalendar('calendar', { workerId: container.dataset.workerId, leaves: leaves, requestUrl: '{% url "rota:request_leave" %}', leavesUrl: '{% url "rota:worker_leaves_json" worker.id %}' }); <div class="control">
})(); <button class="button is-primary" type="submit">Request</button>
</script> </div>
</body> </div>
</html> </form>
<h2 class="subtitle">Existing leave for {{ worker.name }}</h2>
<div class="content">
{% if request.user.is_staff or request.user.is_authenticated and request.user.email and request.user.email == worker.email %}
<form method="post" action="{% url 'rota:leave_delete_all' worker.id %}" hx-post="{% url 'rota:leave_delete_all' worker.id %}" hx-swap="none" style="display:inline;margin-bottom:0.5rem">
{% csrf_token %}
<button class="button is-small is-danger" type="submit" onclick="return confirm('Delete ALL leave requests for this worker?');">Delete all leave</button>
</form>
{% endif %}
{% include 'rota/partials/leave_list.html' with leaves=worker.leaves.all %}
</div>
<div style="margin-top:0.5rem;">
<strong>Legend:</strong>
<span style="display:inline-block;width:12px;height:12px;background:#ffecec;border:1px solid #ddd;margin-left:0.5rem;vertical-align:middle"></span> Existing leave
<span style="display:inline-block;width:12px;height:12px;background:#cdeffd;border:1px solid #2b8fd6;margin-left:0.75rem;vertical-align:middle"></span> Selected range
</div>
</div>
<script id="worker_leaves_json" type="application/json">{{ leaves_json|safe }}</script>
<script src="{% static 'js/calendar.js' %}"></script>
<script>
(function(){
const container = document.getElementById('calendar');
if(!container) return;
let leaves = [];
try {
const raw = document.getElementById('worker_leaves_json');
if (raw) {
leaves = JSON.parse(raw.textContent || raw.innerText || '[]');
}
} catch (e) {
console.debug('Unable to parse embedded leaves JSON', e);
}
window.initCalendar('calendar', { workerId: container.dataset.workerId, leaves: leaves, requestUrl: '{% url "rota:request_leave" %}', leavesUrl: '{% url "rota:worker_leaves_json" worker.id %}', mode: container.dataset.mode || 'inline' });
})();
</script>
{% endblock %}
+7
View File
@@ -17,8 +17,15 @@ Including another URLconf
from django.contrib import admin from django.contrib import admin
from django.urls import path, include from django.urls import path, include
from django.conf import settings
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("", include(("rota.urls", "rota"), namespace="rota")), path("", include(("rota.urls", "rota"), namespace="rota")),
] ]
if settings.DEBUG:
# Include django-debug-toolbar when running in DEBUG mode. Use a string
# include so the project doesn't fail to import if the package isn't
# installed; developers should install it in their dev environment.
urlpatterns = [path("__debug__/", include("debug_toolbar.urls"))] + urlpatterns
+33 -4
View File
@@ -168,11 +168,40 @@ class WorkerForm(forms.ModelForm):
if val is None or val == "": if val is None or val == "":
opts[k] = [] opts[k] = []
else: else:
try: # If the cleaned value is a JSON string, parse it. If
parsed = json.loads(val) # it's already a Python structure (possibly containing
except Exception: # pydantic models from `clean()`), keep it but normalize
# any model instances into serializable dicts.
if isinstance(val, str):
try:
parsed = json.loads(val)
except Exception:
parsed = val
else:
parsed = val parsed = val
opts[k] = parsed
def _normalize(obj):
# Normalize pydantic BaseModel instances (v2:v1)
if hasattr(obj, "model_dump"):
try:
return obj.model_dump()
except Exception:
pass
if hasattr(obj, "dict") and not isinstance(obj, dict):
try:
return obj.dict()
except Exception:
pass
# Recurse lists/tuples
if isinstance(obj, list):
return [_normalize(i) for i in obj]
if isinstance(obj, tuple):
return [_normalize(i) for i in obj]
if isinstance(obj, dict):
return {k: _normalize(v) for k, v in obj.items()}
return obj
opts[k] = _normalize(parsed)
instance.options = opts instance.options = opts
if commit: if commit:
+21 -2
View File
@@ -153,13 +153,32 @@ class Worker(models.Model):
except Exception: except Exception:
raise RuntimeError("Unable to import rota.workers.Worker; ensure project package is importable") raise RuntimeError("Unable to import rota.workers.Worker; ensure project package is importable")
# Map fte: Django stores percentage (100) while pydantic Worker expects int # Map fte: Django stores `fte` as a float (fractional e.g. 1.0 == 100%),
# while the pydantic Worker expects an integer percentage (100).
# Convert conservatively:
# - if stored value looks like a fraction (<= 10) treat it as fraction
# and multiply by 100 (e.g. 1.0 -> 100).
# - otherwise treat it as already a percentage and coerce to int.
raw_fte = getattr(self, "fte", None)
try:
if raw_fte is None:
fte_pct = 100
else:
# numeric values
f = float(raw_fte)
if f <= 10:
fte_pct = int(round(f * 100))
else:
fte_pct = int(round(f))
except Exception:
fte_pct = 100
pyd_kwargs = { pyd_kwargs = {
"name": self.name, "name": self.name,
"site": self.site or "", "site": self.site or "",
"grade": self.grade or 1, "grade": self.grade or 1,
"id": self.pk, "id": self.pk,
"fte": int(self.fte), "fte": fte_pct,
} }
# Merge any additional options stored on the Django model into the # Merge any additional options stored on the Django model into the
+25
View File
@@ -120,11 +120,36 @@ def _run_rota_job(run_id: int, use_external_generator: bool = False, generate_bu
tb = traceback.format_exc() tb = traceback.format_exc()
run.log = (run.log or "") + "\n" + tb run.log = (run.log or "") + "\n" + tb
result["builder_error"] = str(exc) result["builder_error"] = str(exc)
# Persist the log immediately so UI can show it even if
# the outer flow later marks the run failed.
try:
run.save(update_fields=["log"])
except Exception:
pass
run.mark_finished(result=result) run.mark_finished(result=result)
except Exception: except Exception:
tb = traceback.format_exc() tb = traceback.format_exc()
# Ensure the traceback is persisted into the run log and result (if
# available) before marking the run failed so the UI can display the
# diagnostic information when viewing the run details.
try:
run.log = (run.log or "") + "\n" + tb
# If we have a partially constructed `result`, attach it so the
# failure page can show any captured stdout/stderr
try:
if 'result' in locals() and isinstance(result, dict):
run.result = result
run.save(update_fields=["log", "result"])
else:
run.save(update_fields=["log"])
except Exception:
# best-effort: ignore persistence errors here
pass
except Exception:
# ignore log assignment errors and continue to mark failed
pass
try: try:
run.mark_failed(message=tb) run.mark_failed(message=tb)
except Exception: except Exception:
+9
View File
@@ -20,7 +20,16 @@ urlpatterns = [
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("worker/settings/token/<uuid:token>/", views.worker_settings_token, name="worker_settings_token"),
path("leaves/all/", views.leaves_global, name="leaves_global"),
path("leaves/all.json", views.leaves_global_json, name="leaves_global_json"),
# Rota-scoped variants which render the same views but with rota_id supplied
path("rota/<int:rota_id>/leaves/all/", views.leaves_global, name="leaves_global_rota"),
path("rota/<int:rota_id>/leaves/all.json", views.leaves_global_json, name="leaves_global_json_rota"),
path("rota/<int:rota_id>/leave/select_workers/", views.select_workers_modal, name="select_workers_modal_rota"),
path("leave/select_workers/", views.select_workers_modal, name="select_workers_modal"),
path("leave/apply_multi/", views.apply_leave_multi, name="apply_leave_multi"),
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>/leaves/delete_all/", views.leave_delete_all, name="leave_delete_all"),
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"),
path("run/<int:run_id>/export/html/", views.rota_run_export_html, name="rota_run_export_html"), path("run/<int:run_id>/export/html/", views.rota_run_export_html, name="rota_run_export_html"),
+169 -1
View File
@@ -320,7 +320,29 @@ def worker_detail(request, worker_id):
else: else:
form = LeaveForm() form = LeaveForm()
return render(request, "rota/worker_detail.html", {"worker": worker, "form": form}) # Build a JSON-serializable list of leaves for the calendar widget to consume
leaves = []
try:
for leave in worker.leaves.all():
try:
s = leave.start_date.isoformat()
except Exception:
s = str(leave.start_date)
try:
e = leave.end_date.isoformat()
except Exception:
e = str(leave.end_date)
leaves.append({"start": s, "end": e})
except Exception:
leaves = []
# Provide a JSON-encoded string for safe embedding in the template
try:
leaves_json = json.dumps(leaves)
except Exception:
leaves_json = '[]'
return render(request, "rota/worker_detail.html", {"worker": worker, "form": form, "leaves": leaves, "leaves_json": leaves_json})
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
@@ -1017,6 +1039,106 @@ def leaves_json(request, worker_id):
return JsonResponse({'leaves': data}) return JsonResponse({'leaves': data})
def leaves_global_json(request, rota_id=None):
"""Return JSON list of all leave ranges across all workers."""
from .models import Leave
# Optionally filter by rota_id (provided as query param) so the global
# calendar can be scoped to a particular rota's workers.
rota_id = request.GET.get('rota_id') if rota_id is None else rota_id
qs = Leave.objects.select_related('worker')
if rota_id:
try:
rota_id_i = int(rota_id)
qs = qs.filter(worker__rotas=rota_id_i)
except Exception:
pass
# Build a worker->color map for workers present in the query so the
# client can color-code leaves by worker.
worker_colors = {}
def color_for_worker(pk):
# deterministic hue based on id
h = (int(pk) * 37) % 360
return f'hsl({h} 65% 85%)'
data = []
for leave in qs.all().order_by('start_date'):
worker = getattr(leave, 'worker', None)
wid = getattr(worker, 'id', None)
wname = getattr(worker, 'name', '') if worker else ''
if wid not in worker_colors and wid is not None:
worker_colors[wid] = color_for_worker(wid)
data.append({
'start': leave.start_date.isoformat(),
'end': leave.end_date.isoformat(),
'reason': wname or (leave.reason or ''),
'worker_id': wid,
'color': worker_colors.get(wid),
})
return JsonResponse({'leaves': data})
def leaves_global(request, rota_id=None):
"""Page showing a global calendar with all leave requests."""
# Provide an empty JSON payload initially; the calendar will fetch the authoritative list
leaves_json = '[]'
selected = rota_id if rota_id is not None else request.GET.get('rota_id')
return render(request, 'rota/leaves_global.html', {'leaves_json': leaves_json, 'rotas': RotaSchedule.objects.all().order_by('name'), 'selected_rota_id': selected})
@require_http_methods(["GET"])
def select_workers_modal(request, rota_id=None):
"""Return an HTMX modal partial that lists workers to apply a selected leave range to.
Query params: start_date, end_date, rota_id (optional)
"""
start_date = request.GET.get('start_date')
end_date = request.GET.get('end_date')
rota_id = request.GET.get('rota_id') if rota_id is None else rota_id
workers = Worker.objects.all()
if rota_id:
try:
workers = workers.filter(rotas__id=int(rota_id))
except Exception:
pass
workers = workers.order_by('name')
modal_html = render_to_string(
'rota/partials/select_workers_modal.html',
{'workers': workers, 'start_date': start_date, 'end_date': end_date},
request=request,
)
return HttpResponse(modal_html)
@require_http_methods(["POST"])
def apply_leave_multi(request):
"""Apply a leave range to multiple workers. Expects POST with worker_ids (list), start_date and end_date."""
from .models import Leave
worker_ids = request.POST.getlist('worker_id')
start_date = request.POST.get('start_date')
end_date = request.POST.get('end_date')
if not worker_ids or not start_date:
return HttpResponseBadRequest('Missing parameters')
created = []
for wid in worker_ids:
try:
w = Worker.objects.get(pk=int(wid))
except Exception:
continue
try:
leave = Leave(worker=w, start_date=start_date, end_date=end_date)
leave.save()
created.append(leave)
except Exception:
continue
# Return simple HTMX response: close modal and trigger calendar refresh
response = HttpResponse('')
response['HX-Trigger'] = 'closeModal,leaveUpdated'
return response
@require_POST @require_POST
def leave_delete(request, leave_id): def leave_delete(request, leave_id):
"""Delete a Leave record. Allowed if the requester is staff, or if a valid token matches the worker, or the authenticated user's email matches the worker email.""" """Delete a Leave record. Allowed if the requester is staff, or if a valid token matches the worker, or the authenticated user's email matches the worker email."""
@@ -1062,6 +1184,52 @@ def leave_delete(request, leave_id):
return response return response
@require_POST
def leave_delete_all(request, worker_id):
"""Delete all Leave records for a worker. Permissions same as `leave_delete`.
Returns an OOB swap updating `#leave-list` and triggers `leaveUpdated`.
"""
worker = get_object_or_404(Worker, pk=worker_id)
# permission checks (same as leave_delete)
allowed = False
token = request.POST.get('token') or request.GET.get('token')
try:
if request.user and request.user.is_authenticated and request.user.is_staff:
allowed = True
elif request.user and request.user.is_authenticated and getattr(request.user, 'email', None) and request.user.email == (worker.email or ''):
allowed = True
elif token:
try:
import uuid
token_uuid = uuid.UUID(token)
if worker.request_token == token_uuid:
allowed = True
except Exception:
allowed = False
except Exception:
allowed = False
if not allowed:
return HttpResponseBadRequest('Permission denied')
# delete all leaves for this worker
worker.leaves.all().delete()
# render updated leave list for the worker
leave_list_html = render_to_string(
"rota/partials/leave_list.html",
{"leaves": worker.leaves.all(), "token": token},
request=request,
)
resp = f'<div id="leave-list" hx-swap-oob="true">{leave_list_html}</div>'
response = HttpResponse(resp)
response["HX-Trigger"] = "leaveUpdated"
return response
@require_POST @require_POST
def rota_run_export_regenerate(request, run_id): def rota_run_export_regenerate(request, run_id):
"""Regenerate the HTML export for a completed run and persist it on the RotaRun. """Regenerate the HTML export for a completed run and persist it on the RotaRun.
+1
View File
@@ -18,3 +18,4 @@ typer
django>=6.0,<7 django>=6.0,<7
django-crispy-forms django-crispy-forms
crispy-bulma crispy-bulma
django-debug-toolbar
+18 -5
View File
@@ -2007,11 +2007,24 @@ class RotaBuilder(object):
for i in shift.sites for i in shift.sites
) )
target_shifts = ( # Guard against division by zero: if no full-time
total_shifts # equivalent is available for the shift's sites the
/ full_time_equivalent_joined # denominator may be zero (for example when all site
* worker.get_fte(shift=shift.name) # FTEs are 0). In that case emit a warning and set the
) # target to 0 rather than raising an exception.
if not full_time_equivalent_joined:
# avoid ZeroDivisionError
self.add_warning(
"Zero FTE for sites",
f"full_time_equivalent sum is zero for shift {shift.name}; setting target_shifts=0 for worker {worker.name}",
)
target_shifts = 0
else:
target_shifts = (
total_shifts
/ full_time_equivalent_joined
* worker.get_fte(shift=shift.name)
)
if self.use_previous_shifts: if self.use_previous_shifts:
if shift.name in worker.previous_shifts: if shift.name in worker.previous_shifts:
+54 -9
View File
@@ -14,8 +14,12 @@
function iso(y,m,d){ return `${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}` } function iso(y,m,d){ return `${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}` }
function initCalendar(containerId, opts){ function initCalendar(containerId, opts){
const container = document.getElementById(containerId); // Accept either an element id or a direct element reference
const container = typeof containerId === 'string' ? document.getElementById(containerId) : containerId;
if(!container) return; if(!container) return;
// ensure container uses the expected CSS class so shared styles apply
try{ container.classList.add && container.classList.add('calendar'); }catch(e){}
console.debug('initCalendar', containerId, opts);
const workerId = opts.workerId || container.dataset.workerId || ''; const workerId = opts.workerId || container.dataset.workerId || '';
let leaves = opts.leaves || []; let leaves = opts.leaves || [];
const leavesUrl = opts.leavesUrl || null; const leavesUrl = opts.leavesUrl || null;
@@ -25,7 +29,11 @@
let viewMonth = new Date().getMonth(); let viewMonth = new Date().getMonth();
let selStart = null; let selEnd = null; let previewEnd = null; let selStart = null; let selEnd = null; let previewEnd = null;
function inLeaves(isoStr){ for(const L of leaves){ if(!L.start) continue; if(L.start<=isoStr && isoStr<=L.end) return true } return false } function inLeaves(isoStr){
const matches = [];
for(const L of leaves){ if(!L.start) continue; if(L.start<=isoStr && isoStr<=L.end) matches.push(L); }
return matches;
}
function buildControls(){ function buildControls(){
let controls = document.getElementById('calendar-controls'); let controls = document.getElementById('calendar-controls');
@@ -46,7 +54,7 @@
}; };
} }
const label = document.getElementById('sel-label'); const label = document.getElementById('sel-label');
if(selStart && selEnd) label.textContent = `Selected: ${selStart} ${selEnd}`; if(selStart && selEnd) label.textContent = `Selected: ${selStart} -> ${selEnd}`;
else if(selStart) label.textContent = `Start: ${selStart}`; else if(selStart) label.textContent = `Start: ${selStart}`;
else label.textContent = 'No dates selected'; else label.textContent = 'No dates selected';
document.getElementById('sel-confirm').disabled = !selStart; document.getElementById('sel-confirm').disabled = !selStart;
@@ -80,7 +88,31 @@
else{ else{
const isoStr = iso(viewYear, viewMonth, cur); const isoStr = iso(viewYear, viewMonth, cur);
dayDiv.textContent = cur; dayDiv.textContent = cur;
if(inLeaves(isoStr)) dayDiv.classList.add('leave'); const matches = inLeaves(isoStr);
if(matches && matches.length){
dayDiv.classList.add('leave');
try{
// collect distinct colors for this day's matches
const colors = Array.from(new Set(matches.map(x=>x.color).filter(Boolean)));
if(colors.length===1){
dayDiv.style.backgroundImage = ''; // clear any previous gradient
dayDiv.style.backgroundColor = colors[0];
} else if(colors.length>1){
// build an equal-split horizontal gradient (cap to 6 colours)
const n = Math.min(colors.length, 6);
const seg = 100 / n;
const stops = colors.slice(0,n).map((c,i)=>{
const start = Math.round(i*seg);
const end = Math.round((i+1)*seg);
return `${c} ${start}% ${end}%`;
}).join(', ');
dayDiv.style.backgroundColor = '';
dayDiv.style.backgroundImage = `linear-gradient(90deg, ${stops})`;
}
}catch(e){ /* defensive: leave default styling */ }
// set tooltip to show reasons / worker names for overlapping leaves
try{ dayDiv.title = matches.map(x => x.reason || '').filter(Boolean).join(', '); }catch(e){}
}
const rangeEnd = selEnd || previewEnd; const rangeEnd = selEnd || previewEnd;
if(selStart && rangeEnd){ if(selStart<=isoStr && isoStr<=rangeEnd) dayDiv.classList.add('in-range'); } if(selStart && rangeEnd){ if(selStart<=isoStr && isoStr<=rangeEnd) dayDiv.classList.add('in-range'); }
else if(selStart && selStart===isoStr) dayDiv.classList.add('selected'); else if(selStart && selStart===isoStr) dayDiv.classList.add('selected');
@@ -109,7 +141,7 @@
if(res.ok){ if(res.ok){
const data = await res.json(); const data = await res.json();
if(data && Array.isArray(data.leaves)){ if(data && Array.isArray(data.leaves)){
setLeaves(data.leaves.map(l=>({start:l.start, end:l.end, reason:l.reason}))); return; setLeaves(data.leaves.map(l=>({start:l.start, end:l.end, reason:l.reason, color:l.color, worker_id:l.worker_id}))); return;
} }
} }
}catch(e){ console.error('refreshLeaves failed', e); } }catch(e){ console.error('refreshLeaves failed', e); }
@@ -127,6 +159,7 @@
container.addEventListener('pointerdown', function(evt){ container.addEventListener('pointerdown', function(evt){
try{ try{
const dayEl = evt.target.closest && evt.target.closest('.day'); const dayEl = evt.target.closest && evt.target.closest('.day');
console.debug('calendar click', evt.target, dayEl && dayEl.dataset && dayEl.dataset.iso);
if(!dayEl || dayEl.classList.contains('other-month')) return; if(!dayEl || dayEl.classList.contains('other-month')) return;
const clicked = dayEl.dataset.iso; const clicked = dayEl.dataset.iso;
if(!selStart){ selStart = clicked; selEnd = null; } if(!selStart){ selStart = clicked; selEnd = null; }
@@ -135,10 +168,22 @@
previewEnd = null; previewEnd = null;
render(); render();
if(selStart && selEnd){ if(selStart && selEnd){
const url = requestUrl + '?worker_id=' + encodeURIComponent(workerId) + '&start_date=' + selStart + '&end_date=' + selEnd; // If a specific workerId is supplied, use existing single-worker modal flow.
setTimeout(function(){ if(workerId){
try{ if(window.htmx && typeof window.htmx.ajax === 'function'){ window.htmx.ajax('GET', url, {target:'#modal', swap:'innerHTML'}); } else { htmx.ajax('GET', url, {target:'#modal', swap:'innerHTML'}); } }catch(e){ window.location = url; } const url = requestUrl + '?worker_id=' + encodeURIComponent(workerId) + '&start_date=' + selStart + '&end_date=' + selEnd;
}, 80); setTimeout(function(){
try{ if(window.htmx && typeof window.htmx.ajax === 'function'){ window.htmx.ajax('GET', url, {target:'#modal', swap:'innerHTML'}); } else { htmx.ajax('GET', url, {target:'#modal', swap:'innerHTML'}); } }catch(e){ window.location = url; }
}, 80);
} else {
// Global calendar: prompt for workers to apply this leave to.
// Use an HTMX GET to render the select-workers modal.
let url = '/leave/select_workers/?start_date=' + encodeURIComponent(selStart) + '&end_date=' + encodeURIComponent(selEnd);
// Include rota_id if present in the page URL
try{ const rotaParam = new URLSearchParams(window.location.search).get('rota_id'); if(rotaParam){ url += '&rota_id=' + encodeURIComponent(rotaParam); } }catch(e){}
setTimeout(function(){
try{ if(window.htmx && typeof window.htmx.ajax === 'function'){ window.htmx.ajax('GET', url, {target:'#modal', swap:'innerHTML'}); } else { htmx.ajax('GET', url, {target:'#modal', swap:'innerHTML'}); } }catch(e){ window.location = url; }
}, 80);
}
} }
}catch(e){ console.error('calendar pointer handler', e); } }catch(e){ console.error('calendar pointer handler', e); }
}); });