1659 lines
41 KiB
Vue
1659 lines
41 KiB
Vue
<template>
|
|
<div class="schedule-bid-container">
|
|
<!-- Header -->
|
|
<div class="header">
|
|
<h2>Bid Schedule</h2>
|
|
<div class="header-controls">
|
|
<v-btn
|
|
@click="previousWeek"
|
|
icon="mdi-chevron-left"
|
|
variant="outlined"
|
|
size="small"
|
|
></v-btn>
|
|
<v-btn @click="showDatePicker = true" variant="text" class="week-display-btn">
|
|
<span class="date-text">{{ weekDisplayText }}</span>
|
|
<v-icon right size="small">mdi-calendar</v-icon>
|
|
</v-btn>
|
|
<v-btn
|
|
@click="nextWeek"
|
|
icon="mdi-chevron-right"
|
|
variant="outlined"
|
|
size="small"
|
|
></v-btn>
|
|
<v-btn @click="goToCurrentWeek" variant="outlined" size="small" class="ml-4"
|
|
>This Week</v-btn
|
|
>
|
|
<v-btn
|
|
@click="openNewMeetingModal"
|
|
variant="elevated"
|
|
size="small"
|
|
color="primary"
|
|
class="ml-4"
|
|
>
|
|
<v-icon left size="small">mdi-plus</v-icon>
|
|
New
|
|
</v-btn>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Date Picker Dialog -->
|
|
<v-dialog v-model="showDatePicker" max-width="400px">
|
|
<v-card>
|
|
<v-card-title>Select Date</v-card-title>
|
|
<v-card-text>
|
|
<v-date-picker
|
|
v-model="selectedDate"
|
|
@update:model-value="onDateSelected"
|
|
full-width
|
|
></v-date-picker>
|
|
</v-card-text>
|
|
<v-card-actions>
|
|
<v-spacer></v-spacer>
|
|
<v-btn @click="showDatePicker = false">Cancel</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
|
|
<!-- Main Content Layout -->
|
|
<div class="main-layout">
|
|
<!-- Weekly Calendar -->
|
|
<div class="calendar-section">
|
|
<div class="weekly-calendar">
|
|
<!-- Days of Week 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.displayDate }}</div>
|
|
<div class="meetings-count">
|
|
{{ getDayMeetingsCount(day.date) }} meetings
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Time Grid -->
|
|
<div class="calendar-grid">
|
|
<div
|
|
v-for="timeSlot in businessHours"
|
|
: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),
|
|
'past-time': isPastTimeSlot(day.date, timeSlot.time),
|
|
'drag-over':
|
|
isDragOver &&
|
|
dragOverSlot?.date === day.date &&
|
|
dragOverSlot?.time === timeSlot.time,
|
|
weekend: day.isWeekend,
|
|
}"
|
|
@click="selectTimeSlot(day.date, timeSlot.time)"
|
|
@dragover="handleDragOver($event, day.date, timeSlot.time)"
|
|
@dragleave="handleDragLeave"
|
|
@drop="handleDrop($event, day.date, timeSlot.time)"
|
|
>
|
|
<!-- Meetings in this time slot -->
|
|
<div
|
|
v-for="meeting in getMeetingsForTimeSlot(
|
|
day.date,
|
|
timeSlot.time,
|
|
)"
|
|
:key="meeting.id"
|
|
class="meeting-event"
|
|
:class="[getMeetingColorClass(meeting), { 'meeting-completed-locked': meeting.status === 'Completed' }]"
|
|
:draggable="meeting.status !== 'Completed'"
|
|
@dragstart="handleMeetingDragStart($event, meeting)"
|
|
@dragend="handleDragEnd($event)"
|
|
@click.stop="showMeetingDetails(meeting)"
|
|
>
|
|
<div class="event-time">
|
|
{{ formatTimeDisplay(meeting.scheduledTime) }}
|
|
</div>
|
|
<div class="event-address">{{ meeting.address.fullAddress }}</div>
|
|
<div class="event-client" v-if="meeting.client">
|
|
{{ meeting.client }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right Sidebar for Unscheduled Meetings -->
|
|
<div class="sidebar sidebar-right" :class="{ collapsed: isSidebarCollapsed }">
|
|
<div class="sidebar-header">
|
|
<div class="sidebar-title">
|
|
<h4 v-if="!isSidebarCollapsed">Unscheduled Meetings</h4>
|
|
<v-chip color="warning" size="small">{{
|
|
unscheduledMeetings.length
|
|
}}</v-chip>
|
|
</div>
|
|
<v-btn
|
|
@click="isSidebarCollapsed = !isSidebarCollapsed"
|
|
:icon="isSidebarCollapsed ? 'mdi-chevron-left' : 'mdi-chevron-right'"
|
|
variant="text"
|
|
size="small"
|
|
density="compact"
|
|
></v-btn>
|
|
</div>
|
|
<div
|
|
v-if="!isSidebarCollapsed"
|
|
class="unscheduled-meetings-list"
|
|
:class="{ 'drag-over-unscheduled': isUnscheduledDragOver }"
|
|
@dragover="handleUnscheduledDragOver"
|
|
@dragleave="handleUnscheduledDragLeave"
|
|
@drop="handleDropToUnscheduled"
|
|
>
|
|
<div v-if="unscheduledMeetings.length === 0" class="empty-state">
|
|
<v-icon size="large" color="grey-lighten-1">mdi-calendar-check</v-icon>
|
|
<p>No unscheduled meetings</p>
|
|
</div>
|
|
<v-card
|
|
v-for="meeting in unscheduledMeetings"
|
|
:key="meeting.name"
|
|
class="meeting-card mb-3"
|
|
elevation="2"
|
|
hover
|
|
draggable="true"
|
|
@dragstart="handleDragStart($event, meeting)"
|
|
@dragend="handleDragEnd($event)"
|
|
@click="showMeetingDetails(meeting)"
|
|
>
|
|
<v-card-text class="pa-2">
|
|
<div class="unscheduled-address">
|
|
<v-icon size="x-small" class="mr-1">mdi-map-marker</v-icon>
|
|
{{ meeting.address?.fullAddress || meeting.address }}
|
|
</div>
|
|
<div v-if="meeting.contact?.name" class="unscheduled-contact">
|
|
<v-icon size="x-small" class="mr-1">mdi-account</v-icon>
|
|
{{ meeting.contact.name }}
|
|
</div>
|
|
<div v-if="meeting.projectTemplate" class="unscheduled-project">
|
|
<v-icon size="x-small" class="mr-1">mdi-file-document</v-icon>
|
|
{{ meeting.projectTemplate }}
|
|
</div>
|
|
<div class="unscheduled-status">
|
|
<v-chip size="x-small" :color="getStatusColor(meeting.status)">
|
|
{{ meeting.status }}
|
|
</v-chip>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Meeting Details Modal -->
|
|
<MeetingDetailsModal
|
|
:visible="showMeetingModal"
|
|
@update:visible="showMeetingModal = $event"
|
|
:meeting="selectedMeeting"
|
|
@close="closeMeetingModal"
|
|
@meeting-updated="handleMeetingUpdated"
|
|
@complete-meeting="openNoteForm"
|
|
/>
|
|
|
|
<!-- New Meeting Modal -->
|
|
<BidMeetingModal
|
|
:visible="showNewMeetingModal"
|
|
@update:visible="showNewMeetingModal = $event"
|
|
:initial-address="queryAddress"
|
|
@confirm="handleNewMeetingConfirm"
|
|
@cancel="handleNewMeetingCancel"
|
|
/>
|
|
|
|
<!-- Bid Meeting Note Form Modal -->
|
|
<BidMeetingNoteForm
|
|
v-if="selectedMeetingForNotes"
|
|
:visible="showNoteFormModal"
|
|
@update:visible="showNoteFormModal = $event"
|
|
:bid-meeting-name="selectedMeetingForNotes.name"
|
|
:project-template="selectedMeetingForNotes.projectTemplate"
|
|
@submit="handleNoteFormSubmit"
|
|
@cancel="handleNoteFormCancel"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, watch } from "vue";
|
|
import { useRoute, useRouter } from "vue-router";
|
|
import BidMeetingModal from "../../modals/BidMeetingModal.vue";
|
|
import MeetingDetailsModal from "../../modals/MeetingDetailsModal.vue";
|
|
import BidMeetingNoteForm from "../../modals/BidMeetingNoteForm.vue";
|
|
import { useLoadingStore } from "../../../stores/loading";
|
|
import { useNotificationStore } from "../../../stores/notifications-primevue";
|
|
import { useCompanyStore } from "../../../stores/company";
|
|
import Api from "../../../api";
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const loadingStore = useLoadingStore();
|
|
const notificationStore = useNotificationStore();
|
|
const companyStore = useCompanyStore();
|
|
|
|
// Query parameters
|
|
const isNewMode = computed(() => route.query.new === "true");
|
|
const queryAddress = computed(() => route.query.address || "");
|
|
const queryMeetingName = computed(() => route.query.name || "");
|
|
|
|
// Date management
|
|
const currentWeekStart = ref(new Date());
|
|
const showDatePicker = ref(false);
|
|
const selectedDate = ref(null);
|
|
|
|
// Calendar state
|
|
const meetings = ref([]);
|
|
const unscheduledMeetings = ref([]);
|
|
const selectedMeeting = ref(null);
|
|
const showMeetingModal = ref(false);
|
|
const showNewMeetingModal = ref(false);
|
|
const showNoteFormModal = ref(false);
|
|
const selectedMeetingForNotes = ref(null);
|
|
|
|
// Drag and drop state
|
|
const isDragOver = ref(false);
|
|
const dragOverSlot = ref(null);
|
|
const draggedMeeting = ref(null);
|
|
const originalMeetingForReschedule = ref(null); // Store original meeting data for reschedules
|
|
const isUnscheduledDragOver = ref(false);
|
|
|
|
// Sidebar state
|
|
const isSidebarCollapsed = ref(false);
|
|
|
|
// Business hours (8 AM to 6 PM)
|
|
const businessHours = computed(() => {
|
|
const hours = [];
|
|
for (let hour = 8; hour <= 18; hour++) {
|
|
const time = `${hour.toString().padStart(2, "0")}:00`;
|
|
const displayHour = hour > 12 ? hour - 12 : hour === 0 ? 12 : hour;
|
|
const ampm = hour >= 12 ? "PM" : "AM";
|
|
const display = `${displayHour}:00 ${ampm}`;
|
|
|
|
hours.push({ time, display });
|
|
|
|
// Add 30-minute slot (except for the last hour)
|
|
if (hour < 18) {
|
|
const halfTime = `${hour.toString().padStart(2, "0")}:30`;
|
|
const halfDisplay = `${displayHour}:30 ${ampm}`;
|
|
hours.push({ time: halfTime, display: halfDisplay });
|
|
}
|
|
}
|
|
return hours;
|
|
});
|
|
|
|
// Week display
|
|
const weekDays = computed(() => {
|
|
const days = [];
|
|
const startOfWeek = new Date(currentWeekStart.value);
|
|
startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay()); // Get Sunday
|
|
|
|
for (let i = 0; i < 7; i++) {
|
|
const day = new Date(startOfWeek);
|
|
day.setDate(startOfWeek.getDate() + i);
|
|
|
|
const today = new Date();
|
|
const isToday = day.toDateString() === today.toDateString();
|
|
const isWeekend = day.getDay() === 0 || day.getDay() === 6;
|
|
|
|
days.push({
|
|
date: day.toISOString().split("T")[0],
|
|
dayName: day.toLocaleDateString("en-US", { weekday: "short" }),
|
|
displayDate: day.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
|
|
isToday,
|
|
isWeekend,
|
|
});
|
|
}
|
|
return days;
|
|
});
|
|
|
|
const weekDisplayText = computed(() => {
|
|
const startOfWeek = new Date(currentWeekStart.value);
|
|
startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay());
|
|
|
|
const endOfWeek = new Date(startOfWeek);
|
|
endOfWeek.setDate(startOfWeek.getDate() + 6);
|
|
|
|
const startMonth = startOfWeek.toLocaleDateString("en-US", { month: "short" });
|
|
const endMonth = endOfWeek.toLocaleDateString("en-US", { month: "short" });
|
|
const startDay = startOfWeek.getDate();
|
|
const endDay = endOfWeek.getDate();
|
|
const year = startOfWeek.getFullYear();
|
|
|
|
if (startMonth === endMonth) {
|
|
return `${startMonth} ${startDay}-${endDay}, ${year}`;
|
|
} else {
|
|
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`;
|
|
}
|
|
});
|
|
|
|
// Modal options - keeping these for backward compatibility if needed
|
|
const meetingModalOptions = computed(() => ({
|
|
maxWidth: "600px",
|
|
showCancelButton: false,
|
|
confirmButtonText: "Close",
|
|
confirmButtonColor: "primary",
|
|
}));
|
|
|
|
// Methods
|
|
const initializeWeek = () => {
|
|
const today = new Date();
|
|
currentWeekStart.value = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
|
};
|
|
|
|
const previousWeek = () => {
|
|
const newDate = new Date(currentWeekStart.value);
|
|
newDate.setDate(newDate.getDate() - 7);
|
|
currentWeekStart.value = newDate;
|
|
loadWeekMeetings();
|
|
};
|
|
|
|
const nextWeek = () => {
|
|
const newDate = new Date(currentWeekStart.value);
|
|
newDate.setDate(newDate.getDate() + 7);
|
|
currentWeekStart.value = newDate;
|
|
loadWeekMeetings();
|
|
};
|
|
|
|
const goToCurrentWeek = () => {
|
|
initializeWeek();
|
|
loadWeekMeetings();
|
|
};
|
|
|
|
const onDateSelected = (date) => {
|
|
if (date) {
|
|
const dateObj = Array.isArray(date) ? date[0] : date;
|
|
currentWeekStart.value = new Date(
|
|
dateObj.getFullYear(),
|
|
dateObj.getMonth(),
|
|
dateObj.getDate(),
|
|
);
|
|
showDatePicker.value = false;
|
|
loadWeekMeetings();
|
|
}
|
|
};
|
|
|
|
const getMeetingsForTimeSlot = (date, time) => {
|
|
if (!Array.isArray(meetings.value)) {
|
|
return [];
|
|
}
|
|
return meetings.value.filter((meeting) => {
|
|
if (meeting.date !== date) return false;
|
|
|
|
// Check if the meeting starts at this time slot
|
|
const meetingTime = meeting.scheduledTime;
|
|
return meetingTime === time;
|
|
});
|
|
};
|
|
|
|
const getDayMeetingsCount = (date) => {
|
|
if (!Array.isArray(meetings.value)) {
|
|
return 0;
|
|
}
|
|
return meetings.value.filter((meeting) => meeting.date === date).length;
|
|
};
|
|
|
|
const isCurrentTimeSlot = (date, time) => {
|
|
const now = new Date();
|
|
const today = now.toISOString().split("T")[0];
|
|
const currentHour = now.getHours();
|
|
const currentMinute = now.getMinutes();
|
|
const currentTime = `${currentHour.toString().padStart(2, "0")}:${currentMinute >= 30 ? "30" : "00"}`;
|
|
|
|
return date === today && time === currentTime;
|
|
};
|
|
|
|
const isPastTimeSlot = (date, time) => {
|
|
const now = new Date();
|
|
const slotDateTime = new Date(`${date}T${time}:00`);
|
|
return slotDateTime < now;
|
|
};
|
|
|
|
const getMeetingColorClass = (meeting) => {
|
|
if (meeting.status === "Completed") {
|
|
return "meeting-completed";
|
|
}
|
|
|
|
// Check if meeting is in the past based on end time
|
|
if (meeting.endTime) {
|
|
const endDateTime = new Date(meeting.endTime);
|
|
const now = new Date();
|
|
|
|
if (endDateTime < now && meeting.status !== "Completed") {
|
|
return "meeting-overdue";
|
|
}
|
|
}
|
|
|
|
// Default: scheduled and in the future
|
|
return "meeting-scheduled";
|
|
};
|
|
|
|
const formatTimeDisplay = (time) => {
|
|
const [hours, minutes] = time.split(":").map(Number);
|
|
const displayHour = hours > 12 ? hours - 12 : hours === 0 ? 12 : hours;
|
|
const ampm = hours >= 12 ? "PM" : "AM";
|
|
return `${displayHour}:${minutes.toString().padStart(2, "0")} ${ampm}`;
|
|
};
|
|
|
|
const formatDate = (dateStr) => {
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleDateString("en-US", {
|
|
weekday: "long",
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
});
|
|
};
|
|
|
|
const formatDateForUrl = (date) => {
|
|
return date.toISOString().split("T")[0];
|
|
};
|
|
|
|
const getAddressText = (address) => {
|
|
if (!address) return "";
|
|
if (typeof address === "string") return address;
|
|
return address.full_address || address.fullAddress || address.name || "";
|
|
};
|
|
|
|
const selectTimeSlot = (date, time) => {
|
|
console.log("Selected time slot:", date, time);
|
|
};
|
|
|
|
const showMeetingDetails = (meeting) => {
|
|
selectedMeeting.value = meeting;
|
|
showMeetingModal.value = true;
|
|
};
|
|
|
|
const closeMeetingModal = () => {
|
|
showMeetingModal.value = false;
|
|
selectedMeeting.value = null;
|
|
};
|
|
|
|
const handleMeetingUpdated = async () => {
|
|
// Reload both scheduled and unscheduled meetings
|
|
await loadWeekMeetings();
|
|
await loadUnscheduledMeetings();
|
|
};
|
|
|
|
const openNoteForm = (meeting) => {
|
|
// Verify meeting has required data
|
|
if (!meeting || !meeting.name) {
|
|
notificationStore.addNotification({
|
|
type: "error",
|
|
title: "Error",
|
|
message: "Meeting information is incomplete",
|
|
duration: 5000,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!meeting.projectTemplate) {
|
|
notificationStore.addNotification({
|
|
type: "error",
|
|
title: "Missing Project Template",
|
|
message: "This meeting does not have a project template assigned. Cannot open note form.",
|
|
duration: 5000,
|
|
});
|
|
return;
|
|
}
|
|
|
|
selectedMeetingForNotes.value = meeting;
|
|
showNoteFormModal.value = true;
|
|
};
|
|
|
|
const handleNoteFormSubmit = async () => {
|
|
// After successful submission, mark the meeting as completed
|
|
try {
|
|
loadingStore.setLoading(true);
|
|
await Api.updateBidMeeting(selectedMeetingForNotes.value.name, {
|
|
status: "Completed",
|
|
});
|
|
|
|
notificationStore.addNotification({
|
|
type: "success",
|
|
title: "Success",
|
|
message: "Meeting marked as completed",
|
|
duration: 5000,
|
|
});
|
|
|
|
// Reload meetings
|
|
await handleMeetingUpdated();
|
|
} catch (error) {
|
|
console.error("Error updating meeting status:", error);
|
|
} finally {
|
|
loadingStore.setLoading(false);
|
|
showNoteFormModal.value = false;
|
|
selectedMeetingForNotes.value = null;
|
|
}
|
|
};
|
|
|
|
const handleNoteFormCancel = () => {
|
|
showNoteFormModal.value = false;
|
|
selectedMeetingForNotes.value = null;
|
|
};
|
|
|
|
const openNewMeetingModal = () => {
|
|
showNewMeetingModal.value = true;
|
|
};
|
|
|
|
const handleNewMeetingConfirm = async (meetingData) => {
|
|
console.log("Creating new meeting:", meetingData);
|
|
|
|
try {
|
|
loadingStore.setLoading(true);
|
|
|
|
// Create the meeting via API
|
|
const result = await Api.createBidMeeting(meetingData);
|
|
|
|
showNewMeetingModal.value = false;
|
|
|
|
// Optimistically add the new meeting to the unscheduled list
|
|
unscheduledMeetings.value.unshift({
|
|
name: result.name,
|
|
address: meetingData.address,
|
|
projectTemplate: meetingData.projectTemplate,
|
|
contact: meetingData.contact,
|
|
status: "Unscheduled",
|
|
});
|
|
|
|
// Reload unscheduled meetings to ensure consistency
|
|
await loadUnscheduledMeetings();
|
|
|
|
notificationStore.addNotification({
|
|
type: "success",
|
|
title: "Success",
|
|
message: "Meeting created successfully. Drag it to the calendar to schedule a time.",
|
|
duration: 5000,
|
|
});
|
|
|
|
console.log("Meeting created:", result);
|
|
} catch (error) {
|
|
console.error("Error creating meeting:", error);
|
|
notificationStore.addNotification({
|
|
type: "error",
|
|
title: "Error",
|
|
message: "Failed to create meeting. Please try again.",
|
|
duration: 5000,
|
|
});
|
|
} finally {
|
|
loadingStore.setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleNewMeetingCancel = () => {
|
|
showNewMeetingModal.value = false;
|
|
// Clear query params after canceling
|
|
router.push({
|
|
path: "/schedule-bid",
|
|
query: {
|
|
date: formatDateForUrl(currentWeekStart.value),
|
|
},
|
|
});
|
|
};
|
|
|
|
// Drag and drop handlers
|
|
const handleDragStart = (event, meeting = null) => {
|
|
// If a meeting object is passed, use it; otherwise use query address for new meetings
|
|
if (meeting) {
|
|
draggedMeeting.value = {
|
|
id: meeting.name,
|
|
address: meeting.address,
|
|
notes: meeting.notes || "",
|
|
assigned_employee: meeting.assigned_employee || "",
|
|
status: meeting.status,
|
|
projectTemplate: meeting.projectTemplate,
|
|
};
|
|
} else if (!draggedMeeting.value) {
|
|
// If no meeting data is set, use query address
|
|
draggedMeeting.value = {
|
|
address: queryAddress.value,
|
|
client: "",
|
|
duration: 60,
|
|
notes: "",
|
|
};
|
|
}
|
|
event.dataTransfer.effectAllowed = "move";
|
|
console.log("Drag started for meeting:", draggedMeeting.value);
|
|
};
|
|
|
|
const handleMeetingDragStart = (event, meeting) => {
|
|
// Prevent dragging completed meetings
|
|
if (meeting.status === 'Completed') {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
// Handle dragging a scheduled meeting
|
|
draggedMeeting.value = {
|
|
id: meeting.name,
|
|
address: meeting.address,
|
|
notes: meeting.notes || "",
|
|
assigned_employee: meeting.assigned_employee || "",
|
|
status: meeting.status,
|
|
isRescheduling: true, // Flag to indicate this is a reschedule
|
|
projectTemplate: meeting.projectTemplate,
|
|
};
|
|
|
|
// Store the original meeting data in case drag is cancelled
|
|
originalMeetingForReschedule.value = { ...meeting };
|
|
|
|
event.dataTransfer.effectAllowed = "move";
|
|
console.log("Rescheduling meeting:", draggedMeeting.value);
|
|
};
|
|
|
|
const resetDragState = () => {
|
|
isDragOver.value = false;
|
|
dragOverSlot.value = null;
|
|
isUnscheduledDragOver.value = false;
|
|
draggedMeeting.value = null;
|
|
originalMeetingForReschedule.value = null;
|
|
};
|
|
|
|
const handleDragEnd = (event) => {
|
|
// If drag was cancelled (not dropped successfully), restore the original meeting
|
|
if (originalMeetingForReschedule.value && draggedMeeting.value?.isRescheduling) {
|
|
// Meeting wasn't successfully dropped, so it's still in the array
|
|
console.log("Drag cancelled, meeting remains in original position");
|
|
}
|
|
resetDragState();
|
|
};
|
|
|
|
const handleDragOver = (event, date, time) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
if (!draggedMeeting.value) return;
|
|
|
|
// Check if this is a past time slot
|
|
if (isPastTimeSlot(date, time)) {
|
|
event.dataTransfer.dropEffect = "none";
|
|
return;
|
|
}
|
|
|
|
event.dataTransfer.dropEffect = "move";
|
|
isDragOver.value = true;
|
|
dragOverSlot.value = { date, time };
|
|
};
|
|
|
|
const handleDragLeave = (event) => {
|
|
// Use a small delay to prevent flickering
|
|
setTimeout(() => {
|
|
if (!draggedMeeting.value) return;
|
|
|
|
const calendarGrid = document.querySelector(".calendar-grid");
|
|
if (!calendarGrid) return;
|
|
|
|
const rect = calendarGrid.getBoundingClientRect();
|
|
const x = event.clientX;
|
|
const y = event.clientY;
|
|
|
|
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
|
isDragOver.value = false;
|
|
dragOverSlot.value = null;
|
|
}
|
|
}, 10);
|
|
};
|
|
|
|
const handleDrop = async (event, date, time) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
if (!draggedMeeting.value) return;
|
|
|
|
// Store the dragged meeting data locally before any async operations
|
|
// This prevents issues if dragEnd fires before drop completes
|
|
const droppedMeeting = { ...draggedMeeting.value };
|
|
const originalMeeting = originalMeetingForReschedule.value
|
|
? { ...originalMeetingForReschedule.value }
|
|
: null;
|
|
|
|
// Prevent dropping in past time slots
|
|
if (isPastTimeSlot(date, time)) {
|
|
notificationStore.addNotification({
|
|
type: "error",
|
|
title: "Invalid Time",
|
|
message: "Cannot schedule meetings in the past",
|
|
duration: 3000,
|
|
});
|
|
// If rescheduling failed, restore the meeting to its original position
|
|
if (droppedMeeting.isRescheduling) {
|
|
await loadWeekMeetings();
|
|
}
|
|
resetDragState();
|
|
return;
|
|
}
|
|
|
|
// Create datetime string from date and time
|
|
const startDateTime = `${date} ${time}:00`;
|
|
|
|
// Calculate end time (1 hour later)
|
|
const startDate = new Date(`${date}T${time}:00`);
|
|
const endDate = new Date(startDate.getTime() + 60 * 60 * 1000);
|
|
const endTime = `${endDate.getHours().toString().padStart(2, "0")}:${endDate.getMinutes().toString().padStart(2, "0")}:00`;
|
|
const endDateTime = `${date} ${endTime}`;
|
|
|
|
// Create new meeting with the dragged data
|
|
const newMeeting = {
|
|
id: droppedMeeting.id || Date.now(), // Use existing ID if available
|
|
name: droppedMeeting.id, // Store the doctype name if it exists
|
|
date,
|
|
scheduledTime: time,
|
|
address: droppedMeeting.address,
|
|
notes: droppedMeeting.notes || "",
|
|
assigned_employee: droppedMeeting.assigned_employee || "",
|
|
status: "Scheduled",
|
|
projectTemplate: droppedMeeting.projectTemplate,
|
|
};
|
|
|
|
// If this is an existing meeting, update it in the backend
|
|
if (droppedMeeting.id) {
|
|
try {
|
|
loadingStore.setLoading(true);
|
|
|
|
const updateData = {
|
|
start_time: startDateTime,
|
|
end_time: endDateTime,
|
|
status: "Scheduled",
|
|
};
|
|
|
|
await Api.updateBidMeeting(droppedMeeting.id, updateData);
|
|
|
|
// If this was a reschedule, remove the old meeting from its original position
|
|
if (droppedMeeting.isRescheduling && originalMeeting) {
|
|
const oldIndex = meetings.value.findIndex((m) => m.name === droppedMeeting.id);
|
|
if (oldIndex !== -1) {
|
|
meetings.value.splice(oldIndex, 1);
|
|
}
|
|
}
|
|
|
|
// Add to meetings array at new position
|
|
meetings.value.push(newMeeting);
|
|
|
|
// Remove from unscheduled list (if it was unscheduled)
|
|
if (!droppedMeeting.isRescheduling) {
|
|
const index = unscheduledMeetings.value.findIndex(
|
|
(m) => m.name === droppedMeeting.id,
|
|
);
|
|
if (index !== -1) {
|
|
unscheduledMeetings.value.splice(index, 1);
|
|
}
|
|
}
|
|
|
|
// Show success notification
|
|
const actionText = droppedMeeting.isRescheduling ? "rescheduled" : "scheduled";
|
|
notificationStore.addNotification({
|
|
type: "success",
|
|
title: `Meeting ${actionText.charAt(0).toUpperCase() + actionText.slice(1)}`,
|
|
message: `On-site meeting ${actionText} for ${formatDate(date)} at ${formatTimeDisplay(time)}`,
|
|
duration: 5000,
|
|
});
|
|
|
|
console.log("Meeting scheduled:", newMeeting);
|
|
} catch (error) {
|
|
console.error("Error scheduling meeting:", error);
|
|
notificationStore.addNotification({
|
|
type: "error",
|
|
title: "Error",
|
|
message: "Failed to schedule meeting. Please try again.",
|
|
duration: 5000,
|
|
});
|
|
// On error, reload to restore correct state
|
|
if (droppedMeeting.isRescheduling) {
|
|
await loadWeekMeetings();
|
|
}
|
|
} finally {
|
|
loadingStore.setLoading(false);
|
|
}
|
|
} else {
|
|
// For new meetings without an ID, just add to the array
|
|
meetings.value.push(newMeeting);
|
|
notificationStore.addNotification({
|
|
type: "success",
|
|
title: "Meeting Scheduled",
|
|
message: `On-site meeting scheduled for ${formatDate(date)} at ${formatTimeDisplay(time)}`,
|
|
duration: 5000,
|
|
});
|
|
}
|
|
|
|
// Reset drag state
|
|
resetDragState();
|
|
};
|
|
|
|
const handleUnscheduledDragOver = (event) => {
|
|
if (!draggedMeeting.value) return;
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "move";
|
|
isUnscheduledDragOver.value = true;
|
|
};
|
|
|
|
const handleUnscheduledDragLeave = () => {
|
|
isUnscheduledDragOver.value = false;
|
|
};
|
|
|
|
const handleDropToUnscheduled = async (event) => {
|
|
event.preventDefault();
|
|
|
|
if (!draggedMeeting.value) return;
|
|
|
|
const droppedMeeting = { ...draggedMeeting.value };
|
|
|
|
// Only act when moving a scheduled meeting back to unscheduled
|
|
if (!droppedMeeting.isRescheduling || !droppedMeeting.id) {
|
|
resetDragState();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
loadingStore.setLoading(true);
|
|
|
|
await Api.updateBidMeeting(droppedMeeting.id, {
|
|
status: "Unscheduled",
|
|
start_time: null,
|
|
end_time: null,
|
|
});
|
|
|
|
// Remove from the scheduled list
|
|
meetings.value = meetings.value.filter(
|
|
(meeting) => meeting.name !== droppedMeeting.id && meeting.id !== droppedMeeting.id,
|
|
);
|
|
|
|
// Add to unscheduled list if not already present
|
|
const exists = unscheduledMeetings.value.some((m) => m.name === droppedMeeting.id);
|
|
if (!exists) {
|
|
unscheduledMeetings.value.unshift({
|
|
name: droppedMeeting.id,
|
|
address: getAddressText(droppedMeeting.address),
|
|
notes: droppedMeeting.notes || "",
|
|
status: "Unscheduled",
|
|
assigned_employee: droppedMeeting.assigned_employee || "",
|
|
projectTemplate: droppedMeeting.projectTemplate,
|
|
});
|
|
}
|
|
|
|
notificationStore.addNotification({
|
|
type: "success",
|
|
title: "Meeting Unscheduled",
|
|
message: "Meeting moved back to the unscheduled list",
|
|
duration: 4000,
|
|
});
|
|
} catch (error) {
|
|
console.error("Error unscheduling meeting:", error);
|
|
notificationStore.addNotification({
|
|
type: "error",
|
|
title: "Error",
|
|
message: "Failed to move meeting to unscheduled",
|
|
duration: 5000,
|
|
});
|
|
} finally {
|
|
loadingStore.setLoading(false);
|
|
resetDragState();
|
|
}
|
|
};
|
|
|
|
const loadUnscheduledMeetings = async () => {
|
|
loadingStore.setLoading(true);
|
|
try {
|
|
const result = await Api.getUnscheduledBidMeetings(companyStore.currentCompany);
|
|
// Ensure we always have an array
|
|
unscheduledMeetings.value = Array.isArray(result) ? result : [];
|
|
console.log("Loaded unscheduled meetings:", unscheduledMeetings.value);
|
|
} catch (error) {
|
|
console.error("Error loading unscheduled meetings:", error);
|
|
unscheduledMeetings.value = [];
|
|
notificationStore.addNotification({
|
|
type: "error",
|
|
title: "Error",
|
|
message: "Failed to load unscheduled meetings",
|
|
duration: 5000,
|
|
});
|
|
} finally {
|
|
loadingStore.setLoading(false);
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status) => {
|
|
const statusColors = {
|
|
Unscheduled: "warning",
|
|
Scheduled: "info",
|
|
Completed: "success",
|
|
Cancelled: "error",
|
|
};
|
|
return statusColors[status] || "default";
|
|
};
|
|
|
|
const loadWeekMeetings = async () => {
|
|
loadingStore.setLoading(true);
|
|
try {
|
|
// Calculate week start and end dates
|
|
const weekStart = new Date(currentWeekStart.value);
|
|
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
|
|
|
|
const weekEnd = new Date(weekStart);
|
|
weekEnd.setDate(weekStart.getDate() + 6);
|
|
|
|
// Format dates as YYYY-MM-DD strings for the API
|
|
const weekStartStr = weekStart.toISOString().split("T")[0];
|
|
const weekEndStr = weekEnd.toISOString().split("T")[0];
|
|
|
|
console.log(`Loading meetings for week: ${weekStartStr} to ${weekEndStr}`);
|
|
|
|
// Initialize as empty array first
|
|
meetings.value = [];
|
|
|
|
// Try to get meetings from API
|
|
try {
|
|
const apiResult = await Api.getWeekBidMeetings(weekStartStr, weekEndStr, companyStore.currentCompany);
|
|
if (Array.isArray(apiResult)) {
|
|
// Transform the API data to match what the calendar expects
|
|
meetings.value = apiResult
|
|
.map((meeting) => {
|
|
// Extract date and time from startTime
|
|
const startDateTime = meeting.startTime
|
|
? new Date(meeting.startTime)
|
|
: null;
|
|
const date = startDateTime
|
|
? startDateTime.toISOString().split("T")[0]
|
|
: null;
|
|
const time = startDateTime
|
|
? `${startDateTime.getHours().toString().padStart(2, "0")}:${startDateTime.getMinutes().toString().padStart(2, "0")}`
|
|
: null;
|
|
|
|
// Return the full meeting object with calendar-specific fields added
|
|
return {
|
|
...meeting, // Keep all original fields
|
|
id: meeting.name,
|
|
date: date,
|
|
scheduledTime: time,
|
|
};
|
|
})
|
|
.filter((meeting) => meeting.date && meeting.scheduledTime); // Only include meetings with valid date/time
|
|
console.log("Transformed meetings:", meetings.value);
|
|
}
|
|
} catch (apiError) {
|
|
console.warn("API call failed, using empty array:", apiError);
|
|
meetings.value = [];
|
|
}
|
|
|
|
// Simulate API response with dummy meetings (commented out for now)
|
|
// meetings.value = [
|
|
// {
|
|
// id: 1,
|
|
// date: weekDays.value[1]?.date, // Monday
|
|
// scheduledTime: "10:00",
|
|
// address: "123 Main St, Anytown, USA",
|
|
// client: "John Doe",
|
|
// duration: 60,
|
|
// notes: "Initial consultation",
|
|
// status: "scheduled",
|
|
// },
|
|
// {
|
|
// id: 2,
|
|
// date: weekDays.value[3]?.date, // Wednesday
|
|
// scheduledTime: "14:30",
|
|
// address: "456 Oak Ave, Somewhere, USA",
|
|
// client: "Jane Smith",
|
|
// duration: 90,
|
|
// notes: "Follow-up meeting",
|
|
// status: "scheduled",
|
|
// },
|
|
// ].filter((meeting) => meeting.date); // Filter out undefined dates
|
|
} catch (error) {
|
|
console.error("Error loading meetings:", error);
|
|
// Ensure meetings is always an array even on error
|
|
meetings.value = [];
|
|
notificationStore.addNotification({
|
|
type: "error",
|
|
title: "Error",
|
|
message: "Failed to load meetings",
|
|
duration: 5000,
|
|
});
|
|
} finally {
|
|
loadingStore.setLoading(false);
|
|
}
|
|
};
|
|
|
|
const navigateToSpecificMeeting = async () => {
|
|
if (queryAddress.value) {
|
|
// TODO: Implement logic to find the meeting by address and navigate to its week
|
|
// For now, simulate finding an existing meeting and show its details
|
|
console.log("Navigating to specific meeting for address:", queryAddress.value);
|
|
|
|
// Simulate finding the meeting and opening its details
|
|
const mockMeeting = {
|
|
id: 999,
|
|
date: weekDays.value[2]?.date, // Tuesday
|
|
scheduledTime: "11:00",
|
|
address: queryAddress.value,
|
|
client: "Existing Client",
|
|
duration: 60,
|
|
notes: "This meeting was found based on the address query parameter",
|
|
status: "scheduled",
|
|
};
|
|
|
|
if (mockMeeting.date) {
|
|
// Add to calendar for display
|
|
meetings.value.push(mockMeeting);
|
|
|
|
// Auto-open the details modal
|
|
setTimeout(() => {
|
|
showMeetingDetails(mockMeeting);
|
|
}, 500);
|
|
}
|
|
}
|
|
};
|
|
|
|
const findAndDisplayMeetingByName = async () => {
|
|
if (!queryMeetingName.value) return;
|
|
|
|
console.log("Searching for meeting:", queryMeetingName.value);
|
|
|
|
// First, search in the unscheduled meetings list
|
|
const unscheduledMeeting = unscheduledMeetings.value.find(
|
|
(m) => m.name === queryMeetingName.value
|
|
);
|
|
|
|
if (unscheduledMeeting) {
|
|
console.log("Found in unscheduled meetings:", unscheduledMeeting);
|
|
// Meeting is unscheduled, just show notification
|
|
notificationStore.addNotification({
|
|
type: "info",
|
|
title: "Unscheduled Meeting",
|
|
message: "This meeting has not been scheduled yet. Drag it to a time slot to schedule it.",
|
|
duration: 6000,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Not in unscheduled list, fetch from API to get schedule details
|
|
try {
|
|
loadingStore.setLoading(true);
|
|
const meetingData = await Api.getBidMeeting(queryMeetingName.value);
|
|
|
|
if (!meetingData) {
|
|
notificationStore.addNotification({
|
|
type: "error",
|
|
title: "Meeting Not Found",
|
|
message: "Could not find the specified meeting.",
|
|
duration: 5000,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Check if meeting is scheduled
|
|
if (!meetingData.startTime) {
|
|
notificationStore.addNotification({
|
|
type: "info",
|
|
title: "Unscheduled Meeting",
|
|
message: "This meeting has not been scheduled yet.",
|
|
duration: 5000,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Parse the start time to get date and time
|
|
const startDateTime = new Date(meetingData.startTime);
|
|
const meetingDate = startDateTime.toISOString().split("T")[0];
|
|
const meetingTime = `${startDateTime.getHours().toString().padStart(2, "0")}:${startDateTime.getMinutes().toString().padStart(2, "0")}`;
|
|
|
|
// Navigate to the week containing this meeting
|
|
currentWeekStart.value = new Date(
|
|
startDateTime.getFullYear(),
|
|
startDateTime.getMonth(),
|
|
startDateTime.getDate()
|
|
);
|
|
|
|
// Reload meetings for this week
|
|
await loadWeekMeetings();
|
|
|
|
// Find the meeting in the loaded meetings
|
|
const scheduledMeeting = meetings.value.find(
|
|
(m) => m.name === queryMeetingName.value
|
|
);
|
|
|
|
if (scheduledMeeting) {
|
|
// Auto-open the meeting details modal
|
|
setTimeout(() => {
|
|
showMeetingDetails(scheduledMeeting);
|
|
}, 300);
|
|
} else {
|
|
notificationStore.addNotification({
|
|
type: "warning",
|
|
title: "Meeting Found",
|
|
message: `Meeting is scheduled for ${formatDate(meetingDate)} at ${formatTimeDisplay(meetingTime)}`,
|
|
duration: 6000,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching meeting:", error);
|
|
notificationStore.addNotification({
|
|
type: "error",
|
|
title: "Error",
|
|
message: "Failed to load meeting details.",
|
|
duration: 5000,
|
|
});
|
|
} finally {
|
|
loadingStore.setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Lifecycle
|
|
onMounted(async () => {
|
|
initializeWeek();
|
|
await loadUnscheduledMeetings();
|
|
await loadWeekMeetings();
|
|
|
|
// Handle different modes based on query params
|
|
if (isNewMode.value) {
|
|
// New mode - always show create modal (with or without pre-filled address)
|
|
setTimeout(() => {
|
|
openNewMeetingModal();
|
|
}, 500);
|
|
} else if (queryMeetingName.value) {
|
|
// Find and display specific meeting by name
|
|
await findAndDisplayMeetingByName();
|
|
} else if (queryAddress.value) {
|
|
// View mode with address - find and show existing meeting details
|
|
await navigateToSpecificMeeting();
|
|
}
|
|
});
|
|
|
|
// Watch for week changes to reload data
|
|
watch(currentWeekStart, () => {
|
|
loadWeekMeetings();
|
|
});
|
|
|
|
// Watch for company changes
|
|
watch(
|
|
() => companyStore.currentCompany,
|
|
async () => {
|
|
await loadWeekMeetings();
|
|
await loadUnscheduledMeetings();
|
|
}
|
|
);
|
|
|
|
watch(
|
|
() => route.query.new,
|
|
(newVal) => {
|
|
if (newVal === "true") {
|
|
openNewMeetingModal();
|
|
}
|
|
},
|
|
);
|
|
</script>
|
|
|
|
<style scoped>
|
|
.schedule-bid-container {
|
|
padding: 20px;
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
padding-bottom: 15px;
|
|
border-bottom: 1px solid #e0e0e0;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.header-controls {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
}
|
|
|
|
.week-display-btn {
|
|
font-weight: 600;
|
|
font-size: 1.1em;
|
|
color: #1976d2 !important;
|
|
text-transform: none;
|
|
letter-spacing: normal;
|
|
min-width: 280px;
|
|
max-width: 280px;
|
|
}
|
|
|
|
.week-display-btn:hover {
|
|
background-color: rgba(25, 118, 210, 0.08);
|
|
}
|
|
|
|
.date-text {
|
|
display: inline-block;
|
|
text-align: center;
|
|
width: 100%;
|
|
}
|
|
|
|
.main-layout {
|
|
display: flex;
|
|
gap: 20px;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.sidebar {
|
|
width: 280px;
|
|
padding: 0 16px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
transition: width 0.3s ease;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.sidebar.collapsed {
|
|
width: 60px;
|
|
min-width: 60px;
|
|
}
|
|
|
|
.sidebar-right {
|
|
border-left: 1px solid #e0e0e0;
|
|
padding-left: 16px;
|
|
}
|
|
|
|
.sidebar-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.sidebar-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex: 1;
|
|
}
|
|
|
|
.sidebar-header h4 {
|
|
font-size: 1.1em;
|
|
margin: 0;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.unscheduled-meetings-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
padding: 8px 0;
|
|
}
|
|
|
|
.unscheduled-meetings-list.drag-over-unscheduled {
|
|
border: 2px dashed #4caf50;
|
|
border-radius: 6px;
|
|
background-color: #f0fff3;
|
|
}
|
|
|
|
.empty-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 40px 20px;
|
|
text-align: center;
|
|
color: #999;
|
|
}
|
|
|
|
.empty-state p {
|
|
margin-top: 12px;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.pending-meeting {
|
|
flex: 1;
|
|
}
|
|
|
|
.meeting-card {
|
|
border-left: 3px solid #2196f3;
|
|
cursor: grab;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.meeting-card:hover {
|
|
transform: translateX(3px);
|
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
|
}
|
|
|
|
.meeting-card:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.meeting-title {
|
|
font-weight: 600;
|
|
color: #1976d2;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.meeting-address {
|
|
font-size: 0.9em;
|
|
color: #666;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.meeting-duration {
|
|
font-size: 0.8em;
|
|
color: #999;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.meeting-status {
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.meeting-notes {
|
|
font-size: 0.85em;
|
|
color: #666;
|
|
margin-top: 4px;
|
|
font-style: italic;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
}
|
|
|
|
.meeting-employee {
|
|
font-size: 0.8em;
|
|
color: #999;
|
|
margin-top: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.unscheduled-address,
|
|
.unscheduled-contact,
|
|
.unscheduled-project {
|
|
font-size: 0.8em;
|
|
color: #666;
|
|
margin-bottom: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.unscheduled-address {
|
|
font-weight: 600;
|
|
color: #1976d2;
|
|
}
|
|
|
|
.unscheduled-status {
|
|
margin-top: 6px;
|
|
display: flex;
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.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);
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.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: 0.8em;
|
|
color: #666;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.meetings-count {
|
|
font-size: 0.7em;
|
|
color: #999;
|
|
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.weekend {
|
|
background-color: #f9f9f9;
|
|
}
|
|
|
|
.day-column.past-time {
|
|
background-color: #fafafa;
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.day-column.past-time:hover {
|
|
background-color: #fafafa;
|
|
}
|
|
|
|
.meeting-event {
|
|
position: absolute;
|
|
left: 2px;
|
|
right: 2px;
|
|
top: 2px;
|
|
bottom: 2px;
|
|
color: white;
|
|
border-radius: 4px;
|
|
padding: 4px 6px;
|
|
font-size: 0.7em;
|
|
cursor: grab;
|
|
overflow: hidden;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
|
transition: all 0.2s;
|
|
z-index: 10;
|
|
}
|
|
|
|
.meeting-event:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.meeting-event.meeting-scheduled {
|
|
background: linear-gradient(135deg, #2196f3, #1976d2);
|
|
}
|
|
|
|
.meeting-event.meeting-completed {
|
|
background: linear-gradient(135deg, #4caf50, #388e3c);
|
|
}
|
|
|
|
.meeting-event.meeting-overdue {
|
|
background: linear-gradient(135deg, #f44336, #d32f2f);
|
|
}
|
|
|
|
.meeting-event.meeting-completed-locked {
|
|
cursor: default !important;
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.meeting-event.meeting-completed-locked:active {
|
|
cursor: default !important;
|
|
}
|
|
|
|
.meeting-event:hover {
|
|
transform: scale(1.02);
|
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.meeting-event.meeting-completed-locked:hover {
|
|
transform: none;
|
|
}
|
|
|
|
.event-time {
|
|
font-weight: 600;
|
|
font-size: 0.8em;
|
|
margin-bottom: 1px;
|
|
}
|
|
|
|
.event-address {
|
|
font-size: 0.65em;
|
|
opacity: 0.9;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
margin-bottom: 1px;
|
|
}
|
|
|
|
.event-client {
|
|
font-size: 0.6em;
|
|
opacity: 0.8;
|
|
font-style: italic;
|
|
}
|
|
|
|
.meeting-details {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.detail-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.new-meeting-form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.form-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
|
|
.form-group label {
|
|
font-weight: 500;
|
|
color: #333;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
/* Responsive design */
|
|
@media (max-width: 768px) {
|
|
.main-layout {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.sidebar {
|
|
width: 100%;
|
|
border-right: none;
|
|
border-bottom: 1px solid #e0e0e0;
|
|
padding-right: 0;
|
|
padding-bottom: 20px;
|
|
max-height: 200px;
|
|
}
|
|
|
|
.calendar-header-row,
|
|
.time-row {
|
|
grid-template-columns: 60px repeat(7, 1fr);
|
|
}
|
|
}
|
|
</style>
|