create estimate
This commit is contained in:
parent
2ea20a86e3
commit
afa161a0cf
15 changed files with 1234 additions and 435 deletions
|
|
@ -360,7 +360,7 @@ const handleEstimateClick = (status, rowData) => {
|
|||
if (status?.toLowerCase() === "not started") {
|
||||
// Navigate to create quotation/estimate
|
||||
const address = encodeURIComponent(rowData.address);
|
||||
router.push(`/quotations?new=true&address=${address}`);
|
||||
router.push(`/estimate?new=true&address=${address}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
520
frontend/src/components/pages/Estimate.vue
Normal file
520
frontend/src/components/pages/Estimate.vue
Normal file
|
|
@ -0,0 +1,520 @@
|
|||
<template>
|
||||
<div class="estimate-page">
|
||||
<h2>Create 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"
|
||||
fluid
|
||||
/>
|
||||
<Button
|
||||
label="Search"
|
||||
icon="pi pi-search"
|
||||
@click="searchAddresses"
|
||||
:disabled="!formData.address.trim()"
|
||||
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"
|
||||
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 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"
|
||||
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 icon="pi pi-trash" @click="removeItem(index)" severity="danger" />
|
||||
</div>
|
||||
<div class="total-section">
|
||||
<strong>Total Cost: ${{ totalCost.toFixed(2) }}</strong>
|
||||
</div>
|
||||
<div 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 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 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);
|
||||
formData.contact = 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.addNotification("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.addNotification("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 () => {
|
||||
try {
|
||||
quotationItems.value = await Api.getQuotationItems();
|
||||
} catch (error) {
|
||||
console.error("Error loading quotation items:", error);
|
||||
}
|
||||
if (addressQuery) {
|
||||
await selectAddress(addressQuery);
|
||||
}
|
||||
});
|
||||
</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;
|
||||
}
|
||||
|
||||
.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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue