816 lines
40 KiB
JavaScript
816 lines
40 KiB
JavaScript
/**
|
||
* 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();
|