This commit is contained in:
Ross
2025-12-14 09:22:47 +00:00
parent 249e09766d
commit e4eaca378b
4 changed files with 505 additions and 32 deletions
@@ -0,0 +1,295 @@
html {
/* color: red; */
}
table {
/* margin-top: 50px; */
text-align: center;
/* table-layout : fixed; */
/* width:150px */
/* width: 200%; */
/* overflow-x: auto;
display: block;
position: relative; */
border-collapse: separate;
border-spacing: 0;
}
#main-table {
padding-top: 100px;
}
.shift-table {
padding-top: 10px;
}
.shift-table thead {
font-weight: bold;
}
.table-div {
/* overflow-x: auto; */
/* width: 80%; */
overflow-x: scroll;
margin-left: 200px;
overflow-y: visible;
padding: 0;
}
#main-table th {
text-align: center;
white-space: nowrap;
transform: rotate(90deg) translateX(-100px);
position: sticky;
top: 0;
}
#main-table th p {
margin: 0 -100%;
display: inline-block;
}
#main-table th p:before {
content: '';
width: 0;
padding-top: 110%;
/* takes width as reference, + 10% for faking some extra padding */
display: inline-block;
vertical-align: middle;
}
#main-table td,
th {
max-width: 10px;
padding: 0px;
}
#main-table tr {
padding: 0px;
}
#main-table tr:hover {
background-color: lightblue;
}
#main-table tr:hover .unavailable {
background-color: lightgray;
}
.Sat {
/* opacity: 30%; */
/* background-color: lightsteelblue; */
border-left: solid 1px lightsteelblue;
}
.Sun {
border-right: solid 1px lightsteelblue;
}
.bank-holiday {
border-bottom: dotted 1px blue;
}
#main-table th.bank-holiday {
color: darkblue;
}
.plymouth {
color: #9169FF;
}
.exeter {
color: #5F60E8;
}
.truro {
color: #0074D9;
}
.torquay,
.torbay {
color: #5FA8E8;
}
.taunton {
color: #e85fdf;
}
.unavailable {
color: lightgray;
}
.shift-requested {
color: green;
}
.worker-summary {
position: absolute;
display: none;
overflow-x: visible;
width: 100%;
text-align: left;
}
.site-summary {
display: inline-flex;
}
.site-summary .site {
border: 1px solid black;
padding: 5px;
margin: 20px;
}
.site-name {
font-weight: bold;
}
.worker-summary span {
min-width: 160px;
display: inline-flex;
}
.training-days-lost {
padding-top: 5px;
}
.tdl-settings {
padding: 2px;
}
.table-div+pre {
display: none;
}
.shift-breakdown-button+pre {
display: none;
}
.shift-breakdown-button {
display: block;
}
.nwd {
color: lightcoral;
}
#main-table th.worker,
#main-table td.worker {
position: absolute;
width: 200px;
max-width: 200px;
left: 0;
top: auto;
border-top-width: 1px;
/*only relevant for first row*/
margin-top: -1px;
/*compensate for top border*/
}
.hidden {
opacity: 0%;
}
.tsummary {
max-width: auto;
}
#main-table.header th {
max-width: 200px;
}
table.transposed {}
table.transposed tr {}
table.transposed th,
table.transposed td {
width: fit-content;
max-width: unset;
position: relative;
border-top: solid 0.5px black;
border-right: solid 0.5px black;
}
table.transposed .Sat {
/* opacity: 30%; */
/* background-color: lightsteelblue; */
border-top: solid 2px lightsteelblue !important;
}
table.transposed .Sun {
border-bottom: solid 2px lightsteelblue !important;
}
table.transposed .bank-holiday {
border-bottom: dotted 2px blue !important;
}
table.transposed th.bank-holiday {
color: darkblue;
}
.target-assigned.no-colour {
background-color: white !important;
}
.locum-shift {
background-color: lightgreen;
}
.multi-shift {
line-height: 1;
}
button.selected {
background: #0074d9;
color: #fff;
}
#main-table th.col-hover {
background: #ffe066 !important;
color: #222 !important;
}
.highlight-day-btn.highlighted {
background: #ffe066 !important;
color: #222 !important;
border: 2px solid #bfa600 !important;
}
.highlight-day-btn.highlighted {
background: #ffe066 !important;
color: #222 !important;
border: 2px solid #bfa600 !important;
}
#main-table td.day-highlighted,
#main-table th.day-highlighted {
/* background is set inline for custom color */
}
#display-settings-modal {
transition: box-shadow 0.2s;
}
#main-table td.force-assigned.force-assigned-highlight {
background-color: #ffe066 !important;
border: 2px solid #ff8800 !important;
}
#linear-shift-timetable td, #linear-shift-timetable th {
max-width: unset;
}
@@ -3,6 +3,14 @@
{% block content %}
<div class="container">
<h2 class="title">Request leave{% if worker %} for {{ worker.name }}{% endif %}</h2>
<div class="box">
<h3 class="subtitle">Request leave on calendar</h3>
{% include 'rota/partials/flatpickr_includes.html' %}
<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>
</div>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
@@ -13,4 +21,107 @@
</div>
</form>
</div>
<style>
.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>
(function(){
const container = document.getElementById('calendar');
if(!container) return;
const workerId = container.dataset.workerId;
const leaves = [
{% if worker %}
{% for l in worker.leaves.all %}
{start:'{{ l.start_date }}', end:'{{ l.end_date }}'},
{% endfor %}
{% endif %}
];
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>
{% endblock %}
@@ -6,11 +6,13 @@
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
{% include 'rota/partials/flatpickr_includes.html' %}
<style>
.calendar { display:flex; flex-direction:column; gap:0.5rem; }
.calendar .week { display:flex; gap:0.5rem; }
.calendar .day { flex:1; padding:0.5rem; border:1px solid #eee; min-height:3rem; cursor:pointer; }
.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>
<body>
@@ -18,8 +20,8 @@
<div class="container">
<div class="box">
<h2 class="subtitle">Request leave on calendar</h2>
<div id="calendar" class="calendar" data-worker-id="{{ worker.id }}"></div>
<p class="help">Click a date to request leave for that day.</p>
<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>
@@ -51,66 +53,104 @@
</section>
<script>
(function(){
// Render a simple month calendar for current month; mark existing leaves
const container = document.getElementById('calendar');
if(!container) return;
const workerId = container.dataset.workerId;
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth();
// Collect leave dates from template variable
// collect existing leaves
const leaves = [
{% for l in worker.leaves.all %}
{start:'{{ l.start_date }}', end:'{{ l.end_date }}'},
{% endfor %}
];
function dateInLeaves(y,m,d){
const s = `${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
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 <= s && s <= L.end) return true;
if(L.start <= iso && iso <= L.end) return true;
}
return false;
}
function renderMonth(y, m){
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(y,m,1);
const startDay = first.getDay(); // 0 Sun..6 Sat
const days = new Date(y, m+1, 0).getDate();
let cur = 1 - startDay; // offset to start week
// header
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'}); header.appendChild(title);
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';
const day = cur;
if(day < 1 || day > days){ dayDiv.classList.add('other-month'); dayDiv.textContent=''; }
if(d<1 || d>days){ dayDiv.classList.add('other-month'); dayDiv.textContent=''; }
else{
dayDiv.textContent = day;
const inLeave = dateInLeaves(y,m,day);
if(inLeave) dayDiv.classList.add('leave');
// clicking opens leave request modal prefilled for that date
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 dateStr = `${y}-${String(m+1).padStart(2,'0')}-${String(day).padStart(2,'0')}`;
const url = '{% url "rota:request_leave" %}' + '?worker_id=' + workerId + '&date=' + dateStr;
// HTMX GET into #modal
try{ htmx.ajax('GET', url, {target:'#modal', swap:'innerHTML'}); }catch(e){ window.location = url; }
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();
}
renderMonth(year, month);
render();
})();
</script>
</body>
+27
View File
@@ -514,6 +514,33 @@ def request_leave(request, token=None):
response["HX-Trigger"] = "closeModal"
return response
return redirect("rota:worker_detail", worker_id=worker.id)
else:
# Prefill dates when provided via query params for both full page and HTMX modal
date_q = request.GET.get("date") or request.POST.get("date")
start_q = request.GET.get("start_date") or request.POST.get("start_date")
end_q = request.GET.get("end_date") or request.POST.get("end_date")
if date_q:
try:
import datetime
d = datetime.date.fromisoformat(date_q)
form = LeaveForm(initial={"start_date": d, "end_date": d})
except Exception:
form = LeaveForm()
elif start_q or end_q:
try:
import datetime
sd = datetime.date.fromisoformat(start_q) if start_q else None
ed = datetime.date.fromisoformat(end_q) if end_q else None
init = {}
if sd:
init["start_date"] = sd
if ed:
init["end_date"] = ed
form = LeaveForm(initial=init)
except Exception:
form = LeaveForm()
else:
form = LeaveForm()