diff --git a/controllers/StockController.php b/controllers/StockController.php index d62809cc..fe100b95 100644 --- a/controllers/StockController.php +++ b/controllers/StockController.php @@ -156,7 +156,8 @@ class StockController extends BaseController 'userfields' => $this->UserfieldsService->GetFields('products'), 'products' => $this->Database->products()->where('id != :1 AND parent_product_id IS NULL', $product->id)->orderBy('name'), 'isSubProductOfOthers' => $this->Database->products()->where('parent_product_id = :1', $product->id)->count() !== 0, - 'mode' => 'edit' + 'mode' => 'edit', + 'quConversions' => $this->Database->quantity_unit_conversions() ]); } } @@ -212,12 +213,16 @@ class StockController extends BaseController } else { + $quantityUnit = $this->Database->quantity_units($args['quantityunitId']); + return $this->AppContainer->view->render($response, 'quantityunitform', [ - 'quantityunit' => $this->Database->quantity_units($args['quantityunitId']), + 'quantityUnit' => $quantityUnit, 'mode' => 'edit', 'userfields' => $this->UserfieldsService->GetFields('quantity_units'), 'pluralCount' => $this->LocalizationService->GetPluralCount(), - 'pluralRule' => $this->LocalizationService->GetPluralDefinition() + 'pluralRule' => $this->LocalizationService->GetPluralDefinition(), + 'defaultQuConversions' => $this->Database->quantity_unit_conversions()->where('from_qu_id = :1 AND product_id IS NULL', $quantityUnit->id), + 'quantityUnits' => $this->Database->quantity_units() ]); } } @@ -278,4 +283,41 @@ class StockController extends BaseController 'currentStockLocationContent' => $this->StockService->GetCurrentStockLocationContent() ]); } + + public function QuantityUnitConversionEditForm(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + $product = null; + if (isset($request->getQueryParams()['product'])) + { + $product = $this->Database->products($request->getQueryParams()['product']); + } + + $defaultQuUnit = null; + if (isset($request->getQueryParams()['qu-unit'])) + { + $defaultQuUnit = $this->Database->quantity_units($request->getQueryParams()['qu-unit']); + } + + if ($args['quConversionId'] == 'new') + { + return $this->AppContainer->view->render($response, 'quantityunitconversionform', [ + 'mode' => 'create', + 'userfields' => $this->UserfieldsService->GetFields('quantity_unit_conversions'), + 'quantityunits' => $this->Database->quantity_units()->orderBy('name'), + 'product' => $product, + 'defaultQuUnit' => $defaultQuUnit + ]); + } + else + { + return $this->AppContainer->view->render($response, 'quantityunitconversionform', [ + 'quConversion' => $this->Database->quantity_unit_conversions($args['quConversionId']), + 'mode' => 'edit', + 'userfields' => $this->UserfieldsService->GetFields('quantity_unit_conversions'), + 'quantityunits' => $this->Database->quantity_units()->orderBy('name'), + 'product' => $product, + 'defaultQuUnit' => $defaultQuUnit + ]); + } + } } diff --git a/grocy.openapi.json b/grocy.openapi.json index be617bd3..f4d08d7d 100644 --- a/grocy.openapi.json +++ b/grocy.openapi.json @@ -2430,6 +2430,7 @@ "batteries", "locations", "quantity_units", + "quantity_unit_conversions", "shopping_list", "shopping_lists", "recipes", diff --git a/localization/strings.pot b/localization/strings.pot index c5667960..73035a48 100644 --- a/localization/strings.pot +++ b/localization/strings.pot @@ -1322,3 +1322,51 @@ msgstr "" msgid "Not possible because this product is already used as a parent product in another product" msgstr "" + +msgid "Create QU conversion" +msgstr "" + +msgid "Quantity unit from" +msgstr "" + +msgid "Quantity unit to" +msgstr "" + +msgid "The amount cannot be lower than %s and must be a valid number" +msgstr "" + +msgid "Factor" +msgstr "" + +msgid "This cannot be equal to %s" +msgstr "" + +msgid "This means 1 %1$s is the same as %2$s %3$s" +msgstr "" + +msgid "Edit QU conversion" +msgstr "" + +msgid "For product" +msgstr "" + +msgid "Default for QU unit" +msgstr "" + +msgid "Override for product" +msgstr "" + +msgid "Default conversions" +msgstr "" + +msgid "1 %s is the same as..." +msgstr "" + +msgid "Are you sure to remove this conversion?" +msgstr "" + +msgid "QU conversions" +msgstr "" + +msgid "Product overrides" +msgstr "" diff --git a/migrations/0082.sql b/migrations/0082.sql new file mode 100644 index 00000000..9720667d --- /dev/null +++ b/migrations/0082.sql @@ -0,0 +1,82 @@ +CREATE TABLE quantity_unit_conversions ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE, + from_qu_id INT NOT NULL, + to_qu_id INT NOT NULL, + factor REAL NOT NULL, + product_id INT, + row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime')) +); + +CREATE TRIGGER quantity_unit_conversions_custom_unique_constraint_INS BEFORE INSERT ON quantity_unit_conversions +BEGIN +-- Necessary because unique constraints don't include NULL values in SQLite, and also because the constraint should include the products default conversion factor +SELECT CASE WHEN(( + SELECT 1 + FROM quantity_unit_conversions + WHERE from_qu_id = NEW.from_qu_id + AND to_qu_id = NEW.to_qu_id + AND IFNULL(product_id, 0) = IFNULL(NEW.product_id, 0) + UNION + SELECT 1 + FROM products + WHERE id = NEW.product_id + AND qu_id_purchase = NEW.from_qu_id + AND qu_id_stock = NEW.to_qu_id + ) + NOTNULL) THEN RAISE(ABORT, "Unique constraint violation") END; +END; + +CREATE TRIGGER quantity_unit_conversions_custom_unique_constraint_UPD BEFORE UPDATE ON quantity_unit_conversions +BEGIN +-- Necessary because unique constraints don't include NULL values in SQLite, and also because the constraint should include the products default conversion factor +SELECT CASE WHEN(( + SELECT 1 + FROM quantity_unit_conversions + WHERE from_qu_id = NEW.from_qu_id + AND to_qu_id = NEW.to_qu_id + AND IFNULL(product_id, 0) = IFNULL(NEW.product_id, 0) + UNION + SELECT 1 + FROM products + WHERE id = NEW.product_id + AND qu_id_purchase = NEW.from_qu_id + AND qu_id_stock = NEW.to_qu_id + ) + NOTNULL) THEN RAISE(ABORT, "Unique constraint violation") END; +END; + +CREATE VIEW quantity_unit_conversions_resolved +AS +-- First: Product "purchase to stock" conversion factor +SELECT + p.id AS product_id, + p.qu_id_purchase AS from_qu_id, + p.qu_id_stock AS to_qu_id, + p.qu_factor_purchase_to_stock AS factor +FROM products p + +UNION + +-- Second: Product specific overrides +SELECT + p.id AS product_id, + p.qu_id_stock AS from_qu_id, + quc.to_qu_id AS to_qu_id, + quc.factor AS factor +FROM products p +JOIN quantity_unit_conversions quc + ON p.qu_id_stock = quc.from_qu_id + AND p.id = quc.product_id + +UNION + +-- Third: Default quantity unit conversion factors +SELECT + p.id AS product_id, + p.qu_id_stock AS from_qu_id, + quc.to_qu_id AS to_qu_id, + quc.factor AS factor +FROM products p +JOIN quantity_unit_conversions quc + ON p.qu_id_stock = quc.from_qu_id + AND quc.product_id IS NULL; diff --git a/public/viewjs/productform.js b/public/viewjs/productform.js index 6352abdc..6ed5766b 100644 --- a/public/viewjs/productform.js +++ b/public/viewjs/productform.js @@ -9,6 +9,11 @@ redirectDestination = U(returnTo) + '?createdproduct=' + encodeURIComponent($('#name').val()); } + if (Grocy.ProductEditFormRedirectUri !== undefined) + { + redirectDestination = Grocy.ProductEditFormRedirectUri; + } + var jsonData = $('#product-form').serializeJSON({ checkboxUncheckedValue: "0" }); var parentProductId = jsonData.product_id; delete jsonData.product_id; @@ -44,7 +49,14 @@ Grocy.Api.UploadFile($("#product-picture")[0].files[0], 'productpictures', jsonData.picture_file_name, function(result) { - window.location.href = redirectDestination; + if (redirectDestination == "reload") + { + window.location.reload(); + } + else + { + window.location.href = redirectDestination; + } }, function (xhr) { @@ -55,7 +67,14 @@ } else { - window.location.href = redirectDestination; + if (redirectDestination == "reload") + { + window.location.reload(); + } + else + { + window.location.href = redirectDestination; + } } }); }, @@ -93,7 +112,14 @@ Grocy.Api.UploadFile($("#product-picture")[0].files[0], 'productpictures', jsonData.picture_file_name, function(result) { - window.location.href = redirectDestination; + if (redirectDestination == "reload") + { + window.location.reload(); + } + else + { + window.location.href = redirectDestination; + } }, function(xhr) { @@ -104,7 +130,14 @@ } else { - window.location.href = redirectDestination; + if (redirectDestination == "reload") + { + window.location.reload(); + } + else + { + window.location.href = redirectDestination; + } } }); }, @@ -189,6 +222,9 @@ $('.input-group-qu').on('change', function(e) $('#qu-conversion-info').addClass('d-none'); } + $("#qu-conversion-headline-info").text(__t('1 %s is the same as...', $("#qu_id_stock option:selected").text())); + quConversionsTable.column(4).search("from_qu_id xx" + $("#qu_id_stock").val().toString() + "xx").draw(); + $("#tare_weight_qu_info").text($("#qu_id_stock option:selected").text()); Grocy.FrontendHelpers.ValidateForm('product-form'); @@ -278,6 +314,34 @@ if (Grocy.EditMode === 'create') } } +var quConversionsTable = $('#qu-conversions-table').DataTable({ + 'paginate': false, + 'order': [[1, 'asc']], + "orderFixed": [[3, 'asc']], + 'columnDefs': [ + { 'orderable': false, 'targets': 0 }, + { 'visible': false, 'targets': 3 } + ], + 'language': JSON.parse(__t('datatables_localization')), + 'scrollY': false, + 'colReorder': true, + 'stateSave': true, + 'stateSaveParams': function(settings, data) + { + data.search.search = ""; + + data.columns.forEach(column => + { + column.search.search = ""; + }); + }, + 'rowGroup': { + dataSrc: 3 + } +}); +$('#qu-conversions-table tbody').removeClass("d-none"); +quConversionsTable.columns.adjust().draw(); + Grocy.Components.UserfieldsForm.Load(); $('#name').focus(); $('.input-group-qu').trigger('change'); @@ -286,3 +350,52 @@ Grocy.FrontendHelpers.ValidateForm('product-form'); // Click twice to trigger on-click but not change the actual checked state $("#allow_partial_units_in_stock").click(); $("#allow_partial_units_in_stock").click(); + +$(document).on('click', '.qu-conversion-delete-button', function(e) +{ + var objectId = $(e.currentTarget).attr('data-qu-conversion-id'); + + bootbox.confirm({ + message: __t('Are you sure to remove this conversion?'), + buttons: { + confirm: { + label: __t('Yes'), + className: 'btn-success' + }, + cancel: { + label: __t('No'), + className: 'btn-danger' + } + }, + callback: function(result) + { + if (result === true) + { + Grocy.Api.Delete('objects/quantity_unit_conversions/' + objectId, { }, + function(result) + { + Grocy.ProductEditFormRedirectUri = "reload"; + $('#save-product-button').click(); + }, + function(xhr) + { + console.error(xhr); + } + ); + } + } + }); +}); + +$(document).on('click', '.qu-conversion-edit-button', function (e) +{ + var id = $(e.currentTarget).attr('data-qu-conversion-id'); + Grocy.ProductEditFormRedirectUri = U("/quantityunitconversion/" + id.toString() + "?product=" + Grocy.EditObjectId.toString()); + $('#save-product-button').click(); +}); + +$("#qu-conversion-add-button").on("click", function(e) +{ + Grocy.ProductEditFormRedirectUri = U("/quantityunitconversion/new?product=" + Grocy.EditObjectId.toString()); + $('#save-product-button').click(); +}); diff --git a/public/viewjs/quantityunitconversionform.js b/public/viewjs/quantityunitconversionform.js new file mode 100644 index 00000000..fd9c81bd --- /dev/null +++ b/public/viewjs/quantityunitconversionform.js @@ -0,0 +1,115 @@ +$('#save-quconversion-button').on('click', function(e) +{ + e.preventDefault(); + + var jsonData = $('#quconversion-form').serializeJSON(); + jsonData.from_qu_id = $("#from_qu_id").val(); + Grocy.FrontendHelpers.BeginUiBusy("quconversion-form"); + + if (Grocy.EditMode === 'create') + { + Grocy.Api.Post('objects/quantity_unit_conversions', jsonData, + function(result) + { + Grocy.EditObjectId = result.created_object_id; + Grocy.Components.UserfieldsForm.Save(function() + { + if (typeof GetUriParam("qu-unit") !== "undefined") + { + window.location.href = U("/quantityunit/" + GetUriParam("qu-unit")); + } + else + { + window.location.href = U("/product/" + GetUriParam("product")); + } + }); + }, + function(xhr) + { + Grocy.FrontendHelpers.EndUiBusy("quconversion-form"); + Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response) + } + ); + } + else + { + Grocy.Api.Put('objects/quantity_unit_conversions/' + Grocy.EditObjectId, jsonData, + function(result) + { + Grocy.Components.UserfieldsForm.Save(function() + { + if (typeof GetUriParam("qu-unit") !== "undefined") + { + window.location.href = U("/quantityunit/" + GetUriParam("qu-unit")); + } + else + { + window.location.href = U("/product/" + GetUriParam("product")); + } + }); + }, + function(xhr) + { + Grocy.FrontendHelpers.EndUiBusy("quconversion-form"); + Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response) + } + ); + } +}); + +$('#quconversion-form input').keyup(function(event) +{ + $('.input-group-qu').trigger('change'); + Grocy.FrontendHelpers.ValidateForm('quconversion-form'); +}); + +$('#quconversion-form input').keydown(function(event) +{ + if (event.keyCode === 13) //Enter + { + event.preventDefault(); + + if (document.getElementById('quconversion-form').checkValidity() === false) //There is at least one validation error + { + return false; + } + else + { + $('#save-quconversion-button').click(); + } + } +}); + +$('.input-group-qu').on('change', function(e) +{ + var fromQuId = $("#from_qu_id").val(); + var toQuId = $("#to_qu_id").val(); + var factor = $('#factor').val(); + + if (fromQuId == toQuId) + { + $("#to_qu_id").parent().find(".invalid-feedback").text(__t('This cannot be equal to %s', $("#from_qu_id option:selected").text())); + $("#to_qu_id")[0].setCustomValidity("error"); + } + else + { + $("#to_qu_id")[0].setCustomValidity(""); + } + + if (fromQuId && toQuId) + { + $('#qu-conversion-info').text(__t('This means 1 %1$s is the same as %2$s %3$s', $("#from_qu_id option:selected").text(), (1 * factor).toString(), $("#to_qu_id option:selected").text())); + $('#qu-conversion-info').removeClass('d-none'); + } + else + { + $('#qu-conversion-info').addClass('d-none'); + } + + Grocy.FrontendHelpers.ValidateForm('quconversion-form'); +}); + +Grocy.Components.UserfieldsForm.Load(); +$('.input-group-qu').trigger('change'); +$('#from_qu_id').focus(); +Grocy.FrontendHelpers.ValidateForm('quconversion-form'); diff --git a/public/viewjs/quantityunitform.js b/public/viewjs/quantityunitform.js index 1c282030..fcd6b78c 100644 --- a/public/viewjs/quantityunitform.js +++ b/public/viewjs/quantityunitform.js @@ -64,6 +64,94 @@ $('#quantityunit-form input').keydown(function(event) } }); +var quConversionsTable = $('#qu-conversions-table').DataTable({ + 'paginate': false, + 'order': [[1, 'asc']], + 'columnDefs': [ + { 'orderable': false, 'targets': 0 } + ], + 'language': JSON.parse(__t('datatables_localization')), + 'scrollY': false, + 'colReorder': true, + 'stateSave': true, + 'stateSaveParams': function(settings, data) + { + data.search.search = ""; + + data.columns.forEach(column => + { + column.search.search = ""; + }); + } +}); +$('#qu-conversions-table tbody').removeClass("d-none"); +quConversionsTable.columns.adjust().draw(); + Grocy.Components.UserfieldsForm.Load(); $('#name').focus(); Grocy.FrontendHelpers.ValidateForm('quantityunit-form'); + +$(document).on('click', '.qu-conversion-delete-button', function(e) +{ + var objectId = $(e.currentTarget).attr('data-qu-conversion-id'); + + bootbox.confirm({ + message: __t('Are you sure to remove this conversion?'), + buttons: { + confirm: { + label: __t('Yes'), + className: 'btn-success' + }, + cancel: { + label: __t('No'), + className: 'btn-danger' + } + }, + callback: function(result) + { + if (result === true) + { + Grocy.Api.Delete('objects/quantity_unit_conversions/' + objectId, { }, + function(result) + { + window.location.reload(); + }, + function(xhr) + { + console.error(xhr); + } + ); + } + } + }); +}); + +$(document).on('click', '.qu-conversion-edit-button', function (e) +{ + var id = $(e.currentTarget).attr('data-qu-conversion-id'); + + Grocy.Api.Put('objects/quantity_units/' + Grocy.EditObjectId, $('#quantityunit-form').serializeJSON(), + function(result) + { + window.location.href = U("/quantityunitconversion/" + id.toString() + "?qu-unit=" + Grocy.EditObjectId.toString()); + }, + function(xhr) + { + console.error(xhr); + } + ); +}); + +$("#qu-conversion-add-button").on("click", function(e) +{ + Grocy.Api.Put('objects/quantity_units/' + Grocy.EditObjectId, $('#quantityunit-form').serializeJSON(), + function(result) + { + window.location.href = U("/quantityunitconversion/new?qu-unit=" + Grocy.EditObjectId.toString()); + }, + function(xhr) + { + console.error(xhr); + } + ); +}); diff --git a/routes.php b/routes.php index 577e50e6..42d7b80a 100644 --- a/routes.php +++ b/routes.php @@ -38,6 +38,7 @@ $app->group('', function() $this->get('/location/{locationId}', '\Grocy\Controllers\StockController:LocationEditForm'); $this->get('/quantityunits', '\Grocy\Controllers\StockController:QuantityUnitsList'); $this->get('/quantityunit/{quantityunitId}', '\Grocy\Controllers\StockController:QuantityUnitEditForm'); + $this->get('/quantityunitconversion/{quConversionId}', '\Grocy\Controllers\StockController:QuantityUnitConversionEditForm'); $this->get('/productgroups', '\Grocy\Controllers\StockController:ProductGroupsList'); $this->get('/productgroup/{productGroupId}', '\Grocy\Controllers\StockController:ProductGroupEditForm'); $this->get('/stockjournal', '\Grocy\Controllers\StockController:Journal'); diff --git a/views/productform.blade.php b/views/productform.blade.php index b0f76ba0..ebd05dd8 100644 --- a/views/productform.blade.php +++ b/views/productform.blade.php @@ -10,10 +10,13 @@ @push('pageScripts') + + @endpush @push('pageStyles') + @endpush @section('content') @@ -217,14 +220,67 @@
- - - @if(!empty($product->picture_file_name)) -

-

{{ $__t('The current picture will be deleted when you save the product') }}

- @else -

{{ $__t('No picture available') }}

- @endif +

+ {{ $__t('QU conversions') }} + + {{ $__t('Add') }} + +

+
+ + + + + + + + + + + + @if($mode == "edit") + @foreach($quConversions as $quConversion) + + + + + + + + @endforeach + @endif + +
{{ $__t('Factor') }}{{ $__t('Unit') }}Hidden groupHidden from_qu_id
+ + + + + + + + {{ $quConversion->factor }} + + {{ FindObjectInArrayByPropertyValue($quantityunits, 'id', $quConversion->to_qu_id)->name }} + + @if($quConversion->product_id != null) + {{ $__t('Product overrides') }} + @else + {{ $__t('Default conversions') }} + @endif + + from_qu_id xx{{ $quConversion->from_qu_id }}xx +
+ +
+ + + @if(!empty($product->picture_file_name)) +

+

{{ $__t('The current picture will be deleted when you save the product') }}

+ @else +

{{ $__t('No picture available') }}

+ @endif +
@stop diff --git a/views/quantityunitconversionform.blade.php b/views/quantityunitconversionform.blade.php new file mode 100644 index 00000000..e316d163 --- /dev/null +++ b/views/quantityunitconversionform.blade.php @@ -0,0 +1,78 @@ +@extends('layout.default') + +@if($mode == 'edit') + @section('title', $__t('Edit QU conversion')) +@else + @section('title', $__t('Create QU conversion')) +@endif + +@section('viewJsName', 'quantityunitconversionform') + +@section('content') +
+
+

@yield('title')

+ + @if($product != null) +

{{ $__t('Override for product') }} {{ $product->name }}

+ @else +

{{ $__t('Default for QU unit') }} {{ $defaultQuUnit->name }}

+ @endif + + + + @if($mode == 'edit') + + @endif + +
+ + @if($product != null) + + @endif + +
+ + +
{{ $__t('A quantity unit is required') }}
+
+ +
+ + +
{{ $__t('A quantity unit is required') }}
+
+ + @php if($mode == 'edit') { $value = $quConversion->factor; } else { $value = 1; } @endphp + @include('components.numberpicker', array( + 'id' => 'factor', + 'label' => 'Factor', + 'min' => 0, + 'step' => 0.01, + 'value' => $value, + 'invalidFeedback' => $__t('The amount cannot be lower than %s and must be a valid number', '0'), + 'additionalHtmlElements' => '

', + 'additionalCssClasses' => 'input-group-qu' + )) + + @include('components.userfieldsform', array( + 'userfields' => $userfields, + 'entity' => 'quantity_unit_conversions' + )) + + + +
+
+
+@stop diff --git a/views/quantityunitform.blade.php b/views/quantityunitform.blade.php index 01c837aa..054ac3a7 100644 --- a/views/quantityunitform.blade.php +++ b/views/quantityunitform.blade.php @@ -16,20 +16,20 @@ @if($mode == 'edit') - + @endif
- +
{{ $__t('A name is required') }}
- +
@if($pluralCount > 2) @@ -42,13 +42,13 @@ {{ $__t('Plural rule') }}: {{ $pluralRule }} - + @endif
- +
@include('components.userfieldsform', array( @@ -60,5 +60,46 @@
+ +
+

+ {{ $__t('Default conversions') }} + + {{ $__t('Add') }} + +

+
{{ $__t('1 %s is the same as...', $quantityUnit->name) }}
+ + + + + + + + + + @if($mode == "edit") + @foreach($defaultQuConversions as $defaultQuConversion) + + + + + + @endforeach + @endif + +
{{ $__t('Factor') }}{{ $__t('Unit') }}
+ + + + + + + + {{ $defaultQuConversion->factor }} + + {{ FindObjectInArrayByPropertyValue($quantityUnits, 'id', $defaultQuConversion->to_qu_id)->name }} +
+
@stop