171 lines
8.8 KiB
JavaScript
171 lines
8.8 KiB
JavaScript
/* 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){
|
|
// 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;
|
|
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;
|
|
// Pointer handling is done via a delegated listener attached to the
|
|
// container (see below) so clicks that hit the numeric text node are
|
|
// still caught. Keep the day hover handlers here for previews.
|
|
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 };
|
|
|
|
// Delegated pointer handler: attach once to the container so clicks on
|
|
// numeric text nodes (or any child) are still detected via event
|
|
// delegation. This is more reliable than per-day handlers when the
|
|
// DOM changes or when the target is a text node.
|
|
if(!container._pointerHandlerAttached){
|
|
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; }
|
|
else if(selStart && !selEnd){ if(clicked < selStart){ selEnd = selStart; selStart = clicked } else selEnd = clicked }
|
|
else { selStart = clicked; selEnd = null }
|
|
previewEnd = null;
|
|
render();
|
|
if(selStart && selEnd){
|
|
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);
|
|
}
|
|
}catch(e){ console.error('calendar pointer handler', e); }
|
|
});
|
|
container._pointerHandlerAttached = true;
|
|
}
|
|
|
|
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;
|
|
})();
|