2025-12-02 12:28:05 -06:00

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>