diff --git a/.gitignore b/.gitignore index 07aafe0e2..2e46c2324 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,8 @@ # XLSX exports /exports/* +# Archives of cLosed accounting periods +/accounting/* .DS_Store diff --git a/.rubocop.yml b/.rubocop.yml index 09fe97e24..63c71c529 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -7,10 +7,14 @@ Metrics/CyclomaticComplexity: Metrics/PerceivedComplexity: Max: 9 Metrics/AbcSize: - Max: 42 + Max: 45 Style/BracesAroundHashParameters: EnforcedStyle: context_dependent Style/RegexpLiteral: EnforcedStyle: slashes Style/EmptyElse: EnforcedStyle: empty +Style/ClassAndModuleChildren: + EnforcedStyle: compact +Style/AndOr: + EnforcedStyle: conditionals diff --git a/CHANGELOG.md b/CHANGELOG.md index b80ad962f..5b614d017 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ - Fix a bug: error handling on password recovery - Fix a bug: error handling on machine attachment upload +- Fix a bug: first day of week is ignored in statistics custom filter +- Fix a bug: rails DSB locale is invalid +- Refactored frontend invoices translations ## v2.8.1 2019 January 02 diff --git a/Dockerfile b/Dockerfile index cf2d7db52..6205425e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,6 +44,7 @@ RUN mkdir -p /usr/src/app/exports RUN mkdir -p /usr/src/app/log RUN mkdir -p /usr/src/app/public/uploads RUN mkdir -p /usr/src/app/public/assets +RUN mkdir -p /usr/src/app/accounting RUN mkdir -p /usr/src/app/tmp/sockets RUN mkdir -p /usr/src/app/tmp/pids @@ -64,6 +65,7 @@ VOLUME /usr/src/app/exports VOLUME /usr/src/app/public VOLUME /usr/src/app/public/uploads VOLUME /usr/src/app/public/assets +VOLUME /usr/src/app/accounting VOLUME /var/log/supervisor # Expose port 3000 to the Docker host, so we can access it diff --git a/README.md b/README.md index 645c5c945..93a0ae94f 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,7 @@ This can be achieved doing the following: - `db/migrate/20150604131525_add_meta_data_to_notifications.rb` is using [jsonb](https://www.postgresql.org/docs/9.4/static/datatype-json.html), a PostgreSQL 9.4+ datatype. - `db/migrate/20160915105234_add_transformation_to_o_auth2_mapping.rb` is using [jsonb](https://www.postgresql.org/docs/9.4/static/datatype-json.html), a PostgreSQL 9.4+ datatype. - `db/migrate/20181217103441_migrate_settings_value_to_history_values.rb` is using `SELECT DISTINCT ON`. + - `db/migrate/20190107111749_protect_accounting_periods.rb` is using `CREATE RULE` and `DROP RULE`. - If you intend to contribute to the project code, you will need to run the test suite with `rake test`. This also requires your user to have the _SUPERUSER_ role. Please see the [known issues](#known-issues) section for more information about this. diff --git a/app/assets/javascripts/controllers/admin/invoices.js.erb b/app/assets/javascripts/controllers/admin/invoices.js.erb index bbef9d26a..4d577ecab 100644 --- a/app/assets/javascripts/controllers/admin/invoices.js.erb +++ b/app/assets/javascripts/controllers/admin/invoices.js.erb @@ -17,8 +17,8 @@ /** * Controller used in the admin invoices listing page */ -Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'Invoice', 'invoices', '$uibModal', 'growl', '$filter', 'Setting', 'settings', '_t', - function ($scope, $state, Invoice, invoices, $uibModal, growl, $filter, Setting, settings, _t) { +Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'Invoice', 'AccountingPeriod', 'invoices', 'closedPeriods', '$uibModal', 'growl', '$filter', 'Setting', 'settings', '_t', + function ($scope, $state, Invoice, AccountingPeriod, invoices, closedPeriods, $uibModal, growl, $filter, Setting, settings, _t) { /* PRIVATE STATIC CONSTANTS */ // number of invoices loaded each time we click on 'load more...' @@ -105,12 +105,13 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I * @param invoice {Object} invoice inherited from angular's $resource */ $scope.generateAvoirForInvoice = function (invoice) { - // open modal + // open modal const modalInstance = $uibModal.open({ templateUrl: '<%= asset_path "admin/invoices/avoirModal.html" %>', controller: 'AvoirModalController', resolve: { - invoice () { return invoice; } + invoice () { return invoice; }, + closedPeriods() { return AccountingPeriod.query().$promise; } } }); @@ -119,7 +120,7 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I $scope.invoices.unshift(res.avoir); return Invoice.get({ id: invoice.id }, function (data) { invoice.has_avoir = data.has_avoir; - return growl.success(_t('refund_invoice_successfully_created')); + return growl.success(_t('invoices.refund_invoice_successfully_created')); }); }); }; @@ -193,10 +194,10 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I modalInstance.result.then(function (model) { Setting.update({ name: 'invoice_reference' }, { value: model }, function (data) { $scope.invoice.reference.model = model; - growl.success(_t('invoice_reference_successfully_saved')); + growl.success(_t('invoices.invoice_reference_successfully_saved')); } , function (error) { - growl.error(_t('an_error_occurred_while_saving_invoice_reference')); + growl.error(_t('invoices.an_error_occurred_while_saving_invoice_reference')); console.error(error); }); }); @@ -231,24 +232,24 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I Setting.update({ name: 'invoice_code-value' }, { value: result.model }, function (data) { $scope.invoice.code.model = result.model; if (result.active) { - return growl.success(_t('invoicing_code_succesfully_saved')); + return growl.success(_t('invoices.invoicing_code_succesfully_saved')); } } , function (error) { - growl.error(_t('an_error_occurred_while_saving_the_invoicing_code')); + growl.error(_t('invoices.an_error_occurred_while_saving_the_invoicing_code')); return console.error(error); }); return Setting.update({ name: 'invoice_code-active' }, { value: result.active ? 'true' : 'false' }, function (data) { $scope.invoice.code.active = result.active; if (result.active) { - return growl.success(_t('code_successfully_activated')); + return growl.success(_t('invoices.code_successfully_activated')); } else { - return growl.success(_t('code_successfully_disabled')); + return growl.success(_t('invoices.code_successfully_disabled')); } } , function (error) { - growl.error(_t('an_error_occurred_while_activating_the_invoicing_code')); + growl.error(_t('invoices.an_error_occurred_while_activating_the_invoicing_code')); return console.error(error); }); }); @@ -277,10 +278,10 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I return modalInstance.result.then(function (model) { Setting.update({ name: 'invoice_order-nb' }, { value: model }, function (data) { $scope.invoice.number.model = model; - return growl.success(_t('order_number_successfully_saved')); + return growl.success(_t('invoices.order_number_successfully_saved')); } , function (error) { - growl.error(_t('an_error_occurred_while_saving_the_order_number')); + growl.error(_t('invoices.an_error_occurred_while_saving_the_order_number')); return console.error(error); }); }); @@ -316,24 +317,24 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I Setting.update({ name: 'invoice_VAT-rate' }, { value: result.rate + '' }, function (data) { $scope.invoice.VAT.rate = result.rate; if (result.active) { - return growl.success(_t('VAT_rate_successfully_saved')); + return growl.success(_t('invoices.VAT_rate_successfully_saved')); } } , function (error) { - growl.error(_t('an_error_occurred_while_saving_the_VAT_rate')); + growl.error(_t('invoices.an_error_occurred_while_saving_the_VAT_rate')); return console.error(error); }); return Setting.update({ name: 'invoice_VAT-active' }, { value: result.active ? 'true' : 'false' }, function (data) { $scope.invoice.VAT.active = result.active; if (result.active) { - return growl.success(_t('VAT_successfully_activated')); + return growl.success(_t('invoices.VAT_successfully_activated')); } else { - return growl.success(_t('VAT_successfully_disabled')); + return growl.success(_t('invoices.VAT_successfully_disabled')); } } , function (error) { - growl.error(_t('an_error_occurred_while_activating_the_VAT')); + growl.error(_t('invoices.an_error_occurred_while_activating_the_VAT')); return console.error(error); }); }); @@ -346,10 +347,10 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I const parsed = parseHtml($scope.invoice.text.content); return Setting.update({ name: 'invoice_text' }, { value: parsed }, function (data) { $scope.invoice.text.content = parsed; - return growl.success(_t('text_successfully_saved')); + return growl.success(_t('invoices.text_successfully_saved')); } , function (error) { - growl.error(_t('an_error_occurred_while_saving_the_text')); + growl.error(_t('invoices.an_error_occurred_while_saving_the_text')); return console.error(error); }); }; @@ -361,10 +362,10 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I const parsed = parseHtml($scope.invoice.legals.content); return Setting.update({ name: 'invoice_legals' }, { value: parsed }, function (data) { $scope.invoice.legals.content = parsed; - return growl.success(_t('address_and_legal_information_successfully_saved')); + return growl.success(_t('invoices.address_and_legal_information_successfully_saved')); } , function (error) { - growl.error(_t('an_error_occurred_while_saving_the_address_and_the_legal_information')); + growl.error(_t('invoices.an_error_occurred_while_saving_the_address_and_the_legal_information')); return console.error(error); }); }; @@ -387,6 +388,37 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I return invoiceSearch(true); }; + /** + * Open a modal allowing the user to close an accounting period and to + * view all periods already closed. + */ + $scope.closeAnAccountingPeriod = function() { + // open modal + $uibModal.open({ + templateUrl: '<%= asset_path "admin/invoices/closePeriodModal.html" %>', + controller: 'ClosePeriodModalController', + size: 'lg', + resolve: { + periods() { return AccountingPeriod.query().$promise; }, + lastClosingEnd() { return AccountingPeriod.lastClosingEnd().$promise; }, + } + }); + } + + /** + * Test if the given date is within a closed accounting period + * @param date {Date} date to test + * @returns {boolean} true if closed, false otherwise + */ + $scope.isDateClosed = function(date) { + for (const period of closedPeriods) { + if (moment(date).isBetween(moment.utc(period.start_at).startOf('day'), moment.utc(period.end_at).endOf('day'), null, '[]')) { + return true; + } + } + return false; + } + /* PRIVATE SCOPE */ /** @@ -418,9 +450,9 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I return Setting.update( { name: 'invoice_logo' }, { value: $scope.invoice.logo.base64 }, - function (data) { growl.success(_t('logo_successfully_saved')); }, + function (data) { growl.success(_t('invoices.logo_successfully_saved')); }, function (error) { - growl.error(_t('an_error_occurred_while_saving_the_logo')); + growl.error(_t('invoices.an_error_occurred_while_saving_the_logo')); return console.error(error); } ); @@ -496,9 +528,9 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I /** * Controller used in the invoice refunding modal window */ -Application.Controllers.controller('AvoirModalController', ['$scope', '$uibModalInstance', 'invoice', 'Invoice', 'growl', '_t', - function ($scope, $uibModalInstance, invoice, Invoice, growl, _t) { - /* PUBLIC SCOPE */ +Application.Controllers.controller('AvoirModalController', ['$scope', '$uibModalInstance', 'invoice', 'closedPeriods', 'Invoice', 'growl', '_t', + function ($scope, $uibModalInstance, invoice, closedPeriods, Invoice, growl, _t) { + /* PUBLIC SCOPE */ // invoice linked to the current refund $scope.invoice = invoice; @@ -515,11 +547,11 @@ Application.Controllers.controller('AvoirModalController', ['$scope', '$uibModal // Possible refunding methods $scope.avoirModes = [ - { name: _t('none'), value: 'none' }, - { name: _t('by_cash'), value: 'cash' }, - { name: _t('by_cheque'), value: 'cheque' }, - { name: _t('by_transfer'), value: 'transfer' }, - { name: _t('by_wallet'), value: 'wallet' } + { name: _t('invoices.none'), value: 'none' }, + { name: _t('invoices.by_cash'), value: 'cash' }, + { name: _t('invoices.by_cheque'), value: 'cheque' }, + { name: _t('invoices.by_transfer'), value: 'transfer' }, + { name: _t('invoices.by_wallet'), value: 'wallet' } ]; // If a subscription was took with the current invoice, should it be canceled or not @@ -542,14 +574,14 @@ Application.Controllers.controller('AvoirModalController', ['$scope', '$uibModal $scope.openDatePicker = function ($event) { $event.preventDefault(); $event.stopPropagation(); - return $scope.datePicker.opened = true; + $scope.datePicker.opened = true; }; /** * Validate the refunding and generate a refund invoice */ $scope.ok = function () { - // check that at least 1 element of the invoice is refunded + // check that at least 1 element of the invoice is refunded $scope.avoir.invoice_items_ids = []; for (let itemId in $scope.partial) { const refundItem = $scope.partial[itemId]; @@ -557,7 +589,7 @@ Application.Controllers.controller('AvoirModalController', ['$scope', '$uibModal } if ($scope.avoir.invoice_items_ids.length === 0) { - return growl.error(_t('you_must_select_at_least_one_element_to_create_a_refund')); + return growl.error(_t('invoices.you_must_select_at_least_one_element_to_create_a_refund')); } else { return Invoice.save( { avoir: $scope.avoir }, @@ -565,17 +597,31 @@ Application.Controllers.controller('AvoirModalController', ['$scope', '$uibModal $uibModalInstance.close({ avoir, invoice: $scope.invoice }); }, function (err) { // failed - growl.error(_t('unable_to_create_the_refund')); + growl.error(_t('invoices.unable_to_create_the_refund')); } ); } }; - /**q + /** * Cancel the refund, dismiss the modal window */ $scope.cancel = function () { $uibModalInstance.dismiss('cancel'); }; + /** + * Test if the given date is within a closed accounting period + * @param date {Date} date to test + * @returns {boolean} true if closed, false otherwise + */ + $scope.isDateClosed = function(date) { + for (const period of closedPeriods) { + if (moment(date).isBetween(moment.utc(period.start_at).startOf('day'), moment.utc(period.end_at).endOf('day'), null, '[]')) { + return true; + } + } + return false; + } + /* PRIVATE SCOPE */ /** @@ -592,7 +638,7 @@ Application.Controllers.controller('AvoirModalController', ['$scope', '$uibModal }); if (invoice.stripe) { - return $scope.avoirModes.push({ name: _t('online_payment'), value: 'stripe' }); + return $scope.avoirModes.push({ name: _t('invoices.online_payment'), value: 'stripe' }); } }; @@ -600,3 +646,90 @@ Application.Controllers.controller('AvoirModalController', ['$scope', '$uibModal return initialize(); } ]); + + +/** + * Controller used in the modal window allowing an admin to close an accounting period + */ +Application.Controllers.controller('ClosePeriodModalController', ['$scope', '$uibModalInstance', 'Invoice', 'AccountingPeriod', 'periods', 'lastClosingEnd','dialogs', 'growl', '_t', + function ($scope, $uibModalInstance, Invoice, AccountingPeriod, periods, lastClosingEnd, dialogs, growl, _t) { + const YESTERDAY = moment.utc({ h: 0, m: 0, s: 0, ms: 0 }).subtract(1, 'day').toDate(); + const LAST_CLOSING = moment.utc(lastClosingEnd.last_end_date).toDate(); + + /* PUBLIC SCOPE */ + + // date pickers values are bound to these variables + $scope.period = { + start_at: LAST_CLOSING, + end_at: YESTERDAY + }; + + // any form errors will come here + $scope.errors = {}; + + // existing closed periods, provided by the API + $scope.accountingPeriods = periods; + + // AngularUI-Bootstrap datepickers parameters to define the period to close + $scope.datePicker = { + format: Fablab.uibDateFormat, + // default: datePicker are not shown + startOpened: false, + endOpened: false, + minDate: LAST_CLOSING, + maxDate: YESTERDAY, + options: { + startingDay: Fablab.weekStartingDay + } + }; + + /** + * Callback to open the datepicker + */ + $scope.openDatePicker = function ($event, pickerId) { + $event.preventDefault(); + $event.stopPropagation(); + $scope.datePicker[`${pickerId}Opened`] = true; + }; + + /** + * Validate the close period creation + */ + $scope.ok = function () { + dialogs.confirm( + { + resolve: { + object () { + return { + title: _t('invoices.confirmation_required'), + msg: _t( + 'invoices.confirm_close_START_END', + { START: moment($scope.period.start_at).format('LL'), END: moment($scope.period.end_at).format('LL') } + ) + }; + } + } + }, + function () { // creation confirmed + AccountingPeriod.save({ accounting_period: $scope.period }, function (resp) { + growl.success(_t( + 'invoices.period_START_END_closed_success', + { START: moment(resp.start_at).format('LL'), END: moment(resp.end_at).format('LL') } + )); + $uibModalInstance.close(resp); + } + , function(error) { + growl.error(_t('invoices.failed_to_close_period')); + $scope.errors = error.data; + }); + } + ); + + }; + + /** + * Cancel the refund, dismiss the modal window + */ + $scope.cancel = function () { $uibModalInstance.dismiss('cancel'); }; + } +]); diff --git a/app/assets/javascripts/controllers/admin/statistics.js.erb b/app/assets/javascripts/controllers/admin/statistics.js.erb index 61f7a436d..d72ff1fca 100644 --- a/app/assets/javascripts/controllers/admin/statistics.js.erb +++ b/app/assets/javascripts/controllers/admin/statistics.js.erb @@ -94,9 +94,9 @@ Application.Controllers.controller('StatisticsController', ['$scope', '$state', minDate: null, maxDate: moment().toDate(), options: { - startingDay: 1 + startingDay: Fablab.weekStartingDay } - } // France: the week starts on monday + } }; // available custom filters diff --git a/app/assets/javascripts/router.js.erb b/app/assets/javascripts/router.js.erb index d0a256845..95bd0f3eb 100644 --- a/app/assets/javascripts/router.js.erb +++ b/app/assets/javascripts/router.js.erb @@ -885,6 +885,7 @@ angular.module('application.router', ['ui.router']) query: { number: '', customer: '', date: null, order_by: '-reference', page: 1, size: 20 } }).$promise; }], + closedPeriods: [ 'AccountingPeriod', function(AccountingPeriod) { return AccountingPeriod.query().$promise; }], translations: ['Translations', function (Translations) { return Translations.query('app.admin.invoices').$promise; }] } }) diff --git a/app/assets/javascripts/services/accounting_period.js b/app/assets/javascripts/services/accounting_period.js new file mode 100644 index 000000000..9ffb9e03a --- /dev/null +++ b/app/assets/javascripts/services/accounting_period.js @@ -0,0 +1,12 @@ +'use strict'; + +Application.Services.factory('AccountingPeriod', ['$resource', function ($resource) { + return $resource('/api/accounting_periods/:id', + { id: '@id' }, { + lastClosingEnd: { + method: 'GET', + url: '/api/accounting_periods/last_closing_end' + } + } + ); +}]); diff --git a/app/assets/stylesheets/app.components.scss b/app/assets/stylesheets/app.components.scss index 66fffacab..f04f147b6 100644 --- a/app/assets/stylesheets/app.components.scss +++ b/app/assets/stylesheets/app.components.scss @@ -616,4 +616,8 @@ padding: 10px; & > i.fileinput-exists { margin-right: 5px; } -} \ No newline at end of file +} + +.help-block.error { + color: #ff565d; +} diff --git a/app/assets/stylesheets/modules/invoice.scss b/app/assets/stylesheets/modules/invoice.scss index 452138f54..682456da9 100644 --- a/app/assets/stylesheets/modules/invoice.scss +++ b/app/assets/stylesheets/modules/invoice.scss @@ -178,4 +178,42 @@ border-radius: 5px; font-size: small; } -} \ No newline at end of file +} + +table.closings-table { + width: 100%; + border-spacing: 0; + + thead, tbody, tr, th, td { display: block; } + + thead tr { + /* fallback */ + width: 97%; + /* minus scroll bar width */ + width: -webkit-calc(100% - 16px); + width: -moz-calc(100% - 16px); + width: calc(100% - 16px); + } + + thead tr th { + border-bottom: 0; + } + + tr:after { /* clearing float */ + content: ' '; + display: block; + visibility: hidden; + clear: both; + } + + tbody { + height: 200px; + overflow-y: auto; + overflow-x: hidden; + } + + tbody td, thead th { + width: 24%; /* 24% is less than (100% / 4 cols) = 25% */ + float: left; + } +} diff --git a/app/assets/templates/admin/invoices/avoirModal.html.erb b/app/assets/templates/admin/invoices/avoirModal.html.erb index 56e72b16c..4dfae71b1 100644 --- a/app/assets/templates/admin/invoices/avoirModal.html.erb +++ b/app/assets/templates/admin/invoices/avoirModal.html.erb @@ -1,10 +1,10 @@
{{ 'invoices.start_date' }} | +{{ 'invoices.end_date' }} | +{{ 'invoices.closed_at' }} | +{{ 'invoices.closed_by' }} | +
---|---|---|---|
{{period.start_at | amDateFormat:'L'}} | +{{period.end_at | amDateFormat:'L'}} | +{{period.closed_at | amDateFormat:'L'}} | +{{period.user_name}} | +
{{ 'no_invoices_for_now' }}
+{{ 'invoices.no_invoices_for_now' }}