1033 lines
26 KiB
Vue
1033 lines
26 KiB
Vue
<template>
|
|
<div class="calendar-container">
|
|
<div class="calendar-header">
|
|
<h2>Sprinkler Service Calendar</h2>
|
|
<div class="header-controls">
|
|
<v-btn
|
|
@click="previousWeek"
|
|
icon="mdi-chevron-left"
|
|
variant="outlined"
|
|
size="small"
|
|
></v-btn>
|
|
<span class="week-display">{{ weekDisplayText }}</span>
|
|
<v-btn
|
|
@click="nextWeek"
|
|
icon="mdi-chevron-right"
|
|
variant="outlined"
|
|
size="small"
|
|
></v-btn>
|
|
<v-btn @click="goToToday" variant="outlined" size="small" class="ml-4"
|
|
>Today</v-btn
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="calendar-main">
|
|
<!-- Weekly Calendar Grid -->
|
|
<div class="calendar-section">
|
|
<div class="weekly-calendar">
|
|
<!-- Days Header -->
|
|
<div class="calendar-header-row">
|
|
<div class="time-column-header">Time</div>
|
|
<div
|
|
v-for="day in weekDays"
|
|
:key="day.date"
|
|
class="day-header"
|
|
:class="{ today: day.isToday }"
|
|
>
|
|
<div class="day-name">{{ day.dayName }}</div>
|
|
<div class="day-date">{{ day.dayDate }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Time Grid -->
|
|
<div class="calendar-grid">
|
|
<div v-for="timeSlot in timeSlots" :key="timeSlot.time" class="time-row">
|
|
<!-- Time Column -->
|
|
<div class="time-column">
|
|
<span class="time-label">{{ timeSlot.display }}</span>
|
|
</div>
|
|
|
|
<!-- Day Columns -->
|
|
<div
|
|
v-for="day in weekDays"
|
|
:key="`${day.date}-${timeSlot.time}`"
|
|
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
|
|
v-for="event in getEventsForTimeSlot(day.date, timeSlot.time)"
|
|
:key="event.id"
|
|
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>
|
|
<div class="event-time-display">{{ event.scheduledTime }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Unscheduled Services Column -->
|
|
<div class="unscheduled-section">
|
|
<div class="unscheduled-header">
|
|
<h4>Unscheduled Services</h4>
|
|
<v-chip
|
|
:color="getUnscheduledCount() > 0 ? 'warning' : 'success'"
|
|
size="small"
|
|
>
|
|
{{ getUnscheduledCount() }} pending
|
|
</v-chip>
|
|
</div>
|
|
|
|
<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"
|
|
class="unscheduled-item mb-2"
|
|
:class="getPriorityClass(service.priority)"
|
|
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">
|
|
<v-chip :color="getPriorityColor(service.priority)" size="x-small">
|
|
{{ service.priority.toUpperCase() }}
|
|
</v-chip>
|
|
<span class="text-caption text-medium-emphasis"
|
|
>${{ service.estimatedCost.toLocaleString() }}</span
|
|
>
|
|
</div>
|
|
|
|
<div class="service-title-compact">{{ service.serviceType }}</div>
|
|
<div class="service-customer">{{ service.customer }}</div>
|
|
|
|
<div class="service-compact-details mt-1">
|
|
<div class="text-caption">
|
|
<v-icon size="x-small" class="mr-1">mdi-clock</v-icon>
|
|
{{ formatDuration(service.duration) }}
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="service.notes" class="service-notes-compact mt-2">
|
|
<span class="text-caption">{{ service.notes }}</span>
|
|
</div>
|
|
|
|
<v-btn
|
|
color="primary"
|
|
size="x-small"
|
|
variant="outlined"
|
|
class="mt-2"
|
|
block
|
|
@click="scheduleService(service)"
|
|
>
|
|
<v-icon left size="x-small">mdi-calendar-plus</v-icon>
|
|
Schedule
|
|
</v-btn>
|
|
</v-card-text>
|
|
</v-card>
|
|
</div>
|
|
|
|
<div v-if="unscheduledServices.length === 0" class="no-unscheduled">
|
|
<v-icon size="large" color="success">mdi-check-circle</v-icon>
|
|
<p class="text-body-2">All services scheduled!</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Event Details Dialog -->
|
|
<v-dialog v-model="eventDialog" max-width="600px">
|
|
<v-card v-if="selectedEvent">
|
|
<v-card-title class="d-flex justify-space-between align-center">
|
|
<span>{{ selectedEvent.title }}</span>
|
|
<v-chip :color="getPriorityColor(selectedEvent.priority)" small>
|
|
{{ selectedEvent.priority.toUpperCase() }}
|
|
</v-chip>
|
|
</v-card-title>
|
|
|
|
<v-card-text>
|
|
<div class="event-details">
|
|
<div class="detail-row">
|
|
<v-icon class="mr-2">mdi-account</v-icon>
|
|
<strong>Customer:</strong> {{ selectedEvent.customer }}
|
|
</div>
|
|
<div class="detail-row">
|
|
<v-icon class="mr-2">mdi-map-marker</v-icon>
|
|
<strong>Address:</strong> {{ selectedEvent.address }}
|
|
</div>
|
|
<div class="detail-row">
|
|
<v-icon class="mr-2">mdi-wrench</v-icon>
|
|
<strong>Service Type:</strong> {{ selectedEvent.serviceType }}
|
|
</div>
|
|
<div class="detail-row">
|
|
<v-icon class="mr-2">mdi-calendar</v-icon>
|
|
<strong>Date:</strong> {{ selectedEvent.scheduledDate }}
|
|
</div>
|
|
<div class="detail-row">
|
|
<v-icon class="mr-2">mdi-clock</v-icon>
|
|
<strong>Time:</strong> {{ selectedEvent.scheduledTime }} ({{
|
|
formatDuration(selectedEvent.duration)
|
|
}})
|
|
</div>
|
|
<div class="detail-row">
|
|
<v-icon class="mr-2">mdi-account-hard-hat</v-icon>
|
|
<strong>Foreman:</strong> {{ selectedEvent.foreman || "Not assigned" }}
|
|
</div>
|
|
<div class="detail-row">
|
|
<v-icon class="mr-2">mdi-account-group</v-icon>
|
|
<strong>Crew:</strong>
|
|
{{ selectedEvent.crew?.join(", ") || "Not assigned" }}
|
|
</div>
|
|
<div class="detail-row">
|
|
<v-icon class="mr-2">mdi-currency-usd</v-icon>
|
|
<strong>Estimated Cost:</strong> ${{
|
|
selectedEvent.estimatedCost.toLocaleString()
|
|
}}
|
|
</div>
|
|
<div v-if="selectedEvent.notes" class="detail-row">
|
|
<v-icon class="mr-2">mdi-note-text</v-icon>
|
|
<strong>Notes:</strong> {{ selectedEvent.notes }}
|
|
</div>
|
|
</div>
|
|
</v-card-text>
|
|
|
|
<v-card-actions>
|
|
<v-spacer></v-spacer>
|
|
<v-btn color="primary" @click="eventDialog = false">Close</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, computed } from "vue";
|
|
import Api from "../../api";
|
|
|
|
// Reactive data
|
|
const services = ref([]);
|
|
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);
|
|
const day = d.getDay();
|
|
const diff = d.getDate() - day + (day === 0 ? -6 : 1); // Adjust when Sunday (0)
|
|
return new Date(d.setDate(diff));
|
|
}
|
|
|
|
// Computed properties
|
|
const scheduledServices = computed(() =>
|
|
services.value.filter((service) => service.status === "scheduled"),
|
|
);
|
|
|
|
const unscheduledServices = computed(() =>
|
|
services.value.filter((service) => service.status === "unscheduled"),
|
|
);
|
|
|
|
// Weekly calendar computed properties
|
|
const weekDays = computed(() => {
|
|
const days = [];
|
|
const today = new Date();
|
|
const todayStr = today.toISOString().split("T")[0];
|
|
|
|
for (let i = 0; i < 7; i++) {
|
|
const date = new Date(currentWeekStart.value);
|
|
date.setDate(currentWeekStart.value.getDate() + i);
|
|
const dateStr = date.toISOString().split("T")[0];
|
|
|
|
days.push({
|
|
date: dateStr,
|
|
dayName: date.toLocaleDateString("en-US", { weekday: "short" }),
|
|
dayDate: date.getDate(),
|
|
fullDate: date,
|
|
isToday: dateStr === todayStr,
|
|
});
|
|
}
|
|
return days;
|
|
});
|
|
|
|
const weekDisplayText = computed(() => {
|
|
const start = new Date(currentWeekStart.value);
|
|
const end = new Date(currentWeekStart.value);
|
|
end.setDate(start.getDate() + 6);
|
|
|
|
const startStr = start.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
const endStr = end.toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
});
|
|
|
|
return `${startStr} - ${endStr}`;
|
|
});
|
|
|
|
const timeSlots = computed(() => {
|
|
const slots = [];
|
|
for (let hour = 7; hour < 19; hour++) {
|
|
// 7 AM to 7 PM
|
|
for (let minute = 0; minute < 60; minute += 30) {
|
|
// 30-minute intervals
|
|
const time = `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`;
|
|
const displayHour = hour > 12 ? hour - 12 : hour === 0 ? 12 : hour;
|
|
const ampm = hour >= 12 ? "PM" : "AM";
|
|
const display =
|
|
minute === 0 ? `${displayHour}:00 ${ampm}` : `${displayHour}:30 ${ampm}`;
|
|
|
|
slots.push({
|
|
time,
|
|
display: minute === 0 ? display : "", // Only show hour labels on the hour
|
|
isHour: minute === 0,
|
|
});
|
|
}
|
|
}
|
|
return slots;
|
|
});
|
|
|
|
// Methods
|
|
const getUnscheduledCount = () => unscheduledServices.value.length;
|
|
|
|
const getPriorityClass = (priority) => {
|
|
switch (priority) {
|
|
case "urgent":
|
|
return "priority-urgent";
|
|
case "high":
|
|
return "priority-high";
|
|
case "medium":
|
|
return "priority-medium";
|
|
case "low":
|
|
return "priority-low";
|
|
default:
|
|
return "";
|
|
}
|
|
};
|
|
|
|
const getPriorityColor = (priority) => {
|
|
switch (priority) {
|
|
case "urgent":
|
|
return "red";
|
|
case "high":
|
|
return "orange";
|
|
case "medium":
|
|
return "yellow";
|
|
case "low":
|
|
return "green";
|
|
default:
|
|
return "grey";
|
|
}
|
|
};
|
|
|
|
const formatDuration = (minutes) => {
|
|
const hours = Math.floor(minutes / 60);
|
|
const mins = minutes % 60;
|
|
if (hours === 0) return `${mins}m`;
|
|
if (mins === 0) return `${hours}h`;
|
|
return `${hours}h ${mins}m`;
|
|
};
|
|
|
|
const getEndTime = (startTime, durationMinutes) => {
|
|
const [hours, minutes] = startTime.split(":").map(Number);
|
|
const startDate = new Date();
|
|
startDate.setHours(hours, minutes, 0, 0);
|
|
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
|
|
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);
|
|
newDate.setDate(newDate.getDate() - 7);
|
|
currentWeekStart.value = newDate;
|
|
};
|
|
|
|
const nextWeek = () => {
|
|
const newDate = new Date(currentWeekStart.value);
|
|
newDate.setDate(newDate.getDate() + 7);
|
|
currentWeekStart.value = newDate;
|
|
};
|
|
|
|
const goToToday = () => {
|
|
currentWeekStart.value = getWeekStart(new Date());
|
|
};
|
|
|
|
// Event positioning and display methods
|
|
const getEventsForTimeSlot = (date, time) => {
|
|
return scheduledServices.value.filter((service) => {
|
|
if (service.scheduledDate !== date) return false;
|
|
|
|
// Only show the event in its starting time slot to prevent duplication
|
|
return service.scheduledTime === time;
|
|
});
|
|
};
|
|
|
|
const getEventStyle = (event) => {
|
|
const duration = event.duration;
|
|
const slots = Math.ceil(duration / 30); // 30-minute slots
|
|
const height = Math.min(slots * 40 - 4, 200); // Max height to prevent overflow
|
|
|
|
return {
|
|
height: `${height}px`,
|
|
minHeight: "36px",
|
|
};
|
|
};
|
|
|
|
const isCurrentTimeSlot = (date, time) => {
|
|
const now = new Date();
|
|
const today = now.toISOString().split("T")[0];
|
|
const currentTime = `${now.getHours().toString().padStart(2, "0")}:${Math.floor(now.getMinutes() / 30) * 30}`;
|
|
|
|
return date === today && time === currentTime;
|
|
};
|
|
|
|
const selectTimeSlot = (date, time) => {
|
|
console.log("Selected time slot:", 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;
|
|
};
|
|
|
|
const scheduleService = (service) => {
|
|
// Placeholder for future drag-and-drop functionality
|
|
console.log("Scheduling service:", service);
|
|
// This will be implemented when drag-and-drop is added
|
|
alert(
|
|
`Scheduling functionality will be implemented in the future.\nService: ${service.title}`,
|
|
);
|
|
};
|
|
|
|
// 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 {
|
|
const data = await Api.getServiceData();
|
|
services.value = data;
|
|
console.log("Loaded services:", data);
|
|
} catch (error) {
|
|
console.error("Error loading services:", error);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.calendar-container {
|
|
padding: 20px;
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.calendar-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
padding-bottom: 15px;
|
|
border-bottom: 1px solid #e0e0e0;
|
|
}
|
|
|
|
.calendar-main {
|
|
display: flex;
|
|
gap: 20px;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.calendar-section {
|
|
flex: 1;
|
|
overflow: auto;
|
|
}
|
|
|
|
.weekly-calendar {
|
|
min-width: 800px;
|
|
background: white;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.calendar-header-row {
|
|
display: grid;
|
|
grid-template-columns: 80px repeat(7, 1fr);
|
|
border-bottom: 2px solid #e0e0e0;
|
|
background-color: #f8f9fa;
|
|
}
|
|
|
|
.time-column-header {
|
|
padding: 16px 8px;
|
|
font-weight: 600;
|
|
text-align: center;
|
|
border-right: 1px solid #e0e0e0;
|
|
color: #666;
|
|
}
|
|
|
|
.day-header {
|
|
padding: 12px 8px;
|
|
text-align: center;
|
|
border-right: 1px solid #e0e0e0;
|
|
cursor: pointer;
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
.day-header:hover {
|
|
background-color: #f0f0f0;
|
|
}
|
|
|
|
.day-header.today {
|
|
background-color: #e3f2fd;
|
|
color: #1976d2;
|
|
}
|
|
|
|
.day-name {
|
|
font-weight: 600;
|
|
font-size: 0.9em;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.day-date {
|
|
font-size: 1.2em;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.calendar-grid {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.time-row {
|
|
display: grid;
|
|
grid-template-columns: 80px repeat(7, 1fr);
|
|
min-height: 40px;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
}
|
|
|
|
.time-row:nth-child(odd) {
|
|
background-color: #fafafa;
|
|
}
|
|
|
|
.time-column {
|
|
padding: 8px;
|
|
border-right: 1px solid #e0e0e0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background-color: #f8f9fa;
|
|
}
|
|
|
|
.time-label {
|
|
font-size: 0.75em;
|
|
color: #666;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.day-column {
|
|
border-right: 1px solid #e0e0e0;
|
|
position: relative;
|
|
cursor: pointer;
|
|
transition: background-color 0.2s;
|
|
min-height: 40px;
|
|
}
|
|
|
|
.day-column:hover {
|
|
background-color: #f0f8ff;
|
|
}
|
|
|
|
.day-column.current-time {
|
|
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;
|
|
right: 2px;
|
|
top: 2px;
|
|
background: linear-gradient(135deg, #2196f3, #1976d2);
|
|
color: white;
|
|
border-radius: 4px;
|
|
padding: 4px 6px;
|
|
font-size: 0.75em;
|
|
cursor: pointer;
|
|
overflow: hidden;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
|
transition: all 0.2s;
|
|
z-index: 10;
|
|
}
|
|
|
|
.calendar-event:hover {
|
|
transform: scale(1.02);
|
|
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;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
margin-bottom: 1px;
|
|
}
|
|
|
|
.calendar-event .event-customer {
|
|
font-size: 0.65em;
|
|
opacity: 0.9;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.calendar-event .event-time-display {
|
|
font-size: 0.6em;
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.calendar-event.priority-urgent {
|
|
background: linear-gradient(135deg, #f44336, #d32f2f);
|
|
}
|
|
|
|
.calendar-event.priority-high {
|
|
background: linear-gradient(135deg, #ff9800, #f57c00);
|
|
}
|
|
|
|
.calendar-event.priority-medium {
|
|
background: linear-gradient(135deg, #2196f3, #1976d2);
|
|
}
|
|
|
|
.calendar-event.priority-low {
|
|
background: linear-gradient(135deg, #4caf50, #388e3c);
|
|
}
|
|
|
|
.unscheduled-section {
|
|
width: 280px;
|
|
border-left: 1px solid #e0e0e0;
|
|
padding-left: 16px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.unscheduled-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.unscheduled-header h4 {
|
|
font-size: 1.1em;
|
|
margin: 0;
|
|
}
|
|
|
|
.unscheduled-list {
|
|
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 {
|
|
border-left: 3px solid transparent;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.unscheduled-item:hover {
|
|
transform: translateX(3px);
|
|
}
|
|
|
|
.unscheduled-item[draggable="true"] {
|
|
cursor: grab;
|
|
}
|
|
|
|
.unscheduled-item[draggable="true"]:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.priority-urgent {
|
|
border-left-color: #f44336 !important;
|
|
}
|
|
|
|
.priority-high {
|
|
border-left-color: #ff9800 !important;
|
|
}
|
|
|
|
.priority-medium {
|
|
border-left-color: #ffeb3b !important;
|
|
}
|
|
|
|
.priority-low {
|
|
border-left-color: #4caf50 !important;
|
|
}
|
|
|
|
.service-title-compact {
|
|
font-weight: 600;
|
|
font-size: 0.9em;
|
|
color: #1976d2;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.service-customer {
|
|
font-size: 0.8em;
|
|
color: #666;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.service-compact-details {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.service-notes-compact {
|
|
background-color: #f8f9fa;
|
|
padding: 4px 6px;
|
|
border-radius: 3px;
|
|
border-left: 2px solid #2196f3;
|
|
}
|
|
|
|
.service-notes-compact .text-caption {
|
|
font-style: italic;
|
|
color: #666;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.no-unscheduled {
|
|
text-align: center;
|
|
padding: 40px 20px;
|
|
color: #666;
|
|
}
|
|
|
|
.event-details {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.detail-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.header-controls {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
}
|
|
|
|
.week-display {
|
|
font-weight: 600;
|
|
font-size: 1.1em;
|
|
color: #1976d2;
|
|
min-width: 200px;
|
|
text-align: center;
|
|
}
|
|
|
|
/* Responsive design */
|
|
@media (max-width: 768px) {
|
|
.calendar-main {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.unscheduled-section {
|
|
width: 100%;
|
|
border-left: none;
|
|
border-top: 1px solid #e0e0e0;
|
|
padding-left: 0;
|
|
padding-top: 20px;
|
|
max-height: 400px;
|
|
}
|
|
}
|
|
</style>
|