big package update
This commit is contained in:
parent
21173e34c6
commit
991038bc47
15 changed files with 3282 additions and 1248 deletions
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
382
frontend/src/components/modals/SavePackageModal.vue
Normal file
382
frontend/src/components/modals/SavePackageModal.vue
Normal 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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue