Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f019d2f565 | ||
|
|
d2e128006a | ||
|
|
761f52eafa | ||
|
|
2482f6a04a | ||
|
|
e99ab2c1fa | ||
|
|
cf966573de | ||
|
|
6674306306 | ||
|
|
a7e898f0a7 | ||
|
|
bc7036859e | ||
|
|
5b4838e67d | ||
|
|
b48382e990 | ||
|
|
9fc2ba3210 | ||
|
|
c5850eb1b2 | ||
|
|
638fe17c60 | ||
|
|
a783ce1f3e | ||
|
|
00c737becf | ||
|
|
b0af2daca4 | ||
|
|
9b5f52035d |
@@ -43,10 +43,14 @@ INSTALLED_APPS += [
|
||||
"rota",
|
||||
"crispy_forms",
|
||||
"crispy_bulma",
|
||||
# Debug toolbar for local development
|
||||
"debug_toolbar",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
# Debug toolbar middleware should run as early as possible
|
||||
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
@@ -133,6 +137,13 @@ STATICFILES_DIRS = [
|
||||
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_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>
|
||||
{% endif %}
|
||||
</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 %}">← Back to rota: {{ back_rota.name }}</a></p>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
<form method="post">
|
||||
{% csrf_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
|
||||
the #leave-list. Prevent htmx from attempting the main swap by
|
||||
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 %}
|
||||
{% 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>
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% empty %}
|
||||
<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>
|
||||
{% for w in rota.workers.all %}
|
||||
<li>
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:0.75rem;">
|
||||
<div style="display:flex; align-items:center; gap:0.5rem;">
|
||||
<a href="{% url 'rota:worker_detail' w.id %}">{{ w.name }}</a>
|
||||
<small style="color:#666">— {{ 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>
|
||||
{% empty %}
|
||||
<li>No workers assigned</li>
|
||||
@@ -14,3 +28,69 @@
|
||||
</ul>
|
||||
</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>
|
||||
|
||||
<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' %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h2 class="subtitle">Historic runs</h2>
|
||||
<div class="box">
|
||||
<div style="margin-bottom:0.5em;">
|
||||
<details class="box">
|
||||
<summary class="subtitle" style="cursor:pointer; outline:none;">Historic runs</summary>
|
||||
<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.')">
|
||||
{% csrf_token %}
|
||||
<button class="button is-danger is-light is-small" type="submit">Clear all runs</button>
|
||||
@@ -36,7 +39,7 @@
|
||||
<li>No previous runs</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<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><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;">
|
||||
<details class="constraints-details" style="margin-top:0.5rem;">
|
||||
<summary class="subtitle is-6" style="cursor:pointer; outline:none;">Configured constraint values{% if configured_constraints %} ({{ configured_constraints|length }}){% endif %}</summary>
|
||||
<div style="margin-top:0.5rem;">
|
||||
<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">
|
||||
<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>
|
||||
<tbody>
|
||||
{% for key, info in configured_constraints.items %}
|
||||
@@ -73,6 +82,30 @@
|
||||
{% endif %}
|
||||
</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>
|
||||
|
||||
<form method="post">
|
||||
|
||||
@@ -1,54 +1,67 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load crispy_forms_tags static %}
|
||||
<html>
|
||||
<head>
|
||||
<title>{{ worker.name }}</title>
|
||||
<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">
|
||||
{% block content %}
|
||||
<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>
|
||||
<h2 class="title">Request leave for {{ worker.name }}</h2>
|
||||
|
||||
<div class="box">
|
||||
<h2 class="subtitle">Request leave</h2>
|
||||
<h3 class="subtitle">Request leave on calendar</h3>
|
||||
{% include 'rota/partials/flatpickr_includes.html' %}
|
||||
<link rel="stylesheet" href="{% static 'css/calendar.css' %}">
|
||||
<div id="calendar" class="calendar" data-worker-id="{{ worker.id }}" 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>
|
||||
</div>
|
||||
|
||||
{% 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 %}">← Back to rota: {{ back_rota.name }}</a></p>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Add leave</button>
|
||||
<button class="button is-primary" type="submit">Request</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h2 class="subtitle">Existing leave</h2>
|
||||
<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>
|
||||
</section>
|
||||
</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;
|
||||
const leaves = [
|
||||
{% for l in worker.leaves.all %}
|
||||
{start:'{{ l.start_date|date:"Y-m-d" }}', end:'{{ l.end_date|date:"Y-m-d" }}'},
|
||||
{% endfor %}
|
||||
];
|
||||
window.initCalendar('calendar', { workerId: container.dataset.workerId, leaves: leaves, requestUrl: '{% url "rota:request_leave" %}', leavesUrl: '{% url "rota:worker_leaves_json" worker.id %}' });
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
{% endblock %}
|
||||
|
||||
@@ -17,8 +17,15 @@ Including another URLconf
|
||||
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from django.conf import settings
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
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
|
||||
|
||||
@@ -168,11 +168,40 @@ class WorkerForm(forms.ModelForm):
|
||||
if val is None or val == "":
|
||||
opts[k] = []
|
||||
else:
|
||||
# If the cleaned value is a JSON string, parse it. If
|
||||
# it's already a Python structure (possibly containing
|
||||
# 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
|
||||
opts[k] = parsed
|
||||
else:
|
||||
parsed = val
|
||||
|
||||
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
|
||||
if commit:
|
||||
|
||||
@@ -153,13 +153,32 @@ class Worker(models.Model):
|
||||
except Exception:
|
||||
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 = {
|
||||
"name": self.name,
|
||||
"site": self.site or "",
|
||||
"grade": self.grade or 1,
|
||||
"id": self.pk,
|
||||
"fte": int(self.fte),
|
||||
"fte": fte_pct,
|
||||
}
|
||||
|
||||
# Merge any additional options stored on the Django model into the
|
||||
|
||||
@@ -120,11 +120,36 @@ def _run_rota_job(run_id: int, use_external_generator: bool = False, generate_bu
|
||||
tb = traceback.format_exc()
|
||||
run.log = (run.log or "") + "\n" + tb
|
||||
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)
|
||||
|
||||
except Exception:
|
||||
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:
|
||||
run.mark_failed(message=tb)
|
||||
except Exception:
|
||||
|
||||
@@ -20,7 +20,16 @@ urlpatterns = [
|
||||
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("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("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("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"),
|
||||
|
||||
+169
-1
@@ -320,7 +320,29 @@ def worker_detail(request, worker_id):
|
||||
else:
|
||||
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"])
|
||||
@@ -1017,6 +1039,106 @@ def leaves_json(request, worker_id):
|
||||
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
|
||||
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."""
|
||||
@@ -1062,6 +1184,52 @@ def leave_delete(request, leave_id):
|
||||
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
|
||||
def rota_run_export_regenerate(request, run_id):
|
||||
"""Regenerate the HTML export for a completed run and persist it on the RotaRun.
|
||||
|
||||
@@ -18,3 +18,4 @@ typer
|
||||
django>=6.0,<7
|
||||
django-crispy-forms
|
||||
crispy-bulma
|
||||
django-debug-toolbar
|
||||
@@ -2007,6 +2007,19 @@ class RotaBuilder(object):
|
||||
for i in shift.sites
|
||||
)
|
||||
|
||||
# Guard against division by zero: if no full-time
|
||||
# equivalent is available for the shift's sites the
|
||||
# denominator may be zero (for example when all site
|
||||
# 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
|
||||
|
||||
+50
-5
@@ -14,8 +14,12 @@
|
||||
function iso(y,m,d){ return `${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}` }
|
||||
|
||||
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;
|
||||
// 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 || '';
|
||||
let leaves = opts.leaves || [];
|
||||
const leavesUrl = opts.leavesUrl || null;
|
||||
@@ -25,7 +29,11 @@
|
||||
let viewMonth = new Date().getMonth();
|
||||
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(){
|
||||
let controls = document.getElementById('calendar-controls');
|
||||
@@ -46,7 +54,7 @@
|
||||
};
|
||||
}
|
||||
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 label.textContent = 'No dates selected';
|
||||
document.getElementById('sel-confirm').disabled = !selStart;
|
||||
@@ -80,7 +88,31 @@
|
||||
else{
|
||||
const isoStr = iso(viewYear, viewMonth, 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;
|
||||
if(selStart && rangeEnd){ if(selStart<=isoStr && isoStr<=rangeEnd) dayDiv.classList.add('in-range'); }
|
||||
else if(selStart && selStart===isoStr) dayDiv.classList.add('selected');
|
||||
@@ -109,7 +141,7 @@
|
||||
if(res.ok){
|
||||
const data = await res.json();
|
||||
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); }
|
||||
@@ -127,6 +159,7 @@
|
||||
container.addEventListener('pointerdown', function(evt){
|
||||
try{
|
||||
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;
|
||||
const clicked = dayEl.dataset.iso;
|
||||
if(!selStart){ selStart = clicked; selEnd = null; }
|
||||
@@ -135,10 +168,22 @@
|
||||
previewEnd = null;
|
||||
render();
|
||||
if(selStart && selEnd){
|
||||
// If a specific workerId is supplied, use existing single-worker modal flow.
|
||||
if(workerId){
|
||||
const url = requestUrl + '?worker_id=' + encodeURIComponent(workerId) + '&start_date=' + selStart + '&end_date=' + selEnd;
|
||||
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); }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user