From 6a50f74a14cba6d164edc3be92ef4da78823804c Mon Sep 17 00:00:00 2001 From: Bernd Bestel Date: Sat, 2 Apr 2022 17:49:35 +0200 Subject: [PATCH] Optimizations regarding displaying prices (closes #1743) --- changelog/67_UNRELEASED_xxxx-xx-xx.md | 3 + migrations/0182.sql | 64 +++++++++++ public/js/grocy.js | 5 + public/viewjs/components/productcard.js | 138 ++++++++++++++---------- public/viewjs/stockentries.js | 13 ++- views/components/productcard.blade.php | 25 ++++- views/stockentries.blade.php | 14 ++- views/stockoverview.blade.php | 24 ++++- 8 files changed, 215 insertions(+), 71 deletions(-) create mode 100644 migrations/0182.sql diff --git a/changelog/67_UNRELEASED_xxxx-xx-xx.md b/changelog/67_UNRELEASED_xxxx-xx-xx.md index c7809a58..b852f66b 100644 --- a/changelog/67_UNRELEASED_xxxx-xx-xx.md +++ b/changelog/67_UNRELEASED_xxxx-xx-xx.md @@ -28,6 +28,9 @@ - It's now possible to change a products stock QU, even after it was once added to stock - When the product was once added to stock, there needs to exist a corresponding unit conversion for the new QU +- Product card, stock overiew and stock entries page optimizations regarding displaying prices: + - Prices are now shown per default purchase quantity unit, instead of per stock QU and when clicking/hovering, a tooltip shows the price per stock QU + - The price history chart is now based on the value per purchase QU, instead of per stock QU - New product option "Disable own stock" (defaults to disabled) - When enabled, the corresponding product can't have own stock, means it will not be selectable on purchase (useful for parent products which are just used as a summary/total view of the child products) - The location content sheet can now optionally list also out of stock products (at the products default location, new checkbox "Show only in-stock products " at the top of the page, defaults to enabled) diff --git a/migrations/0182.sql b/migrations/0182.sql new file mode 100644 index 00000000..4c1e52ef --- /dev/null +++ b/migrations/0182.sql @@ -0,0 +1,64 @@ +DROP VIEW uihelper_stock_current_overview; +CREATE VIEW uihelper_stock_current_overview +AS +SELECT + p.id, + sc.amount_opened AS amount_opened, + p.tare_weight AS tare_weight, + p.enable_tare_weight_handling AS enable_tare_weight_handling, + sc.amount AS amount, + sc.value as value, + sc.product_id AS product_id, + sc.best_before_date AS best_before_date, + EXISTS(SELECT id FROM stock_missing_products WHERE id = sc.product_id) AS product_missing, + (SELECT name FROM quantity_units WHERE quantity_units.id = p.qu_id_stock) AS qu_unit_name, + (SELECT name_plural FROM quantity_units WHERE quantity_units.id = p.qu_id_stock) AS qu_unit_name_plural, + p.name AS product_name, + (SELECT name FROM product_groups WHERE product_groups.id = p.product_group_id) AS product_group_name, + EXISTS(SELECT * FROM shopping_list WHERE shopping_list.product_id = sc.product_id) AS on_shopping_list, + (SELECT name FROM quantity_units WHERE quantity_units.id = p.qu_id_purchase) AS qu_purchase_unit_name, + (SELECT name_plural FROM quantity_units WHERE quantity_units.id = p.qu_id_purchase) AS qu_purchase_unit_name_plural, + sc.is_aggregated_amount, + sc.amount_opened_aggregated, + sc.amount_aggregated, + p.calories AS product_calories, + sc.amount * p.calories AS calories, + sc.amount_aggregated * p.calories AS calories_aggregated, + p.quick_consume_amount, + p.due_type, + plp.purchased_date AS last_purchased, + plp.price AS last_price, + pap.price as average_price, + p.min_stock_amount, + pbcs.barcodes AS product_barcodes, + p.description AS product_description, + l.name AS product_default_location_name, + p_parent.id AS parent_product_id, + p_parent.name AS parent_product_name, + p.picture_file_name AS product_picture_file_name, + p.no_own_stock AS product_no_own_stock, + p.qu_factor_purchase_to_stock AS product_qu_factor_purchase_to_stock +FROM ( + SELECT * + FROM stock_current + WHERE best_before_date IS NOT NULL + UNION + SELECT m.id, 0, 0, 0, null, 0, 0, 0, p.due_type + FROM stock_missing_products m + JOIN products p + ON m.id = p.id + WHERE m.id NOT IN (SELECT product_id FROM stock_current) + ) sc +LEFT JOIN products_last_purchased plp + ON sc.product_id = plp.product_id +LEFT JOIN products_average_price pap + ON sc.product_id = pap.product_id +LEFT JOIN products p + ON sc.product_id = p.id +LEFT JOIN product_barcodes_comma_separated pbcs + ON sc.product_id = pbcs.product_id +LEFT JOIN products p_parent + ON p.parent_product_id = p_parent.id +LEFT JOIN locations l + ON p.location_id = l.id +WHERE p.hide_on_stock_overview = 0; diff --git a/public/js/grocy.js b/public/js/grocy.js index 61121dc3..aa879bf9 100644 --- a/public/js/grocy.js +++ b/public/js/grocy.js @@ -1181,3 +1181,8 @@ if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_RECIPES) $("#meal-plan-nav-link").attr("href", $("#meal-plan-nav-link").attr("href") + "?start=" + moment().startOf("week").format("YYYY-MM-DD")); } } + +$('[data-toggle="tooltip"][data-html="true"]').on("shown.bs.tooltip", function() +{ + RefreshLocaleNumberDisplay(".tooltip"); +}) diff --git a/public/viewjs/components/productcard.js b/public/viewjs/components/productcard.js index 29dbed99..78c56766 100644 --- a/public/viewjs/components/productcard.js +++ b/public/viewjs/components/productcard.js @@ -12,7 +12,7 @@ Grocy.Components.ProductCard.Refresh = function(productId) $('#productcard-product-description').html(productDetails.product.description); $('#productcard-product-stock-amount').text(stockAmount); $('#productcard-product-stock-qu-name').text(__n(stockAmount, productDetails.quantity_unit_stock.name, productDetails.quantity_unit_stock.name_plural, true)); - $('#productcard-product-stock-value').text(stockValue + ' ' + Grocy.Currency); + $('#productcard-product-stock-value').text(stockValue); $('#productcard-product-last-purchased').text((productDetails.last_purchased || '2999-12-31').substring(0, 10)); $('#productcard-product-last-purchased-timeago').attr("datetime", productDetails.last_purchased || '2999-12-31'); $('#productcard-product-last-used').text((productDetails.last_used || '2999-12-31').substring(0, 10)); @@ -84,20 +84,24 @@ Grocy.Components.ProductCard.Refresh = function(productId) if (productDetails.last_price !== null) { - $('#productcard-product-last-price').text(__t("%1$s per %2$s", Number.parseFloat(productDetails.last_price).toLocaleString(undefined, { style: "currency", currency: Grocy.Currency, minimumFractionDigits: Grocy.UserSettings.stock_decimal_places_prices, maximumFractionDigits: Grocy.UserSettings.stock_decimal_places_prices }), productDetails.quantity_unit_stock.name)); + $('#productcard-product-last-price').text(__t("%1$s per %2$s", (Number.parseFloat(productDetails.last_price) * Number.parseFloat(productDetails.product.qu_factor_purchase_to_stock)).toLocaleString(undefined, { style: "currency", currency: Grocy.Currency, minimumFractionDigits: Grocy.UserSettings.stock_decimal_places_prices, maximumFractionDigits: Grocy.UserSettings.stock_decimal_places_prices }), productDetails.default_quantity_unit_purchase.name)); + $('#productcard-product-last-price').attr("data-original-title", __t("%1$s per %2$s", Number.parseFloat(productDetails.last_price).toLocaleString(undefined, { style: "currency", currency: Grocy.Currency, minimumFractionDigits: Grocy.UserSettings.stock_decimal_places_prices, maximumFractionDigits: Grocy.UserSettings.stock_decimal_places_prices }), productDetails.quantity_unit_stock.name)); } else { $('#productcard-product-last-price').text(__t('Unknown')); + $('#productcard-product-last-price').removeAttr("data-original-title"); } if (productDetails.avg_price !== null) { - $('#productcard-product-average-price').text(__t("%1$s per %2$s", Number.parseFloat(productDetails.avg_price).toLocaleString(undefined, { style: "currency", currency: Grocy.Currency, minimumFractionDigits: Grocy.UserSettings.stock_decimal_places_prices, maximumFractionDigits: Grocy.UserSettings.stock_decimal_places_prices }), productDetails.quantity_unit_stock.name)); + $('#productcard-product-average-price').text(__t("%1$s per %2$s", (Number.parseFloat(productDetails.avg_price) * Number.parseFloat(productDetails.product.qu_factor_purchase_to_stock)).toLocaleString(undefined, { style: "currency", currency: Grocy.Currency, minimumFractionDigits: Grocy.UserSettings.stock_decimal_places_prices, maximumFractionDigits: Grocy.UserSettings.stock_decimal_places_prices }), productDetails.default_quantity_unit_purchase.name)); + $('#productcard-product-average-price').attr("data-original-title", __t("%1$s per %2$s", Number.parseFloat(productDetails.avg_price).toLocaleString(undefined, { style: "currency", currency: Grocy.Currency, minimumFractionDigits: Grocy.UserSettings.stock_decimal_places_prices, maximumFractionDigits: Grocy.UserSettings.stock_decimal_places_prices }), productDetails.quantity_unit_stock.name)); } else { $('#productcard-product-average-price').text(__t('Unknown')); + $().removeAttr("data-original-title"); } if (productDetails.product.picture_file_name !== null && !productDetails.product.picture_file_name.isEmpty()) @@ -120,65 +124,65 @@ Grocy.Components.ProductCard.Refresh = function(productId) RefreshContextualTimeago(".productcard"); RefreshLocaleNumberDisplay(".productcard"); + + if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING) + { + Grocy.Api.Get('stock/products/' + productId + '/price-history', + function(priceHistoryDataPoints) + { + if (priceHistoryDataPoints.length > 0) + { + $("#productcard-product-price-history-chart").removeClass("d-none"); + $("#productcard-no-price-data-hint").addClass("d-none"); + + Grocy.Components.ProductCard.ReInitPriceHistoryChart(); + var datasets = {}; + var chart = Grocy.Components.ProductCard.PriceHistoryChart.data; + priceHistoryDataPoints.forEach((dataPoint) => + { + var key = __t("Unknown store"); + if (dataPoint.shopping_location) + { + key = dataPoint.shopping_location.name + } + + if (!datasets[key]) + { + datasets[key] = [] + } + chart.labels.push(moment(dataPoint.date).toDate()); + datasets[key].push(Number.parseFloat(dataPoint.price) * Number.parseFloat(productDetails.product.qu_factor_purchase_to_stock)); + + }); + Object.keys(datasets).forEach((key) => + { + chart.datasets.push({ + data: datasets[key], + fill: false, + borderColor: "HSL(" + (129 * chart.datasets.length) + ",100%,50%)", + label: key, + }); + }); + Grocy.Components.ProductCard.PriceHistoryChart.update(); + } + else + { + $("#productcard-product-price-history-chart").addClass("d-none"); + $("#productcard-no-price-data-hint").removeClass("d-none"); + } + }, + function(xhr) + { + console.error(xhr); + } + ); + } }, function(xhr) { console.error(xhr); } ); - - if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING) - { - Grocy.Api.Get('stock/products/' + productId + '/price-history', - function(priceHistoryDataPoints) - { - if (priceHistoryDataPoints.length > 0) - { - $("#productcard-product-price-history-chart").removeClass("d-none"); - $("#productcard-no-price-data-hint").addClass("d-none"); - - Grocy.Components.ProductCard.ReInitPriceHistoryChart(); - var datasets = {}; - var chart = Grocy.Components.ProductCard.PriceHistoryChart.data; - priceHistoryDataPoints.forEach((dataPoint) => - { - var key = __t("Unknown store"); - if (dataPoint.shopping_location) - { - key = dataPoint.shopping_location.name - } - - if (!datasets[key]) - { - datasets[key] = [] - } - chart.labels.push(moment(dataPoint.date).toDate()); - datasets[key].push(dataPoint.price); - - }); - Object.keys(datasets).forEach((key) => - { - chart.datasets.push({ - data: datasets[key], - fill: false, - borderColor: "HSL(" + (129 * chart.datasets.length) + ",100%,50%)", - label: key, - }); - }); - Grocy.Components.ProductCard.PriceHistoryChart.update(); - } - else - { - $("#productcard-product-price-history-chart").addClass("d-none"); - $("#productcard-no-price-data-hint").removeClass("d-none"); - } - }, - function(xhr) - { - console.error(xhr); - } - ); - } }; Grocy.Components.ProductCard.ReInitPriceHistoryChart = function() @@ -220,12 +224,32 @@ Grocy.Components.ProductCard.ReInitPriceHistoryChart = function() }], yAxes: [{ ticks: { - beginAtZero: true + beginAtZero: true, + callback: function(value, index, ticks) + { + return Number.parseFloat(value).toLocaleString(undefined, { style: "currency", currency: Grocy.Currency, minimumFractionDigits: Grocy.UserSettings.stock_decimal_places_prices, maximumFractionDigits: Grocy.UserSettings.stock_decimal_places_prices }); + } } }] }, legend: { display: true + }, + tooltips: { + callbacks: { + label: function(tooltipItem, data) + { + var label = data.datasets[tooltipItem.datasetIndex].label || ''; + + if (label) + { + label += ': '; + } + + label += tooltipItem.yLabel.toLocaleString(undefined, { style: "currency", currency: Grocy.Currency, minimumFractionDigits: Grocy.UserSettings.stock_decimal_places_prices, maximumFractionDigits: Grocy.UserSettings.stock_decimal_places_prices }) + return label; + } + } } } }); diff --git a/public/viewjs/stockentries.js b/public/viewjs/stockentries.js index e11add68..6eaef7a6 100644 --- a/public/viewjs/stockentries.js +++ b/public/viewjs/stockentries.js @@ -216,7 +216,18 @@ function RefreshStockEntryRow(stockRowId) } ); - $('#stock-' + stockRowId + '-price').text(result.price); + Grocy.Api.Get("stock/products/" + result.product_id, + function(productDetails) + { + $('#stock-' + stockRowId + '-price').text(__t("%1$s per %2$s", (Number.parseFloat(result.price) * Number.parseFloat(productDetails.product.qu_factor_purchase_to_stock)).toLocaleString(undefined, { style: "currency", currency: Grocy.Currency, minimumFractionDigits: Grocy.UserSettings.stock_decimal_places_prices, maximumFractionDigits: Grocy.UserSettings.stock_decimal_places_prices }), productDetails.default_quantity_unit_purchase.name)); + $('#stock-' + stockRowId + '-price').attr("data-original-title", __t("%1$s per %2$s", Number.parseFloat(result.price).toLocaleString(undefined, { style: "currency", currency: Grocy.Currency, minimumFractionDigits: Grocy.UserSettings.stock_decimal_places_prices, maximumFractionDigits: Grocy.UserSettings.stock_decimal_places_prices }), productDetails.quantity_unit_stock.name)); + }, + function(xhr) + { + console.error(xhr); + } + ); + $('#stock-' + stockRowId + '-note').text(result.note); $('#stock-' + stockRowId + '-purchased-date').text(result.purchased_date); $('#stock-' + stockRowId + '-purchased-date-timeago').attr('datetime', result.purchased_date + ' 23:59:59'); diff --git a/views/components/productcard.blade.php b/views/components/productcard.blade.php index 1038ca21..9b0f3b19 100644 --- a/views/components/productcard.blade.php +++ b/views/components/productcard.blade.php @@ -49,15 +49,32 @@ class="pl-2 text-secondary d-none">
- @if(GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING){{ $__t('Stock value') }}:
@endif + + @if(GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING) + {{ $__t('Stock value') }}:
+ @endif + @if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING){{ $__t('Default location') }}:
@endif {{ $__t('Last purchased') }}:
{{ $__t('Last used') }}:
- @if(GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING){{ $__t('Last price') }}:
@endif - @if(GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING){{ $__t('Average price') }}:
@endif + + @if(GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING) + {{ $__t('Last price') }}: +
+ @endif + + @if(GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING) + {{ $__t('Average price') }}: +
+ @endif + @if(GROCY_FEATURE_FLAG_STOCK_BEST_BEFORE_DATE_TRACKING){{ $__t('Average shelf life') }}:
@endif {{ $__t('Spoil rate') }}: diff --git a/views/stockentries.blade.php b/views/stockentries.blade.php index 07173101..cae88201 100644 --- a/views/stockentries.blade.php +++ b/views/stockentries.blade.php @@ -261,11 +261,15 @@ {{ FindObjectInArrayByPropertyValue($shoppinglocations, 'id', $stockEntry->shopping_location_id)->name }} @endif - - {{ $stockEntry->price }} + + + {!! $__t('%1$s per %2$s', '' . floatval($stockEntry->price) * floatval(FindObjectInArrayByPropertyValue($products, 'id', $stockEntry->product_id)->qu_factor_purchase_to_stock) . '', FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $stockEntry->product_id)->qu_id_purchase)->name) !!} + {{ $stockEntry->purchased_date }} diff --git a/views/stockoverview.blade.php b/views/stockoverview.blade.php index 505be17b..eb80366e 100755 --- a/views/stockoverview.blade.php +++ b/views/stockoverview.blade.php @@ -399,8 +399,16 @@ - - {{ $currentStockEntry->last_price }} + + @if(!empty($currentStockEntry->last_price)) + + {!! $__t('%1$s per %2$s', '' . floatval($currentStockEntry->last_price) * floatval($currentStockEntry->product_qu_factor_purchase_to_stock) . '', $currentStockEntry->qu_purchase_unit_name) !!} + + @endif {{ $currentStockEntry->min_stock_amount }} @@ -421,8 +429,16 @@ class="lazy"> @endif - - {{ $currentStockEntry->average_price }} + + @if(!empty($currentStockEntry->average_price)) + + {!! $__t('%1$s per %2$s', '' . floatval($currentStockEntry->average_price) * floatval($currentStockEntry->product_qu_factor_purchase_to_stock) . '', $currentStockEntry->qu_purchase_unit_name) !!} + + @endif @include('components.userfields_tbody', array(