diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 0d987e0da0..9d53aa23bf 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -18,6 +18,9 @@ def _jsonify(self, recordset, parser, multi=False, **kw): return res[0] if res else None return res + def _simple_record_parser(self): + return ["id", "name"] + def partner(self, record, **kw): return self._jsonify(record, self._partner_parser, **kw) @@ -26,7 +29,7 @@ def partners(self, record, **kw): @property def _partner_parser(self): - return ["id", "name"] + return self._simple_record_parser() def location(self, record, **kw): return self._jsonify( @@ -95,12 +98,12 @@ def _package_packaging_parser(self): def packaging(self, record, **kw): return self._jsonify(record, self._packaging_parser, **kw) - def packagings(self, record, **kw): + def packaging_list(self, record, **kw): return self.packaging(record, multi=True) @property def _packaging_parser(self): - return ["id", "name"] + return self._simple_record_parser() + ["qty"] def lot(self, record, **kw): return self._jsonify(record, self._lot_parser, **kw) @@ -110,7 +113,7 @@ def lots(self, record, **kw): @property def _lot_parser(self): - return ["id", "name", "ref"] + return self._simple_record_parser() + ["ref"] def move_line(self, record, **kw): record = record.with_context(location=record.location_id.id) @@ -153,7 +156,14 @@ def products(self, record, **kw): @property def _product_parser(self): - return ["id", "name", "display_name", "default_code", "barcode"] + return [ + "id", + "name", + "display_name", + "default_code", + "barcode", + ("packaging_ids:packaging", self._packaging_parser), + ] def picking_batch(self, record, with_pickings=False, **kw): parser = self._picking_batch_parser diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index f427115d00..2c63580da0 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -122,7 +122,7 @@ def _response_for_change_packaging(self, picking, package, packaging_list): "package": self.data_struct.package( package, picking=picking, with_packaging=True ), - "packagings": self.data_struct.packagings(packaging_list.sorted()), + "packaging": self.data_struct.packaging_list(packaging_list.sorted()), }, ) @@ -1229,7 +1229,7 @@ def _schema_select_packaging(self): "type": "dict", "schema": self.schemas.package(with_packaging=True), }, - "packagings": { + "packaging": { "type": "list", "schema": {"type": "dict", "schema": self.schemas.packaging()}, }, diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index 6e97fa3525..ebe1523a29 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -112,6 +112,7 @@ def product(self): "display_name": {"type": "string", "nullable": False, "required": True}, "default_code": {"type": "string", "nullable": False, "required": True}, "barcode": {"type": "string", "nullable": True, "required": False}, + "packaging": self._schema_list_of(self.packaging()), } def package(self, with_packaging=False): @@ -122,12 +123,7 @@ def package(self, with_packaging=False): "move_line_count": {"required": False, "nullable": True, "type": "integer"}, } if with_packaging: - schema["packaging"] = { - "type": "dict", - "required": True, - "nullable": True, - "schema": self.packaging(), - } + schema["packaging"] = self._schema_dict_of(self.packaging()) return schema def lot(self): @@ -148,6 +144,7 @@ def packaging(self): return { "id": {"required": True, "type": "integer"}, "name": {"type": "string", "nullable": False, "required": True}, + "qty": {"type": "float", "required": True}, } def picking_batch(self, with_pickings=False): diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index d0612fe1ee..395de60b80 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -64,6 +64,14 @@ def _expected_product(self, record, **kw): "display_name": record.display_name, "default_code": record.default_code, "barcode": record.barcode, + "packaging": [self._expected_packaging(x) for x in record.packaging_ids], + } + + def _expected_packaging(self, record, **kw): + return { + "id": record.id, + "name": record.name, + "qty": record.qty, } @@ -71,8 +79,7 @@ class ActionsDataCase(ActionsDataCaseBase): def test_data_packaging(self): data = self.data.packaging(self.packaging) self.assert_schema(self.schema.packaging(), data) - expected = {"id": self.packaging.id, "name": self.packaging.name} - self.assertDictEqual(data, expected) + self.assertDictEqual(data, self._expected_packaging(self.packaging)) def test_data_location(self): location = self.stock_location @@ -107,8 +114,8 @@ def test_data_package(self): "id": package.id, "name": package.name, "move_line_count": 1, - "packaging": self.data.packaging(package.product_packaging_id), - "weight": 0, + "packaging": self._expected_packaging(package.product_packaging_id), + "weight": 0.0, } self.assertDictEqual(data, expected) diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index 6f3c85d62d..d715d4aefa 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -97,8 +97,7 @@ def test_data_location(self): def test_data_packaging(self): data = self.data_detail.packaging(self.packaging) self.assert_schema(self.schema_detail.packaging(), data) - expected = {"id": self.packaging.id, "name": self.packaging.name} - self.assertDictEqual(data, expected) + self.assertDictEqual(data, self._expected_packaging(self.packaging)) def test_data_lot(self): lot = self.env["stock.production.lot"].create( diff --git a/shopfloor_mobile/static/wms/index.html b/shopfloor_mobile/static/wms/index.html index f3782f4217..3b69e035ee 100644 --- a/shopfloor_mobile/static/wms/index.html +++ b/shopfloor_mobile/static/wms/index.html @@ -46,6 +46,7 @@ + diff --git a/shopfloor_mobile/static/wms/src/components/batch_picking_detail.js b/shopfloor_mobile/static/wms/src/components/batch_picking_detail.js index 86cd6c8269..e988cd5491 100644 --- a/shopfloor_mobile/static/wms/src/components/batch_picking_detail.js +++ b/shopfloor_mobile/static/wms/src/components/batch_picking_detail.js @@ -1,6 +1,6 @@ /* eslint-disable strict */ Vue.component("batch-picking-detail", { - props: ["info"], + props: ["record"], methods: { detail_fields() { return [ @@ -17,36 +17,38 @@ Vue.component("batch-picking-detail", { }, }, template: ` -
-
+
- +
- + - Pickings +
+ + + Start + + + + + Cancel + + +
+
+ +
+ Pickings list - -
-
- - - Start - - - - - Cancel - -
+
`, }); diff --git a/shopfloor_mobile/static/wms/src/components/batch_picking_line_detail.js b/shopfloor_mobile/static/wms/src/components/batch_picking_line_detail.js index 1ba7f40c54..a4bb43ee82 100644 --- a/shopfloor_mobile/static/wms/src/components/batch_picking_line_detail.js +++ b/shopfloor_mobile/static/wms/src/components/batch_picking_line_detail.js @@ -1,12 +1,11 @@ export var batch_picking_line = Vue.component("batch-picking-line-detail", { props: { line: Object, - // TODO: not sure this is still needed - showFullInfo: { + articleScanned: { type: Boolean, - default: true, + default: false, }, - articleScanned: { + showQtyPicker: { type: Boolean, default: false, }, @@ -22,18 +21,6 @@ export var batch_picking_line = Vue.component("batch-picking-line-detail", { }, }, methods: { - detail_fields(key) { - const mapping = { - location_src: [], - product: [ - {path: "package_src.name", label: "Pack"}, - {path: "quantity", label: "Qty"}, - {path: "product.qty_available", label: "Qty on hand"}, - ], - location_dest: [], - }; - return mapping[key]; - }, full_detail_fields() { return [ {path: "batch.name", label: "Batch"}, @@ -50,37 +37,41 @@ export var batch_picking_line = Vue.component("batch-picking-line-detail", { + + + + - +
`, @@ -120,7 +110,7 @@ export var batch_picking_line_actions = Vue.component("batch-picking-line-action
- Action + Action
@@ -129,28 +119,28 @@ export var batch_picking_line_actions = Vue.component("batch-picking-line-action
- Go to destination - full bin(s) + Go to destination - full bin(s) - Skip line + Skip line - Declare stock out + Declare stock out - Change lot or pack + Change lot or pack - Back + Back
@@ -171,11 +161,11 @@ export var batch_picking_line_stock_out = Vue.component( }, template: `
- +
- Confirm stock = 0 + Confirm stock = 0 diff --git a/shopfloor_mobile/static/wms/src/components/detail/detail_card.js b/shopfloor_mobile/static/wms/src/components/detail/detail_card.js index fefac885b7..35ca100eb7 100644 --- a/shopfloor_mobile/static/wms/src/components/detail/detail_card.js +++ b/shopfloor_mobile/static/wms/src/components/detail/detail_card.js @@ -9,8 +9,10 @@ Vue.component("item-detail-card", { - - mdi-information + + @@ -22,11 +24,21 @@ Vue.component("item-detail-card", {
- {{ field.label }}: {{ render_field_value(record, field) }} + {{ field.label }}: + + + {{ render_field_value(record, field) }} + - mdi-information +
diff --git a/shopfloor_mobile/static/wms/src/components/detail/detail_lot.js b/shopfloor_mobile/static/wms/src/components/detail/detail_lot.js index aa87ba63a5..2fed5341e8 100644 --- a/shopfloor_mobile/static/wms/src/components/detail/detail_lot.js +++ b/shopfloor_mobile/static/wms/src/components/detail/detail_lot.js @@ -46,10 +46,10 @@ Vue.component("detail-lot", { :record="supp" :options="{no_title: true, fields: supplier_detail_fields()}" />
- // TODO packagings -
diff --git a/shopfloor_mobile/static/wms/src/components/detail/detail_mixin.js b/shopfloor_mobile/static/wms/src/components/detail/detail_mixin.js index fdea99e5a6..906e4948a1 100644 --- a/shopfloor_mobile/static/wms/src/components/detail/detail_mixin.js +++ b/shopfloor_mobile/static/wms/src/components/detail/detail_mixin.js @@ -1,5 +1,3 @@ -import {utils} from "../../utils.js"; - export var ItemDetailMixin = { props: { record: Object, @@ -41,7 +39,7 @@ export var ItemDetailMixin = { return []; }, _render_date(record, field) { - return this.utils.format_date_display(_.result(record, field.path)); + return this.utils.misc.format_date_display(_.result(record, field.path)); }, has_detail_action(record, field) { return _.result(record, field.action_val_path); @@ -62,13 +60,12 @@ export var ItemDetailMixin = { params: {identifier: identifier}, query: {displayOnly: 1}, }); + } else { + console.error("Action handler found not value for", field); } }, }, computed: { - utils: function() { - return utils; - }, wrapper_klass: function() { return [ "detail", diff --git a/shopfloor_mobile/static/wms/src/components/detail/detail_operation.js b/shopfloor_mobile/static/wms/src/components/detail/detail_operation.js index 2269d7f8d0..6020139dea 100644 --- a/shopfloor_mobile/static/wms/src/components/detail/detail_operation.js +++ b/shopfloor_mobile/static/wms/src/components/detail/detail_operation.js @@ -33,7 +33,7 @@ Vue.component("detail-operation", {
diff --git a/shopfloor_mobile/static/wms/src/components/detail/detail_package.js b/shopfloor_mobile/static/wms/src/components/detail/detail_package.js index 2088ede555..e176fb28b6 100644 --- a/shopfloor_mobile/static/wms/src/components/detail/detail_package.js +++ b/shopfloor_mobile/static/wms/src/components/detail/detail_package.js @@ -39,7 +39,7 @@ Vue.component("detail-package", {
diff --git a/shopfloor_mobile/static/wms/src/components/detail/detail_picking.js b/shopfloor_mobile/static/wms/src/components/detail/detail_picking.js index d1bd52fe64..d6c3b5fe0e 100644 --- a/shopfloor_mobile/static/wms/src/components/detail/detail_picking.js +++ b/shopfloor_mobile/static/wms/src/components/detail/detail_picking.js @@ -1,41 +1,31 @@ export var PickingDetailMixin = { props: { // TODO: rename to `record` - picking: Object, + record: Object, options: Object, - clickable: { - type: Boolean, - // TODO: this must be false when showing picking screen (eg: scan anything) - default: true, - }, - }, - methods: { - on_title_action() { - // TODO: we should probably delegate this to a global event - this.$router.push({ - name: "scananything", - params: {identifier: this.picking.name}, - query: {displayOnly: 1}, - }); - }, + // clickable: { + // type: Boolean, + // // TODO: this must be false when showing record screen (eg: scan anything) + // default: true, + // }, }, computed: { opts() { const opts = _.defaults({}, this.$props.options, { - on_title_action: this.$props.clickable ? this.on_title_action : null, + title_action_field: {path: "name", action_val_path: "name"}, }); return opts; }, }, template: ` - + diff --git a/shopfloor_mobile/static/wms/src/components/detail/detail_product.js b/shopfloor_mobile/static/wms/src/components/detail/detail_product.js index 40921f9611..17c39ae649 100644 --- a/shopfloor_mobile/static/wms/src/components/detail/detail_product.js +++ b/shopfloor_mobile/static/wms/src/components/detail/detail_product.js @@ -32,9 +32,9 @@ Vue.component("detail-product", {
-
diff --git a/shopfloor_mobile/static/wms/src/components/detail/detail_transfer.js b/shopfloor_mobile/static/wms/src/components/detail/detail_transfer.js index 4e67f68a61..30b576e5ba 100644 --- a/shopfloor_mobile/static/wms/src/components/detail/detail_transfer.js +++ b/shopfloor_mobile/static/wms/src/components/detail/detail_transfer.js @@ -1,5 +1,4 @@ import {ItemDetailMixin} from "./detail_mixin.js"; -import {utils} from "../../utils.js"; Vue.component("detail-transfer", { mixins: [ItemDetailMixin], @@ -44,14 +43,14 @@ Vue.component("detail-transfer", { ]; }, grouped_lines() { - return this.utils.group_lines_by_locations(this.record.move_lines); + return this.utils.misc.group_lines_by_locations(this.record.move_lines); }, }, template: `
@@ -63,7 +62,7 @@ Vue.component("detail-transfer", {
diff --git a/shopfloor_mobile/static/wms/src/components/input-number-spinner.js b/shopfloor_mobile/static/wms/src/components/input-number-spinner.js index 3eb8e81e70..50ae6475ce 100644 --- a/shopfloor_mobile/static/wms/src/components/input-number-spinner.js +++ b/shopfloor_mobile/static/wms/src/components/input-number-spinner.js @@ -1,8 +1,8 @@ export var NumberSpinner = Vue.component("input-number-spinner", { template: ` -
-
+
+
+
@@ -11,7 +11,7 @@ export var NumberSpinner = Vue.component("input-number-spinner", { {{ original_value }}
-
+
-
@@ -41,14 +41,18 @@ export var NumberSpinner = Vue.component("input-number-spinner", { type: Boolean, default: true, }, - spinner_position: { + mode: { type: String, - default: "right", + default: "text-only", }, show_init_value: { type: Boolean, default: true, }, + select_value_on_load: { + type: Boolean, + default: true, + }, }, data: function() { return { @@ -81,4 +85,11 @@ export var NumberSpinner = Vue.component("input-number-spinner", { this.original_value = parseInt(this.init_value); this.value = parseInt(this.init_value); }, + mounted: function() { + if (this.$props.select_value_on_load) { + const input = $("input", this.$el).get(0); + input.focus(); + input.select(); + } + }, }); diff --git a/shopfloor_mobile/static/wms/src/components/list.js b/shopfloor_mobile/static/wms/src/components/list.js index c2808d8ae5..a74121c7ba 100644 --- a/shopfloor_mobile/static/wms/src/components/list.js +++ b/shopfloor_mobile/static/wms/src/components/list.js @@ -135,7 +135,7 @@ Vue.component("list-item", { - mdi-information +
diff --git a/shopfloor_mobile/static/wms/src/components/manual_select.js b/shopfloor_mobile/static/wms/src/components/manual_select.js index 077dc68648..61d9bbb9f8 100644 --- a/shopfloor_mobile/static/wms/src/components/manual_select.js +++ b/shopfloor_mobile/static/wms/src/components/manual_select.js @@ -114,7 +114,7 @@ Vue.component("manual-select", { this._updateValue(val, elem.checked); $(elem) .closest(".list-item-wrapper") - .toggleClass("active", elem.checked); + .toggleClass(this.selected_color_klass(), elem.checked); if (!this.opts.showActions) { this._emitSelected(this._getSelected()); } @@ -135,6 +135,13 @@ Vue.component("manual-select", { ? this.selected.includes(rec.id) : this.selected === rec.id; }, + selected_color_klass(modifier) { + return ( + "active " + + this.utils.colors.color_for("item_selected") + + (modifier ? " " + modifier : "") + ); + }, }, computed: { has_records() { @@ -156,6 +163,7 @@ Vue.component("manual-select", { list_item_actions: [], list_item_extra_component: "", selected_event: "select", + group_color: "", }); return opts; }, @@ -201,10 +209,10 @@ Vue.component("manual-select", { }, template: `
- + {{ group.title }} -
+
- + Get work - + Manual selection @@ -90,7 +90,7 @@ Vue.component("state-display-info", { }, template: `
- +
{{ info.title }}
@@ -178,7 +178,7 @@ Vue.component("picking-list-item-progress-bar", { mixins: [ItemDetailMixin], computed: { value() { - return this.utils.picking_completeness(this.record); + return this.utils.misc.picking_completeness(this.record); }, }, template: ` @@ -198,3 +198,30 @@ Vue.component("todo", {
`, }); + +// TODO: use color registry for the icon color +Vue.component("btn-info-icon", { + props: { + color: { + type: String, + }, + }, + template: ` + mdi-information +`, +}); + +// TODO: move to separated file +Vue.component("btn-action", { + props: { + action: { + type: String, + default: "", + }, + }, + template: ` + + + +`, +}); diff --git a/shopfloor_mobile/static/wms/src/components/packaging-qty-picker.js b/shopfloor_mobile/static/wms/src/components/packaging-qty-picker.js new file mode 100644 index 0000000000..6195cb92e3 --- /dev/null +++ b/shopfloor_mobile/static/wms/src/components/packaging-qty-picker.js @@ -0,0 +1,222 @@ +export var PackagingQtyPickerMixin = { + props: { + options: Object, + }, + data: function() { + return { + value: 0, + original_value: 0, + orig_qty_by_pkg: {}, + qty_by_pkg: {}, + }; + }, + methods: { + on_change_pkg_qty: function(event) { + const input = event.target; + let new_qty = parseInt(input.value, 10); + const data = $(input).data(); + const origvalue = data.origvalue || 0; + // Check max qty reached + const future_qty = this.value + data.pkg.qty * (new_qty - origvalue); + if (new_qty && future_qty > this.original_value) { + // restore qty just in case we can get here + new_qty = origvalue; + event.preventDefault(); + // Make it red and shake it + $(input) + .closest(".inner-wrapper") + .addClass("error shake-it") + .delay(800) + .queue(function() { + // End animation + $(this) + .removeClass("error shake-it", 2000, "easeInOutQuad") + .dequeue(); + // Restore value + $(input).val(new_qty); + }); + } + // Trigger update + this.$set(this.qty_by_pkg, data.pkg.id, new_qty); + // Set new orig value + $(input).data("origvalue", new_qty); + }, + packaging_by_id: function(id) { + return _.find(this.opts.available_packaging, ["id", parseInt(id, 10)]); + }, + /** + * + Calculate quantity by packaging. + + Limitation: fractional quantities are lost. + + :prod_qty: + :min_unit: minimal unit of measure as a tuple (qty, name). + Default: to UoM unit. + :returns: list of tuple in the form [(qty_per_package, package_name)] + + * @param {*} prod_qty total qty to satisfy. + * @param {*} min_unit minimal unit of measure as a tuple (qty, name). + Default: to UoM unit. + */ + product_qty_by_packaging: function() { + return this._product_qty_by_packaging(this.sorted_packaging, this.value); + }, + /** + * Produce a list of tuple of packaging qty and packaging name. + * TODO: refactor to handle fractional quantities (eg: 0.5 Kg) + * + * @param {*} pkg_by_qty packaging records sorted by major qty + * @param {*} qty total qty to satisfy + */ + _product_qty_by_packaging: function(pkg_by_qty, qty) { + const self = this; + let res = {}; + // const min_unit = _.last(pkg_by_qty); + pkg_by_qty.forEach(function(pkg) { + let qty_per_pkg = 0; + [qty_per_pkg, qty] = self._qty_by_pkg(pkg.qty, qty); + if (qty_per_pkg) res[pkg.id] = qty_per_pkg; + if (!qty) return; + }); + return res; + }, + /** + * Calculate qty needed for given package qty. + * + * @param {*} pkg_by_qty + * @param {*} qty + */ + _qty_by_pkg: function(pkg_qty, qty) { + const precision = 3; // TODO: get it from product UoM + let qty_per_pkg = 0; + // TODO: anything better to do like `float_compare`? + while (_.round(qty - pkg_qty, precision) >= 0.0) { + qty -= pkg_qty; + qty_per_pkg += 1; + } + return [qty_per_pkg, qty]; + }, + _compute_qty: function() { + const self = this; + let value = 0; + _.forEach(this.qty_by_pkg, function(qty, id) { + value += self.packaging_by_id(id).qty * qty; + }); + return value; + }, + compute_qty: function(newVal, oldVal) { + this.value = this._compute_qty(); + }, + }, + watch: { + value: { + handler: function(newVal, oldVal) { + this.$root.trigger("qty_edit", this.value); + }, + }, + }, + created: function() { + this.original_value = parseInt(this.opts.init_value, 10); + this.value = parseInt(this.opts.init_value, 10); + }, + mounted: function() { + const self = this; + this.$watch( + "qty_by_pkg", + function() { + self.compute_qty(); + }, + {deep: true} + ); + this.qty_by_pkg = this.product_qty_by_packaging(); + this.orig_qty_by_pkg = this.qty_by_pkg; + // hooking via `v-on:change` we don't get the full event but only the qty :/ + // And forget about using v-text-field because it loses the full event object + $(".pkg-value", this.$el).change(this.on_change_pkg_qty); + $(".pkg-value", this.$el).on("focus click", function() { + $(this).select(); + }); + }, + computed: { + opts() { + const opts = _.defaults({}, this.$props.options, { + input_type: "text", + init_value: 0, + mode: "", + available_packaging: [], + }); + return opts; + }, + /** + * + */ + sorted_packaging: function() { + return _.reverse( + _.sortBy(this.opts.available_packaging, _.property("qty")) + ); + }, + /** + * Collect qty of contained packaging inside bigger packaging. + * Eg: "1 Pallet" contains "4 Big boxes". + */ + contained_packaging: function() { + const self = this; + let res = {}; + const packaging = this.sorted_packaging; + _.forEach(packaging, function(pkg, i) { + if (packaging[i + 1]) { + const next_pkg = packaging[i + 1]; + res[pkg.id] = { + pkg: next_pkg, + qty: self._qty_by_pkg(next_pkg.qty, pkg.qty)[0], + }; + } + }); + return res; + }, + }, +}; + +export var PackagingQtyPicker = Vue.component("packaging-qty-picker", { + mixins: [PackagingQtyPickerMixin], + template: ` +
+ + + + + + + + + + +
+
+ +
+
{{ pkg.name }}
+
({{ contained_packaging[pkg.id].qty }} {{ contained_packaging[pkg.id].pkg.name }})
+
+
+
+
+`, +}); + +export var PackagingQtyPickerDisplay = Vue.component("packaging-qty-picker-display", { + mixins: [PackagingQtyPickerMixin], + template: ` +
+ + + , + +
+`, +}); diff --git a/shopfloor_mobile/static/wms/src/components/scenario_picking_detail/mixins.js b/shopfloor_mobile/static/wms/src/components/scenario_picking_detail/mixins.js index 7e83478417..5f82241c55 100644 --- a/shopfloor_mobile/static/wms/src/components/scenario_picking_detail/mixins.js +++ b/shopfloor_mobile/static/wms/src/components/scenario_picking_detail/mixins.js @@ -50,6 +50,10 @@ export var ListActionsConsumerMixin = { export var PickingDetailSelectMixin = { mixins: [PickingDetailMixin, ListActionsConsumerMixin], props: { + show_picking_info: { + type: Boolean, + default: false, + }, select_records: Array, select_records_grouped: Array, select_options: Object, @@ -64,12 +68,12 @@ export var PickingDetailSelectMixin = { }, }, template: ` -
+
- + @@ -80,6 +84,10 @@ export var PickingDetailSelectMixin = { export var PickingDetailListMixin = { mixins: [PickingDetailMixin, ListActionsConsumerMixin], props: { + show_picking_info: { + type: Boolean, + default: false, + }, records: Array, records_grouped: Array, list_options: Object, @@ -93,12 +101,12 @@ export var PickingDetailListMixin = { }, }, template: ` -
+
- + diff --git a/shopfloor_mobile/static/wms/src/components/scenario_picking_detail/picking_select.js b/shopfloor_mobile/static/wms/src/components/scenario_picking_detail/picking_select.js index d5566e684b..d9e0bced6a 100644 --- a/shopfloor_mobile/static/wms/src/components/scenario_picking_detail/picking_select.js +++ b/shopfloor_mobile/static/wms/src/components/scenario_picking_detail/picking_select.js @@ -1,6 +1,7 @@ /* eslint-disable strict */ /* eslint-disable no-implicit-globals */ import {PickingDetailSelectMixin} from "./mixins.js"; +import {ItemDetailMixin} from "../detail/detail_mixin.js"; Vue.component("detail-picking-select", { mixins: [PickingDetailSelectMixin], @@ -26,9 +27,8 @@ Vue.component("detail-picking-select", { }); Vue.component("picking-select-line-content", { + mixins: [ItemDetailMixin], props: { - record: Object, - options: Object, index: Number, count: Number, }, @@ -38,13 +38,19 @@ Vue.component("picking-select-line-content", {
{{ index + 1 }} / {{ count }}
- {{ record.package_dest.name }} + + + {{ record.package_dest.name }} +
{{ index + 1 }} / {{ count }}
- {{ record.product.display_name }} + + + {{ record.product.display_name }} +
Lot: {{ record.lot.name }}
diff --git a/shopfloor_mobile/static/wms/src/components/scenario_picking_detail/picking_summary.js b/shopfloor_mobile/static/wms/src/components/scenario_picking_detail/picking_summary.js index 49a3aa15e2..414683de83 100644 --- a/shopfloor_mobile/static/wms/src/components/scenario_picking_detail/picking_summary.js +++ b/shopfloor_mobile/static/wms/src/components/scenario_picking_detail/picking_summary.js @@ -88,7 +88,7 @@ Vue.component("picking-summary-content", { {{ record.title }} - {{ pkg_type.title }} + {{ pkg_type.title }} - {{ info.title }} + + + {{ info.title }} + + + + + + diff --git a/shopfloor_mobile/static/wms/src/css/main.css b/shopfloor_mobile/static/wms/src/css/main.css index 55b4c4ddc6..dac741d9b5 100644 --- a/shopfloor_mobile/static/wms/src/css/main.css +++ b/shopfloor_mobile/static/wms/src/css/main.css @@ -135,20 +135,20 @@ ul.packaging span:first-child { text-align: center; margin: 0.5rem 0; } -.number-spinner.spinner-position-right .spinner-btn { +.number-spinner.spinner-right .spinner-btn { float: right; margin: 0.5rem 0; } -.number-spinner.spinner-position-right .input-wrapper { +.number-spinner.spinner-right .input-wrapper { float: left; width: 50%; margin: 0.5rem 0; } -.number-spinner.spinner-position-left .spinner-btn { +.number-spinner.spinner-left .spinner-btn { float: left; margin: 0.5rem 0; } -.number-spinner.spinner-position-left .input-wrapper { +.number-spinner.spinner-left .input-wrapper { float: right; width: 50%; margin: 0.5rem 0; @@ -164,6 +164,13 @@ ul.packaging span:first-child { font-size: 1.2rem; color: #999; } +.number-spinner.spinner-text-only input { + max-height: auto; + font-size: 180%; +} +.number-spinner.spinner-text-only .init-value { + font-size: 150%; +} .manual-select.with-bottom-actions { height: 100%; @@ -186,9 +193,11 @@ ul.packaging span:first-child { height: 3.5rem; width: 100%; } - +.manual-select[class*="with-group_color"] .v-card { + border: 5px solid; +} .manual-select.with-groups .v-card__title { - padding: 0.5rem 1rem 1rem 1rem; + padding: 0.5rem 1rem 0.5rem 1rem; } .manual-select.with-groups .v-card.select-group:not(:last-child) { margin-bottom: 1.2rem; @@ -233,9 +242,6 @@ ul.packaging span:first-child { padding: 0; } -.list-item-wrapper.active { - background-color: lightgreen; -} .list-item-wrapper .action-edit { display: none; } @@ -244,7 +250,7 @@ ul.packaging span:first-child { display: block; } -.my-checkbox { +.sf-checkbox { -webkit-appearance: none; width: 30px; height: 30px; @@ -253,10 +259,6 @@ ul.packaging span:first-child { border: 2px solid #555; } -input.my-checkbox[type="checkbox"]:checked { - background: green; -} - .summary-content { max-width: 100%; } @@ -382,3 +384,110 @@ input.my-checkbox[type="checkbox"]:checked { .todo .message { background-color: white; } + +.clickable { + cursor: pointer; +} + +.packaging-qty-picker * { + text-align: center; +} +.packaging-qty-picker .unit-value input { + font-size: 130%; +} +.packaging-qty-picker .unit-value .col { + padding-bottom: 0; +} +.packaging-qty-picker .packaging-value .col { + padding-top: 0; + padding-bottom: 5px; +} +.packaging-qty-picker .packaging-value .col:not(:last-child) { + padding-right: 0; +} +.packaging-qty-picker .pkg-qty { + font-size: 80%; +} +.packaging-qty-picker input.pkg-value { + max-width: 60px; + padding: 0.5rem 0.5rem 0; + font-size: 130%; + border-bottom: 1px solid; +} +.packaging-qty-picker .inner-wrapper { + padding-bottom: 8px; +} +.packaging-qty-picker .input-wrapper { + margin-bottom: 8px; +} + +.shake-it { + -webkit-animation: kf_shake 0.4s 1 linear; + -moz-animation: kf_shake 0.4s 1 linear; + -o-animation: kf_shake 0.4s 1 linear; +} +@-webkit-keyframes kf_shake { + 0% { + -webkit-transform: translate(10px); + } + 20% { + -webkit-transform: translate(-10px); + } + 40% { + -webkit-transform: translate(15px); + } + 60% { + -webkit-transform: translate(-15px); + } + 80% { + -webkit-transform: translate(8px); + } + 100% { + -webkit-transform: translate(0px); + } +} +@-moz-keyframes kf_shake { + 0% { + -moz-transform: translate(10px); + } + 20% { + -moz-transform: translate(-10px); + } + 40% { + -moz-transform: translate(15px); + } + 60% { + -moz-transform: translate(-15px); + } + 80% { + -moz-transform: translate(8px); + } + 100% { + -moz-transform: translate(0px); + } +} +@-o-keyframes kf_shake { + 0% { + -o-transform: translate(10px); + } + 20% { + -o-transform: translate(-10px); + } + 40% { + -o-transform: translate(15px); + } + 60% { + -o-transform: translate(-15px); + } + 80% { + -o-transform: translate(8px); + } + 100% { + -o-origin-transform: translate(0px); + } +} + +.v-card .v-card__title { + font-size: 1.1rem; + line-height: 1.1rem; +} diff --git a/shopfloor_mobile/static/wms/src/demo/demo.checkout.js b/shopfloor_mobile/static/wms/src/demo/demo.checkout.js index 4d745503c1..ea388e366b 100644 --- a/shopfloor_mobile/static/wms/src/demo/demo.checkout.js +++ b/shopfloor_mobile/static/wms/src/demo/demo.checkout.js @@ -187,7 +187,7 @@ const DEMO_CHECKOUT = { change_packaging: { picking: select_pack_picking, package: demotools.makePack(), - packagings: _.sampleSize( + packaging: _.sampleSize( [ demotools.makePackaging(), demotools.makePackaging(), diff --git a/shopfloor_mobile/static/wms/src/demo/demo.core.js b/shopfloor_mobile/static/wms/src/demo/demo.core.js index ae3c1fe06b..c91a45cba9 100644 --- a/shopfloor_mobile/static/wms/src/demo/demo.core.js +++ b/shopfloor_mobile/static/wms/src/demo/demo.core.js @@ -20,7 +20,7 @@ export class DemoTools { this.makeLocation({}, {name_prefix: "LOC-DST"}), this.makeLocation({}, {name_prefix: "LOC-DST"}), ]; - this.packagings = [ + this.packaging = [ this.makePackaging({ name: "Little Box", qty: 10, @@ -226,7 +226,6 @@ export class DemoTools { 0 ); } - makeProduct(defaults = {}, options = {}) { _.defaults(options, { name_prefix: "Prod " + this.getRandomInt(), @@ -237,6 +236,7 @@ export class DemoTools { default_code: default_code, barcode: default_code, qty_available: this.getRandomInt(200), + packaging: this.randomSetFromArray(this.packaging, 4), }); const rec = this.makeSimpleRecord(defaults, options); _.extend(rec, { @@ -252,7 +252,6 @@ export class DemoTools { qty_available: this.getRandomInt(), qty_reserved: this.getRandomInt(), expiry_date: "2020-12-01", - packagings: this.randomSetFromArray(this.packagings, 4), // TODO: load some random images image: null, manufacturer: this.makeSimpleRecord({ diff --git a/shopfloor_mobile/static/wms/src/main.js b/shopfloor_mobile/static/wms/src/main.js index cf1c55f869..38516cf2b7 100644 --- a/shopfloor_mobile/static/wms/src/main.js +++ b/shopfloor_mobile/static/wms/src/main.js @@ -20,19 +20,6 @@ Vue.use(Vuetify); var EventHub = new Vue(); -// TODO: move to color registry -const vuetify_themes = { - light: { - primary: "#491966", - secondary: "#424242", - accent: "#82B1FF", - error: "#FF5252", - info: "#2196F3", - success: "#4CAF50", - warning: "#FFC107", - }, -}; - Vue.mixin(GlobalMixin); const app = new Vue({ @@ -40,7 +27,7 @@ const app = new Vue({ router: router, vuetify: new Vuetify({ theme: { - themes: vuetify_themes, + themes: color_registry.get_themes(), }, }), data: function() { @@ -56,7 +43,6 @@ const app = new Vue({ appconfig: null, authenticated: false, registry: process_registry, - colors: color_registry, }; }, created: function() { diff --git a/shopfloor_mobile/static/wms/src/mixin.js b/shopfloor_mobile/static/wms/src/mixin.js index 02cffa9227..30a74cb316 100644 --- a/shopfloor_mobile/static/wms/src/mixin.js +++ b/shopfloor_mobile/static/wms/src/mixin.js @@ -1,3 +1,5 @@ +import {utils} from "./utils.js"; +import {color_registry} from "./services/color_registry.js"; export var GlobalMixin = { methods: { /* @@ -12,4 +14,15 @@ export var GlobalMixin = { return bits.join("-"); }, }, + computed: { + /* + Provide utils to all components + */ + utils: function() { + return { + misc: utils, + colors: color_registry, + }; + }, + }, }; diff --git a/shopfloor_mobile/static/wms/src/scenario/checkout.js b/shopfloor_mobile/static/wms/src/scenario/checkout.js index 1d7266ff9b..b3e3710e14 100644 --- a/shopfloor_mobile/static/wms/src/scenario/checkout.js +++ b/shopfloor_mobile/static/wms/src/scenario/checkout.js @@ -1,6 +1,5 @@ import {ScenarioBaseMixin} from "./mixins.js"; import {process_registry} from "../services/process_registry.js"; -import {utils} from "../utils.js"; import {demotools} from "../demo/demo.core.js"; // FIXME: dev only import {} from "../demo/demo.checkout.js"; // FIXME: dev only @@ -30,7 +29,7 @@ export var Checkout = Vue.component("checkout", {
- Manual selection + Manual selection
@@ -40,7 +39,7 @@ export var Checkout = Vue.component("checkout", { :records="state.data.pickings" :list_item_fields="manual_select_picking_fields" :options="{list_item_options: {bold_title: true}}" - :key="current_state_key + '-manual-select'" + :key="make_state_component_key(['manual-select'])" />
@@ -52,15 +51,16 @@ export var Checkout = Vue.component("checkout", {
- Summary + Summary
@@ -68,66 +68,44 @@ export var Checkout = Vue.component("checkout", {
- Existing pack + >Existing pack - New pack + >New pack - Process w/o pack - - -
-
- -
- -
- - - Continue checkout - - - - - Mark as done + >Process w/o pack
@@ -138,18 +116,24 @@ export var Checkout = Vue.component("checkout", {
- -
- -
+ + + + + +
- Confirm + Confirm @@ -161,10 +145,10 @@ export var Checkout = Vue.component("checkout", {
@@ -174,17 +158,39 @@ export var Checkout = Vue.component("checkout", {
+
+ +
+ + + Continue checkout + + + + + Mark as done + + +
+
- +
- Confirm + Confirm - Back + Back
@@ -192,9 +198,6 @@ export var Checkout = Vue.component("checkout", { `, computed: { - utils: function() { - return utils; - }, // TODO: move these to methods manual_select_picking_fields: function() { return [ @@ -221,10 +224,34 @@ export var Checkout = Vue.component("checkout", { // this._state_load(state); // }, methods: { - record_by_id: function(records, _id) { - // TODO: double check when the process is done if this is still needed or not. - // `manual-select` can now buble up events w/ full record. - return _.first(_.filter(records, {id: _id})); + screen_title: function() { + if (_.isEmpty(this.current_doc()) || this.state_is("confirm_start")) + return this.menu_item.name; + let title = this.current_doc().record.name; + return title; + }, + current_doc: function() { + const data = this.state_get_data("select_line"); + if (_.isEmpty(data)) { + return null; + } + return { + record: data.picking, + identifier: data.picking.name, + }; + }, + select_line_manual_select_opts: function() { + return { + group_color: this.utils.colors.color_for("screen_step_todo"), + }; + }, + select_package_manual_select_opts: function() { + return { + multiple: true, + initSelectAll: true, + list_item_component: "picking-select-package-content", + list_item_options: {actions: ["action_qty_edit"]}, + }; }, }, data: function() { @@ -368,6 +395,11 @@ export var Checkout = Vue.component("checkout", { console.log("unselected", unselected); this.wait_call( this.odoo.call("reset_line_qty", { + picking_id: this.state.data.picking.id, + selected_line_ids: _.map( + this.state.data.selected, + _.property("id") + ), move_line_id: unselected.id, }) ); @@ -377,7 +409,7 @@ export var Checkout = Vue.component("checkout", { this.state_set_data( { picking: this.state.data.picking, - record: record, + line: record, selected_line_ids: _.map( this.state.data.selected, _.property("id") @@ -431,6 +463,7 @@ export var Checkout = Vue.component("checkout", { }, events: { qty_change_confirm: "on_confirm", + qty_edit: "on_qty_update", }, on_back: () => { this.state_to("select_package"); @@ -445,7 +478,7 @@ export var Checkout = Vue.component("checkout", { this.odoo.call("set_custom_qty", { picking_id: this.state.data.picking.id, selected_line_ids: this.state.data.selected_line_ids, - move_line_id: this.state.data.record.id, + move_line_id: this.state.data.line.id, qty_done: this.state.data.qty, }) ); diff --git a/shopfloor_mobile/static/wms/src/scenario/cluster_picking.js b/shopfloor_mobile/static/wms/src/scenario/cluster_picking.js index 6511b94411..80702afb4f 100644 --- a/shopfloor_mobile/static/wms/src/scenario/cluster_picking.js +++ b/shopfloor_mobile/static/wms/src/scenario/cluster_picking.js @@ -20,14 +20,15 @@ export var ClusterPicking = Vue.component("cluster-picking", { />
-
- -
@@ -114,14 +112,32 @@ export var ClusterPicking = Vue.component("cluster-picking", { if (_.isEmpty(this.current_batch()) || this.state_is("confirm_start")) return this.menu_item.name; let title = this.current_batch().name; - // if (this.current_picking) { - // title += " - " + this.current_picking.name; - // } + const picking = this.current_picking(); + if (picking) { + title += " > " + picking.name; + } return title; }, current_batch: function() { return this.state_get_data("confirm_start"); }, + current_picking: function() { + const data = this.state_get_data("start_line") || {}; + if (!data.picking) { + return null; + } + return data.picking; + }, + current_doc: function() { + const picking = this.current_picking(); + if (!picking) { + return {}; + } + return { + record: picking, + identifier: picking.name, + }; + }, action_full_bin: function() { this.wait_call( this.odoo.call("prepare_unload", { @@ -228,13 +244,16 @@ export var ClusterPicking = Vue.component("cluster-picking", { title: "Check qty and scan a destination bin", scan_placeholder: "Scan destination bin", }, + events: { + qty_edit: "on_qty_edit", + }, enter: () => { // TODO: shalle we hook v-model for qty input straight to the state data? this.scan_destination_qty = this.state_get_data( "start_line" ).quantity; }, - on_qty_update: qty => { + on_qty_edit: qty => { this.scan_destination_qty = parseInt(qty); }, on_scan: scanned => { diff --git a/shopfloor_mobile/static/wms/src/scenario/delivery.js b/shopfloor_mobile/static/wms/src/scenario/delivery.js index cbb75b0487..b3fcd1ce68 100644 --- a/shopfloor_mobile/static/wms/src/scenario/delivery.js +++ b/shopfloor_mobile/static/wms/src/scenario/delivery.js @@ -1,6 +1,5 @@ import {ScenarioBaseMixin} from "./mixins.js"; import {process_registry} from "../services/process_registry.js"; -import {utils} from "../utils.js"; import {demotools} from "../demo/demo.core.js"; // FIXME: dev only import {} from "../demo/demo.delivery.js"; // FIXME: dev only @@ -28,8 +27,8 @@ export var Delivery = Vue.component("checkout", {
@@ -59,11 +58,6 @@ export var Delivery = Vue.component("checkout", {
`, - computed: { - utils: function() { - return utils; - }, - }, // FIXME: just for dev // mounted: function() { // // TEST force state and data @@ -74,17 +68,12 @@ export var Delivery = Vue.component("checkout", { // this._state_load(state); // }, methods: { - record_by_id: function(records, _id) { - // TODO: double check when the process is done if this is still needed or not. - // `manual-select` can now buble up events w/ full record. - return _.first(_.filter(records, {id: _id})); - }, deliver_move_line_list_options: function() { return { list_item_options: { actions: ["action_cancel_line"], fields: this.move_line_detail_fields(), - list_item_klass_maker: this.utils.move_line_color_klass, + list_item_klass_maker: this.utils.misc.move_line_color_klass, }, }; }, @@ -102,7 +91,7 @@ export var Delivery = Vue.component("checkout", { label: "Lines", renderer: function(rec, field) { return ( - self.utils.picking_completed_lines(rec) + + self.utils.misc.picking_completed_lines(rec) + " / " + rec.move_lines.length ); @@ -194,7 +183,7 @@ export var Delivery = Vue.component("checkout", { }, visible_records: records => { const self = this; - let visible_records = utils.order_picking_by_completeness( + let visible_records = this.utils.misc.order_picking_by_completeness( records ); if (this.state.data.filtered) { diff --git a/shopfloor_mobile/static/wms/src/scenario/mixins.js b/shopfloor_mobile/static/wms/src/scenario/mixins.js index 224c84f400..ecd4689e25 100644 --- a/shopfloor_mobile/static/wms/src/scenario/mixins.js +++ b/shopfloor_mobile/static/wms/src/scenario/mixins.js @@ -84,6 +84,7 @@ export var ScenarioBaseMixin = { return { // you can provide a different screen title title: this.screen_title ? this.screen_title() : this.menu_item.name, + current_doc: this.current_doc ? this.current_doc() : null, klass: this.usage + " " + "state-" + this.state.key, user_message: this.user_message, user_popup: this.user_popup, @@ -104,6 +105,11 @@ export var ScenarioBaseMixin = { }, }, methods: { + make_state_component_key: function(bits) { + bits.unshift(this.current_state_key); + bits.unshift(this.usage); + return this.make_component_key(bits); + }, storage_key: function(state_key) { state_key = _.isUndefined(state_key) ? this.current_state_key : state_key; return this.usage + "." + state_key; @@ -321,10 +327,10 @@ export var ScenarioBaseMixin = { _.each(self.state.events, function(handler, name) { if (typeof handler == "string") handler = self.state[handler]; const event_name = self.state.key + ":" + name; - // Wipe old handlers - // TODO: any way to register them just once? - self.$root.event_hub.$off(event_name, handler); - self.$root.event_hub.$on(event_name, handler); + const existing = self.$root.event_hub._events[event_name]; + if (handler && _.isEmpty(existing)) { + self.$root.event_hub.$on(event_name, handler); + } }); } }, diff --git a/shopfloor_mobile/static/wms/src/services/color_registry.js b/shopfloor_mobile/static/wms/src/services/color_registry.js index 17e3b97aed..d7c2e1d184 100644 --- a/shopfloor_mobile/static/wms/src/services/color_registry.js +++ b/shopfloor_mobile/static/wms/src/services/color_registry.js @@ -2,7 +2,38 @@ import {ColorRegistry} from "./registry.js"; export var color_registry = new ColorRegistry(); -color_registry.add_theme({ - screen_step_done: "green accent-4", - screen_step_missing: "amber", -}); +color_registry.add_theme( + { + /** + * standard keys + */ + primary: "#491966", + secondary: "#CFD2FF", + accent: "#82B1FF", + error: "#c22a4a", + info: "#5e60ab", + success: "#8fbf44", + warning: "amber", + /** + * app specific + */ + screen_step_done: "success", + screen_step_todo: "#FFE3AC", + /** + * icons + */ + info_icon: "info darken-2", + /** + * buttons / actions + */ + btn_action: "primary lighten-2", + btn_action_cancel: "error", + btn_action_warn: "amber", + btn_action_complete: "success", + /** + * selection + */ + item_selected: "success", + }, + "light" +); // TODO: we should bave a theme named "coosa" and select it diff --git a/shopfloor_mobile/static/wms/src/services/registry.js b/shopfloor_mobile/static/wms/src/services/registry.js index 08938eb670..8da03a0efb 100644 --- a/shopfloor_mobile/static/wms/src/services/registry.js +++ b/shopfloor_mobile/static/wms/src/services/registry.js @@ -21,26 +21,26 @@ export class Registry { } export class ColorRegistry { - constructor(theme, _default = "default") { - this.themes = []; - this.colors = {}; + constructor(theme, _default = "light") { + this.themes = {}; this.default_theme = _default; } add_theme(colors, theme) { if (_.isUndefined(theme)) theme = this.default_theme; - if (!this.themes.includes(theme)) { - this.themes.push(theme); - } - this.colors[theme] = colors; + this.themes[theme] = colors; } color_for(key, theme) { if (_.isUndefined(theme)) theme = this.default_theme; - if (!this.themes.includes(theme)) { - console.log("Theme", theme, "not registeed."); + if (!this.themes[theme]) { + console.log("Theme", theme, "not registered."); return null; } - return this.colors[theme][key]; + return this.themes[theme][key]; + } + + get_themes() { + return this.themes; } } diff --git a/shopfloor_mobile/static/wms/src/settings/profile.js b/shopfloor_mobile/static/wms/src/settings/profile.js index 8e150047ce..720cd65b23 100644 --- a/shopfloor_mobile/static/wms/src/settings/profile.js +++ b/shopfloor_mobile/static/wms/src/settings/profile.js @@ -33,12 +33,12 @@ export var Profile = Vue.component("profile", { - Reload config and menu + Reload config and menu - Logout + Logout
diff --git a/shopfloor_mobile/static/wms/src/utils.js b/shopfloor_mobile/static/wms/src/utils.js index ee7e78d0ac..617ef0ef80 100644 --- a/shopfloor_mobile/static/wms/src/utils.js +++ b/shopfloor_mobile/static/wms/src/utils.js @@ -146,6 +146,8 @@ export class Utils { return _.reverse(ordered); } + // DIsplay utils: TODO: split them to their own place + format_date_display(date_string, options = {}) { _.defaults(options, { locale: navigator ? navigator.language : "en-US", @@ -174,6 +176,48 @@ export class Utils { } return "move-line-" + klass; } + + /** + * Provide display options for rendering move line product's info. + * + * TODO: @simahawk this is a first attempt to stop creating a specific widget of each case + * whereas you have to handle different options. + * The aim is to be able to re-use detail-card and alike by passing options only. + * + * @param {*} line The move line + */ + move_line_product_detail_options(line, override = {}) { + const fields = [ + {path: "package_src.name", label: "Pack"}, + {path: "lot.name", label: "Lot"}, + { + path: "quantity", + label: "Qty", + render_component: "packaging-qty-picker-display", + render_options: function(record) { + return { + init_value: record.quantity, + available_packaging: record.product.packaging, + }; + }, + }, + {path: "product.qty_available", label: "Qty on hand"}, + ]; + let opts = { + main: true, + key_title: "product.display_name", + fields: fields, + title_action_field: {action_val_path: "product.barcode"}, + }; + return _.extend(opts, override || {}); + } + move_line_qty_picker_options(line, override = {}) { + let opts = { + init_value: line.quantity, + available_packaging: line.product.packaging, + }; + return _.extend(opts, override || {}); + } } export const utils = new Utils();