// vim: set tw=80 ts=4 sw=4 sts=4: /* This file is part of Accountant. Accountant is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Accountant is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with Accountant. If not, see . */ /* jshint node: true */ 'use strict'; var moment = require('moment'), Highcharts = require('highstock-release'); var angular = require('angular'); require('../views/operation.form.tmpl.html'); var ngResource = require('angular-resource'), ngMessages = require('angular-messages'), ngUiNotification = require('angular-ui-notification'), ngBootbox = require('ngbootbox'), ngStrap = require('angular-strap'); // Note: ngBootbox seems to have no module.exports. ngBootbox = 'ngBootbox'; var accountModule = require('./accounts'); var operationModule = angular.module('accountant.operations', [ accountModule.name, ngResource, ngMessages, ngUiNotification, ngBootbox, ngStrap ]) .config(function($resourceProvider) { // Keep trailing slashes to avoid redirect by flask.. $resourceProvider.defaults.stripTrailingSlashes = false; }) .factory('Operation', function($resource) { return $resource( '/api/operation/:id', { id: '@id' } ); }) .factory('OHLC', function($resource, $routeParams) { return $resource( '/api/account/:account_id/ohlc', { // eslint-disable-next-line camelcase account_id: $routeParams.accountId } ); }) .factory('Category', function($resource, $routeParams) { return $resource( '/api/account/:account_id/category', { // eslint-disable-next-line camelcase account_id: $routeParams.accountId } ); }) .factory('Balance', function($resource, $routeParams) { return $resource( '/api/account/:account_id/balance', { // eslint-disable-next-line camelcase account_id: $routeParams.accountId } ); }) /* * Controller for category chart. */ .controller('CategoryChartController', function($rootScope, Category, Balance) { var vm = this; var colors = Highcharts.getOptions().colors; vm.revenueColor = colors[2]; vm.expenseColor = colors[3]; // Configure pie chart for categories. vm.config = { options: { chart: { type: 'pie', animation: { duration: 500 } }, plotOptions: { pie: { startAngle: -90 }, series: { allowPointSelect: 0 } }, tooltip: { valueDecimals: 2, valueSuffix: '€' } }, yAxis: { title: { text: 'Categories' } }, title: { text: 'Répartition dépenses/recettes' }, series: [{ name: 'Value', data: [], innerSize: '33%', size: '60%', dataLabels: { formatter: function() { // eslint-disable-next-line angular/controller-as-vm return this.point.name; }, distance: -40 } }, { name: 'Value', data: [], innerSize: '66%', size: '60%', dataLabels: { formatter: function() { // eslint-disable-next-line angular/controller-as-vm if (this.point.name !== null && this.percentage >= 2.5) { // eslint-disable-next-line angular/controller-as-vm return this.point.name; } return null; } } }] }; vm.brightenColor = function(color) { var brightness = 0.2; // eslint-disable-next-line new-cap return Highcharts.Color(color).brighten(brightness).get(); }; // Load categories, mainly to populate the pie chart. vm.load = function(begin, end) { vm.config.loading = true; Category.query({ begin: begin.format('YYYY-MM-DD'), end: end.format('YYYY-MM-DD') }, function(data) { var expenses = []; var revenues = []; var expenseColor = vm.brightenColor(vm.expenseColor); var revenueColor = vm.brightenColor(vm.revenueColor); angular.forEach(angular.fromJson(data), function(category) { expenses.push({ name: category.category, y: -category.expenses, color: expenseColor }); revenues.push({ name: category.category, y: category.revenues, color: revenueColor }); }); // Note: expenses and revenues must be in the same order than in series[0]. vm.config.series[1].data = revenues.concat(expenses); vm.config.loading = false; }); }; /* * Get account balance. */ vm.getBalance = function(begin, end) { Balance.get({ begin: begin.format('YYYY-MM-DD'), end: end.format('YYYY-MM-DD') }, function(balance) { // Update pie chart subtitle with Balance. vm.config.subtitle = { text: 'Balance: ' + balance.balance }; vm.config.series[0].data = [{ name: 'Revenues', y: balance.revenues, color: vm.revenueColor }, { name: 'Expenses', y: -balance.expenses, color: vm.expenseColor }]; }); }; // Reload categories and account status on range selection. vm.onRangeSelected = $rootScope.$on('rangeSelectedEvent', function(e, args) { vm.load(args.begin, args.end); vm.getBalance(args.begin, args.end); }); $rootScope.$on('$destroy', function(){ vm.onRangeSelected = angular.noop(); }); } ) /* * Controller for the sold chart. */ .controller('SoldChartController', function($rootScope, $scope, OHLC) { var vm = this; // Configure chart for operations. vm.config = { options: { chart: { zoomType: 'x' }, rangeSelector: { buttons: [{ type: 'month', count: 1, text: '1m' }, { type: 'month', count: 3, text: '3m' }, { type: 'month', count: 6, text: '6m' }, { type: 'year', count: 1, text: '1y' }, { type: 'all', text: 'All' }], selected: 0 }, navigator: { enabled: true }, tooltip: { crosshairs: true, shared: true, valueDecimals: 2, valueSuffix: '€' }, scrollbar: { liveRedraw: false } }, series: [{ type: 'ohlc', name: 'Sold', data: [], dataGrouping: { units: [[ 'week', // unit name [1] // allowed multiples ], [ 'month', [1, 2, 3, 4, 6] ]] } }], title: { text: 'Sold evolution' }, xAxis: { type: 'datetime', dateTimeLabelFormats: { month: '%e. %b', year: '%Y' }, minRange: 3600 * 1000 * 24 * 14, // 2 weeks events: { afterSetExtremes: function(e) { $scope.$emit('rangeSelectedEvent', { begin: moment.utc(e.min), end: moment.utc(e.max) }); } }, currentMin: moment.utc().startOf('month'), currentMax: moment.utc().endOf('month') }, yAxis: { plotLines: [{ color: 'orange', width: 2, value: 0.0 }, { color: 'red', width: 2, value: 0.0 }] }, useHighStocks: true }; vm.loadSolds = function() { vm.config.loading = true; OHLC.query({}, function(data) { vm.config.series[0].data = []; angular.forEach(data, function(operation) { vm.config.series[0].data.push([ moment.utc(operation.operation_date).valueOf(), operation.open, operation.high, operation.low, operation.close ]); }); $scope.$emit('rangeSelectedEvent', { begin: vm.config.xAxis.currentMin, end: vm.config.xAxis.currentMax }); vm.config.loading = false; }); }; // Reload solds when an operation is saved. vm.onOperationSaved = $rootScope.$on('operationSavedEvent', function() { vm.loadSolds(); }); // Reload solds when an operation is deleted. vm.onOperationDeleted = $rootScope.$on('operationDeletedEvent', function() { vm.loadSolds(); }); // Update authorized overdraft on account loading. vm.onAccountLoaded = $rootScope.$on('accountLoadedEvent', function(e, account) { vm.config.yAxis.plotLines[1].value = account.authorized_overdraft; }); $rootScope.$on('$destroy', function() { vm.onOperationSaved = angular.noop(); vm.onOperationDeleted = angular.noop(); vm.onAccountLoaded = angular.noop(); }); // Select beginning and end of month. vm.loadSolds(); }) /* * Controller for the operations. */ .controller('OperationController', function($rootScope, $scope, $routeParams, $ngBootbox, Notification, Account, Operation) { var vm = this; // List of operations. vm.operations = []; /* * Add an empty operation. */ vm.add = function() { var operation = new Operation({ // eslint-disable-next-line camelcase account_id: $routeParams.accountId }); vm.operations.splice(0, 0, operation); }; /* * Load operations. */ vm.load = function(begin, end) { vm.operations = Operation.query({ // eslint-disable-next-line camelcase account_id: $routeParams.accountId, begin: begin.format('YYYY-MM-DD'), end: end.format('YYYY-MM-DD') }); }; /* * Cancel edition. */ vm.cancelEdit = function(operation, rowform, $index) { if (operation.id) { rowform.$cancel(); } else { vm.operations.splice($index, 1); } }; /* * Toggle pointed indicator for an operation. */ vm.togglePointed = function(operation, rowform) { operation.pointed = !operation.pointed; // Save operation if not editing it. if (!rowform.$visible) { vm.save(operation); } }; /* * Toggle cancel indicator for an operation. */ vm.toggleCanceled = function(operation) { operation.canceled = !operation.canceled; vm.save(operation); }; /* * Save an operation and emit operationSavedEvent. */ vm.save = function($data, $index) { // Check if $data is already a resource. var operation; if ($data.$save) { operation = $data; } else { operation = vm.operations[$index]; operation = angular.merge(operation, $data); } operation.confirmed = true; return operation.$save().then(function(data) { Notification.success('Operation #' + data.id + ' saved.'); $scope.$emit('operationSavedEvent', data); }); }; /* * Delete an operation and emit operationDeletedEvent. */ vm.delete = function(operation, $index) { var id = operation.id; $ngBootbox.confirm( 'Voulez-vous supprimer l\'opération \\\'' + operation.label + '\\\' ?', function(result) { if (result) { operation.$delete().then(function() { Notification.success('Operation #' + id + ' deleted.'); // Remove operation from array. vm.operation.splice($index, 1); $scope.$emit('operationDeletedEvent', operation); }); } } ); }; vm.account = Account.get({ id: $routeParams.accountId }); /* * Reload operations on rangeSelectedEvent. */ vm.onRangeSelected = $rootScope.$on('rangeSelectedEvent', function(e, args) { vm.load(args.begin, args.end); }); $rootScope.$on('$destroy', function() { vm.onRangeSelected = angular.noop; }); }) .directive('operationFormDialog', function($log, $ngBootbox) { return { restrict: 'A', scope: { operation: '=ngModel' }, link: function(scope, element) { var title = 'Operation'; if (scope.operation && scope.operation.id) { title = title + ' #' + scope.operation.id; } scope.form = {}; element.on('click', function() { scope.data = {}; angular.copy(scope.operation, scope.data); // Open dialog with form. $ngBootbox.customDialog({ scope: scope, title: title, templateUrl: '/views/operation.form.tmpl.html', onEscape: true, buttons: { save: { label: 'Save', className: 'btn-success', callback: function() { // Validate form $log.log(scope.form); // Save operation $log.log(scope.operation); // TODO Alexis Lahouze 2016-05-24 Save operation, handle return. return false; } }, cancel: { label: 'Cancel', className: 'btn-default', callback: true } } }); }); } }; }); module.exports = operationModule;