1654 lines
No EOL
44 KiB
Vue
1654 lines
No EOL
44 KiB
Vue
<template>
|
|
<div class="calendar-container">
|
|
<div class="calendar-header">
|
|
<h2>Weekly Schedule - {{ companyStore.currentCompany }}</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="goToThisWeek" variant="outlined" size="small" class="ml-4"
|
|
>This Week</v-btn
|
|
>
|
|
<v-menu
|
|
v-model="showTemplateMenu"
|
|
:close-on-content-click="false"
|
|
location="bottom"
|
|
>
|
|
<template v-slot:activator="{ props }">
|
|
<v-btn
|
|
v-bind="props"
|
|
variant="outlined"
|
|
size="small"
|
|
class="ml-4"
|
|
>
|
|
<v-icon left size="small">mdi-file-document-multiple</v-icon>
|
|
Project Template ({{ selectedProjectTemplates.length }})
|
|
<v-icon right size="small">mdi-chevron-down</v-icon>
|
|
</v-btn>
|
|
</template>
|
|
<v-card min-width="300" max-width="400">
|
|
<v-card-title class="text-subtitle-1 py-2">
|
|
Select Project Templates
|
|
</v-card-title>
|
|
<v-divider></v-divider>
|
|
<v-card-text class="pa-2">
|
|
<v-list density="compact">
|
|
<v-list-item @click="toggleAllTemplates">
|
|
<template v-slot:prepend>
|
|
<v-checkbox-btn
|
|
:model-value="selectedProjectTemplates.length === projectTemplates.length"
|
|
:indeterminate="selectedProjectTemplates.length > 0 && selectedProjectTemplates.length < projectTemplates.length"
|
|
></v-checkbox-btn>
|
|
</template>
|
|
<v-list-item-title>Select All</v-list-item-title>
|
|
</v-list-item>
|
|
<v-divider></v-divider>
|
|
<v-list-item
|
|
v-for="template in projectTemplates"
|
|
:key="template.name"
|
|
@click="toggleTemplate(template.name)"
|
|
>
|
|
<template v-slot:prepend>
|
|
<v-checkbox-btn
|
|
:model-value="selectedProjectTemplates.includes(template.name)"
|
|
></v-checkbox-btn>
|
|
</template>
|
|
<v-list-item-title>{{ template.name }}</v-list-item-title>
|
|
</v-list-item>
|
|
</v-list>
|
|
</v-card-text>
|
|
<v-card-actions>
|
|
<v-spacer></v-spacer>
|
|
<v-btn
|
|
color="primary"
|
|
variant="flat"
|
|
size="small"
|
|
@click="applyTemplateFilter"
|
|
>
|
|
Apply
|
|
</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-menu>
|
|
<v-menu
|
|
v-model="showForemenMenu"
|
|
:close-on-content-click="false"
|
|
location="bottom"
|
|
>
|
|
<template v-slot:activator="{ props }">
|
|
<v-btn
|
|
v-bind="props"
|
|
variant="outlined"
|
|
size="small"
|
|
class="ml-4"
|
|
>
|
|
<v-icon left size="small">mdi-account-group</v-icon>
|
|
Crews ({{ selectedForemen.length }})
|
|
<v-icon right size="small">mdi-chevron-down</v-icon>
|
|
</v-btn>
|
|
</template>
|
|
<v-card min-width="250" max-width="300">
|
|
<v-card-title class="text-subtitle-1 py-2">
|
|
Select Crews
|
|
</v-card-title>
|
|
<v-divider></v-divider>
|
|
<v-card-text class="pa-2">
|
|
<v-list density="compact">
|
|
<v-list-item @click="toggleAllForemen">
|
|
<template v-slot:prepend>
|
|
<v-checkbox-btn
|
|
:model-value="selectedForemen.length === foremen.length"
|
|
:indeterminate="selectedForemen.length > 0 && selectedForemen.length < foremen.length"
|
|
></v-checkbox-btn>
|
|
</template>
|
|
<v-list-item-title>Select All</v-list-item-title>
|
|
</v-list-item>
|
|
<v-divider></v-divider>
|
|
<v-list-item
|
|
v-for="foreman in foremen"
|
|
:key="foreman.name"
|
|
@click="toggleForeman(foreman.customCrew ? parseInt(foreman.customCrew) : foreman.name)"
|
|
>
|
|
<template v-slot:prepend>
|
|
<v-checkbox-btn
|
|
:model-value="selectedForemen.includes(foreman.customCrew ? parseInt(foreman.customCrew) : foreman.name)"
|
|
></v-checkbox-btn>
|
|
</template>
|
|
<v-list-item-title>{{ foreman.employeeName }}{{ foreman.customCrew ? ` (Crew ${foreman.customCrew})` : '' }}</v-list-item-title>
|
|
</v-list-item>
|
|
</v-list>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-menu>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Date Picker Dialog -->
|
|
<v-dialog v-model="showDatePicker" max-width="400px">
|
|
<v-card>
|
|
<v-card-title>Select Week</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>
|
|
|
|
<div class="calendar-main">
|
|
<!-- Weekly Calendar Grid -->
|
|
<div class="calendar-section">
|
|
<div class="weekly-calendar">
|
|
<!-- Days Header -->
|
|
<div class="calendar-header-row" :style="{ gridTemplateColumns: `150px repeat(7, 1fr)` }">
|
|
<div class="crew-column-header">Crew</div>
|
|
<div
|
|
v-for="day in weekDays"
|
|
:key="day.date"
|
|
class="day-header"
|
|
:class="{ 'today': isToday(day.date) }"
|
|
>
|
|
<div class="day-name">{{ day.dayName }}</div>
|
|
<div class="day-date">{{ day.displayDate }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Foremen Grid -->
|
|
<div class="calendar-grid">
|
|
<div v-for="foreman in visibleForemen" :key="foreman.name" class="foreman-row" :style="{ gridTemplateColumns: `150px repeat(7, 1fr)` }">
|
|
<!-- Foreman/Crew Column -->
|
|
<div class="crew-column">
|
|
<div class="crew-name">{{ foreman.employeeName }}</div>
|
|
<div class="crew-number">{{ foreman.customCrew ? `Crew ${foreman.customCrew}` : 'No Crew #' }}</div>
|
|
</div>
|
|
|
|
<!-- Day Columns -->
|
|
<div
|
|
v-for="day in weekDays"
|
|
:key="`${foreman.name}-${day.date}`"
|
|
class="day-cell"
|
|
:class="{
|
|
'today': isToday(day.date),
|
|
'holiday': isHoliday(day.date),
|
|
'sunday': isSunday(day.date),
|
|
'drag-over': isDragOver && dragOverCell?.foremanId === foreman.name && dragOverCell?.date === day.date,
|
|
}"
|
|
@dragover="handleDragOver($event, foreman.name, day.date)"
|
|
@dragleave="handleDragLeave"
|
|
@drop="handleDrop($event, foreman.name, day.date)"
|
|
>
|
|
<!-- Jobs in this day -->
|
|
<div
|
|
v-for="job in getJobsForCell(foreman.name, day.date)"
|
|
:key="job.id"
|
|
class="calendar-job"
|
|
:class="getPriorityClass(job.priority)"
|
|
:style="getJobStyle(job, day.date)"
|
|
draggable="true"
|
|
@click.stop="showEventDetails({ event: job })"
|
|
@dragstart="handleDragStart(job, $event)"
|
|
@dragend="handleDragEnd"
|
|
@mousedown="startResize($event, job, day.date)"
|
|
>
|
|
<div class="job-content">
|
|
<div class="job-title">{{ job.serviceType }}</div>
|
|
<div class="job-customer">{{ job.customer }}</div>
|
|
</div>
|
|
<v-icon v-if="jobSpansToNextWeek(job)" size="small" class="spans-arrow">mdi-arrow-right</v-icon>
|
|
<div
|
|
class="resize-handle"
|
|
@mousedown.stop="startResize($event, job, day.date)"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Unscheduled Services Column -->
|
|
<div class="unscheduled-section">
|
|
<div class="unscheduled-header">
|
|
<h4>Unscheduled Jobs</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 installs 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>Crew:</strong> {{ selectedEvent.foreman || "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>
|
|
|
|
<!-- Tooltip -->
|
|
<div v-if="tooltipVisible" class="tooltip" :style="{ left: tooltipPosition.x + 10 + 'px', top: tooltipPosition.y - 30 + 'px' }">
|
|
{{ tooltip }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, computed, watch } from "vue";
|
|
import Api from "../../../api";
|
|
import { useNotificationStore } from "../../../stores/notifications-primevue";
|
|
import { useCompanyStore } from "../../../stores/company";
|
|
|
|
const notifications = useNotificationStore();
|
|
const companyStore = useCompanyStore();
|
|
|
|
// Reactive data
|
|
const services = ref([]);
|
|
const weekStartDate = ref(getWeekStart(new Date()));
|
|
const eventDialog = ref(false);
|
|
const selectedEvent = ref(null);
|
|
const projectTemplates = ref([]);
|
|
const holidays = ref([]);
|
|
|
|
// Drag and drop state
|
|
const draggedService = ref(null);
|
|
const isDragOver = ref(false);
|
|
const dragOverCell = ref(null);
|
|
|
|
// Tooltip state
|
|
const tooltip = ref('');
|
|
const tooltipVisible = ref(false);
|
|
const tooltipPosition = ref({ x: 0, y: 0 });
|
|
|
|
// Resize state
|
|
const resizingJob = ref(null);
|
|
const resizeStartX = ref(0);
|
|
const resizeStartDate = ref(null);
|
|
const originalEndDate = ref(null);
|
|
const justFinishedResize = ref(false);
|
|
|
|
// Foremen data (Crews)
|
|
const foremen = ref([]);
|
|
|
|
// Foremen selection
|
|
const selectedForemen = ref([]); // Will be populated on fetch
|
|
const showForemenMenu = ref(false);
|
|
|
|
// Date picker
|
|
const showDatePicker = ref(false);
|
|
const selectedDate = ref(null);
|
|
|
|
// Project template filter
|
|
const selectedProjectTemplates = ref([]);
|
|
const showTemplateMenu = ref(false);
|
|
|
|
// Helper function to get local date string (YYYY-MM-DD)
|
|
function toLocalDateString(date) {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
// Helper function to parse local date string into Date object
|
|
function parseLocalDate(dateStr) {
|
|
const [year, month, day] = dateStr.split('-').map(Number);
|
|
return new Date(year, month - 1, day);
|
|
}
|
|
|
|
// Helper function to get the start of the week (Monday)
|
|
function getWeekStart(date) {
|
|
const d = new Date(date);
|
|
// Get the day of week (0 = Sunday, 1 = Monday, ..., 6 = Saturday)
|
|
const dayOfWeek = d.getDay();
|
|
|
|
// Calculate days to subtract to get to Monday
|
|
// Monday (1): subtract 0 days
|
|
// Tuesday (2): subtract 1 day
|
|
// ...
|
|
// Sunday (0): subtract 6 days
|
|
const daysToSubtract = (dayOfWeek - 1 + 7) % 7;
|
|
|
|
// Create new date and subtract the days
|
|
const monday = new Date(d);
|
|
monday.setDate(d.getDate() - daysToSubtract);
|
|
|
|
// Return as YYYY-MM-DD string using local date
|
|
return toLocalDateString(monday);
|
|
}
|
|
|
|
// Helper function to format date
|
|
function formatDate(dateStr) {
|
|
const date = parseLocalDate(dateStr);
|
|
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
}
|
|
|
|
// Helper function to get day name
|
|
function getDayName(dateStr) {
|
|
const date = parseLocalDate(dateStr);
|
|
return date.toLocaleDateString("en-US", { weekday: "short" });
|
|
}
|
|
|
|
// Helper function to check if date is today
|
|
function isToday(dateStr) {
|
|
const today = new Date();
|
|
const year = today.getFullYear();
|
|
const month = String(today.getMonth() + 1).padStart(2, '0');
|
|
const day = String(today.getDate()).padStart(2, '0');
|
|
const todayStr = `${year}-${month}-${day}`;
|
|
return dateStr === todayStr;
|
|
}
|
|
|
|
// Helper function to add days to a date
|
|
function addDays(dateStr, days) {
|
|
const date = parseLocalDate(dateStr);
|
|
date.setDate(date.getDate() + days);
|
|
return toLocalDateString(date);
|
|
}
|
|
|
|
// Helper function to calculate days between two dates
|
|
function daysBetween(startDate, endDate) {
|
|
const start = parseLocalDate(startDate);
|
|
const end = parseLocalDate(endDate);
|
|
const diffTime = end - start;
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
return diffDays;
|
|
}
|
|
|
|
// Helper function to check if date is a holiday
|
|
function isHoliday(dateStr) {
|
|
return holidays.value.some(holiday => holiday.date === dateStr);
|
|
}
|
|
|
|
// Helper function to check if date is Sunday
|
|
function isSunday(dateStr) {
|
|
const date = parseLocalDate(dateStr);
|
|
return date.getDay() === 0;
|
|
}
|
|
|
|
// Helper function to get next Monday from a date
|
|
function getNextMonday(dateStr) {
|
|
const date = parseLocalDate(dateStr);
|
|
const day = date.getDay();
|
|
const daysToMonday = day === 1 ? 7 : (8 - day) % 7;
|
|
date.setDate(date.getDate() + daysToMonday);
|
|
return toLocalDateString(date);
|
|
}
|
|
|
|
// Helper function to check if any day in range is a holiday
|
|
function hasHolidayInRange(startDate, endDate) {
|
|
const start = parseLocalDate(startDate);
|
|
const end = parseLocalDate(endDate);
|
|
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
|
const dateStr = toLocalDateString(d);
|
|
if (isHoliday(dateStr)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Helper function to check if job spans to next week
|
|
function jobSpansToNextWeek(job) {
|
|
const endDate = job.scheduledEndDate || job.scheduledDate;
|
|
const end = parseLocalDate(endDate);
|
|
const weekEnd = parseLocalDate(addDays(weekStartDate.value, 6));
|
|
return end > weekEnd;
|
|
}
|
|
|
|
// Computed properties
|
|
const weekDisplayText = computed(() => {
|
|
const startDate = parseLocalDate(weekStartDate.value);
|
|
const endDate = parseLocalDate(weekStartDate.value);
|
|
endDate.setDate(endDate.getDate() + 6);
|
|
|
|
return `${startDate.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${endDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`;
|
|
});
|
|
|
|
const weekDays = computed(() => {
|
|
const days = [];
|
|
for (let i = 0; i < 7; i++) {
|
|
const date = addDays(weekStartDate.value, i);
|
|
days.push({
|
|
date,
|
|
dayName: getDayName(date),
|
|
displayDate: formatDate(date),
|
|
});
|
|
}
|
|
return days;
|
|
});
|
|
|
|
const visibleForemen = computed(() => {
|
|
return foremen.value.filter(foreman => {
|
|
const crewNum = foreman.customCrew ? parseInt(foreman.customCrew) : foreman.name;
|
|
return selectedForemen.value.includes(crewNum);
|
|
});
|
|
});
|
|
|
|
const scheduledServices = computed(() =>
|
|
services.value.filter((service) => service.isScheduled && service.foreman),
|
|
);
|
|
|
|
const unscheduledServices = computed(() =>
|
|
services.value.filter((service) => !service.isScheduled || !service.foreman),
|
|
);
|
|
|
|
// 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`;
|
|
};
|
|
|
|
// Get jobs for a specific foreman and date
|
|
const getJobsForCell = (foremanId, date) => {
|
|
// Don't render jobs on Sunday
|
|
if (isSunday(date)) return [];
|
|
|
|
return scheduledServices.value.filter((job) => {
|
|
if (job.foreman !== foremanId) return false;
|
|
|
|
const jobStart = job.scheduledDate;
|
|
const jobEnd = job.scheduledEndDate || job.scheduledDate;
|
|
|
|
// Check if this date falls within the job's date range
|
|
return date >= jobStart && date <= jobEnd;
|
|
});
|
|
};
|
|
|
|
// Get job style for width spanning multiple days
|
|
const getJobStyle = (job, currentDate) => {
|
|
const jobStart = job.scheduledDate;
|
|
const jobEnd = job.scheduledEndDate || job.scheduledDate;
|
|
|
|
// Calculate how many days this job spans
|
|
const duration = daysBetween(jobStart, jobEnd) + 1;
|
|
|
|
// Only render the job on its starting day
|
|
if (currentDate !== jobStart) {
|
|
return { display: 'none' };
|
|
}
|
|
|
|
// For multi-day jobs, calculate width to span cells
|
|
// Each additional day adds 100% + 1px for the border
|
|
const widthCalc = duration === 1
|
|
? 'calc(100% - 8px)' // Single day: full width minus padding
|
|
: `calc(${duration * 100}% + ${(duration - 1)}px)`; // Multi-day: span cells accounting for borders
|
|
|
|
return {
|
|
width: widthCalc,
|
|
zIndex: 10,
|
|
};
|
|
};
|
|
|
|
// Calendar navigation methods
|
|
const previousWeek = () => {
|
|
const date = parseLocalDate(weekStartDate.value);
|
|
date.setDate(date.getDate() - 7);
|
|
weekStartDate.value = toLocalDateString(date);
|
|
};
|
|
|
|
const nextWeek = () => {
|
|
const date = parseLocalDate(weekStartDate.value);
|
|
date.setDate(date.getDate() + 7);
|
|
weekStartDate.value = toLocalDateString(date);
|
|
};
|
|
|
|
const goToThisWeek = () => {
|
|
weekStartDate.value = getWeekStart(new Date());
|
|
};
|
|
|
|
// Foremen selection methods
|
|
const toggleAllForemen = () => {
|
|
if (selectedForemen.value.length === foremen.value.length) {
|
|
// Deselect all
|
|
selectedForemen.value = [];
|
|
} else {
|
|
// Select all
|
|
selectedForemen.value = foremen.value.map(f => {
|
|
if (f.customCrew && f.customCrew.trim() !== '') {
|
|
return parseInt(f.customCrew);
|
|
}
|
|
return f.name;
|
|
});
|
|
}
|
|
};
|
|
|
|
const toggleForeman = (crewNumber) => {
|
|
const index = selectedForemen.value.indexOf(crewNumber);
|
|
if (index > -1) {
|
|
selectedForemen.value.splice(index, 1);
|
|
} else {
|
|
selectedForemen.value.push(crewNumber);
|
|
}
|
|
};
|
|
|
|
// Project template selection methods
|
|
const toggleAllTemplates = () => {
|
|
if (selectedProjectTemplates.value.length === projectTemplates.value.length) {
|
|
// Deselect all
|
|
selectedProjectTemplates.value = [];
|
|
} else {
|
|
// Select all
|
|
selectedProjectTemplates.value = projectTemplates.value.map(t => t.name);
|
|
}
|
|
};
|
|
|
|
const toggleTemplate = (templateName) => {
|
|
const index = selectedProjectTemplates.value.indexOf(templateName);
|
|
if (index > -1) {
|
|
selectedProjectTemplates.value.splice(index, 1);
|
|
} else {
|
|
selectedProjectTemplates.value.push(templateName);
|
|
}
|
|
};
|
|
|
|
const applyTemplateFilter = async () => {
|
|
showTemplateMenu.value = false;
|
|
await fetchProjects(currentDate.value);
|
|
};
|
|
|
|
// Date picker methods
|
|
const onDateSelected = (date) => {
|
|
if (date) {
|
|
const dateObj = Array.isArray(date) ? date[0] : date;
|
|
weekStartDate.value = getWeekStart(dateObj);
|
|
showDatePicker.value = false;
|
|
}
|
|
};
|
|
|
|
const showEventDetails = (event) => {
|
|
// Don't open modal if we just finished resizing
|
|
if (justFinishedResize.value) {
|
|
justFinishedResize.value = false;
|
|
return;
|
|
}
|
|
selectedEvent.value = event.event;
|
|
eventDialog.value = true;
|
|
};
|
|
|
|
const scheduleService = (service) => {
|
|
// Placeholder for future drag-and-drop functionality
|
|
console.log("Scheduling service:", service);
|
|
alert(
|
|
`Drag and drop the job onto a day/crew cell to schedule it.`,
|
|
);
|
|
};
|
|
|
|
// 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;
|
|
|
|
try {
|
|
event.dataTransfer.setDragImage(dragElement, offsetX, offsetY);
|
|
} catch (e) {
|
|
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;
|
|
dragOverCell.value = null;
|
|
tooltipVisible.value = false;
|
|
};
|
|
|
|
const handleDragOver = (event, foremanId, date) => {
|
|
// Prevent dropping on Sunday
|
|
if (isSunday(date)) {
|
|
event.dataTransfer.dropEffect = "none";
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
if (!draggedService.value) return;
|
|
|
|
event.dataTransfer.dropEffect = "move";
|
|
|
|
// Only update state if it's actually different to prevent flickering
|
|
const currentCellKey = `${foremanId}-${date}`;
|
|
const previousCellKey = dragOverCell.value ? `${dragOverCell.value.foremanId}-${dragOverCell.value.date}` : null;
|
|
|
|
if (currentCellKey !== previousCellKey) {
|
|
isDragOver.value = true;
|
|
dragOverCell.value = { foremanId, date };
|
|
}
|
|
};
|
|
|
|
const handleDragLeave = (event) => {
|
|
// Use a small delay to prevent flickering when moving between child elements
|
|
setTimeout(() => {
|
|
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;
|
|
dragOverCell.value = null;
|
|
tooltipVisible.value = false;
|
|
}
|
|
}, 10);
|
|
};
|
|
|
|
const handleDrop = async (event, foremanId, date) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
if (!draggedService.value) return;
|
|
|
|
// Prevent dropping on Sunday
|
|
if (isSunday(date)) {
|
|
notifications.addError("Cannot schedule jobs on Sunday. Please select a weekday.");
|
|
isDragOver.value = false;
|
|
dragOverCell.value = null;
|
|
draggedService.value = null;
|
|
return;
|
|
}
|
|
|
|
// Get foreman details
|
|
const foreman = foremen.value.find(f => f.name === foremanId);
|
|
if (!foreman) return;
|
|
|
|
// Default to single day
|
|
let endDate = date;
|
|
|
|
// Check for holidays in the range
|
|
if (hasHolidayInRange(date, endDate)) {
|
|
notifications.addError("Cannot schedule job on a holiday. Please select different dates.");
|
|
// Reset drag state
|
|
isDragOver.value = false;
|
|
dragOverCell.value = null;
|
|
draggedService.value = null;
|
|
tooltipVisible.value = false;
|
|
return;
|
|
}
|
|
|
|
// 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].isScheduled;
|
|
|
|
services.value[serviceIndex] = {
|
|
...services.value[serviceIndex],
|
|
isScheduled: true,
|
|
scheduledDate: date,
|
|
scheduledEndDate: endDate,
|
|
foreman: foreman.name
|
|
};
|
|
|
|
const action = wasScheduled ? 'Moved' : 'Scheduled';
|
|
console.log(`${action} ${draggedService.value.serviceType} for ${foreman.employeeName} on ${date} to ${endDate}`);
|
|
|
|
// Call API to persist changes (placeholder for now)
|
|
try {
|
|
notifications.addWarning("API update feature coming soon!");
|
|
// Future implementation:
|
|
// await Api.updateJobSchedule({
|
|
// id: draggedService.value.id,
|
|
// expectedStartDate: date,
|
|
// expectedEndDate: endDate,
|
|
// isScheduled: true,
|
|
// customForeman: foreman.name
|
|
// });
|
|
} catch (error) {
|
|
console.error("Error scheduling job:", error);
|
|
notifications.addError("Failed to schedule job");
|
|
}
|
|
}
|
|
|
|
// Reset drag state
|
|
isDragOver.value = false;
|
|
dragOverCell.value = null;
|
|
draggedService.value = null;
|
|
tooltipVisible.value = false;
|
|
};
|
|
|
|
// Handle dropping scheduled items back to unscheduled
|
|
const handleUnscheduledDragOver = (event) => {
|
|
if (draggedService.value?.isScheduled) {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "move";
|
|
}
|
|
};
|
|
|
|
const handleUnscheduledDragLeave = (event) => {
|
|
// Visual feedback will be handled by CSS
|
|
};
|
|
|
|
const handleUnscheduledDrop = async (event) => {
|
|
event.preventDefault();
|
|
|
|
if (!draggedService.value || !draggedService.value.isScheduled) 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],
|
|
isScheduled: false,
|
|
scheduledDate: null,
|
|
scheduledEndDate: null,
|
|
foreman: null
|
|
};
|
|
|
|
console.log(`Unscheduled ${draggedService.value.serviceType}`);
|
|
|
|
// Call API to persist changes (placeholder for now)
|
|
try {
|
|
notifications.addWarning("API update feature coming soon!");
|
|
// Future implementation:
|
|
// await Api.updateJobSchedule({
|
|
// id: draggedService.value.id,
|
|
// expectedStartDate: null,
|
|
// expectedEndDate: null,
|
|
// isScheduled: false,
|
|
// customForeman: null
|
|
// });
|
|
} catch (error) {
|
|
console.error("Error unscheduling job:", error);
|
|
notifications.addError("Failed to unschedule job");
|
|
}
|
|
}
|
|
|
|
// Reset drag state
|
|
isDragOver.value = false;
|
|
dragOverCell.value = null;
|
|
draggedService.value = null;
|
|
};
|
|
|
|
// Resize functionality
|
|
const startResize = (event, job, date) => {
|
|
// Only allow resize from the right edge
|
|
const target = event.target;
|
|
const rect = target.getBoundingClientRect();
|
|
const clickX = event.clientX;
|
|
|
|
// Check if click is on the resize handle or near right edge
|
|
const isNearRightEdge = clickX > rect.right - 10;
|
|
const isResizeHandle = target.classList.contains('resize-handle');
|
|
|
|
if (!isNearRightEdge && !isResizeHandle) return;
|
|
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
|
|
// Set flag immediately to prevent modal from opening
|
|
justFinishedResize.value = true;
|
|
|
|
resizingJob.value = job;
|
|
resizeStartX.value = event.clientX;
|
|
resizeStartDate.value = date;
|
|
originalEndDate.value = job.scheduledEndDate || job.scheduledDate;
|
|
|
|
// Add global mouse move and mouse up listeners
|
|
document.addEventListener('mousemove', handleResize);
|
|
document.addEventListener('mouseup', stopResize);
|
|
|
|
// Add visual feedback
|
|
document.body.style.cursor = 'ew-resize';
|
|
};
|
|
|
|
const handleResize = (event) => {
|
|
if (!resizingJob.value) return;
|
|
|
|
const deltaX = event.clientX - resizeStartX.value;
|
|
|
|
// Get actual cell width from DOM for accurate calculation
|
|
const dayCell = document.querySelector('.day-cell');
|
|
if (!dayCell) return;
|
|
|
|
const cellWidth = dayCell.getBoundingClientRect().width;
|
|
|
|
// Calculate how many days to extend with better snapping (0.4 threshold instead of 0.5)
|
|
const daysToAdd = Math.floor(deltaX / cellWidth + 0.4);
|
|
|
|
// Calculate new end date based on current job duration + adjustment
|
|
const jobStartDate = resizingJob.value.scheduledDate;
|
|
const currentEndDate = originalEndDate.value || jobStartDate;
|
|
const currentDuration = daysBetween(jobStartDate, currentEndDate); // Days from start to current end
|
|
const totalDays = currentDuration + daysToAdd;
|
|
|
|
// Minimum is same day (0 days difference)
|
|
if (totalDays >= 0) {
|
|
let newEndDate = addDays(jobStartDate, totalDays);
|
|
let extendsOverSunday = false;
|
|
|
|
// Check if the new end date or any date in between is Sunday
|
|
for (let i = 0; i <= totalDays; i++) {
|
|
const checkDate = addDays(jobStartDate, i);
|
|
if (isSunday(checkDate)) {
|
|
extendsOverSunday = true;
|
|
// Extend to next Monday
|
|
newEndDate = getNextMonday(checkDate);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Show tooltip if extending over Sunday
|
|
if (extendsOverSunday) {
|
|
tooltip.value = 'Extend to next Monday';
|
|
tooltipVisible.value = true;
|
|
tooltipPosition.value = { x: event.clientX, y: event.clientY };
|
|
} else {
|
|
tooltipVisible.value = false;
|
|
}
|
|
|
|
const serviceIndex = services.value.findIndex(s => s.id === resizingJob.value.id);
|
|
if (serviceIndex !== -1) {
|
|
services.value[serviceIndex] = {
|
|
...services.value[serviceIndex],
|
|
scheduledEndDate: newEndDate
|
|
};
|
|
}
|
|
}
|
|
};
|
|
|
|
const stopResize = async () => {
|
|
if (!resizingJob.value) return;
|
|
|
|
// Hide tooltip
|
|
tooltipVisible.value = false;
|
|
|
|
const job = resizingJob.value;
|
|
const newEndDate = job.scheduledEndDate || job.scheduledDate;
|
|
|
|
// Check for holidays in the range (excluding the extended-to-Monday case)
|
|
const jobStartDate = job.scheduledDate;
|
|
const weekEnd = addDays(weekStartDate.value, 6); // Saturday
|
|
|
|
// Only check for holidays in the current week range
|
|
const endDateToCheck = parseLocalDate(newEndDate) <= parseLocalDate(weekEnd) ? newEndDate : weekEnd;
|
|
|
|
if (hasHolidayInRange(jobStartDate, endDateToCheck)) {
|
|
notifications.addError("Cannot extend job onto a holiday. Resize cancelled.");
|
|
// Revert the change
|
|
const serviceIndex = services.value.findIndex(s => s.id === job.id);
|
|
if (serviceIndex !== -1) {
|
|
services.value[serviceIndex] = {
|
|
...services.value[serviceIndex],
|
|
scheduledEndDate: originalEndDate.value
|
|
};
|
|
}
|
|
// Clean up
|
|
document.removeEventListener('mousemove', handleResize);
|
|
document.removeEventListener('mouseup', stopResize);
|
|
document.body.style.cursor = 'default';
|
|
setTimeout(() => {
|
|
justFinishedResize.value = false;
|
|
}, 150);
|
|
resizingJob.value = null;
|
|
resizeStartX.value = 0;
|
|
resizeStartDate.value = null;
|
|
originalEndDate.value = null;
|
|
return;
|
|
}
|
|
|
|
// Only update if end date changed
|
|
if (newEndDate !== originalEndDate.value) {
|
|
const extendsToNextWeek = parseLocalDate(newEndDate) > parseLocalDate(weekEnd);
|
|
if (extendsToNextWeek) {
|
|
notifications.addInfo(`Job extended to next Monday (${formatDate(newEndDate)})`);
|
|
}
|
|
console.log(`Extended job ${job.serviceType} to ${newEndDate}`);
|
|
|
|
// Call API to persist changes (placeholder for now)
|
|
try {
|
|
notifications.addWarning("API update feature coming soon!");
|
|
// Future implementation:
|
|
// await Api.updateJobSchedule({
|
|
// id: job.id,
|
|
// expectedEndDate: newEndDate
|
|
// });
|
|
} catch (error) {
|
|
console.error("Error updating job end date:", error);
|
|
notifications.addError("Failed to update job");
|
|
}
|
|
}
|
|
|
|
// Clean up
|
|
document.removeEventListener('mousemove', handleResize);
|
|
document.removeEventListener('mouseup', stopResize);
|
|
document.body.style.cursor = 'default';
|
|
|
|
// Keep flag set to prevent modal from opening on click after resize
|
|
// Reset after a longer delay to catch any pending click events
|
|
setTimeout(() => {
|
|
justFinishedResize.value = false;
|
|
}, 150);
|
|
|
|
resizingJob.value = null;
|
|
resizeStartX.value = 0;
|
|
resizeStartDate.value = null;
|
|
originalEndDate.value = null;
|
|
};
|
|
|
|
const fetchProjects = async () => {
|
|
try {
|
|
// Calculate date range for the week
|
|
const startDate = weekStartDate.value;
|
|
const endDate = addDays(startDate, 6);
|
|
|
|
const data = await Api.getJobsForCalendar(startDate, companyStore.currentCompany, selectedProjectTemplates.value);
|
|
|
|
// Transform the API response into the format the component expects
|
|
const transformedServices = [];
|
|
|
|
// Process scheduled projects
|
|
if (data.projects && Array.isArray(data.projects)) {
|
|
data.projects.forEach(project => {
|
|
transformedServices.push({
|
|
id: project.name,
|
|
title: project.projectName || project.jobAddress || 'Unnamed Project',
|
|
serviceType: project.projectName || project.jobAddress || 'Install Project',
|
|
customer: project.customer || 'Unknown Customer',
|
|
address: project.jobAddress || project.customInstallationAddress || '',
|
|
scheduledDate: project.expectedStartDate || startDate,
|
|
scheduledEndDate: project.expectedEndDate || project.expectedStartDate || startDate,
|
|
foreman: project.customForeman,
|
|
priority: (project.priority || 'Medium').toLowerCase(),
|
|
estimatedCost: project.totalSalesAmount || project.estimatedCosting || 0,
|
|
notes: project.notes || '',
|
|
isScheduled: true
|
|
});
|
|
});
|
|
}
|
|
|
|
// Process unscheduled projects
|
|
if (data.unscheduledProjects && Array.isArray(data.unscheduledProjects)) {
|
|
data.unscheduledProjects.forEach(project => {
|
|
transformedServices.push({
|
|
id: project.name,
|
|
title: project.projectName || project.jobAddress || 'Unnamed Project',
|
|
serviceType: project.projectName || project.jobAddress || 'Install Project',
|
|
customer: project.customer || 'Unknown Customer',
|
|
address: project.jobAddress || project.customInstallationAddress || '',
|
|
scheduledDate: null,
|
|
scheduledEndDate: null,
|
|
foreman: null,
|
|
priority: (project.priority || 'Medium').toLowerCase(),
|
|
estimatedCost: project.totalSalesAmount || project.estimatedCosting || 0,
|
|
notes: project.notes || '',
|
|
isScheduled: false
|
|
});
|
|
});
|
|
}
|
|
|
|
services.value = transformedServices;
|
|
console.log("Loaded install projects:", transformedServices);
|
|
} catch (error) {
|
|
console.error("Error loading install projects:", error);
|
|
notifications.addError("Failed to load install projects");
|
|
}
|
|
};
|
|
|
|
const fetchForemen = async () => {
|
|
try {
|
|
const data = await Api.getEmployees(companyStore.currentCompany, ["Foreman"]);
|
|
foremen.value = data;
|
|
// Update selected foremen to use customCrew numbers or foreman name as fallback
|
|
selectedForemen.value = data.map(f => {
|
|
if (f.customCrew && f.customCrew.trim() !== '') {
|
|
return parseInt(f.customCrew);
|
|
}
|
|
return f.name; // Use employee ID as fallback
|
|
});
|
|
console.log("Loaded foremen:", data);
|
|
console.log("Selected foremen IDs:", selectedForemen.value);
|
|
} catch (error) {
|
|
console.error("Error loading foremen:", error);
|
|
notifications.addError("Failed to load foremen");
|
|
}
|
|
};
|
|
|
|
const fetchProjectTemplates = async () => {
|
|
try {
|
|
const data = await Api.getJobTemplates(companyStore.currentCompany);
|
|
projectTemplates.value = data;
|
|
// Select all templates by default
|
|
selectedProjectTemplates.value = data.map(t => t.name);
|
|
console.log("Loaded project templates:", data);
|
|
}
|
|
catch (error) {
|
|
console.error("Error loading project templates:", error);
|
|
notifications.addError("Failed to load project templates");
|
|
}
|
|
}
|
|
|
|
const fetchHolidays = async () => {
|
|
try {
|
|
const data = await Api.getWeekHolidays(weekStartDate.value);
|
|
holidays.value = data;
|
|
console.log("Loaded holidays:", data);
|
|
} catch (error) {
|
|
console.error("Error loading holidays:", error);
|
|
notifications.addError("Failed to load holidays");
|
|
}
|
|
}
|
|
|
|
watch(weekStartDate, async () => {
|
|
await fetchProjects();
|
|
await fetchHolidays();
|
|
});
|
|
|
|
watch(companyStore, async () => {
|
|
await fetchForemen();
|
|
await fetchProjectTemplates();
|
|
await fetchProjects();
|
|
}, { deep: true });
|
|
|
|
// Lifecycle
|
|
onMounted(async () => {
|
|
await fetchForemen();
|
|
await fetchProjectTemplates();
|
|
await fetchProjects();
|
|
await fetchHolidays();
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.calendar-container {
|
|
padding: 20px;
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.calendar-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
padding-bottom: 15px;
|
|
border-bottom: 1px solid var(--surface-border);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.header-controls {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
}
|
|
|
|
.week-display-btn {
|
|
font-weight: 600;
|
|
font-size: 1.1em;
|
|
color: var(--theme-primary) !important;
|
|
text-transform: none;
|
|
letter-spacing: normal;
|
|
min-width: 320px;
|
|
max-width: 320px;
|
|
}
|
|
|
|
.week-display-btn:hover {
|
|
background-color: rgba(25, 118, 210, 0.08);
|
|
}
|
|
|
|
.date-text {
|
|
display: inline-block;
|
|
text-align: center;
|
|
width: 100%;
|
|
}
|
|
|
|
.calendar-main {
|
|
display: flex;
|
|
gap: 20px;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.calendar-section {
|
|
flex: 1;
|
|
overflow: auto;
|
|
}
|
|
|
|
.weekly-calendar {
|
|
min-width: 1000px;
|
|
background: white;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.calendar-header-row {
|
|
display: grid;
|
|
border-bottom: 2px solid var(--surface-border);
|
|
background-color: var(--surface-hover);
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 100;
|
|
}
|
|
|
|
.crew-column-header {
|
|
padding: 16px 8px;
|
|
font-weight: 600;
|
|
text-align: center;
|
|
border-right: 1px solid var(--surface-border);
|
|
color: var(--theme-text-muted);
|
|
background-color: var(--surface-hover);
|
|
}
|
|
|
|
.day-header {
|
|
padding: 12px 8px;
|
|
text-align: center;
|
|
border-right: 1px solid var(--surface-border);
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
.day-header.today {
|
|
background-color: rgba(33, 150, 243, 0.1);
|
|
}
|
|
|
|
.day-name {
|
|
font-weight: 600;
|
|
font-size: 0.9em;
|
|
margin-bottom: 4px;
|
|
color: var(--theme-primary);
|
|
}
|
|
|
|
.day-date {
|
|
font-size: 0.8em;
|
|
color: var(--theme-text-muted);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.calendar-grid {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.foreman-row {
|
|
display: grid;
|
|
min-height: 80px;
|
|
border-bottom: 1px solid var(--surface-border);
|
|
}
|
|
|
|
.crew-column {
|
|
padding: 12px 8px;
|
|
border-right: 1px solid var(--surface-border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background-color: var(--surface-hover);
|
|
}
|
|
|
|
.crew-name {
|
|
font-weight: 600;
|
|
font-size: 0.9em;
|
|
color: var(--theme-primary);
|
|
margin-bottom: 4px;
|
|
text-align: center;
|
|
}
|
|
|
|
.crew-number {
|
|
font-size: 0.75em;
|
|
color: var(--theme-text-muted);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.day-cell {
|
|
border-right: 1px solid var(--surface-border);
|
|
position: relative;
|
|
cursor: pointer;
|
|
transition: background-color 0.2s;
|
|
min-height: 80px;
|
|
max-height: 80px;
|
|
padding: 4px;
|
|
overflow: visible; /* Allow multi-day jobs to overflow */
|
|
width: 100%; /* Ensure cell doesn't expand */
|
|
}
|
|
|
|
.day-cell:hover {
|
|
background-color: var(--theme-surface-alt);
|
|
}
|
|
|
|
.day-cell.today {
|
|
background-color: rgba(33, 150, 243, 0.05);
|
|
}
|
|
|
|
.day-cell.drag-over {
|
|
background-color: #e8f5e8;
|
|
border: 2px dashed #4caf50;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.calendar-job {
|
|
position: absolute;
|
|
left: 4px;
|
|
top: 4px;
|
|
background: linear-gradient(135deg, #2196f3, #1976d2);
|
|
color: white;
|
|
border-radius: 4px;
|
|
padding: 6px 8px;
|
|
font-size: 0.8em;
|
|
cursor: pointer;
|
|
overflow: hidden;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
|
transition: all 0.2s;
|
|
display: flex;
|
|
align-items: center;
|
|
min-height: 40px;
|
|
max-width: none; /* Allow spanning */
|
|
}
|
|
|
|
.calendar-job:hover {
|
|
transform: scale(1.02);
|
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.calendar-job:hover .resize-handle {
|
|
opacity: 1;
|
|
}
|
|
|
|
.calendar-job[draggable="true"] {
|
|
cursor: grab;
|
|
}
|
|
|
|
.calendar-job[draggable="true"]:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.job-content {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.job-title {
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.job-customer {
|
|
font-size: 0.75em;
|
|
opacity: 0.9;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.resize-handle {
|
|
position: absolute;
|
|
right: 0;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 20px;
|
|
background: rgba(255, 255, 255, 0.2);
|
|
cursor: ew-resize;
|
|
opacity: 0.3;
|
|
transition: all 0.2s;
|
|
border-left: 3px solid rgba(255, 255, 255, 0.6);
|
|
}
|
|
|
|
.resize-handle:hover {
|
|
background: rgba(255, 255, 255, 0.4);
|
|
opacity: 1;
|
|
border-left: 3px solid rgba(255, 255, 255, 0.9);
|
|
}
|
|
|
|
.calendar-job.priority-urgent {
|
|
background: linear-gradient(135deg, #f44336, #d32f2f);
|
|
}
|
|
|
|
.calendar-job.priority-high {
|
|
background: linear-gradient(135deg, #ff9800, #f57c00);
|
|
}
|
|
|
|
.calendar-job.priority-medium {
|
|
background: linear-gradient(135deg, #2196f3, #1976d2);
|
|
}
|
|
|
|
.calendar-job.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;
|
|
overflow: hidden;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.unscheduled-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.unscheduled-header h4 {
|
|
font-size: 1.1em;
|
|
margin: 0;
|
|
}
|
|
|
|
.unscheduled-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
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;
|
|
}
|
|
|
|
.day-cell.holiday {
|
|
background-color: rgba(255, 193, 7, 0.1); /* yellow tint for holidays */
|
|
}
|
|
|
|
.day-cell.sunday {
|
|
background-color: rgba(200, 200, 200, 0.1); /* light gray for Sunday */
|
|
}
|
|
|
|
.tooltip {
|
|
position: fixed;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
color: white;
|
|
padding: 8px 12px;
|
|
border-radius: 4px;
|
|
font-size: 0.875em;
|
|
pointer-events: none;
|
|
z-index: 1000;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.spans-arrow {
|
|
position: absolute;
|
|
right: 25px; /* Position before resize handle */
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: rgba(255, 255, 255, 0.8);
|
|
}
|
|
</style> |