2026-01-16 09:06:59 -06:00

1370 lines
37 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="estimate-page">
<h2>{{ isNew ? 'Create Estimate' : 'View Estimate' }}</h2>
<div v-if="!isNew && estimate" class="page-actions">
<div v-if="estimate && estimate.customSent === 1">
<Button label="Estimate Response" @click="showResponseModal = true" />
</div>
<Button label="Duplicate" icon="pi pi-copy" @click="duplicateEstimate" />
</div>
<!-- Address Section -->
<div class="address-section">
<label for="address" class="field-label">
Address
<span class="required">*</span>
</label>
<div class="address-input-group">
<InputText
id="address"
v-model="formData.address"
placeholder="Enter address to search"
:disabled="!isNew"
fluid
/>
<Button
label="Search"
icon="pi pi-search"
@click="searchAddresses"
:disabled="!formData.address.trim() || !isNew"
class="search-button"
/>
</div>
<div v-if="selectedAddress" class="verification-info">
<strong>Customer:</strong> {{ selectedAddress.customCustomerToBill }}
</div>
</div>
<!-- Contact Section -->
<div class="contact-section">
<label for="contact" class="field-label">
Contact
<span class="required">*</span>
</label>
<Select
:key="contactOptions.length"
v-model="formData.contact"
:options="contactOptions"
optionLabel="label"
optionValue="value"
placeholder="Select a contact"
:disabled="!formData.address || !isEditable"
fluid
>
<template #option="slotProps">
<div class="contact-option">
<div class="contact-name">{{ slotProps.option.label }}</div>
<div class="contact-detail" v-if="slotProps.option.email">{{ slotProps.option.email }}</div>
<div class="contact-detail" v-if="slotProps.option.phone">{{ slotProps.option.phone }}</div>
</div>
</template>
</Select>
<div v-if="selectedContact" class="verification-info">
<strong>Email:</strong> {{ selectedContact.emailId || "N/A" }} <br />
<strong>Phone:</strong> {{ selectedContact.phone || "N/A" }} <br />
<strong>Primary Contact:</strong>
{{ selectedAddress?.primaryContact === selectedContact.name ? "Yes" : "No" }}
</div>
</div>
<!-- Template Section -->
<div class="template-section">
<div v-if="isNew">
<label for="template" class="field-label">
From Template
<i class="pi pi-question-circle help-icon" v-tooltip.right="'Pre-fills estimate items and sets default Project Template. Serves as a starting point for this estimate.'"></i>
</label>
<div class="template-input-group">
<Select
v-model="selectedTemplate"
:options="templateOptions"
optionLabel="templateName"
optionValue="name"
placeholder="Select a template"
fluid
@change="onTemplateChange"
>
<template #option="slotProps">
<div class="template-option">
<div class="template-name">{{ slotProps.option.templateName }}</div>
<div class="template-desc">{{ slotProps.option.description }}</div>
</div>
</template>
</Select>
<Button
v-if="selectedTemplate"
icon="pi pi-times"
@click="clearTemplate"
class="clear-button"
severity="secondary"
/>
</div>
</div>
<div v-else>
<Button label="Save As Template" icon="pi pi-save" @click="openSaveTemplateModal" />
</div>
</div>
<!-- Project Template Section -->
<div class="project-template-section">
<label for="projectTemplate" class="field-label">
Project Template
<span class="required">*</span>
<i class="pi pi-question-circle help-icon" v-tooltip.right="'Used when generating a Project from this estimate. Defines tasks and default settings for the new Project.'"></i>
</label>
<Select
v-model="formData.projectTemplate"
:options="projectTemplates"
optionLabel="name"
optionValue="name"
placeholder="Select a project template"
:disabled="!isEditable || isProjectTemplateDisabled"
fluid
/>
</div>
<!-- Items Section -->
<div class="items-section">
<h3>Items</h3>
<Button
v-if="isEditable"
label="Add Item"
icon="pi pi-plus"
@click="showAddItemModal = true"
/>
<div v-for="(item, index) in selectedItems" :key="item.itemCode" class="item-row">
<span>{{ item.itemName }}</span>
<div class="input-wrapper">
<span class="input-label">Quantity</span>
<InputNumber
v-model="item.qty"
:min="1"
:disabled="!isEditable"
showButtons
buttonLayout="horizontal"
@input="onQtyChange(item)"
class="qty-input"
/>
</div>
<span>Price: ${{ (item.standardRate || 0).toFixed(2) }}</span>
<div class="input-wrapper">
<span class="input-label">Discount</span>
<div class="discount-container">
<div class="discount-input-wrapper">
<InputNumber
v-if="item.discountType === 'currency'"
v-model="item.discountAmount"
mode="currency"
currency="USD"
locale="en-US"
:min="0"
:disabled="!isEditable"
@input="updateDiscountFromAmount(item)"
placeholder="$0.00"
class="discount-input"
/>
<InputNumber
v-else
v-model="item.discountPercentage"
suffix="%"
:min="0"
:max="100"
:disabled="!isEditable"
@input="updateDiscountFromPercentage(item)"
placeholder="0%"
class="discount-input"
/>
</div>
<div class="discount-toggle">
<Button
icon="pi pi-dollar"
class="p-button-sm p-button-outlined"
:class="{ 'p-button-secondary': item.discountType !== 'currency' }"
@click="toggleDiscountType(item, 'currency')"
:disabled="!isEditable"
/>
<Button
icon="pi pi-percentage"
class="p-button-sm p-button-outlined"
:class="{ 'p-button-secondary': item.discountType !== 'percentage' }"
@click="toggleDiscountType(item, 'percentage')"
:disabled="!isEditable"
/>
</div>
</div>
</div>
<span>Total: ${{ ((item.qty || 0) * (item.standardRate || 0) - (item.discountAmount || 0)).toFixed(2) }}</span>
<Button
v-if="isEditable"
icon="pi pi-trash"
@click="removeItem(index)"
severity="danger"
/>
</div>
<div class="total-section">
<strong>Total Cost: ${{ totalCost.toFixed(2) }}</strong>
</div>
<div class="half-payment-section">
<v-checkbox
v-model="formData.requiresHalfPayment"
label="Requires Half Payment"
:disabled="!isEditable"
/>
</div>
<div v-if="isEditable" class="action-buttons">
<Button label="Clear Items" @click="clearItems" severity="secondary" />
<Button
label="Save Draft"
@click="saveDraft"
:disabled="selectedItems.length === 0 || estimate?.customSent === 1"
/>
</div>
<div v-if="estimate">
<Button label="Send Estimate" @click="initiateSendEstimate" :disabled="estimate.customSent === 1"/>
</div>
<div v-if="estimate && estimate.customSent === 1" class="response-status">
<h4>Customer Response:</h4>
<span :class="getResponseClass(getResponseText(estimateResponse))">
{{ getResponseText(estimateResponse) }}
</span>
</div>
<DocHistory
v-if="!isNew && estimate && estimate.history"
:events="estimate.history"
doctype="Estimate"
/>
</div>
<!-- Manual Response Modal -->
<Modal
:visible="showResponseModal"
@update:visible="showResponseModal = $event"
@close="showResponseModal = false"
:options="{ showActions: false }"
>
<template #title>Set Response</template>
<Select v-model="estimateResponseSelection" :options="responses" placeholder="Select Response"/>
<Button label="Submit" @click="submitResponse"/>
</Modal>
<!-- Save Template Modal -->
<SaveTemplateModal
:visible="showSaveTemplateModal"
@update:visible="showSaveTemplateModal = $event"
@save="confirmSaveTemplate"
/>
<!-- Address Search Modal -->
<Modal
:visible="showAddressModal"
@update:visible="showAddressModal = $event"
@close="showAddressModal = false"
>
<template #title>Address Search Results</template>
<div class="address-search-results">
<div v-if="addressSearchResults.length === 0" class="no-results">
<i class="pi pi-info-circle"></i>
<p>No addresses found matching your search.</p>
</div>
<div v-else class="results-list">
<div
v-for="(address, index) in addressSearchResults"
:key="index"
class="address-result-item"
@click="selectAddress(address)"
>
<i class="pi pi-map-marker"></i>
<span>{{ address }}</span>
</div>
</div>
</div>
</Modal>
<!-- Add Item Modal -->
<Modal
:visible="showAddItemModal"
@update:visible="showAddItemModal = $event"
@close="closeAddItemModal"
:options="{ showActions: false }"
>
<template #title>Add Item</template>
<div class="modal-content items-modal-content">
<div class="search-section">
<label for="item-search" class="field-label">Search Items</label>
<InputText
id="item-search"
v-model="itemSearchTerm"
placeholder="Search by item code or name..."
fluid
/>
</div>
<div class="tip-section">
<i class="pi pi-info-circle"></i>
<span>Tip: Hold <kbd>Ctrl</kbd> (or <kbd>Cmd</kbd> on Mac) to select multiple items</span>
</div>
<DataTable
:data="filteredItems"
:columns="itemColumns"
:tableName="'estimate-items'"
:tableActions="tableActions"
selectable
:paginator="false"
:scrollHeight="'55vh'"
/>
</div>
</Modal>
<!-- Down Payment Warning Modal -->
<Modal
:visible="showDownPaymentWarningModal"
@update:visible="showDownPaymentWarningModal = $event"
@close="showDownPaymentWarningModal = false"
:options="{ showActions: false }"
>
<template #title>Warning</template>
<div class="modal-content">
<p>Down payment is not required for this estimate. Ok to proceed?</p>
<div class="confirmation-buttons">
<Button
label="No"
@click="showDownPaymentWarningModal = false"
severity="secondary"
/>
<Button label="Yes" @click="proceedFromWarning" />
</div>
</div>
</Modal>
<!-- Confirmation Modal -->
<Modal
:visible="showConfirmationModal"
@update:visible="showConfirmationModal = $event"
@close="showConfirmationModal = false"
:options="{ showActions: false }"
>
<template #title>Confirm Send Estimate</template>
<div class="modal-content">
<div class="company-banner">Company: {{ company.currentCompany }}</div>
<h4>Does this information look correct?</h4>
<p><strong>Address:</strong> {{ formData.address }}</p>
<p>
<strong>Contact:</strong>
{{
selectedContact
? `${selectedContact.firstName} ${selectedContact.lastName}`
: ""
}}
</p>
<p>
<strong>Email:</strong>
{{ selectedContact?.emailId || "N/A" }}
</p>
<p><strong>Items:</strong></p>
<ul>
<li v-for="item in selectedItems" :key="item.itemCode">
{{ item.itemName }} - Qty: {{ item.qty || 0 }} - Total: ${{
((item.qty || 0) * (item.standardRate || 0)).toFixed(2)
}}
</li>
</ul>
<p><strong>Total:</strong> ${{ totalCost.toFixed(2) }}</p>
<p><strong>Requires Half Payment:</strong> {{ formData.requiresHalfPayment ? 'Yes' : 'No' }}</p>
<p class="warning-text"><strong> Warning:</strong> After sending this estimate, it will be locked and cannot be edited.</p>
<div class="confirmation-buttons">
<Button
label="Cancel"
@click="showConfirmationModal = false"
severity="secondary"
/>
<Button label="Send Estimate" @click="confirmAndSendEstimate" :disabled="estimate.customSent !== 0" />
</div>
</div>
</Modal>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import Modal from "../common/Modal.vue";
import SaveTemplateModal from "../modals/SaveTemplateModal.vue";
import DataTable from "../common/DataTable.vue";
import DocHistory from "../common/DocHistory.vue";
import InputText from "primevue/inputtext";
import InputNumber from "primevue/inputnumber";
import Button from "primevue/button";
import Select from "primevue/select";
import Tooltip from "primevue/tooltip";
import Api from "../../api";
import DataUtils from "../../utils";
import { useLoadingStore } from "../../stores/loading";
import { useNotificationStore } from "../../stores/notifications-primevue";
import { useCompanyStore } from "../../stores/company";
const route = useRoute();
const router = useRouter();
const loadingStore = useLoadingStore();
const notificationStore = useNotificationStore();
const company = useCompanyStore();
const vTooltip = Tooltip;
const addressQuery = computed(() => route.query.address || "");
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 isNew = computed(() => route.query.new === "true");
const isSubmitting = ref(false);
const isDuplicating = ref(false);
const duplicatedItems = ref([]);
const formData = reactive({
address: "",
addressName: "",
contact: "",
estimateName: null,
requiresHalfPayment: false,
projectTemplate: null,
fromMeeting: null,
});
const selectedAddress = ref(null);
const selectedContact = ref(null);
const estimateResponseClass = ref(null);
const estimateResponse = ref(null);
const estimateResponseSelection = ref(null);
const contacts = ref([]);
const contactOptions = ref([]);
const quotationItems = ref([]);
const selectedItems = ref([]);
const responses = ref(["Accepted", "Rejected"]);
const templates = ref([]);
const projectTemplates = ref([]);
const selectedTemplate = ref(null);
const showAddressModal = ref(false);
const showAddItemModal = ref(false);
const showConfirmationModal = ref(false);
const showDownPaymentWarningModal = ref(false);
const showResponseModal = ref(false);
const showSaveTemplateModal = ref(false);
const addressSearchResults = ref([]);
const itemSearchTerm = ref("");
const estimate = ref(null);
// Computed property to determine if fields are editable
const isEditable = computed(() => {
if (isNew.value) return true;
if (!estimate.value) return false;
// If docstatus is 0 (draft), allow editing of contact and items
return estimate.value.customSent === 0;
});
const templateOptions = computed(() => {
return [
{ name: null, templateName: 'None', description: 'Start from scratch' },
...templates.value
];
});
const isProjectTemplateDisabled = computed(() => {
return selectedTemplate.value !== null;
});
const itemColumns = [
{ label: "Item Code", fieldName: "itemCode", type: "text" },
{ label: "Item Name", fieldName: "itemName", type: "text" },
{ label: "Price", fieldName: "standardRate", type: "number" },
];
// Methods
const fetchProjectTemplates = async () => {
try {
const result = await Api.getJobTemplates(company.currentCompany);
projectTemplates.value = [...result, { name: "Other" }];
} catch (error) {
console.error("Error fetching project templates:", error);
notificationStore.addNotification("Failed to fetch project templates", "error");
}
};
const fetchTemplates = async () => {
if (!isNew.value) return;
try {
const result = await Api.getEstimateTemplates(company.currentCompany);
templates.value = result;
// Check if template query param exists and set it after templates are loaded
const templateParam = route.query.template;
if (templateParam) {
console.log("DEBUG: Setting template from query param:", templateParam);
// Find template by name (ID) or templateName (Label)
const matchedTemplate = templates.value.find(t =>
t.name === templateParam || t.templateName === templateParam
);
if (matchedTemplate) {
console.log("DEBUG: Found matched template:", matchedTemplate);
selectedTemplate.value = matchedTemplate.name;
// Trigger template change to load items and project template
onTemplateChange();
} else {
console.log("DEBUG: No matching template found for param:", templateParam);
}
}
} catch (error) {
console.error("Error fetching templates:", error);
notificationStore.addNotification("Failed to fetch templates", "error");
}
};
const onTemplateChange = () => {
if (!selectedTemplate.value) {
// None selected - clear items and project template
selectedItems.value = [];
formData.projectTemplate = null;
return;
}
const template = templates.value.find(t => t.name === selectedTemplate.value);
console.log("DEBUG: Selected template:", template);
if (template) {
// Auto-select project template if available (check both camelCase and snake_case)
const projectTemplateValue = template.projectTemplate || template.project_template;
console.log("DEBUG: Project template value from template:", projectTemplateValue);
console.log("DEBUG: Available project templates:", projectTemplates.value);
if (projectTemplateValue) {
formData.projectTemplate = projectTemplateValue;
console.log("DEBUG: Set formData.projectTemplate to:", formData.projectTemplate);
}
if (template.items) {
selectedItems.value = template.items.map(item => ({
itemCode: item.itemCode,
itemName: item.itemName,
qty: item.quantity,
standardRate: item.rate,
discountAmount: null,
discountPercentage: item.discountPercentage,
discountType: item.discountPercentage > 0 ? 'percentage' : 'currency'
}));
// Calculate discount amounts
selectedItems.value.forEach(item => {
if (item.discountType === 'percentage') {
updateDiscountFromPercentage(item);
}
});
}
}
};
const clearTemplate = () => {
selectedTemplate.value = null;
selectedItems.value = [];
formData.projectTemplate = null;
};
const openSaveTemplateModal = () => {
showSaveTemplateModal.value = true;
};
const confirmSaveTemplate = async (templateData) => {
try {
const data = {
templateName: templateData.templateName,
description: templateData.description,
company: company.currentCompany,
sourceQuotation: estimate.value.name,
projectTemplate: formData.projectTemplate,
items: selectedItems.value.map(item => ({
itemCode: item.itemCode,
itemName: item.itemName,
description: item.description,
qty: item.qty,
standardRate: item.standardRate,
discountPercentage: item.discountPercentage
}))
};
await Api.createEstimateTemplate(data);
notificationStore.addSuccess("Template saved successfully", "success");
showSaveTemplateModal.value = false;
} catch (error) {
console.error("Error saving template:", error);
notificationStore.addNotification("Failed to save template", "error");
}
};
const searchAddresses = async () => {
const searchTerm = formData.address.trim();
if (!searchTerm) return;
try {
const results = await Api.searchAddresses(searchTerm);
addressSearchResults.value = results;
if (results.length === 0) {
notificationStore.addNotification(
"No addresses found matching your search.",
"warning",
);
} else {
showAddressModal.value = true;
}
} catch (error) {
console.error("Error searching addresses:", error);
addressSearchResults.value = [];
notificationStore.addNotification(
"Failed to search addresses. Please try again.",
"error",
);
}
};
const selectAddress = async (address) => {
formData.address = address;
selectedAddress.value = await Api.getAddressByFullAddress(address);
formData.addressName = selectedAddress.value.name;
contacts.value = selectedAddress.value.contacts;
contactOptions.value = contacts.value.map((c) => ({
label: `${c.firstName || ""} ${c.lastName || ""}`.trim() || c.name,
value: c.name,
email: c.emailId,
phone: c.phone || c.mobileNo
}));
const primary = contacts.value.find((c) => c.name === selectedAddress.value.primaryContact);
console.log("DEBUG: Selected address contacts:", contacts.value);
const existingContactName = estimate.value ? contacts.value.find((c) => c.fullName === estimate.value.partyName)?.name || "" : null;
// Check for contact query param, then existing contact, then primary, then first contact
if (contactQuery.value) {
const contactFromQuery = contacts.value.find((c) => c.name === contactQuery.value);
formData.contact = contactFromQuery ? contactFromQuery.name : (primary ? primary.name : contacts.value[0]?.name || "");
} else {
formData.contact = estimate.value ? existingContactName : primary ? primary.name : contacts.value[0]?.name || "";
}
showAddressModal.value = false;
};
const addItem = (item) => {
const existing = selectedItems.value.find((i) => i.itemCode === item.itemCode);
if (!existing) {
selectedItems.value.push({ ...item, qty: 1, discountAmount: null, discountPercentage: null, discountType: 'currency' });
}
showAddItemModal.value = false;
};
const addSelectedItems = (selectedRows) => {
selectedRows.forEach((item) => {
const existing = selectedItems.value.find((i) => i.itemCode === item.itemCode);
if (existing) {
// Increase quantity by 1 if item already exists
existing.qty += 1;
} else {
// Add new item with quantity 1
selectedItems.value.push({ ...item, qty: 1, discountAmount: null, discountPercentage: null, discountType: 'currency' });
}
});
showAddItemModal.value = false;
};
const closeAddItemModal = () => {
showAddItemModal.value = false;
};
const removeItem = (index) => {
selectedItems.value.splice(index, 1);
};
const clearItems = () => {
selectedItems.value = [];
};
const updateDiscountFromAmount = (item) => {
const total = (item.qty || 0) * (item.standardRate || 0);
if (total === 0) {
item.discountPercentage = 0;
} else {
item.discountPercentage = ((item.discountAmount || 0) / total) * 100;
}
};
const updateDiscountFromPercentage = (item) => {
const total = (item.qty || 0) * (item.standardRate || 0);
item.discountAmount = total * ((item.discountPercentage || 0) / 100);
};
const onQtyChange = (item) => {
if (item.discountType === 'percentage') {
updateDiscountFromPercentage(item);
} else {
updateDiscountFromAmount(item);
}
};
const saveDraft = async () => {
if (!formData.projectTemplate) {
notificationStore.addNotification("Project Template is required.", "error");
return;
}
isSubmitting.value = true;
try {
const data = {
address: formData.address,
addressName: formData.addressName,
contactName: selectedContact.value.name,
customer: selectedAddress.value?.customer?.name,
items: selectedItems.value.map((i) => ({
itemCode: i.itemCode,
qty: i.qty,
discountAmount: i.discountAmount,
discountPercentage: i.discountPercentage
})),
estimateName: formData.estimateName,
requiresHalfPayment: formData.requiresHalfPayment,
projectTemplate: formData.projectTemplate,
fromMeeting: formData.fromMeeting,
company: company.currentCompany
};
estimate.value = await Api.createEstimate(data);
notificationStore.addSuccess(
formData.estimateName ? "Estimate updated successfully" : "Estimate created successfully",
"success"
);
// Redirect to view mode (remove new param)
router.push(`/estimate?name=${encodeURIComponent(estimate.value.name)}`);
} catch (error) {
console.error("Error saving estimate:", error);
notificationStore.addNotification("Failed to save estimate", "error");
} finally {
isSubmitting.value = false;
}
};
const submitResponse = () => {
Api.updateEstimateResponse(estimate.value.name, estimateResponseSelection.value, false);
estimateResponse.value = estimateResponseSelection.value;
showResponseModal.value = false;
}
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 initiateSendEstimate = () => {
if (!formData.requiresHalfPayment) {
showDownPaymentWarningModal.value = true;
} else {
showConfirmationModal.value = true;
}
};
const proceedFromWarning = () => {
showDownPaymentWarningModal.value = false;
showConfirmationModal.value = true;
};
const confirmAndSendEstimate = async () => {
loadingStore.setLoading(true, "Sending estimate...");
const updatedEstimate = await Api.sendEstimateEmail(estimate.value.name);
loadingStore.setLoading(false);
notificationStore.addSuccess("Estimate sent successfully", "success");
showConfirmationModal.value = false;
notificationStore.addWarning("Estimate has been locked and can no longer be edited.", "warning");
estimate.value = updatedEstimate;
};
const toggleDiscountType = (item, type) => {
item.discountType = type;
};
const tableActions = [
{
label: "Add Selected Items",
action: addSelectedItems,
requiresMultipleSelection: true,
icon: "pi pi-plus",
style: "primary",
},
];
const totalCost = computed(() => {
return (selectedItems.value || []).reduce((sum, item) => {
const qty = item.qty || 0;
const rate = item.standardRate || 0;
const discount = item.discountAmount || 0;
return sum + (qty * rate) - discount;
}, 0);
});
const filteredItems = computed(() => {
if (!itemSearchTerm.value.trim()) {
return quotationItems.value.map((item) => ({ ...item, id: item.itemCode }));
}
const term = itemSearchTerm.value.toLowerCase();
return quotationItems.value
.filter(
(item) =>
item.itemCode.toLowerCase().includes(term) ||
item.itemName.toLowerCase().includes(term),
)
.map((item) => ({ ...item, id: item.itemCode }));
});
watch(
() => formData.contact,
(newVal) => {
selectedContact.value = contacts.value.find((c) => c.name === newVal) || null;
},
);
watch(() => company.currentCompany, () => {
if (isNew.value) {
fetchTemplates();
fetchProjectTemplates();
}
});
// Watch for query param changes to refresh page behavior
watch(
() => route.query,
async (newQuery, oldQuery) => {
// If 'new' param or address changed, reload component state
if (newQuery.new !== oldQuery.new || newQuery.address !== oldQuery.address || newQuery.name !== oldQuery.name) {
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 = "";
formData.estimateName = null;
selectedAddress.value = null;
selectedContact.value = null;
contacts.value = [];
contactOptions.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;
const newNameQuery = newQuery.name;
if (newAddressQuery && newIsNew) {
// Creating new estimate - pre-fill address
await selectAddress(newAddressQuery);
} else if ((newNameQuery || newAddressQuery) && !newIsNew) {
// Viewing existing estimate - load and populate all fields
try {
if (newNameQuery) {
estimate.value = await Api.getEstimate(newNameQuery);
} else {
estimate.value = await Api.getEstimateFromAddress(newAddressQuery);
}
if (estimate.value) {
formData.estimateName = estimate.value.name;
const fullAddress = estimate.value.fullAddress || estimate.value.full_address;
if (fullAddress) {
await selectAddress(fullAddress);
} else if (newAddressQuery) {
await selectAddress(newAddressQuery);
}
formData.contact = estimate.value.contactPerson;
selectedContact.value = contacts.value.find((c) => c.name === estimate.value.contactPerson) || null;
formData.projectTemplate = estimate.value.customProjectTemplate || estimate.value.custom_project_template || null;
if (estimate.value.items && estimate.value.items.length > 0) {
selectedItems.value = estimate.value.items.map(item => {
const fullItem = quotationItems.value.find(qi => qi.itemCode === item.itemCode);
const discountPercentage = item.discountPercentage || item.discount_percentage || 0;
const discountAmount = item.discountAmount || item.discount_amount || 0;
return {
itemCode: item.itemCode,
itemName: item.itemName,
qty: item.qty,
standardRate: item.rate || fullItem?.standardRate || 0,
discountAmount: discountAmount === 0 ? null : discountAmount,
discountPercentage: discountPercentage === 0 ? null : discountPercentage,
discountType: discountPercentage > 0 ? 'percentage' : 'currency'
};
});
}
formData.requiresHalfPayment = estimate.value.custom_requires_half_payment || false;
}
} catch (error) {
console.error("Error loading estimate:", error);
notificationStore.addNotification(
"Failed to load estimate details.",
"error"
);
}
}
}
},
{ deep: true }
);
onMounted(async () => {
console.log("DEBUG: Query params:", route.query);
try {
quotationItems.value = await Api.getQuotationItems();
} catch (error) {
console.error("Error loading quotation items:", error);
}
await fetchProjectTemplates();
if (isNew.value) {
await fetchTemplates();
// Handle from-meeting query parameter
if (fromMeetingQuery.value) {
formData.fromMeeting = fromMeetingQuery.value;
}
}
if (addressQuery.value && isNew.value) {
// Creating new estimate - pre-fill address
await selectAddress(addressQuery.value);
} else if ((nameQuery.value || addressQuery.value) && !isNew.value) {
// Viewing existing estimate - load and populate all fields
try {
if (nameQuery.value) {
estimate.value = await Api.getEstimate(nameQuery.value);
} else {
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;
const fullAddress = estimate.value.fullAddress || estimate.value.full_address;
if (fullAddress) {
await selectAddress(fullAddress);
} else if (addressQuery.value) {
await selectAddress(addressQuery.value);
}
// Set the contact from the estimate
formData.contact = estimate.value.contactPerson;
selectedContact.value = contacts.value.find((c) => c.name === estimate.value.contactPerson) || null;
formData.projectTemplate = estimate.value.customProjectTemplate || estimate.value.custom_project_template || null;
// Populate items from the estimate
if (estimate.value.items && estimate.value.items.length > 0) {
selectedItems.value = estimate.value.items.map(item => {
// Find the full item details from quotationItems
const fullItem = quotationItems.value.find(qi => qi.itemCode === item.itemCode);
const discountPercentage = item.discountPercentage || item.discount_percentage || 0;
const discountAmount = item.discountAmount || item.discount_amount || 0;
return {
itemCode: item.itemCode,
itemName: item.itemName,
qty: item.qty,
standardRate: item.rate || fullItem?.standardRate || 0,
discountAmount: discountAmount === 0 ? null : discountAmount,
discountPercentage: discountPercentage === 0 ? null : discountPercentage,
discountType: discountPercentage > 0 ? 'percentage' : 'currency'
};
});
}
formData.requiresHalfPayment = estimate.value.custom_requires_half_payment || false;
estimateResponse.value = estimate.value.customResponse;
estimateResponseSelection.value = estimate.value.customResponse;
}
} catch (error) {
console.error("Error loading estimate:", error);
notificationStore.addNotification(
"Failed to load estimate details.",
"error"
);
}
}
});
</script>
<style scoped>
.estimate-page {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.page-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 1rem;
}
.address-section,
.contact-section,
.project-template-section,
.template-section {
margin-bottom: 1.5rem;
}
.template-input-group {
display: flex;
gap: 0.5rem;
align-items: center;
}
.clear-button {
flex-shrink: 0;
}
.field-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.required {
color: red;
}
.address-input-group {
display: flex;
gap: 0.5rem;
align-items: center;
}
.search-button {
flex-shrink: 0;
}
.verification-info {
margin-top: 0.5rem;
font-size: 0.9rem;
color: #666;
}
.items-section {
margin-top: 2rem;
}
.item-row {
display: grid;
grid-template-columns: 3fr 140px 1.5fr 220px 1.5fr auto;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.qty-input {
width: 100%;
}
.qty-input :deep(.p-inputtext) {
width: 40px;
text-align: center;
padding: 0.25rem;
}
.qty-input :deep(.p-button) {
width: 2rem;
padding: 0;
}
.discount-container {
display: flex;
align-items: center;
gap: 0.5rem;
}
.discount-input-wrapper {
flex: 1;
}
.discount-input {
width: 100%;
}
.discount-input :deep(.p-inputtext) {
width: 100%;
padding: 0.5rem;
text-align: right;
}
.discount-toggle {
display: flex;
gap: 2px;
}
.discount-toggle .p-button {
padding: 0.25rem 0.5rem;
width: 2rem;
}
/* When viewing (not editing), adjust grid to remove delete button column */
.estimate-page:has(h2:contains("View")) .item-row {
grid-template-columns: 3fr 140px 1.5fr 220px 1.5fr;
}
.total-section {
margin-top: 1rem;
font-size: 1.2rem;
text-align: right;
}
.action-buttons {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1rem;
}
.modal-content {
padding: 1rem;
max-height: 70vh;
overflow-y: auto;
}
.items-modal-content {
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 1rem;
}
.search-section {
margin-bottom: 1rem;
}
.tip-section {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
margin-bottom: 1rem;
background-color: #e3f2fd;
border: 1px solid #2196f3;
border-radius: 4px;
color: #1565c0;
font-size: 0.9rem;
}
.tip-section i {
color: #2196f3;
}
.tip-section kbd {
background-color: #fff;
border: 1px solid #ccc;
border-radius: 3px;
padding: 2px 6px;
font-family: monospace;
font-size: 0.85em;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.confirmation-buttons {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1rem;
}
.warning-text {
margin-top: 1rem;
padding: 0.75rem;
background-color: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
color: #856404;
}
.company-banner {
margin-bottom: 0.75rem;
padding: 0.6rem 0.9rem;
background: linear-gradient(135deg, var(--theme-gradient-start), var(--theme-secondary-gradient-end));
border: 1px solid var(--theme-primary-strong);
border-radius: 8px;
color: var(--theme-text-light);
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.18);
}
.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;
}
.no-results {
text-align: center;
padding: 40px 20px;
color: #666;
}
.no-results i {
font-size: 2em;
color: #f39c12;
margin-bottom: 10px;
display: block;
}
.results-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.address-result-item {
padding: 12px 16px;
border: 1px solid #e0e0e0;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 12px;
}
.address-result-item:hover {
background-color: #f8f9fa;
border-color: #2196f3;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.address-result-item i {
color: #2196f3;
font-size: 1.1em;
}
.address-result-item span {
flex: 1;
font-size: 0.9em;
color: #333;
}
.template-option {
display: flex;
flex-direction: column;
}
.template-name {
font-weight: bold;
}
.template-desc {
font-size: 0.85rem;
color: #666;
}
.field-group {
margin-bottom: 1rem;
}
.help-icon {
margin-left: 0.5rem;
font-size: 0.9rem;
color: #2196f3;
cursor: help;
}
.input-wrapper {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.input-label {
font-size: 0.8rem;
color: #666;
font-weight: 500;
}
.contact-option {
display: flex;
flex-direction: column;
}
.contact-name {
font-weight: 500;
}
.contact-detail {
font-size: 0.85rem;
color: #666;
}
</style>
<parameter name="filePath"></parameter>