Added a Sales Sheet page along with other changes.

This commit is contained in:
Wynne Crisman
2017-05-09 13:51:26 -07:00
parent 184ce1133f
commit e1b0b19589
39 changed files with 3581 additions and 5610 deletions

View File

@@ -53,6 +53,11 @@ let SalesSchema = new SimpleSchema({
// }
// }
},
comment: {
type: String,
trim: false,
optional: true
},
createdAt: {
type: Date,
label: "Created On",
@@ -62,7 +67,7 @@ let SalesSchema = new SimpleSchema({
Sales.attachSchema(SalesSchema);
if(Meteor.isServer) {
Meteor.publish('sales', function(query, limit = 100, skipCount) {
Meteor.publish('sales', function(query, sort, limit = 100, skipCount) {
let dbQuery = [];
if(query) {
@@ -90,7 +95,7 @@ if(Meteor.isServer) {
if(!_.isNumber(skipCount) || skipCount < 0) skipCount = 0;
dbQuery = dbQuery.length > 0 ? {$and: dbQuery} : {};
return Meteor.collections.Sales.find(dbQuery, {limit: limit, sort: {date: -1, createdAt: -1}, skip: skipCount});
return Meteor.collections.Sales.find(dbQuery, {limit: limit, sort, skip: skipCount});
});
// time: expects either undefined, 'weekly', or 'monthly'
// options: expects either undefined, 'markets', or 'types'
@@ -179,6 +184,21 @@ if(Meteor.isServer) {
return Sales.find(query).count();
},
insertSale: function(sale) {
check(sale, {
date: Date,
amount: Match.Where(function(x) {
check(x, Number);
return x > 0;
}),
price: Match.Where(function(x) {
check(x, Number);
return x > 0;
}),
measureId: String,
productId: String,
venueId: String,
comment: Match.Optional(String)
});
//TODO: Check the structure of sale. Use: check(sale, {name: String, ...});
sale.createdAt = new Date();
@@ -193,9 +213,45 @@ if(Meteor.isServer) {
check(id, String);
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
Sales.remove(id, {bypassCollection2: true});
Sales.remove(id);
}
else throw new Meteor.Error(403, "Not authorized.");
},
editSaleComment: function(id, comment) {
check(id, String);
check(comment, String);
//Trim and convert empty comment to undefined.
comment = comment ? comment.trim() : undefined;
comment = comment && comment.length > 0 ? comment : undefined;
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
console.log("Changed comment of " + id + " to: " + comment);
if(comment) {
Sales.update(id, {$set: {comment}}, function(error, count) {
if(error) throw new Meteor.Error(400, "Unexpected database error: " + error);
});
}
else {
Sales.update(id, {$unset: {comment: ""}}, function(error, count) {
if(error) throw new Meteor.Error(400, "Unexpected database error: " + error);
});
}
}
else throw new Meteor.Error(403, "Not authorized.");
},
updateSale: function(id, date, venueId, price, amount) {
check(id, String);
check(date, Date);
check(venueId, String);
check(price, Number);
check(amount, Number);
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
Sales.update(id, {$set: {date, venueId, price, amount}}, function(err, id) {
if(err) console.log(err);
}, {bypassCollection2: true});
}
}
});
}

160
imports/api/SalesSheet.js Normal file
View File

@@ -0,0 +1,160 @@
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { check } from 'meteor/check';
import {SimpleSchema} from 'meteor/aldeed:simple-schema';
SalesSheets = new Mongo.Collection('SalesSheets');
const SalesSheetSchema = new SimpleSchema({
name: {
type: String,
label: "Name",
optional: false,
trim: true,
index: 1,
unique: false
},
products: { //An ordered array of product id's included on the sheet.
type: Array,
label: "products",
optional: false,
defaultValue: []
},
'products.$': {
type: new SimpleSchema({
name: {
type: String,
label: "Name",
optional: false,
trim: true,
unique: false
},
productId: { //Note: Will be non-existent for headings.
type: String,
label: "Product ID",
trim: false,
regEx: SimpleSchema.RegEx.Id,
optional: true
},
measureIds: { //Note: Will be non-existent for headings.
type: [String],
label: "Measure IDs",
optional: true
}
//measureIds: {
// type: Array,
// label: "Measure IDs",
// optional: true
//},
//'measureIds.$': {
// type: String,
// label: "Measure ID",
// trim: false,
// regEx: SimpleSchema.RegEx.Id,
// optional: false
//}
})
},
createdAt: {
type: Date,
label: "Created On",
optional: false
},
updatedAt: {
type: Date,
label: "Updated On",
optional: true
}
});
SalesSheets.attachSchema(SalesSheetSchema);
if(Meteor.isServer) {
Meteor.publish('salesSheets', function() {
return SalesSheets.find({});
});
Meteor.methods({
createSalesSheet: function(name) {
check(name, String);
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
return SalesSheets.insert({name, products: [], createdAt: new Date()});
}
else throw new Meteor.Error(403, "Not authorized.");
},
// This gets ridiculous. What would be required, along with a ton of code to micro manage each change.
//updateSalesSheet_addProduct: function(id, productId, productName, productMeasures) {
//
//},
//updateSalesSheet_removeProduct: function(id, productId) {
//
//},
//updateSalesSheet_updateProduct: function(id, productId, productName) {
//
//},
//updateSalesSheet_updateProduct_addMeasure: function(id, productId, productName, productMeasures) {
//
//},
//updateSalesSheet_updateProduct_removeMeasure: function(id, productId, productName, productMeasures) {
//
//},
updateSalesSheet: function(id, name, products) {
check(id, String);
check(name, String);
check(products, [{
productId: Match.Maybe(String),
name: String,
measureIds: Match.Maybe([String])
}]);
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
try {
// Generates some queries for testing.
//console.log("db.SalesSheet.update({_id: " + id + "}, {{name: " + name + ", products: " + products + ", updatedAt: " + new Date() + "}})");
//let productList = "";
//let firstProduct = true;
//for(next of products) {
// if(firstProduct) firstProduct = false;
// else productList += ',';
// productList += '{id:"' + next.id + '",name:"' + next.name + '",measureIds:[';
// let firstMeasure = true;
// for(measureId of next.measureIds) {
// if(firstMeasure) firstMeasure = false;
// else productList += ',';
// productList += '"' + measureId + '"';
// }
// productList += ']}';
//}
//console.log("db.SalesSheet.update({_id: '" + id + "'}, {$set: {name: '" + name + "', updatedAt: " + new Date() + "}, $pull: {$exists: true}, $pushAll: [" + productList + "]})");
// Forces the object to be re-written, versus piecemeal updated.
SalesSheets.update({_id: id}, {$set: {name: name, products: products, updatedAt: new Date()}}, {validate: false}, function(err, count) {
if(err) console.log(err);
});
// Attempts to remove all products and re-add them. Note: Does not work!
//SalesSheet.update({_id: id}, {$set: {name: name, updatedAt: new Date()}, $pull: {products: {$exists: true}}}, {bypassCollection2: true}, function(err, count) {
// if(err) console.log(err);
//});
//SalesSheet.update({_id: id}, {$push: {products: {$each: [products]}}}, {bypassCollection2: true}, function(err, count) {
// if(err) console.log(err);
//});
}
catch(err) {
console.log(err);
}
}
else throw new Meteor.Error(403, "Not authorized.");
},
removeSalesSheet: function(id) {
check(id, String);
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
SalesSheets.remove(id);
}
else throw new Meteor.Error(403, "Not authorized.");
}
});
}
export default SalesSheets;

View File

@@ -46,16 +46,16 @@ if(Meteor.isServer) Meteor.publish('venues', function() {
return Venues.find({});
});
// //Requires: meteor add matb33:collection-hooks
if(Meteor.isServer) {
Venues.before.insert(function(userId, doc) {
// check(userId, String);
doc.createdAt = new Date();
});
Venues.before.update(function(userId, doc, fieldNames, modifier, options) {
modifier.$set = modifier.$set || {}; //Make sure there is an object.
modifier.$set.updatedAt = new Date();
});
// //Requires: meteor add matb33:collection-hooks
//Venues.before.insert(function(userId, doc) {
// // check(userId, String);
// doc.createdAt = new Date();
//});
//Venues.before.update(function(userId, doc, fieldNames, modifier, options) {
// modifier.$set = modifier.$set || {}; //Make sure there is an object.
// modifier.$set.updatedAt = new Date();
//});
Meteor.methods({
createVenue: function(name, type) {

View File

@@ -1,14 +1,16 @@
//import Categories from "./Category.js";
//import Subcategories from "./Subcategory.js";
import Measures from "./Measure.js";
import Venues from "./Venue.js";
import Products from "./Product.js";
import ProductTags from "./ProductTag.js";
import Sales from "./Sale.js";
import SalesSheets from "./SalesSheet.js";
import Users from "./User.js";
import UserRoles from "./Roles.js";
Meteor.collections = {Measures, Venues, Products, ProductTags, Sales, Users, UserRoles};
//Save the collections in the Meteor.collections property for easy access without name conflicts.
Meteor.collections = {Measures, Venues, Products, ProductTags, Sales, SalesSheets, Users, UserRoles};
//If this is the server then setup the default admin user if none exist.
if(Meteor.isServer) {
//Change this to find admin users, create a default admin user if none exists.
if(Users.find({}).count() == 0) {

View File

@@ -40,9 +40,18 @@ pri.route('/sales', {
name: 'Sales',
action: function(params, queryParams) {
require("/imports/ui/Sales.js");
BlazeLayout.render('Body', {content: 'Sales'});
}
});
pri.route('/salesSheets', {
name: 'SalesSheets',
action: function(params, queryParams) {
require("/imports/ui/SalesSheets.js");
BlazeLayout.render('Body', {content: 'SalesSheets'});
}
});
pri.route('/production', {
name: 'Production',
action: function(params, queryParams) {

View File

@@ -267,6 +267,5 @@ Template.ProductEditor.events({
}
});
}
}
});

View File

@@ -1,43 +1,47 @@
<template name="Sales">
<div id="salesMain">
{{#if Template.subscriptionsReady}}
<div class="insertSale">
{{>InsertSale}}
<div class="paginationContainer">
<div class="pagination">
<span class="prevButton noselect {{#if disablePrev}}disabled{{/if}}"><i class="fa fa-long-arrow-left" aria-hidden="true"></i> Prev</span>
<span class="nextButton noselect {{#if disableNext}}disabled{{/if}}">Next <i class="fa fa-long-arrow-right" aria-hidden="true"></i></span>
</div>
<div class="tableControls">
<select name="sortSelect">
<option value="date" selected>Sale Date</option>
<option value="createdAt">Data Entry Date</option>
</select>
<div class="pagination">
<span class="prevButton noselect {{#if disablePrev}}disabled{{/if}}"><i class="fa fa-long-arrow-left" aria-hidden="true"></i> Prev</span>
<span class="nextButton noselect {{#if disableNext}}disabled{{/if}}">Next <i class="fa fa-long-arrow-right" aria-hidden="true"></i></span>
</div>
</div>
<div class="grid">
<table class="table table-striped table-hover">
<thead>
<tr class="headers">
<th class="amount noselect nonclickable">Amount</th>
<th class="product noselect nonclickable">Product</th>
<th class="price noselect nonclickable">Price</th>
<th class="measure noselect nonclickable">Measure</th>
<th class="date noselect nonclickable">Date (Week)</th>
<th class="venue noselect nonclickable">Venue</th>
<th class="actions noselect nonclickable">Actions</th>
</tr>
<tr class="footers">
<th>{{>SaleSearch columnName='amount' width='90%'}}</th>
<th>{{>SaleSearch columnName='productId' collectionQueryColumnName='name' collection='Products' collectionResultColumnName='_id' width='90%'}}</th>
<th>{{>SaleSearch columnName='price' width='90%'}}</th>
<th>{{>SaleSearch columnName='measureId' collectionQueryColumnName='name' collection='Measures' collectionResultColumnName='_id' width='90%'}}</th>
<th></th>
<th>{{>SaleSearch columnName='venueId' collectionQueryColumnName='name' collection='Venues' collectionResultColumnName='_id' width='90%'}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{#each sales}}
{{> Sale}}
{{/each}}
</tbody>
</table>
<div class="salesListRow">
<div class="salesListCell">
<div class="tableContainer">
<table class="table table-striped table-hover">
<thead>
<tr>
<th class="amount noselect nonclickable">Amount {{>SaleSearch columnName='amount' width='90%'}}</th>
<th class="product noselect nonclickable">Product <br/>{{>SaleSearch columnName='productId' collectionQueryColumnName='name' collection='Products' collectionResultColumnName='_id' width='90%'}}</th>
<th class="price noselect nonclickable">Price {{>SaleSearch columnName='price' width='90%'}}</th>
<th class="measure noselect nonclickable">Measure {{>SaleSearch columnName='measureId' collectionQueryColumnName='name' collection='Measures' collectionResultColumnName='_id' width='90%'}}</th>
<th class="saleDate noselect nonclickable">Date (Week)</th>
<th class="createdDate noselect nonclickable">Created On</th>
<th class="venue noselect nonclickable">Venue {{>SaleSearch columnName='venueId' collectionQueryColumnName='name' collection='Venues' collectionResultColumnName='_id' width='90%'}}</th>
<th class="actions noselect nonclickable">Actions <span class="newSaleButton btn btn-success" title="Create Sale"><i class="fa fa-plus-circle" aria-hidden="true"></i><i class="fa fa-times-circle" aria-hidden="true"></i></span> <i class="fa fa-commenting fa-lg showOnlyComments clickable" title="Show Commented Sales" aria-hidden="true"></i></th>
</tr>
</thead>
<tbody>
{{#if displayNewSale}}
{{> InsertSale}}
{{/if}}
{{#each sales}}
{{#if editing}}
{{> SaleEditor}}
{{else}}
{{> Sale}}
{{/if}}
{{/each}}
</tbody>
</table>
</div>
</div>
</div>
{{else}}
{{/if}}
@@ -50,9 +54,32 @@
<td class="tdLarge noselect nonclickable left">{{productName productId}}</td>
<td class="tdLarge noselect nonclickable left">{{formatPrice price}}{{#if showTotalPrice amount}} ({{formatTotalPrice price amount}}){{/if}}</td>
<td class="tdLarge noselect nonclickable left">{{measureName measureId}}</td>
<td class="tdLarge noselect nonclickable left">{{formatDate date}}</td>
<td class="tdLarge noselect nonclickable left">{{formatDateAndWeek date}}</td>
<td class="tdLarge noselect nonclickable left">{{formatDate createdAt}}</td>
<td class="tdLarge noselect nonclickable left">{{venueName venueId}}</td>
<td class="tdLarge noselect left"><i class="fa fa-times-circle fa-lg saleRemove clickable" aria-hidden="true"></i></td>
<td class="tdLarge noselect left"><i class="fa fa-pencil-square-o fa-lg actionEdit noselect clickable" title="Edit" aria-hidden="true"></i> <i class="fa fa-commenting fa-lg editComment noselect clickable {{commentClass}}" aria-hidden="true"></i> <i class="fa fa-times-circle fa-lg saleRemove noselect clickable" aria-hidden="true"></i></td>
</tr>
</template>
<template name="SaleEditor">
<tr>
<td colspan="7" class="saleEditor">
<form name="editSaleForm">
<div class="grid">
<div class="col-6-12">
<div class="editorDiv heading">{{productName}} - {{measureName measureId}}</div>
<div class="editorDiv"><label>Date</label><input name="date" class="form-control" type="date" data-schema-key='date' required></div>
<div class="editorDiv"><label>Venue</label><input name="venue" class="form-control" type="text" required/></div>
</div>
<div class="col-6-12">
<div class="editorDiv"><label>Amount</label><input type="number" class="form-control amount" name="amount" min="0" step="0.01" data-schema-key='amount' value="{{amount}}" required></div>
<div class="editorDiv"><label>Price</label><div class="priceContainer"><input type="number" class="form-control price" name="price" min="0" step="0.01" data-schema-key='currency' value="{{price}}" required><div class="priceButtons"><i class="fa fa-cogs setDefaultPrice noselect clickable" title="Calculate Default Price" aria-hidden="true"></i></div></div></div>
<div class="editorDiv"><label>Total</label><input type="number" class="form-control total" name="total" data-schema-key='currency' value="{{total}}" tabindex="-1" readonly></div>
</div>
</div>
</form>
</td>
<td class="center productEditorTd noselect"><i class="editorApply fa fa-check-square-o fa-lg noselect clickable" title="Save" aria-hidden="true"></i>&nbsp;/&nbsp;<i class="editorCancel fa fa-times-circle fa-lg noselect clickable" title="Cancel" aria-hidden="true"></i></td>
</tr>
</template>
@@ -61,33 +88,37 @@
</template>
<template name="InsertSale">
<form class="insertSaleForm" autocomplete="off">
<div class="grid">
<div class="col-4-12">
<div class="formGroupHeading">New Sale</div>
<div class="form-group">
<label class='control-label'>Date</label>
<input type="date" class="form-control" name="date" data-schema-key='date' required>
<tr>
<td colspan="8">
<form class="insertSaleForm" autocomplete="off">
<div class="grid">
<div class="col-4-12">
<div class="formGroupHeading">New Sale</div>
<div class="form-group">
<label class='control-label'>Date</label>
<input name="date" class="form-control" type="date" data-schema-key='date' required>
</div>
<div class="form-group">
<label class='control-label'>Product</label>
<input name="product" class="form-control" type="text" required/>
</div>
<div class="form-group">
<label class='control-label'>Venue</label>
<input name="venue" class="form-control" type="text" required/>
</div>
</div>
{{#each productMeasures}}
{{>InsertSaleMeasure this}}
{{/each}}
<div class="col-1-1">
<div class="form-group">
<input type="submit" class="btn btn-success" value="Save Sale">
</div>
</div>
</div>
<div class="form-group">
<label class='control-label'>Product</label>
<input name="product" class="form-control" type="text" required/>
</div>
<div class="form-group">
<label class='control-label'>Venue</label>
<input name="venue" class="form-control" type="text" required/>
</div>
</div>
{{#each productMeasures}}
{{>InsertSaleMeasure this}}
{{/each}}
<div class="col-1-1">
<div class="form-group">
<input type="submit" class="btn btn-success" value="Save Sale">
</div>
</div>
</div>
</form>
</form>
</td>
</tr>
</template>
<template name="InsertSaleMeasure">

View File

@@ -1,69 +1,128 @@
#salesMain
margin: 10px 20px
display: table
content-box: border-box
padding: 10px 20px
height: 100%
width: 100%
text-align: left
.comboList .deactivated
color: red
background: #ffdbd9
.editor
height: 100%
overflow-y: auto
.insertSale
width: 100%
position: relative
.paginationContainer
position: absolute
right: 0
bottom: -20px
.pagination
white-space: nowrap
.tableControls
text-align: right
margin-right: 20px
.salesListRow
display: table-row
.salesListCell
display: table-cell
position: relative
height: 100%
width: 100%
.tableContainer
position: absolute
top: 0
bottom: 0
left: 0
right: 0
width: auto
height: auto
//width: 100%
//margin-bottom: 20px
border: 0
font-size: 12.5px
overflow-y: auto
//height: 100%
label
font-size: 10px
font-weight: 800
table
table-layout: fixed
min-width: 100%
.saleRemove
color: red
margin-left: 8px
.saleEdit
color: darkblue
margin-right: 8px
.editorApply
color: green
.editorCancel
color: red
thead
> tr
> th.amount
width: 90px
> th.product
width: auto
min-width: 140px
> th.price
width: 140px
> th.measure
width: 100px
> th.saleDate
width: 140px
> th.createdDate
width: 100px
> th.venue
width: 160px
> th.actions
width: 90px
.newSaleButton
padding: 0px 12px
.fa-plus-circle
display: inline-block
.fa-times-circle
display: none
.newSaleButton.active
background-color: #fb557b
color: black
.fa-times-circle
display: inline-block
.fa-plus-circle
display: none
.showOnlyComments
color: #bcb95f
padding: 4px 8px
.showOnlyComments:hover
color: white
text-shadow: 0px 0px 10px #ff6d1f
.showOnlyComments.on
color: white
.editComment
color: grey
.hasComment
color: black
.actionEdit
margin-right: 6px
color: #44F
.saleEditor
.heading
font-size: 2em
font-family: verdana, arial, helvetica, sans-serif
text-transform: uppercase
font-weight: 800
margin: 6px 0 14px 0
.priceContainer
display: table
width: 100%
.price
display: table-cell
padding-right: 10px
.priceButtons
display: table-cell
width: 1.5em
.setDefaultPrice
font-size: 1.5em
padding: 6px 8px
margin-left: 8px
border-radius: 8px
.setDefaultPrice:hover
text-shadow: 0px 0px 6px #00b900
.setDefaultPrice:active
text-shadow: 0px 0px 6px grey
.insertSaleForm
.form-group, label
text-align: left
.formGroupHeading
font-size: 1.6em
font-family: "Arial Black", "Arial Bold", Gadget, sans-serif
font-style: normal
font-variant: normal
font-weight: 500
.grid
width: 100%
margin-bottom: 20px
border: 0
font-size: 12.5px
label
font-size: 10px
font-weight: 800
table
table-layout: fixed
min-width: 100%
.saleRemove
color: red
margin-left: 8px
.saleEdit
color: darkblue
margin-right: 8px
.editorApply
color: green
.editorCancel
color: red
thead
> tr
> th.amount
width: 90px
> th.product
width: auto
min-width: 140px
> th.price
width: 140px
> th.measure
width: 90px
> th.date
width: 140px
> th.venue
width: 160px
> th.actions
width: 90px
font-weight: 500

View File

@@ -1,29 +1,65 @@
import './Sales.html';
import '/imports/util/selectize/selectize.js';
import swal from 'sweetalert2';
let QUERY_LIMIT = 20;
let PREFIX = "Sales.";
Meteor.subscribe("products");
Session.set(PREFIX + "sortOption", "date");
Session.set(PREFIX + "showOnlyComments", false);
Tracker.autorun(function() {
Meteor.subscribe("sales", Session.get(PREFIX + 'searchQuery'), QUERY_LIMIT, Session.get(PREFIX + 'skipCount'));
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};
}
Meteor.subscribe("sales", query, sort, QUERY_LIMIT, Session.get(PREFIX + 'skipCount'));
Session.set(PREFIX + 'saleCount', Meteor.call('getSalesCount', Session.get(PREFIX + 'searchQuery')));
});
Template.Sales.onCreated(function() {
Session.set(PREFIX + "displayNewSale", false);
});
Template.Sales.helpers({
displayNewSale: function() {
return Session.get(PREFIX + "displayNewSale");
},
sales: function() {
return Meteor.collections.Sales.find({}, {sort: {date: -1, createdAt: -1}});
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.
}
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));
@@ -31,6 +67,16 @@ Template.Sales.events({
'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');
}
});
@@ -46,9 +92,12 @@ Template.Sale.helpers({
productName: function(id) {
return Meteor.collections.Products.findOne({_id: id}, {fields: {name: 1}}).name;
},
formatDate: function(date) {
formatDateAndWeek: function(date) {
return moment(date).format("MM/DD/YYYY (w)");
},
formatDate: function(date) {
return moment(date).format("MM/DD/YYYY");
},
formatPrice: function(price) {
return price.toLocaleString("en-US", {style: 'currency', currency: 'USD', minimumFractionDigits: 2});
},
@@ -57,21 +106,175 @@ Template.Sale.helpers({
},
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.$('.newSaleButton').removeClass('active');
},
"click .saleRemove": function(event, template) {
let _this = this;
bootbox.confirm({
message: "Delete the sale?",
buttons: {confirm: {label: "Yes", className: 'btn-success'}, cancel: {label: "No", className: "btn-danger"}},
callback: function(result) {
if(result) {
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() {
let _this = this;
this.product = Meteor.collections.Products.findOne({_id: this.data.productId});
this.selectedDate = new ReactiveVar(this.data.date);
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(priceData.effectiveDate).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 = template.selectedDate.get();
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);
}
});
}
});
//let name = template.$("input[name='name']").val().trim();
//let tags = template.$(".productTagsEditor").select2('data');
//let aliases = template.$(".productAliasesEditor").select2('data');
//let measures = template.$(".productMeasuresEditor").select2('data');
//
//tags = tags.map((n)=>n.id);
//aliases = aliases.map((n)=>n.id);
//measures = measures.map((n)=>n.id);
//
//if(Session.get(PREFIX + 'displayNewProduct')) {
// Meteor.call("createProduct", name, tags, aliases, measures, function(error, result) {
// if(error) sAlert.error(error);
// else {
// sAlert.success("Product created.");
// Session.set(PREFIX + 'displayNewProduct', false);
// template.parentTemplate().$('.newProductButton').removeClass('active');
// }
// });
//}
//else {
// Meteor.call("updateProduct", this._id, name, tags, aliases, measures, function(error, result) {
// if(error) sAlert.error(error);
// else {
// sAlert.success("Product updated.");
// Session.set(PREFIX + "editedProduct", undefined);
// template.parentTemplate().$('.newProductButton').removeClass('active');
// }
// });
//}
}
});
@@ -116,37 +319,27 @@ Template.SaleSearch.events({
});
Template.InsertSale.onCreated(function() {
// $('#insertSale').validator();
// $('#insertSale').data('bs.validator');
// this.products = new ReactiveVar([]);
this.selectedDate = new ReactiveVar();
this.selectedProduct = new ReactiveVar();
this.selectedVenue = new ReactiveVar();
});
Template.InsertSale.onRendered(function() {
this.$('.insertSaleForm').validator();
// this.$('[name="product"]').
// this.autorun(function() {
// this.$('[name="product"]').buildCombo(Meteor.collections.Products.find({}).fetch(), {textAttr: 'name', listClass: 'comboList'});
// });
//TODO: Highlight deactivated products in combo
//TODO: Default the price for each size product based on the date.
//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'});
// this.autorun(function(){
// this.products.set(Meteor.collections.Products.find({}));
// }.bind(this));
});
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="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());
},
@@ -155,17 +348,20 @@ Template.InsertSale.events({
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").toDate(),
productId: template.selectedProduct.get()._id,
venueId: template.selectedVenue.get()._id
};
let insertSaleMeasures = template.$(".insertSaleMeasure");
//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()).toFixed(2);
let amount = parseFloat(nextMeasure.find(".amount").val()).toFixed(2);
let price = parseFloat(nextMeasure.find(".price").val());
let amount = parseFloat(nextMeasure.find(".amount").val());
if(amount > 0) {
let nextSale = _.clone(sale);
@@ -177,12 +373,22 @@ Template.InsertSale.events({
}
}
//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");
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);
}
}
});
}
}
@@ -190,9 +396,6 @@ Template.InsertSale.events({
}
});
Template.InsertSale.helpers({
products: function() {
return [{label: "Hermies", value: 1}, {label: "Ralfe", value: 2}, {label: "Bob", value: 3}];
},
productMeasures: function() {
let product = Template.instance().selectedProduct.get();
let result = product ? product.measures : [];
@@ -242,6 +445,20 @@ Template.InsertSaleMeasure.events({
},
'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({

View File

@@ -0,0 +1,76 @@
<!-- ******** Sheet Editor - Edit a sales sheet structure (has two sub-parts: product selector, and configuration) ********* -->
<template name="SalesSheetEditor">
<div class="salesSheetEditorControls vscFixed">
<ul class="tabRow"><li class="productSelection {{productSelectionSelected}}">Selection</li><li class="sheetConfiguration {{sheetConfigurationSelected}}">Configuration</li></ul>
</div>
{{> Template.dynamic template=salesSheetEditorForm data=salesSheetEditorData}}
</template>
<!-- ******** The Sheet Editor's Product Selector ********* -->
<template name="SalesSheetEditorProductSelection">
<div class="salesSheetEditorProductSelectionControls vscFixed">
<span class="button showAlternateNames">Alt. Names</span>
<i class="fa fa-question-circle clickable noselect" aria-hidden="true"></i>
<label>Filter </label><input class="form-control" type="text" name="productFilter" autocomplete="off"/>
</div>
<div class="selectionProductsListing columnContainer">
{{#each products}}
{{>SalesSheetEditorProductSelectionRow}}
{{/each}}
</div>
</template>
<template name="SalesSheetEditorProductSelectionRow">
<div class="selectionProduct {{#if sheetProduct}}selected{{/if}} columnContent">
<span class="include clickable">
{{#if sheetProduct}}
<i class="fa fa-check-circle" aria-hidden="true"></i>
{{else}}
<i class="fa fa-circle-o" aria-hidden="true"></i>
{{/if}}
<span class="productName noselect">{{name}}</span>
</span>
{{#if sheetProduct}}
<div class="includeAs">&nbsp;&nbsp;as "{{sheetProductName}}"</div>
{{/if}}
</div>
</template>
<!-- ******** The Sheet Editor's Configuration ********* -->
<!-- The overall Sheet Configuration Editor. Contains multiple rows in columns, one row for each PRODUCT or HEADER. -->
<template name="SalesSheetEditorConfiguration">
<div class="vscFixed configurationControls">
<div class="heading columnContent noselect">
<div class="name clickable">New Heading</div>
<div class="nameEditor"><input name="name" tabindex="1" value="New Heading"/> <i class="fa fa-check-circle accept" aria-hidden="true"></i> <i class="fa fa-times-circle reject" aria-hidden="true"></i></div>
</div>
</div>
<div class="configurationProductsListing columnContainer">
{{#each products}}
{{>SalesSheetEditorConfigurationRow}}
{{/each}}
</div>
</template>
<!-- A single row of the Sheet Configuration Editor (allows reordering, addition of headers, sorting, and renaming of sheet products). -->
<template name="SalesSheetEditorConfigurationRow">
{{#if isProduct}} {{! PRODUCT }}
<div class="product columnContent noselect" data-model="{{productId}}">
<div class="name clickable">{{name}}</div>
<div class="nameEditor"><input class="form-control" name="name" type="text" tabindex="1" value="{{name}}"/> <i class="fa fa-check-circle accept" aria-hidden="true"></i> <i class="fa fa-times-circle reject" aria-hidden="true"></i></div>
<div class="measures">
{{#each measureId in measures}}
<span class="measureButton button {{#if isSelected measureId}}selected{{/if}}" data-model="{{measureId}}">{{measureName measureId}}</span>
{{/each}}
</div>
</div>
{{else}} {{! HEADING }}
<div class="heading columnContent noselect">
<div class="headingNameRow"><span class="name clickable">{{name}}</span><span class="sort clickable noselect"><i class="fa fa-arrow-down" aria-hidden="true"></i> sort <i class="fa fa-arrow-up" aria-hidden="true"></i></span></div>
<div class="nameEditor"><input class="form-control" name="name" type="text" tabindex="1" value="{{name}}"/> <i class="fa fa-check-circle accept" aria-hidden="true"></i> <i class="fa fa-times-circle reject" aria-hidden="true"></i></div>
</div>
{{/if}}
</template>

149
imports/ui/SalesSheetEditor.import.styl vendored Normal file
View File

@@ -0,0 +1,149 @@
#salesSheetsMain
.salesSheetEditorControls
margin-bottom: 8px
.salesSheetEditorProductSelectionControls
margin-bottom: 8px
width: 100%
text-align: right
input[name='productFilter']
font-size: 1.2em
display: inline
width: auto
.showAlternateNames
margin-right: 20px
.selectionProductsListing
width: 100%
.selectionProduct
color: #9f9f9f
font-size: 1.5em
width: 400px
.include, .includeAs
text-overflow: ellipsis
white-space: nowrap
overflow: hidden
.includedRemove, .includedAdd
cursor: pointer
.includedRemove:hover, .includedAdd:hover
color: blue
.selectionProduct.selected
color: black
.configurationControls
width: 100%
background: #c1c2ff
border-bottom: 2px solid #a7a8ff
.heading
.name
font-size: 1.5em
text-transform: uppercase
font-weight: 800
.nameEditor
display: none
.configurationProductsListing
width: 100%
.product
width: 300px
.name
color: #9f9f9f
font-size: 1.5em
margin-bottom: 6px
text-overflow: ellipsis
white-space: nowrap
overflow: hidden
.name.edit
display: none
.nameEditor
display: none
margin-bottom: 6px
input[name='name']
flex: 1 1 auto
display: inline-block
.accept, .reject
flex: 0 0 auto
font-size: 1.7em
margin-left: 8px
.accept:hover
color: green
.reject:hover
color: red
.accept:active
text-shadow: 0px 0px 10px #fda1ff
.reject:active
text-shadow: 0px 0px 10px #fda1ff
.nameEditor.edit
display: flex;
flex-flow: row nowrap;
justify-content: flex-start;
align-items: center;
align-content: stretch;
.measures
margin: 1px 0 8px 0
.heading
width: 300px
.headingNameRow
display: flex
flex-flow: row nowrap
justify-content: flex-start
align-items: center
align-content: stretch
margin-top: 4px
margin-bottom: 2px
border-bottom: 1px solid grey
.name
flex: 1 0 auto
color: black
font-size: 1.5em
text-transform: uppercase
font-weight: 800
text-overflow: ellipsis
white-space: nowrap
overflow: hidden
.sort
flex: 0 0 auto
.headingNameRow.edit
display: none
.nameEditor
display: none
input[name='name']
flex: 1 1 auto
display: inline-block
.accept, .reject
flex: 0 0 auto
font-size: 1.7em
margin-left: 8px
.accept:hover
color: green
.reject:hover
color: red
.accept:active
text-shadow: 0px 0px 10px #fda1ff
.reject:active
text-shadow: 0px 0px 10px #fda1ff
.nameEditor.edit
display: flex
flex-flow: row nowrap
justify-content: flex-start
align-items: center
align-content: stretch
/*** These styles are for the drag and drop. The D&D element is a child of the body tag while it exists, and as such will not use the styles above. ***/
.heading.gu-mirror
width: 300px
.name
color: black
font-size: 1.5em
text-transform: uppercase
font-weight: 800
margin-top: 4px
margin-bottom: 2px
border-bottom: 1px solid grey
.nameEditor
display: none
.product.gu-mirror
color: #9f9f9f
font-size: 1.5em
width: 300px
.name
font-size: 1em
.nameEditor
display: none
.measures
display: none

View File

@@ -0,0 +1,419 @@
import './SalesSheetEditor.html';
import swal from 'sweetalert2';
import dragula from 'dragula';
let PREFIX = "SalesSheetEditor.";
//******************************************************************
//** The parent template for editing a sheet. Has two children which allow picking products, and organizing products.
//******************************************************************
Template.SalesSheetEditor.onCreated(function() {
// Default the currently displayed form to the product selection form.
let currentFormName = Session.get(PREFIX + "currentFormName");
if(currentFormName != "SalesSheetEditorProductSelection" && currentFormName != "SalesSheetEditorConfiguration") Session.set(PREFIX + "currentFormName", "SalesSheetEditorProductSelection");
//this.currentFormName = new ReactiveVar("SalesSheetEditorProductSelection");
// Save the data as the sheet. This is easier to read in the code, and it avoids the problem in onDestroyed() where our data is being changed out from under us.
this.sheet = Meteor.collections.SalesSheets.findOne(this.data);
});
Template.SalesSheetEditor.onRendered(function() {
});
Template.SalesSheetEditor.onDestroyed(function() {
let sheet = this.sheet; //Note: this.data does not refer to the SAME sheet instance, but rather another copy of the same sheet. So any changes would be lost if we referenced `this.data`.
swal({
title: "Save Changes",
text: "Would you like to save any changes you have made to this sheet?",
type: "question",
showCancelButton: true,
confirmButtonColor: "#7cdd7f",
confirmButtonText: "Yes"
}).then(
function(isConfirm) {
if(isConfirm) {
Meteor.call("updateSalesSheet", sheet._id, sheet.name, sheet.products, function(error) {
if(error) sAlert.error("Failed to update the sheet!\n" + error);
else {
sAlert.success("Updated the Sales Sheet.");
}
});
}
},
function(dismiss) {}
);
});
Template.SalesSheetEditor.events({
'click .productSelection': function(event, template) {
// Toggle which form template is active.
if(Session.get(PREFIX + "currentFormName") != "SalesSheetEditorProductSelection") {
$(event.target).addClass('selected').siblings().removeClass('selected');
Session.set(PREFIX + "currentFormName", "SalesSheetEditorProductSelection");
}
},
'click .sheetConfiguration': function(event, template) {
// Toggle which form template is active.
if(Session.get(PREFIX + "currentFormName") != "SalesSheetEditorConfiguration") {
$(event.target).addClass('selected').siblings().removeClass('selected');
Session.set(PREFIX + "currentFormName", "SalesSheetEditorConfiguration");
}
}
});
Template.SalesSheetEditor.helpers({
salesSheetEditorForm: function() {
return Session.get(PREFIX + "currentFormName");
},
salesSheetEditorData: function() {
return {parentTemplate: Template.instance(), salesSheet: Template.instance().sheet};
},
productSelectionSelected: function() {
return Session.get(PREFIX + "currentFormName") == 'SalesSheetEditorProductSelection' ? "selected" : "";
},
sheetConfigurationSelected: function() {
return Session.get(PREFIX + "currentFormName") == 'SalesSheetEditorConfiguration' ? "selected" : "";
}
});
//******************************************************************
//** Lets the user pick the products on the sheet.
//******************************************************************
Template.SalesSheetEditorProductSelection.onCreated(function() {
//Here, this is the template, and this.data is an object containing the 'parentTemplate' and 'salesSheet' properties.
//Save the sales sheet as a property of this template to make the code later easier to read.
//Note: This is not reactive because we don't expect the sales sheet to change without closing the editor (re-editing would open a new template instance). Also, the sales sheet is a clone of the real one, so any changes will be lost if not saved.
this.salesSheet = this.data.salesSheet;
this.productNameFilter = new ReactiveVar("");
});
Template.SalesSheetEditorProductSelection.events({
'keyup input[name="productFilter"]': _.throttle(function(event, template) {
template.productNameFilter.set($(event.target).val());
})
});
Template.SalesSheetEditorProductSelection.helpers({
products: function() {
let salesSheet = this.salesSheet;
let filter = Template.instance().productNameFilter.get();
let products = salesSheet.products ? salesSheet.products : [];
let productMap = {};
let dbQuery;
//Map the products in the sales sheet by their id so we can later only add products to the list that are not on the sheet.
for(next of products) {
if(next.productId) //Ignore any elements that don't have an id since they may be headers that have no associated product.
productMap[next.productId] = next;
}
//If we have a filter, split the filter (space delimited) and use each piece to match against the name, and the alternate names for the product.
//Only match where the word in the name starts with the characters in the filter. So "ra j" would match "raspberry jam".
if(filter && filter.trim().length > 0) {
let searches = filter.trim().split(/\s+/);
let regex = "";
for(let search of searches) {
search = RegExp.escape(search);
regex += '(?=.*\\b' + search + ')'
}
regex += '.*';
dbQuery = {name: {$regex: regex, $options: 'i'}};
}
else dbQuery = {};
let allProducts = Meteor.collections.Products.find(dbQuery).fetch();
//Mark all the products that are currently included in the sheet and note the name they are included as.
for(next of allProducts) {
//Attach the sheet data for the product to the actual product model if it is in the sheet.
if(productMap[next._id]) {
//Add the sheet product data to the product for those products that are on the sheet. Use this to determine if a product is on the sheet, and to remove it from the sheet.
next.sheetProduct = productMap[next._id];
}
else next.sheetProduct = undefined;
}
return allProducts;
}
});
Template.SalesSheetEditorProductSelectionRow.onCreated(function() {
//Here, this refers to the template, and this.data is the Product object which has been modified to reference a Sheet object via the 'sheetProduct' property if the product is on the sheet.
//We are creating a reactive variable to hold the sheet's product data, if the product is on the sheet, otherwise it is empty.
//Note: The Product's sheetProduct references the product data for the sheet, but it must also be referenced in the sheet object held by the parent.
this.sheetProduct = new ReactiveVar(this.data.sheetProduct);
});
Template.SalesSheetEditorProductSelectionRow.events({
'click .include': function(event, template) {
let sheet = template.parentTemplate(1).salesSheet;
if(template.sheetProduct.get()) { //Remove the product from the sheet, or rename it (if the names don't match and the user clicked on the name instead of the checkbox).
//If the click was on the product's name, and the product name is different from the name used for it in the sheet, then use the product name as the name in the sheet instead of removing it from the sheet.
if($(event.target).closest('.productName') && this.name != template.sheetProduct.get().name) {
template.sheetProduct.get().name = this.name;
}
else {
let index = sheet.products.indexOf(this.sheetProduct);
//Remove the product data from the sheet first. Template.parentData(1) is the sheet.
if(index >= 0) sheet.products.splice(index, 1);
//Clear the sheet product data from the actual product.
template.sheetProduct.set(undefined);
}
}
else {
let sheetProduct = {name: this.name, productId: this._id, measureIds: this.measures.length > 2 ? this.measures.slice(0,2) : this.measures};
//Save the sheet product in the sheet. Template.parentData(1) is the sheet.
sheet.products.push(sheetProduct);
//Attach the sheet product data to the actual product.
template.sheetProduct.set(sheetProduct);
}
}
});
Template.SalesSheetEditorProductSelectionRow.helpers({
sheetProduct: function() {
return Template.instance().sheetProduct.get();
},
sheetProductName: function() {
return Template.instance().sheetProduct.get().name;
}
});
//******************************************************************
//** Lets the user configure the products on the sheet.
//******************************************************************
Template.SalesSheetEditorConfiguration.onCreated(function() {
let template = this;
//Here, this is the template, and this.data is an object containing the 'parentTemplate' and 'salesSheet' properties.
//Save the sales sheet as a property of this template to make the code later easier to read.
//Note: This is not reactive because we don't expect the sales sheet to change without closing the editor (re-editing would open a new template instance). Also, the sales sheet is a clone of the real one, so any changes will be lost if not saved.
this.salesSheet = this.data.salesSheet;
this.measures = new ReactiveDict();
this.productsDependancy = new Tracker.Dependency;
Tracker.autorun(function() {
let measures = Meteor.collections.Measures.find({}).fetch();
template.measures.clear();
for(let measure of measures) {
template.measures.set(measure._id, measure);
}
});
});
Template.SalesSheetEditorConfiguration.onRendered(function() {
let template = this;
//Setup the drag and drop for the view.
this.drake = dragula([this.$('.configurationProductsListing')[0], this.$('.configurationControls')[0]], {
moves: function(el, container, handle, sibling) {
//Don't allow drag and drop of buttons - we want them to be clickable.
return !$(handle).hasClass("button");
},
//Checks whether the element `el` can be moved from the container `target`, to the container `source`, above the `sibling` element.
accepts: function(el, target, source, sibling) {
return (!sibling || !$(sibling).hasClass('newHeading'));
},
copy: function(el, source) {
return $(el).hasClass('heading') && $(source).hasClass('configurationControls');
},
ignoreInputTextSelection: true
}).on('drop', function(el, target, source, sibling) {
if($(el).hasClass('heading')) {
if(el.parentNode) {
let array = template.salesSheet.products;
//Add the heading to the product array.
array.add({name: "New Heading"}, $(el).index());
//Remove the element that was just added by the D&D. The element will be re-added by the template in just a moment. We need the template to add the element so that events will be properly handled for it by meteor.
el.parentNode.removeChild(el);
//Notify the template engine that the products list has changed so it can be re-rendered.
template.productsDependancy.changed();
}
}
else {
//Get the item from the DOM using the blaze data structure. We could make this more blaze agnostic by attaching the object as data to the DOM in the view, but we really can't escape blaze, so why bother.
//let item = el.$blaze_range.view._templateInstance.data;
let productId = $(el).data('model');
let array = template.salesSheet.products;
let item = undefined;
for(let product of array) {
if(productId == product.productId) {
item = product;
break;
}
}
if(item) {
//Rearrange the array of products on the sheet.
array.move(array.indexOf(item), $(el).index());
}
else {
console.log("ERROR: Unable to locate the moved item.");
}
}
});
});
Template.SalesSheetEditorConfiguration.onDestroyed(function() {
//Clean up after the drag and drop.
this.drake.destroy();
});
Template.SalesSheetEditorConfiguration.events({
});
Template.SalesSheetEditorConfiguration.helpers({
products: function() {
let template = Template.instance();
let products = template.salesSheet.products;
//Mark this call as depending on the products array. When we change the array later, we will call changed() on the dependency and it will trigger this function (and the calling template setup) to be re-run.
template.productsDependancy.depend();
return products;
}
});
//Note: The data to this template is a product metadata object that is part of a sheet and wrappers (by ID association) a product in the system. See the schema in SalesSheet.js, look for 'products.$' to see the type definition for this data.
Template.SalesSheetEditorConfigurationRow.onCreated(function() {
let template = this;
this.handleHeaderEditorCancelAndClose = function() {
let $inputField = template.$("input[name='name']");
let index = template.$('.heading').index();
//Reset the text field.
$inputField.val(template.parentTemplate(1).salesSheet.products[index].name);
template.$('.heading .nameEditor, .heading .headingNameRow').removeClass('edit');
};
this.handleHeaderEditorApplyAndClose = function() {
let $inputField = template.$("input[name='name']");
let name = $inputField.val();
let index = template.$('.heading').index();
if(name) name = name.trim();
if(name && name.length > 0) {
template.parentTemplate(1).salesSheet.products[index].name = name;
template.$('.heading .name').text(name);
}
else {
template.parentTemplate(1).salesSheet.products.splice(index, 1);
template.parentTemplate(1).productsDependancy.changed();
}
template.$('.heading .nameEditor, .heading .headingNameRow').removeClass('edit');
};
this.handleProductEditorCancelAndClose = function() {
let $inputField = template.$("input[name='name']");
let index = template.$('.product').index();
//Reset the text field.
$inputField.val(template.parentTemplate(1).salesSheet.products[index].name);
template.$('.product .nameEditor, .product .name').removeClass('edit');
};
this.handleProductEditorApplyAndClose = function() {
let $inputField = template.$("input[name='name']");
let name = $inputField.val();
let index = template.$('.product').index();
template.parentTemplate(1).salesSheet.products[index].name = name;
template.$('.product .name').text(name);
template.$('.product .nameEditor, .product .name').removeClass('edit');
};
});
Template.SalesSheetEditorConfigurationRow.helpers({
measureName: function(measureId) {
return Template.instance().parentTemplate(1).measures.get(measureId).name;
},
measures: function() {
let product = Meteor.collections.Products.findOne(this.productId);
return product.measures;
},
isSelected: function(measureId) {
return this.measureIds.includes(measureId);
},
isProduct: function() {
return !!this.productId;
}
});
Template.SalesSheetEditorConfigurationRow.events({
'click .heading .name': function(event, template) {
template.$('.nameEditor, .headingNameRow').addClass('edit');
template.$('input[name="name"]').select();
},
'blur .heading input[name="name"]': function(event, template) {
template.handleHeaderEditorApplyAndClose();
},
'keyup .heading input[name="name"]': function(event, template) {
if(event.which === 13 || event.which === 9) { //Enter or Tab
template.handleHeaderEditorApplyAndClose();
event.stopPropagation();
return false;
}
else if(event.which === 27) { //Escape
template.handleHeaderEditorCancelAndClose();
event.stopPropagation();
return false;
}
},
'click .heading .accept': function(event, template) {
template.handleHeaderEditorApplyAndClose();
},
'click .heading .reject': function(event, template) {
template.handleHeaderEditorCancelAndClose();
},
'click .product .name': function(event, template) {
template.$('.nameEditor, .name').addClass('edit');
template.$('input[name="name"]').select();
},
'blur .product input[name="name"]': function(event, template) {
template.handleProductEditorApplyAndClose();
},
'keyup .product input[name="name"]': function(event, template) {
if(event.which === 13 || event.which === 9) { //Enter or Tab
template.handleProductEditorApplyAndClose();
event.stopPropagation();
return false;
}
else if(event.which === 27) { //Escape
template.handleProductEditorCancelAndClose();
event.stopPropagation();
return false;
}
},
'click .product .accept': function(event, template) {
template.handleProductEditorApplyAndClose();
},
'click .product .reject': function(event, template) {
template.handleProductEditorCancelAndClose();
},
'click .measureButton': function(event, template) {
let measureId = $(event.target).data("model");
$(event.target).toggleClass("selected");
if(this.measureIds.includes(measureId))
this.measureIds.remove(measureId);
else
this.measureIds.add(measureId);
},
'click .heading .sort': function(event, template) {
let width = event.currentTarget.offsetWidth;
let x = event.pageX - event.currentTarget.offsetLeft;
let sortAlphabetical = x <= (width / 2);
let headingIndex = template.$(event.target).closest(".heading").index();
let firstIndex = headingIndex + 1;
let products = template.parentTemplate(1).salesSheet.products;
let length = 0;
while(firstIndex + length < products.length && products[firstIndex + length].productId) {
length++;
}
//Sort the part of the array that contains products under the sorted heading.
products.partialSort(firstIndex, length, function(a, b) {
return sortAlphabetical ? (a.name < b.name ? -1 : 1) : (a.name > b.name ? -1 : 1);
});
//Notify anything depending on the products list that they have been modified.
template.parentTemplate(1).productsDependancy.changed();
}
});

View File

@@ -0,0 +1,70 @@
<!-- ******** Sales Sheet - Allows user to fill out the selected sales sheet (entering sales data). ********* -->
<template name="SalesSheetForm">
{{#if this}}
<div class="vscFixed">
<div class="sheetHeader grid">
<div class="form-group col-6-12">
<label class='control-label'>Date</label>
<input type="date" class="form-control" name="date" data-schema-key='date' required>
</div>
<div class="form-group col-6-12">
<label class='control-label'>Venue</label>
<input name="venue" class="form-control" type="text" required/>
</div>
</div>
</div>
<div class="columnContainer vscExpand salesSheetProducts">
{{#each product in products}}
{{#if isHeading product}}
{{>SalesSheetFormHeader product}}
{{else}}
{{>SalesSheetFormProduct product}}
{{/if}}
{{/each}}
<div class="sheetControls columnContent">
<div class="saveSheet clickable noselect">Save Sales</div>
<div class="resetSheet clickable noselect">Reset Sheet</div>
</div>
</div>
{{/if}}
</template>
<!-- ******** Sales Sheet Header - The headers in the sales sheet form's list of products. ********* -->
<template name="SalesSheetFormHeader">
<div class="header columnContent">
<div class="name">{{name}}</div>
</div>
</template>
<!-- ******** Sales Sheet Product - The individual products in the sales sheet form. ********* -->
<template name="SalesSheetFormProduct">
{{#if isHeading}}
<div class="header columnContent">
<div class="name">{{name}}</div>
</div>
{{else}}
<div class="product columnContent {{#if odd}}odd{{/if}}" data-model="{{productId}}">
<div class="nameAndTotal">
<div class="name">{{name}}</div>
<div class="total">${{total}}</div>
</div>
<div class="measures">
{{#each measures}}
{{>SalesSheetFormProductMeasure}}
{{/each}}
</div>
</div>
{{/if}}
</template>
<template name="SalesSheetFormProductMeasure">
<div class="measure">
<span class="label">{{measureName}}</span>
#<input name="amount" type="number" step="1" min="0" value="{{measureAmount}}" data-model="{{this}}"/>
$<input name="price" type="number" step="1" min="0" value="{{measurePrice}}" data-model="{{this}}" data-default-price="{{measurePrice}}"/>
</div>
</template>

109
imports/ui/SalesSheetForm.import.styl vendored Normal file
View File

@@ -0,0 +1,109 @@
#salesSheetsMain
.salesSheetProducts
> div
width: 400px
margin: 0 4px 0 4px
.header
padding-top: 4px
.name
font-size: 1.5em
text-transform: uppercase
color: #0a6f10
text-shadow: 0px 0px 12px #8fa4d1
background: white
font-weight: 800
width: 100%
padding: 4px 6px 6px 6px
border-bottom: 2px solid #335a4a
text-overflow: ellipsis
white-space: nowrap
overflow: hidden
.product
background-color: #ffecde
-webkit-box-shadow: inset 0px 0px 40px 14px white
-moz-box-shadow: inset 0px 0px 40px 14px white
box-shadow: inset 0px 0px 40px 14px white
.nameAndTotal
display: flex
flex-flow: row wrap
justify-content: flex-start
align-items: center
align-content: stretch
margin-bottom: 4px
.name
flex: 1 1 auto
font-size: 1.5em
color: #575757
text-overflow: ellipsis
white-space: nowrap
overflow: hidden
.total
flex: 0 0 auto
width: 80px
font-size: 1.2em
color: #00378b
text-shadow: 0px 0px 8px #7690d1
overflow: hidden
.measures
display: flex
flex-flow: row wrap
justify-content: flex-start
align-items: center
align-content: stretch
margin-bottom: 6px
.measure
flex: 1 0 auto
display: flex
flex-flow: row wrap
justify-content: flex-start
align-items: center
align-content: stretch
.label
font-size: 1em
font-weight: 600
margin-right: 4px
input[name="price"]
width: 63px
input[name="amount"]
width: 47px
.product.odd
background-color: #ede0f1
-webkit-box-shadow: inset 0px 0px 40px 14px white
-moz-box-shadow: inset 0px 0px 40px 14px white
box-shadow: inset 0px 0px 40px 14px white
.sheetControls
padding-top: 4px
display: flex
flex-flow: row wrap
justify-content: flex-start
align-items: center
align-content: stretch
.saveSheet, .resetSheet
flex: 1 1 auto
font-size: 1em
color: #e9e9e9
font-weight: 800
text-transform: uppercase
text-align: center
padding: 10px
margin: 4px
.saveSheet
background: #007200
-webkit-box-shadow: inset 0px 0px 20px -2px white
-moz-box-shadow: inset 0px 0px 20px -2px white
box-shadow: inset 0px 0px 20px -2px white
.resetSheet
background: #960000
-webkit-box-shadow: inset 0px 0px 20px -2px white
-moz-box-shadow: inset 0px 0px 20px -2px white
box-shadow: inset 0px 0px 20px -2px white
.saveSheet:hover, .resetSheet:hover
text-shadow: 0px 0px 16px white
.saveSheet:hover
background: #005600
.resetSheet:hover
background: #7f0000
.saveSheet:active
background: #009000
.resetSheet:active
background: #b90000

View File

@@ -0,0 +1,372 @@
import './SalesSheetForm.html';
import swal from 'sweetalert2';
let PREFIX = "SalesSheetForm.";
//******************************************************************
//** The form for filling out a sheet.
//******************************************************************
Template.SalesSheetForm.onCreated(function() {
let template = this;
this.selectedDate = new ReactiveVar();
this.selectedVenue = new ReactiveVar();
this.salesSheet = new ReactiveVar();
//this.measures = new ReactiveDict();
this.oddProductIds = new ReactiveDict(false);
this.productTemplates = [];
//Tracker.autorun(function() {
// let measures = Meteor.collections.Measures.find({}).fetch();
//
// template.measures.clear();
// for(let measure of measures) {
// template.measures.set(measure._id, measure);
// }
//});
// Place the sales sheet in a reactive var and put the setting of the reactive var in an autorun.
// The autorun is needed apparently to ensure changes to the data force a change in the reactive var.
Tracker.autorun(function() {
//Force this to be reactive on the current data.
try {
Template.currentData();
} catch(err) {
// Ignore it. This always has an error accessing the currentData as the template is destroyed.
}
//For some reason the current data is not always set, and does not always equal the template.data. We will use the template.data to get the actual ID of the sales sheet for the query.
template.salesSheet.set(Meteor.collections.SalesSheets.findOne(template.data));
});
Tracker.autorun(function() {
let products = template.salesSheet.get().products;
let index = 1;
// Note: We will ignore orphaned data in the dictionary so we don't have to clear the dictionary, or identify the orphans. The orphans are just a few extra product id's mapped to booleans, and should be fairly rare anyway.
// While ignoring headers (resetting index upon header), collect all odd product id's into a set.
for(let next of products) {
if(next.productId) {
if(index % 2 != 0) {
template.oddProductIds.set(next.productId, true);
}
else {
template.oddProductIds.delete(next.productId);
}
index++;
}
else index = 1;
}
});
});
Template.SalesSheetForm.onRendered(function() {
this.$('.sheetHeader').validator();
this.$('[name="venue"]').buildCombo({cursor: Meteor.collections.Venues.find({}), selection: this.selectedVenue, textAttr: 'name', listClass: 'comboList'});
});
Template.SalesSheetForm.helpers({
isHeading: function(product) {
return !product.productId;
},
products: function() {
if(Template.instance().salesSheet.get())
return Template.instance().salesSheet.get().products;
else
return [];
},
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]);
}
return result;
},
venues: function() {
return Meteor.collections.Venues.find({});
}
});
Template.SalesSheetForm.events({
'change input[name="date"]': function(event, template) {
template.selectedDate.set(moment(event.target.value, "YYYY-MM-DD").toDate());
},
'click .sheetControls .resetSheet': function(event, template) {
for(let next of template.productTemplates) {
next.reset();
}
},
'click .sheetControls .saveSheet': function(event, template) {
event.preventDefault();
template.$('.sheetHeader').data('bs.validator').validate(function(isValid) {
if(isValid) {
let date = template.selectedDate.get();
let venueId = template.selectedVenue.get()._id;
// Track the inserts and errors, display output to the user and log when everything is done.
let insertMetadata = {
serverErrors: [],
insertCount: 0,
finishedCount: 0,
isDoneInserting: false,
incrementInsertCount: function() {
this.insertCount++;
},
incrementFinishedCount: function() {
this.finishedCount++;
this.finished();
},
doneInserting: function() {
this.isDoneInserting = true;
this.finished();
},
finished: function() {
if(this.isDoneInserting && this.finishedCount == this.insertCount) {
if(this.serverErrors.length > 0) {
let log = "__________________________________________\n";
log += "Server Errors:\n\n";
for(let e of this.serverErrors) {
log += e + "\n";
}
log += "\n__________________________________________";
console.log(log);
sAlert.error("Failed to insert some or all of the sales! See the browser logs for details. Successful sales had their amounts set to zero.");
}
else {
sAlert.success("All " + this.insertCount + " sales were saved.");
}
}
}
};
// Iterate over the product templates.
for(let productTemplate of template.productTemplates) {
let productId = productTemplate.product.get()._id;
// Iterate over each measure template in each product template.
for(let measureTemplate of productTemplate.measureTemplates) {
let measureId = measureTemplate.measure.get()._id;
let amount = measureTemplate.amount.get();
let price = measureTemplate.price.get();
// If the amount and price are above zero then we should record a sale.
if(amount > 0 && price > 0) {
let sale = {date, venueId, productId, measureId, amount, price};
insertMetadata.incrementInsertCount();
// Record the sale.
Meteor.call("insertSale", sale, function(error) {
if(error) {
insertMetadata.serverErrors.push(error);
}
else {
measureTemplate.amount.set(0);
measureTemplate.price.set(measureTemplate.autoSetPrice);
}
insertMetadata.incrementFinishedCount();
});
}
}
}
insertMetadata.doneInserting();
}
});
}
});
// ***** A header in the sales sheet. *****
Template.SalesSheetFormHeader.onCreated(function() {
//this.parentTemplate(1).productTemplates.push(this);
});
Template.SalesSheetFormHeader.onDestroyed(function() {
//this.parentTemplate(1).productTemplates.remove(this);
});
Template.SalesSheetFormHeader.events({
});
Template.SalesSheetFormHeader.helpers({
});
// ***** A product in the sales sheet. *****
// The data is the SalesSheet's Product metadata (not a database Product object). It has a name and productId among other things.
Template.SalesSheetFormProduct.onCreated(function() {
let parent = this.parentTemplate(1);
let template = this;
parent.productTemplates.push(this);
this.parent = parent;
this.measureTemplates = [];
this.measureTemplatesDependancy = new Tracker.Dependency;
this.product = new ReactiveVar(); //The actual product for this sheet product.
this.total = new ReactiveVar(0);
this.reset = function() {
for(let measureTemplate of template.measureTemplates) {
measureTemplate.reset();
}
};
//Set the product data in a reactive variable so that changes to the product (such as pricing) are immediately reflected in this sheet. This ensures that the view updates when the reactive variable value changes.
//Set the product reactive variable value in an autorun block so that if the product is updated in the local database, we are notified. This ensures the reactive variable value updates when the database changes.
template.autorun(function() {
// Save a reference to the product.
template.product.set(Meteor.collections.Products.findOne(Template.currentData().productId));
// Depend on the array of measure templates.
template.measureTemplatesDependancy.depend();
for(let measureTemplate of template.measureTemplates) {
measureTemplate.resetPrice();
}
});
//Auto calculate a total from the amounts and prices. This is in a separate autorun so that it only runs when prices or amounts change.
template.autorun(function() {
let total = 0;
// Depend on the array of measure templates.
template.measureTemplatesDependancy.depend();
for(let measureTemplate of template.measureTemplates) { //Iterate over the child templates that display measure data (price & amount).
total += measureTemplate.amount.get() * measureTemplate.price.get();
}
template.total.set(total);
});
});
Template.SalesSheetFormProduct.onDestroyed(function() {
this.parentTemplate(1).productTemplates.remove(this);
});
Template.SalesSheetFormProduct.events({
});
Template.SalesSheetFormProduct.helpers({
measures: function() {
return this.measureIds;
},
total: function() {
let total = Template.instance().total.get();
return (total ? total : 0).toFixed(2);
},
odd: function() {
return Template.instance().parent.oddProductIds.get(Template.currentData().productId);
}
});
// ***** A measure in the product in the sales sheet. *****
// Passed a measureId as the data.
Template.SalesSheetFormProductMeasure.onCreated(function() {
let parent = this.parentTemplate(1);
let template = this;
//Save this template as a child of the parent in a way the parent can used to keep us up to date.
parent.measureTemplates.push(this);
parent.measureTemplatesDependancy.changed();
this.measure = new ReactiveVar();
this.amount = new ReactiveVar(0);
this.price = new ReactiveVar(0);
this.autoSetPrice = 0;
this.autorun(function() {
template.measure.set(Meteor.collections.Measures.findOne(Template.currentData()));
});
this.resetPrice = function(overrideUserData) { // overrideUserData: Optional - used to crush any user defined pricing.
let date = parent.parent.selectedDate.get();
let prices = parent.product.get().prices;
// Change the prices based on the price data.
// Ensure there is price data for the product.
if(prices) {
let measureId = template.measure.get()._id;
if(prices[measureId] && prices[measureId].price) { //Ensure the product has a default price for this measureId.
let price;
//Determine whether we should use the current or previous price.
if(prices[measureId].effectiveDate && date && moment(prices[measureId].effectiveDate).isAfter(date))
price = prices[measureId].previousPrice;
else
price = prices[measureId].price;
//Change the auto set price for the product. This will be changed if either the prices for the product change, or if the date of the sale changes the price (current vs previous pricing in the product).
if(overrideUserData || template.autoSetPrice != price) {
// Change the displayed price in the measure if the previous auto set price is the same as the currently displayed price.
// This prevents the auto pricing from over writing a user defined price.
if(overrideUserData || template.price.get() == template.autoSetPrice) {
template.price.set(price);
}
// Save the auto set price so we know in the future if it has really changed. For example changing the date won't necessarily change this value.
template.autoSetPrice = price;
}
}
else if(overrideUserData) {
template.price.set(0);
}
}
else if(overrideUserData) {
template.price.set(0);
}
};
this.reset = function() {
template.resetPrice(true);
template.amount.set(0);
};
});
Template.SalesSheetFormProductMeasure.onDestroyed(function() {
let parent = this.parentTemplate(1);
parent.measureTemplates.remove(this);
parent.measureTemplatesDependancy.changed();
});
Template.SalesSheetFormProductMeasure.events({
'change input[name="price"]': function(event, template) {
let $input = $(event.target);
let value = parseFloat($input.val());
if(isNaN(value)) {
value = 0;
}
//Save the price in the price map by measureId.
template.price.set(value);
},
'change input[name="amount"]': function(event, template) {
let $input = $(event.target);
let value = parseFloat($input.val());
if(isNaN(value)) {
value = 0;
}
//Save the amount in the amount map by measureId.
template.amount.set(value);
},
'focus input[name="price"]': function(event, template) {
$(event.target).select();
},
'focus input[name="amount"]': function(event, template) {
$(event.target).select();
}
});
Template.SalesSheetFormProductMeasure.helpers({
measureName: function() {
return Template.instance().measure.get().name;
},
measurePrice: function() {
return Template.instance().price.get().toFixed(2);
},
measureAmount: function() {
return Template.instance().amount.get().toFixed(2);
}
});

View File

@@ -0,0 +1,34 @@
<!-- A simple container that ensures that we set some session variables before setting up the child components, and after the subscriptions are ready. -->
<!-- I was having trouble with the tab & tabData helpers being called before the session variables that use them were setup. -->
<template name="SalesSheets">
<div id="salesSheetsMain" class="verticalStack">
{{#if Template.subscriptionsReady}}
{{> SalesSheetsMain}}
{{else}}
{{/if}}
</div>
</template>
<template name="SalesSheetsMain">
<section class="optionsSection vscFixed">
<div class="options">
<label style="margin-right: 10px">Selected Sheet</label>
<select name="sheetSelection" class="form-control">
{{#each sheets}}
<option value="{{_id}}" {{sheetsSelectIsSelected this isFirst}}>{{name}}</option>
{{/each}}
</select>
<i class="fa fa-wrench editSheet noselect clickable {{#if disableButtons}}disabled{{/if}} {{#if isEditingSheet}}selected{{/if}}" aria-hidden="true">
</i><i class="fa fa-trash-o deleteSheet noselect clickable {{#if disableButtons}}disabled{{/if}}" aria-hidden="true">
</i><input type="text" name="newSheetName" class="newSheetName form-control"/><i class="fa fa-plus-circle createSheet noselect clickable {{#if disableNext}}disabled{{/if}}" aria-hidden="true"></i>
</div>
<div class="separator" style="width: 70%"></div>
<div class="separator" style="width: 60%; opacity: .5"></div>
<div class="separator" style="width: 50%; opacity: .25"></div>
</section>
<section class="tabSection verticalStack vscExpand">
{{#if isSheetSelected}}
{{>Template.dynamic template=tab data=tabData}}
{{/if}}
</section>
</template>

98
imports/ui/SalesSheets.import.styl vendored Normal file
View File

@@ -0,0 +1,98 @@
#salesSheetsMain
content-box: border-box
padding: 10px 20px
height: 100%
width: 100%
text-align: left
label
font-family: "Segoe UI", Candara, "Bitstream Vera Sans", "DejaVu Sans", "Bitstream Vera Sans", "Trebuchet MS", Verdana, "Verdana Ref", sans-serif
font-weight: 800
text-transform: uppercase
.optionsSection
display: flex
flex-flow: column
justify-content: flex-start
align-items: flex-start
align-content: stretch
width: 100%
.options
flex: 0 0 auto
display: flex
flex-flow: row
justify-content: flex-start
align-items: center
align-content: stretch
padding: 6px 20px 10px 20px
white-space: nowrap
overflow: hidden
height: 50px
.form-control
display: inline
label
flex: 0 0 auto
vertical-align: text-bottom
font-size: 1.2em
select[name="sheetSelection"]
flex: 0 0 auto
font-size: 1.2em
padding: 2px
margin-right: 4px
min-width: 160px
width: auto
input[name="newSheetName"]
flex: 0 0 auto
transition: all .75s ease
width: 0
border: 0
opacity: 0
font-size: 1.2em
input[name="newSheetName"].show
opacity: 1
border: 1px solid #ccc
border-radius: 4px
width: 200px
transform: translateX(4px)
.createSheet, .editSheet, .deleteSheet
flex: 0 0 auto
padding: 6px
margin: 0 4px
width: 33px
text-align: center
font-size: 1.5em
border-radius: 8px
border: 1px solid rgba(0, 0, 0, 0)
box-sizing: border-box
.createSheet:hover, .editSheet:hover, .deleteSheet:hover
border: 1px inset #b100d1
-webkit-box-shadow: inset 0px 0px 20px 0px #de7cff
-moz-box-shadow: inset 0px 0px 20px 0px #de7cff
box-shadow: inset 0px 0px 20px 0px #de7cff
.editSheet.selected
color: white
border: 1px inset #b100d1
-webkit-box-shadow: inset 0px 0px 36px 0px #57006c
-moz-box-shadow: inset 0px 0px 36px 0px #57006c
box-shadow: inset 0px 0px 36px 0px #57006c
.editSheet
vertical-align: top
white-space: nowrap
overflow: hidden
.disabled
color: grey
cursor: default
.createSheet
transform: translateX(-25px) rotate(0deg)
transition: all .75s ease
.createSheet.move
transform: translateX(6px) rotate(720deg)
.separator
flex: 0 0 auto
width: 100%
margin: 0 auto
padding-top: 6px
height: 1px
border-bottom: 1px solid #333
.separator:last-child
margin-bottom: 10px

160
imports/ui/SalesSheets.js Normal file
View File

@@ -0,0 +1,160 @@
import './SalesSheets.html';
import './SalesSheetForm.js';
import './SalesSheetEditor.js';
import swal from 'sweetalert2';
let PREFIX = "SalesSheets.";
Template.SalesSheets.onCreated(function() {
this.subscribe("products");
this.subscribe("venues");
this.subscribe("measures");
this.subscribe("salesSheets");
});
Template.SalesSheets.onDestroyed(function() {
// Reset the view's session variables used for navigation.
Session.set(PREFIX + "currentFormName", undefined);
Session.set(PREFIX + "tab", undefined);
});
//******************************************************************
//** Container template that allows a user to pick a sheet and either fill it out OR edit it.
//******************************************************************
Template.SalesSheetsMain.onCreated(function() {
//Save the previous session state - whether we are editing the selected sheet.
//The name of the currently active page tab. This will either be the SalesSheetForm or the SalesSheetEditor.
if(!Session.get(PREFIX + "tab")) Session.set(PREFIX + "tab", "SalesSheetForm");
if(!Session.get(PREFIX + 'selectedSheet')) {
Session.set(PREFIX + 'selectedSheet', Meteor.collections.SalesSheets.findOne({}, {sort: {name: 1}}));
}
});
Template.SalesSheetsMain.helpers({
sheets: function() {
//let sheets = Meteor.collections.SalesSheets.find({}, {sort: {name: 1}}).fetch();
//
//if(sheets && sheets.length > 0) sheets[0].isFirst = true;
//
//return sheets;
return Meteor.collections.SalesSheets.find({}, {sort: {name: 1}});
},
sheetsSelectIsSelected: function(sheet, isFirst) {
let selectedSheet = Session.get(PREFIX + "selectedSheet");
if(!selectedSheet && isFirst) Session.set(PREFIX + "selectedSheet", selectedSheet = sheet);
return selectedSheet == sheet ? "selected" : "";
},
disableButtons: function() {
//Disable the edit & delete functionality if nothing is selected.
return !Session.get(PREFIX + "selectedSheet");
},
selected: function() {
//Get whether the current sheet is selected and return the string for use in the option tag.
//return this.isSelected ? "selected" : "";
return this._id == Session.get(PREFIX + 'selectedSheet')._id;
},
tab: function() {
return Session.get(PREFIX + "tab");
},
tabData: function() {
return Session.get(PREFIX + "selectedSheet")._id;
},
isSheetSelected: function() {
return Session.get(PREFIX + "selectedSheet");
},
isEditingSheet: function() {
return Session.get(PREFIX + "tab") == "SalesSheetEditor";
}
});
Template.SalesSheetsMain.events({
'change select[name="sheetSelection"]': function(event, template) {
let id = $(event.target).val();
let selected = Meteor.collections.SalesSheets.findOne(id);
Session.set(PREFIX + "selectedSheet", selected);
// Reset the editor button & the displayed tab.
Session.set(PREFIX + "tab", "SalesSheetForm");
},
'click .editSheet': function(event, template) {
if(!$(event.target).hasClass("selected")) {
// Display the editor for the sheet.
Session.set(PREFIX + "tab", "SalesSheetEditor");
}
else {
// Remove the sheet editor and show the form to fill out the sheet.
Session.set(PREFIX + "tab", "SalesSheetForm");
// Reset the editor session variables.
Session.set(PREFIX + "currentFormName", undefined);
}
},
'click .deleteSheet': function(event, template) {
let selectedSheet = Session.get(PREFIX + "selectedSheet");
if(selectedSheet) {
swal({
title: "Are you sure?",
text: "This will permanently remove the sale sheet named " + selectedSheet.name + ".",
type: "question",
showCancelButton: true,
confirmButtonColor: "#DD6B55",
confirmButtonText: "Yes"
}).then(
function(isConfirm) {
if(isConfirm) {
Meteor.call('removeSalesSheet', selectedSheet._id, function(error) {
if(error) sAlert.error("Failed to delete the sheet!\n" + error);
else {
Session.set(PREFIX + "selectedSheet", Meteor.collections.SalesSheets.findOne({}, {sort: {name: 1}}));
}
});
}
},
function(dismiss) {}
);
}
},
'click .createSheet': function(event, template) {
let $input = template.$('input[name="newSheetName"]');
if($input.hasClass('show')) {
let name = $input.val();
name = name ? name.trim() : undefined;
name = name && name.length > 0 ? name : undefined;
if(name) {
Meteor.call('createSalesSheet', name, function(error, id) {
if(error) sAlert.error("Failed to create the sheet!\n" + error);
else {
//Quick hack to attempt to allow the sheet we created to be added to the select box before we try to select it and edit it.
let count = 0;
let interval = setInterval(function() {
let selected = Meteor.collections.SalesSheets.findOne(id);
if(selected) {
//Select the sheet in the drop down.
template.$('select[name="sheetSelection"]').val(id);
Session.set(PREFIX + "selectedSheet", selected);
//Display the editor tab.
Session.set(PREFIX + "tab", "SalesSheetEditor");
clearInterval(interval);
}
else count++;
//Avoid infinite loop that should never happen.
if(count > 100) clearInterval(interval);
}, 100);
}
});
}
$input.removeClass('show');
$(event.target).toggleClass('move');
}
else {
$input.addClass('show');
$(event.target).toggleClass('move');
}
}
});

View File

@@ -1,77 +1,79 @@
<template name="Body">
{{> sAlert}}
<div id="layoutBody">
<div class="mainBody">
<div class="leftSidebar">
<i class="fa fa-sign-out fa-2x signOut" aria-hidden="true"></i>
<div class="logo">
<img src="/images/PetitTetonLogo_v2.png"/>
<!--<div id="layoutBody" class="verticalStack">-->
<div id="mainBody" class="mainBody verticalStack vscExpand">
<div class="leftSidebar vscFixed">
<div class="logoArea">
<i class="fa fa-sign-out fa-2x signOut" aria-hidden="true"></i>
<div class="logo">
<img src="/images/PetitTetonLogo_v2.png"/>
</div>
</div>
<ul>
{{#if isInRole 'manage'}}
<li class="{{isActiveRoute 'UserManagement'}}">
<a href="{{pathFor 'UserManagement'}}">
User Management
<div class="menuArea">
<ul>
{{#if isInRole 'manage'}}
<li class="{{isActiveRoute 'UserManagement'}}">
<a href="{{pathFor 'UserManagement'}}">
User Management
</a>
</li>
{{/if}}
<li class="{{isActiveRoute 'Sales'}}">
<a href="{{pathFor 'Sales'}}">
Sales <span class="tag">Test Tag</span>
</a>
</li>
{{/if}}
<li class="{{isActiveRoute 'Sales'}}">
<a href="{{pathFor 'Sales'}}">
Sales <span class="tag">Test Tag</span>
</a>
</li>
<li class="{{isActiveRoute 'Production'}}">
<a href="{{pathFor 'Production'}}">
Production <span class="tag">sample</span>
</a>
</li>
<li class="{{isActiveRoute 'Products'}}">
<a href="{{pathFor 'Products'}}">
Products
</a>
</li>
<li class="{{isActiveRoute 'Pricing'}}">
<a href="{{pathFor 'Pricing'}}">
Pricing
</a>
</li>
<li class="{{isActiveRoute 'ProductTags'}}">
<a href="{{pathFor 'ProductTags'}}">
Tags
</a>
</li>
<li class="{{isActiveRoute 'Measures'}}">
<a href="{{pathFor 'Measures'}}">
Measures
</a>
</li>
<li class="{{isActiveRoute 'Venues'}}">
<a href="{{pathFor 'Venues'}}">
Venues
</a>
</li>
<li class="{{isActiveRoute 'Graphs'}}">
<a href="{{pathFor 'Graphs'}}">
Graphs
</a>
</li>
</ul>
</div>
<div class="contentBody">
<div class="contentContainer">
<div class="header">
&nbsp;
</div>
<div class="content">
{{> Template.dynamic template=content}}
</div>
<div class="footer">
&copy; Petit Teton LLC 2017
</div>
<li class="{{isActiveRoute 'SalesSheets'}}">
<a href="{{pathFor 'SalesSheets'}}">
Sales Sheets
</a>
</li>
<li class="{{isActiveRoute 'Production'}}">
<a href="{{pathFor 'Production'}}">
Production <span class="tag">sample</span>
</a>
</li>
<li class="{{isActiveRoute 'Products'}}">
<a href="{{pathFor 'Products'}}">
Products
</a>
</li>
<li class="{{isActiveRoute 'Pricing'}}">
<a href="{{pathFor 'Pricing'}}">
Pricing
</a>
</li>
<li class="{{isActiveRoute 'ProductTags'}}">
<a href="{{pathFor 'ProductTags'}}">
Tags
</a>
</li>
<li class="{{isActiveRoute 'Measures'}}">
<a href="{{pathFor 'Measures'}}">
Measures
</a>
</li>
<li class="{{isActiveRoute 'Venues'}}">
<a href="{{pathFor 'Venues'}}">
Venues
</a>
</li>
<li class="{{isActiveRoute 'Graphs'}}">
<a href="{{pathFor 'Graphs'}}">
Graphs
</a>
</li>
</ul>
</div>
<div class="footer">
&copy; Petit Teton LLC 2017
</div>
</div>
<div class="contentBody verticalStack">
{{> Template.dynamic template=content}}
</div>
</div>
</div>
<!--</div>-->
</template>
<!--<template name="Body">-->

View File

@@ -1,37 +1,52 @@
#layoutBody
width: 100%
height: 100%
display: table
text-align: center
//#layoutBody
// width: 100%
// height: 100%
// //display: table //Firefox does not like this - no idea why. Not required apparently.
// margin: 0
// padding: 0
// border: 0
// overflow: hidden
#mainBody
//position: relative
display: flex;
flex-flow: row;
//display: inline-block // Requried by Firefox for absolute positioning.
margin: 0
padding: 0
border: 0
height: 100%
width: 100%
.mainBody
display: table
.leftSidebar
flex: 0 0 auto
display: flex
flex-flow: column
justify-content: flex-start
align-items: flex-start
align-content: stretch
height: 100%
width: 100%
margin: 0
padding: 0
//position: absolute
border: 0
vertical-align: top
padding: 0
text-align: left
//top: 0px
//left: 0px
//bottom: 0px
width: 220px
//Permalink - use to edit and share this gradient: http://colorzilla.com/gradient-editor/#627d4d+0,1f3b08+100;Olive+3D
background-color: #90b272 //Old browsers
background: -moz-linear-gradient(-180deg, #90b272 0%, #4d7727 100%) //FF3.6-15
background: -webkit-linear-gradient(-180deg, #90b272 0%,#4d7727 100%) //Chrome10-25,Safari5.1-6
background: linear-gradient(180deg, #90b272 0%,#4d7727 100%) //W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+
font-size: 14px
font-weight: 700
overflow: hidden
.leftSidebar
display: table-cell
position: relative
border: 0
vertical-align: top
padding: 0
text-align: left
width: 220px
height: 100%
//Permalink - use to edit and share this gradient: http://colorzilla.com/gradient-editor/#627d4d+0,1f3b08+100;Olive+3D
background: #90b272 //Old browsers
background: -moz-linear-gradient(-180deg, #90b272 0%, #4d7727 100%) //FF3.6-15
background: -webkit-linear-gradient(-180deg, #90b272 0%,#4d7727 100%) //Chrome10-25,Safari5.1-6
background: linear-gradient(180deg, #90b272 0%,#4d7727 100%) //W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+
font-size: 14px
font-weight: 700
.logoArea
flex: 0 0 auto
width: 100%
.signOut
position: absolute
left: 10px
@@ -42,16 +57,20 @@
color: #BBB
.signOut:active
color: black
.logo
text-align: center
margin-top: 20px
img:hover
//-webkit-animation: neon6_drop 1.5s ease-in-out infinite alternate;
//-moz-animation: neon6_drop 1.5s ease-in-out infinite alternate;
animation: neon6_drop 1.5s ease-in-out infinite alternate;
.menuArea
flex: 1 1 auto
width: 100%
ul
padding: 20px 0 0 0
margin: 0
list-style: none
li:first-child
border-top: 1px solid #e4e5e7
li
@@ -59,14 +78,12 @@
color: #96a2ae
text-transform: uppercase
display: block
a
color: black
padding: 15px 20px
cursor: pointer
text-decoration: none
display: block
.tag
padding: .2em .5em
font-size: .7em
@@ -78,40 +95,59 @@
float: right
li:hover
background-color: #333
-webkit-animation: neon6 1.5s ease-in-out infinite alternate;
-moz-animation: neon6 1.5s ease-in-out infinite alternate;
animation: neon6 1.5s ease-in-out infinite alternate;
li.active
background-color: #333
a
color: #96a2ae
.contentBody
display: table-cell
//background: #4d7727
li.active:hover
background-color: #333
a
color: white
.footer
flex: 0 0 auto
width: 100%
font-size: 9px
text-align: center
.contentContainer
display: table
width: 100%
height: 100%
//border-radius 20px
//border: 0;
background: white
.contentBody
flex: 1 1 1px
padding: 10px 20px
-webkit-box-shadow: inset 4px 2px 10px -3px rgba(168,165,168,1)
-moz-box-shadow: inset 4px 2px 10px -3px rgba(168,165,168,1)
box-shadow: inset 8px 0px 10px -3px rgba(168,165,168,1)
//position: absolute
//top: 0
//bottom: 0
//left: 220px
//right: 0
overflow: hidden
.header
display: table-row
background: #90b272
width: 100%
height: 1px
.content
display: table-row
width: 100%
-webkit-box-shadow: inset 4px 2px 6px 2px rgba(168,165,168,1)
-moz-box-shadow: inset 4px 2px 6px 2px rgba(168,165,168,1)
box-shadow: inset 4px 2px 6px 2px rgba(168,165,168,1)
.footer
display: table-row
height: 1px
text-align: center
background: #4d7727
color: white
//.contentBody
// //display: table-cell
// position: absolute
// top: 0
// bottom: 0
// left: 220px
// right: 0
// //background: #4d7727
//
// .contentContainer
// display: table
// width: 100%
// height: 100%
// //border-radius 20px
// //border: 0;
// background: white
//
// .content
// display: table-row
// width: 100%
// -webkit-box-shadow: inset 4px 2px 10px -3px rgba(168,165,168,1)
// -moz-box-shadow: inset 4px 2px 6px 2px rgba(168,165,168,1)
// box-shadow: inset 4px 2px 6px 2px rgba(168,165,168,1)
//#layoutBody

52
imports/ui/styles/buttons.import.styl vendored Normal file
View File

@@ -0,0 +1,52 @@
span.button
margin: 0 0 0 1px
padding: 0.5em 1em
border: 1px solid #d4d4d4
border-radius: 50em
text-overflow: ellipsis
white-space: nowrap
overflow: hidden
cursor: pointer
outline: none
background-color: #ececec
color: #333
font: 11px/normal sans-serif
text-shadow: 1px 1px 0 #fff
text-align: center
text-decoration: none
//Prevent selection
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Chrome/Safari/Opera */
-khtml-user-select: none; /* Konqueror */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none; /* Non-prefixed version, currently not supported by any browser */
span.button:hover
color: blue
span.button:active
color: #fff
background-color: #141414
text-shadow: 1px 1px 0 #000
border: 1px solid #292929
span.button.primary
font-weight: 800
span.button.selected //Use this if they are toggle buttons
color: #fff
background-color: #141414
text-shadow: 1px 1px 0 #000
cursor: default
span.buttonGroup
:not(:first-child):not(:last-child)
border-radius: 0
:first-child
border-top-left-radius: 50em
border-bottom-left-radius: 50em
border-top-right-radius: 0
border-bottom-right-radius: 0
margin-left: 0
:last-child
border-top-left-radius: 0
border-bottom-left-radius: 0
border-top-right-radius: 50em
border-bottom-right-radius: 50em

34
imports/ui/styles/effects.import.styl vendored Normal file
View File

@@ -0,0 +1,34 @@
@-webkit-keyframes neon6
from
text-shadow: 0 0 10px #fff, 0 0 20px #fff, 0 0 30px #fff, 0 0 40px #ff00de, 0 0 70px #ff00de, 0 0 80px #ff00de, 0 0 100px #ff00de, 0 0 150px #ff00de
to
text-shadow: 0 0 5px #fff, 0 0 10px #fff, 0 0 15px #fff, 0 0 20px #ff00de, 0 0 35px #ff00de, 0 0 40px #ff00de, 0 0 50px #ff00de, 0 0 75px #ff00de
@-moz-keyframes neon6
from
text-shadow: 0 0 10px #fff, 0 0 20px #fff, 0 0 30px #fff, 0 0 40px #ff00de, 0 0 70px #ff00de, 0 0 80px #ff00de, 0 0 100px #ff00de, 0 0 150px #ff00de
to
text-shadow: 0 0 5px #fff, 0 0 10px #fff, 0 0 15px #fff, 0 0 20px #ff00de, 0 0 35px #ff00de, 0 0 40px #ff00de, 0 0 50px #ff00de, 0 0 75px #ff00de
@keyframes neon6
from
text-shadow: 0 0 10px #fff, 0 0 20px #fff, 0 0 30px #fff, 0 0 40px #ff00de, 0 0 70px #ff00de, 0 0 80px #ff00de, 0 0 100px #ff00de, 0 0 150px #ff00de
to
text-shadow: 0 0 5px #fff, 0 0 10px #fff, 0 0 15px #fff, 0 0 20px #ff00de, 0 0 35px #ff00de, 0 0 40px #ff00de, 0 0 50px #ff00de, 0 0 75px #ff00de
//@-webkit-keyframes neon6_drop
// from
// -webkit-filter: drop-shadow(0px 0px 20px rgba(194,0,0,0.7))
// filter: url(shadow.svg#drop-shadow)
// 50%
// -webkit-filter: drop-shadow(0px 0px 20px rgba(255,0,0,1))
// filter: url(shadow.svg#drop-shadow)
// to
// -webkit-filter: drop-shadow(0px 0px 20px rgba(194,0,0,0.7))
// filter: url(shadow.svg#drop-shadow)
@keyframes neon6_drop
from
filter: drop-shadow(0px 0px 20px rgba(194,0,0,0.7)) brightness(120%)
50%
filter: drop-shadow(0px 0px 20px rgba(255,0,0,1)) brightness(80%)
to
filter: drop-shadow(0px 0px 20px rgba(194,0,0,0.7)) brightness(100%)

117
imports/ui/styles/forms.import.styl vendored Normal file
View File

@@ -0,0 +1,117 @@
//Form Styles
.select2-container
font-size: 10px
.select2-selection
font-size: 13px //Make the font small enough the control can have a height similar to a standard input field.
margin-bottom: 0px
min-height: 10px !important //This is what really sets the height of the box containing the selection(s)
padding-bottom: 2px //Add a little space below the selections to balance it all out.
input
padding: 6px
border-radius: 4px
border-width: 1px
border-style: solid
border-color: #ccc
//input[type='button'].btn-success, input[type='submit'].btn-success
// background-color: #5cb85c
// :hover
// background-color:
//input[type='button'].btn-danger, input[type='submit'].btn-danger
// background-color: #e55b46
.form-control, .select2-selection //?
font-size: 14px
margin-bottom: 0px
.form-group
margin: 4px 0
.has-error .form-control
border-color: #a94442
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075)
box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075)
@media screen and (-webkit-min-device-pixel-ratio: 0)
input[type="date"].form-control, input[type="time"].form-control, input[type="datetime-local"].form-control, input[type="month"].form-control
line-height: 34px
.form-control
display: block
width: 100%
height: 34px
padding: 6px 12px
font-size: 14px
line-height: 1.42857143
color: #555
background-color: #fff
background-image: none
border: 1px solid #ccc
border-radius: 4px
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075)
box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075)
-webkit-transition: border-color ease-in-out .15s, -webkit-box-shadow ease-in-out .15s
-o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s
transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s
input[type="date"], input[type="datetime-local"], input[type="month"], input[type="time"], input[type="week"]
align-items: center
-webkit-padding-start: 1px
overflow: hidden
padding-left: 10px
input
-webkit-appearance: textfield
background-color: white
-webkit-rtl-ordering: logical
user-select: text
cursor: auto
padding: 1px
border-width: 2px
border-style: inset
border-color: initial
border-image: initial
.form-control[disabled], .form-control[readonly], fieldset[disabled] .form-control
background-color: #eee
opacity: 1
input, textarea, keygen, select, button
text-rendering: auto
color: initial
letter-spacing: normal
word-spacing: normal
text-transform: none
text-indent: 0px
text-shadow: none
display: inline-block
text-align: start
margin: 0em 0em 0em 0em
font: 13.3333px Arial
input, textarea, keygen, select, button, meter, progress
-webkit-writing-mode: horizontal-tb
//.btn.disabled, .btn[disabled], fieldset[disabled] .btn
// cursor: not-allowed
// filter: unquote("alpha(opacity=65)")
// -webkit-box-shadow: none
// box-shadow: none
// opacity: .65
//button, html input[type="button"], input[type="reset"], input[type="submit"]
// -webkit-appearance: button
// cursor: pointer
//button, html input[type="button"], input[type="reset"], input[type="submit"]
// -webkit-appearance: button
// cursor: pointer
//.btn
// display: inline-block;
// padding: 6px 12px;
// margin-bottom: 0;
// font-size: 14px;
// font-weight: normal;
// line-height: 1.42857143;
// text-align: center;
// white-space: nowrap;
// vertical-align: middle;
// -ms-touch-action: manipulation;
// touch-action: manipulation;
// cursor: pointer;
// -webkit-user-select: none;
// -moz-user-select: none;
// -ms-user-select: none;
// user-select: none;
// background-image: none;
// border: 1px solid transparent;
// border-radius: 4px;

View File

@@ -0,0 +1,133 @@
section.maxHeightContainer, div.maxHeightContainer
display: table
height: 100%
width: 100%
section.maxHeightRow, div.maxHeightRow
display: table-row
section.maxHeightContent, div.maxHeightContent //Use this for a row of content that should shrink to fit the content size.
display: table-cell
height: 1px
section.maxHeightContentExpandAndScroll, div.maxHeightContentExpandAndScroll //Use this for a row of content that should take up all remaining space and will contain potentially scrolled content.
display: table-cell
height: 100%
position: relative
section.maxHeightContentScrolled, div.maxHeightContentScrolled //Use this to create the scrolled content. Can use any display within it.
position: absolute
top: 0
bottom: 0
left: 0
right: 0
width: auto
height: auto
overflow-y: auto
// *****************
// ** Vertical Stack
// ** Use .verticalStack on containers, and .vscFixed or .vscExpand on children (they can also be containers).
// ** Designed to use Flexbox to allow full screen (vertical) layouts. Fixed children will fit the content, and expand children will consume all available vertical space, but will not exceed the vertical space.
// ** Use .columnContainer to setup a horizontally scrolling, full height container where children are tiled down first, then wrap to the next column.
/*
Test Code:
-------CSS------
* {
margin: 0;
padding: 0;
}
html {
height: 100%;
min-height: 100%;
}
body {
background: purple;
color: black;
min-height: 100%;
height: 100%;
}
.vscFixed {
flex: 0 0 auto;
width: 100%;
}
.vscExpand {
flex: 1 1 1px;
width: 100%;
}
.verticalStack {
width: 100%;
height: 100%;
display: flex;
flex-flow: column;
justify-content: flex-start;
align-items: flex-start;
align-content: stretch;
}
.columnContainer {
width: 100%;
height: 100%;
display: flex;
flex-flow: column wrap;
justify-content: flex-start;
align-items: stretch;
align-content: flex-start;
overflow-x: auto;
background: white;
}
.columnContent {
flex: none;
width: 300px;
}
-------Javascript------
var container = document.querySelector('.columnContainer');
for(var i = 0; i < 400; i++) {
var child = document.createElement("div");
child.innerHTML = "Element " + i;
child.className = "columnContent";
container.appendChild(child);
}
-------HTML------
<div class="verticalStack">
<div class='vscFixed' style="width: 100%; background:green">
<p>
Some content.
</p>
<p>
More content...
</p>
</div>
<div class="verticalStack vscExpand">
<div class='vscFixed' style="background: yellow;">
Test bar.
</div>
<div class="columnContainer vscExpand">
</div>
</div>
</div>
*/
.vscFixed {
flex: 0 0 auto;
width: 100%;
}
.vscExpand {
flex: 1 1 1px;
width: 100%;
}
.verticalStack {
width: 100%;
height: 100%;
display: flex;
flex-flow: column;
justify-content: flex-start;
align-items: flex-start;
align-content: stretch;
}
.columnContainer {
display: flex;
flex-direction: column;
flex-wrap: wrap;
justify-content: flex-start;
align-items: stretch;
align-content: flex-start;
overflow-x: auto;
}
.columnContent {
flex: none;
}

61
imports/ui/styles/tabs.import.styl vendored Normal file
View File

@@ -0,0 +1,61 @@
ul.tabRow
position: relative
text-align: left
list-style: none
margin: 0
padding: 0 0 0 10px
line-height: 24px
height: 26px
font-size: 12px
overflow: hidden
li
position: relative
z-index: 0
border: 1px solid #AAA
background: #D1D1D1
display: inline-block
border-top-left-radius: 6px
border-top-right-radius: 6px
background: -o-linear-gradient(top, #ECECEC 50%, #D1D1D1 100%)
background: -ms-linear-gradient(top, #ECECEC 50%, #D1D1D1 100%)
background: -moz-linear-gradient(top, #ECECEC 50%, #D1D1D1 100%)
background: -webkit-linear-gradient(top, #ECECEC 50%, #D1D1D1 100%)
background: linear-gradient(top, #ECECEC 50%, #D1D1D1 100%)
box-shadow: 0 3px 3px rgba(0, 0, 0, 0.4), inset 0 1px 0 #FFF
text-shadow: 0 1px #FFF
margin: 0 -5px
padding: 0 20px
li:before, li:after
position: absolute
bottom: -1px
width: 5px
height: 5px
content: " "
border: 1px solid #AAA
li:before
left: -6px
border-bottom-right-radius: 6px
border-width: 0 1px 1px 0
box-shadow: 2px 2px 0 #D1D1D1
li:after
right: -6px
border-bottom-left-radius: 6px
border-width: 0 0 1px 1px
box-shadow: -2px 2px 0 #D1D1D1
li.selected
z-index: 2
background: white
color: #333
border-bottom-color: #FFF
li.selected:before
box-shadow: 2px 2px 0 #FFF
li.selected:after
box-shadow: -2px 2px 0 #FFF
ul.tabRow:before
position: absolute
width: 100%
bottom: 0
left: 0
border-bottom: 1px solid #AAA
z-index: 1
content: " "

View File

@@ -199,11 +199,31 @@
if(this.$hidden && _this.$hidden.val()) {
hiddenInputChanged();
}
//TODO: Should probably check to ensure comparator is a function? Not that it will help much if the function is not written correctly. Stupid Javascript!
if(this.options.selection && this.options.comparator) {
Tracker.autorun(function() {
let selectedData = this.options.selection.get();
if(selectedData) {
let listItems = this.$list.children();
let found = false;
for(let i = 0; !found && i < listItems.length; i++) {
if(this.options.comparator($(listItems[i]).data('model'), selectedData)) {
found = true;
this.select($(listItems[i]));
}
}
}
}.bind(this));
}
};
Combo.DEFAULTS = {
cursor: undefined, //A meteor Cursor.
selection: undefined, //A meteor ReactiveVar whose value will be set to the current selection.
comparator: undefined, //A function that takes two collection objects and compares them for equality. If the combo shows users for example, this comparator would compare one user id to another. Required for the combo to set the selection if the view changes it externally relative to this combo.
textAttr: 'text', //The attribute of the data elements to use for the name. This can also be a function that takes the data object and returns the text.
idAttr: 'id', //The attribute of the data elements to use for the ID. This can also be a function that takes the data obejct and returns the ID.
// groupFunctions: The object containing three functions: 'groupParent', 'parentText', 'children'.
@@ -222,7 +242,7 @@
Combo.prototype.select = function($li) {
if($li.length == 0) {
if(this.$input.val() != '') {
this.$input.val("")
this.$input.val("");
if(this.$hidden) this.$hidden.val(undefined).change();
this.filter();
//Note: Don't trigger the select event - for some reason it causes the dropdown to reopen and the control to retain focus when clicking out of the widget.
@@ -242,13 +262,17 @@
this.filter();
//this.trigger('select', $li);
//Set the reactive var for the selection if one is provided.
if(this.options.selection) {
//Set the reactive var for the selection if one is provided and the selection has changed relative to the model.
if(this.options.selection && this.options.selection.get() != $li.data('model')) {
this.options.selection.set($li.data('model'));
}
}
}
};
Combo.prototype.escapeRegex = function(s) {
return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
};
//Filters the list items by marking those that match the text in the text field as having the class 'visible'.
Combo.prototype.filter = function() {
@@ -273,7 +297,7 @@
if(searches) {
for(let i = 0; i < searches.length; i++) {
regexs.push(new RegExp("\\b" + searches[i]));
regexs.push(new RegExp("\\b" + this.escapeRegex(searches[i])));
}
}

View File

@@ -1,5 +1,5 @@
// https://tc39.github.io/ecma262/#sec-array.prototype.includes
if (!Array.prototype.includes) {
if(!Array.prototype.includes) {
Object.defineProperty(Array.prototype, 'includes', {
value: function(searchElement, fromIndex) {
@@ -8,10 +8,10 @@ if (!Array.prototype.includes) {
throw new TypeError('"this" is null or not defined');
}
var o = Object(this);
let o = Object(this);
// 2. Let len be ? ToLength(? Get(O, "length")).
var len = o.length >>> 0;
let len = o.length >>> 0;
// 3. If len is 0, return false.
if (len === 0) {
@@ -20,14 +20,14 @@ if (!Array.prototype.includes) {
// 4. Let n be ? ToInteger(fromIndex).
// (If fromIndex is undefined, this step produces the value 0.)
var n = fromIndex | 0;
let n = fromIndex | 0;
// 5. If n ≥ 0, then
// a. Let k be n.
// 6. Else n < 0,
// a. Let k be len + n.
// b. If k < 0, let k be 0.
var k = Math.max(n >= 0 ? n : len - Math.abs(n), 0);
let k = Math.max(n >= 0 ? n : len - Math.abs(n), 0);
// 7. Repeat, while k < len
while (k < len) {
@@ -45,4 +45,64 @@ if (!Array.prototype.includes) {
return false;
}
});
}
//http://stackoverflow.com/questions/5306680/move-an-array-element-from-one-array-position-to-another
if(!Array.prototype.move) {
Array.prototype.move = function (old_index, new_index) {
if (new_index >= this.length) {
let k = new_index - this.length;
while ((k--) + 1) {
this.push(undefined);
}
}
this.splice(new_index, 0, this.splice(old_index, 1)[0]);
return this; // for testing purposes
};
}
//My own implementation to work around Javascript's shitty naming and support for collection operations.
if(!Array.prototype.remove) {
Array.prototype.remove = function(item) {
let index = this.indexOf(item);
if(index != -1) {
this.splice(index, 1);
return true;
}
return false;
}
}
//My own implementation to work around Javascript's shitty naming and support for collection operations.
// index is optional
if(!Array.prototype.add) {
Array.prototype.add = function(item, index) {
if(index == undefined || isNaN(index) || index >= this.length) return this.push(item);
else return this.splice(index, 0, item);
}
}
//My own implementation to work around Javascript's shitty naming and support for collection operations.
// Sorts a contiguous section of the array.
// Index is the index of the first element to be sorted (inclusive).
// Length is the number of elements of the array to be sorted (must be >= 2). If the length + index is greater than the array length then it will be adjusted to the end of the array.
// All other invalid inputs will result in no sorting action taken and no error.
if(!Array.prototype.partialSort) {
Array.prototype.partialSort = function(index, length, compareFunction) {
if(index >= 0 && length >= 2 && index <= (this.length - 2)) {
//Adjust the length so it doesn't over-run the array. This is the only error correction we will perform.
if(index + length > this.length) length = this.length - index;
//Shallow copy of the data in the segment to be sorted.
let sorted = this.slice(index, length + index);
sorted.sort(compareFunction);
//Put the sorted array elements back into the array.
for(let i = index, j = 0; i <= length; i++, j++) {
this[i] = sorted[j];
}
}
}
}

View File

@@ -3,7 +3,7 @@
* @param {Number} [levels] How many levels to go up. Default is 1
* @returns {Blaze.TemplateInstance}
*/
Blaze.TemplateInstance.prototype.parentTemplate = function(levels) {
Blaze.TemplateInstance.prototype.parentTemplate = Blaze.TemplateInstance.prototype.parentInstance = function(levels) {
let view = this.view;
levels = (typeof levels === "undefined") ? 1 : levels;

View File

@@ -32,6 +32,11 @@
* validate(fn) - Forces validation to occur and takes an optional callback which will be passed a flag (boolean) indicating the success of the validation (isValid).
* reset() - Resets the form's validation status. Clears all error information, without turning validation off.
* update() - Updates the collection of fields that require validation. Call this after making changes to the form, including initializing any form elements that may generate HTML (such as Select2).
*
* Notes:
* To handle decimal values "0.05", you need to add `step='0.01'` or similar to the input field.
* To get the validator to validate a field, you must add `required` to the property list: `<input type='text' required/>`
* I have modified this to not require a form-group or form-input classed container. If one is found, then the container will be used to mark for errors and success, otherwise the field element will be marked instead. Example: `<div class='form-group'><input type='text' required/></div>`
*/
+function ($) {
@@ -372,8 +377,11 @@
$block.empty().append(errors);
//Add the 'has-error' and 'has-danger' classes to the grouping.
$group.addClass('has-error has-danger');
if($group.length > 0)
//Add the 'has-error' and 'has-danger' classes to the grouping.
$group.addClass('has-error has-danger');
else
$el.addClass('has-error has-danger');
//If this is a select2 control then look for the child of a sibling that has the .select2-selection class and use it instead.
if($el.hasClass('select2-hidden-accessible')) {
@@ -395,7 +403,11 @@
var $feedback = $group.find('.form-control-feedback');
$block.html($block.data('bs.validator.originalContent'));
$group.removeClass('has-error has-danger has-success');
if($group.length > 0)
$group.removeClass('has-error has-danger has-success');
else
$el.removeClass('has-error has-danger has-success');
//Clean the sibling controls for select2.
$el.parent().find('.select2 .select2-selection').removeClass('has-error has-danger has-success');