.
This commit is contained in:
@@ -129,6 +129,8 @@ STATICFILES_DIRS = [
|
|||||||
BASE_DIR / "output",
|
BASE_DIR / "output",
|
||||||
# also look for output/ at the repository root (BASE_DIR.parent)
|
# also look for output/ at the repository root (BASE_DIR.parent)
|
||||||
BASE_DIR.parent / "output",
|
BASE_DIR.parent / "output",
|
||||||
|
# repository-level static folder (workspace root)
|
||||||
|
BASE_DIR.parent / "static",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Default primary key field type
|
# Default primary key field type
|
||||||
|
|||||||
@@ -31,6 +31,69 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
|
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
|
||||||
|
<!-- Modal swap target for HTMX -->
|
||||||
|
<div id="modal" style="display:none;"></div>
|
||||||
|
<style>
|
||||||
|
/* minimal modal container styles: hidden by default, HTMX-inserted content can show it */
|
||||||
|
#modal { position:fixed; left:0; top:0; right:0; bottom:0; display:none; align-items:center; justify-content:center; z-index:1200 }
|
||||||
|
#modal .modal-backdrop { position:absolute; left:0; top:0; right:0; bottom:0; background:rgba(0,0,0,0.4); }
|
||||||
|
#modal .modal-content { position:relative; background:white; padding:1rem; border-radius:6px; box-shadow:0 6px 24px rgba(0,0,0,0.2); max-width:90%; max-height:90%; overflow:auto; z-index:2 }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
// Show modal when HTMX swaps content into #modal, and provide backdrop/close behavior
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function(evt){
|
||||||
|
try{
|
||||||
|
const target = evt.detail.target;
|
||||||
|
if(target && target.id === 'modal'){
|
||||||
|
const modal = document.getElementById('modal');
|
||||||
|
// If server returned empty content, hide modal
|
||||||
|
if(!modal.innerHTML.trim()){ modal.style.display='none';
|
||||||
|
// clear calendar selections on modal close
|
||||||
|
try{ if(window.clearAllCalendarSelections) window.clearAllCalendarSelections(); else if(window._calendars) Object.values(window._calendars).forEach(c=>c.clearSelection && c.clearSelection()); }catch(e){}
|
||||||
|
return; }
|
||||||
|
// wrap content if needed
|
||||||
|
if(!modal.querySelector('.modal-backdrop')){
|
||||||
|
const backdrop = document.createElement('div'); backdrop.className='modal-backdrop';
|
||||||
|
const content = document.createElement('div'); content.className='modal-content';
|
||||||
|
// move existing children into content
|
||||||
|
while(modal.firstChild){ content.appendChild(modal.firstChild); }
|
||||||
|
modal.appendChild(backdrop);
|
||||||
|
modal.appendChild(content);
|
||||||
|
}
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}catch(e){ console.error('modal afterSwap handler error', e); }
|
||||||
|
});
|
||||||
|
// Helper to close the app modal and clear calendar selections
|
||||||
|
window.closeProcRotaModal = function(){
|
||||||
|
try{
|
||||||
|
if(window.clearAllCalendarSelections) window.clearAllCalendarSelections();
|
||||||
|
else if(window._calendars) Object.values(window._calendars).forEach(c=>c.clearSelection && c.clearSelection());
|
||||||
|
}catch(e){/* ignore */}
|
||||||
|
const modal = document.getElementById('modal'); if(modal){ modal.style.display='none'; modal.innerHTML=''; }
|
||||||
|
};
|
||||||
|
// Close when clicking backdrop
|
||||||
|
document.body.addEventListener('click', function(e){
|
||||||
|
const modal = document.getElementById('modal'); if(!modal) return;
|
||||||
|
if(e.target === modal || e.target.classList.contains('modal-backdrop')){
|
||||||
|
modal.style.display='none'; modal.innerHTML='';
|
||||||
|
// clear calendar selections when modal cancelled by backdrop click
|
||||||
|
try{ if(window.clearAllCalendarSelections) window.clearAllCalendarSelections(); else if(window._calendars) Object.values(window._calendars).forEach(c=>c.clearSelection && c.clearSelection()); }catch(e){}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Also respond to HTMX-triggered closeModal custom event (server may send HX-Trigger: closeModal)
|
||||||
|
document.body.addEventListener('closeModal', function(){
|
||||||
|
try{ if(window.clearAllCalendarSelections) window.clearAllCalendarSelections(); else if(window._calendars) Object.values(window._calendars).forEach(c=>c.clearSelection && c.clearSelection()); }catch(e){}
|
||||||
|
// ensure modal is cleared visually as well
|
||||||
|
const modal = document.getElementById('modal'); if(modal){ modal.style.display='none'; modal.innerHTML=''; }
|
||||||
|
});
|
||||||
|
// When leave list updated server-side, HTMX may trigger 'leaveUpdated'.
|
||||||
|
document.body.addEventListener('leaveUpdated', function(){
|
||||||
|
try{
|
||||||
|
if(window._calendars){ Object.values(window._calendars).forEach(c => c.refreshLeaves && c.refreshLeaves()); }
|
||||||
|
}catch(e){ console.error('leaveUpdated handler', e); }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% load crispy_forms_tags %}
|
{% load crispy_forms_tags static %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="title">Request leave{% if worker %} for {{ worker.name }}{% endif %}</h2>
|
<h2 class="title">Request leave{% if worker %} for {{ worker.name }}{% endif %}</h2>
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
<div class="box">
|
<div class="box">
|
||||||
<h3 class="subtitle">Request leave on calendar</h3>
|
<h3 class="subtitle">Request leave on calendar</h3>
|
||||||
{% include 'rota/partials/flatpickr_includes.html' %}
|
{% include 'rota/partials/flatpickr_includes.html' %}
|
||||||
|
<link rel="stylesheet" href="{% static 'css/calendar.css' %}">
|
||||||
<div id="calendar" class="calendar" data-worker-id="{% if worker %}{{ worker.id }}{% endif %}" data-mode="inline"></div>
|
<div id="calendar" class="calendar" data-worker-id="{% if worker %}{{ worker.id }}{% endif %}" data-mode="inline"></div>
|
||||||
<p class="help">Click a start date then a second date to select a range and prefill the leave form.</p>
|
<p class="help">Click a start date then a second date to select a range and prefill the leave form.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -20,108 +21,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
{% if worker %}
|
||||||
|
<h2 class="subtitle">Existing leave for {{ worker.name }}</h2>
|
||||||
|
<div class="content">
|
||||||
|
{% include 'rota/partials/leave_list.html' with leaves=worker.leaves.all %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="help">If you are requesting leave for a specific worker, open this page using the worker link so existing requests will be shown.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<script src="{% static 'js/calendar.js' %}"></script>
|
||||||
.calendar { display:flex; flex-direction:column; gap:0.5rem; margin-bottom:1rem }
|
|
||||||
.calendar .week { display:flex; gap:0.5rem; }
|
|
||||||
.calendar .day { flex:1; padding:0.5rem; border:1px solid #eee; min-height:3rem; cursor:pointer; }
|
|
||||||
.calendar .day.other-month { background:#f9f9f9; color:#999; }
|
|
||||||
.calendar .day.leave { background:#ffecec; }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
const container = document.getElementById('calendar');
|
const container = document.getElementById('calendar');
|
||||||
if(!container) return;
|
if(!container) return;
|
||||||
const workerId = container.dataset.workerId;
|
|
||||||
const leaves = [
|
const leaves = [
|
||||||
{% if worker %}
|
{% if worker %}
|
||||||
{% for l in worker.leaves.all %}
|
{% for l in worker.leaves.all %}
|
||||||
{start:'{{ l.start_date }}', end:'{{ l.end_date }}'},
|
{start:'{{ l.start_date|date:"Y-m-d" }}', end:'{{ l.end_date|date:"Y-m-d" }}'},
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
];
|
];
|
||||||
|
window.initCalendar('calendar', { workerId: container.dataset.workerId, leaves: leaves, requestUrl: '{% url "rota:request_leave" %}', leavesUrl: '{% if worker %}{% url "rota:worker_leaves_json" worker.id %}{% endif %}', mode: container.dataset.mode || 'inline' });
|
||||||
let viewYear = new Date().getFullYear();
|
|
||||||
let viewMonth = new Date().getMonth();
|
|
||||||
let selStart = null; let selEnd = null; let previewEnd = null;
|
|
||||||
|
|
||||||
function iso(y,m,d){ return `${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}` }
|
|
||||||
function inLeaves(isoStr){ for(const L of leaves){ if(!L.start) continue; if(L.start<=isoStr && isoStr<=L.end) return true } return false }
|
|
||||||
|
|
||||||
function buildControls(){
|
|
||||||
let controls = document.getElementById('calendar-controls');
|
|
||||||
if(!controls){
|
|
||||||
controls = document.createElement('div'); controls.id='calendar-controls'; controls.style.marginTop='0.5rem';
|
|
||||||
controls.innerHTML = `<span id="sel-label">No dates selected</span> <button id="sel-confirm" class="button is-primary" style="margin-left:0.5rem;">Apply</button> <button id="sel-clear" class="button" style="margin-left:0.5rem;">Clear</button>`;
|
|
||||||
container.parentNode.insertBefore(controls, container.nextSibling);
|
|
||||||
document.getElementById('sel-clear').onclick = function(){ selStart = null; selEnd = null; previewEnd = null; render(); };
|
|
||||||
document.getElementById('sel-confirm').onclick = function(){
|
|
||||||
if(!selStart) return;
|
|
||||||
if(!selEnd) selEnd = selStart;
|
|
||||||
const startInput = document.querySelector('input[name="start_date"]');
|
|
||||||
const endInput = document.querySelector('input[name="end_date"]');
|
|
||||||
if(startInput) startInput.value = selStart;
|
|
||||||
if(endInput) endInput.value = selEnd;
|
|
||||||
if(typeof initFlatpickr === 'function') initFlatpickr();
|
|
||||||
// scroll to form
|
|
||||||
const form = document.querySelector('form'); if(form) form.scrollIntoView({behavior:'smooth'});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const label = document.getElementById('sel-label');
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(){
|
|
||||||
container.innerHTML='';
|
|
||||||
const first = new Date(viewYear, viewMonth, 1);
|
|
||||||
const startDay = first.getDay();
|
|
||||||
const days = new Date(viewYear, viewMonth+1, 0).getDate();
|
|
||||||
let cur = 1 - startDay;
|
|
||||||
const header = document.createElement('div'); header.style.display='flex'; header.style.justifyContent='space-between'; header.style.alignItems='center'; header.style.marginBottom='0.5rem';
|
|
||||||
const title = document.createElement('div'); title.textContent = first.toLocaleString(undefined,{month:'long', year:'numeric'});
|
|
||||||
const prev = document.createElement('button'); prev.textContent='◀'; prev.className='button'; prev.onclick=()=>{ viewMonth--; if(viewMonth<0){ viewMonth=11; viewYear--; } render(); };
|
|
||||||
const next = document.createElement('button'); next.textContent='▶'; next.className='button'; next.onclick=()=>{ viewMonth++; if(viewMonth>11){ viewMonth=0; viewYear++; } render(); };
|
|
||||||
header.appendChild(prev); header.appendChild(title); header.appendChild(next);
|
|
||||||
container.appendChild(header);
|
|
||||||
|
|
||||||
while(cur<=days){
|
|
||||||
const week = document.createElement('div'); week.className='week';
|
|
||||||
for(let i=0;i<7;i++){
|
|
||||||
const dayDiv = document.createElement('div'); dayDiv.className='day';
|
|
||||||
if(cur<1 || cur>days){ dayDiv.classList.add('other-month'); dayDiv.textContent=''; }
|
|
||||||
else{
|
|
||||||
const isoStr = iso(viewYear, viewMonth, cur);
|
|
||||||
dayDiv.textContent = cur;
|
|
||||||
if(inLeaves(isoStr)) dayDiv.classList.add('leave');
|
|
||||||
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');
|
|
||||||
dayDiv.dataset.iso = isoStr;
|
|
||||||
dayDiv.onclick = function(){
|
|
||||||
const clicked = this.dataset.iso;
|
|
||||||
if(!selStart){ selStart = clicked; selEnd = null; }
|
|
||||||
else if(selStart && !selEnd){ if(clicked < selStart){ selEnd = selStart; selStart = clicked } else selEnd = clicked }
|
|
||||||
else { selStart = clicked; selEnd = null }
|
|
||||||
previewEnd = null;
|
|
||||||
render();
|
|
||||||
};
|
|
||||||
dayDiv.onmouseover = function(){ if(selStart && !selEnd){ previewEnd = this.dataset.iso; render(); } };
|
|
||||||
dayDiv.onmouseout = function(){ if(selStart && !selEnd){ previewEnd = null; render(); } };
|
|
||||||
}
|
|
||||||
week.appendChild(dayDiv);
|
|
||||||
cur++;
|
|
||||||
}
|
|
||||||
container.appendChild(week);
|
|
||||||
}
|
|
||||||
buildControls();
|
|
||||||
}
|
|
||||||
|
|
||||||
render();
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{% comment %}Render a simple leave list with data attributes so client JS can read them{% endcomment %}
|
||||||
|
<div id="leave-list">
|
||||||
|
<ul>
|
||||||
|
{% for l in leaves %}
|
||||||
|
<li data-start="{{ l.start_date|date:'Y-m-d' }}" data-end="{{ l.end_date|date:'Y-m-d' }}">{{ l.start_date }} → {{ l.end_date }}{% if l.reason %} ({{ l.reason }}){% endif %}</li>
|
||||||
|
{% empty %}
|
||||||
|
<li>No leave recorded</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
<header class="modal-card-head">
|
<header class="modal-card-head">
|
||||||
<p class="modal-card-title">Request leave{% if worker %} for {{ worker.name }}{% endif %}</p>
|
<p class="modal-card-title">Request leave{% if worker %} for {{ worker.name }}{% endif %}</p>
|
||||||
<button class="delete" aria-label="close" onclick="document.getElementById('modal').innerHTML='' "></button>
|
<button class="delete" aria-label="close" onclick="(window.closeProcRotaModal || (()=>{document.getElementById('modal').innerHTML=''}))()"></button>
|
||||||
</header>
|
</header>
|
||||||
<section class="modal-card-body">
|
<section class="modal-card-body">
|
||||||
<form method="post" hx-post="{{ form_action }}" hx-target="#modal" hx-swap="innerHTML">
|
<form method="post" hx-post="{{ form_action }}" hx-target="#modal" hx-swap="innerHTML">
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-primary" type="submit">Request</button>
|
<button class="button is-primary" type="submit">Request</button>
|
||||||
<button type="button" class="button" onclick="document.getElementById('modal').innerHTML=''">Cancel</button>
|
<button type="button" class="button" onclick="(window.closeProcRotaModal || (()=>{document.getElementById('modal').innerHTML=''}))()">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
{% load crispy_forms_tags %}
|
{% load crispy_forms_tags static %}
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>{{ worker.name }}</title>
|
<title>{{ worker.name }}</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" rel="stylesheet">
|
<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>
|
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
|
||||||
{% include 'rota/partials/flatpickr_includes.html' %}
|
{% include 'rota/partials/flatpickr_includes.html' %}
|
||||||
<style>
|
<link rel="stylesheet" href="{% static 'css/calendar.css' %}">
|
||||||
.calendar { max-width:900px; margin:0 auto; }
|
|
||||||
.calendar .week { display:flex; gap:0.25rem; }
|
|
||||||
.calendar .day { flex:1; padding:0.5rem; border:1px solid #eee; min-height:3rem; cursor:pointer; text-align:center }
|
|
||||||
.calendar .day.other-month { background:#f9f9f9; color:#999; }
|
|
||||||
.calendar .day.leave { background:#ffecec; }
|
|
||||||
.calendar .day.selected { background:#cdeffd; }
|
|
||||||
.calendar .day.in-range { background:#d6f5d6; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<section class="section">
|
<section class="section">
|
||||||
@@ -41,116 +33,21 @@
|
|||||||
|
|
||||||
<h2 class="subtitle">Existing leave</h2>
|
<h2 class="subtitle">Existing leave</h2>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<ul>
|
{% include 'rota/partials/leave_list.html' with leaves=worker.leaves.all %}
|
||||||
{% for l in worker.leaves.all %}
|
|
||||||
<li>{{ l.start_date }} → {{ l.end_date }} {% if l.reason %}({{ l.reason }}){% endif %}</li>
|
|
||||||
{% empty %}
|
|
||||||
<li>No leave recorded</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<script src="{% static 'js/calendar.js' %}"></script>
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
const container = document.getElementById('calendar');
|
const container = document.getElementById('calendar');
|
||||||
if(!container) return;
|
if(!container) return;
|
||||||
const workerId = container.dataset.workerId;
|
|
||||||
|
|
||||||
// collect existing leaves
|
|
||||||
const leaves = [
|
const leaves = [
|
||||||
{% for l in worker.leaves.all %}
|
{% for l in worker.leaves.all %}
|
||||||
{start:'{{ l.start_date }}', end:'{{ l.end_date }}'},
|
{start:'{{ l.start_date|date:"Y-m-d" }}', end:'{{ l.end_date|date:"Y-m-d" }}'},
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
];
|
];
|
||||||
|
window.initCalendar('calendar', { workerId: container.dataset.workerId, leaves: leaves, requestUrl: '{% url "rota:request_leave" %}', leavesUrl: '{% url "rota:worker_leaves_json" worker.id %}' });
|
||||||
const today = new Date();
|
|
||||||
let viewYear = today.getFullYear();
|
|
||||||
let viewMonth = today.getMonth();
|
|
||||||
let selStart = null; // ISO string
|
|
||||||
let selEnd = null;
|
|
||||||
let previewEnd = null;
|
|
||||||
|
|
||||||
function inLeaves(iso){
|
|
||||||
for(const L of leaves){
|
|
||||||
if(!L.start) continue;
|
|
||||||
if(L.start <= iso && iso <= L.end) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function iso(y,m,d){ return `${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}` }
|
|
||||||
|
|
||||||
function buildControls(){
|
|
||||||
let controls = document.getElementById('calendar-controls');
|
|
||||||
if(!controls){
|
|
||||||
controls = document.createElement('div'); controls.id = 'calendar-controls'; controls.style.marginTop = '0.5rem';
|
|
||||||
controls.innerHTML = `<span id="sel-label">No dates selected</span> <button id="sel-confirm" class="button is-primary" style="margin-left:0.5rem;">Confirm</button> <button id="sel-clear" class="button" style="margin-left:0.5rem;">Clear</button>`;
|
|
||||||
container.parentNode.insertBefore(controls, container.nextSibling);
|
|
||||||
document.getElementById('sel-clear').onclick = function(){ selStart = null; selEnd = null; previewEnd = null; render(); };
|
|
||||||
document.getElementById('sel-confirm').onclick = function(){
|
|
||||||
if(!selStart) return;
|
|
||||||
if(!selEnd) selEnd = selStart;
|
|
||||||
const url = '{% url "rota:request_leave" %}' + '?worker_id=' + workerId + '&start_date=' + selStart + '&end_date=' + selEnd;
|
|
||||||
try{ htmx.ajax('GET', url, {target:'#modal', swap:'innerHTML'}); }catch(e){ window.location = url; }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const label = document.getElementById('sel-label');
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(){
|
|
||||||
container.innerHTML='';
|
|
||||||
const first = new Date(viewYear, viewMonth, 1);
|
|
||||||
const startDay = first.getDay();
|
|
||||||
const days = new Date(viewYear, viewMonth+1, 0).getDate();
|
|
||||||
let cur = 1 - startDay;
|
|
||||||
|
|
||||||
const header = document.createElement('div'); header.style.display='flex'; header.style.justifyContent='space-between'; header.style.alignItems='center'; header.style.marginBottom='0.5rem';
|
|
||||||
const title = document.createElement('div'); title.textContent = first.toLocaleString(undefined,{month:'long', year:'numeric'});
|
|
||||||
const prev = document.createElement('button'); prev.textContent='◀'; prev.className='button'; prev.onclick = ()=>{ viewMonth--; if(viewMonth<0){ viewMonth=11; viewYear--; } render(); };
|
|
||||||
const next = document.createElement('button'); next.textContent='▶'; next.className='button'; next.onclick = ()=>{ viewMonth++; if(viewMonth>11){ viewMonth=0; viewYear++; } render(); };
|
|
||||||
header.appendChild(prev); header.appendChild(title); header.appendChild(next);
|
|
||||||
container.appendChild(header);
|
|
||||||
|
|
||||||
while(cur <= days){
|
|
||||||
const week = document.createElement('div'); week.className='week';
|
|
||||||
for(let i=0;i<7;i++){
|
|
||||||
const d = cur;
|
|
||||||
const dayDiv = document.createElement('div'); dayDiv.className='day';
|
|
||||||
if(d<1 || d>days){ dayDiv.classList.add('other-month'); dayDiv.textContent=''; }
|
|
||||||
else{
|
|
||||||
const dayIso = iso(viewYear, viewMonth, d);
|
|
||||||
dayDiv.textContent = d;
|
|
||||||
if(inLeaves(dayIso)) dayDiv.classList.add('leave');
|
|
||||||
// determine visible selection range: prefer explicit selEnd, else previewEnd
|
|
||||||
const rangeEnd = selEnd || previewEnd;
|
|
||||||
if(selStart && rangeEnd){ if(selStart<=dayIso && dayIso<=rangeEnd) dayDiv.classList.add('in-range'); }
|
|
||||||
else if(selStart && selStart===dayIso) dayDiv.classList.add('selected');
|
|
||||||
dayDiv.dataset.iso = dayIso;
|
|
||||||
dayDiv.onclick = function(){
|
|
||||||
const clicked = this.dataset.iso;
|
|
||||||
if(!selStart){ selStart = clicked; selEnd = null; }
|
|
||||||
else if(selStart && !selEnd){ if(clicked < selStart){ selEnd = selStart; selStart = clicked; } else selEnd = clicked; }
|
|
||||||
else { selStart = clicked; selEnd = null; }
|
|
||||||
previewEnd = null;
|
|
||||||
render();
|
|
||||||
};
|
|
||||||
dayDiv.onmouseover = function(){ if(selStart && !selEnd){ previewEnd = this.dataset.iso; render(); } };
|
|
||||||
dayDiv.onmouseout = function(){ if(selStart && !selEnd){ previewEnd = null; render(); } };
|
|
||||||
}
|
|
||||||
week.appendChild(dayDiv);
|
|
||||||
cur++;
|
|
||||||
}
|
|
||||||
container.appendChild(week);
|
|
||||||
}
|
|
||||||
buildControls();
|
|
||||||
}
|
|
||||||
|
|
||||||
render();
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ urlpatterns = [
|
|||||||
path("worker/<int:rota_id>/<int:worker_id>/edit/", views.worker_edit, name="worker_edit"),
|
path("worker/<int:rota_id>/<int:worker_id>/edit/", views.worker_edit, name="worker_edit"),
|
||||||
path("worker/<int:rota_id>/<int:worker_id>/delete/", views.worker_delete, name="worker_delete"),
|
path("worker/<int:rota_id>/<int:worker_id>/delete/", views.worker_delete, name="worker_delete"),
|
||||||
path("worker/<int:worker_id>/", views.worker_detail, name="worker_detail"),
|
path("worker/<int:worker_id>/", views.worker_detail, name="worker_detail"),
|
||||||
|
path("worker/<int:worker_id>/leaves.json", views.leaves_json, name="worker_leaves_json"),
|
||||||
path("leave/request/", views.request_leave, name="request_leave"),
|
path("leave/request/", views.request_leave, name="request_leave"),
|
||||||
path("leave/request/token/<uuid:token>/", views.request_leave, name="request_leave_token"),
|
path("leave/request/token/<uuid:token>/", views.request_leave, name="request_leave_token"),
|
||||||
path("worker/<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"),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import json
|
|||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.http import HttpResponse, HttpResponseBadRequest
|
from django.http import HttpResponse, HttpResponseBadRequest
|
||||||
|
from django.http import JsonResponse
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
from django.shortcuts import HttpResponseRedirect
|
from django.shortcuts import HttpResponseRedirect
|
||||||
import traceback
|
import traceback
|
||||||
@@ -508,10 +509,19 @@ def request_leave(request, token=None):
|
|||||||
# On HTMX modal post, return an OOB swap to close modal and optionally
|
# On HTMX modal post, return an OOB swap to close modal and optionally
|
||||||
# update worker detail partial if present.
|
# update worker detail partial if present.
|
||||||
if is_hx:
|
if is_hx:
|
||||||
resp = '<div id="modal" hx-swap-oob="true"></div>'
|
# Render updated leave list and return OOB swaps: update leave-list and clear modal.
|
||||||
# optionally include trigger
|
leave_list_html = render_to_string(
|
||||||
|
"rota/partials/leave_list.html",
|
||||||
|
{"leaves": worker.leaves.all()},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
resp = (
|
||||||
|
f'<div id="leave-list" hx-swap-oob="true">{leave_list_html}</div>'
|
||||||
|
+ '<div id="modal" hx-swap-oob="true"></div>'
|
||||||
|
)
|
||||||
response = HttpResponse(resp)
|
response = HttpResponse(resp)
|
||||||
response["HX-Trigger"] = "closeModal"
|
# Trigger modal close and a leaveUpdated event so client can refresh calendar highlights
|
||||||
|
response["HX-Trigger"] = "closeModal,leaveUpdated"
|
||||||
return response
|
return response
|
||||||
return redirect("rota:worker_detail", worker_id=worker.id)
|
return redirect("rota:worker_detail", worker_id=worker.id)
|
||||||
else:
|
else:
|
||||||
@@ -929,6 +939,19 @@ def rota_run_export_download(request, run_id):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def leaves_json(request, worker_id):
|
||||||
|
"""Return JSON list of leave ranges for a given worker."""
|
||||||
|
worker = get_object_or_404(Worker, pk=worker_id)
|
||||||
|
data = []
|
||||||
|
for l in worker.leaves.all().order_by('start_date'):
|
||||||
|
data.append({
|
||||||
|
'start': l.start_date.isoformat(),
|
||||||
|
'end': l.end_date.isoformat(),
|
||||||
|
'reason': l.reason,
|
||||||
|
})
|
||||||
|
return JsonResponse({'leaves': data})
|
||||||
|
|
||||||
|
|
||||||
@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.
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/* Shared calendar styles */
|
||||||
|
.calendar { display:flex; flex-direction:column; gap:0.5rem; margin-bottom:1rem; max-width:900px; margin-left:auto; margin-right:auto }
|
||||||
|
.calendar .week { display:flex; gap:0.5rem; }
|
||||||
|
.calendar .day { flex:1; padding:0.5rem; border:1px solid #eee; min-height:3rem; cursor:pointer; text-align:center }
|
||||||
|
.calendar .day.other-month { background:#f9f9f9; color:#999; }
|
||||||
|
.calendar .day.leave { background:#ffecec; }
|
||||||
|
.calendar .day.selected { color:#2b8fd6; font-weight:700 }
|
||||||
|
.calendar .day.in-range { color:#2b8fd6; font-weight:600 }
|
||||||
|
.calendar .day:hover { box-shadow: 0 1px 2px rgba(0,0,0,0.05); }
|
||||||
|
.calendar .day.day-header { background:transparent; border:0; padding:0.25rem; min-height:unset }
|
||||||
|
#calendar-controls { margin-top:0.5rem }
|
||||||
|
#calendar-controls .button { margin-left:0.5rem }
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/* Shared calendar JS
|
||||||
|
Usage: call initCalendar(containerId, opts)
|
||||||
|
opts:
|
||||||
|
workerId: optional worker id
|
||||||
|
leaves: array of {start:'YYYY-MM-DD', end:'YYYY-MM-DD'}
|
||||||
|
requestUrl: url to use for requesting leave (GET will be used with query params)
|
||||||
|
mode: 'inline' or other
|
||||||
|
*/
|
||||||
|
(function(){
|
||||||
|
// Keep a registry of calendar instances so external code (eg. modal handlers)
|
||||||
|
// can clear selections when needed.
|
||||||
|
window._calendars = window._calendars || {};
|
||||||
|
|
||||||
|
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);
|
||||||
|
if(!container) return;
|
||||||
|
const workerId = opts.workerId || container.dataset.workerId || '';
|
||||||
|
let leaves = opts.leaves || [];
|
||||||
|
const leavesUrl = opts.leavesUrl || null;
|
||||||
|
const requestUrl = opts.requestUrl || (window.location.origin + '/leave/request/');
|
||||||
|
|
||||||
|
let viewYear = new Date().getFullYear();
|
||||||
|
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 buildControls(){
|
||||||
|
let controls = document.getElementById('calendar-controls');
|
||||||
|
if(!controls){
|
||||||
|
controls = document.createElement('div'); controls.id='calendar-controls';
|
||||||
|
controls.innerHTML = `<span id="sel-label">No dates selected</span> <button id="sel-confirm" class="button is-primary">Apply</button> <button id="sel-clear" class="button">Clear</button>`;
|
||||||
|
container.parentNode.insertBefore(controls, container.nextSibling);
|
||||||
|
document.getElementById('sel-clear').onclick = function(){ selStart = null; selEnd = null; previewEnd = null; render(); };
|
||||||
|
document.getElementById('sel-confirm').onclick = function(){
|
||||||
|
if(!selStart) return;
|
||||||
|
if(!selEnd) selEnd = selStart;
|
||||||
|
const startInput = document.querySelector('input[name="start_date"]');
|
||||||
|
const endInput = document.querySelector('input[name="end_date"]');
|
||||||
|
if(startInput) startInput.value = selStart;
|
||||||
|
if(endInput) endInput.value = selEnd;
|
||||||
|
if(typeof initFlatpickr === 'function') initFlatpickr();
|
||||||
|
const form = document.querySelector('form'); if(form) form.scrollIntoView({behavior:'smooth'});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const label = document.getElementById('sel-label');
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(){
|
||||||
|
container.innerHTML='';
|
||||||
|
const first = new Date(viewYear, viewMonth, 1);
|
||||||
|
const startDay = first.getDay();
|
||||||
|
const days = new Date(viewYear, viewMonth+1, 0).getDate();
|
||||||
|
let cur = 1 - startDay;
|
||||||
|
|
||||||
|
const header = document.createElement('div'); header.style.display='flex'; header.style.justifyContent='space-between'; header.style.alignItems='center'; header.style.marginBottom='0.5rem';
|
||||||
|
const title = document.createElement('div'); title.textContent = first.toLocaleString(undefined,{month:'long', year:'numeric'});
|
||||||
|
const prev = document.createElement('button'); prev.textContent='◀'; prev.className='button'; prev.onclick=()=>{ viewMonth--; if(viewMonth<0){ viewMonth=11; viewYear--; } render(); };
|
||||||
|
const next = document.createElement('button'); next.textContent='▶'; next.className='button'; next.onclick=()=>{ viewMonth++; if(viewMonth>11){ viewMonth=0; viewYear++; } render(); };
|
||||||
|
header.appendChild(prev); header.appendChild(title); header.appendChild(next);
|
||||||
|
container.appendChild(header);
|
||||||
|
|
||||||
|
// Day-of-week header row
|
||||||
|
const dow = document.createElement('div'); dow.className='week'; dow.style.fontWeight='bold';
|
||||||
|
const dayNames = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
||||||
|
for(const dn of dayNames){ const dh = document.createElement('div'); dh.className='day day-header'; dh.style.textAlign='center'; dh.style.padding='0.25rem'; dh.textContent = dn; dow.appendChild(dh); }
|
||||||
|
container.appendChild(dow);
|
||||||
|
|
||||||
|
while(cur<=days){
|
||||||
|
const week = document.createElement('div'); week.className='week';
|
||||||
|
for(let i=0;i<7;i++){
|
||||||
|
const dayDiv = document.createElement('div'); dayDiv.className='day';
|
||||||
|
if(cur<1 || cur>days){ dayDiv.classList.add('other-month'); dayDiv.textContent=''; }
|
||||||
|
else{
|
||||||
|
const isoStr = iso(viewYear, viewMonth, cur);
|
||||||
|
dayDiv.textContent = cur;
|
||||||
|
if(inLeaves(isoStr)) dayDiv.classList.add('leave');
|
||||||
|
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');
|
||||||
|
dayDiv.dataset.iso = isoStr;
|
||||||
|
dayDiv.onclick = function(){
|
||||||
|
const clicked = this.dataset.iso;
|
||||||
|
if(!selStart){ selStart = clicked; selEnd = null; }
|
||||||
|
else if(selStart && !selEnd){ if(clicked < selStart){ selEnd = selStart; selStart = clicked } else selEnd = clicked }
|
||||||
|
else { selStart = clicked; selEnd = null }
|
||||||
|
previewEnd = null;
|
||||||
|
render();
|
||||||
|
// Auto-confirm on range completion: open request URL with params
|
||||||
|
if(selStart && selEnd){
|
||||||
|
const url = requestUrl + '?worker_id=' + encodeURIComponent(workerId) + '&start_date=' + selStart + '&end_date=' + selEnd;
|
||||||
|
try{ htmx.ajax('GET', url, {target:'#modal', swap:'innerHTML'}); }catch(e){ window.location = url; }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
dayDiv.onmouseover = function(){ if(selStart && !selEnd){ previewEnd = this.dataset.iso; render(); } };
|
||||||
|
dayDiv.onmouseout = function(){ if(selStart && !selEnd){ previewEnd = null; render(); } };
|
||||||
|
}
|
||||||
|
week.appendChild(dayDiv);
|
||||||
|
cur++;
|
||||||
|
}
|
||||||
|
container.appendChild(week);
|
||||||
|
}
|
||||||
|
buildControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
// expose a simple API to clear the current selection from outside.
|
||||||
|
function clearSelection(){ selStart = null; selEnd = null; previewEnd = null; render(); }
|
||||||
|
function setLeaves(newLeaves){ leaves = newLeaves || []; render(); }
|
||||||
|
async function refreshLeaves(){
|
||||||
|
if(leavesUrl){
|
||||||
|
try{
|
||||||
|
const res = await fetch(leavesUrl, { credentials: 'same-origin' });
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}catch(e){ console.error('refreshLeaves failed', e); }
|
||||||
|
}
|
||||||
|
// fallback: do nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
window._calendars[containerId] = { clearSelection, setLeaves, refreshLeaves };
|
||||||
|
|
||||||
|
render();
|
||||||
|
// If a server leavesUrl is provided, refresh from server to ensure authoritative data
|
||||||
|
if(leavesUrl){
|
||||||
|
// don't await here — fire-and-forget refresh
|
||||||
|
refreshLeaves();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// provide a global helper to clear selections across all calendars
|
||||||
|
window.clearAllCalendarSelections = function(){
|
||||||
|
try{
|
||||||
|
if(window._calendars){ Object.values(window._calendars).forEach(c => c.clearSelection && c.clearSelection()); }
|
||||||
|
}catch(e){ console.error('clearAllCalendarSelections', e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// expose
|
||||||
|
window.initCalendar = initCalendar;
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user