587 lines
14 KiB
Vue
587 lines
14 KiB
Vue
<template>
|
|
<div class="estimate-page">
|
|
<h2>{{ isNew ? 'Create Estimate' : 'View Estimate' }}</h2>
|
|
|
|
<!-- 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
|
|
/>
|
|
<div v-if="selectedContact" class="verification-info">
|
|
<strong>Email:</strong> {{ selectedContact.customEmail || "N/A" }} <br />
|
|
<strong>Phone:</strong> {{ selectedContact.phone || "N/A" }} <br />
|
|
<strong>Primary Contact:</strong>
|
|
{{ selectedContact.isPrimaryContact ? "Yes" : "No" }}
|
|
</div>
|
|
</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>
|
|
<InputNumber
|
|
v-model="item.qty"
|
|
:min="1"
|
|
:disabled="!isEditable"
|
|
showButtons
|
|
buttonLayout="horizontal"
|
|
@input="updateTotal"
|
|
/>
|
|
<span>Price: ${{ (item.standardRate || 0).toFixed(2) }}</span>
|
|
<span>Total: ${{ ((item.qty || 0) * (item.standardRate || 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 v-if="isEditable" class="action-buttons">
|
|
<Button label="Clear Items" @click="clearItems" severity="secondary" />
|
|
<Button
|
|
label="Submit"
|
|
@click="showConfirmationModal = true"
|
|
:disabled="selectedItems.length === 0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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">
|
|
<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>
|
|
<DataTable
|
|
:data="filteredItems"
|
|
:columns="itemColumns"
|
|
:tableName="'estimate-items'"
|
|
:tableActions="tableActions"
|
|
selectable
|
|
:paginator="false"
|
|
:rows="filteredItems.length"
|
|
/>
|
|
</div>
|
|
</Modal>
|
|
|
|
<!-- Confirmation Modal -->
|
|
<Modal
|
|
:visible="showConfirmationModal"
|
|
@update:visible="showConfirmationModal = $event"
|
|
@close="showConfirmationModal = false"
|
|
>
|
|
<template #title>Confirm Estimate</template>
|
|
<div class="modal-content">
|
|
<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>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>
|
|
<div class="confirmation-buttons">
|
|
<Button
|
|
label="No"
|
|
@click="showConfirmationModal = false"
|
|
severity="secondary"
|
|
/>
|
|
<Button label="Yes" @click="confirmSubmit" />
|
|
</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 DataTable from "../common/DataTable.vue";
|
|
import InputText from "primevue/inputtext";
|
|
import InputNumber from "primevue/inputnumber";
|
|
import Button from "primevue/button";
|
|
import Select from "primevue/select";
|
|
import Api from "../../api";
|
|
import DataUtils from "../../utils";
|
|
import { useLoadingStore } from "../../stores/loading";
|
|
import { useNotificationStore } from "../../stores/notifications-primevue";
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const loadingStore = useLoadingStore();
|
|
const notificationStore = useNotificationStore();
|
|
|
|
const addressQuery = route.query.address;
|
|
const isNew = route.query.new === "true" ? true : false;
|
|
|
|
const isSubmitting = ref(false);
|
|
|
|
const formData = reactive({
|
|
address: "",
|
|
addressName: "",
|
|
contact: "",
|
|
});
|
|
|
|
const selectedAddress = ref(null);
|
|
const selectedContact = ref(null);
|
|
const contacts = ref([]);
|
|
const contactOptions = ref([]);
|
|
const quotationItems = ref([]);
|
|
const selectedItems = ref([]);
|
|
|
|
const showAddressModal = ref(false);
|
|
const showAddItemModal = ref(false);
|
|
const showConfirmationModal = 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) return true;
|
|
if (!estimate.value) return false;
|
|
// If docstatus is 0 (draft), allow editing of contact and items
|
|
return estimate.value.docstatus === 0;
|
|
});
|
|
|
|
const itemColumns = [
|
|
{ label: "Item Code", fieldName: "itemCode", type: "text" },
|
|
{ label: "Item Name", fieldName: "itemName", type: "text" },
|
|
{ label: "Price", fieldName: "standardRate", type: "number" },
|
|
];
|
|
|
|
// Methods
|
|
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,
|
|
}));
|
|
const primary = contacts.value.find((c) => c.isPrimaryContact);
|
|
console.log("DEBUG: Selected address contacts:", contacts.value);
|
|
const existingContactName = estimate.value ? contacts.value.find((c) => c.fullName === estimate.value.partyName)?.name || "" : null;
|
|
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 });
|
|
}
|
|
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 });
|
|
}
|
|
});
|
|
showAddItemModal.value = false;
|
|
};
|
|
|
|
const closeAddItemModal = () => {
|
|
showAddItemModal.value = false;
|
|
};
|
|
|
|
const removeItem = (index) => {
|
|
selectedItems.value.splice(index, 1);
|
|
};
|
|
|
|
const clearItems = () => {
|
|
selectedItems.value = [];
|
|
};
|
|
|
|
const updateTotal = () => {
|
|
// Computed will update
|
|
};
|
|
|
|
const confirmSubmit = async () => {
|
|
isSubmitting.value = true;
|
|
showConfirmationModal.value = false;
|
|
try {
|
|
const data = {
|
|
addressName: formData.addressName,
|
|
contactName: selectedContact.value.name,
|
|
items: selectedItems.value.map((i) => ({ itemCode: i.itemCode, qty: i.qty })),
|
|
};
|
|
await Api.createEstimate(data);
|
|
notificationStore.addSuccess("Estimate created successfully", "success");
|
|
router.push(`/estimate?address=${encodeURIComponent(formData.address)}`);
|
|
// Reset form
|
|
formData.address = "";
|
|
formData.addressName = "";
|
|
formData.contact = "";
|
|
selectedAddress.value = null;
|
|
selectedContact.value = null;
|
|
selectedItems.value = [];
|
|
} catch (error) {
|
|
console.error("Error creating estimate:", error);
|
|
notificationStore.addError("Failed to create estimate", "error");
|
|
} finally {
|
|
isSubmitting.value = false;
|
|
}
|
|
};
|
|
|
|
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;
|
|
return sum + qty * rate;
|
|
}, 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;
|
|
},
|
|
);
|
|
|
|
onMounted(async () => {
|
|
console.log("DEBUG: Query params:", route.query);
|
|
try {
|
|
quotationItems.value = await Api.getQuotationItems();
|
|
} catch (error) {
|
|
console.error("Error loading quotation items:", error);
|
|
}
|
|
|
|
if (addressQuery && isNew) {
|
|
// Creating new estimate - pre-fill address
|
|
await selectAddress(addressQuery);
|
|
} else if (addressQuery && !isNew) {
|
|
// Viewing existing estimate - load and populate all fields
|
|
try {
|
|
estimate.value = await Api.getEstimateFromAddress(addressQuery);
|
|
console.log("DEBUG: Loaded estimate:", estimate.value);
|
|
|
|
if (estimate.value) {
|
|
await selectAddress(addressQuery);
|
|
// Set the contact from the estimate
|
|
formData.contact = estimate.value.partyName;
|
|
selectedContact.value = contacts.value.find((c) => c.name === estimate.value.partyName) || 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);
|
|
return {
|
|
itemCode: item.itemCode,
|
|
itemName: item.itemName,
|
|
qty: item.qty,
|
|
standardRate: item.rate || fullItem?.standardRate || 0,
|
|
};
|
|
});
|
|
}
|
|
}
|
|
} 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;
|
|
}
|
|
|
|
.address-section,
|
|
.contact-section {
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.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: 2fr 1fr auto auto auto;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
margin-bottom: 0.5rem;
|
|
padding: 0.5rem;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
/* When viewing (not editing), adjust grid to remove delete button column */
|
|
.estimate-page:has(h2:contains("View")) .item-row {
|
|
grid-template-columns: 2fr 1fr auto auto;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.search-section {
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.confirmation-buttons {
|
|
display: flex;
|
|
gap: 1rem;
|
|
justify-content: flex-end;
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
</style>
|
|
<parameter name="filePath"></parameter>
|