Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,815 @@
/**
* 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();