diff --git a/addons/point_of_sale/static/lib/backbone/backbone-0.5.3.min.js b/addons/point_of_sale/static/lib/backbone/backbone-0.5.3.min.js
new file mode 100644
index 00000000000..3f0d495dc39
--- /dev/null
+++ b/addons/point_of_sale/static/lib/backbone/backbone-0.5.3.min.js
@@ -0,0 +1,33 @@
+// Backbone.js 0.5.3
+// (c) 2010 Jeremy Ashkenas, DocumentCloud Inc.
+// Backbone may be freely distributed under the MIT license.
+// For all details and documentation:
+// http://documentcloud.github.com/backbone
+(function(){var h=this,p=h.Backbone,e;e=typeof exports!=="undefined"?exports:h.Backbone={};e.VERSION="0.5.3";var f=h._;if(!f&&typeof require!=="undefined")f=require("underscore")._;var g=h.jQuery||h.Zepto;e.noConflict=function(){h.Backbone=p;return this};e.emulateHTTP=!1;e.emulateJSON=!1;e.Events={bind:function(a,b,c){var d=this._callbacks||(this._callbacks={});(d[a]||(d[a]=[])).push([b,c]);return this},unbind:function(a,b){var c;if(a){if(c=this._callbacks)if(b){c=c[a];if(!c)return this;for(var d=
+0,e=c.length;d/g,">").replace(/"/g,""").replace(/'/g,"'").replace(/\//g,"/")},has:function(a){return this.attributes[a]!=null},set:function(a,b){b||(b={});if(!a)return this;if(a.attributes)a=a.attributes;var c=this.attributes,d=this._escapedAttributes;if(!b.silent&&this.validate&&!this._performValidation(a,b))return!1;if(this.idAttribute in a)this.id=a[this.idAttribute];
+var e=this._changing;this._changing=!0;for(var g in a){var h=a[g];if(!f.isEqual(c[g],h))c[g]=h,delete d[g],this._changed=!0,b.silent||this.trigger("change:"+g,this,h,b)}!e&&!b.silent&&this._changed&&this.change(b);this._changing=!1;return this},unset:function(a,b){if(!(a in this.attributes))return this;b||(b={});var c={};c[a]=void 0;if(!b.silent&&this.validate&&!this._performValidation(c,b))return!1;delete this.attributes[a];delete this._escapedAttributes[a];a==this.idAttribute&&delete this.id;this._changed=
+!0;b.silent||(this.trigger("change:"+a,this,void 0,b),this.change(b));return this},clear:function(a){a||(a={});var b,c=this.attributes,d={};for(b in c)d[b]=void 0;if(!a.silent&&this.validate&&!this._performValidation(d,a))return!1;this.attributes={};this._escapedAttributes={};this._changed=!0;if(!a.silent){for(b in c)this.trigger("change:"+b,this,void 0,a);this.change(a)}return this},fetch:function(a){a||(a={});var b=this,c=a.success;a.success=function(d,e,f){if(!b.set(b.parse(d,f),a))return!1;c&&
+c(b,d)};a.error=i(a.error,b,a);return(this.sync||e.sync).call(this,"read",this,a)},save:function(a,b){b||(b={});if(a&&!this.set(a,b))return!1;var c=this,d=b.success;b.success=function(a,e,f){if(!c.set(c.parse(a,f),b))return!1;d&&d(c,a,f)};b.error=i(b.error,c,b);var f=this.isNew()?"create":"update";return(this.sync||e.sync).call(this,f,this,b)},destroy:function(a){a||(a={});if(this.isNew())return this.trigger("destroy",this,this.collection,a);var b=this,c=a.success;a.success=function(d){b.trigger("destroy",
+b,b.collection,a);c&&c(b,d)};a.error=i(a.error,b,a);return(this.sync||e.sync).call(this,"delete",this,a)},url:function(){var a=k(this.collection)||this.urlRoot||l();if(this.isNew())return a;return a+(a.charAt(a.length-1)=="/"?"":"/")+encodeURIComponent(this.id)},parse:function(a){return a},clone:function(){return new this.constructor(this)},isNew:function(){return this.id==null},change:function(a){this.trigger("change",this,a);this._previousAttributes=f.clone(this.attributes);this._changed=!1},hasChanged:function(a){if(a)return this._previousAttributes[a]!=
+this.attributes[a];return this._changed},changedAttributes:function(a){a||(a=this.attributes);var b=this._previousAttributes,c=!1,d;for(d in a)f.isEqual(b[d],a[d])||(c=c||{},c[d]=a[d]);return c},previous:function(a){if(!a||!this._previousAttributes)return null;return this._previousAttributes[a]},previousAttributes:function(){return f.clone(this._previousAttributes)},_performValidation:function(a,b){var c=this.validate(a);if(c)return b.error?b.error(this,c,b):this.trigger("error",this,c,b),!1;return!0}});
+e.Collection=function(a,b){b||(b={});if(b.comparator)this.comparator=b.comparator;f.bindAll(this,"_onModelEvent","_removeReference");this._reset();a&&this.reset(a,{silent:!0});this.initialize.apply(this,arguments)};f.extend(e.Collection.prototype,e.Events,{model:e.Model,initialize:function(){},toJSON:function(){return this.map(function(a){return a.toJSON()})},add:function(a,b){if(f.isArray(a))for(var c=0,d=a.length;c').hide().appendTo("body")[0].contentWindow,this.navigate(a);
+this._hasPushState?g(window).bind("popstate",this.checkUrl):"onhashchange"in window&&!b?g(window).bind("hashchange",this.checkUrl):setInterval(this.checkUrl,this.interval);this.fragment=a;m=!0;a=window.location;b=a.pathname==this.options.root;if(this._wantsPushState&&!this._hasPushState&&!b)return this.fragment=this.getFragment(null,!0),window.location.replace(this.options.root+"#"+this.fragment),!0;else if(this._wantsPushState&&this._hasPushState&&b&&a.hash)this.fragment=a.hash.replace(j,""),window.history.replaceState({},
+document.title,a.protocol+"//"+a.host+this.options.root+this.fragment);if(!this.options.silent)return this.loadUrl()},route:function(a,b){this.handlers.unshift({route:a,callback:b})},checkUrl:function(){var a=this.getFragment();a==this.fragment&&this.iframe&&(a=this.getFragment(this.iframe.location.hash));if(a==this.fragment||a==decodeURIComponent(this.fragment))return!1;this.iframe&&this.navigate(a);this.loadUrl()||this.loadUrl(window.location.hash)},loadUrl:function(a){var b=this.fragment=this.getFragment(a);
+return f.any(this.handlers,function(a){if(a.route.test(b))return a.callback(b),!0})},navigate:function(a,b){var c=(a||"").replace(j,"");if(!(this.fragment==c||this.fragment==decodeURIComponent(c))){if(this._hasPushState){var d=window.location;c.indexOf(this.options.root)!=0&&(c=this.options.root+c);this.fragment=c;window.history.pushState({},document.title,d.protocol+"//"+d.host+c)}else if(window.location.hash=this.fragment=c,this.iframe&&c!=this.getFragment(this.iframe.location.hash))this.iframe.document.open().close(),
+this.iframe.location.hash=c;b&&this.loadUrl(a)}}});e.View=function(a){this.cid=f.uniqueId("view");this._configure(a||{});this._ensureElement();this.delegateEvents();this.initialize.apply(this,arguments)};var u=/^(\S+)\s*(.*)$/,n=["model","collection","el","id","attributes","className","tagName"];f.extend(e.View.prototype,e.Events,{tagName:"div",$:function(a){return g(a,this.el)},initialize:function(){},render:function(){return this},remove:function(){g(this.el).remove();return this},make:function(a,
+b,c){a=document.createElement(a);b&&g(a).attr(b);c&&g(a).html(c);return a},delegateEvents:function(a){if(a||(a=this.events))for(var b in f.isFunction(a)&&(a=a.call(this)),g(this.el).unbind(".delegateEvents"+this.cid),a){var c=this[a[b]];if(!c)throw Error('Event "'+a[b]+'" does not exist');var d=b.match(u),e=d[1];d=d[2];c=f.bind(c,this);e+=".delegateEvents"+this.cid;d===""?g(this.el).bind(e,c):g(this.el).delegate(d,e,c)}},_configure:function(a){this.options&&(a=f.extend({},this.options,a));for(var b=
+0,c=n.length;b
+ runCommand = (name, args...) ->
+ proc = spawn name, args
+ proc.stderr.on 'data', (buffer) -> console.log buffer.toString()
+ proc.stdout.on 'data', (buffer) -> console.log buffer.toString()
+ proc.on 'exit', (status) -> process.exit(1) if status isnt 0
+ runCommand 'coffee', '-o', 'js', '-wc', 'js'
+ runCommand 'sass', '--watch', 'src:css'
+
diff --git a/addons/point_of_sale/static/src/css/pos.css b/addons/point_of_sale/static/src/css/pos.css
new file mode 100644
index 00000000000..184eafe717c
--- /dev/null
+++ b/addons/point_of_sale/static/src/css/pos.css
@@ -0,0 +1,370 @@
+body, html {
+ height: 100%; }
+
+body {
+ padding: 0;
+ margin: 0;
+ background-color: #f0eeee;
+ font-family: "Lucida Grande", Helvetica, Verdana, Arial;
+ color: #555555;
+ font-size: 12px; }
+
+table {
+ border-spacing: 0;
+ border-collapse: collapse; }
+
+td {
+ border: 1px solid #e9eaec; }
+
+input {
+ color: #555555; }
+
+a {
+ text-decoration: none;
+ color: #555555; }
+
+button, a.button {
+ display: inline-block;
+ cursor: pointer;
+ padding: 4px 10px;
+ font-size: 11px;
+ border: 1px solid #cacaca;
+ -moz-border-radius: 4px;
+ -webkit-border-radius: 4px;
+ border-radius: 4px;
+ background: #e2e2e2;
+ background: -moz-linear-gradient(#f0f0f0, #e2e2e2);
+ background: -webkit-gradient(linear, left top, left bottom, from(#f0f0f0), to(#e2e2e2)); }
+
+ul, ol {
+ padding: 0;
+ margin: 0; }
+
+li {
+ list-style-type: none; }
+
+#container {
+ width: 100%;
+ height: 100%; }
+
+#topheader {
+ width: 100%;
+ height: 54px;
+ color: gray;
+ border-top: solid 1px #d3d3d3;
+ border-bottom: solid 1px black;
+ background: #393939;
+ background: -moz-linear-gradient(#7b7979, #393939);
+ background: -webkit-gradient(linear, left top, left bottom, from(#7b7979), to(#393939)); }
+ #topheader button {
+ color: black;
+ border: 1px solid black;
+ background: #7f82ac;
+ background: -moz-linear-gradient(#b2b3d7, #7f82ac);
+ background: -webkit-gradient(linear, left top, left bottom, from(#b2b3d7), to(#7f82ac)); }
+
+#branding, #steps, #rightheader {
+ float: left;
+ overflow: hidden;
+ height: 35px;
+ padding: 10px; }
+
+#branding {
+ border-right: 1px solid #373737;
+ text-align: center; }
+ #branding img {
+ height: 32px;
+ width: 116px; }
+
+#steps {
+ padding: 10px 17px;
+ border-right: solid 1px #3b3b3b;
+ vertical-align: top; }
+ #steps label {
+ width: 80px;
+ background-color: "";
+ background-image: url("../img/steps-bg.png");
+ border-bottom: solid 1px #5c5c5c;
+ border-top: solid 1px #373737;
+ vertical-align: top; }
+ #steps label:first-child {
+ border-left: solid 1px #373737; }
+ #steps label:last-child {
+ border-right: solid 1px #373737; }
+ #steps span {
+ padding: 2px 6px; }
+ #steps img {
+ height: 32px; }
+ #steps .ui-button, #steps .ui-button-text-only {
+ height: 30px;
+ margin: 0 -4px; }
+
+#neworder-button {
+ width: 32px;
+ padding: 1px;
+ font-size: 23px; }
+
+#loggedas {
+ float: right;
+ padding: 5px 9px;
+ text-align: center;
+ color: white;
+ border-left: 1px solid #373737; }
+ #loggedas p {
+ margin: 0 0 3px 0; }
+
+#content {
+ width: 100%;
+ position: absolute;
+ top: 56px;
+ bottom: 0; }
+
+#leftpane {
+ height: 100%;
+ width: 440px;
+ position: relative;
+ border-right: solid 1px #afafb6;
+ background-color: white; }
+ #leftpane footer {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ background-color: #e0e0e0;
+ background-image: url(../img/headerbackground.jpg); }
+
+#current-order {
+ width: 100%;
+ position: absolute;
+ top: 0;
+ bottom: 271px;
+ overflow: auto; }
+ #current-order thead {
+ background-color: #cccccc;
+ background-image: url(../img/headerbackground.jpg);
+ border: 0px;
+ font-size: 12px;
+ width: 440px; }
+ #current-order thead td {
+ text-align: center;
+ padding: 8px 0px;
+ min-width: 40px;
+ font-size: 12px; }
+ #current-order td {
+ padding: 6px 4px;
+ font-size: 11px;
+ text-align: right;
+ min-width: 40px;
+ white-space: nowrap; }
+ #current-order td:first-child {
+ width: 320px;
+ padding: 6px;
+ text-align: left;
+ text-overflow: ellipsis; }
+ #current-order td:last-child {
+ border-right: none; }
+ #current-order tr.selected {
+ background-color: #e9eaf2; }
+ #current-order tr.selected td {
+ border-top: 2px solid #d5d6e0;
+ border-bottom: 1px solid #d5d6e0;
+ padding-top: 5px;
+ color: #555555; }
+
+#amounts {
+ background: white;
+ border-bottom: solid 1px #d2d2d2;
+ border-top: solid 1px #e9eaec;
+ font-weight: bold;
+ text-align: right;
+ -webkit-margin-before: 0;
+ -webkit-margin-after: 0; }
+ #amounts li {
+ display: inline-block;
+ padding: 8px;
+ width: 29%; }
+
+#paypad {
+ padding: 9px;
+ float: left;
+ text-align: center; }
+ #paypad button {
+ height: 54px;
+ width: 208px;
+ margin: 0 -3px;
+ font-weight: bold;
+ vertical-align: middle;
+ color: #555555;
+ border-top: 1px solid #efefef; }
+ #paypad button:hover {
+ color: white;
+ background: #7f82ac;
+ background: -moz-linear-gradient(#9d9fc5, #7f82ac);
+ background: -webkit-gradient(linear, left top, left bottom, from(#9d9fc5), to(#7f82ac)); }
+
+#numpad {
+ padding: 9px;
+ float: right;
+ text-align: center; }
+ #numpad button {
+ height: 54px;
+ width: 54px;
+ margin: 0 -3px;
+ font-weight: bold;
+ vertical-align: middle;
+ color: #555555;
+ border-top: 1px solid #efefef; }
+ #numpad button:hover {
+ color: white;
+ background: #7f82ac;
+ background: -moz-linear-gradient(#9d9fc5, #7f82ac);
+ background: -webkit-gradient(linear, left top, left bottom, from(#9d9fc5), to(#7f82ac)); }
+ #numpad .selected-mode {
+ color: white;
+ background: #7f82ac;
+ background: -moz-linear-gradient(#9d9fc5, #7f82ac);
+ background: -webkit-gradient(linear, left top, left bottom, from(#9d9fc5), to(#7f82ac)); }
+
+.payment-button {
+ font-size: 14px; }
+
+.input-button {
+ font-size: 24px; }
+
+.mode-button, #numpad-delete, #numpad-minus {
+ font-size: 14px; }
+
+#rightpane {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 441px;
+ right: 0;
+ height: 100%;
+ vertical-align: top; }
+ #rightpane header {
+ padding: 0;
+ height: 32px;
+ border-bottom: 1px solid #cecbcb;
+ background: #d3d3d3;
+ background: -moz-linear-gradient(white, #d3d3d3);
+ background: -webkit-gradient(linear, left top, left bottom, from(white), to(#d3d3d3)); }
+
+.product-list {
+ overflow: auto;
+ position: absolute;
+ top: 72px;
+ bottom: 0; }
+
+.breadcrumb li {
+ float: left;
+ line-height: 32px;
+ height: 32px; }
+ .breadcrumb li:last-child {
+ padding-right: 3px;
+ border-right: 1px solid #c5c5c5; }
+.breadcrumb a {
+ display: inline-block;
+ padding: 0 9px;
+ vertical-align: top;
+ text-shadow: #f7f7f7 0 1px 1px;
+ color: #555555;
+ font-weight: bold; }
+
+.bc-arrow {
+ height: 33px; }
+
+.homeimg {
+ width: 19px;
+ height: 19px;
+ margin: 6px 0; }
+
+.searchbox {
+ position: absolute;
+ right: 2px; }
+ .searchbox input {
+ width: 130px;
+ -moz-border-radius: 11px;
+ -webkit-border-radius: 11px;
+ border-radius: 11px;
+ border: 1px solid #cecbcb;
+ padding: 3px 19px;
+ margin: 6px;
+ background: url("../img/search.png") no-repeat 5px;
+ background-color: white; }
+
+.search-clear {
+ position: absolute;
+ top: 11px;
+ right: 11px;
+ cursor: pointer;
+ display: none; }
+
+#categories {
+ border-bottom: 1px solid #cecbcb; }
+ #categories h4 {
+ display: inline-block;
+ margin: 9px 5px; }
+ #categories ol {
+ display: inline; }
+ #categories li {
+ display: inline-block; }
+ #categories .button {
+ padding: 6px 14px;
+ margin: 4px 0;
+ font-size: 12px; }
+
+.product {
+ vertical-align: top;
+ display: inline-block;
+ font-size: 11px;
+ margin: 5px;
+ max-width: 120px;
+ border: 1px solid lightgray;
+ -moz-border-radius: 4px;
+ -webkit-border-radius: 4px;
+ border-radius: 4px;
+ -moz-box-shadow: 0px 1px 4px #777777;
+ -webkit-box-shadow: 0px 1px 4px #777777;
+ -box-shadow: 0px 1px 4px #777777; }
+
+.product-img {
+ position: relative;
+ width: 120px;
+ height: 100px;
+ background: white;
+ text-align: center; }
+
+.price-tag {
+ position: absolute;
+ top: 2px;
+ right: 2px;
+ vertical-align: top;
+ color: white;
+ background: #7f82ac;
+ padding: 2px 5px;
+ -moz-border-radius: 3px;
+ -webkit-border-radius: 3px;
+ border-radius: 3px; }
+
+.product-name {
+ padding: 3px; }
+
+#login-form label, #login-form input {
+ display: block; }
+#login-form input {
+ margin: 4px 0 12px;
+ padding: 4px;
+ width: 96%; }
+
+div#order-selector {
+ display: inline; }
+
+ol#orders {
+ display: inline; }
+
+li.order-selector-button {
+ display: inline; }
+
+li.selected-order button {
+ font-weight: 900; }
diff --git a/addons/point_of_sale/static/src/css/pos.sass b/addons/point_of_sale/static/src/css/pos.sass
new file mode 100644
index 00000000000..c02daec43b2
--- /dev/null
+++ b/addons/point_of_sale/static/src/css/pos.sass
@@ -0,0 +1,360 @@
+@mixin vertical-gradient($startColor: #555, $endColor: #333)
+ background: $endColor
+ background: -moz-linear-gradient($startColor, $endColor)
+ background: -webkit-gradient(linear, left top, left bottom, from($startColor), to($endColor))
+
+@mixin radius($radius: 5px)
+ -moz-border-radius: $radius
+ -webkit-border-radius: $radius
+ border-radius: $radius
+
+@mixin box-shadow($bsval: 0px 1px 4px #777)
+ -moz-box-shadow: $bsval
+ -webkit-box-shadow: $bsval
+ -box-shadow: $bsval
+
+body, html
+ height: 100%
+
+body
+ padding: 0
+ margin: 0
+ background-color: #f0eeee
+ font-family: "Lucida Grande", Helvetica, Verdana, Arial
+ color: #555
+ font-size: 12px
+
+table
+ border-spacing: 0
+ border-collapse: collapse
+
+td
+ border: 1px solid #e9eaec
+
+input
+ color: #555
+
+a
+ text-decoration: none
+ color: #555
+
+button, a.button
+ display: inline-block
+ cursor: pointer
+ padding: 4px 10px
+ font-size: 11px
+ border: 1px solid #cacaca
+ @include radius(4px)
+ @include vertical-gradient(#f0f0f0, #e2e2e2)
+
+ul, ol
+ padding: 0
+ margin: 0
+
+li
+ list-style-type: none
+
+#container
+ width: 100%
+ height: 100%
+
+#topheader
+ width: 100%
+ height: 54px
+ color: gray
+ border-top: solid 1px #d3d3d3
+ border-bottom: solid 1px black
+ @include vertical-gradient(#7b7979, #393939)
+ button
+ color: black
+ border: 1px solid black
+ @include vertical-gradient(#b2b3d7, #7f82ac)
+
+#branding, #steps, #rightheader
+ float: left
+ overflow: hidden
+ height: 35px
+ padding: 10px
+
+#branding
+ border-right: 1px solid #373737
+ text-align: center
+ img
+ height: 32px
+ width: 116px
+
+#steps
+ padding: 10px 17px
+ border-right: solid 1px #3b3b3b
+ vertical-align: top
+ label
+ width: 80px
+ background-color: ''
+ background-image: url('../img/steps-bg.png')
+ border-bottom: solid 1px #5c5c5c
+ border-top: solid 1px #373737
+ vertical-align: top
+ &:first-child
+ border-left: solid 1px #373737
+ &:last-child
+ border-right: solid 1px #373737
+ span
+ padding: 2px 6px
+ img
+ height: 32px
+ .ui-button, .ui-button-text-only
+ height: 30px
+ margin: 0 -4px
+
+#neworder-button
+ width: 32px
+ padding: 1px
+ font-size: 23px
+
+#loggedas
+ float: right
+ padding: 5px 9px
+ text-align: center
+ color: white
+ border-left: 1px solid #373737
+ p
+ margin: 0 0 3px 0
+
+#content
+ width: 100%
+ position: absolute
+ top: 56px
+ bottom: 0
+
+#leftpane
+ height: 100%
+ width: 440px
+ position: relative
+ border-right: solid 1px #afafb6
+ background-color: white
+ footer
+ position: absolute
+ bottom: 0
+ left: 0
+ width: 100%
+ background-color: #e0e0e0
+ background-image: url(../img/headerbackground.jpg)
+
+#current-order
+ width: 100%
+ position: absolute
+ top: 0
+ bottom: 271px
+ overflow: auto
+ thead
+ background-color: #ccc
+ background-image: url(../img/headerbackground.jpg)
+ border: 0px
+ font-size: 12px
+ width: 440px
+ td
+ text-align: center
+ padding: 8px 0px
+ min-width: 40px
+ font-size: 12px
+ td
+ padding: 6px 4px
+ font-size: 11px
+ text-align: right
+ min-width: 40px
+ white-space: nowrap
+ &:first-child
+ width: 320px
+ padding: 6px
+ text-align: left
+ text-overflow: ellipsis
+ &:last-child
+ border-right: none
+ tr.selected
+ background-color: #e9eaf2
+ td
+ border-top: 2px solid #d5d6e0
+ border-bottom: 1px solid #d5d6e0
+ padding-top: 5px
+ color: #555
+
+#amounts
+ background: white
+ border-bottom: solid 1px #d2d2d2
+ border-top: solid 1px #e9eaec
+ font-weight: bold
+ text-align: right
+ -webkit-margin-before: 0
+ -webkit-margin-after: 0
+ li
+ display: inline-block
+ padding: 8px
+ width: 29%
+
+@mixin highlighted-button
+ color: white
+ @include vertical-gradient(#9d9fc5, #7f82ac)
+
+@mixin pad-button($height, $width)
+ height: $height
+ width: $width
+ margin: 0 -3px
+ font-weight: bold
+ vertical-align: middle
+ color: #555
+ border-top: 1px solid #efefef
+ &:hover
+ @include highlighted-button
+
+#paypad
+ padding: 9px
+ float: left
+ text-align: center
+ button
+ @include pad-button(54px, 208px)
+
+#numpad
+ padding: 9px
+ float: right
+ text-align: center
+ button
+ @include pad-button(54px, 54px)
+ .selected-mode
+ @include highlighted-button
+
+.payment-button
+ font-size: 14px
+
+.input-button
+ font-size: 24px
+
+.mode-button, #numpad-delete, #numpad-minus
+ font-size: 14px
+
+#rightpane
+ position: absolute
+ top: 0
+ bottom: 0
+ left: 441px
+ right: 0
+ height: 100%
+ vertical-align: top
+ header
+ padding: 0
+ height: 32px
+ border-bottom: 1px solid #cecbcb
+ @include vertical-gradient(#fff, #d3d3d3)
+
+.product-list
+ overflow: auto
+ position: absolute
+ top: 72px
+ bottom: 0
+
+.breadcrumb
+ li
+ float: left
+ line-height: 32px
+ height: 32px
+ &:last-child
+ padding-right: 3px
+ border-right: 1px solid #c5c5c5
+ a
+ display: inline-block
+ padding: 0 9px
+ vertical-align: top
+ text-shadow: #F7F7F7 0 1px 1px
+ color: #555
+ font-weight: bold
+
+.bc-arrow
+ height: 33px
+
+.homeimg
+ width: 19px
+ height: 19px
+ margin: 6px 0
+
+.searchbox
+ position: absolute
+ right: 2px
+ input
+ width: 130px
+ @include radius(11px)
+ border: 1px solid #cecbcb
+ padding: 3px 19px
+ margin: 6px
+ background: url('../img/search.png') no-repeat 5px
+ background-color: white
+
+.search-clear
+ position: absolute
+ top: 11px
+ right: 11px
+ cursor: pointer
+ display: none
+
+#categories
+ border-bottom: 1px solid #cecbcb
+ h4
+ display: inline-block
+ margin: 9px 5px
+ ol
+ display: inline
+ li
+ display: inline-block
+ .button
+ padding: 6px 14px
+ margin: 4px 0
+ font-size: 12px
+
+.product
+ vertical-align: top
+ display: inline-block
+ font-size: 11px
+ margin: 5px
+ max-width: 120px
+ border: 1px solid lightgray
+ @include radius(4px)
+ @include box-shadow()
+
+.product-img
+ position: relative
+ width: 120px
+ height: 100px
+ background: white
+ text-align: center
+
+.price-tag
+ position: absolute
+ top: 2px
+ right: 2px
+ vertical-align: top
+ color: white
+ background: #7F82AC
+ padding: 2px 5px
+ @include radius(3px)
+
+.product-name
+ padding: 3px
+
+#login-form
+ label, input
+ display: block
+ input
+ margin: 4px 0 12px
+ padding: 4px
+ width: 96%
+
+div#order-selector
+ display: inline
+
+ol#orders
+ display: inline
+
+li.order-selector-button
+ display: inline
+
+li.selected-order
+ button
+ font-weight: 900
diff --git a/addons/point_of_sale/static/src/img/backspace.png b/addons/point_of_sale/static/src/img/backspace.png
new file mode 100644
index 00000000000..705051d925e
Binary files /dev/null and b/addons/point_of_sale/static/src/img/backspace.png differ
diff --git a/addons/point_of_sale/static/src/img/barcode.png b/addons/point_of_sale/static/src/img/barcode.png
new file mode 100644
index 00000000000..fc780b9346b
Binary files /dev/null and b/addons/point_of_sale/static/src/img/barcode.png differ
diff --git a/addons/point_of_sale/static/src/img/bc-arrow.png b/addons/point_of_sale/static/src/img/bc-arrow.png
new file mode 100644
index 00000000000..0485c5977aa
Binary files /dev/null and b/addons/point_of_sale/static/src/img/bc-arrow.png differ
diff --git a/addons/point_of_sale/static/src/img/headerbackground.jpg b/addons/point_of_sale/static/src/img/headerbackground.jpg
new file mode 100644
index 00000000000..cec8fa7ccff
Binary files /dev/null and b/addons/point_of_sale/static/src/img/headerbackground.jpg differ
diff --git a/addons/point_of_sale/static/src/img/home.png b/addons/point_of_sale/static/src/img/home.png
new file mode 100644
index 00000000000..53d0b22d50d
Binary files /dev/null and b/addons/point_of_sale/static/src/img/home.png differ
diff --git a/addons/point_of_sale/static/src/img/logo.png b/addons/point_of_sale/static/src/img/logo.png
new file mode 100644
index 00000000000..9cf5eb05312
Binary files /dev/null and b/addons/point_of_sale/static/src/img/logo.png differ
diff --git a/addons/point_of_sale/static/src/img/search.png b/addons/point_of_sale/static/src/img/search.png
new file mode 100644
index 00000000000..d1705e9a24a
Binary files /dev/null and b/addons/point_of_sale/static/src/img/search.png differ
diff --git a/addons/point_of_sale/static/src/img/search_reset.gif b/addons/point_of_sale/static/src/img/search_reset.gif
new file mode 100644
index 00000000000..9d4253e018b
Binary files /dev/null and b/addons/point_of_sale/static/src/img/search_reset.gif differ
diff --git a/addons/point_of_sale/static/src/img/steps-arrow.png b/addons/point_of_sale/static/src/img/steps-arrow.png
new file mode 100644
index 00000000000..fe86913d78e
Binary files /dev/null and b/addons/point_of_sale/static/src/img/steps-arrow.png differ
diff --git a/addons/point_of_sale/static/src/img/steps-bg.png b/addons/point_of_sale/static/src/img/steps-bg.png
new file mode 100644
index 00000000000..cfe810a77a4
Binary files /dev/null and b/addons/point_of_sale/static/src/img/steps-bg.png differ
diff --git a/addons/point_of_sale/static/src/img/validate-icon.png b/addons/point_of_sale/static/src/img/validate-icon.png
new file mode 100644
index 00000000000..a7d02604e43
Binary files /dev/null and b/addons/point_of_sale/static/src/img/validate-icon.png differ
diff --git a/addons/point_of_sale/static/src/js/pos.coffee b/addons/point_of_sale/static/src/js/pos.coffee
new file mode 100644
index 00000000000..f5f0a940107
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/pos.coffee
@@ -0,0 +1,641 @@
+db = openerp.init()
+
+###
+Local store access. Read once from localStorage upon construction and persist on every change.
+There should only be one store active at any given time to ensure data consistency.
+###
+class Store
+ constructor: ->
+ store = localStorage['pos']
+ @data = (store && JSON.parse store) || {}
+ get: (key) ->
+ @data[key]
+ set: (key, value) ->
+ @data[key] = value
+ localStorage['pos'] = JSON.stringify @data
+
+###
+Gets all the necessary data from the OpenERP web client (session, shop data etc.)
+###
+class Pos
+ constructor: ->
+ @session.session_login 'web-trunk-pos', 'admin', 'admin', =>
+ $.when(
+ @fetch(
+ 'pos.category',
+ ['name','parent_id','child_id']
+ ),
+ @fetch(
+ 'product.product',
+ ['name','list_price','pos_categ_id','taxes_id','img'],
+ [['pos_categ_id','!=','false']]
+ ),
+ @fetch(
+ 'account.bank.statement',
+ ['account_id', 'currency', 'journal_id', 'state', 'name']
+ ),
+ @fetch(
+ 'account.journal',
+ ['auto_cash', 'check_dtls', 'currency', 'name', 'type']
+ )
+ ).then @build_tree
+ ready: $.Deferred()
+ session: new db.base.Session 'DEBUG'
+ store: new Store
+ fetch: (osvModel, fields, domain, callback, errorCallback) ->
+ callback = callback || (result) => @store.set osvModel, result
+ dataSetSearch = new db.base.DataSetSearch this, osvModel, null, domain
+ dataSetSearch.read_slice fields, 0, null, callback
+ push: (osvModel, record, callback, errorCallback) ->
+ dataSet = new db.base.DataSet(this, osvModel, null)
+ dataSet.create record, callback, errorCallback
+ categories: {}
+ build_tree: =>
+ for c in @store.get 'pos.category'
+ @categories[c.id] = id:c.id, name:c.name, children:c.child_id,
+ parent:c.parent_id[0], ancestors:[c.id], subtree:[c.id]
+ for id, c of @categories
+ @current_category = c
+ @build_ancestors c.parent
+ @build_subtree c
+ @categories[0] =
+ ancestors: []
+ children: c.id for c in @store.get 'pos.category' when not c.parent_id[0]?
+ subtree: c.id for c in @store.get 'pos.category'
+ @ready.resolve()
+ build_ancestors: (parent) ->
+ if parent?
+ @current_category.ancestors.unshift parent
+ @build_ancestors @categories[parent].parent
+ build_subtree: (category) ->
+ for c in category.children
+ @current_category.subtree.push c
+ @build_subtree @categories[c]
+
+window.pos = new Pos
+
+$ ->
+ $('#steps').buttonset() # jQuery UI buttonset
+
+ ###
+ ---
+ Models
+ ---
+ ###
+
+ class CashRegister extends Backbone.Model
+
+ class CashRegisterCollection extends Backbone.Collection
+ model: CashRegister
+
+ class Product extends Backbone.Model
+
+ class ProductCollection extends Backbone.Collection
+ model: Product
+
+ class Category extends Backbone.Model
+
+ class CategoryCollection extends Backbone.Collection
+ model: Category
+
+ ###
+ Each Order contains zero or more Orderlines (i.e. the content of the "shopping cart".)
+ There should only ever be one Orderline per distinct product in an Order.
+ To add more of the same product, just update the quantity accordingly.
+ The Order also contains payment information.
+ ###
+ class Orderline extends Backbone.Model
+ defaults: {
+ quantity: 1,
+ list_price: 0,
+ discount: 0
+ }
+ incrementQuantity: -> @set quantity: (@get 'quantity') + 1
+ getTotal: -> (@get 'quantity') * (@get 'list_price') * (1 - (@get 'discount')/100)
+ exportAsJSON: ->
+ result = {
+ qty: (@get 'quantity'),
+ price_unit: (@get 'list_price'),
+ discount: (@get 'discount'),
+ product_id: (@get 'id')
+ }
+ return result
+
+ class OrderlineCollection extends Backbone.Collection
+ model: Orderline
+
+ ###
+ Every PaymentLine has all the attributes of the corresponding CashRegister.
+ ###
+ class Paymentline extends Backbone.Model
+ defaults: {
+ amount: 0
+ }
+ getAmount: -> @get 'amount'
+ exportAsJSON: ->
+ result = {
+ name: "Payment line",
+ statement_id: (@get 'id'),
+ account_id: (@get 'account_id')[0],
+ journal_id: (@get 'journal_id')[0],
+ amount: @getAmount()
+ }
+ return result
+
+ class PaymentlineCollection extends Backbone.Collection
+ model: Paymentline
+
+ class Order extends Backbone.Model
+ defaults: {
+ validated: false
+ }
+ initialize: ->
+ @set orderLines: new OrderlineCollection
+ @set paymentLines: new PaymentlineCollection
+ @set name: "Order " + @generateUniqueId()
+ generateUniqueId: ->
+ new Date().getTime()
+ addProduct: (product) ->
+ existing = (@get 'orderLines').get product.id
+ if existing?
+ existing.incrementQuantity()
+ else
+ (@get 'orderLines').add new Orderline product.toJSON()
+ addPaymentLine: (cashRegister) ->
+ newPaymentline = new Paymentline cashRegister
+ ### TODO: Should be 0 for cash-like accounts ###
+ newPaymentline.set amount: @getDueLeft()
+ (@get 'paymentLines').add newPaymentline
+ getName: ->
+ return @get 'name'
+ getTotal: ->
+ return (@get 'orderLines').reduce ((sum, orderLine) -> sum + orderLine.getTotal()), 0
+ getTotalTaxExcluded: ->
+ return @getTotal()/1.21
+ getTax: ->
+ return @getTotal()/1.21*0.21
+ getPaidTotal: ->
+ return (@get 'paymentLines').reduce ((sum, paymentLine) -> sum + paymentLine.getAmount()), 0
+ getChange: ->
+ return @getPaidTotal() - @getTotal()
+ getDueLeft: ->
+ return @getTotal() - @getPaidTotal()
+ exportAsJSON: ->
+ orderLines = []
+ (@get 'orderLines').each (item) => orderLines.push [0, 0, item.exportAsJSON()]
+ paymentLines = []
+ (@get 'paymentLines').each (item) => paymentLines.push [0, 0, item.exportAsJSON()]
+ result = {
+ name: @getName(),
+ amount_paid: @getPaidTotal(),
+ amount_total: @getTotal(),
+ amount_tax: @getTax(),
+ amount_return: @getChange(),
+ lines: orderLines,
+ statement_ids: paymentLines
+ }
+ return result
+
+ class OrderCollection extends Backbone.Collection
+ model: Order
+
+ class Shop extends Backbone.Model
+ defaults: {
+ cashRegisters: (new CashRegisterCollection pos.store.get('account.bank.statement')),
+ orders: new OrderCollection,
+ products: new ProductCollection
+ }
+ initialize: ->
+ (@get 'orders').bind 'remove', (removedOrder) =>
+ if (@get 'orders').isEmpty()
+ @addAndSelectOrder new Order
+ if (@get 'selectedOrder') is removedOrder
+ @set selectedOrder: (@get 'orders').last()
+ addAndSelectOrder: (newOrder) ->
+ (@get 'orders').add newOrder
+ @set selectedOrder: newOrder
+
+
+ ###
+ The numpad handles both the choice of the property currently being modified
+ (quantity, price or discount) and the edition of the corresponding numeric value.
+ ###
+ class NumpadState extends Backbone.Model
+ defaults: {
+ buffer: "0"
+ mode: "quantity"
+ }
+ initialize: (options) ->
+ @shop = options.shop
+ @shop.bind 'change:selectedOrder', @reset, this
+ appendNewChar: (newChar) ->
+ oldBuffer = @get 'buffer'
+ if oldBuffer is '0'
+ @set buffer: newChar
+ else if oldBuffer is '-0'
+ @set buffer: "-" + newChar
+ else
+ @set buffer: (@get 'buffer') + newChar
+ @updateTarget()
+ deleteLastChar: ->
+ tempNewBuffer = (@get 'buffer').slice(0, -1) || "0"
+ if isNaN tempNewBuffer
+ tempNewBuffer = "0"
+ @set buffer: tempNewBuffer
+ @updateTarget()
+ switchSign: ->
+ oldBuffer = @get 'buffer'
+ @set buffer: if oldBuffer[0] is '-' then oldBuffer.substr 1 else "-" + oldBuffer
+ @updateTarget()
+ changeMode: (newMode) ->
+ @set buffer: "0", mode: newMode
+ reset: ->
+ @set buffer: "0"
+ updateTarget: ->
+ bufferContent = @get 'buffer'
+ if bufferContent && !isNaN bufferContent
+ params = {}
+ params[@get 'mode'] = parseFloat bufferContent
+ (@shop.get 'selectedOrder').selected.set params
+
+ ###
+ ---
+ Views
+ ---
+ ###
+
+ class NumpadView extends Backbone.View
+ initialize: (options) ->
+ @state = options.state
+ events: {
+ 'click button#numpad-backspace': 'clickDeleteLastChar',
+ 'click button#numpad-minus': 'clickSwitchSign',
+ 'click button.number-char': 'clickAppendNewChar',
+ 'click button.mode-button': 'clickChangeMode'
+ }
+ clickDeleteLastChar: ->
+ @state.deleteLastChar()
+ clickSwitchSign: ->
+ @state.switchSign()
+ clickAppendNewChar: (event) ->
+ newChar = event.currentTarget.innerText
+ @state.appendNewChar newChar
+ clickChangeMode: (event) ->
+ $('.selected-mode').removeClass 'selected-mode'
+ $(event.currentTarget).addClass 'selected-mode'
+ newMode = event.currentTarget.attributes['data-mode'].nodeValue
+ @state.changeMode newMode
+
+ ###
+ Gives access to the payment methods (aka. 'cash registers')
+ ###
+ class PaypadView extends Backbone.View
+ initialize: (options) ->
+ @shop = options.shop
+ events: {
+ 'click button': 'performPayment'
+ }
+ performPayment: (event) ->
+ cashRegisterId = event.currentTarget.attributes['cash-register-id'].nodeValue
+ cashRegisterCollection = (@shop.get 'cashRegisters')
+ cashRegister = cashRegisterCollection.find (item) => (item.get 'id') is parseInt cashRegisterId, 10
+ (@shop.get 'selectedOrder').addPaymentLine cashRegister
+ render: ->
+ $(@el).empty()
+ (@shop.get 'cashRegisters').each (cashRegister) => $(@el).append (new PaymentButtonView model: cashRegister).render()
+
+ class PaymentButtonView extends Backbone.View
+ template: _.template $('#payment-button-template').html()
+ render: ->
+ $(@el).html @template {id: (@model.get 'id'), name: (@model.get 'journal_id')[1]}
+
+
+ ###
+ There are 3 steps in a POS workflow:
+ 1. prepare the order (i.e. chose products, quantities etc.)
+ 2. choose payment method(s) and amount(s)
+ 3. validae order and print receipt
+ It should be possible to go back to any step as long as step 3 hasn't been completed.
+ Modifying an order after validation shouldn't be allowed.
+ ###
+ class StepsView extends Backbone.View
+ initialize: (options) ->
+ @step = "products"
+ events: {
+ 'click input.step-button': 'clickChangeStep'
+ }
+ clickChangeStep: (event) ->
+ newStep = event.currentTarget.attributes['data-step'].nodeValue
+ $('.step-screen').hide()
+ $('#' + newStep + '-screen').show()
+ @step = newStep
+
+ ###
+ Shopping carts.
+ ###
+ class OrderlineView extends Backbone.View
+ tagName: 'tr'
+ template: _.template $('#orderline-template').html()
+ initialize: (options) ->
+ @model.bind 'change', => $(@el).hide(); @render()
+ @model.bind 'remove', => $(@el).remove()
+ @order = options.order
+ @numpadState = options.numpadState
+ events: {
+ 'click': 'clickHandler'
+ }
+ clickHandler: ->
+ @numpadState.reset()
+ @select()
+ render: ->
+ @select()
+ $(@el).html(@template @model.toJSON()).fadeIn 400, -> $('#current-order').scrollTop $(@).offset().top
+ select: ->
+ $('tr.selected').removeClass 'selected'
+ $(@el).addClass 'selected'
+ @order.selected = @model
+
+ class OrderView extends Backbone.View
+ initialize: (options) ->
+ @shop = options.shop
+ @numpadState = options.numpadState
+ @shop.bind 'change:selectedOrder', @changeSelectedOrder, this
+ @bindOrderLineEvents()
+ changeSelectedOrder: ->
+ @currentOrderLines.unbind()
+ @bindOrderLineEvents()
+ @render()
+ bindOrderLineEvents: ->
+ @currentOrderLines = (@shop.get 'selectedOrder' ).get 'orderLines'
+ @currentOrderLines.bind 'add', @addLine, this
+ @currentOrderLines.bind 'change', @render, this
+ @currentOrderLines.bind 'remove', @render, this
+ addLine: (newLine) ->
+ $(@el).append (new OrderlineView model: newLine, order: (@shop.get 'selectedOrder'), numpadState: @numpadState).render()
+ @updateSummary()
+ render: ->
+ $(@el).empty()
+ @currentOrderLines.each (orderLine) =>
+ $(@el).append (new OrderlineView model: orderLine, order: (@shop.get 'selectedOrder'), numpadState: @numpadState).render()
+ @updateSummary()
+ updateSummary: ->
+ currentOrder = @shop.get 'selectedOrder'
+ total = currentOrder.getTotal()
+ totalTaxExcluded = currentOrder.getTotalTaxExcluded()
+ tax = currentOrder.getTax()
+ $('#subtotal').html(totalTaxExcluded.toFixed 2).hide().fadeIn()
+ $('#tax').html(tax.toFixed 2).hide().fadeIn()
+ $('#total').html(total.toFixed 2).hide().fadeIn()
+
+ ###
+ "Products" step.
+ ###
+ class CategoryView extends Backbone.View
+ template: _.template $('#category-template').html()
+ render: (ancestors, children) ->
+ $(@el).html @template
+ breadcrumb: pos.categories[c] for c in ancestors
+ categories: pos.categories[c] for c in children
+
+ class ProductView extends Backbone.View
+ tagName: 'li'
+ className: 'product'
+ template: _.template $('#product-template').html()
+ events: {
+ 'click a': 'addToOrder'
+ }
+ initialize: (options) ->
+ @shop = options.shop
+ addToOrder: (event) ->
+ ### Preserve the category URL ###
+ event.preventDefault()
+ (@shop.get 'selectedOrder').addProduct @model
+ render: ->
+ $(@el).html @template @model.toJSON()
+
+ class ProductListView extends Backbone.View
+ tagName: 'ol'
+ className: 'product-list'
+ initialize: (options) ->
+ @shop = options.shop
+ (@shop.get 'products').bind 'reset', @render, this
+ render: ->
+ $(@el).empty()
+ (@shop.get 'products').each (product) => $(@el).append (new ProductView model: product, shop: @shop).render()
+ $('#products-screen').append @el
+
+ ###
+ "Payment" step.
+ ###
+ class PaymentlineView extends Backbone.View
+ tagName: 'li'
+ className: 'paymentline'
+ template: _.template $('#paymentline-template').html()
+ initialize: ->
+ @model.bind 'change', @render, this
+ events: {
+ 'keyup input': 'changeAmount'
+ }
+ changeAmount: (event) ->
+ newAmount = event.currentTarget.value
+ if newAmount && !isNaN(newAmount)
+ @model.set amount: parseFloat(newAmount)
+ render: ->
+ $(@el).html @template {name: (@model.get 'journal_id')[1], amount: (@model.get 'amount')}
+
+ class PaymentView extends Backbone.View
+ initialize: (options) ->
+ @shop = options.shop
+ @shop.bind 'change:selectedOrder', @changeSelectedOrder, this
+ @bindPaymentLineEvents()
+ @bindOrderLineEvents()
+ paymentLineList: ->
+ $(@el).find '#paymentlines'
+ events: {
+ 'click button#validate-order': 'validateCurrentOrder'
+ }
+ validateCurrentOrder: ->
+ currentOrder = @shop.get 'selectedOrder'
+ callback = => currentOrder.set validated: true
+ pos.push 'pos.order', currentOrder.exportAsJSON(), callback
+ bindPaymentLineEvents: ->
+ @currentPaymentLines = (@shop.get 'selectedOrder').get 'paymentLines'
+ @currentPaymentLines.bind 'add', @addPaymentLine, this
+ @currentPaymentLines.bind 'change', @render, this
+ @currentPaymentLines.bind 'remove', @render, this
+ @currentPaymentLines.bind 'all', @updatePaymentSummary, this
+ bindOrderLineEvents: ->
+ @currentOrderLines = (@shop.get 'selectedOrder').get 'orderLines'
+ @currentOrderLines.bind 'all', @updatePaymentSummary, this
+ changeSelectedOrder: ->
+ @currentPaymentLines.unbind()
+ @bindPaymentLineEvents()
+ @currentOrderLines.unbind()
+ @bindOrderLineEvents()
+ @render()
+ addPaymentLine: (newPaymentLine) ->
+ @paymentLineList().append (new PaymentlineView model: newPaymentLine).render()
+ render: ->
+ @paymentLineList().empty()
+ @currentPaymentLines.each (paymentLine) => @paymentLineList().append (new PaymentlineView model: paymentLine).render()
+ @updatePaymentSummary()
+ updatePaymentSummary: ->
+ currentOrder = @shop.get 'selectedOrder'
+ paidTotal = currentOrder.getPaidTotal()
+ dueTotal = currentOrder.getTotal()
+ $(@el).find('#payment-due-total').html dueTotal.toFixed 2
+ $(@el).find('#payment-paid-total').html paidTotal.toFixed 2
+ remainingAmount = dueTotal-paidTotal
+ remaining = if remainingAmount > 0 then "Due left: " + remainingAmount.toFixed 2 else "Change: " + (-remainingAmount).toFixed 2
+ $('#payment-remaining').html remaining
+
+ ###
+ "Receipt" step.
+ ###
+ class ReceiptLineView extends Backbone.View
+ tagName: 'li'
+ className: 'receiptline'
+ template: _.template $('#receiptline-template').html()
+ initialize: ->
+ @model.bind 'change', @render, this
+ render: ->
+ $(@el).html @template @model.toJSON()
+
+ class ReceiptView extends Backbone.View
+ initialize: (options) ->
+ @shop = options.shop
+ @shop.bind 'change:selectedOrder', @changeSelectedOrder, this
+ @bindOrderLineEvents()
+ @bindPaymentLineEvents()
+ receiptLineList: ->
+ $(@el).find('#receiptlines')
+ bindOrderLineEvents: ->
+ @currentOrderLines = (@shop.get 'selectedOrder').get 'orderLines'
+ @currentOrderLines.bind 'add', @addReceiptLine, this
+ @currentOrderLines.bind 'change', @render, this
+ @currentOrderLines.bind 'remove', @render, this
+ bindPaymentLineEvents: ->
+ @currentPaymentLines = (@shop.get 'selectedOrder').get 'paymentLines'
+ @currentPaymentLines.bind 'all', @updateReceiptSummary, this
+ changeSelectedOrder: ->
+ @currentOrderLines.unbind()
+ @bindOrderLineEvents()
+ @currentPaymentLines.unbind()
+ @bindPaymentLineEvents()
+ @render()
+ addReceiptLine: (newOrderItem) ->
+ @receiptLineList().append (new ReceiptLineView model: newOrderItem).render()
+ @updateReceiptSummary()
+ render: ->
+ @receiptLineList().empty()
+ @currentOrderLines.each (orderItem) => @receiptLineList().append (new ReceiptLineView model: orderItem).render()
+ @updateReceiptSummary()
+ updateReceiptSummary: ->
+ currentOrder = @shop.get 'selectedOrder'
+ total = currentOrder.getTotal()
+ tax = currentOrder.getTax()
+ change = currentOrder.getPaidTotal() - total
+ $('#receipt-summary-tax').html tax.toFixed 2
+ $('#receipt-summary-total').html total.toFixed 2
+ $('#receipt-summary-change').html change.toFixed 2
+
+ class OrderButtonView extends Backbone.View
+ tagName: 'li'
+ className: 'order-selector-button'
+ template: _.template $('#order-selector-button-template').html()
+ initialize: (options) ->
+ @order = options.order
+ @shop = options.shop
+ @order.bind 'destroy', => $(@el).remove()
+ @shop.bind 'change:selectedOrder', (shop) =>
+ selectedOrder = shop.get 'selectedOrder'
+ if @order is selectedOrder
+ @setButtonSelected()
+ events: {
+ 'click button.select-order': 'selectOrder',
+ 'click button.close-order': 'closeOrder'
+ }
+ selectOrder: (event) ->
+ @shop.set selectedOrder: @order
+ setButtonSelected: ->
+ $('.selected-order').removeClass 'selected-order'
+ $(@el).addClass 'selected-order'
+ closeOrder: (event) ->
+ @order.destroy()
+ render: ->
+ $(@el).html @template @order.toJSON()
+
+ class ShopView extends Backbone.View
+ initialize: (options) ->
+ @shop = options.shop
+ (@shop.get 'orders').bind 'add', @orderAdded, this
+ (@shop.get 'orders').add new Order
+ @numpadState = new NumpadState
+ shop: @shop
+ @productListView = new ProductListView
+ shop: @shop
+ @paypadView = new PaypadView
+ shop: @shop
+ el: $('#paypad')
+ @paypadView.render()
+ @orderView = new OrderView
+ shop: @shop,
+ numpadState: @numpadState
+ el: $('#current-order-content')
+ @paymentView = new PaymentView
+ shop: @shop,
+ el: $('#payment-screen')
+ @receiptView = new ReceiptView
+ shop: @shop,
+ el: $('#receipt-screen')
+ @numpadView = new NumpadView
+ state: @numpadState,
+ el: $('#numpad')
+ @stepsView = new StepsView
+ el: $('#steps')
+ events: {
+ 'click button#neworder-button': 'createNewOrder'
+ }
+ createNewOrder: ->
+ newOrder = new Order
+ (@shop.get 'orders').add newOrder
+ @shop.set selectedOrder: newOrder
+ orderAdded: (newOrder) ->
+ newOrderButton = new OrderButtonView
+ order: newOrder,
+ shop: @shop
+ $('#orders').append (newOrderButton).render()
+ newOrderButton.selectOrder()
+
+ class App extends Backbone.Router
+ routes:
+ '': 'category'
+ 'category/:id': 'category'
+ initialize: ->
+ @shop = new Shop
+ @shopView = new ShopView
+ shop: @shop
+ el: $('body')
+ @categoryView = new CategoryView
+ category: (id = 0) ->
+ c = pos.categories[id]
+ $('#products-screen').html(@categoryView.render c.ancestors, c.children)
+ products = pos.store.get('product.product').filter (p) -> p.pos_categ_id[0] in c.subtree
+ (@shop.get 'products').reset products
+ $('.searchbox input').keyup ->
+ s = $(@).val().toLowerCase()
+ if s
+ m = products.filter (p) -> p.name.toLowerCase().indexOf s
+ $('.search-clear').fadeIn()
+ else
+ m = products
+ $('.search-clear').fadeOut()
+ (@shop.get 'products').reset m
+ $('.search-clear').click ->
+ (@shop.get 'products').reset products
+ $('.searchbox input').val('').focus()
+ $('.search-clear').fadeOut()
+
+ pos.ready.then ->
+ pos.app = new App
+ Backbone.history.start()
diff --git a/addons/point_of_sale/static/src/js/pos.js b/addons/point_of_sale/static/src/js/pos.js
new file mode 100644
index 00000000000..04b7d9991eb
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/pos.js
@@ -0,0 +1,1097 @@
+(function() {
+ var Pos, Store, db;
+ var __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }, __hasProp = Object.prototype.hasOwnProperty, __extends = function(child, parent) {
+ for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; }
+ function ctor() { this.constructor = child; }
+ ctor.prototype = parent.prototype;
+ child.prototype = new ctor;
+ child.__super__ = parent.prototype;
+ return child;
+ }, __indexOf = Array.prototype.indexOf || function(item) {
+ for (var i = 0, l = this.length; i < l; i++) {
+ if (this[i] === item) return i;
+ }
+ return -1;
+ };
+ db = openerp.init();
+ /*
+ Local store access. Read once from localStorage upon construction and persist on every change.
+ There should only be one store active at any given time to ensure data consistency.
+ */
+ Store = (function() {
+ function Store() {
+ var store;
+ store = localStorage['pos'];
+ this.data = (store && JSON.parse(store)) || {};
+ }
+ Store.prototype.get = function(key) {
+ return this.data[key];
+ };
+ Store.prototype.set = function(key, value) {
+ this.data[key] = value;
+ return localStorage['pos'] = JSON.stringify(this.data);
+ };
+ return Store;
+ })();
+ /*
+ Gets all the necessary data from the OpenERP web client (session, shop data etc.)
+ */
+ Pos = (function() {
+ function Pos() {
+ this.build_tree = __bind(this.build_tree, this); this.session.session_login('web-trunk-pos', 'admin', 'admin', __bind(function() {
+ return $.when(this.fetch('pos.category', ['name', 'parent_id', 'child_id']), this.fetch('product.product', ['name', 'list_price', 'pos_categ_id', 'taxes_id', 'img'], [['pos_categ_id', '!=', 'false']]), this.fetch('account.bank.statement', ['account_id', 'currency', 'journal_id', 'state', 'name']), this.fetch('account.journal', ['auto_cash', 'check_dtls', 'currency', 'name', 'type'])).then(this.build_tree);
+ }, this));
+ }
+ Pos.prototype.ready = $.Deferred();
+ Pos.prototype.session = new db.base.Session('DEBUG');
+ Pos.prototype.store = new Store;
+ Pos.prototype.fetch = function(osvModel, fields, domain, callback, errorCallback) {
+ var dataSetSearch;
+ callback = callback || __bind(function(result) {
+ return this.store.set(osvModel, result);
+ }, this);
+ dataSetSearch = new db.base.DataSetSearch(this, osvModel, null, domain);
+ return dataSetSearch.read_slice(fields, 0, null, callback);
+ };
+ Pos.prototype.push = function(osvModel, record, callback, errorCallback) {
+ var dataSet;
+ dataSet = new db.base.DataSet(this, osvModel, null);
+ return dataSet.create(record, callback, errorCallback);
+ };
+ Pos.prototype.categories = {};
+ Pos.prototype.build_tree = function() {
+ var c, id, _i, _len, _ref, _ref2;
+ _ref = this.store.get('pos.category');
+ for (_i = 0, _len = _ref.length; _i < _len; _i++) {
+ c = _ref[_i];
+ this.categories[c.id] = {
+ id: c.id,
+ name: c.name,
+ children: c.child_id,
+ parent: c.parent_id[0],
+ ancestors: [c.id],
+ subtree: [c.id]
+ };
+ }
+ _ref2 = this.categories;
+ for (id in _ref2) {
+ c = _ref2[id];
+ this.current_category = c;
+ this.build_ancestors(c.parent);
+ this.build_subtree(c);
+ }
+ this.categories[0] = {
+ ancestors: [],
+ children: (function() {
+ var _j, _len2, _ref3, _results;
+ _ref3 = this.store.get('pos.category');
+ _results = [];
+ for (_j = 0, _len2 = _ref3.length; _j < _len2; _j++) {
+ c = _ref3[_j];
+ if (!(c.parent_id[0] != null)) {
+ _results.push(c.id);
+ }
+ }
+ return _results;
+ }).call(this),
+ subtree: (function() {
+ var _j, _len2, _ref3, _results;
+ _ref3 = this.store.get('pos.category');
+ _results = [];
+ for (_j = 0, _len2 = _ref3.length; _j < _len2; _j++) {
+ c = _ref3[_j];
+ _results.push(c.id);
+ }
+ return _results;
+ }).call(this)
+ };
+ return this.ready.resolve();
+ };
+ Pos.prototype.build_ancestors = function(parent) {
+ if (parent != null) {
+ this.current_category.ancestors.unshift(parent);
+ return this.build_ancestors(this.categories[parent].parent);
+ }
+ };
+ Pos.prototype.build_subtree = function(category) {
+ var c, _i, _len, _ref, _results;
+ _ref = category.children;
+ _results = [];
+ for (_i = 0, _len = _ref.length; _i < _len; _i++) {
+ c = _ref[_i];
+ this.current_category.subtree.push(c);
+ _results.push(this.build_subtree(this.categories[c]));
+ }
+ return _results;
+ };
+ return Pos;
+ })();
+ window.pos = new Pos;
+ $(function() {
+ var App, CashRegister, CashRegisterCollection, Category, CategoryCollection, CategoryView, NumpadState, NumpadView, Order, OrderButtonView, OrderCollection, OrderView, Orderline, OrderlineCollection, OrderlineView, PaymentButtonView, PaymentView, Paymentline, PaymentlineCollection, PaymentlineView, PaypadView, Product, ProductCollection, ProductListView, ProductView, ReceiptLineView, ReceiptView, Shop, ShopView, StepsView;
+ $('#steps').buttonset();
+ /*
+ ---
+ Models
+ ---
+ */
+ CashRegister = (function() {
+ __extends(CashRegister, Backbone.Model);
+ function CashRegister() {
+ CashRegister.__super__.constructor.apply(this, arguments);
+ }
+ return CashRegister;
+ })();
+ CashRegisterCollection = (function() {
+ __extends(CashRegisterCollection, Backbone.Collection);
+ function CashRegisterCollection() {
+ CashRegisterCollection.__super__.constructor.apply(this, arguments);
+ }
+ CashRegisterCollection.prototype.model = CashRegister;
+ return CashRegisterCollection;
+ })();
+ Product = (function() {
+ __extends(Product, Backbone.Model);
+ function Product() {
+ Product.__super__.constructor.apply(this, arguments);
+ }
+ return Product;
+ })();
+ ProductCollection = (function() {
+ __extends(ProductCollection, Backbone.Collection);
+ function ProductCollection() {
+ ProductCollection.__super__.constructor.apply(this, arguments);
+ }
+ ProductCollection.prototype.model = Product;
+ return ProductCollection;
+ })();
+ Category = (function() {
+ __extends(Category, Backbone.Model);
+ function Category() {
+ Category.__super__.constructor.apply(this, arguments);
+ }
+ return Category;
+ })();
+ CategoryCollection = (function() {
+ __extends(CategoryCollection, Backbone.Collection);
+ function CategoryCollection() {
+ CategoryCollection.__super__.constructor.apply(this, arguments);
+ }
+ CategoryCollection.prototype.model = Category;
+ return CategoryCollection;
+ })();
+ /*
+ Each Order contains zero or more Orderlines (i.e. the content of the "shopping cart".)
+ There should only ever be one Orderline per distinct product in an Order.
+ To add more of the same product, just update the quantity accordingly.
+ The Order also contains payment information.
+ */
+ Orderline = (function() {
+ __extends(Orderline, Backbone.Model);
+ function Orderline() {
+ Orderline.__super__.constructor.apply(this, arguments);
+ }
+ Orderline.prototype.defaults = {
+ quantity: 1,
+ list_price: 0,
+ discount: 0
+ };
+ Orderline.prototype.incrementQuantity = function() {
+ return this.set({
+ quantity: (this.get('quantity')) + 1
+ });
+ };
+ Orderline.prototype.getTotal = function() {
+ return (this.get('quantity')) * (this.get('list_price')) * (1 - (this.get('discount')) / 100);
+ };
+ Orderline.prototype.exportAsJSON = function() {
+ var result;
+ result = {
+ qty: this.get('quantity'),
+ price_unit: this.get('list_price'),
+ discount: this.get('discount'),
+ product_id: this.get('id')
+ };
+ return result;
+ };
+ return Orderline;
+ })();
+ OrderlineCollection = (function() {
+ __extends(OrderlineCollection, Backbone.Collection);
+ function OrderlineCollection() {
+ OrderlineCollection.__super__.constructor.apply(this, arguments);
+ }
+ OrderlineCollection.prototype.model = Orderline;
+ return OrderlineCollection;
+ })();
+ /*
+ Every PaymentLine has all the attributes of the corresponding CashRegister.
+ */
+ Paymentline = (function() {
+ __extends(Paymentline, Backbone.Model);
+ function Paymentline() {
+ Paymentline.__super__.constructor.apply(this, arguments);
+ }
+ Paymentline.prototype.defaults = {
+ amount: 0
+ };
+ Paymentline.prototype.getAmount = function() {
+ return this.get('amount');
+ };
+ Paymentline.prototype.exportAsJSON = function() {
+ var result;
+ result = {
+ name: "Payment line",
+ statement_id: this.get('id'),
+ account_id: (this.get('account_id'))[0],
+ journal_id: (this.get('journal_id'))[0],
+ amount: this.getAmount()
+ };
+ return result;
+ };
+ return Paymentline;
+ })();
+ PaymentlineCollection = (function() {
+ __extends(PaymentlineCollection, Backbone.Collection);
+ function PaymentlineCollection() {
+ PaymentlineCollection.__super__.constructor.apply(this, arguments);
+ }
+ PaymentlineCollection.prototype.model = Paymentline;
+ return PaymentlineCollection;
+ })();
+ Order = (function() {
+ __extends(Order, Backbone.Model);
+ function Order() {
+ Order.__super__.constructor.apply(this, arguments);
+ }
+ Order.prototype.defaults = {
+ validated: false
+ };
+ Order.prototype.initialize = function() {
+ this.set({
+ orderLines: new OrderlineCollection
+ });
+ this.set({
+ paymentLines: new PaymentlineCollection
+ });
+ return this.set({
+ name: "Order " + this.generateUniqueId()
+ });
+ };
+ Order.prototype.generateUniqueId = function() {
+ return new Date().getTime();
+ };
+ Order.prototype.addProduct = function(product) {
+ var existing;
+ existing = (this.get('orderLines')).get(product.id);
+ if (existing != null) {
+ return existing.incrementQuantity();
+ } else {
+ return (this.get('orderLines')).add(new Orderline(product.toJSON()));
+ }
+ };
+ Order.prototype.addPaymentLine = function(cashRegister) {
+ var newPaymentline;
+ newPaymentline = new Paymentline(cashRegister);
+ /* TODO: Should be 0 for cash-like accounts */
+ newPaymentline.set({
+ amount: this.getDueLeft()
+ });
+ return (this.get('paymentLines')).add(newPaymentline);
+ };
+ Order.prototype.getName = function() {
+ return this.get('name');
+ };
+ Order.prototype.getTotal = function() {
+ return (this.get('orderLines')).reduce((function(sum, orderLine) {
+ return sum + orderLine.getTotal();
+ }), 0);
+ };
+ Order.prototype.getTotalTaxExcluded = function() {
+ return this.getTotal() / 1.21;
+ };
+ Order.prototype.getTax = function() {
+ return this.getTotal() / 1.21 * 0.21;
+ };
+ Order.prototype.getPaidTotal = function() {
+ return (this.get('paymentLines')).reduce((function(sum, paymentLine) {
+ return sum + paymentLine.getAmount();
+ }), 0);
+ };
+ Order.prototype.getChange = function() {
+ return this.getPaidTotal() - this.getTotal();
+ };
+ Order.prototype.getDueLeft = function() {
+ return this.getTotal() - this.getPaidTotal();
+ };
+ Order.prototype.exportAsJSON = function() {
+ var orderLines, paymentLines, result;
+ orderLines = [];
+ (this.get('orderLines')).each(__bind(function(item) {
+ return orderLines.push([0, 0, item.exportAsJSON()]);
+ }, this));
+ paymentLines = [];
+ (this.get('paymentLines')).each(__bind(function(item) {
+ return paymentLines.push([0, 0, item.exportAsJSON()]);
+ }, this));
+ result = {
+ name: this.getName(),
+ amount_paid: this.getPaidTotal(),
+ amount_total: this.getTotal(),
+ amount_tax: this.getTax(),
+ amount_return: this.getChange(),
+ lines: orderLines,
+ statement_ids: paymentLines
+ };
+ return result;
+ };
+ return Order;
+ })();
+ OrderCollection = (function() {
+ __extends(OrderCollection, Backbone.Collection);
+ function OrderCollection() {
+ OrderCollection.__super__.constructor.apply(this, arguments);
+ }
+ OrderCollection.prototype.model = Order;
+ return OrderCollection;
+ })();
+ Shop = (function() {
+ __extends(Shop, Backbone.Model);
+ function Shop() {
+ Shop.__super__.constructor.apply(this, arguments);
+ }
+ Shop.prototype.defaults = {
+ cashRegisters: new CashRegisterCollection(pos.store.get('account.bank.statement')),
+ orders: new OrderCollection,
+ products: new ProductCollection
+ };
+ Shop.prototype.initialize = function() {
+ return (this.get('orders')).bind('remove', __bind(function(removedOrder) {
+ if ((this.get('orders')).isEmpty()) {
+ this.addAndSelectOrder(new Order);
+ }
+ if ((this.get('selectedOrder')) === removedOrder) {
+ return this.set({
+ selectedOrder: (this.get('orders')).last()
+ });
+ }
+ }, this));
+ };
+ Shop.prototype.addAndSelectOrder = function(newOrder) {
+ (this.get('orders')).add(newOrder);
+ return this.set({
+ selectedOrder: newOrder
+ });
+ };
+ return Shop;
+ })();
+ /*
+ The numpad handles both the choice of the property currently being modified
+ (quantity, price or discount) and the edition of the corresponding numeric value.
+ */
+ NumpadState = (function() {
+ __extends(NumpadState, Backbone.Model);
+ function NumpadState() {
+ NumpadState.__super__.constructor.apply(this, arguments);
+ }
+ NumpadState.prototype.defaults = {
+ buffer: "0",
+ mode: "quantity"
+ };
+ NumpadState.prototype.initialize = function(options) {
+ this.shop = options.shop;
+ return this.shop.bind('change:selectedOrder', this.reset, this);
+ };
+ NumpadState.prototype.appendNewChar = function(newChar) {
+ var oldBuffer;
+ oldBuffer = this.get('buffer');
+ if (oldBuffer === '0') {
+ this.set({
+ buffer: newChar
+ });
+ } else if (oldBuffer === '-0') {
+ this.set({
+ buffer: "-" + newChar
+ });
+ } else {
+ this.set({
+ buffer: (this.get('buffer')) + newChar
+ });
+ }
+ return this.updateTarget();
+ };
+ NumpadState.prototype.deleteLastChar = function() {
+ var tempNewBuffer;
+ tempNewBuffer = (this.get('buffer')).slice(0, -1) || "0";
+ if (isNaN(tempNewBuffer)) {
+ tempNewBuffer = "0";
+ }
+ this.set({
+ buffer: tempNewBuffer
+ });
+ return this.updateTarget();
+ };
+ NumpadState.prototype.switchSign = function() {
+ var oldBuffer;
+ oldBuffer = this.get('buffer');
+ this.set({
+ buffer: oldBuffer[0] === '-' ? oldBuffer.substr(1) : "-" + oldBuffer
+ });
+ return this.updateTarget();
+ };
+ NumpadState.prototype.changeMode = function(newMode) {
+ return this.set({
+ buffer: "0",
+ mode: newMode
+ });
+ };
+ NumpadState.prototype.reset = function() {
+ return this.set({
+ buffer: "0"
+ });
+ };
+ NumpadState.prototype.updateTarget = function() {
+ var bufferContent, params;
+ bufferContent = this.get('buffer');
+ if (bufferContent && !isNaN(bufferContent)) {
+ params = {};
+ params[this.get('mode')] = parseFloat(bufferContent);
+ return (this.shop.get('selectedOrder')).selected.set(params);
+ }
+ };
+ return NumpadState;
+ })();
+ /*
+ ---
+ Views
+ ---
+ */
+ NumpadView = (function() {
+ __extends(NumpadView, Backbone.View);
+ function NumpadView() {
+ NumpadView.__super__.constructor.apply(this, arguments);
+ }
+ NumpadView.prototype.initialize = function(options) {
+ return this.state = options.state;
+ };
+ NumpadView.prototype.events = {
+ 'click button#numpad-backspace': 'clickDeleteLastChar',
+ 'click button#numpad-minus': 'clickSwitchSign',
+ 'click button.number-char': 'clickAppendNewChar',
+ 'click button.mode-button': 'clickChangeMode'
+ };
+ NumpadView.prototype.clickDeleteLastChar = function() {
+ return this.state.deleteLastChar();
+ };
+ NumpadView.prototype.clickSwitchSign = function() {
+ return this.state.switchSign();
+ };
+ NumpadView.prototype.clickAppendNewChar = function(event) {
+ var newChar;
+ newChar = event.currentTarget.innerText;
+ return this.state.appendNewChar(newChar);
+ };
+ NumpadView.prototype.clickChangeMode = function(event) {
+ var newMode;
+ $('.selected-mode').removeClass('selected-mode');
+ $(event.currentTarget).addClass('selected-mode');
+ newMode = event.currentTarget.attributes['data-mode'].nodeValue;
+ return this.state.changeMode(newMode);
+ };
+ return NumpadView;
+ })();
+ /*
+ Gives access to the payment methods (aka. 'cash registers')
+ */
+ PaypadView = (function() {
+ __extends(PaypadView, Backbone.View);
+ function PaypadView() {
+ PaypadView.__super__.constructor.apply(this, arguments);
+ }
+ PaypadView.prototype.initialize = function(options) {
+ return this.shop = options.shop;
+ };
+ PaypadView.prototype.events = {
+ 'click button': 'performPayment'
+ };
+ PaypadView.prototype.performPayment = function(event) {
+ var cashRegister, cashRegisterCollection, cashRegisterId;
+ cashRegisterId = event.currentTarget.attributes['cash-register-id'].nodeValue;
+ cashRegisterCollection = this.shop.get('cashRegisters');
+ cashRegister = cashRegisterCollection.find(__bind(function(item) {
+ return (item.get('id')) === parseInt(cashRegisterId, 10);
+ }, this));
+ return (this.shop.get('selectedOrder')).addPaymentLine(cashRegister);
+ };
+ PaypadView.prototype.render = function() {
+ $(this.el).empty();
+ return (this.shop.get('cashRegisters')).each(__bind(function(cashRegister) {
+ return $(this.el).append((new PaymentButtonView({
+ model: cashRegister
+ })).render());
+ }, this));
+ };
+ return PaypadView;
+ })();
+ PaymentButtonView = (function() {
+ __extends(PaymentButtonView, Backbone.View);
+ function PaymentButtonView() {
+ PaymentButtonView.__super__.constructor.apply(this, arguments);
+ }
+ PaymentButtonView.prototype.template = _.template($('#payment-button-template').html());
+ PaymentButtonView.prototype.render = function() {
+ return $(this.el).html(this.template({
+ id: this.model.get('id'),
+ name: (this.model.get('journal_id'))[1]
+ }));
+ };
+ return PaymentButtonView;
+ })();
+ /*
+ There are 3 steps in a POS workflow:
+ 1. prepare the order (i.e. chose products, quantities etc.)
+ 2. choose payment method(s) and amount(s)
+ 3. validae order and print receipt
+ It should be possible to go back to any step as long as step 3 hasn't been completed.
+ Modifying an order after validation shouldn't be allowed.
+ */
+ StepsView = (function() {
+ __extends(StepsView, Backbone.View);
+ function StepsView() {
+ StepsView.__super__.constructor.apply(this, arguments);
+ }
+ StepsView.prototype.initialize = function(options) {
+ return this.step = "products";
+ };
+ StepsView.prototype.events = {
+ 'click input.step-button': 'clickChangeStep'
+ };
+ StepsView.prototype.clickChangeStep = function(event) {
+ var newStep;
+ newStep = event.currentTarget.attributes['data-step'].nodeValue;
+ $('.step-screen').hide();
+ $('#' + newStep + '-screen').show();
+ return this.step = newStep;
+ };
+ return StepsView;
+ })();
+ /*
+ Shopping carts.
+ */
+ OrderlineView = (function() {
+ __extends(OrderlineView, Backbone.View);
+ function OrderlineView() {
+ OrderlineView.__super__.constructor.apply(this, arguments);
+ }
+ OrderlineView.prototype.tagName = 'tr';
+ OrderlineView.prototype.template = _.template($('#orderline-template').html());
+ OrderlineView.prototype.initialize = function(options) {
+ this.model.bind('change', __bind(function() {
+ $(this.el).hide();
+ return this.render();
+ }, this));
+ this.model.bind('remove', __bind(function() {
+ return $(this.el).remove();
+ }, this));
+ this.order = options.order;
+ return this.numpadState = options.numpadState;
+ };
+ OrderlineView.prototype.events = {
+ 'click': 'clickHandler'
+ };
+ OrderlineView.prototype.clickHandler = function() {
+ this.numpadState.reset();
+ return this.select();
+ };
+ OrderlineView.prototype.render = function() {
+ this.select();
+ return $(this.el).html(this.template(this.model.toJSON())).fadeIn(400, function() {
+ return $('#current-order').scrollTop($(this).offset().top);
+ });
+ };
+ OrderlineView.prototype.select = function() {
+ $('tr.selected').removeClass('selected');
+ $(this.el).addClass('selected');
+ return this.order.selected = this.model;
+ };
+ return OrderlineView;
+ })();
+ OrderView = (function() {
+ __extends(OrderView, Backbone.View);
+ function OrderView() {
+ OrderView.__super__.constructor.apply(this, arguments);
+ }
+ OrderView.prototype.initialize = function(options) {
+ this.shop = options.shop;
+ this.numpadState = options.numpadState;
+ this.shop.bind('change:selectedOrder', this.changeSelectedOrder, this);
+ return this.bindOrderLineEvents();
+ };
+ OrderView.prototype.changeSelectedOrder = function() {
+ this.currentOrderLines.unbind();
+ this.bindOrderLineEvents();
+ return this.render();
+ };
+ OrderView.prototype.bindOrderLineEvents = function() {
+ this.currentOrderLines = (this.shop.get('selectedOrder')).get('orderLines');
+ this.currentOrderLines.bind('add', this.addLine, this);
+ this.currentOrderLines.bind('change', this.render, this);
+ return this.currentOrderLines.bind('remove', this.render, this);
+ };
+ OrderView.prototype.addLine = function(newLine) {
+ $(this.el).append((new OrderlineView({
+ model: newLine,
+ order: this.shop.get('selectedOrder'),
+ numpadState: this.numpadState
+ })).render());
+ return this.updateSummary();
+ };
+ OrderView.prototype.render = function() {
+ $(this.el).empty();
+ this.currentOrderLines.each(__bind(function(orderLine) {
+ return $(this.el).append((new OrderlineView({
+ model: orderLine,
+ order: this.shop.get('selectedOrder'),
+ numpadState: this.numpadState
+ })).render());
+ }, this));
+ return this.updateSummary();
+ };
+ OrderView.prototype.updateSummary = function() {
+ var currentOrder, tax, total, totalTaxExcluded;
+ currentOrder = this.shop.get('selectedOrder');
+ total = currentOrder.getTotal();
+ totalTaxExcluded = currentOrder.getTotalTaxExcluded();
+ tax = currentOrder.getTax();
+ $('#subtotal').html(totalTaxExcluded.toFixed(2)).hide().fadeIn();
+ $('#tax').html(tax.toFixed(2)).hide().fadeIn();
+ return $('#total').html(total.toFixed(2)).hide().fadeIn();
+ };
+ return OrderView;
+ })();
+ /*
+ "Products" step.
+ */
+ CategoryView = (function() {
+ __extends(CategoryView, Backbone.View);
+ function CategoryView() {
+ CategoryView.__super__.constructor.apply(this, arguments);
+ }
+ CategoryView.prototype.template = _.template($('#category-template').html());
+ CategoryView.prototype.render = function(ancestors, children) {
+ var c;
+ return $(this.el).html(this.template({
+ breadcrumb: (function() {
+ var _i, _len, _results;
+ _results = [];
+ for (_i = 0, _len = ancestors.length; _i < _len; _i++) {
+ c = ancestors[_i];
+ _results.push(pos.categories[c]);
+ }
+ return _results;
+ })(),
+ categories: (function() {
+ var _i, _len, _results;
+ _results = [];
+ for (_i = 0, _len = children.length; _i < _len; _i++) {
+ c = children[_i];
+ _results.push(pos.categories[c]);
+ }
+ return _results;
+ })()
+ }));
+ };
+ return CategoryView;
+ })();
+ ProductView = (function() {
+ __extends(ProductView, Backbone.View);
+ function ProductView() {
+ ProductView.__super__.constructor.apply(this, arguments);
+ }
+ ProductView.prototype.tagName = 'li';
+ ProductView.prototype.className = 'product';
+ ProductView.prototype.template = _.template($('#product-template').html());
+ ProductView.prototype.events = {
+ 'click a': 'addToOrder'
+ };
+ ProductView.prototype.initialize = function(options) {
+ return this.shop = options.shop;
+ };
+ ProductView.prototype.addToOrder = function(event) {
+ /* Preserve the category URL */ event.preventDefault();
+ return (this.shop.get('selectedOrder')).addProduct(this.model);
+ };
+ ProductView.prototype.render = function() {
+ return $(this.el).html(this.template(this.model.toJSON()));
+ };
+ return ProductView;
+ })();
+ ProductListView = (function() {
+ __extends(ProductListView, Backbone.View);
+ function ProductListView() {
+ ProductListView.__super__.constructor.apply(this, arguments);
+ }
+ ProductListView.prototype.tagName = 'ol';
+ ProductListView.prototype.className = 'product-list';
+ ProductListView.prototype.initialize = function(options) {
+ this.shop = options.shop;
+ return (this.shop.get('products')).bind('reset', this.render, this);
+ };
+ ProductListView.prototype.render = function() {
+ $(this.el).empty();
+ (this.shop.get('products')).each(__bind(function(product) {
+ return $(this.el).append((new ProductView({
+ model: product,
+ shop: this.shop
+ })).render());
+ }, this));
+ return $('#products-screen').append(this.el);
+ };
+ return ProductListView;
+ })();
+ /*
+ "Payment" step.
+ */
+ PaymentlineView = (function() {
+ __extends(PaymentlineView, Backbone.View);
+ function PaymentlineView() {
+ PaymentlineView.__super__.constructor.apply(this, arguments);
+ }
+ PaymentlineView.prototype.tagName = 'li';
+ PaymentlineView.prototype.className = 'paymentline';
+ PaymentlineView.prototype.template = _.template($('#paymentline-template').html());
+ PaymentlineView.prototype.initialize = function() {
+ return this.model.bind('change', this.render, this);
+ };
+ PaymentlineView.prototype.events = {
+ 'keyup input': 'changeAmount'
+ };
+ PaymentlineView.prototype.changeAmount = function(event) {
+ var newAmount;
+ newAmount = event.currentTarget.value;
+ if (newAmount && !isNaN(newAmount)) {
+ return this.model.set({
+ amount: parseFloat(newAmount)
+ });
+ }
+ };
+ PaymentlineView.prototype.render = function() {
+ return $(this.el).html(this.template({
+ name: (this.model.get('journal_id'))[1],
+ amount: this.model.get('amount')
+ }));
+ };
+ return PaymentlineView;
+ })();
+ PaymentView = (function() {
+ __extends(PaymentView, Backbone.View);
+ function PaymentView() {
+ PaymentView.__super__.constructor.apply(this, arguments);
+ }
+ PaymentView.prototype.initialize = function(options) {
+ this.shop = options.shop;
+ this.shop.bind('change:selectedOrder', this.changeSelectedOrder, this);
+ this.bindPaymentLineEvents();
+ return this.bindOrderLineEvents();
+ };
+ PaymentView.prototype.paymentLineList = function() {
+ return $(this.el).find('#paymentlines');
+ };
+ PaymentView.prototype.events = {
+ 'click button#validate-order': 'validateCurrentOrder'
+ };
+ PaymentView.prototype.validateCurrentOrder = function() {
+ var callback, currentOrder;
+ currentOrder = this.shop.get('selectedOrder');
+ callback = __bind(function() {
+ return currentOrder.set({
+ validated: true
+ });
+ }, this);
+ return pos.push('pos.order', currentOrder.exportAsJSON(), callback);
+ };
+ PaymentView.prototype.bindPaymentLineEvents = function() {
+ this.currentPaymentLines = (this.shop.get('selectedOrder')).get('paymentLines');
+ this.currentPaymentLines.bind('add', this.addPaymentLine, this);
+ this.currentPaymentLines.bind('change', this.render, this);
+ this.currentPaymentLines.bind('remove', this.render, this);
+ return this.currentPaymentLines.bind('all', this.updatePaymentSummary, this);
+ };
+ PaymentView.prototype.bindOrderLineEvents = function() {
+ this.currentOrderLines = (this.shop.get('selectedOrder')).get('orderLines');
+ return this.currentOrderLines.bind('all', this.updatePaymentSummary, this);
+ };
+ PaymentView.prototype.changeSelectedOrder = function() {
+ this.currentPaymentLines.unbind();
+ this.bindPaymentLineEvents();
+ this.currentOrderLines.unbind();
+ this.bindOrderLineEvents();
+ return this.render();
+ };
+ PaymentView.prototype.addPaymentLine = function(newPaymentLine) {
+ return this.paymentLineList().append((new PaymentlineView({
+ model: newPaymentLine
+ })).render());
+ };
+ PaymentView.prototype.render = function() {
+ this.paymentLineList().empty();
+ this.currentPaymentLines.each(__bind(function(paymentLine) {
+ return this.paymentLineList().append((new PaymentlineView({
+ model: paymentLine
+ })).render());
+ }, this));
+ return this.updatePaymentSummary();
+ };
+ PaymentView.prototype.updatePaymentSummary = function() {
+ var currentOrder, dueTotal, paidTotal, remaining, remainingAmount;
+ currentOrder = this.shop.get('selectedOrder');
+ paidTotal = currentOrder.getPaidTotal();
+ dueTotal = currentOrder.getTotal();
+ $(this.el).find('#payment-due-total').html(dueTotal.toFixed(2));
+ $(this.el).find('#payment-paid-total').html(paidTotal.toFixed(2));
+ remainingAmount = dueTotal - paidTotal;
+ remaining = remainingAmount > 0 ? "Due left: " + remainingAmount.toFixed(2) : "Change: " + (-remainingAmount).toFixed(2);
+ return $('#payment-remaining').html(remaining);
+ };
+ return PaymentView;
+ })();
+ /*
+ "Receipt" step.
+ */
+ ReceiptLineView = (function() {
+ __extends(ReceiptLineView, Backbone.View);
+ function ReceiptLineView() {
+ ReceiptLineView.__super__.constructor.apply(this, arguments);
+ }
+ ReceiptLineView.prototype.tagName = 'li';
+ ReceiptLineView.prototype.className = 'receiptline';
+ ReceiptLineView.prototype.template = _.template($('#receiptline-template').html());
+ ReceiptLineView.prototype.initialize = function() {
+ return this.model.bind('change', this.render, this);
+ };
+ ReceiptLineView.prototype.render = function() {
+ return $(this.el).html(this.template(this.model.toJSON()));
+ };
+ return ReceiptLineView;
+ })();
+ ReceiptView = (function() {
+ __extends(ReceiptView, Backbone.View);
+ function ReceiptView() {
+ ReceiptView.__super__.constructor.apply(this, arguments);
+ }
+ ReceiptView.prototype.initialize = function(options) {
+ this.shop = options.shop;
+ this.shop.bind('change:selectedOrder', this.changeSelectedOrder, this);
+ this.bindOrderLineEvents();
+ return this.bindPaymentLineEvents();
+ };
+ ReceiptView.prototype.receiptLineList = function() {
+ return $(this.el).find('#receiptlines');
+ };
+ ReceiptView.prototype.bindOrderLineEvents = function() {
+ this.currentOrderLines = (this.shop.get('selectedOrder')).get('orderLines');
+ this.currentOrderLines.bind('add', this.addReceiptLine, this);
+ this.currentOrderLines.bind('change', this.render, this);
+ return this.currentOrderLines.bind('remove', this.render, this);
+ };
+ ReceiptView.prototype.bindPaymentLineEvents = function() {
+ this.currentPaymentLines = (this.shop.get('selectedOrder')).get('paymentLines');
+ return this.currentPaymentLines.bind('all', this.updateReceiptSummary, this);
+ };
+ ReceiptView.prototype.changeSelectedOrder = function() {
+ this.currentOrderLines.unbind();
+ this.bindOrderLineEvents();
+ this.currentPaymentLines.unbind();
+ this.bindPaymentLineEvents();
+ return this.render();
+ };
+ ReceiptView.prototype.addReceiptLine = function(newOrderItem) {
+ this.receiptLineList().append((new ReceiptLineView({
+ model: newOrderItem
+ })).render());
+ return this.updateReceiptSummary();
+ };
+ ReceiptView.prototype.render = function() {
+ this.receiptLineList().empty();
+ this.currentOrderLines.each(__bind(function(orderItem) {
+ return this.receiptLineList().append((new ReceiptLineView({
+ model: orderItem
+ })).render());
+ }, this));
+ return this.updateReceiptSummary();
+ };
+ ReceiptView.prototype.updateReceiptSummary = function() {
+ var change, currentOrder, tax, total;
+ currentOrder = this.shop.get('selectedOrder');
+ total = currentOrder.getTotal();
+ tax = currentOrder.getTax();
+ change = currentOrder.getPaidTotal() - total;
+ $('#receipt-summary-tax').html(tax.toFixed(2));
+ $('#receipt-summary-total').html(total.toFixed(2));
+ return $('#receipt-summary-change').html(change.toFixed(2));
+ };
+ return ReceiptView;
+ })();
+ OrderButtonView = (function() {
+ __extends(OrderButtonView, Backbone.View);
+ function OrderButtonView() {
+ OrderButtonView.__super__.constructor.apply(this, arguments);
+ }
+ OrderButtonView.prototype.tagName = 'li';
+ OrderButtonView.prototype.className = 'order-selector-button';
+ OrderButtonView.prototype.template = _.template($('#order-selector-button-template').html());
+ OrderButtonView.prototype.initialize = function(options) {
+ this.order = options.order;
+ this.shop = options.shop;
+ this.order.bind('destroy', __bind(function() {
+ return $(this.el).remove();
+ }, this));
+ return this.shop.bind('change:selectedOrder', __bind(function(shop) {
+ var selectedOrder;
+ selectedOrder = shop.get('selectedOrder');
+ if (this.order === selectedOrder) {
+ return this.setButtonSelected();
+ }
+ }, this));
+ };
+ OrderButtonView.prototype.events = {
+ 'click button.select-order': 'selectOrder',
+ 'click button.close-order': 'closeOrder'
+ };
+ OrderButtonView.prototype.selectOrder = function(event) {
+ return this.shop.set({
+ selectedOrder: this.order
+ });
+ };
+ OrderButtonView.prototype.setButtonSelected = function() {
+ $('.selected-order').removeClass('selected-order');
+ return $(this.el).addClass('selected-order');
+ };
+ OrderButtonView.prototype.closeOrder = function(event) {
+ return this.order.destroy();
+ };
+ OrderButtonView.prototype.render = function() {
+ return $(this.el).html(this.template(this.order.toJSON()));
+ };
+ return OrderButtonView;
+ })();
+ ShopView = (function() {
+ __extends(ShopView, Backbone.View);
+ function ShopView() {
+ ShopView.__super__.constructor.apply(this, arguments);
+ }
+ ShopView.prototype.initialize = function(options) {
+ this.shop = options.shop;
+ (this.shop.get('orders')).bind('add', this.orderAdded, this);
+ (this.shop.get('orders')).add(new Order);
+ this.numpadState = new NumpadState({
+ shop: this.shop
+ });
+ this.productListView = new ProductListView({
+ shop: this.shop
+ });
+ this.paypadView = new PaypadView({
+ shop: this.shop,
+ el: $('#paypad')
+ });
+ this.paypadView.render();
+ this.orderView = new OrderView({
+ shop: this.shop,
+ numpadState: this.numpadState,
+ el: $('#current-order-content')
+ });
+ this.paymentView = new PaymentView({
+ shop: this.shop,
+ el: $('#payment-screen')
+ });
+ this.receiptView = new ReceiptView({
+ shop: this.shop,
+ el: $('#receipt-screen')
+ });
+ this.numpadView = new NumpadView({
+ state: this.numpadState,
+ el: $('#numpad')
+ });
+ return this.stepsView = new StepsView({
+ el: $('#steps')
+ });
+ };
+ ShopView.prototype.events = {
+ 'click button#neworder-button': 'createNewOrder'
+ };
+ ShopView.prototype.createNewOrder = function() {
+ var newOrder;
+ newOrder = new Order;
+ (this.shop.get('orders')).add(newOrder);
+ return this.shop.set({
+ selectedOrder: newOrder
+ });
+ };
+ ShopView.prototype.orderAdded = function(newOrder) {
+ var newOrderButton;
+ newOrderButton = new OrderButtonView({
+ order: newOrder,
+ shop: this.shop
+ });
+ $('#orders').append(newOrderButton.render());
+ return newOrderButton.selectOrder();
+ };
+ return ShopView;
+ })();
+ App = (function() {
+ __extends(App, Backbone.Router);
+ function App() {
+ App.__super__.constructor.apply(this, arguments);
+ }
+ App.prototype.routes = {
+ '': 'category',
+ 'category/:id': 'category'
+ };
+ App.prototype.initialize = function() {
+ this.shop = new Shop;
+ this.shopView = new ShopView({
+ shop: this.shop,
+ el: $('body')
+ });
+ return this.categoryView = new CategoryView;
+ };
+ App.prototype.category = function(id) {
+ var c, products;
+ if (id == null) {
+ id = 0;
+ }
+ c = pos.categories[id];
+ $('#products-screen').html(this.categoryView.render(c.ancestors, c.children));
+ products = pos.store.get('product.product').filter(function(p) {
+ var _ref;
+ return _ref = p.pos_categ_id[0], __indexOf.call(c.subtree, _ref) >= 0;
+ });
+ (this.shop.get('products')).reset(products);
+ $('.searchbox input').keyup(function() {
+ var m, s;
+ s = $(this).val().toLowerCase();
+ if (s) {
+ m = products.filter(function(p) {
+ return p.name.toLowerCase().indexOf(s);
+ });
+ $('.search-clear').fadeIn();
+ } else {
+ m = products;
+ $('.search-clear').fadeOut();
+ }
+ return (this.shop.get('products')).reset(m);
+ });
+ return $('.search-clear').click(function() {
+ (this.shop.get('products')).reset(products);
+ $('.searchbox input').val('').focus();
+ return $('.search-clear').fadeOut();
+ });
+ };
+ return App;
+ })();
+ return pos.ready.then(function() {
+ pos.app = new App;
+ return Backbone.history.start();
+ });
+ });
+}).call(this);
diff --git a/addons/point_of_sale/static/src/pos.html b/addons/point_of_sale/static/src/pos.html
new file mode 100644
index 00000000000..3d8cfab7ba1
--- /dev/null
+++ b/addons/point_of_sale/static/src/pos.html
@@ -0,0 +1,200 @@
+
+
+
+
+ Point of Sale
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Product |
+ Price |
+ Disc (%) |
+ Qty |
+ Total |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+