526 lines
13 KiB
Vue
526 lines
13 KiB
Vue
<template>
|
|
<Modal
|
|
:visible="visible"
|
|
@update:visible="$emit('update:visible', $event)"
|
|
@close="handleClose"
|
|
:options="{ showActions: false, maxWidth: '90vw', width: '1350px' }"
|
|
class="add-item-modal"
|
|
>
|
|
<template #title>
|
|
<div class="modal-title-container">
|
|
<span>Add Item</span>
|
|
<span v-if="selectedItemsCount > 0" class="selection-badge">{{ selectedItemsCount }} selected</span>
|
|
</div>
|
|
</template>
|
|
<div class="modal-content items-modal-content">
|
|
<div class="search-section">
|
|
<label for="item-search" class="field-label">Search Items</label>
|
|
<InputText
|
|
id="item-search"
|
|
v-model="searchTerm"
|
|
placeholder="Search by item code or name..."
|
|
fluid
|
|
/>
|
|
</div>
|
|
<div class="tabs-container">
|
|
<Tabs v-model="activeItemTab" v-if="itemGroups.length > 0 || packageGroups.length > 0">
|
|
<TabList>
|
|
<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">
|
|
<TabList>
|
|
<Tab v-for="packageGroup in packageGroups" :key="packageGroup" :value="packageGroup">{{ packageGroup }}</Tab>
|
|
</TabList>
|
|
<TabPanels>
|
|
<TabPanel v-for="packageGroup in packageGroups" :key="packageGroup" :value="packageGroup">
|
|
<div class="package-items-container">
|
|
<div v-for="item in getFilteredPackageItemsForGroup(packageGroup)" :key="item.itemCode" class="package-item" :class="{ 'package-item-selected': item._selected }">
|
|
<div class="package-item-header">
|
|
<Button
|
|
:icon="isPackageExpanded(item.itemCode) ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"
|
|
@click="togglePackageExpansion(item.itemCode)"
|
|
text
|
|
rounded
|
|
class="expand-button"
|
|
/>
|
|
<span class="package-item-code">{{ item.itemCode }}</span>
|
|
<span class="package-item-name">{{ item.itemName }}</span>
|
|
<span class="package-item-price">${{ item.standardRate?.toFixed(2) || '0.00' }}</span>
|
|
<Button
|
|
:label="item._selected ? 'Selected' : 'Select'"
|
|
:icon="item._selected ? 'pi pi-check' : 'pi pi-plus'"
|
|
@click="handleItemSelection(item)"
|
|
size="small"
|
|
:severity="item._selected ? 'success' : 'secondary'"
|
|
class="add-package-button"
|
|
/>
|
|
</div>
|
|
<div v-if="isPackageExpanded(item.itemCode) && item.bom" 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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TabPanel>
|
|
</TabPanels>
|
|
</Tabs>
|
|
</TabPanel>
|
|
</TabPanels>
|
|
</Tabs>
|
|
<!-- Fallback if no categories -->
|
|
<ItemSelector v-else :items="[]" empty-message="No items available. Please select a Project Template first." />
|
|
</div>
|
|
<div class="modal-actions">
|
|
<Button
|
|
label="Clear Selection"
|
|
@click="clearSelection"
|
|
severity="secondary"
|
|
:disabled="selectedItemsCount === 0"
|
|
/>
|
|
<Button
|
|
:label="`Add ${selectedItemsCount} Item${selectedItemsCount !== 1 ? 's' : ''}`"
|
|
@click="addItems"
|
|
icon="pi pi-plus"
|
|
:disabled="selectedItemsCount === 0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch } from "vue";
|
|
import Modal from "../common/Modal.vue";
|
|
import ItemSelector from "../common/ItemSelector.vue";
|
|
import InputText from "primevue/inputtext";
|
|
import Button from "primevue/button";
|
|
import Tabs from "primevue/tabs";
|
|
import TabList from "primevue/tablist";
|
|
import Tab from "primevue/tab";
|
|
import TabPanels from "primevue/tabpanels";
|
|
import TabPanel from "primevue/tabpanel";
|
|
|
|
const props = defineProps({
|
|
visible: {
|
|
type: Boolean,
|
|
required: true
|
|
},
|
|
quotationItems: {
|
|
type: Object,
|
|
default: () => ({})
|
|
}
|
|
});
|
|
|
|
const emit = defineEmits(['update:visible', 'add-items']);
|
|
|
|
const searchTerm = ref("");
|
|
const expandedPackageItems = ref(new Set());
|
|
const selectedItemsInModal = ref(new Set());
|
|
|
|
const itemGroups = computed(() => {
|
|
if (!props.quotationItems || typeof props.quotationItems !== 'object') return [];
|
|
// Get all keys except 'Packages'
|
|
const groups = Object.keys(props.quotationItems).filter(key => key !== 'Packages').sort();
|
|
return groups;
|
|
});
|
|
|
|
const packageGroups = computed(() => {
|
|
if (!props.quotationItems?.Packages || typeof props.quotationItems.Packages !== 'object') return [];
|
|
return Object.keys(props.quotationItems.Packages).sort();
|
|
});
|
|
|
|
// Active tabs with default to first category
|
|
const activeItemTab = computed({
|
|
get: () => _activeItemTab.value || itemGroups.value[0] || "",
|
|
set: (val) => { _activeItemTab.value = val; }
|
|
});
|
|
|
|
const activePackageTab = computed({
|
|
get: () => _activePackageTab.value || packageGroups.value[0] || "",
|
|
set: (val) => { _activePackageTab.value = val; }
|
|
});
|
|
|
|
const _activeItemTab = ref("");
|
|
const _activePackageTab = ref("");
|
|
|
|
const getFilteredItemsForGroup = (group) => {
|
|
if (!props.quotationItems || typeof props.quotationItems !== 'object') return [];
|
|
|
|
let items = [];
|
|
|
|
// Get items from the specified group
|
|
if (group && props.quotationItems[group]) {
|
|
items = [...props.quotationItems[group]];
|
|
}
|
|
|
|
// Filter by search term
|
|
if (searchTerm.value.trim()) {
|
|
const term = searchTerm.value.toLowerCase();
|
|
items = items.filter(
|
|
(item) =>
|
|
item.itemCode?.toLowerCase().includes(term) ||
|
|
item.itemName?.toLowerCase().includes(term),
|
|
);
|
|
}
|
|
|
|
return items.map((item) => ({
|
|
...item,
|
|
id: item.itemCode,
|
|
_selected: selectedItemsInModal.value.has(item.itemCode)
|
|
}));
|
|
};
|
|
|
|
const getFilteredPackageItemsForGroup = (packageGroup) => {
|
|
if (!props.quotationItems?.Packages || typeof props.quotationItems.Packages !== 'object') return [];
|
|
|
|
let items = [];
|
|
|
|
// Get items from the specified package group
|
|
if (packageGroup && props.quotationItems.Packages[packageGroup]) {
|
|
items = [...props.quotationItems.Packages[packageGroup]];
|
|
}
|
|
|
|
// Filter by search term
|
|
if (searchTerm.value.trim()) {
|
|
const term = searchTerm.value.toLowerCase();
|
|
items = items.filter(
|
|
(item) =>
|
|
item.itemCode?.toLowerCase().includes(term) ||
|
|
item.itemName?.toLowerCase().includes(term),
|
|
);
|
|
}
|
|
|
|
return items.map((item) => ({
|
|
...item,
|
|
id: item.itemCode,
|
|
_selected: selectedItemsInModal.value.has(item.itemCode)
|
|
}));
|
|
};
|
|
|
|
const selectedItemsCount = computed(() => selectedItemsInModal.value.size);
|
|
|
|
const togglePackageExpansion = (itemCode) => {
|
|
if (expandedPackageItems.value.has(itemCode)) {
|
|
expandedPackageItems.value.delete(itemCode);
|
|
} else {
|
|
expandedPackageItems.value.add(itemCode);
|
|
}
|
|
// Force reactivity
|
|
expandedPackageItems.value = new Set(expandedPackageItems.value);
|
|
};
|
|
|
|
const isPackageExpanded = (itemCode) => {
|
|
return expandedPackageItems.value.has(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));
|
|
} else {
|
|
// From package card - toggle single item
|
|
if (selectedItemsInModal.value.has(itemOrRows.itemCode)) {
|
|
selectedItemsInModal.value.delete(itemOrRows.itemCode);
|
|
} else {
|
|
selectedItemsInModal.value.add(itemOrRows.itemCode);
|
|
}
|
|
// Force reactivity
|
|
selectedItemsInModal.value = new Set(selectedItemsInModal.value);
|
|
}
|
|
};
|
|
|
|
const clearSelection = () => {
|
|
selectedItemsInModal.value = new Set();
|
|
};
|
|
|
|
const addItems = () => {
|
|
// Get all selected items from all categories
|
|
const allItems = [];
|
|
|
|
// Collect from regular categories
|
|
if (props.quotationItems && typeof props.quotationItems === 'object') {
|
|
Object.keys(props.quotationItems).forEach(key => {
|
|
if (key !== 'Packages' && Array.isArray(props.quotationItems[key])) {
|
|
props.quotationItems[key].forEach(item => {
|
|
if (selectedItemsInModal.value.has(item.itemCode)) {
|
|
allItems.push(item);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Collect from Packages sub-categories
|
|
if (props.quotationItems.Packages && typeof props.quotationItems.Packages === 'object') {
|
|
Object.keys(props.quotationItems.Packages).forEach(subKey => {
|
|
if (Array.isArray(props.quotationItems.Packages[subKey])) {
|
|
props.quotationItems.Packages[subKey].forEach(item => {
|
|
if (selectedItemsInModal.value.has(item.itemCode)) {
|
|
allItems.push(item);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
if (allItems.length > 0) {
|
|
emit('add-items', allItems);
|
|
selectedItemsInModal.value = new Set();
|
|
}
|
|
};
|
|
|
|
const handleClose = () => {
|
|
selectedItemsInModal.value = new Set();
|
|
searchTerm.value = "";
|
|
emit('update:visible', false);
|
|
};
|
|
|
|
// Watch modal visibility to reset state when closing
|
|
watch(() => props.visible, (newVal) => {
|
|
if (newVal) {
|
|
// Modal is opening - reset to first tabs
|
|
_activeItemTab.value = "";
|
|
_activePackageTab.value = "";
|
|
} else {
|
|
// Modal is closing - reset state
|
|
selectedItemsInModal.value = new Set();
|
|
searchTerm.value = "";
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.items-modal-content {
|
|
height: 70vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.tabs-container {
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.tabs-container :deep(.p-tabs) {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
}
|
|
|
|
.tabs-container :deep(.p-tabpanels) {
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.tabs-container :deep(.p-tabpanel) {
|
|
height: 100%;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.search-section {
|
|
/* margin removed - parent gap handles spacing */
|
|
}
|
|
|
|
.field-label {
|
|
display: block;
|
|
margin-bottom: 0.5rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.tip-section {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.75rem;
|
|
background-color: #e3f2fd;
|
|
border: 1px solid #2196f3;
|
|
border-radius: 4px;
|
|
color: #1565c0;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.tip-section i {
|
|
color: #2196f3;
|
|
}
|
|
|
|
.tip-section kbd {
|
|
background-color: #fff;
|
|
border: 1px solid #ccc;
|
|
border-radius: 3px;
|
|
padding: 2px 6px;
|
|
font-family: monospace;
|
|
font-size: 0.85em;
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.modal-title-container {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.selection-badge {
|
|
background-color: #2196f3;
|
|
color: white;
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 12px;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.modal-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 0.75rem;
|
|
padding-top: 1rem;
|
|
border-top: 1px solid #e0e0e0;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.nested-tabs {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
}
|
|
|
|
.nested-tabs :deep(.p-tabs) {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
}
|
|
|
|
.nested-tabs :deep(.p-tabpanels) {
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.nested-tabs :deep(.p-tabpanel) {
|
|
height: 100%;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.package-items-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
height: 100%;
|
|
overflow-y: scroll;
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
.package-item {
|
|
border: 1px solid #e0e0e0;
|
|
border-radius: 6px;
|
|
padding: 0.75rem;
|
|
background-color: #fafafa;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.package-item:hover {
|
|
background-color: #f0f0f0;
|
|
border-color: #2196f3;
|
|
}
|
|
|
|
.package-item-selected {
|
|
background-color: #e3f2fd;
|
|
border-color: #2196f3;
|
|
border-width: 2px;
|
|
}
|
|
|
|
.package-item-header {
|
|
display: grid;
|
|
grid-template-columns: 40px 120px 1fr 100px 80px;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.expand-button {
|
|
width: 2rem;
|
|
height: 2rem;
|
|
}
|
|
|
|
.package-item-code {
|
|
font-weight: 600;
|
|
color: #333;
|
|
font-family: monospace;
|
|
}
|
|
|
|
.package-item-name {
|
|
color: #555;
|
|
}
|
|
|
|
.package-item-price {
|
|
font-weight: 600;
|
|
color: #2196f3;
|
|
text-align: right;
|
|
}
|
|
|
|
.add-package-button {
|
|
justify-self: end;
|
|
}
|
|
|
|
.bom-details {
|
|
margin-top: 0.75rem;
|
|
padding: 0.75rem;
|
|
background-color: #fff;
|
|
border: 1px solid #e0e0e0;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.bom-header {
|
|
font-weight: 600;
|
|
margin-bottom: 0.5rem;
|
|
color: #666;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.bom-item {
|
|
display: grid;
|
|
grid-template-columns: 120px 1fr 100px;
|
|
gap: 1rem;
|
|
padding: 0.5rem;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
align-items: center;
|
|
}
|
|
|
|
.bom-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.bom-item-code {
|
|
font-family: monospace;
|
|
color: #666;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.bom-item-name {
|
|
color: #555;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.bom-item-qty {
|
|
color: #888;
|
|
font-size: 0.85rem;
|
|
text-align: right;
|
|
}
|
|
</style>
|