// ============================================================ // 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 = `

${MONTHS[state.month - 1]} ${state.year}

${Auth.can('calendar', 'create') ? `` : ''} ${Auth.isDev() ? `
` : ''}
${Object.entries(TYPE_COLORS).map(([t, c]) => ` ${t.charAt(0).toUpperCase() + t.slice(1)} `).join('')}
`; 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 = `
${DAYS_SHORT.map(d => `
${d}
`).join('')}
${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 = `
📅
No events this month
`; 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 = `
📭

Nothing scheduled

`; } 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 || `
📭

Nothing scheduled

`; } // 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); }