Add "full lookup" product search modal for new custorder page

This commit is contained in:
Lance Edgar 2022-02-08 12:21:24 -06:00
parent 025cabd1ad
commit 072f5da69d
4 changed files with 403 additions and 13 deletions

View file

@ -223,6 +223,12 @@ const TailboneAutocomplete = {
// we have nothing to go on here..
return ""
},
// returns the "raw" user input from the underlying buefy
// autocomplete component
getUserInput() {
return this.buefyValue
},
},
}

View file

@ -1,5 +1,6 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/create.mako" />
<%namespace name="product_lookup" file="/products/lookup.mako" />
<%def name="extra_styles()">
${parent.extra_styles()}
@ -54,6 +55,7 @@
<%def name="render_this_page_template()">
${parent.render_this_page_template()}
${product_lookup.tailbone_product_lookup_template()}
<script type="text/x-template" id="customer-order-creator-template">
<div>
@ -524,6 +526,15 @@
@input="productChanged">
</tailbone-autocomplete>
</b-field>
<b-button type="is-primary"
v-if="!productUUID"
@click="productFullLookup()"
icon-pack="fas"
icon-left="search">
Full Lookup
</b-button>
<b-button v-if="productUUID"
type="is-primary"
tag="a" target="_blank"
@ -822,6 +833,11 @@
</div>
</b-modal>
<tailbone-product-lookup ref="productLookup"
@canceled="productLookupCanceled"
@selected="productLookupSelected">
</tailbone-product-lookup>
<b-modal :active.sync="pastItemsShowDialog">
<div class="card">
<div class="card-content">
@ -1017,6 +1033,7 @@
<%def name="make_this_page_component()">
${parent.make_this_page_component()}
${product_lookup.tailbone_product_lookup_component()}
<script type="text/javascript">
const CustomerOrderCreator = {
@ -1096,7 +1113,6 @@
productIsKnown: true,
productUUID: null,
productDisplay: null,
productUPC: null,
productKey: null,
productKeyField: ${json.dumps(product_key_field)|n},
productKeyLabel: ${json.dumps(product_key_label)|n},
@ -1716,6 +1732,22 @@
}
},
productFullLookup() {
this.showingItemDialog = false
let term = this.$refs.productAutocomplete.getUserInput()
this.$refs.productLookup.showDialog(term)
},
productLookupCanceled() {
this.showingItemDialog = true
},
productLookupSelected(selected) {
this.clearProduct()
this.productChanged(selected.uuid)
this.showingItemDialog = true
},
copyPendingProductAttrs(from, to) {
to.upc = from.upc
to.item_id = from.item_id
@ -1738,7 +1770,6 @@
this.productIsKnown = true
this.productUUID = null
this.productDisplay = null
this.productUPC = null
this.productKey = null
this.productSize = null
this.productCaseQuantity = null
@ -1794,7 +1825,6 @@
this.productIsKnown = true
this.productUUID = selected.uuid
this.productDisplay = selected.full_description
this.productUPC = selected.upc_pretty || selected.upc
this.productKey = selected.key
this.productSize = selected.size
this.productCaseQuantity = selected.case_quantity
@ -1833,7 +1863,6 @@
}
this.productDisplay = row.product_full_description
this.productUPC = row.product_upc_pretty || row.product_upc
this.productKey = row.product_key
this.productSize = row.product_size
this.productCaseQuantity = row.case_quantity
@ -1886,7 +1915,6 @@
clearProduct() {
this.productUUID = null
this.productDisplay = null
this.productUPC = null
this.productKey = null
this.productSize = null
this.productCaseQuantity = null
@ -1936,7 +1964,6 @@
// whatever came back from handler
this.submitBatchData(params, response => {
this.productUUID = response.data.uuid
this.productUPC = response.data.upc_pretty
this.productKey = response.data.key
this.productDisplay = response.data.full_description
this.productSize = response.data.size

View file

@ -0,0 +1,257 @@
## -*- coding: utf-8; -*-
<%def name="tailbone_product_lookup_template()">
<script type="text/x-template" id="tailbone-product-lookup-template">
<div>
<b-modal :active.sync="showingDialog">
<div class="card">
<div class="card-content">
<b-field grouped>
<b-input v-model="searchTerm"
ref="searchTermInput"
@keydown.native="searchTermInputKeydown">
</b-input>
<b-button class="control"
type="is-primary"
@click="performSearch()">
Search
</b-button>
<b-checkbox v-model="searchProductKey"
native-value="true">
${request.rattail_config.product_key_title()}
</b-checkbox>
<b-checkbox v-model="searchVendorItemCode"
native-value="true">
Vendor Code
</b-checkbox>
<b-checkbox v-model="searchAlternateCode"
native-value="true">
Alt Code
</b-checkbox>
<b-checkbox v-model="searchProductBrand"
native-value="true">
Brand
</b-checkbox>
<b-checkbox v-model="searchProductDescription"
native-value="true">
Description
</b-checkbox>
</b-field>
<b-table :data="searchResults"
narrowed
icon-pack="fas"
:loading="searchResultsLoading"
:selected.sync="searchResultSelected">
<template slot-scope="props">
<b-table-column label="${request.rattail_config.product_key_title()}"
field="product_key">
{{ props.row.product_key }}
</b-table-column>
<b-table-column label="Brand"
field="brand_name">
{{ props.row.brand_name }}
</b-table-column>
<b-table-column label="Description"
field="description">
{{ props.row.description }}
{{ props.row.size }}
</b-table-column>
<b-table-column label="Unit Price"
field="unit_price">
{{ props.row.unit_price_display }}
</b-table-column>
<b-table-column label="Sale Price"
field="sale_price">
<span class="has-background-warning">
{{ props.row.sale_price_display }}
</span>
</b-table-column>
<b-table-column label="Sale Ends"
field="sale_ends">
<span class="has-background-warning">
{{ props.row.sale_ends_display }}
</span>
</b-table-column>
<b-table-column label="Department"
field="department_name">
{{ props.row.department_name }}
</b-table-column>
<b-table-column label="Vendor"
field="vendor_name">
{{ props.row.vendor_name }}
</b-table-column>
<b-table-column label="Actions">
<a :href="props.row.url"
target="_blank"
class="grid-action">
<i class="fas fa-external-link-alt"></i>
View
</a>
</b-table-column>
</template>
<template slot="empty">
<div class="content has-text-grey has-text-centered">
<p>
<b-icon
pack="fas"
icon="fas fa-sad-tear"
size="is-large">
</b-icon>
</p>
<p>Nothing here.</p>
</div>
</template>
</b-table>
<br />
<div class="level">
<div class="level-left">
<div class="level-item buttons">
<b-button @click="cancelDialog()">
Cancel
</b-button>
<b-button type="is-primary"
@click="selectResult()"
:disabled="!searchResultSelected">
Choose Selected
</b-button>
</div>
</div>
<div class="level-right">
<div class="level-item">
<span v-if="searchResultsElided"
class="has-text-danger">
{{ searchResultsElided }} results are not shown
</span>
</div>
</div>
</div>
</div>
</div>
</b-modal>
</div>
</script>
</%def>
<%def name="tailbone_product_lookup_component()">
<script type="text/javascript">
const TailboneProductLookup = {
template: '#tailbone-product-lookup-template',
data() {
return {
showingDialog: false,
searchTerm: null,
searchTermLastUsed: null,
searchProductKey: true,
searchVendorItemCode: true,
searchProductBrand: true,
searchProductDescription: true,
searchAlternateCode: true,
searchResults: [],
searchResultsLoading: false,
searchResultsElided: 0,
searchResultSelected: null,
}
},
methods: {
showDialog(term) {
this.searchResultSelected = null
if (term !== undefined) {
this.searchTerm = term
// perform search if invoked with new term
if (term != this.searchTermLastUsed) {
this.searchTermLastUsed = null
this.performSearch()
}
} else {
this.searchTerm = this.searchTermLastUsed
}
this.showingDialog = true
this.$nextTick(() => {
this.$refs.searchTermInput.focus()
})
},
searchTermInputKeydown(event) {
if (event.which == 13) {
this.performSearch()
}
},
cancelDialog() {
this.searchResultSelected = null
this.showingDialog = false
this.$emit('canceled')
},
selectResult() {
this.showingDialog = false
this.$emit('selected', this.searchResultSelected)
},
performSearch() {
if (this.searchResultsLoading) {
return
}
if (!this.searchTerm || !this.searchTerm.length) {
this.$refs.searchTermInput.focus()
return
}
this.searchResultsLoading = true
this.searchResultSelected = null
let url = '${url('products.search')}'
let params = {
term: this.searchTerm,
search_product_key: this.searchProductKey,
search_vendor_code: this.searchVendorItemCode,
search_brand_name: this.searchProductBrand,
search_description: this.searchProductDescription,
search_alt_code: this.searchAlternateCode,
}
this.$http.get(url, {params: params}).then((response) => {
this.searchTermLastUsed = params.term
this.searchResults = response.data.results
this.searchResultsElided = response.data.elided
this.searchResultsLoading = false
})
},
},
}
Vue.component('tailbone-product-lookup', TailboneProductLookup)
</script>
</%def>

View file

@ -179,9 +179,10 @@ class ProductView(MasterView):
'tailbone', 'products.print_labels', default=False)
app = self.get_rattail_app()
self.product_handler = app.get_products_handler()
# TODO: deprecate / remove this
self.handler = self.product_handler
self.products_handler = app.get_products_handler()
# TODO: deprecate / remove these
self.product_handler = self.products_handler
self.handler = self.products_handler
def query(self, session):
user = self.request.user
@ -535,7 +536,7 @@ class ProductView(MasterView):
if not product.not_for_sale:
price = product[field]
if price:
return self.product_handler.render_price(price)
return self.products_handler.render_price(price)
def render_current_price_for_grid(self, product, field):
text = self.render_price(product, field) or ""
@ -1173,7 +1174,7 @@ class ProductView(MasterView):
key = self.rattail_config.product_key()
kwargs['product_key_field'] = self.product_key_fields.get(key, key)
kwargs['image_url'] = self.product_handler.get_image_url(product)
kwargs['image_url'] = self.products_handler.get_image_url(product)
# add price history, if user has access
if self.rattail_config.versioning_enabled() and self.has_perm('versions'):
@ -1743,6 +1744,105 @@ class ProductView(MasterView):
return {'ok': True}
def search(self):
"""
Perform a product search across multiple fields, and return
the results as JSON suitable for row data for a Buefy
``<b-table>`` component.
"""
if 'term' not in self.request.GET:
# TODO: deprecate / remove this? not sure if/where it is used
return self.search_v1()
term = self.request.GET.get('term')
if not term:
return {'ok': True, 'results': []}
supported_fields = [
'product_key',
'vendor_code',
'alt_code',
'brand_name',
'description',
]
search_fields = []
for field in supported_fields:
key = 'search_{}'.format(field)
if self.request.GET.get(key) == 'true':
search_fields.append(field)
final_results = []
session = self.Session()
model = self.model
lookup_fields = []
if 'product_key' in search_fields:
lookup_fields.append('_product_key_')
if 'vendor_code' in search_fields:
lookup_fields.append('vendor_code')
if 'alt_code' in search_fields:
lookup_fields.append('alt_code')
if lookup_fields:
product = self.products_handler.locate_product_for_entry(
session, term, lookup_fields=lookup_fields)
if product:
final_results.append(self.search_normalize_result(product))
# base wildcard query
query = session.query(model.Product)
if 'brand_name' in search_fields:
query = query.outerjoin(model.Brand)
# now figure out wildcard criteria
criteria = []
for word in term.split():
if 'brand_name' in search_fields and 'description' in search_fields:
criteria.append(sa.or_(
model.Brand.name.ilike('%{}%'.format(word)),
model.Product.description.ilike('%{}%'.format(word))))
elif 'brand_name' in search_fields:
criteria.append(model.Brand.name.ilike('%{}%'.format(word)))
elif 'description' in search_fields:
criteria.append(model.Product.description.ilike('%{}%'.format(word)))
# execute wildcard query if applicable
max_results = 30 # TODO: make conifgurable?
elided = 0
if criteria:
query = query.filter(sa.and_(*criteria))
count = query.count()
if count > max_results:
elided = count - max_results
for product in query[:max_results]:
final_results.append(self.search_normalize_result(product))
return {'ok': True, 'results': final_results, 'elided': elided}
def search_normalize_result(self, product, **kwargs):
return self.products_handler.normalize_product(product, fields=[
'product_key',
'url',
'image_url',
'brand_name',
'description',
'size',
'full_description',
'department_name',
'unit_price',
'unit_price_display',
'sale_price',
'sale_price_display',
'sale_ends_display',
'vendor_name',
# TODO: should be case_size
'case_quantity',
'case_price',
'case_price_display',
'uom_choices',
])
# TODO: deprecate / remove this? not sure if/where it is used
def search_v1(self):
"""
Locate a product(s) by UPC.
@ -2027,10 +2127,10 @@ class ProductView(MasterView):
renderer='{}/batch.mako'.format(template_prefix),
permission='{}.make_batch'.format(permission_prefix))
# search (by upc)
# search
config.add_route('products.search', '/products/search')
config.add_view(cls, attr='search', route_name='products.search',
renderer='json', permission='products.view')
renderer='json', permission='products.list')
# product image
config.add_route('products.image', '/products/{uuid}/image')