diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/point_of_sale/static | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/point_of_sale/static')
251 files changed, 28184 insertions, 0 deletions
diff --git a/addons/point_of_sale/static/description/icon.png b/addons/point_of_sale/static/description/icon.png Binary files differnew file mode 100644 index 00000000..13890ffe --- /dev/null +++ b/addons/point_of_sale/static/description/icon.png diff --git a/addons/point_of_sale/static/description/icon.svg b/addons/point_of_sale/static/description/icon.svg new file mode 100644 index 00000000..bcc50ac6 --- /dev/null +++ b/addons/point_of_sale/static/description/icon.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70"><defs><path id="a" d="M4 0h61c4 0 5 1 5 5v60c0 4-1 5-5 5H4c-3 0-4-1-4-5V5c0-4 1-5 4-5z"/><linearGradient id="c" x1="100%" x2="0%" y1="0%" y2="98.616%"><stop offset="0%" stop-color="#797C79"/><stop offset="100%" stop-color="#545554"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><g mask="url(#b)"><path fill="url(#c)" d="M0 0H70V70H0z"/><path fill="#FFF" fill-opacity=".383" d="M4 1h61c2.667 0 4.333.667 5 2V0H0v3c.667-1.333 2-2 4-2z"/><path fill="#393939" d="M32.25 69H4c-2 0-4-1-4-4V39.181L19 20h32v6.208l1.992 12.632L51 41.123V50L32.25 69z" opacity=".324"/><path fill="#000" fill-opacity=".383" d="M4 69h61c2.667 0 4.333-1 5-3v4H0v-4c.667 2 2 3 4 3z"/><path fill="#000" fill-opacity=".3" d="M51 22H19v3.75h32V22zm2 18.75V37l-2-9.375H19L17 37v3.75h2V52h20V40.75h8V52h4V40.75h2zm-18 7.5H23v-7.5h12v7.5z"/><path fill="#FFF" d="M51 20H19v3.75h32V20zm2 18.75V35l-2-9.375H19L17 35v3.75h2V50h20V38.75h8V50h4V38.75h2zm-18 7.5H23v-7.5h12v7.5z"/></g></g></svg>
\ No newline at end of file diff --git a/addons/point_of_sale/static/img/barcode.png b/addons/point_of_sale/static/img/barcode.png Binary files differnew file mode 100644 index 00000000..2e9d2f4b --- /dev/null +++ b/addons/point_of_sale/static/img/barcode.png diff --git a/addons/point_of_sale/static/img/desk_organizer.png b/addons/point_of_sale/static/img/desk_organizer.png Binary files differnew file mode 100644 index 00000000..880f69b9 --- /dev/null +++ b/addons/point_of_sale/static/img/desk_organizer.png diff --git a/addons/point_of_sale/static/img/desk_pad.png b/addons/point_of_sale/static/img/desk_pad.png Binary files differnew file mode 100644 index 00000000..b335d9f6 --- /dev/null +++ b/addons/point_of_sale/static/img/desk_pad.png diff --git a/addons/point_of_sale/static/img/led_lamp.png b/addons/point_of_sale/static/img/led_lamp.png Binary files differnew file mode 100644 index 00000000..9e7cf633 --- /dev/null +++ b/addons/point_of_sale/static/img/led_lamp.png diff --git a/addons/point_of_sale/static/img/letter_tray.png b/addons/point_of_sale/static/img/letter_tray.png Binary files differnew file mode 100644 index 00000000..faaf88ac --- /dev/null +++ b/addons/point_of_sale/static/img/letter_tray.png diff --git a/addons/point_of_sale/static/img/magnetic_board.png b/addons/point_of_sale/static/img/magnetic_board.png Binary files differnew file mode 100644 index 00000000..edb7def2 --- /dev/null +++ b/addons/point_of_sale/static/img/magnetic_board.png diff --git a/addons/point_of_sale/static/img/monitor_stand.png b/addons/point_of_sale/static/img/monitor_stand.png Binary files differnew file mode 100644 index 00000000..bea3d135 --- /dev/null +++ b/addons/point_of_sale/static/img/monitor_stand.png diff --git a/addons/point_of_sale/static/img/newspaper_stand.png b/addons/point_of_sale/static/img/newspaper_stand.png Binary files differnew file mode 100644 index 00000000..ae87417e --- /dev/null +++ b/addons/point_of_sale/static/img/newspaper_stand.png diff --git a/addons/point_of_sale/static/img/partners/acsone.png b/addons/point_of_sale/static/img/partners/acsone.png Binary files differnew file mode 100644 index 00000000..f6ec9893 --- /dev/null +++ b/addons/point_of_sale/static/img/partners/acsone.png diff --git a/addons/point_of_sale/static/img/partners/bhc.jpg b/addons/point_of_sale/static/img/partners/bhc.jpg Binary files differnew file mode 100644 index 00000000..30005451 --- /dev/null +++ b/addons/point_of_sale/static/img/partners/bhc.jpg diff --git a/addons/point_of_sale/static/img/partners/camptocamp.png b/addons/point_of_sale/static/img/partners/camptocamp.png Binary files differnew file mode 100644 index 00000000..0cb138e1 --- /dev/null +++ b/addons/point_of_sale/static/img/partners/camptocamp.png diff --git a/addons/point_of_sale/static/img/partners/datalp.jpg b/addons/point_of_sale/static/img/partners/datalp.jpg Binary files differnew file mode 100644 index 00000000..9ef1f913 --- /dev/null +++ b/addons/point_of_sale/static/img/partners/datalp.jpg diff --git a/addons/point_of_sale/static/img/partners/eezee-it.png b/addons/point_of_sale/static/img/partners/eezee-it.png Binary files differnew file mode 100644 index 00000000..2e975e5a --- /dev/null +++ b/addons/point_of_sale/static/img/partners/eezee-it.png diff --git a/addons/point_of_sale/static/img/partners/eggs-solutions.jpg b/addons/point_of_sale/static/img/partners/eggs-solutions.jpg Binary files differnew file mode 100644 index 00000000..b15656b0 --- /dev/null +++ b/addons/point_of_sale/static/img/partners/eggs-solutions.jpg diff --git a/addons/point_of_sale/static/img/partners/ekomurz.gif b/addons/point_of_sale/static/img/partners/ekomurz.gif Binary files differnew file mode 100644 index 00000000..73905684 --- /dev/null +++ b/addons/point_of_sale/static/img/partners/ekomurz.gif diff --git a/addons/point_of_sale/static/img/partners/openbig.jpg b/addons/point_of_sale/static/img/partners/openbig.jpg Binary files differnew file mode 100644 index 00000000..88be89bf --- /dev/null +++ b/addons/point_of_sale/static/img/partners/openbig.jpg diff --git a/addons/point_of_sale/static/img/partners/services.jpg b/addons/point_of_sale/static/img/partners/services.jpg Binary files differnew file mode 100644 index 00000000..915f21fc --- /dev/null +++ b/addons/point_of_sale/static/img/partners/services.jpg diff --git a/addons/point_of_sale/static/img/partners/vauxoo.png b/addons/point_of_sale/static/img/partners/vauxoo.png Binary files differnew file mode 100644 index 00000000..be52bd1f --- /dev/null +++ b/addons/point_of_sale/static/img/partners/vauxoo.png diff --git a/addons/point_of_sale/static/img/small_shelf.png b/addons/point_of_sale/static/img/small_shelf.png Binary files differnew file mode 100644 index 00000000..5cccc2bb --- /dev/null +++ b/addons/point_of_sale/static/img/small_shelf.png diff --git a/addons/point_of_sale/static/img/storage.png b/addons/point_of_sale/static/img/storage.png Binary files differnew file mode 100644 index 00000000..484d02f1 --- /dev/null +++ b/addons/point_of_sale/static/img/storage.png diff --git a/addons/point_of_sale/static/img/wall_shelf_unit.png b/addons/point_of_sale/static/img/wall_shelf_unit.png Binary files differnew file mode 100644 index 00000000..7fea22be --- /dev/null +++ b/addons/point_of_sale/static/img/wall_shelf_unit.png diff --git a/addons/point_of_sale/static/img/whiteboard.png b/addons/point_of_sale/static/img/whiteboard.png Binary files differnew file mode 100644 index 00000000..10720871 --- /dev/null +++ b/addons/point_of_sale/static/img/whiteboard.png diff --git a/addons/point_of_sale/static/img/whiteboard_pen.png b/addons/point_of_sale/static/img/whiteboard_pen.png Binary files differnew file mode 100644 index 00000000..db39c09f --- /dev/null +++ b/addons/point_of_sale/static/img/whiteboard_pen.png diff --git a/addons/point_of_sale/static/lib/backbone/backbone.js b/addons/point_of_sale/static/lib/backbone/backbone.js new file mode 100644 index 00000000..f7783c2c --- /dev/null +++ b/addons/point_of_sale/static/lib/backbone/backbone.js @@ -0,0 +1,1581 @@ +// Backbone.js 1.1.0 + +// (c) 2010-2011 Jeremy Ashkenas, DocumentCloud Inc. +// (c) 2011-2013 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors +// Backbone may be freely distributed under the MIT license. +// For all details and documentation: +// http://backbonejs.org + +(function(){ + + // Initial Setup + // ------------- + + // Save a reference to the global object (`window` in the browser, `exports` + // on the server). + var root = this; + + // Save the previous value of the `Backbone` variable, so that it can be + // restored later on, if `noConflict` is used. + var previousBackbone = root.Backbone; + + // Create local references to array methods we'll want to use later. + var array = []; + var push = array.push; + var slice = array.slice; + var splice = array.splice; + + // The top-level namespace. All public Backbone classes and modules will + // be attached to this. Exported for both the browser and the server. + var Backbone; + if (typeof exports !== 'undefined') { + Backbone = exports; + } else { + Backbone = root.Backbone = {}; + } + + // Current version of the library. Keep in sync with `package.json`. + Backbone.VERSION = '1.1.0'; + + // Require Underscore, if we're on the server, and it's not already present. + var _ = root._; + if (!_ && (typeof require !== 'undefined')) _ = require('underscore'); + + // For Backbone's purposes, jQuery, Zepto, Ender, or My Library (kidding) owns + // the `$` variable. + Backbone.$ = root.jQuery || root.Zepto || root.ender || root.$; + + // Runs Backbone.js in *noConflict* mode, returning the `Backbone` variable + // to its previous owner. Returns a reference to this Backbone object. + Backbone.noConflict = function() { + root.Backbone = previousBackbone; + return this; + }; + + // Turn on `emulateHTTP` to support legacy HTTP servers. Setting this option + // will fake `"PATCH"`, `"PUT"` and `"DELETE"` requests via the `_method` parameter and + // set a `X-Http-Method-Override` header. + Backbone.emulateHTTP = false; + + // Turn on `emulateJSON` to support legacy servers that can't deal with direct + // `application/json` requests ... will encode the body as + // `application/x-www-form-urlencoded` instead and will send the model in a + // form param named `model`. + Backbone.emulateJSON = false; + + // Backbone.Events + // --------------- + + // A module that can be mixed in to *any object* in order to provide it with + // custom events. You may bind with `on` or remove with `off` callback + // functions to an event; `trigger`-ing an event fires all callbacks in + // succession. + // + // var object = {}; + // _.extend(object, Backbone.Events); + // object.on('expand', function(){ alert('expanded'); }); + // object.trigger('expand'); + // + var Events = Backbone.Events = { + + // Bind an event to a `callback` function. Passing `"all"` will bind + // the callback to all events fired. + on: function(name, callback, context) { + if (!eventsApi(this, 'on', name, [callback, context]) || !callback) return this; + this._events || (this._events = {}); + var events = this._events[name] || (this._events[name] = []); + events.push({callback: callback, context: context, ctx: context || this}); + return this; + }, + + // Bind an event to only be triggered a single time. After the first time + // the callback is invoked, it will be removed. + once: function(name, callback, context) { + if (!eventsApi(this, 'once', name, [callback, context]) || !callback) return this; + var self = this; + var once = _.once(function() { + self.off(name, once); + callback.apply(this, arguments); + }); + once._callback = callback; + return this.on(name, once, context); + }, + + // Remove one or many callbacks. If `context` is null, removes all + // callbacks with that function. If `callback` is null, removes all + // callbacks for the event. If `name` is null, removes all bound + // callbacks for all events. + off: function(name, callback, context) { + var retain, ev, events, names, i, l, j, k; + if (!this._events || !eventsApi(this, 'off', name, [callback, context])) return this; + if (!name && !callback && !context) { + this._events = {}; + return this; + } + names = name ? [name] : _.keys(this._events); + for (i = 0, l = names.length; i < l; i++) { + name = names[i]; + if (events = this._events[name]) { + this._events[name] = retain = []; + if (callback || context) { + for (j = 0, k = events.length; j < k; j++) { + ev = events[j]; + if ((callback && callback !== ev.callback && callback !== ev.callback._callback) || + (context && context !== ev.context)) { + retain.push(ev); + } + } + } + if (!retain.length) delete this._events[name]; + } + } + + return this; + }, + + // Trigger one or many events, firing all bound callbacks. Callbacks are + // passed the same arguments as `trigger` is, apart from the event name + // (unless you're listening on `"all"`, which will cause your callback to + // receive the true name of the event as the first argument). + trigger: function(name) { + if (!this._events) return this; + var args = slice.call(arguments, 1); + if (!eventsApi(this, 'trigger', name, args)) return this; + var events = this._events[name]; + var allEvents = this._events.all; + if (events) triggerEvents(events, args); + if (allEvents) triggerEvents(allEvents, arguments); + return this; + }, + + // Tell this object to stop listening to either specific events ... or + // to every object it's currently listening to. + stopListening: function(obj, name, callback) { + var listeningTo = this._listeningTo; + if (!listeningTo) return this; + var remove = !name && !callback; + if (!callback && typeof name === 'object') callback = this; + if (obj) (listeningTo = {})[obj._listenId] = obj; + for (var id in listeningTo) { + obj = listeningTo[id]; + obj.off(name, callback, this); + if (remove || _.isEmpty(obj._events)) delete this._listeningTo[id]; + } + return this; + } + + }; + + // Regular expression used to split event strings. + var eventSplitter = /\s+/; + + // Implement fancy features of the Events API such as multiple event + // names `"change blur"` and jQuery-style event maps `{change: action}` + // in terms of the existing API. + var eventsApi = function(obj, action, name, rest) { + if (!name) return true; + + // Handle event maps. + if (typeof name === 'object') { + for (var key in name) { + obj[action].apply(obj, [key, name[key]].concat(rest)); + } + return false; + } + + // Handle space separated event names. + if (eventSplitter.test(name)) { + var names = name.split(eventSplitter); + for (var i = 0, l = names.length; i < l; i++) { + obj[action].apply(obj, [names[i]].concat(rest)); + } + return false; + } + + return true; + }; + + // A difficult-to-believe, but optimized internal dispatch function for + // triggering events. Tries to keep the usual cases speedy (most internal + // Backbone events have 3 arguments). + var triggerEvents = function(events, args) { + var ev, i = -1, l = events.length, a1 = args[0], a2 = args[1], a3 = args[2]; + switch (args.length) { + case 0: while (++i < l) (ev = events[i]).callback.call(ev.ctx); return; + case 1: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1); return; + case 2: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1, a2); return; + case 3: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1, a2, a3); return; + default: while (++i < l) (ev = events[i]).callback.apply(ev.ctx, args); + } + }; + + var listenMethods = {listenTo: 'on', listenToOnce: 'once'}; + + // Inversion-of-control versions of `on` and `once`. Tell *this* object to + // listen to an event in another object ... keeping track of what it's + // listening to. + _.each(listenMethods, function(implementation, method) { + Events[method] = function(obj, name, callback) { + var listeningTo = this._listeningTo || (this._listeningTo = {}); + var id = obj._listenId || (obj._listenId = _.uniqueId('l')); + listeningTo[id] = obj; + if (!callback && typeof name === 'object') callback = this; + obj[implementation](name, callback, this); + return this; + }; + }); + + // Aliases for backwards compatibility. + Events.bind = Events.on; + Events.unbind = Events.off; + + // Allow the `Backbone` object to serve as a global event bus, for folks who + // want global "pubsub" in a convenient place. + _.extend(Backbone, Events); + + // Backbone.Model + // -------------- + + // Backbone **Models** are the basic data object in the framework -- + // frequently representing a row in a table in a database on your server. + // A discrete chunk of data and a bunch of useful, related methods for + // performing computations and transformations on that data. + + // Create a new model with the specified attributes. A client id (`cid`) + // is automatically generated and assigned for you. + var Model = Backbone.Model = function(attributes, options) { + var attrs = attributes || {}; + options || (options = {}); + this.cid = _.uniqueId('c'); + this.attributes = {}; + if (options.collection) this.collection = options.collection; + if (options.parse) attrs = this.parse(attrs, options) || {}; + attrs = _.defaults({}, attrs, _.result(this, 'defaults')); + this.set(attrs, options); + this.changed = {}; + this.initialize.apply(this, arguments); + }; + + // Attach all inheritable methods to the Model prototype. + _.extend(Model.prototype, Events, { + + // A hash of attributes whose current and previous value differ. + changed: null, + + // The value returned during the last failed validation. + validationError: null, + + // The default name for the JSON `id` attribute is `"id"`. MongoDB and + // CouchDB users may want to set this to `"_id"`. + idAttribute: 'id', + + // Initialize is an empty function by default. Override it with your own + // initialization logic. + initialize: function(){}, + + // Return a copy of the model's `attributes` object. + toJSON: function(options) { + return _.clone(this.attributes); + }, + + // Proxy `Backbone.sync` by default -- but override this if you need + // custom syncing semantics for *this* particular model. + sync: function() { + return Backbone.sync.apply(this, arguments); + }, + + // Get the value of an attribute. + get: function(attr) { + return this.attributes[attr]; + }, + + // Get the HTML-escaped value of an attribute. + escape: function(attr) { + return _.escape(this.get(attr)); + }, + + // Returns `true` if the attribute contains a value that is not null + // or undefined. + has: function(attr) { + return this.get(attr) != null; + }, + + // Set a hash of model attributes on the object, firing `"change"`. This is + // the core primitive operation of a model, updating the data and notifying + // anyone who needs to know about the change in state. The heart of the beast. + set: function(key, val, options) { + var attr, attrs, unset, changes, silent, changing, prev, current; + if (key == null) return this; + + // Handle both `"key", value` and `{key: value}` -style arguments. + if (typeof key === 'object') { + attrs = key; + options = val; + } else { + (attrs = {})[key] = val; + } + + options || (options = {}); + + // Run validation. + if (!this._validate(attrs, options)) return false; + + // Extract attributes and options. + unset = options.unset; + silent = options.silent; + changes = []; + changing = this._changing; + this._changing = true; + + if (!changing) { + this._previousAttributes = _.clone(this.attributes); + this.changed = {}; + } + current = this.attributes, prev = this._previousAttributes; + + // Check for changes of `id`. + if (this.idAttribute in attrs) this.id = attrs[this.idAttribute]; + + // For each `set` attribute, update or delete the current value. + for (attr in attrs) { + val = attrs[attr]; + if (!_.isEqual(current[attr], val)) changes.push(attr); + if (!_.isEqual(prev[attr], val)) { + this.changed[attr] = val; + } else { + delete this.changed[attr]; + } + unset ? delete current[attr] : current[attr] = val; + } + + // Trigger all relevant attribute changes. + if (!silent) { + if (changes.length) this._pending = true; + for (var i = 0, l = changes.length; i < l; i++) { + this.trigger('change:' + changes[i], this, current[changes[i]], options); + } + } + + // You might be wondering why there's a `while` loop here. Changes can + // be recursively nested within `"change"` events. + if (changing) return this; + if (!silent) { + while (this._pending) { + this._pending = false; + this.trigger('change', this, options); + } + } + this._pending = false; + this._changing = false; + return this; + }, + + // Remove an attribute from the model, firing `"change"`. `unset` is a noop + // if the attribute doesn't exist. + unset: function(attr, options) { + return this.set(attr, void 0, _.extend({}, options, {unset: true})); + }, + + // Clear all attributes on the model, firing `"change"`. + clear: function(options) { + var attrs = {}; + for (var key in this.attributes) attrs[key] = void 0; + return this.set(attrs, _.extend({}, options, {unset: true})); + }, + + // Determine if the model has changed since the last `"change"` event. + // If you specify an attribute name, determine if that attribute has changed. + hasChanged: function(attr) { + if (attr == null) return !_.isEmpty(this.changed); + return _.has(this.changed, attr); + }, + + // Return an object containing all the attributes that have changed, or + // false if there are no changed attributes. Useful for determining what + // parts of a view need to be updated and/or what attributes need to be + // persisted to the server. Unset attributes will be set to undefined. + // You can also pass an attributes object to diff against the model, + // determining if there *would be* a change. + changedAttributes: function(diff) { + if (!diff) return this.hasChanged() ? _.clone(this.changed) : false; + var val, changed = false; + var old = this._changing ? this._previousAttributes : this.attributes; + for (var attr in diff) { + if (_.isEqual(old[attr], (val = diff[attr]))) continue; + (changed || (changed = {}))[attr] = val; + } + return changed; + }, + + // Get the previous value of an attribute, recorded at the time the last + // `"change"` event was fired. + previous: function(attr) { + if (attr == null || !this._previousAttributes) return null; + return this._previousAttributes[attr]; + }, + + // Get all of the attributes of the model at the time of the previous + // `"change"` event. + previousAttributes: function() { + return _.clone(this._previousAttributes); + }, + + // Fetch the model from the server. If the server's representation of the + // model differs from its current attributes, they will be overridden, + // triggering a `"change"` event. + fetch: function(options) { + options = options ? _.clone(options) : {}; + if (options.parse === void 0) options.parse = true; + var model = this; + var success = options.success; + options.success = function(resp) { + if (!model.set(model.parse(resp, options), options)) return false; + if (success) success(model, resp, options); + model.trigger('sync', model, resp, options); + }; + wrapError(this, options); + return this.sync('read', this, options); + }, + + // Set a hash of model attributes, and sync the model to the server. + // If the server returns an attributes hash that differs, the model's + // state will be `set` again. + save: function(key, val, options) { + var attrs, method, xhr, attributes = this.attributes; + + // Handle both `"key", value` and `{key: value}` -style arguments. + if (key == null || typeof key === 'object') { + attrs = key; + options = val; + } else { + (attrs = {})[key] = val; + } + + options = _.extend({validate: true}, options); + + // If we're not waiting and attributes exist, save acts as + // `set(attr).save(null, opts)` with validation. Otherwise, check if + // the model will be valid when the attributes, if any, are set. + if (attrs && !options.wait) { + if (!this.set(attrs, options)) return false; + } else { + if (!this._validate(attrs, options)) return false; + } + + // Set temporary attributes if `{wait: true}`. + if (attrs && options.wait) { + this.attributes = _.extend({}, attributes, attrs); + } + + // After a successful server-side save, the client is (optionally) + // updated with the server-side state. + if (options.parse === void 0) options.parse = true; + var model = this; + var success = options.success; + options.success = function(resp) { + // Ensure attributes are restored during synchronous saves. + model.attributes = attributes; + var serverAttrs = model.parse(resp, options); + if (options.wait) serverAttrs = _.extend(attrs || {}, serverAttrs); + if (_.isObject(serverAttrs) && !model.set(serverAttrs, options)) { + return false; + } + if (success) success(model, resp, options); + model.trigger('sync', model, resp, options); + }; + wrapError(this, options); + + method = this.isNew() ? 'create' : (options.patch ? 'patch' : 'update'); + if (method === 'patch') options.attrs = attrs; + xhr = this.sync(method, this, options); + + // Restore attributes. + if (attrs && options.wait) this.attributes = attributes; + + return xhr; + }, + + // Destroy this model on the server if it was already persisted. + // Optimistically removes the model from its collection, if it has one. + // If `wait: true` is passed, waits for the server to respond before removal. + destroy: function(options) { + options = options ? _.clone(options) : {}; + var model = this; + var success = options.success; + + var destroy = function() { + model.trigger('destroy', model, model.collection, options); + }; + + options.success = function(resp) { + if (options.wait || model.isNew()) destroy(); + if (success) success(model, resp, options); + if (!model.isNew()) model.trigger('sync', model, resp, options); + }; + + if (this.isNew()) { + options.success(); + return false; + } + wrapError(this, options); + + var xhr = this.sync('delete', this, options); + if (!options.wait) destroy(); + return xhr; + }, + + // Default URL for the model's representation on the server -- if you're + // using Backbone's restful methods, override this to change the endpoint + // that will be called. + url: function() { + var base = _.result(this, 'urlRoot') || _.result(this.collection, 'url') || urlError(); + if (this.isNew()) return base; + return base + (base.charAt(base.length - 1) === '/' ? '' : '/') + encodeURIComponent(this.id); + }, + + // **parse** converts a response into the hash of attributes to be `set` on + // the model. The default implementation is just to pass the response along. + parse: function(resp, options) { + return resp; + }, + + // Create a new model with identical attributes to this one. + clone: function() { + return new this.constructor(this.attributes); + }, + + // A model is new if it has never been saved to the server, and lacks an id. + isNew: function() { + return this.id == null; + }, + + // Check if the model is currently in a valid state. + isValid: function(options) { + return this._validate({}, _.extend(options || {}, { validate: true })); + }, + + // Run validation against the next complete set of model attributes, + // returning `true` if all is well. Otherwise, fire an `"invalid"` event. + _validate: function(attrs, options) { + if (!options.validate || !this.validate) return true; + attrs = _.extend({}, this.attributes, attrs); + var error = this.validationError = this.validate(attrs, options) || null; + if (!error) return true; + this.trigger('invalid', this, error, _.extend(options, {validationError: error})); + return false; + } + + }); + + // Underscore methods that we want to implement on the Model. + var modelMethods = ['keys', 'values', 'pairs', 'invert', 'pick', 'omit']; + + // Mix in each Underscore method as a proxy to `Model#attributes`. + _.each(modelMethods, function(method) { + Model.prototype[method] = function() { + var args = slice.call(arguments); + args.unshift(this.attributes); + return _[method].apply(_, args); + }; + }); + + // Backbone.Collection + // ------------------- + + // If models tend to represent a single row of data, a Backbone Collection is + // more analagous to a table full of data ... or a small slice or page of that + // table, or a collection of rows that belong together for a particular reason + // -- all of the messages in this particular folder, all of the documents + // belonging to this particular author, and so on. Collections maintain + // indexes of their models, both in order, and for lookup by `id`. + + // Create a new **Collection**, perhaps to contain a specific type of `model`. + // If a `comparator` is specified, the Collection will maintain + // its models in sort order, as they're added and removed. + var Collection = Backbone.Collection = function(models, options) { + options || (options = {}); + if (options.model) this.model = options.model; + if (options.comparator !== void 0) this.comparator = options.comparator; + this._reset(); + this.initialize.apply(this, arguments); + if (models) this.reset(models, _.extend({silent: true}, options)); + }; + + // Default options for `Collection#set`. + var setOptions = {add: true, remove: true, merge: true}; + var addOptions = {add: true, remove: false}; + + // Define the Collection's inheritable methods. + _.extend(Collection.prototype, Events, { + + // The default model for a collection is just a **Backbone.Model**. + // This should be overridden in most cases. + model: Model, + + // Initialize is an empty function by default. Override it with your own + // initialization logic. + initialize: function(){}, + + // The JSON representation of a Collection is an array of the + // models' attributes. + toJSON: function(options) { + return this.map(function(model){ return model.toJSON(options); }); + }, + + // Proxy `Backbone.sync` by default. + sync: function() { + return Backbone.sync.apply(this, arguments); + }, + + // Add a model, or list of models to the set. + add: function(models, options) { + return this.set(models, _.extend({merge: false}, options, addOptions)); + }, + + // Remove a model, or a list of models from the set. + remove: function(models, options) { + var singular = !_.isArray(models); + models = singular ? [models] : _.clone(models); + options || (options = {}); + var i, l, index, model; + for (i = 0, l = models.length; i < l; i++) { + model = models[i] = this.get(models[i]); + if (!model) continue; + delete this._byId[model.id]; + delete this._byId[model.cid]; + index = this.indexOf(model); + this.models.splice(index, 1); + this.length--; + if (!options.silent) { + options.index = index; + model.trigger('remove', model, this, options); + } + this._removeReference(model); + } + return singular ? models[0] : models; + }, + + // Update a collection by `set`-ing a new list of models, adding new ones, + // removing models that are no longer present, and merging models that + // already exist in the collection, as necessary. Similar to **Model#set**, + // the core operation for updating the data contained by the collection. + set: function(models, options) { + options = _.defaults({}, options, setOptions); + if (options.parse) models = this.parse(models, options); + var singular = !_.isArray(models); + models = singular ? (models ? [models] : []) : _.clone(models); + var i, l, id, model, attrs, existing, sort; + var at = options.at; + var targetModel = this.model; + var sortable = this.comparator && (at == null) && options.sort !== false; + var sortAttr = _.isString(this.comparator) ? this.comparator : null; + var toAdd = [], toRemove = [], modelMap = {}; + var add = options.add, merge = options.merge, remove = options.remove; + var order = !sortable && add && remove ? [] : false; + + // Turn bare objects into model references, and prevent invalid models + // from being added. + for (i = 0, l = models.length; i < l; i++) { + attrs = models[i]; + if (attrs instanceof Model) { + id = model = attrs; + } else { + id = attrs[targetModel.prototype.idAttribute]; + } + + // If a duplicate is found, prevent it from being added and + // optionally merge it into the existing model. + if (existing = this.get(id)) { + if (remove) modelMap[existing.cid] = true; + if (merge) { + attrs = attrs === model ? model.attributes : attrs; + if (options.parse) attrs = existing.parse(attrs, options); + existing.set(attrs, options); + if (sortable && !sort && existing.hasChanged(sortAttr)) sort = true; + } + models[i] = existing; + + // If this is a new, valid model, push it to the `toAdd` list. + } else if (add) { + model = models[i] = this._prepareModel(attrs, options); + if (!model) continue; + toAdd.push(model); + + // Listen to added models' events, and index models for lookup by + // `id` and by `cid`. + model.on('all', this._onModelEvent, this); + this._byId[model.cid] = model; + if (model.id != null) this._byId[model.id] = model; + } + if (order) order.push(existing || model); + } + + // Remove nonexistent models if appropriate. + if (remove) { + for (i = 0, l = this.length; i < l; ++i) { + if (!modelMap[(model = this.models[i]).cid]) toRemove.push(model); + } + if (toRemove.length) this.remove(toRemove, options); + } + + // See if sorting is needed, update `length` and splice in new models. + if (toAdd.length || (order && order.length)) { + if (sortable) sort = true; + this.length += toAdd.length; + if (at != null) { + for (i = 0, l = toAdd.length; i < l; i++) { + this.models.splice(at + i, 0, toAdd[i]); + } + } else { + if (order) this.models.length = 0; + var orderedModels = order || toAdd; + for (i = 0, l = orderedModels.length; i < l; i++) { + this.models.push(orderedModels[i]); + } + } + } + + // Silently sort the collection if appropriate. + if (sort) this.sort({silent: true}); + + // Unless silenced, it's time to fire all appropriate add/sort events. + if (!options.silent) { + for (i = 0, l = toAdd.length; i < l; i++) { + (model = toAdd[i]).trigger('add', model, this, options); + } + if (sort || (order && order.length)) this.trigger('sort', this, options); + } + + // Return the added (or merged) model (or models). + return singular ? models[0] : models; + }, + + // When you have more items than you want to add or remove individually, + // you can reset the entire set with a new list of models, without firing + // any granular `add` or `remove` events. Fires `reset` when finished. + // Useful for bulk operations and optimizations. + reset: function(models, options) { + options || (options = {}); + for (var i = 0, l = this.models.length; i < l; i++) { + this._removeReference(this.models[i]); + } + options.previousModels = this.models; + this._reset(); + models = this.add(models, _.extend({silent: true}, options)); + if (!options.silent) this.trigger('reset', this, options); + return models; + }, + + // Add a model to the end of the collection. + push: function(model, options) { + return this.add(model, _.extend({at: this.length}, options)); + }, + + // Remove a model from the end of the collection. + pop: function(options) { + var model = this.at(this.length - 1); + this.remove(model, options); + return model; + }, + + // Add a model to the beginning of the collection. + unshift: function(model, options) { + return this.add(model, _.extend({at: 0}, options)); + }, + + // Remove a model from the beginning of the collection. + shift: function(options) { + var model = this.at(0); + this.remove(model, options); + return model; + }, + + // Slice out a sub-array of models from the collection. + slice: function() { + return slice.apply(this.models, arguments); + }, + + // Get a model from the set by id. + get: function(obj) { + if (obj == null) return void 0; + return this._byId[obj.id] || this._byId[obj.cid] || this._byId[obj]; + }, + + // Get the model at the given index. + at: function(index) { + return this.models[index]; + }, + + // Return models with matching attributes. Useful for simple cases of + // `filter`. + where: function(attrs, first) { + if (_.isEmpty(attrs)) return first ? void 0 : []; + return this[first ? 'find' : 'filter'](function(model) { + for (var key in attrs) { + if (attrs[key] !== model.get(key)) return false; + } + return true; + }); + }, + + // Return the first model with matching attributes. Useful for simple cases + // of `find`. + findWhere: function(attrs) { + return this.where(attrs, true); + }, + + // Force the collection to re-sort itself. You don't need to call this under + // normal circumstances, as the set will maintain sort order as each item + // is added. + sort: function(options) { + if (!this.comparator) throw new Error('Cannot sort a set without a comparator'); + options || (options = {}); + + // Run sort based on type of `comparator`. + if (_.isString(this.comparator) || this.comparator.length === 1) { + this.models = this.sortBy(this.comparator, this); + } else { + this.models.sort(_.bind(this.comparator, this)); + } + + if (!options.silent) this.trigger('sort', this, options); + return this; + }, + + // Pluck an attribute from each model in the collection. + pluck: function(attr) { + return _.invoke(this.models, 'get', attr); + }, + + // Fetch the default set of models for this collection, resetting the + // collection when they arrive. If `reset: true` is passed, the response + // data will be passed through the `reset` method instead of `set`. + fetch: function(options) { + options = options ? _.clone(options) : {}; + if (options.parse === void 0) options.parse = true; + var success = options.success; + var collection = this; + options.success = function(resp) { + var method = options.reset ? 'reset' : 'set'; + collection[method](resp, options); + if (success) success(collection, resp, options); + collection.trigger('sync', collection, resp, options); + }; + wrapError(this, options); + return this.sync('read', this, options); + }, + + // Create a new instance of a model in this collection. Add the model to the + // collection immediately, unless `wait: true` is passed, in which case we + // wait for the server to agree. + create: function(model, options) { + options = options ? _.clone(options) : {}; + if (!(model = this._prepareModel(model, options))) return false; + if (!options.wait) this.add(model, options); + var collection = this; + var success = options.success; + options.success = function(model, resp, options) { + if (options.wait) collection.add(model, options); + if (success) success(model, resp, options); + }; + model.save(null, options); + return model; + }, + + // **parse** converts a response into a list of models to be added to the + // collection. The default implementation is just to pass it through. + parse: function(resp, options) { + return resp; + }, + + // Create a new collection with an identical list of models as this one. + clone: function() { + return new this.constructor(this.models); + }, + + // Private method to reset all internal state. Called when the collection + // is first initialized or reset. + _reset: function() { + this.length = 0; + this.models = []; + this._byId = {}; + }, + + // Prepare a hash of attributes (or other model) to be added to this + // collection. + _prepareModel: function(attrs, options) { + if (attrs instanceof Model) { + if (!attrs.collection) attrs.collection = this; + return attrs; + } + options = options ? _.clone(options) : {}; + options.collection = this; + var model = new this.model(attrs, options); + if (!model.validationError) return model; + this.trigger('invalid', this, model.validationError, options); + return false; + }, + + // Internal method to sever a model's ties to a collection. + _removeReference: function(model) { + if (this === model.collection) delete model.collection; + model.off('all', this._onModelEvent, this); + }, + + // Internal method called every time a model in the set fires an event. + // Sets need to update their indexes when models change ids. All other + // events simply proxy through. "add" and "remove" events that originate + // in other collections are ignored. + _onModelEvent: function(event, model, collection, options) { + if ((event === 'add' || event === 'remove') && collection !== this) return; + if (event === 'destroy') this.remove(model, options); + if (model && event === 'change:' + model.idAttribute) { + delete this._byId[model.previous(model.idAttribute)]; + if (model.id != null) this._byId[model.id] = model; + } + this.trigger.apply(this, arguments); + } + + }); + + // Underscore methods that we want to implement on the Collection. + // 90% of the core usefulness of Backbone Collections is actually implemented + // right here: + var methods = ['forEach', 'each', 'map', 'collect', 'reduce', 'foldl', + 'inject', 'reduceRight', 'foldr', 'find', 'detect', 'filter', 'select', + 'reject', 'every', 'all', 'some', 'any', 'include', 'contains', 'invoke', + 'max', 'min', 'toArray', 'size', 'first', 'head', 'take', 'initial', 'rest', + 'tail', 'drop', 'last', 'without', 'difference', 'indexOf', 'shuffle', + 'lastIndexOf', 'isEmpty', 'chain']; + + // Mix in each Underscore method as a proxy to `Collection#models`. + _.each(methods, function(method) { + Collection.prototype[method] = function() { + var args = slice.call(arguments); + args.unshift(this.models); + return _[method].apply(_, args); + }; + }); + + // Underscore methods that take a property name as an argument. + var attributeMethods = ['groupBy', 'countBy', 'sortBy']; + + // Use attributes instead of properties. + _.each(attributeMethods, function(method) { + Collection.prototype[method] = function(value, context) { + var iterator = _.isFunction(value) ? value : function(model) { + return model.get(value); + }; + return _[method](this.models, iterator, context); + }; + }); + + // Backbone.View + // ------------- + + // Backbone Views are almost more convention than they are actual code. A View + // is simply a JavaScript object that represents a logical chunk of UI in the + // DOM. This might be a single item, an entire list, a sidebar or panel, or + // even the surrounding frame which wraps your whole app. Defining a chunk of + // UI as a **View** allows you to define your DOM events declaratively, without + // having to worry about render order ... and makes it easy for the view to + // react to specific changes in the state of your models. + + // Creating a Backbone.View creates its initial element outside of the DOM, + // if an existing element is not provided... + var View = Backbone.View = function(options) { + this.cid = _.uniqueId('view'); + options || (options = {}); + _.extend(this, _.pick(options, viewOptions)); + this._ensureElement(); + this.initialize.apply(this, arguments); + this.delegateEvents(); + }; + + // Cached regex to split keys for `delegate`. + var delegateEventSplitter = /^(\S+)\s*(.*)$/; + + // List of view options to be merged as properties. + var viewOptions = ['model', 'collection', 'el', 'id', 'attributes', 'className', 'tagName', 'events']; + + // Set up all inheritable **Backbone.View** properties and methods. + _.extend(View.prototype, Events, { + + // The default `tagName` of a View's element is `"div"`. + tagName: 'div', + + // jQuery delegate for element lookup, scoped to DOM elements within the + // current view. This should be preferred to global lookups where possible. + $: function(selector) { + return this.$el.find(selector); + }, + + // Initialize is an empty function by default. Override it with your own + // initialization logic. + initialize: function(){}, + + // **render** is the core function that your view should override, in order + // to populate its element (`this.el`), with the appropriate HTML. The + // convention is for **render** to always return `this`. + render: function() { + return this; + }, + + // Remove this view by taking the element out of the DOM, and removing any + // applicable Backbone.Events listeners. + remove: function() { + this.$el.remove(); + this.stopListening(); + return this; + }, + + // Change the view's element (`this.el` property), including event + // re-delegation. + setElement: function(element, delegate) { + if (this.$el) this.undelegateEvents(); + this.$el = element instanceof Backbone.$ ? element : Backbone.$(element); + this.el = this.$el[0]; + if (delegate !== false) this.delegateEvents(); + return this; + }, + + // Set callbacks, where `this.events` is a hash of + // + // *{"event selector": "callback"}* + // + // { + // 'mousedown .title': 'edit', + // 'click .button': 'save', + // 'click .open': function(e) { ... } + // } + // + // pairs. Callbacks will be bound to the view, with `this` set properly. + // Uses event delegation for efficiency. + // Omitting the selector binds the event to `this.el`. + // This only works for delegate-able events: not `focus`, `blur`, and + // not `change`, `submit`, and `reset` in Internet Explorer. + delegateEvents: function(events) { + if (!(events || (events = _.result(this, 'events')))) return this; + this.undelegateEvents(); + for (var key in events) { + var method = events[key]; + if (!_.isFunction(method)) method = this[events[key]]; + if (!method) continue; + + var match = key.match(delegateEventSplitter); + var eventName = match[1], selector = match[2]; + method = _.bind(method, this); + eventName += '.delegateEvents' + this.cid; + if (selector === '') { + this.$el.on(eventName, method); + } else { + this.$el.on(eventName, selector, method); + } + } + return this; + }, + + // Clears all callbacks previously bound to the view with `delegateEvents`. + // You usually don't need to use this, but may wish to if you have multiple + // Backbone views attached to the same DOM element. + undelegateEvents: function() { + this.$el.off('.delegateEvents' + this.cid); + return this; + }, + + // Ensure that the View has a DOM element to render into. + // If `this.el` is a string, pass it through `$()`, take the first + // matching element, and re-assign it to `el`. Otherwise, create + // an element from the `id`, `className` and `tagName` properties. + _ensureElement: function() { + if (!this.el) { + var attrs = _.extend({}, _.result(this, 'attributes')); + if (this.id) attrs.id = _.result(this, 'id'); + if (this.className) attrs['class'] = _.result(this, 'className'); + var $el = Backbone.$('<' + _.result(this, 'tagName') + '>').attr(attrs); + this.setElement($el, false); + } else { + this.setElement(_.result(this, 'el'), false); + } + } + + }); + + // Backbone.sync + // ------------- + + // Override this function to change the manner in which Backbone persists + // models to the server. You will be passed the type of request, and the + // model in question. By default, makes a RESTful Ajax request + // to the model's `url()`. Some possible customizations could be: + // + // * Use `setTimeout` to batch rapid-fire updates into a single request. + // * Send up the models as XML instead of JSON. + // * Persist models via WebSockets instead of Ajax. + // + // Turn on `Backbone.emulateHTTP` in order to send `PUT` and `DELETE` requests + // as `POST`, with a `_method` parameter containing the true HTTP method, + // as well as all requests with the body as `application/x-www-form-urlencoded` + // instead of `application/json` with the model in a param named `model`. + // Useful when interfacing with server-side languages like **PHP** that make + // it difficult to read the body of `PUT` requests. + Backbone.sync = function(method, model, options) { + var type = methodMap[method]; + + // Default options, unless specified. + _.defaults(options || (options = {}), { + emulateHTTP: Backbone.emulateHTTP, + emulateJSON: Backbone.emulateJSON + }); + + // Default JSON-request options. + var params = {type: type, dataType: 'json'}; + + // Ensure that we have a URL. + if (!options.url) { + params.url = _.result(model, 'url') || urlError(); + } + + // Ensure that we have the appropriate request data. + if (options.data == null && model && (method === 'create' || method === 'update' || method === 'patch')) { + params.contentType = 'application/json'; + params.data = JSON.stringify(options.attrs || model.toJSON(options)); + } + + // For older servers, emulate JSON by encoding the request into an HTML-form. + if (options.emulateJSON) { + params.contentType = 'application/x-www-form-urlencoded'; + params.data = params.data ? {model: params.data} : {}; + } + + // For older servers, emulate HTTP by mimicking the HTTP method with `_method` + // And an `X-HTTP-Method-Override` header. + if (options.emulateHTTP && (type === 'PUT' || type === 'DELETE' || type === 'PATCH')) { + params.type = 'POST'; + if (options.emulateJSON) params.data._method = type; + var beforeSend = options.beforeSend; + options.beforeSend = function(xhr) { + xhr.setRequestHeader('X-HTTP-Method-Override', type); + if (beforeSend) return beforeSend.apply(this, arguments); + }; + } + + // Don't process data on a non-GET request. + if (params.type !== 'GET' && !options.emulateJSON) { + params.processData = false; + } + + // If we're sending a `PATCH` request, and we're in an old Internet Explorer + // that still has ActiveX enabled by default, override jQuery to use that + // for XHR instead. Remove this line when jQuery supports `PATCH` on IE8. + if (params.type === 'PATCH' && noXhrPatch) { + params.xhr = function() { + return new ActiveXObject("Microsoft.XMLHTTP"); + }; + } + + // Make the request, allowing the user to override any Ajax options. + var xhr = options.xhr = Backbone.ajax(_.extend(params, options)); + model.trigger('request', model, xhr, options); + return xhr; + }; + + var noXhrPatch = typeof window !== 'undefined' && !!window.ActiveXObject && !(window.XMLHttpRequest && (new XMLHttpRequest).dispatchEvent); + + // Map from CRUD to HTTP for our default `Backbone.sync` implementation. + var methodMap = { + 'create': 'POST', + 'update': 'PUT', + 'patch': 'PATCH', + 'delete': 'DELETE', + 'read': 'GET' + }; + + // Set the default implementation of `Backbone.ajax` to proxy through to `$`. + // Override this if you'd like to use a different library. + Backbone.ajax = function() { + return Backbone.$.ajax.apply(Backbone.$, arguments); + }; + + // Backbone.Router + // --------------- + + // Routers map faux-URLs to actions, and fire events when routes are + // matched. Creating a new one sets its `routes` hash, if not set statically. + var Router = Backbone.Router = function(options) { + options || (options = {}); + if (options.routes) this.routes = options.routes; + this._bindRoutes(); + this.initialize.apply(this, arguments); + }; + + // Cached regular expressions for matching named param parts and splatted + // parts of route strings. + var optionalParam = /\((.*?)\)/g; + var namedParam = /(\(\?)?:\w+/g; + var splatParam = /\*\w+/g; + var escapeRegExp = /[\-{}\[\]+?.,\\\^$|#\s]/g; + + // Set up all inheritable **Backbone.Router** properties and methods. + _.extend(Router.prototype, Events, { + + // Initialize is an empty function by default. Override it with your own + // initialization logic. + initialize: function(){}, + + // Manually bind a single named route to a callback. For example: + // + // this.route('search/:query/p:num', 'search', function(query, num) { + // ... + // }); + // + route: function(route, name, callback) { + if (!_.isRegExp(route)) route = this._routeToRegExp(route); + if (_.isFunction(name)) { + callback = name; + name = ''; + } + if (!callback) callback = this[name]; + var router = this; + Backbone.history.route(route, function(fragment) { + var args = router._extractParameters(route, fragment); + callback && callback.apply(router, args); + router.trigger.apply(router, ['route:' + name].concat(args)); + router.trigger('route', name, args); + Backbone.history.trigger('route', router, name, args); + }); + return this; + }, + + // Simple proxy to `Backbone.history` to save a fragment into the history. + navigate: function(fragment, options) { + Backbone.history.navigate(fragment, options); + return this; + }, + + // Bind all defined routes to `Backbone.history`. We have to reverse the + // order of the routes here to support behavior where the most general + // routes can be defined at the bottom of the route map. + _bindRoutes: function() { + if (!this.routes) return; + this.routes = _.result(this, 'routes'); + var route, routes = _.keys(this.routes); + while ((route = routes.pop()) != null) { + this.route(route, this.routes[route]); + } + }, + + // Convert a route string into a regular expression, suitable for matching + // against the current location hash. + _routeToRegExp: function(route) { + route = route.replace(escapeRegExp, '\\$&') + .replace(optionalParam, '(?:$1)?') + .replace(namedParam, function(match, optional) { + return optional ? match : '([^\/]+)'; + }) + .replace(splatParam, '(.*?)'); + return new RegExp('^' + route + '$'); + }, + + // Given a route, and a URL fragment that it matches, return the array of + // extracted decoded parameters. Empty or unmatched parameters will be + // treated as `null` to normalize cross-browser behavior. + _extractParameters: function(route, fragment) { + var params = route.exec(fragment).slice(1); + return _.map(params, function(param) { + return param ? decodeURIComponent(param) : null; + }); + } + + }); + + // Backbone.History + // ---------------- + + // Handles cross-browser history management, based on either + // [pushState](http://diveintohtml5.info/history.html) and real URLs, or + // [onhashchange](https://developer.mozilla.org/en-US/docs/DOM/window.onhashchange) + // and URL fragments. If the browser supports neither (old IE, natch), + // falls back to polling. + var History = Backbone.History = function() { + this.handlers = []; + _.bindAll(this, 'checkUrl'); + + // Ensure that `History` can be used outside of the browser. + if (typeof window !== 'undefined') { + this.location = window.location; + this.history = window.history; + } + }; + + // Cached regex for stripping a leading hash/slash and trailing space. + var routeStripper = /^[#\/]|\s+$/g; + + // Cached regex for stripping leading and trailing slashes. + var rootStripper = /^\/+|\/+$/g; + + // Cached regex for detecting MSIE. + var isExplorer = /msie [\w.]+/; + + // Cached regex for removing a trailing slash. + var trailingSlash = /\/$/; + + // Cached regex for stripping urls of hash and query. + var pathStripper = /[?#].*$/; + + // Has the history handling already been started? + History.started = false; + + // Set up all inheritable **Backbone.History** properties and methods. + _.extend(History.prototype, Events, { + + // The default interval to poll for hash changes, if necessary, is + // twenty times a second. + interval: 50, + + // Gets the true hash value. Cannot use location.hash directly due to bug + // in Firefox where location.hash will always be decoded. + getHash: function(window) { + var match = (window || this).location.href.match(/#(.*)$/); + return match ? match[1] : ''; + }, + + // Get the cross-browser normalized URL fragment, either from the URL, + // the hash, or the override. + getFragment: function(fragment, forcePushState) { + if (fragment == null) { + if (this._hasPushState || !this._wantsHashChange || forcePushState) { + fragment = this.location.pathname; + var root = this.root.replace(trailingSlash, ''); + if (!fragment.indexOf(root)) fragment = fragment.slice(root.length); + } else { + fragment = this.getHash(); + } + } + return fragment.replace(routeStripper, ''); + }, + + // Start the hash change handling, returning `true` if the current URL matches + // an existing route, and `false` otherwise. + start: function(options) { + if (History.started) throw new Error("Backbone.history has already been started"); + History.started = true; + + // Figure out the initial configuration. Do we need an iframe? + // Is pushState desired ... is it available? + this.options = _.extend({root: '/'}, this.options, options); + this.root = this.options.root; + this._wantsHashChange = this.options.hashChange !== false; + this._wantsPushState = !!this.options.pushState; + this._hasPushState = !!(this.options.pushState && this.history && this.history.pushState); + var fragment = this.getFragment(); + var docMode = document.documentMode; + var oldIE = (isExplorer.exec(navigator.userAgent.toLowerCase()) && (!docMode || docMode <= 7)); + + // Normalize root to always include a leading and trailing slash. + this.root = ('/' + this.root + '/').replace(rootStripper, '/'); + + if (oldIE && this._wantsHashChange) { + this.iframe = Backbone.$('<iframe src="javascript:0" tabindex="-1" />').hide().appendTo('body')[0].contentWindow; + this.navigate(fragment); + } + + // Depending on whether we're using pushState or hashes, and whether + // 'onhashchange' is supported, determine how we check the URL state. + if (this._hasPushState) { + Backbone.$(window).on('popstate', this.checkUrl); + } else if (this._wantsHashChange && ('onhashchange' in window) && !oldIE) { + Backbone.$(window).on('hashchange', this.checkUrl); + } else if (this._wantsHashChange) { + this._checkUrlInterval = setInterval(this.checkUrl, this.interval); + } + + // Determine if we need to change the base url, for a pushState link + // opened by a non-pushState browser. + this.fragment = fragment; + var loc = this.location; + var atRoot = loc.pathname.replace(/[^\/]$/, '$&/') === this.root; + + // Transition from hashChange to pushState or vice versa if both are + // requested. + if (this._wantsHashChange && this._wantsPushState) { + + // If we've started off with a route from a `pushState`-enabled + // browser, but we're currently in a browser that doesn't support it... + if (!this._hasPushState && !atRoot) { + this.fragment = this.getFragment(null, true); + this.location.replace(this.root + this.location.search + '#' + this.fragment); + // Return immediately as browser will do redirect to new url + return true; + + // Or if we've started out with a hash-based route, but we're currently + // in a browser where it could be `pushState`-based instead... + } else if (this._hasPushState && atRoot && loc.hash) { + this.fragment = this.getHash().replace(routeStripper, ''); + this.history.replaceState({}, document.title, this.root + this.fragment + loc.search); + } + + } + + if (!this.options.silent) return this.loadUrl(); + }, + + // Disable Backbone.history, perhaps temporarily. Not useful in a real app, + // but possibly useful for unit testing Routers. + stop: function() { + Backbone.$(window).off('popstate', this.checkUrl).off('hashchange', this.checkUrl); + clearInterval(this._checkUrlInterval); + History.started = false; + }, + + // Add a route to be tested when the fragment changes. Routes added later + // may override previous routes. + route: function(route, callback) { + this.handlers.unshift({route: route, callback: callback}); + }, + + // Checks the current URL to see if it has changed, and if it has, + // calls `loadUrl`, normalizing across the hidden iframe. + checkUrl: function(e) { + var current = this.getFragment(); + if (current === this.fragment && this.iframe) { + current = this.getFragment(this.getHash(this.iframe)); + } + if (current === this.fragment) return false; + if (this.iframe) this.navigate(current); + this.loadUrl(); + }, + + // Attempt to load the current URL fragment. If a route succeeds with a + // match, returns `true`. If no defined routes matches the fragment, + // returns `false`. + loadUrl: function(fragment) { + fragment = this.fragment = this.getFragment(fragment); + return _.any(this.handlers, function(handler) { + if (handler.route.test(fragment)) { + handler.callback(fragment); + return true; + } + }); + }, + + // Save a fragment into the hash history, or replace the URL state if the + // 'replace' option is passed. You are responsible for properly URL-encoding + // the fragment in advance. + // + // The options object can contain `trigger: true` if you wish to have the + // route callback be fired (not usually desirable), or `replace: true`, if + // you wish to modify the current URL without adding an entry to the history. + navigate: function(fragment, options) { + if (!History.started) return false; + if (!options || options === true) options = {trigger: !!options}; + + var url = this.root + (fragment = this.getFragment(fragment || '')); + + // Strip the fragment of the query and hash for matching. + fragment = fragment.replace(pathStripper, ''); + + if (this.fragment === fragment) return; + this.fragment = fragment; + + // Don't include a trailing slash on the root. + if (fragment === '' && url !== '/') url = url.slice(0, -1); + + // If pushState is available, we use it to set the fragment as a real URL. + if (this._hasPushState) { + this.history[options.replace ? 'replaceState' : 'pushState']({}, document.title, url); + + // If hash changes haven't been explicitly disabled, update the hash + // fragment to store history. + } else if (this._wantsHashChange) { + this._updateHash(this.location, fragment, options.replace); + if (this.iframe && (fragment !== this.getFragment(this.getHash(this.iframe)))) { + // Opening and closing the iframe tricks IE7 and earlier to push a + // history entry on hash-tag change. When replace is true, we don't + // want this. + if(!options.replace) this.iframe.document.open().close(); + this._updateHash(this.iframe.location, fragment, options.replace); + } + + // If you've told us that you explicitly don't want fallback hashchange- + // based history, then `navigate` becomes a page refresh. + } else { + return this.location.assign(url); + } + if (options.trigger) return this.loadUrl(fragment); + }, + + // Update the hash location, either replacing the current entry, or adding + // a new one to the browser history. + _updateHash: function(location, fragment, replace) { + if (replace) { + var href = location.href.replace(/(javascript:|#).*$/, ''); + location.replace(href + '#' + fragment); + } else { + // Some browsers require that `hash` contains a leading #. + location.hash = '#' + fragment; + } + } + + }); + + // Create the default Backbone.history. + Backbone.history = new History; + + // Helpers + // ------- + + // Helper function to correctly set up the prototype chain, for subclasses. + // Similar to `goog.inherits`, but uses a hash of prototype properties and + // class properties to be extended. + var extend = function(protoProps, staticProps) { + var parent = this; + var child; + + // The constructor function for the new subclass is either defined by you + // (the "constructor" property in your `extend` definition), or defaulted + // by us to simply call the parent's constructor. + if (protoProps && _.has(protoProps, 'constructor')) { + child = protoProps.constructor; + } else { + child = function(){ return parent.apply(this, arguments); }; + } + + // Add static properties to the constructor function, if supplied. + _.extend(child, parent, staticProps); + + // Set the prototype chain to inherit from `parent`, without calling + // `parent`'s constructor function. + var Surrogate = function(){ this.constructor = child; }; + Surrogate.prototype = parent.prototype; + child.prototype = new Surrogate; + + // Add prototype properties (instance properties) to the subclass, + // if supplied. + if (protoProps) _.extend(child.prototype, protoProps); + + // Set a convenience property in case the parent's prototype is needed + // later. + child.__super__ = parent.prototype; + + return child; + }; + + // Set up inheritance for the model, collection, router, view and history. + Model.extend = Collection.extend = Router.extend = View.extend = History.extend = extend; + + // Throw an error when a URL is needed, and none is supplied. + var urlError = function() { + throw new Error('A "url" property or function must be specified'); + }; + + // Wrap an optional error callback with a fallback error event. + var wrapError = function(model, options) { + var error = options.error; + options.error = function(resp) { + if (error) error(model, resp, options); + model.trigger('error', model, resp, options); + }; + }; + +}).call(this); diff --git a/addons/point_of_sale/static/lib/html2canvas.js b/addons/point_of_sale/static/lib/html2canvas.js new file mode 100644 index 00000000..4c228889 --- /dev/null +++ b/addons/point_of_sale/static/lib/html2canvas.js @@ -0,0 +1,2868 @@ +/* + html2canvas 0.4.1 <http://html2canvas.hertzen.com> + Copyright (c) 2013 Niklas von Hertzen + + Released under MIT License +*/ + +(function(window, document, undefined){ + + "use strict"; + + var _html2canvas = {}, + previousElement, + computedCSS, + html2canvas; + + _html2canvas.Util = {}; + + _html2canvas.Util.log = function(a) { + if (_html2canvas.logging && window.console && window.console.log) { + window.console.log(a); + } + }; + + _html2canvas.Util.trimText = (function(isNative){ + return function(input) { + return isNative ? isNative.apply(input) : ((input || '') + '').replace( /^\s+|\s+$/g , '' ); + }; + })(String.prototype.trim); + + _html2canvas.Util.asFloat = function(v) { + return parseFloat(v); + }; + + (function() { + // TODO: support all possible length values + var TEXT_SHADOW_PROPERTY = /((rgba|rgb)\([^\)]+\)(\s-?\d+px){0,})/g; + var TEXT_SHADOW_VALUES = /(-?\d+px)|(#.+)|(rgb\(.+\))|(rgba\(.+\))/g; + _html2canvas.Util.parseTextShadows = function (value) { + if (!value || value === 'none') { + return []; + } + + // find multiple shadow declarations + var shadows = value.match(TEXT_SHADOW_PROPERTY), + results = []; + for (var i = 0; shadows && (i < shadows.length); i++) { + var s = shadows[i].match(TEXT_SHADOW_VALUES); + results.push({ + color: s[0], + offsetX: s[1] ? s[1].replace('px', '') : 0, + offsetY: s[2] ? s[2].replace('px', '') : 0, + blur: s[3] ? s[3].replace('px', '') : 0 + }); + } + return results; + }; + })(); + + + _html2canvas.Util.parseBackgroundImage = function (value) { + var whitespace = ' \r\n\t', + method, definition, prefix, prefix_i, block, results = [], + c, mode = 0, numParen = 0, quote, args; + + var appendResult = function(){ + if(method) { + if(definition.substr( 0, 1 ) === '"') { + definition = definition.substr( 1, definition.length - 2 ); + } + if(definition) { + args.push(definition); + } + if(method.substr( 0, 1 ) === '-' && + (prefix_i = method.indexOf( '-', 1 ) + 1) > 0) { + prefix = method.substr( 0, prefix_i); + method = method.substr( prefix_i ); + } + results.push({ + prefix: prefix, + method: method.toLowerCase(), + value: block, + args: args + }); + } + args = []; //for some odd reason, setting .length = 0 didn't work in safari + method = + prefix = + definition = + block = ''; + }; + + appendResult(); + for(var i = 0, ii = value.length; i<ii; i++) { + c = value[i]; + if(mode === 0 && whitespace.indexOf( c ) > -1){ + continue; + } + switch(c) { + case '"': + if(!quote) { + quote = c; + } + else if(quote === c) { + quote = null; + } + break; + + case '(': + if(quote) { break; } + else if(mode === 0) { + mode = 1; + block += c; + continue; + } else { + numParen++; + } + break; + + case ')': + if(quote) { break; } + else if(mode === 1) { + if(numParen === 0) { + mode = 0; + block += c; + appendResult(); + continue; + } else { + numParen--; + } + } + break; + + case ',': + if(quote) { break; } + else if(mode === 0) { + appendResult(); + continue; + } + else if (mode === 1) { + if(numParen === 0 && !method.match(/^url$/i)) { + args.push(definition); + definition = ''; + block += c; + continue; + } + } + break; + } + + block += c; + if(mode === 0) { method += c; } + else { definition += c; } + } + appendResult(); + + return results; + }; + + _html2canvas.Util.Bounds = function (element) { + var clientRect, bounds = {}; + + if (element.getBoundingClientRect){ + clientRect = element.getBoundingClientRect(); + + // TODO add scroll position to bounds, so no scrolling of window necessary + bounds.top = clientRect.top; + bounds.bottom = clientRect.bottom || (clientRect.top + clientRect.height); + bounds.left = clientRect.left; + + bounds.width = element.offsetWidth; + bounds.height = element.offsetHeight; + } + + return bounds; + }; + + // TODO ideally, we'd want everything to go through this function instead of Util.Bounds, + // but would require further work to calculate the correct positions for elements with offsetParents + _html2canvas.Util.OffsetBounds = function (element) { + var parent = element.offsetParent ? _html2canvas.Util.OffsetBounds(element.offsetParent) : {top: 0, left: 0}; + + return { + top: element.offsetTop + parent.top, + bottom: element.offsetTop + element.offsetHeight + parent.top, + left: element.offsetLeft + parent.left, + width: element.offsetWidth, + height: element.offsetHeight + }; + }; + + function toPX(element, attribute, value ) { + var rsLeft = element.runtimeStyle && element.runtimeStyle[attribute], + left, + style = element.style; + + // Check if we are not dealing with pixels, (Opera has issues with this) + // Ported from jQuery css.js + // From the awesome hack by Dean Edwards + // http://erik.eae.net/archives/2007/07/27/18.54.15/#comment-102291 + + // If we're not dealing with a regular pixel number + // but a number that has a weird ending, we need to convert it to pixels + + if ( !/^-?[0-9]+\.?[0-9]*(?:px)?$/i.test( value ) && /^-?\d/.test(value) ) { + // Remember the original values + left = style.left; + + // Put in the new values to get a computed value out + if (rsLeft) { + element.runtimeStyle.left = element.currentStyle.left; + } + style.left = attribute === "fontSize" ? "1em" : (value || 0); + value = style.pixelLeft + "px"; + + // Revert the changed values + style.left = left; + if (rsLeft) { + element.runtimeStyle.left = rsLeft; + } + } + + if (!/^(thin|medium|thick)$/i.test(value)) { + return Math.round(parseFloat(value)) + "px"; + } + + return value; + } + + function asInt(val) { + return parseInt(val, 10); + } + + function parseBackgroundSizePosition(value, element, attribute, index) { + value = (value || '').split(','); + value = value[index || 0] || value[0] || 'auto'; + value = _html2canvas.Util.trimText(value).split(' '); + + if(attribute === 'backgroundSize' && (!value[0] || value[0].match(/cover|contain|auto/))) { + //these values will be handled in the parent function + } else { + value[0] = (value[0].indexOf( "%" ) === -1) ? toPX(element, attribute + "X", value[0]) : value[0]; + if(value[1] === undefined) { + if(attribute === 'backgroundSize') { + value[1] = 'auto'; + return value; + } else { + // IE 9 doesn't return double digit always + value[1] = value[0]; + } + } + value[1] = (value[1].indexOf("%") === -1) ? toPX(element, attribute + "Y", value[1]) : value[1]; + } + return value; + } + + _html2canvas.Util.getCSS = function (element, attribute, index) { + if (previousElement !== element) { + computedCSS = document.defaultView.getComputedStyle(element, null); + } + + var value = computedCSS[attribute]; + + if (/^background(Size|Position)$/.test(attribute)) { + return parseBackgroundSizePosition(value, element, attribute, index); + } else if (/border(Top|Bottom)(Left|Right)Radius/.test(attribute)) { + var arr = value.split(" "); + if (arr.length <= 1) { + arr[1] = arr[0]; + } + return arr.map(asInt); + } + + return value; + }; + + _html2canvas.Util.resizeBounds = function( current_width, current_height, target_width, target_height, stretch_mode ){ + var target_ratio = target_width / target_height, + current_ratio = current_width / current_height, + output_width, output_height; + + if(!stretch_mode || stretch_mode === 'auto') { + output_width = target_width; + output_height = target_height; + } else if(target_ratio < current_ratio ^ stretch_mode === 'contain') { + output_height = target_height; + output_width = target_height * current_ratio; + } else { + output_width = target_width; + output_height = target_width / current_ratio; + } + + return { + width: output_width, + height: output_height + }; + }; + + function backgroundBoundsFactory( prop, el, bounds, image, imageIndex, backgroundSize ) { + var bgposition = _html2canvas.Util.getCSS( el, prop, imageIndex ) , + topPos, + left, + percentage, + val; + + if (bgposition.length === 1){ + val = bgposition[0]; + + bgposition = []; + + bgposition[0] = val; + bgposition[1] = val; + } + + if (bgposition[0].toString().indexOf("%") !== -1){ + percentage = (parseFloat(bgposition[0])/100); + left = bounds.width * percentage; + if(prop !== 'backgroundSize') { + left -= (backgroundSize || image).width*percentage; + } + } else { + if(prop === 'backgroundSize') { + if(bgposition[0] === 'auto') { + left = image.width; + } else { + if (/contain|cover/.test(bgposition[0])) { + var resized = _html2canvas.Util.resizeBounds(image.width, image.height, bounds.width, bounds.height, bgposition[0]); + left = resized.width; + topPos = resized.height; + } else { + left = parseInt(bgposition[0], 10); + } + } + } else { + left = parseInt( bgposition[0], 10); + } + } + + + if(bgposition[1] === 'auto') { + topPos = left / image.width * image.height; + } else if (bgposition[1].toString().indexOf("%") !== -1){ + percentage = (parseFloat(bgposition[1])/100); + topPos = bounds.height * percentage; + if(prop !== 'backgroundSize') { + topPos -= (backgroundSize || image).height * percentage; + } + + } else { + topPos = parseInt(bgposition[1],10); + } + + return [left, topPos]; + } + + _html2canvas.Util.BackgroundPosition = function( el, bounds, image, imageIndex, backgroundSize ) { + var result = backgroundBoundsFactory( 'backgroundPosition', el, bounds, image, imageIndex, backgroundSize ); + return { left: result[0], top: result[1] }; + }; + + _html2canvas.Util.BackgroundSize = function( el, bounds, image, imageIndex ) { + var result = backgroundBoundsFactory( 'backgroundSize', el, bounds, image, imageIndex ); + return { width: result[0], height: result[1] }; + }; + + _html2canvas.Util.Extend = function (options, defaults) { + for (var key in options) { + if (options.hasOwnProperty(key)) { + defaults[key] = options[key]; + } + } + return defaults; + }; + + + /* + * Derived from jQuery.contents() + * Copyright 2010, John Resig + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + */ + _html2canvas.Util.Children = function( elem ) { + var children; + try { + children = (elem.nodeName && elem.nodeName.toUpperCase() === "IFRAME") ? elem.contentDocument || elem.contentWindow.document : (function(array) { + var ret = []; + if (array !== null) { + (function(first, second ) { + var i = first.length, + j = 0; + + if (typeof second.length === "number") { + for (var l = second.length; j < l; j++) { + first[i++] = second[j]; + } + } else { + while (second[j] !== undefined) { + first[i++] = second[j++]; + } + } + + first.length = i; + + return first; + })(ret, array); + } + return ret; + })(elem.childNodes); + + } catch (ex) { + _html2canvas.Util.log("html2canvas.Util.Children failed with exception: " + ex.message); + children = []; + } + return children; + }; + + _html2canvas.Util.isTransparent = function(backgroundColor) { + return (backgroundColor === "transparent" || backgroundColor === "rgba(0, 0, 0, 0)" || backgroundColor === undefined); + }; + _html2canvas.Util.Font = (function () { + + var fontData = {}; + + return function(font, fontSize, doc) { + if (fontData[font + "-" + fontSize] !== undefined) { + return fontData[font + "-" + fontSize]; + } + + var container = doc.createElement('div'), + img = doc.createElement('img'), + span = doc.createElement('span'), + sampleText = 'Hidden Text', + baseline, + middle, + metricsObj; + + container.style.visibility = "hidden"; + container.style.fontFamily = font; + container.style.fontSize = fontSize; + container.style.margin = 0; + container.style.padding = 0; + + doc.body.appendChild(container); + + // http://probablyprogramming.com/2009/03/15/the-tiniest-gif-ever (handtinywhite.gif) + img.src = "data:image/gif;base64,R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs="; + img.width = 1; + img.height = 1; + + img.style.margin = 0; + img.style.padding = 0; + img.style.verticalAlign = "baseline"; + + span.style.fontFamily = font; + span.style.fontSize = fontSize; + span.style.margin = 0; + span.style.padding = 0; + + span.appendChild(doc.createTextNode(sampleText)); + container.appendChild(span); + container.appendChild(img); + baseline = (img.offsetTop - span.offsetTop) + 1; + + container.removeChild(span); + container.appendChild(doc.createTextNode(sampleText)); + + container.style.lineHeight = "normal"; + img.style.verticalAlign = "super"; + + middle = (img.offsetTop-container.offsetTop) + 1; + metricsObj = { + baseline: baseline, + lineWidth: 1, + middle: middle + }; + + fontData[font + "-" + fontSize] = metricsObj; + + doc.body.removeChild(container); + + return metricsObj; + }; + })(); + + (function(){ + var Util = _html2canvas.Util, + Generate = {}; + + _html2canvas.Generate = Generate; + + var reGradients = [ + /^(-webkit-linear-gradient)\(([a-z\s]+)([\w\d\.\s,%\(\)]+)\)$/, + /^(-o-linear-gradient)\(([a-z\s]+)([\w\d\.\s,%\(\)]+)\)$/, + /^(-webkit-gradient)\((linear|radial),\s((?:\d{1,3}%?)\s(?:\d{1,3}%?),\s(?:\d{1,3}%?)\s(?:\d{1,3}%?))([\w\d\.\s,%\(\)\-]+)\)$/, + /^(-moz-linear-gradient)\(((?:\d{1,3}%?)\s(?:\d{1,3}%?))([\w\d\.\s,%\(\)]+)\)$/, + /^(-webkit-radial-gradient)\(((?:\d{1,3}%?)\s(?:\d{1,3}%?)),\s(\w+)\s([a-z\-]+)([\w\d\.\s,%\(\)]+)\)$/, + /^(-moz-radial-gradient)\(((?:\d{1,3}%?)\s(?:\d{1,3}%?)),\s(\w+)\s?([a-z\-]*)([\w\d\.\s,%\(\)]+)\)$/, + /^(-o-radial-gradient)\(((?:\d{1,3}%?)\s(?:\d{1,3}%?)),\s(\w+)\s([a-z\-]+)([\w\d\.\s,%\(\)]+)\)$/ + ]; + + /* + * TODO: Add IE10 vendor prefix (-ms) support + * TODO: Add W3C gradient (linear-gradient) support + * TODO: Add old Webkit -webkit-gradient(radial, ...) support + * TODO: Maybe some RegExp optimizations are possible ;o) + */ + Generate.parseGradient = function(css, bounds) { + var gradient, i, len = reGradients.length, m1, stop, m2, m2Len, step, m3, tl,tr,br,bl; + + for(i = 0; i < len; i+=1){ + m1 = css.match(reGradients[i]); + if(m1) { + break; + } + } + + if(m1) { + switch(m1[1]) { + case '-webkit-linear-gradient': + case '-o-linear-gradient': + + gradient = { + type: 'linear', + x0: null, + y0: null, + x1: null, + y1: null, + colorStops: [] + }; + + // get coordinates + m2 = m1[2].match(/\w+/g); + if(m2){ + m2Len = m2.length; + for(i = 0; i < m2Len; i+=1){ + switch(m2[i]) { + case 'top': + gradient.y0 = 0; + gradient.y1 = bounds.height; + break; + + case 'right': + gradient.x0 = bounds.width; + gradient.x1 = 0; + break; + + case 'bottom': + gradient.y0 = bounds.height; + gradient.y1 = 0; + break; + + case 'left': + gradient.x0 = 0; + gradient.x1 = bounds.width; + break; + } + } + } + if(gradient.x0 === null && gradient.x1 === null){ // center + gradient.x0 = gradient.x1 = bounds.width / 2; + } + if(gradient.y0 === null && gradient.y1 === null){ // center + gradient.y0 = gradient.y1 = bounds.height / 2; + } + + // get colors and stops + m2 = m1[3].match(/((?:rgb|rgba)\(\d{1,3},\s\d{1,3},\s\d{1,3}(?:,\s[0-9\.]+)?\)(?:\s\d{1,3}(?:%|px))?)+/g); + if(m2){ + m2Len = m2.length; + step = 1 / Math.max(m2Len - 1, 1); + for(i = 0; i < m2Len; i+=1){ + m3 = m2[i].match(/((?:rgb|rgba)\(\d{1,3},\s\d{1,3},\s\d{1,3}(?:,\s[0-9\.]+)?\))\s*(\d{1,3})?(%|px)?/); + if(m3[2]){ + stop = parseFloat(m3[2]); + if(m3[3] === '%'){ + stop /= 100; + } else { // px - stupid opera + stop /= bounds.width; + } + } else { + stop = i * step; + } + gradient.colorStops.push({ + color: m3[1], + stop: stop + }); + } + } + break; + + case '-webkit-gradient': + + gradient = { + type: m1[2] === 'radial' ? 'circle' : m1[2], // TODO: Add radial gradient support for older mozilla definitions + x0: 0, + y0: 0, + x1: 0, + y1: 0, + colorStops: [] + }; + + // get coordinates + m2 = m1[3].match(/(\d{1,3})%?\s(\d{1,3})%?,\s(\d{1,3})%?\s(\d{1,3})%?/); + if(m2){ + gradient.x0 = (m2[1] * bounds.width) / 100; + gradient.y0 = (m2[2] * bounds.height) / 100; + gradient.x1 = (m2[3] * bounds.width) / 100; + gradient.y1 = (m2[4] * bounds.height) / 100; + } + + // get colors and stops + m2 = m1[4].match(/((?:from|to|color-stop)\((?:[0-9\.]+,\s)?(?:rgb|rgba)\(\d{1,3},\s\d{1,3},\s\d{1,3}(?:,\s[0-9\.]+)?\)\))+/g); + if(m2){ + m2Len = m2.length; + for(i = 0; i < m2Len; i+=1){ + m3 = m2[i].match(/(from|to|color-stop)\(([0-9\.]+)?(?:,\s)?((?:rgb|rgba)\(\d{1,3},\s\d{1,3},\s\d{1,3}(?:,\s[0-9\.]+)?\))\)/); + stop = parseFloat(m3[2]); + if(m3[1] === 'from') { + stop = 0.0; + } + if(m3[1] === 'to') { + stop = 1.0; + } + gradient.colorStops.push({ + color: m3[3], + stop: stop + }); + } + } + break; + + case '-moz-linear-gradient': + + gradient = { + type: 'linear', + x0: 0, + y0: 0, + x1: 0, + y1: 0, + colorStops: [] + }; + + // get coordinates + m2 = m1[2].match(/(\d{1,3})%?\s(\d{1,3})%?/); + + // m2[1] == 0% -> left + // m2[1] == 50% -> center + // m2[1] == 100% -> right + + // m2[2] == 0% -> top + // m2[2] == 50% -> center + // m2[2] == 100% -> bottom + + if(m2){ + gradient.x0 = (m2[1] * bounds.width) / 100; + gradient.y0 = (m2[2] * bounds.height) / 100; + gradient.x1 = bounds.width - gradient.x0; + gradient.y1 = bounds.height - gradient.y0; + } + + // get colors and stops + m2 = m1[3].match(/((?:rgb|rgba)\(\d{1,3},\s\d{1,3},\s\d{1,3}(?:,\s[0-9\.]+)?\)(?:\s\d{1,3}%)?)+/g); + if(m2){ + m2Len = m2.length; + step = 1 / Math.max(m2Len - 1, 1); + for(i = 0; i < m2Len; i+=1){ + m3 = m2[i].match(/((?:rgb|rgba)\(\d{1,3},\s\d{1,3},\s\d{1,3}(?:,\s[0-9\.]+)?\))\s*(\d{1,3})?(%)?/); + if(m3[2]){ + stop = parseFloat(m3[2]); + if(m3[3]){ // percentage + stop /= 100; + } + } else { + stop = i * step; + } + gradient.colorStops.push({ + color: m3[1], + stop: stop + }); + } + } + break; + + case '-webkit-radial-gradient': + case '-moz-radial-gradient': + case '-o-radial-gradient': + + gradient = { + type: 'circle', + x0: 0, + y0: 0, + x1: bounds.width, + y1: bounds.height, + cx: 0, + cy: 0, + rx: 0, + ry: 0, + colorStops: [] + }; + + // center + m2 = m1[2].match(/(\d{1,3})%?\s(\d{1,3})%?/); + if(m2){ + gradient.cx = (m2[1] * bounds.width) / 100; + gradient.cy = (m2[2] * bounds.height) / 100; + } + + // size + m2 = m1[3].match(/\w+/); + m3 = m1[4].match(/[a-z\-]*/); + if(m2 && m3){ + switch(m3[0]){ + case 'farthest-corner': + case 'cover': // is equivalent to farthest-corner + case '': // mozilla removes "cover" from definition :( + tl = Math.sqrt(Math.pow(gradient.cx, 2) + Math.pow(gradient.cy, 2)); + tr = Math.sqrt(Math.pow(gradient.cx, 2) + Math.pow(gradient.y1 - gradient.cy, 2)); + br = Math.sqrt(Math.pow(gradient.x1 - gradient.cx, 2) + Math.pow(gradient.y1 - gradient.cy, 2)); + bl = Math.sqrt(Math.pow(gradient.x1 - gradient.cx, 2) + Math.pow(gradient.cy, 2)); + gradient.rx = gradient.ry = Math.max(tl, tr, br, bl); + break; + case 'closest-corner': + tl = Math.sqrt(Math.pow(gradient.cx, 2) + Math.pow(gradient.cy, 2)); + tr = Math.sqrt(Math.pow(gradient.cx, 2) + Math.pow(gradient.y1 - gradient.cy, 2)); + br = Math.sqrt(Math.pow(gradient.x1 - gradient.cx, 2) + Math.pow(gradient.y1 - gradient.cy, 2)); + bl = Math.sqrt(Math.pow(gradient.x1 - gradient.cx, 2) + Math.pow(gradient.cy, 2)); + gradient.rx = gradient.ry = Math.min(tl, tr, br, bl); + break; + case 'farthest-side': + if(m2[0] === 'circle'){ + gradient.rx = gradient.ry = Math.max( + gradient.cx, + gradient.cy, + gradient.x1 - gradient.cx, + gradient.y1 - gradient.cy + ); + } else { // ellipse + + gradient.type = m2[0]; + + gradient.rx = Math.max( + gradient.cx, + gradient.x1 - gradient.cx + ); + gradient.ry = Math.max( + gradient.cy, + gradient.y1 - gradient.cy + ); + } + break; + case 'closest-side': + case 'contain': // is equivalent to closest-side + if(m2[0] === 'circle'){ + gradient.rx = gradient.ry = Math.min( + gradient.cx, + gradient.cy, + gradient.x1 - gradient.cx, + gradient.y1 - gradient.cy + ); + } else { // ellipse + + gradient.type = m2[0]; + + gradient.rx = Math.min( + gradient.cx, + gradient.x1 - gradient.cx + ); + gradient.ry = Math.min( + gradient.cy, + gradient.y1 - gradient.cy + ); + } + break; + + // TODO: add support for "30px 40px" sizes (webkit only) + } + } + + // color stops + m2 = m1[5].match(/((?:rgb|rgba)\(\d{1,3},\s\d{1,3},\s\d{1,3}(?:,\s[0-9\.]+)?\)(?:\s\d{1,3}(?:%|px))?)+/g); + if(m2){ + m2Len = m2.length; + step = 1 / Math.max(m2Len - 1, 1); + for(i = 0; i < m2Len; i+=1){ + m3 = m2[i].match(/((?:rgb|rgba)\(\d{1,3},\s\d{1,3},\s\d{1,3}(?:,\s[0-9\.]+)?\))\s*(\d{1,3})?(%|px)?/); + if(m3[2]){ + stop = parseFloat(m3[2]); + if(m3[3] === '%'){ + stop /= 100; + } else { // px - stupid opera + stop /= bounds.width; + } + } else { + stop = i * step; + } + gradient.colorStops.push({ + color: m3[1], + stop: stop + }); + } + } + break; + } + } + + return gradient; + }; + + function addScrollStops(grad) { + return function(colorStop) { + try { + grad.addColorStop(colorStop.stop, colorStop.color); + } + catch(e) { + Util.log(['failed to add color stop: ', e, '; tried to add: ', colorStop]); + } + }; + } + + Generate.Gradient = function(src, bounds) { + if(bounds.width === 0 || bounds.height === 0) { + return; + } + + var canvas = document.createElement('canvas'), + ctx = canvas.getContext('2d'), + gradient, grad; + + canvas.width = bounds.width; + canvas.height = bounds.height; + + // TODO: add support for multi defined background gradients + gradient = _html2canvas.Generate.parseGradient(src, bounds); + + if(gradient) { + switch(gradient.type) { + case 'linear': + grad = ctx.createLinearGradient(gradient.x0, gradient.y0, gradient.x1, gradient.y1); + gradient.colorStops.forEach(addScrollStops(grad)); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, bounds.width, bounds.height); + break; + + case 'circle': + grad = ctx.createRadialGradient(gradient.cx, gradient.cy, 0, gradient.cx, gradient.cy, gradient.rx); + gradient.colorStops.forEach(addScrollStops(grad)); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, bounds.width, bounds.height); + break; + + case 'ellipse': + var canvasRadial = document.createElement('canvas'), + ctxRadial = canvasRadial.getContext('2d'), + ri = Math.max(gradient.rx, gradient.ry), + di = ri * 2; + + canvasRadial.width = canvasRadial.height = di; + + grad = ctxRadial.createRadialGradient(gradient.rx, gradient.ry, 0, gradient.rx, gradient.ry, ri); + gradient.colorStops.forEach(addScrollStops(grad)); + + ctxRadial.fillStyle = grad; + ctxRadial.fillRect(0, 0, di, di); + + ctx.fillStyle = gradient.colorStops[gradient.colorStops.length - 1].color; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(canvasRadial, gradient.cx - gradient.rx, gradient.cy - gradient.ry, 2 * gradient.rx, 2 * gradient.ry); + break; + } + } + + return canvas; + }; + + Generate.ListAlpha = function(number) { + var tmp = "", + modulus; + + do { + modulus = number % 26; + tmp = String.fromCharCode((modulus) + 64) + tmp; + number = number / 26; + }while((number*26) > 26); + + return tmp; + }; + + Generate.ListRoman = function(number) { + var romanArray = ["M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"], + decimal = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1], + roman = "", + v, + len = romanArray.length; + + if (number <= 0 || number >= 4000) { + return number; + } + + for (v=0; v < len; v+=1) { + while (number >= decimal[v]) { + number -= decimal[v]; + roman += romanArray[v]; + } + } + + return roman; + }; + })(); + function h2cRenderContext(width, height) { + var storage = []; + return { + storage: storage, + width: width, + height: height, + clip: function() { + storage.push({ + type: "function", + name: "clip", + 'arguments': arguments + }); + }, + translate: function() { + storage.push({ + type: "function", + name: "translate", + 'arguments': arguments + }); + }, + fill: function() { + storage.push({ + type: "function", + name: "fill", + 'arguments': arguments + }); + }, + save: function() { + storage.push({ + type: "function", + name: "save", + 'arguments': arguments + }); + }, + restore: function() { + storage.push({ + type: "function", + name: "restore", + 'arguments': arguments + }); + }, + fillRect: function () { + storage.push({ + type: "function", + name: "fillRect", + 'arguments': arguments + }); + }, + createPattern: function() { + storage.push({ + type: "function", + name: "createPattern", + 'arguments': arguments + }); + }, + drawShape: function() { + + var shape = []; + + storage.push({ + type: "function", + name: "drawShape", + 'arguments': shape + }); + + return { + moveTo: function() { + shape.push({ + name: "moveTo", + 'arguments': arguments + }); + }, + lineTo: function() { + shape.push({ + name: "lineTo", + 'arguments': arguments + }); + }, + arcTo: function() { + shape.push({ + name: "arcTo", + 'arguments': arguments + }); + }, + bezierCurveTo: function() { + shape.push({ + name: "bezierCurveTo", + 'arguments': arguments + }); + }, + quadraticCurveTo: function() { + shape.push({ + name: "quadraticCurveTo", + 'arguments': arguments + }); + } + }; + + }, + drawImage: function () { + storage.push({ + type: "function", + name: "drawImage", + 'arguments': arguments + }); + }, + fillText: function () { + storage.push({ + type: "function", + name: "fillText", + 'arguments': arguments + }); + }, + setVariable: function (variable, value) { + storage.push({ + type: "variable", + name: variable, + 'arguments': value + }); + return value; + } + }; + } + _html2canvas.Parse = function (images, options) { + window.scroll(0,0); + + var element = (( options.elements === undefined ) ? document.body : options.elements[0]), // select body by default + numDraws = 0, + doc = element.ownerDocument, + Util = _html2canvas.Util, + support = Util.Support(options, doc), + ignoreElementsRegExp = new RegExp("(" + options.ignoreElements + ")"), + body = doc.body, + getCSS = Util.getCSS, + pseudoHide = "___html2canvas___pseudoelement", + hidePseudoElements = doc.createElement('style'); + + hidePseudoElements.innerHTML = '.' + pseudoHide + '-before:before { content: "" !important; display: none !important; }' + + '.' + pseudoHide + '-after:after { content: "" !important; display: none !important; }'; + + body.appendChild(hidePseudoElements); + + images = images || {}; + + function documentWidth () { + return Math.max( + Math.max(doc.body.scrollWidth, doc.documentElement.scrollWidth), + Math.max(doc.body.offsetWidth, doc.documentElement.offsetWidth), + Math.max(doc.body.clientWidth, doc.documentElement.clientWidth) + ); + } + + function documentHeight () { + return Math.max( + Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight), + Math.max(doc.body.offsetHeight, doc.documentElement.offsetHeight), + Math.max(doc.body.clientHeight, doc.documentElement.clientHeight) + ); + } + + function getCSSInt(element, attribute) { + var val = parseInt(getCSS(element, attribute), 10); + return (isNaN(val)) ? 0 : val; // borders in old IE are throwing 'medium' for demo.html + } + + function renderRect (ctx, x, y, w, h, bgcolor) { + if (bgcolor !== "transparent"){ + ctx.setVariable("fillStyle", bgcolor); + ctx.fillRect(x, y, w, h); + numDraws+=1; + } + } + + function capitalize(m, p1, p2) { + if (m.length > 0) { + return p1 + p2.toUpperCase(); + } + } + + function textTransform (text, transform) { + switch(transform){ + case "lowercase": + return text.toLowerCase(); + case "capitalize": + return text.replace( /(^|\s|:|-|\(|\))([a-z])/g, capitalize); + case "uppercase": + return text.toUpperCase(); + default: + return text; + } + } + + function noLetterSpacing(letter_spacing) { + return (/^(normal|none|0px)$/.test(letter_spacing)); + } + + function drawText(currentText, x, y, ctx){ + if (currentText !== null && Util.trimText(currentText).length > 0) { + ctx.fillText(currentText, x, y); + numDraws+=1; + } + } + + function setTextVariables(ctx, el, text_decoration, color) { + var align = false, + bold = getCSS(el, "fontWeight"), + family = getCSS(el, "fontFamily"), + size = getCSS(el, "fontSize"), + shadows = Util.parseTextShadows(getCSS(el, "textShadow")); + + switch(parseInt(bold, 10)){ + case 401: + bold = "bold"; + break; + case 400: + bold = "normal"; + break; + } + + ctx.setVariable("fillStyle", color); + ctx.setVariable("font", [getCSS(el, "fontStyle"), getCSS(el, "fontVariant"), bold, size, family].join(" ")); + ctx.setVariable("textAlign", (align) ? "right" : "left"); + + if (shadows.length) { + // TODO: support multiple text shadows + // apply the first text shadow + ctx.setVariable("shadowColor", shadows[0].color); + ctx.setVariable("shadowOffsetX", shadows[0].offsetX); + ctx.setVariable("shadowOffsetY", shadows[0].offsetY); + ctx.setVariable("shadowBlur", shadows[0].blur); + } + + if (text_decoration !== "none"){ + return Util.Font(family, size, doc); + } + } + + function renderTextDecoration(ctx, text_decoration, bounds, metrics, color) { + switch(text_decoration) { + case "underline": + // Draws a line at the baseline of the font + // TODO As some browsers display the line as more than 1px if the font-size is big, need to take that into account both in position and size + renderRect(ctx, bounds.left, Math.round(bounds.top + metrics.baseline + metrics.lineWidth), bounds.width, 1, color); + break; + case "overline": + renderRect(ctx, bounds.left, Math.round(bounds.top), bounds.width, 1, color); + break; + case "line-through": + // TODO try and find exact position for line-through + renderRect(ctx, bounds.left, Math.ceil(bounds.top + metrics.middle + metrics.lineWidth), bounds.width, 1, color); + break; + } + } + + function getTextBounds(state, text, textDecoration, isLast, transform) { + var bounds; + if (support.rangeBounds && !transform) { + if (textDecoration !== "none" || Util.trimText(text).length !== 0) { + bounds = textRangeBounds(text, state.node, state.textOffset); + } + state.textOffset += text.length; + } else if (state.node && typeof state.node.nodeValue === "string" ){ + var newTextNode = (isLast) ? state.node.splitText(text.length) : null; + bounds = textWrapperBounds(state.node, transform); + state.node = newTextNode; + } + return bounds; + } + + function textRangeBounds(text, textNode, textOffset) { + var range = doc.createRange(); + range.setStart(textNode, textOffset); + range.setEnd(textNode, textOffset + text.length); + return range.getBoundingClientRect(); + } + + function textWrapperBounds(oldTextNode, transform) { + var parent = oldTextNode.parentNode, + wrapElement = doc.createElement('wrapper'), + backupText = oldTextNode.cloneNode(true); + + wrapElement.appendChild(oldTextNode.cloneNode(true)); + parent.replaceChild(wrapElement, oldTextNode); + + var bounds = transform ? Util.OffsetBounds(wrapElement) : Util.Bounds(wrapElement); + parent.replaceChild(backupText, wrapElement); + return bounds; + } + + function renderText(el, textNode, stack) { + var ctx = stack.ctx, + color = getCSS(el, "color"), + textDecoration = getCSS(el, "textDecoration"), + textAlign = getCSS(el, "textAlign"), + metrics, + textList, + state = { + node: textNode, + textOffset: 0 + }; + + if (Util.trimText(textNode.nodeValue).length > 0) { + textNode.nodeValue = textTransform(textNode.nodeValue, getCSS(el, "textTransform")); + textAlign = textAlign.replace(["-webkit-auto"],["auto"]); + + textList = (!options.letterRendering && /^(left|right|justify|auto)$/.test(textAlign) && noLetterSpacing(getCSS(el, "letterSpacing"))) ? + textNode.nodeValue.split(/(\b| )/) + : textNode.nodeValue.split(""); + + metrics = setTextVariables(ctx, el, textDecoration, color); + + if (options.chinese) { + textList.forEach(function(word, index) { + if (/.*[\u4E00-\u9FA5].*$/.test(word)) { + word = word.split(""); + word.unshift(index, 1); + textList.splice.apply(textList, word); + } + }); + } + + textList.forEach(function(text, index) { + var bounds = getTextBounds(state, text, textDecoration, (index < textList.length - 1), stack.transform.matrix); + if (bounds) { + drawText(text, bounds.left, bounds.bottom, ctx); + renderTextDecoration(ctx, textDecoration, bounds, metrics, color); + } + }); + } + } + + function listPosition (element, val) { + var boundElement = doc.createElement( "boundelement" ), + originalType, + bounds; + + boundElement.style.display = "inline"; + + originalType = element.style.listStyleType; + element.style.listStyleType = "none"; + + boundElement.appendChild(doc.createTextNode(val)); + + element.insertBefore(boundElement, element.firstChild); + + bounds = Util.Bounds(boundElement); + element.removeChild(boundElement); + element.style.listStyleType = originalType; + return bounds; + } + + function elementIndex(el) { + var i = -1, + count = 1, + childs = el.parentNode.childNodes; + + if (el.parentNode) { + while(childs[++i] !== el) { + if (childs[i].nodeType === 1) { + count++; + } + } + return count; + } else { + return -1; + } + } + + function listItemText(element, type) { + var currentIndex = elementIndex(element), text; + switch(type){ + case "decimal": + text = currentIndex; + break; + case "decimal-leading-zero": + text = (currentIndex.toString().length === 1) ? currentIndex = "0" + currentIndex.toString() : currentIndex.toString(); + break; + case "upper-roman": + text = _html2canvas.Generate.ListRoman( currentIndex ); + break; + case "lower-roman": + text = _html2canvas.Generate.ListRoman( currentIndex ).toLowerCase(); + break; + case "lower-alpha": + text = _html2canvas.Generate.ListAlpha( currentIndex ).toLowerCase(); + break; + case "upper-alpha": + text = _html2canvas.Generate.ListAlpha( currentIndex ); + break; + } + + return text + ". "; + } + + function renderListItem(element, stack, elBounds) { + var x, + text, + ctx = stack.ctx, + type = getCSS(element, "listStyleType"), + listBounds; + + if (/^(decimal|decimal-leading-zero|upper-alpha|upper-latin|upper-roman|lower-alpha|lower-greek|lower-latin|lower-roman)$/i.test(type)) { + text = listItemText(element, type); + listBounds = listPosition(element, text); + setTextVariables(ctx, element, "none", getCSS(element, "color")); + + if (getCSS(element, "listStylePosition") === "inside") { + ctx.setVariable("textAlign", "left"); + x = elBounds.left; + } else { + return; + } + + drawText(text, x, listBounds.bottom, ctx); + } + } + + function loadImage (src){ + var img = images[src]; + return (img && img.succeeded === true) ? img.img : false; + } + + function clipBounds(src, dst){ + var x = Math.max(src.left, dst.left), + y = Math.max(src.top, dst.top), + x2 = Math.min((src.left + src.width), (dst.left + dst.width)), + y2 = Math.min((src.top + src.height), (dst.top + dst.height)); + + return { + left:x, + top:y, + width:x2-x, + height:y2-y + }; + } + + function setZ(element, stack, parentStack){ + var newContext, + isPositioned = stack.cssPosition !== 'static', + zIndex = isPositioned ? getCSS(element, 'zIndex') : 'auto', + opacity = getCSS(element, 'opacity'), + isFloated = getCSS(element, 'cssFloat') !== 'none'; + + // https://developer.mozilla.org/en-US/docs/Web/Guide/CSS/Understanding_z_index/The_stacking_context + // When a new stacking context should be created: + // the root element (HTML), + // positioned (absolutely or relatively) with a z-index value other than "auto", + // elements with an opacity value less than 1. (See the specification for opacity), + // on mobile WebKit and Chrome 22+, position: fixed always creates a new stacking context, even when z-index is "auto" (See this post) + + stack.zIndex = newContext = h2czContext(zIndex); + newContext.isPositioned = isPositioned; + newContext.isFloated = isFloated; + newContext.opacity = opacity; + newContext.ownStacking = (zIndex !== 'auto' || opacity < 1); + + if (parentStack) { + parentStack.zIndex.children.push(stack); + } + } + + function renderImage(ctx, element, image, bounds, borders) { + + var paddingLeft = getCSSInt(element, 'paddingLeft'), + paddingTop = getCSSInt(element, 'paddingTop'), + paddingRight = getCSSInt(element, 'paddingRight'), + paddingBottom = getCSSInt(element, 'paddingBottom'); + + drawImage( + ctx, + image, + 0, //sx + 0, //sy + image.width, //sw + image.height, //sh + bounds.left + paddingLeft + borders[3].width, //dx + bounds.top + paddingTop + borders[0].width, // dy + bounds.width - (borders[1].width + borders[3].width + paddingLeft + paddingRight), //dw + bounds.height - (borders[0].width + borders[2].width + paddingTop + paddingBottom) //dh + ); + } + + function getBorderData(element) { + return ["Top", "Right", "Bottom", "Left"].map(function(side) { + return { + width: getCSSInt(element, 'border' + side + 'Width'), + color: getCSS(element, 'border' + side + 'Color') + }; + }); + } + + function getBorderRadiusData(element) { + return ["TopLeft", "TopRight", "BottomRight", "BottomLeft"].map(function(side) { + return getCSS(element, 'border' + side + 'Radius'); + }); + } + + var getCurvePoints = (function(kappa) { + + return function(x, y, r1, r2) { + var ox = (r1) * kappa, // control point offset horizontal + oy = (r2) * kappa, // control point offset vertical + xm = x + r1, // x-middle + ym = y + r2; // y-middle + return { + topLeft: bezierCurve({ + x:x, + y:ym + }, { + x:x, + y:ym - oy + }, { + x:xm - ox, + y:y + }, { + x:xm, + y:y + }), + topRight: bezierCurve({ + x:x, + y:y + }, { + x:x + ox, + y:y + }, { + x:xm, + y:ym - oy + }, { + x:xm, + y:ym + }), + bottomRight: bezierCurve({ + x:xm, + y:y + }, { + x:xm, + y:y + oy + }, { + x:x + ox, + y:ym + }, { + x:x, + y:ym + }), + bottomLeft: bezierCurve({ + x:xm, + y:ym + }, { + x:xm - ox, + y:ym + }, { + x:x, + y:y + oy + }, { + x:x, + y:y + }) + }; + }; + })(4 * ((Math.sqrt(2) - 1) / 3)); + + function bezierCurve(start, startControl, endControl, end) { + + var lerp = function (a, b, t) { + return { + x:a.x + (b.x - a.x) * t, + y:a.y + (b.y - a.y) * t + }; + }; + + return { + start: start, + startControl: startControl, + endControl: endControl, + end: end, + subdivide: function(t) { + var ab = lerp(start, startControl, t), + bc = lerp(startControl, endControl, t), + cd = lerp(endControl, end, t), + abbc = lerp(ab, bc, t), + bccd = lerp(bc, cd, t), + dest = lerp(abbc, bccd, t); + return [bezierCurve(start, ab, abbc, dest), bezierCurve(dest, bccd, cd, end)]; + }, + curveTo: function(borderArgs) { + borderArgs.push(["bezierCurve", startControl.x, startControl.y, endControl.x, endControl.y, end.x, end.y]); + }, + curveToReversed: function(borderArgs) { + borderArgs.push(["bezierCurve", endControl.x, endControl.y, startControl.x, startControl.y, start.x, start.y]); + } + }; + } + + function parseCorner(borderArgs, radius1, radius2, corner1, corner2, x, y) { + if (radius1[0] > 0 || radius1[1] > 0) { + borderArgs.push(["line", corner1[0].start.x, corner1[0].start.y]); + corner1[0].curveTo(borderArgs); + corner1[1].curveTo(borderArgs); + } else { + borderArgs.push(["line", x, y]); + } + + if (radius2[0] > 0 || radius2[1] > 0) { + borderArgs.push(["line", corner2[0].start.x, corner2[0].start.y]); + } + } + + function drawSide(borderData, radius1, radius2, outer1, inner1, outer2, inner2) { + var borderArgs = []; + + if (radius1[0] > 0 || radius1[1] > 0) { + borderArgs.push(["line", outer1[1].start.x, outer1[1].start.y]); + outer1[1].curveTo(borderArgs); + } else { + borderArgs.push([ "line", borderData.c1[0], borderData.c1[1]]); + } + + if (radius2[0] > 0 || radius2[1] > 0) { + borderArgs.push(["line", outer2[0].start.x, outer2[0].start.y]); + outer2[0].curveTo(borderArgs); + borderArgs.push(["line", inner2[0].end.x, inner2[0].end.y]); + inner2[0].curveToReversed(borderArgs); + } else { + borderArgs.push([ "line", borderData.c2[0], borderData.c2[1]]); + borderArgs.push([ "line", borderData.c3[0], borderData.c3[1]]); + } + + if (radius1[0] > 0 || radius1[1] > 0) { + borderArgs.push(["line", inner1[1].end.x, inner1[1].end.y]); + inner1[1].curveToReversed(borderArgs); + } else { + borderArgs.push([ "line", borderData.c4[0], borderData.c4[1]]); + } + + return borderArgs; + } + + function calculateCurvePoints(bounds, borderRadius, borders) { + + var x = bounds.left, + y = bounds.top, + width = bounds.width, + height = bounds.height, + + tlh = borderRadius[0][0], + tlv = borderRadius[0][1], + trh = borderRadius[1][0], + trv = borderRadius[1][1], + brh = borderRadius[2][0], + brv = borderRadius[2][1], + blh = borderRadius[3][0], + blv = borderRadius[3][1], + + topWidth = width - trh, + rightHeight = height - brv, + bottomWidth = width - brh, + leftHeight = height - blv; + + return { + topLeftOuter: getCurvePoints( + x, + y, + tlh, + tlv + ).topLeft.subdivide(0.5), + + topLeftInner: getCurvePoints( + x + borders[3].width, + y + borders[0].width, + Math.max(0, tlh - borders[3].width), + Math.max(0, tlv - borders[0].width) + ).topLeft.subdivide(0.5), + + topRightOuter: getCurvePoints( + x + topWidth, + y, + trh, + trv + ).topRight.subdivide(0.5), + + topRightInner: getCurvePoints( + x + Math.min(topWidth, width + borders[3].width), + y + borders[0].width, + (topWidth > width + borders[3].width) ? 0 :trh - borders[3].width, + trv - borders[0].width + ).topRight.subdivide(0.5), + + bottomRightOuter: getCurvePoints( + x + bottomWidth, + y + rightHeight, + brh, + brv + ).bottomRight.subdivide(0.5), + + bottomRightInner: getCurvePoints( + x + Math.min(bottomWidth, width + borders[3].width), + y + Math.min(rightHeight, height + borders[0].width), + Math.max(0, brh - borders[1].width), + Math.max(0, brv - borders[2].width) + ).bottomRight.subdivide(0.5), + + bottomLeftOuter: getCurvePoints( + x, + y + leftHeight, + blh, + blv + ).bottomLeft.subdivide(0.5), + + bottomLeftInner: getCurvePoints( + x + borders[3].width, + y + leftHeight, + Math.max(0, blh - borders[3].width), + Math.max(0, blv - borders[2].width) + ).bottomLeft.subdivide(0.5) + }; + } + + function getBorderClip(element, borderPoints, borders, radius, bounds) { + var backgroundClip = getCSS(element, 'backgroundClip'), + borderArgs = []; + + switch(backgroundClip) { + case "content-box": + case "padding-box": + parseCorner(borderArgs, radius[0], radius[1], borderPoints.topLeftInner, borderPoints.topRightInner, bounds.left + borders[3].width, bounds.top + borders[0].width); + parseCorner(borderArgs, radius[1], radius[2], borderPoints.topRightInner, borderPoints.bottomRightInner, bounds.left + bounds.width - borders[1].width, bounds.top + borders[0].width); + parseCorner(borderArgs, radius[2], radius[3], borderPoints.bottomRightInner, borderPoints.bottomLeftInner, bounds.left + bounds.width - borders[1].width, bounds.top + bounds.height - borders[2].width); + parseCorner(borderArgs, radius[3], radius[0], borderPoints.bottomLeftInner, borderPoints.topLeftInner, bounds.left + borders[3].width, bounds.top + bounds.height - borders[2].width); + break; + + default: + parseCorner(borderArgs, radius[0], radius[1], borderPoints.topLeftOuter, borderPoints.topRightOuter, bounds.left, bounds.top); + parseCorner(borderArgs, radius[1], radius[2], borderPoints.topRightOuter, borderPoints.bottomRightOuter, bounds.left + bounds.width, bounds.top); + parseCorner(borderArgs, radius[2], radius[3], borderPoints.bottomRightOuter, borderPoints.bottomLeftOuter, bounds.left + bounds.width, bounds.top + bounds.height); + parseCorner(borderArgs, radius[3], radius[0], borderPoints.bottomLeftOuter, borderPoints.topLeftOuter, bounds.left, bounds.top + bounds.height); + break; + } + + return borderArgs; + } + + function parseBorders(element, bounds, borders){ + var x = bounds.left, + y = bounds.top, + width = bounds.width, + height = bounds.height, + borderSide, + bx, + by, + bw, + bh, + borderArgs, + // http://www.w3.org/TR/css3-background/#the-border-radius + borderRadius = getBorderRadiusData(element), + borderPoints = calculateCurvePoints(bounds, borderRadius, borders), + borderData = { + clip: getBorderClip(element, borderPoints, borders, borderRadius, bounds), + borders: [] + }; + + for (borderSide = 0; borderSide < 4; borderSide++) { + + if (borders[borderSide].width > 0) { + bx = x; + by = y; + bw = width; + bh = height - (borders[2].width); + + switch(borderSide) { + case 0: + // top border + bh = borders[0].width; + + borderArgs = drawSide({ + c1: [bx, by], + c2: [bx + bw, by], + c3: [bx + bw - borders[1].width, by + bh], + c4: [bx + borders[3].width, by + bh] + }, borderRadius[0], borderRadius[1], + borderPoints.topLeftOuter, borderPoints.topLeftInner, borderPoints.topRightOuter, borderPoints.topRightInner); + break; + case 1: + // right border + bx = x + width - (borders[1].width); + bw = borders[1].width; + + borderArgs = drawSide({ + c1: [bx + bw, by], + c2: [bx + bw, by + bh + borders[2].width], + c3: [bx, by + bh], + c4: [bx, by + borders[0].width] + }, borderRadius[1], borderRadius[2], + borderPoints.topRightOuter, borderPoints.topRightInner, borderPoints.bottomRightOuter, borderPoints.bottomRightInner); + break; + case 2: + // bottom border + by = (by + height) - (borders[2].width); + bh = borders[2].width; + + borderArgs = drawSide({ + c1: [bx + bw, by + bh], + c2: [bx, by + bh], + c3: [bx + borders[3].width, by], + c4: [bx + bw - borders[3].width, by] + }, borderRadius[2], borderRadius[3], + borderPoints.bottomRightOuter, borderPoints.bottomRightInner, borderPoints.bottomLeftOuter, borderPoints.bottomLeftInner); + break; + case 3: + // left border + bw = borders[3].width; + + borderArgs = drawSide({ + c1: [bx, by + bh + borders[2].width], + c2: [bx, by], + c3: [bx + bw, by + borders[0].width], + c4: [bx + bw, by + bh] + }, borderRadius[3], borderRadius[0], + borderPoints.bottomLeftOuter, borderPoints.bottomLeftInner, borderPoints.topLeftOuter, borderPoints.topLeftInner); + break; + } + + borderData.borders.push({ + args: borderArgs, + color: borders[borderSide].color + }); + + } + } + + return borderData; + } + + function createShape(ctx, args) { + var shape = ctx.drawShape(); + args.forEach(function(border, index) { + shape[(index === 0) ? "moveTo" : border[0] + "To" ].apply(null, border.slice(1)); + }); + return shape; + } + + function renderBorders(ctx, borderArgs, color) { + if (color !== "transparent") { + ctx.setVariable( "fillStyle", color); + createShape(ctx, borderArgs); + ctx.fill(); + numDraws+=1; + } + } + + function renderFormValue (el, bounds, stack){ + + var valueWrap = doc.createElement('valuewrap'), + cssPropertyArray = ['lineHeight','textAlign','fontFamily','color','fontSize','paddingLeft','paddingTop','width','height','border','borderLeftWidth','borderTopWidth'], + textValue, + textNode; + + cssPropertyArray.forEach(function(property) { + try { + valueWrap.style[property] = getCSS(el, property); + } catch(e) { + // Older IE has issues with "border" + Util.log("html2canvas: Parse: Exception caught in renderFormValue: " + e.message); + } + }); + + valueWrap.style.borderColor = "black"; + valueWrap.style.borderStyle = "solid"; + valueWrap.style.display = "block"; + valueWrap.style.position = "absolute"; + + if (/^(submit|reset|button|text|password)$/.test(el.type) || el.nodeName === "SELECT"){ + valueWrap.style.lineHeight = getCSS(el, "height"); + } + + valueWrap.style.top = bounds.top + "px"; + valueWrap.style.left = bounds.left + "px"; + + textValue = (el.nodeName === "SELECT") ? (el.options[el.selectedIndex] || 0).text : el.value; + if(!textValue) { + textValue = el.placeholder; + } + + textNode = doc.createTextNode(textValue); + + valueWrap.appendChild(textNode); + body.appendChild(valueWrap); + + renderText(el, textNode, stack); + body.removeChild(valueWrap); + } + + function drawImage (ctx) { + ctx.drawImage.apply(ctx, Array.prototype.slice.call(arguments, 1)); + numDraws+=1; + } + + function getPseudoElement(el, which) { + var elStyle = window.getComputedStyle(el, which); + if(!elStyle || !elStyle.content || elStyle.content === "none" || elStyle.content === "-moz-alt-content" || elStyle.display === "none") { + return; + } + var content = elStyle.content + '', + first = content.substr( 0, 1 ); + //strips quotes + if(first === content.substr( content.length - 1 ) && first.match(/'|"/)) { + content = content.substr( 1, content.length - 2 ); + } + + var isImage = content.substr( 0, 3 ) === 'url', + elps = document.createElement( isImage ? 'img' : 'span' ); + + elps.className = pseudoHide + "-before " + pseudoHide + "-after"; + + Object.keys(elStyle).filter(indexedProperty).forEach(function(prop) { + // Prevent assigning of read only CSS Rules, ex. length, parentRule + try { + elps.style[prop] = elStyle[prop]; + } catch (e) { + Util.log(['Tried to assign readonly property ', prop, 'Error:', e]); + } + }); + + if(isImage) { + elps.src = Util.parseBackgroundImage(content)[0].args[0]; + } else { + elps.innerHTML = content; + } + return elps; + } + + function indexedProperty(property) { + return (isNaN(window.parseInt(property, 10))); + } + + function injectPseudoElements(el, stack) { + var before = getPseudoElement(el, ':before'), + after = getPseudoElement(el, ':after'); + if(!before && !after) { + return; + } + + if(before) { + el.className += " " + pseudoHide + "-before"; + el.parentNode.insertBefore(before, el); + parseElement(before, stack, true); + el.parentNode.removeChild(before); + el.className = el.className.replace(pseudoHide + "-before", "").trim(); + } + + if (after) { + el.className += " " + pseudoHide + "-after"; + el.appendChild(after); + parseElement(after, stack, true); + el.removeChild(after); + el.className = el.className.replace(pseudoHide + "-after", "").trim(); + } + + } + + function renderBackgroundRepeat(ctx, image, backgroundPosition, bounds) { + var offsetX = Math.round(bounds.left + backgroundPosition.left), + offsetY = Math.round(bounds.top + backgroundPosition.top); + + ctx.createPattern(image); + ctx.translate(offsetX, offsetY); + ctx.fill(); + ctx.translate(-offsetX, -offsetY); + } + + function backgroundRepeatShape(ctx, image, backgroundPosition, bounds, left, top, width, height) { + var args = []; + args.push(["line", Math.round(left), Math.round(top)]); + args.push(["line", Math.round(left + width), Math.round(top)]); + args.push(["line", Math.round(left + width), Math.round(height + top)]); + args.push(["line", Math.round(left), Math.round(height + top)]); + createShape(ctx, args); + ctx.save(); + ctx.clip(); + renderBackgroundRepeat(ctx, image, backgroundPosition, bounds); + ctx.restore(); + } + + function renderBackgroundColor(ctx, backgroundBounds, bgcolor) { + renderRect( + ctx, + backgroundBounds.left, + backgroundBounds.top, + backgroundBounds.width, + backgroundBounds.height, + bgcolor + ); + } + + function renderBackgroundRepeating(el, bounds, ctx, image, imageIndex) { + var backgroundSize = Util.BackgroundSize(el, bounds, image, imageIndex), + backgroundPosition = Util.BackgroundPosition(el, bounds, image, imageIndex, backgroundSize), + backgroundRepeat = getCSS(el, "backgroundRepeat").split(",").map(Util.trimText); + + image = resizeImage(image, backgroundSize); + + backgroundRepeat = backgroundRepeat[imageIndex] || backgroundRepeat[0]; + + switch (backgroundRepeat) { + case "repeat-x": + backgroundRepeatShape(ctx, image, backgroundPosition, bounds, + bounds.left, bounds.top + backgroundPosition.top, 99999, image.height); + break; + + case "repeat-y": + backgroundRepeatShape(ctx, image, backgroundPosition, bounds, + bounds.left + backgroundPosition.left, bounds.top, image.width, 99999); + break; + + case "no-repeat": + backgroundRepeatShape(ctx, image, backgroundPosition, bounds, + bounds.left + backgroundPosition.left, bounds.top + backgroundPosition.top, image.width, image.height); + break; + + default: + renderBackgroundRepeat(ctx, image, backgroundPosition, { + top: bounds.top, + left: bounds.left, + width: image.width, + height: image.height + }); + break; + } + } + + function renderBackgroundImage(element, bounds, ctx) { + var backgroundImage = getCSS(element, "backgroundImage"), + backgroundImages = Util.parseBackgroundImage(backgroundImage), + image, + imageIndex = backgroundImages.length; + + while(imageIndex--) { + backgroundImage = backgroundImages[imageIndex]; + + if (!backgroundImage.args || backgroundImage.args.length === 0) { + continue; + } + + var key = backgroundImage.method === 'url' ? + backgroundImage.args[0] : + backgroundImage.value; + + image = loadImage(key); + + // TODO add support for background-origin + if (image) { + renderBackgroundRepeating(element, bounds, ctx, image, imageIndex); + } else { + Util.log("html2canvas: Error loading background:", backgroundImage); + } + } + } + + function resizeImage(image, bounds) { + if(image.width === bounds.width && image.height === bounds.height) { + return image; + } + + var ctx, canvas = doc.createElement('canvas'); + canvas.width = bounds.width; + canvas.height = bounds.height; + ctx = canvas.getContext("2d"); + drawImage(ctx, image, 0, 0, image.width, image.height, 0, 0, bounds.width, bounds.height ); + return canvas; + } + + function setOpacity(ctx, element, parentStack) { + return ctx.setVariable("globalAlpha", getCSS(element, "opacity") * ((parentStack) ? parentStack.opacity : 1)); + } + + function removePx(str) { + return str.replace("px", ""); + } + + var transformRegExp = /(matrix)\((.+)\)/; + + function getTransform(element, parentStack) { + var transform = getCSS(element, "transform") || getCSS(element, "-webkit-transform") || getCSS(element, "-moz-transform") || getCSS(element, "-ms-transform") || getCSS(element, "-o-transform"); + var transformOrigin = getCSS(element, "transform-origin") || getCSS(element, "-webkit-transform-origin") || getCSS(element, "-moz-transform-origin") || getCSS(element, "-ms-transform-origin") || getCSS(element, "-o-transform-origin") || "0px 0px"; + + transformOrigin = transformOrigin.split(" ").map(removePx).map(Util.asFloat); + + var matrix; + if (transform && transform !== "none") { + var match = transform.match(transformRegExp); + if (match) { + switch(match[1]) { + case "matrix": + matrix = match[2].split(",").map(Util.trimText).map(Util.asFloat); + break; + } + } + } + + return { + origin: transformOrigin, + matrix: matrix + }; + } + + function createStack(element, parentStack, bounds, transform) { + var ctx = h2cRenderContext((!parentStack) ? documentWidth() : bounds.width , (!parentStack) ? documentHeight() : bounds.height), + stack = { + ctx: ctx, + opacity: setOpacity(ctx, element, parentStack), + cssPosition: getCSS(element, "position"), + borders: getBorderData(element), + transform: transform, + clip: (parentStack && parentStack.clip) ? Util.Extend( {}, parentStack.clip ) : null + }; + + setZ(element, stack, parentStack); + + // TODO correct overflow for absolute content residing under a static position + if (options.useOverflow === true && /(hidden|scroll|auto)/.test(getCSS(element, "overflow")) === true && /(BODY)/i.test(element.nodeName) === false){ + stack.clip = (stack.clip) ? clipBounds(stack.clip, bounds) : bounds; + } + + return stack; + } + + function getBackgroundBounds(borders, bounds, clip) { + var backgroundBounds = { + left: bounds.left + borders[3].width, + top: bounds.top + borders[0].width, + width: bounds.width - (borders[1].width + borders[3].width), + height: bounds.height - (borders[0].width + borders[2].width) + }; + + if (clip) { + backgroundBounds = clipBounds(backgroundBounds, clip); + } + + return backgroundBounds; + } + + function getBounds(element, transform) { + var bounds = (transform.matrix) ? Util.OffsetBounds(element) : Util.Bounds(element); + transform.origin[0] += bounds.left; + transform.origin[1] += bounds.top; + return bounds; + } + + function renderElement(element, parentStack, pseudoElement, ignoreBackground) { + var transform = getTransform(element, parentStack), + bounds = getBounds(element, transform), + image, + stack = createStack(element, parentStack, bounds, transform), + borders = stack.borders, + ctx = stack.ctx, + backgroundBounds = getBackgroundBounds(borders, bounds, stack.clip), + borderData = parseBorders(element, bounds, borders), + backgroundColor = (ignoreElementsRegExp.test(element.nodeName)) ? "#efefef" : getCSS(element, "backgroundColor"); + + + createShape(ctx, borderData.clip); + + ctx.save(); + ctx.clip(); + + if (backgroundBounds.height > 0 && backgroundBounds.width > 0 && !ignoreBackground) { + renderBackgroundColor(ctx, bounds, backgroundColor); + renderBackgroundImage(element, backgroundBounds, ctx); + } else if (ignoreBackground) { + stack.backgroundColor = backgroundColor; + } + + ctx.restore(); + + borderData.borders.forEach(function(border) { + renderBorders(ctx, border.args, border.color); + }); + + if (!pseudoElement) { + injectPseudoElements(element, stack); + } + + switch(element.nodeName){ + case "IMG": + if ((image = loadImage(element.getAttribute('src')))) { + renderImage(ctx, element, image, bounds, borders); + } else { + Util.log("html2canvas: Error loading <img>:" + element.getAttribute('src')); + } + break; + case "INPUT": + // TODO add all relevant type's, i.e. HTML5 new stuff + // todo add support for placeholder attribute for browsers which support it + if (/^(text|url|email|submit|button|reset)$/.test(element.type) && (element.value || element.placeholder || "").length > 0){ + renderFormValue(element, bounds, stack); + } + break; + case "TEXTAREA": + if ((element.value || element.placeholder || "").length > 0){ + renderFormValue(element, bounds, stack); + } + break; + case "SELECT": + if ((element.options||element.placeholder || "").length > 0){ + renderFormValue(element, bounds, stack); + } + break; + case "LI": + renderListItem(element, stack, backgroundBounds); + break; + case "CANVAS": + renderImage(ctx, element, element, bounds, borders); + break; + } + + return stack; + } + + function isElementVisible(element) { + return (getCSS(element, 'display') !== "none" && getCSS(element, 'visibility') !== "hidden" && !element.hasAttribute("data-html2canvas-ignore")); + } + + function parseElement (element, stack, pseudoElement) { + if (isElementVisible(element)) { + stack = renderElement(element, stack, pseudoElement, false) || stack; + if (!ignoreElementsRegExp.test(element.nodeName)) { + parseChildren(element, stack, pseudoElement); + } + } + } + + function parseChildren(element, stack, pseudoElement) { + Util.Children(element).forEach(function(node) { + if (node.nodeType === node.ELEMENT_NODE) { + parseElement(node, stack, pseudoElement); + } else if (node.nodeType === node.TEXT_NODE) { + renderText(element, node, stack); + } + }); + } + + function init() { + var background = getCSS(document.documentElement, "backgroundColor"), + transparentBackground = (Util.isTransparent(background) && element === document.body), + stack = renderElement(element, null, false, transparentBackground); + parseChildren(element, stack); + + if (transparentBackground) { + background = stack.backgroundColor; + } + + body.removeChild(hidePseudoElements); + return { + backgroundColor: background, + stack: stack + }; + } + + return init(); + }; + + function h2czContext(zindex) { + return { + zindex: zindex, + children: [] + }; + } + + _html2canvas.Preload = function( options ) { + + var images = { + numLoaded: 0, // also failed are counted here + numFailed: 0, + numTotal: 0, + cleanupDone: false + }, + pageOrigin, + Util = _html2canvas.Util, + methods, + i, + count = 0, + element = options.elements[0] || document.body, + doc = element.ownerDocument, + domImages = element.getElementsByTagName('img'), // Fetch images of the present element only + imgLen = domImages.length, + link = doc.createElement("a"), + supportCORS = (function( img ){ + return (img.crossOrigin !== undefined); + })(new Image()), + timeoutTimer; + + link.href = window.location.href; + pageOrigin = link.protocol + link.host; + + function isSameOrigin(url){ + link.href = url; + link.href = link.href; // YES, BELIEVE IT OR NOT, that is required for IE9 - http://jsfiddle.net/niklasvh/2e48b/ + var origin = link.protocol + link.host; + return (origin === pageOrigin); + } + + function start(){ + Util.log("html2canvas: start: images: " + images.numLoaded + " / " + images.numTotal + " (failed: " + images.numFailed + ")"); + if (!images.firstRun && images.numLoaded >= images.numTotal){ + Util.log("Finished loading images: # " + images.numTotal + " (failed: " + images.numFailed + ")"); + + if (typeof options.complete === "function"){ + options.complete(images); + } + + } + } + + // TODO modify proxy to serve images with CORS enabled, where available + function proxyGetImage(url, img, imageObj){ + var callback_name, + scriptUrl = options.proxy, + script; + + link.href = url; + url = link.href; // work around for pages with base href="" set - WARNING: this may change the url + + callback_name = 'html2canvas_' + (count++); + imageObj.callbackname = callback_name; + + if (scriptUrl.indexOf("?") > -1) { + scriptUrl += "&"; + } else { + scriptUrl += "?"; + } + scriptUrl += 'url=' + encodeURIComponent(url) + '&callback=' + callback_name; + script = doc.createElement("script"); + + window[callback_name] = function(a){ + if (a.substring(0,6) === "error:"){ + imageObj.succeeded = false; + images.numLoaded++; + images.numFailed++; + start(); + } else { + setImageLoadHandlers(img, imageObj); + img.src = a; + } + window[callback_name] = undefined; // to work with IE<9 // NOTE: that the undefined callback property-name still exists on the window object (for IE<9) + try { + delete window[callback_name]; // for all browser that support this + } catch(ex) {} + script.parentNode.removeChild(script); + script = null; + delete imageObj.script; + delete imageObj.callbackname; + }; + + script.setAttribute("type", "text/javascript"); + script.setAttribute("src", scriptUrl); + imageObj.script = script; + window.document.body.appendChild(script); + + } + + function loadPseudoElement(element, type) { + var style = window.getComputedStyle(element, type), + content = style.content; + if (content.substr(0, 3) === 'url') { + methods.loadImage(_html2canvas.Util.parseBackgroundImage(content)[0].args[0]); + } + loadBackgroundImages(style.backgroundImage, element); + } + + function loadPseudoElementImages(element) { + loadPseudoElement(element, ":before"); + loadPseudoElement(element, ":after"); + } + + function loadGradientImage(backgroundImage, bounds) { + var img = _html2canvas.Generate.Gradient(backgroundImage, bounds); + + if (img !== undefined){ + images[backgroundImage] = { + img: img, + succeeded: true + }; + images.numTotal++; + images.numLoaded++; + start(); + } + } + + function invalidBackgrounds(background_image) { + return (background_image && background_image.method && background_image.args && background_image.args.length > 0 ); + } + + function loadBackgroundImages(background_image, el) { + var bounds; + + _html2canvas.Util.parseBackgroundImage(background_image).filter(invalidBackgrounds).forEach(function(background_image) { + if (background_image.method === 'url') { + methods.loadImage(background_image.args[0]); + } else if(background_image.method.match(/\-?gradient$/)) { + if(bounds === undefined) { + bounds = _html2canvas.Util.Bounds(el); + } + loadGradientImage(background_image.value, bounds); + } + }); + } + + function getImages (el) { + var elNodeType = false; + + // Firefox fails with permission denied on pages with iframes + try { + Util.Children(el).forEach(getImages); + } + catch( e ) {} + + try { + elNodeType = el.nodeType; + } catch (ex) { + elNodeType = false; + Util.log("html2canvas: failed to access some element's nodeType - Exception: " + ex.message); + } + + if (elNodeType === 1 || elNodeType === undefined) { + loadPseudoElementImages(el); + try { + loadBackgroundImages(Util.getCSS(el, 'backgroundImage'), el); + } catch(e) { + Util.log("html2canvas: failed to get background-image - Exception: " + e.message); + } + loadBackgroundImages(el); + } + } + + function setImageLoadHandlers(img, imageObj) { + img.onload = function() { + if ( imageObj.timer !== undefined ) { + // CORS succeeded + window.clearTimeout( imageObj.timer ); + } + + images.numLoaded++; + imageObj.succeeded = true; + img.onerror = img.onload = null; + start(); + }; + img.onerror = function() { + if (img.crossOrigin === "anonymous") { + // CORS failed + window.clearTimeout( imageObj.timer ); + + // let's try with proxy instead + if ( options.proxy ) { + var src = img.src; + img = new Image(); + imageObj.img = img; + img.src = src; + + proxyGetImage( img.src, img, imageObj ); + return; + } + } + + images.numLoaded++; + images.numFailed++; + imageObj.succeeded = false; + img.onerror = img.onload = null; + start(); + }; + } + + methods = { + loadImage: function( src ) { + var img, imageObj; + if ( src && images[src] === undefined ) { + img = new Image(); + if ( src.match(/data:image\/.*;base64,/i) ) { + img.src = src.replace(/url\(['"]{0,}|['"]{0,}\)$/ig, ''); + imageObj = images[src] = { + img: img + }; + images.numTotal++; + setImageLoadHandlers(img, imageObj); + } else if ( isSameOrigin( src ) || options.allowTaint === true ) { + imageObj = images[src] = { + img: img + }; + images.numTotal++; + setImageLoadHandlers(img, imageObj); + img.src = src; + } else if ( supportCORS && !options.allowTaint && options.useCORS ) { + // attempt to load with CORS + + img.crossOrigin = "anonymous"; + imageObj = images[src] = { + img: img + }; + images.numTotal++; + setImageLoadHandlers(img, imageObj); + img.src = src; + } else if ( options.proxy ) { + imageObj = images[src] = { + img: img + }; + images.numTotal++; + proxyGetImage( src, img, imageObj ); + } + } + + }, + cleanupDOM: function(cause) { + var img, src; + if (!images.cleanupDone) { + if (cause && typeof cause === "string") { + Util.log("html2canvas: Cleanup because: " + cause); + } else { + Util.log("html2canvas: Cleanup after timeout: " + options.timeout + " ms."); + } + + for (src in images) { + if (images.hasOwnProperty(src)) { + img = images[src]; + if (typeof img === "object" && img.callbackname && img.succeeded === undefined) { + // cancel proxy image request + window[img.callbackname] = undefined; // to work with IE<9 // NOTE: that the undefined callback property-name still exists on the window object (for IE<9) + try { + delete window[img.callbackname]; // for all browser that support this + } catch(ex) {} + if (img.script && img.script.parentNode) { + img.script.setAttribute("src", "about:blank"); // try to cancel running request + img.script.parentNode.removeChild(img.script); + } + images.numLoaded++; + images.numFailed++; + Util.log("html2canvas: Cleaned up failed img: '" + src + "' Steps: " + images.numLoaded + " / " + images.numTotal); + } + } + } + + // cancel any pending requests + if(window.stop !== undefined) { + window.stop(); + } else if(document.execCommand !== undefined) { + document.execCommand("Stop", false); + } + if (document.close !== undefined) { + document.close(); + } + images.cleanupDone = true; + if (!(cause && typeof cause === "string")) { + start(); + } + } + }, + + renderingDone: function() { + if (timeoutTimer) { + window.clearTimeout(timeoutTimer); + } + } + }; + + if (options.timeout > 0) { + timeoutTimer = window.setTimeout(methods.cleanupDOM, options.timeout); + } + + Util.log('html2canvas: Preload starts: finding background-images'); + images.firstRun = true; + + getImages(element); + + Util.log('html2canvas: Preload: Finding images'); + // load <img> images + for (i = 0; i < imgLen; i+=1){ + methods.loadImage( domImages[i].getAttribute( "src" ) ); + } + + images.firstRun = false; + Util.log('html2canvas: Preload: Done.'); + if (images.numTotal === images.numLoaded) { + start(); + } + + return methods; + }; + + _html2canvas.Renderer = function(parseQueue, options){ + + // http://www.w3.org/TR/CSS21/zindex.html + function createRenderQueue(parseQueue) { + var queue = [], + rootContext; + + rootContext = (function buildStackingContext(rootNode) { + var rootContext = {}; + function insert(context, node, specialParent) { + var zi = (node.zIndex.zindex === 'auto') ? 0 : Number(node.zIndex.zindex), + contextForChildren = context, // the stacking context for children + isPositioned = node.zIndex.isPositioned, + isFloated = node.zIndex.isFloated, + stub = {node: node}, + childrenDest = specialParent; // where children without z-index should be pushed into + + if (node.zIndex.ownStacking) { + // '!' comes before numbers in sorted array + contextForChildren = stub.context = { '!': [{node:node, children: []}]}; + childrenDest = undefined; + } else if (isPositioned || isFloated) { + childrenDest = stub.children = []; + } + + if (zi === 0 && specialParent) { + specialParent.push(stub); + } else { + if (!context[zi]) { context[zi] = []; } + context[zi].push(stub); + } + + node.zIndex.children.forEach(function(childNode) { + insert(contextForChildren, childNode, childrenDest); + }); + } + insert(rootContext, rootNode); + return rootContext; + })(parseQueue); + + function sortZ(context) { + Object.keys(context).sort().forEach(function(zi) { + var nonPositioned = [], + floated = [], + positioned = [], + list = []; + + // positioned after static + context[zi].forEach(function(v) { + if (v.node.zIndex.isPositioned || v.node.zIndex.opacity < 1) { + // http://www.w3.org/TR/css3-color/#transparency + // non-positioned element with opactiy < 1 should be stacked as if it were a positioned element with ‘z-index: 0’ and ‘opacity: 1’. + positioned.push(v); + } else if (v.node.zIndex.isFloated) { + floated.push(v); + } else { + nonPositioned.push(v); + } + }); + + (function walk(arr) { + arr.forEach(function(v) { + list.push(v); + if (v.children) { walk(v.children); } + }); + })(nonPositioned.concat(floated, positioned)); + + list.forEach(function(v) { + if (v.context) { + sortZ(v.context); + } else { + queue.push(v.node); + } + }); + }); + } + + sortZ(rootContext); + + return queue; + } + + function getRenderer(rendererName) { + var renderer; + + if (typeof options.renderer === "string" && _html2canvas.Renderer[rendererName] !== undefined) { + renderer = _html2canvas.Renderer[rendererName](options); + } else if (typeof rendererName === "function") { + renderer = rendererName(options); + } else { + throw new Error("Unknown renderer"); + } + + if ( typeof renderer !== "function" ) { + throw new Error("Invalid renderer defined"); + } + return renderer; + } + + return getRenderer(options.renderer)(parseQueue, options, document, createRenderQueue(parseQueue.stack), _html2canvas); + }; + + _html2canvas.Util.Support = function (options, doc) { + + function supportSVGRendering() { + var img = new Image(), + canvas = doc.createElement("canvas"), + ctx = (canvas.getContext === undefined) ? false : canvas.getContext("2d"); + if (ctx === false) { + return false; + } + canvas.width = canvas.height = 10; + img.src = [ + "data:image/svg+xml,", + "<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10'>", + "<foreignObject width='10' height='10'>", + "<div xmlns='http://www.w3.org/1999/xhtml' style='width:10;height:10;'>", + "sup", + "</div>", + "</foreignObject>", + "</svg>" + ].join(""); + try { + ctx.drawImage(img, 0, 0); + canvas.toDataURL(); + } catch(e) { + return false; + } + _html2canvas.Util.log('html2canvas: Parse: SVG powered rendering available'); + return true; + } + + // Test whether we can use ranges to measure bounding boxes + // Opera doesn't provide valid bounds.height/bottom even though it supports the method. + + function supportRangeBounds() { + var r, testElement, rangeBounds, rangeHeight, support = false; + + if (doc.createRange) { + r = doc.createRange(); + if (r.getBoundingClientRect) { + testElement = doc.createElement('boundtest'); + testElement.style.height = "123px"; + testElement.style.display = "block"; + doc.body.appendChild(testElement); + + r.selectNode(testElement); + rangeBounds = r.getBoundingClientRect(); + rangeHeight = rangeBounds.height; + + if (rangeHeight === 123) { + support = true; + } + doc.body.removeChild(testElement); + } + } + + return support; + } + + return { + rangeBounds: supportRangeBounds(), + svgRendering: options.svgRendering && supportSVGRendering() + }; + }; + window.html2canvas = function(elements, opts) { + elements = (elements.length) ? elements : [elements]; + var queue, + canvas, + options = { + // general + logging: false, + elements: elements, + background: "#fff", + + // preload options + proxy: null, + timeout: 0, // no timeout + useCORS: false, // try to load images as CORS (where available), before falling back to proxy + allowTaint: false, // whether to allow images to taint the canvas, won't need proxy if set to true + + // parse options + svgRendering: false, // use svg powered rendering where available (FF11+) + ignoreElements: "IFRAME|OBJECT|PARAM", + useOverflow: true, + letterRendering: false, + chinese: false, + + // render options + + width: null, + height: null, + taintTest: true, // do a taint test with all images before applying to canvas + renderer: "Canvas" + }; + + options = _html2canvas.Util.Extend(opts, options); + + _html2canvas.logging = options.logging; + options.complete = function( images ) { + + if (typeof options.onpreloaded === "function") { + if ( options.onpreloaded( images ) === false ) { + return; + } + } + queue = _html2canvas.Parse( images, options ); + + if (typeof options.onparsed === "function") { + if ( options.onparsed( queue ) === false ) { + return; + } + } + + canvas = _html2canvas.Renderer( queue, options ); + + if (typeof options.onrendered === "function") { + options.onrendered( canvas ); + } + + + }; + + // for pages without images, we still want this to be async, i.e. return methods before executing + window.setTimeout( function(){ + _html2canvas.Preload( options ); + }, 0 ); + + return { + render: function( queue, opts ) { + return _html2canvas.Renderer( queue, _html2canvas.Util.Extend(opts, options) ); + }, + parse: function( images, opts ) { + return _html2canvas.Parse( images, _html2canvas.Util.Extend(opts, options) ); + }, + preload: function( opts ) { + return _html2canvas.Preload( _html2canvas.Util.Extend(opts, options) ); + }, + log: _html2canvas.Util.log + }; + }; + + window.html2canvas.log = _html2canvas.Util.log; // for renderers + window.html2canvas.Renderer = { + Canvas: undefined // We are assuming this will be used + }; + _html2canvas.Renderer.Canvas = function(options) { + options = options || {}; + + var doc = document, + safeImages = [], + testCanvas = document.createElement("canvas"), + testctx = testCanvas.getContext("2d"), + Util = _html2canvas.Util, + canvas = options.canvas || doc.createElement('canvas'); + + function createShape(ctx, args) { + ctx.beginPath(); + args.forEach(function(arg) { + ctx[arg.name].apply(ctx, arg['arguments']); + }); + ctx.closePath(); + } + + function safeImage(item) { + if (safeImages.indexOf(item['arguments'][0].src ) === -1) { + testctx.drawImage(item['arguments'][0], 0, 0); + try { + testctx.getImageData(0, 0, 1, 1); + } catch(e) { + testCanvas = doc.createElement("canvas"); + testctx = testCanvas.getContext("2d"); + return false; + } + safeImages.push(item['arguments'][0].src); + } + return true; + } + + function renderItem(ctx, item) { + switch(item.type){ + case "variable": + ctx[item.name] = item['arguments']; + break; + case "function": + switch(item.name) { + case "createPattern": + if (item['arguments'][0].width > 0 && item['arguments'][0].height > 0) { + try { + ctx.fillStyle = ctx.createPattern(item['arguments'][0], "repeat"); + } + catch(e) { + Util.log("html2canvas: Renderer: Error creating pattern", e.message); + } + } + break; + case "drawShape": + createShape(ctx, item['arguments']); + break; + case "drawImage": + if (item['arguments'][8] > 0 && item['arguments'][7] > 0) { + if (!options.taintTest || (options.taintTest && safeImage(item))) { + ctx.drawImage.apply( ctx, item['arguments'] ); + } + } + break; + default: + ctx[item.name].apply(ctx, item['arguments']); + } + break; + } + } + + return function(parsedData, options, document, queue, _html2canvas) { + var ctx = canvas.getContext("2d"), + newCanvas, + bounds, + fstyle, + zStack = parsedData.stack; + + canvas.width = canvas.style.width = options.width || zStack.ctx.width; + canvas.height = canvas.style.height = options.height || zStack.ctx.height; + + fstyle = ctx.fillStyle; + ctx.fillStyle = (Util.isTransparent(zStack.backgroundColor) && options.background !== undefined) ? options.background : parsedData.backgroundColor; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = fstyle; + + queue.forEach(function(storageContext) { + // set common settings for canvas + ctx.textBaseline = "bottom"; + ctx.save(); + + if (storageContext.transform.matrix) { + ctx.translate(storageContext.transform.origin[0], storageContext.transform.origin[1]); + ctx.transform.apply(ctx, storageContext.transform.matrix); + ctx.translate(-storageContext.transform.origin[0], -storageContext.transform.origin[1]); + } + + if (storageContext.clip){ + ctx.beginPath(); + ctx.rect(storageContext.clip.left, storageContext.clip.top, storageContext.clip.width, storageContext.clip.height); + ctx.clip(); + } + + if (storageContext.ctx.storage) { + storageContext.ctx.storage.forEach(function(item) { + renderItem(ctx, item); + }); + } + + ctx.restore(); + }); + + Util.log("html2canvas: Renderer: Canvas renderer done - returning canvas obj"); + + if (options.elements.length === 1) { + if (typeof options.elements[0] === "object" && options.elements[0].nodeName !== "BODY") { + // crop image to the bounds of selected (single) element + bounds = _html2canvas.Util.Bounds(options.elements[0]); + newCanvas = document.createElement('canvas'); + newCanvas.width = Math.ceil(bounds.width); + newCanvas.height = Math.ceil(bounds.height); + ctx = newCanvas.getContext("2d"); + + ctx.drawImage(canvas, bounds.left, bounds.top, bounds.width, bounds.height, 0, 0, bounds.width, bounds.height); + canvas = null; + return newCanvas; + } + } + + return canvas; + }; + }; + })(window,document);
\ No newline at end of file diff --git a/addons/point_of_sale/static/lib/sha1.js b/addons/point_of_sale/static/lib/sha1.js new file mode 100644 index 00000000..0fc324cf --- /dev/null +++ b/addons/point_of_sale/static/lib/sha1.js @@ -0,0 +1,159 @@ +/* from http://www.movable-type.co.uk/scripts/sha1.html */ +/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ +/* SHA-1 implementation in JavaScript (c) Chris Veness 2002-2014 / MIT Licence */ +/* */ +/* - see http://csrc.nist.gov/groups/ST/toolkit/secure_hashing.html */ +/* http://csrc.nist.gov/groups/ST/toolkit/examples.html */ +/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ + +/* jshint node:true *//* global define, escape, unescape */ +'use strict'; + + +/** + * SHA-1 hash function reference implementation. + * + * @namespace + */ +var Sha1 = {}; + + +/** + * Generates SHA-1 hash of string. + * + * @param {string} msg - (Unicode) string to be hashed. + * @returns {string} Hash of msg as hex character string. + */ +Sha1.hash = function(msg) { + // convert string to UTF-8, as SHA only deals with byte-streams + msg = msg.utf8Encode(); + + // constants [§4.2.1] + var K = [ 0x5a827999, 0x6ed9eba1, 0x8f1bbcdc, 0xca62c1d6 ]; + + // PREPROCESSING + + msg += String.fromCharCode(0x80); // add trailing '1' bit (+ 0's padding) to string [§5.1.1] + + // convert string msg into 512-bit/16-integer blocks arrays of ints [§5.2.1] + var l = msg.length/4 + 2; // length (in 32-bit integers) of msg + ‘1’ + appended length + var N = Math.ceil(l/16); // number of 16-integer-blocks required to hold 'l' ints + var M = new Array(N); + + for (var i=0; i<N; i++) { + M[i] = new Array(16); + for (var j=0; j<16; j++) { // encode 4 chars per integer, big-endian encoding + M[i][j] = (msg.charCodeAt(i*64+j*4)<<24) | (msg.charCodeAt(i*64+j*4+1)<<16) | + (msg.charCodeAt(i*64+j*4+2)<<8) | (msg.charCodeAt(i*64+j*4+3)); + } // note running off the end of msg is ok 'cos bitwise ops on NaN return 0 + } + // add length (in bits) into final pair of 32-bit integers (big-endian) [§5.1.1] + // note: most significant word would be (len-1)*8 >>> 32, but since JS converts + // bitwise-op args to 32 bits, we need to simulate this by arithmetic operators + M[N-1][14] = ((msg.length-1)*8) / Math.pow(2, 32); M[N-1][14] = Math.floor(M[N-1][14]); + M[N-1][15] = ((msg.length-1)*8) & 0xffffffff; + + // set initial hash value [§5.3.1] + var H0 = 0x67452301; + var H1 = 0xefcdab89; + var H2 = 0x98badcfe; + var H3 = 0x10325476; + var H4 = 0xc3d2e1f0; + + // HASH COMPUTATION [§6.1.2] + + var W = new Array(80); var a, b, c, d, e; + for (var i=0; i<N; i++) { + + // 1 - prepare message schedule 'W' + for (var t=0; t<16; t++) W[t] = M[i][t]; + for (var t=16; t<80; t++) W[t] = Sha1.ROTL(W[t-3] ^ W[t-8] ^ W[t-14] ^ W[t-16], 1); + + // 2 - initialise five working variables a, b, c, d, e with previous hash value + a = H0; b = H1; c = H2; d = H3; e = H4; + + // 3 - main loop + for (var t=0; t<80; t++) { + var s = Math.floor(t/20); // seq for blocks of 'f' functions and 'K' constants + var T = (Sha1.ROTL(a,5) + Sha1.f(s,b,c,d) + e + K[s] + W[t]) & 0xffffffff; + e = d; + d = c; + c = Sha1.ROTL(b, 30); + b = a; + a = T; + } + + // 4 - compute the new intermediate hash value (note 'addition modulo 2^32') + H0 = (H0+a) & 0xffffffff; + H1 = (H1+b) & 0xffffffff; + H2 = (H2+c) & 0xffffffff; + H3 = (H3+d) & 0xffffffff; + H4 = (H4+e) & 0xffffffff; + } + + return Sha1.toHexStr(H0) + Sha1.toHexStr(H1) + Sha1.toHexStr(H2) + + Sha1.toHexStr(H3) + Sha1.toHexStr(H4); +}; + + +/** + * Function 'f' [§4.1.1]. + * @private + */ +Sha1.f = function(s, x, y, z) { + switch (s) { + case 0: return (x & y) ^ (~x & z); // Ch() + case 1: return x ^ y ^ z; // Parity() + case 2: return (x & y) ^ (x & z) ^ (y & z); // Maj() + case 3: return x ^ y ^ z; // Parity() + } +}; + +/** + * Rotates left (circular left shift) value x by n positions [§3.2.5]. + * @private + */ +Sha1.ROTL = function(x, n) { + return (x<<n) | (x>>>(32-n)); +}; + + +/** + * Hexadecimal representation of a number. + * @private + */ +Sha1.toHexStr = function(n) { + // note can't use toString(16) as it is implementation-dependant, + // and in IE returns signed numbers when used on full words + var s="", v; + for (var i=7; i>=0; i--) { v = (n>>>(i*4)) & 0xf; s += v.toString(16); } + return s; +}; + + +/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ + + +/** Extend String object with method to encode multi-byte string to utf8 + * - monsur.hossa.in/2012/07/20/utf-8-in-javascript.html */ +if (typeof String.prototype.utf8Encode == 'undefined') { + String.prototype.utf8Encode = function() { + return unescape( encodeURIComponent( this ) ); + }; +} + +/** Extend String object with method to decode utf8 string to multi-byte */ +if (typeof String.prototype.utf8Decode == 'undefined') { + String.prototype.utf8Decode = function() { + try { + return decodeURIComponent( escape( this ) ); + } catch (e) { + return this; // invalid UTF-8? return as-is + } + }; +} + + +/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ +if (typeof module != 'undefined' && module.exports) module.exports = Sha1; // CommonJs export +if (typeof define == 'function' && define.amd) define([], function() { return Sha1; }); // AMD diff --git a/addons/point_of_sale/static/lib/waitfont.js b/addons/point_of_sale/static/lib/waitfont.js new file mode 100644 index 00000000..e9359896 --- /dev/null +++ b/addons/point_of_sale/static/lib/waitfont.js @@ -0,0 +1,59 @@ +// http://stackoverflow.com/questions/4383226/using-jquery-to-know-when-font-face-fonts-are-loaded +(function(){ + function waitForWebfonts(fonts, callback) { + var loadedFonts = 0; + for(var i = 0, l = fonts.length; i < l; ++i) { + (function(font) { + var node = document.createElement('span'); + // Characters that vary significantly among different fonts + node.innerHTML = 'giItT1WQy@!-/#'; + // Visible - so we can measure it - but not on the screen + node.style.position = 'absolute'; + node.style.left = '-10000px'; + node.style.top = '-10000px'; + // Large font size makes even subtle changes obvious + node.style.fontSize = '300px'; + // Reset any font properties + node.style.fontFamily = 'sans-serif'; + node.style.fontVariant = 'normal'; + node.style.fontStyle = 'normal'; + node.style.fontWeight = 'normal'; + node.style.letterSpacing = '0'; + document.body.appendChild(node); + + // Remember width with no applied web font + var width = node.offsetWidth; + + node.style.fontFamily = font; + + var interval; + function checkFont() { + // Compare current width with original width + if(node && node.offsetWidth != width) { + ++loadedFonts; + node.parentNode.removeChild(node); + node = null; + } + + // If all fonts have been loaded + if(loadedFonts >= fonts.length) { + if(interval) { + clearInterval(interval); + } + if(loadedFonts == fonts.length) { + callback(); + return true; + } + } + }; + + if(!checkFont()) { + interval = setInterval(checkFont, 50); + } + })(fonts[i]); + } + } + window.waitForWebfonts = waitForWebfonts; +})(); + + diff --git a/addons/point_of_sale/static/src/css/chrome50.css b/addons/point_of_sale/static/src/css/chrome50.css new file mode 100644 index 00000000..67ecddcc --- /dev/null +++ b/addons/point_of_sale/static/src/css/chrome50.css @@ -0,0 +1,6 @@ +.pos .screen .content-cell{ + height: 100%; +} +.pos .subwindow .subwindow-container{ + height: 100%; +}
\ No newline at end of file diff --git a/addons/point_of_sale/static/src/css/customer_facing_display.css b/addons/point_of_sale/static/src/css/customer_facing_display.css new file mode 100644 index 00000000..824d220b --- /dev/null +++ b/addons/point_of_sale/static/src/css/customer_facing_display.css @@ -0,0 +1,730 @@ +@keyframes item_in { + 0% { + opacity: 0; + margin-top: -30px; + } + 50% { + margin-top: 0; + } + 100% { + opacity: 1; + } +} +@-webkit-keyframes item_in { + 0% { + opacity: 0; + margin-top: -30px; + } + 50% { + margin-top: 0; + } + 100% { + opacity: 1; + } +} +body { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; + font-smooth: always; +} +body .pos-customer_facing_display { + background-color: #f6f6f6; + font-size: 2vw; + font-family: Futura, HelveticaNeue, Helvetica, Arial, "Lucida Grande", sans-serif; + font-weight: 300; + width: 100%; + height: 100%; + padding: 0; + -webkit-display: flex; + -moz-display: flex; + -ms-display: flex; + -o-display: flex; + display: flex; + -webkit-flex-direction: row; + -moz-flex-direction: row; + -ms-flex-direction: row; + -o-flex-direction: row; + flex-direction: row; +} +body .pos-customer_facing_display .pos-customer_products, +body .pos-customer_facing_display .pos-payment_info { + height: 100%; + padding: 2%; + -webkit-display: flex; + -moz-display: flex; + -ms-display: flex; + -o-display: flex; + display: flex; + -webkit-flex-direction: column; + -moz-flex-direction: column; + -ms-flex-direction: column; + -o-flex-direction: column; + flex-direction: column; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -moz-box-flex: 1; + -ms-flex-positive: 1; + flex-grow: 1; +} +body .pos-customer_facing_display .pos_orderlines { + width: 100%; + height: 100%; + -webkit-display: flex; + -moz-display: flex; + -ms-display: flex; + -o-display: flex; + display: flex; + -webkit-flex-direction: column; + -moz-flex-direction: column; + -ms-flex-direction: column; + -o-flex-direction: column; + flex-direction: column; +} +body .pos-customer_facing_display .pos_orderlines .pos_orderlines_list { + overflow-y: scroll; + padding-right: 1.5vw; + position: relative; + height: 100%; +} +body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item { + margin-bottom: 1vw; + padding: 1%; + border-radius: 0.3vw; + height: auto; + -webkit-box-flex: 0 1 auto; + -webkit-flex: 0 1 auto; + -moz-box-flex: 0 1 auto; + -ms-flex: 0 1 auto; + flex: 0 1 auto; + -webkit-display: flex; + -moz-display: flex; + -ms-display: flex; + -o-display: flex; + display: flex; + -webkit-flex-direction: row; + -moz-flex-direction: row; + -ms-flex-direction: row; + -o-flex-direction: row; + flex-direction: row; + -webkit-box-align: center; + -webkit-align-items: center; + -moz-box-align: center; + -ms-flex-align: center; + -ms-grid-row-align: center; + align-items: center; +} +body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item:last-of-type { + animation: item_in 1s ease; +} +body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item.pos_orderlines_header { + background-color: transparent; + box-shadow: none; + animation: none; +} +body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item.pos_orderlines_header > div, body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item.pos_orderlines_header > div:last-child { + border-left-width: 0; + text-align: center; + font-size: 70%; + font-weight: normal; +} +body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item.pos_orderlines_header > div:last-child { + text-align: left; +} +body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item > div { + width: 5%; + text-align: left; + margin-right: 4%; + font-size: 80%; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -moz-box-flex: 1; + -ms-flex-positive: 1; + flex-grow: 1; +} +body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item > div:first-child { + margin-right: 2%; + -webkit-box-flex: 1 1 1%; + -webkit-flex: 1 1 1%; + -moz-box-flex: 1 1 1%; + -ms-flex: 1 1 1%; + flex: 1 1 1%; +} +body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item > div:nth-child(2) { + width: 40%; + border-left: 1px solid; + padding-left: 2%; +} +body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item > div:nth-child(3) { + text-align: center; +} +body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item > div:last-child { + margin-right: 0; + font-weight: bold; +} +body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item > div div { + background-position: center; + background-size: cover; + padding-top: 75%; + display: block; +} +body .pos-customer_facing_display .pos-payment_info { + max-width: 30%; + padding: 2% 2% 1% 2%; + -webkit-flex-direction: column; + -moz-flex-direction: column; + -ms-flex-direction: column; + -o-flex-direction: column; + flex-direction: column; + -webkit-box-pack: space-between; + -webkit-justify-content: space-between; + -moz-box-pack: space-between; + -ms-flex-pack: space-between; + justify-content: space-between; +} +body .pos-customer_facing_display .pos-payment_info .pos-adv, +body .pos-customer_facing_display .pos-payment_info .pos-company_logo { + background-position: center top; + background-size: contain; + background-repeat: no-repeat; +} +body .pos-customer_facing_display .pos-payment_info .pos-adv[style*="url(http://placehold.it"], +body .pos-customer_facing_display .pos-payment_info .pos-company_logo[style*="url(http://placehold.it"] { + background-color: #ccc; +} +body .pos-customer_facing_display .pos-payment_info .pos-company_logo { + background-image: url(/logo); + margin-bottom: 10%; + -webkit-box-flex: 0 0 20%; + -webkit-flex: 0 0 20%; + -moz-box-flex: 0 0 20%; + -ms-flex: 0 0 20%; + flex: 0 0 20%; +} +body .pos-customer_facing_display .pos-payment_info .pos-adv { + margin-bottom: 5%; + border-bottom: 10px solid transparent; + box-shadow: 0 1px rgba(246, 246, 246, 0.2); + -webkit-box-flex: 1 1 60%; + -webkit-flex: 1 1 60%; + -moz-box-flex: 1 1 60%; + -ms-flex: 1 1 60%; + flex: 1 1 60%; +} +body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total, +body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-paymentlines { + -webkit-flex-direction: row; + -moz-flex-direction: row; + -ms-flex-direction: row; + -o-flex-direction: row; + flex-direction: row; + -webkit-display: flex; + -moz-display: flex; + -ms-display: flex; + -o-display: flex; + display: flex; + -webkit-flex-wrap: wrap; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -webkit-box-pack: space-between; + -webkit-justify-content: space-between; + -moz-box-pack: space-between; + -ms-flex-pack: space-between; + justify-content: space-between; + -webkit-box-align: baseline; + -webkit-align-items: baseline; + -moz-box-align: baseline; + -ms-flex-align: baseline; + -ms-grid-row-align: baseline; + align-items: baseline; +} +body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total > div, +body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-paymentlines > div { + -webkit-box-flex: 1 0 48%; + -webkit-flex: 1 0 48%; + -moz-box-flex: 1 0 48%; + -ms-flex: 1 0 48%; + flex: 1 0 48%; +} +body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total > div:nth-child(even), +body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-paymentlines > div:nth-child(even) { + font-weight: bold; + font-size: 120%; + margin-right: 0; +} +body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total { + font-size: 2vw; +} +body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-paymentlines { + margin-top: 2%; + font-size: 1.5vw; + line-height: 1.3; +} +body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-odoo_logo_container { + text-align: right; + margin-top: 10%; + -webkit-box-flex: 0 1 auto; + -webkit-flex: 0 1 auto; + -moz-box-flex: 0 1 auto; + -ms-flex: 0 1 auto; + flex: 0 1 auto; +} +body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-odoo_logo_container img { + max-width: 40px; +} +@media all and (orientation: portrait) { + body .pos-customer_facing_display { + font-size: 2vh; + height: 100%; + -webkit-flex-direction: column; + -moz-flex-direction: column; + -ms-flex-direction: column; + -o-flex-direction: column; + flex-direction: column; + } + body .pos-customer_facing_display:before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 17vh; + } + body .pos-customer_facing_display .pos-payment_info .pos-adv { + position: fixed; + top: 0; + left: 0; + height: 15vh; + width: 99vw; + margin: 0.5vh; + border-width: 0; + -webkit-display: flex; + -moz-display: flex; + -ms-display: flex; + -o-display: flex; + display: flex; + } + body .pos-customer_facing_display.pos-js_no_ADV:before { + display: none; + } + body .pos-customer_facing_display.pos-js_no_ADV .pos-customer_products { + padding-top: 0; + } + body .pos-customer_facing_display .pos-customer_products { + padding-top: 17vh; + height: 72vw; + overflow: hidden; + } + body .pos-customer_facing_display .pos-customer_products .pos_orderlines { + -webkit-box-flex: 1 0 auto; + -webkit-flex: 1 0 auto; + -moz-box-flex: 1 0 auto; + -ms-flex: 1 0 auto; + flex: 1 0 auto; + } + body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_item > div:nth-child(2) { + width: 30%; + } + body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_item.pos_orderlines_header div { + font-size: 90%; + } + body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_list { + padding-right: 1.5vh; + height: auto; + } + body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_list .pos_orderlines_item { + box-shadow: 0 0.1vh 0.1vh #dddddd; + margin-bottom: 1vh; + } + body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_list .pos_orderlines_item > div { + font-size: 100%; + } + body .pos-customer_facing_display .pos-payment_info { + max-width: 100%; + overflow: hidden; + padding-top: 0; + min-height: 120px; + -webkit-box-flex: 0 1 23vw; + -webkit-flex: 0 1 23vw; + -moz-box-flex: 0 1 23vw; + -ms-flex: 0 1 23vw; + flex: 0 1 23vw; + -webkit-flex-direction: row; + -moz-flex-direction: row; + -ms-flex-direction: row; + -o-flex-direction: row; + flex-direction: row; + -webkit-box-align: center; + -webkit-align-items: center; + -moz-box-align: center; + -ms-flex-align: center; + -ms-grid-row-align: center; + align-items: center; + -webkit-box-pack: space-between; + -webkit-justify-content: space-between; + -moz-box-pack: space-between; + -ms-flex-pack: space-between; + justify-content: space-between; + } + body .pos-customer_facing_display .pos-payment_info .pos-company_logo { + margin: 0; + background-position: left center; + margin-right: 5%; + height: 100%; + padding: 0; + -webkit-box-flex: 1 1 20%; + -webkit-flex: 1 1 20%; + -moz-box-flex: 1 1 20%; + -ms-flex: 1 1 20%; + flex: 1 1 20%; + } + body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details { + -webkit-box-flex: 0 1 50%; + -webkit-flex: 0 1 50%; + -moz-box-flex: 0 1 50%; + -ms-flex: 0 1 50%; + flex: 0 1 50%; + -webkit-flex-direction: column; + -moz-flex-direction: column; + -ms-flex-direction: column; + -o-flex-direction: column; + flex-direction: column; + min-width: 170px; + } + body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total { + font-size: 3vw; + } + body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total .pos_total-amount { + font-size: 3.5vw; + } + body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-paymentlines { + margin-top: 2%; + font-size: 80%; + line-height: 1.2; + } + body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-odoo_logo_container { + position: absolute; + right: 3%; + bottom: 1%; + } +} +@media all and (orientation: portrait) and (max-width: 340px) { + body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_list { + padding-right: 0; + } + body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_list .pos_orderlines_item > div { + font-size: 70%; + } + body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_header > div { + font-size: 60%; + } + body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_header > div:last-child { + text-align: center; + } + body .pos-customer_facing_display .pos-payment_info .pos-company_logo { + display: none !important; + } + body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details { + -webkit-box-flex: 1 0 100%; + -webkit-flex: 1 0 100%; + -moz-box-flex: 1 0 100%; + -ms-flex: 1 0 100%; + flex: 1 0 100%; + } + body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total { + font-size: 6vw; + } + body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total .pos_total-amount { + font-size: 6.5vw; + } +} + +body .pos-hidden { + opacity: 0; +} + +.pos-palette_01 .pos-payment_info { + background: #3E3E3E; + color: #f6f6f6; +} +.pos-palette_01 .pos-customer_products { + background: #f6f6f6; + color: #585858; +} +.pos-palette_01 .pos-customer_products .pos_orderlines_list .pos_orderlines_item { + background-color: white; + color: #3E3E3E; + box-shadow: 0 0.1vh 0.1vh #aaaaaa; +} +.pos-palette_01 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) { + border-color: rgba(62, 62, 62, 0.3); +} +@media all and (orientation: portrait) { + .pos-palette_01:before { + background: #3E3E3E; + } +} + +.pos-palette_02 .pos-payment_info { + background: #364152; + color: #e6e7e8; +} +.pos-palette_02 .pos-customer_products { + background: #ecf2f6; + color: #364152; +} +.pos-palette_02 .pos-customer_products .pos_orderlines_list .pos_orderlines_item { + background-color: white; + color: #3E3E3E; + box-shadow: 0 0.1vh 0.1vh #364152; +} +.pos-palette_02 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) { + border-color: rgba(62, 62, 62, 0.3); +} +@media all and (orientation: portrait) { + .pos-palette_02:before { + background: #364152; + } +} + +.pos-palette_03 .pos-payment_info { + background: #1BA39C; + color: #f6f6f6; +} +.pos-palette_03 .pos-customer_products { + background: #ececec; + color: #585858; +} +.pos-palette_03 .pos-customer_products .pos_orderlines_list .pos_orderlines_item { + background-color: white; + color: #3E3E3E; + box-shadow: 0 0.1vh 0.1vh #a0a0a0; +} +.pos-palette_03 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) { + border-color: rgba(62, 62, 62, 0.3); +} +@media all and (orientation: portrait) { + .pos-palette_03:before { + background: #1BA39C; + } +} + +.pos-palette_04 .pos-payment_info { + background: #0b7b6c; + color: #f6f6f6; +} +.pos-palette_04 .pos-customer_products { + background: #efeeec; + color: #585858; +} +.pos-palette_04 .pos-customer_products .pos_orderlines_list .pos_orderlines_item { + background-color: white; + color: #3E3E3E; + box-shadow: 0 0.1vh 0.1vh #a9a499; +} +.pos-palette_04 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) { + border-color: rgba(62, 62, 62, 0.3); +} +@media all and (orientation: portrait) { + .pos-palette_04:before { + background: #0b7b6c; + } +} + +.pos-palette_05 .pos-payment_info { + background: #E26868; + color: #f6f6f6; +} +.pos-palette_05 .pos-customer_products { + background: #ececec; + color: #585858; +} +.pos-palette_05 .pos-customer_products .pos_orderlines_list .pos_orderlines_item { + background-color: white; + color: #3E3E3E; + box-shadow: 0 0.1vh 0.1vh #a0a0a0; +} +.pos-palette_05 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) { + border-color: rgba(62, 62, 62, 0.3); +} +@media all and (orientation: portrait) { + .pos-palette_05:before { + background: #E26868; + } +} + +.pos-palette_06 .pos-payment_info { + background: #9E373B; + color: #f6f6f6; +} +.pos-palette_06 .pos-customer_products { + background: #f6f6f6; + color: #585858; +} +.pos-palette_06 .pos-customer_products .pos_orderlines_list .pos_orderlines_item { + background-color: white; + color: #3E3E3E; + box-shadow: 0 0.1vh 0.1vh #aaaaaa; +} +.pos-palette_06 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) { + border-color: rgba(62, 62, 62, 0.3); +} +@media all and (orientation: portrait) { + .pos-palette_06:before { + background: #9E373B; + } +} + +.pos-palette_07 .pos-payment_info { + background: #ce9934; + color: white; +} +.pos-palette_07 .pos-customer_products { + background: #ececec; + color: #585858; +} +.pos-palette_07 .pos-customer_products .pos_orderlines_list .pos_orderlines_item { + background-color: white; + color: #3E3E3E; + box-shadow: 0 0.1vh 0.1vh #a0a0a0; +} +.pos-palette_07 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) { + border-color: rgba(62, 62, 62, 0.3); +} +@media all and (orientation: portrait) { + .pos-palette_07:before { + background: #ce9934; + } +} + +.pos-palette_08 .pos-payment_info { + background: #a48c77; + color: #f6f6f6; +} +.pos-palette_08 .pos-customer_products { + background: #ececec; + color: #585858; +} +.pos-palette_08 .pos-customer_products .pos_orderlines_list .pos_orderlines_item { + background-color: white; + color: #3E3E3E; + box-shadow: 0 0.1vh 0.1vh #a0a0a0; +} +.pos-palette_08 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) { + border-color: rgba(62, 62, 62, 0.3); +} +@media all and (orientation: portrait) { + .pos-palette_08:before { + background: #a48c77; + } +} + +.pos-palette_09 .pos-payment_info { + background: linear-gradient(30deg, #014d43, #127e71); + color: #f6f6f6; +} +.pos-palette_09 .pos-customer_products { + background: #ececec; + color: #585858; +} +.pos-palette_09 .pos-customer_products .pos_orderlines_list .pos_orderlines_item { + background-color: white; + color: #3E3E3E; + box-shadow: 0 0.1vh 0.1vh #a0a0a0; +} +.pos-palette_09 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) { + border-color: rgba(62, 62, 62, 0.3); +} +@media all and (orientation: portrait) { + .pos-palette_09:before { + background: linear-gradient(30deg, #014d43, #127e71); + } +} + +.pos-palette_10 .pos-payment_info { + background: linear-gradient(30deg, #e2316c, #ea4c89); + color: white; +} +.pos-palette_10 .pos-customer_products { + background: #ececec; + color: #585858; +} +.pos-palette_10 .pos-customer_products .pos_orderlines_list .pos_orderlines_item { + background-color: white; + color: #3E3E3E; + box-shadow: 0 0.1vh 0.1vh #a0a0a0; +} +.pos-palette_10 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) { + border-color: rgba(62, 62, 62, 0.3); +} +@media all and (orientation: portrait) { + .pos-palette_10:before { + background: linear-gradient(30deg, #e2316c, #ea4c89); + } +} + +.pos-palette_11 .pos-payment_info { + background: linear-gradient(30deg, #362b3d, #5b4a63); + color: white; +} +.pos-palette_11 .pos-customer_products { + background: #ececec; + color: #585858; +} +.pos-palette_11 .pos-customer_products .pos_orderlines_list .pos_orderlines_item { + background-color: white; + color: #3E3E3E; + box-shadow: 0 0.1vh 0.1vh #a0a0a0; +} +.pos-palette_11 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) { + border-color: rgba(62, 62, 62, 0.3); +} +@media all and (orientation: portrait) { + .pos-palette_11:before { + background: linear-gradient(30deg, #362b3d, #5b4a63); + } +} + +.pos-palette_12 .pos-payment_info { + background: #434343; + color: #e6e6e6; +} +.pos-palette_12 .pos-customer_products { + background: #5b5b5b; + color: #bdb9b9; +} +.pos-palette_12 .pos-customer_products .pos_orderlines_list .pos_orderlines_item { + background-color: #f5f5f5; + color: #3E3E3E; + box-shadow: 0 0.1vh 0.1vh #0f0f0f; +} +.pos-palette_12 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) { + border-color: rgba(62, 62, 62, 0.3); +} +@media all and (orientation: portrait) { + .pos-palette_12:before { + background: #434343; + } +} + +.pos-palette_13 .pos-payment_info { + background: linear-gradient(30deg, #1a1b1f, #3d3f45); + color: white; +} +.pos-palette_13 .pos-customer_products { + background: #a2a2ab; + color: #f6f6f6; +} +.pos-palette_13 .pos-customer_products .pos_orderlines_list .pos_orderlines_item { + background-color: #f6f6f6; + color: #3E3E3E; + box-shadow: 0 0.1vh 0.1vh #55555f; +} +.pos-palette_13 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) { + border-color: rgba(62, 62, 62, 0.3); +} +@media all and (orientation: portrait) { + .pos-palette_13:before { + background: linear-gradient(30deg, #1a1b1f, #3d3f45); + } +} diff --git a/addons/point_of_sale/static/src/css/keyboard.css b/addons/point_of_sale/static/src/css/keyboard.css new file mode 100644 index 00000000..ee5fcfe3 --- /dev/null +++ b/addons/point_of_sale/static/src/css/keyboard.css @@ -0,0 +1,153 @@ +/* Onscreen Keyboard http://net.tutsplus.com/tutorials/javascript-ajax/creating-a-keyboard-with-css-and-jquery/ */ + +/*rtl:ignore*/ +.pos .keyboard_frame{ + display: none; + position:absolute; + left: 0; + bottom: 0px; + margin: 0; + padding: 0; + padding-top: 15px; + width: 100%; + height: 0px; /* 235px, animated via jquery */ + background-color: #BBB; + overflow:hidden; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + z-index:10000; +} +.pos .keyboard_frame .close_button{ + height:40px; + width:60px; + text-align:center; + background-color: #DDD; + font-size: 12px; + line-height:40px; + -webkit-border-radius: 5px; + position:absolute; + top:0; + right:15px; + cursor: pointer; +} +/*rtl:ignore*/ +.pos .keyboard li { + float: left; + text-align: center; + background-color: #fff; + border: 1px solid #f0f0f0; + top:0; + cursor: pointer; + -moz-border-radius: 5px; + -webkit-border-radius: 5px; + -webkit-transition-property: top, background-color; + -webkit-transition-duration: 0.2s; + -webkit-transition-timing-function: linear; +} +/*rtl:ignore*/ +.pos .keyboard li:active{ + position: relative; + top: 2px; + left: 0px; + border-color: #ddd; + background-color:#e5e5e5; + cursor: pointer; + -webkit-transition-property: top, background-color; + -webkit-transition-duration: 0.1s; + -webkit-transition-timing-function: ease-out; +} +.pos .uppercase { + text-transform: uppercase; +} +.pos .on { + display: none; +} +/*rtl:ignore*/ +.pos .firstitem{ + clear: left; +} +/*rtl:ignore*/ +.pos .keyboard .lastitem { + margin-right: 0 !important; +} + +/* ---- full sized keyboard ---- */ + +.pos .full_keyboard { + list-style: none; + font-size: 14px; + width: 685px; + height: 100%; + margin-left: auto !important; + margin-right: auto !important; +} +/*rtl:ignore*/ +.pos .full_keyboard li{ + margin: 0 5px 5px 0 !important; + width: 40px; + height: 40px; + line-height: 40px; +} +.pos .full_keyboard .tab, .pos .full_keyboard .delete { + width: 70px; +} +.pos .full_keyboard .capslock { + width: 80px; +} +.pos .full_keyboard .return { + width: 77px; +} +.pos .full_keyboard .left-shift { + width: 95px; +} +.pos .full_keyboard .right-shift { + width: 109px; +} + +/*rtl:ignore*/ +.pos .full_keyboard .space { + clear: left; + width: 673px; +} + +/* ---- simplified keyboard ---- */ + +.pos .simple_keyboard { + list-style: none; + font-size: 16px; + width: 555px; + height: 220px; + margin-left: auto !important; + margin-right: auto !important; +} +/*rtl:ignore*/ +.pos .simple_keyboard li{ + margin: 0 5px 5px 0 !important; + width: 49px; + height: 49px; + line-height: 49px; +} +/*rtl:ignore*/ +.pos .simple_keyboard .firstitem.row_asdf{ + margin-left:25px !important; +} +/*rtl:ignore*/ +.pos .simple_keyboard .firstitem.row_zxcv{ + margin-left:55px !important; +} +.pos .simple_keyboard .delete{ + width: 103px; +} +.pos .simple_keyboard .return{ + width: 103px; +} +.pos .simple_keyboard .space{ + width:273px; +} +.pos .simple_keyboard .numlock{ + width:103px; +} 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 00000000..b124326c --- /dev/null +++ b/addons/point_of_sale/static/src/css/pos.css @@ -0,0 +1,3555 @@ +/* --- Fonts --- */ + +@font-face{ + font-family: 'Inconsolata'; + src: url(../fonts/Inconsolata.otf); +} + +/* --- Styling of OpenERP Elements --- */ +.ui-dialog, .modal-dialog { + background: white; + padding: 10px; + border-radius: 3px; + font-family: sans-serif; + box-shadow: 0px 10px 40px rgba(0,0,0,0.4); + position: absolute; + top: 30px; + height: 400px; + overflow: scroll; +} +.ui-dialog button, .modal-dialog button { + padding: 8px; + min-width: 48px; +} +.ui-dialog .ui-icon-closethick{ + float: right; +} +div.modal.in { + position: absolute; + background: white; + padding: 20px; + box-shadow: 0px 10px 20px black; + border-radius: 3px; + max-width: 600px; + max-height: 400px; + margin-top: -200px; + margin-left: -300px; + top: 50%; + left: 50%; +} + +/* --- Generic Restyling and Resets --- */ + +html { + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + font-family: sans-serif; +} + +table { + border-spacing: 0px; + border-collapse: collapse; +} +td { + padding: 0px; +} + +.oe_hidden{ + display: none !important; +} +.oe_invisible{ + visibility: hidden !important; +} +.clearfix:after { + content:" "; + display: block; + visibility: hidden; + line-height: 0; + height: 0; + clear: both; +} + + +.pos input::-webkit-outer-spin-button, +.pos input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.pos button{ + box-shadow: none; + outline: none; + border: none; + font-family: 'Lato'; +} +.pos button:hover{ + background: default; +} +.pos textarea { + font-family: "Lato"; + font-size: 20px; + color: #444; + padding: 10px; + border-radius: 3px; + border: none; + box-shadow: 0px 0px 0px 1px rgb(220,220,220) inset; +} +.pos textarea:focus { + outline: none; + box-shadow: 0px 0px 0px 3px #6EC89B; +} + + +.pos .oe_hidden{ + display: none !important; +} + +.pos ul, .pos li { + margin: 0; + padding: 0; + list-style-type: none; +} + +.pos { + direction: ltr; + padding: 0; + margin: 0; + background-color: #f0eeee; + font-family: "Lato","Lucida Grande", Helvetica, Verdana, Arial; + color: #555555; + font-size: 12px; + /* Some elements inside .pos are allowed to be moved/resized. When + * this 'move/resize' event is performed in touch devices, .pos + * element also tries to scroll (as the default action of touchstart+ + * touchmove events). + * Position is fixed to prevent the movement of .pos element during + * the described touch events. + */ + position: fixed; + left: 0; + top: 0; + width: 100%; + height: 100%; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + text-shadow: none; + overflow: hidden; +} + +/* ********* The black loading screen ********* */ + +.pos .loader{ + background-color: #222; + position:absolute; + left:0px; + top:0px; + width:100%; + height:100%; + z-index: 999; + text-align: center; + font-family: Lato; + color: #555555; +} + +.pos .loader-feedback{ + width: 400px; + height: 160px; + margin: -60px -200px; + position: absolute; + left: 50%; top: 50%; + text-align: center; +} +.pos .loader-feedback h1{ + font-weight: 300; +} +.pos .loader-feedback .progressbar{ + background: rgb(73,73,73); + height: 1px; +} +.pos .loader-feedback .progressbar > .progress{ + height: 100%; + background: white; + width: 0%; + box-shadow: 0px 0px 5px rgba(255,255,255,0.35); +} +.pos .loader-feedback .button{ + display: inline-block; + margin: 25px auto; + line-height: 42px; + padding: 0px 16px; + font-size: 20px; + font-weight: 300; + border: solid 1px; + border-radius: 5px; + cursor: pointer; +} +.pos .loader-feedback .button:active{ + color: #222; + background: #555555; +} +/* ********* Generic Layout Constructs ********* */ + +.pos .window{ + position: absolute; + top: 0px; + left: 0px; + width: 100%; + height: 100%; + display: table; + border: none; + overflow: hidden; +} +.pos .window .subwindow{ + display: table-row; + width: 100%; + height: 100%; +} +.pos .window .subwindow.collapsed{ + height: 0px; +} +.pos .window .subwindow-container .collapsed{ + height: 0px; +} +.pos .subwindow .subwindow-container{ + display: table-cell; + position: relative; +} +/* firefox seems to ignore the relative positionning of the subwindow-container + * putting this inside subwindow-container fixes it. + */ +.pos .subwindow .subwindow-container-fix{ + height: 100%; + position: relative; +} + +.pos .clientlist-screen .window, +.pos .clientlist-screen .full-content .subwindow{ + display: block; +} +.pos .clientlist-screen .full-content .subwindow-container{ + display: block; + height: 100%; +} +.pos .clientlist-screen .full-content .subwindow.collapsed, +.pos .clientlist-screen .full-content .subwindow-container.collapsed{ + height: auto; +} + +/* ---- Scrollers ----- */ + +.pos .scroller-container{ + position: absolute; + top: 0px; + left: 0px; + right: 0px; + bottom: 0px; +} +.pos .scroller{ + width: 100%; + height: 100%; + overflow: hidden; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} +.pos .scroller.horizontal{ + overflow-y: hidden; + overflow-x: auto; +} +.pos .scroller-content{ + -webkit-transform: translate3d(0,0,0); +} +.pos .scroller-container ::-webkit-scrollbar{ + width: 10px; + height: 10px; +} +.pos .scroller-container ::-webkit-scrollbar-track{ + background: rgb(224,224,224); + border-left: solid 1px rgb(200,200,200); + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; +} +.pos .scroller-container ::-webkit-scrollbar-thumb{ + background: rgb(168,168,168); + min-height: 30px; +} + +/* ********* Generic element styling ********* */ + +.pos a { + text-decoration: none; + color: #555555; +} +.pos button, .pos a.button { + display: inline-block; + cursor: pointer; + padding: 4px 10px; + font-size: 11px; + border: 1px solid #cacaca; + background: #e2e2e2; + border-radius: 3px; +} +.pos ul, .pos ol { + padding: 0; + margin: 0; +} +.pos li { + list-style-type: none; +} +.pos .pos-right-align { + text-align: right; +} +.pos .pos-center-align { + text-align: center; +} +.pos .pos-disc-font { + font-size: 12px; + font-style:italic; + color: #808080; +} + +/* ********* The black header bar ********* */ + + +.pos .pos-topheader { + position:absolute; + left:0; + top:0; + width: 100%; + height: 48px; + margin:0; + padding:0; + color: gray; + background: #875A7B; + display: flex; +} + +/* a) The left part of the top-bar */ + +.pos .pos-branding{ + min-width: 503px; + max-width: 503px; + flex-grow: 1; + height:100%; + margin:0; + padding:0; + text-align:left; + line-height:100%; + vertical-align: middle; + display: flex; + justify-content: space-between; +} +.pos .pos-logo { + height: 35px; + margin-left: 10px; + margin-top: 5px; + vertical-align:middle; +} +.pos .pos-branding .username{ + float:right; + color:#DDD; + font-size:16px; + margin-right:32px; + line-height: 48px; + font-style:italic; + cursor: pointer; +} + +.pos .ticket-button { + display: flex; +} + +/* b) The right part of the top-bar */ + +.pos .pos-rightheader { + flex-grow: 1; + height:100%; + display: flex; + overflow : hidden; + overflow-x: auto; +} +.pos .pos-rightheader > * { + border-right: 1px solid #875A7B; +} + +.pos .status-buttons-portal { + flex: 1; +} + +.pos .status-buttons { + display: flex; + justify-content: flex-end; + flex: 1; +} + +.pos .order-button{ + color: #f0f0f0; + display: inline-block; + box-sizing: border-box; + -moz-box-sizing: border-box; + height: 46px; + padding: 4px 8px; + margin: 3px; + margin-bottom: 0px; + margin-right: 2px; + padding-top: 0px; + background: #8b8b8b; + border-top-left-radius: 3px; + border-top-right-radius: 3px; + vertical-align: top; + line-height: 42px; + text-align: center; + box-shadow: 0px -5px 10px -6px rgb(82,82,82) inset; + cursor: pointer; + min-width: 45px; +} + +.pos .order-button:first-child { + margin-left: 0px; +} + +.pos .order-button.selected{ + font-weight: 900; + background: #EEEEEE; + color: rgb(75,75,75); + height: 45px; + border-bottom: solid 1px rgb(196, 196, 196); + box-shadow: none; + -webkit-flex-shrink: 0; + flex-shrink: 0; +} + +.pos .order-button .order-sequence{ + font-size: 16px; + font-weight: 800; + vertical-align: middle; +} +.pos .order-button.selected .order-sequence{ + color: white; + background: black; + display: inline-block; + line-height: 24px; + min-width: 24px; + border-radius: 12px; + margin-right: 4px; + margin-left: -4px; +} + +.pos .order-button.square{ + margin-left:1px; + background: #5c5c5c; + color: rgb(160,160,160); + font-size: 18px; + line-height: 45px; +} +.pos .order-button:not(.square) > .fa { + font-size: 16px; + vertical-align: middle; + margin-right: 4px; +} +.pos .order-button .order-sequence{ + font-size: 16px; + font-weight: 800; +} + +.pos .order-selector { + display: -webkit-flex; + display: flex; + -webkit-flex: 1; + flex: 1; +} +.pos .orders { + display: -webkit-flex; + display: flex; + vertical-align: top; + margin-left: 0px; + overflow: hidden; +} + +/* c) The session buttons */ + +.pos .pos-rightheader .header-button{ + float: right; + height: 48px; + padding-left: 16px; + padding-right: 16px; + border-right: 1px solid #875A7B; + border-left: 1px solid #875A7B; + color: #DDD; + line-height: 48px; + text-align: center; + cursor: pointer; + + -webkit-transition-property: background; + -webkit-transition-duration: 0.2s; + -webkit-transition-timing-function: ease-out; +} +.pos .pos-rightheader .header-button:last-child{ + border-left: 1px solid #875A7B; +} +.pos .pos-rightheader .header-button:active{ + background: rgba(0,0,0,0.2); + color:#EEE; +} +.pos .pos-rightheader .header-button.confirm { + background: #359766; + color: white; + font-weight: bold; +} + +/* c) The notifications indicator */ + +.pos .oe_status{ + float:right; + color: rgba(255,255,255,0.4); + padding: 14px; + line-height: 20px; + font-size: 20px; + vertical-align:middle; + font-style: italic; + cursor:pointer; +} +.pos .oe_status.oe_inactive{ + cursor: default; +} +.pos .oe_status .oe_icon{ + display:inline-block; + cursor:pointer; + width:20px; height:16px; + color: white; +} +.pos .oe_status .oe_red, +.pos .oe_icon.oe_red { + color: rgb(197, 52, 0); +} +.pos .oe_status .oe_green, +.pos .oe_icon.oe_green { + color: rgb(94, 185, 55); +} +.pos .oe_status .oe_orange, +.pos .oe_icon.oe_orange { + color: rgb(239, 153, 65); +} +.pos .oe_link_icon{ + cursor:pointer; +} +/* ********* Contains everything below the bar ********* */ + +.pos .pos-content { + width: 100%; + position: absolute; + top: 48px; + bottom: 0; + background: #F0EEEE; +} + +/* ********* The leftpane contains the order, numpad and paypad ********* */ + +.pos .switchpane { + height: 100px; + flex-shrink: 0; + display: flex; +} + +.pos .switchpane .order-info { + flex-grow: 1; + border-top: 1px solid #ebebeb; + padding: 8px 16px; + background-color: #FFFFFF; + color: #6ec89b; +} +.pos .switchpane .order-info h2 { + padding: 0px; + margin: 3px 0px; + font-weight: bold; +} + +.pos .switchpane .btn-switchpane { + background-color: #6ec89b; + border-radius: 0px; + color: #FFFFFF; + font-size: 15px; + font-weight: bold; + flex-grow: 1; + flex-basis: 50%; + padding-bottom: 20px; +} +.pos .switchpane .btn-switchpane h1 { + margin-bottom: 0px; +} + +.pos .switchpane .btn-switchpane.secondary { + background-color: #FFFFFF; + color: #6ec89b; +} + + + +.pos .leftpane { + border-right: solid 3px #787878; + background: #e2e2e2; + height: 100%; + display: flex; + flex-direction: column; + flex-grow: 1; + max-width: 500px; +} + +.pos .leftpane .pads { + border-top: solid 3px rgb(110, 200, 155); +} + +.pos .leftpane .pads .subpads { + display: flex; + flex-direction: row; +} + +/* ********* The control buttons ********* */ + +.pos .control-buttons { + display: -webkit-flex; + display: flex; + -webkit-flex-flow: row wrap; + flex-flow: row wrap; + padding: 8px 16px 0px 11px; + margin-bottom: -6px; +} +.pos .control-button { + -webkit-flex-grow: 1; + flex-grow: 1; + background: #e2e2e2; + border: solid 1px #bfbfbf; + display: inline-block; + line-height: 38px; + min-width: 80px; + text-align: center; + border-radius: 3px; + padding: 0px 10px; + font-size: 18px; + margin-left: 6px; + margin-bottom: 6px; + cursor: pointer; + overflow: hidden; + transition: all linear 150ms; +} +.pos .control-button:hover { + background: #efefef; +} +.pos .control-button:active { + background: black; + color: white; + border-color: black; +} +.pos .control-button .fa{ + margin-right: 4px; +} +.pos .control-button .control-button-number { + color: rgb(226, 226, 226); + background: rgb(85, 85, 85); + display: inline-block; + height: 28px; + vertical-align: middle; + font-weight: bold; + line-height: 28px; + width: 28px; + border-radius: 50%; + text-align: center; + margin-left: -16px; + margin-right: 4px; +} + +.pos .control-button.highlight, +.pos .button.highlight { + background: #6EC89B !important; + border: solid 1px #64AF8A !important; + color: white !important; +} +.pos .control-button.altlight, +.pos .button.altlight { + background: #7F82AC !important; + border: solid 1px #756A99 !important; + color: white !important; +} +.pos .control-button.disabled, +.pos .control-button.disabled:active{ + background: #e2e2e2; + border: solid 1px #BEBEBE; + opacity: 0.5; + cursor: default; + color: inherit; +} + +/* ********* The actionpad (payment, set customer) ********* */ + +.pos .actionpad { + padding: 0; + margin: 16px; + margin-top: 8px; + margin-right: 0; + text-align: center; + vertical-align: top; + border: none; + border-radius: 0; + border-top: 1px solid; + border-left: 1px solid; + border-color: #bfbfbf; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + flex-grow: 1; +} +.pos .actionpad .button { + position: relative; + display: block; + height: 54px; + width: 100%; + font-weight: bold; + vertical-align: middle; + color: #555555; + font-size: 14px; + border-radius: 0; + border: none; + border-right: 1px solid; + border-bottom: 1px solid; + border-color: #bfbfbf; + transition: all 150ms linear; +} +.pos .actionpad .button:hover { + background: #efefef; +} +.pos .actionpad .button:active { + background: black; + border-color: black; + color: white; +} +.pos .actionpad .button:first-child { + border-top-left-radius: 4px; +} +.pos .actionpad .button:last-child { + border-bottom-left-radius: 4px; +} +.pos .actionpad .button.pay { + height: 162px; +} +.pos .actionpad .button.pay .pay-circle { + display: block; + font-size: 32px; + line-height: 54px; + padding-top: 6px; + background: rgb(86, 86, 86); + color: white; + width: 60px; + margin: auto; + border-radius: 30px; + margin-bottom: 10px; +} +.pos .actionpad .button.pay .pay-circle .fa { + position: relative; + top: -1px; + left: 3px; +} + +.pos .actionpad .button.set-customer{ + padding-left: 40px; + padding-right: 40px; +} +.pos .actionpad .button.set-customer.decentered { + padding-left: 40px; + padding-right: 5px; +} +.pos .actionpad .button .fa-user { + position: absolute; + left: 13px; + top: 13px; + margin-right: 8px; + font-size: 18px; + background: rgba(255, 255, 255, 0.5); + line-height: 30px; + width: 30px; + border-radius: 100%; +} + +@media screen and (max-width: 768px) { + .pos .actionpad .button.set-customer{ + padding-left: 0px; + padding-right: 0px; + } + .pos .actionpad .button.set-customer.decentered{ + padding-left: 0px; + padding-right: 0px; + } +} + +/* ********* The Numpad ********* */ + +.pos .numpad { + text-align: center; + width: 216px; + margin: 16px; + margin-top: 8px; + margin-left: 0px; + border: none; + border-radius: 0; + border-top: 1px solid; + border-color: #bfbfbf; + border-top-right-radius: 4px; + min-width: 216px; +} +.pos .numpad button { + float: left/*rtl:ignore*/; /* rtlcss forced to keep ltr */ + height: 54px; + width: 54px; + font-weight: bold; + vertical-align: middle; + color: #555555; + border-radius: 0; + border: none; + border-right: 1px solid; + border-bottom: 1px solid; + border-color: #bfbfbf; + transition: all 150ms linear; +} +.pos .numpad button:hover { + background: #efefef; +} +.pos .numpad button:active { + background: black; + color: white; + border-color: transparent; +} +.pos .numpad button:nth-child(4) { + border-top-right-radius: 4px; +} +.pos .numpad button:last-child { + border-bottom-right-radius: 4px; +} +.pos .input-button { + font-size: 24px; +} +.pos .mode-button { + font-size: 14px; +} +.pos .mode-button.selected-mode { + color: white; + background: #6EC89B; + border-color: transparent; +} +.pos .mode-button.selected-mode:hover { + background: #6EC89B; + color: white; + border-color: transparent; +} +.pos .numpad .disabled-mode, .pos .numpad .disabled-mode:hover { + background: #c7c7c7; + color: #a5a1a1; + cursor: not-allowed; +} + +/* ********* The right pane contains the screens and headers ********* */ + +.pos .rightpane { + height: 100%; + display: flex; + flex-direction: column; + flex-basis: 25%; + flex-grow: 1; +} + +.pos .products-widget { + display: flex; + flex-direction: column; + flex-grow: 1; + overflow: hidden; +} + +.pos .product-list-container { + overflow: hidden; + overflow-y: auto; + flex-grow: 1; +} + +.pos .rightpane-header { + padding: 0; + background: #d3d3d3; + text-align: center; + display: flex; + flex-flow: row wrap; +} + +.pos .green-border-bottom { + border-bottom: solid 3px rgb(110, 200, 155); +} + +.pos .grey-border-bottom { + border-bottom: 1px solid #c7c7c7; +} + +/* ********* The product list ********* */ + +.pos .product-list { + padding: 10px; + text-align: left; + -webkit-transform: translate3d(0,0,0); + display: flex; + flex-wrap: wrap; +} + +.pos .product-list-scroller{ + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + -ms-box-sizing: border-box; + box-sizing: border-box; + width:100%; + height:100%; + overflow: hidden; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + -webkit-transform: translate3d(0,0,0); + +} + +/* a) the product list navigation bar */ + +.pos .breadcrumb{ + float: left; + display: inline-block; + line-height: 48px; + height: 48px; + min-width: 48px; +} +.pos .breadcrumb:last-child { + padding-right: 3px; + border-right: 1px solid #c5c5c5; +} +.pos .breadcrumb-button { + display: inline-block; + padding: 0 9px; + vertical-align: top; + color: #808080; + font-size: 14px; + cursor: pointer; +} +.pos .breadcrumb-button.breadcrumb-home { + line-height: 50px; + font-size: 25px; + text-align: center; +} + +.pos .breadcrumb-arrow{ + width: 28px; +} +.pos .breadcrumb-homeimg { + width: 27px; + margin: 12px 6px; +} + +@media screen and (max-width: 768px) { + .pos .breadcrumb-button.breadcrumb-home { + width: auto; + font-size: 13px; + margin-left: 3px; + } +} + +/* b) the search box */ + +.pos .searchbox { + flex-grow: 1; + position: relative; +} +.pos .searchbox input { + width: 150px; + border: 1px solid #cecbcb; + padding: 10px 20px; + margin: 6px; + background-color: white; + border-radius: 20px; + font-family: "Lato","Lucida Grande", Helvetica, Verdana, Arial; + font-size: 13px; +} +.pos .searchbox input:focus { + outline: none; + box-shadow: 0px 0px 0px 2px rgb(153, 153, 255) inset; + color: rgb(153, 153, 255); +} + +.pos .search-clear { + top: 9px; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; +} + +.search-clear.left { + left: 11px; + color: #808080; + margin: 6px; +} + +.search-clear.right { + color: #808080; + cursor: pointer; + margin: 6px; +} + +@media screen and (max-width: 768px) { + .search-clear.left { + position: relative; + top: -40px; + left: 5%; + } + + .search-clear.right { + position: relative; + top: -70px; + left: 85%; + } + .pos .searchbox input { + width: 70%; + } + .pos .searchbox { + position: relative; + } +} + +/* c) the categories list */ + +.pos .categories { + position: relative; + border-bottom: solid 3px rgb(110, 200, 155); + flex: 1; +} +.pos .categories h4 { + display: inline-block; + margin: 9px 5px; +} +.pos .category-list-scroller{ + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + -ms-box-sizing: border-box; + box-sizing: border-box; + width:100%; + height:100%; + max-height:40vh; + overflow: hidden; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + -webkit-transform: translate3d(0,0,0); + +} +.pos .category-list { + text-align: left; + padding: 10px; + background: rgb(229, 229, 229); +} +.pos .category-list.simple { + padding: 0px; + background: #cecece; + display: flex; + flex-flow: row wrap; + flex: 1; +} + + +/* d) the category button */ + +.pos .category-button { + position: relative; + vertical-align: top; + display: inline-block; + font-size: 11px; + margin: 8px !important; + width: 120px; + height:120px; + background:#fff; + border: 1px solid #d7d7d7; + border-radius: 3px; + border-bottom-width: 3px; + cursor: pointer; +} + +.pos .category-simple-button{ + display: flex; + align-items: center; + font-size: 14px; + padding: 5px 12px; + cursor: pointer; + flex: 1; + text-align: left; + background: rgb(229, 229, 229); + border-right: solid 1px #d3d3d3; + border-top: solid 1px #d3d3d3; +} +.pos .category-simple-button:active{ + color: white; + background: black; + + -webkit-transition-property: background, border; + -webkit-transition-duration: 0.2s; + -webkit-transition-timing-function: ease-out; +} + + + +.pos .category-button .category-img { + position: relative; + width: 120px; + height: 100px; + text-align: center; + cursor: pointer; +} + +.pos .category-button .category-img img { + max-height: 100px; + max-width: 120px; + vertical-align: middle; +} + +.pos .category-button .category-name { + position: absolute; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + -ms-box-sizing: border-box; + box-sizing: border-box; + bottom: 0; + top: auto; + line-height: 14px; + width: 100%; + /* for some reason the -90deg orientation doesn't match the -webkit-linear-gradient. It should be 180deg here. + * webkit also insists on rendering *both* gradients instead of only the native one. So it doesn't looks right. ugh. + background: linear-gradient(-90deg,rgba(255,255,255,0),rgba(255,255,255,1), rgba(255,255,255,1)); */ + /*background:#FFF;*/ + padding: 3px; + padding-top: 15px; + color: #7C7BAD; +} + +/* e) the product */ + +.pos .product { + position:relative; + vertical-align: top; + display: inline-block; + line-height: 100px; + font-size: 11px; + margin: 8px !important; + width: 122px; + height:115px; + background:#fff; + border: 1px solid #e2e2e2; + border-radius: 3px; + border-bottom-width: 3px; + overflow: hidden; + cursor: pointer; +} + +.pos .product .product-img { + position: relative; + width: 120px; + height: 100px; + background: white; + text-align: center; +} + +.pos .product .product-img img { + max-height: 100px; + max-width: 120px; + vertical-align: middle; +} + +@media screen and (max-width: 768px) { + .pos .product-list { + padding: 0px; + } + .pos .product { + width: 32%; + height: auto; + margin: 0px !important; + } + .pos .product:active { + border: solid 50px #6ec89b; + box-sizing: border-box; + } + .pos .product:after { + content: ""; + display: block; + padding-bottom: 100%; + } + .pos .product .product-img { + position: absolute; + width: 100%; + height: 100%; + } + .pos .product .product-img img { + max-height: none; + max-width: none; + } + +} + + + +.pos .product .price-tag { + position: absolute; + top: 2px; + right: 2px; + vertical-align: top; + color: white; + line-height: 13px; + background: #7f82ac; + padding: 2px 5px; + border-radius: 2px; +} + +.pos .product .product-name { + position: absolute; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + -ms-box-sizing: border-box; + box-sizing: border-box; + bottom:0; + top:auto; + line-height: 14px; + width:100%; + overflow: hidden; + text-overflow: ellipsis; + background: -webkit-linear-gradient(-90deg,rgba(255,255,255,0),rgba(255,255,255,1), rgba(255,255,255,1)); + background: -moz-linear-gradient(-90deg,rgba(255,255,255,0),rgba(255,255,255,1), rgba(255,255,255,1)); + background: -ms-linear-gradient(-90deg,rgba(255,255,255,0),rgba(255,255,255,1), rgba(255,255,255,1)); + /* troublesome in latest webkit + background: linear-gradient(-90deg,rgba(255,255,255,0),rgba(255,255,255,1), rgba(255,255,255,1)); + */ + /*background:#FFF;*/ + padding: 3px; + padding-top:15px; +} + + +/* ********* The Screens ********* */ + +.pos .screen { + position:absolute; + text-align: center; + top:0px; + bottom:0px; + width:100%; + overflow: auto; + -webkit-overflow-scrolling: touch; +} +.pos .screen header h2 { + margin-top: 0px; + padding-top: 7px; +} +.pos .screen p{ + font-size: 18px; +} +.pos .dialog{ + width: 500px; + margin-left: auto; + margin-right: auto; + margin-top: 50px; + text-align: center; +} +.pos .dialog p{ + font-size: 25px; + margin-top: 10px; + color: #5a5a5a; +} + +/* a) Generic Screen Layout Constructs */ + +.screen .screen-content{ + position: relative; + margin: 0px auto; + max-width: 1024px; + text-align: left; + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.screen .screen-full-width{ + height: 100%; + width: 100%; + display: flex; + flex-wrap: wrap; + flex-direction: row; +} + +@media screen and (min-width: 1024px) { + .screen .screen-content{ + border-left: dashed 1px rgb(215,215,215); + border-right: dashed 1px rgb(215,215,215); + } +} + +.screen .top-content{ + height: 64px; + border-bottom: dashed 1px rgb(215,215,215); + text-align: center; + display: flex; + padding-right: 10px; + padding-left: 10px; +} +.screen .top-content .button { + line-height: 32px; + padding: 3px 13px; + font-size: 20px; + background: rgb(230, 230, 230); + margin-top: 12px; + margin-bottom: 12px; + margin-left: 6px; + margin-right: 6px; + border-radius: 3px; + border: solid 1px rgb(209, 209, 209); + cursor: pointer; + transition: all 150ms linear; +} +.screen .top-content .button:hover { + background: #efefef; +} +.screen .top-content .button:active { + background: black; + border-color: black; + color: white; +} +.screen .top-content .button.highlight { + background: rgb(110,200,155); + color: white; + border: solid 1px rgb(110,200,155); +} +.screen .top-content .button.highlight:hover { + background: rgb(120,210,165); +} + +.screen .top-content .top-content-center { + flex-grow: 1; +} + +.screen .main-content{ + display: flex; + flex-grow: 1; + flex-wrap: wrap; + overflow-y: auto; +} +.screen .left-content{ + overflow-x: hidden; + overflow-y: auto; + border-right: dashed 1px rgb(215,215,215); + flex-grow: 1; + min-width: 200px; +} + +.screen .right-content{ + overflow-x: hidden; + overflow-y: auto; + flex-grow: 1; + display: flex; + flex-direction: column; +} + +@media screen and (min-width: 768px) { + .screen .left-content { + max-width: 34% + } +} + +.pos .btn-switch-payment { + background-color: #6ec89b; + border-radius: 0px; + color: #FFFFFF; + font-size: 15px; + font-weight: bold; + height: 100px; + width: 100%; +} +.screen .centered-content{ + border-right: dashed 1px rgb(215,215,215); + border-left: dashed 1px rgb(215,215,215); + overflow-x: hidden; + overflow-y: auto; + max-width: 512px; + margin-left: auto; + margin-right: auto; +} +.screen .full-content{ + position: absolute; + right: 0%; top: 65px; bottom: 0px; + left: 0%; +} + +/* a) Layout for the Product Screen */ + +.pos .screen .layout-table { + border:none; + width:100%; + display: flex; + flex-grow: 1; +} + +.pos .screen .header-cell{ + border:none; + width:100%; + height:0px; +} +.pos .screen .content-row { + width:100%; + height:100%; +} +.pos .screen .content-cell{ + width:100%; +} +.pos .screen .content-cell .content-container{ + height:100%; + position:relative; +} + + +/* b) The payment screen */ + +.pos .payment-buttons-container { + display: flex; + flex-direction: row; + flex-wrap: wrap; +} + +.pos .payment-numpad { + box-sizing: border-box; + margin: 16px; + text-align: center; + flex-grow: 1; +} +.pos .payment-numpad .numpad { + border-radius: 4px; + border-top: 1px solid; + border-left: 1px solid; + border-color: #cacaca; + width: 296px; + height: 100%; + margin: auto; +} +.pos .payment-numpad .numpad button { + width: 74px; + height: 74px; +} +.pos .payment-numpad .numpad button:first-child { + border-top-left-radius: 4px; +} +.pos .payment-numpad .numpad button:nth-child(16) { + border-bottom-left-radius: 4px; +} + +.pos .paymentlines-container { + padding: 16px; + padding-top: 0; + border-bottom: dashed 1px gainsboro; + min-height: 154px; +} + +.pos .paymentlines { + border-spacing: 0px 10px; + border-collapse: inherit; + margin: 16px; +} + +.paymentlines .paymentline:first-child { + border-top-width: 1px; + border-top-left-radius: 3px; + border-top-right-radius: 3px; +} +.paymentlines .paymentline:last-child { + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; +} + +.pos .paymentline { + background: #e2e2e2; + line-height: 74px; + font-size: 16px; + border: solid 1px rgb(202, 202, 202); + border-top-width: 0px; + cursor: pointer; + display: flex; + padding-left: 30px; + padding-right: 30px; + flex-grow: 1; + flex-basis: 100%; +} +.paymentline:active { + background: black; + border-color: black; + color: white; +} +.paymentline .payment-name { + flex-grow: 1; + margin-left: 10px; + margin-right: 10px; +} +.paymentline .payment-amount { + margin-left: 10px; + margin-right: 10px; +} +.paymentline .delete-button { + margin-left: 10px; + margin-right: 10px; +} +.paymentline.selected{ + background: white; +} + +.pos .payment-buttons { + box-sizing: border-box; + margin: 16px; + padding-left: 0; + flex-grow: 5; +} +.payment-screen .customer-button { + margin-bottom: 10px; +} +.payment-screen .payment-buttons .button { + background: #e2e2e2; + line-height: 73px; + font-size: 16px; + padding: 0px 8px; + border: solid 1px rgb(200,200,200); + border-top-width: 0; + cursor: pointer; + text-align: center; + position: relative; + transition: background-color, border-color, color 150ms linear; +} +.payment-screen .payment-buttons .button:hover { + background-color: #efefef; +} +.payment-screen .payment-buttons .button:first-child { + border-top-left-radius: 3px; + border-top-right-radius: 3px; + border-top-width: 1px; +} +.payment-screen .payment-buttons .button:last-child { + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; +} +.payment-screen .payment-buttons .button.highlight:not(:first-child) { + margin-top: -1px; + border-top: solid 1px; +} +.payment-screen .payment-buttons .button:active { + background: black; + border-color: black; + color: white; +} +.payment-screen .payment-buttons .button.highlight .fa { + border-color: rgba(0, 0, 0, 0.109804); + background: rgba(0, 0, 0, 0.0980392); +} +.payment-screen .payment-buttons .button .fa { + position: absolute; + left: 11px; + top: 50%; + width: 48px; + height: 48px; + line-height: 48px; + margin-top: -25px; + vertical-align: middle; + border-radius: 26px; + border: 1px solid rgba(0,0,0,0.2); + border-image-source: initial; + border-image-slice: initial; + border-image-width: initial; + border-image-outset: initial; + border-image-repeat: initial; + background: rgba(255,255,255,0.4); + font-size: 20px; + transition: all 150ms linear; +} +.payment-screen .paymentlines-empty .total { + text-align: center; + padding: 24px 0px 18px; + font-size: 64px; + color: #43996E; + text-shadow: 0px 2px white, 0px 2px 2px rgba(0, 0, 0, 0.27); +} +.payment-screen .paymentlines-empty .message { + text-align: center; +} + +.paymentlines .button { + cursor: pointer; + border: 1px solid #cacaca; +} +.paymentlines .electronic_payment { + background: #e2e2e2; + border-collapse: unset; + font-size: 16px; + padding-right: 0; +} + +.paymentlines .electronic_payment div:first-child { + flex-grow: 2; + margin-left: 10px; + margin-right: 10px; +} + +.paymentlines .electronic_payment div:last-child { + flex-grow: 1; + text-align: center; +} + +.payment-status-container { + display: flex; + justify-content: space-between; + font-size: 25px; + padding-top: 15px; +} + +.payment-status-total-due { + font-size: 17px; + padding-top: 12px; + padding-bottom: 12px; + color: #5c5c5cd1; +} + +.payment-status-container .amount.highlight { + font-weight: bold; + color: #6EC89B; +} + +.payment-status-container .label { + padding-right: 7px; +} + +@media screen and (max-width: 768px) { + .pos .paymentlines-container { + min-height: 0px; + border-bottom: none; + padding-bottom: 3px; + } + .pos .paymentlines { + margin-top: 0px; + margin-bottom: 0px; + } + .payment-status-container { + font-size: 22px; + } + .payment-screen .payment-buttons { + display: flex; + flex-wrap: wrap; + flex-direction: column; + } + .payment-screen .payment-controls { + display: flex; + flex-wrap: wrap; + } + .payment-screen .payment-buttons .button { + flex-basis: 30%; + flex-grow: 1; + border: 1px solid rgba(0,0,0,0.2); + border-radius: 0px; + line-height: normal; + padding-top: 28px; + padding-bottom: 28px; + } + .payment-screen .payment-buttons .button .fa { + display: none; + } +} + +/* c) The receipt screen */ + +.pos .receipt-screen .centered-content .button { + line-height: 40px; + padding: 3px 13px; + font-size: 20px; + text-align: center; + background: rgb(230, 230, 230); + margin: 16px; + margin-bottom: 0px; + border-radius: 3px; + border: solid 1px rgb(209, 209, 209); + cursor: pointer; +} + +.pos .pos-receipt-container { + font-size: 0.75em; + text-align: center; + direction: ltr; +} + +.pos .pos-receipt-container > div { + text-align: left; + width: 300px; + background-color: white; + margin: 20px; + padding: 15px; + font-size: 16px; + padding-bottom:30px; + display: inline-block; + border: solid 1px rgb(220,220,220); + border-radius: 3px; + overflow: hidden; +} + +@page { + margin: 0; +} + +@media print { + body { + background: white; + } + body * { + visibility: hidden; + } + .pos, .pos * { + position: static !important; + } + .pos .receipt-screen .pos-receipt-container { + position: absolute !important; + top: 0; + left: 0; + } + .pos .receipt-screen .pos-receipt-container, .pos .receipt-screen .pos-receipt-container * { + visibility: visible; + background: white !important; + color: black !important; + } + .pos .pos-receipt { + margin: 0 !important; + margin-left: auto !important; + margin-right: auto !important; + border: none !important; + font-size: 14px !important; + width: 266px !important; + } +} + +/* d) The Scale screen */ + +.pos .scale-screen .product-price{ + font-size: 25px; + margin: 16px; + text-align: center; + display: inline-block; + width: 35%; +} +.pos .scale-screen .computed-price{ + font-size: 25px; + display: inline-block; + text-align: right; + margin: 16px; + margin-top: 0px; + padding: 16px; + background: white; + width: 35%; + border-radius: 3px; + font-family: Inconsolata; + font-weight: bold; + text-shadow: 0px 2px 0px rgb(210,210,210); + box-shadow: 0px 2px 0px rgb(225,225,225) inset; + float: right; +} +.pos .scale-screen .buy-product{ + text-align: center; + font-size: 32px; + background: rgb(110,200,155); + color: white; + border-radius: 3px; + padding: 16px; + margin: 16px; + cursor: pointer; +} + +.pos .scale-screen .weight{ + text-align: right; + margin: 16px; + background: white; + padding: 20px; + padding-right: 30px; + font-size: 56px; + border-radius: 3px; + font-family: Inconsolata; + text-shadow: 0px 2px 0px rgb(210, 210, 210); + box-shadow: 0px 2px 0px rgb(225,225,225) inset; +} + + +/* e) The Client List Screen */ + +.pos .clientlist-screen .full-content{ + overflow: auto; +} + +.pos .clientlist-screen .client-list{ + font-size: 16px; + width: 100%; +} +.pos .clientlist-screen .client-list th, +.pos .clientlist-screen .client-list td { + padding: 12px 8px; +} +.pos .clientlist-screen .client-list tr{ + transition: all 150ms linear; + background: rgb(230,230,230); +} +.pos .clientlist-screen .client-list thead > tr, +.pos .clientlist-screen .client-list tr:nth-child(even) { + background: rgb(247,247,247); +} +.pos .clientlist-screen .client-list tr.highlight{ + transition: all 150ms linear; + background: rgb(110,200,155) !important; + color: white; +} +.pos .clientlist-screen .client-list tr.lowlight{ + transition: all 150ms linear; + background: rgb(216, 238, 227); +} +.pos .clientlist-screen .client-list tr.lowlight:nth-child(even){ + transition: all 150ms linear; + background: rgb(227, 246, 237); +} +.pos .client-line { + vertical-align: text-top; +} +.pos .edit-client-button { + margin-top: 6px; + color: black; +} +.pos .clientlist-screen .client-details{ + padding: 16px; + border-bottom: solid 5px rgb(110,200,155); +} +.pos .clientlist-screen .client-picture{ + height: 64px; + width: 64px; + border-radius: 32px; + overflow: hidden; + text-align: center; + float: left; + margin-right: 16px; + background: white; + position: relative; +} +.pos .clientlist-screen .client-picture > img { + position: absolute; + top: -9999px; + bottom: -9999px; + right: -9999px; + left: -9999px; + max-height: 64px; + margin: auto; +} +.pos .clientlist-screen .client-picture > .fa { + line-height: 64px; + font-size: 32px; +} +.pos .clientlist-screen .client-picture .image-uploader { + position: absolute; + z-index: 1000; + top: 0; + left: 0; + right: 0; + bottom: 0; + opacity: 0; + cursor: pointer; +} +.pos .clientlist-screen .client-name { + font-size: 32px; + line-height: 64px; + margin-bottom:16px; +} +.pos .clientlist-screen .edit-buttons { + position: absolute; + right: 16px; + top: 10px; +} +.pos .clientlist-screen .edit-buttons .button{ + display: inline-block; + margin-left: 16px; + color: rgb(128,128,128); + cursor: pointer; + font-size: 36px; +} +.pos .clientlist-screen .client-details-box{ + position: relative; + font-size: 16px; +} +.pos .clientlist-screen .client-details-left{ + width: 50%; + float: left; +} +.pos .clientlist-screen .client-details-right{ + width: 50%; + float: right; +} +.pos .clientlist-screen .client-detail{ + line-height: 24px; +} +.pos .clientlist-screen .client-detail > .label{ + font-weight: bold; + display: inline-block; + width: 75px; + text-align: right; + margin-right: 8px; +} +.pos .clientlist-screen .client-details input, +.pos .clientlist-screen .client-details select +{ + padding: 4px; + border-radius: 3px; + border: solid 1px #cecbcb; + margin-bottom: 4px; + background: white; + font-family: "Lato","Lucida Grande", Helvetica, Verdana, Arial; + color: #555555; + width: 340px; + font-size: 14px; + box-sizing: border-box; +} +.pos .clientlist-screen .client-details input.client-name { + font-size: 24px; + line-height: 24px; + margin: 18px 6px; + width: 340px; +} +.pos .clientlist-screen .client-detail > .empty{ + opacity: 0.3; +} + +.pos .clientlist-screen .button.new-customer { + min-width: 30px; +} + +.pos .searchbox-client { + padding: 3px 13px; + margin-top: 12px; + margin-bottom : 12px; +} + +.pos .searchbox-client input { + width: 120px; + border: 1px solid #cecbcb; + padding: 10px 20px; + padding-left: 38px; + padding-right: 33px; + background-color: white; + border-radius: 20px; + font-family: "Lato","Lucida Grande", Helvetica, Verdana, Arial; + font-size: 13px; +} +.pos .searchbox-client input:focus { + outline: none; + box-shadow: 0px 0px 0px 2px rgb(153, 153, 255) inset; + color: rgb(153, 153, 255); +} + +.pos .search-clear-client { + position: absolute; + top: 9px; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; +} + +.search-clear-client.left { + left: 11px; + color: #808080; +} + +.search-clear-client.right { + left: 145px; + color: #808080; + cursor: pointer; +} + +@media screen and (max-width: 768px) { + .searchbox-client.top-content-center { + display: flex + } + .pos .searchbox-client input { + width: auto; + flex-grow: 1; + padding-left: 10px; + padding-right: 10px; + } + .pos .clientlist-screen .client-details-box { + display: flex; + flex-wrap: wrap; + } + .pos .clientlist-screen .client-details-left{ + width: auto; + float: none; + flex-grow: 1; + } + .pos .clientlist-screen .client-details-right{ + width: auto; + float: none; + flex-grow: 1; + } + .pos .clientlist-screen .client-detail{ + display: flex; + flex-direction: column; + } + .pos .clientlist-screen .client-details input, + .pos .clientlist-screen .client-details select + { + width: 100%; + } + .pos .clientlist-screen .client-details input.client-name { + width: 100%; + } + .pos .clientlist-screen .client-detail > .label{ + width: auto; + text-align: left; + } + .pos .clientlist-screen .client-list td { + overflow: hidden; + white-space: nowrap; + } +} + + + + + +/* ********* The OrderWidget ********* */ + +.pos .order-container{ + top: 0px; + width:100%; + height: 100%; + background: white; + flex-grow: 1; + overflow: hidden; + overflow-y: auto; +} + +.pos .scrollable-y{ + overflow: hidden !important; + overflow-y: auto !important; + -webkit-overflow-scrolling: touch !important; +} + +.pos .order{ + background: white; + padding-bottom: 8px; + padding-top: 8px; + font-size: 16px; + text-align: left; + max-width: 500px; + -webkit-transform: translate3d(0,0,0); +} + +.pos .order .order-empty { + text-align: center; + margin: 48px; + color: #DDD; +} +.pos .order .order-empty .fa { + font-size: 64px; +} +.pos .order .order-empty h1 { + font-size: 20px; +} + +.pos .order .summary{ + width:100%; + text-align:right; + font-weight: bold; + margin-top:20px; + margin-bottom:10px; +} +.pos .order .summary .line{ + float: right; + margin-right:15px; + margin-left: 15px; + padding-top:5px; + border-top: solid 2px; + border-color:#777; +} +.pos .order .summary .total { + font-size: 22px; +} +.pos .order .summary .line .subentry{ + font-size: 16px; + font-weight: normal; + text-align: center; +} +.pos .order .summary .line.empty{ + text-align: right; + border-color:#BBB; + color:#999; +} + +.pos .order .summary .fidpoints{ + position: absolute; + left: 20px; + padding: 10px; + color: #6EC89B; + background: rgba(110, 200, 155, 0.17); + border-radius: 3px; +} + +.submit-kitchen-button { + float: left; + background: rgb(61, 235, 82); + color: white; + padding: 12px 20px; + margin: 0px 15px; + border-radius: 3px; + cursor: pointer; +} + +/* ********* The OrderLineWidget ********* */ + +.pos .order .orderline{ + width:100%; + margin:0px; + padding-top:3px; + padding-bottom:10px; + padding-left:15px; + padding-right:15px; + cursor: pointer; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + -ms-box-sizing: border-box; + box-sizing: border-box; + -webkit-transition: background 250ms ease-in-out; + -moz-transition: background 250ms ease-in-out; + transition: background 250ms ease-in-out; +} +.pos .order .orderline:active{ + background: rgba(140,143,183,0.05); + -webkit-transition: background 50ms ease-in-out; + -moz-transition: background 50ms ease-in-out; + transition: background 50ms ease-in-out; +} +.pos .order .orderline.empty:active{ + background: transparent; + cursor: default; +} + +.pos .order .orderline.selected{ + background: rgba(140,143,183,0.2); + -webkit-transition: background 250ms ease-in-out; + -moz-transition: background 250ms ease-in-out; + transition: background 250ms ease-in-out; + cursor: default; +} +.pos .order .orderline .product-name{ + padding:0; + display:inline-block; + font-weight: bold; + width:80%; + overflow:hidden; + text-overflow: ellipsis; +} +.pos .order .orderline .price{ + padding:0; + font-weight: bold; + float:right; +} +.pos .order .orderline .info-list{ + color: #888; + margin-left:10px; +} +.pos .order .orderline .info-list em{ + color: #777; + font-weight: bold; + font-style:normal; +} + +/* ********* SplitBill ********* */ + +.splitbill-screen .order-info { + text-align: center; + padding: 20px 0px; + font-size: 64px; + color: #43996E; + text-shadow: 0px 2px white, 0px 2px 2px rgba(0, 0, 0, 0.27); + border-bottom: dashed 1px rgb(215,215,215); +} +.pos .splitbill-screen .order { + background: white; + padding-bottom:15px; + padding-top:15px; + margin-left:16px; + margin-right:16px; + margin-top:16px; + margin-bottom:16px; + font-size:16px; + border-radius: 3px; + border: solid 1px rgb(220,220,220); + text-align: left; + max-width: 500px; + -webkit-transform: translate3d(0,0,0); + height: max-content; + width: 100%; +} +.splitbill-screen .order .orderline.selected{ + background: rgb(110,200,155); + color: white; + -webkit-transition: background 250ms ease-in-out; + -moz-transition: background 250ms ease-in-out; + transition: background 250ms ease-in-out; + cursor: default; +} +.splitbill-screen .order .orderline.partially.selected{ + background: rgb(136, 214, 176); +} +.splitbill-screen .order .orderline.selected .info-list { + color: white; +} +.splitbill-screen .order .orderline.selected .info-list em{ + color: white; + font-size: 24px; + vertical-align: top; +} +.paymentmethods { + margin: 16px; + display: flex; + flex-wrap: wrap; +} +.paymentmethods .button { + background: #e2e2e2; + line-height: 74px; + font-size: 16px; + border: solid 1px rgb(202, 202, 202); + border-top-width: 0px; + cursor: pointer; +} +.paymentmethods .button:first-child { + border-top-width: 1px; + border-top-left-radius: 3px; + border-top-right-radius: 3px; +} +.paymentmethods .button:last-child { + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; +} +.paymentmethods .button:active { + background: black; + border-color: black; + color: white; +} +.paymentmethod .button.active { + background: #6EC89B; + color: white; + border-color: #6EC89B; +} +.paymentmethod { + display: flex; + padding-left: 30px; + padding-right: 30px; + flex-grow: 1; + flex-basis: 100%; +} +.paymentmethod .payment-name { + flex-grow: 1; + margin-left: 10px; + margin-right: 10px; +} +.paymentmethod .payment-amount { + margin-left: 10px; + margin-right: 10px; +} +.paymentmethod .delete-button { + margin-left: 10px; + margin-right: 10px; +} +.paymentmethod.selected{ + background: white; +} + +@media screen and (max-width: 768px) { + .paymentmethods { + margin-top: 0px; + margin-bottom: 0px; + } + .paymentmethods .button { + border: solid 1px rgb(202, 202, 202); + border-radius: 3px; + } + .paymentmethod { + flex-basis: 30%; + } +} + +/* ********* The ActionBarWidget ********* */ + +.pos .pos-actionbar{ + height: 105px; + background: #f5f5f5; /*#ebebeb;*/ + border-top: solid 1px #cecece; + z-index:900; +} + +.pos .pos-actionbar ul{ + list-style: none; +} + +.pos .pos-actionbar-button-list{ + height: 100%; + margin: 0px; + padding-left:3px; + padding-right:3px; + overflow:hidden; +} + +.pos .pos-actionbar .button{ + width: 90px; + height: 90px; + text-align:center; + margin:3px; + margin-top:6px; + float:left; + + font-size: 14px; + font-weight: bold; + + cursor: pointer; + + border: 1px solid #cacaca; + border-radius: 3px; + + background: #e2e2e2; +} +.pos .pos-actionbar .button .label{ + margin-top: 37px; +} +.pos .pos-actionbar .button .icon{ + margin-top: 10px; +} +.pos .pos-actionbar .button:active{ + color: white; + background: #7f82ac; + border: 1px solid #7f82ac; + + -webkit-transition-property: background, border; + -webkit-transition-duration: 0.2s; + -webkit-transition-timing-function: ease-out; +} + +.pos .pos-actionbar .button.disabled{ + opacity: 0.5; +} +.pos .pos-actionbar .button.disabled:active{ + border: 1px solid #cacaca; + color: #555; + cursor: default; + + background: #e2e2e2; +} + +.pos .pos-actionbar .button.rightalign{ + float:right; +} +/* ********* The Debug Widget ********* */ + +.pos .debug-widget{ + z-index:100000; + position: absolute; + right: 10px; + top: 10px; + width: 200px; + font-size: 10px; + + background: rgba(0,0,0,0.82); + color: white; + padding-bottom: 10px; + cursor: move; + -webkit-transform: translate3d(0,0,0); +} +.pos .debug-widget .toggle{ + position: absolute; + font-size: 16px; + cursor:pointer; + top:0px; + right:0px; + padding:10px; + padding-right:15px; +} +.pos .debug-widget .content{ + overflow: hidden; +} +.pos .debug-widget header { + position: relative; +} +.pos .debug-widget h1{ + background:black; + padding-top: 10px; + padding-left: 10px; + margin-top:0; + margin-bottom:0; +} +.pos .debug-widget .category{ + background: black; + padding-left: 10px; + margin: 0px; + font-weight: bold; + padding-top:3px; + padding-bottom:3px; +} +.pos .debug-widget .button{ + padding: 5px; + padding-left: 15px; + display: block; + cursor:pointer; +} +.pos .debug-widget .button:active{ + background: rgba(96,21,177,0.45); +} +.pos .debug-widget input{ + margin-left:10px; + margin-top:7px; + padding: 4px; + width: 180px; + border: none; + box-sizing: border-box; + -moz-box-sizing: border-box; + border-radius: 3px; +} +.pos .debug-widget .status{ + padding: 5px; + padding-left: 15px; + display: block; + cursor:default; +} +.pos .debug-widget .status.on{ + background-color: #6cd11d; +} +.pos .debug-widget .event{ + padding: 5px; + padding-left: 15px; + display: block; + cursor:default; + background-color: #1E1E1E; +} + +/* ********* The PopupWidgets ********* */ + +.pos .modal-dialog{ + position: absolute; + left: 0; + top: 0; + width: 100%; + height:100%; + background-color: rgba(0,0,0,0.5); + z-index:1000; +} +.pos .modal-dialog header{ + position: relative; +} +.pos .modal-dialog .popup{ + position: absolute; + margin: auto; + max-width:500px; + width: 100%; + text-align:center; + font-size:20px; + font-weight:bold; + background-color: #F0EEEE; + border-radius: 3px; + box-shadow: 0px 10px 20px rgba(0,0,0,0.4); + z-index:1200; + font-family: 'Lato'; + font-family: Lato; + /* position the popup at center and and still making it draggable*/ + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.pos .modal-dialog .popup-lg{ + max-width: 80%; + max-height: 600px; + height: auto; +} + +.pos .popup .title { + background: rgba(255,255,255,0.5); + margin: 0; + padding: 20px; + border-radius: 3px 3px 0px 0px; + border-bottom: solid 1px rgba(60,60,60,0.1); +} +.pos .popup .body { + font-weight: normal; + font-size: 18px; + margin: 16px; +} + +.pos .popup-lg .body { + max-height: 400px; + overflow-y: auto; +} + +.pos .popup .body.traceback { + height: 238px; + overflow: auto; + font-size: 14px; + white-space: pre-wrap; + text-align: left; + font-family: 'Inconsolata'; + -webkit-user-select: text; + -moz-user-select: text; + user-select: text; +} +.pos .popup .footer{ + width:100%; + height:60px; + border-top: solid 1px rgba(60,60,60,0.1); +} +.pos .popup .button{ + float:right; + width: 110px; + height: 40px; + line-height:40px; + text-align:center; + border-radius: 2px; + margin-top:10px; + margin-right:10px; + + font-size: 14px; + font-weight: bold; + + cursor: pointer; + + border: solid 1px rgba(60,60,60,0.1); + + background: rgba(0,0,0,0.05); +} + +.pos .popup .button.dont-show-again { + width: 130px; +} + +.pos .popup .button.icon { + width: 40px; + font-size: 20px; +} +.pos .popup .button:active{ + color: white; + background: black; + border: 1px solid black; + + -webkit-transition-property: background, border; + -webkit-transition-duration: 0.2s; + -webkit-transition-timing-function: ease-out; +} + + +.pos .popup .button.big-left{ + position:absolute; + top: 120px; + left:40px; + width: 180px; + height: 180px; + line-height:180px; +} + +.pos .popup .button.big-right{ + position:absolute; + top: 120px; + right:40px; + width: 180px; + height: 180px; + line-height:180px; +} +.pos .popup input, +.pos .popup-input { + text-align: left; + display: inline-block; + overflow: hidden; + background: white; + min-height: 44px; + font-family: "Lato"; + font-size: 20px; + color: #444; + padding: 10px; + border-radius: 3px; + border: none; + box-shadow: 0px 0px 0px 1px rgb(220,220,220) inset; + box-sizing: border-box; + width: 80%; +} +.pos .popup .list-lines{ + overflow: auto; + height: 250px; + margin: 10px; +} +.pos .popup .list-line-input { + margin: 3px; +} + +.pos .popup-number .popup-input { + text-align: center; +} +.pos .popup input:focus, +.pos .popup-input.active { + outline: none; + box-shadow: 0px 0px 0px 3px #6EC89B; +} +.pos .popup.popup-error { + background-color: #F3BBBB; + color: rgb(168, 89, 89); + box-shadow: 0px 10px 20px rgba(92,51,51,0.4); +} +.pos .popup.popup-error .title { + color: white; + background: rgba(255, 76, 76, 0.5); +} +.pos .popup.popup-selection .selection { + overflow-y: auto; + max-height: 273px; + font-size: 16px; + width: auto; + line-height: 50px; + margin-top: -1px; + border-top: solid 3px rgba(60,60,60,0.1); + +} +.pos .popup.popup-selection .selection-item { + width: auto; + background: rgb(230,230,230); + cursor: pointer; + text-align: left; + padding: 0px 16px; +} +.pos .popup.popup-selection .selection-item:nth-child(odd) { + background: rgb(247,247,247); +} +.pos .popup.popup-selection .selection-item.selected { + background: #6EC89B; +} +.pos .popup.popup-number { + width: 300px; + height: 450px; +} +.pos .footer.centered { + text-align: center; +} +.pos .footer.centered .button { + float: none; + display: inline-block; + margin-left: 3px; + margin-right: 3px; +} +.pos .popup-numpad { + direction: ltr/*rtl:ignore*/; /* rtlcss forced to keep ltr */ + margin: 12px auto; + text-align: center; + width: 254px; +} +.pos .popup-number .title, +.pos .popup-textinput .title +{ + margin-bottom: 20px; +} +.pos .popup-numpad .input-button, +.pos .popup-numpad .mode-button { + background: none; + height: 50px; + width: 50px; + padding: 0; + border-radius: 25px; + margin: 4px; + vertical-align: top; + color: #444; +} +.pos .popup-numpad .input-button:active, +.pos .popup-numpad .mode-button:active { + background: #444; + color: white; + border-color: #444; +} + +.pos .popup.popup-password { + width: 254px; +} +.pos .popup-password .mode-button.add, +.pos .popup-password .input-button.dot { + display: none; +} +.pos .popup-password .popup-numpad { + width: 190px; +} +.pos .popup-password .popup-input { + width: 70%; + } + +.pos .popup .body ul, +.pos .popup ul.body { + text-align: left; + margin-left: 1em; +} +.pos .popup .body li { + text-indent: 1em; +} +.pos .popup .body li:before { + content: '—'; + position: relative; + font-size: 0.6em; + left: -1em; + bottom: 0.2em; +} + + +/* ********* The Webkit Scrollbar ********* */ + +.pos *::-webkit-scrollbar{ + width: 4px; + height: 4px; +} +.pos *::-webkit-scrollbar-track{ + background: rgb(224,224,224); + border-left: solid 1px rgb(200,200,200); +} +.pos *::-webkit-scrollbar-thumb{ + background: rgb(168,168,168); + background: #393939; + min-height: 30px; +} + +.pos.big-scrollbars *::-webkit-scrollbar{ + width: 40px; + height: 40px; +} +.pos.big-scrollbars *::-webkit-scrollbar-track{ + background: rgb(224,224,224); + border-left: none; +} +.pos.big-scrollbars *::-webkit-scrollbar-thumb{ + background: rgb(168,168,168); + min-height: 40px; + border-radius: 3px; +} +.pos.big-scrollbars *::-webkit-scrollbar-button{ + width: 40px; + height: 40px; + border-radius: 3px; + background: rgb(210,210,210); + background-size: cover; +} +.pos.big-scrollbars *::-webkit-scrollbar-button:decrement{ + background-image: url('../img/scroll-up.png'); +} +.pos.big-scrollbars *::-webkit-scrollbar-button:increment{ + background-image: url('../img/scroll-down.png'); +} + + +/* ********* Unsupported Browser Page ********* */ + +.pos .not-supported-browser{ + position: absolute; + z-index: 100000; + top: 0; bottom: 0; left: 0; right: 0; + background: #2C2C2C; +} +.pos .not-supported-browser .message{ + width:600px; + margin-top: 100px; + margin-left: auto; + margin-right: auto; + text-align: center; + color: #d3d3d3; + font-size: 14px; +} +.pos .not-supported-browser img{ + border-collapse: separate; +} + +.fade-enter-active, .fade-leave-active { + transition: opacity .2s; +} + +.fade-enter, .fade-leave-to { + opacity: 0; +} + +.swing-enter-active, .swing-leave-active { + transition: opacity 0.8s; +} + +.swing-enter, .swing-leave-to { + opacity: 0; +} + +/* + We block the top-header when a temp screen is displayed. + Similar to blocking the whole ui when a popup is displayed. +*/ +.pos .block-top-header { + position: absolute; + left: 0; + top: 0; + width: 100%; + height:100%; + background-color: rgba(0,0,0,0.5); + z-index:1000; +} + +.drag-handle { + cursor: move; /* fallback if grab cursor is unsupported */ + cursor: grab; + cursor: -moz-grab; + cursor: -webkit-grab; +} + +.drag-handle:active { + cursor: grabbing; + cursor: -moz-grabbing; + cursor: -webkit-grabbing; +} + + +/* Order Management */ + +.order-row { + display: flex; + flex-direction: row; + justify-content: space-evenly; + background-color: rgb(230,230,230); +} + +.order-list .order-row:hover { + color: white; + background-color: rgb(110,200,155); + font-weight: bold; +} + +.order-row.highlight { + color: white; + background-color: rgb(110,200,155); + font-weight: bold; +} + +.order-row.lighter { + background-color: #f5f5f5; + cursor: pointer; +} + +.order-row .header { + font-size: medium; + font-weight: bolder; + flex-grow: 1; + flex-basis: 0; + text-align: left; + padding: 10px 10px; + background-color: #cecece; + border-bottom: solid 1px; + border-top: solid 1px +} + +.order-row .header.total { + text-align: right; +} + +.order-row .item { + font-size: medium; + flex-grow: 1; + flex-basis: 0; + text-align: left; + padding: 10px 10px; + border-bottom: solid rgb(150,150,150) 1px; +} + +.order-row .item.total { + text-align: right; +} + +.order-management-screen .flex-container { + display: flex; + flex-direction: column; + height: 100%; +} + +.order-management-screen .orders { + display: flex; + flex-direction: column; +} + +.order-management-screen .order-list { + flex: 1; + overflow-y: auto; +} + +.order-management-screen .control-panel { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + margin: 0.5rem; +} + +.order-management-screen .control-panel .item { + font-size: medium; +} + +.order-management-screen .control-panel .item .page-controls { + display: flex; + flex-direction: row; + align-items: center; + font-size: x-large; +} + +.order-management-screen .control-panel .page-controls > div { + border: darkgray solid 1px; + border-radius: 2px; +} + +.order-management-screen .control-panel .page-controls > div:hover { + color: rgb(110,200,155); +} + +.order-management-screen .control-panel .page-controls .previous { + margin-right: 0.2rem; +} + +.order-management-screen .control-panel .page-controls .next { + margin-left: 0.2rem; +} + + +.order-management-screen .control-panel .search-box { + flex: 1; + position: relative; + text-align: center; + margin: 0.2rem; +} + +.order-management-screen .control-panel .search-box .clear { + position: relative; + right: 25px; + cursor: pointer; + color: #808080; + font-size: 13px; +} + +.order-management-screen .control-panel .search-box .icon { + position: relative; + left: 25px; + color: #808080; + font-size: 13px; +} + +.order-management-screen .control-panel .search-box input { + border: 1px solid #cecbcb; + padding: 10px 30px; + margin: auto; + background-color: white; + border-radius: 20px; + font-family: "Lato","Lucida Grande", Helvetica, Verdana, Arial; + font-size: 13px; +} + +.order-management-screen .control-panel .search-box input:focus { + outline: none; + box-shadow: 0px 0px 0px 2px rgb(153, 153, 255) inset; + color: rgb(153, 153, 255); +} + +.order-management-screen .control-panel .button { + line-height: 32px; + padding: 3px 13px; + font-size: 20px; + background: rgb(230, 230, 230); + border-radius: 3px; + border: solid 1px rgb(209, 209, 209); + cursor: pointer; + transition: all 150ms linear; +} + +.order-management-screen .control-panel .button:hover { + background: #efefef; +} + +.order-management-screen .back-to-list { + font-size: large; + padding: 10px; + background-color: #6EC89B; + color: white; +} + +.order-receipt { + color: white; + font-size: medium; + text-align: center; +} + +.order-receipt .title { + font-size: large; +} + +/* ********* Product Configurator Popup ********* */ + +.pos .product_configurator_attributes { + text-align: left; + margin: 1em; +} + +.pos .product_configurator_attributes .attribute { + margin-bottom: 1em; + display: inline-block; + width: 45%; + padding-left: 0.5em; + vertical-align: top; +} + +@media screen and (max-width: 768px) { + .pos .product_configurator_attributes .attribute { + width: 95%; + } +} + +.pos .product_configurator_attributes .attribute_name { + margin-bottom: 0.5em; + font-weight: bold; +} + +.pos .product_configurator_attributes input { + min-height: 0; + width: auto; +} + +/** Radio attribute **/ + +.pos .product_configurator_attributes .configurator_radio { + line-height: 1.5; +} + +.pos .product_configurator_attributes .configurator_radio input[type='radio'] { + box-shadow: none; + margin-right: 0.5em; +} + +.pos .product_configurator_attributes .configurator_radio .radio_attribute_label { + font-weight: normal; + display: inline-block; + width: 80%; +} + +.pos .product_configurator_attributes .configurator_radio .price_extra { + margin-left: 0.5em; + padding: 0.2em 0.4em; + border-radius: 10rem; + color: #FFFFFF; + background-color: #6c757d; +} + +.pos .product_configurator_attributes .configurator_radio .custom_value { + margin: 0.3em 1.3em; +} + +/** Selector attribute **/ + +.pos .product_configurator_attributes .configurator_select { + cursor: pointer; + background-color: transparent; + width: 90%; + padding: 0.5em; + color: #666666; + font-size: 18px; + margin-bottom: 0.5em; +} + +/** Color attribute **/ + +.pos .product_configurator_attributes ul.color_attribute_list { + margin-left: 0; +} + +.pos .product_configurator_attributes li.color_attribute_list_item:before { + content: ''; +} + +.pos .product_configurator_attributes li.color_attribute_list_item { + margin-bottom: 0.5em; + text-indent: 0; + display: inline-block; +} + +.pos .product_configurator_attributes .color_attribute_list_item:not(:last-child) { + margin-right: 1rem; +} + +.pos .product_configurator_attributes .configurator_color { + display: inline-block; + border: 1px solid #999999; +} + +.pos .product_configurator_attributes .configurator_color.active { + border: 3px ridge #66ee66; +} + +.pos .product_configurator_attributes .configurator_color input { + margin: 20px; + opacity: 0; +} + +.pos .product_configurator_attributes .configurator_color.active input { + margin: 18px; +} + +/* TICKET SCREEN */ + +.ticket-screen { + font-size: medium; +} + +.ticket-screen .orders { + display: flex; + flex-flow: column nowrap; + overflow: hidden; + overflow-y: hidden; + overflow-y: auto; +} + +.ticket-screen .orders .header-row{ + display: flex; + flex-flow: row nowrap; + flex: 1; + justify-content: space-evenly; + background: #868686; + color: white; +} + +.ticket-screen .orders .order-row { + display: flex; + flex-flow: row nowrap; + flex: 1; + justify-content: space-evenly; +} + +.ticket-screen .orders .col { + display: flex; + flex: 1; + padding: 10px; +} + +.ticket-screen .orders .col.start { + justify-content: flex-start; +} + +.ticket-screen .orders .col.center { + justify-content: center; +} + +.ticket-screen .orders .col.end { + justify-content: flex-end; +} + +.ticket-screen .orders .col.very-narrow { + flex: 0.2; +} + +.ticket-screen .orders .col.narrow { + flex: 0.5; +} + +.ticket-screen .orders .col.wide { + flex: 1.5; +} + +.ticket-screen .order-row:nth-child(odd) { + background: #DDD; +} + +.ticket-screen .order-row:nth-child(even) { + background: white; +} + +.ticket-screen .order-row:hover { + background: rgb(110,200,155); + color: white; +} + +.ticket-screen .pointer { + cursor: pointer; +} + +.ticket-screen .controls { + display: flex; + justify-content: space-between; + align-items: center; + margin: 0px 20px; + border-bottom: dashed 1px rgb(215,215,215); + flex: 0 0 80px; +} + +.ticket-screen .controls button { + font-size: medium; + padding: 12px; + margin-right: 20px; + font-weight: bold; +} + +.ticket-screen .controls button.discard { + background: rgb(230, 230, 230); + border: solid 1px rgb(209, 209, 209); +} + +.ticket-screen .controls button.highlight { + background: #6EC89B; + border: solid 1px #64AF8A; + color: white; +} + +.ticket-screen .pos-search-bar { + vertical-align: middle; + white-space: nowrap; + position: relative; + display: flex; + max-width: 500px; + flex: 1; +} + +.ticket-screen .pos-search-bar .search { + display: flex; + position: relative; + flex: 1; +} + +.ticket-screen .pos-search-bar .search input { + height: 40px; + font-size: medium; + color: #63717f; + padding-left: 40px; + border: solid 1px rgb(209, 209, 209); + flex: 1; +} + +.ticket-screen .pos-search-bar .radius-right { + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; +} + +.ticket-screen .pos-search-bar .radius-left { + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; +} + +.ticket-screen .pos-search-bar .search input:focus { + outline: none; +} + +.ticket-screen .pos-search-bar .search .search-icon { + position: absolute; + left: 15px; + top: 12px; + z-index: 1; + color: #4f5b66; +} + +.ticket-screen .pos-search-bar .search ul { + background: white; + position: absolute; + top: calc(100% + 5px); + right: 2px; + left: 2px; + box-shadow: 1px 1px 3px grey; + font-size: small; +} + +.ticket-screen .pos-search-bar .search li { + color: rgb(1,160,157); + margin: 0.2em 0; + padding-top: 0.2em; + padding-bottom: 0.2em; + padding-left: 35px; +} + +.ticket-screen .pos-search-bar .search li:hover { + background: #DDD; +} + +.ticket-screen .pos-search-bar .search li .field { + font-style: italic; +} + +.ticket-screen .pos-search-bar .search li .term { + font-weight: bold; +} + +.ticket-screen .pos-search-bar .search li.highlight { + background: #DDD; +} + +.ticket-screen .pos-search-bar .filter .down-icon { + position: absolute; + right: 13px; + top: 10px; +} + +.ticket-screen .pos-search-bar .filter { + height: 40px; + background: white; + padding-top: 1px; + padding-bottom: 1px; + padding-left: 20px; + padding-right: 40px; + border: solid 1px rgb(209, 209, 209); + border-left: none; + position: relative; + display: flex; + align-items: center; + max-width: 150px; + font-size: medium; +} + +.ticket-screen .pos-search-bar .filter:hover { + color: #868686; +} + +.ticket-screen .pos-search-bar .filter .options { + display: block; + position: absolute; + top: calc(100% + 5px); + right: 0; + z-index: 1; + box-shadow: 1px 1px 5px grey; + padding: 0.5em 0; + background: white; + color: #555555; +} + +.ticket-screen .pos-search-bar .filter ul.options li { + padding: 0.2em 1.2em; + border-top: none; + display: flex; + justify-content: start; + align-items: center; +} + +.ticket-screen .pos-search-bar .filter ul.options li:hover { + background-color: #DDD; +} + +.ticket-screen .pos-search-bar .search { + display: flex; +} + +.ticket-button { + display: flex; + align-items: center; + padding: 0 15px; + font-size: medium; + color: white; +} + +.ticket-button.highlight { + background: rgb(104,69,95); +} + +.ticket-button:hover { + background: rgb(104,69,95); + cursor: pointer; +} + +.ticket-button .with-badge { + margin-right: 0.7em; + font-size: larger; +} + +[badge] { + position: relative; +} + +[badge]:after { + background: rgb(1,160,157); + border-radius: 10rem; + color: #fff; + content: attr(badge); + font-size: small; + min-width: 20px; + padding: 2px; + position: absolute; + text-align: center; + left: 0.6em; + bottom: 0.6em; +} + +[badge^="-"]:after, +[badge="0"]:after, +[badge=""]:after { + display: none; +} + +/* Product Screen Search Bar */ + +.search-bar-portal { + display: flex; +} + +.search-bar-portal .search-box { + font-size: medium; + position: relative; + display: flex; + align-items: center; + margin: 0 15px; +} + +.search-bar-portal .search-box input { + font-size: medium; + height: 30px; + border: none; + color: #63717f; + padding-left: 38px; + padding-right: 38px; + border-radius: 5px; +} + +.search-bar-portal .search-box input:focus { + outline: none; +} + +.search-bar-portal .search-box .icon { + position: absolute; + left: 0; + margin-left: 12px; + z-index: 1; + color: #4f5b66; +} + +.search-bar-portal .search-box .clear-icon { + position: absolute; + right: 0; + margin-right: 12px; + z-index: 1; + color: #4f5b66; + cursor: pointer; +} + +.cashbox-input { + margin: auto; + line-height: 74px; + font-size: 16px; + border: solid 1px rgb(202, 202, 202); + border-top-width: 0px; + cursor: pointer; + padding-left: 30px; + padding-right: 30px; + flex-grow: 1; + flex-basis: 100%; +} + +.currencyCashBox { + font-size: 30px; +} + +.receipt-screen .default-view { + display: flex; + overflow: hidden; +} + +.receipt-screen .default-view .pos-receipt-container { + flex: 0 1 400px; + overflow: auto; +} + +.receipt-screen .default-view .actions { + flex: 1; + margin: 0 1.5rem; +} + +.receipt-screen .default-view .actions * { + font-size: 1rem; +} + +.receipt-screen .default-view .actions h1 { + font-size: 1.5rem; + margin-bottom: 4rem; +} + +.receipt-screen .default-view .actions .buttons { + display: flex; + margin: 1rem 0px; +} + +.receipt-screen .default-view .actions .buttons .button { + flex: 1; + border: solid 1px rgb(209, 209, 209); + padding: 1rem; + text-align: center; + border-radius: 3px; + cursor: pointer; + background: rgb(230, 230, 230); +} + +.receipt-screen .default-view .actions .send-email { + display: flex; +} + +.receipt-screen .default-view .actions .send-email .email-icon { + padding: 1rem; + border: solid 1px rgb(209, 209, 209); + border-right: none; + border-top-left-radius: 3px; + border-bottom-left-radius: 3px; +} + +.receipt-screen .default-view .actions .send-email .input-email { + display: flex; + flex: 1; + background: white; + border: solid 1px rgb(209, 209, 209); + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; +} + +.receipt-screen .default-view .actions .send-email input { + flex: 1; + padding-left: 1rem; + border: none; + color: #555555; +} + +.receipt-screen .default-view .actions .send-email input:focus { + outline: none; +} + +.receipt-screen .default-view .actions .send-email button.send { + padding: 1rem 40px; + margin: 2px; + border: none; + background: rgb(230, 230, 230); +} + +.receipt-screen .default-view .actions .send-email button.send.highlight { + color: white; + background-color: #6EC89B; +} + +.receipt-screen .notice { + margin-top: 6px; +} + +.receipt-screen .notice.failed { + color: rgb(168, 89, 89); +} + +.receipt-screen .notice.successful { + color: #6EC89B; +} + +@media screen and (max-width: 768px) { + .receipt-screen .default-view { + flex-direction: column-reverse; + overflow: auto; + } + .receipt-screen .default-view .actions { + flex: 0; + } + .receipt-screen .default-view .actions h1 { + margin-bottom: 1.5rem; + } + .receipt-screen .default-view .pos-receipt-container { + flex: 1; + overflow: visible; + } +} diff --git a/addons/point_of_sale/static/src/css/pos_receipts.css b/addons/point_of_sale/static/src/css/pos_receipts.css new file mode 100644 index 00000000..b8ed9af2 --- /dev/null +++ b/addons/point_of_sale/static/src/css/pos_receipts.css @@ -0,0 +1,65 @@ +.pos-receipt-print { + width: 512px; + height: 0; + overflow: hidden; + position: absolute; + left: 0; + top: 0; + text-align: left; + direction: ltr; + font-size: 27px; + color: #000000; +} + +.pos-receipt .pos-receipt-right-align { + float: right; +} + +.pos-receipt .pos-receipt-center-align { + text-align: center; +} + +.pos-receipt .pos-receipt-left-padding { + padding-left: 2em; +} + +.pos-receipt .pos-receipt-logo { + width: 50%; + display: block; + margin: 0 auto; +} + +.pos-receipt .pos-receipt-contact { + text-align: center; + font-size: 75%; +} + +.pos-receipt .pos-receipt-order-data { + text-align: center; + font-size: 75%; +} + +.pos-receipt .pos-receipt-amount { + font-size: 125%; + padding-left: 6em; +} + +.pos-receipt .pos-receipt-title { + font-weight: bold; + font-size: 125%; + text-align: center; +} + +.pos-receipt .pos-receipt-header { + font-size: 125%; + text-align: center; +} + +.pos-receipt .pos-order-receipt-cancel { + color: red; +} + +.pos-payment-terminal-receipt { + text-align: center; + font-size: 75%; +} diff --git a/addons/point_of_sale/static/src/fonts/Inconsolata.otf b/addons/point_of_sale/static/src/fonts/Inconsolata.otf Binary files differnew file mode 100644 index 00000000..34888982 --- /dev/null +++ b/addons/point_of_sale/static/src/fonts/Inconsolata.otf diff --git a/addons/point_of_sale/static/src/img/backspace.png b/addons/point_of_sale/static/src/img/backspace.png Binary files differnew file mode 100644 index 00000000..705051d9 --- /dev/null +++ b/addons/point_of_sale/static/src/img/backspace.png diff --git a/addons/point_of_sale/static/src/img/bc-arrow-big.png b/addons/point_of_sale/static/src/img/bc-arrow-big.png Binary files differnew file mode 100644 index 00000000..f845fe68 --- /dev/null +++ b/addons/point_of_sale/static/src/img/bc-arrow-big.png diff --git a/addons/point_of_sale/static/src/img/bc-arrow.png b/addons/point_of_sale/static/src/img/bc-arrow.png Binary files differnew file mode 100644 index 00000000..0485c597 --- /dev/null +++ b/addons/point_of_sale/static/src/img/bc-arrow.png diff --git a/addons/point_of_sale/static/src/img/blocks/block_simple_text.png b/addons/point_of_sale/static/src/img/blocks/block_simple_text.png Binary files differnew file mode 100644 index 00000000..7099744d --- /dev/null +++ b/addons/point_of_sale/static/src/img/blocks/block_simple_text.png diff --git a/addons/point_of_sale/static/src/img/default_category_photo.png b/addons/point_of_sale/static/src/img/default_category_photo.png Binary files differnew file mode 100644 index 00000000..25af75ee --- /dev/null +++ b/addons/point_of_sale/static/src/img/default_category_photo.png diff --git a/addons/point_of_sale/static/src/img/home.png b/addons/point_of_sale/static/src/img/home.png Binary files differnew file mode 100644 index 00000000..53d0b22d --- /dev/null +++ b/addons/point_of_sale/static/src/img/home.png diff --git a/addons/point_of_sale/static/src/img/ios-share-icon.png b/addons/point_of_sale/static/src/img/ios-share-icon.png Binary files differnew file mode 100644 index 00000000..8588657e --- /dev/null +++ b/addons/point_of_sale/static/src/img/ios-share-icon.png diff --git a/addons/point_of_sale/static/src/img/logo.png b/addons/point_of_sale/static/src/img/logo.png Binary files differnew file mode 100644 index 00000000..5bcb128d --- /dev/null +++ b/addons/point_of_sale/static/src/img/logo.png diff --git a/addons/point_of_sale/static/src/img/pos_screenshot.jpg b/addons/point_of_sale/static/src/img/pos_screenshot.jpg Binary files differnew file mode 100644 index 00000000..1e884eb8 --- /dev/null +++ b/addons/point_of_sale/static/src/img/pos_screenshot.jpg diff --git a/addons/point_of_sale/static/src/img/scroll-down.png b/addons/point_of_sale/static/src/img/scroll-down.png Binary files differnew file mode 100644 index 00000000..5fd07589 --- /dev/null +++ b/addons/point_of_sale/static/src/img/scroll-down.png diff --git a/addons/point_of_sale/static/src/img/scroll-up.png b/addons/point_of_sale/static/src/img/scroll-up.png Binary files differnew file mode 100644 index 00000000..b34a9001 --- /dev/null +++ b/addons/point_of_sale/static/src/img/scroll-up.png diff --git a/addons/point_of_sale/static/src/img/touch-icon-128.png b/addons/point_of_sale/static/src/img/touch-icon-128.png Binary files differnew file mode 100644 index 00000000..5bbf31d6 --- /dev/null +++ b/addons/point_of_sale/static/src/img/touch-icon-128.png diff --git a/addons/point_of_sale/static/src/img/touch-icon-196.png b/addons/point_of_sale/static/src/img/touch-icon-196.png Binary files differnew file mode 100644 index 00000000..dbde2d99 --- /dev/null +++ b/addons/point_of_sale/static/src/img/touch-icon-196.png diff --git a/addons/point_of_sale/static/src/img/touch-icon-ipad-retina.png b/addons/point_of_sale/static/src/img/touch-icon-ipad-retina.png Binary files differnew file mode 100644 index 00000000..4f1e1db5 --- /dev/null +++ b/addons/point_of_sale/static/src/img/touch-icon-ipad-retina.png diff --git a/addons/point_of_sale/static/src/img/touch-icon-ipad.png b/addons/point_of_sale/static/src/img/touch-icon-ipad.png Binary files differnew file mode 100644 index 00000000..8b8c1114 --- /dev/null +++ b/addons/point_of_sale/static/src/img/touch-icon-ipad.png diff --git a/addons/point_of_sale/static/src/img/touch-icon-iphone-retina.png b/addons/point_of_sale/static/src/img/touch-icon-iphone-retina.png Binary files differnew file mode 100644 index 00000000..593c1506 --- /dev/null +++ b/addons/point_of_sale/static/src/img/touch-icon-iphone-retina.png diff --git a/addons/point_of_sale/static/src/img/touch-icon-iphone.png b/addons/point_of_sale/static/src/img/touch-icon-iphone.png Binary files differnew file mode 100644 index 00000000..4122e61f --- /dev/null +++ b/addons/point_of_sale/static/src/img/touch-icon-iphone.png diff --git a/addons/point_of_sale/static/src/img/touch-icon.svg b/addons/point_of_sale/static/src/img/touch-icon.svg new file mode 100644 index 00000000..8ce5f30e --- /dev/null +++ b/addons/point_of_sale/static/src/img/touch-icon.svg @@ -0,0 +1,199 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + id="svg3162" + version="1.1" + inkscape:version="0.48.3.1 r9886" + width="152" + height="152" + sodipodi:docname="ios7-icon.png" + inkscape:export-filename="/home/fva/Code/openerp/point_of_sale/touch-icon-ipad-retina.png" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90"> + <metadata + id="metadata3168"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title></dc:title> + </cc:Work> + </rdf:RDF> + </metadata> + <defs + id="defs3166"> + <linearGradient + id="linearGradient3944"> + <stop + style="stop-color:#483c98;stop-opacity:1;" + offset="0" + id="stop3946" /> + <stop + style="stop-color:#8075c9;stop-opacity:1;" + offset="1" + id="stop3948" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient3944" + id="linearGradient3950" + x1="116.83051" + y1="0.49999994" + x2="115.35169" + y2="227.45763" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(0.67555556,0,0,0.67555556,0,-0.67555559)" /> + </defs> + <sodipodi:namedview + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1" + objecttolerance="10" + gridtolerance="10" + guidetolerance="10" + inkscape:pageopacity="0" + inkscape:pageshadow="2" + inkscape:window-width="1920" + inkscape:window-height="1111" + id="namedview3164" + showgrid="true" + inkscape:zoom="1" + inkscape:cx="39.575132" + inkscape:cy="237.57664" + inkscape:window-x="0" + inkscape:window-y="27" + inkscape:window-maximized="1" + inkscape:current-layer="svg3162"> + <inkscape:grid + type="xygrid" + id="grid3942" + empspacing="5" + visible="true" + enabled="true" + snapvisiblegridlinesonly="true" /> + </sodipodi:namedview> + <rect + style="fill:url(#linearGradient3950);fill-opacity:1;fill-rule:evenodd;stroke:none" + id="rect3172" + width="152" + height="152" + x="0" + y="-3.9968029e-15" + ry="34.723557" /> + <rect + style="fill:#ffffff;fill-opacity:1;stroke:none" + id="rect3952" + width="5" + height="80" + x="25" + y="37" + ry="1" /> + <rect + ry="1" + y="37" + x="35" + height="80" + width="3.0532093" + id="rect3954" + style="fill:#ffffff;fill-opacity:1;stroke:none" /> + <rect + style="fill:#ffffff;fill-opacity:1;stroke:none" + id="rect3956" + width="5.0000033" + height="80" + x="45" + y="37" + ry="1" /> + <rect + ry="1" + y="37" + x="54.999996" + height="80" + width="3.0000036" + id="rect3958" + style="fill:#ffffff;fill-opacity:1;stroke:none" /> + <rect + style="fill:#ffffff;fill-opacity:1;stroke:none" + id="rect3960" + width="3.0000036" + height="80" + x="65" + y="37" + ry="1" /> + <rect + ry="1" + y="37" + x="75" + height="80" + width="3.0000036" + id="rect3962" + style="fill:#ffffff;fill-opacity:1;stroke:none" /> + <rect + style="fill:#ffffff;fill-opacity:1;stroke:none" + id="rect3964" + width="5.0000057" + height="80" + x="80" + y="37" + ry="1" /> + <rect + ry="1" + y="37" + x="90" + height="80" + width="3.0000036" + id="rect3966" + style="fill:#ffffff;fill-opacity:1;stroke:none" /> + <rect + ry="1" + y="37" + x="100.02039" + height="80" + width="5.0000057" + id="rect3968" + style="fill:#ffffff;fill-opacity:1;stroke:none" /> + <rect + style="fill:#ffffff;fill-opacity:1;stroke:none" + id="rect3970" + width="3.0000036" + height="80" + x="107" + y="37" + ry="1" /> + <rect + ry="1" + y="37" + x="114.99999" + height="80" + width="5.0000057" + id="rect3972" + style="fill:#ffffff;fill-opacity:1;stroke:none" /> + <rect + style="fill:#ffffff;fill-opacity:1;stroke:none" + id="rect3974" + width="3.0532093" + height="80" + x="122" + y="37" + ry="1" /> + <rect + style="fill:#f80000;fill-opacity:1;stroke:none" + id="rect3976" + width="2.0000024" + height="110" + x="-103.85593" + y="20" + ry="1.375" + transform="matrix(0,-1,1,0,0,0)" /> +</svg> diff --git a/addons/point_of_sale/static/src/js/Chrome.js b/addons/point_of_sale/static/src/js/Chrome.js new file mode 100644 index 00000000..63f5c363 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Chrome.js @@ -0,0 +1,454 @@ +odoo.define('point_of_sale.Chrome', function(require) { + 'use strict'; + + const { useState, useRef, useContext } = owl.hooks; + const { debounce } = owl.utils; + const { loadCSS } = require('web.ajax'); + const { useListener } = require('web.custom_hooks'); + const { CrashManager } = require('web.CrashManager'); + const { BarcodeEvents } = require('barcodes.BarcodeEvents'); + const PosComponent = require('point_of_sale.PosComponent'); + const NumberBuffer = require('point_of_sale.NumberBuffer'); + const PopupControllerMixin = require('point_of_sale.PopupControllerMixin'); + const Registries = require('point_of_sale.Registries'); + const IndependentToOrderScreen = require('point_of_sale.IndependentToOrderScreen'); + const contexts = require('point_of_sale.PosContext'); + + // This is kind of a trick. + // We get a reference to the whole exports so that + // when we create an instance of one of the classes, + // we instantiate the extended one. + const models = require('point_of_sale.models'); + + /** + * Chrome is the root component of the PoS App. + */ + class Chrome extends PopupControllerMixin(PosComponent) { + constructor() { + super(...arguments); + useListener('show-main-screen', this.__showScreen); + useListener('toggle-debug-widget', debounce(this._toggleDebugWidget, 100)); + useListener('show-temp-screen', this.__showTempScreen); + useListener('close-temp-screen', this.__closeTempScreen); + useListener('close-pos', this._closePos); + useListener('loading-skip-callback', () => this._loadingSkipCallback()); + useListener('play-sound', this._onPlaySound); + useListener('set-sync-status', this._onSetSyncStatus); + NumberBuffer.activate(); + + this.chromeContext = useContext(contexts.chrome); + + this.state = useState({ + uiState: 'LOADING', // 'LOADING' | 'READY' | 'CLOSING' + debugWidgetIsShown: true, + hasBigScrollBars: false, + sound: { src: null }, + }); + + this.loading = useState({ + message: 'Loading', + skipButtonIsShown: false, + }); + + this.mainScreen = useState({ name: null, component: null }); + this.mainScreenProps = {}; + + this.tempScreen = useState({ isShown: false, name: null, component: null }); + this.tempScreenProps = {}; + + this.progressbar = useRef('progressbar'); + + this.previous_touch_y_coordinate = -1; + } + + // OVERLOADED METHODS // + + mounted() { + // remove default webclient handlers that induce click delay + $(document).off(); + $(window).off(); + $('html').off(); + $('body').off(); + // The above lines removed the bindings, but we really need them for the barcode + BarcodeEvents.start(); + } + willUnmount() { + BarcodeEvents.stop(); + } + destroy() { + super.destroy(...arguments); + this.env.pos.destroy(); + } + catchError(error) { + console.error(error); + } + + // GETTERS // + + get clientScreenButtonIsShown() { + return ( + this.env.pos.config.use_proxy && this.env.pos.config.iface_customer_facing_display + ); + } + /** + * Startup screen can be based on pos config so the startup screen + * is only determined after pos data is completely loaded. + * + * NOTE: Wait for pos data to be completed before calling this getter. + */ + get startScreen() { + if (this.state.uiState !== 'READY') { + console.warn( + `Accessing startScreen of Chrome component before 'state.uiState' to be 'READY' is not recommended.` + ); + } + return { name: 'ProductScreen' }; + } + + // CONTROL METHODS // + + /** + * Call this function after the Chrome component is mounted. + * This will load pos and assign it to the environment. + */ + async start() { + try { + // Instead of passing chrome to the instantiation the PosModel, + // we inject functions needed by pos. + // This way, we somehow decoupled Chrome from PosModel. + // We can then test PosModel independently from Chrome by supplying + // mocked version of these default attributes. + const posModelDefaultAttributes = { + env: this.env, + rpc: this.rpc.bind(this), + session: this.env.session, + do_action: this.props.webClient.do_action.bind(this.props.webClient), + setLoadingMessage: this.setLoadingMessage.bind(this), + showLoadingSkip: this.showLoadingSkip.bind(this), + setLoadingProgress: this.setLoadingProgress.bind(this), + }; + this.env.pos = new models.PosModel(posModelDefaultAttributes); + await this.env.pos.ready; + this._buildChrome(); + this._closeOtherTabs(); + this.env.pos.set( + 'selectedCategoryId', + this.env.pos.config.iface_start_categ_id + ? this.env.pos.config.iface_start_categ_id[0] + : 0 + ); + this.state.uiState = 'READY'; + this.env.pos.on('change:selectedOrder', this._showSavedScreen, this); + this._showStartScreen(); + if (_.isEmpty(this.env.pos.db.product_by_category_id)) { + this._loadDemoData(); + } + setTimeout(() => { + // push order in the background, no need to await + this.env.pos.push_orders(); + // Allow using the app even if not all the images are loaded. + // Basically, preload the images in the background. + this._preloadImages(); + }); + } catch (error) { + let title = 'Unknown Error', + body; + + if (error.message && [100, 200, 404, -32098].includes(error.message.code)) { + // this is the signature of rpc error + if (error.message.code === -32098) { + title = 'Network Failure (XmlHttpRequestError)'; + body = + 'The Point of Sale could not be loaded due to a network problem.\n' + + 'Please check your internet connection.'; + } else if (error.message.code === 200) { + title = error.message.data.message || this.env._t('Server Error'); + body = + error.message.data.debug || + this.env._t( + 'The server encountered an error while receiving your order.' + ); + } + } else if (error instanceof Error) { + title = error.message; + body = error.stack; + } + + await this.showPopup('ErrorTracebackPopup', { + title, + body, + exitButtonIsShown: true, + }); + } + } + + // EVENT HANDLERS // + + _showStartScreen() { + const { name, props } = this.startScreen; + this.showScreen(name, props); + } + /** + * Show the screen saved in the order when the `selectedOrder` of pos is changed. + * @param {models.PosModel} pos + * @param {models.Order} newSelectedOrder + */ + _showSavedScreen(pos, newSelectedOrder) { + const { name, props } = this._getSavedScreen(newSelectedOrder); + this.showScreen(name, props); + } + _getSavedScreen(order) { + return order.get_screen_data(); + } + __showTempScreen(event) { + const { name, props, resolve } = event.detail; + this.tempScreen.isShown = true; + this.tempScreen.name = name; + this.tempScreen.component = this.constructor.components[name]; + this.tempScreenProps = Object.assign({}, props, { resolve }); + } + __closeTempScreen() { + this.tempScreen.isShown = false; + } + __showScreen({ detail: { name, props = {} } }) { + const component = this.constructor.components[name]; + // 1. Set the information of the screen to display. + this.mainScreen.name = name; + this.mainScreen.component = component; + this.mainScreenProps = props; + + // 2. Set some options + this.chromeContext.showOrderSelector = !component.hideOrderSelector; + + // 3. Save the screen to the order. + // - This screen is shown when the order is selected. + if (!(component.prototype instanceof IndependentToOrderScreen) && name !== "ReprintReceiptScreen") { + this._setScreenData(name, props); + } + } + /** + * Set the latest screen to the current order. This is done so that + * when the order is selected again, the ui returns to the latest screen + * saved in the order. + * + * @param {string} name Screen name + * @param {Object} props props for the Screen component + */ + _setScreenData(name, props) { + const order = this.env.pos.get_order(); + if (order) { + order.set_screen_data({ name, props }); + } + } + async _closePos() { + // If pos is not properly loaded, we just go back to /web without + // doing anything in the order data. + if (!this.env.pos || this.env.pos.db.get_orders().length === 0) { + window.location = '/web#action=point_of_sale.action_client_pos_menu'; + } + + if (this.env.pos.db.get_orders().length) { + // If there are orders in the db left unsynced, we try to sync. + // If sync successful, close without asking. + // Otherwise, ask again saying that some orders are not yet synced. + try { + await this.env.pos.push_orders(); + window.location = '/web#action=point_of_sale.action_client_pos_menu'; + } catch (error) { + console.warn(error); + const reason = this.env.pos.get('failed') + ? this.env._t( + 'Some orders could not be submitted to ' + + 'the server due to configuration errors. ' + + 'You can exit the Point of Sale, but do ' + + 'not close the session before the issue ' + + 'has been resolved.' + ) + : this.env._t( + 'Some orders could not be submitted to ' + + 'the server due to internet connection issues. ' + + 'You can exit the Point of Sale, but do ' + + 'not close the session before the issue ' + + 'has been resolved.' + ); + const { confirmed } = await this.showPopup('ConfirmPopup', { + title: this.env._t('Offline Orders'), + body: reason, + }); + if (confirmed) { + this.state.uiState = 'CLOSING'; + this.loading.skipButtonIsShown = false; + this.setLoadingMessage(this.env._t('Closing ...')); + window.location = '/web#action=point_of_sale.action_client_pos_menu'; + } + } + } + } + _toggleDebugWidget() { + this.state.debugWidgetIsShown = !this.state.debugWidgetIsShown; + } + _onPlaySound({ detail: name }) { + let src; + if (name === 'error') { + src = "/point_of_sale/static/src/sounds/error.wav"; + } else if (name === 'bell') { + src = "/point_of_sale/static/src/sounds/bell.wav"; + } + this.state.sound.src = src; + } + _onSetSyncStatus({ detail: { status, pending }}) { + this.env.pos.set('synch', { status, pending }); + } + + // TO PASS AS PARAMETERS // + + setLoadingProgress(fac) { + if (this.progressbar.el) { + this.progressbar.el.style.width = `${Math.floor(fac * 100)}%`; + } + } + setLoadingMessage(msg, progress) { + this.loading.message = msg; + if (typeof progress !== 'undefined') { + this.setLoadingProgress(progress); + } + } + /** + * Show Skip button in the loading screen and allow to assign callback + * when the button is pressed. + * + * @param {Function} callback function to call when Skip button is pressed. + */ + showLoadingSkip(callback) { + if (callback) { + this.loading.skipButtonIsShown = true; + this._loadingSkipCallback = callback; + } + } + + get isTicketScreenShown() { + return this.mainScreen.name === 'TicketScreen'; + } + + // MISC METHODS // + + async _loadDemoData() { + const { confirmed } = await this.showPopup('ConfirmPopup', { + title: this.env._t('You do not have any products'), + body: this.env._t( + 'Would you like to load demo data?' + ), + }); + if (confirmed) { + await this.rpc({ + 'route': '/pos/load_onboarding_data', + }); + this.env.pos.load_server_data(); + } + } + + _preloadImages() { + for (let product of this.env.pos.db.get_product_by_category(0)) { + const image = new Image(); + image.src = `/web/image?model=product.product&field=image_128&id=${product.id}&write_date=${product.write_date}&unique=1`; + } + for (let category of Object.values(this.env.pos.db.category_by_id)) { + if (category.id == 0) continue; + const image = new Image(); + image.src = `/web/image?model=pos.category&field=image_128&id=${category.id}&write_date=${category.write_date}&unique=1`; + } + const staticImages = ['backspace.png', 'bc-arrow-big.png']; + for (let imageName of staticImages) { + const image = new Image(); + image.src = `/point_of_sale/static/src/img/${imageName}`; + } + } + + _buildChrome() { + if ($.browser.chrome) { + var chrome_version = $.browser.version.split('.')[0]; + if (parseInt(chrome_version, 10) >= 50) { + loadCSS('/point_of_sale/static/src/css/chrome50.css'); + } + } + + if (this.env.pos.config.iface_big_scrollbars) { + this.state.hasBigScrollBars = true; + } + + this._disableBackspaceBack(); + this._replaceCrashmanager(); + } + // replaces the error handling of the existing crashmanager which + // uses jquery dialog to display the error, to use the pos popup + // instead + _replaceCrashmanager() { + var self = this; + CrashManager.include({ + show_warning: function (error) { + if (self.env.pos) { + // self == this component + self.showPopup('ErrorPopup', { + title: error.data.title.toString(), + body: error.data.message, + }); + } else { + // this == CrashManager instance + this._super(error); + } + }, + show_error: function (error) { + if (self.env.pos) { + // self == this component + self.showPopup('ErrorTracebackPopup', { + title: error.type, + body: error.message + '\n' + error.data.debug + '\n', + }); + } else { + // this == CrashManager instance + this._super(error); + } + }, + }); + } + // prevent backspace from performing a 'back' navigation + _disableBackspaceBack() { + $(document).on('keydown', function (e) { + if (e.which === 8 && !$(e.target).is('input, textarea')) { + e.preventDefault(); + } + }); + } + _closeOtherTabs() { + localStorage['message'] = ''; + localStorage['message'] = JSON.stringify({ + message: 'close_tabs', + session: this.env.pos.pos_session.id, + }); + + window.addEventListener( + 'storage', + (event) => { + if (event.key === 'message' && event.newValue) { + const msg = JSON.parse(event.newValue); + if ( + msg.message === 'close_tabs' && + msg.session == this.env.pos.pos_session.id + ) { + console.info( + 'POS / Session opened in another window. EXITING POS' + ); + this._closePos(); + } + } + }, + false + ); + } + } + Chrome.template = 'Chrome'; + + Registries.Component.add(Chrome); + + return Chrome; +}); diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/CashierName.js b/addons/point_of_sale/static/src/js/ChromeWidgets/CashierName.js new file mode 100644 index 00000000..02e61967 --- /dev/null +++ b/addons/point_of_sale/static/src/js/ChromeWidgets/CashierName.js @@ -0,0 +1,23 @@ +odoo.define('point_of_sale.CashierName', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + // Previously UsernameWidget + class CashierName extends PosComponent { + get username() { + const cashier = this.env.pos.get_cashier(); + if (cashier) { + return cashier.name; + } else { + return ''; + } + } + } + CashierName.template = 'CashierName'; + + Registries.Component.add(CashierName); + + return CashierName; +}); diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/ClientScreenButton.js b/addons/point_of_sale/static/src/js/ChromeWidgets/ClientScreenButton.js new file mode 100644 index 00000000..38403b58 --- /dev/null +++ b/addons/point_of_sale/static/src/js/ChromeWidgets/ClientScreenButton.js @@ -0,0 +1,87 @@ +odoo.define('point_of_sale.ClientScreenButton', function(require) { + 'use strict'; + + const { useState } = owl; + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + // Formerly ClientScreenWidget + class ClientScreenButton extends PosComponent { + constructor() { + super(...arguments); + this.state = useState({ status: 'failure' }); + this._start(); + } + get message() { + return { + success: '', + warning: this.env._t('Connected, Not Owned'), + failure: this.env._t('Disconnected'), + not_found: this.env._t('Client Screen Unsupported. Please upgrade the IoT Box'), + }[this.state.status]; + } + async onClick() { + try { + const renderedHtml = await this.env.pos.render_html_for_customer_facing_display(); + const ownership = await this.env.pos.proxy.take_ownership_over_client_screen( + renderedHtml + ); + if (typeof ownership === 'string') { + ownership = JSON.parse(ownership); + } + if (ownership.status === 'success') { + this.state.status = 'success'; + } else { + this.state.status = 'warning'; + } + if (!this.env.pos.proxy.posbox_supports_display) { + this.env.pos.proxy.posbox_supports_display = true; + this._start(); + } + } catch (error) { + if (typeof error == 'undefined') { + this.state.status = 'failure'; + } else { + this.state.status = 'not_found'; + } + } + } + _start() { + const self = this; + async function loop() { + if (self.env.pos.proxy.posbox_supports_display) { + try { + const ownership = await self.env.pos.proxy.test_ownership_of_client_screen(); + if (typeof ownership === 'string') { + ownership = JSON.parse(ownership); + } + if (ownership.status === 'OWNER') { + self.state.status = 'success'; + } else { + self.state.status = 'warning'; + } + setTimeout(loop, 3000); + } catch (error) { + if (error.abort) { + // Stop the loop + return; + } + if (typeof error == 'undefined') { + self.state.status = 'failure'; + } else { + self.state.status = 'not_found'; + self.env.pos.proxy.posbox_supports_display = false; + } + setTimeout(loop, 3000); + } + } + } + loop(); + } + } + ClientScreenButton.template = 'ClientScreenButton'; + + Registries.Component.add(ClientScreenButton); + + return ClientScreenButton; +}); diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/DebugWidget.js b/addons/point_of_sale/static/src/js/ChromeWidgets/DebugWidget.js new file mode 100644 index 00000000..f5158428 --- /dev/null +++ b/addons/point_of_sale/static/src/js/ChromeWidgets/DebugWidget.js @@ -0,0 +1,161 @@ +odoo.define('point_of_sale.DebugWidget', function (require) { + 'use strict'; + + const { useState } = owl; + const { useRef } = owl.hooks; + const { getFileAsText } = require('point_of_sale.utils'); + const { parse } = require('web.field_utils'); + const NumberBuffer = require('point_of_sale.NumberBuffer'); + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class DebugWidget extends PosComponent { + constructor() { + super(...arguments); + this.state = useState({ + barcodeInput: '', + weightInput: '', + isPaidOrdersReady: false, + isUnpaidOrdersReady: false, + buffer: NumberBuffer.get(), + }); + + // NOTE: Perhaps this can still be improved. + // What we do here is loop thru the `event` elements + // then we assign animation that happens when the event is triggered + // in the proxy. E.g. if open_cashbox is sent, the open_cashbox element + // changes color from '#6CD11D' to '#1E1E1E' for a duration of 2sec. + this.eventElementsRef = {}; + this.animations = {}; + for (let eventName of ['open_cashbox', 'print_receipt', 'scale_read']) { + this.eventElementsRef[eventName] = useRef(eventName); + this.env.pos.proxy.add_notification( + eventName, + (() => { + if (this.animations[eventName]) { + this.animations[eventName].cancel(); + } + const eventElement = this.eventElementsRef[eventName].el; + eventElement.style.backgroundColor = '#6CD11D'; + this.animations[eventName] = eventElement.animate( + { backgroundColor: ['#6CD11D', '#1E1E1E'] }, + 2000 + ); + }).bind(this) + ); + } + } + mounted() { + NumberBuffer.on('buffer-update', this, this._onBufferUpdate); + } + willUnmount() { + NumberBuffer.off('buffer-update', this, this._onBufferUpdate); + } + toggleWidget() { + this.state.isShown = !this.state.isShown; + } + setWeight() { + var weightInKg = parse.float(this.state.weightInput); + if (!isNaN(weightInKg)) { + this.env.pos.proxy.debug_set_weight(weightInKg); + } + } + resetWeight() { + this.state.weightInput = ''; + this.env.pos.proxy.debug_reset_weight(); + } + barcodeScan() { + this.env.pos.barcode_reader.scan(this.state.barcodeInput); + } + barcodeScanEAN() { + const ean = this.env.pos.barcode_reader.barcode_parser.sanitize_ean( + this.state.barcodeInput || '0' + ); + this.state.barcodeInput = ean; + this.env.pos.barcode_reader.scan(ean); + } + async deleteOrders() { + const { confirmed } = await this.showPopup('ConfirmPopup', { + title: this.env._t('Delete Paid Orders ?'), + body: this.env._t( + 'This operation will permanently destroy all paid orders from the local storage. You will lose all the data. This operation cannot be undone.' + ), + }); + if (confirmed) { + this.env.pos.db.remove_all_orders(); + this.env.pos.set_synch('connected', 0); + } + } + async deleteUnpaidOrders() { + const { confirmed } = await this.showPopup('ConfirmPopup', { + title: this.env._t('Delete Unpaid Orders ?'), + body: this.env._t( + 'This operation will destroy all unpaid orders in the browser. You will lose all the unsaved data and exit the point of sale. This operation cannot be undone.' + ), + }); + if (confirmed) { + this.env.pos.db.remove_all_unpaid_orders(); + window.location = '/'; + } + } + _createBlob(contents) { + if (typeof contents !== 'string') { + contents = JSON.stringify(contents, null, 2); + } + return new Blob([contents]); + } + // IMPROVEMENT: Duplicated codes for downloading paid and unpaid orders. + // The implementation can be better. + preparePaidOrders() { + try { + this.paidOrdersBlob = this._createBlob(this.env.pos.export_paid_orders()); + this.state.isPaidOrdersReady = true; + } catch (error) { + console.warn(error); + } + } + get paidOrdersFilename() { + return `${this.env._t('paid orders')} ${moment().format('YYYY-MM-DD-HH-mm-ss')}.json`; + } + get paidOrdersURL() { + var URL = window.URL || window.webkitURL; + return URL.createObjectURL(this.paidOrdersBlob); + } + prepareUnpaidOrders() { + try { + this.unpaidOrdersBlob = this._createBlob(this.env.pos.export_unpaid_orders()); + this.state.isUnpaidOrdersReady = true; + } catch (error) { + console.warn(error); + } + } + get unpaidOrdersFilename() { + return `${this.env._t('unpaid orders')} ${moment().format('YYYY-MM-DD-HH-mm-ss')}.json`; + } + get unpaidOrdersURL() { + var URL = window.URL || window.webkitURL; + return URL.createObjectURL(this.unpaidOrdersBlob); + } + async importOrders(event) { + const file = event.target.files[0]; + if (file) { + const report = this.env.pos.import_orders(await getFileAsText(file)); + await this.showPopup('OrderImportPopup', { report }); + } + } + refreshDisplay() { + this.env.pos.proxy.message('display_refresh', {}); + } + _onBufferUpdate(buffer) { + this.state.buffer = buffer; + } + get bufferRepr() { + return `"${this.state.buffer}"`; + } + } + DebugWidget.template = 'DebugWidget'; + + Registries.Component.add(DebugWidget); + + return DebugWidget; +}); diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/HeaderButton.js b/addons/point_of_sale/static/src/js/ChromeWidgets/HeaderButton.js new file mode 100644 index 00000000..84036ecb --- /dev/null +++ b/addons/point_of_sale/static/src/js/ChromeWidgets/HeaderButton.js @@ -0,0 +1,36 @@ +odoo.define('point_of_sale.HeaderButton', function(require) { + 'use strict'; + + const { useState } = owl; + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + // Previously HeaderButtonWidget + // This is the close session button + class HeaderButton extends PosComponent { + constructor() { + super(...arguments); + this.state = useState({ label: 'Close' }); + this.confirmed = null; + } + get translatedLabel() { + return this.env._t(this.state.label); + } + onClick() { + if (!this.confirmed) { + this.state.label = 'Confirm'; + this.confirmed = setTimeout(() => { + this.state.label = 'Close'; + this.confirmed = null; + }, 2000); + } else { + this.trigger('close-pos'); + } + } + } + HeaderButton.template = 'HeaderButton'; + + Registries.Component.add(HeaderButton); + + return HeaderButton; +}); diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/OrderManagementButton.js b/addons/point_of_sale/static/src/js/ChromeWidgets/OrderManagementButton.js new file mode 100644 index 00000000..0bee8880 --- /dev/null +++ b/addons/point_of_sale/static/src/js/ChromeWidgets/OrderManagementButton.js @@ -0,0 +1,36 @@ +odoo.define('point_of_sale.OrderManagementButton', function (require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + const { isRpcError } = require('point_of_sale.utils'); + + class OrderManagementButton extends PosComponent { + async onClick() { + try { + // ping the server, if no error, show the screen + await this.rpc({ + model: 'pos.order', + method: 'browse', + args: [[]], + kwargs: { context: this.env.session.user_context }, + }); + this.showScreen('OrderManagementScreen'); + } catch (error) { + if (isRpcError(error) && error.message.code < 0) { + this.showPopup('ErrorPopup', { + title: this.env._t('Network Error'), + body: this.env._t('Cannot access order management screen if offline.'), + }); + } else { + throw error; + } + } + } + } + OrderManagementButton.template = 'OrderManagementButton'; + + Registries.Component.add(OrderManagementButton); + + return OrderManagementButton; +}); diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/ProxyStatus.js b/addons/point_of_sale/static/src/js/ChromeWidgets/ProxyStatus.js new file mode 100644 index 00000000..98c24c02 --- /dev/null +++ b/addons/point_of_sale/static/src/js/ChromeWidgets/ProxyStatus.js @@ -0,0 +1,91 @@ +odoo.define('point_of_sale.ProxyStatus', function(require) { + 'use strict'; + + const { useState } = owl; + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + // Previously ProxyStatusWidget + class ProxyStatus extends PosComponent { + constructor() { + super(...arguments); + const initialProxyStatus = this.env.pos.proxy.get('status'); + this.state = useState({ + status: initialProxyStatus.status, + msg: initialProxyStatus.msg, + }); + this.statuses = ['connected', 'connecting', 'disconnected', 'warning']; + this.index = 0; + } + mounted() { + this.env.pos.proxy.on('change:status', this, this._onChangeStatus); + } + willUnmount() { + this.env.pos.proxy.off('change:status', this, this._onChangeStatus); + } + async onClick() { + try { + await this.env.pos.connect_to_proxy(); + } catch (error) { + if (error instanceof Error) { + throw error; + } else { + this.showPopup('ErrorPopup', error); + } + } + } + _onChangeStatus(posProxy, statusChange) { + this._setStatus(statusChange.newValue); + } + _setStatus(newStatus) { + if (newStatus.status === 'connected') { + var warning = false; + var msg = ''; + if (this.env.pos.config.iface_scan_via_proxy) { + var scannerStatus = newStatus.drivers.scanner + ? newStatus.drivers.scanner.status + : false; + if (scannerStatus != 'connected' && scannerStatus != 'connecting') { + warning = true; + msg += this.env._t('Scanner'); + } + } + if ( + this.env.pos.config.iface_print_via_proxy || + this.env.pos.config.iface_cashdrawer + ) { + var printerStatus = newStatus.drivers.printer + ? newStatus.drivers.printer.status + : false; + if (printerStatus != 'connected' && printerStatus != 'connecting') { + warning = true; + msg = msg ? msg + ' & ' : msg; + msg += this.env._t('Printer'); + } + } + if (this.env.pos.config.iface_electronic_scale) { + var scaleStatus = newStatus.drivers.scale + ? newStatus.drivers.scale.status + : false; + if (scaleStatus != 'connected' && scaleStatus != 'connecting') { + warning = true; + msg = msg ? msg + ' & ' : msg; + msg += this.env._t('Scale'); + } + } + msg = msg ? msg + ' ' + this.env._t('Offline') : msg; + + this.state.status = warning ? 'warning' : 'connected'; + this.state.msg = msg; + } else { + this.state.status = newStatus.status; + this.state.msg = newStatus.msg || ''; + } + } + } + ProxyStatus.template = 'ProxyStatus'; + + Registries.Component.add(ProxyStatus); + + return ProxyStatus; +}); diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/SaleDetailsButton.js b/addons/point_of_sale/static/src/js/ChromeWidgets/SaleDetailsButton.js new file mode 100644 index 00000000..e646547e --- /dev/null +++ b/addons/point_of_sale/static/src/js/ChromeWidgets/SaleDetailsButton.js @@ -0,0 +1,38 @@ +odoo.define('point_of_sale.SaleDetailsButton', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class SaleDetailsButton extends PosComponent { + async onClick() { + // IMPROVEMENT: Perhaps put this logic in a parent component + // so that for unit testing, we can check if this simple + // component correctly triggers an event. + const saleDetails = await this.rpc({ + model: 'report.point_of_sale.report_saledetails', + method: 'get_sale_details', + args: [false, false, false, [this.env.pos.pos_session.id]], + }); + const report = this.env.qweb.renderToString( + 'SaleDetailsReport', + Object.assign({}, saleDetails, { + date: new Date().toLocaleString(), + pos: this.env.pos, + }) + ); + const printResult = await this.env.pos.proxy.printer.print_receipt(report); + if (!printResult.successful) { + await this.showPopup('ErrorPopup', { + title: printResult.message.title, + body: printResult.message.body, + }); + } + } + } + SaleDetailsButton.template = 'SaleDetailsButton'; + + Registries.Component.add(SaleDetailsButton); + + return SaleDetailsButton; +}); diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/SyncNotification.js b/addons/point_of_sale/static/src/js/ChromeWidgets/SyncNotification.js new file mode 100644 index 00000000..5a4e158d --- /dev/null +++ b/addons/point_of_sale/static/src/js/ChromeWidgets/SyncNotification.js @@ -0,0 +1,37 @@ +odoo.define('point_of_sale.SyncNotification', function(require) { + 'use strict'; + + const { useState } = owl; + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + // Previously SynchNotificationWidget + class SyncNotification extends PosComponent { + constructor() { + super(...arguments); + const synch = this.env.pos.get('synch'); + this.state = useState({ status: synch.status, msg: synch.pending }); + } + mounted() { + this.env.pos.on( + 'change:synch', + (pos, synch) => { + this.state.status = synch.status; + this.state.msg = synch.pending; + }, + this + ); + } + willUnmount() { + this.env.pos.on('change:synch', null, this); + } + onClick() { + this.env.pos.push_orders(null, { show_error: true }); + } + } + SyncNotification.template = 'SyncNotification'; + + Registries.Component.add(SyncNotification); + + return SyncNotification; +}); diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/TicketButton.js b/addons/point_of_sale/static/src/js/ChromeWidgets/TicketButton.js new file mode 100644 index 00000000..d142bbde --- /dev/null +++ b/addons/point_of_sale/static/src/js/ChromeWidgets/TicketButton.js @@ -0,0 +1,41 @@ +odoo.define('point_of_sale.TicketButton', function (require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + const { posbus } = require('point_of_sale.utils'); + + class TicketButton extends PosComponent { + onClick() { + if (this.props.isTicketScreenShown) { + posbus.trigger('ticket-button-clicked'); + } else { + this.showScreen('TicketScreen'); + } + } + willPatch() { + posbus.off('order-deleted', this); + } + patched() { + posbus.on('order-deleted', this, this.render); + } + mounted() { + posbus.on('order-deleted', this, this.render); + } + willUnmount() { + posbus.off('order-deleted', this); + } + get count() { + if (this.env.pos) { + return this.env.pos.get_order_list().length; + } else { + return 0; + } + } + } + TicketButton.template = 'TicketButton'; + + Registries.Component.add(TicketButton); + + return TicketButton; +}); diff --git a/addons/point_of_sale/static/src/js/ClassRegistry.js b/addons/point_of_sale/static/src/js/ClassRegistry.js new file mode 100644 index 00000000..eed07fe3 --- /dev/null +++ b/addons/point_of_sale/static/src/js/ClassRegistry.js @@ -0,0 +1,262 @@ +odoo.define('point_of_sale.ClassRegistry', function (require) { + 'use strict'; + + /** + * **Usage:** + * ``` + * const Registry = new ClassRegistry(); + * + * class A {} + * Registry.add(A); + * + * const AExt1 = A => class extends A {} + * Registry.extend(A, AExt1) + * + * const B = A => class extends A {} + * Registry.addByExtending(B, A) + * + * const AExt2 = A => class extends A {} + * Registry.extend(A, AExt2) + * + * Registry.get(A) + * // above returns: AExt2 -> AExt1 -> A + * // Basically, 'A' in the registry points to + * // the inheritance chain above. + * + * Registry.get(B) + * // above returns: B -> AExt2 -> AExt1 -> A + * // Even though B extends A before applying all + * // the extensions of A, when getting it from the + * // registry, the return points to a class with + * // inheritance chain that includes all the extensions + * // of 'A'. + * + * Registry.freeze() + * // Example 'B' above is lazy. Basically, it is only + * // computed when we call `get` from the registry. + * // If we know that no more dynamic inheritances will happen, + * // we can freeze the registry and cache the final form + * // of each class in the registry. + * ``` + * + * IMPROVEMENT: + * * So far, mixin can be accomplished by creating a method + * the takes a class and returns a class expression. This is then + * used before the extends keyword like so: + * + * ```js + * class A {} + * Registry.add(A) + * const Mixin = x => class extends x {} + * // apply mixin + * // | + * // v + * const B = x => class extends Mixin(x) {} + * Registry.addByExtending(B, A) + * ``` + * + * In the example, `|B| => B -> Mixin -> A`, and this is pretty convenient + * already. However, this can still be improved since classes are only + * compiled after `Registry.freeze()`. Perhaps, we can make the + * Mixins extensible as well, such as so: + * + * ``` + * class A {} + * Registry.add(A) + * const Mixin = x => class extends x {} + * Registry.add(Mixin) + * const OtherMixin = x => class extends x {} + * Registry.add(OtherMixin) + * const B = x => class extends x {} + * Registry.addByExtending(B, A, [Mixin, OtherMixin]) + * const ExtendMixin = x => class extends x {} + * Registry.extend(Mixin, ExtendMixin) + * ``` + * + * In the above, after `Registry.freeze()`, + * `|B| => B -> OtherMixin -> ExtendMixin -> Mixin -> A` + */ + class ClassRegistry { + constructor() { + // base name map + this.baseNameMap = {}; + // Object that maps `baseClass` to the class implementation extended in-place. + this.includedMap = new Map(); + // Object that maps `baseClassCB` to the array of callbacks to generate the extended class. + this.extendedCBMap = new Map(); + // Object that maps `baseClassCB` extended class to the `baseClass` of its super in the includedMap. + this.extendedSuperMap = new Map(); + // For faster access, we can `freeze` the registry so that instead of dynamically generating + // the extended classes, it is taken from the cache instead. + this.cache = new Map(); + } + /** + * Add a new class in the Registry. + * @param {Function} baseClass `class` + */ + add(baseClass) { + this.includedMap.set(baseClass, []); + this.baseNameMap[baseClass.name] = baseClass; + } + /** + * Add a new class in the Registry based on other class + * in the registry. + * @param {Function} baseClassCB `class -> class` + * @param {Function} base `class | class -> class` + */ + addByExtending(baseClassCB, base) { + this.extendedCBMap.set(baseClassCB, [baseClassCB]); + this.extendedSuperMap.set(baseClassCB, base); + this.baseNameMap[baseClassCB.name] = baseClassCB; + } + /** + * Extend in-place a class in the registry. E.g. + * ``` + * // Using the following notation: + * // * |A| - compiled class in the registry + * // * A - class or an extension callback + * // * |A| => A2 -> A1 -> A + * // - the above means, compiled class A + * // points to the class inheritance derived from + * // A2(A1(A)) + * + * class A {}; + * Registry.add(A); + * // |A| => A + * + * let A1 = x => class extends x {}; + * Registry.extend(A, A1); + * // |A| => A1 -> A + * + * let B = x => class extends x {}; + * Registry.addByExtending(B, A); + * // |B| => B -> |A| + * // |B| => B -> A1 -> A + * + * let B1 = x => class extends x {}; + * Registry.extend(B, B1); + * // |B| => B1 -> B -> |A| + * + * let C = x => class extends x {}; + * Registry.addByExtending(C, B); + * // |C| => C -> |B| + * + * let B2 = x => class extends x {}; + * Registry.extend(B, B2); + * // |B| => B2 -> B1 -> B -> |A| + * + * // Overall: + * // |A| => A1 -> A + * // |B| => B2 -> B1 -> B -> A1 -> A + * // |C| => C -> B2 -> B1 -> B -> A1 -> A + * ``` + * @param {Function} base `class | class -> class` + * @param {Function} extensionCB `class -> class` + */ + extend(base, extensionCB) { + if (typeof base === 'string') { + base = this.baseNameMap[base]; + } + let extensionArray; + if (this.includedMap.get(base)) { + extensionArray = this.includedMap.get(base); + } else if (this.extendedCBMap.get(base)) { + extensionArray = this.extendedCBMap.get(base); + } else { + throw new Error( + `'${base.name}' is not in the Registry. Add it to Registry before extending.` + ); + } + extensionArray.push(extensionCB); + const locOfNewExtension = extensionArray.length - 1; + const self = this; + const oldCompiled = this.isFrozen ? this.cache.get(base) : null; + return { + remove() { + extensionArray.splice(locOfNewExtension, 1); + self._recompute(base, oldCompiled); + }, + compile() { + self._recompute(base); + } + }; + } + _compile(base) { + let res; + if (this.includedMap.has(base)) { + res = this.includedMap.get(base).reduce((acc, ext) => ext(acc), base); + } else { + const superClass = this.extendedSuperMap.get(base); + const extensionCBs = this.extendedCBMap.get(base); + res = extensionCBs.reduce((acc, ext) => ext(acc), this._compile(superClass)); + } + Object.defineProperty(res, 'name', { value: base.name }); + return res; + } + /** + * Return the compiled class (containing all the extensions) of the base class. + * @param {Function} base `class | class -> class` function used in adding the class + */ + get(base) { + if (typeof base === 'string') { + base = this.baseNameMap[base]; + } + if (this.isFrozen) { + return this.cache.get(base); + } + return this._compile(base); + } + /** + * Uses the callbacks registered in the registry to compile the classes. + */ + freeze() { + // Step: Compile the `included classes`. + for (let [baseClass, extensionCBs] of this.includedMap.entries()) { + const compiled = extensionCBs.reduce((acc, ext) => ext(acc), baseClass); + this.cache.set(baseClass, compiled); + } + + // Step: Compile the `extended classes` based on `included classes`. + // Also gather those the are based on `extended classes`. + const remaining = []; + for (let [baseClassCB, extensionCBArray] of this.extendedCBMap.entries()) { + const compiled = this.cache.get(this.extendedSuperMap.get(baseClassCB)); + if (!compiled) { + remaining.push([baseClassCB, extensionCBArray]); + continue; + } + const extendedClass = extensionCBArray.reduce( + (acc, extensionCB) => extensionCB(acc), + compiled + ); + this.cache.set(baseClassCB, extendedClass); + } + + // Step: Compile the `extended classes` based on `extended classes`. + for (let [baseClassCB, extensionCBArray] of remaining) { + const compiled = this.cache.get(this.extendedSuperMap.get(baseClassCB)); + const extendedClass = extensionCBArray.reduce( + (acc, extensionCB) => extensionCB(acc), + compiled + ); + this.cache.set(baseClassCB, extendedClass); + } + + // Step: Set the name of the compiled classess + for (let [base, compiledClass] of this.cache.entries()) { + Object.defineProperty(compiledClass, 'name', { value: base.name }); + } + + // Step: Set the flag to true; + this.isFrozen = true; + } + _recompute(base, old) { + if (typeof base === 'string') { + base = this.baseNameMap[base]; + } + return old ? old : this._compile(base); + } + } + + return ClassRegistry; +}); diff --git a/addons/point_of_sale/static/src/js/ComponentRegistry.js b/addons/point_of_sale/static/src/js/ComponentRegistry.js new file mode 100644 index 00000000..1e820782 --- /dev/null +++ b/addons/point_of_sale/static/src/js/ComponentRegistry.js @@ -0,0 +1,29 @@ +odoo.define('point_of_sale.ComponentRegistry', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const ClassRegistry = require('point_of_sale.ClassRegistry'); + + class ComponentRegistry extends ClassRegistry { + freeze() { + super.freeze(); + // Make sure PosComponent has the compiled classes. + // This way, we don't need to explicitly declare that + // a set of components is children of another. + PosComponent.components = {}; + for (let [base, compiledClass] of this.cache.entries()) { + PosComponent.components[base.name] = compiledClass; + } + } + _recompute(base, old) { + const res = super._recompute(base, old); + if (typeof base === 'string') { + base = this.baseNameMap[base]; + } + PosComponent.components[base.name] = res; + return res; + } + } + + return ComponentRegistry; +}); diff --git a/addons/point_of_sale/static/src/js/ControlButtonsMixin.js b/addons/point_of_sale/static/src/js/ControlButtonsMixin.js new file mode 100644 index 00000000..02b4c367 --- /dev/null +++ b/addons/point_of_sale/static/src/js/ControlButtonsMixin.js @@ -0,0 +1,84 @@ +odoo.define('point_of_sale.ControlButtonsMixin', function (require) { + 'use strict'; + + const Registries = require('point_of_sale.Registries'); + + /** + * Component that has this mixin allows the use of `addControlButton`. + * All added control buttons that satisfies the condition can be accessed + * thru the `controlButtons` field of the Component's instance. These + * control buttons can then be rendered in the Component. + * @param {Function} x superclass + */ + const ControlButtonsMixin = (x) => { + class Extended extends x { + get controlButtons() { + return this.constructor.controlButtons + .filter((cb) => { + return cb.condition.bind(this)(); + }) + .map((cb) => + Object.assign({}, cb, { component: Registries.Component.get(cb.component) }) + ); + } + } + Extended.controlButtons = []; + /** + * @param {Object} controlButton + * @param {Function} controlButton.component + * Base class that is added in the Registries.Component. + * @param {Function} controlButton.condition zero argument function that is bound + * to the instance of ProductScreen, such that `this.env.pos` can be used + * inside the function. + * @param {Array} [controlButton.position] array of two elements + * [locator, relativeTo] + * locator: string -> any of ('before', 'after', 'replace') + * relativeTo: string -> other controlButtons component name + */ + Extended.addControlButton = function (controlButton) { + // We set the name first. + if (!controlButton.name) { + controlButton.name = controlButton.component.name; + } + + // If no position is set, we just push it to the array. + if (!controlButton.position) { + this.controlButtons.push(controlButton); + } else { + // Find where to put the new controlButton. + const [locator, relativeTo] = controlButton.position; + let whereIndex = -1; + for (let i = 0; i < this.controlButtons.length; i++) { + if (this.controlButtons[i].name === relativeTo) { + if (['before', 'replace'].includes(locator)) { + whereIndex = i; + } else if (locator === 'after') { + whereIndex = i + 1; + } + break; + } + } + + // If found where to put, then perform the necessary mutation of + // the buttons array. + // Else, we just push this controlButton to the array. + if (whereIndex > -1) { + this.controlButtons.splice( + whereIndex, + locator === 'replace' ? 1 : 0, + controlButton + ); + } else { + let warningMessage = + `'${controlButton.name}' has invalid 'position' ([${locator}, ${relativeTo}]).` + + 'It is pushed to the controlButtons stack instead.'; + console.warn(warningMessage); + this.controlButtons.push(controlButton); + } + } + }; + return Extended; + }; + + return ControlButtonsMixin; +}); diff --git a/addons/point_of_sale/static/src/js/Gui.js b/addons/point_of_sale/static/src/js/Gui.js new file mode 100644 index 00000000..0720b397 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Gui.js @@ -0,0 +1,60 @@ +odoo.define('point_of_sale.Gui', function (require) { + 'use strict'; + + /** + * This module bridges the data classes (such as those defined in + * models.js) to the view (owl.Component) but not vice versa. + * + * The idea is to be able to perform side-effects to the user interface + * during calculation. Think of console.log during times we want to see + * the result of calculations. This is no different, except that instead + * of printing something in the console, we access a method in the user + * interface then the user interface reacts, e.g. calling `showPopup`. + * + * This however can be dangerous to the user interface as it can be possible + * that a rendered component is destroyed during the calculation. Because of + * this, we are going to limit external ui controls to those safe ones to + * use such as: + * - `showPopup` + * - `showTempScreen` + * + * IMPROVEMENT: After all, this Gui layer seems to be a good abstraction because + * there is a complete decoupling between data and view despite the data being + * able to use selected functionalities in the view layer. More formalized + * implementation is welcome. + */ + + const config = {}; + + /** + * Call this when the user interface is ready. Provide the component + * that will be used to control the ui. + * @param {owl.component} component component having the ui methods. + */ + const configureGui = ({ component }) => { + config.component = component; + config.availableMethods = new Set([ + 'showPopup', + 'showTempScreen', + 'playSound', + 'setSyncStatus', + ]); + }; + + /** + * Import this and consume like so: `Gui.showPopup(<PopupName>, <props>)`. + * Like you would call `showPopup` in a component. + */ + const Gui = new Proxy(config, { + get(target, key) { + const { component, availableMethods } = target; + if (!component) throw new Error(`Call 'configureGui' before using Gui.`); + const isMounted = component.__owl__.status === 3 /* mounted */; + if (availableMethods.has(key) && isMounted) { + return component[key].bind(component); + } + }, + }); + + return { configureGui, Gui }; +}); diff --git a/addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js b/addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js new file mode 100644 index 00000000..2ebdce20 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js @@ -0,0 +1,62 @@ +odoo.define('point_of_sale.AbstractReceiptScreen', function (require) { + 'use strict'; + + const { useRef } = owl.hooks; + const { nextFrame } = require('point_of_sale.utils'); + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + /** + * This relies on the assumption that there is a reference to + * `order-receipt` so it is important to declare a `t-ref` to + * `order-receipt` in the template of the Component that extends + * this abstract component. + */ + class AbstractReceiptScreen extends PosComponent { + constructor() { + super(...arguments); + this.orderReceipt = useRef('order-receipt'); + } + async _printReceipt() { + if (this.env.pos.proxy.printer) { + const printResult = await this.env.pos.proxy.printer.print_receipt(this.orderReceipt.el.outerHTML); + if (printResult.successful) { + return true; + } else { + const { confirmed } = await this.showPopup('ConfirmPopup', { + title: printResult.message.title, + body: 'Do you want to print using the web printer?', + }); + if (confirmed) { + // We want to call the _printWeb when the popup is fully gone + // from the screen which happens after the next animation frame. + await nextFrame(); + return await this._printWeb(); + } + return false; + } + } else { + return await this._printWeb(); + } + } + async _printWeb() { + try { + window.print(); + return true; + } catch (err) { + await this.showPopup('ErrorPopup', { + title: this.env._t('Printing is not supported on some browsers'), + body: this.env._t( + 'Printing is not supported on some browsers due to no default printing protocol ' + + 'is available. It is possible to print your tickets by making use of an IoT Box.' + ), + }); + return false; + } + } + } + + Registries.Component.add(AbstractReceiptScreen); + + return AbstractReceiptScreen; +}); diff --git a/addons/point_of_sale/static/src/js/Misc/Draggable.js b/addons/point_of_sale/static/src/js/Misc/Draggable.js new file mode 100644 index 00000000..cbb1eba8 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/Draggable.js @@ -0,0 +1,142 @@ +odoo.define('point_of_sale.Draggable', function(require) { + 'use strict'; + + const { useExternalListener } = owl.hooks; + const { useListener } = require('web.custom_hooks'); + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + /** + * Wrap an element or a component with { position: absolute } to make it + * draggable around the limitArea or the nearest positioned ancestor. + * + * e.g. + * ``` + * <div class="limit-area"> + * <Draggable limitArea="'.limit-area'"> + * <div class="popup"> + * <header class="drag-handle"></header> + * </div> + * <div class="popup body"></div> + * </Draggable> + * </div> + * ``` + * + * In the above snippet, if the popup div is { position: absolute }, + * then it becomes draggable around the .limit-area element if it is dragged + * thru its Header -- because of the .drag-handle element. + * + * @trigger 'drag-end' when dragging ended with payload `{ loc: { top, left } }` + */ + class Draggable extends PosComponent { + constructor() { + super(...arguments); + this.isDragging = false; + this.dx = 0; + this.dy = 0; + // drag with mouse + useExternalListener(document, 'mousemove', this.move); + useExternalListener(document, 'mouseup', this.endDrag); + // drag with touch + useExternalListener(document, 'touchmove', this.move); + useExternalListener(document, 'touchend', this.endDrag); + + useListener('mousedown', '.drag-handle', this.startDrag); + useListener('touchstart', '.drag-handle', this.startDrag); + } + mounted() { + this.limitArea = this.props.limitArea + ? document.querySelector(this.props.limitArea) + : this.el.offsetParent; + this.limitAreaBoundingRect = this.limitArea.getBoundingClientRect(); + if (this.limitArea === this.el.offsetParent) { + this.limitLeft = 0; + this.limitTop = 0; + this.limitRight = this.limitAreaBoundingRect.width; + this.limitBottom = this.limitAreaBoundingRect.height; + } else { + this.limitLeft = -this.el.offsetParent.offsetLeft; + this.limitTop = -this.el.offsetParent.offsetTop; + this.limitRight = + this.limitAreaBoundingRect.width - this.el.offsetParent.offsetLeft; + this.limitBottom = + this.limitAreaBoundingRect.height - this.el.offsetParent.offsetTop; + } + this.limitAreaWidth = this.limitAreaBoundingRect.width; + this.limitAreaHeight = this.limitAreaBoundingRect.height; + + // absolutely position the element then remove the transform. + const elBoundingRect = this.el.getBoundingClientRect(); + this.el.style.top = `${elBoundingRect.top}px`; + this.el.style.left = `${elBoundingRect.left}px`; + this.el.style.transform = 'none'; + } + startDrag(event) { + let realEvent; + if (event instanceof CustomEvent) { + realEvent = event.detail; + } else { + realEvent = event; + } + const { x, y } = this._getEventLoc(realEvent); + this.isDragging = true; + this.dx = this.el.offsetLeft - x; + this.dy = this.el.offsetTop - y; + event.stopPropagation(); + } + move(event) { + if (this.isDragging) { + const { x: pointerX, y: pointerY } = this._getEventLoc(event); + const posLeft = this._getPosLeft(pointerX, this.dx); + const posTop = this._getPosTop(pointerY, this.dy); + this.el.style.left = `${posLeft}px`; + this.el.style.top = `${posTop}px`; + } + } + endDrag() { + if (this.isDragging) { + this.isDragging = false; + this.trigger('drag-end', { + loc: { top: this.el.offsetTop, left: this.el.offsetLeft }, + }); + } + } + _getEventLoc(event) { + let coordX, coordY; + if (event.touches && event.touches[0]) { + coordX = event.touches[0].clientX; + coordY = event.touches[0].clientY; + } else { + coordX = event.clientX; + coordY = event.clientY; + } + return { + x: coordX, + y: coordY, + }; + } + _getPosLeft(pointerX, dx) { + const posLeft = pointerX + dx; + if (posLeft < this.limitLeft) { + return this.limitLeft; + } else if (posLeft > this.limitRight - this.el.offsetWidth) { + return this.limitRight - this.el.offsetWidth; + } + return posLeft; + } + _getPosTop(pointerY, dy) { + const posTop = pointerY + dy; + if (posTop < this.limitTop) { + return this.limitTop; + } else if (posTop > this.limitBottom - this.el.offsetHeight) { + return this.limitBottom - this.el.offsetHeight; + } + return posTop; + } + } + Draggable.template = 'Draggable'; + + Registries.Component.add(Draggable); + + return Draggable; +}); diff --git a/addons/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js b/addons/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js new file mode 100644 index 00000000..e2f2148b --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js @@ -0,0 +1,23 @@ +odoo.define('point_of_sale.IndependentToOrderScreen', function (require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + + class IndependentToOrderScreen extends PosComponent { + /** + * Alias the forceTriggerSelectedOrder method as it also + * means 'closing' this screen. + */ + close() { + this.forceTriggerSelectedOrder(); + } + forceTriggerSelectedOrder() { + // Calling this method forcefully trigger change + // on the selectedOrder attribute, which then shows the screen of the + // current order, essentially closing this screen. + this.env.pos.trigger('change:selectedOrder', this.env.pos, this.env.pos.get_order()); + } + } + + return IndependentToOrderScreen; +}); diff --git a/addons/point_of_sale/static/src/js/Misc/MobileOrderWidget.js b/addons/point_of_sale/static/src/js/Misc/MobileOrderWidget.js new file mode 100644 index 00000000..024a77b3 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/MobileOrderWidget.js @@ -0,0 +1,39 @@ +odoo.define('point_of_sale.MobileOrderWidget', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class MobileOrderWidget extends PosComponent { + constructor() { + super(...arguments); + this.pane = this.props.pane; + this.update(); + } + get order() { + return this.env.pos.get_order(); + } + mounted() { + this.order.on('change', () => { + this.update(); + this.render(); + }); + this.order.orderlines.on('change', () => { + this.update(); + this.render(); + }); + } + update() { + const total = this.order ? this.order.get_total_with_tax() : 0; + const tax = this.order ? total - this.order.get_total_without_tax() : 0; + this.total = this.env.pos.format_currency(total); + this.items_number = this.order ? this.order.orderlines.reduce((items_number,line) => items_number + line.quantity, 0) : 0; + } + } + + MobileOrderWidget.template = 'MobileOrderWidget'; + + Registries.Component.add(MobileOrderWidget); + + return MobileOrderWidget; +}); diff --git a/addons/point_of_sale/static/src/js/Misc/NotificationSound.js b/addons/point_of_sale/static/src/js/Misc/NotificationSound.js new file mode 100644 index 00000000..540e84f1 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/NotificationSound.js @@ -0,0 +1,19 @@ +odoo.define('point_of_sale.NotificationSound', function (require) { + 'use strict'; + + const { useListener } = require('web.custom_hooks'); + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class NotificationSound extends PosComponent { + constructor() { + super(...arguments); + useListener('ended', () => (this.props.sound.src = null)); + } + } + NotificationSound.template = 'NotificationSound'; + + Registries.Component.add(NotificationSound); + + return NotificationSound; +}); diff --git a/addons/point_of_sale/static/src/js/Misc/NumberBuffer.js b/addons/point_of_sale/static/src/js/Misc/NumberBuffer.js new file mode 100644 index 00000000..8e25f601 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/NumberBuffer.js @@ -0,0 +1,297 @@ +odoo.define('point_of_sale.NumberBuffer', function(require) { + 'use strict'; + + const { Component } = owl; + const { EventBus } = owl.core; + const { onMounted, onWillUnmount, useExternalListener } = owl.hooks; + const { useListener } = require('web.custom_hooks'); + const { parse } = require('web.field_utils'); + const { BarcodeEvents } = require('barcodes.BarcodeEvents'); + const { _t } = require('web.core'); + const { Gui } = require('point_of_sale.Gui'); + + const INPUT_KEYS = new Set( + ['Delete', 'Backspace', '+1', '+2', '+5', '+10', '+20', '+50'].concat('0123456789+-.,'.split('')) + ); + const CONTROL_KEYS = new Set(['Enter', 'Esc']); + const ALLOWED_KEYS = new Set([...INPUT_KEYS, ...CONTROL_KEYS]); + const getDefaultConfig = () => ({ + decimalPoint: false, + triggerAtEnter: false, + triggerAtEsc: false, + triggerAtInput: false, + nonKeyboardInputEvent: false, + useWithBarcode: false, + }); + + /** + * This is a singleton. + * + * Only one component can `use` the buffer at a time. + * This is done by keeping track of each component (and its + * corresponding state and config) using a stack (bufferHolderStack). + * The component on top of the stack is the one that currently + * `holds` the buffer. + * + * When the current component is unmounted, the top of the stack + * is popped and NumberBuffer is set up again for the new component + * on top of the stack. + * + * Usage + * ===== + * - Activate in the construction of root component. `NumberBuffer.activate()` + * - Use the buffer in a child component by calling `NumberBuffer.use(<config>)` + * in the constructor of the child component. + * - The component that `uses` the buffer has access to the following instance + * methods of the NumberBuffer: + * - get() + * - set(val) + * - reset() + * - getFloat() + * - capture() + * + * Note + * ==== + * - No need to instantiate as it is a singleton created before exporting in this module. + * + * Possible Improvements + * ===================== + * - Relieve the buffer from responsibility of handling `Enter` and other control keys. + * - Make the constants (ALLOWED_KEYS, etc.) more configurable. + * - Write more integration tests. NumberPopup can be used as test component. + */ + class NumberBuffer extends EventBus { + constructor() { + super(); + this.isReset = false; + this.bufferHolderStack = []; + } + /** + * @returns {String} value of the buffer, e.g. '-95.79' + */ + get() { + return this.state ? this.state.buffer : null; + } + /** + * Takes a string that is convertible to float, and set it as + * value of the buffer. e.g. val = '2.99'; + * + * @param {String} val + */ + set(val) { + this.state.buffer = !isNaN(parseFloat(val)) ? val : ''; + this.trigger('buffer-update', this.state.buffer); + } + /** + * Resets the buffer to empty string. + */ + reset() { + this.isReset = true; + this.state.buffer = ''; + this.trigger('buffer-update', this.state.buffer); + } + /** + * Calling this function, we immediately invoke the `handler` method + * that handles the contents of the input events buffer (`eventsBuffer`). + * This is helpful when we don't want to wait for the timeout that + * is supposed to invoke the handler. + */ + capture() { + if (this.handler) { + clearTimeout(this._timeout); + this.handler(); + delete this.handler; + } + } + /** + * @returns {number} float equivalent of the value of buffer + */ + getFloat() { + return parse.float(this.get()); + } + /** + * Add keyup listener to window via the useExternalListener hook. + * When the component calling this is unmounted, the listener is also + * removed from window. + */ + activate() { + this.defaultDecimalPoint = _t.database.parameters.decimal_point; + useExternalListener(window, 'keyup', this._onKeyboardInput.bind(this)); + } + /** + * @param {Object} config Use to setup the buffer + * @param {String|null} config.decimalPoint The decimal character. + * @param {String|null} config.triggerAtEnter Event triggered when 'Enter' key is pressed. + * @param {String|null} config.triggerAtEsc Event triggered when 'Esc' key is pressed. + * @param {String|null} config.triggerAtInput Event triggered for every accepted input. + * @param {String|null} config.nonKeyboardInputEvent Also listen to a non-keyboard input event + * that carries a payload of { key }. The key is checked if it is a valid input. If valid, + * the number buffer is modified just as it is modified when a keyboard key is pressed. + * @param {Boolean} config.useWithBarcode Whether this buffer is used with barcode. + * @emits config.triggerAtEnter when 'Enter' key is pressed. + * @emits config.triggerAtEsc when 'Esc' key is pressed. + * @emits config.triggerAtInput when an input is accepted. + */ + use(config) { + this.eventsBuffer = []; + const currentComponent = Component.current; + config = Object.assign(getDefaultConfig(), config); + onMounted(() => { + this.bufferHolderStack.push({ + component: currentComponent, + state: config.state ? config.state : { buffer: '' }, + config, + }); + this._setUp(); + }); + onWillUnmount(() => { + this.bufferHolderStack.pop(); + this._setUp(); + }); + // Add listener that accepts non keyboard inputs + if (typeof config.nonKeyboardInputEvent === 'string') { + useListener(config.nonKeyboardInputEvent, this._onNonKeyboardInput.bind(this)); + } + } + get _currentBufferHolder() { + return this.bufferHolderStack[this.bufferHolderStack.length - 1]; + } + _setUp() { + if (!this._currentBufferHolder) return; + const { component, state, config } = this._currentBufferHolder; + this.component = component; + this.state = state; + this.config = config; + this.decimalPoint = config.decimalPoint || this.defaultDecimalPoint; + this.maxTimeBetweenKeys = this.config.useWithBarcode + ? BarcodeEvents.max_time_between_keys_in_ms + : 0; + } + _onKeyboardInput(event) { + return this._bufferEvents(this._onInput(event => event.key))(event); + } + _onNonKeyboardInput(event) { + return this._bufferEvents(this._onInput(event => event.detail.key))(event); + } + _bufferEvents(handler) { + return event => { + if (['INPUT', 'TEXTAREA'].includes(event.target.tagName) || !this.eventsBuffer) return; + clearTimeout(this._timeout); + this.eventsBuffer.push(event); + this._timeout = setTimeout(handler, this.maxTimeBetweenKeys); + this.handler = handler + }; + } + _onInput(keyAccessor) { + return () => { + if (this.eventsBuffer.length <= 2) { + // Check first the buffer if its contents are all valid + // number input. + for (let event of this.eventsBuffer) { + if (!ALLOWED_KEYS.has(keyAccessor(event))) { + this.eventsBuffer = []; + return; + } + } + // At this point, all the events in buffer + // contains number input. It's now okay to handle + // each input. + for (let event of this.eventsBuffer) { + this._handleInput(keyAccessor(event)); + event.preventDefault(); + event.stopPropagation(); + } + } + this.eventsBuffer = []; + }; + } + _handleInput(key) { + if (key === 'Enter' && this.config.triggerAtEnter) { + this.component.trigger(this.config.triggerAtEnter, this.state); + } else if (key === 'Esc' && this.config.triggerAtEsc) { + this.component.trigger(this.config.triggerAtEsc, this.state); + } else if (INPUT_KEYS.has(key)) { + this._updateBuffer(key); + if (this.config.triggerAtInput) + this.component.trigger(this.config.triggerAtInput, { buffer: this.state.buffer, key }); + } + } + /** + * Updates the current buffer state using the given input. + * @param {String} input valid input + */ + _updateBuffer(input) { + const isEmpty = val => { + return val === '' || val === null; + }; + if (input === undefined || input === null) return; + let isFirstInput = isEmpty(this.state.buffer); + if (input === ',' || input === '.') { + if (isFirstInput) { + this.state.buffer = '0' + this.decimalPoint; + } else if (!this.state.buffer.length || this.state.buffer === '-') { + this.state.buffer += '0' + this.decimalPoint; + } else if (this.state.buffer.indexOf(this.decimalPoint) < 0) { + this.state.buffer = this.state.buffer + this.decimalPoint; + } + } else if (input === 'Delete') { + if (this.isReset) { + this.state.buffer = ''; + this.isReset = false; + return; + } + this.state.buffer = isEmpty(this.state.buffer) ? null : ''; + } else if (input === 'Backspace') { + if (this.isReset) { + this.state.buffer = ''; + this.isReset = false; + return; + } + const buffer = this.state.buffer; + if (isEmpty(buffer)) { + this.state.buffer = null; + } else { + const nCharToRemove = buffer[buffer.length - 1] === this.decimalPoint ? 2 : 1; + this.state.buffer = buffer.substring(0, buffer.length - nCharToRemove); + } + } else if (input === '+') { + if (this.state.buffer[0] === '-') { + this.state.buffer = this.state.buffer.substring(1, this.state.buffer.length); + } + } else if (input === '-') { + if (isFirstInput) { + this.state.buffer = '-0'; + } else if (this.state.buffer[0] === '-') { + this.state.buffer = this.state.buffer.substring(1, this.state.buffer.length); + } else { + this.state.buffer = '-' + this.state.buffer; + } + } else if (input[0] === '+' && !isNaN(parseFloat(input))) { + // when input is like '+10', '+50', etc + const inputValue = parse.float(input.slice(1)); + const currentBufferValue = this.state.buffer ? parse.float(this.state.buffer) : 0; + this.state.buffer = this.component.env.pos.formatFixed( + inputValue + currentBufferValue + ); + } else if (!isNaN(parseInt(input, 10))) { + if (isFirstInput) { + this.state.buffer = '' + input; + } else if (this.state.buffer.length > 12) { + Gui.playSound('bell'); + } else { + this.state.buffer += input; + } + } + if (this.state.buffer === '-') { + this.state.buffer = ''; + } + // once an input is accepted and updated the buffer, + // the buffer should not be in reset state anymore. + this.isReset = false; + + this.trigger('buffer-update', this.state.buffer); + } + } + + return new NumberBuffer(); +}); diff --git a/addons/point_of_sale/static/src/js/Misc/SearchBar.js b/addons/point_of_sale/static/src/js/Misc/SearchBar.js new file mode 100644 index 00000000..e9f56fea --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/SearchBar.js @@ -0,0 +1,115 @@ +odoo.define('point_of_sale.SearchBar', function (require) { + 'use strict'; + + const { useState, useExternalListener } = owl.hooks; + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + /** + * This is a simple configurable search bar component. It has search fields + * and selection filter. Search fields allow the users to specify the type + * of their searches. The filter is a dropdown menu for selection. Depending on + * user's action, this component emits corresponding event with the action + * information (payload). + * + * TODO: This component can be made more generic and be able to replace + * all the search bars across pos ui. + * + * @prop {{ + * config: { + * searchFields: string[], + * filter: { show: boolean, options: string[] } + * }, + * placeholder: string, + * }} + * @emits search @payload { fieldValue: string, searchTerm: '' } + * @emits filter-selected @payload { filter: string } + * + * NOTE: The payload of the emitted event is accessible via the `detail` + * field of the event. + */ + class SearchBar extends PosComponent { + constructor() { + super(...arguments); + this.config = this.props.config; + this.state = useState({ + searchInput: '', + selectedFieldId: this.config.searchFields.length ? 0 : null, + showSearchFields: false, + showFilterOptions: false, + selectedFilter: this.config.filter.options[0] || this.env._t('Select'), + }); + useExternalListener(window, 'click', this._hideOptions); + } + selectFilter(option) { + this.state.selectedFilter = option; + this.trigger('filter-selected', { filter: this.state.selectedFilter }); + } + get placeholder() { + return this.props.placeholder; + } + /** + * When vertical arrow keys are pressed, select fields for searching. + * When enter key is pressed, trigger search event if there is searchInput. + */ + onKeydown(event) { + if (['ArrowUp', 'ArrowDown'].includes(event.key)) { + event.preventDefault(); + this.state.selectedFieldId = this._fieldIdToSelect(event.key); + } else if (event.key === 'Enter') { + this.trigger('search', { + fieldValue: this.config.searchFields[this.state.selectedFieldId], + searchTerm: this.state.searchInput, + }); + this.state.showSearchFields = false; + } else { + if (this.state.selectedFieldId === null && this.config.searchFields.length) { + this.state.selectedFieldId = 0; + } + this.state.showSearchFields = true; + } + } + /** + * Called when a search field is clicked. + */ + onClickSearchField(id) { + this.state.showSearchFields = false; + this.trigger('search', { + fieldValue: this.config.searchFields[id], + searchTerm: this.state.searchInput, + }); + } + /** + * Given an arrow key, return the next selectedFieldId. + * E.g. If the selectedFieldId is 1 and ArrowDown is pressed, return 2. + * + * @param {string} key vertical arrow key + */ + _fieldIdToSelect(key) { + const length = this.config.searchFields.length; + if (!length) return null; + if (this.state.selectedFieldId === null) return 0; + const current = this.state.selectedFieldId || length; + return (current + (key === 'ArrowDown' ? 1 : -1)) % length; + } + _hideOptions() { + this.state.showFilterOptions = false; + this.state.showSearchFields = false; + } + } + SearchBar.template = 'point_of_sale.SearchBar'; + SearchBar.defaultProps = { + config: { + searchFields: [], + filter: { + show: false, + options: [], + }, + }, + placeholder: 'Search ...', + }; + + Registries.Component.add(SearchBar); + + return SearchBar; +}); diff --git a/addons/point_of_sale/static/src/js/PopupControllerMixin.js b/addons/point_of_sale/static/src/js/PopupControllerMixin.js new file mode 100644 index 00000000..446a514a --- /dev/null +++ b/addons/point_of_sale/static/src/js/PopupControllerMixin.js @@ -0,0 +1,44 @@ +odoo.define('point_of_sale.PopupControllerMixin', function(require) { + 'use strict'; + + const { useState } = owl; + const { useListener } = require('web.custom_hooks'); + + /** + * Allows the component declared with this mixin the ability show popup dynamically, + * provided the following: + * 1. The following element is declared in the template. It is where the Popup will be rendered. + * `<t t-if="popup.isShown" t-component="popup.component" t-props="popupProps" t-key="popup.name" />` + * 2. The component should trigger `show-popup` event to show the popup and `close-popup` event + * to close. In PosComponent, `showPopup` is conveniently declared to satisfy this requirement. + * @param {Function} x class definition to mix with during extension + */ + const PopupControllerMixin = x => + class extends x { + constructor() { + super(...arguments); + useListener('show-popup', this.__showPopup); + useListener('close-popup', this.__closePopup); + + this.popup = useState({ isShown: false, name: null, component: null }); + this.popupProps = {}; // We want to avoid making the props to become Proxy! + } + __showPopup(event) { + const { name, props, resolve } = event.detail; + const popupConstructor = this.constructor.components[name]; + if (popupConstructor.dontShow) { + resolve(); + return; + } + this.popup.isShown = true; + this.popup.name = name; + this.popup.component = popupConstructor; + this.popupProps = Object.assign({}, props, { resolve }); + } + __closePopup() { + this.popup.isShown = false; + } + }; + + return PopupControllerMixin; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/AbstractAwaitablePopup.js b/addons/point_of_sale/static/src/js/Popups/AbstractAwaitablePopup.js new file mode 100644 index 00000000..6cdd6a04 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/AbstractAwaitablePopup.js @@ -0,0 +1,60 @@ +odoo.define('point_of_sale.AbstractAwaitablePopup', function (require) { + 'use strict'; + + const { useExternalListener } = owl.hooks; + const PosComponent = require('point_of_sale.PosComponent'); + + /** + * Implement this abstract class by extending it like so: + * ```js + * class ConcretePopup extends AbstractAwaitablePopup { + * async getPayload() { + * return 'result'; + * } + * } + * ConcretePopup.template = owl.tags.xml` + * <div> + * <button t-on-click="confirm">Okay</button> + * <button t-on-click="cancel">Cancel</button> + * </div> + * ` + * ``` + * + * The concrete popup can now be instantiated and be awaited for + * the user's response like so: + * ```js + * const { confirmed, payload } = await this.showPopup('ConcretePopup'); + * // based on the implementation above, + * // if confirmed, payload = 'result' + * // otherwise, payload = null + * ``` + */ + class AbstractAwaitablePopup extends PosComponent { + constructor() { + super(...arguments); + useExternalListener(window, 'keyup', this._cancelAtEscape); + } + async confirm() { + this.props.resolve({ confirmed: true, payload: await this.getPayload() }); + this.trigger('close-popup'); + } + cancel() { + this.props.resolve({ confirmed: false, payload: null }); + this.trigger('close-popup'); + } + _cancelAtEscape(event) { + if (event.key === 'Escape') { + this.cancel(); + } + } + /** + * Override this in the concrete popup implementation to set the + * payload when the popup is confirmed. + */ + async getPayload() { + return null; + } + } + + return AbstractAwaitablePopup; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/ConfirmPopup.js b/addons/point_of_sale/static/src/js/Popups/ConfirmPopup.js new file mode 100644 index 00000000..e22c1aaa --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/ConfirmPopup.js @@ -0,0 +1,20 @@ +odoo.define('point_of_sale.ConfirmPopup', function(require) { + 'use strict'; + + const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup'); + const Registries = require('point_of_sale.Registries'); + + // formerly ConfirmPopupWidget + class ConfirmPopup extends AbstractAwaitablePopup {} + ConfirmPopup.template = 'ConfirmPopup'; + ConfirmPopup.defaultProps = { + confirmText: 'Ok', + cancelText: 'Cancel', + title: 'Confirm ?', + body: '', + }; + + Registries.Component.add(ConfirmPopup); + + return ConfirmPopup; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/EditListInput.js b/addons/point_of_sale/static/src/js/Popups/EditListInput.js new file mode 100644 index 00000000..09b39f21 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/EditListInput.js @@ -0,0 +1,19 @@ +odoo.define('point_of_sale.EditListInput', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class EditListInput extends PosComponent { + onKeyup(event) { + if (event.key === "Enter" && event.target.value.trim() !== '') { + this.trigger('create-new-item'); + } + } + } + EditListInput.template = 'EditListInput'; + + Registries.Component.add(EditListInput); + + return EditListInput; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/EditListPopup.js b/addons/point_of_sale/static/src/js/Popups/EditListPopup.js new file mode 100644 index 00000000..ac4b262d --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/EditListPopup.js @@ -0,0 +1,105 @@ +odoo.define('point_of_sale.EditListPopup', function(require) { + 'use strict'; + + const { useState } = owl.hooks; + const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup'); + const Registries = require('point_of_sale.Registries'); + const { useAutoFocusToLast } = require('point_of_sale.custom_hooks'); + + /** + * Given a array of { id, text }, we show the user this popup to be able to modify this given array. + * (used to replace PackLotLinePopupWidget) + * + * The expected return of showPopup when this popup is used is an array of { _id, [id], text }. + * - _id is the assigned unique identifier for each item. + * - id is the original id. if not provided, then it means that the item is new. + * - text is the modified/unmodified text. + * + * Example: + * + * ``` + * -- perhaps inside a click handler -- + * // gather the items to edit + * const names = [{ id: 1, text: 'Joseph'}, { id: 2, text: 'Kaykay' }]; + * + * // supply the items to the popup and wait for user's response + * // when user pressed `confirm` in the popup, the changes he made will be returned by the showPopup function. + * const { confirmed, payload: newNames } = await this.showPopup('EditListPopup', { + * title: "Can you confirm this item?", + * array: names }) + * + * // we then consume the new data. In this example, it is only logged. + * if (confirmed) { + * console.log(newNames); + * // the above might log the following: + * // [{ _id: 1, id: 1, text: 'Joseph Caburnay' }, { _id: 2, id: 2, 'Kaykay' }, { _id: 3, 'James' }] + * // The result showed that the original item with id=1 was changed to have text 'Joseph Caburnay' from 'Joseph' + * // The one with id=2 did not change. And a new item with text='James' is added. + * } + * ``` + */ + class EditListPopup extends AbstractAwaitablePopup { + /** + * @param {String} title required title of popup + * @param {Array} [props.array=[]] the array of { id, text } to be edited or an array of strings + * @param {Boolean} [props.isSingleItem=false] true if only allowed to edit single item (the first item) + */ + constructor() { + super(...arguments); + this._id = 0; + this.state = useState({ array: this._initialize(this.props.array) }); + useAutoFocusToLast(); + } + _nextId() { + return this._id++; + } + _emptyItem() { + return { + text: '', + _id: this._nextId(), + }; + } + _initialize(array) { + // If no array is provided, we initialize with one empty item. + if (array.length === 0) return [this._emptyItem()]; + // Put _id for each item. It will serve as unique identifier of each item. + return array.map((item) => Object.assign({}, { _id: this._nextId() }, typeof item === 'object'? item: { 'text': item})); + } + removeItem(event) { + const itemToRemove = event.detail; + this.state.array.splice( + this.state.array.findIndex(item => item._id == itemToRemove._id), + 1 + ); + // We keep a minimum of one empty item in the popup. + if (this.state.array.length === 0) { + this.state.array.push(this._emptyItem()); + } + } + createNewItem() { + if (this.props.isSingleItem) return; + this.state.array.push(this._emptyItem()); + } + /** + * @override + */ + getPayload() { + return { + newArray: this.state.array + .filter((item) => item.text.trim() !== '') + .map((item) => Object.assign({}, item)), + }; + } + } + EditListPopup.template = 'EditListPopup'; + EditListPopup.defaultProps = { + confirmText: 'Ok', + cancelText: 'Cancel', + array: [], + isSingleItem: false, + }; + + Registries.Component.add(EditListPopup); + + return EditListPopup; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/ErrorBarcodePopup.js b/addons/point_of_sale/static/src/js/Popups/ErrorBarcodePopup.js new file mode 100644 index 00000000..8cf11c40 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/ErrorBarcodePopup.js @@ -0,0 +1,26 @@ +odoo.define('point_of_sale.ErrorBarcodePopup', function(require) { + 'use strict'; + + const ErrorPopup = require('point_of_sale.ErrorPopup'); + const Registries = require('point_of_sale.Registries'); + + // formerly ErrorBarcodePopupWidget + class ErrorBarcodePopup extends ErrorPopup { + get translatedMessage() { + return this.env._t(this.props.message); + } + } + ErrorBarcodePopup.template = 'ErrorBarcodePopup'; + ErrorBarcodePopup.defaultProps = { + confirmText: 'Ok', + cancelText: 'Cancel', + title: 'Error', + body: '', + message: + 'The Point of Sale could not find any product, client, employee or action associated with the scanned barcode.', + }; + + Registries.Component.add(ErrorBarcodePopup); + + return ErrorBarcodePopup; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/ErrorPopup.js b/addons/point_of_sale/static/src/js/Popups/ErrorPopup.js new file mode 100644 index 00000000..865779c4 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/ErrorPopup.js @@ -0,0 +1,24 @@ +odoo.define('point_of_sale.ErrorPopup', function(require) { + 'use strict'; + + const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup'); + const Registries = require('point_of_sale.Registries'); + + // formerly ErrorPopupWidget + class ErrorPopup extends AbstractAwaitablePopup { + mounted() { + this.playSound('error'); + } + } + ErrorPopup.template = 'ErrorPopup'; + ErrorPopup.defaultProps = { + confirmText: 'Ok', + cancelText: 'Cancel', + title: 'Error', + body: '', + }; + + Registries.Component.add(ErrorPopup); + + return ErrorPopup; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/ErrorTracebackPopup.js b/addons/point_of_sale/static/src/js/Popups/ErrorTracebackPopup.js new file mode 100644 index 00000000..1af25e42 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/ErrorTracebackPopup.js @@ -0,0 +1,44 @@ +odoo.define('point_of_sale.ErrorTracebackPopup', function(require) { + 'use strict'; + + const ErrorPopup = require('point_of_sale.ErrorPopup'); + const Registries = require('point_of_sale.Registries'); + + // formerly ErrorTracebackPopupWidget + class ErrorTracebackPopup extends ErrorPopup { + get tracebackUrl() { + const blob = new Blob([this.props.body]); + const URL = window.URL || window.webkitURL; + return URL.createObjectURL(blob); + } + get tracebackFilename() { + return `${this.env._t('error')} ${moment().format('YYYY-MM-DD-HH-mm-ss')}.txt`; + } + emailTraceback() { + const address = this.env.pos.company.email; + const subject = this.env._t('IMPORTANT: Bug Report From Odoo Point Of Sale'); + window.open( + 'mailto:' + + address + + '?subject=' + + (subject ? window.encodeURIComponent(subject) : '') + + '&body=' + + (this.props.body ? window.encodeURIComponent(this.props.body) : '') + ); + } + } + ErrorTracebackPopup.template = 'ErrorTracebackPopup'; + ErrorTracebackPopup.defaultProps = { + confirmText: 'Ok', + cancelText: 'Cancel', + title: 'Error with Traceback', + body: '', + exitButtonIsShown: false, + exitButtonText: 'Exit Pos', + exitButtonTrigger: 'close-pos' + }; + + Registries.Component.add(ErrorTracebackPopup); + + return ErrorTracebackPopup; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/NumberPopup.js b/addons/point_of_sale/static/src/js/Popups/NumberPopup.js new file mode 100644 index 00000000..bf63ba8d --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/NumberPopup.js @@ -0,0 +1,79 @@ +odoo.define('point_of_sale.NumberPopup', function(require) { + 'use strict'; + var core = require('web.core'); + var _t = core._t; + + const { useState } = owl; + const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup'); + const NumberBuffer = require('point_of_sale.NumberBuffer'); + const { useListener } = require('web.custom_hooks'); + const Registries = require('point_of_sale.Registries'); + + // formerly NumberPopupWidget + class NumberPopup extends AbstractAwaitablePopup { + /** + * @param {Object} props + * @param {Boolean} props.isPassword Show password popup. + * @param {number|null} props.startingValue Starting value of the popup. + * + * Resolve to { confirmed, payload } when used with showPopup method. + * @confirmed {Boolean} + * @payload {String} + */ + constructor() { + super(...arguments); + useListener('accept-input', this.confirm); + useListener('close-this-popup', this.cancel); + let startingBuffer = ''; + if (typeof this.props.startingValue === 'number' && this.props.startingValue > 0) { + startingBuffer = this.props.startingValue.toString(); + } + this.state = useState({ buffer: startingBuffer }); + NumberBuffer.use({ + nonKeyboardInputEvent: 'numpad-click-input', + triggerAtEnter: 'accept-input', + triggerAtEscape: 'close-this-popup', + state: this.state, + }); + } + get decimalSeparator() { + return this.env._t.database.parameters.decimal_point; + } + get inputBuffer() { + if (this.state.buffer === null) { + return ''; + } + if (this.props.isPassword) { + return this.state.buffer.replace(/./g, '•'); + } else { + return this.state.buffer; + } + } + confirm(event) { + const bufferState = event.detail; + if (bufferState.buffer !== '') { + super.confirm(); + } + } + sendInput(key) { + this.trigger('numpad-click-input', { key }); + } + getPayload() { + return NumberBuffer.get(); + } + } + NumberPopup.template = 'NumberPopup'; + NumberPopup.defaultProps = { + confirmText: _t('Ok'), + cancelText: _t('Cancel'), + title: _t('Confirm ?'), + body: '', + cheap: false, + startingValue: null, + isPassword: false, + }; + + Registries.Component.add(NumberPopup); + + return NumberPopup; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/OfflineErrorPopup.js b/addons/point_of_sale/static/src/js/Popups/OfflineErrorPopup.js new file mode 100644 index 00000000..147ed7c4 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/OfflineErrorPopup.js @@ -0,0 +1,29 @@ +odoo.define('point_of_sale.OfflineErrorPopup', function(require) { + 'use strict'; + + const ErrorPopup = require('point_of_sale.ErrorPopup'); + const Registries = require('point_of_sale.Registries'); + + /** + * This is a special kind of error popup as it introduces + * an option to not show it again. + */ + class OfflineErrorPopup extends ErrorPopup { + dontShowAgain() { + this.constructor.dontShow = true; + this.cancel(); + } + } + OfflineErrorPopup.template = 'OfflineErrorPopup'; + OfflineErrorPopup.dontShow = false; + OfflineErrorPopup.defaultProps = { + confirmText: 'Ok', + cancelText: 'Cancel', + title: 'Offline Error', + body: 'Either the server is inaccessible or browser is not connected online.', + }; + + Registries.Component.add(OfflineErrorPopup); + + return OfflineErrorPopup; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/OrderImportPopup.js b/addons/point_of_sale/static/src/js/Popups/OrderImportPopup.js new file mode 100644 index 00000000..c2c35291 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/OrderImportPopup.js @@ -0,0 +1,27 @@ +odoo.define('point_of_sale.OrderImportPopup', function(require) { + 'use strict'; + + const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup'); + const Registries = require('point_of_sale.Registries'); + + // formerly OrderImportPopupWidget + class OrderImportPopup extends AbstractAwaitablePopup { + get unpaidSkipped() { + return ( + (this.props.report.unpaid_skipped_existing || 0) + + (this.props.report.unpaid_skipped_session || 0) + ); + } + getPayload() {} + } + OrderImportPopup.template = 'OrderImportPopup'; + OrderImportPopup.defaultProps = { + confirmText: 'Ok', + cancelText: 'Cancel', + body: '', + }; + + Registries.Component.add(OrderImportPopup); + + return OrderImportPopup; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/ProductConfiguratorPopup.js b/addons/point_of_sale/static/src/js/Popups/ProductConfiguratorPopup.js new file mode 100644 index 00000000..b04e55d8 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/ProductConfiguratorPopup.js @@ -0,0 +1,89 @@ +odoo.define('point_of_sale.ProductConfiguratorPopup', function(require) { + 'use strict'; + + const { useState, useSubEnv } = owl.hooks; + const PosComponent = require('point_of_sale.PosComponent'); + const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup'); + const Registries = require('point_of_sale.Registries'); + + class ProductConfiguratorPopup extends AbstractAwaitablePopup { + constructor() { + super(...arguments); + useSubEnv({ attribute_components: [] }); + } + + getPayload() { + var selected_attributes = []; + var price_extra = 0.0; + + this.env.attribute_components.forEach((attribute_component) => { + let { value, extra } = attribute_component.getValue(); + selected_attributes.push(value); + price_extra += extra; + }); + + return { + selected_attributes, + price_extra, + }; + } + } + ProductConfiguratorPopup.template = 'ProductConfiguratorPopup'; + Registries.Component.add(ProductConfiguratorPopup); + + class BaseProductAttribute extends PosComponent { + constructor() { + super(...arguments); + + this.env.attribute_components.push(this); + + this.attribute = this.props.attribute; + this.values = this.attribute.values; + this.state = useState({ + selected_value: parseFloat(this.values[0].id), + custom_value: '', + }); + } + + getValue() { + let selected_value = this.values.find((val) => val.id === parseFloat(this.state.selected_value)); + let value = selected_value.name; + if (selected_value.is_custom && this.state.custom_value) { + value += `: ${this.state.custom_value}`; + } + + return { + value, + extra: selected_value.price_extra + }; + } + } + + class RadioProductAttribute extends BaseProductAttribute { + mounted() { + // With radio buttons `t-model` selects the default input by searching for inputs with + // a matching `value` attribute. In our case, we use `t-att-value` so `value` is + // not found yet and no radio is selected by default. + // We then manually select the first input of each radio attribute. + $(this.el).find('input[type="radio"]:first').prop('checked', true); + } + } + RadioProductAttribute.template = 'RadioProductAttribute'; + Registries.Component.add(RadioProductAttribute); + + class SelectProductAttribute extends BaseProductAttribute { } + SelectProductAttribute.template = 'SelectProductAttribute'; + Registries.Component.add(SelectProductAttribute); + + class ColorProductAttribute extends BaseProductAttribute {} + ColorProductAttribute.template = 'ColorProductAttribute'; + Registries.Component.add(ColorProductAttribute); + + return { + ProductConfiguratorPopup, + BaseProductAttribute, + RadioProductAttribute, + SelectProductAttribute, + ColorProductAttribute, + }; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/SelectionPopup.js b/addons/point_of_sale/static/src/js/Popups/SelectionPopup.js new file mode 100644 index 00000000..5321fdea --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/SelectionPopup.js @@ -0,0 +1,57 @@ +odoo.define('point_of_sale.SelectionPopup', function (require) { + 'use strict'; + + const { useState } = owl.hooks; + const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup'); + const Registries = require('point_of_sale.Registries'); + + // formerly SelectionPopupWidget + class SelectionPopup extends AbstractAwaitablePopup { + /** + * Value of the `item` key of the selected element in the Selection + * Array is the payload of this popup. + * + * @param {Object} props + * @param {String} [props.confirmText='Confirm'] + * @param {String} [props.cancelText='Cancel'] + * @param {String} [props.title='Select'] + * @param {String} [props.body=''] + * @param {Array<Selection>} [props.list=[]] + * Selection { + * id: integer, + * label: string, + * isSelected: boolean, + * item: any, + * } + */ + constructor() { + super(...arguments); + this.state = useState({ selectedId: this.props.list.find((item) => item.isSelected) }); + } + selectItem(itemId) { + this.state.selectedId = itemId; + this.confirm(); + } + /** + * We send as payload of the response the selected item. + * + * @override + */ + getPayload() { + const selected = this.props.list.find((item) => this.state.selectedId === item.id); + return selected && selected.item; + } + } + SelectionPopup.template = 'SelectionPopup'; + SelectionPopup.defaultProps = { + confirmText: 'Confirm', + cancelText: 'Cancel', + title: 'Select', + body: '', + list: [], + }; + + Registries.Component.add(SelectionPopup); + + return SelectionPopup; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/TextAreaPopup.js b/addons/point_of_sale/static/src/js/Popups/TextAreaPopup.js new file mode 100644 index 00000000..1f2735f6 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/TextAreaPopup.js @@ -0,0 +1,39 @@ +odoo.define('point_of_sale.TextAreaPopup', function(require) { + 'use strict'; + + const { useState, useRef } = owl.hooks; + const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup'); + const Registries = require('point_of_sale.Registries'); + + // formerly TextAreaPopupWidget + // IMPROVEMENT: This code is very similar to TextInputPopup. + // Combining them would reduce the code. + class TextAreaPopup extends AbstractAwaitablePopup { + /** + * @param {Object} props + * @param {string} props.startingValue + */ + constructor() { + super(...arguments); + this.state = useState({ inputValue: this.props.startingValue }); + this.inputRef = useRef('input'); + } + mounted() { + this.inputRef.el.focus(); + } + getPayload() { + return this.state.inputValue; + } + } + TextAreaPopup.template = 'TextAreaPopup'; + TextAreaPopup.defaultProps = { + confirmText: 'Ok', + cancelText: 'Cancel', + title: '', + body: '', + }; + + Registries.Component.add(TextAreaPopup); + + return TextAreaPopup; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/TextInputPopup.js b/addons/point_of_sale/static/src/js/Popups/TextInputPopup.js new file mode 100644 index 00000000..4a0612d2 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/TextInputPopup.js @@ -0,0 +1,34 @@ +odoo.define('point_of_sale.TextInputPopup', function(require) { + 'use strict'; + + const { useState, useRef } = owl.hooks; + const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup'); + const Registries = require('point_of_sale.Registries'); + + // formerly TextInputPopupWidget + class TextInputPopup extends AbstractAwaitablePopup { + constructor() { + super(...arguments); + this.state = useState({ inputValue: this.props.startingValue }); + this.inputRef = useRef('input'); + } + mounted() { + this.inputRef.el.focus(); + } + getPayload() { + return this.state.inputValue; + } + } + TextInputPopup.template = 'TextInputPopup'; + TextInputPopup.defaultProps = { + confirmText: 'Ok', + cancelText: 'Cancel', + title: '', + body: '', + startingValue: '', + }; + + Registries.Component.add(TextInputPopup); + + return TextInputPopup; +}); diff --git a/addons/point_of_sale/static/src/js/PosComponent.js b/addons/point_of_sale/static/src/js/PosComponent.js new file mode 100644 index 00000000..ae2873ba --- /dev/null +++ b/addons/point_of_sale/static/src/js/PosComponent.js @@ -0,0 +1,59 @@ +odoo.define('point_of_sale.PosComponent', function (require) { + 'use strict'; + + const { Component } = owl; + + class PosComponent extends Component { + /** + * This function is available to all Components that inherit this class. + * The goal of this function is to show an awaitable dialog (popup) that + * returns a response after user interaction. See the following for quick + * demonstration: + * + * ``` + * async getUserName() { + * const userResponse = await this.showPopup( + * 'TextInputPopup', + * { title: 'What is your name?' } + * ); + * // at this point, the TextInputPopup is displayed. Depending on how the popup is defined, + * // say the input contains the name, the result of the interaction with the user is + * // saved in `userResponse`. + * console.log(userResponse); // logs { confirmed: true, payload: <name> } + * } + * ``` + * + * @param {String} name Name of the popup component + * @param {Object} props Object that will be used to render to popup + */ + showPopup(name, props) { + return new Promise((resolve) => { + this.trigger('show-popup', { name, props, resolve }); + }); + } + showTempScreen(name, props) { + return new Promise((resolve) => { + this.trigger('show-temp-screen', { name, props, resolve }); + }); + } + showScreen(name, props) { + this.trigger('show-main-screen', { name, props }); + } + /** + * @param {String} name 'bell' | 'error' + */ + playSound(name) { + this.trigger('play-sound', name); + } + /** + * Control the SyncNotification component. + * @param {String} status 'connected' | 'connecting' | 'disconnected' | 'error' + * @param {String} pending number of pending orders to sync + */ + setSyncStatus(status, pending) { + this.trigger('set-sync-status', { status, pending }); + } + } + + return PosComponent; +}); diff --git a/addons/point_of_sale/static/src/js/PosContext.js b/addons/point_of_sale/static/src/js/PosContext.js new file mode 100644 index 00000000..b52a155d --- /dev/null +++ b/addons/point_of_sale/static/src/js/PosContext.js @@ -0,0 +1,12 @@ +odoo.define('point_of_sale.PosContext', function (require) { + 'use strict'; + + const { Context } = owl; + + // Create global context objects + // e.g. component.env.device = new Context({ isMobile: false }); + return { + orderManagement: new Context({ searchString: '', selectedOrder: null }), + chrome: new Context({ showOrderSelector: true }), + }; +}); diff --git a/addons/point_of_sale/static/src/js/Registries.js b/addons/point_of_sale/static/src/js/Registries.js new file mode 100644 index 00000000..e95817ab --- /dev/null +++ b/addons/point_of_sale/static/src/js/Registries.js @@ -0,0 +1,11 @@ +odoo.define('point_of_sale.Registries', function(require) { + 'use strict'; + + /** + * This definition contains all the instances of ClassRegistry. + */ + + const ComponentRegistry = require('point_of_sale.ComponentRegistry'); + + return { Component: new ComponentRegistry() }; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js new file mode 100644 index 00000000..3c126ec2 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js @@ -0,0 +1,129 @@ +odoo.define('point_of_sale.ClientDetailsEdit', function(require) { + 'use strict'; + + const { _t } = require('web.core'); + const { getDataURLFromFile } = require('web.utils'); + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class ClientDetailsEdit extends PosComponent { + constructor() { + super(...arguments); + this.intFields = ['country_id', 'state_id', 'property_product_pricelist']; + const partner = this.props.partner; + this.changes = { + 'country_id': partner.country_id && partner.country_id[0], + 'state_id': partner.state_id && partner.state_id[0], + }; + } + mounted() { + this.env.bus.on('save-customer', this, this.saveChanges); + } + willUnmount() { + this.env.bus.off('save-customer', this); + } + get partnerImageUrl() { + // We prioritize image_1920 in the `changes` field because we want + // to show the uploaded image without fetching new data from the server. + const partner = this.props.partner; + if (this.changes.image_1920) { + return this.changes.image_1920; + } else if (partner.id) { + return `/web/image?model=res.partner&id=${partner.id}&field=image_128&write_date=${partner.write_date}&unique=1`; + } else { + return false; + } + } + /** + * Save to field `changes` all input changes from the form fields. + */ + captureChange(event) { + this.changes[event.target.name] = event.target.value; + } + saveChanges() { + let processedChanges = {}; + for (let [key, value] of Object.entries(this.changes)) { + if (this.intFields.includes(key)) { + processedChanges[key] = parseInt(value) || false; + } else { + processedChanges[key] = value; + } + } + if ((!this.props.partner.name && !processedChanges.name) || + processedChanges.name === '' ){ + return this.showPopup('ErrorPopup', { + title: _t('A Customer Name Is Required'), + }); + } + processedChanges.id = this.props.partner.id || false; + this.trigger('save-changes', { processedChanges }); + } + async uploadImage(event) { + const file = event.target.files[0]; + if (!file.type.match(/image.*/)) { + await this.showPopup('ErrorPopup', { + title: this.env._t('Unsupported File Format'), + body: this.env._t( + 'Only web-compatible Image formats such as .png or .jpeg are supported.' + ), + }); + } else { + const imageUrl = await getDataURLFromFile(file); + const loadedImage = await this._loadImage(imageUrl); + if (loadedImage) { + const resizedImage = await this._resizeImage(loadedImage, 800, 600); + this.changes.image_1920 = resizedImage.toDataURL(); + // Rerender to reflect the changes in the screen + this.render(); + } + } + } + _resizeImage(img, maxwidth, maxheight) { + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext('2d'); + var ratio = 1; + + if (img.width > maxwidth) { + ratio = maxwidth / img.width; + } + if (img.height * ratio > maxheight) { + ratio = maxheight / img.height; + } + var width = Math.floor(img.width * ratio); + var height = Math.floor(img.height * ratio); + + canvas.width = width; + canvas.height = height; + ctx.drawImage(img, 0, 0, width, height); + return canvas; + } + /** + * Loading image is converted to a Promise to allow await when + * loading an image. It resolves to the loaded image if succesful, + * else, resolves to false. + * + * [Source](https://stackoverflow.com/questions/45788934/how-to-turn-this-callback-into-a-promise-using-async-await) + */ + _loadImage(url) { + return new Promise((resolve) => { + const img = new Image(); + img.addEventListener('load', () => resolve(img)); + img.addEventListener('error', () => { + this.showPopup('ErrorPopup', { + title: this.env._t('Loading Image Error'), + body: this.env._t( + 'Encountered error when loading image. Please try again.' + ), + }); + resolve(false); + }); + img.src = url; + }); + } + } + ClientDetailsEdit.template = 'ClientDetailsEdit'; + + Registries.Component.add(ClientDetailsEdit); + + return ClientDetailsEdit; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientLine.js b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientLine.js new file mode 100644 index 00000000..86f55645 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientLine.js @@ -0,0 +1,17 @@ +odoo.define('point_of_sale.ClientLine', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class ClientLine extends PosComponent { + get highlight() { + return this.props.partner !== this.props.selectedClient ? '' : 'highlight'; + } + } + ClientLine.template = 'ClientLine'; + + Registries.Component.add(ClientLine); + + return ClientLine; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js new file mode 100644 index 00000000..4863d588 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js @@ -0,0 +1,182 @@ +odoo.define('point_of_sale.ClientListScreen', function(require) { + 'use strict'; + + const { debounce } = owl.utils; + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + const { useListener } = require('web.custom_hooks'); + + /** + * Render this screen using `showTempScreen` to select client. + * When the shown screen is confirmed ('Set Customer' or 'Deselect Customer' + * button is clicked), the call to `showTempScreen` resolves to the + * selected client. E.g. + * + * ```js + * const { confirmed, payload: selectedClient } = await showTempScreen('ClientListScreen'); + * if (confirmed) { + * // do something with the selectedClient + * } + * ``` + * + * @props client - originally selected client + */ + class ClientListScreen extends PosComponent { + constructor() { + super(...arguments); + useListener('click-save', () => this.env.bus.trigger('save-customer')); + useListener('click-edit', () => this.editClient()); + useListener('save-changes', this.saveChanges); + + // We are not using useState here because the object + // passed to useState converts the object and its contents + // to Observer proxy. Not sure of the side-effects of making + // a persistent object, such as pos, into owl.Observer. But it + // is better to be safe. + this.state = { + query: null, + selectedClient: this.props.client, + detailIsShown: false, + isEditMode: false, + editModeProps: { + partner: { + country_id: this.env.pos.company.country_id, + state_id: this.env.pos.company.state_id, + } + }, + }; + this.updateClientList = debounce(this.updateClientList, 70); + } + + // Lifecycle hooks + back() { + if(this.state.detailIsShown) { + this.state.detailIsShown = false; + this.render(); + } else { + this.props.resolve({ confirmed: false, payload: false }); + this.trigger('close-temp-screen'); + } + } + confirm() { + this.props.resolve({ confirmed: true, payload: this.state.selectedClient }); + this.trigger('close-temp-screen'); + } + // Getters + + get currentOrder() { + return this.env.pos.get_order(); + } + + get clients() { + if (this.state.query && this.state.query.trim() !== '') { + return this.env.pos.db.search_partner(this.state.query.trim()); + } else { + return this.env.pos.db.get_partners_sorted(1000); + } + } + get isNextButtonVisible() { + return this.state.selectedClient ? true : false; + } + /** + * Returns the text and command of the next button. + * The command field is used by the clickNext call. + */ + get nextButton() { + if (!this.props.client) { + return { command: 'set', text: this.env._t('Set Customer') }; + } else if (this.props.client && this.props.client === this.state.selectedClient) { + return { command: 'deselect', text: this.env._t('Deselect Customer') }; + } else { + return { command: 'set', text: this.env._t('Change Customer') }; + } + } + + // Methods + + // We declare this event handler as a debounce function in + // order to lower its trigger rate. + updateClientList(event) { + this.state.query = event.target.value; + const clients = this.clients; + if (event.code === 'Enter' && clients.length === 1) { + this.state.selectedClient = clients[0]; + this.clickNext(); + } else { + this.render(); + } + } + clickClient(event) { + let partner = event.detail.client; + if (this.state.selectedClient === partner) { + this.state.selectedClient = null; + } else { + this.state.selectedClient = partner; + } + this.render(); + } + editClient() { + this.state.editModeProps = { + partner: this.state.selectedClient, + }; + this.state.detailIsShown = true; + this.render(); + } + clickNext() { + this.state.selectedClient = this.nextButton.command === 'set' ? this.state.selectedClient : null; + this.confirm(); + } + activateEditMode(event) { + const { isNewClient } = event.detail; + this.state.isEditMode = true; + this.state.detailIsShown = true; + this.state.isNewClient = isNewClient; + if (!isNewClient) { + this.state.editModeProps = { + partner: this.state.selectedClient, + }; + } + this.render(); + } + deactivateEditMode() { + this.state.isEditMode = false; + this.state.editModeProps = { + partner: { + country_id: this.env.pos.company.country_id, + state_id: this.env.pos.company.state_id, + }, + }; + this.render(); + } + async saveChanges(event) { + try { + let partnerId = await this.rpc({ + model: 'res.partner', + method: 'create_from_ui', + args: [event.detail.processedChanges], + }); + await this.env.pos.load_new_partners(); + this.state.selectedClient = this.env.pos.db.get_partner_by_id(partnerId); + this.state.detailIsShown = false; + this.render(); + } catch (error) { + if (error.message.code < 0) { + await this.showPopup('OfflineErrorPopup', { + title: this.env._t('Offline'), + body: this.env._t('Unable to save changes.'), + }); + } else { + throw error; + } + } + } + cancelEdit() { + this.deactivateEditMode(); + } + } + ClientListScreen.template = 'ClientListScreen'; + + Registries.Component.add(ClientListScreen); + + return ClientListScreen; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.js new file mode 100644 index 00000000..53b858ba --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.js @@ -0,0 +1,155 @@ +odoo.define('point_of_sale.InvoiceButton', function (require) { + 'use strict'; + + const { useListener } = require('web.custom_hooks'); + const { useContext } = owl.hooks; + const { isRpcError } = require('point_of_sale.utils'); + const PosComponent = require('point_of_sale.PosComponent'); + const OrderManagementScreen = require('point_of_sale.OrderManagementScreen'); + const OrderFetcher = require('point_of_sale.OrderFetcher'); + const Registries = require('point_of_sale.Registries'); + const contexts = require('point_of_sale.PosContext'); + + class InvoiceButton extends PosComponent { + constructor() { + super(...arguments); + useListener('click', this._onClick); + this.orderManagementContext = useContext(contexts.orderManagement); + } + get selectedOrder() { + return this.orderManagementContext.selectedOrder; + } + set selectedOrder(value) { + this.orderManagementContext.selectedOrder = value; + } + get isAlreadyInvoiced() { + if (!this.selectedOrder) return false; + return Boolean(this.selectedOrder.account_move); + } + get commandName() { + if (!this.selectedOrder) { + return 'Invoice'; + } else { + return this.isAlreadyInvoiced + ? 'Reprint Invoice' + : this.selectedOrder.isFromClosedSession + ? 'Cannot Invoice' + : 'Invoice'; + } + } + get isHighlighted() { + return this.selectedOrder && !this.isAlreadyInvoiced && !this.selectedOrder.isFromClosedSession; + } + async _downloadInvoice(orderId) { + try { + await this.env.pos.do_action('point_of_sale.pos_invoice_report', { + additional_context: { + active_ids: [orderId], + }, + }); + } catch (error) { + if (error instanceof Error) { + throw error; + } else { + // NOTE: error here is most probably undefined + this.showPopup('ErrorPopup', { + title: this.env._t('Network Error'), + body: this.env._t('Unable to download invoice.'), + }); + } + } + } + async _invoiceOrder() { + const order = this.selectedOrder; + if (!order) return; + + const orderId = order.backendId; + + // Part 0.1. If already invoiced, print the invoice. + if (this.isAlreadyInvoiced) { + await this._downloadInvoice(orderId); + return; + } + + // Part 0.2. Check if order belongs to an active session. + // If not, do not allow invoicing. + if (order.isFromClosedSession) { + this.showPopup('ErrorPopup', { + title: this.env._t('Session is closed'), + body: this.env._t('Cannot invoice order from closed session.'), + }); + return; + } + + // Part 1: Handle missing client. + // Write to pos.order the selected client. + if (!order.get_client()) { + const { confirmed: confirmedPopup } = await this.showPopup('ConfirmPopup', { + title: 'Need customer to invoice', + body: 'Do you want to open the customer list to select customer?', + }); + if (!confirmedPopup) return; + + const { confirmed: confirmedTempScreen, payload: newClient } = await this.showTempScreen( + 'ClientListScreen' + ); + if (!confirmedTempScreen) return; + + await this.rpc({ + model: 'pos.order', + method: 'write', + args: [[orderId], { partner_id: newClient.id }], + kwargs: { context: this.env.session.user_context }, + }); + } + + // Part 2: Invoice the order. + await this.rpc( + { + model: 'pos.order', + method: 'action_pos_order_invoice', + args: [orderId], + kwargs: { context: this.env.session.user_context }, + }, + { + timeout: 30000, + shadow: true, + } + ); + + // Part 3: Download invoice. + await this._downloadInvoice(orderId); + + // Invalidate the cache then fetch the updated order. + OrderFetcher.invalidateCache([orderId]); + await OrderFetcher.fetch(); + this.selectedOrder = OrderFetcher.get(this.selectedOrder.backendId); + } + async _onClick() { + try { + await this._invoiceOrder(); + } catch (error) { + if (isRpcError(error) && error.message.code < 0) { + this.showPopup('ErrorPopup', { + title: this.env._t('Network Error'), + body: this.env._t('Unable to invoice order.'), + }); + } else { + throw error; + } + } + } + } + InvoiceButton.template = 'InvoiceButton'; + + OrderManagementScreen.addControlButton({ + component: InvoiceButton, + condition: function () { + return this.env.pos.config.module_account; + }, + }); + + Registries.Component.add(InvoiceButton); + + return InvoiceButton; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.js new file mode 100644 index 00000000..5a227827 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.js @@ -0,0 +1,36 @@ +odoo.define('point_of_sale.ReprintReceiptButton', function (require) { + 'use strict'; + + const { useListener } = require('web.custom_hooks'); + const { useContext } = owl.hooks; + const PosComponent = require('point_of_sale.PosComponent'); + const OrderManagementScreen = require('point_of_sale.OrderManagementScreen'); + const Registries = require('point_of_sale.Registries'); + const contexts = require('point_of_sale.PosContext'); + + class ReprintReceiptButton extends PosComponent { + constructor() { + super(...arguments); + useListener('click', this._onClick); + this.orderManagementContext = useContext(contexts.orderManagement); + } + async _onClick() { + const order = this.orderManagementContext.selectedOrder; + if (!order) return; + + this.showScreen('ReprintReceiptScreen', { order: order }); + } + } + ReprintReceiptButton.template = 'ReprintReceiptButton'; + + OrderManagementScreen.addControlButton({ + component: ReprintReceiptButton, + condition: function () { + return true; + }, + }); + + Registries.Component.add(ReprintReceiptButton); + + return ReprintReceiptButton; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/MobileOrderManagementScreen.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/MobileOrderManagementScreen.js new file mode 100644 index 00000000..b5766ccf --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/MobileOrderManagementScreen.js @@ -0,0 +1,25 @@ +odoo.define('point_of_sale.MobileOrderManagementScreen', function (require) { + const OrderManagementScreen = require('point_of_sale.OrderManagementScreen'); + const Registries = require('point_of_sale.Registries'); + const { useListener } = require('web.custom_hooks'); + const { useState } = owl.hooks; + + const MobileOrderManagementScreen = (OrderManagementScreen) => { + class MobileOrderManagementScreen extends OrderManagementScreen { + constructor() { + super(...arguments); + useListener('click-order', this._onShowDetails) + this.mobileState = useState({ showDetails: false }); + } + _onShowDetails() { + this.mobileState.showDetails = true; + } + } + MobileOrderManagementScreen.template = 'MobileOrderManagementScreen'; + return MobileOrderManagementScreen; + }; + + Registries.Component.addByExtending(MobileOrderManagementScreen, OrderManagementScreen); + + return MobileOrderManagementScreen; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderDetails.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderDetails.js new file mode 100644 index 00000000..cc0c671c --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderDetails.js @@ -0,0 +1,29 @@ +odoo.define('point_of_sale.OrderDetails', function (require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + /** + * @props {models.Order} order + */ + class OrderDetails extends PosComponent { + get order() { + return this.props.order; + } + get orderlines() { + return this.order ? this.order.orderlines.models : []; + } + get total() { + return this.env.pos.format_currency(this.order ? this.order.get_total_with_tax() : 0); + } + get tax() { + return this.env.pos.format_currency(this.order ? this.order.get_total_tax() : 0) + } + } + OrderDetails.template = 'OrderDetails'; + + Registries.Component.add(OrderDetails); + + return OrderDetails; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderFetcher.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderFetcher.js new file mode 100644 index 00000000..57a02635 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderFetcher.js @@ -0,0 +1,214 @@ +odoo.define('point_of_sale.OrderFetcher', function (require) { + 'use strict'; + + const { EventBus } = owl.core; + const { Gui } = require('point_of_sale.Gui'); + const { isRpcError } = require('point_of_sale.utils'); + const models = require('point_of_sale.models'); + + class OrderFetcher extends EventBus { + constructor() { + super(); + this.currentPage = 1; + this.ordersToShow = []; + this.cache = {}; + this.totalCount = 0; + } + get activeOrders() { + const allActiveOrders = this.comp.env.pos.get('orders').models; + return this.searchDomain + ? allActiveOrders.filter(this._predicateBasedOnSearchDomain.bind(this)) + : allActiveOrders; + } + _predicateBasedOnSearchDomain(order) { + function check(order, field, searchWord) { + searchWord = searchWord.toLowerCase(); + switch (field) { + case 'pos_reference': + return order.name.toLowerCase().includes(searchWord); + case 'partner_id.display_name': + const client = order.get_client(); + return client ? client.name.toLowerCase().includes(searchWord) : false; + case 'date_order': + return moment(order.creation_date).format('YYYY-MM-DD hh:mm A').includes(searchWord); + default: + return false; + } + } + for (let [field, _, searchWord] of (this.searchDomain || []).filter((item) => item !== '|')) { + // remove surrounding "%" from `searchWord` + searchWord = searchWord.substring(1, searchWord.length - 1); + if (check(order, field, searchWord)) { + return true; + } + } + return false; + } + get nActiveOrders() { + return this.activeOrders.length; + } + get lastPageFullOfActiveOrders() { + return Math.trunc(this.nActiveOrders / this.nPerPage); + } + get remainingActiveOrders() { + return this.nActiveOrders % this.nPerPage; + } + /** + * for nPerPage = 10 + * +--------+----------+ + * | nItems | lastPage | + * +--------+----------+ + * | 2 | 1 | + * | 10 | 1 | + * | 11 | 2 | + * | 30 | 3 | + * | 35 | 4 | + * +--------+----------+ + */ + get lastPage() { + const nItems = this.nActiveOrders + this.totalCount; + return Math.trunc(nItems / (this.nPerPage + 1)) + 1; + } + /** + * Calling this methods populates the `ordersToShow` then trigger `update` event. + * @related get + * + * NOTE: This is tightly-coupled with pagination. So if the current page contains all + * active orders, it will not fetch anything from the server but only sets `ordersToShow` + * to the active orders that fits the current page. + */ + async fetch() { + try { + let limit, offset; + let start, end; + if (this.currentPage <= this.lastPageFullOfActiveOrders) { + // Show only active orders. + start = (this.currentPage - 1) * this.nPerPage; + end = this.currentPage * this.nPerPage; + this.ordersToShow = this.activeOrders.slice(start, end); + } else if (this.currentPage === this.lastPageFullOfActiveOrders + 1) { + // Show partially the remaining active orders and + // some orders from the backend. + offset = 0; + limit = this.nPerPage - this.remainingActiveOrders; + start = (this.currentPage - 1) * this.nPerPage; + end = this.nActiveOrders; + this.ordersToShow = [ + ...this.activeOrders.slice(start, end), + ...(await this._fetch(limit, offset)), + ]; + } else { + // Show orders from the backend. + offset = + this.nPerPage - + this.remainingActiveOrders + + (this.currentPage - (this.lastPageFullOfActiveOrders + 1) - 1) * + this.nPerPage; + limit = this.nPerPage; + this.ordersToShow = await this._fetch(limit, offset); + } + this.trigger('update'); + } catch (error) { + if (isRpcError(error) && error.message.code < 0) { + Gui.showPopup('ErrorPopup', { + title: this.comp.env._t('Network Error'), + body: this.comp.env._t('Unable to fetch orders if offline.'), + }); + Gui.setSyncStatus('error'); + } else { + throw error; + } + } + } + /** + * This returns the orders from the backend that needs to be shown. + * If the order is already in cache, the full information about that + * order is not fetched anymore, instead, we use info from cache. + * + * @param {number} limit + * @param {number} offset + */ + async _fetch(limit, offset) { + const { ids, totalCount } = await this._getOrderIdsForCurrentPage(limit, offset); + const idsNotInCache = ids.filter((id) => !(id in this.cache)); + if (idsNotInCache.length > 0) { + const fetchedOrders = await this._fetchOrders(idsNotInCache); + // Cache these fetched orders so that next time, no need to fetch + // them again, unless invalidated. See `invalidateCache`. + fetchedOrders.forEach((order) => { + this.cache[order.id] = new models.Order( + {}, + { pos: this.comp.env.pos, json: order } + ); + }); + } + this.totalCount = totalCount; + return ids.map((id) => this.cache[id]); + } + async _getOrderIdsForCurrentPage(limit, offset) { + return await this.rpc({ + model: 'pos.order', + method: 'search_paid_order_ids', + kwargs: { config_id: this.configId, domain: this.searchDomain ? this.searchDomain : [], limit, offset }, + context: this.comp.env.session.user_context, + }); + } + async _fetchOrders(ids) { + return await this.rpc({ + model: 'pos.order', + method: 'export_for_ui', + args: [ids], + context: this.comp.env.session.user_context, + }); + } + nextPage() { + if (this.currentPage < this.lastPage) { + this.currentPage += 1; + this.fetch(); + } + } + prevPage() { + if (this.currentPage > 1) { + this.currentPage -= 1; + this.fetch(); + } + } + /** + * @param {integer|undefined} id id of the cached order + * @returns {Array<models.Order>} + */ + get(id) { + if (id) return this.cache[id]; + return this.ordersToShow; + } + setSearchDomain(searchDomain) { + this.searchDomain = searchDomain; + } + setComponent(comp) { + this.comp = comp; + return this; + } + setConfigId(configId) { + this.configId = configId; + } + setNPerPage(val) { + this.nPerPage = val; + } + setPage(page) { + this.currentPage = page; + } + invalidateCache(ids) { + for (let id of ids) { + delete this.cache[id]; + } + } + async rpc() { + Gui.setSyncStatus('connecting'); + const result = await this.comp.rpc(...arguments); + Gui.setSyncStatus('connected'); + return result; + } + } + + return new OrderFetcher(); +}); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderList.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderList.js new file mode 100644 index 00000000..2b4d3cd9 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderList.js @@ -0,0 +1,31 @@ +odoo.define('point_of_sale.OrderList', function (require) { + 'use strict'; + + const { useState } = owl.hooks; + const { useListener } = require('web.custom_hooks'); + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + /** + * @props {models.Order} [initHighlightedOrder] initially highligted order + * @props {Array<models.Order>} orders + */ + class OrderList extends PosComponent { + constructor() { + super(...arguments); + useListener('click-order', this._onClickOrder); + this.state = useState({ highlightedOrder: this.props.initHighlightedOrder || null }); + } + get highlightedOrder() { + return this.state.highlightedOrder; + } + _onClickOrder({ detail: order }) { + this.state.highlightedOrder = order; + } + } + OrderList.template = 'OrderList'; + + Registries.Component.add(OrderList); + + return OrderList; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementControlPanel.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementControlPanel.js new file mode 100644 index 00000000..951a0956 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementControlPanel.js @@ -0,0 +1,124 @@ +odoo.define('point_of_sale.OrderManagementControlPanel', function (require) { + 'use strict'; + + const { useContext } = owl.hooks; + const { useAutofocus, useListener } = require('web.custom_hooks'); + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + const OrderFetcher = require('point_of_sale.OrderFetcher'); + const contexts = require('point_of_sale.PosContext'); + + // NOTE: These are constants so that they are only instantiated once + // and they can be used efficiently by the OrderManagementControlPanel. + const VALID_SEARCH_TAGS = new Set(['date', 'customer', 'client', 'name', 'order']); + const FIELD_MAP = { + date: 'date_order', + customer: 'partner_id.display_name', + client: 'partner_id.display_name', + name: 'pos_reference', + order: 'pos_reference', + }; + const SEARCH_FIELDS = ['pos_reference', 'partner_id.display_name', 'date_order']; + + function getDomainForSingleCondition(fields, toSearch) { + const orSymbols = Array(fields.length - 1).fill('|'); + return orSymbols.concat(fields.map((field) => [field, 'ilike', `%${toSearch}%`])); + } + + /** + * @emits close-screen + * @emits prev-page + * @emits next-page + * @emits search + */ + class OrderManagementControlPanel extends PosComponent { + constructor() { + super(...arguments); + // We are using context because we want the `searchString` to be alive + // even if this component is destroyed (unmounted). + this.orderManagementContext = useContext(contexts.orderManagement); + useListener('clear-search', this._onClearSearch); + useAutofocus({ selector: 'input' }); + } + onInputKeydown(event) { + if (event.key === 'Enter') { + this.trigger('search', this._computeDomain()); + } + } + get showPageControls() { + return OrderFetcher.lastPage > 1; + } + get pageNumber() { + const currentPage = OrderFetcher.currentPage; + const lastPage = OrderFetcher.lastPage; + return isNaN(lastPage) ? '' : `(${currentPage}/${lastPage})`; + } + get validSearchTags() { + return VALID_SEARCH_TAGS; + } + get fieldMap() { + return FIELD_MAP; + } + get searchFields() { + return SEARCH_FIELDS; + } + /** + * E.g. 1 + * ``` + * searchString = 'Customer 1' + * result = [ + * '|', + * '|', + * ['pos_reference', 'ilike', '%Customer 1%'], + * ['partner_id.display_name', 'ilike', '%Customer 1%'], + * ['date_order', 'ilike', '%Customer 1%'] + * ] + * ``` + * + * E.g. 2 + * ``` + * searchString = 'date: 2020-05' + * result = [ + * ['date_order', 'ilike', '%2020-05%'] + * ] + * ``` + * + * E.g. 3 + * ``` + * searchString = 'customer: Steward, date: 2020-05-01' + * result = [ + * ['partner_id.display_name', 'ilike', '%Steward%'], + * ['date_order', 'ilike', '%2020-05-01%'] + * ] + * ``` + */ + _computeDomain() { + const input = this.orderManagementContext.searchString.trim(); + if (!input) return; + + const searchConditions = this.orderManagementContext.searchString.split(/[,&]\s*/); + if (searchConditions.length === 1) { + let cond = searchConditions[0].split(/:\s*/); + if (cond.length === 1) { + return getDomainForSingleCondition(this.searchFields, cond[0]); + } + } + const domain = []; + for (let cond of searchConditions) { + let [tag, value] = cond.split(/:\s*/); + if (!this.validSearchTags.has(tag)) continue; + domain.push([this.fieldMap[tag], 'ilike', `%${value}%`]); + } + return domain; + } + _onClearSearch() { + this.orderManagementContext.searchString = ''; + this.onInputKeydown({ key: 'Enter' }); + } + } + OrderManagementControlPanel.template = 'OrderManagementControlPanel'; + + Registries.Component.add(OrderManagementControlPanel); + + return OrderManagementControlPanel; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js new file mode 100644 index 00000000..dcde9739 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js @@ -0,0 +1,101 @@ +odoo.define('point_of_sale.OrderManagementScreen', function (require) { + 'use strict'; + + const { useContext } = owl.hooks; + const { useListener } = require('web.custom_hooks'); + const ControlButtonsMixin = require('point_of_sale.ControlButtonsMixin'); + const NumberBuffer = require('point_of_sale.NumberBuffer'); + const Registries = require('point_of_sale.Registries'); + const OrderFetcher = require('point_of_sale.OrderFetcher'); + const IndependentToOrderScreen = require('point_of_sale.IndependentToOrderScreen'); + const contexts = require('point_of_sale.PosContext'); + + class OrderManagementScreen extends ControlButtonsMixin(IndependentToOrderScreen) { + constructor() { + super(...arguments); + useListener('close-screen', this.close); + useListener('set-numpad-mode', this._setNumpadMode); + useListener('click-order', this._onClickOrder); + useListener('next-page', this._onNextPage); + useListener('prev-page', this._onPrevPage); + useListener('search', this._onSearch); + NumberBuffer.use({ + nonKeyboardInputEvent: 'numpad-click-input', + useWithBarcode: true, + }); + this.numpadMode = 'quantity'; + OrderFetcher.setComponent(this); + OrderFetcher.setConfigId(this.env.pos.config_id); + this.orderManagementContext = useContext(contexts.orderManagement); + } + mounted() { + OrderFetcher.on('update', this, this.render); + this.env.pos.get('orders').on('add remove', this.render, this); + + // calculate how many can fit in the screen. + // It is based on the height of the header element. + // So the result is only accurate if each row is just single line. + const flexContainer = this.el.querySelector('.flex-container'); + const cpEl = this.el.querySelector('.control-panel'); + const headerEl = this.el.querySelector('.order-row.header'); + const val = Math.trunc( + (flexContainer.offsetHeight - cpEl.offsetHeight - headerEl.offsetHeight) / + headerEl.offsetHeight + ); + OrderFetcher.setNPerPage(val); + + // Fetch the order after mounting so that order management screen + // is shown while fetching. + setTimeout(() => OrderFetcher.fetch(), 0); + } + willUnmount() { + OrderFetcher.off('update', this); + this.env.pos.get('orders').off('add remove', null, this); + } + get selectedClient() { + const order = this.orderManagementContext.selectedOrder; + return order ? order.get_client() : null; + } + get orders() { + return OrderFetcher.get(); + } + async _setNumpadMode(event) { + const { mode } = event.detail; + this.numpadMode = mode; + NumberBuffer.reset(); + } + _onNextPage() { + OrderFetcher.nextPage(); + } + _onPrevPage() { + OrderFetcher.prevPage(); + } + _onSearch({ detail: domain }) { + OrderFetcher.setSearchDomain(domain); + OrderFetcher.setPage(1); + OrderFetcher.fetch(); + } + _onClickOrder({ detail: clickedOrder }) { + if (!clickedOrder || clickedOrder.locked) { + this.orderManagementContext.selectedOrder = clickedOrder; + } else { + this._setOrder(clickedOrder); + } + } + /** + * @param {models.Order} order + */ + _setOrder(order) { + this.env.pos.set_order(order); + if (order === this.env.pos.get_order()) { + this.close(); + } + } + } + OrderManagementScreen.template = 'OrderManagementScreen'; + OrderManagementScreen.hideOrderSelector = true; + + Registries.Component.add(OrderManagementScreen); + + return OrderManagementScreen; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderRow.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderRow.js new file mode 100644 index 00000000..959ea5a1 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderRow.js @@ -0,0 +1,42 @@ +odoo.define('point_of_sale.OrderRow', function (require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + /** + * @props {models.Order} order + * @props columns + * @emits click-order + */ + class OrderRow extends PosComponent { + get order() { + return this.props.order; + } + get highlighted() { + const highlightedOrder = this.props.highlightedOrder; + return !highlightedOrder ? false : highlightedOrder.backendId === this.props.order.backendId; + } + + // Column getters // + + get name() { + return this.order.get_name(); + } + get date() { + return moment(this.order.validation_date).format('YYYY-MM-DD hh:mm A'); + } + get customer() { + const customer = this.order.get('client'); + return customer ? customer.name : null; + } + get total() { + return this.env.pos.format_currency(this.order.get_total_with_tax()); + } + } + OrderRow.template = 'OrderRow'; + + Registries.Component.add(OrderRow); + + return OrderRow; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderlineDetails.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderlineDetails.js new file mode 100644 index 00000000..35f6ec5d --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderlineDetails.js @@ -0,0 +1,55 @@ +odoo.define('point_of_sale.OrderlineDetails', function (require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + const { format } = require('web.field_utils'); + const { round_precision: round_pr } = require('web.utils'); + + /** + * @props {pos.order.line} line + */ + class OrderlineDetails extends PosComponent { + get line() { + const line = this.props.line; + const formatQty = (line) => { + const quantity = line.get_quantity(); + const unit = line.get_unit(); + const decimals = this.env.pos.dp['Product Unit of Measure']; + const rounding = Math.max(unit.rounding, Math.pow(10, -decimals)); + const roundedQuantity = round_pr(quantity, rounding); + return format.float(roundedQuantity, { digits: [69, decimals] }); + }; + return { + productName: line.get_full_product_name(), + totalPrice: line.get_price_with_tax(), + quantity: formatQty(line), + unit: line.get_unit().name, + unitPrice: line.get_unit_price(), + }; + } + get productName() { + return this.line.productName; + } + get totalPrice() { + return this.env.pos.format_currency(this.line.totalPrice); + } + get quantity() { + return this.line.quantity; + } + get unitPrice() { + return this.line.unitPrice; + } + get unit() { + return this.line.unit; + } + get pricePerUnit() { + return ` ${this.unit} at ${this.unitPrice} / ${this.unit}`; + } + } + OrderlineDetails.template = 'OrderlineDetails'; + + Registries.Component.add(OrderlineDetails); + + return OrderlineDetails; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ReprintReceiptScreen.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ReprintReceiptScreen.js new file mode 100644 index 00000000..7fcc514d --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ReprintReceiptScreen.js @@ -0,0 +1,32 @@ +odoo.define('point_of_sale.ReprintReceiptScreen', function (require) { + 'use strict'; + + const AbstractReceiptScreen = require('point_of_sale.AbstractReceiptScreen'); + const Registries = require('point_of_sale.Registries'); + + const ReprintReceiptScreen = (AbstractReceiptScreen) => { + class ReprintReceiptScreen extends AbstractReceiptScreen { + mounted() { + this.printReceipt(); + } + confirm() { + this.showScreen('OrderManagementScreen'); + } + async printReceipt() { + if(this.env.pos.proxy.printer && this.env.pos.config.iface_print_skip_screen) { + let result = await this._printReceipt(); + if(result) + this.showScreen('OrderManagementScreen'); + } + } + async tryReprint() { + await this._printReceipt(); + } + } + ReprintReceiptScreen.template = 'ReprintReceiptScreen'; + return ReprintReceiptScreen; + }; + Registries.Component.addByExtending(ReprintReceiptScreen, AbstractReceiptScreen); + + return ReprintReceiptScreen; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PSNumpadInputButton.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PSNumpadInputButton.js new file mode 100644 index 00000000..b5dc6a7b --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PSNumpadInputButton.js @@ -0,0 +1,17 @@ +odoo.define('point_of_sale.PSNumpadInputButton', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class PSNumpadInputButton extends PosComponent { + get _class() { + return this.props.changeClassTo || 'input-button number-char'; + } + } + PSNumpadInputButton.template = 'PSNumpadInputButton'; + + Registries.Component.add(PSNumpadInputButton); + + return PSNumpadInputButton; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentMethodButton.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentMethodButton.js new file mode 100644 index 00000000..8e5853d3 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentMethodButton.js @@ -0,0 +1,13 @@ +odoo.define('point_of_sale.PaymentMethodButton', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class PaymentMethodButton extends PosComponent {} + PaymentMethodButton.template = 'PaymentMethodButton'; + + Registries.Component.add(PaymentMethodButton); + + return PaymentMethodButton; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js new file mode 100644 index 00000000..6fe25a11 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js @@ -0,0 +1,376 @@ +odoo.define('point_of_sale.PaymentScreen', function (require) { + 'use strict'; + + const { parse } = require('web.field_utils'); + const PosComponent = require('point_of_sale.PosComponent'); + const { useErrorHandlers } = require('point_of_sale.custom_hooks'); + const NumberBuffer = require('point_of_sale.NumberBuffer'); + const { useListener } = require('web.custom_hooks'); + const Registries = require('point_of_sale.Registries'); + const { onChangeOrder } = require('point_of_sale.custom_hooks'); + + class PaymentScreen extends PosComponent { + constructor() { + super(...arguments); + useListener('delete-payment-line', this.deletePaymentLine); + useListener('select-payment-line', this.selectPaymentLine); + useListener('new-payment-line', this.addNewPaymentLine); + useListener('update-selected-paymentline', this._updateSelectedPaymentline); + useListener('send-payment-request', this._sendPaymentRequest); + useListener('send-payment-cancel', this._sendPaymentCancel); + useListener('send-payment-reverse', this._sendPaymentReverse); + useListener('send-force-done', this._sendForceDone); + NumberBuffer.use({ + // The numberBuffer listens to this event to update its state. + // Basically means 'update the buffer when this event is triggered' + nonKeyboardInputEvent: 'input-from-numpad', + // When the buffer is updated, trigger this event. + // Note that the component listens to it. + triggerAtInput: 'update-selected-paymentline', + }); + onChangeOrder(this._onPrevOrder, this._onNewOrder); + useErrorHandlers(); + this.payment_interface = null; + this.error = false; + this.payment_methods_from_config = this.env.pos.payment_methods.filter(method => this.env.pos.config.payment_method_ids.includes(method.id)); + } + get currentOrder() { + return this.env.pos.get_order(); + } + get paymentLines() { + return this.currentOrder.get_paymentlines(); + } + get selectedPaymentLine() { + return this.currentOrder.selected_paymentline; + } + async selectClient() { + // IMPROVEMENT: This code snippet is repeated multiple times. + // Maybe it's better to create a function for it. + const currentClient = this.currentOrder.get_client(); + const { confirmed, payload: newClient } = await this.showTempScreen( + 'ClientListScreen', + { client: currentClient } + ); + if (confirmed) { + this.currentOrder.set_client(newClient); + this.currentOrder.updatePricelist(newClient); + } + } + addNewPaymentLine({ detail: paymentMethod }) { + // original function: click_paymentmethods + if (this.currentOrder.electronic_payment_in_progress()) { + this.showPopup('ErrorPopup', { + title: this.env._t('Error'), + body: this.env._t('There is already an electronic payment in progress.'), + }); + return false; + } else { + this.currentOrder.add_paymentline(paymentMethod); + NumberBuffer.reset(); + this.payment_interface = paymentMethod.payment_terminal; + if (this.payment_interface) { + this.currentOrder.selected_paymentline.set_payment_status('pending'); + } + return true; + } + } + _updateSelectedPaymentline() { + if (this.paymentLines.every((line) => line.paid)) { + this.currentOrder.add_paymentline(this.payment_methods_from_config[0]); + } + if (!this.selectedPaymentLine) return; // do nothing if no selected payment line + // disable changing amount on paymentlines with running or done payments on a payment terminal + if ( + this.payment_interface && + !['pending', 'retry'].includes(this.selectedPaymentLine.get_payment_status()) + ) { + return; + } + if (NumberBuffer.get() === null) { + this.deletePaymentLine({ detail: { cid: this.selectedPaymentLine.cid } }); + } else { + this.selectedPaymentLine.set_amount(NumberBuffer.getFloat()); + } + } + toggleIsToInvoice() { + // click_invoice + this.currentOrder.set_to_invoice(!this.currentOrder.is_to_invoice()); + this.render(); + } + openCashbox() { + this.env.pos.proxy.printer.open_cashbox(); + } + async addTip() { + // click_tip + const tip = this.currentOrder.get_tip(); + const change = this.currentOrder.get_change(); + let value = tip.toFixed(this.env.pos.decimals); + + if (tip === 0 && change > 0) { + value = change; + } + + const { confirmed, payload } = await this.showPopup('NumberPopup', { + title: tip ? this.env._t('Change Tip') : this.env._t('Add Tip'), + startingValue: value, + }); + + if (confirmed) { + this.currentOrder.set_tip(parse.float(payload)); + } + } + deletePaymentLine(event) { + const { cid } = event.detail; + const line = this.paymentLines.find((line) => line.cid === cid); + + // If a paymentline with a payment terminal linked to + // it is removed, the terminal should get a cancel + // request. + if (['waiting', 'waitingCard', 'timeout'].includes(line.get_payment_status())) { + line.payment_method.payment_terminal.send_payment_cancel(this.currentOrder, cid); + } + + this.currentOrder.remove_paymentline(line); + NumberBuffer.reset(); + this.render(); + } + selectPaymentLine(event) { + const { cid } = event.detail; + const line = this.paymentLines.find((line) => line.cid === cid); + this.currentOrder.select_paymentline(line); + NumberBuffer.reset(); + this.render(); + } + async validateOrder(isForceValidate) { + if(this.env.pos.config.cash_rounding) { + if(!this.env.pos.get_order().check_paymentlines_rounding()) { + this.showPopup('ErrorPopup', { + title: this.env._t('Rounding error in payment lines'), + body: this.env._t("The amount of your payment lines must be rounded to validate the transaction."), + }); + return; + } + } + if (await this._isOrderValid(isForceValidate)) { + // remove pending payments before finalizing the validation + for (let line of this.paymentLines) { + if (!line.is_done()) this.currentOrder.remove_paymentline(line); + } + await this._finalizeValidation(); + } + } + async _finalizeValidation() { + if ((this.currentOrder.is_paid_with_cash() || this.currentOrder.get_change()) && this.env.pos.config.iface_cashdrawer) { + this.env.pos.proxy.printer.open_cashbox(); + } + + this.currentOrder.initialize_validation_date(); + this.currentOrder.finalized = true; + + let syncedOrderBackendIds = []; + + try { + if (this.currentOrder.is_to_invoice()) { + syncedOrderBackendIds = await this.env.pos.push_and_invoice_order( + this.currentOrder + ); + } else { + syncedOrderBackendIds = await this.env.pos.push_single_order(this.currentOrder); + } + } catch (error) { + if (error.code == 700) + this.error = true; + if (error instanceof Error) { + throw error; + } else { + await this._handlePushOrderError(error); + } + } + if (syncedOrderBackendIds.length && this.currentOrder.wait_for_push_order()) { + const result = await this._postPushOrderResolve( + this.currentOrder, + syncedOrderBackendIds + ); + if (!result) { + await this.showPopup('ErrorPopup', { + title: 'Error: no internet connection.', + body: error, + }); + } + } + + this.showScreen(this.nextScreen); + + // If we succeeded in syncing the current order, and + // there are still other orders that are left unsynced, + // we ask the user if he is willing to wait and sync them. + if (syncedOrderBackendIds.length && this.env.pos.db.get_orders().length) { + const { confirmed } = await this.showPopup('ConfirmPopup', { + title: this.env._t('Remaining unsynced orders'), + body: this.env._t( + 'There are unsynced orders. Do you want to sync these orders?' + ), + }); + if (confirmed) { + // NOTE: Not yet sure if this should be awaited or not. + // If awaited, some operations like changing screen + // might not work. + this.env.pos.push_orders(); + } + } + } + get nextScreen() { + return !this.error? 'ReceiptScreen' : 'ProductScreen'; + } + async _isOrderValid(isForceValidate) { + if (this.currentOrder.get_orderlines().length === 0) { + this.showPopup('ErrorPopup', { + title: this.env._t('Empty Order'), + body: this.env._t( + 'There must be at least one product in your order before it can be validated' + ), + }); + return false; + } + + if (this.currentOrder.is_to_invoice() && !this.currentOrder.get_client()) { + const { confirmed } = await this.showPopup('ConfirmPopup', { + title: this.env._t('Please select the Customer'), + body: this.env._t( + 'You need to select the customer before you can invoice an order.' + ), + }); + if (confirmed) { + this.selectClient(); + } + return false; + } + + if (!this.currentOrder.is_paid() || this.invoicing) { + return false; + } + + if (this.currentOrder.has_not_valid_rounding()) { + var line = this.currentOrder.has_not_valid_rounding(); + this.showPopup('ErrorPopup', { + title: this.env._t('Incorrect rounding'), + body: this.env._t( + 'You have to round your payments lines.' + line.amount + ' is not rounded.' + ), + }); + return false; + } + + // The exact amount must be paid if there is no cash payment method defined. + if ( + Math.abs( + this.currentOrder.get_total_with_tax() - this.currentOrder.get_total_paid() + this.currentOrder.get_rounding_applied() + ) > 0.00001 + ) { + var cash = false; + for (var i = 0; i < this.env.pos.payment_methods.length; i++) { + cash = cash || this.env.pos.payment_methods[i].is_cash_count; + } + if (!cash) { + this.showPopup('ErrorPopup', { + title: this.env._t('Cannot return change without a cash payment method'), + body: this.env._t( + 'There is no cash payment method available in this point of sale to handle the change.\n\n Please pay the exact amount or add a cash payment method in the point of sale configuration' + ), + }); + return false; + } + } + + // if the change is too large, it's probably an input error, make the user confirm. + if ( + !isForceValidate && + this.currentOrder.get_total_with_tax() > 0 && + this.currentOrder.get_total_with_tax() * 1000 < this.currentOrder.get_total_paid() + ) { + this.showPopup('ConfirmPopup', { + title: this.env._t('Please Confirm Large Amount'), + body: + this.env._t('Are you sure that the customer wants to pay') + + ' ' + + this.env.pos.format_currency(this.currentOrder.get_total_paid()) + + ' ' + + this.env._t('for an order of') + + ' ' + + this.env.pos.format_currency(this.currentOrder.get_total_with_tax()) + + ' ' + + this.env._t('? Clicking "Confirm" will validate the payment.'), + }).then(({ confirmed }) => { + if (confirmed) this.validateOrder(true); + }); + return false; + } + + return true; + } + async _postPushOrderResolve(order, order_server_ids) { + return true; + } + async _sendPaymentRequest({ detail: line }) { + // Other payment lines can not be reversed anymore + this.paymentLines.forEach(function (line) { + line.can_be_reversed = false; + }); + + const payment_terminal = line.payment_method.payment_terminal; + line.set_payment_status('waiting'); + + const isPaymentSuccessful = await payment_terminal.send_payment_request(line.cid); + if (isPaymentSuccessful) { + line.set_payment_status('done'); + line.can_be_reversed = this.payment_interface.supports_reversals; + } else { + line.set_payment_status('retry'); + } + } + async _sendPaymentCancel({ detail: line }) { + const payment_terminal = line.payment_method.payment_terminal; + line.set_payment_status('waitingCancel'); + const isCancelSuccessful = await payment_terminal.send_payment_cancel(this.currentOrder, line.cid); + if (isCancelSuccessful) { + line.set_payment_status('retry'); + } else { + line.set_payment_status('waitingCard'); + } + } + async _sendPaymentReverse({ detail: line }) { + const payment_terminal = line.payment_method.payment_terminal; + line.set_payment_status('reversing'); + + const isReversalSuccessful = await payment_terminal.send_payment_reversal(line.cid); + if (isReversalSuccessful) { + line.set_amount(0); + line.set_payment_status('reversed'); + } else { + line.can_be_reversed = false; + line.set_payment_status('done'); + } + } + async _sendForceDone({ detail: line }) { + line.set_payment_status('done'); + } + _onPrevOrder(prevOrder) { + prevOrder.off('change', null, this); + prevOrder.paymentlines.off('change', null, this); + if (prevOrder) { + prevOrder.stop_electronic_payment(); + } + } + async _onNewOrder(newOrder) { + newOrder.on('change', this.render, this); + newOrder.paymentlines.on('change', this.render, this); + NumberBuffer.reset(); + await this.render(); + } + } + PaymentScreen.template = 'PaymentScreen'; + + Registries.Component.add(PaymentScreen); + + return PaymentScreen; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenElectronicPayment.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenElectronicPayment.js new file mode 100644 index 00000000..6cafac15 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenElectronicPayment.js @@ -0,0 +1,23 @@ +odoo.define('point_of_sale.PaymentScreenElectronicPayment', function (require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class PaymentScreenElectronicPayment extends PosComponent { + mounted() { + this.props.line.on('change', this.render, this); + } + willUnmount() { + if (this.props.line) { + // It could be that the line is deleted before unmounting the element. + this.props.line.off('change', null, this); + } + } + } + PaymentScreenElectronicPayment.template = 'PaymentScreenElectronicPayment'; + + Registries.Component.add(PaymentScreenElectronicPayment); + + return PaymentScreenElectronicPayment; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenNumpad.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenNumpad.js new file mode 100644 index 00000000..e661722f --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenNumpad.js @@ -0,0 +1,18 @@ +odoo.define('point_of_sale.PaymentScreenNumpad', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class PaymentScreenNumpad extends PosComponent { + constructor() { + super(...arguments); + this.decimalPoint = this.env._t.database.parameters.decimal_point; + } + } + PaymentScreenNumpad.template = 'PaymentScreenNumpad'; + + Registries.Component.add(PaymentScreenNumpad); + + return PaymentScreenNumpad; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenPaymentLines.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenPaymentLines.js new file mode 100644 index 00000000..8f231146 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenPaymentLines.js @@ -0,0 +1,23 @@ +odoo.define('point_of_sale.PaymentScreenPaymentLines', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class PaymentScreenPaymentLines extends PosComponent { + formatLineAmount(paymentline) { + return this.env.pos.format_currency_no_symbol(paymentline.get_amount()); + } + selectedLineClass(line) { + return { 'payment-terminal': line.get_payment_status() }; + } + unselectedLineClass(line) { + return {}; + } + } + PaymentScreenPaymentLines.template = 'PaymentScreenPaymentLines'; + + Registries.Component.add(PaymentScreenPaymentLines); + + return PaymentScreenPaymentLines; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenStatus.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenStatus.js new file mode 100644 index 00000000..12ccaa84 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenStatus.js @@ -0,0 +1,30 @@ +odoo.define('point_of_sale.PaymentScreenStatus', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class PaymentScreenStatus extends PosComponent { + get changeText() { + return this.env.pos.format_currency(this.currentOrder.get_change()); + } + get totalDueText() { + return this.env.pos.format_currency( + this.currentOrder.get_total_with_tax() + this.currentOrder.get_rounding_applied() + ); + } + get remainingText() { + return this.env.pos.format_currency( + this.currentOrder.get_due() > 0 ? this.currentOrder.get_due() : 0 + ); + } + get currentOrder() { + return this.env.pos.get_order(); + } + } + PaymentScreenStatus.template = 'PaymentScreenStatus'; + + Registries.Component.add(PaymentScreenStatus); + + return PaymentScreenStatus; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ActionpadWidget.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ActionpadWidget.js new file mode 100644 index 00000000..d30fe85e --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ActionpadWidget.js @@ -0,0 +1,25 @@ +odoo.define('point_of_sale.ActionpadWidget', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + /** + * @props client + * @emits click-customer + * @emits click-pay + */ + class ActionpadWidget extends PosComponent { + get isLongName() { + return this.client && this.client.name.length > 10; + } + get client() { + return this.props.client; + } + } + ActionpadWidget.template = 'ActionpadWidget'; + + Registries.Component.add(ActionpadWidget); + + return ActionpadWidget; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/CashBoxOpening.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CashBoxOpening.js new file mode 100644 index 00000000..be42e45d --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CashBoxOpening.js @@ -0,0 +1,42 @@ +odoo.define('point_of_sale.CashBoxOpening', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + const { Gui } = require('point_of_sale.Gui'); + + class CashBoxOpening extends PosComponent { + constructor() { + super(...arguments); + this.changes = {}; + this.defaultValue = this.env.pos.bank_statement.balance_start || 0; + this.symbol = this.env.pos.currency.symbol; + } + captureChange(event) { + this.changes[event.target.name] = event.target.value; + } + startSession() { + let cashOpening = this.changes.cashBoxValue? this.changes.cashBoxValue: this.defaultValue; + if(isNaN(cashOpening)) { + Gui.showPopup('ErrorPopup',{ + 'title': 'Wrong value', + 'body': 'Please insert a correct value.', + }); + return; + } + this.env.pos.bank_statement.balance_start = cashOpening; + this.env.pos.pos_session.state = 'opened'; + this.props.cashControl.cashControl = false; + this.rpc({ + model: 'pos.session', + method: 'set_cashbox_pos', + args: [this.env.pos.pos_session.id, cashOpening, this.changes.notes], + }); + } + } + CashBoxOpening.template = 'CashBoxOpening'; + + Registries.Component.add(CashBoxOpening); + + return CashBoxOpening; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryBreadcrumb.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryBreadcrumb.js new file mode 100644 index 00000000..843cc248 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryBreadcrumb.js @@ -0,0 +1,13 @@ +odoo.define('point_of_sale.CategoryBreadcrumb', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class CategoryBreadcrumb extends PosComponent {} + CategoryBreadcrumb.template = 'CategoryBreadcrumb'; + + Registries.Component.add(CategoryBreadcrumb); + + return CategoryBreadcrumb; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryButton.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryButton.js new file mode 100644 index 00000000..05914bec --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryButton.js @@ -0,0 +1,18 @@ +odoo.define('point_of_sale.CategoryButton', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class CategoryButton extends PosComponent { + get imageUrl() { + const category = this.props.category + return `/web/image?model=pos.category&field=image_128&id=${category.id}&write_date=${category.write_date}&unique=1`; + } + } + CategoryButton.template = 'CategoryButton'; + + Registries.Component.add(CategoryButton); + + return CategoryButton; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategorySimpleButton.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategorySimpleButton.js new file mode 100644 index 00000000..675512d8 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategorySimpleButton.js @@ -0,0 +1,13 @@ +odoo.define('point_of_sale.CategorySimpleButton', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class CategorySimpleButton extends PosComponent {} + CategorySimpleButton.template = 'CategorySimpleButton'; + + Registries.Component.add(CategorySimpleButton); + + return CategorySimpleButton; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.js new file mode 100644 index 00000000..901e70e7 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.js @@ -0,0 +1,80 @@ +odoo.define('point_of_sale.SetFiscalPositionButton', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const ProductScreen = require('point_of_sale.ProductScreen'); + const { useListener } = require('web.custom_hooks'); + const Registries = require('point_of_sale.Registries'); + + class SetFiscalPositionButton extends PosComponent { + constructor() { + super(...arguments); + useListener('click', this.onClick); + } + mounted() { + this.env.pos.get('orders').on('add remove change', () => this.render(), this); + this.env.pos.on('change:selectedOrder', () => this.render(), this); + } + willUnmount() { + this.env.pos.get('orders').off('add remove change', null, this); + this.env.pos.off('change:selectedOrder', null, this); + } + get currentOrder() { + return this.env.pos.get_order(); + } + get currentFiscalPositionName() { + return this.currentOrder && this.currentOrder.fiscal_position + ? this.currentOrder.fiscal_position.display_name + : this.env._t('Tax'); + } + async onClick() { + const currentFiscalPosition = this.currentOrder.fiscal_position; + const fiscalPosList = [ + { + id: -1, + label: this.env._t('None'), + isSelected: !currentFiscalPosition, + }, + ]; + for (let fiscalPos of this.env.pos.fiscal_positions) { + fiscalPosList.push({ + id: fiscalPos.id, + label: fiscalPos.name, + isSelected: currentFiscalPosition + ? fiscalPos.id === currentFiscalPosition.id + : false, + item: fiscalPos, + }); + } + const { confirmed, payload: selectedFiscalPosition } = await this.showPopup( + 'SelectionPopup', + { + title: this.env._t('Select Fiscal Position'), + list: fiscalPosList, + } + ); + if (confirmed) { + this.currentOrder.fiscal_position = selectedFiscalPosition; + // IMPROVEMENT: The following is the old implementation and I believe + // there could be a better way of doing it. + for (let line of this.currentOrder.orderlines.models) { + line.set_quantity(line.quantity); + } + this.currentOrder.trigger('change'); + } + } + } + SetFiscalPositionButton.template = 'SetFiscalPositionButton'; + + ProductScreen.addControlButton({ + component: SetFiscalPositionButton, + condition: function() { + return this.env.pos.fiscal_positions.length > 0; + }, + position: ['before', 'SetPricelistButton'], + }); + + Registries.Component.add(SetFiscalPositionButton); + + return SetFiscalPositionButton; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetPricelistButton.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetPricelistButton.js new file mode 100644 index 00000000..c0a01f87 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetPricelistButton.js @@ -0,0 +1,67 @@ +odoo.define('point_of_sale.SetPricelistButton', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const ProductScreen = require('point_of_sale.ProductScreen'); + const { useListener } = require('web.custom_hooks'); + const Registries = require('point_of_sale.Registries'); + + class SetPricelistButton extends PosComponent { + constructor() { + super(...arguments); + useListener('click', this.onClick); + } + mounted() { + this.env.pos.get('orders').on('add remove change', () => this.render(), this); + this.env.pos.on('change:selectedOrder', () => this.render(), this); + } + willUnmount() { + this.env.pos.get('orders').off('add remove change', null, this); + this.env.pos.off('change:selectedOrder', null, this); + } + get currentOrder() { + return this.env.pos.get_order(); + } + get currentPricelistName() { + const order = this.currentOrder; + return order && order.pricelist + ? order.pricelist.display_name + : this.env._t('Pricelist'); + } + async onClick() { + // Create the list to be passed to the SelectionPopup. + // Pricelist object is passed as item in the list because it + // is the object that will be returned when the popup is confirmed. + const selectionList = this.env.pos.pricelists.map(pricelist => ({ + id: pricelist.id, + label: pricelist.name, + isSelected: pricelist.id === this.currentOrder.pricelist.id, + item: pricelist, + })); + + const { confirmed, payload: selectedPricelist } = await this.showPopup( + 'SelectionPopup', + { + title: this.env._t('Select the pricelist'), + list: selectionList, + } + ); + + if (confirmed) { + this.currentOrder.set_pricelist(selectedPricelist); + } + } + } + SetPricelistButton.template = 'SetPricelistButton'; + + ProductScreen.addControlButton({ + component: SetPricelistButton, + condition: function() { + return this.env.pos.config.use_pricelist && this.env.pos.pricelists.length > 1; + }, + }); + + Registries.Component.add(SetPricelistButton); + + return SetPricelistButton; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/HomeCategoryBreadcrumb.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/HomeCategoryBreadcrumb.js new file mode 100644 index 00000000..28641236 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/HomeCategoryBreadcrumb.js @@ -0,0 +1,47 @@ +odoo.define('point_of_sale.HomeCategoryBreadcrumb', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + const { useListener } = require('web.custom_hooks'); + + class HomeCategoryBreadcrumb extends PosComponent { + constructor() { + super(...arguments); + useListener('categ-popup', this._categPopup); + } + get selectedCategoryId() { + return this.env.pos.get('selectedCategoryId'); + } + async _categPopup() { + let selectionList = [{ + id: 0, + label:'All Items', + isSelected: 0 === this.env.pos.get('selectedCategoryId'), + item: {id:0,name:'All Items'}, + }]; + let subs = this.props.subcategories.map(category => ({ + id: category.id, + label: category.name, + isSelected: category.id === this.env.pos.get('selectedCategoryId'), + item: category, + })); + selectionList = selectionList.concat(subs); + const { confirmed, payload: selectedCategory } = await this.showPopup( + 'SelectionPopup', + { + title: this.env._t('Select the category'), + list: selectionList, + } + ); + if (confirmed) { + this.trigger('switch-category', selectedCategory.id); + } + } + } + HomeCategoryBreadcrumb.template = 'HomeCategoryBreadcrumb'; + + Registries.Component.add(HomeCategoryBreadcrumb); + + return HomeCategoryBreadcrumb; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/NumpadWidget.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/NumpadWidget.js new file mode 100644 index 00000000..5850dc83 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/NumpadWidget.js @@ -0,0 +1,59 @@ +odoo.define('point_of_sale.NumpadWidget', function (require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + /** + * @prop {'quantiy' | 'price' | 'discount'} activeMode + * @event set-numpad-mode - triggered when mode button is clicked + * @event numpad-click-input - triggered when numpad button is clicked + * + * IMPROVEMENT: Whenever new-orderline-selected is triggered, + * numpad mode should be set to 'quantity'. Now that the mode state + * is lifted to the parent component, this improvement can be done in + * the parent component. + */ + class NumpadWidget extends PosComponent { + mounted() { + // IMPROVEMENT: This listener shouldn't be here because in core point_of_sale + // there is no way of changing the cashier. Only when pos_hr is installed + // that this listener makes sense. + this.env.pos.on('change:cashier', () => { + if (!this.hasPriceControlRights && this.props.activeMode === 'price') { + this.trigger('set-numpad-mode', { mode: 'quantity' }); + } + }); + } + willUnmount() { + this.env.pos.on('change:cashier', null, this); + } + get hasPriceControlRights() { + const cashier = this.env.pos.get('cashier') || this.env.pos.get_cashier(); + return !this.env.pos.config.restrict_price_control || cashier.role == 'manager'; + } + get hasManualDiscount() { + return this.env.pos.config.manual_discount; + } + changeMode(mode) { + if (!this.hasPriceControlRights && mode === 'price') { + return; + } + if (!this.hasManualDiscount && mode === 'discount') { + return; + } + this.trigger('set-numpad-mode', { mode }); + } + sendInput(key) { + this.trigger('numpad-click-input', { key }); + } + get decimalSeparator() { + return this.env._t.database.parameters.decimal_point; + } + } + NumpadWidget.template = 'NumpadWidget'; + + Registries.Component.add(NumpadWidget); + + return NumpadWidget; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderSummary.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderSummary.js new file mode 100644 index 00000000..aeb9891f --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderSummary.js @@ -0,0 +1,13 @@ +odoo.define('point_of_sale.OrderSummary', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class OrderSummary extends PosComponent {} + OrderSummary.template = 'OrderSummary'; + + Registries.Component.add(OrderSummary); + + return OrderSummary; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderWidget.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderWidget.js new file mode 100644 index 00000000..ee610afd --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderWidget.js @@ -0,0 +1,110 @@ +odoo.define('point_of_sale.OrderWidget', function(require) { + 'use strict'; + + const { useState, useRef, onPatched } = owl.hooks; + const { useListener } = require('web.custom_hooks'); + const { onChangeOrder } = require('point_of_sale.custom_hooks'); + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class OrderWidget extends PosComponent { + constructor() { + super(...arguments); + useListener('select-line', this._selectLine); + useListener('edit-pack-lot-lines', this._editPackLotLines); + onChangeOrder(this._onPrevOrder, this._onNewOrder); + this.scrollableRef = useRef('scrollable'); + this.scrollToBottom = false; + onPatched(() => { + // IMPROVEMENT + // This one just stays at the bottom of the orderlines list. + // Perhaps it is better to scroll to the added or modified orderline. + if (this.scrollToBottom) { + this.scrollableRef.el.scrollTop = this.scrollableRef.el.scrollHeight; + this.scrollToBottom = false; + } + }); + this.state = useState({ total: 0, tax: 0 }); + this._updateSummary(); + } + get order() { + return this.env.pos.get_order(); + } + get orderlinesArray() { + return this.order ? this.order.get_orderlines() : []; + } + _selectLine(event) { + this.order.select_orderline(event.detail.orderline); + } + // IMPROVEMENT: Might be better to lift this to ProductScreen + // because there is similar operation when clicking a product. + // + // Furthermore, what if a number different from 1 (or -1) is specified + // to an orderline that has product tracked by lot. Lot tracking (based + // on the current implementation) requires that 1 item per orderline is + // allowed. + async _editPackLotLines(event) { + const orderline = event.detail.orderline; + const isAllowOnlyOneLot = orderline.product.isAllowOnlyOneLot(); + const packLotLinesToEdit = orderline.getPackLotLinesToEdit(isAllowOnlyOneLot); + const { confirmed, payload } = await this.showPopup('EditListPopup', { + title: this.env._t('Lot/Serial Number(s) Required'), + isSingleItem: isAllowOnlyOneLot, + array: packLotLinesToEdit, + }); + if (confirmed) { + // Segregate the old and new packlot lines + const modifiedPackLotLines = Object.fromEntries( + payload.newArray.filter(item => item.id).map(item => [item.id, item.text]) + ); + const newPackLotLines = payload.newArray + .filter(item => !item.id) + .map(item => ({ lot_name: item.text })); + + orderline.setPackLotLines({ modifiedPackLotLines, newPackLotLines }); + } + this.order.select_orderline(event.detail.orderline); + } + _onNewOrder(order) { + if (order) { + order.orderlines.on( + 'new-orderline-selected', + () => this.trigger('new-orderline-selected'), + this + ); + order.orderlines.on('change', this._updateSummary, this); + order.orderlines.on( + 'add remove', + () => { + this.scrollToBottom = true; + this._updateSummary(); + }, + this + ); + order.on('change', this.render, this); + } + this._updateSummary(); + this.trigger('new-orderline-selected'); + } + _onPrevOrder(order) { + if (order) { + order.orderlines.off('new-orderline-selected', null, this); + order.orderlines.off('change', null, this); + order.orderlines.off('add remove', null, this); + order.off('change', null, this); + } + } + _updateSummary() { + const total = this.order ? this.order.get_total_with_tax() : 0; + const tax = this.order ? total - this.order.get_total_without_tax() : 0; + this.state.total = this.env.pos.format_currency(total); + this.state.tax = this.env.pos.format_currency(tax); + this.render(); + } + } + OrderWidget.template = 'OrderWidget'; + + Registries.Component.add(OrderWidget); + + return OrderWidget; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/Orderline.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/Orderline.js new file mode 100644 index 00000000..71a96bd4 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/Orderline.js @@ -0,0 +1,25 @@ +odoo.define('point_of_sale.Orderline', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class Orderline extends PosComponent { + selectLine() { + this.trigger('select-line', { orderline: this.props.line }); + } + lotIconClicked() { + this.trigger('edit-pack-lot-lines', { orderline: this.props.line }); + } + get addedClasses() { + return { + selected: this.props.line.selected, + }; + } + } + Orderline.template = 'Orderline'; + + Registries.Component.add(Orderline); + + return Orderline; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductItem.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductItem.js new file mode 100644 index 00000000..ac93500c --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductItem.js @@ -0,0 +1,49 @@ +odoo.define('point_of_sale.ProductItem', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class ProductItem extends PosComponent { + /** + * For accessibility, pressing <space> should be like clicking the product. + * <enter> is not considered because it conflicts with the barcode. + * + * @param {KeyPressEvent} event + */ + spaceClickProduct(event) { + if (event.which === 32) { + this.trigger('click-product', this.props.product); + } + } + get imageUrl() { + const product = this.props.product; + return `/web/image?model=product.product&field=image_128&id=${product.id}&write_date=${product.write_date}&unique=1`; + } + get pricelist() { + const current_order = this.env.pos.get_order(); + if (current_order) { + return current_order.pricelist; + } + return this.env.pos.default_pricelist; + } + get price() { + const formattedUnitPrice = this.env.pos.format_currency( + this.props.product.get_price(this.pricelist, 1), + 'Product Price' + ); + if (this.props.product.to_weight) { + return `${formattedUnitPrice}/${ + this.env.pos.units_by_id[this.props.product.uom_id[0]].name + }`; + } else { + return formattedUnitPrice; + } + } + } + ProductItem.template = 'ProductItem'; + + Registries.Component.add(ProductItem); + + return ProductItem; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductList.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductList.js new file mode 100644 index 00000000..aeee2ede --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductList.js @@ -0,0 +1,13 @@ +odoo.define('point_of_sale.ProductList', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class ProductList extends PosComponent {} + ProductList.template = 'ProductList'; + + Registries.Component.add(ProductList); + + return ProductList; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js new file mode 100644 index 00000000..65daa7cc --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js @@ -0,0 +1,327 @@ +odoo.define('point_of_sale.ProductScreen', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const ControlButtonsMixin = require('point_of_sale.ControlButtonsMixin'); + const NumberBuffer = require('point_of_sale.NumberBuffer'); + const { useListener } = require('web.custom_hooks'); + const Registries = require('point_of_sale.Registries'); + const { onChangeOrder, useBarcodeReader } = require('point_of_sale.custom_hooks'); + const { useState } = owl.hooks; + const { parse } = require('web.field_utils'); + + class ProductScreen extends ControlButtonsMixin(PosComponent) { + constructor() { + super(...arguments); + useListener('update-selected-orderline', this._updateSelectedOrderline); + useListener('new-orderline-selected', this._newOrderlineSelected); + useListener('set-numpad-mode', this._setNumpadMode); + useListener('click-product', this._clickProduct); + useListener('click-customer', this._onClickCustomer); + useListener('click-pay', this._onClickPay); + useBarcodeReader({ + product: this._barcodeProductAction, + weight: this._barcodeProductAction, + price: this._barcodeProductAction, + client: this._barcodeClientAction, + discount: this._barcodeDiscountAction, + error: this._barcodeErrorAction, + }) + onChangeOrder(null, (newOrder) => newOrder && this.render()); + NumberBuffer.use({ + nonKeyboardInputEvent: 'numpad-click-input', + triggerAtInput: 'update-selected-orderline', + useWithBarcode: true, + }); + let status = this.showCashBoxOpening() + this.state = useState({ cashControl: status, numpadMode: 'quantity' }); + this.mobile_pane = this.props.mobile_pane || 'right'; + } + mounted() { + this.env.pos.on('change:selectedClient', this.render, this); + } + willUnmount() { + this.env.pos.off('change:selectedClient', null, this); + } + /** + * To be overridden by modules that checks availability of + * connected scale. + * @see _onScaleNotAvailable + */ + get isScaleAvailable() { + return true; + } + get client() { + return this.env.pos.get_client(); + } + get currentOrder() { + return this.env.pos.get_order(); + } + showCashBoxOpening() { + if(this.env.pos.config.cash_control && this.env.pos.pos_session.state == 'opening_control') + return true; + return false; + } + async _getAddProductOptions(product) { + let price_extra = 0.0; + let draftPackLotLines, weight, description, packLotLinesToEdit; + + if (this.env.pos.config.product_configurator && _.some(product.attribute_line_ids, (id) => id in this.env.pos.attributes_by_ptal_id)) { + let attributes = _.map(product.attribute_line_ids, (id) => this.env.pos.attributes_by_ptal_id[id]) + .filter((attr) => attr !== undefined); + let { confirmed, payload } = await this.showPopup('ProductConfiguratorPopup', { + product: product, + attributes: attributes, + }); + + if (confirmed) { + description = payload.selected_attributes.join(', '); + price_extra += payload.price_extra; + } else { + return; + } + } + + // Gather lot information if required. + if (['serial', 'lot'].includes(product.tracking) && (this.env.pos.picking_type.use_create_lots || this.env.pos.picking_type.use_existing_lots)) { + const isAllowOnlyOneLot = product.isAllowOnlyOneLot(); + if (isAllowOnlyOneLot) { + packLotLinesToEdit = []; + } else { + const orderline = this.currentOrder + .get_orderlines() + .filter(line => !line.get_discount()) + .find(line => line.product.id === product.id); + if (orderline) { + packLotLinesToEdit = orderline.getPackLotLinesToEdit(); + } else { + packLotLinesToEdit = []; + } + } + const { confirmed, payload } = await this.showPopup('EditListPopup', { + title: this.env._t('Lot/Serial Number(s) Required'), + isSingleItem: isAllowOnlyOneLot, + array: packLotLinesToEdit, + }); + if (confirmed) { + // Segregate the old and new packlot lines + const modifiedPackLotLines = Object.fromEntries( + payload.newArray.filter(item => item.id).map(item => [item.id, item.text]) + ); + const newPackLotLines = payload.newArray + .filter(item => !item.id) + .map(item => ({ lot_name: item.text })); + + draftPackLotLines = { modifiedPackLotLines, newPackLotLines }; + } else { + // We don't proceed on adding product. + return; + } + } + + // Take the weight if necessary. + if (product.to_weight && this.env.pos.config.iface_electronic_scale) { + // Show the ScaleScreen to weigh the product. + if (this.isScaleAvailable) { + const { confirmed, payload } = await this.showTempScreen('ScaleScreen', { + product, + }); + if (confirmed) { + weight = payload.weight; + } else { + // do not add the product; + return; + } + } else { + await this._onScaleNotAvailable(); + } + } + + return { draftPackLotLines, quantity: weight, description, price_extra }; + } + async _clickProduct(event) { + if (!this.currentOrder) { + this.env.pos.add_new_order(); + } + const product = event.detail; + const options = await this._getAddProductOptions(product); + // Do not add product if options is undefined. + if (!options) return; + // Add the product after having the extra information. + this.currentOrder.add_product(product, options); + NumberBuffer.reset(); + } + _setNumpadMode(event) { + const { mode } = event.detail; + NumberBuffer.capture(); + NumberBuffer.reset(); + this.state.numpadMode = mode; + } + async _updateSelectedOrderline(event) { + if(this.state.numpadMode === 'quantity' && this.env.pos.disallowLineQuantityChange()) { + let order = this.env.pos.get_order(); + let selectedLine = order.get_selected_orderline(); + let lastId = order.orderlines.last().cid; + let currentQuantity = this.env.pos.get_order().get_selected_orderline().get_quantity(); + + if(selectedLine.noDecrease) { + this.showPopup('ErrorPopup', { + title: this.env._t('Invalid action'), + body: this.env._t('You are not allowed to change this quantity'), + }); + return; + } + const parsedInput = event.detail.buffer && parse.float(event.detail.buffer) || 0; + if(lastId != selectedLine.cid) + this._showDecreaseQuantityPopup(); + else if(currentQuantity < parsedInput) + this._setValue(event.detail.buffer); + else if(parsedInput < currentQuantity) + this._showDecreaseQuantityPopup(); + } else { + let { buffer } = event.detail; + let val = buffer === null ? 'remove' : buffer; + this._setValue(val); + } + } + async _newOrderlineSelected() { + NumberBuffer.reset(); + } + _setValue(val) { + if (this.currentOrder.get_selected_orderline()) { + if (this.state.numpadMode === 'quantity') { + this.currentOrder.get_selected_orderline().set_quantity(val); + } else if (this.state.numpadMode === 'discount') { + this.currentOrder.get_selected_orderline().set_discount(val); + } else if (this.state.numpadMode === 'price') { + var selected_orderline = this.currentOrder.get_selected_orderline(); + selected_orderline.price_manually_set = true; + selected_orderline.set_unit_price(val); + } + if (this.env.pos.config.iface_customer_facing_display) { + this.env.pos.send_current_order_to_customer_facing_display(); + } + } + } + async _barcodeProductAction(code) { + const product = this.env.pos.db.get_product_by_barcode(code.base_code) + if (!product) { + return this._barcodeErrorAction(code); + } + const options = await this._getAddProductOptions(product); + // Do not proceed on adding the product when no options is returned. + // This is consistent with _clickProduct. + if (!options) return; + + // update the options depending on the type of the scanned code + if (code.type === 'price') { + Object.assign(options, { price: code.value }); + } else if (code.type === 'weight') { + Object.assign(options, { + quantity: code.value, + merge: false, + }); + } else if (code.type === 'discount') { + Object.assign(options, { + discount: code.value, + merge: false, + }); + } + this.currentOrder.add_product(product, options) + } + _barcodeClientAction(code) { + const partner = this.env.pos.db.get_partner_by_barcode(code.code); + if (partner) { + if (this.currentOrder.get_client() !== partner) { + this.currentOrder.set_client(partner); + this.currentOrder.set_pricelist( + _.findWhere(this.env.pos.pricelists, { + id: partner.property_product_pricelist[0], + }) || this.env.pos.default_pricelist + ); + } + return true; + } + this._barcodeErrorAction(code); + return false; + } + _barcodeDiscountAction(code) { + var last_orderline = this.currentOrder.get_last_orderline(); + if (last_orderline) { + last_orderline.set_discount(code.value); + } + } + // IMPROVEMENT: The following two methods should be in PosScreenComponent? + // Why? Because once we start declaring barcode actions in different + // screens, these methods will also be declared over and over. + _barcodeErrorAction(code) { + this.showPopup('ErrorBarcodePopup', { code: this._codeRepr(code) }); + } + _codeRepr(code) { + if (code.code.length > 32) { + return code.code.substring(0, 29) + '...'; + } else { + return code.code; + } + } + /** + * override this method to perform procedure if the scale is not available. + * @see isScaleAvailable + */ + async _onScaleNotAvailable() {} + async _showDecreaseQuantityPopup() { + const { confirmed, payload: inputNumber } = await this.showPopup('NumberPopup', { + startingValue: 0, + title: this.env._t('Set the new quantity'), + }); + let newQuantity = inputNumber !== "" ? parse.float(inputNumber) : null; + if (confirmed && newQuantity !== null) { + let order = this.env.pos.get_order(); + let selectedLine = this.env.pos.get_order().get_selected_orderline(); + let currentQuantity = selectedLine.get_quantity() + if(selectedLine.is_last_line() && currentQuantity === 1 && newQuantity < currentQuantity) + selectedLine.set_quantity(newQuantity); + else if(newQuantity >= currentQuantity) + selectedLine.set_quantity(newQuantity); + else { + let newLine = selectedLine.clone(); + let decreasedQuantity = currentQuantity - newQuantity + newLine.order = order; + + newLine.set_quantity( - decreasedQuantity, true); + order.add_orderline(newLine); + } + } + } + async _onClickCustomer() { + // IMPROVEMENT: This code snippet is very similar to selectClient of PaymentScreen. + const currentClient = this.currentOrder.get_client(); + const { confirmed, payload: newClient } = await this.showTempScreen( + 'ClientListScreen', + { client: currentClient } + ); + if (confirmed) { + this.currentOrder.set_client(newClient); + this.currentOrder.updatePricelist(newClient); + } + } + _onClickPay() { + this.showScreen('PaymentScreen'); + } + switchPane() { + if (this.mobile_pane === "left") { + this.mobile_pane = "right"; + this.render(); + } + else { + this.mobile_pane = "left"; + this.render(); + } + } + } + ProductScreen.template = 'ProductScreen'; + + Registries.Component.add(ProductScreen); + + return ProductScreen; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js new file mode 100644 index 00000000..17481058 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js @@ -0,0 +1,88 @@ +odoo.define('point_of_sale.ProductsWidget', function(require) { + 'use strict'; + + const { useState } = owl.hooks; + const PosComponent = require('point_of_sale.PosComponent'); + const { useListener } = require('web.custom_hooks'); + const Registries = require('point_of_sale.Registries'); + + class ProductsWidget extends PosComponent { + /** + * @param {Object} props + * @param {number?} props.startCategoryId + */ + constructor() { + super(...arguments); + useListener('switch-category', this._switchCategory); + useListener('update-search', this._updateSearch); + useListener('try-add-product', this._tryAddProduct); + useListener('clear-search', this._clearSearch); + this.state = useState({ searchWord: '' }); + } + mounted() { + this.env.pos.on('change:selectedCategoryId', this.render, this); + } + willUnmount() { + this.env.pos.off('change:selectedCategoryId', null, this); + } + get selectedCategoryId() { + return this.env.pos.get('selectedCategoryId'); + } + get searchWord() { + return this.state.searchWord.trim(); + } + get productsToDisplay() { + if (this.searchWord !== '') { + return this.env.pos.db.search_product_in_category( + this.selectedCategoryId, + this.searchWord + ); + } else { + return this.env.pos.db.get_product_by_category(this.selectedCategoryId); + } + } + get subcategories() { + return this.env.pos.db + .get_category_childs_ids(this.selectedCategoryId) + .map(id => this.env.pos.db.get_category_by_id(id)); + } + get breadcrumbs() { + if (this.selectedCategoryId === this.env.pos.db.root_category_id) return []; + return [ + ...this.env.pos.db + .get_category_ancestors_ids(this.selectedCategoryId) + .slice(1), + this.selectedCategoryId, + ].map(id => this.env.pos.db.get_category_by_id(id)); + } + get hasNoCategories() { + return this.env.pos.db.get_category_childs_ids(0).length === 0; + } + _switchCategory(event) { + this.env.pos.set('selectedCategoryId', event.detail); + } + _updateSearch(event) { + this.state.searchWord = event.detail; + } + _tryAddProduct(event) { + const searchResults = this.productsToDisplay; + // If the search result contains one item, add the product and clear the search. + if (searchResults.length === 1) { + const { searchWordInput } = event.detail; + this.trigger('click-product', searchResults[0]); + // the value of the input element is not linked to the searchWord state, + // so we clear both the state and the element's value. + searchWordInput.el.value = ''; + this._clearSearch(); + } + } + _clearSearch() { + this.state.searchWord = ''; + } + } + ProductsWidget.template = 'ProductsWidget'; + + Registries.Component.add(ProductsWidget); + + return ProductsWidget; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidgetControlPanel.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidgetControlPanel.js new file mode 100644 index 00000000..fc2df5b0 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidgetControlPanel.js @@ -0,0 +1,33 @@ +odoo.define('point_of_sale.ProductsWidgetControlPanel', function(require) { + 'use strict'; + + const { useRef } = owl.hooks; + const { debounce } = owl.utils; + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class ProductsWidgetControlPanel extends PosComponent { + constructor() { + super(...arguments); + this.searchWordInput = useRef('search-word-input'); + this.updateSearch = debounce(this.updateSearch, 100); + } + clearSearch() { + this.searchWordInput.el.value = ''; + this.trigger('clear-search'); + } + updateSearch(event) { + this.trigger('update-search', event.target.value); + if (event.key === 'Enter') { + // We are passing the searchWordInput ref so that when necessary, + // it can be modified by the parent. + this.trigger('try-add-product', { searchWordInput: this.searchWordInput }); + } + } + } + ProductsWidgetControlPanel.template = 'ProductsWidgetControlPanel'; + + Registries.Component.add(ProductsWidgetControlPanel); + + return ProductsWidgetControlPanel; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/OrderReceipt.js b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/OrderReceipt.js new file mode 100644 index 00000000..c06b6339 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/OrderReceipt.js @@ -0,0 +1,47 @@ +odoo.define('point_of_sale.OrderReceipt', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class OrderReceipt extends PosComponent { + constructor() { + super(...arguments); + this._receiptEnv = this.props.order.getOrderReceiptEnv(); + } + willUpdateProps(nextProps) { + this._receiptEnv = nextProps.order.getOrderReceiptEnv(); + } + get receipt() { + return this.receiptEnv.receipt; + } + get orderlines() { + return this.receiptEnv.orderlines; + } + get paymentlines() { + return this.receiptEnv.paymentlines; + } + get isTaxIncluded() { + return Math.abs(this.receipt.subtotal - this.receipt.total_with_tax) <= 0.000001; + } + get receiptEnv () { + return this._receiptEnv; + } + isSimple(line) { + return ( + line.discount === 0 && + line.is_in_unit && + line.quantity === 1 && + !( + line.display_discount_policy == 'without_discount' && + line.price < line.price_lst + ) + ); + } + } + OrderReceipt.template = 'OrderReceipt'; + + Registries.Component.add(OrderReceipt); + + return OrderReceipt; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js new file mode 100644 index 00000000..720c65e4 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js @@ -0,0 +1,123 @@ +odoo.define('point_of_sale.ReceiptScreen', function (require) { + 'use strict'; + + const { Printer } = require('point_of_sale.Printer'); + const { is_email } = require('web.utils'); + const { useRef, useContext } = owl.hooks; + const { useErrorHandlers, onChangeOrder } = require('point_of_sale.custom_hooks'); + const Registries = require('point_of_sale.Registries'); + const AbstractReceiptScreen = require('point_of_sale.AbstractReceiptScreen'); + + const ReceiptScreen = (AbstractReceiptScreen) => { + class ReceiptScreen extends AbstractReceiptScreen { + constructor() { + super(...arguments); + useErrorHandlers(); + onChangeOrder(null, (newOrder) => newOrder && this.render()); + this.orderReceipt = useRef('order-receipt'); + const order = this.currentOrder; + const client = order.get_client(); + this.orderUiState = useContext(order.uiState.ReceiptScreen); + this.orderUiState.inputEmail = this.orderUiState.inputEmail || (client && client.email) || ''; + this.is_email = is_email; + } + mounted() { + // Here, we send a task to the event loop that handles + // the printing of the receipt when the component is mounted. + // We are doing this because we want the receipt screen to be + // displayed regardless of what happen to the handleAutoPrint + // call. + setTimeout(async () => await this.handleAutoPrint(), 0); + } + async onSendEmail() { + if (!is_email(this.orderUiState.inputEmail)) { + this.orderUiState.emailSuccessful = false; + this.orderUiState.emailNotice = this.env._t('Invalid email.'); + return; + } + try { + await this._sendReceiptToCustomer(); + this.orderUiState.emailSuccessful = true; + this.orderUiState.emailNotice = this.env._t('Email sent.'); + } catch (error) { + this.orderUiState.emailSuccessful = false; + this.orderUiState.emailNotice = this.env._t('Sending email failed. Please try again.'); + } + } + get orderAmountPlusTip() { + const order = this.currentOrder; + const orderTotalAmount = order.get_total_with_tax(); + const tip_product_id = this.env.pos.config.tip_product_id && this.env.pos.config.tip_product_id[0]; + const tipLine = order + .get_orderlines() + .find((line) => tip_product_id && line.product.id === tip_product_id); + const tipAmount = tipLine ? tipLine.get_all_prices().priceWithTax : 0; + const orderAmountStr = this.env.pos.format_currency(orderTotalAmount - tipAmount); + if (!tipAmount) return orderAmountStr; + const tipAmountStr = this.env.pos.format_currency(tipAmount); + return `${orderAmountStr} + ${tipAmountStr} tip`; + } + get currentOrder() { + return this.env.pos.get_order(); + } + get nextScreen() { + return { name: 'ProductScreen' }; + } + whenClosing() { + this.orderDone(); + } + /** + * This function is called outside the rendering call stack. This way, + * we don't block the displaying of ReceiptScreen when it is mounted; additionally, + * any error that can happen during the printing does not affect the rendering. + */ + async handleAutoPrint() { + if (this._shouldAutoPrint()) { + await this.printReceipt(); + if (this.currentOrder._printed && this._shouldCloseImmediately()) { + this.whenClosing(); + } + } + } + orderDone() { + this.currentOrder.finalize(); + const { name, props } = this.nextScreen; + this.showScreen(name, props); + } + async printReceipt() { + const isPrinted = await this._printReceipt(); + if (isPrinted) { + this.currentOrder._printed = true; + } + } + _shouldAutoPrint() { + return this.env.pos.config.iface_print_auto && !this.currentOrder._printed; + } + _shouldCloseImmediately() { + var invoiced_finalized = this.currentOrder.is_to_invoice() ? this.currentOrder.finalized : true; + return this.env.pos.proxy.printer && this.env.pos.config.iface_print_skip_screen && invoiced_finalized; + } + async _sendReceiptToCustomer() { + const printer = new Printer(); + const receiptString = this.orderReceipt.comp.el.outerHTML; + const ticketImage = await printer.htmlToImg(receiptString); + const order = this.currentOrder; + const client = order.get_client(); + const orderName = order.get_name(); + const orderClient = { email: this.orderUiState.inputEmail, name: client ? client.name : this.orderUiState.inputEmail }; + const order_server_id = this.env.pos.validated_orders_name_server_id_map[orderName]; + await this.rpc({ + model: 'pos.order', + method: 'action_receipt_to_customer', + args: [[order_server_id], orderName, orderClient, ticketImage], + }); + } + } + ReceiptScreen.template = 'ReceiptScreen'; + return ReceiptScreen; + }; + + Registries.Component.addByExtending(ReceiptScreen, AbstractReceiptScreen); + + return ReceiptScreen; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/WrappedProductNameLines.js b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/WrappedProductNameLines.js new file mode 100644 index 00000000..e7527eee --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/WrappedProductNameLines.js @@ -0,0 +1,18 @@ +odoo.define('point_of_sale.WrappedProductNameLines', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class WrappedProductNameLines extends PosComponent { + constructor() { + super(...arguments); + this.line = this.props.line; + } + } + WrappedProductNameLines.template = 'WrappedProductNameLines'; + + Registries.Component.add(WrappedProductNameLines); + + return WrappedProductNameLines; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ScaleScreen/ScaleScreen.js b/addons/point_of_sale/static/src/js/Screens/ScaleScreen/ScaleScreen.js new file mode 100644 index 00000000..f9b1ea97 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ScaleScreen/ScaleScreen.js @@ -0,0 +1,102 @@ +odoo.define('point_of_sale.ScaleScreen', function(require) { + 'use strict'; + + const { useState, useExternalListener } = owl.hooks; + const PosComponent = require('point_of_sale.PosComponent'); + const { round_precision: round_pr } = require('web.utils'); + const Registries = require('point_of_sale.Registries'); + + class ScaleScreen extends PosComponent { + /** + * @param {Object} props + * @param {Object} props.product The product to weight. + */ + constructor() { + super(...arguments); + useExternalListener(document, 'keyup', this._onHotkeys); + this.state = useState({ weight: 0 }); + } + mounted() { + // start the scale reading + this._readScale(); + } + willUnmount() { + // stop the scale reading + this.env.pos.proxy_queue.clear(); + } + back() { + this.props.resolve({ confirmed: false, payload: null }); + this.trigger('close-temp-screen'); + } + confirm() { + this.props.resolve({ + confirmed: true, + payload: { weight: this.state.weight }, + }); + this.trigger('close-temp-screen'); + } + _onHotkeys(event) { + if (event.key === 'Escape') { + this.back(); + } else if (event.key === 'Enter') { + this.confirm(); + } + } + _readScale() { + this.env.pos.proxy_queue.schedule(this._setWeight.bind(this), { + duration: 500, + repeat: true, + }); + } + async _setWeight() { + const reading = await this.env.pos.proxy.scale_read(); + this.state.weight = reading.weight; + } + get _activePricelist() { + const current_order = this.env.pos.get_order(); + let current_pricelist = this.env.pos.default_pricelist; + if (current_order) { + current_pricelist = current_order.pricelist; + } + return current_pricelist; + } + get productWeightString() { + const defaultstr = (this.state.weight || 0).toFixed(3) + ' Kg'; + if (!this.props.product || !this.env.pos) { + return defaultstr; + } + const unit_id = this.props.product.uom_id; + if (!unit_id) { + return defaultstr; + } + const unit = this.env.pos.units_by_id[unit_id[0]]; + const weight = round_pr(this.state.weight || 0, unit.rounding); + let weightstr = weight.toFixed(Math.ceil(Math.log(1.0 / unit.rounding) / Math.log(10))); + weightstr += ' ' + unit.name; + return weightstr; + } + get computedPriceString() { + return this.env.pos.format_currency(this.productPrice * this.state.weight); + } + get productPrice() { + const product = this.props.product; + return (product ? product.get_price(this._activePricelist, this.state.weight) : 0) || 0; + } + get productName() { + return ( + (this.props.product ? this.props.product.display_name : undefined) || + 'Unnamed Product' + ); + } + get productUom() { + return this.props.product + ? this.env.pos.units_by_id[this.props.product.uom_id[0]].name + : ''; + } + } + ScaleScreen.template = 'ScaleScreen'; + + Registries.Component.add(ScaleScreen); + + return ScaleScreen; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/TicketScreen/TicketScreen.js b/addons/point_of_sale/static/src/js/Screens/TicketScreen/TicketScreen.js new file mode 100644 index 00000000..f59b72d0 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/TicketScreen/TicketScreen.js @@ -0,0 +1,220 @@ +odoo.define('point_of_sale.TicketScreen', function (require) { + 'use strict'; + + const Registries = require('point_of_sale.Registries'); + const IndependentToOrderScreen = require('point_of_sale.IndependentToOrderScreen'); + const { useListener } = require('web.custom_hooks'); + const { posbus } = require('point_of_sale.utils'); + + class TicketScreen extends IndependentToOrderScreen { + constructor() { + super(...arguments); + useListener('close-screen', this.close); + useListener('filter-selected', this._onFilterSelected); + useListener('search', this._onSearch); + this.searchDetails = {}; + this.filter = null; + this._initializeSearchFieldConstants(); + } + mounted() { + posbus.on('ticket-button-clicked', this, this.close); + this.env.pos.get('orders').on('add remove change', () => this.render(), this); + this.env.pos.on('change:selectedOrder', () => this.render(), this); + } + willUnmount() { + posbus.off('ticket-button-clicked', this); + this.env.pos.get('orders').off('add remove change', null, this); + this.env.pos.off('change:selectedOrder', null, this); + } + _onFilterSelected(event) { + this.filter = event.detail.filter; + this.render(); + } + _onSearch(event) { + const searchDetails = event.detail; + Object.assign(this.searchDetails, searchDetails); + this.render(); + } + /** + * Override to conditionally show the new ticket button. + */ + get showNewTicketButton() { + return true; + } + get orderList() { + return this.env.pos.get_order_list(); + } + get filteredOrderList() { + const { AllTickets } = this.getOrderStates(); + const filterCheck = (order) => { + if (this.filter && this.filter !== AllTickets) { + const screen = order.get_screen_data(); + return this.filter === this.constants.screenToStatusMap[screen.name]; + } + return true; + }; + const { fieldValue, searchTerm } = this.searchDetails; + const fieldAccessor = this._searchFields[fieldValue]; + const searchCheck = (order) => { + if (!fieldAccessor) return true; + const fieldValue = fieldAccessor(order); + if (fieldValue === null) return true; + if (!searchTerm) return true; + return fieldValue && fieldValue.toString().toLowerCase().includes(searchTerm.toLowerCase()); + }; + const predicate = (order) => { + return filterCheck(order) && searchCheck(order); + }; + return this.orderList.filter(predicate); + } + selectOrder(order) { + this._setOrder(order); + if (order === this.env.pos.get_order()) { + this.close(); + } + } + _setOrder(order) { + this.env.pos.set_order(order); + } + createNewOrder() { + this.env.pos.add_new_order(); + } + async deleteOrder(order) { + const screen = order.get_screen_data(); + if (['ProductScreen', 'PaymentScreen'].includes(screen.name) && order.get_orderlines().length > 0) { + const { confirmed } = await this.showPopup('ConfirmPopup', { + title: 'Existing orderlines', + body: `${order.name} has total amount of ${this.getTotal( + order + )}, are you sure you want delete this order?`, + }); + if (!confirmed) return; + } + if (order) { + await this._canDeleteOrder(order); + order.destroy({ reason: 'abandon' }); + } + posbus.trigger('order-deleted'); + } + getDate(order) { + return moment(order.creation_date).format('YYYY-MM-DD hh:mm A'); + } + getTotal(order) { + return this.env.pos.format_currency(order.get_total_with_tax()); + } + getCustomer(order) { + return order.get_client_name(); + } + getCardholderName(order) { + return order.get_cardholder_name(); + } + getEmployee(order) { + return order.employee ? order.employee.name : ''; + } + getStatus(order) { + const screen = order.get_screen_data(); + return this.constants.screenToStatusMap[screen.name]; + } + /** + * Hide the delete button if one of the payments is a 'done' electronic payment. + */ + hideDeleteButton(order) { + return order + .get_paymentlines() + .some((payment) => payment.is_electronic() && payment.get_payment_status() === 'done'); + } + showCardholderName() { + return this.env.pos.payment_methods.some(method => method.use_payment_terminal); + } + get searchBarConfig() { + return { + searchFields: this.constants.searchFieldNames, + filter: { show: true, options: this.filterOptions }, + }; + } + get filterOptions() { + const { AllTickets, Ongoing, Payment, Receipt } = this.getOrderStates(); + return [AllTickets, Ongoing, Payment, Receipt]; + } + /** + * An object with keys containing the search field names which map to functions. + * The mapped functions will be used to generate representative string for the order + * to match the search term when searching. + * E.g. Given 2 orders, search those with `Receipt Number` containing `1111`. + * ``` + * orders = [{ + * name: '000-1111-222' + * total: 10, + * }, { + * name: '444-5555-666' + * total: 15, + * }] + * ``` + * `Receipt Number` search field maps to the `name` of the order. So, the orders will be + * represented by their name, and the search will result to: + * ``` + * result = [{ + * name: '000-1111-222', + * total: 10, + * }] + * ``` + * @returns Record<string, (models.Order) => string> + */ + get _searchFields() { + const { ReceiptNumber, Date, Customer, CardholderName } = this.getSearchFieldNames(); + var fields = { + [ReceiptNumber]: (order) => order.name, + [Date]: (order) => moment(order.creation_date).format('YYYY-MM-DD hh:mm A'), + [Customer]: (order) => order.get_client_name(), + }; + + if (this.showCardholderName()) { + fields[CardholderName] = (order) => order.get_cardholder_name(); + } + + return fields; + } + /** + * Maps the order screen params to order status. + */ + get _screenToStatusMap() { + const { Ongoing, Payment, Receipt } = this.getOrderStates(); + return { + ProductScreen: Ongoing, + PaymentScreen: Payment, + ReceiptScreen: Receipt, + }; + } + _initializeSearchFieldConstants() { + this.constants = {}; + Object.assign(this.constants, { + searchFieldNames: Object.keys(this._searchFields), + screenToStatusMap: this._screenToStatusMap, + }); + } + async _canDeleteOrder(order) { + return true; + } + getOrderStates() { + return { + AllTickets: this.env._t('All Tickets'), + Ongoing: this.env._t('Ongoing'), + Payment: this.env._t('Payment'), + Receipt: this.env._t('Receipt'), + }; + } + getSearchFieldNames() { + return { + ReceiptNumber: this.env._t('Receipt Number'), + Date: this.env._t('Date'), + Customer: this.env._t('Customer'), + CardholderName: this.env._t('Cardholder Name'), + }; + } + } + TicketScreen.template = 'TicketScreen'; + + Registries.Component.add(TicketScreen); + + return TicketScreen; +}); diff --git a/addons/point_of_sale/static/src/js/barcode_reader.js b/addons/point_of_sale/static/src/js/barcode_reader.js new file mode 100644 index 00000000..10f5b9a2 --- /dev/null +++ b/addons/point_of_sale/static/src/js/barcode_reader.js @@ -0,0 +1,158 @@ +odoo.define('point_of_sale.BarcodeReader', function (require) { +"use strict"; + +var core = require('web.core'); + +// this module interfaces with the barcode reader. It assumes the barcode reader +// is set-up to act like a keyboard. Use connect() and disconnect() to activate +// and deactivate the barcode reader. Use set_action_callbacks to tell it +// what to do when it reads a barcode. +var BarcodeReader = core.Class.extend({ + actions:[ + 'product', + 'cashier', + 'client', + ], + + init: function (attributes) { + this.pos = attributes.pos; + this.action_callbacks = {}; + this.exclusive_callbacks = {}; + this.proxy = attributes.proxy; + this.remote_scanning = false; + this.remote_active = 0; + + this.barcode_parser = attributes.barcode_parser; + + this.action_callback_stack = []; + + core.bus.on('barcode_scanned', this, function (barcode) { + this.scan(barcode); + }); + }, + + set_barcode_parser: function (barcode_parser) { + this.barcode_parser = barcode_parser; + }, + + // when a barcode is scanned and parsed, the callback corresponding + // to its type is called with the parsed_barcode as a parameter. + // (parsed_barcode is the result of parse_barcode(barcode)) + // + // callbacks is a Map of 'actions' : callback(parsed_barcode) + // that sets the callback for each action. if a callback for the + // specified action already exists, it is replaced. + // + // possible actions include : + // 'product' | 'cashier' | 'client' | 'discount' + set_action_callback: function (name, callback) { + if (this.action_callbacks[name]) { + this.action_callbacks[name].add(callback); + } else { + this.action_callbacks[name] = new Set([callback]); + } + }, + + remove_action_callback: function(name, callback) { + if (!callback) { + delete this.action_callbacks[name]; + return; + } + const callbacks = this.action_callbacks[name]; + if (callbacks) { + callbacks.delete(callback); + if (callbacks.size === 0) { + delete this.action_callbacks[name]; + } + } + }, + + /** + * Allow setting of exclusive callbacks. If there are exclusive callbacks, + * these callbacks are called neglecting the regular callbacks. This is + * useful for rendered Components that wants to take exclusive access + * to the barcode reader. + * + * @param {String} name + * @param {Function} callback function that takes parsed barcode + */ + set_exclusive_callback: function (name, callback) { + if (this.exclusive_callbacks[name]) { + this.exclusive_callbacks[name].add(callback); + } else { + this.exclusive_callbacks[name] = new Set([callback]); + } + }, + + remove_exclusive_callback: function (name, callback) { + if (!callback) { + delete this.exclusive_callbacks[name]; + return; + } + const callbacks = this.exclusive_callbacks[name]; + if (callbacks) { + callbacks.delete(callback); + if (callbacks.size === 0) { + delete this.exclusive_callbacks[name]; + } + } + }, + + scan: function (code) { + if (!code) return; + + const callbacks = Object.keys(this.exclusive_callbacks).length + ? this.exclusive_callbacks + : this.action_callbacks; + + const parsed_result = this.barcode_parser.parse_barcode(code); + if (callbacks[parsed_result.type]) { + [...callbacks[parsed_result.type]].map((cb) => cb(parsed_result)); + } else if (callbacks.error) { + [...callbacks.error].map((cb) => cb(parsed_result)); + } else { + console.warn('Ignored Barcode Scan:', parsed_result); + } + + }, + + // the barcode scanner will listen on the hw_proxy/scanner interface for + // scan events until disconnect_from_proxy is called + connect_to_proxy: function () { + var self = this; + this.remote_scanning = true; + if (this.remote_active >= 1) { + return; + } + this.remote_active = 1; + + function waitforbarcode(){ + return self.proxy.connection.rpc('/hw_proxy/scanner',{},{shadow: true, timeout:7500}) + .then(function (barcode) { + if (!self.remote_scanning) { + self.remote_active = 0; + return; + } + self.scan(barcode); + waitforbarcode(); + }, + function () { + if (!self.remote_scanning) { + self.remote_active = 0; + return; + } + waitforbarcode(); + }); + } + waitforbarcode(); + }, + + // the barcode scanner will stop listening on the hw_proxy/scanner remote interface + disconnect_from_proxy: function () { + this.remote_scanning = false; + }, +}); + +return BarcodeReader; + +}); diff --git a/addons/point_of_sale/static/src/js/custom_hooks.js b/addons/point_of_sale/static/src/js/custom_hooks.js new file mode 100644 index 00000000..c1e87b24 --- /dev/null +++ b/addons/point_of_sale/static/src/js/custom_hooks.js @@ -0,0 +1,149 @@ +odoo.define('point_of_sale.custom_hooks', function (require) { + 'use strict'; + + const { Component } = owl; + const { onMounted, onPatched, onWillUnmount } = owl.hooks; + + /** + * Introduce error handlers in the component. + * + * IMPROVEMENT: This is a terrible hook. There could be a better way to handle + * the error when the order failed to sync. + */ + function useErrorHandlers() { + const component = Component.current; + + component._handlePushOrderError = async function (error) { + // This error handler receives `error` equivalent to `error.message` of the rpc error. + if (error.message === 'Backend Invoice') { + await this.showPopup('ConfirmPopup', { + title: this.env._t('Please print the invoice from the backend'), + body: + this.env._t( + 'The order has been synchronized earlier. Please make the invoice from the backend for the order: ' + ) + error.data.order.name, + }); + } else if (error.code < 0) { + // XmlHttpRequest Errors + const title = this.env._t('Unable to sync order'); + const body = this.env._t( + 'Check the internet connection then try to sync again by clicking on the red wifi button (upper right of the screen).' + ); + await this.showPopup('OfflineErrorPopup', { title, body }); + } else if (error.code === 200) { + // OpenERP Server Errors + await this.showPopup('ErrorTracebackPopup', { + title: error.data.message || this.env._t('Server Error'), + body: + error.data.debug || + this.env._t('The server encountered an error while receiving your order.'), + }); + } else if (error.code === 700) { + // Fiscal module errors + await this.showPopup('ErrorPopup', { + title: this.env._t('Fiscal data module error'), + body: + error.data.error.status || + this.env._t('The fiscal data module encountered an error while receiving your order.'), + }); + } else { + // ??? + await this.showPopup('ErrorPopup', { + title: this.env._t('Unknown Error'), + body: this.env._t( + 'The order could not be sent to the server due to an unknown error' + ), + }); + } + }; + } + + function useAutoFocusToLast() { + const current = Component.current; + let target = null; + function autofocus() { + const prevTarget = target; + const allInputs = current.el.querySelectorAll('input'); + target = allInputs[allInputs.length - 1]; + if (target && target !== prevTarget) { + target.focus(); + target.selectionStart = target.selectionEnd = target.value.length; + } + } + onMounted(autofocus); + onPatched(autofocus); + } + + /** + * Use this hook when you want to do something on previously selected and + * newly selected order when the order changes. + * + * Normally, a component is rendered then the current order is changed. When + * this happens, we want to rerender the component because the new information + * should be reflected in the screen. Additionally, we might want to remove listeners + * to the previous order and attach listeners to the new one. This hook is + * perfect for the described situation. + * + * Internally, this hook performs the following: + * 1. call newOrderCB on mounted + * 2. listen to order changes and perform the following sequence: + * - call prevOrderCB(prevOrder) + * - call newOrderCB(newOrder) + * 3. call prevOrderCB on willUnmount + * + * @param {Function} prevOrderCB apply this callback on the previous order + * @param {Function} newOrderCB apply this callback on the new order + */ + function onChangeOrder(prevOrderCB, newOrderCB) { + const current = Component.current; + prevOrderCB = prevOrderCB ? prevOrderCB.bind(current) : () => {}; + newOrderCB = newOrderCB ? newOrderCB.bind(current) : () => {}; + onMounted(() => { + current.env.pos.on( + 'change:selectedOrder', + async (pos, newOrder) => { + await prevOrderCB(pos.previous('selectedOrder')); + await newOrderCB(newOrder); + }, + current + ); + newOrderCB(current.env.pos.get_order()); + }); + onWillUnmount(() => { + current.env.pos.off('change:selectedOrder', null, current); + prevOrderCB(current.env.pos.get_order()); + }); + } + + function useBarcodeReader(callbackMap, exclusive = false) { + const current = Component.current; + const barcodeReader = current.env.pos.barcode_reader; + for (let [key, callback] of Object.entries(callbackMap)) { + callbackMap[key] = callback.bind(current); + } + onMounted(() => { + if (barcodeReader) { + for (let key in callbackMap) { + if (exclusive) { + barcodeReader.set_exclusive_callback(key, callbackMap[key]); + } else { + barcodeReader.set_action_callback(key, callbackMap[key]); + } + } + } + }); + onWillUnmount(() => { + if (barcodeReader) { + for (let key in callbackMap) { + if (exclusive) { + barcodeReader.remove_exclusive_callback(key, callbackMap[key]); + } else { + barcodeReader.remove_action_callback(key, callbackMap[key]); + } + } + } + }); + } + + return { useErrorHandlers, useAutoFocusToLast, onChangeOrder, useBarcodeReader }; +}); diff --git a/addons/point_of_sale/static/src/js/db.js b/addons/point_of_sale/static/src/js/db.js new file mode 100644 index 00000000..ca0afd28 --- /dev/null +++ b/addons/point_of_sale/static/src/js/db.js @@ -0,0 +1,556 @@ +odoo.define('point_of_sale.DB', function (require) { +"use strict"; + +var core = require('web.core'); +var utils = require('web.utils'); +/* The PosDB holds reference to data that is either + * - static: does not change between pos reloads + * - persistent : must stay between reloads ( orders ) + */ + +var PosDB = core.Class.extend({ + name: 'openerp_pos_db', //the prefix of the localstorage data + limit: 100, // the maximum number of results returned by a search + init: function(options){ + options = options || {}; + this.name = options.name || this.name; + this.limit = options.limit || this.limit; + + if (options.uuid) { + this.name = this.name + '_' + options.uuid; + } + + //cache the data in memory to avoid roundtrips to the localstorage + this.cache = {}; + + this.product_by_id = {}; + this.product_by_barcode = {}; + this.product_by_category_id = {}; + + this.partner_sorted = []; + this.partner_by_id = {}; + this.partner_by_barcode = {}; + this.partner_search_string = ""; + this.partner_write_date = null; + + this.category_by_id = {}; + this.root_category_id = 0; + this.category_products = {}; + this.category_ancestors = {}; + this.category_childs = {}; + this.category_parent = {}; + this.category_search_string = {}; + }, + + /** + * sets an uuid to prevent conflict in locally stored data between multiple PoS Configs. By + * using the uuid of the config the local storage from other configs will not get effected nor + * loaded in sessions that don't belong to them. + * + * @param {string} uuid Unique identifier of the PoS Config linked to the current session. + */ + set_uuid: function(uuid){ + this.name = this.name + '_' + uuid; + }, + + /* returns the category object from its id. If you pass a list of id as parameters, you get + * a list of category objects. + */ + get_category_by_id: function(categ_id){ + if(categ_id instanceof Array){ + var list = []; + for(var i = 0, len = categ_id.length; i < len; i++){ + var cat = this.category_by_id[categ_id[i]]; + if(cat){ + list.push(cat); + }else{ + console.error("get_category_by_id: no category has id:",categ_id[i]); + } + } + return list; + }else{ + return this.category_by_id[categ_id]; + } + }, + /* returns a list of the category's child categories ids, or an empty list + * if a category has no childs */ + get_category_childs_ids: function(categ_id){ + return this.category_childs[categ_id] || []; + }, + /* returns a list of all ancestors (parent, grand-parent, etc) categories ids + * starting from the root category to the direct parent */ + get_category_ancestors_ids: function(categ_id){ + return this.category_ancestors[categ_id] || []; + }, + /* returns the parent category's id of a category, or the root_category_id if no parent. + * the root category is parent of itself. */ + get_category_parent_id: function(categ_id){ + return this.category_parent[categ_id] || this.root_category_id; + }, + /* adds categories definitions to the database. categories is a list of categories objects as + * returned by the openerp server. Categories must be inserted before the products or the + * product/ categories association may (will) not work properly */ + add_categories: function(categories){ + var self = this; + if(!this.category_by_id[this.root_category_id]){ + this.category_by_id[this.root_category_id] = { + id : this.root_category_id, + name : 'Root', + }; + } + categories.forEach(function(cat){ + self.category_by_id[cat.id] = cat; + }); + categories.forEach(function(cat){ + var parent_id = cat.parent_id[0]; + if(!(parent_id && self.category_by_id[parent_id])){ + parent_id = self.root_category_id; + } + self.category_parent[cat.id] = parent_id; + if(!self.category_childs[parent_id]){ + self.category_childs[parent_id] = []; + } + self.category_childs[parent_id].push(cat.id); + }); + function make_ancestors(cat_id, ancestors){ + self.category_ancestors[cat_id] = ancestors; + + ancestors = ancestors.slice(0); + ancestors.push(cat_id); + + var childs = self.category_childs[cat_id] || []; + for(var i=0, len = childs.length; i < len; i++){ + make_ancestors(childs[i], ancestors); + } + } + make_ancestors(this.root_category_id, []); + }, + category_contains: function(categ_id, product_id) { + var product = this.product_by_id[product_id]; + if (product) { + var cid = product.pos_categ_id[0]; + while (cid && cid !== categ_id){ + cid = this.category_parent[cid]; + } + return !!cid; + } + return false; + }, + /* loads a record store from the database. returns default if nothing is found */ + load: function(store,deft){ + if(this.cache[store] !== undefined){ + return this.cache[store]; + } + var data = localStorage[this.name + '_' + store]; + if(data !== undefined && data !== ""){ + data = JSON.parse(data); + this.cache[store] = data; + return data; + }else{ + return deft; + } + }, + /* saves a record store to the database */ + save: function(store,data){ + localStorage[this.name + '_' + store] = JSON.stringify(data); + this.cache[store] = data; + }, + _product_search_string: function(product){ + var str = product.display_name; + if (product.barcode) { + str += '|' + product.barcode; + } + if (product.default_code) { + str += '|' + product.default_code; + } + if (product.description) { + str += '|' + product.description; + } + if (product.description_sale) { + str += '|' + product.description_sale; + } + str = product.id + ':' + str.replace(/:/g,'') + '\n'; + return str; + }, + add_products: function(products){ + var stored_categories = this.product_by_category_id; + + if(!products instanceof Array){ + products = [products]; + } + for(var i = 0, len = products.length; i < len; i++){ + var product = products[i]; + if (product.id in this.product_by_id) continue; + if (product.available_in_pos){ + var search_string = utils.unaccent(this._product_search_string(product)); + var categ_id = product.pos_categ_id ? product.pos_categ_id[0] : this.root_category_id; + product.product_tmpl_id = product.product_tmpl_id[0]; + if(!stored_categories[categ_id]){ + stored_categories[categ_id] = []; + } + stored_categories[categ_id].push(product.id); + + if(this.category_search_string[categ_id] === undefined){ + this.category_search_string[categ_id] = ''; + } + this.category_search_string[categ_id] += search_string; + + var ancestors = this.get_category_ancestors_ids(categ_id) || []; + + for(var j = 0, jlen = ancestors.length; j < jlen; j++){ + var ancestor = ancestors[j]; + if(! stored_categories[ancestor]){ + stored_categories[ancestor] = []; + } + stored_categories[ancestor].push(product.id); + + if( this.category_search_string[ancestor] === undefined){ + this.category_search_string[ancestor] = ''; + } + this.category_search_string[ancestor] += search_string; + } + } + this.product_by_id[product.id] = product; + if(product.barcode){ + this.product_by_barcode[product.barcode] = product; + } + } + }, + _partner_search_string: function(partner){ + var str = partner.name || ''; + if(partner.barcode){ + str += '|' + partner.barcode; + } + if(partner.address){ + str += '|' + partner.address; + } + if(partner.phone){ + str += '|' + partner.phone.split(' ').join(''); + } + if(partner.mobile){ + str += '|' + partner.mobile.split(' ').join(''); + } + if(partner.email){ + str += '|' + partner.email; + } + if(partner.vat){ + str += '|' + partner.vat; + } + str = '' + partner.id + ':' + str.replace(':', '').replace(/\n/g, ' ') + '\n'; + return str; + }, + add_partners: function(partners){ + var updated_count = 0; + var new_write_date = ''; + var partner; + for(var i = 0, len = partners.length; i < len; i++){ + partner = partners[i]; + + var local_partner_date = (this.partner_write_date || '').replace(/^(\d{4}-\d{2}-\d{2}) ((\d{2}:?){3})$/, '$1T$2Z'); + var dist_partner_date = (partner.write_date || '').replace(/^(\d{4}-\d{2}-\d{2}) ((\d{2}:?){3})$/, '$1T$2Z'); + if ( this.partner_write_date && + this.partner_by_id[partner.id] && + new Date(local_partner_date).getTime() + 1000 >= + new Date(dist_partner_date).getTime() ) { + // FIXME: The write_date is stored with milisec precision in the database + // but the dates we get back are only precise to the second. This means when + // you read partners modified strictly after time X, you get back partners that were + // modified X - 1 sec ago. + continue; + } else if ( new_write_date < partner.write_date ) { + new_write_date = partner.write_date; + } + if (!this.partner_by_id[partner.id]) { + this.partner_sorted.push(partner.id); + } + this.partner_by_id[partner.id] = partner; + + updated_count += 1; + } + + this.partner_write_date = new_write_date || this.partner_write_date; + + if (updated_count) { + // If there were updates, we need to completely + // rebuild the search string and the barcode indexing + + this.partner_search_string = ""; + this.partner_by_barcode = {}; + + for (var id in this.partner_by_id) { + partner = this.partner_by_id[id]; + + if(partner.barcode){ + this.partner_by_barcode[partner.barcode] = partner; + } + partner.address = (partner.street ? partner.street + ', ': '') + + (partner.zip ? partner.zip + ', ': '') + + (partner.city ? partner.city + ', ': '') + + (partner.state_id ? partner.state_id[1] + ', ': '') + + (partner.country_id ? partner.country_id[1]: ''); + this.partner_search_string += this._partner_search_string(partner); + } + + this.partner_search_string = utils.unaccent(this.partner_search_string); + } + return updated_count; + }, + get_partner_write_date: function(){ + return this.partner_write_date || "1970-01-01 00:00:00"; + }, + get_partner_by_id: function(id){ + return this.partner_by_id[id]; + }, + get_partner_by_barcode: function(barcode){ + return this.partner_by_barcode[barcode]; + }, + get_partners_sorted: function(max_count){ + max_count = max_count ? Math.min(this.partner_sorted.length, max_count) : this.partner_sorted.length; + var partners = []; + for (var i = 0; i < max_count; i++) { + partners.push(this.partner_by_id[this.partner_sorted[i]]); + } + return partners; + }, + search_partner: function(query){ + try { + query = query.replace(/[\[\]\(\)\+\*\?\.\-\!\&\^\$\|\~\_\{\}\:\,\\\/]/g,'.'); + query = query.replace(/ /g,'.+'); + var re = RegExp("([0-9]+):.*?"+utils.unaccent(query),"gi"); + }catch(e){ + return []; + } + var results = []; + for(var i = 0; i < this.limit; i++){ + var r = re.exec(this.partner_search_string); + if(r){ + var id = Number(r[1]); + results.push(this.get_partner_by_id(id)); + }else{ + break; + } + } + return results; + }, + /* removes all the data from the database. TODO : being able to selectively remove data */ + clear: function(){ + for(var i = 0, len = arguments.length; i < len; i++){ + localStorage.removeItem(this.name + '_' + arguments[i]); + } + }, + /* this internal methods returns the count of properties in an object. */ + _count_props : function(obj){ + var count = 0; + for(var prop in obj){ + if(obj.hasOwnProperty(prop)){ + count++; + } + } + return count; + }, + get_product_by_id: function(id){ + return this.product_by_id[id]; + }, + get_product_by_barcode: function(barcode){ + if(this.product_by_barcode[barcode]){ + return this.product_by_barcode[barcode]; + } else { + return undefined; + } + }, + get_product_by_category: function(category_id){ + var product_ids = this.product_by_category_id[category_id]; + var list = []; + if (product_ids) { + for (var i = 0, len = Math.min(product_ids.length, this.limit); i < len; i++) { + list.push(this.product_by_id[product_ids[i]]); + } + } + return list; + }, + /* returns a list of products with : + * - a category that is or is a child of category_id, + * - a name, package or barcode containing the query (case insensitive) + */ + search_product_in_category: function(category_id, query){ + try { + query = query.replace(/[\[\]\(\)\+\*\?\.\-\!\&\^\$\|\~\_\{\}\:\,\\\/]/g,'.'); + query = query.replace(/ /g,'.+'); + var re = RegExp("([0-9]+):.*?"+utils.unaccent(query),"gi"); + }catch(e){ + return []; + } + var results = []; + for(var i = 0; i < this.limit; i++){ + var r = re.exec(this.category_search_string[category_id]); + if(r){ + var id = Number(r[1]); + results.push(this.get_product_by_id(id)); + }else{ + break; + } + } + return results; + }, + /* from a product id, and a list of category ids, returns + * true if the product belongs to one of the provided category + * or one of its child categories. + */ + is_product_in_category: function(category_ids, product_id) { + if (!(category_ids instanceof Array)) { + category_ids = [category_ids]; + } + var cat = this.get_product_by_id(product_id).pos_categ_id[0]; + while (cat) { + for (var i = 0; i < category_ids.length; i++) { + if (cat == category_ids[i]) { // The == is important, ids may be strings + return true; + } + } + cat = this.get_category_parent_id(cat); + } + return false; + }, + + /* paid orders */ + add_order: function(order){ + var order_id = order.uid; + var orders = this.load('orders',[]); + + // if the order was already stored, we overwrite its data + for(var i = 0, len = orders.length; i < len; i++){ + if(orders[i].id === order_id){ + orders[i].data = order; + this.save('orders',orders); + return order_id; + } + } + + // Only necessary when we store a new, validated order. Orders + // that where already stored should already have been removed. + this.remove_unpaid_order(order); + + orders.push({id: order_id, data: order}); + this.save('orders',orders); + return order_id; + }, + remove_order: function(order_id){ + var orders = this.load('orders',[]); + orders = _.filter(orders, function(order){ + return order.id !== order_id; + }); + this.save('orders',orders); + }, + remove_all_orders: function(){ + this.save('orders',[]); + }, + get_orders: function(){ + return this.load('orders',[]); + }, + get_order: function(order_id){ + var orders = this.get_orders(); + for(var i = 0, len = orders.length; i < len; i++){ + if(orders[i].id === order_id){ + return orders[i]; + } + } + return undefined; + }, + + /* working orders */ + save_unpaid_order: function(order){ + var order_id = order.uid; + var orders = this.load('unpaid_orders',[]); + var serialized = order.export_as_JSON(); + + for (var i = 0; i < orders.length; i++) { + if (orders[i].id === order_id){ + orders[i].data = serialized; + this.save('unpaid_orders',orders); + return order_id; + } + } + + orders.push({id: order_id, data: serialized}); + this.save('unpaid_orders',orders); + return order_id; + }, + remove_unpaid_order: function(order){ + var orders = this.load('unpaid_orders',[]); + orders = _.filter(orders, function(o){ + return o.id !== order.uid; + }); + this.save('unpaid_orders',orders); + }, + remove_all_unpaid_orders: function(){ + this.save('unpaid_orders',[]); + }, + get_unpaid_orders: function(){ + var saved = this.load('unpaid_orders',[]); + var orders = []; + for (var i = 0; i < saved.length; i++) { + orders.push(saved[i].data); + } + return orders; + }, + /** + * Return the orders with requested ids if they are unpaid. + * @param {array<number>} ids order_ids. + * @return {array<object>} list of orders. + */ + get_unpaid_orders_to_sync: function(ids){ + var saved = this.load('unpaid_orders',[]); + var orders = []; + saved.forEach(function(o) { + if (ids.includes(o.id) && (o.data.server_id || o.data.lines.length)){ + orders.push(o); + } + }); + return orders; + }, + /** + * Add a given order to the orders to be removed from the server. + * + * If an order is removed from a table it also has to be removed from the server to prevent it from reapearing + * after syncing. This function will add the server_id of the order to a list of orders still to be removed. + * @param {object} order object. + */ + set_order_to_remove_from_server: function(order){ + if (order.server_id !== undefined) { + var to_remove = this.load('unpaid_orders_to_remove',[]); + to_remove.push(order.server_id); + this.save('unpaid_orders_to_remove', to_remove); + } + }, + /** + * Get a list of server_ids of orders to be removed. + * @return {array<number>} list of server_ids. + */ + get_ids_to_remove_from_server: function(){ + return this.load('unpaid_orders_to_remove',[]); + }, + /** + * Remove server_ids from the list of orders to be removed. + * @param {array<number>} ids + */ + set_ids_removed_from_server: function(ids){ + var to_remove = this.load('unpaid_orders_to_remove',[]); + + to_remove = _.filter(to_remove, function(id){ + return !ids.includes(id); + }); + this.save('unpaid_orders_to_remove', to_remove); + }, + set_cashier: function(cashier) { + // Always update if the user is the same as before + this.save('cashier', cashier || null); + }, + get_cashier: function() { + return this.load('cashier'); + } +}); + +return PosDB; + +}); + diff --git a/addons/point_of_sale/static/src/js/debug_manager.js b/addons/point_of_sale/static/src/js/debug_manager.js new file mode 100644 index 00000000..bcc9f608 --- /dev/null +++ b/addons/point_of_sale/static/src/js/debug_manager.js @@ -0,0 +1,20 @@ +odoo.define('point_of_sale.DebugManager.Backend', function(require) { + 'use strict'; + + const { _t } = require('web.core'); + const DebugManager = require('web.DebugManager.Backend'); + + DebugManager.include({ + /** + * Runs the JS (desktop) tests + */ + perform_pos_js_tests() { + this.do_action({ + name: _t('JS Tests'), + target: 'new', + type: 'ir.actions.act_url', + url: '/pos/ui/tests?mod=*', + }); + }, + }); +}); diff --git a/addons/point_of_sale/static/src/js/devices.js b/addons/point_of_sale/static/src/js/devices.js new file mode 100644 index 00000000..a4a80a9c --- /dev/null +++ b/addons/point_of_sale/static/src/js/devices.js @@ -0,0 +1,492 @@ +odoo.define('point_of_sale.devices', function (require) { +"use strict"; + +var core = require('web.core'); +var mixins = require('web.mixins'); +var Session = require('web.Session'); +var Printer = require('point_of_sale.Printer').Printer; + +// the JobQueue schedules a sequence of 'jobs'. each job is +// a function returning a promise. The queue waits for each job to finish +// before launching the next. Each job can also be scheduled with a delay. +// the is used to prevent parallel requests to the proxy. + +var JobQueue = function(){ + var queue = []; + var running = false; + var scheduled_end_time = 0; + var end_of_queue = Promise.resolve(); + var stoprepeat = false; + + var run = function () { + var runNextJob = function () { + if (queue.length === 0) { + running = false; + scheduled_end_time = 0; + return Promise.resolve(); + } + running = true; + var job = queue[0]; + if (!job.opts.repeat || stoprepeat) { + queue.shift(); + stoprepeat = false; + } + + // the time scheduled for this job + scheduled_end_time = (new Date()).getTime() + (job.opts.duration || 0); + + // we run the job and put in prom when it finishes + var prom = job.fun() || Promise.resolve(); + + var always = function () { + // we run the next job after the scheduled_end_time, even if it finishes before + return new Promise(function (resolve, reject) { + setTimeout( + resolve, + Math.max(0, scheduled_end_time - (new Date()).getTime()) + ); + }); + }; + // we don't care if a job fails ... + return prom.then(always, always).then(runNextJob); + }; + + if (!running) { + end_of_queue = runNextJob(); + } + }; + + /** + * Adds a job to the schedule. + * + * @param {function} fun must return a promise + * @param {object} [opts] + * @param {number} [opts.duration] the job is guaranteed to finish no quicker than this (milisec) + * @param {boolean} [opts.repeat] if true, the job will be endlessly repeated + * @param {boolean} [opts.important] if true, the scheduled job cannot be canceled by a queue.clear() + */ + this.schedule = function (fun, opts) { + queue.push({fun:fun, opts:opts || {}}); + if(!running){ + run(); + } + }; + + // remove all jobs from the schedule (except the ones marked as important) + this.clear = function(){ + queue = _.filter(queue,function(job){return job.opts.important === true;}); + }; + + // end the repetition of the current job + this.stoprepeat = function(){ + stoprepeat = true; + }; + + /** + * Returns a promise that resolves when all scheduled jobs have been run. + * (jobs added after the call to this method are considered as well) + * + * @returns {Promise} + */ + this.finished = function () { + return end_of_queue; + }; + +}; + + +// this object interfaces with the local proxy to communicate to the various hardware devices +// connected to the Point of Sale. As the communication only goes from the POS to the proxy, +// methods are used both to signal an event, and to fetch information. + +var ProxyDevice = core.Class.extend(mixins.PropertiesMixin,{ + init: function(parent,options){ + mixins.PropertiesMixin.init.call(this); + var self = this; + this.setParent(parent); + options = options || {}; + + this.pos = parent; + + this.weighing = false; + this.debug_weight = 0; + this.use_debug_weight = false; + + this.paying = false; + this.default_payment_status = { + status: 'waiting', + message: '', + payment_method: undefined, + receipt_client: undefined, + receipt_shop: undefined, + }; + this.custom_payment_status = this.default_payment_status; + + this.notifications = {}; + this.bypass_proxy = false; + + this.connection = null; + this.host = ''; + this.keptalive = false; + + this.set('status',{}); + + this.set_connection_status('disconnected'); + + this.on('change:status',this,function(eh,status){ + status = status.newValue; + if(status.status === 'connected' && self.printer) { + self.printer.print_receipt(); + } + }); + + this.posbox_supports_display = true; + + window.hw_proxy = this; + }, + set_connection_status: function(status, drivers, msg=''){ + var oldstatus = this.get('status'); + var newstatus = {}; + newstatus.status = status; + newstatus.drivers = status === 'disconnected' ? {} : oldstatus.drivers; + newstatus.drivers = drivers ? drivers : newstatus.drivers; + newstatus.msg = msg; + this.set('status',newstatus); + }, + disconnect: function(){ + if(this.get('status').status !== 'disconnected'){ + this.connection.destroy(); + this.set_connection_status('disconnected'); + } + }, + + /** + * Connects to the specified url. + * + * @param {string} url + * @returns {Promise} + */ + connect: function(url){ + var self = this; + this.connection = new Session(undefined,url, { use_cors: true}); + this.host = url; + if (this.pos.config.iface_print_via_proxy) { + this.connect_to_printer(); + } + this.set_connection_status('connecting',{}); + + return this.message('handshake').then(function(response){ + if(response){ + self.set_connection_status('connected'); + localStorage.hw_proxy_url = url; + self.keepalive(); + }else{ + self.set_connection_status('disconnected'); + console.error('Connection refused by the Proxy'); + } + },function(){ + self.set_connection_status('disconnected'); + console.error('Could not connect to the Proxy'); + }); + }, + + connect_to_printer: function () { + this.printer = new Printer(this.host, this.pos); + }, + + /** + * Find a proxy and connects to it. + * + * @param {Object} [options] + * @param {string} [options.force_ip] only try to connect to the specified ip. + * @param {string} [options.port] @see find_proxy + * @param {function} [options.progress] @see find_proxy + * @returns {Promise} + */ + autoconnect: function (options) { + var self = this; + this.set_connection_status('connecting',{}); + if (this.pos.config.iface_print_via_proxy) { + this.connect_to_printer(); + } + var found_url = new Promise(function () {}); + + if (options.force_ip) { + // if the ip is forced by server config, bailout on fail + found_url = this.try_hard_to_connect(options.force_ip, options); + } else if (localStorage.hw_proxy_url) { + // try harder when we remember a good proxy url + found_url = this.try_hard_to_connect(localStorage.hw_proxy_url, options) + .catch(function () { + if (window.location.protocol != 'https:') { + return self.find_proxy(options); + } + }); + } else { + // just find something quick + if (window.location.protocol != 'https:'){ + found_url = this.find_proxy(options); + } + } + + var successProm = found_url.then(function (url) { + return self.connect(url); + }); + + successProm.catch(function () { + self.set_connection_status('disconnected'); + }); + + return successProm; + }, + + // starts a loop that updates the connection status + keepalive: function () { + var self = this; + + function status(){ + var always = function () { + setTimeout(status, 5000); + }; + self.connection.rpc('/hw_proxy/status_json',{},{shadow: true, timeout:2500}) + .then(function (driver_status) { + self.set_connection_status('connected',driver_status); + }, function () { + if(self.get('status').status !== 'connecting'){ + self.set_connection_status('disconnected'); + } + }).then(always, always); + } + + if (!this.keptalive) { + this.keptalive = true; + status(); + } + }, + + /** + * @param {string} name + * @param {Object} [params] + * @returns {Promise} + */ + message : function (name, params) { + var callbacks = this.notifications[name] || []; + for (var i = 0; i < callbacks.length; i++) { + callbacks[i](params); + } + if (this.get('status').status !== 'disconnected') { + return this.connection.rpc('/hw_proxy/' + name, params || {}, {shadow: true}); + } else { + return Promise.reject(); + } + }, + + /** + * Tries several time to connect to a known proxy url. + * + * @param {*} url + * @param {Object} [options] + * @param {string} [options.port=8069] what port to listen to + * @returns {Promise<string|Array>} + */ + try_hard_to_connect: function (url, options) { + options = options || {}; + var protocol = window.location.protocol; + var port = ( !options.port && protocol == "https:") ? ':443' : ':' + (options.port || '8069'); + + this.set_connection_status('connecting'); + + if(url.indexOf('//') < 0){ + url = protocol + '//' + url; + } + + if(url.indexOf(':',5) < 0){ + url = url + port; + } + + // try real hard to connect to url, with a 1sec timeout and up to 'retries' retries + function try_real_hard_to_connect(url, retries) { + return Promise.resolve( + $.ajax({ + url: url + '/hw_proxy/hello', + method: 'GET', + timeout: 1000, + }) + .then(function () { + return Promise.resolve(url); + }, function (resp) { + if (retries > 0) { + return try_real_hard_to_connect(url, retries-1); + } else { + return Promise.reject([resp.statusText, url]); + } + }) + ); + } + + return try_real_hard_to_connect(url, 3); + }, + + /** + * Returns as a promise a valid host url that can be used as proxy. + * + * @param {Object} [options] + * @param {string} [options.port] what port to listen to (default 8069) + * @param {function} [options.progress] callback for search progress ( fac in [0,1] ) + * @returns {Promise<string>} will be resolved with the proxy valid url + */ + find_proxy: function(options){ + options = options || {}; + var self = this; + var port = ':' + (options.port || '8069'); + var urls = []; + var found = false; + var parallel = 8; + var threads = []; + var progress = 0; + + + urls.push('http://localhost'+port); + for(var i = 0; i < 256; i++){ + urls.push('http://192.168.0.'+i+port); + urls.push('http://192.168.1.'+i+port); + urls.push('http://10.0.0.'+i+port); + } + + var prog_inc = 1/urls.length; + + function update_progress(){ + progress = found ? 1 : progress + prog_inc; + if(options.progress){ + options.progress(progress); + } + } + + function thread () { + var url = urls.shift(); + + if (!url || found || !self.searching_for_proxy) { + return Promise.resolve(); + } + + return Promise.resolve( + $.ajax({ + url: url + '/hw_proxy/hello', + method: 'GET', + timeout: 400, + }).then(function () { + found = true; + update_progress(); + return Promise.resolve(url); + }, function () { + update_progress(); + return thread(); + }) + ); + } + + this.searching_for_proxy = true; + + var len = Math.min(parallel, urls.length); + for(i = 0; i < len; i++){ + threads.push(thread()); + } + + return new Promise(function (resolve, reject) { + Promise.all(threads).then(function(results){ + var urls = []; + for(var i = 0; i < results.length; i++){ + if(results[i]){ + urls.push(results[i]); + } + } + resolve(urls[0]); + }); + }); + }, + + stop_searching: function(){ + this.searching_for_proxy = false; + this.set_connection_status('disconnected'); + }, + + // this allows the client to be notified when a proxy call is made. The notification + // callback will be executed with the same arguments as the proxy call + add_notification: function(name, callback){ + if(!this.notifications[name]){ + this.notifications[name] = []; + } + this.notifications[name].push(callback); + }, + + /** + * Returns the weight on the scale. + * + * @returns {Promise<Object>} + */ + scale_read: function () { + var self = this; + if (self.use_debug_weight) { + return Promise.resolve({weight:this.debug_weight, unit:'Kg', info:'ok'}); + } + return new Promise(function (resolve, reject) { + self.message('scale_read',{}) + .then(function (weight) { + resolve(weight); + }, function () { //failed to read weight + resolve({weight:0.0, unit:'Kg', info:'ok'}); + }); + }); + }, + + // sets a custom weight, ignoring the proxy returned value. + debug_set_weight: function(kg){ + this.use_debug_weight = true; + this.debug_weight = kg; + }, + + // resets the custom weight and re-enable listening to the proxy for weight values + debug_reset_weight: function(){ + this.use_debug_weight = false; + this.debug_weight = 0; + }, + + update_customer_facing_display: function(html) { + if (this.posbox_supports_display) { + return this.message('customer_facing_display', + { html: html }, + { timeout: 5000 }); + } + }, + + /** + * @param {string} html + * @returns {Promise} + */ + take_ownership_over_client_screen: function(html) { + return this.message("take_control", { html: html }); + }, + + /** + * @returns {Promise} + */ + test_ownership_of_client_screen: function() { + if (this.connection) { + return this.message("test_ownership", {}); + } + return Promise.reject({abort: true}); + }, + + // asks the proxy to log some information, as with the debug.log you can provide several arguments. + log: function(){ + return this.message('log',{'arguments': _.toArray(arguments)}); + }, + +}); + +return { + JobQueue: JobQueue, + ProxyDevice: ProxyDevice, +}; + +}); diff --git a/addons/point_of_sale/static/src/js/keyboard.js b/addons/point_of_sale/static/src/js/keyboard.js new file mode 100644 index 00000000..a9ea1f55 --- /dev/null +++ b/addons/point_of_sale/static/src/js/keyboard.js @@ -0,0 +1,207 @@ +odoo.define('point_of_sale.keyboard', function (require) { +"use strict"; + +var Widget = require('web.Widget'); + +// ---------- OnScreen Keyboard Widget ---------- +// A Widget that displays an onscreen keyboard. +// There are two options when creating the widget : +// +// * 'keyboard_model' : 'simple' (default) | 'full' +// The 'full' emulates a PC keyboard, while 'simple' emulates an 'android' one. +// +// * 'input_selector : (default: '.searchbox input') +// defines the dom element that the keyboard will write to. +// +// The widget is initially hidden. It can be shown with this.show(), and is +// automatically shown when the input_selector gets focused. + +var OnscreenKeyboardWidget = Widget.extend({ + template: 'OnscreenKeyboardSimple', + init: function(parent, options){ + this._super(parent,options); + options = options || {}; + + this.keyboard_model = options.keyboard_model || 'simple'; + if(this.keyboard_model === 'full'){ + this.template = 'OnscreenKeyboardFull'; + } + + this.input_selector = options.input_selector || '.searchbox input'; + this.$target = null; + + //Keyboard state + this.capslock = false; + this.shift = false; + this.numlock = false; + }, + + connect : function(target){ + var self = this; + this.$target = $(target); + this.$target.focus(function(){self.show();}); + }, + generateEvent: function(type,key){ + var event = document.createEvent("KeyboardEvent"); + var initMethod = event.initKeyboardEvent ? 'initKeyboardEvent' : 'initKeyEvent'; + event[initMethod]( type, + true, //bubbles + true, //cancelable + window, //viewArg + false, //ctrl + false, //alt + false, //shift + false, //meta + ((typeof key.code === 'undefined') ? key.char.charCodeAt(0) : key.code), + ((typeof key.char === 'undefined') ? String.fromCharCode(key.code) : key.char) + ); + return event; + + }, + + // Write a character to the input zone + writeCharacter: function(character){ + var input = this.$target[0]; + input.dispatchEvent(this.generateEvent('keypress',{char: character})); + if(character !== '\n'){ + input.value += character; + } + input.dispatchEvent(this.generateEvent('keyup',{char: character})); + }, + + // Removes the last character from the input zone. + deleteCharacter: function(){ + var input = this.$target[0]; + input.dispatchEvent(this.generateEvent('keypress',{code: 8})); + input.value = input.value.substr(0, input.value.length -1); + input.dispatchEvent(this.generateEvent('keyup',{code: 8})); + }, + + // Clears the content of the input zone. + deleteAllCharacters: function(){ + var input = this.$target[0]; + if(input.value){ + input.dispatchEvent(this.generateEvent('keypress',{code: 8})); + input.value = ""; + input.dispatchEvent(this.generateEvent('keyup',{code: 8})); + } + }, + + // Makes the keyboard show and slide from the bottom of the screen. + show: function(){ + $('.keyboard_frame').show().css({'height':'235px'}); + }, + + // Makes the keyboard hide by sliding to the bottom of the screen. + hide: function(){ + $('.keyboard_frame') + .css({'height':'0'}) + .hide(); + this.reset(); + }, + + //What happens when the shift key is pressed : toggle case, remove capslock + toggleShift: function(){ + $('.letter').toggleClass('uppercase'); + $('.symbol span').toggle(); + + this.shift = (this.shift === true) ? false : true; + this.capslock = false; + }, + + //what happens when capslock is pressed : toggle case, set capslock + toggleCapsLock: function(){ + $('.letter').toggleClass('uppercase'); + this.capslock = true; + }, + + //What happens when numlock is pressed : toggle symbols and numlock label + toggleNumLock: function(){ + $('.symbol span').toggle(); + $('.numlock span').toggle(); + this.numlock = (this.numlock === true ) ? false : true; + }, + + //After a key is pressed, shift is disabled. + removeShift: function(){ + if (this.shift === true) { + $('.symbol span').toggle(); + if (this.capslock === false) $('.letter').toggleClass('uppercase'); + + this.shift = false; + } + }, + + // Resets the keyboard to its original state; capslock: false, shift: false, numlock: false + reset: function(){ + if(this.shift){ + this.toggleShift(); + } + if(this.capslock){ + this.toggleCapsLock(); + } + if(this.numlock){ + this.toggleNumLock(); + } + }, + + //called after the keyboard is in the DOM, sets up the key bindings. + start: function(){ + var self = this; + + //this.show(); + + + $('.close_button').click(function(){ + self.deleteAllCharacters(); + self.hide(); + }); + + // Keyboard key click handling + $('.keyboard li').click(function(){ + + var $this = $(this), + character = $this.html(); // If it's a lowercase letter, nothing happens to this variable + + if ($this.hasClass('left-shift') || $this.hasClass('right-shift')) { + self.toggleShift(); + return false; + } + + if ($this.hasClass('capslock')) { + self.toggleCapsLock(); + return false; + } + + if ($this.hasClass('delete')) { + self.deleteCharacter(); + return false; + } + + if ($this.hasClass('numlock')){ + self.toggleNumLock(); + return false; + } + + // Special characters + if ($this.hasClass('symbol')) character = $('span:visible', $this).html(); + if ($this.hasClass('space')) character = ' '; + if ($this.hasClass('tab')) character = "\t"; + if ($this.hasClass('return')) character = "\n"; + + // Uppercase letter + if ($this.hasClass('uppercase')) character = character.toUpperCase(); + + // Remove shift once a key is clicked. + self.removeShift(); + + self.writeCharacter(character); + }); + }, +}); + +return { + OnscreenKeyboardWidget: OnscreenKeyboardWidget, +}; + +}); diff --git a/addons/point_of_sale/static/src/js/main.js b/addons/point_of_sale/static/src/js/main.js new file mode 100644 index 00000000..346a6167 --- /dev/null +++ b/addons/point_of_sale/static/src/js/main.js @@ -0,0 +1,49 @@ +odoo.define('web.web_client', function (require) { + 'use strict'; + + const AbstractService = require('web.AbstractService'); + const env = require('web.env'); + const WebClient = require('web.AbstractWebClient'); + const Chrome = require('point_of_sale.Chrome'); + const Registries = require('point_of_sale.Registries'); + const { configureGui } = require('point_of_sale.Gui'); + + owl.config.mode = env.isDebug() ? 'dev' : 'prod'; + owl.Component.env = env; + + Registries.Component.add(owl.misc.Portal); + + function setupResponsivePlugin(env) { + const isMobile = () => window.innerWidth <= 768; + env.isMobile = isMobile(); + const updateEnv = owl.utils.debounce(() => { + if (env.isMobile !== isMobile()) { + env.isMobile = !env.isMobile; + env.qweb.forceUpdate(); + } + }, 15); + window.addEventListener("resize", updateEnv); + } + + setupResponsivePlugin(owl.Component.env); + + async function startPosApp(webClient) { + Registries.Component.freeze(); + await env.session.is_bound; + env.qweb.addTemplates(env.session.owlTemplates); + env.bus = new owl.core.EventBus(); + await owl.utils.whenReady(); + await webClient.setElement(document.body); + await webClient.start(); + webClient.isStarted = true; + const chrome = new (Registries.Component.get(Chrome))(null, { webClient }); + await chrome.mount(document.querySelector('.o_action_manager')); + await chrome.start(); + configureGui({ component: chrome }); + } + + AbstractService.prototype.deployServices(env); + const webClient = new WebClient(); + startPosApp(webClient); + return webClient; +}); diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js new file mode 100644 index 00000000..100596d5 --- /dev/null +++ b/addons/point_of_sale/static/src/js/models.js @@ -0,0 +1,3514 @@ +odoo.define('point_of_sale.models', function (require) { +"use strict"; + +const { Context } = owl; +var BarcodeParser = require('barcodes.BarcodeParser'); +var BarcodeReader = require('point_of_sale.BarcodeReader'); +var PosDB = require('point_of_sale.DB'); +var devices = require('point_of_sale.devices'); +var concurrency = require('web.concurrency'); +var config = require('web.config'); +var core = require('web.core'); +var field_utils = require('web.field_utils'); +var time = require('web.time'); +var utils = require('web.utils'); + +var QWeb = core.qweb; +var _t = core._t; +var Mutex = concurrency.Mutex; +var round_di = utils.round_decimals; +var round_pr = utils.round_precision; + +var exports = {}; + +// The PosModel contains the Point Of Sale's representation of the backend. +// Since the PoS must work in standalone ( Without connection to the server ) +// it must contains a representation of the server's PoS backend. +// (taxes, product list, configuration options, etc.) this representation +// is fetched and stored by the PosModel at the initialisation. +// this is done asynchronously, a ready deferred alows the GUI to wait interactively +// for the loading to be completed +// There is a single instance of the PosModel for each Front-End instance, it is usually called +// 'pos' and is available to all widgets extending PosWidget. + +exports.PosModel = Backbone.Model.extend({ + initialize: function(attributes) { + Backbone.Model.prototype.initialize.call(this, attributes); + var self = this; + this.flush_mutex = new Mutex(); // used to make sure the orders are sent to the server once at time + + this.env = this.get('env'); + this.rpc = this.get('rpc'); + this.session = this.get('session'); + this.do_action = this.get('do_action'); + this.setLoadingMessage = this.get('setLoadingMessage'); + this.setLoadingProgress = this.get('setLoadingProgress'); + this.showLoadingSkip = this.get('showLoadingSkip'); + + this.proxy = new devices.ProxyDevice(this); // used to communicate to the hardware devices via a local proxy + this.barcode_reader = new BarcodeReader({'pos': this, proxy:this.proxy}); + + this.proxy_queue = new devices.JobQueue(); // used to prevent parallels communications to the proxy + this.db = new PosDB(); // a local database used to search trough products and categories & store pending orders + this.debug = config.isDebug(); //debug mode + + // Business data; loaded from the server at launch + this.company_logo = null; + this.company_logo_base64 = ''; + this.currency = null; + this.company = null; + this.user = null; + this.users = []; + this.employee = {name: null, id: null, barcode: null, user_id:null, pin:null}; + this.employees = []; + this.partners = []; + this.taxes = []; + this.pos_session = null; + this.config = null; + this.units = []; + this.units_by_id = {}; + this.uom_unit_id = null; + this.default_pricelist = null; + this.order_sequence = 1; + window.posmodel = this; + + // Object mapping the order's name (which contains the uid) to it's server_id after + // validation (order paid then sent to the backend). + this.validated_orders_name_server_id_map = {}; + + // Extract the config id from the url. + var given_config = new RegExp('[\?&]config_id=([^&#]*)').exec(window.location.href); + this.config_id = given_config && given_config[1] && parseInt(given_config[1]) || false; + + // these dynamic attributes can be watched for change by other models or widgets + this.set({ + 'synch': { status:'connected', pending:0 }, + 'orders': new OrderCollection(), + 'selectedOrder': null, + 'selectedClient': null, + 'cashier': null, + 'selectedCategoryId': null, + }); + + this.get('orders').on('remove', function(order,_unused_,options){ + self.on_removed_order(order,options.index,options.reason); + }); + + // Forward the 'client' attribute on the selected order to 'selectedClient' + function update_client() { + var order = self.get_order(); + this.set('selectedClient', order ? order.get_client() : null ); + } + this.get('orders').on('add remove change', update_client, this); + this.on('change:selectedOrder', update_client, this); + + // We fetch the backend data on the server asynchronously. this is done only when the pos user interface is launched, + // Any change on this data made on the server is thus not reflected on the point of sale until it is relaunched. + // when all the data has loaded, we compute some stuff, and declare the Pos ready to be used. + this.ready = this.load_server_data().then(function(){ + return self.after_load_server_data(); + }); + }, + after_load_server_data: function(){ + this.load_orders(); + this.set_start_order(); + if(this.config.use_proxy){ + if (this.config.iface_customer_facing_display) { + this.on('change:selectedOrder', this.send_current_order_to_customer_facing_display, this); + } + + return this.connect_to_proxy(); + } + return Promise.resolve(); + }, + // releases ressources holds by the model at the end of life of the posmodel + destroy: function(){ + // FIXME, should wait for flushing, return a deferred to indicate successfull destruction + // this.flush(); + this.proxy.disconnect(); + this.barcode_reader.disconnect_from_proxy(); + }, + + connect_to_proxy: function () { + var self = this; + return new Promise(function (resolve, reject) { + self.barcode_reader.disconnect_from_proxy(); + self.setLoadingMessage(_t('Connecting to the IoT Box'), 0); + self.showLoadingSkip(function () { + self.proxy.stop_searching(); + }); + self.proxy.autoconnect({ + force_ip: self.config.proxy_ip || undefined, + progress: function(prog){ + self.setLoadingProgress(prog); + }, + }).then( + function () { + if (self.config.iface_scan_via_proxy) { + self.barcode_reader.connect_to_proxy(); + } + resolve(); + }, + function (statusText, url) { + // this should reject so that it can be captured when we wait for pos.ready + // in the chrome component. + // then, if it got really rejected, we can show the error. + if (statusText == 'error' && window.location.protocol == 'https:') { + reject({ + title: _t('HTTPS connection to IoT Box failed'), + body: _.str.sprintf( + _t('Make sure you are using IoT Box v18.12 or higher. Navigate to %s to accept the certificate of your IoT Box.'), + url + ), + popup: 'alert', + }); + } else { + resolve(); + } + } + ); + }); + }, + + // Server side model loaders. This is the list of the models that need to be loaded from + // the server. The models are loaded one by one by this list's order. The 'loaded' callback + // is used to store the data in the appropriate place once it has been loaded. This callback + // can return a promise that will pause the loading of the next module. + // a shared temporary dictionary is available for loaders to communicate private variables + // used during loading such as object ids, etc. + models: [ + { + label: 'version', + loaded: function (self) { + return self.session.rpc('/web/webclient/version_info',{}).then(function (version) { + self.version = version; + }); + }, + + },{ + model: 'res.company', + fields: [ 'currency_id', 'email', 'website', 'company_registry', 'vat', 'name', 'phone', 'partner_id' , 'country_id', 'state_id', 'tax_calculation_rounding_method'], + ids: function(self){ return [self.session.user_context.allowed_company_ids[0]]; }, + loaded: function(self,companies){ self.company = companies[0]; }, + },{ + model: 'decimal.precision', + fields: ['name','digits'], + loaded: function(self,dps){ + self.dp = {}; + for (var i = 0; i < dps.length; i++) { + self.dp[dps[i].name] = dps[i].digits; + } + }, + },{ + model: 'uom.uom', + fields: [], + domain: null, + context: function(self){ return { active_test: false }; }, + loaded: function(self,units){ + self.units = units; + _.each(units, function(unit){ + self.units_by_id[unit.id] = unit; + }); + } + },{ + model: 'ir.model.data', + fields: ['res_id'], + domain: function(){ return [['name', '=', 'product_uom_unit']]; }, + loaded: function(self,unit){ + self.uom_unit_id = unit[0].res_id; + } + },{ + model: 'res.partner', + label: 'load_partners', + fields: ['name','street','city','state_id','country_id','vat','lang', + 'phone','zip','mobile','email','barcode','write_date', + 'property_account_position_id','property_product_pricelist'], + loaded: function(self,partners){ + self.partners = partners; + self.db.add_partners(partners); + }, + },{ + model: 'res.country.state', + fields: ['name', 'country_id'], + loaded: function(self,states){ + self.states = states; + }, + },{ + model: 'res.country', + fields: ['name', 'vat_label', 'code'], + loaded: function(self,countries){ + self.countries = countries; + self.company.country = null; + for (var i = 0; i < countries.length; i++) { + if (countries[i].id === self.company.country_id[0]){ + self.company.country = countries[i]; + } + } + }, + },{ + model: 'res.lang', + fields: ['name', 'code'], + loaded: function (self, langs){ + self.langs = langs; + }, + },{ + model: 'account.tax', + fields: ['name','amount', 'price_include', 'include_base_amount', 'amount_type', 'children_tax_ids'], + domain: function(self) {return [['company_id', '=', self.company && self.company.id || false]]}, + loaded: function(self, taxes){ + self.taxes = taxes; + self.taxes_by_id = {}; + _.each(taxes, function(tax){ + self.taxes_by_id[tax.id] = tax; + }); + _.each(self.taxes_by_id, function(tax) { + tax.children_tax_ids = _.map(tax.children_tax_ids, function (child_tax_id) { + return self.taxes_by_id[child_tax_id]; + }); + }); + return new Promise(function (resolve, reject) { + var tax_ids = _.pluck(self.taxes, 'id'); + self.rpc({ + model: 'account.tax', + method: 'get_real_tax_amount', + args: [tax_ids], + }).then(function (taxes) { + _.each(taxes, function (tax) { + self.taxes_by_id[tax.id].amount = tax.amount; + }); + resolve(); + }); + }); + }, + },{ + model: 'pos.session', + fields: ['id', 'name', 'user_id', 'config_id', 'start_at', 'stop_at', 'sequence_number', 'payment_method_ids', 'cash_register_id', 'state'], + domain: function(self){ + var domain = [ + ['state','in',['opening_control','opened']], + ['rescue', '=', false], + ]; + if (self.config_id) domain.push(['config_id', '=', self.config_id]); + return domain; + }, + loaded: function(self, pos_sessions, tmp){ + self.pos_session = pos_sessions[0]; + self.pos_session.login_number = odoo.login_number; + self.config_id = self.config_id || self.pos_session && self.pos_session.config_id[0]; + tmp.payment_method_ids = pos_sessions[0].payment_method_ids; + }, + },{ + model: 'pos.config', + fields: [], + domain: function(self){ return [['id','=', self.config_id]]; }, + loaded: function(self,configs){ + self.config = configs[0]; + self.config.use_proxy = self.config.is_posbox && ( + self.config.iface_electronic_scale || + self.config.iface_print_via_proxy || + self.config.iface_scan_via_proxy || + self.config.iface_customer_facing_display); + + self.db.set_uuid(self.config.uuid); + self.set_cashier(self.get_cashier()); + // We need to do it here, since only then the local storage has the correct uuid + self.db.save('pos_session_id', self.pos_session.id); + + var orders = self.db.get_orders(); + for (var i = 0; i < orders.length; i++) { + self.pos_session.sequence_number = Math.max(self.pos_session.sequence_number, orders[i].data.sequence_number+1); + } + }, + },{ + model: 'stock.picking.type', + fields: ['use_create_lots', 'use_existing_lots'], + domain: function(self){ return [['id', '=', self.config.picking_type_id[0]]]; }, + loaded: function(self, picking_type) { + self.picking_type = picking_type[0]; + }, + },{ + model: 'res.users', + fields: ['name','company_id', 'id', 'groups_id', 'lang'], + domain: function(self){ return [['company_ids', 'in', self.config.company_id[0]],'|', ['groups_id','=', self.config.group_pos_manager_id[0]],['groups_id','=', self.config.group_pos_user_id[0]]]; }, + loaded: function(self,users){ + users.forEach(function(user) { + user.role = 'cashier'; + user.groups_id.some(function(group_id) { + if (group_id === self.config.group_pos_manager_id[0]) { + user.role = 'manager'; + return true; + } + }); + if (user.id === self.session.uid) { + self.user = user; + self.employee.name = user.name; + self.employee.role = user.role; + self.employee.user_id = [user.id, user.name]; + } + }); + self.users = users; + self.employees = [self.employee]; + self.set_cashier(self.employee); + }, + },{ + model: 'product.pricelist', + fields: ['name', 'display_name', 'discount_policy'], + domain: function(self) { + if (self.config.use_pricelist) { + return [['id', 'in', self.config.available_pricelist_ids]]; + } else { + return [['id', '=', self.config.pricelist_id[0]]]; + } + }, + loaded: function(self, pricelists){ + _.map(pricelists, function (pricelist) { pricelist.items = []; }); + self.default_pricelist = _.findWhere(pricelists, {id: self.config.pricelist_id[0]}); + self.pricelists = pricelists; + }, + },{ + model: 'account.bank.statement', + fields: ['id', 'balance_start'], + domain: function(self){ return [['id', '=', self.pos_session.cash_register_id[0]]]; }, + loaded: function(self, statement){ + self.bank_statement = statement[0]; + }, + },{ + model: 'product.pricelist.item', + domain: function(self) { return [['pricelist_id', 'in', _.pluck(self.pricelists, 'id')]]; }, + loaded: function(self, pricelist_items){ + var pricelist_by_id = {}; + _.each(self.pricelists, function (pricelist) { + pricelist_by_id[pricelist.id] = pricelist; + }); + + _.each(pricelist_items, function (item) { + var pricelist = pricelist_by_id[item.pricelist_id[0]]; + pricelist.items.push(item); + item.base_pricelist = pricelist_by_id[item.base_pricelist_id[0]]; + }); + }, + },{ + model: 'product.category', + fields: ['name', 'parent_id'], + loaded: function(self, product_categories){ + var category_by_id = {}; + _.each(product_categories, function (category) { + category_by_id[category.id] = category; + }); + _.each(product_categories, function (category) { + category.parent = category_by_id[category.parent_id[0]]; + }); + + self.product_categories = product_categories; + }, + },{ + model: 'res.currency', + fields: ['name','symbol','position','rounding','rate'], + ids: function(self){ return [self.config.currency_id[0], self.company.currency_id[0]]; }, + loaded: function(self, currencies){ + self.currency = currencies[0]; + if (self.currency.rounding > 0 && self.currency.rounding < 1) { + self.currency.decimals = Math.ceil(Math.log(1.0 / self.currency.rounding) / Math.log(10)); + } else { + self.currency.decimals = 0; + } + + self.company_currency = currencies[1]; + }, + },{ + model: 'pos.category', + fields: ['id', 'name', 'parent_id', 'child_id', 'write_date'], + domain: function(self) { + return self.config.limit_categories && self.config.iface_available_categ_ids.length ? [['id', 'in', self.config.iface_available_categ_ids]] : []; + }, + loaded: function(self, categories){ + self.db.add_categories(categories); + }, + },{ + model: 'product.product', + fields: ['display_name', 'lst_price', 'standard_price', 'categ_id', 'pos_categ_id', 'taxes_id', + 'barcode', 'default_code', 'to_weight', 'uom_id', 'description_sale', 'description', + 'product_tmpl_id','tracking', 'write_date', 'available_in_pos', 'attribute_line_ids'], + order: _.map(['sequence','default_code','name'], function (name) { return {name: name}; }), + domain: function(self){ + var domain = ['&', '&', ['sale_ok','=',true],['available_in_pos','=',true],'|',['company_id','=',self.config.company_id[0]],['company_id','=',false]]; + if (self.config.limit_categories && self.config.iface_available_categ_ids.length) { + domain.unshift('&'); + domain.push(['pos_categ_id', 'in', self.config.iface_available_categ_ids]); + } + if (self.config.iface_tipproduct){ + domain.unshift(['id', '=', self.config.tip_product_id[0]]); + domain.unshift('|'); + } + return domain; + }, + context: function(self){ return { display_default_code: false }; }, + loaded: function(self, products){ + var using_company_currency = self.config.currency_id[0] === self.company.currency_id[0]; + var conversion_rate = self.currency.rate / self.company_currency.rate; + self.db.add_products(_.map(products, function (product) { + if (!using_company_currency) { + product.lst_price = round_pr(product.lst_price * conversion_rate, self.currency.rounding); + } + product.categ = _.findWhere(self.product_categories, {'id': product.categ_id[0]}); + product.pos = self; + return new exports.Product({}, product); + })); + }, + },{ + model: 'product.attribute', + fields: ['name', 'display_type'], + condition: function (self) { return self.config.product_configurator; }, + domain: function(){ return [['create_variant', '=', 'no_variant']]; }, + loaded: function(self, product_attributes, tmp) { + tmp.product_attributes_by_id = {}; + _.map(product_attributes, function (product_attribute) { + tmp.product_attributes_by_id[product_attribute.id] = product_attribute; + }); + } + },{ + model: 'product.attribute.value', + fields: ['name', 'attribute_id', 'is_custom', 'html_color'], + condition: function (self) { return self.config.product_configurator; }, + domain: function(self, tmp){ return [['attribute_id', 'in', _.keys(tmp.product_attributes_by_id).map(parseFloat)]]; }, + loaded: function(self, pavs, tmp) { + tmp.pav_by_id = {}; + _.map(pavs, function (pav) { + tmp.pav_by_id[pav.id] = pav; + }); + } + }, { + model: 'product.template.attribute.value', + fields: ['product_attribute_value_id', 'attribute_id', 'attribute_line_id', 'price_extra'], + condition: function (self) { return self.config.product_configurator; }, + domain: function(self, tmp){ return [['attribute_id', 'in', _.keys(tmp.product_attributes_by_id).map(parseFloat)]]; }, + loaded: function(self, ptavs, tmp) { + self.attributes_by_ptal_id = {}; + _.map(ptavs, function (ptav) { + if (!self.attributes_by_ptal_id[ptav.attribute_line_id[0]]){ + self.attributes_by_ptal_id[ptav.attribute_line_id[0]] = { + id: ptav.attribute_line_id[0], + name: tmp.product_attributes_by_id[ptav.attribute_id[0]].name, + display_type: tmp.product_attributes_by_id[ptav.attribute_id[0]].display_type, + values: [], + }; + } + self.attributes_by_ptal_id[ptav.attribute_line_id[0]].values.push({ + id: ptav.product_attribute_value_id[0], + name: tmp.pav_by_id[ptav.product_attribute_value_id[0]].name, + is_custom: tmp.pav_by_id[ptav.product_attribute_value_id[0]].is_custom, + html_color: tmp.pav_by_id[ptav.product_attribute_value_id[0]].html_color, + price_extra: ptav.price_extra, + }); + }); + } + },{ + model: 'account.cash.rounding', + fields: ['name', 'rounding', 'rounding_method'], + domain: function(self){return [['id', '=', self.config.rounding_method[0]]]; }, + loaded: function(self, cash_rounding) { + self.cash_rounding = cash_rounding; + } + },{ + model: 'pos.payment.method', + fields: ['name', 'is_cash_count', 'use_payment_terminal'], + domain: function(self){return ['|',['active', '=', false], ['active', '=', true]]; }, + loaded: function(self, payment_methods) { + self.payment_methods = payment_methods.sort(function(a,b){ + // prefer cash payment_method to be first in the list + if (a.is_cash_count && !b.is_cash_count) { + return -1; + } else if (!a.is_cash_count && b.is_cash_count) { + return 1; + } else { + return a.id - b.id; + } + }); + self.payment_methods_by_id = {}; + _.each(self.payment_methods, function(payment_method) { + self.payment_methods_by_id[payment_method.id] = payment_method; + + var PaymentInterface = self.electronic_payment_interfaces[payment_method.use_payment_terminal]; + if (PaymentInterface) { + payment_method.payment_terminal = new PaymentInterface(self, payment_method); + } + }); + } + },{ + model: 'account.fiscal.position', + fields: [], + domain: function(self){ return [['id','in',self.config.fiscal_position_ids]]; }, + loaded: function(self, fiscal_positions){ + self.fiscal_positions = fiscal_positions; + } + }, { + model: 'account.fiscal.position.tax', + fields: [], + domain: function(self){ + var fiscal_position_tax_ids = []; + + self.fiscal_positions.forEach(function (fiscal_position) { + fiscal_position.tax_ids.forEach(function (tax_id) { + fiscal_position_tax_ids.push(tax_id); + }); + }); + + return [['id','in',fiscal_position_tax_ids]]; + }, + loaded: function(self, fiscal_position_taxes){ + self.fiscal_position_taxes = fiscal_position_taxes; + self.fiscal_positions.forEach(function (fiscal_position) { + fiscal_position.fiscal_position_taxes_by_id = {}; + fiscal_position.tax_ids.forEach(function (tax_id) { + var fiscal_position_tax = _.find(fiscal_position_taxes, function (fiscal_position_tax) { + return fiscal_position_tax.id === tax_id; + }); + + fiscal_position.fiscal_position_taxes_by_id[fiscal_position_tax.id] = fiscal_position_tax; + }); + }); + } + }, { + label: 'fonts', + loaded: function(){ + return new Promise(function (resolve, reject) { + // Waiting for fonts to be loaded to prevent receipt printing + // from printing empty receipt while loading Inconsolata + // ( The font used for the receipt ) + waitForWebfonts(['Lato','Inconsolata'], function () { + resolve(); + }); + // The JS used to detect font loading is not 100% robust, so + // do not wait more than 5sec + setTimeout(resolve, 5000); + }); + }, + },{ + label: 'pictures', + loaded: function (self) { + self.company_logo = new Image(); + return new Promise(function (resolve, reject) { + self.company_logo.onload = function () { + var img = self.company_logo; + var ratio = 1; + var targetwidth = 300; + var maxheight = 150; + if( img.width !== targetwidth ){ + ratio = targetwidth / img.width; + } + if( img.height * ratio > maxheight ){ + ratio = maxheight / img.height; + } + var width = Math.floor(img.width * ratio); + var height = Math.floor(img.height * ratio); + var c = document.createElement('canvas'); + c.width = width; + c.height = height; + var ctx = c.getContext('2d'); + ctx.drawImage(self.company_logo,0,0, width, height); + + self.company_logo_base64 = c.toDataURL(); + resolve(); + }; + self.company_logo.onerror = function () { + reject(); + }; + self.company_logo.crossOrigin = "anonymous"; + self.company_logo.src = '/web/binary/company_logo' + '?dbname=' + self.session.db + '&company=' + self.company.id + '&_' + Math.random(); + }); + }, + }, { + label: 'barcodes', + loaded: function(self) { + var barcode_parser = new BarcodeParser({'nomenclature_id': self.config.barcode_nomenclature_id}); + self.barcode_reader.set_barcode_parser(barcode_parser); + return barcode_parser.is_loaded(); + }, + }, + ], + + // loads all the needed data on the sever. returns a promise indicating when all the data has loaded. + load_server_data: function(){ + var self = this; + var progress = 0; + var progress_step = 1.0 / self.models.length; + var tmp = {}; // this is used to share a temporary state between models loaders + + var loaded = new Promise(function (resolve, reject) { + function load_model(index) { + if (index >= self.models.length) { + resolve(); + } else { + var model = self.models[index]; + self.setLoadingMessage(_t('Loading')+' '+(model.label || model.model || ''), progress); + + var cond = typeof model.condition === 'function' ? model.condition(self,tmp) : true; + if (!cond) { + load_model(index+1); + return; + } + + var fields = typeof model.fields === 'function' ? model.fields(self,tmp) : model.fields; + var domain = typeof model.domain === 'function' ? model.domain(self,tmp) : model.domain; + var context = typeof model.context === 'function' ? model.context(self,tmp) : model.context || {}; + var ids = typeof model.ids === 'function' ? model.ids(self,tmp) : model.ids; + var order = typeof model.order === 'function' ? model.order(self,tmp): model.order; + progress += progress_step; + + if( model.model ){ + var params = { + model: model.model, + context: _.extend(context, self.session.user_context || {}), + }; + + if (model.ids) { + params.method = 'read'; + params.args = [ids, fields]; + } else { + params.method = 'search_read'; + params.domain = domain; + params.fields = fields; + params.orderBy = order; + } + + self.rpc(params).then(function (result) { + try { // catching exceptions in model.loaded(...) + Promise.resolve(model.loaded(self, result, tmp)) + .then(function () { load_model(index + 1); }, + function (err) { reject(err); }); + } catch (err) { + console.error(err.message, err.stack); + reject(err); + } + }, function (err) { + reject(err); + }); + } else if (model.loaded) { + try { // catching exceptions in model.loaded(...) + Promise.resolve(model.loaded(self, tmp)) + .then(function () { load_model(index +1); }, + function (err) { reject(err); }); + } catch (err) { + reject(err); + } + } else { + load_model(index + 1); + } + } + } + + try { + return load_model(0); + } catch (err) { + return Promise.reject(err); + } + }); + + return loaded; + }, + + prepare_new_partners_domain: function(){ + return [['write_date','>', this.db.get_partner_write_date()]]; + }, + + // reload the list of partner, returns as a promise that resolves if there were + // updated partners, and fails if not + load_new_partners: function(){ + var self = this; + return new Promise(function (resolve, reject) { + var fields = _.find(self.models, function(model){ return model.label === 'load_partners'; }).fields; + var domain = self.prepare_new_partners_domain(); + self.rpc({ + model: 'res.partner', + method: 'search_read', + args: [domain, fields], + }, { + timeout: 3000, + shadow: true, + }) + .then(function (partners) { + if (self.db.add_partners(partners)) { // check if the partners we got were real updates + resolve(); + } else { + reject('Failed in updating partners.'); + } + }, function (type, err) { reject(); }); + }); + }, + + // this is called when an order is removed from the order collection. It ensures that there is always an existing + // order and a valid selected order + on_removed_order: function(removed_order,index,reason){ + var order_list = this.get_order_list(); + if( (reason === 'abandon' || removed_order.temporary) && order_list.length > 0){ + // when we intentionally remove an unfinished order, and there is another existing one + this.set_order(order_list[index] || order_list[order_list.length - 1], { silent: true }); + }else{ + // when the order was automatically removed after completion, + // or when we intentionally delete the only concurrent order + this.add_new_order({ silent: true }); + } + }, + + // returns the user who is currently the cashier for this point of sale + get_cashier: function(){ + // reset the cashier to the current user if session is new + if (this.db.load('pos_session_id') !== this.pos_session.id) { + this.set_cashier(this.employee); + } + return this.db.get_cashier() || this.get('cashier') || this.employee; + }, + // changes the current cashier + set_cashier: function(employee){ + this.set('cashier', employee); + this.db.set_cashier(this.get('cashier')); + }, + // creates a new empty order and sets it as the current order + add_new_order: function(options){ + var order = new exports.Order({},{pos:this}); + this.get('orders').add(order); + this.set('selectedOrder', order, options); + return order; + }, + /** + * Load the locally saved unpaid orders for this PoS Config. + * + * First load all orders belonging to the current session. + * Second load all orders belonging to the same config but from other sessions, + * Only if tho order has orderlines. + */ + load_orders: function(){ + var jsons = this.db.get_unpaid_orders(); + var orders = []; + + for (var i = 0; i < jsons.length; i++) { + var json = jsons[i]; + if (json.pos_session_id === this.pos_session.id) { + orders.push(new exports.Order({},{ + pos: this, + json: json, + })); + } + } + for (var i = 0; i < jsons.length; i++) { + var json = jsons[i]; + if (json.pos_session_id !== this.pos_session.id && json.lines.length > 0) { + orders.push(new exports.Order({},{ + pos: this, + json: json, + })); + } else if (json.pos_session_id !== this.pos_session.id) { + this.db.remove_unpaid_order(jsons[i]); + } + } + + orders = orders.sort(function(a,b){ + return a.sequence_number - b.sequence_number; + }); + + if (orders.length) { + this.get('orders').add(orders); + } + }, + + set_start_order: function(){ + var orders = this.get('orders').models; + + if (orders.length && !this.get('selectedOrder')) { + this.set('selectedOrder',orders[0]); + } else { + this.add_new_order(); + } + }, + + // return the current order + get_order: function(){ + return this.get('selectedOrder'); + }, + + get_client: function() { + var order = this.get_order(); + if (order) { + return order.get_client(); + } + return null; + }, + + // change the current order + set_order: function(order, options){ + this.set({ selectedOrder: order }, options); + }, + + // return the list of unpaid orders + get_order_list: function(){ + return this.get('orders').models; + }, + + //removes the current order + delete_current_order: function(){ + var order = this.get_order(); + if (order) { + order.destroy({'reason':'abandon'}); + } + }, + + _convert_product_img_to_base64: function (product, url) { + return new Promise(function (resolve, reject) { + var img = new Image(); + + img.onload = function () { + var canvas = document.createElement('CANVAS'); + var ctx = canvas.getContext('2d'); + + canvas.height = this.height; + canvas.width = this.width; + ctx.drawImage(this,0,0); + + var dataURL = canvas.toDataURL('image/jpeg'); + product.image_base64 = dataURL; + canvas = null; + + resolve(); + }; + img.crossOrigin = 'use-credentials'; + img.src = url; + }); + }, + + send_current_order_to_customer_facing_display: function() { + var self = this; + this.render_html_for_customer_facing_display().then(function (rendered_html) { + self.proxy.update_customer_facing_display(rendered_html); + }); + }, + + /** + * @returns {Promise<string>} + */ + render_html_for_customer_facing_display: function () { + var self = this; + var order = this.get_order(); + var rendered_html = this.config.customer_facing_display_html; + + // If we're using an external device like the IoT Box, we + // cannot get /web/image?model=product.product because the + // IoT Box is not logged in and thus doesn't have the access + // rights to access product.product. So instead we'll base64 + // encode it and embed it in the HTML. + var get_image_promises = []; + + if (order) { + order.get_orderlines().forEach(function (orderline) { + var product = orderline.product; + var image_url = `/web/image?model=product.product&field=image_128&id=${product.id}&write_date=${product.write_date}&unique=1`; + + // only download and convert image if we haven't done it before + if (! product.image_base64) { + get_image_promises.push(self._convert_product_img_to_base64(product, image_url)); + } + }); + } + + // when all images are loaded in product.image_base64 + return Promise.all(get_image_promises).then(function () { + var rendered_order_lines = ""; + var rendered_payment_lines = ""; + var order_total_with_tax = self.format_currency(0); + + if (order) { + rendered_order_lines = QWeb.render('CustomerFacingDisplayOrderLines', { + 'orderlines': order.get_orderlines(), + 'pos': self, + }); + rendered_payment_lines = QWeb.render('CustomerFacingDisplayPaymentLines', { + 'order': order, + 'pos': self, + }); + order_total_with_tax = self.format_currency(order.get_total_with_tax()); + } + + var $rendered_html = $(rendered_html); + $rendered_html.find('.pos_orderlines_list').html(rendered_order_lines); + $rendered_html.find('.pos-total').find('.pos_total-amount').html(order_total_with_tax); + var pos_change_title = $rendered_html.find('.pos-change_title').text(); + $rendered_html.find('.pos-paymentlines').html(rendered_payment_lines); + $rendered_html.find('.pos-change_title').text(pos_change_title); + + // prop only uses the first element in a set of elements, + // and there's no guarantee that + // customer_facing_display_html is wrapped in a single + // root element. + rendered_html = _.reduce($rendered_html, function (memory, current_element) { + return memory + $(current_element).prop('outerHTML'); + }, ""); // initial memory of "" + + rendered_html = QWeb.render('CustomerFacingDisplayHead', { + origin: window.location.origin + }) + rendered_html; + return rendered_html; + }); + }, + + // saves the order locally and try to send it to the backend. + // it returns a promise that succeeds after having tried to send the order and all the other pending orders. + push_orders: function (order, opts) { + opts = opts || {}; + var self = this; + + if (order) { + this.db.add_order(order.export_as_JSON()); + } + + return new Promise(function (resolve, reject) { + self.flush_mutex.exec(function () { + var flushed = self._flush_orders(self.db.get_orders(), opts); + + flushed.then(resolve, reject); + + return flushed; + }); + }); + }, + + push_single_order: function (order, opts) { + opts = opts || {}; + const self = this; + const order_id = self.db.add_order(order.export_as_JSON()); + + return new Promise(function (resolve, reject) { + self.flush_mutex.exec(function () { + var order = self.db.get_order(order_id); + if (order){ + var flushed = self._flush_orders([order], opts); + } else { + var flushed = Promise.resolve([]); + } + flushed.then(resolve, reject); + + return flushed; + }); + }); + }, + + // saves the order locally and try to send it to the backend and make an invoice + // returns a promise that succeeds when the order has been posted and successfully generated + // an invoice. This method can fail in various ways: + // error-no-client: the order must have an associated partner_id. You can retry to make an invoice once + // this error is solved + // error-transfer: there was a connection error during the transfer. You can retry to make the invoice once + // the network connection is up + + push_and_invoice_order: function (order) { + var self = this; + var invoiced = new Promise(function (resolveInvoiced, rejectInvoiced) { + if(!order.get_client()){ + rejectInvoiced({code:400, message:'Missing Customer', data:{}}); + } + else { + var order_id = self.db.add_order(order.export_as_JSON()); + + self.flush_mutex.exec(function () { + var done = new Promise(function (resolveDone, rejectDone) { + // send the order to the server + // we have a 30 seconds timeout on this push. + // FIXME: if the server takes more than 30 seconds to accept the order, + // the client will believe it wasn't successfully sent, and very bad + // things will happen as a duplicate will be sent next time + // so we must make sure the server detects and ignores duplicated orders + + var transfer = self._flush_orders([self.db.get_order(order_id)], {timeout:30000, to_invoice:true}); + + transfer.catch(function (error) { + rejectInvoiced(error); + rejectDone(); + }); + + // on success, get the order id generated by the server + transfer.then(function(order_server_id){ + // generate the pdf and download it + if (order_server_id.length) { + self.do_action('point_of_sale.pos_invoice_report',{additional_context:{ + active_ids:order_server_id, + }}).then(function () { + resolveInvoiced(order_server_id); + resolveDone(); + }).guardedCatch(function (error) { + rejectInvoiced({code:401, message:'Backend Invoice', data:{order: order}}); + rejectDone(); + }); + } else if (order_server_id.length) { + resolveInvoiced(order_server_id); + resolveDone(); + } else { + // The order has been pushed separately in batch when + // the connection came back. + // The user has to go to the backend to print the invoice + rejectInvoiced({code:401, message:'Backend Invoice', data:{order: order}}); + rejectDone(); + } + }); + return done; + }); + }); + } + }); + + return invoiced; + }, + + // wrapper around the _save_to_server that updates the synch status widget + // Resolves to the backend ids of the synced orders. + _flush_orders: function(orders, options) { + var self = this; + this.set_synch('connecting', orders.length); + + return this._save_to_server(orders, options).then(function (server_ids) { + self.set_synch('connected'); + for (let i = 0; i < server_ids.length; i++) { + self.validated_orders_name_server_id_map[server_ids[i].pos_reference] = server_ids[i].id; + } + return _.pluck(server_ids, 'id'); + }).catch(function(error){ + self.set_synch(self.get('failed') ? 'error' : 'disconnected'); + return Promise.reject(error); + }); + }, + + set_synch: function(status, pending) { + if (['connected', 'connecting', 'error', 'disconnected'].indexOf(status) === -1) { + console.error(status, ' is not a known connection state.'); + } + pending = pending || this.db.get_orders().length + this.db.get_ids_to_remove_from_server().length; + this.set('synch', { status, pending }); + }, + + // send an array of orders to the server + // available options: + // - timeout: timeout for the rpc call in ms + // returns a promise that resolves with the list of + // server generated ids for the sent orders + _save_to_server: function (orders, options) { + if (!orders || !orders.length) { + return Promise.resolve([]); + } + + options = options || {}; + + var self = this; + var timeout = typeof options.timeout === 'number' ? options.timeout : 30000 * orders.length; + + // Keep the order ids that are about to be sent to the + // backend. In between create_from_ui and the success callback + // new orders may have been added to it. + var order_ids_to_sync = _.pluck(orders, 'id'); + + // we try to send the order. shadow prevents a spinner if it takes too long. (unless we are sending an invoice, + // then we want to notify the user that we are waiting on something ) + var args = [_.map(orders, function (order) { + order.to_invoice = options.to_invoice || false; + return order; + })]; + args.push(options.draft || false); + return this.rpc({ + model: 'pos.order', + method: 'create_from_ui', + args: args, + kwargs: {context: this.session.user_context}, + }, { + timeout: timeout, + shadow: !options.to_invoice + }) + .then(function (server_ids) { + _.each(order_ids_to_sync, function (order_id) { + self.db.remove_order(order_id); + }); + self.set('failed',false); + return server_ids; + }).catch(function (reason){ + var error = reason.message; + console.warn('Failed to send orders:', orders); + if(error.code === 200 ){ // Business Logic Error, not a connection problem + // Hide error if already shown before ... + if ((!self.get('failed') || options.show_error) && !options.to_invoice) { + self.set('failed',error); + throw error; + } + } + throw error; + }); + }, + + /** + * Remove orders with given ids from the database. + * @param {array<number>} server_ids ids of the orders to be removed. + * @param {dict} options. + * @param {number} options.timeout optional timeout parameter for the rpc call. + * @return {Promise<array<number>>} returns a promise of the ids successfully removed. + */ + _remove_from_server: function (server_ids, options) { + options = options || {}; + if (!server_ids || !server_ids.length) { + return Promise.resolve([]); + } + + var self = this; + var timeout = typeof options.timeout === 'number' ? options.timeout : 7500 * server_ids.length; + + return this.rpc({ + model: 'pos.order', + method: 'remove_from_ui', + args: [server_ids], + kwargs: {context: this.session.user_context}, + }, { + timeout: timeout, + shadow: true, + }) + .then(function (data) { + return self._post_remove_from_server(server_ids, data) + }).catch(function (reason){ + var error = reason.message; + if(error.code === 200 ){ // Business Logic Error, not a connection problem + //if warning do not need to display traceback!! + if (error.data.exception_type == 'warning') { + delete error.data.debug; + } + } + // important to throw error here and let the rendering component handle the + // error + console.warn('Failed to remove orders:', server_ids); + throw error; + }); + }, + + // to override + _post_remove_from_server(server_ids, data) { + this.db.set_ids_removed_from_server(server_ids); + return server_ids; + }, + + scan_product: function(parsed_code){ + var selectedOrder = this.get_order(); + var product = this.db.get_product_by_barcode(parsed_code.base_code); + + if(!product){ + return false; + } + + if(parsed_code.type === 'price'){ + selectedOrder.add_product(product, {price:parsed_code.value}); + }else if(parsed_code.type === 'weight'){ + selectedOrder.add_product(product, {quantity:parsed_code.value, merge:false}); + }else if(parsed_code.type === 'discount'){ + selectedOrder.add_product(product, {discount:parsed_code.value, merge:false}); + }else{ + selectedOrder.add_product(product); + } + return true; + }, + + // Exports the paid orders (the ones waiting for internet connection) + export_paid_orders: function() { + return JSON.stringify({ + 'paid_orders': this.db.get_orders(), + 'session': this.pos_session.name, + 'session_id': this.pos_session.id, + 'date': (new Date()).toUTCString(), + 'version': this.version.server_version_info, + },null,2); + }, + + // Exports the unpaid orders (the tabs) + export_unpaid_orders: function() { + return JSON.stringify({ + 'unpaid_orders': this.db.get_unpaid_orders(), + 'session': this.pos_session.name, + 'session_id': this.pos_session.id, + 'date': (new Date()).toUTCString(), + 'version': this.version.server_version_info, + },null,2); + }, + + // This imports paid or unpaid orders from a json file whose + // contents are provided as the string str. + // It returns a report of what could and what could not be + // imported. + import_orders: function(str) { + var json = JSON.parse(str); + var report = { + // Number of paid orders that were imported + paid: 0, + // Number of unpaid orders that were imported + unpaid: 0, + // Orders that were not imported because they already exist (uid conflict) + unpaid_skipped_existing: 0, + // Orders that were not imported because they belong to another session + unpaid_skipped_session: 0, + // The list of session ids to which skipped orders belong. + unpaid_skipped_sessions: [], + }; + + if (json.paid_orders) { + for (var i = 0; i < json.paid_orders.length; i++) { + this.db.add_order(json.paid_orders[i].data); + } + report.paid = json.paid_orders.length; + this.push_orders(); + } + + if (json.unpaid_orders) { + + var orders = []; + var existing = this.get_order_list(); + var existing_uids = {}; + var skipped_sessions = {}; + + for (var i = 0; i < existing.length; i++) { + existing_uids[existing[i].uid] = true; + } + + for (var i = 0; i < json.unpaid_orders.length; i++) { + var order = json.unpaid_orders[i]; + if (order.pos_session_id !== this.pos_session.id) { + report.unpaid_skipped_session += 1; + skipped_sessions[order.pos_session_id] = true; + } else if (existing_uids[order.uid]) { + report.unpaid_skipped_existing += 1; + } else { + orders.push(new exports.Order({},{ + pos: this, + json: order, + })); + } + } + + orders = orders.sort(function(a,b){ + return a.sequence_number - b.sequence_number; + }); + + if (orders.length) { + report.unpaid = orders.length; + this.get('orders').add(orders); + } + + report.unpaid_skipped_sessions = _.keys(skipped_sessions); + } + + return report; + }, + + _load_orders: function(){ + var jsons = this.db.get_unpaid_orders(); + var orders = []; + var not_loaded_count = 0; + + for (var i = 0; i < jsons.length; i++) { + var json = jsons[i]; + if (json.pos_session_id === this.pos_session.id) { + orders.push(new exports.Order({},{ + pos: this, + json: json, + })); + } else { + not_loaded_count += 1; + } + } + + if (not_loaded_count) { + console.info('There are '+not_loaded_count+' locally saved unpaid orders belonging to another session'); + } + + orders = orders.sort(function(a,b){ + return a.sequence_number - b.sequence_number; + }); + + if (orders.length) { + this.get('orders').add(orders); + } + }, + + /** + * Directly calls the requested service, instead of triggering a + * 'call_service' event up, which wouldn't work as services have no parent + * + * @param {OdooEvent} ev + */ + _trigger_up: function (ev) { + if (ev.is_stopped()) { + return; + } + const payload = ev.data; + if (ev.name === 'call_service') { + let args = payload.args || []; + if (payload.service === 'ajax' && payload.method === 'rpc') { + // ajax service uses an extra 'target' argument for rpc + args = args.concat(ev.target); + } + const service = this.env.services[payload.service]; + const result = service[payload.method].apply(service, args); + payload.callback(result); + } + }, + + electronic_payment_interfaces: {}, + + format_currency: function(amount, precision) { + var currency = + this && this.currency + ? this.currency + : { symbol: '$', position: 'after', rounding: 0.01, decimals: 2 }; + + amount = this.format_currency_no_symbol(amount, precision, currency); + + if (currency.position === 'after') { + return amount + ' ' + (currency.symbol || ''); + } else { + return (currency.symbol || '') + ' ' + amount; + } + }, + + format_currency_no_symbol: function(amount, precision, currency) { + if (!currency) { + currency = + this && this.currency + ? this.currency + : { symbol: '$', position: 'after', rounding: 0.01, decimals: 2 }; + } + var decimals = currency.decimals; + + if (precision && this.dp[precision] !== undefined) { + decimals = this.dp[precision]; + } + + if (typeof amount === 'number') { + amount = round_di(amount, decimals).toFixed(decimals); + amount = field_utils.format.float(round_di(amount, decimals), { + digits: [69, decimals], + }); + } + + return amount; + }, + + format_pr: function(value, precision) { + var decimals = + precision > 0 + ? Math.max(0, Math.ceil(Math.log(1.0 / precision) / Math.log(10))) + : 0; + return value.toFixed(decimals); + }, + + /** + * (value = 1.0000, decimals = 2) => '1' + * (value = 1.1234, decimals = 2) => '1.12' + * @param {number} value amount to format + */ + formatFixed: function(value) { + const currency = this.currency || { decimals: 2 }; + return `${Number(value.toFixed(currency.decimals || 0))}`; + }, + + disallowLineQuantityChange() { + return false; + }, + + getCurrencySymbol() { + return this.currency ? this.currency.symbol : '$'; + }, +}); + +/** + * Call this function to map your PaymentInterface implementation to + * the use_payment_terminal field. When the POS loads it will take + * care of instantiating your interface and setting it on the right + * payment methods. + * + * @param {string} use_payment_terminal - value used in the + * use_payment_terminal selection field + * + * @param {Object} ImplementedPaymentInterface - implemented + * PaymentInterface + */ +exports.register_payment_method = function(use_payment_terminal, ImplementedPaymentInterface) { + exports.PosModel.prototype.electronic_payment_interfaces[use_payment_terminal] = ImplementedPaymentInterface; +}; + +// Add fields to the list of read fields when a model is loaded +// by the point of sale. +// e.g: module.load_fields("product.product",['price','category']) + +exports.load_fields = function(model_name, fields) { + if (!(fields instanceof Array)) { + fields = [fields]; + } + + var models = exports.PosModel.prototype.models; + for (var i = 0; i < models.length; i++) { + var model = models[i]; + if (model.model === model_name) { + // if 'fields' is empty all fields are loaded, so we do not need + // to modify the array + if ((model.fields instanceof Array) && model.fields.length > 0) { + model.fields = model.fields.concat(fields || []); + } + } + } +}; + +// Loads openerp models at the point of sale startup. +// load_models take an array of model loader declarations. +// - The models will be loaded in the array order. +// - If no openerp model name is provided, no server data +// will be loaded, but the system can be used to preprocess +// data before load. +// - loader arguments can be functions that return a dynamic +// value. The function takes the PosModel as the first argument +// and a temporary object that is shared by all models, and can +// be used to store transient information between model loads. +// - There is no dependency management. The models must be loaded +// in the right order. Newly added models are loaded at the end +// but the after / before options can be used to load directly +// before / after another model. +// +// models: [{ +// model: [string] the name of the openerp model to load. +// label: [string] The label displayed during load. +// fields: [[string]|function] the list of fields to be loaded. +// Empty Array / Null loads all fields. +// order: [[string]|function] the models will be ordered by +// the provided fields +// domain: [domain|function] the domain that determines what +// models need to be loaded. Null loads everything +// ids: [[id]|function] the id list of the models that must +// be loaded. Overrides domain. +// context: [Dict|function] the openerp context for the model read +// condition: [function] do not load the models if it evaluates to +// false. +// loaded: [function(self,model)] this function is called once the +// models have been loaded, with the data as second argument +// if the function returns a promise, the next model will +// wait until it resolves before loading. +// }] +// +// options: +// before: [string] The model will be loaded before the named models +// (applies to both model name and label) +// after: [string] The model will be loaded after the (last loaded) +// named model. (applies to both model name and label) +// +exports.load_models = function(models,options) { + options = options || {}; + if (!(models instanceof Array)) { + models = [models]; + } + + var pmodels = exports.PosModel.prototype.models; + var index = pmodels.length; + if (options.before) { + for (var i = 0; i < pmodels.length; i++) { + if ( pmodels[i].model === options.before || + pmodels[i].label === options.before ){ + index = i; + break; + } + } + } else if (options.after) { + for (var i = 0; i < pmodels.length; i++) { + if ( pmodels[i].model === options.after || + pmodels[i].label === options.after ){ + index = i + 1; + } + } + } + pmodels.splice.apply(pmodels,[index,0].concat(models)); +}; + +exports.Product = Backbone.Model.extend({ + initialize: function(attr, options){ + _.extend(this, options); + }, + isAllowOnlyOneLot: function() { + const productUnit = this.get_unit(); + return this.tracking === 'lot' || !productUnit || !productUnit.is_pos_groupable; + }, + get_unit: function() { + var unit_id = this.uom_id; + if(!unit_id){ + return undefined; + } + unit_id = unit_id[0]; + if(!this.pos){ + return undefined; + } + return this.pos.units_by_id[unit_id]; + }, + // Port of get_product_price on product.pricelist. + // + // Anything related to UOM can be ignored, the POS will always use + // the default UOM set on the product and the user cannot change + // it. + // + // Pricelist items do not have to be sorted. All + // product.pricelist.item records are loaded with a search_read + // and were automatically sorted based on their _order by the + // ORM. After that they are added in this order to the pricelists. + get_price: function(pricelist, quantity, price_extra){ + var self = this; + var date = moment().startOf('day'); + + // In case of nested pricelists, it is necessary that all pricelists are made available in + // the POS. Display a basic alert to the user in this case. + if (pricelist === undefined) { + alert(_t( + 'An error occurred when loading product prices. ' + + 'Make sure all pricelists are available in the POS.' + )); + } + + var category_ids = []; + var category = this.categ; + while (category) { + category_ids.push(category.id); + category = category.parent; + } + + var pricelist_items = _.filter(pricelist.items, function (item) { + return (! item.product_tmpl_id || item.product_tmpl_id[0] === self.product_tmpl_id) && + (! item.product_id || item.product_id[0] === self.id) && + (! item.categ_id || _.contains(category_ids, item.categ_id[0])) && + (! item.date_start || moment(item.date_start).isSameOrBefore(date)) && + (! item.date_end || moment(item.date_end).isSameOrAfter(date)); + }); + + var price = self.lst_price; + if (price_extra){ + price += price_extra; + } + _.find(pricelist_items, function (rule) { + if (rule.min_quantity && quantity < rule.min_quantity) { + return false; + } + + if (rule.base === 'pricelist') { + price = self.get_price(rule.base_pricelist, quantity); + } else if (rule.base === 'standard_price') { + price = self.standard_price; + } + + if (rule.compute_price === 'fixed') { + price = rule.fixed_price; + return true; + } else if (rule.compute_price === 'percentage') { + price = price - (price * (rule.percent_price / 100)); + return true; + } else { + var price_limit = price; + price = price - (price * (rule.price_discount / 100)); + if (rule.price_round) { + price = round_pr(price, rule.price_round); + } + if (rule.price_surcharge) { + price += rule.price_surcharge; + } + if (rule.price_min_margin) { + price = Math.max(price, price_limit + rule.price_min_margin); + } + if (rule.price_max_margin) { + price = Math.min(price, price_limit + rule.price_max_margin); + } + return true; + } + + return false; + }); + + // This return value has to be rounded with round_di before + // being used further. Note that this cannot happen here, + // because it would cause inconsistencies with the backend for + // pricelist that have base == 'pricelist'. + return price; + }, +}); + +var orderline_id = 1; + +// An orderline represent one element of the content of a client's shopping cart. +// An orderline contains a product, its quantity, its price, discount. etc. +// An Order contains zero or more Orderlines. +exports.Orderline = Backbone.Model.extend({ + initialize: function(attr,options){ + this.pos = options.pos; + this.order = options.order; + if (options.json) { + try { + this.init_from_JSON(options.json); + } catch(error) { + console.error('ERROR: attempting to recover product ID', options.json.product_id, + 'not available in the point of sale. Correct the product or clean the browser cache.'); + } + return; + } + this.product = options.product; + this.set_product_lot(this.product); + this.set_quantity(1); + this.discount = 0; + this.discountStr = '0'; + this.selected = false; + this.description = ''; + this.price_extra = 0; + this.full_product_name = ''; + this.id = orderline_id++; + this.price_manually_set = false; + + if (options.price) { + this.set_unit_price(options.price); + } else { + this.set_unit_price(this.product.get_price(this.order.pricelist, this.get_quantity())); + } + }, + init_from_JSON: function(json) { + this.product = this.pos.db.get_product_by_id(json.product_id); + this.set_product_lot(this.product); + this.price = json.price_unit; + this.set_discount(json.discount); + this.set_quantity(json.qty, 'do not recompute unit price'); + this.set_description(json.description); + this.set_price_extra(json.price_extra); + this.set_full_product_name(json.full_product_name); + this.id = json.id ? json.id : orderline_id++; + orderline_id = Math.max(this.id+1,orderline_id); + var pack_lot_lines = json.pack_lot_ids; + for (var i = 0; i < pack_lot_lines.length; i++) { + var packlotline = pack_lot_lines[i][2]; + var pack_lot_line = new exports.Packlotline({}, {'json': _.extend(packlotline, {'order_line':this})}); + this.pack_lot_lines.add(pack_lot_line); + } + }, + clone: function(){ + var orderline = new exports.Orderline({},{ + pos: this.pos, + order: this.order, + product: this.product, + price: this.price, + }); + orderline.order = null; + orderline.quantity = this.quantity; + orderline.quantityStr = this.quantityStr; + orderline.discount = this.discount; + orderline.price = this.price; + orderline.selected = false; + orderline.price_manually_set = this.price_manually_set; + return orderline; + }, + getPackLotLinesToEdit: function(isAllowOnlyOneLot) { + const currentPackLotLines = this.pack_lot_lines.models; + let nExtraLines = Math.abs(this.quantity) - currentPackLotLines.length; + nExtraLines = nExtraLines > 0 ? nExtraLines : 1; + const tempLines = currentPackLotLines + .map(lotLine => ({ + id: lotLine.cid, + text: lotLine.get('lot_name'), + })) + .concat( + Array.from(Array(nExtraLines)).map(_ => ({ + text: '', + })) + ); + return isAllowOnlyOneLot ? [tempLines[0]] : tempLines; + }, + /** + * @param { modifiedPackLotLines, newPackLotLines } + * @param {Object} modifiedPackLotLines key-value pair of String (the cid) & String (the new lot_name) + * @param {Array} newPackLotLines array of { lot_name: String } + */ + setPackLotLines: function({ modifiedPackLotLines, newPackLotLines }) { + // Set the new values for modified lot lines. + let lotLinesToRemove = []; + for (let lotLine of this.pack_lot_lines.models) { + const modifiedLotName = modifiedPackLotLines[lotLine.cid]; + if (modifiedLotName) { + lotLine.set({ lot_name: modifiedLotName }); + } else { + // We should not call lotLine.remove() here because + // we don't want to mutate the array while looping thru it. + lotLinesToRemove.push(lotLine); + } + } + + // Remove those that needed to be removed. + for (let lotLine of lotLinesToRemove) { + lotLine.remove(); + } + + // Create new pack lot lines. + let newPackLotLine; + for (let newLotLine of newPackLotLines) { + newPackLotLine = new exports.Packlotline({}, { order_line: this }); + newPackLotLine.set({ lot_name: newLotLine.lot_name }); + this.pack_lot_lines.add(newPackLotLine); + } + + // Set the quantity of the line based on number of pack lots. + this.pack_lot_lines.set_quantity_by_lot(); + }, + set_product_lot: function(product){ + this.has_product_lot = product.tracking !== 'none'; + this.pack_lot_lines = this.has_product_lot && new PacklotlineCollection(null, {'order_line': this}); + }, + // sets a discount [0,100]% + set_discount: function(discount){ + var parsed_discount = isNaN(parseFloat(discount)) ? 0 : field_utils.parse.float('' + discount); + var disc = Math.min(Math.max(parsed_discount || 0, 0),100); + this.discount = disc; + this.discountStr = '' + disc; + this.trigger('change',this); + }, + // returns the discount [0,100]% + get_discount: function(){ + return this.discount; + }, + get_discount_str: function(){ + return this.discountStr; + }, + set_description: function(description){ + this.description = description || ''; + }, + set_price_extra: function(price_extra){ + this.price_extra = parseFloat(price_extra) || 0.0; + }, + set_full_product_name: function(full_product_name){ + this.full_product_name = full_product_name || ''; + }, + get_price_extra: function () { + return this.price_extra; + }, + // sets the quantity of the product. The quantity will be rounded according to the + // product's unity of measure properties. Quantities greater than zero will not get + // rounded to zero + set_quantity: function(quantity, keep_price){ + this.order.assert_editable(); + if(quantity === 'remove'){ + this.order.remove_orderline(this); + return; + }else{ + var quant = typeof(quantity) === 'number' ? quantity : (field_utils.parse.float('' + quantity) || 0); + var unit = this.get_unit(); + if(unit){ + if (unit.rounding) { + var decimals = this.pos.dp['Product Unit of Measure']; + var rounding = Math.max(unit.rounding, Math.pow(10, -decimals)); + this.quantity = round_pr(quant, rounding); + this.quantityStr = field_utils.format.float(this.quantity, {digits: [69, decimals]}); + } else { + this.quantity = round_pr(quant, 1); + this.quantityStr = this.quantity.toFixed(0); + } + }else{ + this.quantity = quant; + this.quantityStr = '' + this.quantity; + } + } + + // just like in sale.order changing the quantity will recompute the unit price + if(! keep_price && ! this.price_manually_set){ + this.set_unit_price(this.product.get_price(this.order.pricelist, this.get_quantity(), this.get_price_extra())); + this.order.fix_tax_included_price(this); + } + this.trigger('change', this); + }, + // return the quantity of product + get_quantity: function(){ + return this.quantity; + }, + get_quantity_str: function(){ + return this.quantityStr; + }, + get_quantity_str_with_unit: function(){ + var unit = this.get_unit(); + if(unit && !unit.is_pos_groupable){ + return this.quantityStr + ' ' + unit.name; + }else{ + return this.quantityStr; + } + }, + + get_lot_lines: function() { + return this.pack_lot_lines.models; + }, + + get_required_number_of_lots: function(){ + var lots_required = 1; + + if (this.product.tracking == 'serial') { + lots_required = Math.abs(this.quantity); + } + + return lots_required; + }, + + has_valid_product_lot: function(){ + if(!this.has_product_lot){ + return true; + } + var valid_product_lot = this.pack_lot_lines.get_valid_lots(); + return this.get_required_number_of_lots() === valid_product_lot.length; + }, + + // return the unit of measure of the product + get_unit: function(){ + return this.product.get_unit(); + }, + // return the product of this orderline + get_product: function(){ + return this.product; + }, + get_full_product_name: function () { + if (this.full_product_name) { + return this.full_product_name + } + var full_name = this.product.display_name; + if (this.description) { + full_name += ` (${this.description})`; + } + return full_name; + }, + // selects or deselects this orderline + set_selected: function(selected){ + this.selected = selected; + // this trigger also triggers the change event of the collection. + this.trigger('change',this); + this.trigger('new-orderline-selected'); + }, + // returns true if this orderline is selected + is_selected: function(){ + return this.selected; + }, + // when we add an new orderline we want to merge it with the last line to see reduce the number of items + // in the orderline. This returns true if it makes sense to merge the two + can_be_merged_with: function(orderline){ + var price = parseFloat(round_di(this.price || 0, this.pos.dp['Product Price']).toFixed(this.pos.dp['Product Price'])); + var order_line_price = orderline.get_product().get_price(orderline.order.pricelist, this.get_quantity()); + order_line_price = orderline.compute_fixed_price(order_line_price); + if( this.get_product().id !== orderline.get_product().id){ //only orderline of the same product can be merged + return false; + }else if(!this.get_unit() || !this.get_unit().is_pos_groupable){ + return false; + }else if(this.get_discount() > 0){ // we don't merge discounted orderlines + return false; + }else if(!utils.float_is_zero(price - order_line_price - orderline.get_price_extra(), + this.pos.currency.decimals)){ + return false; + }else if(this.product.tracking == 'lot' && (this.pos.picking_type.use_create_lots || this.pos.picking_type.use_existing_lots)) { + return false; + }else if (this.description !== orderline.description) { + return false; + }else{ + return true; + } + }, + merge: function(orderline){ + this.order.assert_editable(); + this.set_quantity(this.get_quantity() + orderline.get_quantity()); + }, + export_as_JSON: function() { + var pack_lot_ids = []; + if (this.has_product_lot){ + this.pack_lot_lines.each(_.bind( function(item) { + return pack_lot_ids.push([0, 0, item.export_as_JSON()]); + }, this)); + } + return { + qty: this.get_quantity(), + price_unit: this.get_unit_price(), + price_subtotal: this.get_price_without_tax(), + price_subtotal_incl: this.get_price_with_tax(), + discount: this.get_discount(), + product_id: this.get_product().id, + tax_ids: [[6, false, _.map(this.get_applicable_taxes(), function(tax){ return tax.id; })]], + id: this.id, + pack_lot_ids: pack_lot_ids, + description: this.description, + full_product_name: this.get_full_product_name(), + price_extra: this.get_price_extra(), + }; + }, + //used to create a json of the ticket, to be sent to the printer + export_for_printing: function(){ + return { + id: this.id, + quantity: this.get_quantity(), + unit_name: this.get_unit().name, + is_in_unit: this.get_unit().id == this.pos.uom_unit_id, + price: this.get_unit_display_price(), + discount: this.get_discount(), + product_name: this.get_product().display_name, + product_name_wrapped: this.generate_wrapped_product_name(), + price_lst: this.get_lst_price(), + display_discount_policy: this.display_discount_policy(), + price_display_one: this.get_display_price_one(), + price_display : this.get_display_price(), + price_with_tax : this.get_price_with_tax(), + price_without_tax: this.get_price_without_tax(), + price_with_tax_before_discount: this.get_price_with_tax_before_discount(), + tax: this.get_tax(), + product_description: this.get_product().description, + product_description_sale: this.get_product().description_sale, + pack_lot_lines: this.get_lot_lines() + }; + }, + generate_wrapped_product_name: function() { + var MAX_LENGTH = 24; // 40 * line ratio of .6 + var wrapped = []; + var name = this.get_full_product_name(); + var current_line = ""; + + while (name.length > 0) { + var space_index = name.indexOf(" "); + + if (space_index === -1) { + space_index = name.length; + } + + if (current_line.length + space_index > MAX_LENGTH) { + if (current_line.length) { + wrapped.push(current_line); + } + current_line = ""; + } + + current_line += name.slice(0, space_index + 1); + name = name.slice(space_index + 1); + } + + if (current_line.length) { + wrapped.push(current_line); + } + + return wrapped; + }, + // changes the base price of the product for this orderline + set_unit_price: function(price){ + this.order.assert_editable(); + var parsed_price = !isNaN(price) ? + price : + isNaN(parseFloat(price)) ? 0 : field_utils.parse.float('' + price) + this.price = round_di(parsed_price || 0, this.pos.dp['Product Price']); + this.trigger('change',this); + }, + get_unit_price: function(){ + var digits = this.pos.dp['Product Price']; + // round and truncate to mimic _symbol_set behavior + return parseFloat(round_di(this.price || 0, digits).toFixed(digits)); + }, + get_unit_display_price: function(){ + if (this.pos.config.iface_tax_included === 'total') { + var quantity = this.quantity; + this.quantity = 1.0; + var price = this.get_all_prices().priceWithTax; + this.quantity = quantity; + return price; + } else { + return this.get_unit_price(); + } + }, + get_base_price: function(){ + var rounding = this.pos.currency.rounding; + return round_pr(this.get_unit_price() * this.get_quantity() * (1 - this.get_discount()/100), rounding); + }, + get_display_price_one: function(){ + var rounding = this.pos.currency.rounding; + var price_unit = this.get_unit_price(); + if (this.pos.config.iface_tax_included !== 'total') { + return round_pr(price_unit * (1.0 - (this.get_discount() / 100.0)), rounding); + } else { + var product = this.get_product(); + var taxes_ids = product.taxes_id; + var taxes = this.pos.taxes; + var product_taxes = []; + + _(taxes_ids).each(function(el){ + product_taxes.push(_.detect(taxes, function(t){ + return t.id === el; + })); + }); + + var all_taxes = this.compute_all(product_taxes, price_unit, 1, this.pos.currency.rounding); + + return round_pr(all_taxes.total_included * (1 - this.get_discount()/100), rounding); + } + }, + get_display_price: function(){ + if (this.pos.config.iface_tax_included === 'total') { + return this.get_price_with_tax(); + } else { + return this.get_base_price(); + } + }, + get_price_without_tax: function(){ + return this.get_all_prices().priceWithoutTax; + }, + get_price_with_tax: function(){ + return this.get_all_prices().priceWithTax; + }, + get_price_with_tax_before_discount: function () { + return this.get_all_prices().priceWithTaxBeforeDiscount; + }, + get_tax: function(){ + return this.get_all_prices().tax; + }, + get_applicable_taxes: function(){ + var i; + // Shenaningans because we need + // to keep the taxes ordering. + var ptaxes_ids = this.get_product().taxes_id; + var ptaxes_set = {}; + for (i = 0; i < ptaxes_ids.length; i++) { + ptaxes_set[ptaxes_ids[i]] = true; + } + var taxes = []; + for (i = 0; i < this.pos.taxes.length; i++) { + if (ptaxes_set[this.pos.taxes[i].id]) { + taxes.push(this.pos.taxes[i]); + } + } + return taxes; + }, + get_tax_details: function(){ + return this.get_all_prices().taxDetails; + }, + get_taxes: function(){ + var taxes_ids = this.get_product().taxes_id; + var taxes = []; + for (var i = 0; i < taxes_ids.length; i++) { + if (this.pos.taxes_by_id[taxes_ids[i]]) { + taxes.push(this.pos.taxes_by_id[taxes_ids[i]]); + } + } + return taxes; + }, + _map_tax_fiscal_position: function(tax, order = false) { + var self = this; + var current_order = order || this.pos.get_order(); + var order_fiscal_position = current_order && current_order.fiscal_position; + var taxes = []; + + if (order_fiscal_position) { + var tax_mappings = _.filter(order_fiscal_position.fiscal_position_taxes_by_id, function (fiscal_position_tax) { + return fiscal_position_tax.tax_src_id[0] === tax.id; + }); + + if (tax_mappings && tax_mappings.length) { + _.each(tax_mappings, function(tm) { + if (tm.tax_dest_id) { + taxes.push(self.pos.taxes_by_id[tm.tax_dest_id[0]]); + } + }); + } else{ + taxes.push(tax); + } + } else { + taxes.push(tax); + } + + return taxes; + }, + /** + * Mirror JS method of: + * _compute_amount in addons/account/models/account.py + */ + _compute_all: function(tax, base_amount, quantity, price_exclude) { + if(price_exclude === undefined) + var price_include = tax.price_include; + else + var price_include = !price_exclude; + if (tax.amount_type === 'fixed') { + var sign_base_amount = Math.sign(base_amount) || 1; + // Since base amount has been computed with quantity + // we take the abs of quantity + // Same logic as bb72dea98de4dae8f59e397f232a0636411d37ce + return tax.amount * sign_base_amount * Math.abs(quantity); + } + if (tax.amount_type === 'percent' && !price_include){ + return base_amount * tax.amount / 100; + } + if (tax.amount_type === 'percent' && price_include){ + return base_amount - (base_amount / (1 + tax.amount / 100)); + } + if (tax.amount_type === 'division' && !price_include) { + return base_amount / (1 - tax.amount / 100) - base_amount; + } + if (tax.amount_type === 'division' && price_include) { + return base_amount - (base_amount * (tax.amount / 100)); + } + return false; + }, + /** + * Mirror JS method of: + * compute_all in addons/account/models/account.py + * + * Read comments in the python side method for more details about each sub-methods. + */ + compute_all: function(taxes, price_unit, quantity, currency_rounding, handle_price_include=true) { + var self = this; + + // 1) Flatten the taxes. + + var _collect_taxes = function(taxes, all_taxes){ + taxes.sort(function (tax1, tax2) { + return tax1.sequence - tax2.sequence; + }); + _(taxes).each(function(tax){ + if(tax.amount_type === 'group') + all_taxes = _collect_taxes(tax.children_tax_ids, all_taxes); + else + all_taxes.push(tax); + }); + return all_taxes; + } + var collect_taxes = function(taxes){ + return _collect_taxes(taxes, []); + } + + taxes = collect_taxes(taxes); + + // 2) Deal with the rounding methods + + var round_tax = this.pos.company.tax_calculation_rounding_method != 'round_globally'; + + var initial_currency_rounding = currency_rounding; + if(!round_tax) + currency_rounding = currency_rounding * 0.00001; + + // 3) Iterate the taxes in the reversed sequence order to retrieve the initial base of the computation. + var recompute_base = function(base_amount, fixed_amount, percent_amount, division_amount){ + return (base_amount - fixed_amount) / (1.0 + percent_amount / 100.0) * (100 - division_amount) / 100; + } + + var base = round_pr(price_unit * quantity, initial_currency_rounding); + + var sign = 1; + if(base < 0){ + base = -base; + sign = -1; + } + + var total_included_checkpoints = {}; + var i = taxes.length - 1; + var store_included_tax_total = true; + + var incl_fixed_amount = 0.0; + var incl_percent_amount = 0.0; + var incl_division_amount = 0.0; + + var cached_tax_amounts = {}; + if (handle_price_include){ + _(taxes.reverse()).each(function(tax){ + if(tax.include_base_amount){ + base = recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount); + incl_fixed_amount = 0.0; + incl_percent_amount = 0.0; + incl_division_amount = 0.0; + store_included_tax_total = true; + } + if(tax.price_include){ + if(tax.amount_type === 'percent') + incl_percent_amount += tax.amount; + else if(tax.amount_type === 'division') + incl_division_amount += tax.amount; + else if(tax.amount_type === 'fixed') + incl_fixed_amount += Math.abs(quantity) * tax.amount + else{ + var tax_amount = self._compute_all(tax, base, quantity); + incl_fixed_amount += tax_amount; + cached_tax_amounts[i] = tax_amount; + } + if(store_included_tax_total){ + total_included_checkpoints[i] = base; + store_included_tax_total = false; + } + } + i -= 1; + }); + } + + var total_excluded = round_pr(recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount), initial_currency_rounding); + var total_included = total_excluded; + + // 4) Iterate the taxes in the sequence order to fill missing base/amount values. + + base = total_excluded; + + var skip_checkpoint = false; + + var taxes_vals = []; + i = 0; + var cumulated_tax_included_amount = 0; + _(taxes.reverse()).each(function(tax){ + if(!skip_checkpoint && tax.price_include && total_included_checkpoints[i] !== undefined){ + var tax_amount = total_included_checkpoints[i] - (base + cumulated_tax_included_amount); + cumulated_tax_included_amount = 0; + }else + var tax_amount = self._compute_all(tax, base, quantity, true); + + tax_amount = round_pr(tax_amount, currency_rounding); + + if(tax.price_include && total_included_checkpoints[i] === undefined) + cumulated_tax_included_amount += tax_amount; + + taxes_vals.push({ + 'id': tax.id, + 'name': tax.name, + 'amount': sign * tax_amount, + 'base': sign * round_pr(base, currency_rounding), + }); + + if(tax.include_base_amount){ + base += tax_amount; + if(!tax.price_include) + skip_checkpoint = true; + } + + total_included += tax_amount; + i += 1; + }); + + return { + 'taxes': taxes_vals, + 'total_excluded': sign * round_pr(total_excluded, this.pos.currency.rounding), + 'total_included': sign * round_pr(total_included, this.pos.currency.rounding), + } + }, + get_all_prices: function(){ + var self = this; + + var price_unit = this.get_unit_price() * (1.0 - (this.get_discount() / 100.0)); + var taxtotal = 0; + + var product = this.get_product(); + var taxes = this.pos.taxes; + var taxes_ids = _.filter(product.taxes_id, t => t in this.pos.taxes_by_id); + var taxdetail = {}; + var product_taxes = []; + + _(taxes_ids).each(function(el){ + var tax = _.detect(taxes, function(t){ + return t.id === el; + }); + product_taxes.push.apply(product_taxes, self._map_tax_fiscal_position(tax, self.order)); + }); + product_taxes = _.uniq(product_taxes, function(tax) { return tax.id; }); + + var all_taxes = this.compute_all(product_taxes, price_unit, this.get_quantity(), this.pos.currency.rounding); + var all_taxes_before_discount = this.compute_all(product_taxes, this.get_unit_price(), this.get_quantity(), this.pos.currency.rounding); + _(all_taxes.taxes).each(function(tax) { + taxtotal += tax.amount; + taxdetail[tax.id] = tax.amount; + }); + + return { + "priceWithTax": all_taxes.total_included, + "priceWithoutTax": all_taxes.total_excluded, + "priceSumTaxVoid": all_taxes.total_void, + "priceWithTaxBeforeDiscount": all_taxes_before_discount.total_included, + "tax": taxtotal, + "taxDetails": taxdetail, + }; + }, + display_discount_policy: function(){ + return this.order.pricelist.discount_policy; + }, + compute_fixed_price: function (price) { + var order = this.order; + if(order.fiscal_position) { + var taxes = this.get_taxes(); + var mapped_included_taxes = []; + var new_included_taxes = []; + var self = this; + _(taxes).each(function(tax) { + var line_taxes = self._map_tax_fiscal_position(tax, order); + if (line_taxes.length && line_taxes[0].price_include){ + new_included_taxes = new_included_taxes.concat(line_taxes); + } + if(tax.price_include && !_.contains(line_taxes, tax)){ + mapped_included_taxes.push(tax); + } + }); + + if (mapped_included_taxes.length > 0) { + if (new_included_taxes.length > 0) { + var price_without_taxes = this.compute_all(mapped_included_taxes, price, 1, order.pos.currency.rounding, true).total_excluded + return this.compute_all(new_included_taxes, price_without_taxes, 1, order.pos.currency.rounding, false).total_included + } + else{ + return this.compute_all(mapped_included_taxes, price, 1, order.pos.currency.rounding, true).total_excluded; + } + } + } + return price; + }, + get_fixed_lst_price: function(){ + return this.compute_fixed_price(this.get_lst_price()); + }, + get_lst_price: function(){ + return this.product.lst_price; + }, + set_lst_price: function(price){ + this.order.assert_editable(); + this.product.lst_price = round_di(parseFloat(price) || 0, this.pos.dp['Product Price']); + this.trigger('change',this); + }, + is_last_line: function() { + var order = this.pos.get_order(); + var last_id = Object.keys(order.orderlines._byId)[Object.keys(order.orderlines._byId).length-1]; + var selectedLine = order? order.selected_orderline: null; + + return !selectedLine ? false : last_id === selectedLine.cid; + }, +}); + +var OrderlineCollection = Backbone.Collection.extend({ + model: exports.Orderline, +}); + +exports.Packlotline = Backbone.Model.extend({ + defaults: { + lot_name: null + }, + initialize: function(attributes, options){ + this.order_line = options.order_line; + if (options.json) { + this.init_from_JSON(options.json); + return; + } + }, + + init_from_JSON: function(json) { + this.order_line = json.order_line; + this.set_lot_name(json.lot_name); + }, + + set_lot_name: function(name){ + this.set({lot_name : _.str.trim(name) || null}); + }, + + get_lot_name: function(){ + return this.get('lot_name'); + }, + + export_as_JSON: function(){ + return { + lot_name: this.get_lot_name(), + }; + }, + + add: function(){ + var order_line = this.order_line, + index = this.collection.indexOf(this); + var new_lot_model = new exports.Packlotline({}, {'order_line': this.order_line}); + this.collection.add(new_lot_model, {at: index + 1}); + return new_lot_model; + }, + + remove: function(){ + this.collection.remove(this); + } +}); + +var PacklotlineCollection = Backbone.Collection.extend({ + model: exports.Packlotline, + initialize: function(models, options) { + this.order_line = options.order_line; + }, + + get_valid_lots: function(){ + return this.filter(function(model){ + return model.get('lot_name'); + }); + }, + + set_quantity_by_lot: function() { + var valid_lots_quantity = this.get_valid_lots().length; + if (this.order_line.quantity < 0){ + valid_lots_quantity = -valid_lots_quantity; + } + this.order_line.set_quantity(valid_lots_quantity); + } +}); + +// Every Paymentline contains a cashregister and an amount of money. +exports.Paymentline = Backbone.Model.extend({ + initialize: function(attributes, options) { + this.pos = options.pos; + this.order = options.order; + this.amount = 0; + this.selected = false; + this.cashier_receipt = ''; + this.ticket = ''; + this.payment_status = ''; + this.card_type = ''; + this.cardholder_name = ''; + this.transaction_id = ''; + + if (options.json) { + this.init_from_JSON(options.json); + return; + } + this.payment_method = options.payment_method; + if (this.payment_method === undefined) { + throw new Error(_t('Please configure a payment method in your POS.')); + } + this.name = this.payment_method.name; + }, + init_from_JSON: function(json){ + this.amount = json.amount; + this.payment_method = this.pos.payment_methods_by_id[json.payment_method_id]; + this.name = this.payment_method.name; + this.payment_status = json.payment_status; + this.ticket = json.ticket; + this.card_type = json.card_type; + this.cardholder_name = json.cardholder_name; + this.transaction_id = json.transaction_id; + this.is_change = json.is_change; + }, + //sets the amount of money on this payment line + set_amount: function(value){ + this.order.assert_editable(); + this.amount = round_di(parseFloat(value) || 0, this.pos.currency.decimals); + if (this.pos.config.iface_customer_facing_display) this.pos.send_current_order_to_customer_facing_display(); + this.trigger('change',this); + }, + // returns the amount of money on this paymentline + get_amount: function(){ + return this.amount; + }, + get_amount_str: function(){ + return field_utils.format.float(this.amount, {digits: [69, this.pos.currency.decimals]}); + }, + set_selected: function(selected){ + if(this.selected !== selected){ + this.selected = selected; + this.trigger('change',this); + } + }, + /** + * returns {string} payment status. + */ + get_payment_status: function() { + return this.payment_status; + }, + + /** + * Set the new payment status. + * + * @param {string} value - new status. + */ + set_payment_status: function(value) { + this.payment_status = value; + this.trigger('change', this); + }, + + /** + * Check if paymentline is done. + * Paymentline is done if there is no payment status or the payment status is done. + */ + is_done: function() { + return this.get_payment_status() ? this.get_payment_status() === 'done' || this.get_payment_status() === 'reversed': true; + }, + + /** + * Set info to be printed on the cashier receipt. value should + * be compatible with both the QWeb and ESC/POS receipts. + * + * @param {string} value - receipt info + */ + set_cashier_receipt: function (value) { + this.cashier_receipt = value; + this.trigger('change', this); + }, + + /** + * Set additional info to be printed on the receipts. value should + * be compatible with both the QWeb and ESC/POS receipts. + * + * @param {string} value - receipt info + */ + set_receipt_info: function(value) { + this.ticket += value; + this.trigger('change', this); + }, + + // returns the associated cashregister + //exports as JSON for server communication + export_as_JSON: function(){ + return { + name: time.datetime_to_str(new Date()), + payment_method_id: this.payment_method.id, + amount: this.get_amount(), + payment_status: this.payment_status, + ticket: this.ticket, + card_type: this.card_type, + cardholder_name: this.cardholder_name, + transaction_id: this.transaction_id, + }; + }, + //exports as JSON for receipt printing + export_for_printing: function(){ + return { + cid: this.cid, + amount: this.get_amount(), + name: this.name, + ticket: this.ticket, + }; + }, + // If payment status is a non-empty string, then it is an electronic payment. + // TODO: There has to be a less confusing way to distinguish simple payments + // from electronic transactions. Perhaps use a flag? + is_electronic: function() { + return Boolean(this.get_payment_status()); + }, +}); + +var PaymentlineCollection = Backbone.Collection.extend({ + model: exports.Paymentline, +}); + +// An order more or less represents the content of a client's shopping cart (the OrderLines) +// plus the associated payment information (the Paymentlines) +// there is always an active ('selected') order in the Pos, a new one is created +// automaticaly once an order is completed and sent to the server. +exports.Order = Backbone.Model.extend({ + initialize: function(attributes,options){ + Backbone.Model.prototype.initialize.apply(this, arguments); + var self = this; + options = options || {}; + + this.locked = false; + this.pos = options.pos; + this.selected_orderline = undefined; + this.selected_paymentline = undefined; + this.screen_data = {}; // see Gui + this.temporary = options.temporary || false; + this.creation_date = new Date(); + this.to_invoice = false; + this.orderlines = new OrderlineCollection(); + this.paymentlines = new PaymentlineCollection(); + this.pos_session_id = this.pos.pos_session.id; + this.employee = this.pos.employee; + this.finalized = false; // if true, cannot be modified. + this.set_pricelist(this.pos.default_pricelist); + + this.set({ client: null }); + + this.uiState = { + ReceiptScreen: new Context({ + inputEmail: '', + // if null: not yet tried to send + // if false/true: tried sending email + emailSuccessful: null, + emailNotice: '', + }), + TipScreen: new Context({ + inputTipAmount: '', + }) + }; + + if (options.json) { + this.init_from_JSON(options.json); + } else { + this.sequence_number = this.pos.pos_session.sequence_number++; + this.uid = this.generate_unique_id(); + this.name = _.str.sprintf(_t("Order %s"), this.uid); + this.validation_date = undefined; + this.fiscal_position = _.find(this.pos.fiscal_positions, function(fp) { + return fp.id === self.pos.config.default_fiscal_position_id[0]; + }); + } + + this.on('change', function(){ this.save_to_db("order:change"); }, this); + this.orderlines.on('change', function(){ this.save_to_db("orderline:change"); }, this); + this.orderlines.on('add', function(){ this.save_to_db("orderline:add"); }, this); + this.orderlines.on('remove', function(){ this.save_to_db("orderline:remove"); }, this); + this.paymentlines.on('change', function(){ this.save_to_db("paymentline:change"); }, this); + this.paymentlines.on('add', function(){ this.save_to_db("paymentline:add"); }, this); + this.paymentlines.on('remove', function(){ this.save_to_db("paymentline:rem"); }, this); + + if (this.pos.config.iface_customer_facing_display) { + this.paymentlines.on('add', this.pos.send_current_order_to_customer_facing_display, this.pos); + this.paymentlines.on('remove', this.pos.send_current_order_to_customer_facing_display, this.pos); + } + + this.save_to_db(); + + return this; + }, + save_to_db: function(){ + if (!this.temporary && !this.locked) { + this.pos.db.save_unpaid_order(this); + } + }, + /** + * Initialize PoS order from a JSON string. + * + * If the order was created in another session, the sequence number should be changed so it doesn't conflict + * with orders in the current session. + * Else, the sequence number of the session should follow on the sequence number of the loaded order. + * + * @param {object} json JSON representing one PoS order. + */ + init_from_JSON: function(json) { + var client; + if (json.pos_session_id !== this.pos.pos_session.id) { + this.sequence_number = this.pos.pos_session.sequence_number++; + } else { + this.sequence_number = json.sequence_number; + this.pos.pos_session.sequence_number = Math.max(this.sequence_number+1,this.pos.pos_session.sequence_number); + } + this.session_id = this.pos.pos_session.id; + this.uid = json.uid; + this.name = _.str.sprintf(_t("Order %s"), this.uid); + this.validation_date = json.creation_date; + this.server_id = json.server_id ? json.server_id : false; + this.user_id = json.user_id; + + if (json.fiscal_position_id) { + var fiscal_position = _.find(this.pos.fiscal_positions, function (fp) { + return fp.id === json.fiscal_position_id; + }); + + if (fiscal_position) { + this.fiscal_position = fiscal_position; + } else { + console.error('ERROR: trying to load a fiscal position not available in the pos'); + } + } + + if (json.pricelist_id) { + this.pricelist = _.find(this.pos.pricelists, function (pricelist) { + return pricelist.id === json.pricelist_id; + }); + } else { + this.pricelist = this.pos.default_pricelist; + } + + if (json.partner_id) { + client = this.pos.db.get_partner_by_id(json.partner_id); + if (!client) { + console.error('ERROR: trying to load a partner not available in the pos'); + } + } else { + client = null; + } + this.set_client(client); + + this.temporary = false; // FIXME + this.to_invoice = false; // FIXME + + var orderlines = json.lines; + for (var i = 0; i < orderlines.length; i++) { + var orderline = orderlines[i][2]; + this.add_orderline(new exports.Orderline({}, {pos: this.pos, order: this, json: orderline})); + } + + var paymentlines = json.statement_ids; + for (var i = 0; i < paymentlines.length; i++) { + var paymentline = paymentlines[i][2]; + var newpaymentline = new exports.Paymentline({},{pos: this.pos, order: this, json: paymentline}); + this.paymentlines.add(newpaymentline); + + if (i === paymentlines.length - 1) { + this.select_paymentline(newpaymentline); + } + } + + // Tag this order as 'locked' if it is already paid. + this.locked = ['paid', 'done', 'invoiced'].includes(json.state); + this.state = json.state; + this.amount_return = json.amount_return; + this.account_move = json.account_move; + this.backendId = json.id; + this.isFromClosedSession = json.is_session_closed; + this.is_tipped = json.is_tipped || false; + this.tip_amount = json.tip_amount || 0; + }, + export_as_JSON: function() { + var orderLines, paymentLines; + orderLines = []; + this.orderlines.each(_.bind( function(item) { + return orderLines.push([0, 0, item.export_as_JSON()]); + }, this)); + paymentLines = []; + this.paymentlines.each(_.bind( function(item) { + return paymentLines.push([0, 0, item.export_as_JSON()]); + }, this)); + var json = { + name: this.get_name(), + amount_paid: this.get_total_paid() - this.get_change(), + amount_total: this.get_total_with_tax(), + amount_tax: this.get_total_tax(), + amount_return: this.get_change(), + lines: orderLines, + statement_ids: paymentLines, + pos_session_id: this.pos_session_id, + pricelist_id: this.pricelist ? this.pricelist.id : false, + partner_id: this.get_client() ? this.get_client().id : false, + user_id: this.pos.user.id, + uid: this.uid, + sequence_number: this.sequence_number, + creation_date: this.validation_date || this.creation_date, // todo: rename creation_date in master + fiscal_position_id: this.fiscal_position ? this.fiscal_position.id : false, + server_id: this.server_id ? this.server_id : false, + to_invoice: this.to_invoice ? this.to_invoice : false, + is_tipped: this.is_tipped || false, + tip_amount: this.tip_amount || 0, + }; + if (!this.is_paid && this.user_id) { + json.user_id = this.user_id; + } + return json; + }, + export_for_printing: function(){ + var orderlines = []; + var self = this; + + this.orderlines.each(function(orderline){ + orderlines.push(orderline.export_for_printing()); + }); + + // If order is locked (paid), the 'change' is saved as negative payment, + // and is flagged with is_change = true. A receipt that is printed first + // time doesn't show this negative payment so we filter it out. + var paymentlines = this.paymentlines.models + .filter(function (paymentline) { + return !paymentline.is_change; + }) + .map(function (paymentline) { + return paymentline.export_for_printing(); + }); + var client = this.get('client'); + var cashier = this.pos.get_cashier(); + var company = this.pos.company; + var date = new Date(); + + function is_html(subreceipt){ + return subreceipt ? (subreceipt.split('\n')[0].indexOf('<!DOCTYPE QWEB') >= 0) : false; + } + + function render_html(subreceipt){ + if (!is_html(subreceipt)) { + return subreceipt; + } else { + subreceipt = subreceipt.split('\n').slice(1).join('\n'); + var qweb = new QWeb2.Engine(); + qweb.debug = config.isDebug(); + qweb.default_dict = _.clone(QWeb.default_dict); + qweb.add_template('<templates><t t-name="subreceipt">'+subreceipt+'</t></templates>'); + + return qweb.render('subreceipt',{'pos':self.pos,'order':self, 'receipt': receipt}) ; + } + } + + var receipt = { + orderlines: orderlines, + paymentlines: paymentlines, + subtotal: this.get_subtotal(), + total_with_tax: this.get_total_with_tax(), + total_rounded: this.get_total_with_tax() + this.get_rounding_applied(), + total_without_tax: this.get_total_without_tax(), + total_tax: this.get_total_tax(), + total_paid: this.get_total_paid(), + total_discount: this.get_total_discount(), + rounding_applied: this.get_rounding_applied(), + tax_details: this.get_tax_details(), + change: this.locked ? this.amount_return : this.get_change(), + name : this.get_name(), + client: client ? client : null , + invoice_id: null, //TODO + cashier: cashier ? cashier.name : null, + precision: { + price: 2, + money: 2, + quantity: 3, + }, + date: { + year: date.getFullYear(), + month: date.getMonth(), + date: date.getDate(), // day of the month + day: date.getDay(), // day of the week + hour: date.getHours(), + minute: date.getMinutes() , + isostring: date.toISOString(), + localestring: this.formatted_validation_date, + }, + company:{ + email: company.email, + website: company.website, + company_registry: company.company_registry, + contact_address: company.partner_id[1], + vat: company.vat, + vat_label: company.country && company.country.vat_label || _t('Tax ID'), + name: company.name, + phone: company.phone, + logo: this.pos.company_logo_base64, + }, + currency: this.pos.currency, + }; + + if (is_html(this.pos.config.receipt_header)){ + receipt.header = ''; + receipt.header_html = render_html(this.pos.config.receipt_header); + } else { + receipt.header = this.pos.config.receipt_header || ''; + } + + if (is_html(this.pos.config.receipt_footer)){ + receipt.footer = ''; + receipt.footer_html = render_html(this.pos.config.receipt_footer); + } else { + receipt.footer = this.pos.config.receipt_footer || ''; + } + + return receipt; + }, + is_empty: function(){ + return this.orderlines.models.length === 0; + }, + generate_unique_id: function() { + // Generates a public identification number for the order. + // The generated number must be unique and sequential. They are made 12 digit long + // to fit into EAN-13 barcodes, should it be needed + + function zero_pad(num,size){ + var s = ""+num; + while (s.length < size) { + s = "0" + s; + } + return s; + } + return zero_pad(this.pos.pos_session.id,5) +'-'+ + zero_pad(this.pos.pos_session.login_number,3) +'-'+ + zero_pad(this.sequence_number,4); + }, + get_name: function() { + return this.name; + }, + assert_editable: function() { + if (this.finalized) { + throw new Error('Finalized Order cannot be modified'); + } + }, + /* ---- Order Lines --- */ + add_orderline: function(line){ + this.assert_editable(); + if(line.order){ + line.order.remove_orderline(line); + } + line.order = this; + this.orderlines.add(line); + this.select_orderline(this.get_last_orderline()); + }, + get_orderline: function(id){ + var orderlines = this.orderlines.models; + for(var i = 0; i < orderlines.length; i++){ + if(orderlines[i].id === id){ + return orderlines[i]; + } + } + return null; + }, + get_orderlines: function(){ + return this.orderlines.models; + }, + get_last_orderline: function(){ + return this.orderlines.at(this.orderlines.length -1); + }, + get_tip: function() { + var tip_product = this.pos.db.get_product_by_id(this.pos.config.tip_product_id[0]); + var lines = this.get_orderlines(); + if (!tip_product) { + return 0; + } else { + for (var i = 0; i < lines.length; i++) { + if (lines[i].get_product() === tip_product) { + return lines[i].get_unit_price(); + } + } + return 0; + } + }, + + initialize_validation_date: function () { + this.validation_date = new Date(); + this.formatted_validation_date = field_utils.format.datetime( + moment(this.validation_date), {}, {timezone: false}); + }, + + set_tip: function(tip) { + var tip_product = this.pos.db.get_product_by_id(this.pos.config.tip_product_id[0]); + var lines = this.get_orderlines(); + if (tip_product) { + for (var i = 0; i < lines.length; i++) { + if (lines[i].get_product() === tip_product) { + lines[i].set_unit_price(tip); + lines[i].set_lst_price(tip); + lines[i].price_manually_set = true; + lines[i].order.tip_amount = tip; + return; + } + } + return this.add_product(tip_product, { + is_tip: true, + quantity: 1, + price: tip, + lst_price: tip, + extras: {price_manually_set: true}, + }); + } + }, + set_pricelist: function (pricelist) { + var self = this; + this.pricelist = pricelist; + + var lines_to_recompute = _.filter(this.get_orderlines(), function (line) { + return ! line.price_manually_set; + }); + _.each(lines_to_recompute, function (line) { + line.set_unit_price(line.product.get_price(self.pricelist, line.get_quantity(), line.get_price_extra())); + self.fix_tax_included_price(line); + }); + this.trigger('change'); + }, + remove_orderline: function( line ){ + this.assert_editable(); + this.orderlines.remove(line); + this.select_orderline(this.get_last_orderline()); + }, + + fix_tax_included_price: function(line){ + line.set_unit_price(line.compute_fixed_price(line.price)); + }, + + add_product: function(product, options){ + if(this._printed){ + this.destroy(); + return this.pos.get_order().add_product(product, options); + } + this.assert_editable(); + options = options || {}; + var line = new exports.Orderline({}, {pos: this.pos, order: this, product: product}); + this.fix_tax_included_price(line); + + if(options.quantity !== undefined){ + line.set_quantity(options.quantity); + } + + if (options.price_extra !== undefined){ + line.price_extra = options.price_extra; + line.set_unit_price(line.product.get_price(this.pricelist, line.get_quantity(), options.price_extra)); + this.fix_tax_included_price(line); + } + + if(options.price !== undefined){ + line.set_unit_price(options.price); + this.fix_tax_included_price(line); + } + + if(options.lst_price !== undefined){ + line.set_lst_price(options.lst_price); + } + + if(options.discount !== undefined){ + line.set_discount(options.discount); + } + + if (options.description !== undefined){ + line.description += options.description; + } + + if(options.extras !== undefined){ + for (var prop in options.extras) { + line[prop] = options.extras[prop]; + } + } + if (options.is_tip) { + this.is_tipped = true; + this.tip_amount = options.price; + } + + var to_merge_orderline; + for (var i = 0; i < this.orderlines.length; i++) { + if(this.orderlines.at(i).can_be_merged_with(line) && options.merge !== false){ + to_merge_orderline = this.orderlines.at(i); + } + } + if (to_merge_orderline){ + to_merge_orderline.merge(line); + this.select_orderline(to_merge_orderline); + } else { + this.orderlines.add(line); + this.select_orderline(this.get_last_orderline()); + } + + if (options.draftPackLotLines) { + this.selected_orderline.setPackLotLines(options.draftPackLotLines); + } + if (this.pos.config.iface_customer_facing_display) { + this.pos.send_current_order_to_customer_facing_display(); + } + }, + get_selected_orderline: function(){ + return this.selected_orderline; + }, + select_orderline: function(line){ + if(line){ + if(line !== this.selected_orderline){ + // if line (new line to select) is not the same as the old + // selected_orderline, then we set the old line to false, + // and set the new line to true. Also, set the new line as + // the selected_orderline. + if(this.selected_orderline){ + this.selected_orderline.set_selected(false); + } + this.selected_orderline = line; + this.selected_orderline.set_selected(true); + } + }else{ + this.selected_orderline = undefined; + } + }, + deselect_orderline: function(){ + if(this.selected_orderline){ + this.selected_orderline.set_selected(false); + this.selected_orderline = undefined; + } + }, + + /* ---- Payment Lines --- */ + add_paymentline: function(payment_method) { + this.assert_editable(); + var newPaymentline = new exports.Paymentline({},{order: this, payment_method:payment_method, pos: this.pos}); + newPaymentline.set_amount(this.get_due()); + this.paymentlines.add(newPaymentline); + this.select_paymentline(newPaymentline); + if(this.pos.config.cash_rounding){ + this.selected_paymentline.set_amount(0); + this.selected_paymentline.set_amount(this.get_due()); + } + return newPaymentline; + }, + get_paymentlines: function(){ + return this.paymentlines.models; + }, + /** + * Retrieve the paymentline with the specified cid + * + * @param {String} cid + */ + get_paymentline: function (cid) { + var lines = this.get_paymentlines(); + return lines.find(function (line) { + return line.cid === cid; + }); + }, + remove_paymentline: function(line){ + this.assert_editable(); + if(this.selected_paymentline === line){ + this.select_paymentline(undefined); + } + this.paymentlines.remove(line); + }, + clean_empty_paymentlines: function() { + var lines = this.paymentlines.models; + var empty = []; + for ( var i = 0; i < lines.length; i++) { + if (!lines[i].get_amount()) { + empty.push(lines[i]); + } + } + for ( var i = 0; i < empty.length; i++) { + this.remove_paymentline(empty[i]); + } + }, + select_paymentline: function(line){ + if(line !== this.selected_paymentline){ + if(this.selected_paymentline){ + this.selected_paymentline.set_selected(false); + } + this.selected_paymentline = line; + if(this.selected_paymentline){ + this.selected_paymentline.set_selected(true); + } + this.trigger('change:selected_paymentline',this.selected_paymentline); + } + }, + electronic_payment_in_progress: function() { + return this.get_paymentlines() + .some(function(pl) { + if (pl.payment_status) { + return !['done', 'reversed'].includes(pl.payment_status); + } else { + return false; + } + }); + }, + /** + * Stops a payment on the terminal if one is running + */ + stop_electronic_payment: function () { + var lines = this.get_paymentlines(); + var line = lines.find(function (line) { + var status = line.get_payment_status(); + return status && !['done', 'reversed', 'reversing', 'pending', 'retry'].includes(status); + }); + if (line) { + line.set_payment_status('waitingCancel'); + line.payment_method.payment_terminal.send_payment_cancel(this, line.cid).finally(function () { + line.set_payment_status('retry'); + }); + } + }, + /* ---- Payment Status --- */ + get_subtotal: function(){ + return round_pr(this.orderlines.reduce((function(sum, orderLine){ + return sum + orderLine.get_display_price(); + }), 0), this.pos.currency.rounding); + }, + get_total_with_tax: function() { + return this.get_total_without_tax() + this.get_total_tax(); + }, + get_total_without_tax: function() { + return round_pr(this.orderlines.reduce((function(sum, orderLine) { + return sum + orderLine.get_price_without_tax(); + }), 0), this.pos.currency.rounding); + }, + get_total_discount: function() { + return round_pr(this.orderlines.reduce((function(sum, orderLine) { + sum += (orderLine.get_unit_price() * (orderLine.get_discount()/100) * orderLine.get_quantity()); + if (orderLine.display_discount_policy() === 'without_discount'){ + sum += ((orderLine.get_lst_price() - orderLine.get_unit_price()) * orderLine.get_quantity()); + } + return sum; + }), 0), this.pos.currency.rounding); + }, + get_total_tax: function() { + if (this.pos.company.tax_calculation_rounding_method === "round_globally") { + // As always, we need: + // 1. For each tax, sum their amount across all order lines + // 2. Round that result + // 3. Sum all those rounded amounts + var groupTaxes = {}; + this.orderlines.each(function (line) { + var taxDetails = line.get_tax_details(); + var taxIds = Object.keys(taxDetails); + for (var t = 0; t<taxIds.length; t++) { + var taxId = taxIds[t]; + if (!(taxId in groupTaxes)) { + groupTaxes[taxId] = 0; + } + groupTaxes[taxId] += taxDetails[taxId]; + } + }); + + var sum = 0; + var taxIds = Object.keys(groupTaxes); + for (var j = 0; j<taxIds.length; j++) { + var taxAmount = groupTaxes[taxIds[j]]; + sum += round_pr(taxAmount, this.pos.currency.rounding); + } + return sum; + } else { + return round_pr(this.orderlines.reduce((function(sum, orderLine) { + return sum + orderLine.get_tax(); + }), 0), this.pos.currency.rounding); + } + }, + get_total_paid: function() { + return round_pr(this.paymentlines.reduce((function(sum, paymentLine) { + if (paymentLine.is_done()) { + sum += paymentLine.get_amount(); + } + return sum; + }), 0), this.pos.currency.rounding); + }, + get_tax_details: function(){ + var details = {}; + var fulldetails = []; + + this.orderlines.each(function(line){ + var ldetails = line.get_tax_details(); + for(var id in ldetails){ + if(ldetails.hasOwnProperty(id)){ + details[id] = (details[id] || 0) + ldetails[id]; + } + } + }); + + for(var id in details){ + if(details.hasOwnProperty(id)){ + fulldetails.push({amount: details[id], tax: this.pos.taxes_by_id[id], name: this.pos.taxes_by_id[id].name}); + } + } + + return fulldetails; + }, + // Returns a total only for the orderlines with products belonging to the category + get_total_for_category_with_tax: function(categ_id){ + var total = 0; + var self = this; + + if (categ_id instanceof Array) { + for (var i = 0; i < categ_id.length; i++) { + total += this.get_total_for_category_with_tax(categ_id[i]); + } + return total; + } + + this.orderlines.each(function(line){ + if ( self.pos.db.category_contains(categ_id,line.product.id) ) { + total += line.get_price_with_tax(); + } + }); + + return total; + }, + get_total_for_taxes: function(tax_id){ + var total = 0; + + if (!(tax_id instanceof Array)) { + tax_id = [tax_id]; + } + + var tax_set = {}; + + for (var i = 0; i < tax_id.length; i++) { + tax_set[tax_id[i]] = true; + } + + this.orderlines.each(function(line){ + var taxes_ids = line.get_product().taxes_id; + for (var i = 0; i < taxes_ids.length; i++) { + if (tax_set[taxes_ids[i]]) { + total += line.get_price_with_tax(); + return; + } + } + }); + + return total; + }, + get_change: function(paymentline) { + if (!paymentline) { + var change = this.get_total_paid() - this.get_total_with_tax() - this.get_rounding_applied(); + } else { + var change = -this.get_total_with_tax(); + var lines = this.paymentlines.models; + for (var i = 0; i < lines.length; i++) { + change += lines[i].get_amount(); + if (lines[i] === paymentline) { + break; + } + } + } + return round_pr(Math.max(0,change), this.pos.currency.rounding); + }, + get_due: function(paymentline) { + if (!paymentline) { + var due = this.get_total_with_tax() - this.get_total_paid() + this.get_rounding_applied(); + } else { + var due = this.get_total_with_tax(); + var lines = this.paymentlines.models; + for (var i = 0; i < lines.length; i++) { + if (lines[i] === paymentline) { + break; + } else { + due -= lines[i].get_amount(); + } + } + } + return round_pr(due, this.pos.currency.rounding); + }, + get_rounding_applied: function() { + if(this.pos.config.cash_rounding) { + const only_cash = this.pos.config.only_round_cash_method; + const paymentlines = this.get_paymentlines(); + const last_line = paymentlines ? paymentlines[paymentlines.length-1]: false; + const last_line_is_cash = last_line ? last_line.payment_method.is_cash_count == true: false; + if (!only_cash || (only_cash && last_line_is_cash)) { + var remaining = this.get_total_with_tax() - this.get_total_paid(); + var total = round_pr(remaining, this.pos.cash_rounding[0].rounding); + var sign = remaining > 0 ? 1.0 : -1.0; + + var rounding_applied = total - remaining; + rounding_applied *= sign; + // because floor and ceil doesn't include decimals in calculation, we reuse the value of the half-up and adapt it. + if (utils.float_is_zero(rounding_applied, this.pos.currency.decimals)){ + // https://xkcd.com/217/ + return 0; + } + else if(this.pos.cash_rounding[0].rounding_method === "UP" && rounding_applied < 0 && remaining > 0) { + rounding_applied += this.pos.cash_rounding[0].rounding; + } + else if(this.pos.cash_rounding[0].rounding_method === "UP" && rounding_applied > 0 && remaining < 0) { + rounding_applied -= this.pos.cash_rounding[0].rounding; + } + else if(this.pos.cash_rounding[0].rounding_method === "DOWN" && rounding_applied > 0 && remaining > 0){ + rounding_applied -= this.pos.cash_rounding[0].rounding; + } + else if(this.pos.cash_rounding[0].rounding_method === "DOWN" && rounding_applied < 0 && remaining < 0){ + rounding_applied += this.pos.cash_rounding[0].rounding; + } + return sign * rounding_applied; + } + else { + return 0; + } + } + return 0; + }, + has_not_valid_rounding: function() { + if(!this.pos.config.cash_rounding) + return false; + + const only_cash = this.pos.config.only_round_cash_method; + var lines = this.paymentlines.models; + + for(var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (only_cash && !line.payment_method.is_cash_count) + continue; + + if(!utils.float_is_zero(line.amount - round_pr(line.amount, this.pos.cash_rounding[0].rounding), 6)) + return line; + } + return false; + }, + is_paid: function(){ + return this.get_due() <= 0 && this.check_paymentlines_rounding(); + }, + is_paid_with_cash: function(){ + return !!this.paymentlines.find( function(pl){ + return pl.payment_method.is_cash_count; + }); + }, + check_paymentlines_rounding: function() { + if(this.pos.config.cash_rounding) { + var cash_rounding = this.pos.cash_rounding[0].rounding; + var default_rounding = this.pos.currency.rounding; + for(var id in this.get_paymentlines()) { + var line = this.get_paymentlines()[id]; + var diff = round_pr(round_pr(line.amount, cash_rounding) - round_pr(line.amount, default_rounding), default_rounding); + if(diff && line.payment_method.is_cash_count) { + return false; + } else if(!this.pos.config.only_round_cash_method && diff) { + return false; + } + } + return true; + } + return true; + }, + finalize: function(){ + this.destroy(); + }, + destroy: function(){ + Backbone.Model.prototype.destroy.apply(this,arguments); + this.pos.db.remove_unpaid_order(this); + }, + /* ---- Invoice --- */ + set_to_invoice: function(to_invoice) { + this.assert_editable(); + this.to_invoice = to_invoice; + }, + is_to_invoice: function(){ + return this.to_invoice; + }, + /* ---- Client / Customer --- */ + // the client related to the current order. + set_client: function(client){ + this.assert_editable(); + this.set('client',client); + }, + get_client: function(){ + return this.get('client'); + }, + get_client_name: function(){ + var client = this.get('client'); + return client ? client.name : ""; + }, + get_cardholder_name: function(){ + var card_payment_line = this.paymentlines.find(pl => pl.cardholder_name); + return card_payment_line ? card_payment_line.cardholder_name : ""; + }, + /* ---- Screen Status --- */ + // the order also stores the screen status, as the PoS supports + // different active screens per order. This method is used to + // store the screen status. + set_screen_data: function(value){ + this.screen_data['value'] = value; + }, + //see set_screen_data + get_screen_data: function(){ + const screen = this.screen_data['value']; + // If no screen data is saved + // no payment line -> product screen + // with payment line -> payment screen + if (!screen) { + if (this.get_paymentlines().length > 0) return { name: 'PaymentScreen' }; + return { name: 'ProductScreen' }; + } + if (!this.finalized && this.get_paymentlines().length > 0) { + return { name: 'PaymentScreen' }; + } + return screen; + }, + wait_for_push_order: function () { + return false; + }, + /** + * @returns {Object} object to use as props for instantiating OrderReceipt. + */ + getOrderReceiptEnv: function() { + // Formerly get_receipt_render_env defined in ScreenWidget. + return { + order: this, + receipt: this.export_for_printing(), + orderlines: this.get_orderlines(), + paymentlines: this.get_paymentlines(), + }; + }, + updatePricelist: function(newClient) { + let newClientPricelist, newClientFiscalPosition; + const defaultFiscalPosition = this.pos.fiscal_positions.find( + (position) => position.id === this.pos.config.default_fiscal_position_id[0] + ); + if (newClient) { + newClientFiscalPosition = newClient.property_account_position_id + ? this.pos.fiscal_positions.find( + (position) => position.id === newClient.property_account_position_id[0] + ) + : defaultFiscalPosition; + newClientPricelist = + this.pos.pricelists.find( + (pricelist) => pricelist.id === newClient.property_product_pricelist[0] + ) || this.pos.default_pricelist; + } else { + newClientFiscalPosition = defaultFiscalPosition; + newClientPricelist = this.pos.default_pricelist; + } + this.fiscal_position = newClientFiscalPosition; + this.set_pricelist(newClientPricelist); + } +}); + +var OrderCollection = Backbone.Collection.extend({ + model: exports.Order, +}); + +// exports = { +// PosModel: PosModel, +// load_fields: load_fields, +// load_models: load_models, +// Orderline: Orderline, +// Order: Order, +// }; +return exports; + +}); diff --git a/addons/point_of_sale/static/src/js/payment.js b/addons/point_of_sale/static/src/js/payment.js new file mode 100644 index 00000000..ae73f552 --- /dev/null +++ b/addons/point_of_sale/static/src/js/payment.js @@ -0,0 +1,95 @@ +odoo.define('point_of_sale.PaymentInterface', function (require) { +"use strict"; + +var core = require('web.core'); + +/** + * Implement this interface to support a new payment method in the POS: + * + * var PaymentInterface = require('point_of_sale.PaymentInterface'); + * var MyPayment = PaymentInterface.extend({ + * ... + * }) + * + * To connect the interface to the right payment methods register it: + * + * var models = require('point_of_sale.models'); + * models.register_payment_method('my_payment', MyPayment); + * + * my_payment is the technical name of the added selection in + * use_payment_terminal. + * + * If necessary new fields can be loaded on any model: + * + * models.load_fields('pos.payment.method', ['new_field1', 'new_field2']); + */ +var PaymentInterface = core.Class.extend({ + init: function (pos, payment_method) { + this.pos = pos; + this.payment_method = payment_method; + this.supports_reversals = false; + }, + + /** + * Call this function to enable UI elements that allow a user to + * reverse a payment. This requires that you implement + * send_payment_reversal. + */ + enable_reversals: function () { + this.supports_reversals = true; + }, + + /** + * Called when a user clicks the "Send" button in the + * interface. This should initiate a payment request and return a + * Promise that resolves when the final status of the payment line + * is set with set_payment_status. + * + * For successful transactions set_receipt_info() should be used + * to set info that should to be printed on the receipt. You + * should also set card_type and transaction_id on the line for + * successful transactions. + * + * @param {string} cid - The id of the paymentline + * @returns {Promise} resolved with a boolean that is false when + * the payment should be retried. Rejected when the status of the + * paymentline will be manually updated. + */ + send_payment_request: function (cid) {}, + + /** + * Called when a user removes a payment line that's still waiting + * on send_payment_request to complete. Should execute some + * request to ensure the current payment request is + * cancelled. This is not to refund payments, only to cancel + * them. The payment line being cancelled will be deleted + * automatically after the returned promise resolves. + * + * @param {} order - The order of the paymentline + * @param {string} cid - The id of the paymentline + * @returns {Promise} + */ + send_payment_cancel: function (order, cid) {}, + + /** + * This is an optional method. When implementing this make sure to + * call enable_reversals() in the constructor of your + * interface. This should reverse a previous payment with status + * 'done'. The paymentline will be removed based on returned + * Promise. + * + * @param {string} cid - The id of the paymentline + * @returns {Promise} returns true if the reversal was successful. + */ + send_payment_reversal: function (cid) {}, + + /** + * Called when the payment screen in the POS is closed (by + * e.g. clicking the "Back" button). Could be used to cancel in + * progress payments. + */ + close: function () {}, +}); + +return PaymentInterface; +}); diff --git a/addons/point_of_sale/static/src/js/printers.js b/addons/point_of_sale/static/src/js/printers.js new file mode 100644 index 00000000..20ea4454 --- /dev/null +++ b/addons/point_of_sale/static/src/js/printers.js @@ -0,0 +1,172 @@ +odoo.define('point_of_sale.Printer', function (require) { +"use strict"; + +var Session = require('web.Session'); +var core = require('web.core'); +const { Gui } = require('point_of_sale.Gui'); +var _t = core._t; + +// IMPROVEMENT: This is too much. We can get away from this class. +class PrintResult { + constructor({ successful, message }) { + this.successful = successful; + this.message = message; + } +} + +class PrintResultGenerator { + IoTActionError() { + return new PrintResult({ + successful: false, + message: { + title: _t('Connection to IoT Box failed'), + body: _t('Please check if the IoT Box is still connected.'), + }, + }); + } + IoTResultError() { + return new PrintResult({ + successful: false, + message: { + title: _t('Connection to the printer failed'), + body: _t('Please check if the printer is still connected.'), + }, + }); + } + Successful() { + return new PrintResult({ + successful: true, + }); + } +} + +var PrinterMixin = { + init: function() { + this.receipt_queue = []; + this.printResultGenerator = new PrintResultGenerator(); + }, + + /** + * Add the receipt to the queue of receipts to be printed and process it. + * We clear the print queue if printing is not successful. + * @param {String} receipt: The receipt to be printed, in HTML + * @returns {PrintResult} + */ + print_receipt: async function(receipt) { + if (receipt) { + this.receipt_queue.push(receipt); + } + let image, sendPrintResult; + while (this.receipt_queue.length > 0) { + receipt = this.receipt_queue.shift(); + image = await this.htmlToImg(receipt); + try { + sendPrintResult = await this.send_printing_job(image); + } catch (error) { + // Error in communicating to the IoT box. + this.receipt_queue.length = 0; + return this.printResultGenerator.IoTActionError(); + } + // rpc call is okay but printing failed because + // IoT box can't find a printer. + if (!sendPrintResult || sendPrintResult.result === false) { + this.receipt_queue.length = 0; + return this.printResultGenerator.IoTResultError(); + } + } + return this.printResultGenerator.Successful(); + }, + + /** + * Generate a jpeg image from a canvas + * @param {DOMElement} canvas + */ + process_canvas: function (canvas) { + return canvas.toDataURL('image/jpeg').replace('data:image/jpeg;base64,',''); + }, + + /** + * Renders the html as an image to print it + * @param {String} receipt: The receipt to be printed, in HTML + */ + htmlToImg: function (receipt) { + var self = this; + $('.pos-receipt-print').html(receipt); + var promise = new Promise(function (resolve, reject) { + self.receipt = $('.pos-receipt-print>.pos-receipt'); + html2canvas(self.receipt[0], { + onparsed: function(queue) { + queue.stack.ctx.height = Math.ceil(self.receipt.outerHeight() + self.receipt.offset().top); + }, + onrendered: function (canvas) { + $('.pos-receipt-print').empty(); + resolve(self.process_canvas(canvas)); + }, + letterRendering: true, + }) + }); + return promise; + }, + + _onIoTActionResult: function (data){ + if (this.pos && (data === false || data.result === false)) { + Gui.showPopup('ErrorPopup',{ + 'title': _t('Connection to the printer failed'), + 'body': _t('Please check if the printer is still connected.'), + }); + } + }, + + _onIoTActionFail: function () { + if (this.pos) { + Gui.showPopup('ErrorPopup',{ + 'title': _t('Connection to IoT Box failed'), + 'body': _t('Please check if the IoT Box is still connected.'), + }); + } + }, +} + +var Printer = core.Class.extend(PrinterMixin, { + init: function (url, pos) { + PrinterMixin.init.call(this, arguments); + this.pos = pos; + this.connection = new Session(undefined, url || 'http://localhost:8069', { use_cors: true}); + }, + + /** + * Sends a command to the connected proxy to open the cashbox + * (the physical box where you store the cash). Updates the status of + * the printer with the answer from the proxy. + */ + open_cashbox: function () { + var self = this; + return this.connection.rpc('/hw_proxy/default_printer_action', { + data: { + action: 'cashbox' + } + }).then(self._onIoTActionResult.bind(self)) + .guardedCatch(self._onIoTActionFail.bind(self)); + }, + + /** + * Sends the printing command the connected proxy + * @param {String} img : The receipt to be printed, as an image + */ + send_printing_job: function (img) { + return this.connection.rpc('/hw_proxy/default_printer_action', { + data: { + action: 'print_receipt', + receipt: img, + } + }); + }, +}); + +return { + PrinterMixin: PrinterMixin, + Printer: Printer, + PrintResult, + PrintResultGenerator, +} +}); diff --git a/addons/point_of_sale/static/src/js/tours/point_of_sale.js b/addons/point_of_sale/static/src/js/tours/point_of_sale.js new file mode 100644 index 00000000..49a6fc0a --- /dev/null +++ b/addons/point_of_sale/static/src/js/tours/point_of_sale.js @@ -0,0 +1,31 @@ +odoo.define('point_of_sale.tour', function(require) { +"use strict"; + +var core = require('web.core'); +var tour = require('web_tour.tour'); + +var _t = core._t; + +tour.register('point_of_sale_tour', { + url: "/web", + rainbowMan: false, + sequence: 45, +}, [tour.stepUtils.showAppsMenuItem(), { + trigger: '.o_app[data-menu-xmlid="point_of_sale.menu_point_root"]', + content: _t("Ready to launch your <b>point of sale</b>?"), + width: 215, + position: 'right', + edition: 'community' +}, { + trigger: '.o_app[data-menu-xmlid="point_of_sale.menu_point_root"]', + content: _t("Ready to launch your <b>point of sale</b>?"), + width: 215, + position: 'bottom', + edition: 'enterprise' +}, { + trigger: ".o_pos_kanban button.oe_kanban_action_button", + content: _t("<p>Ready to have a look at the <b>POS Interface</b>? Let's start our first session.</p>"), + position: "bottom" +}]); + +}); diff --git a/addons/point_of_sale/static/src/js/utils.js b/addons/point_of_sale/static/src/js/utils.js new file mode 100644 index 00000000..7aa7b35e --- /dev/null +++ b/addons/point_of_sale/static/src/js/utils.js @@ -0,0 +1,49 @@ +odoo.define('point_of_sale.utils', function (require) { + 'use strict'; + + const { EventBus } = owl.core; + + function getFileAsText(file) { + return new Promise((resolve, reject) => { + if (!file) { + reject(); + } else { + const reader = new FileReader(); + reader.addEventListener('load', function () { + resolve(reader.result); + }); + reader.addEventListener('abort', reject); + reader.addEventListener('error', reject); + reader.readAsText(file); + } + }); + } + + /** + * This global variable is used by nextFrame to store the timer and + * be able to cancel it before another request for animation frame. + */ + let timer = null; + + /** + * Wait for the next animation frame to finish. + */ + const nextFrame = () => { + return new Promise((resolve) => { + cancelAnimationFrame(timer); + timer = requestAnimationFrame(() => { + resolve(); + }); + }); + }; + + function isRpcError(error) { + return ( + !(error instanceof Error) && + error.message && + [100, 200, 404, -32098].includes(error.message.code) + ); + } + + return { getFileAsText, nextFrame, isRpcError, posbus: new EventBus() }; +}); diff --git a/addons/point_of_sale/static/src/scss/customer_facing_display.scss b/addons/point_of_sale/static/src/scss/customer_facing_display.scss new file mode 100644 index 00000000..b1c85ac6 --- /dev/null +++ b/addons/point_of_sale/static/src/scss/customer_facing_display.scss @@ -0,0 +1,475 @@ +// out: ../css/customer_facing_display.css, sourcemap: false, compress: false + +// =========== Variables =========== +$color-gray-lighter: #f6f6f6; +$color-gray-dark: #3E3E3E; + +// =========== Animations =========== +@keyframes item_in { + 0% { opacity: 0; margin-top: -30px; } + 50% { margin-top: 0; } + 100% { opacity: 1; } +} +@-webkit-keyframes item_in { + 0% { opacity: 0; margin-top: -30px; } + 50% { margin-top: 0; } + 100% { opacity: 1; } +} + +// =========== MIXINS =========== +@mixin pos-bg { + background-position: center top; + background-size: contain; + background-repeat: no-repeat; + + &[style*="url(http://placehold.it"] { + // Add a bg-color in case we are using a pleceholder. + // This will help the user to identify the right image dimension + // before apply customizations. + background-color: #ccc; + } +} + +// =========== VENDOR PREFIX =========== +@mixin flex-display { + -webkit-display: flex; + -moz-display: flex; + -ms-display: flex; + -o-display: flex; + display: flex; +} +@mixin flex-direction($direction) { + -webkit-flex-direction: $direction; + -moz-flex-direction: $direction; + -ms-flex-direction: $direction; + -o-flex-direction: $direction; + flex-direction: $direction; +}; +@mixin flex-grow($grow) { + -webkit-box-flex: $grow; + -webkit-flex-grow: $grow; + -moz-box-flex: $grow; + -ms-flex-positive: $grow; + flex-grow: $grow; +} +@mixin flex($flex) { + -webkit-box-flex: $flex; + -webkit-flex: $flex; + -moz-box-flex: $flex; + -ms-flex: $flex; + flex: $flex; +} +@mixin align-items($align) { + -webkit-box-align: $align; + -webkit-align-items: $align; + -moz-box-align: $align; + -ms-flex-align: $align; + -ms-grid-row-align: $align; + align-items: $align; +}; +@mixin justify-content($justify) { + -webkit-box-pack: $justify; + -webkit-justify-content: $justify; + -moz-box-pack: $justify; + -ms-flex-pack: $justify; + justify-content: $justify; +} +@mixin flex-wrap($wrap) { + -webkit-flex-wrap: $wrap; + -ms-flex-wrap: $wrap; + flex-wrap: $wrap; +} + + +// =========== MAIN LAYOUT =========== +body { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; + font-smooth: always; + + .pos-customer_facing_display { + background-color: $color-gray-lighter; + font-size: 2vw; + font-family: Futura, HelveticaNeue, Helvetica, Arial, "Lucida Grande", sans-serif; + font-weight: 300; + width: 100%; + height: 100%; + padding: 0; + @include flex-display; + @include flex-direction(row); + + .pos-customer_products, + .pos-payment_info { + height: 100%; + padding: 2%; + @include flex-display; + @include flex-direction(column); + @include flex-grow(1); + } + + .pos_orderlines { + width: 100%; + height: 100%; + @include flex-display; + @include flex-direction(column); + + .pos_orderlines_list { + overflow-y: scroll; + padding-right: 1.5vw; + position: relative; + height: 100%; + } + + .pos_orderlines_item { + margin-bottom: 1vw; + padding: 1%; + border-radius: 0.3vw; + height: auto; + @include flex(0 1 auto); + @include flex-display; + @include flex-direction(row); + @include align-items(center); + + &:last-of-type { + animation: item_in 1s ease; + } + + &.pos_orderlines_header { + background-color: transparent; + box-shadow: none; + animation: none; + + > div { + &, + &:last-child { + border-left-width: 0; + text-align: center; + font-size: 70%; + font-weight: normal; + } + } + + > div:last-child { + text-align: left; + } + } + + > div { + width: 5%; + text-align: left; + margin-right: 4%; + font-size: 80%; + @include flex-grow(1); + + &:first-child { + margin-right: 2%; + @include flex(1 1 1%); + } + + &:nth-child(2) { + width: 40%; + border-left: 1px solid; + padding-left: 2%; + } + + &:nth-child(3) { + text-align: center; + } + + &:last-child { + margin-right: 0; + font-weight: bold; + } + + div { + background-position: center; + background-size: cover; + padding-top: 75%; + display: block; + } + } + } + } + + .pos-payment_info { + max-width: 30%; + padding: 2% 2% 1% 2%; + @include flex-direction(column); + @include justify-content(space-between); + + .pos-adv, + .pos-company_logo { + @include pos-bg; + } + + .pos-company_logo { + background-image: url(/logo); + margin-bottom: 10%; + @include flex(0 0 20%); + } + + .pos-adv { + margin-bottom: 5%; + border-bottom: 10px solid transparent; + box-shadow: 0 1px rgba($color-gray-lighter, 0.2); + @include flex(1 1 60%); + } + + .pos-payment_info_details{ + .pos-total, + .pos-paymentlines { + @include flex-direction(row); + @include flex-display; + @include flex-wrap(wrap); + @include justify-content(space-between); + @include align-items(baseline); + + > div { + @include flex(1 0 48%); + + &:nth-child(even) { + font-weight: bold; + font-size: 120%; + margin-right: 0 + } + } + } + + .pos-total { + font-size: 2vw; + } + + .pos-paymentlines { + margin-top: 2%; + font-size: 1.5vw; + line-height: 1.3; + } + + .pos-odoo_logo_container { + text-align: right; + margin-top: 10%; + @include flex(0 1 auto); + + img { + max-width: 40px; + } + } + } + } + } + + // =========== PORTRAIT LAYOUT =========== + @media all and (orientation: portrait) { + .pos-customer_facing_display { + font-size: 2vh; + height: 100%; + @include flex-direction(column); + + &:before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 17vh; + } + + .pos-payment_info .pos-adv { + // Move ADV on top. + position: fixed; + top: 0; + left: 0; + height: 15vh; + width: 99vw; + margin:0.5vh; + border-width: 0; + @include flex-display; + } + + &.pos-js_no_ADV { + &:before { + display: none; + } + .pos-customer_products { + padding-top: 0; + } + } + + .pos-customer_products { + padding-top: 17vh; + height: 72vw; + overflow: hidden; + + .pos_orderlines { + @include flex(1 0 auto); + + .pos_orderlines_item { + > div:nth-child(2) { + width: 30%; + } + &.pos_orderlines_header div{ + font-size: 90%; + } + } + + .pos_orderlines_list { + padding-right: 1.5vh; + height: auto; + .pos_orderlines_item { + box-shadow: 0 .1vh .1vh darken($color-gray-lighter, 10%); + margin-bottom: 1vh; + > div { + font-size: 100%; + } + } + } + } + } + + .pos-payment_info { + max-width: 100%; + overflow: hidden; + padding-top: 0; + min-height: 120px; + @include flex(0 1 23vw); + @include flex-direction(row); + @include align-items(center); + @include justify-content(space-between); + + .pos-company_logo { + margin: 0; + background-position: left center; + margin-right: 5%; + height: 100%; + padding: 0; + @include flex(1 1 20%); + } + + .pos-payment_info_details { + @include flex(0 1 50%); + @include flex-direction(column); + min-width: 170px; + + .pos-total { + font-size: 3vw; + .pos_total-amount { + font-size: 3.5vw; + } + } + + .pos-paymentlines { + margin-top: 2%; + font-size: 80%; + line-height: 1.2; + } + .pos-odoo_logo_container { + position: absolute; + right: 3%; + bottom: 1%; + } + } + } + } + } + + @media all and (orientation: portrait) and (max-width: 340px ) { + .pos-customer_facing_display { + .pos-customer_products .pos_orderlines { + .pos_orderlines_list{ + padding-right: 0; + .pos_orderlines_item > div{ + font-size: 70%; + } + } + .pos_orderlines_header > div { + font-size: 60%; + &:last-child { + text-align: center; + } + } + } + .pos-payment_info { + + .pos-company_logo { + display: none!important; + } + .pos-payment_info_details { + @include flex(1 0 100%); + + .pos-total { + font-size: 6vw; + .pos_total-amount { + font-size: 6.5vw; + } + } + } + } + } + } +} + + +// @media all and (max-width: 340px ) { +// } + +body { + // =========== UTILITY CLASSES =========== + .pos-hidden { + opacity: 0; + } +} + + +// =========== PALETTE GENERATOR =========== +@mixin palette-variant ( + $bg-info: $color-gray-dark, + $text-info: $color-gray-lighter, + $bg-products: $color-gray-lighter, + $text-products: lighten($color-gray-dark, 10%), + $card-bg: white, + $card-text: $color-gray-dark, + $card-shadow: darken($bg-products, 30%)) { + + .pos-payment_info { + background: $bg-info; + color: $text-info; + } + + .pos-customer_products { + background: $bg-products; + color: $text-products; + + .pos_orderlines_list .pos_orderlines_item { + background-color: $card-bg; + color: $card-text; + box-shadow: 0 .1vh .1vh $card-shadow; + div:nth-child(2) { + border-color: rgba($card-text, 0.3); + } + } + } + + @media all and (orientation: portrait) { + &:before { + background: $bg-info; + } + } +} + +// =========== PALETTES =========== +// Those are kept for compatibility for now (previously there was a feature +// which allowed to change the default pos-palette_01 class. +.pos-palette_01 { @include palette-variant; } +.pos-palette_02 { @include palette-variant(#364152, #e6e7e8, #ecf2f6, #364152, white, $color-gray-dark, #364152 ); } +.pos-palette_03 { @include palette-variant(#1BA39C, $color-gray-lighter, #ececec ); } +.pos-palette_04 { @include palette-variant(#0b7b6c, $color-gray-lighter, #efeeec); } +.pos-palette_05 { @include palette-variant(#E26868, $color-gray-lighter, #ececec ); } +.pos-palette_06 { @include palette-variant(#9E373B, $color-gray-lighter); } +.pos-palette_07 { @include palette-variant(#ce9934, white, #ececec ); } +.pos-palette_08 { @include palette-variant(#a48c77, $color-gray-lighter, #ececec ); } +.pos-palette_09 { @include palette-variant(linear-gradient(30deg, #014d43, #127e71), $color-gray-lighter, #ececec ); } +.pos-palette_10 { @include palette-variant(linear-gradient(30deg, #e2316c, #ea4c89), white, #ececec ); } +.pos-palette_11 { @include palette-variant(linear-gradient(30deg, #362b3d, #5b4a63), white, #ececec ); } +.pos-palette_12 { @include palette-variant(#434343, #e6e6e6, #5b5b5b, #bdb9b9, #f5f5f5); } +.pos-palette_13 { @include palette-variant(linear-gradient(30deg, #1a1b1f, #3d3f45), white, #a2a2ab, $color-gray-lighter, $color-gray-lighter); } diff --git a/addons/point_of_sale/static/src/scss/pos_dashboard.scss b/addons/point_of_sale/static/src/scss/pos_dashboard.scss new file mode 100644 index 00000000..469fd998 --- /dev/null +++ b/addons/point_of_sale/static/src/scss/pos_dashboard.scss @@ -0,0 +1,5 @@ +.o_kanban_view.o_kanban_dashboard.o_pos_kanban.o_kanban_ungrouped { + .o_kanban_record { + width: 500px; + } +} diff --git a/addons/point_of_sale/static/src/sounds/bell.wav b/addons/point_of_sale/static/src/sounds/bell.wav Binary files differnew file mode 100644 index 00000000..660779c5 --- /dev/null +++ b/addons/point_of_sale/static/src/sounds/bell.wav diff --git a/addons/point_of_sale/static/src/sounds/error.wav b/addons/point_of_sale/static/src/sounds/error.wav Binary files differnew file mode 100644 index 00000000..472f3910 --- /dev/null +++ b/addons/point_of_sale/static/src/sounds/error.wav diff --git a/addons/point_of_sale/static/src/xml/Chrome.xml b/addons/point_of_sale/static/src/xml/Chrome.xml new file mode 100644 index 00000000..17593b2f --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Chrome.xml @@ -0,0 +1,136 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="Chrome" owl="1"> + <div class="pos" t-att-class="{ 'big-scrollbars': state.hasBigScrollBars }"> + <div class="pos-receipt-print"></div> + <div class="pos-topheader" t-att-class="{ oe_hidden: state.uiState !== 'READY' }"> + <div t-if="tempScreen.isShown" class="block-top-header" /> + <div class="pos-branding" t-if= "!env.isMobile"> + <img class="pos-logo" t-on-click="trigger('toggle-debug-widget')" + src="/point_of_sale/static/src/img/logo.png" alt="Logo" /> + <TicketButton isTicketScreenShown="isTicketScreenShown" /> + </div> + <div class="pos-rightheader"> + <TicketButton isTicketScreenShown="isTicketScreenShown" t-if="env.isMobile" /> + <div class="search-bar-portal" /> + <div class="status-buttons-portal" /> + </div> + </div> + <t t-if="state.uiState === 'READY'"> + <Portal target="'.pos .status-buttons-portal'"> + <div class="status-buttons"> + <t t-if="!env.isMobile"> + <CashierName /> + </t> + <OrderManagementButton t-if="env.pos.config.manage_orders" /> + <SaleDetailsButton t-if="env.pos.proxy.printer" /> + <ProxyStatus t-if="env.pos.config.use_proxy" /> + <ClientScreenButton t-if="clientScreenButtonIsShown" /> + <SyncNotification /> + <HeaderButton /> + </div> + </Portal> + <div class="pos-content"> + <div class="window"> + <div class="subwindow"> + <div class="subwindow-container"> + <div class="subwindow-container-fix screens"> + <t isShown="!tempScreen.isShown" t-component="mainScreen.component" + t-props="mainScreenProps" t-key="mainScreen.name" /> + <t t-if="tempScreen.isShown" t-component="tempScreen.component" + t-props="tempScreenProps" t-key="tempScreen.name" /> + </div> + </div> + </div> + </div> + <DebugWidget t-if="env.isDebug() and state.debugWidgetIsShown" + t-transition="fade" /> + </div> + </t> + + <div t-if="['LOADING', 'CLOSING'].includes(state.uiState)" class="loader" t-transition="swing"> + <div class="loader-feedback"> + <h1 class="message"> + <t t-esc="loading.message" /> + </h1> + <div class="progressbar"> + <div class="progress" t-ref="progressbar"></div> + </div> + <div t-if="loading.skipButtonIsShown" class="button skip" t-on-click="trigger('loading-skip-callback')"> + Skip + </div> + </div> + </div> + + <!-- Allow popups to be visible at any state of the ui. --> + <div t-if="popup.isShown" class="popups"> + <t t-component="popup.component" t-props="popupProps" + t-key="popup.name" /> + </div> + + <NotificationSound t-if="state.sound.src" sound="state.sound" /> + </div> + </t> + + <t t-name="CustomerFacingDisplayHead"> + <div class="resources"> + <base t-att-href="origin" /> + <link href="/point_of_sale/static/src/css/customer_facing_display.css" + rel="stylesheet" /> + <script type="text/javascript"> + // This function needs to be named that way, call it the foreign JS API + // The iotbox will execute it, with the behavior intended + function foreign_js() { + if ($('.pos-adv').hasClass('pos-hidden')) { + $('.pos-customer_facing_display').addClass('pos-js_no_ADV'); + } + $(window).on('resize', function () { + $('.pos-customer_facing_display').toggleClass('pos-js_no_ADV', $('.pos-adv').hasClass('pos-hidden')); + }).trigger('resize'); + }; + </script> + </div> + </t> + + <t t-name="CustomerFacingDisplayOrderLines"> + <t t-foreach="orderlines" t-as="orderline"> + <div class="pos_orderlines_item"> + <div> + <div t-attf-style="background-image:url(#{orderline.product.image_base64})" /> + </div> + <div> + <t t-esc="orderline.get_full_product_name()" /> + </div> + <div> + <t t-esc="orderline.get_quantity_str()" /> + </div> + <div> + <t t-esc="pos.format_currency(orderline.get_display_price())" /> + </div> + </div> + </t> + </t> + + <t t-name="CustomerFacingDisplayPaymentLines"> + <t t-foreach="order.get_paymentlines()" t-as="paymentline"> + <div> + <span> + <t t-esc="paymentline.name" /></span> + </div> + <div> + <span> + <t t-esc="pos.format_currency(paymentline.get_amount())" /></span> + </div> + </t> + <t t-if="order.get_paymentlines().length > 0"> + <div> + <span class="pos-change_title">Change:</span> + </div> + <div> + <span class="pos-change_amount"> + <t t-esc="pos.format_currency(order.get_change())" /></span> + </div> + </t> + </t> +</templates> diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/CashierName.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/CashierName.xml new file mode 100644 index 00000000..41b7ee69 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/CashierName.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="CashierName" owl="1"> + <div class="oe_status"> + <span class="username"> + <t t-esc="username" /> + </span> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/ClientScreenButton.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/ClientScreenButton.xml new file mode 100644 index 00000000..bbcb1167 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/ClientScreenButton.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ClientScreenButton" owl="1"> + <div class="oe_status" t-on-click="onClick"> + <span class="message"><t t-esc="message" /></span> + <div t-if="state.status === 'warning'" class="js_warning oe_icon oe_orange"> + <i class="fa fa-fw fa-desktop" role="img" aria-label="Client Screen Warning" title="Client Screen Warning"></i> + </div> + <div t-if="state.status === 'failure'" class="js_disconnected oe_icon oe_red"> + <i class="fa fa-fw fa-desktop" role="img" aria-label="Client Screen Disconnected" title="Client Screen Disconnected"></i> + </div> + <div t-if="state.status === 'success'" class="js_connected oe_icon oe_green"> + <i class="fa fa-fw fa-desktop" role="img" aria-label="Client Screen Connected" title="Client Screen Connected"></i> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/DebugWidget.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/DebugWidget.xml new file mode 100644 index 00000000..6e67512e --- /dev/null +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/DebugWidget.xml @@ -0,0 +1,93 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="DebugWidget" owl="1"> + <Draggable limitArea="'.pos'"> + <div class="debug-widget"> + <header class="drag-handle"> + <h1>Debug Window</h1> + </header> + <div class="toggle" t-on-click="trigger('toggle-debug-widget')" title="Dismiss" + role="img" aria-label="Dismiss"><i class="fa fa-times" /></div> + <div class="content"> + <p class="category">Electronic Scale</p> + <ul> + <li> + <input t-model="state.weightInput" type="text" class="weight"></input> + </li> + <li class="button set_weight" t-on-click="setWeight">Set Weight</li> + <li class="button reset_weight" t-on-click="resetWeight">Reset</li> + </ul> + + <p class="category">Barcode Scanner</p> + <ul> + <li> + <input t-model="state.barcodeInput" type="text" class="ean"></input> + </li> + <li class="button barcode" t-on-click="barcodeScan">Scan</li> + <li class="button custom_ean" t-on-click="barcodeScanEAN">Scan EAN-13</li> + </ul> + + <p class="category">Orders</p> + + <ul> + <li class="button" t-on-click="deleteOrders"> + Delete Paid Orders + </li> + <li class="button" t-on-click="deleteUnpaidOrders"> + Delete Unpaid Orders + </li> + <li t-if="!state.isPaidOrdersReady" class="button" + t-on-click="preparePaidOrders"> + Export Paid Orders + </li> + <a t-else="" t-att-download="paidOrdersFilename" t-att-href="paidOrdersURL" + t-on-click="state.isPaidOrdersReady = !state.isPaidOrdersReady"> + <li class="button"> + Download Paid Orders + </li> + </a> + <li t-if="!state.isUnpaidOrdersReady" class="button" + t-on-click="prepareUnpaidOrders"> + Export Unpaid Orders + </li> + <a t-else="" t-att-download="unpaidOrdersFilename" + t-att-href="unpaidOrdersURL" + t-on-click="state.isUnpaidOrdersReady = !state.isUnpaidOrdersReady"> + <li class="button"> + Download Unpaid Orders + </li> + </a> + <li class="button import_orders" style="position:relative"> + Import Orders + <input t-on-change="importOrders" type="file" + style="opacity:0;position:absolute;top:0;left:0;right:0;bottom:0;margin:0;cursor:pointer" /> + </li> + </ul> + + <p class="category">Hardware Status</p> + <ul> + <li class="status weighing">Weighing</li> + <li class="button display_refresh" t-on-click="refreshDisplay"> + Refresh Display + </li> + </ul> + <p class="category">Hardware Events</p> + <ul> + <li class="event" t-ref="open_cashbox">Open Cashbox</li> + <li class="event" t-ref="print_receipt">Print Receipt</li> + <li class="event" t-ref="scale_read">Read Weighing Scale</li> + </ul> + <p class="category">Others</p> + <ul> + <li class="event"> + <span>Buffer: </span> + <t t-esc="bufferRepr" /> + </li> + </ul> + </div> + </div> + </Draggable> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/HeaderButton.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/HeaderButton.xml new file mode 100644 index 00000000..19d9c7c8 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/HeaderButton.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="HeaderButton" owl="1"> + <div class="header-button close_button" t-att-class="{ confirm: state.label === 'Confirm' }" + t-on-click="onClick"> + <t t-esc="translatedLabel" /> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/OrderManagementButton.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/OrderManagementButton.xml new file mode 100644 index 00000000..062e11c3 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/OrderManagementButton.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OrderManagementButton" owl="1"> + <div class="oe_status order-management" t-on-click="onClick"> + <div class="oe_icon oe_green"> + <i class="fa fa-fw fa-search" role="img" aria-label="Order Management Button" title="Order Management Button"></i> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/ProxyStatus.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/ProxyStatus.xml new file mode 100644 index 00000000..3bcbef6d --- /dev/null +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/ProxyStatus.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ProxyStatus" owl="1"> + <div class="oe_status js_proxy" t-on-click="onClick"> + <span t-if="state.msg and !env.isMobile" class="js_msg"> + <t t-esc="state.msg" /> + </span> + <span t-if="state.status === 'connected'" class="js_connected oe_green"> + <i class="fa fa-fw fa-sitemap" role="img" aria-label="Proxy Connected" + title="Proxy Connected"></i> + </span> + <span t-if="state.status === 'connecting'" class="js_connecting"> + <i class="fa fa-fw fa-spin fa-spinner" role="img" aria-label="Connecting to Proxy" + title="Connecting to Proxy"></i> + </span> + <span t-if="state.status === 'warning'" class="js_warning oe_orange"> + <i class="fa fa-fw fa-sitemap" role="img" aria-label="Proxy Warning" + title="Proxy Warning"></i> + </span> + <span t-if="state.status === 'disconnected'" class="js_disconnected oe_red"> + <i class="fa fa-fw fa-sitemap" role="img" aria-label="Proxy Disconnected" + title="Proxy Disconnected"></i> + </span> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/SaleDetailsButton.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/SaleDetailsButton.xml new file mode 100644 index 00000000..dc5ecc04 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/SaleDetailsButton.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="SaleDetailsButton" owl="1"> + <div class="oe_status"> + <div class="js_connected oe_icon"> + <i class="fa fa-fw fa-print" role="img" aria-label="Print" t-on-click="onClick" + title="Print a report with all the sales of the current PoS Session"></i> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/SyncNotification.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/SyncNotification.xml new file mode 100644 index 00000000..4a08c7ac --- /dev/null +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/SyncNotification.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="SyncNotification" owl="1"> + <div class="oe_status" t-on-click="onClick"> + <span t-if="state.msg" class="js_msg"> + <t t-esc="state.msg" /> + <span> </span> + </span> + <div t-if="state.status === 'connected'" class="js_connected oe_icon oe_green"> + <i class="fa fa-fw fa-wifi" role="img" aria-label="Synchronisation Connected" + title="Synchronisation Connected"></i> + </div> + <div t-if="state.status === 'connecting'" class="js_connecting oe_icon"> + <i class="fa fa-fw fa-spin fa-spinner" role="img" + aria-label="Synchronisation Connecting" title="Synchronisation Connecting"></i> + </div> + <div t-if="state.status === 'disconnected'" class="js_disconnected oe_icon oe_red"> + <i class="fa fa-fw fa-wifi" role="img" aria-label="Synchronisation Disconnected" + title="Synchronisation Disconnected"></i> + </div> + <div t-if="state.status === 'error'" class="js_error oe_icon oe_red"> + <i class="fa fa-fw fa-warning" role="img" aria-label="Synchronisation Error" + title="Synchronisation Error"></i> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/TicketButton.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/TicketButton.xml new file mode 100644 index 00000000..8a1a3a32 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/TicketButton.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="TicketButton" owl="1"> + <div class="ticket-button" t-att-class="{ highlight: props.isTicketScreenShown }" t-on-click="onClick"> + <div class="with-badge" t-att-badge="count"> + <i class="fa fa-ticket" aria-hidden="true"></i> + </div> + <div>Orders</div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Misc/Draggable.xml b/addons/point_of_sale/static/src/xml/Misc/Draggable.xml new file mode 100644 index 00000000..c0449381 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Misc/Draggable.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="Draggable" owl="1"> + <t t-slot="default"></t> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Misc/MobileOrderWidget.xml b/addons/point_of_sale/static/src/xml/Misc/MobileOrderWidget.xml new file mode 100644 index 00000000..883631a2 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Misc/MobileOrderWidget.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="MobileOrderWidget" owl="1"> + <div class="switchpane"> + <t t-if="pane === 'right'"> + <button class="btn-switchpane" t-on-click="trigger('click-pay')"> + <h1>Pay</h1> + <span><t t-esc="total" /></span> + </button> + <button class="btn-switchpane secondary" t-on-click="trigger('switchpane')"> + <h1>Review</h1> + <span><t t-esc="items_number"/> items</span> + </button> + </t> + <t t-if="pane === 'left'"> + <button class="btn-switchpane" t-on-click="trigger('switchpane')"><h1>Back</h1></button> + </t> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Misc/NotificationSound.xml b/addons/point_of_sale/static/src/xml/Misc/NotificationSound.xml new file mode 100644 index 00000000..6467e807 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Misc/NotificationSound.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="NotificationSound" owl="1"> + <audio t-att-src="props.sound.src" autoplay="true"></audio> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Misc/SearchBar.xml b/addons/point_of_sale/static/src/xml/Misc/SearchBar.xml new file mode 100644 index 00000000..a480f169 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Misc/SearchBar.xml @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="point_of_sale.SearchBar" owl="1"> + <div class="pos-search-bar"> + <div class="search"> + <span class="search-icon"><i class="fa fa-search"></i></span> + <input class="radius-left" t-att-class="{ 'radius-right': !props.config.filter.show }" + t-model="state.searchInput" t-on-keydown="onKeydown" type="text" t-att-placeholder="placeholder" /> + <ul t-if="state.showSearchFields and state.searchInput" class="fields"> + <t t-foreach="config.searchFields" t-as="value" t-key="value_index"> + <li t-att-class="{ highlight: value_index == state.selectedFieldId }" + t-on-click="onClickSearchField(value_index)"> + <span class="field"> + <t t-esc="value"></t> + </span> + <span>: </span> + <span class="term"> + <t t-esc="state.searchInput"></t> + </span> + </li> + </t> + </ul> + </div> + <div t-if="props.config.filter.show" class="filter radius-right" + t-on-click.stop="state.showFilterOptions = !state.showFilterOptions"> + <span class="down-icon"> + <i class="fa fa-chevron-down" aria-hidden="true"></i> + </span> + <span> + <t t-esc="state.selectedFilter" /> + </span> + <ul t-if="state.showFilterOptions" class="options"> + <t t-foreach="config.filter.options" t-as="option" t-key="option"> + <li t-on-click="selectFilter(option)"> + <t t-esc="option"></t> + </li> + </t> + </ul> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/ConfirmPopup.xml b/addons/point_of_sale/static/src/xml/Popups/ConfirmPopup.xml new file mode 100644 index 00000000..f3b22b61 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/ConfirmPopup.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ConfirmPopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <Draggable> + <div class="popup popup-confirm"> + <header class="title drag-handle"> + <t t-esc="props.title" /> + </header> + <main class="body"> + <t t-esc=" props.body" /> + </main> + <footer class="footer"> + <div class="button confirm" t-on-click="confirm"> + <t t-esc="props.confirmText" /> + </div> + <div class="button cancel" t-on-click="cancel"> + <t t-esc="props.cancelText" /> + </div> + </footer> + </div> + </Draggable> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/EditListInput.xml b/addons/point_of_sale/static/src/xml/Popups/EditListInput.xml new file mode 100644 index 00000000..b33a5161 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/EditListInput.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="EditListInput" owl="1"> + <div> + <input type="text" t-model="props.item.text" class="popup-input list-line-input" + placeholder="Serial/Lot Number" t-on-keyup="onKeyup" /> + <i class="oe_link_icon fa fa-trash-o" role="img" aria-label="Remove" title="Remove" + t-on-click="trigger('remove-item', props.item)"></i> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/EditListPopup.xml b/addons/point_of_sale/static/src/xml/Popups/EditListPopup.xml new file mode 100644 index 00000000..9b6d6354 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/EditListPopup.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="EditListPopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <div class="popup popup-text"> + <header class="title"> + <t t-esc="props.title" /> + </header> + <main class="list-lines" t-on-remove-item="removeItem" + t-on-create-new-item="createNewItem"> + <t t-foreach="state.array" t-as="item" t-key="item._id"> + <EditListInput item="item" /> + </t> + </main> + <footer class="footer"> + <div class="button confirm" t-on-click="confirm"> + Ok + </div> + <div class="button cancel" t-on-click="cancel"> + Cancel + </div> + </footer> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/ErrorBarcodePopup.xml b/addons/point_of_sale/static/src/xml/Popups/ErrorBarcodePopup.xml new file mode 100644 index 00000000..6b455fca --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/ErrorBarcodePopup.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ErrorBarcodePopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <Draggable> + <div class="popup popup-barcode"> + <header class="title drag-handle"> + <span>Unknown Barcode</span> + <br /> + <span class="barcode"> + <t t-esc="props.code" /> + </span> + </header> + <main class="body"> + <t t-esc="translatedMessage" /> + </main> + <footer class="footer"> + <div class="button cancel" t-on-click="confirm"> + Ok + </div> + </footer> + </div> + </Draggable> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/ErrorPopup.xml b/addons/point_of_sale/static/src/xml/Popups/ErrorPopup.xml new file mode 100644 index 00000000..0f2e19e3 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/ErrorPopup.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ErrorPopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <div class="popup popup-error"> + <p class="title"> + <t t-esc="props.title" /> + </p> + <p class="body"> + <t t-esc="props.body" /> + </p> + <div class="footer"> + <div class="button cancel" t-on-click="confirm"> + <t t-esc="props.confirmText" /> + </div> + </div> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/ErrorTracebackPopup.xml b/addons/point_of_sale/static/src/xml/Popups/ErrorTracebackPopup.xml new file mode 100644 index 00000000..e7552c7c --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/ErrorTracebackPopup.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ErrorTracebackPopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <div class="popup popup-error"> + <header class="title"> + <t t-esc="props.title" /> + </header> + <main class="body traceback"> + <t t-esc="props.body" /> + </main> + <footer class="footer"> + <div t-if="!props.exitButtonIsShown" class="button cancel" t-on-click="confirm"> + <t t-esc="props.confirmText" /> + </div> + <div t-if="props.exitButtonIsShown" class="button cancel" t-on-click="trigger(props.exitButtonTrigger)"> + <t t-esc="props.exitButtonText" /> + </div> + <a t-att-download="tracebackFilename" t-att-href="tracebackUrl"> + <div class="button icon download"> + <i class="fa fa-download" role="img" + aria-label="Download error traceback" + title="Download error traceback"></i> + </div> + </a> + <div class="button icon email" t-on-click="emailTraceback"> + <i class="fa fa-paper-plane" role="img" aria-label="Send by email" + title="Send by email"></i> + </div> + </footer> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/NumberPopup.xml b/addons/point_of_sale/static/src/xml/Popups/NumberPopup.xml new file mode 100644 index 00000000..41d37ee5 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/NumberPopup.xml @@ -0,0 +1,65 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="NumberPopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <Draggable> + <div class="popup popup-number" t-att-class="{ 'popup-password': props.isPassword }"> + <header class="title drag-handle"> + <t t-esc="props.title" /> + </header> + <div class="popup-input value active"> + <t t-esc="inputBuffer" /> + </div> + <div class="popup-numpad"> + <button class="input-button number-char" t-on-mousedown.prevent="sendInput('1')">1</button> + <button class="input-button number-char" t-on-mousedown.prevent="sendInput('2')">2</button> + <button class="input-button number-char" t-on-mousedown.prevent="sendInput('3')">3</button> + <t t-if="props.cheap"> + <button class="mode-button add" t-on-mousedown.prevent="sendInput('+1')">+1</button> + </t> + <t t-if="!props.cheap"> + <button class="mode-button add" t-on-mousedown.prevent="sendInput('+10')">+10</button> + </t> + <br /> + <button class="input-button number-char" t-on-mousedown.prevent="sendInput('4')">4</button> + <button class="input-button number-char" t-on-mousedown.prevent="sendInput('5')">5</button> + <button class="input-button number-char" t-on-mousedown.prevent="sendInput('6')">6</button> + <t t-if="props.cheap"> + <button class="mode-button add" t-on-mousedown.prevent="sendInput('+2')">+2</button> + </t> + <t t-if="!props.cheap"> + <button class="mode-button add" t-on-mousedown.prevent="sendInput('+20')">+20</button> + </t> + <br /> + <button class="input-button number-char" t-on-mousedown.prevent="sendInput('7')">7</button> + <button class="input-button number-char" t-on-mousedown.prevent="sendInput('8')">8</button> + <button class="input-button number-char" t-on-mousedown.prevent="sendInput('9')">9</button> + <button t-if="!props.isPassword" class="input-button number-char" t-on-mousedown.prevent="sendInput('-')">-</button> + <br /> + <button class="input-button numpad-char" t-on-mousedown.prevent="sendInput('Delete')">C</button> + <button class="input-button number-char" t-on-mousedown.prevent="sendInput('0')">0</button> + <button class="input-button number-char dot" t-on-mousedown.prevent="sendInput(decimalSeparator)"> + <t t-esc="decimalSeparator" /></button> + <button class="input-button numpad-backspace" t-on-mousedown.prevent="sendInput('Backspace')"> + <img style="pointer-events: none;" + src="/point_of_sale/static/src/img/backspace.png" width="24" + height="21" alt="Backspace" /> + </button> + <br /> + </div> + <footer class="footer centered"> + <div class="button cancel" t-on-mousedown.prevent="cancel"> + <t t-esc="props.cancelText" /> + </div> + <div class="button confirm" t-on-mousedown.prevent="confirm"> + <t t-esc="props.confirmText" /> + </div> + </footer> + </div> + </Draggable> + + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/OfflineErrorPopup.xml b/addons/point_of_sale/static/src/xml/Popups/OfflineErrorPopup.xml new file mode 100644 index 00000000..9950bc3d --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/OfflineErrorPopup.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OfflineErrorPopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <Draggable> + <div class="popup popup-error"> + <header class="title drag-handle"> + <t t-esc="props.title" /> + </header> + <main class="body traceback"><t t-esc="props.body"/></main> + <footer class="footer"> + <div class="button cancel" t-on-click="cancel"> + Ok + </div> + <div class="button dont-show-again" t-on-click="dontShowAgain"> + Don't show again + </div> + </footer> + </div> + </Draggable> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/OrderImportPopup.xml b/addons/point_of_sale/static/src/xml/Popups/OrderImportPopup.xml new file mode 100644 index 00000000..b2f142b9 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/OrderImportPopup.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OrderImportPopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <Draggable> + <div class="popup popup-import"> + <header class="title drag-handle"> + <span>Finished Importing Orders</span> + </header> + <ul class="body"> + <li>Successfully imported <b><t t-esc="props.report.paid or 0" /></b> paid orders</li> + <li>Successfully imported <b><t t-esc="props.report.unpaid or 0" /></b> unpaid orders</li> + <t t-if="unpaidSkipped"> + <li><b><t t-esc="unpaidSkipped"/></b> unpaid orders could not be imported + <ul> + <li><b><t t-esc="props.report.unpaid_skipped_existing or 0" /></b> were duplicates of existing orders</li> + <li><b><t t-esc="props.report.unpaid_skipped_session or 0" /></b> belong to another session: + <t t-if="props.report.unpaid_skipped_sessions"> + <ul> + <li>Session ids: <b><t t-esc="props.report.unpaid_skipped_sessions" /></b></li> + </ul> + </t> + </li> + </ul> + </li> + </t> + </ul> + <footer class="footer"> + <div class="button cancel" t-on-click="cancel"> + <t t-esc="props.confirmText" /> + </div> + </footer> + </div> + </Draggable> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/ProductConfiguratorPopup.xml b/addons/point_of_sale/static/src/xml/Popups/ProductConfiguratorPopup.xml new file mode 100644 index 00000000..fb863754 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/ProductConfiguratorPopup.xml @@ -0,0 +1,90 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ProductConfiguratorPopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <div class="popup popup-text popup-lg product-configurator-popup"> + <header class="title"> + <t t-esc="props.product.display_name" /> + </header> + + <main class="body product_configurator_attributes col-lg-4 col-md-6 col-sm-12"> + <div t-foreach="props.attributes" t-as="attribute" class="attribute"> + <div class="attribute_name" t-esc="attribute.name"/> + <RadioProductAttribute t-if="attribute.display_type === 'radio'" attribute="attribute"/> + <SelectProductAttribute t-elif="attribute.display_type === 'select'" attribute="attribute"/> + <ColorProductAttribute t-elif="attribute.display_type === 'color'" attribute="attribute"/> + </div> + </main> + + <footer class="footer"> + <div class="button highlight confirm" t-on-click="confirm"> + Add + </div> + <div class="button cancel" t-on-click="cancel"> + Cancel + </div> + </footer> + </div> + </div> + </t> + + <t t-name="RadioProductAttribute" owl="1"> + <div class="configurator_radio"> + <div t-foreach="values" t-as="value"> + <input type="radio" t-model="state.selected_value" t-att-name="attribute.id" + t-attf-id="{{ attribute.id }}_{{ value.id }}" t-att-value="value.id"/> + + <label t-attf-for="{{ attribute.id }}_{{ value.id }}"> + <div class="radio_attribute_label"> + <t t-esc="value.name"/> + <span t-if="value.price_extra" class="price_extra"> + + <t t-esc="env.pos.format_currency(value.price_extra)"/> + </span> + </div> + + <t t-if="value.id == state.selected_value && value.is_custom"> + <input class="custom_value" type="text" t-model="state.custom_value"/> + </t> + </label> + </div> + </div> + </t> + + <t t-name="SelectProductAttribute" owl="1"> + <div> + <t t-set="is_custom" t-value="false"/> + + <select class="configurator_select" t-model="state.selected_value"> + <option t-foreach="values" t-as="value" t-att-value="value.id"> + <t t-set="is_custom" t-value="is_custom || (value.is_custom && value.id == state.selected_value)"/> + <t t-esc="value.name"/> + <t t-if="value.price_extra"> + + <t t-esc="env.pos.format_currency(value.price_extra)"/> + </t> + </option> + </select> + + <input class="custom_value" t-if="is_custom" type="text" t-model="state.custom_value"/> + </div> + </t> + + <t t-name="ColorProductAttribute" owl="1"> + <div> + <t t-set="is_custom" t-value="false"/> + + <ul class="color_attribute_list"> + <li t-foreach="values" t-as="value" class="color_attribute_list_item"> + <t t-set="is_custom" t-value="is_custom || (value.is_custom && value.id == state.selected_value)"/> + <label t-attf-class="configurator_color {{ value.id == state.selected_value ? 'active' : '' }}" + t-attf-style="background-color: {{ value.html_color }};" t-att-data-color="value.name"> + <input type="radio" t-model="state.selected_value" t-att-value="value.id" t-att-name="attribute.id"/> + </label> + </li> + </ul> + + <input class="custom_value" t-if="is_custom" type="text" t-model="state.custom_value"/> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/SelectionPopup.xml b/addons/point_of_sale/static/src/xml/Popups/SelectionPopup.xml new file mode 100644 index 00000000..b9ca1bc7 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/SelectionPopup.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="SelectionPopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <Draggable> + <div class="popup popup-selection"> + <header class="title drag-handle"> + <t t-esc="props.title" /> + </header> + <div class="selection scrollable-y"> + <t t-foreach="props.list" t-as="item" t-key="item.id"> + <div class="selection-item" t-att-class="{ selected: item.isSelected }" + t-on-click="selectItem(item.id)"> + <t t-esc="item.label" /> + </div> + </t> + </div> + <footer class="footer"> + <div class="button cancel" t-on-click="cancel"> + <t t-esc="props.cancelText" /> + </div> + </footer> + </div> + </Draggable> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/TextAreaPopup.xml b/addons/point_of_sale/static/src/xml/Popups/TextAreaPopup.xml new file mode 100644 index 00000000..a142c995 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/TextAreaPopup.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="TextAreaPopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <Draggable> + <div class="popup popup-textarea"> + <header class="title drag-handle"> + <t t-esc="props.title" /> + </header> + <textarea t-model="state.inputValue" t-ref="input"></textarea> + <footer class="footer"> + <div class="button confirm" t-on-click="confirm"> + <t t-esc="props.confirmText" /> + </div> + <div class="button cancel" t-on-click="cancel"> + <t t-esc="props.cancelText" /> + </div> + </footer> + </div> + </Draggable> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/TextInputPopup.xml b/addons/point_of_sale/static/src/xml/Popups/TextInputPopup.xml new file mode 100644 index 00000000..cc28d706 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/TextInputPopup.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="TextInputPopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <div class="popup popup-textinput"> + <header class="title"> + <t t-esc="props.title" /> + </header> + <div class="div"> + <p> + <t t-esc="props.body" /> + </p> + <input type="text" t-model="state.inputValue" t-ref="input" /> + </div> + <div class="footer"> + <div class="button confirm" t-on-click="confirm"> + <t t-esc="props.confirmText" /> + </div> + <div class="button cancel" t-on-click="cancel"> + <t t-esc="props.cancelText" /> + </div> + </div> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/SaleDetailsReport.xml b/addons/point_of_sale/static/src/xml/SaleDetailsReport.xml new file mode 100644 index 00000000..cce9616e --- /dev/null +++ b/addons/point_of_sale/static/src/xml/SaleDetailsReport.xml @@ -0,0 +1,75 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="SaleDetailsReport" owl="1"> + <div class="pos-receipt"> + <t t-if="pos.company_logo_base64"> + <img class="pos-receipt-logo" t-att-src="pos.company_logo_base64" alt="Logo"/> + <br/> + </t> + <t t-if="!pos.company_logo_base64" class="pos-receipt-center-align"> + <h1 t-esc="pos.company.name" /> + <br/> + </t> + <br /><br /> + + <div class="orderlines"> + <t t-foreach="products" t-as="line" t-key="line.product_id"> + <div> + <t t-esc="line.product_name.substr(0,20)" /> + <span class="pos-receipt-right-align"> + <t t-esc="Math.round(line.quantity * Math.pow(10, pos.dp['Product Unit of Measure'])) / Math.pow(10, pos.dp['Product Unit of Measure'])" /> + <t t-if="line.uom !== 'Units'"> + <t t-esc="line.uom" /> + </t> + x + <t t-esc="pos.format_currency_no_symbol(line.price_unit)" /> + </span> + </div> + <t t-if="line.discount !== 0"> + <div class="pos-receipt-left-padding">Discount: <t t-esc="line.discount" />%</div> + </t> + </t> + </div> + + <br/> + <div>------------------------</div> + <br/> + + <div> + Payments: + </div> + <div t-foreach="payments" t-as="payment"> + <t t-esc="payment.name" /> + <span t-esc="pos.format_currency_no_symbol(payment.total)" class="pos-receipt-right-align"/> + </div> + + <br/> + <div>------------------------</div> + <br/> + + <div> + Taxes: + </div> + <div t-foreach="taxes" t-as="tax"> + <t t-esc="tax.name" /> + <span t-esc="pos.format_currency_no_symbol(tax.tax_amount)" class="pos-receipt-right-align"/> + </div> + + <br/> + <div>------------------------</div> + <br/> + + <div> + Total: + <span t-esc="pos.format_currency_no_symbol(total_paid)" class="pos-receipt-right-align"/> + </div> + + <br/> + <div class="pos-receipt-order-data"> + <div><t t-esc="date" /></div> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientDetailsEdit.xml b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientDetailsEdit.xml new file mode 100644 index 00000000..5699dc64 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientDetailsEdit.xml @@ -0,0 +1,119 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ClientDetailsEdit" owl="1"> + <section class="client-details edit"> + <div class="client-picture"> + <t t-if="partnerImageUrl"> + <img t-att-src="partnerImageUrl" alt="Partner" + style="width: 64px; height: 64px; object-fit: cover;" /> + </t> + <t t-else=""> + <i class="fa fa-camera" role="img" aria-label="Picture" title="Picture"></i> + </t> + <input type="file" class="image-uploader" t-on-change="uploadImage" /> + </div> + <input class="detail client-name" name="name" t-att-value="props.partner.name" + placeholder="Name" t-on-change="captureChange" /> + <div class="client-details-box clearfix"> + <div class="client-details-left"> + <div class="client-detail"> + <span class="label">Street</span> + <input class="detail client-address-street" name="street" + t-on-change="captureChange" t-att-value="props.partner.street || ''" + placeholder="Street" /> + </div> + <div class="client-detail"> + <span class="label">City</span> + <input class="detail client-address-city" name="city" + t-on-change="captureChange" t-att-value="props.partner.city || ''" + placeholder="City" /> + </div> + <div class="client-detail"> + <span class="label">Postcode</span> + <input class="detail client-address-zip" name="zip" + t-on-change="captureChange" t-att-value="props.partner.zip || ''" + placeholder="ZIP" /> + </div> + <div class="client-detail"> + <span class="label">State</span> + <select class="detail client-address-states needsclick" name="state_id" + t-on-change="captureChange"> + <option value="">None</option> + <t t-foreach="env.pos.states" t-as="state" t-key="state.id"> + <option t-if="props.partner.country_id[0] == state.country_id[0]" + t-att-value="state.id" + t-att-selected="props.partner.state_id ? ((state.id === props.partner.state_id[0]) ? true : undefined) : undefined"> + <t t-esc="state.name" /> + </option> + </t> + </select> + </div> + <div class="client-detail"> + <span class="label">Country</span> + <select class="detail client-address-country needsclick" name="country_id" + t-on-change="captureChange"> + <option value="">None</option> + <t t-foreach="env.pos.countries" t-as="country" t-key="country.id"> + <option t-att-value="country.id" + t-att-selected="props.partner.country_id ? ((country.id === props.partner.country_id[0]) ? true : undefined) : undefined"> + <t t-esc="country.name" /> + </option> + </t> + </select> + </div> + </div> + <div class="client-details-right"> + <div class="client-detail"> + <span class="label">Language</span> + <select class="detail client-lang needsclick" name="lang" + t-on-change="captureChange"> + <t t-foreach="env.pos.langs" t-as="lang" t-key="lang.id"> + <option t-att-value="lang.code" + t-att-selected="props.partner.lang ? ((lang.code === props.partner.lang) ? true : undefined) : lang.code === env.pos.user.lang? true : undefined"> + <t t-esc="lang.name" /> + </option> + </t> + </select> + </div> + <div class="client-detail"> + <span class="label">Email</span> + <input class="detail client-email" name="email" type="email" + t-on-change="captureChange" + t-att-value="props.partner.email || ''" /> + </div> + <div class="client-detail"> + <span class="label">Phone</span> + <input class="detail client-phone" name="phone" type="tel" + t-on-change="captureChange" + t-att-value="props.partner.phone || ''" /> + </div> + <div class="client-detail"> + <span class="label">Barcode</span> + <input class="detail barcode" name="barcode" t-on-change="captureChange" + t-att-value="props.partner.barcode || ''" /> + </div> + <div class="client-detail"> + <span class="label">Tax ID</span> + <input class="detail vat" name="vat" t-on-change="captureChange" + t-att-value="props.partner.vat || ''" /> + </div> + <div t-if="env.pos.pricelists.length gt 1" class="client-detail"> + <span class="label">Pricelist</span> + <select class="detail needsclick" name="property_product_pricelist" + t-on-change="captureChange"> + <t t-foreach="env.pos.pricelists" t-as="pricelist" + t-key="pricelist.id"> + <option t-att-value="pricelist.id" + t-att-selected="props.partner.property_product_pricelist ? (pricelist.id === props.partner.property_product_pricelist[0] ? true : false) : false"> + <t t-esc="pricelist.display_name" /> + </option> + </t> + </select> + </div> + </div> + </div> + </section> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientLine.xml b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientLine.xml new file mode 100644 index 00000000..7693f08c --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientLine.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ClientLine" owl="1"> + <tr t-attf-class="client-line {{highlight}}" t-att-data-id="props.partner.id" + t-on-click="trigger('click-client', {client: props.partner})"> + <td> + <t t-esc="props.partner.name" /> + <span t-if="highlight"> + <br/><button class="edit-client-button" t-on-click.stop="trigger('click-edit')">EDIT</button> + </span> + </td> + <td t-if="!env.isMobile"> + <t t-esc="props.partner.address" /> + </td> + <td t-if="!env.isMobile" style="width: 130px;"> + <t t-esc="props.partner.phone || ''" /> + </td> + <td t-if="env.isMobile"> + <t t-esc="props.partner.zip or ''" /> + <span t-if="highlight"><br/></span> + </td> + <td> + <t t-esc="props.partner.email or ''" /> + <span t-if="highlight"><br/></span> + </td> + </tr> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientListScreen.xml b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientListScreen.xml new file mode 100644 index 00000000..baefab13 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientListScreen.xml @@ -0,0 +1,90 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ClientListScreen" owl="1"> + <div class="clientlist-screen screen" t-on-activate-edit-mode="activateEditMode"> + <div class="screen-content"> + <div class="top-content"> + <div t-if="!state.detailIsShown && !state.selectedClient" class="button new-customer" role="img" aria-label="Add a customer" + t-on-click="trigger('activate-edit-mode', { isNewClient: true })" + title="Add a customer"> + <t t-if="!env.isMobile"> + Create + </t> + <t t-else=""> + <i class="fa fa-plus"></i> + </t> + </div> + <div t-if="isNextButtonVisible" t-on-click="clickNext" + class="button next highlight"> + <t t-if="!env.isMobile"> + <t t-esc="nextButton.text" /> + </t> + <t t-else=""> + <i t-if="nextButton.command === 'deselect'" class="fa fa-trash"></i> + <i t-if="nextButton.command === 'set'" class="fa fa-check"></i> + </t> + </div> + <div class="button" t-if="state.detailIsShown" t-on-click="trigger('click-save')"> + <t t-if="!env.isMobile"> + <i class="fa fa-floppy-o"/> + <span> Save</span> + </t> + <t t-else=""> + <i class="fa fa-floppy-o"/> + </t> + </div> + <div class="button back" t-on-click="back"> + <t t-if="!env.isMobile">Discard</t> + <t t-else=""> + <i class="fa fa-undo"></i> + </t> + </div> + <div t-if="!state.detailIsShown" class="searchbox-client top-content-center"> + <input placeholder="Search Customers" size="1" t-on-keyup="updateClientList" /> + <span class="search-clear-client"></span> + </div> + </div> + <section class="full-content"> + <div class="client-window"> + <section class="subwindow collapsed"> + <div class="subwindow-container collapsed"> + <div t-if="state.detailIsShown" class="client-details-contents subwindow-container-fix"> + <ClientDetailsEdit t-props="state.editModeProps" + t-on-cancel-edit="cancelEdit"/> + </div> + </div> + </section> + <section class="subwindow list"> + <div class="subwindow-container"> + <div t-if="!state.detailIsShown" class="subwindow-container-fix scrollable-y"> + <table class="client-list"> + <thead> + <tr> + <th>Name</th> + <th t-if="!env.isMobile">Address</th> + <th t-if="!env.isMobile">Phone</th> + <th t-if="env.isMobile">ZIP</th> + <th>Email</th> + </tr> + </thead> + <tbody class="client-list-contents"> + <t t-foreach="clients" t-as="partner" + t-key="partner.id"> + <ClientLine partner="partner" + selectedClient="state.selectedClient" + detailIsShown="state.detailIsShown" + t-on-click-client="clickClient" /> + </t> + </tbody> + </table> + </div> + </div> + </section> + </div> + </section> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.xml new file mode 100644 index 00000000..72c85188 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="InvoiceButton" owl="1"> + <div class="control-button" t-att-class="{ highlight: isHighlighted }"> + <i class="fa fa-file-pdf-o"></i> + <span> </span> + <span><t t-esc="commandName"></t></span> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.xml new file mode 100644 index 00000000..df3e4e06 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ReprintReceiptButton" owl="1"> + <div class="control-button"> + <i class="fa fa-print"></i> + <span> </span> + <span>Print Receipt</span> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/MobileOrderManagementScreen.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/MobileOrderManagementScreen.xml new file mode 100644 index 00000000..479b35a4 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/MobileOrderManagementScreen.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <div t-name="MobileOrderManagementScreen" class="screen-full-width" owl="1"> + <div t-if="mobileState.showDetails" class="leftpane"> + <OrderDetails order="orderManagementContext.selectedOrder" /> + <div class="pads"> + <div class="control-buttons"> + <t t-foreach="controlButtons" t-as="cb" t-key="cb.name"> + <t t-component="cb.component" t-key="cb.name" /> + </t> + </div> + <div class="subpads"> + <ActionpadWidget client="selectedClient" /> + <NumpadWidget /> + </div> + </div> + <div class="back-to-list" t-on-click="mobileState.showDetails = false"> + <span>Back to list</span> + </div> + </div> + <div t-else="" class="rightpane"> + <div class="flex-container"> + <OrderManagementControlPanel /> + <OrderList orders="orders" initHighlightedOrder="orderManagementContext.selectedOrder" /> + </div> + </div> + </div> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderDetails.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderDetails.xml new file mode 100644 index 00000000..87579d09 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderDetails.xml @@ -0,0 +1,35 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OrderDetails" owl="1"> + <div class="order-container"> + <div t-ref="scrollable" class="order-scroller touch-scrollable"> + <div class="order"> + <t t-if="!props.order"> + <div class="order-empty"> + <i class="fa fa-shopping-cart" role="img" aria-label="Shopping cart" + title="Shopping cart" /> + <h1>Select an order</h1> + </div> + </t> + <t t-elif="orderlines.length === 0"> + <div class="order-empty"> + <i class="fa fa-shopping-cart" role="img" aria-label="Shopping cart" + title="Shopping cart" /> + <h1>Order is empty</h1> + </div> + </t> + <t t-else=""> + <ul class="orderlines"> + <t t-foreach="orderlines" t-as="orderline" t-key="orderline.id"> + <OrderlineDetails line="orderline" /> + </t> + </ul> + <OrderSummary total="total" tax="tax" /> + </t> + </div> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderList.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderList.xml new file mode 100644 index 00000000..865f609b --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderList.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OrderList" owl="1"> + <div class="orders"> + <div class="order-row header"> + <div class="header name">Order</div> + <div class="header date">Date</div> + <div class="header customer">Customer</div> + <div class="header total">Total</div> + </div> + <div class="order-list"> + <t t-foreach="props.orders" t-as="order" t-key="order.cid"> + <OrderRow order="order" highlightedOrder="highlightedOrder" /> + </t> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementControlPanel.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementControlPanel.xml new file mode 100644 index 00000000..3a294bfd --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementControlPanel.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OrderManagementControlPanel" owl="1"> + <div class="control-panel"> + <div class="item button back" t-on-click="trigger('close-screen')"> + <i class="fa fa-angle-double-left"></i> + <span> Back</span> + </div> + <div class="item search-box"> + <span class="icon"> + <i class="fa fa-search" /> + </span> + <input type="text" t-model="orderManagementContext.searchString" t-on-keydown="onInputKeydown" placeholder="E.g. customer: Steward, date: 2020-05-09" /> + <span class="clear" t-on-click="trigger('clear-search')"> + <i class="fa fa-remove" /> + </span> + </div> + <div t-if="showPageControls" class="item"> + <div class="page-controls"> + <div class="previous" t-on-click="trigger('prev-page')"> + <i class="fa fa-fw fa-caret-left" role="img" aria-label="Previous Order List" title="Previous Order List"></i> + </div> + <div class="next" t-on-click="trigger('next-page')"> + <i class="fa fa-fw fa-caret-right" role="img" aria-label="Next Order List" title="Next Order List"></i> + </div> + </div> + <div class="page"> + <span><t t-esc="pageNumber" /></span> + </div> + </div> + <div t-else="" class="item"></div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementScreen.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementScreen.xml new file mode 100644 index 00000000..8992e2c8 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementScreen.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OrderManagementScreen" owl="1"> + <div class="order-management-screen screen" t-att-class="{ oe_hidden: !props.isShown }"> + <div t-if="!env.isMobile" class="screen-full-width"> + <div class="leftpane"> + <OrderDetails order="orderManagementContext.selectedOrder" /> + <div class="pads"> + <div class="control-buttons"> + <t t-foreach="controlButtons" t-as="cb" t-key="cb.name"> + <t t-component="cb.component" t-key="cb.name" /> + </t> + </div> + <div class="subpads"> + <ActionpadWidget client="selectedClient" /> + <NumpadWidget /> + </div> + </div> + </div> + <div class="rightpane"> + <div class="flex-container"> + <OrderManagementControlPanel /> + <OrderList orders="orders" initHighlightedOrder="orderManagementContext.selectedOrder" /> + </div> + </div> + </div> + <MobileOrderManagementScreen t-else="" /> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderRow.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderRow.xml new file mode 100644 index 00000000..29b07cfe --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderRow.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OrderRow" owl="1"> + <div class="order-row" + t-att-class="{ highlight: highlighted, lighter: !props.order.locked }" + t-on-click="trigger('click-order', props.order)"> + <div class="item name"><t t-esc="name" /></div> + <div class="item date"><t t-esc="date" /></div> + <div class="item customer"><t t-esc="customer" /></div> + <div class="item total"><t t-esc="total" /></div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderlineDetails.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderlineDetails.xml new file mode 100644 index 00000000..2e6869e5 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderlineDetails.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OrderlineDetails" owl="1"> + <li class="orderline"> + <span class="product-name"> + <t t-esc="productName" /> + </span> + <span class="price"> + <t t-esc="totalPrice" /> + </span> + <li class="info"> + <strong> + <t t-esc="quantity" /> + </strong> + <span><t t-esc="pricePerUnit" /></span> + </li> + </li> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ReprintReceiptScreen.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ReprintReceiptScreen.xml new file mode 100644 index 00000000..0a80a0e0 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ReprintReceiptScreen.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ReprintReceiptScreen" owl="1"> + <div class="receipt-screen screen"> + <div class="screen-content"> + <div class="top-content"> + <span class="button back" t-on-click="confirm"> + <i class="fa fa-angle-double-left"></i> + <span> </span> + <span>Back</span> + </span> + </div> + <div class="centered-content"> + <div class="button print" t-on-click="tryReprint"> + <i class="fa fa-print"></i> Print Receipt + </div> + <div class="pos-receipt-container"> + <OrderReceipt order="props.order" t-ref="order-receipt" /> + </div> + </div> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PSNumpadInputButton.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PSNumpadInputButton.xml new file mode 100644 index 00000000..381cd88c --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PSNumpadInputButton.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="PSNumpadInputButton" owl="1"> + <button t-attf-class="{{ _class }}" + t-on-click="trigger('input-from-numpad', { key: props.value })"> + <t t-slot="default"> + <t t-if="props.text"> + <t t-esc="props.text" /> + </t> + <t t-else=""> + <t t-esc="props.value" /> + </t> + </t> + </button> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentMethodButton.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentMethodButton.xml new file mode 100644 index 00000000..dacf7e96 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentMethodButton.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="PaymentMethodButton" owl="1"> + <div class="button paymentmethod" + t-on-click="trigger('new-payment-line', props.paymentMethod)"> + <div class="payment-name"> + <t t-esc="props.paymentMethod.name" /> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreen.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreen.xml new file mode 100644 index 00000000..d78128c9 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreen.xml @@ -0,0 +1,99 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="PaymentScreen" owl="1"> + <div class="payment-screen screen" t-att-class="{ oe_hidden: !props.isShown }"> + <div class="screen-content"> + <t t-if="!env.isMobile"> + <div class="top-content"> + <div class="button back" + t-on-click="showScreen('ProductScreen')"> + <i class="fa fa-angle-double-left fa-fw"></i> + <span class="back_text">Back</span> + </div> + <div class="top-content-center"><h1>Payment</h1></div> + <div class="button next" t-att-class="{ highlight: currentOrder.is_paid() }" + t-on-click="validateOrder(false)"> + <span class="next_text">Validate</span> + <i class="fa fa-angle-double-right fa-fw"></i> + </div> + </div> + </t> + <div class="main-content"> + <div class="left-content"> + <t t-if="env.isMobile"> + <section class="paymentlines-container"> + <PaymentScreenStatus paymentLines="paymentLines" /> + </section> + </t> + <div class="paymentmethods-container"> + <PaymentScreenPaymentLines paymentLines="paymentLines" /> + <div class="paymentmethods"> + <t t-foreach="payment_methods_from_config" t-as="paymentMethod" + t-key="paymentMethod.id"> + <PaymentMethodButton paymentMethod="paymentMethod" /> + </t> + </div> + </div> + </div> + <div class="right-content"> + <t t-if="!env.isMobile"> + <section class="paymentlines-container"> + <PaymentScreenStatus paymentLines="paymentLines" /> + </section> + </t> + + <div class="payment-buttons-container"> + <section class="payment-numpad"> + <PaymentScreenNumpad /> + </section> + + <div class="payment-buttons"> + <div class="customer-button"> + <div class="button" t-on-click="selectClient"> + <i class="fa fa-user" role="img" aria-label="Customer" + title="Customer" /> + <span class="js_customer_name"> + <t t-if="env.pos.get_client()"> + <t t-esc="env.pos.get_client().name" /> + </t> + <t t-if="!env.pos.get_client()"> + Customer + </t> + </span> + </div> + </div> + <div class="payment-controls"> + <div t-if="env.pos.config.module_account" class="button js_invoice" + t-att-class="{ highlight: currentOrder.is_to_invoice() }" + t-on-click="toggleIsToInvoice"> + <i class="fa fa-file-text-o" /> Invoice + </div> + <div t-if="env.pos.config.tip_product_id" class="button js_tip" + t-on-click="addTip"> + <i class="fa fa-heart" /> Tip + </div> + <div t-if="env.pos.config.iface_cashdrawer" class="button js_cashdrawer" + t-on-click="openCashbox"> + <i class="fa fa-archive" /> Open Cashbox + </div> + </div> + </div> + </div> + </div> + </div> + <t t-if="env.isMobile"> + <div class="switchpane"> + <button class="btn-switchpane" t-att-class="{ secondary: !currentOrder.is_paid() }" t-on-click="validateOrder(false)"> + <h1>Validate</h1> + </button> + <button class="btn-switchpane secondary" t-on-click="showScreen('ProductScreen', {mobile_pane: 'left'})"> + <h1>Review</h1> + </button> + </div> + </t> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenElectronicPayment.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenElectronicPayment.xml new file mode 100644 index 00000000..792c490c --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenElectronicPayment.xml @@ -0,0 +1,76 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="PaymentScreenElectronicPayment" owl="1"> + <div class="paymentline electronic_payment"> + <t t-if="props.line.payment_status == 'pending'"> + <div> + Payment request pending + </div> + <div class="button send_payment_request highlight" title="Send Payment Request" t-on-click="trigger('send-payment-request', props.line)"> + Send + </div> + </t> + <t t-elif="props.line.payment_status == 'retry'"> + <div> + Transaction cancelled + </div> + <div class="button send_payment_request highlight" title="Send Payment Request" t-on-click="trigger('send-payment-request', props.line)"> + Retry + </div> + </t> + <t t-elif="props.line.payment_status == 'force_done'"> + <div> + Connection error + </div> + <div class="button send_force_done" title="Force Done" t-on-click="trigger('send-force-done', props.line)"> + Force done + </div> + </t> + <t t-elif="props.line.payment_status == 'waitingCard'"> + <div> + Waiting for card + </div> + <div class="button send_payment_cancel" title="Cancel Payment Request" t-on-click="trigger('send-payment-cancel', props.line)"> + Cancel + </div> + </t> + <t t-elif="['waiting', 'waitingCancel'].includes(props.line.payment_status)"> + <div> + Request sent + </div> + <div> + <i class="fa fa-spinner fa-spin" role="img" /> + </div> + </t> + <t t-elif="props.line.payment_status == 'reversing'"> + <div> + Reversal request sent to terminal + </div> + <div> + <i class="fa fa-spinner fa-spin" role="img" /> + </div> + </t> + <t t-elif="props.line.payment_status == 'done'"> + <div> + Payment Successful + </div> + <t t-if="props.line.can_be_reversed"> + <div class="button send_payment_reversal" title="Reverse Payment" t-on-click="trigger('send-payment-reverse', props.line)"> + Reverse + </div> + </t> + <t t-else=""> + <div></div> + </t> + </t> + <t t-elif="props.line.payment_status == 'reversed'"> + <div> + Payment reversed + </div> + <div></div> + </t> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenNumpad.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenNumpad.xml new file mode 100644 index 00000000..d988ab5f --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenNumpad.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="PaymentScreenNumpad" owl="1"> + <div class="numpad"> + <PSNumpadInputButton value="'1'" /> + <PSNumpadInputButton value="'2'" /> + <PSNumpadInputButton value="'3'" /> + <PSNumpadInputButton value="'+10'" changeClassTo="'mode-button'" /> + <br /> + <PSNumpadInputButton value="'4'" /> + <PSNumpadInputButton value="'5'" /> + <PSNumpadInputButton value="'6'" /> + <PSNumpadInputButton value="'+20'" changeClassTo="'mode-button'" /> + <br /> + <PSNumpadInputButton value="'7'" /> + <PSNumpadInputButton value="'8'" /> + <PSNumpadInputButton value="'9'" /> + <PSNumpadInputButton value="'+50'" changeClassTo="'mode-button'" /> + <br /> + <PSNumpadInputButton value="'-'" text="'+/-'" /> + <PSNumpadInputButton value="'0'" /> + <PSNumpadInputButton value="decimalPoint" /> + <PSNumpadInputButton value="'Backspace'"> + <img src="/point_of_sale/static/src/img/backspace.png" width="24" height="21" + alt="Backspace" /> + </PSNumpadInputButton> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenPaymentLines.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenPaymentLines.xml new file mode 100644 index 00000000..6816f300 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenPaymentLines.xml @@ -0,0 +1,62 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="PaymentScreenPaymentLines" owl="1"> + <div class="paymentlines"> + <t t-foreach="props.paymentLines" t-as="line" t-key="line.cid"> + <t t-if="line.selected"> + <div class="paymentline selected" + t-att-class="selectedLineClass(line)" + t-on-click="trigger('select-payment-line', { cid: line.cid })"> + <div class="payment-name"> + <t t-esc="line.payment_method.name" /> + </div> + <div class="payment-amount"> + <t t-if="line and line.payment_status and ['done', 'waitingCard', 'waiting', 'reversing', 'reversed'].includes(line.payment_status)"> + <t t-esc="env.pos.format_currency_no_symbol(line.get_amount())" /> + </t> + <t t-else=""> + <t t-esc="formatLineAmount(line)" /> + </t> + </div> + <t t-if="!line.payment_status or !['done', 'reversed'].includes(line.payment_status)"> + <div class="delete-button" + t-on-click="trigger('delete-payment-line', { cid: line.cid })" + aria-label="Delete" title="Delete"> + <i class="fa fa-times-circle" /> + </div> + </t> + </div> + <t t-if="line and line.payment_status"> + <PaymentScreenElectronicPayment line="line" /> + </t> + </t> + <t t-else=""> + <div class="paymentline" + t-att-class="unselectedLineClass(line)" + t-on-click="trigger('select-payment-line', { cid: line.cid })"> + <div class="payment-name"> + <t t-esc="line.payment_method.name" /> + </div> + <div class="payment-amount"> + <t t-if="line and line.payment_status and ['done', 'waitingCard', 'waiting', 'reversing', 'reversed'].includes(line.payment_status)"> + <t t-esc="env.pos.format_currency_no_symbol(line.get_amount())" /> + </t> + <t t-else=""> + <t t-esc="formatLineAmount(line)" /> + </t> + </div> + <t t-if="!line.payment_status or !['done', 'reversed'].includes(line.payment_status)"> + <div class="delete-button" + t-on-click="trigger('delete-payment-line', { cid: line.cid })" + aria-label="Delete" title="Delete"> + <i class="fa fa-times-circle" /> + </div> + </t> + </div> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenStatus.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenStatus.xml new file mode 100644 index 00000000..7c90c8a5 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenStatus.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + +<t t-name="PaymentScreenStatus" owl="1"> + <div t-if="props.paymentLines.length === 0" class="paymentlines-empty"> + <div class="total"> + <t t-esc="totalDueText" /> + </div> + <div class="message"> + Please select a payment method. + </div> + </div> + + <div t-else=""> + <div class="payment-status-container"> + <div> + <div class="payment-status-remaining"> + <span class="label">Remaining</span> + <span class="amount" + t-att-class="{ highlight: currentOrder.get_due() > 0 }"> + <t t-esc="remainingText" /> + </span> + </div> + <div class="payment-status-total-due"> + <span class="label">Total Due</span> + <span> + <t t-esc="totalDueText" /> + </span> + </div> + </div> + <div> + <div class="payment-status-change"> + <span class="label">Change</span> + <span class="amount" + t-att-class="{ highlight: currentOrder.get_change() > 0 }"> + <t t-esc="changeText" /> + </span> + </div> + </div> + </div> + </div> +</t> +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ActionpadWidget.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ActionpadWidget.xml new file mode 100644 index 00000000..183912fd --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ActionpadWidget.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ActionpadWidget" owl="1"> + <div class="actionpad"> + <button class="button set-customer" t-att-class="{'decentered': isLongName}" + t-on-click="trigger('click-customer')"> + <t t-if="!env.isMobile"><i class="fa fa-user" role="img" aria-label="Customer" title="Customer" /></t> + <t t-if="client"> + <t t-esc="client.name" /> + </t> + <t t-else=""> + Customer + </t> + </button> + <button class="button pay" t-on-click="trigger('click-pay')"> + <div class="pay-circle"> + <i class="fa fa-chevron-right" role="img" aria-label="Pay" title="Pay" /> + </div> + Payment + </button> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CashBoxOpening.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CashBoxOpening.xml new file mode 100644 index 00000000..27193bf9 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CashBoxOpening.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates id="template" xml:space="preserve"> + + <t t-name="CashBoxOpening" owl="1"> + <div style="margin-top: -20px;"> + <br/> + <h1> + <span> + Pos closed. + </span><br/> + <span> + Set a cash opening + </span> + + </h1> + <h1>Opening amount:</h1> + <input name="cashBoxValue" class="cashbox-input" t-on-change="captureChange" t-att-value="defaultValue"/> + <span class="currencyCashBox" t-esc="symbol"/> + + <h1>Notes:</h1> + <textarea name="notes" style="width: 51%" t-on-change="captureChange"/><br/><br/> + + <span class="control-button" t-on-click="startSession()">Open</span> + + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryBreadcrumb.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryBreadcrumb.xml new file mode 100644 index 00000000..0e9ba155 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryBreadcrumb.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates id="template" xml:space="preserve"> + + <t t-name="CategoryBreadcrumb" owl="1"> + <span class="breadcrumb"> + <img src="/point_of_sale/static/src/img/bc-arrow-big.png" class="breadcrumb-arrow" + alt="Slash" /> + <span class="breadcrumb-button" + t-on-click="trigger('switch-category', props.category.id)"> + <t t-esc="props.category.name"></t> + </span> + </span> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryButton.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryButton.xml new file mode 100644 index 00000000..da829cba --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryButton.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates id="template" xml:space="preserve"> + + <t t-name="CategoryButton" owl="1"> + <span class="category-button" t-on-click="trigger('switch-category', props.category.id)"> + <div class="category-img"> + <img t-att-src="imageUrl" alt="Category" /> + </div> + <div class="category-name"> + <t t-esc="props.category.name" /> + </div> + </span> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategorySimpleButton.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategorySimpleButton.xml new file mode 100644 index 00000000..de052e16 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategorySimpleButton.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates id="template" xml:space="preserve"> + + <t t-name="CategorySimpleButton" owl="1"> + <span class="category-simple-button" + t-on-click="trigger('switch-category', props.category.id)"> + <t t-esc="props.category.name" /> + </span> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.xml new file mode 100644 index 00000000..ab09f363 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="SetFiscalPositionButton" owl="1"> + <div class="control-button o_fiscal_position_button"> + <i class="fa fa-book" role="img" aria-label="Set fiscal position" + title="Set fiscal position" /> + <t t-esc='currentFiscalPositionName' /> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetPricelistButton.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetPricelistButton.xml new file mode 100644 index 00000000..ffe3e3ec --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetPricelistButton.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="SetPricelistButton" owl="1"> + <div class="control-button o_pricelist_button"> + <i class="fa fa-th-list" role="img" aria-label="Price list" title="Price list" /> + <t t-esc="currentPricelistName" /> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/HomeCategoryBreadcrumb.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/HomeCategoryBreadcrumb.xml new file mode 100644 index 00000000..2bfa426e --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/HomeCategoryBreadcrumb.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates id="template" xml:space="preserve"> + + <t t-name="HomeCategoryBreadcrumb" owl="1"> + <span class="breadcrumb"> + <span t-if="!env.isMobile" class="breadcrumb-button breadcrumb-home" + t-on-click="trigger('switch-category', 0)"> + <i class="fa fa-home" role="img" aria-label="Home" title="Home"></i> + </span> + <span t-if="env.isMobile" class="breadcrumb-button breadcrumb-home" + t-on-click="trigger('categ-popup', props.subcategories)"> + <t t-if="env.pos.get('selectedCategoryId') === 0"> + All + </t> + <t t-else=""> + <t t-esc="props.currentCat.name"/> + </t> + </span> + </span> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/NumpadWidget.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/NumpadWidget.xml new file mode 100644 index 00000000..4b9962c0 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/NumpadWidget.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="NumpadWidget" owl="1"> + <div class="numpad"> + <button class="input-button number-char" t-on-click="sendInput('1')">1</button> + <button class="input-button number-char" t-on-click="sendInput('2')">2</button> + <button class="input-button number-char" t-on-click="sendInput('3')">3</button> + <button class="mode-button" t-att-class="{'selected-mode': props.activeMode === 'quantity'}" + t-on-click="changeMode('quantity')">Qty</button> + <br /> + <button class="input-button number-char" t-on-click="sendInput('4')">4</button> + <button class="input-button number-char" t-on-click="sendInput('5')">5</button> + <button class="input-button number-char" t-on-click="sendInput('6')">6</button> + <button class="mode-button" t-att-class="{ + 'selected-mode': props.activeMode === 'discount', + 'disabled-mode': !hasManualDiscount + }" + t-att-disabled="!hasManualDiscount" + t-on-click="changeMode('discount')">Disc</button> + <br /> + <button class="input-button number-char" t-on-click="sendInput('7')">7</button> + <button class="input-button number-char" t-on-click="sendInput('8')">8</button> + <button class="input-button number-char" t-on-click="sendInput('9')">9</button> + <button class="mode-button" t-att-class="{ + 'selected-mode': props.activeMode === 'price', + 'disabled-mode': !hasPriceControlRights + }" t-att-disabled="!hasPriceControlRights" + t-on-click="changeMode('price')">Price</button> + <br /> + <button class="input-button numpad-minus" t-on-click="sendInput('-')">+/-</button> + <button class="input-button number-char" t-on-click="sendInput('0')">0</button> + <button class="input-button number-char" t-on-click="sendInput(decimalSeparator)"> + <t t-esc="decimalSeparator" /> + </button> + <button class="input-button numpad-backspace" t-on-click="sendInput('Backspace')"> + <img style="pointer-events: none;" src="/point_of_sale/static/src/img/backspace.png" + width="24" height="21" alt="Backspace" /> + </button> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderSummary.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderSummary.xml new file mode 100644 index 00000000..a229c53a --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderSummary.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OrderSummary" owl="1"> + <div class="summary clearfix"> + <div class="line"> + <div class="entry total"> + <span class="badge">Total: </span> + <span class="value"> + <t t-esc="props.total" /> + </span> + <div t-if="props.tax" class="subentry"> + Taxes: + <span class="value"> + <t t-esc="props.tax" /> + </span> + </div> + </div> + </div> + </div> + </t> + +</templates>
\ No newline at end of file diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderWidget.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderWidget.xml new file mode 100644 index 00000000..532309dc --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderWidget.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OrderWidget" owl="1"> + <div class="order-container" t-ref="scrollable"> + <div class="order"> + <t t-if="orderlinesArray.length === 0" > + <div class='order-empty'> + <i class='fa fa-shopping-cart' role="img" aria-label="Shopping cart" + title="Shopping cart"/> + <h1>This order is empty</h1> + </div> + </t> + <t t-else=""> + <ul class="orderlines"> + <t t-foreach="orderlinesArray" t-as="orderline" t-key="orderline.id"> + <Orderline line="orderline" /> + </t> + </ul> + <OrderSummary total="state.total" tax="state.tax" /> + </t> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml new file mode 100644 index 00000000..e4ede636 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml @@ -0,0 +1,75 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="Orderline" owl="1"> + <li t-on-click="selectLine" class="orderline" t-att-class="addedClasses"> + <span class="product-name"> + <t t-esc="props.line.get_full_product_name()"/> + <span> </span> + <t t-if="props.line.get_product().tracking!=='none' && (env.pos.picking_type.use_create_lots || env.pos.picking_type.use_existing_lots)"> + <t t-if="props.line.has_valid_product_lot()"> + <i t-on-click.stop="lotIconClicked" + class="oe_link_icon fa fa-list oe_icon line-lot-icon oe_green" + aria-label="Valid product lot" + role="img" + title="Valid product lot" + /> + </t> + <t t-else=""> + <i t-on-click.stop="lotIconClicked" + class="oe_link_icon fa fa-list oe_icon line-lot-icon oe_red" + aria-label="Invalid product lot" + role="img" + title="Invalid product lot" + /> + </t> + </t> + </span> + <span class="price"> + <t t-esc="env.pos.format_currency(props.line.get_display_price())"/> + </span> + <ul class="info-list"> + <t t-if="props.line.get_quantity_str() !== '1' || props.line.selected "> + <li class="info"> + <em> + <t t-esc="props.line.get_quantity_str()" /> + </em> + <span> </span><t t-esc="props.line.get_unit().name" /> + at + <t t-if="props.line.display_discount_policy() == 'without_discount' and + props.line.get_unit_display_price() < props.line.get_lst_price()"> + <s> + <t t-esc="env.pos.format_currency(props.line.get_fixed_lst_price(),'Product Price')" /> + </s> + <t t-esc="env.pos.format_currency(props.line.get_unit_display_price(),'Product Price')" /> + </t> + <t t-else=""> + <t t-esc="env.pos.format_currency(props.line.get_unit_display_price(),'Product Price')" /> + </t> + / + <t t-esc="props.line.get_unit().name" /> + </li> + </t> + <t t-if="props.line.get_discount_str() !== '0'"> + <li class="info"> + With a + <em> + <t t-esc="props.line.get_discount_str()" />% + </em> + discount + </li> + </t> + </ul> + <t t-if="props.line.get_lot_lines()"> + <ul class="info-list"> + <t t-foreach="props.line.get_lot_lines()" t-as="lot" t-key="lot.cid"> + <li> + SN <t t-esc="lot.attributes['lot_name']"/> + </li> + </t> + </ul> + </t> + </li> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductItem.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductItem.xml new file mode 100644 index 00000000..4825efaf --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductItem.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates id="template" xml:space="preserve"> + + <t t-name="ProductItem" owl="1"> + <article class="product" tabindex="0" t-on-keypress="spaceClickProduct" + t-on-click="trigger('click-product', props.product)" + t-att-data-product-id="props.product.id" + t-attf-aria-labelledby="article_product_{{props.product.id}}"> + <div class="product-img"> + <img t-att-src="imageUrl" t-att-alt="props.product.display_name" /> + <span class="price-tag"> + <t t-esc="price" /> + </span> + </div> + <div class="product-name" t-attf-id="article_product_{{props.product.id}}"> + <t t-esc="props.product.display_name" /> + </div> + </article> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductList.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductList.xml new file mode 100644 index 00000000..9e87ca95 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductList.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates id="template" xml:space="preserve"> + + <t t-name="ProductList" owl="1"> + <div class="product-list-container"> + <div t-if="props.products.length != 0" class="product-list"> + <t t-foreach="props.products" t-as="product" t-key="product.id"> + <ProductItem product="product" /> + </t> + </div> + <div t-else="" class="product-list-empty"> + <div class="product-list-empty"> + <t t-if="props.searchWord !== ''"> + <p> + No results found for " + <b t-esc="props.searchWord"></b> + ". + </p> + </t> + <t t-else=""> + <p>There are no products in this category.</p> + </t> + </div> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductScreen.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductScreen.xml new file mode 100644 index 00000000..6bef9281 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductScreen.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ProductScreen" owl="1"> + <div class="product-screen screen" t-att-class="{ oe_hidden: !props.isShown }"> + <div class="screen-full-width"> + <div class="leftpane" t-if="!env.isMobile || mobile_pane === 'left'"> + <t t-if="state.cashControl"> + <CashBoxOpening cashControl="state"/> + </t> + <t t-else=""> + <OrderWidget/> + <div class="pads"> + <div class="control-buttons"> + <t t-foreach="controlButtons" t-as="cb" t-key="cb.name"> + <t t-component="cb.component" t-key="cb.name" /> + </t> + </div> + <div class="subpads"> + <ActionpadWidget client="client"/> + <NumpadWidget activeMode="state.numpadMode" /> + </div> + </div> + <t t-if="env.isMobile"> + <MobileOrderWidget pane="mobile_pane" t-on-switchpane="switchPane"/> + </t> + </t> + </div> + <div class="rightpane" t-if="!env.isMobile || mobile_pane === 'right'"> + <ProductsWidget t-if="!state.cashControl"/> + <t t-if="env.isMobile"> + <MobileOrderWidget pane="mobile_pane" t-on-switchpane="switchPane"/> + </t> + </div> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidget.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidget.xml new file mode 100644 index 00000000..3dfd5276 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidget.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates id="template" xml:space="preserve"> + + <t t-name="ProductsWidget" owl="1"> + <div class="products-widget"> + <ProductsWidgetControlPanel breadcrumbs="breadcrumbs" subcategories="subcategories" hasNoCategories="hasNoCategories" /> + <ProductList products="productsToDisplay" searchWord="searchWord" /> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml new file mode 100644 index 00000000..2c7d7727 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml @@ -0,0 +1,51 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates id="template" xml:space="preserve"> + + <t t-name="ProductsWidgetControlPanel" owl="1"> + <div class="products-widget-control"> + <t t-if="!props.hasNoCategories"> + <div class="rightpane-header" t-att-class="{ + 'green-border-bottom': !env.pos.config.iface_display_categ_images, + 'grey-border-bottom': env.pos.config.iface_display_categ_images, + }"> + <!-- Breadcrumbs --> + <div class="breadcrumbs"> + <HomeCategoryBreadcrumb subcategories="props.subcategories" currentCat="props.breadcrumbs[props.breadcrumbs.length - 1]"/> + <t t-if="!env.isMobile"> + <t t-foreach="props.breadcrumbs" t-as="category" t-key="category.id"> + <CategoryBreadcrumb category="category" /> + </t> + </t> + </div> + <!-- Subcategories --> + <t t-if="props.subcategories.length > 0 and !env.pos.config.iface_display_categ_images and !env.isMobile"> + <t t-foreach="props.subcategories" t-as="category" t-key="category.id"> + <CategorySimpleButton category="category" /> + </t> + </t> + </div> + <t t-if="props.subcategories.length > 0 and env.pos.config.iface_display_categ_images and !env.isMobile"> + <div class="categories"> + <div class="category-list-scroller"> + <div class="category-list"> + <t t-foreach="props.subcategories" t-as="category" t-key="category.id"> + <CategoryButton category="category" /> + </t> + </div> + </div> + </div> + </t> + </t> + <Portal target="'.pos .search-bar-portal'"> + <div class="search-box"> + <span class="icon"><i class="fa fa-search"></i></span> + <span t-on-click="clearSearch" class="clear-icon"> + <i class="fa fa-times" aria-hidden="true"></i> + </span> + <input t-ref="search-word-input" type="text" placeholder="Search Products..." t-on-keyup="updateSearch" /> + </div> + </Portal> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml new file mode 100644 index 00000000..379b360d --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml @@ -0,0 +1,211 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OrderReceipt" owl="1"> + <div class="pos-receipt"> + <t t-if="receipt.company.logo"> + <img class="pos-receipt-logo" t-att-src="receipt.company.logo" alt="Logo"/> + <br/> + </t> + <t t-if="!receipt.company.logo"> + <h2 class="pos-receipt-center-align"> + <t t-esc="receipt.company.name" /> + </h2> + <br/> + </t> + <div class="pos-receipt-contact"> + <t t-if="receipt.company.contact_address"> + <div><t t-esc="receipt.company.contact_address" /></div> + </t> + <t t-if="receipt.company.phone"> + <div>Tel:<t t-esc="receipt.company.phone" /></div> + </t> + <t t-if="receipt.company.vat"> + <div><t t-esc="receipt.company.vat_label"/>:<t t-esc="receipt.company.vat" /></div> + </t> + <t t-if="receipt.company.email"> + <div><t t-esc="receipt.company.email" /></div> + </t> + <t t-if="receipt.company.website"> + <div><t t-esc="receipt.company.website" /></div> + </t> + <t t-if="receipt.header_html"> + <t t-raw="receipt.header_html" /> + </t> + <t t-if="!receipt.header_html and receipt.header"> + <div style="white-space:pre-line"><t t-esc="receipt.header" /></div> + </t> + <t t-if="receipt.cashier"> + <div class="cashier"> + <div>--------------------------------</div> + <div>Served by <t t-esc="receipt.cashier" /></div> + </div> + </t> + </div> + <br /><br /> + + <!-- Orderlines --> + + <div class="orderlines"> + <t t-foreach="receipt.orderlines" t-as="line" t-key="line.id"> + <t t-if="isSimple(line)"> + <div> + <t t-esc="line.product_name_wrapped[0]" /> + <span t-esc="env.pos.format_currency_no_symbol(line.price_display)" class="price_display pos-receipt-right-align"/> + </div> + <WrappedProductNameLines line="line" /> + </t> + <t t-else=""> + <div t-esc="line.product_name_wrapped[0]" /> + <WrappedProductNameLines line="line" /> + <t t-if="line.display_discount_policy == 'without_discount' and line.price != line.price_lst"> + <div class="pos-receipt-left-padding"> + <t t-esc="env.pos.format_currency_no_symbol(line.price_lst)" /> + -> + <t t-esc="env.pos.format_currency_no_symbol(line.price)" /> + </div> + </t> + <t t-elif="line.discount !== 0"> + <div class="pos-receipt-left-padding"> + <t t-if="env.pos.config.iface_tax_included === 'total'"> + <t t-esc="env.pos.format_currency_no_symbol(line.price_with_tax_before_discount)"/> + </t> + <t t-else=""> + <t t-esc="env.pos.format_currency_no_symbol(line.price)"/> + </t> + </div> + </t> + <t t-if="line.discount !== 0"> + <div class="pos-receipt-left-padding"> + Discount: <t t-esc="line.discount" />% + </div> + </t> + <div class="pos-receipt-left-padding"> + <t t-esc="Math.round(line.quantity * Math.pow(10, env.pos.dp['Product Unit of Measure'])) / Math.pow(10, env.pos.dp['Product Unit of Measure'])"/> + <t t-if="!line.is_in_unit" t-esc="line.unit_name" /> + x + <t t-esc="env.pos.format_currency_no_symbol(line.price_display_one)" /> + <span class="price_display pos-receipt-right-align"> + <t t-esc="env.pos.format_currency_no_symbol(line.price_display)" /> + </span> + </div> + </t> + <t t-if="line.pack_lot_lines"> + <div class="pos-receipt-left-padding"> + <ul> + <t t-foreach="line.pack_lot_lines" t-as="lot" t-key="lot.cid"> + <li> + SN <t t-esc="lot.attributes['lot_name']"/> + </li> + </t> + </ul> + </div> + </t> + </t> + </div> + + <!-- Subtotal --> + + <t t-if="!isTaxIncluded"> + <div class="pos-receipt-right-align">--------</div> + <br/> + <div>Subtotal<span t-esc="env.pos.format_currency(receipt.subtotal)" class="pos-receipt-right-align"/></div> + <t t-foreach="receipt.tax_details" t-as="tax" t-key="tax.name"> + <div> + <t t-esc="tax.name" /> + <span t-esc='env.pos.format_currency_no_symbol(tax.amount)' class="pos-receipt-right-align"/> + </div> + </t> + </t> + + <!-- Total --> + <div class="pos-receipt-right-align">--------</div> + <br/> + <div class="pos-receipt-amount"> + TOTAL + <span t-esc="env.pos.format_currency(receipt.total_with_tax)" class="pos-receipt-right-align"/> + </div> + <t t-if="receipt.total_rounded != receipt.total_with_tax"> + <div class="pos-receipt-amount"> + Rounding + <span t-esc='env.pos.format_currency(receipt.rounding_applied)' class="pos-receipt-right-align"/> + </div> + <div class="pos-receipt-amount"> + To Pay + <span t-esc='env.pos.format_currency(receipt.total_rounded)' class="pos-receipt-right-align"/> + </div> + </t> + <br/><br/> + + <!-- Payment Lines --> + + <t t-foreach="receipt.paymentlines" t-as="line" t-key="line.cid"> + <div> + <t t-esc="line.name" /> + <span t-esc="env.pos.format_currency_no_symbol(line.amount)" class="pos-receipt-right-align"/> + </div> + </t> + <br/> + + <div class="pos-receipt-amount receipt-change"> + CHANGE + <span t-esc="env.pos.format_currency(receipt.change)" class="pos-receipt-right-align"/> + </div> + <br/> + + <!-- Extra Payment Info --> + + <t t-if="receipt.total_discount"> + <div> + Discounts + <span t-esc="env.pos.format_currency(receipt.total_discount)" class="pos-receipt-right-align"/> + </div> + </t> + <t t-if="isTaxIncluded"> + <t t-foreach="receipt.tax_details" t-as="tax" t-key="tax.name"> + <div> + <t t-esc="tax.name" /> + <span t-esc="env.pos.format_currency_no_symbol(tax.amount)" class="pos-receipt-right-align"/> + </div> + </t> + <div> + Total Taxes + <span t-esc="env.pos.format_currency(receipt.total_tax)" class="pos-receipt-right-align"/> + </div> + </t> + + <div class="before-footer" /> + + <!-- Footer --> + <div t-if="receipt.footer_html" class="pos-receipt-center-align"> + <t t-raw="receipt.footer_html" /> + </div> + + <div t-if="!receipt.footer_html and receipt.footer" class="pos-receipt-center-align" style="white-space:pre-line"> + <br/> + <t t-esc="receipt.footer" /> + <br/> + <br/> + </div> + + <div class="after-footer"> + <t t-foreach="receipt.paymentlines" t-as="line"> + <t t-if="line.ticket"> + <br /> + <div class="pos-payment-terminal-receipt"> + <t t-raw="line.ticket" /> + </div> + </t> + </t> + </div> + + <br/> + <div class="pos-receipt-order-data"> + <div><t t-esc="receipt.name" /></div> + <div><t t-esc="receipt.date.localestring" /></div> + </div> + + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/ReceiptScreen.xml b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/ReceiptScreen.xml new file mode 100644 index 00000000..8f0bd54a --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/ReceiptScreen.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ReceiptScreen" owl="1"> + <div class="receipt-screen screen"> + <div class="screen-content"> + <div class="top-content"> + <div class="top-content-center"> + <h1 t-if="!env.isMobile"> + <t t-esc="orderAmountPlusTip" /> + </h1> + </div> + <div class="button next" t-att-class="{ highlight: !locked }" + t-on-click="orderDone"> + New Order <i class="fa fa-angle-double-right"></i> + </div> + </div> + <div class="default-view"> + <div class="pos-receipt-container"> + <OrderReceipt order="currentOrder" t-ref="order-receipt" /> + </div> + <div class="actions"> + <h1>How would you like to receive your receipt?</h1> + <div class="buttons"> + <div class="button print" t-on-click="printReceipt"> + <i class="fa fa-print"></i> Print Receipt + </div> + </div> + <form t-on-submit.prevent="onSendEmail" class="send-email"> + <div class="email-icon"><i class="fa fa-envelope-o" aria-hidden="true"></i></div> + <div class="input-email"> + <input type="email" placeholder="Email Receipt" t-model="orderUiState.inputEmail" /> + <button class="send" t-att-class="{ highlight: is_email(orderUiState.inputEmail) }" type="submit">Send</button> + </div> + </form> + <t t-if="orderUiState.emailSuccessful !== null"> + <div class="notice" t-attf-class="{{ orderUiState.emailSuccessful ? 'successful' : 'failed' }}"> + <t t-esc="orderUiState.emailNotice"></t> + </div> + </t> + </div> + </div> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/WrappedProductNameLines.xml b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/WrappedProductNameLines.xml new file mode 100644 index 00000000..d49061a8 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/WrappedProductNameLines.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="WrappedProductNameLines" owl="1"> + <span> + <t t-foreach="props.line.product_name_wrapped.slice(1)" t-as="wrapped_line"><t t-esc="wrapped_line"/></t> + </span> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ScaleScreen/ScaleScreen.xml b/addons/point_of_sale/static/src/xml/Screens/ScaleScreen/ScaleScreen.xml new file mode 100644 index 00000000..de21dcc3 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ScaleScreen/ScaleScreen.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates id="template" xml:space="preserve"> + + <t t-name="ScaleScreen" owl="1"> + <div class="scale-screen screen"> + <div class="screen-content"> + <div class="top-content"> + <span class="button back" t-on-click="back"> + <i class="fa fa-angle-double-left"></i> + Back + </span> + <h1 class="product-name"> + <t t-esc="productName" /> + </h1> + </div> + <div class="centered-content"> + <div class="weight js-weight"> + <t t-esc="productWeightString" /> + </div> + <div class="product-price"> + <t + t-esc="env.pos.format_currency(productPrice) + '/' + productUom" /> + </div> + <div class="computed-price"> + <t t-esc="computedPriceString" /> + </div> + <div class="buy-product" t-on-click="confirm"> + Order + <i class="fa fa-angle-double-right"></i> + </div> + </div> + </div> + </div> + </t> + +</templates>
\ No newline at end of file diff --git a/addons/point_of_sale/static/src/xml/Screens/TicketScreen/TicketScreen.xml b/addons/point_of_sale/static/src/xml/Screens/TicketScreen/TicketScreen.xml new file mode 100644 index 00000000..40bfc501 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/TicketScreen/TicketScreen.xml @@ -0,0 +1,60 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="TicketScreen" owl="1"> + <div class="ticket-screen screen"> + <div class="screen-content"> + <div class="controls"> + <div class="buttons"> + <button t-if="showNewTicketButton" class="highlight" t-on-click="createNewOrder">New Order</button> + <button class="discard" t-on-click="trigger('close-screen')">Discard</button> + </div> + <t t-set="placeholder">Search Tickets...</t> + <SearchBar config="searchBarConfig" placeholder="placeholder" /> + </div> + <div class="orders"> + <div class="header-row"> + <div class="col start wide">Date</div> + <div class="col start wide">Receipt Number</div> + <div class="col start">Customer</div> + <div class="col start wide" t-if="showCardholderName()">Cardholder Name</div> + <div class="col start">Employee</div> + <div class="col end">Total</div> + <div class="col start narrow">Status</div> + <div class="col center very-narrow" name="delete"></div> + </div> + <t t-foreach="filteredOrderList" t-as="order" t-key="order.cid"> + <div class="order-row pointer" t-on-click="selectOrder(order)"> + <div class="col start wide"> + <t t-esc="getDate(order)"></t> + </div> + <div class="col start wide"> + <t t-esc="order.name"></t> + </div> + <div class="col start"> + <t t-esc="getCustomer(order)"></t> + </div> + <div t-if="showCardholderName()" class="col start"> + <t t-esc="getCardholderName(order)"></t> + </div> + <div class="col start"> + <t t-esc="getEmployee(order)"></t> + </div> + <div class="col end"> + <t t-esc="getTotal(order)"></t> + </div> + <div class="col start narrow"> + <t t-esc="getStatus(order)"></t> + </div> + <div t-if="!hideDeleteButton(order)" class="col center very-narrow delete-button" name="delete" t-on-click.stop="deleteOrder(order)"> + <i class="fa fa-trash" aria-hidden="true"></i> + </div> + <div t-else="" class="col center very-narrow delete-button"></div> + </div> + </t> + </div> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/debug_manager.xml b/addons/point_of_sale/static/src/xml/debug_manager.xml new file mode 100644 index 00000000..7ecc7cc1 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/debug_manager.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + +<t t-extend="WebClient.DebugManager.Backend"> + <t t-jquery="a[data-action='perform_click_everywhere_test']" t-operation="after"> + <a role="menuitem" href="#" data-action="perform_pos_js_tests" class="dropdown-item">Run Point of Sale JS Tests</a> + </t> +</t> + +</templates> diff --git a/addons/point_of_sale/static/tests/tours/Chrome.tour.js b/addons/point_of_sale/static/tests/tours/Chrome.tour.js new file mode 100644 index 00000000..a1c992de --- /dev/null +++ b/addons/point_of_sale/static/tests/tours/Chrome.tour.js @@ -0,0 +1,103 @@ +odoo.define('point_of_sale.tour.Chrome', function (require) { + 'use strict'; + + const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods'); + const { ReceiptScreen } = require('point_of_sale.tour.ReceiptScreenTourMethods'); + const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods'); + const { TicketScreen } = require('point_of_sale.tour.TicketScreenTourMethods'); + const { Chrome } = require('point_of_sale.tour.ChromeTourMethods'); + const { getSteps, startSteps } = require('point_of_sale.tour.utils'); + var Tour = require('web_tour.tour'); + + startSteps(); + + // Order 1 is at Product Screen + ProductScreen.do.clickHomeCategory(); + ProductScreen.exec.addOrderline('Desk Pad', '1', '2', '2.0'); + Chrome.do.clickTicketButton(); + TicketScreen.check.checkStatus('-0001', 'Ongoing'); + + // Order 2 is at Payment Screen + TicketScreen.do.clickNewTicket(); + ProductScreen.exec.addOrderline('Monitor Stand', '3', '4', '12.0'); + ProductScreen.do.clickPayButton(); + PaymentScreen.check.isShown(); + Chrome.do.clickTicketButton(); + TicketScreen.check.checkStatus('-0002', 'Payment'); + + // Order 3 is at Receipt Screen + TicketScreen.do.clickNewTicket(); + ProductScreen.exec.addOrderline('Whiteboard Pen', '5', '6', '30.0'); + ProductScreen.do.clickPayButton(); + PaymentScreen.do.clickPaymentMethod('Bank'); + PaymentScreen.check.remainingIs('0.0'); + PaymentScreen.check.validateButtonIsHighlighted(true); + PaymentScreen.do.clickValidate(); + ReceiptScreen.check.isShown(); + Chrome.do.clickTicketButton(); + TicketScreen.check.checkStatus('-0003', 'Receipt'); + + // Select order 1, should be at Product Screen + TicketScreen.do.selectOrder('-0001'); + ProductScreen.check.productIsDisplayed('Desk Pad'); + ProductScreen.check.selectedOrderlineHas('Desk Pad', '1.0', '2.0'); + + // Select order 2, should be at Payment Screen + Chrome.do.clickTicketButton(); + TicketScreen.do.selectOrder('-0002'); + PaymentScreen.check.emptyPaymentlines('12.0'); + PaymentScreen.check.validateButtonIsHighlighted(false); + + // Select order 3, should be at Receipt Screen + Chrome.do.clickTicketButton(); + TicketScreen.do.selectOrder('-0003'); + ReceiptScreen.check.totalAmountContains('30.0'); + + // Pay order 1, with change + Chrome.do.clickTicketButton(); + TicketScreen.do.selectOrder('-0001'); + ProductScreen.do.clickPayButton(); + PaymentScreen.do.clickPaymentMethod('Cash'); + PaymentScreen.do.pressNumpad('2 0'); + PaymentScreen.check.remainingIs('0.0'); + PaymentScreen.check.validateButtonIsHighlighted(true); + PaymentScreen.do.clickValidate(); + ReceiptScreen.check.totalAmountContains('2.0'); + + // Order 1 now should have Receipt status + Chrome.do.clickTicketButton(); + TicketScreen.check.checkStatus('-0001', 'Receipt'); + + // Select order 3, should still be at Receipt Screen + // and the total amount doesn't change. + TicketScreen.do.selectOrder('-0003'); + ReceiptScreen.check.totalAmountContains('30.0'); + + // click next screen on order 3 + // then delete the new empty order + ReceiptScreen.do.clickNextOrder(); + ProductScreen.check.orderIsEmpty(); + Chrome.do.clickTicketButton(); + TicketScreen.do.deleteOrder('-0004'); + TicketScreen.do.deleteOrder('-0001'); + + // After deleting order 1 above, order 2 became + // the 2nd-row order and it has payment status + TicketScreen.check.nthRowContains(2, 'Payment') + TicketScreen.do.deleteOrder('-0002'); + Chrome.do.confirmPopup(); + TicketScreen.do.clickNewTicket(); + + // Invoice an order + ProductScreen.exec.addOrderline('Whiteboard Pen', '5', '6'); + ProductScreen.do.clickCustomerButton(); + ProductScreen.do.clickCustomer('Nicole Ford'); + ProductScreen.do.clickSetCustomer(); + ProductScreen.do.clickPayButton(); + PaymentScreen.do.clickPaymentMethod('Bank'); + PaymentScreen.do.clickInvoiceButton(); + PaymentScreen.do.clickValidate(); + ReceiptScreen.check.isShown(); + + Tour.register('ChromeTour', { test: true, url: '/pos/ui' }, getSteps()); +}); diff --git a/addons/point_of_sale/static/tests/tours/OrderManagementScreen.tour.js b/addons/point_of_sale/static/tests/tours/OrderManagementScreen.tour.js new file mode 100644 index 00000000..cfd6483a --- /dev/null +++ b/addons/point_of_sale/static/tests/tours/OrderManagementScreen.tour.js @@ -0,0 +1,138 @@ +odoo.define('point_of_sale.tour.OrderManagementScreen', function (require) { + 'use strict'; + + const { OrderManagementScreen } = require('point_of_sale.tour.OrderManagementScreenTourMethods'); + const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods'); + const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods'); + const { ClientListScreen } = require('point_of_sale.tour.ClientListScreenTourMethods'); + const { TicketScreen } = require('point_of_sale.tour.TicketScreenTourMethods'); + const { Chrome } = require('point_of_sale.tour.ChromeTourMethods'); + const { makeFullOrder } = require('point_of_sale.tour.CompositeTourMethods'); + const { getSteps, startSteps } = require('point_of_sale.tour.utils'); + var Tour = require('web_tour.tour'); + + // signal to start generating steps + // when finished, steps can be taken from getSteps + startSteps(); + + // Go by default to home category + ProductScreen.do.clickHomeCategory(); + + // make one order and check if it can be seen from the management screen. + // order 0001 + makeFullOrder({ orderlist: [['Whiteboard Pen', '5', '6']], payment: ['Cash', '30'] }); + Chrome.do.clickOrderManagementButton(); + OrderManagementScreen.check.isShown(); + OrderManagementScreen.check.orderlistHas({ orderName: '-0001', total: '30' }); + + OrderManagementScreen.do.clickBack(); + + // make multiple orders and check them in the management screen. + // order 0002 + makeFullOrder({ + orderlist: [ + ['Desk Pad', '1', '2'], + ['Monitor Stand', '3', '4'], + ['Whiteboard Pen', '5', '6'], + ], + payment: ['Bank', '44'], + }); + // order 0003 + makeFullOrder({ + orderlist: [ + ['Desk Pad', '1', '2'], + ['Whiteboard Pen', '5', '6'], + ], + customer: 'Colleen Diaz', + payment: ['Cash', '50'], + }); + // order 0004 + makeFullOrder({ + orderlist: [ + ['Monitor Stand', '3', '4'], + ['Whiteboard Pen', '5', '6'], + ], + payment: ['Bank', '42'], + }); + + Chrome.do.clickOrderManagementButton(); + OrderManagementScreen.check.isShown(); + OrderManagementScreen.check.orderlistHas({ orderName: '-0002', total: '44' }); + OrderManagementScreen.check.orderlistHas({ + orderName: '0003', + total: '32', + customer: 'Colleen Diaz', + }); + OrderManagementScreen.check.orderlistHas({ orderName: '-0004', total: '42' }); + + // click the currently active order + OrderManagementScreen.do.clickOrder('-0005'); + ProductScreen.check.isShown(); + + // Add 2 orders, they should appear in order management screen + // order 0006 + Chrome.do.clickTicketButton(); + TicketScreen.do.clickNewTicket(); + ProductScreen.exec.addOrderline('Whiteboard Pen', '66', '6'); + + // order 0007, should be at payment screen + Chrome.do.clickTicketButton(); + TicketScreen.do.clickNewTicket(); + ProductScreen.exec.addOrderline('Monitor Stand', '55', '5'); + ProductScreen.do.clickCustomerButton(); + ClientListScreen.exec.setClient('Azure Interior'); + ProductScreen.do.clickPayButton(); + + Chrome.do.clickOrderManagementButton(); + OrderManagementScreen.check.orderlistHas({ orderName: '-0006', total: '396' }); + OrderManagementScreen.check.orderlistHas({ + orderName: '-0007', + total: '275', + customer: 'Azure Interior', + }); + + // select a paid order, order row should be highlighted and should show order details + OrderManagementScreen.do.clickOrder('-0004'); + OrderManagementScreen.check.highlightedOrderRowHas('-0004'); + OrderManagementScreen.check.orderDetailsHas({ + lines: [ + { product: 'Monitor Stand', quantity: '3' }, + { product: 'Whiteboard Pen', quantity: '5' }, + ], + total: '42', + }); + OrderManagementScreen.do.clickOrder('-0001'); + OrderManagementScreen.check.highlightedOrderRowHas('-0001'); + // 0004 should not be highlighted anymore + OrderManagementScreen.check.orderRowIsNotHighlighted('-0004'); + OrderManagementScreen.check.orderDetailsHas({ + lines: [{ product: 'Whiteboard Pen', quantity: '5' }], + total: '30', + }); + + // Select a paid order then invoice it. The selected order should remain selected + // and will contain a new customer. After invoice, the current customer should be removed. + // TODO: enable the following steps once the issue in invoicing is solved. + // OrderManagementScreen.do.clickInvoiceButton(); + // Chrome.do.confirmPopup(); + // ClientListScreen.check.isShown(); + // ClientListScreen.exec.setClient('Jesse Brown'); + // OrderManagementScreen.check.highlightedOrderRowHas('Jesse Brown'); + + // Check if order 0007 is selected, it should be at payment screen + OrderManagementScreen.do.clickOrder('-0007'); + PaymentScreen.check.isShown(); + + Chrome.do.clickOrderManagementButton(); + OrderManagementScreen.check.isShown(); + OrderManagementScreen.do.clickOrder('-0003'); + OrderManagementScreen.do.clickPrintReceiptButton(); + OrderManagementScreen.check.reprintReceiptIsShown(); + OrderManagementScreen.check.receiptChangeIs('18.0'); + OrderManagementScreen.check.receiptOrderDataContains('-0003'); + OrderManagementScreen.check.receiptAmountIs('32.0'); + OrderManagementScreen.do.closeReceipt(); + OrderManagementScreen.check.isNotHidden(); + + Tour.register('OrderManagementScreenTour', { test: true, url: '/pos/ui' }, getSteps()); +}); diff --git a/addons/point_of_sale/static/tests/tours/PaymentScreen.tour.js b/addons/point_of_sale/static/tests/tours/PaymentScreen.tour.js new file mode 100644 index 00000000..296fbd55 --- /dev/null +++ b/addons/point_of_sale/static/tests/tours/PaymentScreen.tour.js @@ -0,0 +1,70 @@ +odoo.define('point_of_sale.tour.PaymentScreen', function (require) { + 'use strict'; + + const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods'); + const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods'); + const { getSteps, startSteps } = require('point_of_sale.tour.utils'); + var Tour = require('web_tour.tour'); + + startSteps(); + + ProductScreen.exec.addOrderline('Letter Tray', '10'); + ProductScreen.check.selectedOrderlineHas('Letter Tray', '10.0'); + ProductScreen.do.clickPayButton(); + PaymentScreen.check.emptyPaymentlines('52.8'); + + PaymentScreen.do.clickPaymentMethod('Cash'); + PaymentScreen.do.pressNumpad('1 1'); + PaymentScreen.check.selectedPaymentlineHas('Cash', '11.00'); + PaymentScreen.check.remainingIs('41.8'); + PaymentScreen.check.changeIs('0.0'); + PaymentScreen.check.validateButtonIsHighlighted(false); + // remove the selected paymentline with multiple backspace presses + PaymentScreen.do.pressNumpad('Backspace Backspace'); + PaymentScreen.check.selectedPaymentlineHas('Cash', '0.00'); + PaymentScreen.do.pressNumpad('Backspace'); + PaymentScreen.check.emptyPaymentlines('52.8'); + + // Pay with bank, the selected line should have full amount + PaymentScreen.do.clickPaymentMethod('Bank'); + PaymentScreen.check.remainingIs('0.0'); + PaymentScreen.check.changeIs('0.0'); + PaymentScreen.check.validateButtonIsHighlighted(true); + // remove the line using the delete button + PaymentScreen.do.clickPaymentlineDelButton('Bank', '52.8'); + + // Use +10 and +50 to increment the amount of the paymentline + PaymentScreen.do.clickPaymentMethod('Cash'); + PaymentScreen.do.pressNumpad('+10'); + PaymentScreen.check.remainingIs('42.8'); + PaymentScreen.check.changeIs('0.0'); + PaymentScreen.check.validateButtonIsHighlighted(false); + PaymentScreen.do.pressNumpad('+50'); + PaymentScreen.check.remainingIs('0.0'); + PaymentScreen.check.changeIs('7.2'); + PaymentScreen.check.validateButtonIsHighlighted(true); + PaymentScreen.do.clickPaymentlineDelButton('Cash', '60.0'); + + // Multiple paymentlines + PaymentScreen.do.clickPaymentMethod('Cash'); + PaymentScreen.do.pressNumpad('1'); + PaymentScreen.check.remainingIs('51.8'); + PaymentScreen.check.changeIs('0.0'); + PaymentScreen.check.validateButtonIsHighlighted(false); + PaymentScreen.do.clickPaymentMethod('Cash'); + PaymentScreen.do.pressNumpad('5'); + PaymentScreen.check.remainingIs('46.8'); + PaymentScreen.check.changeIs('0.0'); + PaymentScreen.check.validateButtonIsHighlighted(false); + PaymentScreen.do.clickPaymentMethod('Bank'); + PaymentScreen.do.pressNumpad('2 0'); + PaymentScreen.check.remainingIs('26.8'); + PaymentScreen.check.changeIs('0.0'); + PaymentScreen.check.validateButtonIsHighlighted(false); + PaymentScreen.do.clickPaymentMethod('Bank'); + PaymentScreen.check.remainingIs('0.0'); + PaymentScreen.check.changeIs('0.0'); + PaymentScreen.check.validateButtonIsHighlighted(true); + + Tour.register('PaymentScreenTour', { test: true, url: '/pos/ui' }, getSteps()); +}); diff --git a/addons/point_of_sale/static/tests/tours/ProductConfigurator.tour.js b/addons/point_of_sale/static/tests/tours/ProductConfigurator.tour.js new file mode 100644 index 00000000..d3acf388 --- /dev/null +++ b/addons/point_of_sale/static/tests/tours/ProductConfigurator.tour.js @@ -0,0 +1,66 @@ +odoo.define('point_of_sale.tour.ProductConfigurator', function (require) { + 'use strict'; + + const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods'); + const { ProductConfigurator } = require('point_of_sale.tour.ProductConfiguratorTourMethods'); + const { getSteps, startSteps } = require('point_of_sale.tour.utils'); + var Tour = require('web_tour.tour'); + + // signal to start generating steps + // when finished, steps can be taken from getSteps + startSteps(); + + // Go by default to home category + ProductScreen.do.clickHomeCategory(); + + // Click on Configurable Chair product + ProductScreen.do.clickDisplayedProduct('Configurable Chair'); + ProductConfigurator.check.isShown(); + + // Cancel configuration, not product should be in order + ProductConfigurator.do.cancelAttributes(); + ProductScreen.check.orderIsEmpty(); + + // Click on Configurable Chair product + ProductScreen.do.clickDisplayedProduct('Configurable Chair'); + ProductConfigurator.check.isShown(); + + // Pick Color + ProductConfigurator.do.pickColor('Red'); + + // Pick Radio + ProductConfigurator.do.pickSelect('Metal'); + + // Pick Select + ProductConfigurator.do.pickRadio('Other'); + + // Fill in custom attribute + ProductConfigurator.do.fillCustomAttribute('Custom Fabric'); + + // Confirm configuration + ProductConfigurator.do.confirmAttributes(); + + // Check that the product has been added to the order with correct attributes and price + ProductScreen.check.selectedOrderlineHas('Configurable Chair (Red, Metal, Other: Custom Fabric)', '1.0', '11.0'); + + // Orderlines with the same attributes should be merged + ProductScreen.do.clickHomeCategory(); + ProductScreen.do.clickDisplayedProduct('Configurable Chair'); + ProductConfigurator.do.pickColor('Red'); + ProductConfigurator.do.pickSelect('Metal'); + ProductConfigurator.do.pickRadio('Other'); + ProductConfigurator.do.fillCustomAttribute('Custom Fabric'); + ProductConfigurator.do.confirmAttributes(); + ProductScreen.check.selectedOrderlineHas('Configurable Chair (Red, Metal, Other: Custom Fabric)', '2.0', '22.0'); + + // Orderlines with different attributes shouldn't be merged + ProductScreen.do.clickHomeCategory(); + ProductScreen.do.clickDisplayedProduct('Configurable Chair'); + ProductConfigurator.do.pickColor('Blue'); + ProductConfigurator.do.pickSelect('Metal'); + ProductConfigurator.do.pickRadio('Leather'); + ProductConfigurator.do.confirmAttributes(); + ProductScreen.check.selectedOrderlineHas('Configurable Chair (Blue, Metal, Leather)', '1.0', '10.0'); + + Tour.register('ProductConfiguratorTour', { test: true, url: '/pos/ui' }, getSteps()); +}); diff --git a/addons/point_of_sale/static/tests/tours/ProductScreen.tour.js b/addons/point_of_sale/static/tests/tours/ProductScreen.tour.js new file mode 100644 index 00000000..9d3dcc3f --- /dev/null +++ b/addons/point_of_sale/static/tests/tours/ProductScreen.tour.js @@ -0,0 +1,105 @@ +odoo.define('point_of_sale.tour.ProductScreen', function (require) { + 'use strict'; + + const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods'); + const { getSteps, startSteps } = require('point_of_sale.tour.utils'); + var Tour = require('web_tour.tour'); + + // signal to start generating steps + // when finished, steps can be taken from getSteps + startSteps(); + + // Go by default to home category + ProductScreen.do.clickHomeCategory(); + + // Clicking product multiple times should increment quantity + ProductScreen.do.clickDisplayedProduct('Desk Organizer'); + ProductScreen.check.selectedOrderlineHas('Desk Organizer', '1.0', '5.10'); + ProductScreen.do.clickDisplayedProduct('Desk Organizer'); + ProductScreen.check.selectedOrderlineHas('Desk Organizer', '2.0', '10.20'); + + // Clicking product should add new orderline and select the orderline + // If orderline exists, increment the quantity + ProductScreen.do.clickDisplayedProduct('Letter Tray'); + ProductScreen.check.selectedOrderlineHas('Letter Tray', '1.0', '4.80'); + ProductScreen.do.clickDisplayedProduct('Desk Organizer'); + ProductScreen.check.selectedOrderlineHas('Desk Organizer', '3.0', '15.30'); + + // Check effects of clicking numpad buttons + ProductScreen.do.clickOrderline('Letter Tray', '1'); + ProductScreen.check.selectedOrderlineHas('Letter Tray', '1.0'); + ProductScreen.do.pressNumpad('Backspace'); + ProductScreen.check.selectedOrderlineHas('Letter Tray', '0.0', '0.0'); + ProductScreen.do.pressNumpad('Backspace'); + ProductScreen.check.selectedOrderlineHas('Desk Organizer', '3', '15.30'); + ProductScreen.do.pressNumpad('Backspace'); + ProductScreen.check.selectedOrderlineHas('Desk Organizer', '0.0', '0.0'); + ProductScreen.do.pressNumpad('1'); + ProductScreen.check.selectedOrderlineHas('Desk Organizer', '1.0', '5.1'); + ProductScreen.do.pressNumpad('2'); + ProductScreen.check.selectedOrderlineHas('Desk Organizer', '12.0', '61.2'); + ProductScreen.do.pressNumpad('3'); + ProductScreen.check.selectedOrderlineHas('Desk Organizer', '123.0', '627.3'); + ProductScreen.do.pressNumpad('. 5'); + ProductScreen.check.selectedOrderlineHas('Desk Organizer', '123.5', '629.85'); + ProductScreen.do.pressNumpad('Price'); + ProductScreen.do.pressNumpad('1'); + ProductScreen.check.selectedOrderlineHas('Desk Organizer', '123.5', '123.5'); + ProductScreen.do.pressNumpad('1 .'); + ProductScreen.check.selectedOrderlineHas('Desk Organizer', '123.5', '1,358.5'); + ProductScreen.do.pressNumpad('Disc'); + ProductScreen.do.pressNumpad('5 .'); + ProductScreen.check.selectedOrderlineHas('Desk Organizer', '123.5', '1,290.58'); + ProductScreen.do.pressNumpad('Qty'); + ProductScreen.do.pressNumpad('Backspace'); + ProductScreen.do.pressNumpad('Backspace'); + ProductScreen.check.orderIsEmpty(); + + // Check different subcategories + ProductScreen.do.clickSubcategory('Desks'); + ProductScreen.check.productIsDisplayed('Desk Pad'); + ProductScreen.do.clickHomeCategory(); + ProductScreen.do.clickSubcategory('Miscellaneous'); + ProductScreen.check.productIsDisplayed('Whiteboard Pen'); + ProductScreen.do.clickHomeCategory(); + ProductScreen.do.clickSubcategory('Chairs'); + ProductScreen.check.productIsDisplayed('Letter Tray'); + ProductScreen.do.clickHomeCategory(); + + // Add multiple orderlines then delete each of them until empty + ProductScreen.do.clickDisplayedProduct('Whiteboard Pen'); + ProductScreen.do.clickDisplayedProduct('Wall Shelf Unit'); + ProductScreen.do.clickDisplayedProduct('Small Shelf'); + ProductScreen.do.clickDisplayedProduct('Magnetic Board'); + ProductScreen.do.clickDisplayedProduct('Monitor Stand'); + ProductScreen.do.clickOrderline('Whiteboard Pen', '1.0'); + ProductScreen.check.selectedOrderlineHas('Whiteboard Pen', '1.0'); + ProductScreen.do.pressNumpad('Backspace'); + ProductScreen.check.selectedOrderlineHas('Whiteboard Pen', '0.0'); + ProductScreen.do.pressNumpad('Backspace'); + ProductScreen.check.selectedOrderlineHas('Monitor Stand', '1.0'); + ProductScreen.do.clickOrderline('Wall Shelf Unit', '1.0'); + ProductScreen.check.selectedOrderlineHas('Wall Shelf Unit', '1.0'); + ProductScreen.do.pressNumpad('Backspace'); + ProductScreen.check.selectedOrderlineHas('Wall Shelf Unit', '0.0'); + ProductScreen.do.pressNumpad('Backspace'); + ProductScreen.check.selectedOrderlineHas('Monitor Stand', '1.0'); + ProductScreen.do.clickOrderline('Small Shelf', '1.0'); + ProductScreen.check.selectedOrderlineHas('Small Shelf', '1.0'); + ProductScreen.do.pressNumpad('Backspace'); + ProductScreen.check.selectedOrderlineHas('Small Shelf', '0.0'); + ProductScreen.do.pressNumpad('Backspace'); + ProductScreen.check.selectedOrderlineHas('Monitor Stand', '1.0'); + ProductScreen.do.clickOrderline('Magnetic Board', '1.0'); + ProductScreen.check.selectedOrderlineHas('Magnetic Board', '1.0'); + ProductScreen.do.pressNumpad('Backspace'); + ProductScreen.check.selectedOrderlineHas('Magnetic Board', '0.0'); + ProductScreen.do.pressNumpad('Backspace'); + ProductScreen.check.selectedOrderlineHas('Monitor Stand', '1.0'); + ProductScreen.do.pressNumpad('Backspace'); + ProductScreen.check.selectedOrderlineHas('Monitor Stand', '0.0'); + ProductScreen.do.pressNumpad('Backspace'); + ProductScreen.check.orderIsEmpty(); + + Tour.register('ProductScreenTour', { test: true, url: '/pos/ui' }, getSteps()); +}); diff --git a/addons/point_of_sale/static/tests/tours/ReceiptScreen.tour.js b/addons/point_of_sale/static/tests/tours/ReceiptScreen.tour.js new file mode 100644 index 00000000..2e330a9a --- /dev/null +++ b/addons/point_of_sale/static/tests/tours/ReceiptScreen.tour.js @@ -0,0 +1,61 @@ +odoo.define('point_of_sale.tour.ReceiptScreen', function (require) { + 'use strict'; + + const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods'); + const { ReceiptScreen } = require('point_of_sale.tour.ReceiptScreenTourMethods'); + const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods'); + const { NumberPopup } = require('point_of_sale.tour.NumberPopupTourMethods'); + const { getSteps, startSteps } = require('point_of_sale.tour.utils'); + const Tour = require('web_tour.tour'); + + startSteps(); + + // press close button in receipt screen + ProductScreen.exec.addOrderline('Letter Tray', '10', '5'); + ProductScreen.check.selectedOrderlineHas('Letter Tray', '10'); + ProductScreen.do.clickPayButton(); + PaymentScreen.do.clickPaymentMethod('Bank'); + PaymentScreen.check.validateButtonIsHighlighted(true); + PaymentScreen.do.clickValidate(); + ReceiptScreen.check.receiptIsThere(); + // letter tray has 10% tax (search SRC) + ReceiptScreen.check.totalAmountContains('55.0'); + ReceiptScreen.do.clickNextOrder(); + + // send email in receipt screen + ProductScreen.do.clickHomeCategory(); + ProductScreen.exec.addOrderline('Desk Pad', '6', '5', '30.0'); + ProductScreen.exec.addOrderline('Whiteboard Pen', '6', '6', '36.0'); + ProductScreen.exec.addOrderline('Monitor Stand', '6', '1', '6.0'); + ProductScreen.do.clickPayButton(); + PaymentScreen.do.clickPaymentMethod('Cash'); + PaymentScreen.do.pressNumpad('7 0'); + PaymentScreen.check.remainingIs('2.0'); + PaymentScreen.do.pressNumpad('0'); + PaymentScreen.check.remainingIs('0.00'); + PaymentScreen.check.changeIs('628.0'); + PaymentScreen.do.clickValidate(); + ReceiptScreen.check.receiptIsThere(); + ReceiptScreen.check.totalAmountContains('72.0'); + ReceiptScreen.do.setEmail('test@receiptscreen.com'); + ReceiptScreen.do.clickSend(); + ReceiptScreen.check.emailIsSuccessful(); + ReceiptScreen.do.clickNextOrder(); + + // order with tip + // check if tip amount is displayed + ProductScreen.exec.addOrderline('Desk Pad', '6', '5'); + ProductScreen.do.clickPayButton(); + PaymentScreen.do.clickTipButton(); + NumberPopup.do.pressNumpad('1'); + NumberPopup.check.inputShownIs('1'); + NumberPopup.do.clickConfirm(); + PaymentScreen.check.emptyPaymentlines('31.0'); + PaymentScreen.do.clickPaymentMethod('Cash'); + PaymentScreen.do.clickValidate(); + ReceiptScreen.check.receiptIsThere(); + ReceiptScreen.check.totalAmountContains('$ 30.00 + $ 1.00 tip'); + ReceiptScreen.do.clickNextOrder(); + + Tour.register('ReceiptScreenTour', { test: true, url: '/pos/ui' }, getSteps()); +}); diff --git a/addons/point_of_sale/static/tests/tours/TicketScreen.tour.js b/addons/point_of_sale/static/tests/tours/TicketScreen.tour.js new file mode 100644 index 00000000..a26c0b36 --- /dev/null +++ b/addons/point_of_sale/static/tests/tours/TicketScreen.tour.js @@ -0,0 +1,54 @@ +odoo.define('point_of_sale.tour.TicketScreen', function (require) { + 'use strict'; + + const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods'); + const { ReceiptScreen } = require('point_of_sale.tour.ReceiptScreenTourMethods'); + const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods'); + const { TicketScreen } = require('point_of_sale.tour.TicketScreenTourMethods'); + const { Chrome } = require('point_of_sale.tour.ChromeTourMethods'); + const { getSteps, startSteps } = require('point_of_sale.tour.utils'); + var Tour = require('web_tour.tour'); + + startSteps(); + + ProductScreen.do.clickHomeCategory(); + ProductScreen.exec.addOrderline('Desk Pad', '1', '2'); + ProductScreen.do.clickCustomerButton(); + ProductScreen.do.clickCustomer('Nicole Ford'); + ProductScreen.do.clickSetCustomer(); + Chrome.do.clickTicketButton(); + TicketScreen.check.nthRowContains(2, 'Nicole Ford'); + TicketScreen.do.clickNewTicket(); + ProductScreen.exec.addOrderline('Desk Pad', '1', '3'); + ProductScreen.do.clickCustomerButton(); + ProductScreen.do.clickCustomer('Brandon Freeman'); + ProductScreen.do.clickSetCustomer(); + ProductScreen.do.clickPayButton(); + PaymentScreen.check.isShown(); + Chrome.do.clickTicketButton(); + TicketScreen.check.nthRowContains(3, 'Brandon Freeman'); + TicketScreen.do.clickNewTicket(); + ProductScreen.exec.addOrderline('Desk Pad', '1', '4'); + ProductScreen.do.clickPayButton(); + PaymentScreen.do.clickPaymentMethod('Bank'); + PaymentScreen.do.clickValidate(); + ReceiptScreen.check.isShown(); + Chrome.do.clickTicketButton(); + TicketScreen.check.nthRowContains(4, 'Receipt'); + TicketScreen.do.selectFilter('Receipt'); + TicketScreen.check.nthRowContains(2, 'Receipt'); + TicketScreen.do.selectFilter('Payment'); + TicketScreen.check.nthRowContains(2, 'Payment'); + TicketScreen.do.selectFilter('Ongoing'); + TicketScreen.check.nthRowContains(2, 'Ongoing'); + TicketScreen.do.selectFilter('All'); + TicketScreen.check.nthRowContains(4, 'Receipt'); + TicketScreen.do.search('Customer', 'Nicole'); + TicketScreen.check.nthRowContains(2, 'Nicole'); + TicketScreen.do.search('Customer', 'Brandon'); + TicketScreen.check.nthRowContains(2, 'Brandon'); + TicketScreen.do.search('Receipt Number', '-0003'); + TicketScreen.check.nthRowContains(2, 'Receipt'); + + Tour.register('TicketScreenTour', { test: true, url: '/pos/ui' }, getSteps()); +}); diff --git a/addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js new file mode 100644 index 00000000..30609a9f --- /dev/null +++ b/addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js @@ -0,0 +1,42 @@ +odoo.define('point_of_sale.tour.ChromeTourMethods', function (require) { + 'use strict'; + + const { createTourMethods } = require('point_of_sale.tour.utils'); + + class Do { + confirmPopup() { + return [ + { + content: 'confirm popup', + trigger: '.popups .modal-dialog .button.confirm', + }, + ]; + } + clickOrderManagementButton() { + return [ + { + content: 'check order management button is shown', + trigger: '.pos .pos-rightheader .order-management', + run: () => {}, + }, + { + content: 'click order management button', + trigger: '.pos .pos-rightheader .order-management', + }, + ]; + } + clickTicketButton() { + return [ + { + trigger: '.pos-topheader .ticket-button', + }, + { + trigger: '.subwindow .ticket-screen', + run: () => {}, + }, + ]; + } + } + + return createTourMethods('Chrome', Do); +}); diff --git a/addons/point_of_sale/static/tests/tours/helpers/ClientListScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ClientListScreenTourMethods.js new file mode 100644 index 00000000..d6be643e --- /dev/null +++ b/addons/point_of_sale/static/tests/tours/helpers/ClientListScreenTourMethods.js @@ -0,0 +1,57 @@ +odoo.define('point_of_sale.tour.ClientListScreenTourMethods', function (require) { + 'use strict'; + + const { createTourMethods } = require('point_of_sale.tour.utils'); + + class Do { + clickClient(name) { + return [ + { + content: `click client '${name}' from client list screen`, + trigger: `.clientlist-screen .client-list-contents .client-line td:contains("${name}")`, + }, + { + content: `check if client '${name}' is highlighted`, + trigger: `.clientlist-screen .client-list-contents .client-line.highlight td:contains("${name}")`, + run: () => {}, + }, + ]; + } + clickSet() { + return [ + { + content: 'check if set button shown', + trigger: '.clientlist-screen .button.next.highlight', + run: () => {}, + }, + { + content: 'click set button', + trigger: '.clientlist-screen .button.next.highlight', + }, + ]; + } + } + + class Check { + isShown() { + return [ + { + content: 'client list screen is shown', + trigger: '.pos-content .clientlist-screen', + run: () => {}, + }, + ]; + } + } + + class Execute { + setClient(name) { + const steps = []; + steps.push(...this._do.clickClient(name)); + steps.push(...this._do.clickSet()); + return steps; + } + } + + return createTourMethods('ClientListScreen', Do, Check, Execute); +}); diff --git a/addons/point_of_sale/static/tests/tours/helpers/CompositeTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/CompositeTourMethods.js new file mode 100644 index 00000000..c361a532 --- /dev/null +++ b/addons/point_of_sale/static/tests/tours/helpers/CompositeTourMethods.js @@ -0,0 +1,23 @@ +odoo.define('point_of_sale.tour.CompositeTourMethods', function (require) { + 'use strict'; + + const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods'); + const { ReceiptScreen } = require('point_of_sale.tour.ReceiptScreenTourMethods'); + const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods'); + const { ClientListScreen } = require('point_of_sale.tour.ClientListScreenTourMethods'); + + function makeFullOrder({ orderlist, customer, payment, ntimes = 1 }) { + for (let i = 0; i < ntimes; i++) { + ProductScreen.exec.addMultiOrderlines(...orderlist); + if (customer) { + ProductScreen.do.clickCustomerButton(); + ClientListScreen.exec.setClient(customer); + } + ProductScreen.do.clickPayButton(); + PaymentScreen.exec.pay(...payment); + ReceiptScreen.exec.nextOrder(); + } + } + + return { makeFullOrder }; +}); diff --git a/addons/point_of_sale/static/tests/tours/helpers/ErrorPopupTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ErrorPopupTourMethods.js new file mode 100644 index 00000000..3d8c07cf --- /dev/null +++ b/addons/point_of_sale/static/tests/tours/helpers/ErrorPopupTourMethods.js @@ -0,0 +1,30 @@ +odoo.define('point_of_sale.tour.ErrorPopupTourMethods', function (require) { + 'use strict'; + + const { createTourMethods } = require('point_of_sale.tour.utils'); + + class Do { + clickConfirm() { + return [ + { + content: 'click confirm button', + trigger: '.popup-error .footer .cancel', + }, + ]; + } + } + + class Check { + isShown() { + return [ + { + content: 'error popup is shown', + trigger: '.modal-dialog .popup-error', + run: () => {}, + }, + ]; + } + } + + return createTourMethods('ErrorPopup', Do, Check); +}); diff --git a/addons/point_of_sale/static/tests/tours/helpers/NumberPopupTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/NumberPopupTourMethods.js new file mode 100644 index 00000000..c12d0d02 --- /dev/null +++ b/addons/point_of_sale/static/tests/tours/helpers/NumberPopupTourMethods.js @@ -0,0 +1,72 @@ +odoo.define('point_of_sale.tour.NumberPopupTourMethods', function (require) { + 'use strict'; + + const { createTourMethods } = require('point_of_sale.tour.utils'); + + class Do { + /** + * Note: Maximum of 2 characters because NumberBuffer only allows 2 consecutive + * fast inputs. Fast inputs is the case in tours. + * + * @param {String} keys space-separated input keys + */ + pressNumpad(keys) { + const numberChars = '0 1 2 3 4 5 6 7 8 9 C'.split(' '); + const modeButtons = '+1 +10 +2 +20 +5 +50'.split(' '); + const decimalSeparators = ', .'.split(' '); + function generateStep(key) { + let trigger; + if (numberChars.includes(key)) { + trigger = `.popup-numpad .number-char:contains("${key}")`; + } else if (modeButtons.includes(key)) { + trigger = `.popup-numpad .mode-button:contains("${key}")`; + } else if (key === 'Backspace') { + trigger = `.popup-numpad .numpad-backspace`; + } else if (decimalSeparators.includes(key)) { + trigger = `.popup-numpad .number-char.dot`; + } + return { + content: `'${key}' pressed in numpad`, + trigger, + }; + } + return keys.split(' ').map(generateStep); + } + clickConfirm() { + return [ + { + content: 'click confirm button', + trigger: '.popup-number .footer .confirm', + }, + ]; + } + } + + class Check { + isShown() { + return [ + { + content: 'number popup is shown', + trigger: '.modal-dialog .popup-number', + run: () => {}, + }, + ]; + } + inputShownIs(val) { + return [ + { + content: 'number input element check', + trigger: '.modal-dialog .popup-number .popup-input', + run: () => {}, + }, + { + content: `input shown is '${val}'`, + trigger: `.modal-dialog .popup-number .popup-input:contains("${val}")`, + run: () => {}, + }, + ]; + } + } + + return createTourMethods('NumberPopup', Do, Check); +}); diff --git a/addons/point_of_sale/static/tests/tours/helpers/OrderManagementScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/OrderManagementScreenTourMethods.js new file mode 100644 index 00000000..26e48589 --- /dev/null +++ b/addons/point_of_sale/static/tests/tours/helpers/OrderManagementScreenTourMethods.js @@ -0,0 +1,180 @@ +odoo.define('point_of_sale.tour.OrderManagementScreenTourMethods', function (require) { + 'use strict'; + + const { createTourMethods } = require('point_of_sale.tour.utils'); + + class Do { + clickBack() { + return [ + { + content: 'order management screen, click back button', + trigger: '.order-management-screen .control-panel .button.back', + }, + ]; + } + clickOrder(name, [otherCol, otherColVal] = [null, null]) { + let trigger = `.order-management-screen .order-list .order-row .item.name:contains("${name}")`; + if (otherCol) { + trigger = `${trigger} ~ .item.${otherCol}:contains("${otherColVal}")`; + } + return [ + { + content: `clicking order '${name}' from orderlist`, + trigger, + }, + ]; + } + clickInvoiceButton() { + return [ + { + content: 'click invoice button', + trigger: '.order-management-screen .control-button span:contains("Invoice")', + }, + ]; + } + clickPrintReceiptButton() { + return [ + { + content: 'click reprint receipt button', + trigger: '.order-management-screen .control-button span:contains("Print Receipt")' + } + ] + } + clickCustomerButton() { + return [ + { + content: 'click customer button', + trigger: '.order-management-screen .actionpad .button.set-customer', + }, + ]; + } + closeReceipt() { + return [ + { + content: 'close receipt', + trigger: '.receipt-screen .button.back', + } + ] + } + } + + class Check { + isShown() { + return [ + { + content: 'order management screen is shown', + trigger: '.pos .pos-content .order-management-screen', + run: () => {}, + }, + ]; + } + orderlistHas({ orderName, total, customer }) { + const steps = []; + steps.push({ + content: `order list has row having: name '${orderName}', total '${total}'`, + trigger: `.order-list .order-row .item:contains("${orderName}") ~ .item:contains("${total}")`, + run: () => {}, + }); + if (customer) { + steps.push({ + content: `order list has row having: name '${orderName}', customer '${customer}'`, + trigger: `.order-list .order-row .item:contains("${orderName}") ~ .item:contains("${customer}")`, + run: () => {}, + }); + } + return steps; + } + highlightedOrderRowHas(name) { + return [ + { + content: `order '${name}' in orderlist is highligted`, + trigger: `.order-list .order-row.highlight:has(> .item:contains("${name}"))`, + run: () => {}, + }, + ]; + } + orderRowIsNotHighlighted(name) { + return [ + { + content: `order '${name}' in orderlist is not highligted`, + trigger: `.order-list .order-row:not(:has(.highlight)):has(> .item:contains("${name}"))`, + run: () => {}, + }, + ]; + } + orderDetailsHas({ lines, total }) { + const steps = []; + for (let { product, quantity } of lines) { + steps.push({ + content: `order details has product '${product}' and quantity '${quantity}'`, + trigger: `.orderlines .product-name:contains("${product}") ~ .info strong:contains("${quantity}")`, + run: () => {}, + }); + } + if (total) { + steps.push({ + content: `order details has total amount of ${total}`, + trigger: `.order-container .summary .total .value:contains("${total}")`, + run: () => {}, + }); + } + return steps; + } + customerIs(name) { + return [ + { + content: `set customer is '${name}'`, + trigger: `.order-management-screen .actionpad .set-customer:contains("${name}")`, + run: () => {}, + }, + ]; + } + reprintReceiptIsShown() { + return [ + { + content: 'reprint receipt screen is shown', + trigger: '.pos .receipt-screen', + run: () => {}, + } + ] + } + receiptChangeIs(amount) { + return [ + { + content: `receipt change is ${amount}`, + trigger: `.pos-receipt-amount.receipt-change:contains("${amount}")`, + run: () => {}, + } + ] + } + receiptOrderDataContains(orderInfo) { + return [ + { + content: `order data contains ${orderInfo}`, + trigger: `.pos-receipt-order-data:contains("${orderInfo}")`, + run: () => {}, + } + ] + } + receiptAmountIs(amount) { + return [ + { + content: `receipt amount is ${amount}`, + trigger: `.pos-receipt-amount:contains("${amount}")`, + run: () => {}, + } + ] + } + isNotHidden() { + return [ + { + content: 'order management screen is not hidden', + trigger: `.order-management-screen:not(:has(.oe_hidden))`, + run: () => {}, + } + ] + } + } + + return createTourMethods('OrderManagementScreen', Do, Check); +}); diff --git a/addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js new file mode 100644 index 00000000..93a5cef6 --- /dev/null +++ b/addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js @@ -0,0 +1,215 @@ +odoo.define('point_of_sale.tour.PaymentScreenTourMethods', function (require) { + 'use strict'; + + const { createTourMethods } = require('point_of_sale.tour.utils'); + + class Do { + clickPaymentMethod(name) { + return [ + { + content: `click '${name}' payment method`, + trigger: `.paymentmethods .button.paymentmethod:contains("${name}")`, + }, + ]; + } + + /** + * Delete the paymentline having the given payment method name and amount. + * @param {String} name payment method + * @param {String} amount + */ + clickPaymentlineDelButton(name, amount) { + return [ + { + content: `delete ${name} paymentline with ${amount} amount`, + trigger: `.paymentlines .paymentline .payment-name:contains("${name}") ~ .delete-button`, + }, + ]; + } + + clickEmailButton() { + return [ + { + content: `click email button`, + trigger: `.payment-buttons .js_email`, + }, + ]; + } + + clickTipButton() { + return [ + { + trigger: `.payment-buttons .js_tip`, + }, + ]; + } + + clickInvoiceButton() { + return [{ content: 'click invoice button', trigger: '.payment-buttons .js_invoice' }]; + } + + clickValidate() { + return [ + { + content: 'validate payment', + trigger: `.payment-screen .button.next.highlight`, + }, + ]; + } + + /** + * Press the numpad in sequence based on the given space-separated keys. + * Note: Maximum of 2 characters because NumberBuffer only allows 2 consecutive + * fast inputs. Fast inputs is the case in tours. + * + * @param {String} keys space-separated numpad keys + */ + pressNumpad(keys) { + const numberChars = '. +/- 0 1 2 3 4 5 6 7 8 9'.split(' '); + const modeButtons = '+10 +20 +50'.split(' '); + function generateStep(key) { + let trigger; + if (numberChars.includes(key)) { + trigger = `.payment-numpad .number-char:contains("${key}")`; + } else if (modeButtons.includes(key)) { + trigger = `.payment-numpad .mode-button:contains("${key}")`; + } else if (key === 'Backspace') { + trigger = `.payment-numpad .number-char img[alt="Backspace"]`; + } + return { + content: `'${key}' pressed in payment numpad`, + trigger, + }; + } + return keys.split(' ').map(generateStep); + } + + clickBack() { + return [ + { + content: 'click back button', + trigger: '.payment-screen .button.back', + }, + ]; + } + + clickTipButton() { + return [ + { + trigger: '.payment-screen .button.js_tip', + }, + ] + } + } + + class Check { + isShown() { + return [ + { + content: 'payment screen is shown', + trigger: '.pos .payment-screen', + run: () => {}, + }, + ]; + } + /** + * Check if change is the provided amount. + * @param {String} amount + */ + changeIs(amount) { + return [ + { + content: `change is ${amount}`, + trigger: `.payment-status-change .amount:contains("${amount}")`, + run: () => {}, + }, + ]; + } + + /** + * Check if the remaining is the provided amount. + * @param {String} amount + */ + remainingIs(amount) { + return [ + { + content: `remaining amount is ${amount}`, + trigger: `.payment-status-remaining .amount:contains("${amount}")`, + run: () => {}, + }, + ]; + } + + /** + * Check if validate button is highlighted. + * @param {Boolean} isHighlighted + */ + validateButtonIsHighlighted(isHighlighted = true) { + return [ + { + content: `validate button is ${ + isHighlighted ? 'highlighted' : 'not highligted' + }`, + trigger: isHighlighted + ? `.payment-screen .button.next.highlight` + : `.payment-screen .button.next:not(:has(.highlight))`, + run: () => {}, + }, + ]; + } + + /** + * Check if the paymentlines are empty. Also provide the amount to pay. + * @param {String} amountToPay + */ + emptyPaymentlines(amountToPay) { + return [ + { + content: `there are no paymentlines`, + trigger: `.paymentlines-empty`, + run: () => {}, + }, + { + content: `amount to pay is '${amountToPay}'`, + trigger: `.paymentlines-empty .total:contains("${amountToPay}")`, + run: () => {}, + }, + ]; + } + + /** + * Check if the selected paymentline has the given payment method and amount. + * @param {String} paymentMethodName + * @param {String} amount + */ + selectedPaymentlineHas(paymentMethodName, amount) { + return [ + { + content: `line paid via '${paymentMethodName}' is selected`, + trigger: `.paymentlines .paymentline.selected .payment-name:contains("${paymentMethodName}")`, + run: () => {}, + }, + { + content: `amount tendered in the line is '${amount}'`, + trigger: `.paymentlines .paymentline.selected .payment-amount:contains("${amount}")`, + run: () => {}, + }, + ]; + } + } + + class Execute { + pay(method, amount) { + const steps = []; + steps.push(...this._do.clickPaymentMethod(method)); + for (let char of amount.split('')) { + steps.push(...this._do.pressNumpad(char)); + } + steps.push(...this._check.validateButtonIsHighlighted()); + steps.push(...this._do.clickValidate()); + return steps; + } + } + + return createTourMethods('PaymentScreen', Do, Check, Execute); +}); diff --git a/addons/point_of_sale/static/tests/tours/helpers/ProductConfiguratorTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ProductConfiguratorTourMethods.js new file mode 100644 index 00000000..5d10f9fd --- /dev/null +++ b/addons/point_of_sale/static/tests/tours/helpers/ProductConfiguratorTourMethods.js @@ -0,0 +1,77 @@ +odoo.define('point_of_sale.tour.ProductConfiguratorTourMethods', function (require) { + 'use strict'; + + const { createTourMethods } = require('point_of_sale.tour.utils'); + + class Do { + pickRadio(name) { + return [ + { + content: `picking radio attribute with name ${name}`, + trigger: `.product-configurator-popup .radio_attribute_label:contains('${name}')`, + }, + ]; + } + + pickSelect(name) { + return [ + { + content: `picking select attribute with name ${name}`, + trigger: `.product-configurator-popup .configurator_select:has(option:contains('${name}'))`, + run: `text ${name}`, + }, + ]; + } + + pickColor(name) { + return [ + { + content: `picking color attribute with name ${name}`, + trigger: `.product-configurator-popup .configurator_color[data-color='${name}']`, + }, + ]; + } + + fillCustomAttribute(value) { + return [ + { + content: `filling custom attribute with value ${value}`, + trigger: `.product-configurator-popup .custom_value`, + run: `text ${value}`, + }, + ]; + } + + confirmAttributes() { + return [ + { + content: `confirming product configuration`, + trigger: `.product-configurator-popup .button.confirm`, + }, + ]; + } + + cancelAttributes() { + return [ + { + content: `canceling product configuration`, + trigger: `.product-configurator-popup .button.cancel`, + }, + ]; + } + } + + class Check { + isShown() { + return [ + { + content: 'product configurator is shown', + trigger: '.product-configurator-popup:not(:has(.oe_hidden))', + run: () => {}, + }, + ]; + } + } + + return createTourMethods('ProductConfigurator', Do, Check); +}); diff --git a/addons/point_of_sale/static/tests/tours/helpers/ProductScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ProductScreenTourMethods.js new file mode 100644 index 00000000..69aab18b --- /dev/null +++ b/addons/point_of_sale/static/tests/tours/helpers/ProductScreenTourMethods.js @@ -0,0 +1,254 @@ +odoo.define('point_of_sale.tour.ProductScreenTourMethods', function (require) { + 'use strict'; + + const { createTourMethods } = require('point_of_sale.tour.utils'); + + class Do { + clickDisplayedProduct(name) { + return [ + { + content: `click product '${name}'`, + trigger: `.product-list .product-name:contains("${name}")`, + }, + ]; + } + + clickOrderline(name, quantity) { + return [ + { + content: `selecting orderline with product '${name}' and quantity '${quantity}'`, + trigger: `.order .orderline:not(:has(.selected)) .product-name:contains("${name}") ~ .info-list em:contains("${quantity}")`, + }, + { + content: `orderline with product '${name}' and quantity '${quantity}' has been selected`, + trigger: `.order .orderline.selected .product-name:contains("${name}") ~ .info-list em:contains("${quantity}")`, + run: () => {}, + }, + ]; + } + + clickSubcategory(name) { + return [ + { + content: `selecting '${name}' subcategory`, + trigger: `.products-widget > .products-widget-control .category-simple-button:contains("${name}")`, + }, + { + content: `'${name}' subcategory selected`, + trigger: `.breadcrumbs .breadcrumb-button:contains("${name}")`, + run: () => {}, + }, + ]; + } + + clickHomeCategory() { + return [ + { + content: `click Home subcategory`, + trigger: `.breadcrumbs .breadcrumb-home`, + }, + ]; + } + + /** + * Press the numpad in sequence based on the given space-separated keys. + * NOTE: Maximum of 2 characters because NumberBuffer only allows 2 consecutive + * fast inputs. Fast inputs is the case in tours. + * + * @param {String} keys space-separated numpad keys + */ + pressNumpad(keys) { + const numberChars = '. 0 1 2 3 4 5 6 7 8 9'.split(' '); + const modeButtons = 'Qty Price Disc'.split(' '); + function generateStep(key) { + let trigger; + if (numberChars.includes(key)) { + trigger = `.numpad .number-char:contains("${key}")`; + } else if (modeButtons.includes(key)) { + trigger = `.numpad .mode-button:contains("${key}")`; + } else if (key === 'Backspace') { + trigger = `.numpad .numpad-backspace`; + } else if (key === '+/-') { + trigger = `.numpad .numpad-minus`; + } + return { + content: `'${key}' pressed in product screen numpad`, + trigger, + }; + } + return keys.split(' ').map(generateStep); + } + + clickPayButton() { + return [ + { content: 'click pay button', trigger: '.actionpad .button.pay' }, + { + content: 'now in payment screen', + trigger: '.pos-content .payment-screen', + run: () => {}, + }, + ]; + } + + clickCustomerButton() { + return [ + { content: 'click customer button', trigger: '.actionpad .button.set-customer' }, + { + content: 'customer screen is shown', + trigger: '.pos-content .clientlist-screen', + run: () => {}, + }, + ]; + } + + clickCustomer(name) { + return [ + { + content: `select customer '${name}'`, + trigger: `.clientlist-screen .client-line td:contains("${name}")`, + }, + { + content: `client line '${name}' is highlighted`, + trigger: `.clientlist-screen .client-line.highlight td:contains("${name}")`, + run: () => {}, + }, + ]; + } + + clickSetCustomer() { + return [ + { + content: 'click set customer', + trigger: '.clientlist-screen .button.next.highlight', + }, + ]; + } + } + + class Check { + isShown() { + return [ + { + content: 'product screen is shown', + trigger: '.product-screen:not(:has(.oe_hidden))', + run: () => {}, + }, + ]; + } + selectedOrderlineHas(name, quantity, price) { + const res = [ + { + // check first if the order widget is there and has orderlines + content: 'order widget has orderlines', + trigger: '.order .orderlines', + run: () => {}, + }, + { + content: `'${name}' is selected`, + trigger: `.order .orderline.selected .product-name:contains("${name}")`, + run: function () {}, // it's a check + }, + ]; + if (quantity) { + res.push({ + content: `selected line has ${quantity} quantity`, + trigger: `.order .orderline.selected .product-name:contains("${name}") ~ .info-list em:contains("${quantity}")`, + run: function () {}, // it's a check + }); + } + if (price) { + res.push({ + content: `selected line has total price of ${price}`, + trigger: `.order .orderline.selected .product-name:contains("${name}") ~ .price:contains("${price}")`, + run: function () {}, // it's a check + }); + } + return res; + } + orderIsEmpty() { + return [ + { + content: `order is empty`, + trigger: `.order .order-empty`, + run: () => {}, + }, + ]; + } + + productIsDisplayed(name) { + return [ + { + content: `'${name}' should be displayed`, + trigger: `.product-list .product-name:contains("${name}")`, + run: () => {}, + }, + ]; + } + totalAmountIs(amount) { + return [ + { + content: `order total amount is '${amount}'`, + trigger: `.order-container .order .summary .value:contains("${amount}")`, + run: () => {}, + } + ] + } + modeIsActive(mode) { + return [ + { + content: `'${mode}' is active`, + trigger: `.numpad button.selected-mode:contains('${mode}')`, + run: function () {}, + }, + ]; + } + } + + class Execute { + /** + * Create an orderline for the given `productName` and `quantity`. + * - If `unitPrice` is provided, price of the product of the created line + * is changed to that value. + * - If `expectedTotal` is provided, the created orderline (which is the currently + * selected orderline) is checked if it contains the correct quantity and total + * price. + * + * @param {string} productName + * @param {string} quantity + * @param {string} unitPrice + * @param {string} expectedTotal + */ + addOrderline(productName, quantity, unitPrice = undefined, expectedTotal = undefined) { + const res = this._do.clickDisplayedProduct(productName); + if (unitPrice) { + res.push(...this._do.pressNumpad('Price')); + res.push(...this._check.modeIsActive('Price')); + res.push(...this._do.pressNumpad(unitPrice.toString().split('').join(' '))); + res.push(...this._do.pressNumpad('Qty')); + res.push(...this._check.modeIsActive('Qty')); + } + for (let char of quantity.toString()) { + if ('.0123456789'.includes(char)) { + res.push(...this._do.pressNumpad(char)); + } else if ('-'.includes(char)) { + res.push(...this._do.pressNumpad('+/-')); + } + } + if (expectedTotal) { + res.push(...this._check.selectedOrderlineHas(productName, quantity, expectedTotal)); + } else { + res.push(...this._check.selectedOrderlineHas(productName, quantity)); + } + return res; + } + addMultiOrderlines(...list) { + const steps = []; + for (let [product, qty, price] of list) { + steps.push(...this.addOrderline(product, qty, price)); + } + return steps; + } + } + + return createTourMethods('ProductScreen', Do, Check, Execute); +}); diff --git a/addons/point_of_sale/static/tests/tours/helpers/ReceiptScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ReceiptScreenTourMethods.js new file mode 100644 index 00000000..49c26703 --- /dev/null +++ b/addons/point_of_sale/static/tests/tours/helpers/ReceiptScreenTourMethods.js @@ -0,0 +1,79 @@ +odoo.define('point_of_sale.tour.ReceiptScreenTourMethods', function (require) { + 'use strict'; + + const { createTourMethods } = require('point_of_sale.tour.utils'); + + class Do { + clickNextOrder() { + return [ + { + content: 'go to next screen', + trigger: '.receipt-screen .button.next.highlight', + }, + ]; + } + setEmail(email) { + return [ + { + trigger: '.receipt-screen .input-email input', + run: `text ${email}`, + }, + ]; + } + clickSend(isHighlighted = true) { + return [ + { + trigger: `.receipt-screen .input-email .send${isHighlighted ? '.highlight' : ''}`, + }, + ]; + } + } + + class Check { + isShown() { + return [ + { + content: 'receipt screen is shown', + trigger: '.pos .receipt-screen', + run: () => {}, + }, + ]; + } + + receiptIsThere() { + return [ + { + content: 'there should be the receipt', + trigger: '.receipt-screen .pos-receipt', + run: () => {}, + }, + ]; + } + + totalAmountContains(value) { + return [ + { + trigger: `.receipt-screen .top-content h1:contains("${value}")`, + run: () => {}, + }, + ]; + } + + emailIsSuccessful() { + return [ + { + trigger: `.receipt-screen .notice.successful`, + run: () => {}, + }, + ]; + } + } + + class Execute { + nextOrder() { + return [...this._check.isShown(), ...this._do.clickNextOrder()]; + } + } + + return createTourMethods('ReceiptScreen', Do, Check, Execute); +}); diff --git a/addons/point_of_sale/static/tests/tours/helpers/SelectionPopupTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/SelectionPopupTourMethods.js new file mode 100644 index 00000000..bbe4fc2d --- /dev/null +++ b/addons/point_of_sale/static/tests/tours/helpers/SelectionPopupTourMethods.js @@ -0,0 +1,39 @@ +odoo.define('point_of_sale.tour.SelectionPopupTourMethods', function (require) { + 'use strict'; + + const { createTourMethods } = require('point_of_sale.tour.utils'); + + class Do { + clickItem(name) { + return [ + { + content: `click selection '${name}'`, + trigger: `.selection-item:contains("${name}")`, + }, + ]; + } + } + + class Check { + hasSelectionItem(name) { + return [ + { + content: `selection popup has '${name}'`, + trigger: `.selection-item:contains("${name}")`, + run: () => {}, + }, + ]; + } + isShown() { + return [ + { + content: 'selection popup is shown', + trigger: '.modal-dialog .popup-selection', + run: () => {}, + }, + ]; + } + } + + return createTourMethods('SelectionPopup', Do, Check); +}); diff --git a/addons/point_of_sale/static/tests/tours/helpers/TicketScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/TicketScreenTourMethods.js new file mode 100644 index 00000000..fe8f8127 --- /dev/null +++ b/addons/point_of_sale/static/tests/tours/helpers/TicketScreenTourMethods.js @@ -0,0 +1,107 @@ +odoo.define('point_of_sale.tour.TicketScreenTourMethods', function (require) { + 'use strict'; + + const { createTourMethods } = require('point_of_sale.tour.utils'); + + class Do { + clickNewTicket() { + return [{ trigger: '.ticket-screen .highlight' }]; + } + clickDiscard() { + return [{ trigger: '.ticket-screen button.discard' }]; + } + selectOrder(orderName) { + return [ + { + trigger: `.ticket-screen .order-row > .col:nth-child(2):contains("${orderName}")`, + }, + ]; + } + deleteOrder(orderName) { + return [ + { + trigger: `.ticket-screen .orders > .order-row > .col:contains("${orderName}") ~ .col[name="delete"]`, + }, + ]; + } + selectFilter(name) { + return [ + { + trigger: `.pos-search-bar .filter`, + }, + { + trigger: `.pos-search-bar .filter ul`, + run: () => {}, + }, + { + trigger: `.pos-search-bar .filter ul li:contains("${name}")`, + }, + ]; + } + search(field, searchWord) { + return [ + { + trigger: '.pos-search-bar input', + run: `text ${searchWord}`, + }, + { + /** + * Manually trigger keydown event to show the search field list + * because the previous step do not trigger keydown event. + */ + trigger: '.pos-search-bar input', + run: function () { + document + .querySelector('.pos-search-bar input') + .dispatchEvent(new KeyboardEvent('keydown', { key: '' })); + }, + }, + { + trigger: `.pos-search-bar .search ul li:contains("${field}")`, + }, + ]; + } + settleTips() { + return [ + { + trigger: '.ticket-screen .buttons .settle-tips', + }, + ]; + } + } + + class Check { + checkStatus(orderName, status) { + return [ + { + trigger: `.ticket-screen .order-row > .col:nth-child(2):contains("${orderName}") ~ .col:nth-child(6):contains(${status})`, + run: () => {}, + }, + ]; + } + /** + * Check if the nth row contains the given string. + * Note that 1st row is the header-row. + */ + nthRowContains(n, string) { + return [ + { + trigger: `.ticket-screen .orders > .order-row:nth-child(${n}):contains("${string}")`, + run: () => {}, + }, + ]; + } + noNewTicketButton() { + return [ + { + trigger: '.ticket-screen .controls .buttons:nth-child(1):has(.discard)', + run: () => {}, + }, + ]; + } + } + + class Execute {} + + return createTourMethods('TicketScreen', Do, Check, Execute); +}); diff --git a/addons/point_of_sale/static/tests/tours/helpers/utils.js b/addons/point_of_sale/static/tests/tours/helpers/utils.js new file mode 100644 index 00000000..e8fcc591 --- /dev/null +++ b/addons/point_of_sale/static/tests/tours/helpers/utils.js @@ -0,0 +1,153 @@ +odoo.define('point_of_sale.tour.utils', function (require) { + 'use strict'; + + const config = require('web.config'); + + /** + * USAGE + * ----- + * + * ``` + * const { startSteps, getSteps, createTourMethods } = require('point_of_sale.utils'); + * const { Other } = require('point_of_sale.tour.OtherMethods'); + * + * // 1. Define classes Do, Check and Execute having methods that + * // each return array of tour steps. + * class Do { + * click() { + * return [{ content: 'click button', trigger: '.button' }]; + * } + * } + * class Check { + * isHighligted() { + * return [{ content: 'button is highlighted', trigger: '.button.highlight', run: () => {} }]; + * } + * } + * // Notice that Execute has access to methods defined in Do and Check classes + * // Also, we can compose steps from other module. + * class Execute { + * complexSteps() { + * return [...this._do.click(), ...this._check.isHighlighted(), ...Other._exec.complicatedSteps()]; + * } + * } + * + * // 2. Instantiate these class definitions using `createTourMethods`. + * // The returned object gives access to the defined methods above + * // thru the do, check and exec properties. + * // - do gives access to the methods defined in Do class + * // - check gives access to the methods defined in Check class + * // - exec gives access to the methods defined in Execute class + * const Screen = createTourMethods('Screen', Do, Check, Execute); + * + * // 3. Call `startSteps` to start empty steps. + * startSteps(); + * + * // 4. Call the tour methods to populate the steps created by `startSteps`. + * Screen.do.click(); // return of this method call is added to steps created by startSteps + * Screen.check.isHighlighted() // same as above + * Screen.exec.complexSteps() // same as above + * + * // 5. Call `getSteps` which returns the generated tour steps. + * const steps = getSteps(); + * ``` + */ + let steps = []; + + function startSteps() { + // always start by waiting for loading to finish + steps = [ + { + content: 'wait for loading to finish', + trigger: 'body:not(:has(.loader))', + run: function () {}, + }, + ]; + } + + function getSteps() { + return steps; + } + + // this is the method decorator + // when the method is called, the generated steps are added + // to steps + const methodProxyHandler = { + apply(target, thisArg, args) { + const res = target.call(thisArg, ...args); + if (config.isDebug()) { + // This step is added before the real steps. + // Very useful when debugging because we know which + // method call failed and what were the parameters. + const constructor = thisArg.constructor.name.split(' ')[1]; + const methodName = target.name.split(' ')[1]; + const argList = args + .map((a) => (typeof a === 'string' ? `'${a}'` : `${a}`)) + .join(', '); + steps.push({ + content: `DOING "${constructor}.${methodName}(${argList})"`, + trigger: '.pos', + run: () => {}, + }); + } + steps.push(...res); + return res; + }, + }; + + // we proxy get of the method to decorate the method call + const proxyHandler = { + get(target, key) { + const method = target[key]; + if (!method) { + throw new Error(`Tour method '${key}' is not available.`); + } + return new Proxy(method.bind(target), methodProxyHandler); + }, + }; + + /** + * Creates an object with `do`, `check` and `exec` properties which are instances of + * the given `Do`, `Check` and `Execute` classes, respectively. Calling methods + * automatically adds the returned steps to the steps created by `startSteps`. + * + * There are however underscored version (_do, _check, _exec). + * Calling methods thru the underscored version does not automatically + * add the returned steps to the current steps array. Useful when composing + * steps from other methods. + * + * @param {String} name + * @param {Function} Do class containing methods which return array of tour steps + * @param {Function} Check similar to Do class but the steps are mainly for checking + * @param {Function} Execute class containing methods which return array of tour steps + * but has access to methods of Do and Check classes via .do and .check, + * respectively. Here, we define methods that return tour steps based + * on the combination of steps from Do and Check. + */ + function createTourMethods(name, Do, Check = class {}, Execute = class {}) { + Object.defineProperty(Do, 'name', { value: `${name}.do` }); + Object.defineProperty(Check, 'name', { value: `${name}.check` }); + Object.defineProperty(Execute, 'name', { + value: `${name}.exec`, + }); + const methods = { do: new Do(), check: new Check(), exec: new Execute() }; + // Allow Execute to have access to methods defined in Do and Check + // via do and exec, respectively. + methods.exec._do = methods.do; + methods.exec._check = methods.check; + return { + Do, + Check, + Execute, + [name]: { + do: new Proxy(methods.do, proxyHandler), + check: new Proxy(methods.check, proxyHandler), + exec: new Proxy(methods.exec, proxyHandler), + _do: methods.do, + _check: methods.check, + _exec: methods.exec, + }, + }; + } + + return { startSteps, getSteps, createTourMethods }; +}); diff --git a/addons/point_of_sale/static/tests/tours/point_of_sale.js b/addons/point_of_sale/static/tests/tours/point_of_sale.js new file mode 100644 index 00000000..25f88d3a --- /dev/null +++ b/addons/point_of_sale/static/tests/tours/point_of_sale.js @@ -0,0 +1,436 @@ +odoo.define('point_of_sale.tour.pricelist', function (require) { + "use strict"; + + var Tour = require('web_tour.tour'); + var rpc = require('web.rpc'); + var utils = require('web.utils'); + var round_di = utils.round_decimals; + + function assert (condition, message) { + if (! condition) { + throw message || "Assertion failed"; + } + } + + function _build_pricelist_context (pricelist, quantity, date) { + return { + pricelist: pricelist.id, + quantity: quantity, + }; + } + + function compare_backend_frontend (product, pricelist_name, quantity) { + return function () { + var pricelist = _.findWhere(posmodel.pricelists, {name: pricelist_name}); + var frontend_price = product.get_price(pricelist, quantity); + // ORM applies digits= on non-stored computed field when + // reading. It does not however truncate like it does when + // storing the field. + frontend_price = round_di(frontend_price, posmodel.dp['Product Price']); + + var context = _build_pricelist_context(pricelist, quantity); + return rpc.query({model: 'product.product', method: 'read', args: [[product.id], ['price']], context: context}) + .then(function (backend_result) { + var debug_info = _.extend(context, { + product: product.id, + product_display_name: product.display_name, + pricelist_name: pricelist.name, + }); + var backend_price = backend_result[0].price; + assert(frontend_price === backend_price, + JSON.stringify(debug_info) + ' DOESN\'T MATCH -> ' + backend_price + ' (backend) != ' + frontend_price + ' (frontend)'); + return Promise.resolve(); + }); + }; + } + + // The global posmodel is only present when the posmodel is instanciated + // So, wait for everythiong to be loaded + var steps = [{ // Leave category displayed by default + content: 'waiting for loading to finish', + extra_trigger: 'body .pos:not(:has(.loader))', // Pos has finished loading + trigger: 'body:not(.oe_wait)', // WebClient has finished Loading + run: function () { + var product_wall_shelf = posmodel.db.search_product_in_category(0, 'Wall Shelf Unit')[0]; + var product_small_shelf = posmodel.db.search_product_in_category(0, 'Small Shelf')[0]; + var product_magnetic_board = posmodel.db.search_product_in_category(0, 'Magnetic Board')[0]; + var product_monitor_stand = posmodel.db.search_product_in_category(0, 'Monitor Stand')[0]; + var product_desk_pad = posmodel.db.search_product_in_category(0, 'Desk Pad')[0]; + var product_letter_tray = posmodel.db.search_product_in_category(0, 'Letter Tray')[0]; + var product_whiteboard = posmodel.db.search_product_in_category(0, 'Whiteboard')[0]; + + compare_backend_frontend(product_letter_tray, 'Public Pricelist', 0, undefined)() + .then(compare_backend_frontend(product_letter_tray, 'Public Pricelist', 1, undefined)) + .then(compare_backend_frontend(product_letter_tray, 'Fixed', 1, undefined)) + .then(compare_backend_frontend(product_wall_shelf, 'Fixed', 1, undefined)) + .then(compare_backend_frontend(product_small_shelf, 'Fixed', 1, undefined)) + .then(compare_backend_frontend(product_wall_shelf, 'Percentage', 1, undefined)) + .then(compare_backend_frontend(product_small_shelf, 'Percentage', 1, undefined)) + .then(compare_backend_frontend(product_magnetic_board, 'Percentage', 1, undefined)) + .then(compare_backend_frontend(product_wall_shelf, 'Formula', 1, undefined)) + .then(compare_backend_frontend(product_small_shelf, 'Formula', 1, undefined)) + .then(compare_backend_frontend(product_magnetic_board, 'Formula', 1, undefined)) + .then(compare_backend_frontend(product_monitor_stand, 'Formula', 1, undefined)) + .then(compare_backend_frontend(product_desk_pad, 'Formula', 1, undefined)) + .then(compare_backend_frontend(product_wall_shelf, 'min_quantity ordering', 1, undefined)) + .then(compare_backend_frontend(product_wall_shelf, 'min_quantity ordering', 2, undefined)) + .then(compare_backend_frontend(product_letter_tray, 'Category vs no category', 1, undefined)) + .then(compare_backend_frontend(product_letter_tray, 'Category', 1, undefined)) + .then(compare_backend_frontend(product_wall_shelf, 'Product template', 1, undefined)) + .then(compare_backend_frontend(product_wall_shelf, 'Dates', 1, undefined)) + .then(compare_backend_frontend(product_small_shelf, 'Pricelist base rounding', 1, undefined)) + .then(compare_backend_frontend(product_whiteboard, 'Public Pricelist', 1, undefined)) + .then(function () { + $('.pos').addClass('done-testing'); + }); + }, + }]; + + steps = steps.concat([{ + content: "wait for unit tests to finish", + trigger: ".pos.done-testing", + run: function () {}, // it's a check + }, { + content: "click category switch", + trigger: ".breadcrumb-home", + run: 'click', + }, { + content: "click pricelist button", + trigger: ".control-button.o_pricelist_button", + }, { + content: "verify default pricelist is set", + trigger: ".selection-item.selected:contains('Public Pricelist')", + run: function () {}, // it's a check + }, { + content: "select fixed pricelist", + trigger: ".selection-item:contains('Fixed')", + }, { + content: "open customer list", + trigger: "button.set-customer", + }, { + content: "select Deco Addict", + trigger: ".client-line:contains('Deco Addict')", + }, { + content: "confirm selection", + trigger: ".clientlist-screen .next", + }, { + content: "click pricelist button", + trigger: ".control-button.o_pricelist_button", + }, { + content: "verify pricelist changed", + trigger: ".selection-item.selected:contains('Public Pricelist')", + run: function () {}, // it's a check + }, { + content: "cancel pricelist dialog", + trigger: ".button.cancel:visible", + }, { + content: "open customer list", + trigger: "button.set-customer", + }, { + content: "select Lumber Inc", + trigger: ".client-line:contains('Lumber Inc')", + }, { + content: "confirm selection", + trigger: ".clientlist-screen .next", + }, { + content: "click pricelist button", + trigger: ".control-button.o_pricelist_button", + }, { + content: "verify pricelist remained public pricelist ('Not loaded' is not available)", + trigger: ".selection-item.selected:contains('Public Pricelist')", + run: function () {}, // it's a check + }, { + content: "cancel pricelist dialog", + trigger: ".button.cancel:visible", + }, { + content: "click pricelist button", + trigger: ".control-button.o_pricelist_button", + }, { + content: "select fixed pricelist", + trigger: ".selection-item:contains('min_quantity ordering')", + }, { + content: "order 1 kg shelf", + trigger: ".product:contains('Wall Shelf')", + }, { + content: "change qty to 2 kg", + trigger: ".numpad button.input-button:visible:contains('2')", + }, { + content: "qty of Wall Shelf line should be 2", + trigger: ".order-container .orderlines .orderline.selected .product-name:contains('Wall Shelf')", + extra_trigger: ".order-container .orderlines .orderline.selected .product-name:contains('Wall Shelf') ~ .info-list .info em:contains('2.0')", + run: function() {}, + }, { + content: "verify that unit price of shelf changed to $1", + trigger: ".total > .value:contains('$ 2.00')", + run: function() {}, + }, { + content: "order different shelf", + trigger: ".product:contains('Small Shelf')", + }, { + content: "Small Shelf line should be selected with quantity 1", + trigger: ".order-container .orderlines .orderline.selected .product-name:contains('Small Shelf')", + extra_trigger: ".order-container .orderlines .orderline.selected .product-name:contains('Small Shelf') ~ .info-list .info em:contains('1.0')", + run: function() {} + }, { + content: "change to price mode", + trigger: ".numpad button:contains('Price')", + }, { + content: "make sure price mode is activated", + trigger: ".numpad button.selected-mode:contains('Price')", + run: function() {}, + }, { + content: "manually override the unit price of these shelf to $5", + trigger: ".numpad button.input-button:visible:contains('5')", + }, { + content: "Small Shelf line should be selected with unit price of 5", + trigger: ".order-container .orderlines .orderline.selected .product-name:contains('Small Shelf')", + extra_trigger: ".order-container .orderlines .orderline.selected .product-name:contains('Small Shelf') ~ .price:contains('5.0')", + }, { + content: "change back to qty mode", + trigger: ".numpad button:contains('Qty')", + }, { + content: "make sure qty mode is activated", + trigger: ".numpad button.selected-mode:contains('Qty')", + run: function() {}, + }, { + content: "click pricelist button", + trigger: ".control-button.o_pricelist_button", + }, { + content: "select public pricelist", + trigger: ".selection-item:contains('Public Pricelist')", + }, { + content: "verify that the boni shelf have been recomputed and the shelf have not (their price was manually overridden)", + trigger: ".total > .value:contains('$ 8.96')", + }, { + content: "click pricelist button", + trigger: ".control-button.o_pricelist_button", + }, { + content: "select fixed pricelist", + trigger: ".selection-item:contains('min_quantity ordering')", + }, { + content: "close the Point of Sale frontend", + trigger: ".header-button", + }, { + content: "confirm closing the frontend", + trigger: ".header-button", + run: function() {}, //it's a check, + }]); + + Tour.register('pos_pricelist', { test: true, url: '/pos/ui' }, steps); +}); + +odoo.define('point_of_sale.tour.acceptance', function (require) { + "use strict"; + + var Tour = require("web_tour.tour"); + + function add_product_to_order(product_name) { + return [{ + content: 'buy ' + product_name, + trigger: '.product-list .product-name:contains("' + product_name + '")', + }, { + content: 'the ' + product_name + ' have been added to the order', + trigger: '.order .product-name:contains("' + product_name + '")', + run: function () {}, + }]; + } + + function set_fiscal_position_on_order(fp_name) { + return [{ + content: 'set fiscal position', + trigger: '.control-button.o_fiscal_position_button', + }, { + content: 'choose fiscal position ' + fp_name + ' to add to the order', + trigger: '.popups .popup .selection .selection-item:contains("' + fp_name + '")', + }, { + content: 'the fiscal position ' + fp_name + ' has been set to the order', + trigger: '.control-button.o_fiscal_position_button:contains("' + fp_name + '")', + run: function () {}, + }]; + } + + function generate_keypad_steps(amount_str, keypad_selector) { + var i, steps = [], current_char; + for (i = 0; i < amount_str.length; ++i) { + current_char = amount_str[i]; + steps.push({ + content: 'press ' + current_char + ' on payment keypad', + trigger: keypad_selector + ' .input-button:contains("' + current_char + '"):visible' + }); + } + return steps; + } + + function press_payment_numpad(val) { + return [{ + content: `press ${val} on payment screen numpad`, + trigger: `.payment-numpad .input-button:contains("${val}"):visible`, + }] + } + + function press_product_numpad(val) { + return [{ + content: `press ${val} on product screen numpad`, + trigger: `.numpad .input-button:contains("${val}"):visible`, + }] + } + + function selected_payment_has(name, val) { + return [{ + content: `selected payment is ${name} and has ${val}`, + trigger: `.paymentlines .paymentline.selected .payment-name:contains("${name}")`, + extra_trigger: `.paymentlines .paymentline.selected .payment-name:contains("${name}") ~ .payment-amount:contains("${val}")`, + run: function () {}, + }] + } + + function selected_orderline_has({ product, price = null, quantity = null }) { + const result = []; + if (price !== null) { + result.push({ + content: `Selected line has product '${product}' and price '${price}'`, + trigger: `.order-container .orderlines .orderline.selected .product-name:contains("${product}") ~ span.price:contains("${price}")`, + run: function () {}, + }); + } + if (quantity !== null) { + result.push({ + content: `Selected line has product '${product}' and quantity '${quantity}'`, + trigger: `.order-container .orderlines .orderline.selected .product-name:contains('${product}') ~ .info-list .info em:contains('${quantity}')`, + run: function () {}, + }); + } + return result; + } + + function verify_order_total(total_str) { + return [{ + content: 'order total contains ' + total_str, + trigger: '.order .total .value:contains("' + total_str + '")', + run: function () {}, // it's a check + }]; + } + + function goto_payment_screen_and_select_payment_method() { + return [{ + content: "go to payment screen", + trigger: '.button.pay', + }, { + content: "pay with cash", + trigger: '.paymentmethod:contains("Cash")', + }]; + } + + function finish_order() { + return [{ + content: "validate the order", + trigger: '.payment-screen .button.next.highlight:visible', + }, { + content: "verify that the order has been successfully sent to the backend", + trigger: ".js_connected:visible", + run: function () {}, + }, { + content: "click Next Order", + trigger: '.receipt-screen .button.next.highlight:visible', + }, { + content: "check if we left the receipt screen", + trigger: '.pos-content .screen:not(:has(.receipt-screen))', + run: function () {}, + }]; + } + + var steps = [{ + content: 'waiting for loading to finish', + trigger: 'body:not(:has(.loader))', + run: function () {}, + }, { // Leave category displayed by default + content: "click category switch", + trigger: ".breadcrumb-home", + }]; + + steps = steps.concat(add_product_to_order('Desk Organizer')); + steps = steps.concat(verify_order_total('5.10')); + + steps = steps.concat(add_product_to_order('Desk Organizer')); + steps = steps.concat(verify_order_total('10.20')); + steps = steps.concat(goto_payment_screen_and_select_payment_method()); + + /* add payment line of only 5.20 + status: + order-total := 10.20 + total-payment := 11.70 + expect: + remaining := 0.00 + change := 1.50 + */ + steps = steps.concat(press_payment_numpad('5')); + steps = steps.concat(selected_payment_has('Cash', '5.0')); + steps = steps.concat([{ + content: "verify remaining", + trigger: '.payment-status-remaining .amount:contains("5.20")', + run: function () {}, + }, { + content: "verify change", + trigger: '.payment-status-change .amount:contains("0.00")', + run: function () {}, + }]); + + /* make additional payment line of 6.50 + status: + order-total := 10.20 + total-payment := 11.70 + expect: + remaining := 0.00 + change := 1.50 + */ + steps = steps.concat([{ + content: "pay with cash", + trigger: '.paymentmethod:contains("Cash")', + }]); + steps = steps.concat(selected_payment_has('Cash', '5.2')); + steps = steps.concat(press_payment_numpad('6')) + steps = steps.concat(selected_payment_has('Cash', '6.0')); + steps = steps.concat([{ + content: "verify remaining", + trigger: '.payment-status-remaining .amount:contains("0.00")', + run: function () {}, + }, { + content: "verify change", + trigger: '.payment-status-change .amount:contains("0.80")', + run: function () {}, + }]); + + steps = steps.concat(finish_order()); + + // test opw-672118 orderline subtotal rounding + steps = steps.concat(add_product_to_order('Desk Organizer')); + steps = steps.concat(selected_orderline_has({product: 'Desk Organizer', quantity: '1.0'})); + steps = steps.concat(press_product_numpad('.')) + steps = steps.concat(selected_orderline_has({product: 'Desk Organizer', quantity: '0.0', price: '0.0'})); + steps = steps.concat(press_product_numpad('9')) + steps = steps.concat(selected_orderline_has({product: 'Desk Organizer', quantity: '0.9', price: '4.59'})); + steps = steps.concat(press_product_numpad('9')) + steps = steps.concat(selected_orderline_has({product: 'Desk Organizer', quantity: '0.99', price: '5.05'})); + steps = steps.concat(goto_payment_screen_and_select_payment_method()); + steps = steps.concat(selected_payment_has('Cash', '5.05')); + steps = steps.concat(finish_order()); + + // Test fiscal position one2many map (align with backend) + steps = steps.concat(add_product_to_order('Letter Tray')); + steps = steps.concat(selected_orderline_has({product: 'Letter Tray', quantity: '1.0'})); + steps = steps.concat(verify_order_total('5.28')); + steps = steps.concat(set_fiscal_position_on_order('FP-POS-2M')); + steps = steps.concat(verify_order_total('5.52')); + + steps = steps.concat([{ + content: "close the Point of Sale frontend", + trigger: ".header-button", + }, { + content: "confirm closing the frontend", + trigger: ".header-button.confirm", + run: function() {}, //it's a check, + }]); + + Tour.register('pos_basic_order', { test: true, url: '/pos/ui' }, steps); + +}); diff --git a/addons/point_of_sale/static/tests/unit/helpers/test_env.js b/addons/point_of_sale/static/tests/unit/helpers/test_env.js new file mode 100644 index 00000000..c4b0b3ec --- /dev/null +++ b/addons/point_of_sale/static/tests/unit/helpers/test_env.js @@ -0,0 +1,46 @@ +odoo.define('point_of_sale.test_env', async function (require) { + 'use strict'; + + /** + * Many components in PoS are dependent on the PosModel instance (pos). + * Therefore, for unit tests that require pos in the Components' env, we + * prepared here a test env maker (makePosTestEnv) based on + * makeTestEnvironment of web. + */ + + const makeTestEnvironment = require('web.test_env'); + const env = require('web.env'); + const models = require('point_of_sale.models'); + const Registries = require('point_of_sale.Registries'); + + Registries.Component.add(owl.misc.Portal); + + await env.session.is_bound; + const pos = new models.PosModel({ + rpc: env.services.rpc, + session: env.session, + do_action: async () => {}, + setLoadingMessage: () => {}, + setLoadingProgress: () => {}, + showLoadingSkip: () => {}, + }); + await pos.ready; + + /** + * @param {Object} env default env + * @param {Function} providedRPC mock rpc + * @param {Function} providedDoAction mock do_action + */ + function makePosTestEnv(env = {}, providedRPC = null, providedDoAction = null) { + env = Object.assign(env, { pos }); + let posEnv = makeTestEnvironment(env, providedRPC); + // Replace rpc in the PosModel instance after loading + // data from the server so that every succeeding rpc calls + // made by pos are mocked by the providedRPC. + pos.rpc = posEnv.rpc; + pos.do_action = providedDoAction; + return posEnv; + } + + return makePosTestEnv; +}); diff --git a/addons/point_of_sale/static/tests/unit/helpers/test_main.js b/addons/point_of_sale/static/tests/unit/helpers/test_main.js new file mode 100644 index 00000000..f42e01cb --- /dev/null +++ b/addons/point_of_sale/static/tests/unit/helpers/test_main.js @@ -0,0 +1,23 @@ +odoo.define('web.web_client', function (require) { + // this module is required by the test + const { bus } = require('web.core'); + const WebClient = require('web.AbstractWebClient'); + + // listen to unhandled rejected promises, and when the rejection is not due + // to a crash, prevent the browser from displaying an 'unhandledrejection' + // error in the console, which would make tests crash on each Promise.reject() + // something similar is done by the CrashManagerService, but by default, it + // isn't deployed in tests + bus.on('crash_manager_unhandledrejection', this, function (ev) { + if (!ev.reason || !(ev.reason instanceof Error)) { + ev.stopPropagation(); + ev.stopImmediatePropagation(); + ev.preventDefault(); + } + }); + + owl.config.mode = "dev"; + + const webClient = new WebClient(); + return webClient; +}); diff --git a/addons/point_of_sale/static/tests/unit/test_ChromeWidgets.js b/addons/point_of_sale/static/tests/unit/test_ChromeWidgets.js new file mode 100644 index 00000000..a0df97fd --- /dev/null +++ b/addons/point_of_sale/static/tests/unit/test_ChromeWidgets.js @@ -0,0 +1,89 @@ +odoo.define('point_of_sale.tests.ChromeWidgets', function (require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const PopupControllerMixin = require('point_of_sale.PopupControllerMixin'); + const testUtils = require('web.test_utils'); + const makePosTestEnv = require('point_of_sale.test_env'); + const { xml } = owl.tags; + + QUnit.module('unit tests for Chrome Widgets', {}); + + QUnit.test('CashierName', async function (assert) { + assert.expect(1); + + class Parent extends PosComponent {} + Parent.env = makePosTestEnv(); + Parent.template = xml/* html */ ` + <div><CashierName></CashierName></div> + `; + Parent.env.pos.employee.name = 'Test Employee'; + + const parent = new Parent(); + await parent.mount(testUtils.prepareTarget()); + + assert.strictEqual(parent.el.querySelector('span.username').innerText, 'Test Employee'); + + parent.unmount(); + parent.destroy(); + }); + + QUnit.test('HeaderButton', async function (assert) { + assert.expect(1); + + class Parent extends PosComponent {} + Parent.env = makePosTestEnv(); + Parent.template = xml/* html */ ` + <div><HeaderButton></HeaderButton></div> + `; + + const parent = new Parent(); + await parent.mount(testUtils.prepareTarget()); + + const headerButton = parent.el.querySelector('.header-button'); + await testUtils.dom.click(headerButton); + await testUtils.nextTick(); + assert.ok(headerButton.classList.contains('confirm')); + + parent.unmount(); + parent.destroy(); + }); + + QUnit.test('SyncNotification', async function (assert) { + assert.expect(5); + + class Parent extends PosComponent {} + Parent.env = makePosTestEnv(); + Parent.template = xml/* html */ ` + <div> + <SyncNotification></SyncNotification> + </div> + `; + + const pos = Parent.env.pos; + pos.set('synch', { status: 'connected', pending: false }); + + const parent = new Parent(); + await parent.mount(testUtils.prepareTarget()); + assert.ok(parent.el.querySelector('i.fa').parentElement.classList.contains('js_connected')); + + pos.set('synch', { status: 'connecting', pending: false }); + await testUtils.nextTick(); + assert.ok(parent.el.querySelector('i.fa').parentElement.classList.contains('js_connecting')); + + pos.set('synch', { status: 'disconnected', pending: false }); + await testUtils.nextTick(); + assert.ok(parent.el.querySelector('i.fa').parentElement.classList.contains('js_disconnected')); + + pos.set('synch', { status: 'error', pending: false }); + await testUtils.nextTick(); + assert.ok(parent.el.querySelector('i.fa').parentElement.classList.contains('js_error')); + + pos.set('synch', { status: 'error', pending: 10 }); + await testUtils.nextTick(); + assert.ok(parent.el.querySelector('.js_msg').innerText.includes('10')); + + parent.unmount(); + parent.destroy(); + }); +}); diff --git a/addons/point_of_sale/static/tests/unit/test_ComponentRegistry.js b/addons/point_of_sale/static/tests/unit/test_ComponentRegistry.js new file mode 100644 index 00000000..4b2217cb --- /dev/null +++ b/addons/point_of_sale/static/tests/unit/test_ComponentRegistry.js @@ -0,0 +1,414 @@ +odoo.define('point_of_sale.tests.ComponentRegistry', function(require) { + 'use strict'; + + const Registries = require('point_of_sale.Registries'); + + QUnit.module('unit tests for ComponentRegistry', { + before() {}, + }); + + QUnit.test('basic extend', async function(assert) { + assert.expect(5); + + class A { + constructor() { + assert.step('A'); + } + } + Registries.Component.add(A); + + let A1 = x => + class extends x { + constructor() { + super(); + assert.step('A1'); + } + }; + Registries.Component.extend(A, A1); + + Registries.Component.freeze(); + + const RegA = Registries.Component.get(A); + let a = new RegA(); + assert.verifySteps(['A', 'A1']); + assert.ok(a instanceof RegA); + assert.ok(RegA.name === 'A'); + }); + + QUnit.test('addByExtending', async function(assert) { + assert.expect(8); + + class A { + constructor() { + assert.step('A'); + } + } + Registries.Component.add(A); + + let B = x => + class extends x { + constructor() { + super(); + assert.step('B'); + } + }; + Registries.Component.addByExtending(B, A); + + let A1 = x => + class extends x { + constructor() { + super(); + assert.step('A1'); + } + }; + Registries.Component.extend(A, A1); + + let A2 = x => + class extends x { + constructor() { + super(); + assert.step('A2'); + } + }; + Registries.Component.extend(A, A2); + + Registries.Component.freeze(); + + const RegA = Registries.Component.get(A); + const RegB = Registries.Component.get(B); + let b = new RegB(); + assert.verifySteps(['A', 'A1', 'A2', 'B']); + assert.ok(b instanceof RegA); + assert.ok(b instanceof RegB); + assert.ok(RegB.name === 'B'); + }); + + QUnit.test('extend the one that is added by extending', async function(assert) { + assert.expect(6); + + class A { + constructor() { + assert.step('A'); + } + } + Registries.Component.add(A); + + let B = x => + class extends x { + constructor() { + super(); + assert.step('B'); + } + }; + Registries.Component.addByExtending(B, A); + + let B1 = x => + class extends x { + constructor() { + super(); + assert.step('B1'); + } + }; + Registries.Component.extend(B, B1); + + let B2 = x => + class extends x { + constructor() { + super(); + assert.step('B2'); + } + }; + Registries.Component.extend(B, B2); + + let A1 = x => + class extends x { + constructor() { + super(); + assert.step('A1'); + } + }; + Registries.Component.extend(A, A1); + + Registries.Component.freeze(); + + const RegB = Registries.Component.get(B); + new RegB(); + assert.verifySteps(['A', 'A1', 'B', 'B1', 'B2']); + }); + + QUnit.test('addByExtending based on added by extending', async function(assert) { + assert.expect(10); + + class A { + constructor() { + assert.step('A'); + } + } + Registries.Component.add(A); + + let B = x => + class extends x { + constructor() { + super(); + assert.step('B'); + } + }; + Registries.Component.addByExtending(B, A); + + let A1 = x => + class extends x { + constructor() { + super(); + assert.step('A1'); + } + }; + Registries.Component.extend(A, A1); + + let C = x => + class extends x { + constructor() { + super(); + assert.step('C'); + } + }; + Registries.Component.addByExtending(C, B); + + let B7 = x => + class extends x { + constructor() { + super(); + assert.step('B7'); + } + }; + Registries.Component.extend(B, B7); + + Registries.Component.freeze(); + + const RegA = Registries.Component.get(A); + const RegB = Registries.Component.get(B); + const RegC = Registries.Component.get(C); + let c = new RegC(); + assert.verifySteps(['A', 'A1', 'B', 'B7', 'C']); + assert.ok(c instanceof RegA); + assert.ok(c instanceof RegB); + assert.ok(c instanceof RegC); + assert.ok(RegC.name === 'C'); + }); + + QUnit.test('deeper inheritance', async function(assert) { + assert.expect(9); + + class A { + constructor() { + assert.step('A'); + } + } + Registries.Component.add(A); + + let B = x => + class extends x { + constructor() { + super(); + assert.step('B'); + } + }; + Registries.Component.addByExtending(B, A); + + let A1 = x => + class extends x { + constructor() { + super(); + assert.step('A1'); + } + }; + Registries.Component.extend(A, A1); + + let C = x => + class extends x { + constructor() { + super(); + assert.step('C'); + } + }; + Registries.Component.addByExtending(C, B); + + let B2 = x => + class extends x { + constructor() { + super(); + assert.step('B2'); + } + }; + Registries.Component.extend(B, B2); + + let B3 = x => + class extends x { + constructor() { + super(); + assert.step('B3'); + } + }; + Registries.Component.extend(B, B3); + + let A9 = x => + class extends x { + constructor() { + super(); + assert.step('A9'); + } + }; + Registries.Component.extend(A, A9); + + let E = x => + class extends x { + constructor() { + super(); + assert.step('E'); + } + }; + Registries.Component.addByExtending(E, C); + + Registries.Component.freeze(); + + // |A| => A9 -> A1 -> A + // |B| => B3 -> B2 -> B -> |A| + // |C| => C -> |B| + // |E| => E -> |C| + + new (Registries.Component.get(E))(); + assert.verifySteps(['A', 'A1', 'A9', 'B', 'B2', 'B3', 'C', 'E']); + }); + + QUnit.test('mixins?', async function(assert) { + assert.expect(12); + + class A { + constructor() { + assert.step('A'); + } + } + Registries.Component.add(A); + + let Mixin = x => + class extends x { + constructor() { + super(); + assert.step('Mixin'); + } + mixinMethod() { + return 'mixinMethod'; + } + get mixinGetter() { + return 'mixinGetter'; + } + }; + + // use the mixin when declaring B. + let B = x => + class extends Mixin(x) { + constructor() { + super(); + assert.step('B'); + } + }; + Registries.Component.addByExtending(B, A); + + let A1 = x => + class extends x { + constructor() { + super(); + assert.step('A1'); + } + }; + Registries.Component.extend(A, A1); + + Registries.Component.freeze(); + + B = Registries.Component.get(B); + const b = new B(); + assert.verifySteps(['A', 'A1', 'Mixin', 'B']); + // instance of B should have the mixin properties + assert.strictEqual(b.mixinMethod(), 'mixinMethod'); + assert.strictEqual(b.mixinGetter, 'mixinGetter'); + + // instance of A should not have the mixin properties + A = Registries.Component.get(A); + const a = new A(); + assert.verifySteps(['A', 'A1']); + assert.notOk(a.mixinMethod); + assert.notOk(a.mixinGetter); + }); + + QUnit.test('extending methods', async function(assert) { + assert.expect(16); + + class A { + foo() { + assert.step('A foo'); + } + } + Registries.Component.add(A); + + let B = x => + class extends x { + bar() { + assert.step('B bar'); + } + }; + Registries.Component.addByExtending(B, A); + + let A1 = x => + class extends x { + bar() { + assert.step('A1 bar'); + // should only be for A. + } + }; + Registries.Component.extend(A, A1); + + let B1 = x => + class extends x { + foo() { + super.foo(); + assert.step('B1 foo'); + } + }; + Registries.Component.extend(B, B1); + + let C = x => + class extends x { + foo() { + super.foo(); + assert.step('C foo'); + } + bar() { + super.bar(); + assert.step('C bar'); + } + }; + Registries.Component.addByExtending(C, B); + + Registries.Component.freeze(); + + A = Registries.Component.get(A); + B = Registries.Component.get(B); + C = Registries.Component.get(C); + const a = new A(); + const b = new B(); + const c = new C(); + + a.foo(); + assert.verifySteps(['A foo']); + b.foo(); + assert.verifySteps(['A foo', 'B1 foo']); + c.foo(); + assert.verifySteps(['A foo', 'B1 foo', 'C foo']); + + a.bar(); + assert.verifySteps(['A1 bar']); + b.bar(); + assert.verifySteps(['B bar']); + c.bar(); + assert.verifySteps(['B bar', 'C bar']); + }); +}); diff --git a/addons/point_of_sale/static/tests/unit/test_NumberBuffer.js b/addons/point_of_sale/static/tests/unit/test_NumberBuffer.js new file mode 100644 index 00000000..1e9da1e6 --- /dev/null +++ b/addons/point_of_sale/static/tests/unit/test_NumberBuffer.js @@ -0,0 +1,65 @@ +odoo.define('point_of_sale.tests.NumberBuffer', function(require) { + 'use strict'; + + const { Component, useState } = owl; + const { xml } = owl.tags; + const NumberBuffer = require('point_of_sale.NumberBuffer'); + const makeTestEnvironment = require('web.test_env'); + const testUtils = require('web.test_utils'); + + QUnit.module('unit tests for NumberBuffer', { + before() {}, + }); + + QUnit.test('simple fast inputs with capture in between', async function(assert) { + assert.expect(3); + + class Root extends Component { + constructor() { + super(); + this.state = useState({ buffer: '' }); + NumberBuffer.activate(); + NumberBuffer.use({ + nonKeyboardInputEvent: 'numpad-click-input', + state: this.state, + }); + } + resetBuffer() { + NumberBuffer.capture(); + NumberBuffer.reset(); + } + } + Root.env = makeTestEnvironment(); + Root.template = xml/* html */ ` + <div> + <p><t t-esc="state.buffer" /></p> + <button class="one" t-on-click="trigger('numpad-click-input', { key: '1' })">1</button> + <button class="two" t-on-click="trigger('numpad-click-input', { key: '2' })">2</button> + <button class="reset" t-on-click="resetBuffer">reset</button> + </div> + `; + + const root = new Root(); + await root.mount(testUtils.prepareTarget()); + + const oneButton = root.el.querySelector('button.one'); + const twoButton = root.el.querySelector('button.two'); + const resetButton = root.el.querySelector('button.reset'); + const bufferEl = root.el.querySelector('p'); + + testUtils.dom.click(oneButton); + testUtils.dom.click(twoButton); + await testUtils.nextTick(); + assert.strictEqual(bufferEl.textContent, '12'); + testUtils.dom.click(resetButton); + await testUtils.nextTick(); + assert.strictEqual(bufferEl.textContent, ''); + testUtils.dom.click(twoButton); + testUtils.dom.click(oneButton); + await testUtils.nextTick(); + assert.strictEqual(bufferEl.textContent, '21'); + + root.unmount(); + root.destroy(); + }); +}); diff --git a/addons/point_of_sale/static/tests/unit/test_PaymentScreen.js b/addons/point_of_sale/static/tests/unit/test_PaymentScreen.js new file mode 100644 index 00000000..48d3b55d --- /dev/null +++ b/addons/point_of_sale/static/tests/unit/test_PaymentScreen.js @@ -0,0 +1,309 @@ +odoo.define('point_of_sale.tests.PaymentScreen', function (require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const { useListener } = require('web.custom_hooks'); + const testUtils = require('web.test_utils'); + const makePosTestEnv = require('point_of_sale.test_env'); + const { xml } = owl.tags; + const { useState } = owl; + + QUnit.module('unit tests for PaymentScreen components', {}); + + QUnit.test('PaymentMethodButton', async function (assert) { + assert.expect(2); + + class Parent extends PosComponent { + constructor() { + super(...arguments); + useListener('new-payment-line', this._newPaymentLine); + } + _newPaymentLine() { + assert.step('new-payment-line'); + } + } + Parent.env = makePosTestEnv(); + Parent.template = xml/* html */ ` + <div> + <PaymentMethodButton paymentMethod="{ name: 'Cash', id: 1 }" /> + </div> + `; + + const parent = new Parent(); + await parent.mount(testUtils.prepareTarget()); + + const button = parent.el.querySelector('.paymentmethod'); + await testUtils.dom.click(button); + assert.verifySteps(['new-payment-line']); + + parent.unmount(); + parent.destroy(); + }); + + QUnit.test('PSNumpadInputButton', async function (assert) { + assert.expect(15); + + class Parent extends PosComponent { + constructor({ value, text, changeClassTo }) { + super(); + this.state = useState({ value, text, changeClassTo }); + useListener('input-from-numpad', this._inputFromNumpad); + } + _inputFromNumpad({ detail: { key } }) { + assert.step(`${key}-input`); + } + setState(obj) { + Object.assign(this.state, obj); + } + } + Parent.env = makePosTestEnv(); + Parent.template = xml/* html */ ` + <div> + <PSNumpadInputButton value="state.value" text="state.text" changeClassTo="state.changeClassTo" /> + </div> + `; + + let parent = new Parent({ value: '1' }); + await parent.mount(testUtils.prepareTarget()); + + let button = parent.el.querySelector('button'); + assert.ok(button.textContent.includes('1')); + assert.ok(button.classList.contains('number-char')); + await testUtils.dom.click(button); + await testUtils.nextTick(); + assert.verifySteps(['1-input']); + + parent.setState({ value: '2', text: 'Two' }); + await testUtils.nextTick(); + assert.ok(button.textContent.includes('Two')); + await testUtils.dom.click(button); + await testUtils.nextTick(); + assert.verifySteps(['2-input']); + + parent.setState({ value: '+12', text: null, changeClassTo: 'not-number-char' }); + await testUtils.nextTick(); + assert.ok(button.textContent.includes('+12')); + assert.ok(button.classList.contains('not-number-char')); + // class number-char should have been replaced + assert.notOk(button.classList.contains('number-char')); + await testUtils.dom.click(button); + await testUtils.nextTick(); + assert.verifySteps(['+12-input']); + + parent.unmount(); + parent.destroy(); + + // using the slot should ignore value and text props of the component + Parent.template = xml/* html */ ` + <div> + <PSNumpadInputButton value="state.value" text="state.text" changeClassTo="state.changeClassTo"> + <span>UseSlot</span> + </PSNumpadInputButton> + </div> + `; + parent = new Parent({ value: 'slotted', text: 'Text' }); + await parent.mount(testUtils.prepareTarget()); + + button = parent.el.querySelector('button'); + assert.ok(button.textContent.includes('UseSlot')); + await testUtils.dom.click(button); + await testUtils.nextTick(); + assert.verifySteps(['slotted-input']); + + parent.unmount(); + parent.destroy(); + }); + + QUnit.test('PaymentScreenPaymentLines', async function (assert) { + assert.expect(12); + + class Parent extends PosComponent { + constructor() { + super(); + useListener('delete-payment-line', this._onDeletePaymentLine); + useListener('select-payment-line', this._onSelectPaymentLine); + } + get paymentLines() { + return this.order.get_paymentlines(); + } + get order() { + return this.env.pos.get_order(); + } + mounted() { + this.order.paymentlines.on('change', this.render, this); + } + willUnmount() { + this.order.paymentlines.off('change', null, this); + } + _onDeletePaymentLine() { + assert.step('delete-click'); + } + _onSelectPaymentLine() { + assert.step('select-click'); + } + } + Parent.env = makePosTestEnv(); + Parent.template = xml/* html */ ` + <div> + <PaymentScreenPaymentLines paymentLines="paymentLines" /> + </div> + `; + + let parent = new Parent(); + await parent.mount(testUtils.prepareTarget()); + + const order = parent.env.pos.get_order(); + const cashPM = { id: 0, name: 'Cash', is_cash_count: true, use_payment_terminal: false }; + const bankPM = { id: 0, name: 'Bank', is_cash_count: false, use_payment_terminal: false }; + + let paymentline1 = order.add_paymentline(cashPM); + await testUtils.nextTick(); + + let statusContainer = parent.el.querySelector('.payment-status-container'); + let linesEl = parent.el.querySelector('.paymentlines'); + assert.ok(linesEl, 'payment lines are shown'); + let newLine = linesEl.querySelector('.selected'); + assert.ok(newLine, 'the new line is automatically selected'); + + let paymentline2 = order.add_paymentline(bankPM); + await testUtils.nextTick(); + assert.notOk( + linesEl.querySelector('.selected') === newLine, + 'the previously added paymentline should not be selected anymore' + ); + assert.ok( + linesEl.querySelectorAll('.paymentline:not(.heading)').length === 2, + 'there should be two paymentlines' + ); + + let paymentline3 = order.add_paymentline(cashPM); + await testUtils.nextTick(); + assert.ok( + linesEl.querySelectorAll('.paymentline:not(.heading)').length === 3, + 'there should be three paymentlines' + ); + assert.ok( + linesEl.querySelectorAll('.paymentline.selected').length === 1, + 'there should only be one selected paymentline' + ); + + await testUtils.dom.click(linesEl.querySelector('.paymentline.selected .delete-button')); + await testUtils.nextTick(); + assert.verifySteps(['delete-click', 'select-click']); + + // click the 2nd payment line + await testUtils.dom.click(linesEl.querySelectorAll('.paymentline:not(.heading)')[1]); + await testUtils.nextTick(); + assert.verifySteps(['select-click']); + + // remove paymentline3 (the selected) + order.remove_paymentline(paymentline3); + await testUtils.nextTick(); + assert.notOk( + linesEl.querySelector('.paymentline.selected'), + 'no more selected payment line' + ); + + order.remove_paymentline(paymentline1); + order.remove_paymentline(paymentline2); + + parent.unmount(); + parent.destroy(); + }); + + QUnit.test('PaymentScreenElectronicPayment', async function (assert) { + assert.expect(17); + + class SimulatedPaymentLine extends Backbone.Model { + constructor() { + super(); + this.payment_status = 'pending'; + this.can_be_reversed = false; + } + canBeAdjusted() { + return false; + } + setPaymentStatus(status) { + this.payment_status = status; + this.trigger('change'); + } + toggleCanBeReversed() { + this.can_be_reversed = !this.can_be_reversed; + this.trigger('change'); + } + } + + class Parent extends PosComponent { + constructor() { + super(); + this.line = new SimulatedPaymentLine(); + useListener('send-payment-request', () => assert.step('send-payment-request')); + useListener('send-force-done', () => assert.step('send-force-done')); + useListener('send-payment-cancel', () => assert.step('send-payment-cancel')); + useListener('send-payment-reverse', () => assert.step('send-payment-reverse')); + } + } + Parent.env = makePosTestEnv(); + Parent.template = xml/* html */ ` + <div> + <PaymentScreenElectronicPayment line="line" /> + </div> + `; + + let parent = new Parent(); + await parent.mount(testUtils.prepareTarget()); + + assert.ok(parent.el.querySelector('.paymentline .send_payment_request')); + await testUtils.dom.click(parent.el.querySelector('.paymentline .send_payment_request')); + await testUtils.nextTick(); + assert.verifySteps(['send-payment-request']); + + parent.line.setPaymentStatus('retry'); + await testUtils.nextTick(); + await testUtils.dom.click(parent.el.querySelector('.paymentline .send_payment_request')); + await testUtils.nextTick(); + assert.verifySteps(['send-payment-request']); + + parent.line.setPaymentStatus('force_done'); + await testUtils.nextTick(); + await testUtils.dom.click(parent.el.querySelector('.paymentline .send_force_done')); + await testUtils.nextTick(); + assert.verifySteps(['send-force-done']); + + parent.line.setPaymentStatus('waitingCard'); + await testUtils.nextTick(); + await testUtils.dom.click(parent.el.querySelector('.paymentline .send_payment_cancel')); + await testUtils.nextTick(); + assert.verifySteps(['send-payment-cancel']); + + parent.line.setPaymentStatus('waiting'); + await testUtils.nextTick(); + assert.ok(parent.el.querySelector('.paymentline i.fa-spinner')); + + parent.line.setPaymentStatus('waitingCancel'); + await testUtils.nextTick(); + assert.ok(parent.el.querySelector('.paymentline i.fa-spinner')); + + parent.line.setPaymentStatus('reversing'); + await testUtils.nextTick(); + assert.ok(parent.el.querySelector('.paymentline i.fa-spinner')); + + parent.line.setPaymentStatus('done'); + await testUtils.nextTick(); + assert.notOk(parent.el.querySelector('.paymentline .send_payment_reversal')); + + parent.line.toggleCanBeReversed(); + await testUtils.nextTick(); + assert.ok(parent.el.querySelector('.paymentline .send_payment_reversal')); + await testUtils.dom.click(parent.el.querySelector('.paymentline .send_payment_reversal')); + await testUtils.nextTick(); + assert.verifySteps(['send-payment-reverse']); + + parent.line.setPaymentStatus('reversed'); + await testUtils.nextTick(); + assert.ok(parent.el.querySelector('.paymentline')); + + parent.unmount(); + parent.destroy(); + }); +}); diff --git a/addons/point_of_sale/static/tests/unit/test_ProductScreen.js b/addons/point_of_sale/static/tests/unit/test_ProductScreen.js new file mode 100644 index 00000000..bdd9b732 --- /dev/null +++ b/addons/point_of_sale/static/tests/unit/test_ProductScreen.js @@ -0,0 +1,603 @@ +odoo.define('point_of_sale.tests.ProductScreen', function (require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + const { useListener } = require('web.custom_hooks'); + const testUtils = require('web.test_utils'); + const makePosTestEnv = require('point_of_sale.test_env'); + const { xml } = owl.tags; + const { useState } = owl; + + QUnit.module('unit tests for ProductScreen components', {}); + + QUnit.test('ActionpadWidget', async function (assert) { + assert.expect(7); + + class Parent extends PosComponent { + constructor() { + super(); + useListener('click-customer', () => assert.step('click-customer')); + useListener('click-pay', () => assert.step('click-pay')); + this.state = useState({ client: null }); + } + } + Parent.env = makePosTestEnv(); + Parent.template = xml/* html */ ` + <div> + <ActionpadWidget client="state.client" /> + </div> + `; + + const parent = new Parent(); + await parent.mount(testUtils.prepareTarget()); + + const setCustomerButton = parent.el.querySelector('button.set-customer'); + const payButton = parent.el.querySelector('button.pay'); + + await testUtils.nextTick(); + assert.ok(setCustomerButton.innerText.includes('Customer')); + + // change to customer with short name + parent.state.client = { name: 'Test' }; + await testUtils.nextTick(); + assert.ok(setCustomerButton.innerText.includes('Test')); + + // change to customer with long name + parent.state.client = { name: 'Change Customer' }; + await testUtils.nextTick(); + assert.ok(setCustomerButton.classList.contains('decentered')); + + parent.state.client = null; + + // click set-customer button + await testUtils.dom.click(setCustomerButton); + await testUtils.nextTick(); + assert.verifySteps(['click-customer']); + + // click pay button + await testUtils.dom.click(payButton); + await testUtils.nextTick(); + assert.verifySteps(['click-pay']); + + parent.unmount(); + parent.destroy(); + }); + + QUnit.test('NumpadWidget', async function (assert) { + assert.expect(25); + + class Parent extends PosComponent { + constructor() { + super(...arguments); + useListener('set-numpad-mode', this.setNumpadMode); + useListener('numpad-click-input', this.numpadClickInput); + this.state = useState({ mode: 'quantity' }); + } + setNumpadMode({ detail: { mode } }) { + this.state.mode = mode; + assert.step(mode); + } + numpadClickInput({ detail: { key } }) { + assert.step(key); + } + } + Parent.env = makePosTestEnv(); + Parent.template = xml/* html */ ` + <div><NumpadWidget activeMode="state.mode"></NumpadWidget></div> + `; + + const pos = Parent.env.pos; + // set this old values back after testing + const old_config = pos.config; + const old_cashier = pos.get('cashier'); + + // set dummy values in pos.config and pos.get('cashier') + pos.config = { + restrict_price_control: false, + manual_discount: true + }; + pos.set('cashier', { role: 'manager' }); + + const parent = new Parent(); + await parent.mount(testUtils.prepareTarget()); + + const modeButtons = parent.el.querySelectorAll('.mode-button'); + let qtyButton, discButton, priceButton; + for (let button of modeButtons) { + if (button.textContent.includes('Qty')) { + qtyButton = button; + } + if (button.textContent.includes('Disc')) { + discButton = button; + } + if (button.textContent.includes('Price')) { + priceButton = button; + } + } + + // initially, qty button is active + assert.ok(qtyButton.classList.contains('selected-mode')); + assert.ok(!discButton.classList.contains('selected-mode')); + assert.ok(!priceButton.classList.contains('selected-mode')); + + await testUtils.dom.click(discButton); + await testUtils.nextTick(); + assert.ok(!qtyButton.classList.contains('selected-mode')); + assert.ok(discButton.classList.contains('selected-mode')); + assert.ok(!priceButton.classList.contains('selected-mode')); + assert.verifySteps(['discount']); + + await testUtils.dom.click(priceButton); + await testUtils.nextTick(); + assert.ok(!qtyButton.classList.contains('selected-mode')); + assert.ok(!discButton.classList.contains('selected-mode')); + assert.ok(priceButton.classList.contains('selected-mode')); + assert.verifySteps(['price']); + + const numpadOne = [...parent.el.querySelectorAll('.number-char').values()].find((el) => + el.textContent.includes('1') + ); + const numpadMinus = parent.el.querySelector('.numpad-minus'); + const numpadBackspace = parent.el.querySelector('.numpad-backspace'); + + await testUtils.dom.click(numpadOne); + await testUtils.nextTick(); + assert.verifySteps(['1']); + + await testUtils.dom.click(numpadMinus); + await testUtils.nextTick(); + assert.verifySteps(['-']); + + await testUtils.dom.click(numpadBackspace); + await testUtils.nextTick(); + assert.verifySteps(['Backspace']); + + await testUtils.dom.click(priceButton); + await testUtils.nextTick(); + assert.verifySteps(['price']); + + // change to price control restriction and the cashier is not manager + pos.config.restrict_price_control = true; + pos.set('cashier', { role: 'not manager' }); + await testUtils.nextTick(); + + assert.ok(priceButton.classList.contains('disabled-mode')); + assert.ok(qtyButton.classList.contains('selected-mode')); + // after the cashier is changed, since it is not a manager, + // the 'set-numpad-mode' is triggered, setting the mode to + // 'quantity'. + assert.verifySteps(['quantity']); + + // reset old config and cashier values to pos + pos.config = old_config; + pos.set('cashier', old_cashier); + + parent.unmount(); + parent.destroy(); + }); + + QUnit.test('ProductsWidgetControlPanel', async function (assert) { + assert.expect(32); + + // This test incorporates the following components: + // CategoryBreadcrumb + // CategoryButton + // CategorySimpleButton + // HomeCategoryBreadcrumb + + // Create dummy category data + // + // Root + // | Test1 + // | | Test2 + // | ` Test3 + // | | Test5 + // | ` Test6 + // ` Test4 + + const rootCategory = { id: 0, name: 'Root', parent: null }; + const testCategory1 = { id: 1, name: 'Test1', parent: 0 }; + const testCategory2 = { id: 2, name: 'Test2', parent: 1 }; + const testCategory3 = { id: 3, name: 'Test3', parent: 1 }; + const testCategory4 = { id: 4, name: 'Test4', parent: 0 }; + const testCategory5 = { id: 5, name: 'Test5', parent: 3 }; + const testCategory6 = { id: 6, name: 'Test6', parent: 3 }; + const categories = { + 0: rootCategory, + 1: testCategory1, + 2: testCategory2, + 3: testCategory3, + 4: testCategory4, + 5: testCategory5, + 6: testCategory6, + }; + + class Parent extends PosComponent { + constructor() { + super(...arguments); + this.state = useState({ selectedCategoryId: 0 }); + useListener('switch-category', this.switchCategory); + useListener('update-search', this.updateSearch); + useListener('clear-search', this.clearSearch); + } + get breadcrumbs() { + if (this.state.selectedCategoryId === 0) return []; + let current = categories[this.state.selectedCategoryId]; + const res = [current]; + while (current.parent != 0) { + const toAdd = categories[current.parent]; + res.push(toAdd); + current = toAdd; + } + return res.reverse(); + } + get subcategories() { + return Object.values(categories).filter( + ({ parent }) => parent == this.state.selectedCategoryId + ); + } + switchCategory({ detail: id }) { + this.state.selectedCategoryId = id; + assert.step(`${id}`); + } + updateSearch(event) { + assert.step(event.detail); + } + clearSearch() { + assert.step('cleared'); + } + } + Parent.env = makePosTestEnv(); + Parent.template = xml/* html */ ` + <div class="pos"> + <div class="search-bar-portal"> + <ProductsWidgetControlPanel breadcrumbs="breadcrumbs" subcategories="subcategories" /> + </div> + </div> + `; + + const pos = Parent.env.pos; + const old_config = pos.config; + // set dummy config + pos.config = { iface_display_categ_images: false }; + + const parent = new Parent(); + await parent.mount(testUtils.prepareTarget()); + + // The following tests the breadcrumbs and subcategory buttons + + // check if HomeCategoryBreadcrumb is rendered + assert.ok( + parent.el.querySelector('.breadcrumb-home'), + 'Home category should always be there' + ); + let subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')]; + assert.ok(subcategorySpans.length === 2, 'There should be 2 subcategories for Root.'); + assert.ok(subcategorySpans.find((span) => span.textContent.includes('Test1'))); + assert.ok(subcategorySpans.find((span) => span.textContent.includes('Test4'))); + + // click Test1 + let test1Span = subcategorySpans.find((span) => span.textContent.includes('Test1')); + await testUtils.dom.click(test1Span); + await testUtils.nextTick(); + assert.verifySteps(['1']); + assert.ok( + [...parent.el.querySelectorAll('.breadcrumb-button')][1].textContent.includes('Test1') + ); + subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')]; + assert.ok(subcategorySpans.length === 2, 'There should be 2 subcategories for Root.'); + assert.ok(subcategorySpans.find((span) => span.textContent.includes('Test2'))); + assert.ok(subcategorySpans.find((span) => span.textContent.includes('Test3'))); + + // click Test2 + let test2Span = subcategorySpans.find((span) => span.textContent.includes('Test2')); + await testUtils.dom.click(test2Span); + await testUtils.nextTick(); + assert.verifySteps(['2']); + subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')]; + assert.ok(subcategorySpans.length === 0, 'Test2 should not have subcategories'); + + // go back to Test1 + let breadcrumb1 = [...parent.el.querySelectorAll('.breadcrumb-button')].find((el) => + el.textContent.includes('Test1') + ); + await testUtils.dom.click(breadcrumb1); + await testUtils.nextTick(); + assert.verifySteps(['1']); + + // click Test3 + subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')]; + let test3Span = subcategorySpans.find((span) => span.textContent.includes('Test3')); + await testUtils.dom.click(test3Span); + await testUtils.nextTick(); + assert.verifySteps(['3']); + subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')]; + assert.ok(subcategorySpans.length === 2); + + // click Test6 + let test6Span = subcategorySpans.find((span) => span.textContent.includes('Test6')); + await testUtils.dom.click(test6Span); + await testUtils.nextTick(); + assert.verifySteps(['6']); + let breadcrumbButtons = [...parent.el.querySelectorAll('.breadcrumb-button')]; + assert.ok(breadcrumbButtons.length === 4); + + // Now check subcategory buttons with images + pos.config.iface_display_categ_images = true; + + let breadcrumbHome = parent.el.querySelector('.breadcrumb-home'); + await testUtils.dom.click(breadcrumbHome); + await testUtils.nextTick(); + assert.verifySteps(['0']); + assert.ok( + !parent.el.querySelector('.category-list').classList.contains('simple'), + 'Category list should not have simple class' + ); + let categoryButtons = [...parent.el.querySelectorAll('.category-button')]; + assert.ok(categoryButtons.length === 2, 'There should be 2 subcategories for Root'); + + // The following tests the search bar + + const wait = (ms) => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }; + + const inputEl = parent.el.querySelector('.search-box input'); + await testUtils.dom.triggerEvent(inputEl, 'keyup', { key: 'A' }); + // Triggering keyup event doesn't type the key to the input + // so we manually assign the value of the input. + inputEl.value = 'A'; + await wait(30); + await testUtils.dom.triggerEvent(inputEl, 'keyup', { key: 'B' }); + inputEl.value = 'AB'; + await wait(30); + await testUtils.dom.triggerEvent(inputEl, 'keyup', { key: 'C' }); + inputEl.value = 'ABC'; + await wait(110); + // Only after waiting for more than 100ms that update-search is triggered + // because the method is debounced. + assert.verifySteps(['ABC']); + await testUtils.dom.triggerEvent(inputEl, 'keyup', { key: 'D' }); + inputEl.value = 'ABCD'; + await wait(110); + assert.verifySteps(['ABCD']); + + // clear the search bar + await testUtils.dom.click(parent.el.querySelector('.search-box .clear-icon')); + await testUtils.nextTick(); + assert.verifySteps(['cleared']); + assert.ok(inputEl.value === '', 'value of the input element should be empty'); + + pos.config = old_config; + + parent.unmount(); + parent.destroy(); + }); + + QUnit.test('ProductList, ProductItem', async function (assert) { + assert.expect(10); + + // patch imageUrl and price of ProductItem component + const MockProductItemExt = (X) => + class extends X { + get imageUrl() { + return 'data:,'; + } + get price() { + return this.props.product.price; + } + }; + + const extension = Registries.Component.extend('ProductItem', MockProductItemExt); + extension.compile(); + + const dummyProducts = [ + { id: 0, display_name: 'Burger', price: '$10' }, + { id: 1, display_name: 'Water', price: '$2' }, + { id: 2, display_name: 'Chair', price: '$25' }, + ]; + + class Parent extends PosComponent { + constructor() { + super(...arguments); + this.state = useState({ searchWord: '', products: dummyProducts }); + useListener('click-product', this._clickProduct); + } + _clickProduct({ detail: product }) { + assert.step(product.display_name); + } + } + Parent.env = makePosTestEnv(); + Parent.template = xml/* html */ ` + <div> + <ProductList products="state.products" searchWord="state.searchWord" /> + </div> + `; + + const parent = new Parent(); + await parent.mount(testUtils.prepareTarget()); + + // Check if there are 3 products listed + assert.strictEqual( + parent.el.querySelectorAll('article.product').length, + 3, + 'There should be 3 products listed' + ); + + // Check contents of product item and click + const product1el = parent.el.querySelector( + 'article.product[aria-labelledby="article_product_1"]' + ); + assert.ok(product1el.querySelector('.product-img img[alt="Water"]')); + assert.ok(product1el.querySelector('.product-img .price-tag').textContent.includes('$2')); + await testUtils.dom.click(product1el); + await testUtils.nextTick(); + assert.verifySteps(['Water']); + + // Remove one product, check if only two is listed + parent.state.products.splice(0, 1); + await testUtils.nextTick(); + assert.strictEqual( + parent.el.querySelectorAll('article.product').length, + 2, + 'There should be 2 products listed after removing the first item' + ); + + // Remove all products, check if empty message is There are no products in this category + parent.state.products.splice(0, parent.state.products.length); + await testUtils.nextTick(); + assert.strictEqual( + parent.el.querySelectorAll('article.product').length, + 0, + 'There should be 0 products listed after removing everything' + ); + assert.ok( + parent.el + .querySelector('.product-list-empty p') + .textContent.includes('There are no products in this category.') + ); + + // change the searchWord to 'something', check if empty message is No results found + parent.state.searchWord = 'something'; + await testUtils.nextTick(); + assert.ok( + parent.el + .querySelector('.product-list-empty p') + .textContent.includes('No results found for') + ); + assert.ok( + parent.el.querySelector('.product-list-empty p b').textContent.includes('something') + ); + + extension.remove(); + + parent.unmount(); + parent.destroy(); + }); + + QUnit.test('Orderline', async function (assert) { + assert.expect(10); + + class Parent extends PosComponent { + constructor(product) { + super(); + useListener('select-line', this._selectLine); + useListener('edit-pack-lot-lines', this._editPackLotLines); + this.order.add_product(product); + } + get order() { + return this.env.pos.get_order(); + } + get line() { + return this.env.pos.get_order().get_orderlines()[0]; + } + _selectLine() { + assert.step('select-line'); + } + _editPackLotLines() { + assert.step('edit-pack-lot-lines'); + } + willUnmount() { + this.order.remove_orderline(this.line); + } + } + Parent.env = makePosTestEnv(); + Parent.template = xml/* html */ ` + <div> + <Orderline line="line" /> + </div> + `; + + const [chair1, chair2] = Parent.env.pos.db.search_product_in_category(0, 'Office Chair'); + // patch chair2 to have tracking + chair2.tracking = 'serial'; + + // 1. Test orderline without lot icon + + let parent = new Parent(chair1); + await parent.mount(testUtils.prepareTarget()); + + let line = parent.el.querySelector('li.orderline'); + assert.ok(line); + assert.notOk(line.querySelector('.line-lot-icon'), 'there should be no lot icon'); + await testUtils.dom.click(line); + assert.verifySteps(['select-line']); + + parent.unmount(); + parent.destroy(); + + // 2. Test orderline with lot icon + + parent = new Parent(chair2); + await parent.mount(testUtils.prepareTarget()); + + line = parent.el.querySelector('li.orderline'); + const lotIcon = line.querySelector('.line-lot-icon'); + assert.ok(line); + assert.ok(lotIcon, 'there should be lot icon'); + await testUtils.dom.click(line); + assert.verifySteps(['select-line']); + await testUtils.dom.click(lotIcon); + assert.verifySteps(['edit-pack-lot-lines']); + + parent.unmount(); + parent.destroy(); + }); + + QUnit.test('OrderWidget', async function (assert) { + assert.expect(8); + + // OrderWidget is dependent on its parent's rerendering + class Parent extends PosComponent { + mounted() { + this.env.pos.on('change:selectedOrder', this.render, this); + } + willUnmount() { + this.env.pos.off('change:selectedOrder', null, this); + } + } + Parent.env = makePosTestEnv(); + Parent.template = xml/* html */ ` + <div> + <OrderWidget /> + </div> + `; + + const [chair1, chair2] = Parent.env.pos.db.search_product_in_category(0, 'Office Chair'); + + let parent = new Parent(); + await parent.mount(testUtils.prepareTarget()); + + // current order is empty + assert.notOk(parent.el.querySelector('.summary')); + assert.ok(parent.el.querySelector('.order-empty')); + + // add line to the current order + const order1 = parent.env.pos.get_order(); + order1.add_product(chair1); + await testUtils.nextTick(); + assert.ok(parent.el.querySelector('.summary')); + assert.notOk(parent.el.querySelector('.order-empty')); + + // selected new order, new order is empty + const order2 = parent.env.pos.add_new_order(); + await testUtils.nextTick(); + assert.notOk(parent.el.querySelector('.summary')); + assert.ok(parent.el.querySelector('.order-empty')); + + // add line to the current order + order2.add_product(chair2); + await testUtils.nextTick(); + assert.ok(parent.el.querySelector('.summary')); + assert.notOk(parent.el.querySelector('.order-empty')); + + parent.env.pos.delete_current_order(); + parent.env.pos.delete_current_order(); + + parent.unmount(); + parent.destroy(); + }); +}); diff --git a/addons/point_of_sale/static/tests/unit/test_popups.js b/addons/point_of_sale/static/tests/unit/test_popups.js new file mode 100644 index 00000000..205d1b24 --- /dev/null +++ b/addons/point_of_sale/static/tests/unit/test_popups.js @@ -0,0 +1,180 @@ +odoo.define('point_of_sale.test_popups', function(require) { + 'use strict'; + + const Registries = require('point_of_sale.Registries'); + const testUtils = require('web.test_utils'); + const PosComponent = require('point_of_sale.PosComponent'); + const PopupControllerMixin = require('point_of_sale.PopupControllerMixin'); + const makePosTestEnv = require('point_of_sale.test_env'); + const { xml } = owl.tags; + + QUnit.module('unit tests for Popups', { + before() { + class Root extends PopupControllerMixin(PosComponent) { + static template = xml` + <div> + <t t-if="popup.isShown" t-component="popup.component" t-props="popupProps" t-key="popup.name" /> + </div> + `; + } + Root.env = makePosTestEnv(); + this.Root = Root; + Registries.Component.freeze(); + }, + }); + + QUnit.test('ConfirmPopup', async function(assert) { + assert.expect(6); + + const root = new this.Root(); + await root.mount(testUtils.prepareTarget()); + + let promResponse, userResponse; + + // Step: show popup and confirm + promResponse = root.showPopup('ConfirmPopup', {}); + await testUtils.nextTick(); + testUtils.dom.click(root.el.querySelector('.confirm')); + await testUtils.nextTick(); + userResponse = await promResponse; + assert.strictEqual(userResponse.confirmed, true); + + // Step: show popup then cancel + promResponse = root.showPopup('ConfirmPopup', {}); + await testUtils.nextTick(); + testUtils.dom.click(root.el.querySelector('.cancel')); + await testUtils.nextTick(); + userResponse = await promResponse; + assert.strictEqual(userResponse.confirmed, false); + + // Step: check texts + promResponse = root.showPopup('ConfirmPopup', { + title: 'Are you sure?', + body: 'Are you having fun?', + confirmText: 'Hell Yeah!', + cancelText: 'Are you kidding me?', + }); + await testUtils.nextTick(); + assert.strictEqual(root.el.querySelector('.title').innerText.trim(), 'Are you sure?'); + assert.strictEqual(root.el.querySelector('.body').innerText.trim(), 'Are you having fun?'); + assert.strictEqual(root.el.querySelector('.confirm').innerText.trim(), 'Hell Yeah!'); + assert.strictEqual( + root.el.querySelector('.cancel').innerText.trim(), + 'Are you kidding me?' + ); + + root.unmount(); + root.destroy(); + }); + + QUnit.test('NumberPopup', async function(assert) { + assert.expect(8); + + const root = new this.Root(); + await root.mount(testUtils.prepareTarget()); + + let promResponse, userResponse; + + // Step: show NumberPopup and confirm with empty buffer + promResponse = root.showPopup('NumberPopup', {}); + await testUtils.nextTick(); + testUtils.dom.triggerEvent(root.el.querySelector('.confirm'), 'mousedown'); + await testUtils.nextTick(); + userResponse = await promResponse; + assert.strictEqual(userResponse.confirmed, true); + assert.strictEqual(userResponse.payload, ""); + + // Step: show NumberPopup and cancel + promResponse = root.showPopup('NumberPopup', {}); + await testUtils.nextTick(); + testUtils.dom.triggerEvent(root.el.querySelector('.cancel'), 'mousedown'); + await testUtils.nextTick(); + userResponse = await promResponse; + assert.strictEqual(userResponse.confirmed, false); + + // Step: show NumberPopup and confirm with filled buffer, new title, new text + promResponse = root.showPopup('NumberPopup', { + title: 'Are you sure?', + confirmText: 'Hell Yeah!', + cancelText: 'Are you kidding me?', + }); + await testUtils.nextTick(); + let nodes = Array.from(root.el.querySelectorAll('button')); + testUtils.dom.triggerEvent(nodes.find(elem => elem.innerHTML === "7"), 'mousedown'); + await testUtils.nextTick(); + testUtils.dom.triggerEvent(nodes.find(elem => elem.innerHTML === "+10"), 'mousedown'); + await testUtils.nextTick(); + assert.strictEqual(root.el.querySelector('.title').innerText.trim(), 'Are you sure?'); + assert.strictEqual(root.el.querySelector('.confirm').innerText.trim(), 'Hell Yeah!'); + assert.strictEqual(root.el.querySelector('.cancel').innerText.trim(), 'Are you kidding me?'); + testUtils.dom.triggerEvent(root.el.querySelector('.confirm'), 'mousedown'); + await testUtils.nextTick(); + userResponse = await promResponse; + assert.strictEqual(userResponse.confirmed, true); + assert.strictEqual(userResponse.payload, "17"); + + root.unmount(); + root.destroy(); + }); + + QUnit.test('EditListPopup', async function(assert) { + assert.expect(7); + + const root = new this.Root(); + await root.mount(testUtils.prepareTarget()); + + let promResponse, userResponse; + + // Step: show popup and confirm + promResponse = root.showPopup('EditListPopup', {}); + await testUtils.nextTick(); + testUtils.dom.click(root.el.querySelector('.confirm')); + await testUtils.nextTick(); + userResponse = await promResponse; + assert.strictEqual(userResponse.confirmed, true); + assert.strictEqual(JSON.stringify(userResponse.payload.newArray), JSON.stringify([])); + + // Step: show popup and cancel + promResponse = root.showPopup('EditListPopup', {}); + await testUtils.nextTick(); + testUtils.dom.click(root.el.querySelector('.cancel')); + await testUtils.nextTick(); + userResponse = await promResponse; + assert.strictEqual(userResponse.confirmed, false); + + // Step: show popup and confirm with a default array + let defaultArray = ["Banana", "Cherry"]; + promResponse = root.showPopup('EditListPopup', { + title: "Fruits", + isSingleItem: false, + array: defaultArray, + }); + await testUtils.nextTick(); + testUtils.dom.click(root.el.querySelector('.confirm')); + await testUtils.nextTick(); + userResponse = await promResponse; + + assert.strictEqual(userResponse.confirmed, true); + let i = 0; + defaultArray = defaultArray.map((item) => Object.assign({}, { _id: i++ }, { 'text': item})); + assert.strictEqual(JSON.stringify(userResponse.payload.newArray), JSON.stringify(defaultArray)); + + // Step: show popup and confirm with a new array + promResponse = root.showPopup('EditListPopup', { + title: "Fruits", + isSingleItem: false, + array: ["Banana", "Cherry"], + }); + await testUtils.nextTick(); + testUtils.dom.click(root.el.querySelector('.fa-trash-o')); + await testUtils.nextTick(); + testUtils.dom.click(root.el.querySelector('.confirm')); + await testUtils.nextTick(); + userResponse = await promResponse; + assert.strictEqual(userResponse.confirmed, true); + assert.strictEqual(JSON.stringify(userResponse.payload.newArray), JSON.stringify([{ _id: 1, text: "Cherry"}])); + + root.unmount(); + root.destroy(); + }); +}); |
