add drag and drop for calendar
This commit is contained in:
parent
8d9bb81fe2
commit
5bf9b45861
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user