Initial commit
This commit is contained in:
@@ -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();
|
||||
Reference in New Issue
Block a user