add drag and drop for calendar

This commit is contained in:
Casey Wittrock 2025-10-30 03:48:21 -05:00
parent 8d9bb81fe2
commit 5bf9b45861

View File

@ -55,8 +55,14 @@
class="day-column" class="day-column"
:class="{ :class="{
'current-time': isCurrentTimeSlot(day.date, timeSlot.time), 'current-time': isCurrentTimeSlot(day.date, timeSlot.time),
'drag-over': isDragOver && dragOverSlot?.date === day.date && dragOverSlot?.time === timeSlot.time && !dragOverSlot?.isOccupied,
'drag-over-occupied': isDragOver && dragOverSlot?.date === day.date && dragOverSlot?.time === timeSlot.time && dragOverSlot?.isOccupied,
'drag-preview': isDragOver && isInPreviewSlots(day.date, timeSlot.time),
}" }"
@click="selectTimeSlot(day.date, timeSlot.time)" @click="selectTimeSlot(day.date, timeSlot.time)"
@dragover="handleDragOver($event, day.date, timeSlot.time)"
@dragleave="handleDragLeave"
@drop="handleDrop($event, day.date, timeSlot.time)"
> >
<!-- Events in this time slot --> <!-- Events in this time slot -->
<div <div
@ -65,7 +71,12 @@
class="calendar-event" class="calendar-event"
:class="getPriorityClass(event.priority)" :class="getPriorityClass(event.priority)"
:style="getEventStyle(event)" :style="getEventStyle(event)"
draggable="true"
@click.stop="showEventDetails({ event })" @click.stop="showEventDetails({ event })"
@dragstart="handleDragStart(event, $event)"
@dragend="handleDragEnd"
@dragover.stop.prevent
@dragenter.stop.prevent
> >
<div class="event-title">{{ event.serviceType }}</div> <div class="event-title">{{ event.serviceType }}</div>
<div class="event-customer">{{ event.customer }}</div> <div class="event-customer">{{ event.customer }}</div>
@ -89,7 +100,13 @@
</v-chip> </v-chip>
</div> </div>
<div class="unscheduled-list"> <div
class="unscheduled-list"
:class="{ 'unscheduled-drop-zone': isDragOver && draggedService?.status === 'scheduled' }"
@dragover="handleUnscheduledDragOver"
@dragleave="handleUnscheduledDragLeave"
@drop="handleUnscheduledDrop"
>
<v-card <v-card
v-for="service in unscheduledServices" v-for="service in unscheduledServices"
:key="service.id" :key="service.id"
@ -98,6 +115,9 @@
elevation="1" elevation="1"
hover hover
density="compact" density="compact"
draggable="true"
@dragstart="handleDragStart(service, $event)"
@dragend="handleDragEnd"
> >
<v-card-text class="pa-3"> <v-card-text class="pa-3">
<div class="d-flex justify-space-between align-center mb-2"> <div class="d-flex justify-space-between align-center mb-2">
@ -220,6 +240,12 @@ const currentWeekStart = ref(getWeekStart(new Date("2025-10-25")));
const eventDialog = ref(false); const eventDialog = ref(false);
const selectedEvent = ref(null); const selectedEvent = ref(null);
// Drag and drop state
const draggedService = ref(null);
const isDragOver = ref(false);
const dragOverSlot = ref(null);
const previewSlots = ref([]);
// Helper function to get week start (Monday) // Helper function to get week start (Monday)
function getWeekStart(date) { function getWeekStart(date) {
const d = new Date(date); const d = new Date(date);
@ -345,6 +371,52 @@ const getEndTime = (startTime, durationMinutes) => {
return endDate.toTimeString().slice(0, 5); return endDate.toTimeString().slice(0, 5);
}; };
// Check if a time slot has any conflicts with existing events
const isTimeSlotOccupied = (date, startTime, durationMinutes, excludeServiceId = null) => {
const endTime = getEndTime(startTime, durationMinutes);
return scheduledServices.value.some((service) => {
// Exclude the service being moved
if (excludeServiceId && service.id === excludeServiceId) return false;
if (service.scheduledDate !== date) return false;
const serviceEndTime = getEndTime(service.scheduledTime, service.duration);
// Check for time overlap
return (
(startTime >= service.scheduledTime && startTime < serviceEndTime) ||
(endTime > service.scheduledTime && endTime <= serviceEndTime) ||
(startTime <= service.scheduledTime && endTime >= serviceEndTime)
);
});
};
// Get all time slots that would be occupied by an event
const getOccupiedSlots = (date, startTime, durationMinutes) => {
const slots = [];
const endTime = getEndTime(startTime, durationMinutes);
// Generate all 30-minute slots between start and end time
let currentTime = startTime;
while (currentTime < endTime) {
slots.push({ date, time: currentTime });
// Add 30 minutes
const [hours, minutes] = currentTime.split(':').map(Number);
const nextMinutes = minutes + 30;
const nextHours = hours + Math.floor(nextMinutes / 60);
const finalMinutes = nextMinutes % 60;
currentTime = `${nextHours.toString().padStart(2, '0')}:${finalMinutes.toString().padStart(2, '0')}`;
// Safety check to prevent infinite loop
if (nextHours >= 24) break;
}
return slots;
};
// Calendar navigation methods // Calendar navigation methods
const previousWeek = () => { const previousWeek = () => {
const newDate = new Date(currentWeekStart.value); const newDate = new Date(currentWeekStart.value);
@ -367,11 +439,8 @@ const getEventsForTimeSlot = (date, time) => {
return scheduledServices.value.filter((service) => { return scheduledServices.value.filter((service) => {
if (service.scheduledDate !== date) return false; if (service.scheduledDate !== date) return false;
const serviceTime = service.scheduledTime; // Only show the event in its starting time slot to prevent duplication
const serviceEndTime = getEndTime(service.scheduledTime, service.duration); return service.scheduledTime === time;
// Check if this time slot falls within the service duration
return time >= serviceTime && time < serviceEndTime;
}); });
}; };
@ -399,6 +468,10 @@ const selectTimeSlot = (date, time) => {
// This will be used for drag-and-drop functionality // This will be used for drag-and-drop functionality
}; };
const isInPreviewSlots = (date, time) => {
return previewSlots.value.some(slot => slot.date === date && slot.time === time);
};
const showEventDetails = (event) => { const showEventDetails = (event) => {
selectedEvent.value = event.event; selectedEvent.value = event.event;
eventDialog.value = true; eventDialog.value = true;
@ -413,6 +486,179 @@ const scheduleService = (service) => {
); );
}; };
// Drag and Drop methods
const handleDragStart = (service, event) => {
draggedService.value = service;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", service.id);
// Get the dimensions of the dragged element
const dragElement = event.target;
const rect = dragElement.getBoundingClientRect();
// Set the drag image offset to center the element horizontally and position cursor at top
const offsetX = rect.width / 2;
const offsetY = 10; // Position cursor near the top of the element
try {
event.dataTransfer.setDragImage(dragElement, offsetX, offsetY);
} catch (e) {
// Fallback if setDragImage fails
console.log("Could not set custom drag image");
}
// Add visual feedback
event.target.style.opacity = '0.5';
console.log("Drag started for service:", service.serviceType);
};
const handleDragEnd = (event) => {
// Reset visual feedback
event.target.style.opacity = '1';
draggedService.value = null;
isDragOver.value = false;
dragOverSlot.value = null;
previewSlots.value = [];
};
const handleDragOver = (event, date, time) => {
event.preventDefault();
event.stopPropagation(); // Prevent event bubbling to avoid conflicts with nested elements
if (!draggedService.value) return;
// Check if the slot would be occupied (excluding the current service)
const isOccupied = isTimeSlotOccupied(date, time, draggedService.value.duration, draggedService.value.id);
// Calculate which slots would be occupied by this event
const wouldOccupySlots = getOccupiedSlots(date, time, draggedService.value.duration);
event.dataTransfer.dropEffect = isOccupied ? "none" : "move";
// Only update state if it's actually different to prevent flickering
const currentSlotKey = `${date}-${time}`;
const previousSlotKey = dragOverSlot.value ? `${dragOverSlot.value.date}-${dragOverSlot.value.time}` : null;
if (currentSlotKey !== previousSlotKey || dragOverSlot.value?.isOccupied !== isOccupied) {
isDragOver.value = true;
dragOverSlot.value = { date, time, isOccupied };
previewSlots.value = wouldOccupySlots;
}
};
const handleDragLeave = (event) => {
// Use a small delay to prevent flickering when moving between child elements
setTimeout(() => {
// Check if we're still dragging and if the mouse has actually left the calendar area
if (!draggedService.value) return;
const calendarGrid = document.querySelector('.calendar-grid');
if (!calendarGrid) return;
const rect = calendarGrid.getBoundingClientRect();
const x = event.clientX;
const y = event.clientY;
// Only clear if mouse is outside the entire calendar grid
if (
x < rect.left ||
x > rect.right ||
y < rect.top ||
y > rect.bottom
) {
isDragOver.value = false;
dragOverSlot.value = null;
previewSlots.value = [];
}
}, 10);
};
const handleDrop = (event, date, time) => {
event.preventDefault();
event.stopPropagation();
if (!draggedService.value) return;
// Check if the slot is occupied (excluding the service being moved)
const isOccupied = isTimeSlotOccupied(date, time, draggedService.value.duration, draggedService.value.id);
if (isOccupied) {
console.log("Cannot drop here - time slot is occupied");
// Reset drag state
isDragOver.value = false;
dragOverSlot.value = null;
previewSlots.value = [];
draggedService.value = null;
return;
}
// Convert time to 12-hour format for display
const [hours, minutes] = time.split(':').map(Number);
const displayHour = hours > 12 ? hours - 12 : hours === 0 ? 12 : hours;
const ampm = hours >= 12 ? 'PM' : 'AM';
const formattedTime = `${displayHour}:${minutes.toString().padStart(2, '0')} ${ampm}`;
// Update the service with scheduling information
const serviceIndex = services.value.findIndex(s => s.id === draggedService.value.id);
if (serviceIndex !== -1) {
const wasScheduled = services.value[serviceIndex].status === 'scheduled';
services.value[serviceIndex] = {
...services.value[serviceIndex],
status: 'scheduled',
scheduledDate: date,
scheduledTime: time,
scheduledTimeDisplay: formattedTime
};
const action = wasScheduled ? 'Moved' : 'Scheduled';
console.log(`${action} ${draggedService.value.serviceType} for ${date} at ${formattedTime}`);
}
// Reset drag state
isDragOver.value = false;
dragOverSlot.value = null;
previewSlots.value = [];
draggedService.value = null;
};
// Handle dropping scheduled items back to unscheduled
const handleUnscheduledDragOver = (event) => {
if (draggedService.value?.status === 'scheduled') {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}
};
const handleUnscheduledDragLeave = (event) => {
// Visual feedback will be handled by CSS
};
const handleUnscheduledDrop = (event) => {
event.preventDefault();
if (!draggedService.value || draggedService.value.status !== 'scheduled') return;
// Update the service to unscheduled status
const serviceIndex = services.value.findIndex(s => s.id === draggedService.value.id);
if (serviceIndex !== -1) {
services.value[serviceIndex] = {
...services.value[serviceIndex],
status: 'unscheduled',
scheduledDate: null,
scheduledTime: null,
scheduledTimeDisplay: null
};
console.log(`Unscheduled ${draggedService.value.serviceType}`);
}
// Reset drag state
isDragOver.value = false;
dragOverSlot.value = null;
previewSlots.value = [];
draggedService.value = null;
};
// Lifecycle // Lifecycle
onMounted(async () => { onMounted(async () => {
try { try {
@ -551,6 +797,25 @@ onMounted(async () => {
background-color: #fff3e0; background-color: #fff3e0;
} }
.day-column.drag-over {
background-color: #e8f5e8;
border: 2px dashed #4caf50;
box-sizing: border-box;
}
.day-column.drag-over-occupied {
background-color: #ffebee;
border: 2px dashed #f44336;
box-sizing: border-box;
}
.day-column.drag-preview {
background-color: #e3f2fd;
border: 1px solid #2196f3;
box-sizing: border-box;
opacity: 0.7;
}
.calendar-event { .calendar-event {
position: absolute; position: absolute;
left: 2px; left: 2px;
@ -573,6 +838,14 @@ onMounted(async () => {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
} }
.calendar-event[draggable="true"] {
cursor: grab;
}
.calendar-event[draggable="true"]:active {
cursor: grabbing;
}
.calendar-event .event-title { .calendar-event .event-title {
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
@ -634,6 +907,14 @@ onMounted(async () => {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
max-height: calc(100vh - 200px); max-height: calc(100vh - 200px);
transition: all 0.3s ease;
border-radius: 8px;
}
.unscheduled-list.unscheduled-drop-zone {
background-color: #e8f5e8;
border: 2px dashed #4caf50;
box-sizing: border-box;
} }
.unscheduled-item { .unscheduled-item {
@ -645,6 +926,14 @@ onMounted(async () => {
transform: translateX(3px); transform: translateX(3px);
} }
.unscheduled-item[draggable="true"] {
cursor: grab;
}
.unscheduled-item[draggable="true"]:active {
cursor: grabbing;
}
.priority-urgent { .priority-urgent {
border-left-color: #f44336 !important; border-left-color: #f44336 !important;
} }