add drag and drop for calendar
This commit is contained in:
parent
8d9bb81fe2
commit
5bf9b45861
@ -55,8 +55,14 @@
|
||||
class="day-column"
|
||||
:class="{
|
||||
'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)"
|
||||
@dragover="handleDragOver($event, day.date, timeSlot.time)"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop($event, day.date, timeSlot.time)"
|
||||
>
|
||||
<!-- Events in this time slot -->
|
||||
<div
|
||||
@ -65,7 +71,12 @@
|
||||
class="calendar-event"
|
||||
:class="getPriorityClass(event.priority)"
|
||||
:style="getEventStyle(event)"
|
||||
draggable="true"
|
||||
@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-customer">{{ event.customer }}</div>
|
||||
@ -89,7 +100,13 @@
|
||||
</v-chip>
|
||||
</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-for="service in unscheduledServices"
|
||||
:key="service.id"
|
||||
@ -98,6 +115,9 @@
|
||||
elevation="1"
|
||||
hover
|
||||
density="compact"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart(service, $event)"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<v-card-text class="pa-3">
|
||||
<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 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)
|
||||
function getWeekStart(date) {
|
||||
const d = new Date(date);
|
||||
@ -345,6 +371,52 @@ const getEndTime = (startTime, durationMinutes) => {
|
||||
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
|
||||
const previousWeek = () => {
|
||||
const newDate = new Date(currentWeekStart.value);
|
||||
@ -367,11 +439,8 @@ const getEventsForTimeSlot = (date, time) => {
|
||||
return scheduledServices.value.filter((service) => {
|
||||
if (service.scheduledDate !== date) return false;
|
||||
|
||||
const serviceTime = service.scheduledTime;
|
||||
const serviceEndTime = getEndTime(service.scheduledTime, service.duration);
|
||||
|
||||
// Check if this time slot falls within the service duration
|
||||
return time >= serviceTime && time < serviceEndTime;
|
||||
// Only show the event in its starting time slot to prevent duplication
|
||||
return service.scheduledTime === time;
|
||||
});
|
||||
};
|
||||
|
||||
@ -399,6 +468,10 @@ const selectTimeSlot = (date, time) => {
|
||||
// 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) => {
|
||||
selectedEvent.value = event.event;
|
||||
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
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@ -551,6 +797,25 @@ onMounted(async () => {
|
||||
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 {
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
@ -573,6 +838,14 @@ onMounted(async () => {
|
||||
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 {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
@ -634,6 +907,14 @@ onMounted(async () => {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
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 {
|
||||
@ -645,6 +926,14 @@ onMounted(async () => {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.unscheduled-item[draggable="true"] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.unscheduled-item[draggable="true"]:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.priority-urgent {
|
||||
border-left-color: #f44336 !important;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user