bug fixes and calendar fixes

This commit is contained in:
Casey 2026-01-27 11:21:20 -06:00
parent 0ec89a1269
commit 6cd3d138ad
9 changed files with 446 additions and 270 deletions

View file

@ -17,15 +17,9 @@
<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-btn>
<v-menu
v-model="showTemplateMenu"
:close-on-content-click="false"
@ -203,19 +197,21 @@
<!-- Jobs in this day -->
<div
v-for="job in getJobsForCell(foreman.name, day.date)"
:key="job.id"
:key="job.name"
class="calendar-job"
:style="getJobStyle(job, day.date)"
draggable="true"
@click.stop="showEventDetails({ event: job })"
@dragstart="handleDragStart(job, $event)"
@dragend="handleDragEnd"
@mousedown="startResize($event, job, day.date)"
>
:style="getJobStyle(job, day.date)"
:draggable="job.status === 'Scheduled'"
@click.stop="showEventDetails({ event: job })"
@dragstart="job.status === 'Scheduled' ? handleDragStart(job, $event) : null"
@dragend="handleDragEnd"
@mousedown="(job.status === 'Scheduled' || job.status === 'Started') ? startResize($event, job, day.date) : null"
@drop.stop.prevent="handleScheduledDrop(job, $event, foreman.name, day.date)"
@dragover.stop.prevent="handleScheduledDragOver(job, $event, foreman.name, day.date)"
>
<v-icon v-if="jobStartsBeforeWeek(job)" size="small" class="spans-arrow-left">mdi-arrow-left</v-icon>
<div class="job-content">
<div class="job-title">{{ job.projectTemplate || job.serviceType }}</div>
<div class="job-address">{{ stripAddress(job.address || job.jobAddress) }}</div>
<div class="job-title">{{ job.projectTemplate }}</div>
<div class="job-address">{{ job.serviceAddress.fullAddress }}</div>
</div>
<v-icon v-if="jobSpansToNextWeek(job)" size="small" class="spans-arrow-right">mdi-arrow-right</v-icon>
<div class="resize-handle"></div>
@ -225,7 +221,7 @@
<template v-if="isHoliday(day.date)">
<div
v-for="job in getJobsWithConnector(foreman.name, day.date)"
:key="`connector-${job.id}`"
:key="`connector-${job.name}`"
class="holiday-connector"
:class="getPriorityClass(job.priority)"
></div>
@ -241,10 +237,10 @@
<div class="unscheduled-header">
<h4>Unscheduled Jobs</h4>
<v-chip
:color="getUnscheduledCount() > 0 ? 'warning' : 'success'"
:color="unscheduledServices.length > 0 ? 'warning' : 'success'"
size="small"
>
{{ getUnscheduledCount() }} pending
{{ unscheduledServices.length }} pending
</v-chip>
</div>
@ -255,46 +251,47 @@
@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="service-title-compact mb-2">{{ service.projectTemplate || service.serviceType }}</div>
<div class="service-address mb-1">
<v-icon size="x-small" class="mr-1">mdi-map-marker</v-icon>
{{ stripAddress(service.address || service.jobAddress) }}
</div>
<div class="service-customer mb-2">
<v-icon size="x-small" class="mr-1">mdi-account</v-icon>
{{ service.customer }}
</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>
<v-card
v-for="service in unscheduledServices"
:key="service.name"
class="unscheduled-item mb-2"
:class="getPriorityClass(service.project?.priority || service.priority)"
elevation="1"
hover
density="compact"
draggable="true"
@click.stop="showEventDetails({ event: service })"
@dragstart="handleDragStart(service, $event)"
@dragend="handleDragEnd"
>
<v-card-text class="pa-3">
<div class="service-title-compact mb-2">
{{ service.project?.projectName || service.projectTemplate || service.name }}
</div>
<div class="service-address mb-1">
<v-icon size="x-small" class="mr-1">mdi-map-marker</v-icon>
{{ service.serviceAddress?.fullAddress || service.serviceAddress?.addressTitle || service.serviceAddress?.name || service.project?.jobAddress }}
</div>
<div class="service-customer mb-2">
<v-icon size="x-small" class="mr-1">mdi-account</v-icon>
{{ service.customer?.customerName || service.customer?.name || service.project?.customer || service.customer || 'N/A' }}
</div>
<div v-if="service.project?.notes || service.notes" class="service-notes-compact mt-2">
<span class="text-caption">{{ service.project?.notes || 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">
@ -309,6 +306,9 @@
v-model="eventDialog"
:job="selectedEvent"
:foremen="foremen"
:project="selectedEvent?.project"
:customer="selectedEvent?.customer"
:service-address="selectedEvent?.serviceAddress"
@close="eventDialog = false"
/>
@ -330,7 +330,8 @@ const notifications = useNotificationStore();
const companyStore = useCompanyStore();
// Reactive data
const services = ref([]);
const scheduledServices = ref([]);
const unscheduledServices = ref([]);
const weekStartDate = ref(getWeekStart(new Date()));
const eventDialog = ref(false);
const selectedEvent = ref(null);
@ -479,7 +480,7 @@ function hasHolidayInRange(startDate, endDate) {
// Helper function to check if job spans to next week
function jobSpansToNextWeek(job) {
const endDate = job.scheduledEndDate || job.scheduledDate;
const endDate = job.expectedEndDate || job.expectedStartDate;
const end = parseLocalDate(endDate);
const weekEnd = parseLocalDate(addDays(weekStartDate.value, 6));
return end > weekEnd;
@ -487,18 +488,12 @@ function jobSpansToNextWeek(job) {
// Helper function to check if job starts before current week
function jobStartsBeforeWeek(job) {
const startDate = job.expectedStartDate || job.scheduledDate;
const startDate = job.expectedStartDate;
const start = parseLocalDate(startDate);
const weekStart = parseLocalDate(weekStartDate.value);
return start < weekStart;
}
// Helper function to strip address after "-#-"
function stripAddress(address) {
if (!address) return '';
const index = address.indexOf('-#-');
return index > -1 ? address.substring(0, index).trim() : address;
}
// Helper function to get crew name from foreman ID
function getCrewName(foremanId) {
@ -524,8 +519,8 @@ function getHolidaysInRange(startDate, endDate) {
// Helper function to calculate job segments (parts between holidays/Sundays)
function getJobSegments(job) {
const startDate = job.scheduledDate;
const endDate = job.scheduledEndDate || job.scheduledDate;
const startDate = job.expectedStartDate;
const endDate = job.expectedEndDate || job.expectedStartDate;
const weekEndDate = addDays(weekStartDate.value, 6); // Saturday
const segments = [];
@ -598,16 +593,9 @@ const visibleForemen = computed(() => {
});
});
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) {
@ -655,8 +643,8 @@ const getJobsForCell = (foremanId, date) => {
return scheduledServices.value.filter((job) => {
if (job.foreman !== foremanId) return false;
const jobStart = job.scheduledDate;
const jobEnd = job.scheduledEndDate || job.scheduledDate;
const jobStart = job.expectedStartDate;
const jobEnd = job.expectedEndDate || job.expectedStartDate;
// Check if this date falls within the job's date range
// AND that it's a valid segment start date
@ -672,8 +660,8 @@ const getJobsWithConnector = (foremanId, date) => {
return scheduledServices.value.filter((job) => {
if (job.foreman !== foremanId) return false;
const jobStart = job.scheduledDate;
const jobEnd = job.scheduledEndDate || job.scheduledDate;
const jobStart = job.expectedStartDate;
const jobEnd = job.expectedEndDate || job.expectedStartDate;
// Check if this holiday date falls within the job's range
return date > jobStart && date < jobEnd;
@ -682,8 +670,8 @@ const getJobsWithConnector = (foremanId, date) => {
// Get job style for width spanning multiple days
const getJobStyle = (job, currentDate) => {
const jobStart = job.scheduledDate;
const jobEnd = job.scheduledEndDate || job.scheduledDate;
const jobStart = job.expectedStartDate;
const jobEnd = job.expectedEndDate || job.expectedStartDate;
const segments = getJobSegments(job);
// Find which segment (if any) should be rendered at currentDate
@ -842,7 +830,7 @@ const scheduleService = (service) => {
const handleDragStart = (service, event) => {
draggedService.value = service;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", service.id);
event.dataTransfer.setData("text/plain", service.name);
// Get the dimensions of the dragged element
const dragElement = event.target;
@ -860,7 +848,7 @@ const handleDragStart = (service, event) => {
// Add visual feedback
event.target.style.opacity = '0.5';
console.log("Drag started for service:", service.serviceType);
console.log("Drag started for service:", service.projectTemplate);
};
const handleDragEnd = (event) => {
@ -962,43 +950,61 @@ const handleDrop = async (event, foremanId, date) => {
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;
// 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);
if (unscheduledIndex !== -1) {
// Scheduling an unscheduled job
const action = 'Scheduled';
console.log(`${action} ${draggedService.value.projectTemplate} for ${foreman.employeeName} on ${date} to ${endDate}`);
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)
// Call API to persist changes
try {
await Api.updateJobScheduledDates(
draggedService.value.id,
await Api.updateServiceAppointmentScheduledDates(
draggedService.value.name,
date,
endDate,
foreman.name
)
services.value[serviceIndex] = {
...services.value[serviceIndex],
isScheduled: true,
scheduledDate: date,
scheduledEndDate: endDate,
foreman: foreman.name
);
// Remove from unscheduled and add to scheduled
const scheduledService = {
...unscheduledServices.value[unscheduledIndex],
expectedStartDate: date,
expectedEndDate: endDate,
foreman: foreman.name,
status: 'Scheduled'
};
unscheduledServices.value.splice(unscheduledIndex, 1);
scheduledServices.value.push(scheduledService);
notifications.addSuccess("Job scheduled successfully!");
// 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");
}
} else if (scheduledIndex !== -1 && draggedService.value.status === 'Scheduled') {
// Moving a scheduled job to a new date/foreman
console.log(`Moving ${draggedService.value.projectTemplate} to ${foreman.employeeName} on ${date}`);
// Call API to persist changes
try {
await Api.updateServiceAppointmentScheduledDates(
draggedService.value.name,
date,
draggedService.value.expectedEndDate, // Keep the same end date
foreman.name
);
// Update the scheduled job
scheduledServices.value[scheduledIndex] = {
...scheduledServices.value[scheduledIndex],
expectedStartDate: date,
foreman: foreman.name
};
notifications.addSuccess("Job moved successfully!");
} catch (error) {
console.error("Error moving job:", error);
notifications.addError("Failed to move job");
}
}
// Reset drag state
@ -1009,7 +1015,8 @@ const handleDrop = async (event, foremanId, date) => {
// Handle dropping scheduled items back to unscheduled
const handleUnscheduledDragOver = (event) => {
if (draggedService.value?.isScheduled) {
// Only allow dropping if the dragged job is scheduled
if (draggedService.value && draggedService.value.status === 'Scheduled') {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}
@ -1021,79 +1028,127 @@ const handleUnscheduledDragLeave = (event) => {
const handleUnscheduledDrop = async (event) => {
event.preventDefault();
if (!draggedService.value || !draggedService.value.isScheduled) return;
if (!draggedService.value) return;
// Only allow unscheduling if status is 'Scheduled'
if (draggedService.value.status !== 'Scheduled') {
notifications.addError('Only jobs with status "Scheduled" can be unscheduled.');
isDragOver.value = false;
dragOverCell.value = null;
draggedService.value = null;
return;
}
// Update the service to unscheduled status
const serviceIndex = services.value.findIndex(s => s.id === draggedService.value.id);
const serviceIndex = scheduledServices.value.findIndex(s => s.name === draggedService.value.name);
if (serviceIndex !== -1) {
services.value[serviceIndex] = {
...services.value[serviceIndex],
isScheduled: false,
scheduledDate: null,
scheduledEndDate: null,
// Remove from scheduled and add to unscheduled
const unscheduledService = {
...scheduledServices.value[serviceIndex],
expectedStartDate: null,
expectedEndDate: null,
foreman: null
};
console.log(`Unscheduled ${draggedService.value.serviceType}`);
scheduledServices.value.splice(serviceIndex, 1);
unscheduledServices.value.push(unscheduledService);
console.log(`Unscheduled ${draggedService.value.projectTemplate}`);
// Call API to persist changes (placeholder for now)
// Call API to persist changes
try {
await Api.updateJobScheduledDates(
draggedService.value.id,
await Api.updateServiceAppointmentScheduledDates(
draggedService.value.name,
null,
null,
null
)
notifications.addSuccess("Job unscheduled successfully!");
// 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;
};
// Allow moving scheduled jobs between days/crews if status is 'Scheduled'
const handleScheduledDragOver = (job, event, foremanId, date) => {
if (!draggedService.value) return;
// Only allow if dragging a scheduled job and target is a valid cell
if (draggedService.value.status === 'Scheduled' && job.status === 'Scheduled') {
event.dataTransfer.dropEffect = 'move';
}
};
const handleScheduledDrop = async (job, event, foremanId, date) => {
if (!draggedService.value) return;
// Only allow if dragging a scheduled job and target is a valid cell
if (draggedService.value.status !== 'Scheduled') return;
// Prevent dropping on same cell
if (draggedService.value.name === job.name && draggedService.value.foreman === foremanId && draggedService.value.expectedStartDate === date) return;
// Prevent dropping on Sunday or holidays
if (isSunday(date) || isHoliday(date)) return;
// Update job's foreman and date
const serviceIndex = scheduledServices.value.findIndex(s => s.name === draggedService.value.name);
if (serviceIndex !== -1) {
try {
await Api.updateServiceAppointmentScheduledDates(
draggedService.value.name,
date,
draggedService.value.expectedEndDate,
foremanId
);
scheduledServices.value[serviceIndex] = {
...scheduledServices.value[serviceIndex],
expectedStartDate: date,
foreman: foremanId
};
notifications.addSuccess('Job moved successfully!');
} catch (error) {
notifications.addError('Failed to move job');
}
}
isDragOver.value = false;
dragOverCell.value = null;
draggedService.value = null;
};
// Resize functionality
const startResize = (event, job, date) => {
// Only allow resize from the right edge
// Only allow if status is 'Scheduled' or 'Started'
if (!(job.status === 'Scheduled' || job.status === 'Started')) return;
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;
originalEndDate.value = job.expectedEndDate || job.expectedStartDate;
// 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';
};
@ -1163,11 +1218,11 @@ const handleResize = (event) => {
// Show popup if extending over Sunday
showExtendToNextWeekPopup.value = extendsOverSunday;
const serviceIndex = services.value.findIndex(s => s.id === resizingJob.value.id);
const serviceIndex = scheduledServices.value.findIndex(s => s.name === resizingJob.value.name);
if (serviceIndex !== -1) {
services.value[serviceIndex] = {
...services.value[serviceIndex],
scheduledEndDate: newEndDate
scheduledServices.value[serviceIndex] = {
...scheduledServices.value[serviceIndex],
expectedEndDate: newEndDate
};
}
};
@ -1181,12 +1236,12 @@ const stopResize = async () => {
const job = resizingJob.value;
// Find the updated job in services array (because resizingJob.value is a stale reference)
const serviceIndex = services.value.findIndex(s => s.id === job.id);
const serviceIndex = scheduledServices.value.findIndex(s => s.name === job.name);
if (serviceIndex === -1) return;
const updatedJob = services.value[serviceIndex];
const newEndDate = updatedJob.scheduledEndDate || updatedJob.scheduledDate;
console.log(`Proposed new end date for job ${job.serviceType}: ${newEndDate}`);
const updatedJob = scheduledServices.value[serviceIndex];
const newEndDate = updatedJob.expectedEndDate || updatedJob.expectedStartDate;
console.log(`Proposed new end date for job ${job.projectTemplate}: ${newEndDate}`);
// Only update if end date changed
if (newEndDate !== originalEndDate.value) {
@ -1197,13 +1252,13 @@ const stopResize = async () => {
notifications.addInfo(`Job extended to next Monday (${formatDate(newEndDate)})`);
}
console.log(`Extended job ${job.serviceType} from ${originalEndDate.value} to ${newEndDate}`);
console.log(`Extended job ${job.projectTemplate} from ${originalEndDate.value} to ${newEndDate}`);
// Call API to persist changes
try {
await Api.updateJobScheduledDates(
job.id,
job.scheduledDate,
await Api.updateServiceAppointmentScheduledDates(
job.name,
job.expectedStartDate,
newEndDate,
job.foreman
);
@ -1212,11 +1267,11 @@ const stopResize = async () => {
console.error("Error updating job end date:", error);
notifications.addError("Failed to update job end date");
// Revert on error
const serviceIndex = services.value.findIndex(s => s.id === job.id);
const serviceIndex = scheduledServices.value.findIndex(s => s.name === job.name);
if (serviceIndex !== -1) {
services.value[serviceIndex] = {
...services.value[serviceIndex],
scheduledEndDate: originalEndDate.value
scheduledServices.value[serviceIndex] = {
...scheduledServices.value[serviceIndex],
expectedEndDate: originalEndDate.value
};
}
}
@ -1246,69 +1301,26 @@ const fetchServiceAppointments = async (currentDate) => {
const endDate = addDays(startDate, 6);
// const data = await Api.getJobsForCalendar(startDate, endDate, companyStore.currentCompany, selectedProjectTemplates.value);
const data = await Api.getServiceAppointments(
scheduledServices.value = await Api.getServiceAppointments(
[companyStore.currentCompany],
{
"expectedStartDate": ["<=", endDate],
"expectedEndDate": [">=", startDate]
"expectedEndDate": [">=", startDate],
"status": ["not in", ["Canceled"]]
}
);
// Transform the API response into the format the component expects
const transformedServices = [];
unscheduledServices.value = await Api.getServiceAppointments(
[companyStore.currentCompany],
{
"status": "Open"
}
);
// Process scheduled projects
if (data.projects && Array.isArray(data.projects)) {
data.projects.forEach(project => {
transformedServices.push({
...project, // Include all fields from API
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,
projectTemplate: project.projectTemplate,
calendarColor: project.calendarColor,
jobAddress: project.jobAddress
});
});
}
// Process unscheduled projects
if (data.unscheduledProjects && Array.isArray(data.unscheduledProjects)) {
data.unscheduledProjects.forEach(project => {
transformedServices.push({
...project, // Include all fields from API
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,
projectTemplate: project.projectTemplate,
calendarColor: project.calendarColor,
jobAddress: project.jobAddress
});
});
}
services.value = transformedServices;
console.log("Loaded install projects:", transformedServices);
console.log("Loaded service appointments:", scheduledServices.value);
console.log("Loaded unscheduled service appointments:", unscheduledServices.value);
} catch (error) {
console.error("Error loading install projects:", error);
notifications.addError("Failed to load install projects");
console.error("Error loading service appointments:", error);
notifications.addError("Failed to load service appointments");
}
};

View file

@ -59,6 +59,15 @@
style="margin-top: 0"
/>
<label :for="`isBilling-${index}`"><i class="pi pi-dollar" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Is Billing Address</label>
<input
type="checkbox"
:id="`isService-${index}`"
v-model="address.isServiceAddress"
:disabled="isSubmitting"
@change="handleServiceChange(index)"
style="margin-top: 0; margin-left: 1.5rem;"
/>
<label :for="`isService-${index}`"><i class="pi pi-truck" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Is Service Address</label>
</div>
<div class="form-row">
<div class="form-field">
@ -120,7 +129,7 @@
<Select
:id="`primaryContact-${index}`"
v-model="address.primaryContact"
:options="contactOptions"
:options="address.contacts.map(c => contactOptions.find(opt => opt.value === c))"
optionLabel="label"
optionValue="value"
:disabled="isSubmitting || contactOptions.length === 0"
@ -174,6 +183,7 @@ const localFormData = computed({
addressLine1: "",
addressLine2: "",
isBillingAddress: true,
isServiceAddress: true,
pincode: "",
city: "",
state: "",
@ -205,6 +215,7 @@ onMounted(() => {
addressLine1: "",
addressLine2: "",
isBillingAddress: true,
isServiceAddress: true,
pincode: "",
city: "",
state: "",
@ -221,6 +232,7 @@ const addAddress = () => {
addressLine1: "",
addressLine2: "",
isBillingAddress: false,
isServiceAddress: true,
pincode: "",
city: "",
state: "",
@ -259,6 +271,7 @@ const handleBillingChange = (selectedIndex) => {
localFormData.value.addresses.forEach((addr, idx) => {
if (idx !== selectedIndex) {
addr.isBillingAddress = false;
addr.isServiceAddress = true; // Ensure service address is true for others
}
});
@ -279,6 +292,21 @@ const handleBillingChange = (selectedIndex) => {
localFormData.value.addresses[selectedIndex].primaryContact = 0;
}
}
} else {
localFormData.value.addresses[selectedIndex].isBillingAddress = true;
notificationStore.addInfo("At least one of Billing Address must be selected.");
}
};
const handleServiceChange = (selectedIndex) => {
// If the address does not have billing address selected, ensure that service address is always true
if (!localFormData.value.addresses[selectedIndex].isBillingAddress) {
localFormData.value.addresses[selectedIndex].isServiceAddress = true;
notificationStore.addInfo("Service Address must be selected if not a Billing Address.");
}
if (!localFormData.value.addresses.some(addr => addr.isServiceAddress)) {
localFormData.value.addresses[selectedIndex].isServiceAddress = true;
notificationStore.addInfo("At least one Service Address must be selected.");
}
};

View file

@ -11,18 +11,18 @@
/>
</div>
<Dropdown
<Select
v-model="selectedAddressIndex"
:options="addressOptions"
option-label="label"
option-value="value"
optionLabel="label"
optionValue="value"
placeholder="Select an address"
class="w-full address-dropdown"
@change="handleAddressChange"
>
<template #value="slotProps">
<div v-if="slotProps.value !== null && slotProps.value !== undefined" class="dropdown-value">
<span class="address-title">{{ addresses[slotProps.value]?.addressTitle || 'Unnamed Address' }}</span>
<span class="address-title">{{ addresses[slotProps.value]?.fullAddress || 'Unnamed Address' }}</span>
<div class="address-badges">
<Badge
v-if="addresses[slotProps.value]?.isPrimaryAddress && !addresses[slotProps.value]?.isServiceAddress"
@ -78,7 +78,7 @@
</div>
</div>
</template>
</Dropdown>
</Select>
<!-- Selected Address Info -->
<div v-if="selectedAddress" class="selected-address-info">
@ -146,13 +146,17 @@
</template>
<script setup>
import { computed, ref, watch } from "vue";
import { computed, ref, watch, nextTick } from "vue";
import { useRoute } from "vue-router";
import Badge from "primevue/badge";
import Button from "primevue/button";
import Dialog from "primevue/dialog";
import Dropdown from "primevue/dropdown";
import Select from "primevue/select";
import DataUtils from "../../utils";
const route = useRoute();
const addressParam = route.query.address || null;
const props = defineProps({
addresses: {
type: Array,
@ -168,10 +172,27 @@ const props = defineProps({
},
});
const findAddressIndexByParam = (addressStr) => {
const trimmedParam = addressStr.trim();
for (let i = 0; i < props.addresses.length; i++) {
const addr = props.addresses[i];
const fullAddr = (addr.fullAddress || DataUtils.calculateFullAddress(addr)).trim();
if (fullAddr === trimmedParam) {
return i;
}
}
return null;
};
const emit = defineEmits(["update:selectedAddressIdx"]);
const showAddAddressModal = ref(false);
const selectedAddressIndex = ref(props.selectedAddressIdx);
const selectedAddressIndex = ref(addressParam ? findAddressIndexByParam(addressParam) : props.selectedAddressIdx);
// Emit update if the initial index is different from props
if (addressParam && selectedAddressIndex.value !== null && selectedAddressIndex.value !== props.selectedAddressIdx) {
nextTick(() => emit("update:selectedAddressIdx", selectedAddressIndex.value));
}
// Watch for external changes to selectedAddressIdx
watch(() => props.selectedAddressIdx, (newVal) => {
@ -189,9 +210,9 @@ const selectedAddress = computed(() => {
// Address options for dropdown
const addressOptions = computed(() => {
return props.addresses.map((addr, idx) => ({
label: addr.addressTitle || DataUtils.calculateFullAddress(addr),
label: addr.fullAddress || DataUtils.calculateFullAddress(addr),
value: idx,
addressTitle: addr.addressTitle || 'Unnamed Address',
addressTitle: addr.fullAddress || 'Unnamed Address',
isPrimaryAddress: addr.isPrimaryAddress,
isServiceAddress: addr.isServiceAddress,
projectCount: addr.projects?.length || 0,
@ -277,11 +298,12 @@ const handleAddressChange = () => {
.selected-address-info {
display: flex;
flex-direction: column;
flex-direction: row;
gap: 1rem;
padding: 1rem;
padding: 0.75rem;
background: var(--surface-ground);
border-radius: 8px;
align-items: center;
}
.address-status {
@ -290,9 +312,10 @@ const handleAddressChange = () => {
}
.service-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
display: flex;
flex-direction: row;
gap: 1rem;
flex-wrap: wrap;
}
.detail-item {
@ -316,7 +339,7 @@ const handleAddressChange = () => {
}
.detail-item.primary-contact {
grid-column: 1 / -1;
flex: 1;
}
.contact-info {

View file

@ -7,7 +7,7 @@
:class="getStatusClass(onsiteMeetingStatus)"
@click="handleBidMeetingClick"
>
<span class="status-label">Meeting</span>
<span class="status-label">Bid Meeting</span>
<span class="status-badge">{{ onsiteMeetingStatus }}</span>
</div>
@ -16,7 +16,7 @@
:class="getStatusClass(estimateSentStatus)"
@click="handleEstimateClick"
>
<span class="status-label">Estimate</span>
<span class="status-label">Estimate Sent</span>
<span class="status-badge">{{ estimateSentStatus }}</span>
</div>
@ -109,8 +109,10 @@ const handleBidMeetingClick = () => {
};
const handleEstimateClick = () => {
if (props.estimateSentStatus === "Not Started") {
router.push(`/estimate?new=true&address=${encodeURIComponent(props.fullAddress)}&template=SNW%20Install`);
if ((props.estimateSentStatus === "Not Started") && props.onsiteMeetingStatus != "Completed") {
notificationStore.addWarning("Bid Meeting must be scheduled and completed before an Estimate can be made for a SNW Install")
} else if (props.estimateSentStatus === "Not Started") {
router.push(`/estimate?new=true&address=${encodeURIComponent(props.fullAddress)}&project-template=SNW%20Install&from-meeting=${encodeURIComponent(props.bidMeeting)}`);
} else {
router.push(`/estimate?name=${encodeURIComponent(props.estimate)}`);
}

View file

@ -327,14 +327,14 @@ const emitChanges = () => {
.property-details {
background: var(--surface-card);
border-radius: 12px;
padding: 1.5rem;
padding: 1rem;
border: 1px solid var(--surface-border);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
margin-bottom: 1rem;
}
.property-details > h3 {
margin: 0 0 1.5rem 0;
margin: 0 0 1rem 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-color);
@ -343,13 +343,13 @@ const emitChanges = () => {
.details-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
gap: 1rem;
}
.detail-section {
background: var(--surface-ground);
border-radius: 8px;
padding: 1.25rem;
padding: 1rem;
}
.detail-section.full-width {
@ -360,7 +360,7 @@ const emitChanges = () => {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid var(--surface-border);
}

View file

@ -3,11 +3,11 @@
<v-card v-if="job">
<v-card-title class="d-flex justify-space-between align-center bg-primary">
<div>
<div class="text-h6">{{ job.projectTemplate || job.serviceType }}</div>
<div class="text-h6">{{ job.project?.projectName || job.projectTemplate || job.serviceType || job.name }}</div>
<div class="text-caption">{{ job.name }}</div>
</div>
<v-chip :color="getPriorityColor(job.priority)" size="small">
{{ job.priority }}
<v-chip :color="getPriorityColor(job.project?.priority || job.priority)" size="small">
{{ job.project?.priority || job.priority || 'Normal' }}
</v-chip>
</v-card-title>
@ -19,11 +19,15 @@
<h4 class="text-subtitle-1 mb-2">Basic Information</h4>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-account</v-icon>
<strong>Customer:</strong> {{ job.customer }}
<strong>Customer:</strong> {{ job.customer?.customerName || job.customer?.name || 'N/A' }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-map-marker</v-icon>
<strong>Address:</strong> {{ stripAddress(job.address || job.jobAddress) }}
<strong>Service Address:</strong> {{ job.serviceAddress?.fullAddress || job.serviceAddress?.addressTitle || 'N/A' }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-office-building</v-icon>
<strong>Project:</strong> {{ job.project?.projectName || job.projectTemplate || 'N/A' }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-clipboard-text</v-icon>
@ -33,22 +37,34 @@
<v-icon size="small" class="mr-2">mdi-file-document</v-icon>
<strong>Sales Order:</strong> {{ job.salesOrder || 'N/A' }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-wrench</v-icon>
<strong>Service Type:</strong> {{ job.serviceType || 'N/A' }}
</div>
</div>
<div class="detail-section mb-4">
<h4 class="text-subtitle-1 mb-2">Schedule</h4>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-calendar-start</v-icon>
<strong>Start Date:</strong> {{ job.expectedStartDate || job.scheduledDate }}
<strong>Start Date:</strong> {{ formatDate(job.expectedStartDate || job.scheduledDate) }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-calendar-end</v-icon>
<strong>End Date:</strong> {{ job.expectedEndDate || job.scheduledEndDate }}
<strong>End Date:</strong> {{ formatDate(job.expectedEndDate || job.scheduledEndDate) }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-account-hard-hat</v-icon>
<strong>Assigned Crew:</strong> {{ getCrewName(job.foreman || job.customForeman) }}
</div>
<div class="detail-row" v-if="job.expectedStartTime">
<v-icon size="small" class="mr-2">mdi-clock-start</v-icon>
<strong>Start Time:</strong> {{ job.expectedStartTime }}
</div>
<div class="detail-row" v-if="job.expectedEndTime">
<v-icon size="small" class="mr-2">mdi-clock-end</v-icon>
<strong>End Time:</strong> {{ job.expectedEndTime }}
</div>
</div>
<div class="detail-section mb-4">
@ -71,6 +87,10 @@
<v-icon size="small" class="mr-2">mdi-shield-check</v-icon>
<strong>Warranty:</strong> {{ job.customWarrantyDurationDays }} days
</div>
<div class="detail-row" v-if="job.customJobType">
<v-icon size="small" class="mr-2">mdi-tools</v-icon>
<strong>Job Type:</strong> {{ job.customJobType }}
</div>
</div>
</v-col>
@ -128,7 +148,7 @@
<!-- Map Display -->
<div v-if="hasCoordinates" class="detail-section mb-4">
<h4 class="text-subtitle-1 mb-2">Location</h4>
<h4 class="text-subtitle-1 mb-2">Service Location</h4>
<div class="map-container">
<iframe
:src="mapUrl"
@ -136,13 +156,21 @@
height="200"
frameborder="0"
style="border: 1px solid var(--surface-border); border-radius: 6px;"
allowfullscreen
></iframe>
</div>
</div>
<div v-if="job.notes" class="detail-section">
<div v-if="job.notes || job.project?.notes" class="detail-section">
<h4 class="text-subtitle-1 mb-2">Notes</h4>
<p class="text-body-2">{{ job.notes }}</p>
<div v-if="job.project?.notes" class="mb-3">
<strong>Project Notes:</strong>
<p class="text-body-2 mt-1">{{ job.project.notes }}</p>
</div>
<div v-if="job.notes">
<strong>Job Notes:</strong>
<p class="text-body-2 mt-1">{{ job.notes }}</p>
</div>
</div>
</v-col>
</v-row>
@ -199,17 +227,17 @@ const showModal = computed({
});
const hasCoordinates = computed(() => {
if (!props.job?.jobAddress) return false;
// Check if address has coordinates - you may need to adjust based on your data structure
const lat = props.job.latitude || props.job.customLatitude;
const lon = props.job.longitude || props.job.customLongitude;
if (!props.job?.serviceAddress) return false;
// Check if service address has coordinates
const lat = props.job.serviceAddress.latitude || props.job.serviceAddress.customLatitude;
const lon = props.job.serviceAddress.longitude || props.job.serviceAddress.customLongitude;
return lat && lon && parseFloat(lat) !== 0 && parseFloat(lon) !== 0;
});
const mapUrl = computed(() => {
if (!hasCoordinates.value) return "";
const lat = parseFloat(props.job.latitude || props.job.customLatitude);
const lon = parseFloat(props.job.longitude || props.job.customLongitude);
const lat = parseFloat(props.job.serviceAddress.latitude || props.job.serviceAddress.customLatitude);
const lon = parseFloat(props.job.serviceAddress.longitude || props.job.serviceAddress.customLongitude);
// Using OpenStreetMap embed with marker
return `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.01},${lat - 0.01},${lon + 0.01},${lat + 0.01}&layer=mapnik&marker=${lat},${lon}`;
});
@ -221,6 +249,20 @@ const stripAddress = (address) => {
return index > -1 ? address.substring(0, index).trim() : address;
};
const formatDate = (dateStr) => {
if (!dateStr) return 'Not scheduled';
try {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch (e) {
return dateStr;
}
};
const getCrewName = (foremanId) => {
if (!foremanId) return 'Not assigned';
const foreman = props.foremen.find(f => f.name === foremanId);

View file

@ -407,7 +407,7 @@ const handleCreateEstimate = () => {
new: "true",
address: addressText,
"from-meeting": fromMeeting,
template: template,
"project-template": template,
contact: contactName,
},
});

View file

@ -101,7 +101,7 @@ const loadingStore = useLoadingStore();
const notificationStore = useNotificationStore();
const companyStore = useCompanyStore();
const address = route.query.address || null;
const address = (route.query.address || '').trim();
const clientName = route.query.client || null;
const isNew = computed(() => route.query.new === "true" || false);
@ -118,7 +118,7 @@ const selectedAddressObject = computed(() =>
);
const addresses = computed(() => {
if (client.value && client.value.addresses) {
return client.value.addresses.map((addr) => DataUtils.calculateFullAddress(addr));
return client.value.addresses.map((addr) => DataUtils.calculateFullAddress(addr).trim());
}
return [];
});
@ -144,7 +144,7 @@ const selectedAddressData = computed(() => {
return null;
}
return client.value.addresses.find(
(addr) => DataUtils.calculateFullAddress(addr) === selectedAddress.value
(addr) => DataUtils.calculateFullAddress(addr).trim() === selectedAddress.value.trim()
);
});
@ -174,10 +174,11 @@ const getClient = async (name) => {
// Set initial selected address if provided in route or use first address
if (address && client.value.addresses) {
const fullAddresses = client.value.addresses.map((addr) =>
DataUtils.calculateFullAddress(addr),
DataUtils.calculateFullAddress(addr).trim(),
);
if (fullAddresses.includes(address)) {
selectedAddress.value = address;
const trimmedAddress = address.trim();
if (fullAddresses.includes(trimmedAddress)) {
selectedAddress.value = trimmedAddress;
} else if (fullAddresses.length > 0) {
selectedAddress.value = fullAddresses[0];
}
@ -297,7 +298,7 @@ const handleSubmit = async () => {
console.log("Created client:", createdClient);
notificationStore.addSuccess("Client created successfully!");
// Navigate to the created client
window.location.hash = '#/client?client=' + encodeURIComponent(createdClient.name || createdClient.customerName);
router.push('/client?client=' + encodeURIComponent(createdClient.name));
} else {
// TODO: Implement save logic
notificationStore.addSuccess("Changes saved successfully!");

View file

@ -444,6 +444,7 @@ const nameQuery = computed(() => route.query.name || "");
const templateQuery = computed(() => route.query.template || "");
const fromMeetingQuery = computed(() => route.query["from-meeting"] || "");
const contactQuery = computed(() => route.query.contact || "");
const projectTemplateQuery = computed(() => route.query["project-template"] || "");
const isNew = computed(() => route.query.new === "true");
const isSubmitting = ref(false);
@ -457,7 +458,7 @@ const formData = reactive({
estimateName: null,
requiresHalfPayment: false,
projectTemplate: null,
fromMeeting: null,
fromOnsiteMeeting: null,
});
const selectedAddress = ref(null);
@ -757,7 +758,7 @@ const saveDraft = async () => {
estimateName: formData.estimateName,
requiresHalfPayment: formData.requiresHalfPayment,
projectTemplate: formData.projectTemplate,
fromMeeting: formData.fromMeeting,
fromOnsiteMeeting: formData.fromOnsiteMeeting,
company: company.currentCompany
};
estimate.value = await Api.createEstimate(data);
@ -923,6 +924,8 @@ watch(
const newIsNew = newQuery.new === "true";
const newAddressQuery = newQuery.address;
const newNameQuery = newQuery.name;
const newFromMeetingQuery = newQuery["from-meeting"];
const newProjectTemplateQuery = newQuery["project-template"];
if (newAddressQuery && newIsNew) {
// Creating new estimate - pre-fill address
@ -966,7 +969,15 @@ watch(
};
});
}
formData.requiresHalfPayment = estimate.value.custom_requires_half_payment || false;
formData.requiresHalfPayment = Boolean(estimate.value.requiresHalfPayment || estimate.value.custom_requires_half_payment);
// If estimate has fromOnsiteMeeting, fetch bid meeting
if (estimate.value.fromOnsiteMeeting) {
try {
bidMeeting.value = await Api.getBidMeeting(estimate.value.fromOnsiteMeeting);
} catch (error) {
console.error("Error fetching bid meeting for existing estimate:", error);
}
}
}
} catch (error) {
console.error("Error loading estimate:", error);
@ -976,6 +987,35 @@ watch(
);
}
}
// Handle from-meeting for new estimates
if (newFromMeetingQuery && newIsNew) {
formData.fromOnsiteMeeting = newFromMeetingQuery;
try {
bidMeeting.value = await Api.getBidMeeting(newFromMeetingQuery);
if (bidMeeting.value?.bidNotes?.quantities) {
selectedItems.value = bidMeeting.value.bidNotes.quantities.map(q => {
const item = quotationItems.value.find(i => i.itemCode === q.item);
return {
itemCode: q.item,
itemName: item?.itemName || q.item,
qty: q.quantity,
standardRate: item?.standardRate || 0,
discountAmount: null,
discountPercentage: null,
discountType: 'currency'
};
});
}
} catch (error) {
console.error("Error fetching bid meeting:", error);
}
}
// Handle project-template
if (newProjectTemplateQuery) {
formData.projectTemplate = newProjectTemplateQuery;
}
}
},
{ deep: true }
@ -995,16 +1035,36 @@ onMounted(async () => {
// Handle from-meeting query parameter
if (fromMeetingQuery.value) {
formData.fromMeeting = fromMeetingQuery.value;
formData.fromOnsiteMeeting = fromMeetingQuery.value;
// Fetch the bid meeting to check for bidNotes
try {
bidMeeting.value = await Api.getBidMeeting(fromMeetingQuery.value);
// If new estimate and bid notes have quantities, set default items
if (isNew.value && bidMeeting.value?.bidNotes?.quantities) {
selectedItems.value = bidMeeting.value.bidNotes.quantities.map(q => {
const item = quotationItems.value.find(i => i.itemCode === q.item);
return {
itemCode: q.item,
itemName: item?.itemName || q.item,
qty: q.quantity,
standardRate: item?.standardRate || 0,
discountAmount: null,
discountPercentage: null,
discountType: 'currency'
};
});
}
} catch (error) {
console.error("Error fetching bid meeting:", error);
}
}
}
// Handle project-template query parameter
if (projectTemplateQuery.value) {
formData.projectTemplate = projectTemplateQuery.value;
}
if (addressQuery.value && isNew.value) {
// Creating new estimate - pre-fill address
await selectAddress(addressQuery.value);
@ -1053,7 +1113,15 @@ onMounted(async () => {
};
});
}
formData.requiresHalfPayment = estimate.value.requiresHalfPayment || false;
formData.requiresHalfPayment = Boolean(estimate.value.requiresHalfPayment || estimate.value.custom_requires_half_payment);
// If estimate has fromOnsiteMeeting, fetch bid meeting
if (estimate.value.fromOnsiteMeeting) {
try {
bidMeeting.value = await Api.getBidMeeting(estimate.value.fromOnsiteMeeting);
} catch (error) {
console.error("Error fetching bid meeting for existing estimate:", error);
}
}
estimateResponse.value = estimate.value.customResponse;
estimateResponseSelection.value = estimate.value.customResponse;
}