This commit is contained in:
Casey 2026-02-15 07:06:51 -06:00
parent 8ebd77540c
commit 8c818f8dde
17 changed files with 14799 additions and 322 deletions

View file

@ -476,12 +476,13 @@ class Api {
return await this.request(FRAPPE_GET_SERVICE_APPOINTMENTS_METHOD, { companies, filters });
}
static async updateServiceAppointmentScheduledDates(serviceAppointmentName, startDate, endDate, crewLeadName, startTime = null, endTime = null) {
static async updateServiceAppointmentScheduledDates(serviceAppointmentName, startDate = null, endDate = null, crewLeadName = null, skippedDays = [],startTime = null, endTime = null) {
return await this.request(FRAPPE_UPDATE_SERVICE_APPOINTMENT_SCHEDULED_DATES_METHOD, {
serviceAppointmentName,
startDate,
endDate,
crewLeadName,
skippedDays,
startTime,
endTime
})

View file

@ -1,5 +1,5 @@
<template>
<div class="calendar-container">
<div class="calendar-container" :class="{ 'skip-mode': skipMode }">
<div class="calendar-header">
<h2>Weekly Schedule - {{ companyStore.currentCompany }}</h2>
<div class="header-controls">
@ -26,6 +26,16 @@
<v-btn @click="goToThisWeek" variant="outlined" size="small" class="ml-4">
This Week
</v-btn>
<v-btn
@click="toggleSkipMode"
:color="skipMode ? 'error' : 'default'"
variant="outlined"
size="small"
class="ml-4"
>
<v-icon left size="small">mdi-content-cut</v-icon>
Skip Day
</v-btn>
<v-menu
v-model="showTemplateMenu"
:close-on-content-click="false"
@ -158,6 +168,21 @@
</v-card>
</v-dialog>
<!-- Holiday Prompt Dialog -->
<v-dialog v-model="showHolidayPrompt" max-width="500px">
<v-card>
<v-card-title>Holiday Scheduling</v-card-title>
<v-card-text>
The event has been scheduled on or over a holiday/Sunday. Should this day be skipped?
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="handleHolidayChoice(false)">Include Holiday</v-btn>
<v-btn color="primary" @click="handleHolidayChoice(true)">Skip Holiday</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<div class="calendar-main">
<!-- Weekly Calendar Grid -->
<div class="calendar-section">
@ -199,6 +224,7 @@
@dragover="handleDragOver($event, foreman.name, day.date)"
@dragleave="handleDragLeave"
@drop="handleDrop($event, foreman.name, day.date)"
@click="skipMode ? handleSkipDayClick(foreman.name, day.date) : null"
>
<!-- Jobs in this day -->
<div
@ -222,8 +248,22 @@
<v-icon v-if="jobSpansToNextWeek(job)" size="small" class="spans-arrow-right">mdi-arrow-right</v-icon>
<div class="resize-handle"></div>
</div>
<!-- Holiday connector line for split jobs -->
<!-- Skipped days -->
<div
v-for="job in getSkippedJobsForCell(foreman.name, day.date)"
:key="`skip-${job.name}`"
class="skipped-day"
:class="getPriorityClass(job.priority)"
>
<button
class="remove-skip-btn"
@click.stop="handleRemoveSkip(job, day.date)"
title="Remove skipped day"
>
<v-icon size="small">mdi-close</v-icon>
</button>
</div>
<template v-if="isHoliday(day.date)">
<div
v-for="job in getJobsWithConnector(foreman.name, day.date)"
@ -374,6 +414,14 @@ const showForemenMenu = ref(false);
const showDatePicker = ref(false);
const selectedDate = ref(null);
// Holiday prompt
const showHolidayPrompt = ref(false);
const pendingAction = ref(null); // { type: 'drop' or 'resize', data: {...} }
const holidayDates = ref([]); // dates that are holidays in the range
// Skip mode
const skipMode = ref(false);
// Project template filter
const selectedProjectTemplates = ref([]);
const showTemplateMenu = ref(false);
@ -513,21 +561,21 @@ function getCrewName(foremanId) {
return foreman.customCrew ? `${foreman.employeeName} (Crew ${foreman.customCrew})` : foreman.employeeName;
}
// Helper function to get all holidays in a date range
function getHolidaysInRange(startDate, endDate) {
// Helper function to get all Sundays in a date range
function getSundaysInRange(startDate, endDate) {
const start = parseLocalDate(startDate);
const end = parseLocalDate(endDate);
const holidaysInRange = [];
const sundays = [];
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const dateStr = toLocalDateString(d);
if (isHoliday(dateStr)) {
holidaysInRange.push(dateStr);
if (isSunday(dateStr)) {
sundays.push(dateStr);
}
}
return holidaysInRange;
return sundays;
}
// Helper function to calculate job segments (parts between holidays/Sundays)
// Helper function to calculate job segments (parts between holidays/Sundays/skipDays)
function getJobSegments(job) {
const startDate = job.expectedStartDate;
const endDate = job.expectedEndDate || job.expectedStartDate;
@ -547,8 +595,9 @@ function getJobSegments(job) {
for (let d = new Date(start); d <= effectiveEnd; d.setDate(d.getDate() + 1)) {
const dateStr = toLocalDateString(d);
// If we hit a holiday or Sunday, close the current segment
if (isHoliday(dateStr) || isSunday(dateStr)) {
// If we hit a holiday, Sunday, or skipped day, close the current segment
const isSkipped = job.skipDays && job.skipDays.some(skip => skip.date === dateStr);
if (isHoliday(dateStr) || isSunday(dateStr) || isSkipped) {
// Close previous segment if it exists
if (segmentStart !== null) {
const prevDate = toLocalDateString(new Date(d.getTime() - 86400000)); // Previous day
@ -658,8 +707,24 @@ const getJobsForCell = (foremanId, date) => {
// Check if this date falls within the job's date range
// AND that it's a valid segment start date
// AND not in skipDays
const segments = getJobSegments(job);
return segments.some(seg => seg.start === date);
const isSkipped = job.skipDays && job.skipDays.some(skip => skip.date === date);
return segments.some(seg => seg.start === date) && !isSkipped;
});
};
// Get skipped jobs for a specific foreman and date
const getSkippedJobsForCell = (foremanId, date) => {
return scheduledServices.value.filter((job) => {
if (job.foreman !== foremanId) return false;
const jobStart = job.expectedStartDate;
const jobEnd = job.expectedEndDate || job.expectedStartDate;
// Check if this date is in the job's skipDays
const isSkipped = job.skipDays && job.skipDays.some(skip => skip.date === date);
return isSkipped && parseLocalDate(date) >= parseLocalDate(jobStart) && parseLocalDate(date) <= parseLocalDate(jobEnd);
});
};
@ -758,6 +823,112 @@ const goToThisWeek = () => {
weekStartDate.value = getWeekStart(new Date());
};
const toggleSkipMode = () => {
skipMode.value = !skipMode.value;
};
const handleSkipDayClick = async (foremanId, date) => {
// Find jobs that cover this date for this foreman
const jobs = scheduledServices.value.filter(job => {
if (job.foreman !== foremanId) return false;
const start = job.expectedStartDate;
const end = job.expectedEndDate || start;
return date >= start && date <= end;
});
if (jobs.length === 0) {
notifications.addInfo("No jobs found for this day.");
return;
}
// For now, take the first job. In future, could show selection if multiple.
const job = jobs[0];
// Check if already skipped
const isAlreadySkipped = job.skipDays && job.skipDays.some(skip => skip.date === date);
if (isAlreadySkipped) {
notifications.addInfo("This day is already skipped.");
return;
}
// Prompt
const confirmed = confirm(`Are you sure you want to skip ${formatDate(date)} for job ${job.projectTemplate}?`);
if (!confirmed) return;
// Add to skipDays
const newSkipDays = [...(job.skipDays || []), { date }];
// Update local
const serviceIndex = scheduledServices.value.findIndex(s => s.name === job.name);
if (serviceIndex !== -1) {
scheduledServices.value[serviceIndex] = {
...scheduledServices.value[serviceIndex],
skipDays: newSkipDays
};
}
// Call API
try {
await Api.updateServiceAppointmentScheduledDates(
job.name,
job.expectedStartDate,
job.expectedEndDate,
job.foreman,
newSkipDays
);
notifications.addSuccess("Day skipped successfully!");
} catch (error) {
console.error("Error skipping day:", error);
notifications.addError("Failed to skip day");
// Revert
if (serviceIndex !== -1) {
scheduledServices.value[serviceIndex] = {
...scheduledServices.value[serviceIndex],
skipDays: job.skipDays
};
}
}
};
// Remove skip day
const handleRemoveSkip = async (job, date) => {
const confirmed = confirm(`Remove skipped day ${formatDate(date)} for job ${job.projectTemplate}?`);
if (!confirmed) return;
const newSkipDays = (job.skipDays || []).filter(skip => skip.date !== date);
// Update local
const serviceIndex = scheduledServices.value.findIndex(s => s.name === job.name);
if (serviceIndex !== -1) {
scheduledServices.value[serviceIndex] = {
...scheduledServices.value[serviceIndex],
skipDays: newSkipDays
};
}
// Call API
try {
await Api.updateServiceAppointmentScheduledDates(
job.name,
job.expectedStartDate,
job.expectedEndDate,
job.foreman,
newSkipDays
);
notifications.addSuccess("Skipped day removed successfully!");
} catch (error) {
console.error("Error removing skipped day:", error);
notifications.addError("Failed to remove skipped day");
// Revert
if (serviceIndex !== -1) {
scheduledServices.value[serviceIndex] = {
...scheduledServices.value[serviceIndex],
skipDays: job.skipDays
};
}
}
};
// Foremen selection methods
const toggleAllForemen = () => {
if (selectedForemen.value.length === foremen.value.length) {
@ -924,25 +1095,6 @@ const handleDrop = async (event, foremanId, date) => {
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;
}
// Prevent dropping on holidays
if (isHoliday(date)) {
const holidayDesc = getHolidayDescription(date);
notifications.addError(`Cannot schedule jobs on ${holidayDesc || 'a holiday'}. Please select a different date.`);
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;
@ -950,16 +1102,29 @@ const handleDrop = async (event, foremanId, date) => {
// 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
// Check for holidays or Sundays in the range
const holidaysInRange = getHolidaysInRange(date, endDate);
const sundaysInRange = getSundaysInRange(date, endDate);
const conflictingDates = [...holidaysInRange, ...sundaysInRange];
if (conflictingDates.length > 0) {
// Show prompt
holidayDates.value = conflictingDates;
pendingAction.value = {
type: 'drop',
data: { event, foremanId, date, endDate, foreman }
};
showHolidayPrompt.value = true;
isDragOver.value = false;
dragOverCell.value = null;
draggedService.value = null;
return;
}
// Proceed with normal drop
await performDrop(foremanId, date, endDate, foreman);
};
const performDrop = async (foremanId, date, endDate, foreman, skipDays = []) => {
// Check if this is scheduling an unscheduled job or moving a scheduled job
const unscheduledIndex = unscheduledServices.value.findIndex(s => s.name === draggedService.value.name);
const scheduledIndex = scheduledServices.value.findIndex(s => s.name === draggedService.value.name);
@ -975,7 +1140,8 @@ const handleDrop = async (event, foremanId, date) => {
draggedService.value.name,
date,
endDate,
foreman.name
foreman.name,
skipDays
);
// Remove from unscheduled and add to scheduled
const scheduledService = {
@ -983,7 +1149,8 @@ const handleDrop = async (event, foremanId, date) => {
expectedStartDate: date,
expectedEndDate: endDate,
foreman: foreman.name,
status: 'Scheduled'
status: 'Scheduled',
skipDays: skipDays
};
unscheduledServices.value.splice(unscheduledIndex, 1);
scheduledServices.value.push(scheduledService);
@ -1002,14 +1169,16 @@ const handleDrop = async (event, foremanId, date) => {
draggedService.value.name,
date,
date, // Reset to single day when moved
foreman.name
foreman.name,
skipDays
);
// Update the scheduled job
scheduledServices.value[scheduledIndex] = {
...scheduledServices.value[scheduledIndex],
expectedStartDate: date,
expectedEndDate: date, // Reset to single day
foreman: foreman.name
foreman: foreman.name,
skipDays: skipDays
};
notifications.addSuccess("Job moved successfully!");
} catch (error) {
@ -1024,12 +1193,57 @@ const handleDrop = async (event, foremanId, date) => {
draggedService.value = null;
};
// Handle dropping scheduled items back to unscheduled
const handleUnscheduledDragOver = (event) => {
// Only allow dropping if the dragged job is scheduled
if (draggedService.value && draggedService.value.status === 'Scheduled') {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
const handleHolidayChoice = async (skip) => {
showHolidayPrompt.value = false;
const action = pendingAction.value;
if (!action) return;
const skipDays = skip ? holidayDates.value : [];
if (action.type === 'drop') {
const { foremanId, date, endDate, foreman } = action.data;
await performDrop(foremanId, date, endDate, foreman, skipDays);
} else if (action.type === 'resize') {
await performResize(action.data, skipDays);
}
pendingAction.value = null;
holidayDates.value = [];
};
const performResize = async (data, skipDays) => {
const { job, newEndDate, originalEndDate } = data;
// Update the job with new end date and skipDays
const serviceIndex = scheduledServices.value.findIndex(s => s.name === job.name);
if (serviceIndex !== -1) {
const originalSkipDays = scheduledServices.value[serviceIndex].skipDays;
scheduledServices.value[serviceIndex] = {
...scheduledServices.value[serviceIndex],
expectedEndDate: newEndDate,
skipDays: skipDays
};
// Call API to persist changes
try {
await Api.updateServiceAppointmentScheduledDates(
job.name,
job.expectedStartDate,
newEndDate,
job.foreman,
skipDays
);
notifications.addSuccess("Job end date updated successfully!");
} catch (error) {
console.error("Error updating job end date:", error);
notifications.addError("Failed to update job end date");
// Revert on error
scheduledServices.value[serviceIndex] = {
...scheduledServices.value[serviceIndex],
expectedEndDate: originalEndDate,
skipDays: originalSkipDays
};
}
}
};
@ -1070,7 +1284,8 @@ const handleUnscheduledDrop = async (event) => {
draggedService.value.name,
null,
null,
null
null,
[]
)
notifications.addSuccess("Job unscheduled successfully!");
} catch (error) {
@ -1112,7 +1327,8 @@ const handleScheduledDrop = async (job, event, foremanId, date) => {
draggedService.value.name,
date,
draggedService.value.expectedEndDate,
foremanId
foremanId,
draggedService.value.skipDays || []
);
scheduledServices.value[serviceIndex] = {
...scheduledServices.value[serviceIndex],
@ -1198,37 +1414,24 @@ const handleResize = (event) => {
const proposedDate = parseLocalDate(proposedEndDate);
const saturdayDate = parseLocalDate(weekEndDate);
// Check for holidays in the EXTENSION range only (from current end to proposed end)
// Check for holidays and Sundays in the EXTENSION range only (from current end to proposed end)
const holidaysInRange = getHolidaysInRange(currentEndDate, proposedEndDate);
if (holidaysInRange.length > 0) {
hasHolidayBlock = true;
// Allow the extension - visual split will be handled by getJobSegments
newEndDate = proposedEndDate;
const sundaysInRange = getSundaysInRange(currentEndDate, proposedEndDate);
const conflictingDates = [...holidaysInRange, ...sundaysInRange];
if (conflictingDates.length > 0) {
// Show prompt
holidayDates.value = conflictingDates;
pendingAction.value = {
type: 'resize',
data: { job: resizingJob.value, newEndDate: proposedEndDate, originalEndDate: currentEndDate }
};
showHolidayPrompt.value = true;
return; // Don't update yet
}
// Check if the proposed end date extends past Saturday or lands on Sunday
if (proposedDate > saturdayDate || isSunday(proposedEndDate)) {
extendsOverSunday = true;
// Set logical end date to next Monday, but visually cap at Saturday
newEndDate = getNextMonday(weekEndDate);
} else if (!hasHolidayBlock && daysToAdd > 0) {
// Check if any date in the EXTENSION range is Sunday (only if no holiday block)
// Only check from current end to proposed end
const startCheck = parseLocalDate(currentEndDate);
const endCheck = parseLocalDate(proposedEndDate);
for (let d = new Date(startCheck.getTime() + 86400000); d <= endCheck; d.setDate(d.getDate() + 1)) {
const checkDate = toLocalDateString(d);
if (isSunday(checkDate)) {
extendsOverSunday = true;
// Extend to next Monday
newEndDate = getNextMonday(checkDate);
break;
}
}
}
// Show popup if extending over Sunday
showExtendToNextWeekPopup.value = extendsOverSunday;
// No conflicts, proceed
newEndDate = proposedEndDate;
const serviceIndex = scheduledServices.value.findIndex(s => s.name === resizingJob.value.name);
if (serviceIndex !== -1) {
@ -1272,7 +1475,8 @@ const stopResize = async () => {
job.name,
job.expectedStartDate,
newEndDate,
job.foreman
job.foreman,
job.skipDays || []
);
notifications.addSuccess("Job end date updated successfully!");
} catch (error) {
@ -1860,6 +2064,59 @@ onMounted(async () => {
color: #4caf50;
}
.skipped-day {
position: absolute;
left: 4px;
right: 4px;
top: 50%;
height: 3px;
transform: translateY(-50%);
border-top: 3px dotted currentColor;
opacity: 0.6;
pointer-events: none;
z-index: 5;
}
.skipped-day .remove-skip-btn {
display: none;
position: absolute;
top: -8px;
right: -8px;
width: 16px;
height: 16px;
border-radius: 50%;
background: white;
border: 1px solid #ccc;
cursor: pointer;
z-index: 10;
pointer-events: auto;
padding: 0;
}
.skipped-day:hover .remove-skip-btn {
display: block;
}
.skipped-day .remove-skip-btn:hover {
background: #f5f5f5;
}
.skipped-day.priority-urgent {
color: #f44336;
}
.skipped-day.priority-high {
color: #ff9800;
}
.skipped-day.priority-medium {
color: #2196f3;
}
.skipped-day.priority-low {
color: #4caf50;
}
.extend-popup {
position: fixed;
top: 100px;
@ -1897,4 +2154,12 @@ onMounted(async () => {
align-items: center;
line-height: 1.4;
}
.calendar-container.skip-mode {
cursor: crosshair;
}
.calendar-container.skip-mode .day-cell {
cursor: crosshair;
}
</style>