// ============================================================
// Calendar Module
// ============================================================
const Cal = (() => {
let state = {
year: new Date().getFullYear(),
month: new Date().getMonth() + 1, // 1-based
events: [],
view: 'month', // month | week | list
selectedDay: null,
viewUserId: null, // null = current user, 'all' = all, number = specific user
};
let _calUsers = []; // cached user list for admin filter
const TYPE_COLORS = {
jobcard: { bg: '#dbeafe', text: '#1e40af', dot: '#3b82f6' },
todo: { bg: '#ede9fe', text: '#5b21b6', dot: '#7c3aed' },
leave: { bg: '#ffedd5', text: '#9a3412', dot: '#f97316' },
project: { bg: '#dcfce7', text: '#14532d', dot: '#22c55e' },
event: { bg: '#e0f2fe', text: '#075985', dot: '#0ea5e9' },
meeting: { bg: '#dbeafe', text: '#1e3a8a', dot: '#1d4ed8' },
task: { bg: '#f3e8ff', text: '#6b21a8', dot: '#9333ea' },
reminder: { bg: '#fef9c3', text: '#713f12', dot: '#eab308' },
deadline: { bg: '#fee2e2', text: '#7f1d1d', dot: '#ef4444' },
meeting_internal: { bg: '#dbeafe', text: '#1e40af', dot: '#3b82f6' },
meeting_client: { bg: '#dcfce7', text: '#166534', dot: '#22c55e' },
checklist: { bg: '#ccfbf1', text: '#065f46', dot: '#14b8a6' },
};
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const DAYS_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
// ── Data loading ────────────────────────────────────────
async function loadEvents() {
const params = { year: state.year, month: state.month };
if (state.viewUserId === 'all') params.view_all = 1;
else if (state.viewUserId) params.view_user_id = state.viewUserId;
const res = await API.post('calendar/events', params);
if (res.success) state.events = res.data.events;
else state.events = [];
}
async function loadCalUsers() {
if (_calUsers.length) return;
const res = await API.post('auth/users_list', { limit: 100 });
if (res.success) _calUsers = res.data.users || [];
}
// ── Helpers ─────────────────────────────────────────────
function eventsForDate(dateStr) {
return state.events.filter(e => e.date === dateStr);
}
function colorFor(type) {
return TYPE_COLORS[type] || TYPE_COLORS.event;
}
function todayStr() {
return new Date().toISOString().split('T')[0];
}
function daysInMonth(y, m) {
return new Date(y, m, 0).getDate();
}
function firstDayOfMonth(y, m) {
return new Date(y, m - 1, 1).getDay(); // 0=Sun
}
function padDate(y, m, d) {
return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
}
// ── Render shell ─────────────────────────────────────────
async function render(params = {}) {
const content = document.getElementById('page-content');
content.innerHTML = `
${Object.entries(TYPE_COLORS).map(([t, c]) => `
${t.charAt(0).toUpperCase() + t.slice(1)}
`).join('')}
${Auth.can('calendar', 'create') ? `
` : ''}
`;
await loadEvents();
renderBody();
// Populate admin user filter — always rebuild to avoid duplicates on re-render
if (Auth.isDev()) {
await loadCalUsers();
const sel = document.getElementById('cal-user-sel');
if (sel) {
sel.innerHTML =
'' +
'' +
_calUsers.map(u => ``).join('');
if (state.viewUserId) sel.value = state.viewUserId;
}
}
// Auto-open today's events panel
selectDay(todayStr());
}
// ── Render the calendar grid or list ─────────────────────
function renderBody() {
const title = document.getElementById('cal-title');
if (title) title.textContent = `${MONTHS[state.month - 1]} ${state.year}`;
const body = document.getElementById('cal-body');
if (!body) return;
if (state.view === 'list') { renderList(body); return; }
renderMonthGrid(body);
}
// ── Month grid ───────────────────────────────────────────
function renderMonthGrid(container) {
const today = todayStr();
const totalDays = daysInMonth(state.year, state.month);
const startDay = firstDayOfMonth(state.year, state.month);
// Previous month tail
const prevMonthDays = daysInMonth(state.year, state.month - 1 || 12);
const prevYear = state.month === 1 ? state.year - 1 : state.year;
const prevMonth = state.month === 1 ? 12 : state.month - 1;
let cells = [];
// Fill leading cells
for (let i = startDay - 1; i >= 0; i--) {
cells.push({ day: prevMonthDays - i, month: prevMonth, year: prevYear, other: true });
}
// Current month
for (let d = 1; d <= totalDays; d++) {
cells.push({ day: d, month: state.month, year: state.year, other: false });
}
// Trailing cells
const nextYear = state.month === 12 ? state.year + 1 : state.year;
const nextMonth = state.month === 12 ? 1 : state.month + 1;
let nextDay = 1;
while (cells.length % 7 !== 0) {
cells.push({ day: nextDay++, month: nextMonth, year: nextYear, other: true });
}
const MAX_VISIBLE = window.innerWidth < 640 ? 2 : 3;
container.innerHTML = `
${cells.map(c => {
const dateStr = padDate(c.year, c.month, c.day);
const evts = c.other ? [] : eventsForDate(dateStr);
const isToday = dateStr === today;
const isSelected = dateStr === state.selectedDay;
const isWeekend = false; // could detect
return `
${c.day}
${evts.slice(0, MAX_VISIBLE).map(e => {
const col = colorFor(e.type);
const overdue = isOverdue(e);
const done = isDoneEvent(e);
const showUser = (state.viewUserId === 'all' || state.viewUserId) && e.assigned_user;
let bg = col.bg, fg = col.text, border = '', extra = '';
if (done) { bg = '#f3f4f6'; fg = '#9ca3af'; extra = 'text-decoration:line-through;opacity:.7'; }
else if (overdue) { bg = '#fee2e2'; fg = '#991b1b'; border = 'border-left:2px solid #ef4444'; }
const initials = showUser ? userInitials(e.assigned_user) : '';
const isDraggable = !done && ['evt-', 'jc-', 'cl-'].some(p => e.id.startsWith(p));
return `
${showUser ? `${initials}` : ''}
${e.time ? `${e.time}` : ''}
${overdue && !done ? '⚠ ' : ''}${e.label}
`;
}).join('')}
${evts.length > MAX_VISIBLE ? `
+${evts.length - MAX_VISIBLE} more
` : ''}
`;
}).join('')}
`;
}
// ── List view ────────────────────────────────────────────
function renderList(container) {
const today = todayStr();
// Group events by date
const byDate = {};
state.events.forEach(e => {
if (!byDate[e.date]) byDate[e.date] = [];
byDate[e.date].push(e);
});
const dates = Object.keys(byDate).sort();
if (!dates.length) {
container.innerHTML = ``;
return;
}
container.innerHTML = `
${dates.map(dateStr => {
const d = new Date(dateStr + 'T00:00');
const isToday = dateStr === today;
const label = isToday ? 'Today' : d.toLocaleDateString('en-ZA', { weekday: 'long', day: 'numeric', month: 'long' });
return `
${label}
${byDate[dateStr].map(e => renderListEvent(e)).join('')}
`;
}).join('')}
`;
}
const STATUS_BADGES = {
draft: { bg: '#f3f4f6', text: '#6b7280', label: 'Draft' },
assigned: { bg: '#dbeafe', text: '#1e40af', label: 'Assigned' },
travelling: { bg: '#fef3c7', text: '#92400e', label: 'Travelling' },
on_site: { bg: '#fef3c7', text: '#92400e', label: 'On Site' },
working: { bg: '#fef9c3', text: '#713f12', label: 'Working' },
completed: { bg: '#dcfce7', text: '#166534', label: 'Completed' },
internal_complete: { bg: '#dcfce7', text: '#166534', label: 'Done' },
invoiced: { bg: '#dbeafe', text: '#1e40af', label: 'Invoiced' },
pending: { bg: '#f3f4f6', text: '#6b7280', label: 'Pending' },
in_progress: { bg: '#fef3c7', text: '#92400e', label: 'In Progress' },
missed: { bg: '#fee2e2', text: '#991b1b', label: 'Missed' },
todo: { bg: '#f3f4f6', text: '#6b7280', label: 'To Do' },
done: { bg: '#dcfce7', text: '#166534', label: 'Done' },
};
function userInitials(name) {
if (!name) return '';
return name.split(',')[0].trim().split(' ').map(p => p[0]).join('').substring(0, 2).toUpperCase();
}
function isOverdue(e) {
if (isDoneEvent(e)) return false;
const today = todayStr();
return e.date < today && e.status !== 'completed' && e.status !== 'invoiced' && e.status !== 'no_charge' && e.type !== 'leave';
}
function isDoneEvent(e) {
return e.status === 'done' || e.status === 'completed' ||
e.status === 'internal_complete' || e.status === 'invoiced' || e.status === 'no_charge';
}
function renderListEvent(e) {
const col = colorFor(e.type);
// Determine if this event/item is "done"
const isDone = isDoneEvent(e);
const statusBadge = e.status && STATUS_BADGES[e.status]
? `${STATUS_BADGES[e.status].label}`
: '';
const hasLink = e.link && (e.link.id || e.link.instance_id);
const isChecklist = e.type === 'checklist';
const overdue = isOverdue(e);
const borderCol = overdue ? '#ef4444' : col.dot;
const overdueStyle = overdue ? ';background:#fff5f5' : '';
const clickAttr = hasLink
? `onclick="Cal._navigateEvent(${JSON.stringify(e.link).replace(/"/g, '"')})" style="cursor:pointer;border-left:3px solid ${borderCol}${isDone ? ';opacity:.65' : ''}${overdueStyle};border-radius:var(--radius-sm)"`
: `style="border-left:3px solid ${borderCol}${isDone ? ';opacity:.65' : ''}${overdueStyle};border-radius:var(--radius-sm)"`;
return `
${e.time ? `${e.time}` : ''}
${e.label}
${isDone
? `✓ Done`
: overdue
? `⚠ Overdue`
: statusBadge
}
${e.type}
${(e.sub || ((state.viewUserId === 'all' || state.viewUserId) && e.assigned_user)) ? `
${[e.sub, (state.viewUserId === 'all' || state.viewUserId) && e.assigned_user ? '👤 ' + e.assigned_user : null].filter(Boolean).join(' · ')}
` : ''}
${isChecklist && !isDone ? `
` : ''}
${e.editable && !isChecklist && !isDone ? `
${Auth.can('calendar', 'edit') ? `` : ''}
` : e.editable && !isChecklist ? `
` : ''}
`;
}
function _navigateEvent(link) {
if (link.instance_id) {
// Navigate to checklists page and open the instance
Router.navigate('checklists', { instance_id: link.instance_id });
} else if (link.page && link.id) {
Router.navigate(link.page, { id: link.id });
}
}
async function setViewUser(val) {
state.viewUserId = val || null;
await loadEvents();
renderBody();
if (state.selectedDay) selectDay(state.selectedDay);
}
async function completeEvent(prefixedId, rawId) {
const numId = rawId || prefixedId.replace('evt-', '');
if (!await Modal.confirm('Mark this event as complete? It will move to the completed section.', 'Complete Event', false)) return;
const res = await API.post('calendar/event_complete', { id: numId });
if (res.success) {
Toast.show('Event marked complete ✅', 'success');
await loadEvents();
renderBody();
if (state.selectedDay) selectDay(state.selectedDay);
} else Toast.show(res.message, 'error');
}
// ── Day panel ─────────────────────────────────────────────
function selectDay(dateStr) {
state.selectedDay = dateStr;
const panel = document.getElementById('cal-day-panel');
const titleEl = document.getElementById('cal-day-title');
const evtsEl = document.getElementById('cal-day-events');
if (!panel) return;
const d = new Date(dateStr + 'T00:00');
const evts = eventsForDate(dateStr);
panel.style.display = 'flex';
titleEl.textContent = d.toLocaleDateString('en-ZA', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
if (!evts.length) {
evtsEl.innerHTML = ``;
} else {
const active = evts.filter(e => !isDoneEvent(e));
const completed = evts.filter(e => isDoneEvent(e));
let html = '';
if (active.length) {
html += `${active.map(e => renderListEvent(e)).join('')}
`;
}
if (completed.length) {
html += `
✅ Completed (${completed.length})
▾
${completed.map(e => renderListEvent(e)).join('')}
`;
}
evtsEl.innerHTML = html || ``;
}
// Re-render grid to show selected state
renderBody();
}
function closeDayPanel() {
state.selectedDay = null;
const panel = document.getElementById('cal-day-panel');
if (panel) panel.style.display = 'none';
renderBody();
}
// ── Navigation ────────────────────────────────────────────
async function prevMonth() {
if (state.month === 1) { state.month = 12; state.year--; }
else state.month--;
state.selectedDay = null;
closeDayPanel();
await loadEvents();
renderBody();
}
async function nextMonth() {
if (state.month === 12) { state.month = 1; state.year++; }
else state.month++;
state.selectedDay = null;
closeDayPanel();
await loadEvents();
renderBody();
}
async function goToday() {
const now = new Date();
state.year = now.getFullYear();
state.month = now.getMonth() + 1;
state.selectedDay = null;
closeDayPanel();
await loadEvents();
renderBody();
}
async function setView(v) {
state.view = v;
// Update switcher buttons
document.querySelectorAll('.cal-view-switcher button').forEach(b => {
b.classList.toggle('active', b.textContent.toLowerCase().trim() === v);
});
renderBody();
}
// ── Add/Edit event modal ──────────────────────────────────
function openAddEvent(defaultDate = null) {
const today = defaultDate || new Date().toISOString().split('T')[0];
Modal.open({
id: 'add-cal-event',
title: 'Add Event',
body: `
`,
footer: `
`
});
}
async function submitEvent(id = null) {
const btn = document.querySelector('#modal-add-cal-event .btn-primary, #modal-edit-cal-event .btn-primary');
if (btn) btn.classList.add('loading');
const data = getFormData('cal-event-form');
data.action = id ? 'update' : 'create';
if (id) data.id = id;
const res = await API.post('calendar/event_save', data);
if (btn) btn.classList.remove('loading');
if (res.success) {
Toast.show(id ? 'Event updated.' : 'Event added.', 'success');
Modal.close();
await loadEvents();
renderBody();
if (state.selectedDay) selectDay(state.selectedDay);
} else Toast.show(res.message, 'error');
}
async function deleteEvent(idStr) {
// idStr format is "evt-123"
const numId = idStr.replace('evt-', '');
const ok = await Modal.confirm('Delete this event?', 'This cannot be undone.');
if (!ok) return;
const res = await API.post('calendar/event_save', { action: 'delete', id: numId });
if (res.success) {
Toast.show('Event deleted.', 'success');
await loadEvents();
renderBody();
if (state.selectedDay) selectDay(state.selectedDay);
} else Toast.show(res.message, 'error');
}
function openEditEvent(idStr) {
// Find event in state
const evt = state.events.find(e => e.id === idStr);
if (!evt) return;
const numId = idStr.replace('evt-', '');
Modal.open({
id: 'edit-cal-event',
title: 'Edit Event',
body: `
`,
footer: `
`
});
}
// ══════════════════════════════════════════════════════════
// DRAG & DROP ENGINE (desktop + mobile)
// ══════════════════════════════════════════════════════════
let _drag = {
eventId: null, fromDate: null, label: null, bg: null, fg: null,
ghost: null, moved: false, active: false,
};
// ── Desktop HTML5 drag ────────────────────────────────────
function _dragStart(e) {
const pill = e.currentTarget;
_drag.eventId = pill.dataset.eventId;
_drag.fromDate = pill.dataset.eventDate;
_drag.label = pill.dataset.eventLabel;
_drag.bg = pill.dataset.eventBg;
_drag.fg = pill.dataset.eventFg;
_drag.moved = false;
_drag.active = true;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', _drag.eventId);
pill.style.opacity = '.4';
}
function _dragEnd(e) {
e.currentTarget.style.opacity = '';
_drag.active = false;
document.querySelectorAll('.cal-cell-drop-over').forEach(el => el.classList.remove('cal-cell-drop-over'));
}
function _dragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const cell = e.currentTarget;
document.querySelectorAll('.cal-cell-drop-over').forEach(el => el !== cell && el.classList.remove('cal-cell-drop-over'));
cell.classList.add('cal-cell-drop-over');
}
function _dragLeave(e) {
e.currentTarget.classList.remove('cal-cell-drop-over');
}
async function _drop(e) {
e.preventDefault();
const cell = e.currentTarget;
cell.classList.remove('cal-cell-drop-over');
const newDate = cell.dataset.date;
if (!newDate || !_drag.eventId || newDate === _drag.fromDate) return;
_drag.moved = true;
await _reschedule(_drag.eventId, _drag.fromDate, newDate);
}
function _didDrag() {
return _drag.moved;
}
// ── Mobile touch drag ─────────────────────────────────────
let _touch = { startX: 0, startY: 0, pill: null, movedEnough: false };
function _touchStart(e) {
const pill = e.currentTarget;
_touch.pill = pill;
_touch.startX = e.touches[0].clientX;
_touch.startY = e.touches[0].clientY;
_touch.movedEnough = false;
_drag.eventId = pill.dataset.eventId;
_drag.fromDate = pill.dataset.eventDate;
_drag.label = pill.dataset.eventLabel;
_drag.bg = pill.dataset.eventBg;
_drag.fg = pill.dataset.eventFg;
_drag.moved = false;
_drag.active = true;
}
function _touchMove(e) {
const dx = e.touches[0].clientX - _touch.startX;
const dy = e.touches[0].clientY - _touch.startY;
if (!_touch.movedEnough && Math.sqrt(dx * dx + dy * dy) < 8) return;
_touch.movedEnough = true;
e.preventDefault(); // stop scroll
const x = e.touches[0].clientX;
const y = e.touches[0].clientY;
// Create/move ghost
if (!_drag.ghost) {
const g = document.createElement('div');
g.id = 'cal-drag-ghost';
g.textContent = _drag.label;
g.style.cssText = `position:fixed;z-index:9999;pointer-events:none;padding:3px 8px;
border-radius:4px;font-size:.72rem;font-weight:600;max-width:180px;white-space:nowrap;
overflow:hidden;text-overflow:ellipsis;box-shadow:0 4px 16px rgba(0,0,0,.25);
background:${_drag.bg};color:${_drag.fg};transform:translate(-50%,-50%) scale(1.08);
opacity:.9;transition:none`;
document.body.appendChild(g);
_drag.ghost = g;
_touch.pill.style.opacity = '.3';
}
_drag.ghost.style.left = x + 'px';
_drag.ghost.style.top = y + 'px';
// Highlight cell under finger
_drag.ghost.style.display = 'none';
const el = document.elementFromPoint(x, y);
_drag.ghost.style.display = '';
const cell = el?.closest('.cal-cell:not(.cal-cell-other)');
document.querySelectorAll('.cal-cell-drop-over').forEach(c => c !== cell && c.classList.remove('cal-cell-drop-over'));
if (cell) cell.classList.add('cal-cell-drop-over');
}
async function _touchEnd(e) {
if (_drag.ghost) { _drag.ghost.remove(); _drag.ghost = null; }
if (_touch.pill) _touch.pill.style.opacity = '';
document.querySelectorAll('.cal-cell-drop-over').forEach(c => c.classList.remove('cal-cell-drop-over'));
_drag.active = false;
if (!_touch.movedEnough) return; // it was a tap, not a drag
const x = e.changedTouches[0].clientX;
const y = e.changedTouches[0].clientY;
const el = document.elementFromPoint(x, y);
const cell = el?.closest('.cal-cell:not(.cal-cell-other)');
const newDate = cell?.dataset.date;
if (!newDate || !_drag.eventId || newDate === _drag.fromDate) return;
_drag.moved = true;
await _reschedule(_drag.eventId, _drag.fromDate, newDate);
}
// ── Shared reschedule handler ─────────────────────────────
async function _reschedule(eventId, fromDate, newDate) {
// Optimistic update in state
const ev = state.events.find(e => e.id === eventId);
if (ev) ev.date = newDate;
renderBody();
if (state.selectedDay === fromDate || state.selectedDay === newDate) selectDay(state.selectedDay);
const res = await API.post('calendar/reschedule', { event_id: eventId, new_date: newDate });
if (res.success) {
Toast.show(`Moved to ${Fmt.date(newDate)} ✓`, 'success');
} else {
// Revert
Toast.show(res.message || 'Could not reschedule.', 'error');
if (ev) ev.date = fromDate;
renderBody();
}
}
return { render, prevMonth, nextMonth, goToday, setView, selectDay, setViewUser, closeDayPanel, openAddEvent, openEditEvent, submitEvent, deleteEvent, completeEvent, _navigateEvent, _dragStart, _dragEnd, _dragOver, _dragLeave, _drop, _didDrag, _touchStart, _touchMove, _touchEnd, state: () => state };
})();
// Global alias for router
async function renderCalendar(params = {}) {
await Cal.render(params);
}