lots of updates

This commit is contained in:
Casey 2025-12-09 16:38:58 -06:00
parent 02c48e6108
commit 8ed083fce1
14 changed files with 730 additions and 83 deletions

View file

@ -1,6 +1,9 @@
<template>
<div class="estimate-page">
<h2>{{ isNew ? 'Create Estimate' : 'View Estimate' }}</h2>
<div v-if="!isNew && estimate" class="page-actions">
<Button label="Duplicate" icon="pi pi-copy" @click="duplicateEstimate" />
</div>
<!-- Address Section -->
<div class="address-section">
@ -89,11 +92,17 @@
<Button
label="Save Draft"
@click="saveDraft"
:disabled="selectedItems.length === 0"
:disabled="selectedItems.length === 0 || estimate?.customSent === 1"
/>
</div>
<div v-if="estimate">
<Button label="Send Estimate" @click="showConfirmationModal = true" :disabled="estimate.docstatus !== 0"/>
<Button label="Send Estimate" @click="showConfirmationModal = true" :disabled="estimate.customSent === 1"/>
</div>
<div v-if="estimate && estimate.customSent === 1" class="response-status">
<h4>Customer Response:</h4>
<span :class="getResponseClass(estimate.customResponse)">
{{ getResponseText(estimate.customResponse) }}
</span>
</div>
</div>
@ -131,7 +140,7 @@
:options="{ showActions: false }"
>
<template #title>Add Item</template>
<div class="modal-content">
<div class="modal-content items-modal-content">
<div class="search-section">
<label for="item-search" class="field-label">Search Items</label>
<InputText
@ -152,7 +161,7 @@
:tableActions="tableActions"
selectable
:paginator="false"
:rows="filteredItems.length"
:scrollHeight="'55vh'"
/>
</div>
</Modal>
@ -223,10 +232,12 @@ const router = useRouter();
const loadingStore = useLoadingStore();
const notificationStore = useNotificationStore();
const addressQuery = route.query.address;
const isNew = route.query.new === "true" ? true : false;
const addressQuery = computed(() => route.query.address || "");
const isNew = computed(() => route.query.new === "true");
const isSubmitting = ref(false);
const isDuplicating = ref(false);
const duplicatedItems = ref([]);
const formData = reactive({
address: "",
@ -252,10 +263,10 @@ const estimate = ref(null);
// Computed property to determine if fields are editable
const isEditable = computed(() => {
if (isNew) return true;
if (isNew.value) return true;
if (!estimate.value) return false;
// If docstatus is 0 (draft), allow editing of contact and items
return estimate.value.docstatus === 0;
return estimate.value.customSent === 0;
});
const itemColumns = [
@ -370,6 +381,31 @@ const saveDraft = async () => {
}
};
const duplicateEstimate = () => {
if (!estimate.value) return;
// Preserve current items/quantities for the new estimate
duplicatedItems.value = (selectedItems.value || []).map((item) => ({ ...item }));
isDuplicating.value = true;
// Navigate to new estimate mode without address/contact in query params
router.push({ path: "/estimate", query: { new: "true" } });
};
const getResponseClass = (response) => {
if (response === "Accepted") return "response-accepted";
if (response === "Rejected") return "response-rejected";
if (response === "Requested help") return "response-requested-help";
return "response-no-response";
};
const getResponseText = (response) => {
if (response === "Accepted") return "Accepted";
if (response === "Rejected") return "Rejected";
if (response === "Requested help") return "Requested Help";
return "No response yet";
};
const confirmAndSendEstimate = async () => {
loadingStore.setLoading(true, "Sending estimate...");
const updatedEstimate = await Api.sendEstimateEmail(estimate.value.name);
@ -424,7 +460,12 @@ watch(
async (newQuery, oldQuery) => {
// If 'new' param or address changed, reload component state
if (newQuery.new !== oldQuery.new || newQuery.address !== oldQuery.address) {
// Reset all state
const duplicating = isDuplicating.value;
const preservedItems = duplicating
? (duplicatedItems.value || []).map((item) => ({ ...item }))
: [];
// Reset all state, but keep items if duplicating
formData.address = "";
formData.addressName = "";
formData.contact = "";
@ -433,9 +474,16 @@ watch(
selectedContact.value = null;
contacts.value = [];
contactOptions.value = [];
selectedItems.value = [];
estimate.value = null;
selectedItems.value = preservedItems;
// Clear duplication state once applied
if (duplicating) {
isDuplicating.value = false;
duplicatedItems.value = [];
return;
}
// Reload data based on new query params
const newIsNew = newQuery.new === "true";
const newAddressQuery = newQuery.address;
@ -487,20 +535,20 @@ onMounted(async () => {
console.error("Error loading quotation items:", error);
}
if (addressQuery && isNew) {
if (addressQuery.value && isNew.value) {
// Creating new estimate - pre-fill address
await selectAddress(addressQuery);
} else if (addressQuery && !isNew) {
await selectAddress(addressQuery.value);
} else if (addressQuery.value && !isNew.value) {
// Viewing existing estimate - load and populate all fields
try {
estimate.value = await Api.getEstimateFromAddress(addressQuery);
estimate.value = await Api.getEstimateFromAddress(addressQuery.value);
console.log("DEBUG: Loaded estimate:", estimate.value);
if (estimate.value) {
// Set the estimate name for upserting
formData.estimateName = estimate.value.name;
await selectAddress(addressQuery);
await selectAddress(addressQuery.value);
// Set the contact from the estimate
formData.contact = estimate.value.partyName;
selectedContact.value = contacts.value.find((c) => c.name === estimate.value.partyName) || null;
@ -537,6 +585,12 @@ onMounted(async () => {
padding: 2rem;
}
.page-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 1rem;
}
.address-section,
.contact-section {
margin-bottom: 1.5rem;
@ -607,6 +661,14 @@ onMounted(async () => {
overflow-y: auto;
}
.items-modal-content {
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 1rem;
}
.search-section {
margin-bottom: 1rem;
}
@ -654,6 +716,40 @@ onMounted(async () => {
color: #856404;
}
.response-status {
margin-top: 1rem;
padding: 1rem;
border-radius: 8px;
background-color: #f9f9f9;
border: 1px solid #e0e0e0;
}
.response-status h4 {
margin: 0 0 0.5rem 0;
font-size: 1.1em;
color: #333;
}
.response-accepted {
color: #28a745;
font-weight: bold;
}
.response-rejected {
color: #dc3545;
font-weight: bold;
}
.response-requested-help {
color: #ffc107;
font-weight: bold;
}
.response-no-response {
color: #6c757d;
font-weight: bold;
}
.address-search-results {
min-height: 200px;
}

View file

@ -152,7 +152,14 @@
density="compact"
></v-btn>
</div>
<div v-if="!isSidebarCollapsed" class="unscheduled-meetings-list">
<div
v-if="!isSidebarCollapsed"
class="unscheduled-meetings-list"
:class="{ 'drag-over-unscheduled': isUnscheduledDragOver }"
@dragover="handleUnscheduledDragOver"
@dragleave="handleUnscheduledDragLeave"
@drop="handleDropToUnscheduled"
>
<div v-if="unscheduledMeetings.length === 0" class="empty-state">
<v-icon size="large" color="grey-lighten-1">mdi-calendar-check</v-icon>
<p>No unscheduled meetings</p>
@ -239,6 +246,7 @@ const isDragOver = ref(false);
const dragOverSlot = ref(null);
const draggedMeeting = ref(null);
const originalMeetingForReschedule = ref(null); // Store original meeting data for reschedules
const isUnscheduledDragOver = ref(false);
// Sidebar state
const isSidebarCollapsed = ref(false);
@ -431,6 +439,12 @@ const formatDateForUrl = (date) => {
return date.toISOString().split("T")[0];
};
const getAddressText = (address) => {
if (!address) return "";
if (typeof address === "string") return address;
return address.full_address || address.fullAddress || address.name || "";
};
const selectTimeSlot = (date, time) => {
console.log("Selected time slot:", date, time);
};
@ -537,17 +551,21 @@ const handleMeetingDragStart = (event, meeting) => {
console.log("Rescheduling meeting:", draggedMeeting.value);
};
const resetDragState = () => {
isDragOver.value = false;
dragOverSlot.value = null;
isUnscheduledDragOver.value = false;
draggedMeeting.value = null;
originalMeetingForReschedule.value = null;
};
const handleDragEnd = (event) => {
// If drag was cancelled (not dropped successfully), restore the original meeting
if (originalMeetingForReschedule.value && draggedMeeting.value?.isRescheduling) {
// Meeting wasn't successfully dropped, so it's still in the array
console.log("Drag cancelled, meeting remains in original position");
}
draggedMeeting.value = null;
originalMeetingForReschedule.value = null;
isDragOver.value = false;
dragOverSlot.value = null;
resetDragState();
};
const handleDragOver = (event, date, time) => {
@ -611,10 +629,7 @@ const handleDrop = async (event, date, time) => {
if (droppedMeeting.isRescheduling) {
await loadWeekMeetings();
}
isDragOver.value = false;
dragOverSlot.value = null;
draggedMeeting.value = null;
originalMeetingForReschedule.value = null;
resetDragState();
return;
}
@ -710,10 +725,77 @@ const handleDrop = async (event, date, time) => {
}
// Reset drag state
isDragOver.value = false;
dragOverSlot.value = null;
draggedMeeting.value = null;
originalMeetingForReschedule.value = null;
resetDragState();
};
const handleUnscheduledDragOver = (event) => {
if (!draggedMeeting.value) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
isUnscheduledDragOver.value = true;
};
const handleUnscheduledDragLeave = () => {
isUnscheduledDragOver.value = false;
};
const handleDropToUnscheduled = async (event) => {
event.preventDefault();
if (!draggedMeeting.value) return;
const droppedMeeting = { ...draggedMeeting.value };
// Only act when moving a scheduled meeting back to unscheduled
if (!droppedMeeting.isRescheduling || !droppedMeeting.id) {
resetDragState();
return;
}
try {
loadingStore.setLoading(true);
await Api.updateOnSiteMeeting(droppedMeeting.id, {
status: "Unscheduled",
start_time: null,
end_time: null,
});
// Remove from the scheduled list
meetings.value = meetings.value.filter(
(meeting) => meeting.name !== droppedMeeting.id && meeting.id !== droppedMeeting.id,
);
// Add to unscheduled list if not already present
const exists = unscheduledMeetings.value.some((m) => m.name === droppedMeeting.id);
if (!exists) {
unscheduledMeetings.value.unshift({
name: droppedMeeting.id,
address: getAddressText(droppedMeeting.address),
notes: droppedMeeting.notes || "",
status: "Unscheduled",
assigned_employee: droppedMeeting.assigned_employee || "",
});
}
notificationStore.addNotification({
type: "success",
title: "Meeting Unscheduled",
message: "Meeting moved back to the unscheduled list",
duration: 4000,
});
} catch (error) {
console.error("Error unscheduling meeting:", error);
notificationStore.addNotification({
type: "error",
title: "Error",
message: "Failed to move meeting to unscheduled",
duration: 5000,
});
} finally {
loadingStore.setLoading(false);
resetDragState();
}
};
const loadUnscheduledMeetings = async () => {
@ -1001,6 +1083,12 @@ watch(
padding: 8px 0;
}
.unscheduled-meetings-list.drag-over-unscheduled {
border: 2px dashed #4caf50;
border-radius: 6px;
background-color: #f0fff3;
}
.empty-state {
display: flex;
flex-direction: column;