1370 lines
37 KiB
Vue
1370 lines
37 KiB
Vue
<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>
|