big package update

This commit is contained in:
Casey 2026-02-05 17:05:56 -06:00
parent 21173e34c6
commit 991038bc47
15 changed files with 3282 additions and 1248 deletions

View file

@ -19,6 +19,9 @@ const FRAPPE_GET_ESTIMATE_TEMPLATES_METHOD = "custom_ui.api.db.estimates.get_est
const FRAPPE_CREATE_ESTIMATE_TEMPLATE_METHOD = "custom_ui.api.db.estimates.create_estimate_template";
const FRAPPE_GET_UNAPPROVED_ESTIMATES_COUNT_METHOD = "custom_ui.api.db.estimates.get_unapproved_estimates_count";
const FRAPPE_GET_ESTIMATES_HALF_DOWN_COUNT_METHOD = "custom_ui.api.db.estimates.get_estimates_half_down_count";
// Item methods
const FRAPPE_SAVE_AS_PACKAGE_ITEM_METHOD = "custom_ui.api.db.items.save_as_package_item";
const FRAPPE_GET_ITEMS_BY_PROJECT_TEMPLATE_METHOD = "custom_ui.api.db.items.get_by_project_template";
// Job methods
const FRAPPE_GET_JOB_METHOD = "custom_ui.api.db.jobs.get_job";
const FRAPPE_GET_JOBS_METHOD = "custom_ui.api.db.jobs.get_jobs_table_data";
@ -657,6 +660,22 @@ class Api {
return await this.request(FRAPPE_GET_ADDRESSES_METHOD, { fields, filters });
}
// ============================================================================
// ITEM/PACKAGE METHODS
// ============================================================================
static async getItemsByProjectTemplate(projectTemplate) {
return await this.request(FRAPPE_GET_ITEMS_BY_PROJECT_TEMPLATE_METHOD, { projectTemplate });
}
static async saveAsPackageItem(data) {
return await this.request(FRAPPE_SAVE_AS_PACKAGE_ITEM_METHOD, { data });
}
static async getItemCategories() {
return await this.request("custom_ui.api.db.items.get_item_categories");
}
// ============================================================================
// SERVICE / ROUTE / TIMESHEET METHODS
// ============================================================================

View file

@ -4,17 +4,17 @@
<i class="pi pi-inbox"></i>
<p>{{ emptyMessage }}</p>
</div>
<div v-else v-for="item in items" :key="item.itemCode" class="item-card" :class="{ 'item-selected': item._selected }" @click="handleItemClick(item, $event)">
<div v-else v-for="item in items" :key="item.itemCode" class="item-card" :class="{ 'item-selected': isItemSelected(item.itemCode) }" @click="handleItemClick(item, $event)">
<div class="item-card-header">
<span class="item-code">{{ item.itemCode }}</span>
<span class="item-name">{{ item.itemName }}</span>
<span class="item-price">${{ item.standardRate?.toFixed(2) || '0.00' }}</span>
<Button
:label="item._selected ? 'Selected' : 'Select'"
:icon="item._selected ? 'pi pi-check' : 'pi pi-plus'"
:label="isItemSelected(item.itemCode) ? 'Selected' : 'Select'"
:icon="isItemSelected(item.itemCode) ? 'pi pi-check' : 'pi pi-plus'"
@click.stop="handleItemClick(item, $event)"
size="small"
:severity="item._selected ? 'success' : 'secondary'"
:severity="isItemSelected(item.itemCode) ? 'success' : 'secondary'"
class="select-item-button"
/>
</div>
@ -26,7 +26,7 @@
</template>
<script setup>
import { ref } from "vue";
import { ref, watch, computed, shallowRef } from "vue";
import Button from "primevue/button";
const props = defineProps({
@ -35,6 +35,10 @@ const props = defineProps({
required: true,
default: () => []
},
selectedItems: {
type: Array,
default: () => []
},
emptyMessage: {
type: String,
default: "No items found in this category"
@ -43,18 +47,36 @@ const props = defineProps({
const emit = defineEmits(['select']);
const selectedItems = ref([]);
const internalSelection = ref([]);
const selectionSet = shallowRef(new Set());
// Sync internal selection with prop
watch(() => props.selectedItems, (newVal) => {
internalSelection.value = [...newVal];
selectionSet.value = new Set(newVal.map(item => item.itemCode));
}, { immediate: true });
const isItemSelected = (itemCode) => {
return selectionSet.value.has(itemCode);
};
const handleItemClick = (item, event) => {
// Always multi-select mode - toggle item in selection
const index = selectedItems.value.findIndex(i => i.itemCode === item.itemCode);
const index = internalSelection.value.findIndex(i => i.itemCode === item.itemCode);
const newSet = new Set(selectionSet.value);
if (index >= 0) {
selectedItems.value.splice(index, 1);
internalSelection.value.splice(index, 1);
newSet.delete(item.itemCode);
} else {
selectedItems.value.push(item);
internalSelection.value.push(item);
newSet.add(item.itemCode);
}
// Update Set directly instead of recreating from array
selectionSet.value = newSet;
// Emit the entire selection array
emit('select', [...selectedItems.value]);
emit('select', [...internalSelection.value]);
};
</script>

View file

@ -25,15 +25,13 @@
<div class="tabs-container">
<Tabs v-model="activeItemTab" v-if="itemGroups.length > 0 || packageGroups.length > 0">
<TabList>
<Tab v-if="packageGroups.length > 0" value="Packages">
<i class="pi pi-box"></i>
<span>Packages</span>
</Tab>
<Tab v-for="group in itemGroups" :key="group" :value="group">{{ group }}</Tab>
<Tab v-if="packageGroups.length > 0" value="Packages">Packages</Tab>
</TabList>
<TabPanels>
<!-- Regular category tabs -->
<TabPanel v-for="group in itemGroups" :key="group" :value="group">
<ItemSelector :items="getFilteredItemsForGroup(group)" @select="handleItemSelection" />
</TabPanel>
<!-- Packages tab with nested sub-tabs -->
<TabPanel v-if="packageGroups.length > 0" value="Packages">
<Tabs v-model="activePackageTab" class="nested-tabs">
@ -64,12 +62,10 @@
class="add-package-button"
/>
</div>
<div v-if="isPackageExpanded(item.itemCode) && item.bom" class="bom-details">
<div v-if="isPackageExpanded(item.itemCode) && item.bom && item.bom.items" class="bom-details">
<div class="bom-header">Bill of Materials:</div>
<div v-for="bomItem in (item.bom.items || [])" :key="bomItem.itemCode" class="bom-item">
<span class="bom-item-code">{{ bomItem.itemCode }}</span>
<span class="bom-item-name">{{ bomItem.itemName }}</span>
<span class="bom-item-qty">Qty: {{ bomItem.qty }}</span>
<div v-for="bomItem in item.bom.items" :key="bomItem.itemCode" class="bom-item-wrapper">
<BomItem :item="bomItem" :parentPath="item.itemCode" :level="0" />
</div>
</div>
</div>
@ -78,6 +74,14 @@
</TabPanels>
</Tabs>
</TabPanel>
<!-- Regular category tabs -->
<TabPanel v-for="group in itemGroups" :key="group" :value="group">
<ItemSelector
:items="getFilteredItemsForGroup(group)"
:selected-items="getSelectedItemsForGroup(group)"
@select="handleItemSelection"
/>
</TabPanel>
</TabPanels>
</Tabs>
<!-- Fallback if no categories -->
@ -102,7 +106,7 @@
</template>
<script setup>
import { ref, computed, watch } from "vue";
import { ref, computed, watch, defineComponent, h, shallowRef } from "vue";
import Modal from "../common/Modal.vue";
import ItemSelector from "../common/ItemSelector.vue";
import InputText from "primevue/inputtext";
@ -127,8 +131,73 @@ const props = defineProps({
const emit = defineEmits(['update:visible', 'add-items']);
const searchTerm = ref("");
const expandedPackageItems = ref(new Set());
const selectedItemsInModal = ref(new Set());
const expandedPackageItems = shallowRef(new Set());
const selectedItemsInModal = shallowRef(new Set());
// BomItem component for recursive rendering
const BomItem = defineComponent({
name: 'BomItem',
props: {
item: Object,
parentPath: String,
level: {
type: Number,
default: 0
}
},
setup(props) {
const itemPath = computed(() => {
return props.parentPath ? `${props.parentPath}.${props.item.itemCode}` : props.item.itemCode;
});
const isPackage = computed(() => {
return props.item.bom && props.item.bom.items && props.item.bom.items.length > 0;
});
const isExpanded = computed(() => {
return expandedPackageItems.value.has(itemPath.value);
});
const toggleExpansion = () => {
if (expandedPackageItems.value.has(itemPath.value)) {
expandedPackageItems.value.delete(itemPath.value);
} else {
expandedPackageItems.value.add(itemPath.value);
}
expandedPackageItems.value = new Set(expandedPackageItems.value);
};
return () => h('div', {
class: 'bom-item',
style: { paddingLeft: `${props.level * 1}rem` }
}, [
h('div', { class: 'bom-item-content' }, [
isPackage.value ? h(Button, {
icon: isExpanded.value ? 'pi pi-chevron-down' : 'pi pi-chevron-right',
onClick: toggleExpansion,
text: true,
rounded: true,
size: 'small',
class: 'bom-expand-button'
}) : h('i', { class: 'pi pi-circle-fill bom-item-bullet' }),
isPackage.value ? h('i', { class: 'pi pi-box package-icon' }) : null,
h('span', { class: 'bom-item-code' }, props.item.itemCode),
h('span', { class: 'bom-item-name' }, props.item.itemName),
h('span', { class: 'bom-item-qty' }, `Qty: ${props.item.qty}`)
]),
isPackage.value && isExpanded.value && props.item.bom?.items ? h('div', { class: 'nested-bom' },
props.item.bom.items.map(nestedItem =>
h(BomItem, {
key: nestedItem.itemCode,
item: nestedItem,
parentPath: itemPath.value,
level: props.level + 1
})
)
) : null
]);
}
});
const itemGroups = computed(() => {
if (!props.quotationItems || typeof props.quotationItems !== 'object') return [];
@ -142,9 +211,9 @@ const packageGroups = computed(() => {
return Object.keys(props.quotationItems.Packages).sort();
});
// Active tabs with default to first category
// Active tabs with default to Packages
const activeItemTab = computed({
get: () => _activeItemTab.value || itemGroups.value[0] || "",
get: () => _activeItemTab.value || (packageGroups.value.length > 0 ? "Packages" : itemGroups.value[0]) || "",
set: (val) => { _activeItemTab.value = val; }
});
@ -176,13 +245,19 @@ const getFilteredItemsForGroup = (group) => {
);
}
return items.map((item) => ({
...item,
id: item.itemCode,
_selected: selectedItemsInModal.value.has(item.itemCode)
// Map items and mark those that are selected
return items.map((item) => ({
...item,
id: item.itemCode
}));
};
const getSelectedItemsForGroup = (group) => {
if (selectedItemsInModal.value.size === 0) return [];
const allItems = getFilteredItemsForGroup(group);
return allItems.filter(item => selectedItemsInModal.value.has(item.itemCode));
};
const getFilteredPackageItemsForGroup = (packageGroup) => {
if (!props.quotationItems?.Packages || typeof props.quotationItems.Packages !== 'object') return [];
@ -213,13 +288,13 @@ const getFilteredPackageItemsForGroup = (packageGroup) => {
const selectedItemsCount = computed(() => selectedItemsInModal.value.size);
const togglePackageExpansion = (itemCode) => {
if (expandedPackageItems.value.has(itemCode)) {
expandedPackageItems.value.delete(itemCode);
const newExpanded = new Set(expandedPackageItems.value);
if (newExpanded.has(itemCode)) {
newExpanded.delete(itemCode);
} else {
expandedPackageItems.value.add(itemCode);
newExpanded.add(itemCode);
}
// Force reactivity
expandedPackageItems.value = new Set(expandedPackageItems.value);
expandedPackageItems.value = newExpanded;
};
const isPackageExpanded = (itemCode) => {
@ -229,17 +304,31 @@ const isPackageExpanded = (itemCode) => {
const handleItemSelection = (itemOrRows) => {
// Handle both single item (from package cards) and array (from DataTable)
if (Array.isArray(itemOrRows)) {
// From DataTable - replace selection with new array
selectedItemsInModal.value = new Set(itemOrRows.map(row => row.itemCode));
// From ItemSelector - merge with existing selection
const newSelection = new Set(selectedItemsInModal.value);
const itemCodes = itemOrRows.map(row => row.itemCode);
// Check if all items are already selected
const allSelected = itemCodes.every(code => newSelection.has(code));
if (allSelected) {
// Deselect all items
itemCodes.forEach(code => newSelection.delete(code));
} else {
// Select all items
itemCodes.forEach(code => newSelection.add(code));
}
selectedItemsInModal.value = newSelection;
} else {
// From package card - toggle single item
if (selectedItemsInModal.value.has(itemOrRows.itemCode)) {
selectedItemsInModal.value.delete(itemOrRows.itemCode);
const newSelection = new Set(selectedItemsInModal.value);
if (newSelection.has(itemOrRows.itemCode)) {
newSelection.delete(itemOrRows.itemCode);
} else {
selectedItemsInModal.value.add(itemOrRows.itemCode);
newSelection.add(itemOrRows.itemCode);
}
// Force reactivity
selectedItemsInModal.value = new Set(selectedItemsInModal.value);
selectedItemsInModal.value = newSelection;
}
};
@ -378,6 +467,12 @@ watch(() => props.visible, (newVal) => {
gap: 1rem;
}
.modal-title-container :deep(.p-tab) {
display: flex;
align-items: center;
gap: 0.5rem;
}
.selection-badge {
background-color: #2196f3;
color: white;
@ -495,18 +590,40 @@ watch(() => props.visible, (newVal) => {
}
.bom-item {
display: grid;
grid-template-columns: 120px 1fr 100px;
gap: 1rem;
padding: 0.5rem;
display: flex;
flex-direction: column;
border-bottom: 1px solid #f0f0f0;
align-items: center;
}
.bom-item:last-child {
border-bottom: none;
}
.bom-item-content {
display: grid;
grid-template-columns: 32px 24px 120px 1fr 100px;
gap: 0.5rem;
padding: 0.5rem;
align-items: center;
}
.bom-expand-button {
width: 1.5rem;
height: 1.5rem;
padding: 0;
}
.bom-item-bullet {
font-size: 0.4rem;
color: #ccc;
margin-left: 0.5rem;
}
.package-icon {
color: #2196f3;
font-size: 0.9rem;
}
.bom-item-code {
font-family: monospace;
color: #666;
@ -523,4 +640,10 @@ watch(() => props.visible, (newVal) => {
font-size: 0.85rem;
text-align: right;
}
.nested-bom {
background-color: #fafafa;
border-left: 2px solid #e0e0e0;
margin-left: 1rem;
}
</style>

View file

@ -0,0 +1,382 @@
<template>
<Modal
:visible="visible"
@update:visible="$emit('update:visible', $event)"
@close="handleClose"
:options="{ showActions: false }"
>
<template #title>Save as Package</template>
<div class="modal-content">
<div class="form-section">
<label for="packageName" class="field-label">
Package Name
<span class="required">*</span>
</label>
<InputText
id="packageName"
v-model="formData.packageName"
placeholder="Enter package name"
fluid
/>
</div>
<div class="form-section">
<label for="description" class="field-label">
Description
</label>
<InputText
id="description"
v-model="formData.description"
placeholder="Enter package description (optional)"
fluid
/>
</div>
<div class="form-section">
<label for="codePrefix" class="field-label">
Code Prefix
<span class="required">*</span>
</label>
<Select
id="codePrefix"
v-model="formData.codePrefix"
:options="codePrefixOptions"
placeholder="Select a code prefix"
fluid
/>
</div>
<div class="form-section">
<label for="category" class="field-label">
Category
<span class="required">*</span>
</label>
<Select
id="category"
v-model="formData.category"
:options="categories"
placeholder="Select a category"
fluid
/>
</div>
<div class="form-section">
<label for="rate" class="field-label">
Rate
<span class="required">*</span>
</label>
<InputNumber
id="rate"
v-model="formData.rate"
mode="currency"
currency="USD"
locale="en-US"
:min="0"
placeholder="$0.00"
fluid
/>
</div>
<div class="form-section">
<h4>Package Contents</h4>
<div v-if="items.length === 0" class="no-items">
No items selected
</div>
<div v-else class="items-list">
<div
v-for="(item, index) in items"
:key="index"
class="package-item"
>
<div class="item-header" @click="toggleItemExpansion(index)">
<div class="item-info">
<i
v-if="isPackage(item)"
:class="[
'pi',
expandedItems.has(index) ? 'pi-chevron-down' : 'pi-chevron-right',
'expand-icon'
]"
></i>
<span class="item-name">{{ item.itemName || item.itemCode }}</span>
<span v-if="isPackage(item)" class="package-badge">Package</span>
</div>
<span class="item-qty">Qty: {{ item.qty || 1 }}</span>
</div>
<div
v-if="isPackage(item) && expandedItems.has(index)"
class="package-contents"
>
<div
v-for="(bomItem, bomIndex) in item.bom"
:key="bomIndex"
class="bom-item"
>
<span class="bom-item-name">{{ bomItem.itemName || bomItem.itemCode }}</span>
<span class="bom-item-qty">Qty: {{ bomItem.qty || 1 }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="action-buttons">
<Button label="Cancel" @click="handleClose" severity="secondary" />
<Button
label="Save Package"
@click="handleSave"
:disabled="!isFormValid"
/>
</div>
</div>
</Modal>
</template>
<script setup>
import { ref, reactive, computed, watch } from "vue";
import Modal from "../common/Modal.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 { useNotificationStore } from "../../stores/notifications-primevue";
const props = defineProps({
visible: {
type: Boolean,
required: true,
},
items: {
type: Array,
default: () => [],
},
defaultRate: {
type: Number,
default: 0,
},
});
const emit = defineEmits(["update:visible", "save"]);
const notificationStore = useNotificationStore();
const formData = reactive({
packageName: "",
description: "",
codePrefix: null,
category: null,
rate: null,
});
const codePrefixOptions = ref(["BLDR", "SNW-I"]);
const categories = ref([]);
const expandedItems = ref(new Set());
const isLoading = ref(false);
const isFormValid = computed(() => {
return formData.packageName.trim() !== "" &&
formData.codePrefix !== null &&
formData.category !== null &&
formData.rate !== null &&
formData.rate > 0;
});
const isPackage = (item) => {
return item.bom && Array.isArray(item.bom) && item.bom.length > 0;
};
const toggleItemExpansion = (index) => {
const item = props.items[index];
if (!isPackage(item)) return;
if (expandedItems.value.has(index)) {
expandedItems.value.delete(index);
} else {
expandedItems.value.add(index);
}
};
const fetchCategories = async () => {
try {
isLoading.value = true;
const result = await Api.getItemCategories();
categories.value = result || [];
} catch (error) {
console.error("Error fetching item categories:", error);
notificationStore.addNotification("Failed to fetch item categories", "error");
categories.value = [];
} finally {
isLoading.value = false;
}
};
const handleClose = () => {
// Reset form
formData.packageName = "";
formData.description = "";
formData.codePrefix = null;
formData.category = null;
formData.rate = null;
expandedItems.value.clear();
emit("update:visible", false);
};
const handleSave = () => {
if (!isFormValid.value) {
notificationStore.addNotification("Please fill in all required fields", "error");
return;
}
const packageData = {
packageName: formData.packageName,
description: formData.description,
codePrefix: formData.codePrefix,
category: formData.category,
rate: formData.rate,
items: props.items.map(item => ({
itemCode: item.itemCode,
itemName: item.itemName,
qty: item.qty || 1,
uom: item.uom || item.stockUom,
})),
};
emit("save", packageData);
handleClose();
};
// Watch for modal opening to fetch categories
watch(
() => props.visible,
(newVal) => {
if (newVal) {
fetchCategories();
// Set rate to defaultRate when modal opens
if (props.defaultRate > 0) {
formData.rate = props.defaultRate;
}
}
}
);
</script>
<style scoped>
.modal-content {
padding: 1.5rem;
max-height: 70vh;
overflow-y: auto;
}
.form-section {
margin-bottom: 1.5rem;
}
.field-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #333;
}
.required {
color: red;
}
.no-items {
padding: 2rem;
text-align: center;
color: #666;
font-style: italic;
}
.items-list {
border: 1px solid #e0e0e0;
border-radius: 6px;
overflow: hidden;
}
.package-item {
border-bottom: 1px solid #e0e0e0;
}
.package-item:last-child {
border-bottom: none;
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
.item-header:hover {
background-color: #f8f9fa;
}
.item-info {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
}
.expand-icon {
font-size: 0.8rem;
color: #666;
}
.item-name {
font-weight: 500;
color: #333;
}
.package-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
background-color: #e3f2fd;
color: #1976d2;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.item-qty {
color: #666;
font-size: 0.9rem;
}
.package-contents {
background-color: #f8f9fa;
padding: 0.5rem 1rem 0.5rem 2.5rem;
border-top: 1px solid #e0e0e0;
}
.bom-item {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
color: #666;
font-size: 0.9rem;
}
.bom-item-name {
flex: 1;
}
.bom-item-qty {
color: #999;
}
.action-buttons {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #e0e0e0;
}
</style>

View file

@ -67,43 +67,7 @@
</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">
@ -118,102 +82,137 @@
optionLabel="name"
optionValue="name"
placeholder="Select a project template"
:disabled="!isEditable || isProjectTemplateDisabled"
:disabled="!isEditable"
fluid
/>
<div v-if="isLoadingQuotationItems" class="loading-message">
<i class="pi pi-spin pi-spinner"></i>
<span>Loading available items...</span>
</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"
:disabled="!quotationItems || Object.keys(quotationItems).length === 0"
/>
<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 class="items-header">
<h3>Items</h3>
<div v-if="isEditable" class="items-actions">
<Button
label="Add Item"
icon="pi pi-plus"
@click="showAddItemModal = true"
:disabled="!hasQuotationItems || isLoadingQuotationItems"
:loading="isLoadingQuotationItems"
/>
<Button
label="Save as Package"
icon="pi pi-save"
@click="showSavePackageModal = true"
:disabled="selectedItems.length === 0"
severity="secondary"
/>
</div>
<span>X</span>
<div class="input-wrapper">
<span class="input-label">Rate</span>
<InputNumber
v-model="item.rate"
mode="currency"
currency="USD"
locale="en-US"
:min="0"
:disabled="!isEditable"
@input="onRateChange(item)"
class="rate-input"
/>
</div>
<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 v-for="(item, index) in selectedItems" :key="item.itemCode" class="item-container">
<div class="item-row">
<div class="item-name-wrapper">
<Button
v-if="isPackageItem(item)"
:icon="isItemExpanded(item.itemCode) ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"
text
rounded
size="small"
@click="toggleItemExpansion(item.itemCode)"
class="expand-button"
/>
<i v-if="isPackageItem(item)" class="pi pi-box package-icon"></i>
<i v-else class="pi pi-circle-fill item-bullet"></i>
<span class="item-name">{{ item.itemName }}</span>
</div>
<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>X</span>
<div class="input-wrapper">
<span class="input-label">Rate</span>
<InputNumber
v-model="item.rate"
mode="currency"
currency="USD"
locale="en-US"
:min="0"
:disabled="!isEditable"
@input="onRateChange(item)"
class="rate-input"
/>
</div>
<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.rate || 0) - (item.discountAmount || 0)).toFixed(2) }}</span>
<Button
v-if="isEditable"
icon="pi pi-trash"
@click="removeItem(index)"
severity="danger"
/>
</div>
<div v-if="isPackageItem(item) && isItemExpanded(item.itemCode)" class="nested-items">
<div v-for="bomItem in item.bom.items" :key="bomItem.itemCode">
<BomItem :item="bomItem" :parentPath="item.itemCode" :level="0" />
</div>
</div>
<span>Total: ${{ ((item.qty || 0) * (item.rate || 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>
@ -301,6 +300,14 @@
:quotation-items="quotationItems"
@add-items="addSelectedItems"
/>
<!-- Save Package Modal -->
<SavePackageModal
:visible="showSavePackageModal"
@update:visible="showSavePackageModal = $event"
:items="selectedItems"
:default-rate="totalCost"
@save="handleSavePackage"
/>
<!-- Down Payment Warning Modal -->
<Modal
:visible="showDownPaymentWarningModal"
@ -400,11 +407,12 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch } from "vue";
import { ref, reactive, computed, onMounted, watch, defineComponent, h } from "vue";
import { useRoute, useRouter } from "vue-router";
import Modal from "../common/Modal.vue";
import SaveTemplateModal from "../modals/SaveTemplateModal.vue";
import AddItemModal from "../modals/AddItemModal.vue";
import SavePackageModal from "../modals/SavePackageModal.vue";
import DataTable from "../common/DataTable.vue";
import DocHistory from "../common/DocHistory.vue";
import BidMeetingNotes from "../modals/BidMeetingNotes.vue";
@ -456,12 +464,10 @@ const estimateResponse = ref(null);
const estimateResponseSelection = ref(null);
const contacts = ref([]);
const contactOptions = ref([]);
const quotationItems = 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);
@ -469,12 +475,78 @@ const showConfirmationModal = ref(false);
const showDownPaymentWarningModal = ref(false);
const showResponseModal = ref(false);
const showSaveTemplateModal = ref(false);
const showSavePackageModal = ref(false);
const addressSearchResults = ref([]);
const showDrawer = ref(false);
const isLoadingQuotationItems = ref(false);
const expandedSelectedItems = ref(new Set());
const estimate = ref(null);
const bidMeeting = ref(null);
// BomItem component for recursive rendering of nested packages
const BomItem = defineComponent({
name: 'BomItem',
props: {
item: Object,
parentPath: String,
level: {
type: Number,
default: 0
}
},
setup(props) {
const itemPath = computed(() => {
return props.parentPath ? `${props.parentPath}.${props.item.itemCode}` : props.item.itemCode;
});
const isPackage = computed(() => {
return props.item.bom && props.item.bom.items && props.item.bom.items.length > 0;
});
const isExpanded = computed(() => {
return expandedSelectedItems.value.has(itemPath.value);
});
const toggleExpansion = () => {
if (expandedSelectedItems.value.has(itemPath.value)) {
expandedSelectedItems.value.delete(itemPath.value);
} else {
expandedSelectedItems.value.add(itemPath.value);
}
expandedSelectedItems.value = new Set(expandedSelectedItems.value);
};
return () => h('div', {
class: 'nested-item',
style: { paddingLeft: `${props.level * 1}rem` }
}, [
h('div', { class: 'nested-item-content' }, [
isPackage.value ? h(Button, {
icon: isExpanded.value ? 'pi pi-chevron-down' : 'pi pi-chevron-right',
onClick: toggleExpansion,
text: true,
rounded: true,
size: 'small',
class: 'nested-expand-button'
}) : null,
isPackage.value ? h('i', { class: 'pi pi-box package-icon' }) : h('i', { class: 'pi pi-circle-fill item-bullet' }),
h('span', { class: 'nested-item-name' }, `${props.item.itemName} (Qty: ${props.item.qty})`)
]),
isPackage.value && isExpanded.value && props.item.bom?.items ? h('div', { class: 'deeply-nested-items' },
props.item.bom.items.map(nestedItem =>
h(BomItem, {
key: nestedItem.itemCode,
item: nestedItem,
parentPath: itemPath.value,
level: props.level + 1
})
)
) : null
]);
}
});
// Computed property to determine if fields are editable
const isEditable = computed(() => {
if (isNew.value) return true;
@ -483,15 +555,8 @@ const isEditable = computed(() => {
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 hasQuotationItems = computed(() => {
return quotationItems.value && Object.keys(quotationItems.value).length > 0;
});
const itemColumns = [
@ -511,87 +576,55 @@ const fetchProjectTemplates = async () => {
}
};
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 handleSavePackage = async (packageData) => {
try {
const newPackageItem = await Api.saveAsPackageItem(packageData);
// Initialize Packages object if it doesn't exist
if (!quotationItems.value.Packages) {
quotationItems.value = { ...quotationItems.value, Packages: {} };
}
// Initialize category array if it doesn't exist and add the new package
if (!quotationItems.value.Packages[packageData.category]) {
quotationItems.value.Packages = {
...quotationItems.value.Packages,
[packageData.category]: [newPackageItem]
};
} else {
// Add to existing category
quotationItems.value.Packages[packageData.category] = [
...quotationItems.value.Packages[packageData.category],
newPackageItem
];
}
// Replace all selected items with just the new package
selectedItems.value = [{
itemCode: newPackageItem.itemCode || newPackageItem.item_code,
itemName: newPackageItem.itemName || newPackageItem.item_name,
qty: 1,
rate: newPackageItem.standardRate || newPackageItem.standard_rate || packageData.rate,
standardRate: newPackageItem.standardRate || newPackageItem.standard_rate || packageData.rate,
bom: newPackageItem.bom || null,
uom: newPackageItem.uom || newPackageItem.stockUom || newPackageItem.stock_uom || 'Nos',
discountAmount: null,
discountPercentage: null,
discountType: 'currency'
}];
notificationStore.addSuccess("Package saved successfully", "success");
showSavePackageModal.value = false;
} catch (error) {
console.error("Error saving package:", error);
notificationStore.addNotification("Failed to save package", "error");
}
};
const confirmSaveTemplate = async (templateData) => {
try {
const data = {
@ -827,6 +860,23 @@ const toggleDiscountType = (item, type) => {
item.discountType = type;
};
const toggleItemExpansion = (itemCode) => {
if (expandedSelectedItems.value.has(itemCode)) {
expandedSelectedItems.value.delete(itemCode);
} else {
expandedSelectedItems.value.add(itemCode);
}
expandedSelectedItems.value = new Set(expandedSelectedItems.value);
};
const isItemExpanded = (itemCode) => {
return expandedSelectedItems.value.has(itemCode);
};
const isPackageItem = (item) => {
return item.bom && item.bom.items && item.bom.items.length > 0;
};
const onTabClick = () => {
console.log('Bid notes tab clicked');
console.log('Current showDrawer value:', showDrawer.value);
@ -853,12 +903,29 @@ watch(
);
watch(() => formData.projectTemplate, async (newValue) => {
quotationItems.value = await Api.getQuotationItems(newValue);
if (!newValue) {
quotationItems.value = {};
return;
}
isLoadingQuotationItems.value = true;
try {
quotationItems.value = await Api.getItemsByProjectTemplate(newValue);
console.log("DEBUG: quotationItems after API call:", quotationItems.value);
console.log("DEBUG: quotationItems type:", typeof quotationItems.value);
console.log("DEBUG: quotationItems keys length:", quotationItems.value ? Object.keys(quotationItems.value).length : 0);
console.log("DEBUG: hasQuotationItems computed value:", hasQuotationItems.value);
} catch (error) {
console.error("Error fetching items by project template:", error);
notificationStore.addNotification("Failed to load items for selected project template", "error");
quotationItems.value = {};
} finally {
isLoadingQuotationItems.value = false;
console.log("DEBUG: Loading finished, isLoadingQuotationItems:", isLoadingQuotationItems.value);
}
})
watch(() => company.currentCompany, () => {
if (isNew.value) {
fetchTemplates();
fetchProjectTemplates();
}
});
@ -928,12 +995,12 @@ watch(
// Load quotation items if project template is set (needed for item details)
if (formData.projectTemplate) {
quotationItems.value = await Api.getQuotationItems(formData.projectTemplate);
quotationItems.value = await Api.getItemsByProjectTemplate(formData.projectTemplate);
}
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 fullItem = Object.values(quotationItems.value).flat().find(qi => qi.itemCode === item.itemCode);
const discountPercentage = item.discountPercentage || item.discount_percentage || 0;
const discountAmount = item.discountAmount || item.discount_amount || 0;
return {
@ -972,12 +1039,12 @@ watch(
try {
bidMeeting.value = await Api.getBidMeeting(newFromMeetingQuery);
if (bidMeeting.value?.bidNotes?.quantities) {
// Ensure quotationItems is an array before using find
if (!Array.isArray(quotationItems.value)) {
quotationItems.value = [];
// Ensure quotationItems is an object before using
if (typeof quotationItems.value !== 'object' || quotationItems.value === null) {
quotationItems.value = {};
}
selectedItems.value = bidMeeting.value.bidNotes.quantities.map(q => {
const item = quotationItems.value.find(i => i.itemCode === q.item);
const item = Object.values(quotationItems.value).flat().find(i => i.itemCode === q.item);
return {
itemCode: q.item,
itemName: item?.itemName || q.item,
@ -1013,8 +1080,6 @@ onMounted(async () => {
await fetchProjectTemplates();
if (isNew.value) {
await fetchTemplates();
// Handle from-meeting query parameter
if (fromMeetingQuery.value) {
formData.fromOnsiteMeeting = fromMeetingQuery.value;
@ -1023,12 +1088,12 @@ onMounted(async () => {
bidMeeting.value = await Api.getBidMeeting(fromMeetingQuery.value);
// If new estimate and bid notes have quantities, set default items
if (isNew.value && bidMeeting.value?.bidNotes?.quantities) {
// Ensure quotationItems is an array before using find
if (!Array.isArray(quotationItems.value)) {
quotationItems.value = [];
// Ensure quotationItems is an object before using
if (typeof quotationItems.value !== 'object' || quotationItems.value === null) {
quotationItems.value = {};
}
selectedItems.value = bidMeeting.value.bidNotes.quantities.map(q => {
const item = quotationItems.value.find(i => i.itemCode === q.item);
const item = Object.values(quotationItems.value).flat().find(i => i.itemCode === q.item);
return {
itemCode: q.item,
itemName: item?.itemName || q.item,
@ -1049,7 +1114,7 @@ onMounted(async () => {
// Handle project-template query parameter
if (projectTemplateQuery.value) {
formData.projectTemplate = projectTemplateQuery.value;
quotationItems.value = await Api.getQuotationItems(projectTemplateQuery.value);
quotationItems.value = await Api.getItemsByProjectTemplate(projectTemplateQuery.value);
}
if (addressQuery.value && isNew.value) {
@ -1084,13 +1149,9 @@ onMounted(async () => {
// Load quotation items if project template is set (needed for item details)
if (formData.projectTemplate) {
quotationItems.value = await Api.getQuotationItems(formData.projectTemplate);
quotationItems.value = await Api.getItemsByProjectTemplate(formData.projectTemplate);
}
// Ensure quotationItems is an array
if (!Array.isArray(quotationItems.value)) {
quotationItems.value = [];
}
// Populate items from the estimate
if (estimate.value.items && estimate.value.items.length > 0) {
selectedItems.value = estimate.value.items.map(item => {
@ -1186,21 +1247,10 @@ onMounted(async () => {
.address-section,
.contact-section,
.project-template-section,
.template-section {
.project-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;
@ -1231,17 +1281,115 @@ onMounted(async () => {
margin-top: 2rem;
}
.items-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.items-header h3 {
margin: 0;
}
.items-actions {
display: flex;
gap: 0.5rem;
}
.item-container {
margin-bottom: 0.5rem;
}
.item-row {
display: grid;
grid-template-columns: 3fr 140px 30px 120px 220px 1.5fr auto;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.item-name-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
}
.expand-button {
padding: 0.25rem;
min-width: auto;
}
.package-icon {
color: #2196f3;
font-size: 1.1em;
}
.item-bullet {
color: #666;
font-size: 0.5em;
}
.item-name {
flex: 1;
}
.nested-items {
margin-left: 2rem;
margin-top: 0.5rem;
padding: 0.5rem;
background-color: #f9f9f9;
border-left: 3px solid #2196f3;
border-radius: 0 4px 4px 0;
}
.nested-item {
display: flex;
flex-direction: column;
border-bottom: 1px solid #f0f0f0;
}
.nested-item:last-child {
border-bottom: none;
}
.nested-item-content {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
color: #666;
font-size: 0.9em;
}
.nested-expand-button {
width: 1.5rem;
height: 1.5rem;
padding: 0;
flex-shrink: 0;
}
.nested-item-name {
flex: 1;
}
.deeply-nested-items {
background-color: #fafafa;
border-left: 2px solid #e0e0e0;
margin-left: 1rem;
margin-top: 0.25rem;
}
.nested-item .package-icon {
font-size: 1em;
}
.nested-item .item-bullet {
font-size: 0.4em;
}
.qty-input {
width: 100%;
}
@ -1436,20 +1584,6 @@ onMounted(async () => {
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;
}
@ -1487,6 +1621,19 @@ onMounted(async () => {
color: #666;
}
.loading-message {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
color: #2196f3;
font-size: 0.9rem;
}
.loading-message i {
font-size: 1.1rem;
}
.bid-notes-drawer {
box-shadow: -4px 0 8px rgba(0, 0, 0, 0.1);
}