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
-