Files
2026-04-23 21:38:24 -04:00

816 lines
40 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Appointment Calendar / Schedule Module
* Handles day, week and month calendar views for appointments, jobs, and maintenance
*/
const appointmentCalendar = {
currentView: 'week',
currentDate: new Date(),
events: [],
unscheduledJobs: [],
sidebarCollapsed: false,
// ──────────────────────────────────────────────────────────────────────────
// Init
// ──────────────────────────────────────────────────────────────────────────
init: function(view = 'week', date = new Date()) {
this.currentView = view;
this.currentDate = new Date(date);
this.updateViewButtons();
this.attachEventListeners();
this.loadAndRender();
this.loadUnscheduledJobs();
this.setupSidebarToggle();
this.setupSidebarDropZone();
},
attachEventListeners: function() {
document.getElementById('btnPrevious').addEventListener('click', () => this.goToPrevious());
document.getElementById('btnNext').addEventListener('click', () => this.goToNext());
document.getElementById('btnToday').addEventListener('click', () => this.goToToday());
document.getElementById('btnDayView').addEventListener('click', () => this.switchView('day'));
document.getElementById('btnWeekView').addEventListener('click', () => this.switchView('week'));
document.getElementById('btnMonthView').addEventListener('click', () => this.switchView('month'));
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => this.renderCalendar(), 250);
});
},
// ──────────────────────────────────────────────────────────────────────────
// Load & Render
// ──────────────────────────────────────────────────────────────────────────
loadAndRender: async function() {
const { start, end } = this.getDateRange();
await this.loadEvents(start, end);
this.renderCalendar();
this.updateCurrentDateDisplay();
this.updateURL();
},
loadEvents: async function(startDate, endDate) {
try {
const url = `/Appointments/GetCalendarEvents?start=${startDate.toISOString()}&end=${endDate.toISOString()}`;
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to load events');
this.events = await response.json();
} catch (error) {
console.error('Error loading events:', error);
this.events = [];
}
},
renderCalendar: function() {
const container = document.getElementById('calendarContainer');
if (this.currentView === 'day') {
container.innerHTML = this.renderDayView();
} else if (this.currentView === 'week') {
container.innerHTML = this.renderWeekView();
} else {
container.innerHTML = this.renderMonthView();
}
this.attachEventClickHandlers();
this.attachDropHandlers();
},
// ──────────────────────────────────────────────────────────────────────────
// Unscheduled Jobs Sidebar
// ──────────────────────────────────────────────────────────────────────────
loadUnscheduledJobs: async function() {
try {
const resp = await fetch('/Appointments/GetUnscheduledJobs');
this.unscheduledJobs = await resp.json();
} catch (e) {
this.unscheduledJobs = [];
}
this.renderUnscheduledPanel();
},
renderUnscheduledPanel: function() {
const panel = document.getElementById('unscheduledJobsPanel');
const countEl = document.getElementById('unscheduledCount');
if (!panel) return;
const count = this.unscheduledJobs.length;
if (countEl) countEl.textContent = count > 0 ? `${count} job${count === 1 ? '' : 's'}` : '';
if (count === 0) {
panel.innerHTML = '<div class="text-muted small text-center py-3"><i class="bi bi-check-circle me-1"></i>All jobs scheduled</div>';
return;
}
let html = '';
this.unscheduledJobs.forEach(job => {
let dueLine = '';
if (job.dueDate) {
const cls = job.isOverdue ? 'text-danger fw-semibold' : 'text-muted';
dueLine = `<div class="sj-due ${cls}"><i class="bi bi-calendar-event me-1"></i>${this.formatDateShort(job.dueDate)}${job.isOverdue ? ' ⚠' : ''}</div>`;
}
const previewData = JSON.stringify({
jobNumber: job.jobNumber,
customerName: job.customerName,
statusName: job.statusName,
color: job.color,
dueDate: job.dueDate,
isOverdue: job.isOverdue,
quotedPrice: job.quotedPrice,
specialInstructions: job.specialInstructions,
items: job.items || [],
itemCount: job.itemCount || 0
});
html += `
<div class="sj-card" draggable="true"
data-job-id="${job.id}"
data-preview="${encodeURIComponent(previewData)}"
style="border-left:3px solid ${this.escapeHtml(job.color)}">
<div class="sj-number">${this.escapeHtml(job.jobNumber)}</div>
<div class="sj-customer">${this.escapeHtml(job.customerName)}</div>
<div class="sj-status" style="color:${this.escapeHtml(job.color)}">${this.escapeHtml(job.statusName)}</div>
${dueLine}
</div>`;
});
panel.innerHTML = html;
this.attachSidebarDragHandlers();
this.attachHoverPreviewHandlers();
},
attachHoverPreviewHandlers: function() {
const popout = document.getElementById('sjPreviewCard');
if (!popout) return;
document.querySelectorAll('.sj-card').forEach(card => {
card.addEventListener('mouseenter', (e) => {
let data;
try { data = JSON.parse(decodeURIComponent(card.dataset.preview)); } catch { return; }
popout.innerHTML = this.buildPreviewHtml(data);
popout.style.display = 'block';
this.positionPreview(e);
});
card.addEventListener('mousemove', (e) => this.positionPreview(e));
card.addEventListener('mouseleave', () => { popout.style.display = 'none'; });
card.addEventListener('dragstart', () => { popout.style.display = 'none'; });
});
},
positionPreview: function(e) {
const popout = document.getElementById('sjPreviewCard');
if (!popout) return;
const offset = 14;
const vw = window.innerWidth, vh = window.innerHeight;
const pw = popout.offsetWidth || 240, ph = popout.offsetHeight || 160;
let left = e.clientX + offset;
let top = e.clientY + offset;
if (left + pw > vw - 8) left = e.clientX - pw - offset;
if (top + ph > vh - 8) top = e.clientY - ph - offset;
popout.style.left = left + 'px';
popout.style.top = top + 'px';
},
buildPreviewHtml: function(d) {
const dueLine = d.dueDate
? `<div class="sjp-row ${d.isOverdue ? 'sjp-overdue' : ''}">
<i class="bi bi-calendar-event"></i>
Due ${this.formatDateShort(d.dueDate)}${d.isOverdue ? ' — Overdue' : ''}
</div>` : '';
const priceLine = d.quotedPrice > 0
? `<div class="sjp-row"><i class="bi bi-currency-dollar"></i>Quoted $${Number(d.quotedPrice).toFixed(2)}</div>` : '';
const notesLine = d.specialInstructions
? `<div class="sjp-notes">${this.escapeHtml(d.specialInstructions)}</div>` : '';
let itemsHtml = '';
if (d.items && d.items.length > 0) {
itemsHtml = '<div class="sjp-items">';
d.items.forEach(item => {
const colorDot = item.colorName
? `<span class="sjp-color-dot" title="${this.escapeHtml(item.colorName)}"></span><span class="sjp-color">${this.escapeHtml(item.colorName)}</span>`
: '';
itemsHtml += `<div class="sjp-item"><span class="sjp-qty">×${item.quantity}</span><span class="sjp-item-desc">${this.escapeHtml(item.description)}</span>${colorDot ? colorDot : ''}</div>`;
});
if (d.itemCount > d.items.length) {
itemsHtml += `<div class="sjp-item sjp-more">+ ${d.itemCount - d.items.length} more item${d.itemCount - d.items.length > 1 ? 's' : ''}</div>`;
}
itemsHtml += '</div>';
}
return `<div class="sjp-card">
<div class="sjp-status-bar" style="background:${this.escapeHtml(d.color)}">${this.escapeHtml(d.statusName)}</div>
<div class="sjp-header" style="border-left:3px solid ${this.escapeHtml(d.color)}">
<span class="sjp-job">${this.escapeHtml(d.jobNumber)}</span>
</div>
<div class="sjp-customer">${this.escapeHtml(d.customerName)}</div>
${dueLine}${priceLine}${notesLine}
${itemsHtml}
</div>`;
},
attachSidebarDragHandlers: function() {
document.querySelectorAll('.sj-card').forEach(card => {
card.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', JSON.stringify({
type: 'job',
jobId: card.dataset.jobId,
fromDate: null
}));
e.dataTransfer.effectAllowed = 'move';
card.classList.add('sj-dragging');
});
card.addEventListener('dragend', () => card.classList.remove('sj-dragging'));
});
},
setupSidebarDropZone: function() {
const panel = document.getElementById('unscheduledJobsPanel');
if (!panel) return;
const sidebar = document.getElementById('unscheduledSidebar');
if (!sidebar) return;
[panel, sidebar].forEach(el => {
el.addEventListener('dragover', (e) => {
e.preventDefault();
sidebar.classList.add('sidebar-drag-over');
});
el.addEventListener('dragleave', () => sidebar.classList.remove('sidebar-drag-over'));
el.addEventListener('drop', (e) => {
e.preventDefault();
sidebar.classList.remove('sidebar-drag-over');
let data;
try { data = JSON.parse(e.dataTransfer.getData('text/plain')); } catch { return; }
if (data.type === 'job' && data.fromDate) {
this.scheduleJob(data.jobId, null);
}
});
});
},
setupSidebarToggle: function() {
const btn = document.getElementById('btnCollapseSidebar');
if (!btn) return;
btn.addEventListener('click', () => {
this.sidebarCollapsed = !this.sidebarCollapsed;
const sidebar = document.getElementById('unscheduledSidebar');
const chevron = document.getElementById('sidebarChevron');
const body = document.getElementById('unscheduledJobsPanel');
const countEl = document.getElementById('unscheduledCount');
const titleEl = document.getElementById('sidebarTitleText');
if (this.sidebarCollapsed) {
sidebar.classList.add('sidebar-collapsed');
if (chevron) { chevron.classList.remove('bi-chevron-left'); chevron.classList.add('bi-chevron-right'); }
if (body) body.style.display = 'none';
if (countEl) countEl.style.display = 'none';
if (titleEl) titleEl.style.display = 'none';
} else {
sidebar.classList.remove('sidebar-collapsed');
if (chevron) { chevron.classList.remove('bi-chevron-right'); chevron.classList.add('bi-chevron-left'); }
if (body) body.style.display = '';
if (countEl) countEl.style.display = '';
if (titleEl) titleEl.style.display = '';
}
});
},
// ──────────────────────────────────────────────────────────────────────────
// Drag & Drop — Drop Handlers
// ──────────────────────────────────────────────────────────────────────────
attachDropHandlers: function() {
// Week view: day header columns
document.querySelectorAll('.calendar-day-header[data-date]').forEach(cell => {
this.makeDayDropTarget(cell);
});
// Month view: month cells
document.querySelectorAll('.calendar-month-cell[data-date]').forEach(cell => {
this.makeDayDropTarget(cell);
});
// Day view: single day header
const dayHeader = document.querySelector('.calendar-day-header-single[data-date]');
if (dayHeader) this.makeDayDropTarget(dayHeader);
},
makeDayDropTarget: function(cell) {
cell.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
cell.classList.add('calendar-drag-over');
});
cell.addEventListener('dragleave', (e) => {
if (!cell.contains(e.relatedTarget)) {
cell.classList.remove('calendar-drag-over');
}
});
cell.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
cell.classList.remove('calendar-drag-over');
let data;
try { data = JSON.parse(e.dataTransfer.getData('text/plain')); } catch { return; }
if (data.type !== 'job') return;
const dateStr = cell.dataset.date;
if (!dateStr) return;
this.scheduleJob(data.jobId, new Date(dateStr));
});
},
scheduleJob: async function(jobId, date) {
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
const params = new URLSearchParams({ id: jobId });
if (date) params.append('date', date.toISOString().split('T')[0]);
if (token) params.append('__RequestVerificationToken', token);
try {
const resp = await fetch('/Appointments/ScheduleJob', { method: 'POST', body: params });
const result = await resp.json();
if (result.success) {
await this.loadAndRender();
await this.loadUnscheduledJobs();
if (result.removedFromBatch) {
this.showToast(`Job scheduled. Removed from oven batch ${this.escapeHtml(result.removedFromBatch)}.`, 'warning');
} else if (date) {
this.showToast('Job scheduled.', 'success');
} else {
this.showToast('Job unscheduled.', 'info');
}
} else {
this.showToast(result.message || 'Failed to update job.', 'danger');
}
} catch (e) {
this.showToast('Failed to update job.', 'danger');
}
},
showToast: function(message, type = 'success') {
const container = document.getElementById('scheduleToastContainer');
if (!container) return;
const id = 'toast-' + Date.now();
const bgMap = { success: 'bg-success', warning: 'bg-warning text-dark', info: 'bg-info text-dark', danger: 'bg-danger' };
const cls = bgMap[type] || 'bg-secondary';
container.insertAdjacentHTML('beforeend', `
<div id="${id}" class="toast align-items-center ${cls} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>`);
const el = document.getElementById(id);
if (el && window.bootstrap) new bootstrap.Toast(el, { delay: 4000 }).show();
},
// ──────────────────────────────────────────────────────────────────────────
// Day View
// ──────────────────────────────────────────────────────────────────────────
renderDayView: function() {
const day = this.currentDate;
const isToday = this.isToday(day);
const allDayEvents = this.getAllDayEventsForDay(day);
const totalEvents = this.getEventsForDay(day).length;
let html = '<div class="calendar-day-view">';
html += '<div class="calendar-day-view-header">';
html += `<div class="calendar-day-header-single ${isToday ? 'today' : ''}" data-date="${day.toISOString()}">`;
html += '<div class="day-header-top">';
html += `<div class="day-name-long">${this.getDayName(day, 'long')}</div>`;
html += `<div class="day-number-large">${day.getDate()}</div>`;
html += `<div class="day-month-year">${this.getMonthName(day)} ${day.getFullYear()}</div>`;
if (totalEvents > 0) {
html += `<div class="day-event-count ms-auto"><span class="badge bg-primary">${totalEvents} event${totalEvents === 1 ? '' : 's'}</span></div>`;
}
html += '</div>';
if (allDayEvents.length > 0) {
html += '<div class="day-header-events">';
allDayEvents.forEach(event => html += this.renderEventCard(event, 'week-header'));
html += '</div>';
}
html += '</div></div>';
html += '<div class="calendar-day-grid">';
for (let hour = 6; hour < 21; hour++) {
const hourEvents = this.getEventsForDayHour(day, hour);
html += `<div class="calendar-day-time-row ${hourEvents.length > 0 ? 'has-events' : ''}">`;
html += `<div class="time-label">${this.formatHour(hour)}</div>`;
html += '<div class="calendar-day-time-cell">';
hourEvents.forEach(event => html += this.renderEventCard(event, 'day'));
html += '</div></div>';
}
html += '</div></div>';
return html;
},
// ──────────────────────────────────────────────────────────────────────────
// Week View
// ──────────────────────────────────────────────────────────────────────────
renderWeekView: function() {
const { start } = this.getDateRange();
const days = [];
for (let i = 0; i < 7; i++) {
const day = new Date(start);
day.setDate(start.getDate() + i);
days.push(day);
}
let html = '<div class="calendar-week-view">';
html += '<div class="calendar-week-header">';
html += '<div class="calendar-week-header-spacer"></div>';
days.forEach(day => {
const isToday = this.isToday(day);
const allDayEvents = this.getAllDayEventsForDay(day);
html += `<div class="calendar-day-header ${isToday ? 'today' : ''}" data-date="${day.toISOString()}">`;
html += '<div class="day-header-top">';
html += `<div class="day-name">${this.getDayName(day, 'short')}</div>`;
html += `<div class="day-number day-drill" data-day-date="${day.toISOString()}" title="View ${this.getDayName(day, 'long')} ${day.getDate()}">${day.getDate()}</div>`;
html += '</div>';
if (allDayEvents.length > 0) {
html += '<div class="day-header-events">';
allDayEvents.forEach(event => html += this.renderEventCard(event, 'week-header'));
html += '</div>';
}
html += '</div>';
});
html += '</div>';
html += '<div class="calendar-week-grid">';
for (let hour = 6; hour < 20; hour++) {
html += '<div class="calendar-time-row">';
html += `<div class="time-label">${this.formatHour(hour)}</div>`;
days.forEach(day => {
const dayEvents = this.getEventsForDayHour(day, hour);
html += `<div class="calendar-time-cell" data-date="${day.toISOString()}" data-hour="${hour}">`;
dayEvents.forEach(event => html += this.renderEventCard(event, 'week'));
html += '</div>';
});
html += '</div>';
}
html += '</div></div>';
return html;
},
// ──────────────────────────────────────────────────────────────────────────
// Month View
// ──────────────────────────────────────────────────────────────────────────
renderMonthView: function() {
const firstDay = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), 1);
const calendarStart = new Date(firstDay);
calendarStart.setDate(calendarStart.getDate() - ((calendarStart.getDay() + 6) % 7));
let html = '<div class="calendar-month-view">';
html += '<div class="calendar-month-header">';
['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].forEach(name => {
html += `<div class="calendar-day-name">${name}</div>`;
});
html += '</div>';
html += '<div class="calendar-month-grid">';
let currentDay = new Date(calendarStart);
for (let week = 0; week < 6; week++) {
html += '<div class="calendar-week-row">';
for (let day = 0; day < 7; day++) {
const isCurrentMonth = currentDay.getMonth() === this.currentDate.getMonth();
const isToday = this.isToday(currentDay);
const allEvents = this.getEventsForDay(currentDay);
const dayIso = currentDay.toISOString();
const allDayEvents = allEvents.filter(e => e.allDay);
const timedEvents = allEvents.filter(e => !e.allDay);
html += `<div class="calendar-month-cell ${isCurrentMonth ? '' : 'other-month'} ${isToday ? 'today' : ''}" data-date="${dayIso}">`;
html += `<div class="cell-date day-drill" data-day-date="${dayIso}" title="View day">${currentDay.getDate()}</div>`;
html += '<div class="cell-events">';
allDayEvents.forEach(event => html += this.renderEventCard(event, 'month'));
const maxEvents = 3;
const remainingSlots = maxEvents - allDayEvents.length;
timedEvents.slice(0, remainingSlots).forEach(event => html += this.renderEventCard(event, 'month'));
const totalEvents = allEvents.length;
if (totalEvents > maxEvents) {
html += `<div class="event-more day-drill" data-day-date="${dayIso}">+${totalEvents - maxEvents} more</div>`;
}
html += '</div></div>';
currentDay.setDate(currentDay.getDate() + 1);
}
html += '</div>';
}
html += '</div></div>';
return html;
},
// ──────────────────────────────────────────────────────────────────────────
// Event Card Rendering
// ──────────────────────────────────────────────────────────────────────────
renderEventCard: function(event, viewType) {
const colorClass = this.getColorClass(event.backgroundColor);
const time = event.allDay ? 'All Day' : new Date(event.start).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
const eventType = event.eventType || 'Appointment';
const allDayClass = event.allDay ? 'all-day-event' : '';
const isJob = eventType === 'Job';
const dragAttr = isJob ? `draggable="true" data-job-id="${event.id}"` : '';
const fallbackAttr = isJob && event.isFallbackDate ? 'data-fallback="true"' : '';
const fallbackStyle = isJob && event.isFallbackDate ? 'opacity:0.75;border-style:dashed;' : '';
const jobIcon = isJob ? '<i class="bi bi-briefcase me-1" style="font-size:0.65rem"></i>' : '';
const fallbackIcon = isJob && event.isFallbackDate ? ' <i class="bi bi-calendar-x" title="No scheduled date \u2014 showing on due date" style="font-size:0.65rem"></i>' : '';
if (viewType === 'week-header') {
return `<div class="calendar-event-header ${colorClass} ${allDayClass}"
data-event-id="${event.id}" data-event-type="${eventType}"
${dragAttr} ${fallbackAttr}
style="${fallbackStyle}"
title="${this.escapeHtml(event.title)}">
${jobIcon}<span class="event-text">${this.escapeHtml(isJob ? (event.jobNumber || event.title) : event.title)}</span>${fallbackIcon}
</div>`;
} else if (viewType === 'day') {
const locationHtml = event.location
? `<div class="event-location"><i class="bi bi-geo-alt me-1"></i>${this.escapeHtml(event.location)}</div>` : '';
return `<div class="calendar-event calendar-event-day ${colorClass} ${allDayClass}"
data-event-id="${event.id}" data-event-type="${eventType}"
${dragAttr} ${fallbackAttr}
style="${fallbackStyle}"
title="${this.escapeHtml(event.title)}">
<div class="event-time">${jobIcon}${time}</div>
<div class="event-title">${this.escapeHtml(event.title)}</div>
<div class="event-customer">${this.escapeHtml(event.customerName || '')}</div>
${locationHtml}
</div>`;
} else if (viewType === 'week') {
return `<div class="calendar-event ${colorClass} ${allDayClass}"
data-event-id="${event.id}" data-event-type="${eventType}"
${dragAttr} ${fallbackAttr}
style="${fallbackStyle}"
title="${this.escapeHtml(event.title)} \u2014 ${this.escapeHtml(event.customerName)}">
<div class="event-time">${jobIcon}${time}</div>
<div class="event-title">${this.escapeHtml(event.title)}</div>
<div class="event-customer">${this.escapeHtml(event.customerName)}</div>
</div>`;
} else {
// Month view
return `<div class="calendar-event-month ${colorClass} ${allDayClass}"
data-event-id="${event.id}" data-event-type="${eventType}"
${dragAttr} ${fallbackAttr}
title="${time} \u2014 ${this.escapeHtml(event.title)}">
<span class="event-dot"></span>
<span class="event-text">${jobIcon}${isJob ? this.escapeHtml(event.jobNumber || event.title) : (time + ' ' + this.escapeHtml(event.title))}</span>
</div>`;
}
},
// ──────────────────────────────────────────────────────────────────────────
// Event Helpers
// ──────────────────────────────────────────────────────────────────────────
getEventsForDay: function(day) {
const dayStart = new Date(day); dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(day); dayEnd.setHours(23, 59, 59, 999);
return this.events.filter(event => {
const eventStart = new Date(event.start);
return eventStart >= dayStart && eventStart <= dayEnd;
});
},
getAllDayEventsForDay: function(day) {
const dayStart = new Date(day); dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(day); dayEnd.setHours(23, 59, 59, 999);
return this.events.filter(event => {
if (!event.allDay) return false;
const eventStart = new Date(event.start);
return eventStart >= dayStart && eventStart <= dayEnd;
});
},
getEventsForDayHour: function(day, hour) {
const hourStart = new Date(day); hourStart.setHours(hour, 0, 0, 0);
const hourEnd = new Date(day); hourEnd.setHours(hour, 59, 59, 999);
return this.events.filter(event => {
if (event.allDay) return false;
const eventStart = new Date(event.start);
return eventStart >= hourStart && eventStart <= hourEnd;
});
},
// ──────────────────────────────────────────────────────────────────────────
// Click Handlers
// ──────────────────────────────────────────────────────────────────────────
attachEventClickHandlers: function() {
document.querySelectorAll('.calendar-event, .calendar-event-month, .calendar-event-header').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
const eventId = el.dataset.eventId;
const eventType = el.dataset.eventType || 'Appointment';
if (eventType === 'Maintenance') {
window.location.href = `/Maintenance/Details/${eventId}`;
} else if (eventType === 'Job') {
window.location.href = `/Jobs/Details/${eventId}`;
} else {
window.location.href = `/Appointments/Details/${eventId}`;
}
});
});
document.querySelectorAll('.day-drill').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
const date = new Date(el.dataset.dayDate);
this.switchToDay(date);
});
});
// Job event drag handlers (calendar → calendar reschedule)
document.querySelectorAll('[data-event-type="Job"]').forEach(el => {
el.setAttribute('draggable', 'true');
el.addEventListener('dragstart', (e) => {
e.stopPropagation();
const cell = el.closest('[data-date]');
e.dataTransfer.setData('text/plain', JSON.stringify({
type: 'job',
jobId: el.dataset.eventId,
fromDate: cell?.dataset.date || null
}));
e.dataTransfer.effectAllowed = 'move';
el.classList.add('event-dragging');
});
el.addEventListener('dragend', () => el.classList.remove('event-dragging'));
});
},
// ──────────────────────────────────────────────────────────────────────────
// Date Ranges & Navigation
// ──────────────────────────────────────────────────────────────────────────
getDateRange: function() {
if (this.currentView === 'day') return this.getDayRange(this.currentDate);
if (this.currentView === 'week') return this.getWeekRange(this.currentDate);
return this.getMonthRange(this.currentDate);
},
getDayRange: function(date) {
const start = new Date(date); start.setHours(0, 0, 0, 0);
const end = new Date(date); end.setHours(23, 59, 59, 999);
return { start, end };
},
getWeekRange: function(date) {
const start = new Date(date);
const day = start.getDay();
const diff = start.getDate() - day + (day === 0 ? -6 : 1);
start.setDate(diff);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(start.getDate() + 7);
end.setHours(23, 59, 59, 999);
return { start, end };
},
getMonthRange: function(date) {
const start = new Date(date.getFullYear(), date.getMonth(), 1);
start.setHours(0, 0, 0, 0);
const end = new Date(date.getFullYear(), date.getMonth() + 1, 0);
end.setHours(23, 59, 59, 999);
return { start, end };
},
goToPrevious: function() {
if (this.currentView === 'day') this.currentDate.setDate(this.currentDate.getDate() - 1);
else if (this.currentView === 'week') this.currentDate.setDate(this.currentDate.getDate() - 7);
else this.currentDate.setMonth(this.currentDate.getMonth() - 1);
this.loadAndRender();
},
goToNext: function() {
if (this.currentView === 'day') this.currentDate.setDate(this.currentDate.getDate() + 1);
else if (this.currentView === 'week') this.currentDate.setDate(this.currentDate.getDate() + 7);
else this.currentDate.setMonth(this.currentDate.getMonth() + 1);
this.loadAndRender();
},
goToToday: function() {
this.currentDate = new Date();
this.loadAndRender();
},
switchView: function(view) {
this.currentView = view;
this.updateViewButtons();
this.loadAndRender();
},
switchToDay: function(date) {
this.currentDate = new Date(date);
this.currentView = 'day';
this.updateViewButtons();
this.loadAndRender();
},
// ──────────────────────────────────────────────────────────────────────────
// UI State
// ──────────────────────────────────────────────────────────────────────────
updateViewButtons: function() {
document.getElementById('btnDayView').classList.toggle('active', this.currentView === 'day');
document.getElementById('btnWeekView').classList.toggle('active', this.currentView === 'week');
document.getElementById('btnMonthView').classList.toggle('active', this.currentView === 'month');
},
updateCurrentDateDisplay: function() {
const display = document.getElementById('currentDateDisplay');
if (this.currentView === 'day') {
display.textContent = `${this.getDayName(this.currentDate, 'long')}, ${this.getMonthName(this.currentDate)} ${this.currentDate.getDate()}, ${this.currentDate.getFullYear()}`;
} else if (this.currentView === 'week') {
const { start, end } = this.getWeekRange(this.currentDate);
const endDisplay = new Date(end);
endDisplay.setDate(endDisplay.getDate() - 1);
if (start.getMonth() === endDisplay.getMonth()) {
display.textContent = `${this.getMonthName(start)} ${start.getDate()}\u2013${endDisplay.getDate()}, ${start.getFullYear()}`;
} else {
display.textContent = `${this.getMonthName(start)} ${start.getDate()} \u2013 ${this.getMonthName(endDisplay)} ${endDisplay.getDate()}, ${start.getFullYear()}`;
}
} else {
display.textContent = `${this.getMonthName(this.currentDate)} ${this.currentDate.getFullYear()}`;
}
},
updateURL: function() {
const url = new URL(window.location);
url.searchParams.set('view', this.currentView);
url.searchParams.set('date', this.currentDate.toISOString().split('T')[0]);
window.history.replaceState({}, '', url);
},
// ──────────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────────
isToday: function(date) {
const today = new Date();
return date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear();
},
getDayName: function(date, format = 'long') {
return date.toLocaleDateString('en-US', { weekday: format });
},
getMonthName: function(date) {
return date.toLocaleDateString('en-US', { month: 'long' });
},
formatHour: function(hour) {
const period = hour >= 12 ? 'PM' : 'AM';
const displayHour = hour > 12 ? hour - 12 : (hour === 0 ? 12 : hour);
return `${displayHour} ${period}`;
},
formatDateShort: function(dateStr) {
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
},
getColorClass: function(bgColor) {
if (bgColor && bgColor.startsWith('#')) {
const colorMap = {
'#6f42c1': 'calendar-event-purple',
'#198754': 'calendar-event-green',
'#0d6efd': 'calendar-event-blue',
'#fd7e14': 'calendar-event-orange',
'#d63384': 'calendar-event-pink',
'#0dcaf0': 'calendar-event-cyan',
'#20c997': 'calendar-event-teal',
'#6610f2': 'calendar-event-indigo',
'#84cc16': 'calendar-event-lime',
'#795548': 'calendar-event-brown',
'#dc3545': 'calendar-event-red',
'#ffc107': 'calendar-event-yellow',
'#6c757d': 'calendar-event-gray',
'#adb5bd': 'calendar-event-gray',
'#212529': 'calendar-event-dark'
};
return colorMap[bgColor] || 'calendar-event-blue';
}
return bgColor ? `calendar-event-${bgColor}` : 'calendar-event-blue';
},
escapeHtml: function(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
};
window.appointmentCalendar = appointmentCalendar;
window.loadCalendarEvents = () => appointmentCalendar.loadAndRender();