import './Sales.html'; import '/imports/util/selectize/selectize.js'; import swal from 'sweetalert2'; //import 'malihu-custom-scrollbar-plugin/jquery.mCustomScrollbar.concat.min.js'; //import 'jquery-mousewheel'; //import 'malihu-custom-scrollbar-plugin'; /** * Notes: * The Sale object has a date field which stores the date as a number in the format YYYYMMDD. Converting this number into a local date is done with moment(sale.date.toString(), "YYYYMMDD").toDate(), and converting it to a number from a date can be accomplished with ~~(moment(date).format("YYYYMMDD")), where the ~~ is a bitwise not and converts a string to a number quickly and reliably. */ let QUERY_LIMIT = 20; let PREFIX = "Sales."; Template.Sales.onCreated(function() { Session.set(PREFIX + "displayNewSale", false); Meteor.subscribe("products"); Session.set(PREFIX + "sortOption", "date"); Session.set(PREFIX + "showOnlyComments", false); Tracker.autorun(function() { let sortOption = Session.get(PREFIX + "sortOption"); let sort = sortOption == 'createdAt' ? {createdAt: -1} : {date: -1, createdAt: -1}; let showOnlyComments = Session.get(PREFIX + "showOnlyComments"); let query = _.clone(Session.get(PREFIX + 'searchQuery')); if(showOnlyComments) { if(!query) query = {}; query.comment = {$exists: true}; } //if(Template.Sales.salesSubscription) Template.Sales.salesSubscription.stop(); Template.Sales.salesSubscription = Meteor.subscribe("sales", query, sort, QUERY_LIMIT, Session.get(PREFIX + 'skipCount')); Session.set(PREFIX + 'saleCount', Meteor.call('getSalesCount', Session.get(PREFIX + 'searchQuery'))); }); }); Template.Sales.onRendered(function() { $(".tableContainer").mCustomScrollbar({ scrollButtons: {enable:true}, theme: "light-thick", scrollbarPosition: "outside", scrollEasing: "linear" }); }); Template.Sales.onDestroyed(function() { if(Template.Sales.salesSubscription) { Template.Sales.salesSubscription.stop(); } }); Template.Sales.helpers({ displayNewSale: function() { return Session.get(PREFIX + "displayNewSale"); }, sales: function() { let sortOption = Session.get(PREFIX + "sortOption"); return Meteor.collections.Sales.find({}, {sort: (sortOption == 'createdAt' ? {createdAt: -1} : {date: -1, createdAt: -1})}); }, disablePrev: function() { return (Session.get(PREFIX + 'skipCount') || 0) == 0; }, disableNext: function() { return Session.get(PREFIX + 'saleCount') - (Session.get(PREFIX + 'skipCount') || 0) - QUERY_LIMIT <= 0; }, editing: function() { let editedSale = Session.get(PREFIX + "editedSale"); return editedSale == this._id; } }); Template.Sales.events({ 'click .newSaleButton': function(event, template) { if(template.$('.newSaleButton').hasClass('active')) { Session.set(PREFIX + 'displayNewSale', false); } else { Session.set(PREFIX + 'displayNewSale', true); Session.set(PREFIX + "editedSale", undefined); //Clear the edited sale so that only one editor is open at a time. //Set the focus to the date field of the form. Put this in a timeout so that it is queued for processing after the form is added to the DOM. setTimeout(function() { $("form[name='insertSaleForm'] input[name='date']").focus(); }, 10); } template.$('.newSaleButton').toggleClass('active'); }, 'click .prevButton': function(event, template) { if(!$(event.target).hasClass('disabled')) Session.set(PREFIX + 'skipCount', Math.max(0, (Session.get(PREFIX + 'skipCount') || 0) - QUERY_LIMIT)); }, 'click .nextButton': function(event, template) { if(!$(event.target).hasClass('disabled')) Session.set(PREFIX + 'skipCount', (Session.get(PREFIX + 'skipCount') || 0) + QUERY_LIMIT); }, 'change select[name="sortSelect"]': function(event, template) { Session.get(PREFIX + 'skipCount', 0); Session.set(PREFIX + "sortOption", $(event.target).val()); }, 'click .showOnlyComments': function(event, template) { let $button = $(event.target); Session.set(PREFIX + "showOnlyComments", !$button.hasClass('on')); $button.toggleClass('on'); }, 'click .showDuplicates': function(event, template) { FlowRouter.go('SaleDuplicates'); } }); Template.Sale.onCreated(function() { }); Template.Sale.helpers({ measureName: function(id) { return Meteor.collections.Measures.findOne({_id: id}, {fields: {name: 1}}).name; }, venueName: function(id) { return Meteor.collections.Venues.findOne({_id: id}, {fields: {name: 1}}).name; }, productName: function(id) { return Meteor.collections.Products.findOne({_id: id}, {fields: {name: 1}}).name; }, formatDateAndWeek: function(date, weekOfYear) { return moment.utc(date.toString(), "YYYYMMDD").utc().format("MM/DD/YYYY") + "(" + weekOfYear + ")"; }, formatDateTime: function(date) { return moment.utc(date).format("MM/DD/YYYY"); }, formatPrice: function(price) { return price.toLocaleString("en-US", {style: 'currency', currency: 'USD', minimumFractionDigits: 2}); }, formatTotalPrice: function(price, amount) { return (price * amount).toLocaleString("en-US", {style: 'currency', currency: 'USD', minimumFractionDigits: 2}); }, showTotalPrice: function(amount) { return amount > 1; }, commentClass: function() { return this.comment ? "hasComment" : ""; } }); Template.Sale.events({ "click .actionEdit": function(event, template) { Session.set(PREFIX + "editedSale", this._id); Session.set(PREFIX + 'displayNewSale', false); //Ensure the new sale editor is closed. template.parentTemplate().$('.newSaleButton').removeClass('active'); }, "click .saleRemove": function(event, template) { let _this = this; swal({ title: "Are you sure?", text: "This will permanently remove the sale.", type: "question", showCancelButton: true, confirmButtonColor: "#DD6B55", confirmButtonText: "Yes" }).then( function(isConfirm) { if(isConfirm) { // Meteor.collections.Sales.remove(_this._id); Meteor.call('deleteSale', _this._id); } }, function(dismiss) { } ); }, "click .editComment": function(event, template) { let _this = this; swal({ title: "Sale Comment", text: "Change the comment, or clear it to remove the comment.", input: "textarea", showCancelButton: true, closeOnConfirm: true, closeOnCancel: true, animation: "slide-from-top", inputPlaceholder: "Write a comment...", allowEscapeKey: true, inputValue: _this.comment ? _this.comment : "" }).then( function(text) { Meteor.call('editSaleComment', _this._id, text); }, function(dismiss) {} ); } }); Template.SaleEditor.onCreated(function() { this.product = Meteor.collections.Products.findOne({_id: this.data.productId}); this.selectedDate = new ReactiveVar(moment(this.data.date.toString(), "YYYYMMDD").toDate()); this.selectedVenue = new ReactiveVar(Meteor.collections.Venues.findOne({_id: this.data.venueId})); this.price = new ReactiveVar(this.data.price); this.amount = new ReactiveVar(this.data.amount); }); Template.SaleEditor.onRendered(function() { this.$('form[name="editSaleForm"]').validator(); this.$('[name="venue"]').buildCombo({cursor: Meteor.collections.Venues.find({}), selection: this.selectedVenue, comparator: function(a, b) {return a._id == b._id;}, textAttr: 'name', listClass: 'comboList'}); this.$('input[name="date"]').val(moment(this.selectedDate.get()).format("YYYY-MM-DD")); }); Template.SaleEditor.helpers({ measureName: function(id) { let measure = Meteor.collections.Measures.findOne({_id: id}, {fields: {name: 1}}); return measure ? measure.name : "???"; }, productName: function() { let product = Template.instance().product; return product ? product.name : "???"; }, price: function() { return Template.instance().price.get(); }, amount: function() { return Template.instance().amount.get(); }, total: function() { let template = Template.instance(); return (template.price.get() * template.amount.get()).toFixed(2); } }); Template.SaleEditor.events({ 'click .setDefaultPrice': function(event, template) { let date = template.selectedDate.get(); let prices = template.product.prices; let priceData; let price = 0; if(prices) priceData = prices[template.data.measureId]; //If this product has pricing data for the given measure, then either use the price, or the previousPrice (if there is one and the effectiveDate is after the sale date). if(priceData) { if(priceData.effectiveDate && date && moment.utc(priceData.effectiveDate.toString(), "YYYYMMDD").isAfter(date)) price = priceData.previousPrice; else price = priceData.price } template.price.set(price); }, 'change input[name="date"]': function(event, template) { template.selectedDate.set(moment(event.target.value, "YYYY-MM-DD").toDate()); }, 'change .price': function(event, template) { template.price.set(parseFloat($(event.target).val())); }, 'change .amount': function(event, template) { template.amount.set(parseFloat($(event.target).val())); }, "click .editorCancel": function(event, template) { Session.set(PREFIX + "editedSale", undefined); }, "click .editorApply": function(event, template) { template.$('form[name="editSaleForm"]').data('bs.validator').validate(function(isValid) { if(isValid) { let id = template.data._id; let date = ~~(moment(template.selectedDate.get()).format("YYYYMMDD")); // Note: The ~~ is a bitwise not that is a fast method of converting a string to a number. let venue = template.selectedVenue.get(); let price = template.price.get(); let amount = template.amount.get(); Meteor.call("updateSale", id, date, venue._id, price, amount, function(error, result) { if(error) sAlert.error(error); else { sAlert.success("Sale updated."); Session.set(PREFIX + "editedSale", undefined); } }); } }); } }); Template.SaleSearch.helpers({ searchValue: function() { let searchFields = Session.get(PREFIX + 'searchFields'); return (searchFields && searchFields[this.columnName]) ? searchFields[this.columnName] : ''; } }); Template.SaleSearch.events({ "keyup .searchInput": _.throttle(function(event, template) { let searchQuery = Session.get(PREFIX + 'searchQuery') || {}; let searchFields = Session.get(PREFIX + 'searchFields') || {}; let searchValue = template.$(event.target).val(); if(searchValue) { if(this.number) searchValue = parseFloat(searchValue); // A collection name will be provided if there is a related table of data that will contain the text provided and will map to an ID that is then searched for in the current table of data. // For example we are displaying a table of Sales which has the ID of a Product. The Product table has a Name field and the search box searches for Product Names. The ID's of the Products found should be used to filter the Sales by Product ID. if(this.collection) { let ids = Meteor.collections[this.collection].find({[this.collectionQueryColumnName]: {$regex: searchValue, $options: 'i'}}, {fields: {[this.collectionResultColumnName]: 1}}).fetch(); //Convert the ids to an array of ids instead of an array of objects containing an id. for(let i = 0; i < ids.length; i++) {ids[i] = ids[i]._id;} searchQuery[this.columnName] = {$in: ids}; searchFields[this.columnName] = searchValue; } else { searchFields[this.columnName] = searchQuery[this.columnName] = searchValue; } } else { //Remove columns from the search query whose values are empty so we don't bother the database with them. delete searchQuery[this.columnName]; delete searchFields[this.columnName]; } Session.set(PREFIX + 'searchQuery', searchQuery); Session.set(PREFIX + 'searchFields', searchFields); Session.set(PREFIX + 'skipCount', 0); //Reset the paging of the results. }, 500) }); Template.DateRangeSearch.helpers({ startDate: function() { let searchFields = Session.get(PREFIX + 'searchFields'); let searchValue = (searchFields && searchFields[this.columnName]) ? searchFields[this.columnName] : {}; return searchValue.start ? moment(searchValue.start.toString(), "YYYYMMDD").format("MM/DD/YYYY") : ""; }, endDate: function() { let searchFields = Session.get(PREFIX + 'searchFields'); let searchValue = (searchFields && searchFields[this.columnName]) ? searchFields[this.columnName] : {}; return searchValue.end ? moment(searchValue.end.toString(), "YYYYMMDD").format("MM/DD/YYYY") : ""; } }); Template.DateRangeSearch.events({ "change .searchDateStartInput": function(event, template) {Template.DateRangeSearch.dateChanged(true, event, template)}, "keyup .searchDateStartInput": _.throttle(function(event, template) {Template.DateRangeSearch.dateChanged(true, event, template)}, 500), "change .searchDateEndInput": function(event, template) {Template.DateRangeSearch.dateChanged(false, event, template)}, "keyup .searchDateEndInput": _.throttle(function(event, template) {Template.DateRangeSearch.dateChanged(false, event, template)}, 500) }); Template.DateRangeSearch.dateChanged = function(isStart, event, template) { let searchQuery = Session.get(PREFIX + 'searchQuery') || {}; let searchFields = Session.get(PREFIX + 'searchFields') || {}; let searchValue = template.$(event.target).val(); let columnName = template.data.columnName; if(searchValue) { let search = searchQuery[columnName]; // Create a search object and attach it to the searchFields and searchQuery objects if needed. if(!search) { search = {type: 'dateRange'}; searchFields[columnName] = searchQuery[columnName] = search; } // Use moment to parse date and convert it to YYYYMMDD for searching the database. searchValue = ~~(moment(searchValue, searchValue.includes("-") ? "YYYY-MM-DD" : "MM/DD/YYYY").format("YYYYMMDD")); // Note: ~~ performs a bitwise not which is a fast method of converting a string to a number. // Save the search ending date. isStart ? search.start = searchValue : search.end = searchValue; } else { if(searchQuery[columnName]) { // Remove columns from the search query whose values are empty so we don't bother the database with them. if(isStart) { delete searchQuery[columnName].start; delete searchFields[columnName].start; } else { delete searchQuery[columnName].end; delete searchFields[columnName].end; } } } Session.set(PREFIX + 'searchQuery', searchQuery); Session.set(PREFIX + 'searchFields', searchFields); Session.set(PREFIX + 'skipCount', 0); //Reset the paging of the results. }; Template.InsertSale.onCreated(function() { this.selectedDate = new ReactiveVar(); this.selectedProduct = new ReactiveVar(); this.selectedVenue = new ReactiveVar(); }); Template.InsertSale.onRendered(function() { this.$('.insertSaleForm').validator(); //TODO: Make the query for products reactive, by putting it inside an autorun block. //TODO: Fix the combo's change event firing. It does not fire a change event when selecting an item for the first time. It's $(input).val() call also returns the name of the thing selected instead of the selected object. // Note: The combo will automatically update our selection reactive variable. No need to capture change events. this.$('[name="product"]').buildCombo({cursor: Meteor.collections.Products.find({$or: [{hidden: false}, {hidden: {$exists:false}}]}), selection: this.selectedProduct, textAttr: 'name', listClass: 'comboList', getClasses: function(data) { return (data && data.deactivated) ? "deactivated" : ""; }}); this.$('[name="venue"]').buildCombo({cursor: Meteor.collections.Venues.find({}), selection: this.selectedVenue, textAttr: 'name', listClass: 'comboList'}); }); Template.InsertSale.events({ //'change input[name="product"]': function(event, template) { // let selectedId = template.$('input[name="product"]').val(); // let selected = Meteor.collections.Products.findOne(selectedId); // template.selectedProduct.set(selected); //}, 'change input[name="date"]': function(event, template) { template.selectedDate.set(moment(event.target.value, "YYYY-MM-DD").toDate()); }, 'click input[type="submit"]': function(event, template) { event.preventDefault(); template.$('.insertSaleForm').data('bs.validator').validate(function(isValid) { if(isValid) { let sales = []; let insertSaleMeasures = template.$(".insertSaleMeasure"); let sale = { date: ~~(moment(template.find("[name='date']").value, "YYYY-MM-DD").format("YYYYMMDD")), // Note: ~~ performs a bitwise not which is a fast method of converting a string to a number. productId: template.selectedProduct.get()._id, venueId: template.selectedVenue.get()._id }; //Iterate over the measures for the sale (based on the product chosen) and collection amounts and prices. for(let next = 0; next < insertSaleMeasures.length; next++) { let nextMeasure = $(insertSaleMeasures[next]); let measureId = nextMeasure.find(".measureId").val(); let price = parseFloat(nextMeasure.find(".price").val()); let amount = parseFloat(nextMeasure.find(".amount").val()); if(amount > 0) { let nextSale = _.clone(sale); nextSale.measureId = measureId; nextSale.price = price; nextSale.amount = amount; sales.push(nextSale); } } //Iterate over the product measures that have a quantity greater than zero and add them as a sale. for(let index = 0; index < sales.length; index++) { let next = sales[index]; //console.log("Inserting: " + JSON.stringify(next)); Meteor.call('insertSale', next, function(error) { if(error) sAlert.error("Failed to insert the sale!\n" + error); else { sAlert.success("Sale Created"); //Clear the measure quantity fields so the user can enter another sale without the quantities already set. for(let next = 0; next < insertSaleMeasures.length; next++) { let nextMeasure = $(insertSaleMeasures[next]); nextMeasure.find(".amount").val(0); } //Set the focus to the product field of the form. $("form[name='insertSaleForm'] input[name='product']").focus(); //Clear the product since it is highly unlikely the same product will be added twice for the same date and market. $("form[name='insertSaleForm'] input[name='product']").val(""); } }); } } }); }, "click .speechLink": function(event, template) { try { let SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; let r = new SpeechRecognition(); r.lang='en-US'; r.interimResult = false; r.maxAlternatives = 0; r.start(); r.onresult = function(event) { let product = template.$('input[name="product"]'); product.val(event.results[0][0].transcript); let combo = product.data("de.combo"); combo.show(); combo.filter(); //Count the number of leaf nodes that are visible. combo.$list.find('li.visible[role="leaf"]').length } } catch(e) { console.error(e); } } }); Template.InsertSale.helpers({ productMeasures: function() { let product = Template.instance().selectedProduct.get(); let result = product ? product.measures : []; for(let i = 0; i < result.length; i++) { result[i] = Meteor.collections.Measures.findOne(result[i]); } // if(product) console.log("Found " + result.length + " measures for the product " + product.name); // else console.log("No product!"); return result; }, venues: function() { return Meteor.collections.Venues.find({}); } }); Template.InsertSaleMeasure.onCreated(function() { let _this = this; this.price = new ReactiveVar(0); this.amount = new ReactiveVar(0); Tracker.autorun(function() { let date = _this.parentTemplate().selectedDate.get(); let prices = _this.parentTemplate().selectedProduct.get().prices; let priceData; let price = 0; if(prices) priceData = prices[_this.data._id]; //If this product has pricing data for the given measure, then either use the price, or the previousPrice (if there is one and the effectiveDate is after the sale date). if(priceData) { if(priceData.effectiveDate && date && moment(priceData.effectiveDate).isAfter(date)) price = priceData.previousPrice; else price = priceData.price } _this.price.set(price); }); }); Template.InsertSaleMeasure.events({ 'change .price': function(event, template) { template.price.set(parseFloat($(event.target).val())); }, 'change .amount': function(event, template) { template.amount.set(parseFloat($(event.target).val())); }, 'focus input[name="amount"],input[name="price"]': function(event, template) { //See http://stackoverflow.com/questions/3150275/jquery-input-select-all-on-focus //Handle selecting the text in the field on receipt of focus. let $this = $(this) .one('mouseup.mouseupSelect', function() { $this.select(); return false; }) .one('mousedown', function() { // compensate for untriggered 'mouseup' caused by focus via tab $this.off('mouseup.mouseupSelect'); }) .select(); } }); Template.InsertSaleMeasure.helpers({ price: function() { return Template.instance().price.get().toFixed(2); }, total: function() { let template = Template.instance(); return (template.price.get() * template.amount.get()).toFixed(2); }, amount: function() { return Template.instance().amount.get(); } });