diff --git a/package.json b/package.json index ce156e2..b82bdf2 100644 --- a/package.json +++ b/package.json @@ -6,50 +6,62 @@ "devDependencies": { "babel-core": "^6.25.0", "babel-eslint": "^7.2.3", - "babel-loader": "^7.0.0", - "bootstrap-webpack": "^0.0.6", + "babel-loader": "^7.1.1", "css-loader": "^0.28.4", - "eslint": "^4.1.1", - "eslint-config-angular": "^0.5", - "eslint-config-webpack": "^1.2.3", - "eslint-loader": "^1.7.1", + "eslint": "^4.4.1", + "eslint-config-angular": "^0.5.0", + "eslint-config-webpack": "^1.2.5", + "eslint-loader": "^1.9.0", "eslint-plugin-angular": "^3.0.0", - "eslint-plugin-html": "^3.0.0", - "eslint-plugin-jquery": "^1.2", - "eslint-plugin-promise": "^3.5", - "eslint-plugin-security": "^1.3", - "eslint-plugin-this": "^0.2", - "extract-text-webpack-plugin": "^2.1.2", + "eslint-plugin-html": "^3.2.0", + "eslint-plugin-jquery": "^1.2.1", + "eslint-plugin-promise": "^3.5.0", + "eslint-plugin-security": "^1.4.0", + "eslint-plugin-this": "^0.2.2", + "extract-text-webpack-plugin": "^3.0.0", "file-loader": "^0.11.2", - "html-loader": "^0.4.5", - "html-webpack-plugin": "^2.28.0", + "html-loader": "^0.5.1", + "html-webpack-plugin": "^2.30.1", "htmllint-loader": "^1.3.8", "imports-loader": "^0.7.1", "less": "^2.7.2", - "less-loader": "^4.0.4", - "ngtemplate-loader": "^2.0.0", + "less-loader": "^4.0.5", + "loglevel": "^1.4.1", + "ngtemplate-loader": "^2.0.1", + "node-sass": "^4.5.3", + "sass-loader": "^6.0.6", "style-loader": "^0.18.2", - "url-loader": "^0.5.8", - "webpack": "^3.1.0", - "webpack-dev-server": "^2.5.1" + "ts-loader": "^2.3.2", + "typescript": "^2.4.2", + "url-loader": "^0.5.9", + "webpack": "^3.5.4", + "webpack-dev-server": "^2.7.1" }, "dependencies": { - "angular": "^1.6", - "angular-http-auth": "^1.5", - "angular-messages": "^1.6", - "angular-resource": "^1.6", - "angular-route": "^1.6.5", - "angular-strap": "^2.3.12", - "angular-ui-notification": "^0.3", + "@angular/animations": "^4.3.4", + "@angular/common": "^4.3.4", + "@angular/compiler": "^4.3.4", + "@angular/core": "^4.3.4", + "@angular/forms": "^4.3.4", + "@angular/http": "^4.3.4", + "@angular/platform-browser": "^4.3.4", + "@angular/platform-browser-dynamic": "^4.3.4", + "@angular/router": "^4.3.4", + "@ng-bootstrap/ng-bootstrap": "^1.0.0-beta.1", + "@nsalaun/ng-logger": "^2.0.1", + "@types/c3": "^0.4.44", + "@types/node": "^8.0.20", + "angular2-text-mask": "^8.0.3", "base64util": "^1.0.2", - "bootbox": "^4.4.0", - "bootstrap": "^3.3.7", - "bootstrap-additions": "^0.3.1", - "c3": "^0.4.13", + "bootstrap": "4.0.0-beta", + "c3": "^0.4.15", "font-awesome": "^4.7.0", - "jquery": "^3.2", - "meanie-angular-storage": "^1.3.1", - "moment": "^2.18" + "jquery": "^3.2.1", + "moment": "^2.18.1", + "ngx-toastr": "^6.0.1", + "reflect-metadata": "^0.1.10", + "rxjs": "^5.4.3", + "zone.js": "^0.8.16" }, "scripts": { "build": "webpack --config webpack.config.js", diff --git a/src/accounts/account.config.js b/src/accounts/account.config.js deleted file mode 100644 index e3d412b..0000000 --- a/src/accounts/account.config.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = function($resourceProvider) { - // Keep trailing slashes to avoid redirect by flask. - $resourceProvider.defaults.stripTrailingSlashes = false; -}; diff --git a/src/accounts/account.controller.js b/src/accounts/account.controller.js deleted file mode 100644 index 73ea57c..0000000 --- a/src/accounts/account.controller.js +++ /dev/null @@ -1,156 +0,0 @@ -var accountFormTmpl = require('./account.form.tmpl.html'), - accountDeleteTmpl = require('./account.delete.tmpl.html'); - -module.exports = function(Account, AccountBalances, Notification, $log, $modal) { - var vm = this; - - /* - * Return the class for an account current value compared to authorized - * overdraft. - */ - vm.rowClass = function(account) { - // eslint-disable-next-line camelcase - if (!account || !account.authorized_overdraft || !account.current) { - return; - } - - // eslint-disable-next-line camelcase - if (account.current < account.authorized_overdraft) { - return 'danger'; - } else if (account.current < 0) { - return 'warning'; - } - }; - - /* - * Return the class for a value compared to account authorized overdraft. - */ - vm.valueClass = function(account, value) { - if (!account || !value) { - return; - } - - // eslint-disable-next-line camelcase - if (value < account.authorized_overdraft) { - return 'text-danger'; - } else if (value < 0) { - return 'text-warning'; - } - }; - - vm.load = function() { - vm.accounts = Account.query({}, function(result) { - return result.map(function(item) { - item.balances = AccountBalances.get({id: item.id}); - return item; - }); - }); - }; - - /* - * Add an empty account. - */ - vm.add = function() { - var account = new Account({ - // eslint-disable-next-line camelcase - authorized_overdraft: 0 - }); - - // Insert account at the begining of the array. - return vm.modify(account); - }; - - /* - * Save account. - */ - vm.save = function(account) { - return account.$save().then(function(data) { - Notification.success('Account #' + data.id + ' saved.'); - - vm.load(); - - return data; - }, function(result){ - $log.error('Error while saving account', account, result); - - Notification.error( - 'Error while saving account: ' + result.message - ); - }); - }; - - vm.confirmDelete = function(account) { - var title = "Delete account #" + account.id; - - $modal({ - templateUrl: accountDeleteTmpl, - controller: function($scope, title, account, $delete) { - $scope.title = title; - $scope.account = account; - $scope.$delete = function() { - $scope.$hide(); - $delete($scope.account); - }; - }, - locals: { - title: title, - account: account, - $delete: vm.delete - } - }); - }; - - /* - * Delete an account. - */ - vm.delete = function(account) { - var id = account.id; - - return account.$delete().then(function() { - Notification.success('account #' + id + ' deleted.'); - - vm.load(); - - return account; - }, function(result) { - Notification.error( - 'An error occurred while trying to delete account #' + - id + ':
' + result - ); - }); - }; - - /* - * Open the popup to modify the account, save it on confirm. - */ - vm.modify = function(account) { - // FIXME Alexis Lahouze 2017-06-15 i18n - var title = "Account"; - - if (account.id) { - title = title + " #" + account.id; - } - - $modal({ - templateUrl: accountFormTmpl, - controller: function($scope, title, account, $save) { - $scope.title = title; - $scope.account = account; - $scope.account.authorized_overdraft *= -1; - $scope.$save = function() { - $scope.$hide(); - $scope.account.authorized_overdraft *= -1; - $save($scope.account); - }; - }, - locals: { - title: title, - account: account, - $save: vm.save - } - }); - }; - - // Load accounts. - vm.load(); -}; diff --git a/src/accounts/account.delete.tmpl.html b/src/accounts/account.delete.tmpl.html deleted file mode 100644 index b1f4bb5..0000000 --- a/src/accounts/account.delete.tmpl.html +++ /dev/null @@ -1,23 +0,0 @@ - diff --git a/src/accounts/account.factory.js b/src/accounts/account.factory.js deleted file mode 100644 index 1f24176..0000000 --- a/src/accounts/account.factory.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = function($resource) { - return $resource( - '/api/account/:id', { - id: '@id' - } - ); -}; diff --git a/src/accounts/account.form.tmpl.html b/src/accounts/account.form.tmpl.html deleted file mode 100644 index 9963fd1..0000000 --- a/src/accounts/account.form.tmpl.html +++ /dev/null @@ -1,72 +0,0 @@ - - - diff --git a/src/accounts/account.module.ts b/src/accounts/account.module.ts new file mode 100644 index 0000000..735b174 --- /dev/null +++ b/src/accounts/account.module.ts @@ -0,0 +1,53 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule } from '@angular/forms'; +import { HttpClientModule } from '@angular/common/http'; +import { RouterModule } from '@angular/router'; + +import { NgLoggerModule, Level } from '@nsalaun/ng-logger'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { ToastrModule } from 'ngx-toastr'; + +import { AccountService } from './account.service'; +import { AccountBalancesService } from './accountBalances.service'; +import { AccountListComponent } from './accountList.component'; +import { AccountDeleteModalComponent } from './accountDeleteModal.component'; +import { AccountEditModalComponent } from './accountEditModal.component'; +import { AccountFormComponent } from './accountForm.component'; +import { AccountRowComponent } from './accountRow.component'; +import { DailyBalanceService } from './dailyBalance.service'; +import { AccountListState } from './account.states' + +@NgModule({ + imports: [ + HttpClientModule, + CommonModule, + ReactiveFormsModule, + RouterModule.forChild([ + AccountListState + ]), + NgLoggerModule, + ToastrModule, + NgbModule + ], + providers: [ + AccountService, + AccountBalancesService, + DailyBalanceService, + ], + declarations: [ + AccountListComponent, + AccountDeleteModalComponent, + AccountEditModalComponent, + AccountFormComponent, + AccountRowComponent + ], + entryComponents: [ + AccountListComponent, + AccountDeleteModalComponent, + AccountEditModalComponent, + ] +}) +export class AccountModule {} diff --git a/src/accounts/account.service.ts b/src/accounts/account.service.ts new file mode 100644 index 0000000..74b55eb --- /dev/null +++ b/src/accounts/account.service.ts @@ -0,0 +1,42 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +import { Observable } from 'rxjs/Rx'; + +import { Account } from './account'; + +@Injectable() +export class AccountService { + constructor( + private http: HttpClient + ) {} + + private url(id?: Number): string { + if(id) { + return `/api/account/${id}`; + } + + return `/api/account`; + } + + query(): Observable { + return this.http.get(this.url()); + } + + get(id: number): Observable { + return this.http.get(this.url(id)); + } + + create(account: Account): Observable { + return this.http.post(this.url(), account); + } + + update(account: Account): Observable { + return this.http.post(this.url(account.id), account); + } + + delete(account: Account): Observable { + return this.http.delete(this.url(account.id)); + } +} diff --git a/src/accounts/account.states.ts b/src/accounts/account.states.ts new file mode 100644 index 0000000..2b75fc0 --- /dev/null +++ b/src/accounts/account.states.ts @@ -0,0 +1,8 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : + +import { AccountListComponent } from './accountList.component'; + +export const AccountListState = { + path: 'accounts', + component: AccountListComponent +} diff --git a/src/accounts/account.ts b/src/accounts/account.ts new file mode 100644 index 0000000..8e80565 --- /dev/null +++ b/src/accounts/account.ts @@ -0,0 +1,15 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : + +import { AccountBalances } from './accountBalances'; + +export class Account { + id: number; + name: string; + authorized_overdraft: number; + + balances: AccountBalances; + + constructor() { + this.authorized_overdraft = 0; + } +} diff --git a/src/accounts/accountBalances.factory.js b/src/accounts/accountBalances.factory.js deleted file mode 100644 index df19920..0000000 --- a/src/accounts/accountBalances.factory.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = function($resource) { - return $resource( - '/api/account/:id/balances', { - id: '@id' - } - ); -}; diff --git a/src/accounts/accountBalances.service.ts b/src/accounts/accountBalances.service.ts new file mode 100644 index 0000000..020b4c3 --- /dev/null +++ b/src/accounts/accountBalances.service.ts @@ -0,0 +1,18 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs/Rx'; + +import { HttpClient, HttpParams } from "@angular/common/http"; + +import { AccountBalances } from './accountBalances'; + +@Injectable() +export class AccountBalancesService { + constructor( + private http: HttpClient + ) {} + + get(id: number): Observable { + return this.http.get(`/api/account/${id}/balances`); + } +} diff --git a/src/accounts/accountBalances.ts b/src/accounts/accountBalances.ts new file mode 100644 index 0000000..d649ef6 --- /dev/null +++ b/src/accounts/accountBalances.ts @@ -0,0 +1,6 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : +export class AccountBalances { + current: number; + pointed: number; + future: number; +} diff --git a/src/accounts/accountDeleteModal.component.ts b/src/accounts/accountDeleteModal.component.ts new file mode 100644 index 0000000..0f375fa --- /dev/null +++ b/src/accounts/accountDeleteModal.component.ts @@ -0,0 +1,53 @@ +// vim: set tw=80 ts=2 sw=2 sts=2: +import { Component, Input } from '@angular/core'; + +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import { Account } from './account'; + +@Component({ + selector: 'account-delete-modal', + template: ` + + + + + + ` +}) +export class AccountDeleteModalComponent { + @Input() account: Account + + constructor(public activeModal: NgbActiveModal) {} + + title(): string { + if(this.account.id) { + return "Account #" + this.account.id; + } else { + return "New account"; + } + } + + submit(): void { + this.activeModal.close(this.account); + } + + cancel(): void { + this.activeModal.dismiss("closed"); + } +} diff --git a/src/accounts/accountEditModal.component.ts b/src/accounts/accountEditModal.component.ts new file mode 100644 index 0000000..ae859bd --- /dev/null +++ b/src/accounts/accountEditModal.component.ts @@ -0,0 +1,59 @@ +// vim: set tw=80 ts=2 sw=2 sts=2: +import { Component, Input, ViewChild } from '@angular/core'; + +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import { Account } from './account'; +import { AccountFormComponent } from './accountForm.component'; + +@Component({ + selector: 'account-edit-modal', + template: ` + + + + + + ` +}) +export class AccountEditModalComponent { + @Input() account: Account; + @ViewChild('accountForm') accountForm: AccountFormComponent; + + constructor(private activeModal: NgbActiveModal) {} + + title(): string { + if(this.account.id) { + return "Account #" + this.account.id; + } else { + return "New account"; + } + } + + submit(): void { + let formModel = this.accountForm.form.value; + let account = Object.assign({}, this.account); + + account.id = this.account.id; + account.name = formModel.name; + account.authorized_overdraft = formModel.authorizedOverdraft; + + this.activeModal.close(account); + } + + cancel(): void { + this.activeModal.dismiss("closed"); + } +} diff --git a/src/accounts/accountForm.component.ts b/src/accounts/accountForm.component.ts new file mode 100644 index 0000000..c0b6e5a --- /dev/null +++ b/src/accounts/accountForm.component.ts @@ -0,0 +1,91 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : +import { Component, OnInit, OnChanges, Input, Output, EventEmitter } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; + +import { Account } from './account'; + +@Component({ + selector: 'account-form', + exportAs: 'accountForm', + template: ` +
+
+ + +
+ + +
+

The account name is required.

+
+
+
+ +
+ + +
+
+ + +
.00€
+
+ +
+

+ The authorized overdraft is required. +

+ +

+ The authorized overdraft must be less than or equal to 0. +

+
+
+
+
+ ` +}) +export class AccountFormComponent implements OnInit { + public form: FormGroup; + @Input() account: Account; + @Output('submit') submitEventEmitter: EventEmitter = new EventEmitter(); + + constructor(private formBuilder: FormBuilder) {} + + ngOnInit() { + this.form = this.formBuilder.group({ + name: ['', Validators.required], + authorizedOverdraft: ['', [Validators.required, Validators.max(0)]], + }); + + this.form.patchValue({ + name: this.account.name, + authorizedOverdraft: this.account.authorized_overdraft + }); + } + + submit() { + if(this.form.valid) { + this.submitEventEmitter.emit(); + } + } + + get name() { + return this.form.get('name'); + } + + get authorizedOverdraft() { + return this.form.get('authorizedOverdraft'); + } +} diff --git a/src/accounts/accountList.component.ts b/src/accounts/accountList.component.ts new file mode 100644 index 0000000..a9d48c8 --- /dev/null +++ b/src/accounts/accountList.component.ts @@ -0,0 +1,102 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : +import { Component, Inject, OnInit } from '@angular/core'; +import { Observable } from 'rxjs/Rx'; + +import { Logger } from '@nsalaun/ng-logger'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ToastrService } from 'ngx-toastr'; + +import { Account } from './account'; +import { AccountBalances } from './accountBalances'; +import { AccountService } from './account.service'; +import { AccountEditModalComponent } from './accountEditModal.component'; + +@Component({ + selector: 'account-list', + template: ` +
+ + + + + + + + + + + + + + + + + + + +
Nom du compteSolde courantSolde pointéDécouvert autoriséActions
+ +
+
+ `, +}) +export class AccountListComponent implements OnInit { + accounts: Account[]; + + constructor( + private accountService: AccountService, + private toastrService: ToastrService, + private logger: Logger, + private ngbModal: NgbModal + ) { + } + + ngOnInit() { + // Load accounts. + this.load(); + } + + load() { + this.logger.log("Load accounts."); + this.accountService.query().subscribe(accounts => { + this.accounts = accounts; + }); + }; + + /* + * Add an empty account. + */ + add() { + const modal = this.ngbModal.open(AccountEditModalComponent, { + size: 'lg' + }); + + modal.componentInstance.account = new Account(); + + modal.result.then((account: Account) => { + this.logger.log("Modal closed => save account", account); + this.save(account); + }, (reason) => function(reason) { + }); + }; + + /* + * Save account. + */ + save(account) { + this.accountService.create(account).subscribe(account => { + this.toastrService.success('Account #' + account.id + ' saved.'); + + this.load(); + }, result => { + this.logger.error('Error while saving account', account, result); + + this.toastrService.error( + 'Error while saving account: ' + result.message + ); + }); + }; +}; diff --git a/src/accounts/accountRow.component.ts b/src/accounts/accountRow.component.ts new file mode 100644 index 0000000..ebc2cc0 --- /dev/null +++ b/src/accounts/accountRow.component.ts @@ -0,0 +1,176 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : +import { CurrencyPipe } from '@angular/common'; +import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core'; + +import { Logger } from '@nsalaun/ng-logger'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ToastrService } from 'ngx-toastr'; + +import { Account } from './account'; +import { AccountBalances } from './accountBalances'; +import { AccountBalancesService } from './accountBalances.service'; +import { AccountService } from './account.service'; +import { AccountDeleteModalComponent } from './accountDeleteModal.component'; +import { AccountEditModalComponent } from './accountEditModal.component'; + +@Component({ + selector: 'tr[account-row]', + host: { + "[id]": "account.id", + "[class.warning]": "warning", + "[class.danger]": "danger" + }, + template: ` + + {{ account.name }} + + + + + {{ accountBalances?.current | currency:"EUR":true }} + + + + + + {{ accountBalances?.pointed | currency:"EUR":true }} + + + +{{ account.authorized_overdraft | currency:"EUR":true }} + + +
+ + + + + + + + + + +
+ + ` +}) +export class AccountRowComponent implements OnInit { + @Input('account-row') account: Account; + @Output() needsReload: EventEmitter = new EventEmitter(); + + private accountBalances: AccountBalances; + + constructor( + private accountService: AccountService, + private accountBalancesService: AccountBalancesService, + private toastrService: ToastrService, + private logger: Logger, + private ngbModal: NgbModal + ) { + this.logger.log("AccountRowComponent constructor"); + } + + ngOnInit() { + this.logger.log(this.account); + this.accountBalancesService + .get(this.account.id) + .subscribe((accountBalances: AccountBalances) => { + this.accountBalances = accountBalances; + }) + } + + get warning() { + return this.account && this.accountBalances + && this.account.authorized_overdraft < this.accountBalances.current + && this.accountBalances.current < 0; + }; + + get error() { + return this.account && this.accountBalances + && this.accountBalances.current < this.account.authorized_overdraft; + }; + + /* + * Return the class for a value compared to account authorized overdraft. + */ + valueClass(value: number) { + if (!value) { + return; + } + + if (value < this.account.authorized_overdraft) { + return 'text-danger'; + } else if (value < 0) { + return 'text-warning'; + } + }; + + confirmDelete() { + const modal = this.ngbModal.open(AccountDeleteModalComponent); + + modal.componentInstance.account = this.account; + + modal.result.then((account: Account) => { + this.delete(account); + }, (reason) => function(reason) { + }); + }; + + /* + * Delete an account. + */ + delete(account: Account) { + var id = account.id; + + this.accountService.delete(account).subscribe(account => { + this.toastrService.success('account #' + id + ' deleted.'); + + this.needsReload.emit(); + }, function(result) { + this.toastrService.error( + 'An error occurred while trying to delete account #' + + id + ':
' + result + ); + }); + }; + + /* + * Open the popup to modify the account, save it on confirm. + */ + modify() { + const modal = this.ngbModal.open(AccountEditModalComponent, { + size: 'lg' + }); + + modal.componentInstance.account = this.account; + + modal.result.then((account: Account) => { + this.logger.log("Modal closed => save account", account); + this.save(account); + }, (reason) => function(reason) { + }); + }; + + save(account: Account) { + this.accountService.update(account).subscribe((account: Account) => { + this.toastrService.success('Account #' + account.id + ' saved.'); + + this.needsReload.emit(); + }, result => { + this.logger.error('Error while saving account', account, result); + + this.toastrService.error( + 'Error while saving account: ' + result.message + ); + }); + }; +} diff --git a/src/accounts/accounts.html b/src/accounts/accounts.html deleted file mode 100644 index 0916589..0000000 --- a/src/accounts/accounts.html +++ /dev/null @@ -1,89 +0,0 @@ - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Nom du compteSolde courantSolde pointéDécouvert autoriséActions
- -
- {{ account.name }} - - - {{ account.balances.current | currency : "€" }} - - - - {{ account.balances.pointed | currency : "€" }} - - - {{ account.authorized_overdraft | currency : "€" }} - -
- - - - - - - - - - -
-
-
diff --git a/src/accounts/dailyBalance.service.ts b/src/accounts/dailyBalance.service.ts new file mode 100644 index 0000000..774c8ea --- /dev/null +++ b/src/accounts/dailyBalance.service.ts @@ -0,0 +1,19 @@ +// vim: set tw=80 ts=2 sw=2 sts=2: + +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; + +import { Observable } from 'rxjs/Rx'; + +import { DailyBalance } from './dailyBalance'; + +@Injectable() +export class DailyBalanceService { + constructor( + private http: HttpClient + ) {} + + query(id: number): Observable { + return this.http.get(`/api/account/${id}/daily_balances`); + } +} diff --git a/src/accounts/dailyBalance.ts b/src/accounts/dailyBalance.ts new file mode 100644 index 0000000..68c3669 --- /dev/null +++ b/src/accounts/dailyBalance.ts @@ -0,0 +1,8 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : + +export class DailyBalance { + operation_date: string; + balance: number; + expenses: number; + revenues: number; +} diff --git a/src/accounts/index.js b/src/accounts/index.js deleted file mode 100644 index 75e3bc0..0000000 --- a/src/accounts/index.js +++ /dev/null @@ -1,48 +0,0 @@ -// 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 angular = require('angular'); - -var ngResource = require('angular-resource'), - ngMessages = require('angular-messages'), - ngUiNotification = require('angular-ui-notification'), - ngStrap = require('angular-strap'); - -var AccountFactory = require('./account.factory.js'); -var AccountBalancesFactory = require('./accountBalances.factory.js'); -var AccountConfig = require('./account.config.js'); -var AccountController = require('./account.controller.js'); - -module.exports = angular.module('accountant.accounts', [ - ngResource, - ngMessages, - ngUiNotification, - ngStrap, -]) - - .config(AccountConfig) - - .factory('AccountBalances', AccountBalancesFactory) - - .factory('Account', AccountFactory) - - .controller('AccountController', AccountController) - - .name; diff --git a/src/app.component.ts b/src/app.component.ts new file mode 100644 index 0000000..9d53703 --- /dev/null +++ b/src/app.component.ts @@ -0,0 +1,18 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : + +import { Component } from '@angular/core'; + +@Component({ + selector: 'accountant', + template: ` + + + +
+ +
+ ` +}) +export class AppComponent { } diff --git a/src/app.config.js b/src/app.config.js deleted file mode 100644 index b3400dd..0000000 --- a/src/app.config.js +++ /dev/null @@ -1,26 +0,0 @@ -var operationsTmpl = require('./operations/operations.html'); -var accountsTmpl = require('./accounts/accounts.html'); -var schedulerTmpl = require('./scheduler/scheduler.html'); - -module.exports = function($routeProvider) { - // Defining template and controller in function of route. - $routeProvider - .when('/account/:accountId/operations', { - templateUrl: operationsTmpl, - controller: 'OperationController', - controllerAs: 'operationsCtrl' - }) - .when('/account/:accountId/scheduler', { - templateUrl: schedulerTmpl, - controller: 'SchedulerController', - controllerAs: 'schedulerCtrl' - }) - .when('/accounts', { - templateUrl: accountsTmpl, - controller: 'AccountController', - controllerAs: 'accountsCtrl' - }) - .otherwise({ - redirectTo: '/accounts' - }); -}; diff --git a/src/app.config.ts b/src/app.config.ts new file mode 100644 index 0000000..482e7b4 --- /dev/null +++ b/src/app.config.ts @@ -0,0 +1,5 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : +import { Level } from '@nsalaun/ng-logger'; + +export const ApiBaseURL = "http://localhost:8080/api"; +export const LogLevel = Level.LOG; diff --git a/src/app.js b/src/app.js deleted file mode 100644 index b4b2d47..0000000 --- a/src/app.js +++ /dev/null @@ -1,40 +0,0 @@ -// 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 angular = require('angular'); - -var ngRoute = require('angular-route'); - -var accountModule = require('./accounts'), - loginModule = require('./login'), - operationModule = require('./operations'), - schedulerModule = require('./scheduler'); - -var routing = require('./app.config'); - -require('bootstrap-webpack!./bootstrap.config.js'); - -angular.module('accountant', [ - ngRoute, - accountModule, - loginModule, - operationModule, - schedulerModule, -]).config(routing); diff --git a/src/app.module.ts b/src/app.module.ts new file mode 100644 index 0000000..48d4d72 --- /dev/null +++ b/src/app.module.ts @@ -0,0 +1,55 @@ +// vim: set tw=80 ts=2 sw=2 sts=2: +import 'zone.js'; +import 'reflect-metadata'; + +require('./main.scss'); + +import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterModule } from '@angular/router'; + +import { NgLoggerModule } from '@nsalaun/ng-logger'; +import { ToastrModule } from 'ngx-toastr'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; + +import { LoginModule } from './login/login.module'; +import { AccountModule } from './accounts/account.module'; +import { ScheduleModule } from './scheduler/schedule.module'; +import { OperationModule } from './operations/operation.module'; + +import { AppComponent } from './app.component'; + +import { ApiBaseURL, LogLevel } from './app.config'; + +@NgModule({ + imports: [ + BrowserModule, + BrowserAnimationsModule, + RouterModule.forRoot([ + { + path: '', + redirectTo: '/accounts', + pathMatch: 'full' + } + ], { + enableTracing: true, + useHash: true + }), + LoginModule, + NgLoggerModule.forRoot(LogLevel), + ToastrModule.forRoot(), + NgbModule.forRoot(), + AccountModule, + ScheduleModule, + OperationModule, + ], + declarations: [ + AppComponent + ], + bootstrap: [ AppComponent ] +}) + +export class AppModule { + constructor() {} +} diff --git a/src/bootstrap.config.js b/src/bootstrap.config.js deleted file mode 100644 index bcd24eb..0000000 --- a/src/bootstrap.config.js +++ /dev/null @@ -1,64 +0,0 @@ -/* jshint node: true */ -'use strict'; - -module.exports = { - scripts: { - 'transition': true, - 'alert': true, - 'button': true, - 'carousel': true, - 'collapse': true, - 'dropdown': true, - 'modal': true, - 'tooltip': true, - 'popover': true, - 'scrollspy': true, - 'tab': true, - 'affix': true - }, - - styles: { - 'mixins': true, - - 'normalize': true, - 'print': true, - - 'scaffolding': true, - 'type': true, - 'code': true, - 'grid': true, - 'tables': true, - 'forms': true, - 'buttons': true, - - 'component-animations': true, - 'glyphicons': true, - 'dropdowns': true, - 'button-groups': true, - 'input-groups': true, - 'navs': true, - 'navbar': true, - 'breadcrumbs': true, - 'pagination': true, - 'pager': true, - 'labels': true, - 'badges': true, - 'jumbotron': true, - 'thumbnails': true, - 'alerts': true, - 'progress-bars': true, - 'media': true, - 'list-group': true, - 'panels': true, - 'wells': true, - 'close': true, - - 'modals': true, - 'tooltip': true, - 'popovers': true, - 'carousel': true, - - 'utilities': true, - 'responsive-utilities': true - } -}; diff --git a/src/bootstrap.config.less b/src/bootstrap.config.less deleted file mode 100644 index f2d357b..0000000 --- a/src/bootstrap.config.less +++ /dev/null @@ -1,3 +0,0 @@ -@pre-border-color: @pre-bg; // hide the border. - -@import "./main.less"; diff --git a/src/index.ejs b/src/index.ejs index 888c8b5..e69bd09 100644 --- a/src/index.ejs +++ b/src/index.ejs @@ -19,30 +19,16 @@ + <% htmlWebpackPlugin.options.title %> - + - - - -
-
-
- - - + diff --git a/src/login/authInterceptor.ts b/src/login/authInterceptor.ts new file mode 100644 index 0000000..bd3c984 --- /dev/null +++ b/src/login/authInterceptor.ts @@ -0,0 +1,92 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : + +import { Injectable, Injector } from '@angular/core'; +import { + HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, HttpErrorResponse +} from '@angular/common/http'; + +import { Observable} from 'rxjs/Rx'; +import 'rxjs/add/operator/catch'; + +import { Logger } from '@nsalaun/ng-logger'; + +import { LoginService } from './login.service'; +import { Token } from './token'; + +@Injectable() +export class AuthInterceptor implements HttpInterceptor { + private observable: Observable; + + constructor( + private logger: Logger, + private injector: Injector, + ) {} + + injectAuthorizationHeader(request: HttpRequest, accessToken: string) { + this.logger.log('Injecting Authorization header'); + + return request; + } + + intercept( + request: HttpRequest, + next: HttpHandler, + pass?: number + ): Observable> { + if(!pass) { + pass = 1; + } + + let loginService = this.injector.get(LoginService); + + if(request.url == loginService.url) { + this.logger.log("Login URL, do not handle."); + + return next.handle(request); + } + + this.logger.log(`Intercepted request, pass #${pass}`, request, next); + + let accessToken = loginService.accessToken; + + if(accessToken){ + request = request.clone({ + headers: request.headers.set('Authorization', `Bearer ${accessToken}`) + }); + } + + this.logger.log('Request', request); + + let observable: Observable = next.handle(request); + + return observable.catch( + (error, caught): Observable => { + this.logger.error("Error", error, caught); + + if(!(error instanceof HttpErrorResponse) || error.status != 401) { + return Observable.throw(error); + } + + this.logger.log('Unauthorized', error); + + if(pass === 3) { + return Observable.throw(error); + } + + if(!this.observable) { + this.logger.log("No current login observable.") + this.observable = loginService.login(); + } + + return this.observable.flatMap((token: Token): Observable> => { + this.logger.log("Logged in, access_token:", token.access_token); + this.observable = null; + return this.intercept(request, next, ++pass); + }).catch((error) => { + this.observable = null; + return Observable.throw(error); + }); + } + ); + } +} diff --git a/src/login/index.js b/src/login/index.js deleted file mode 100644 index ad71013..0000000 --- a/src/login/index.js +++ /dev/null @@ -1,50 +0,0 @@ -// 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 angular = require('angular'); - -var ngStorage = require('meanie-angular-storage'), - ngHttpAuth = require('angular-http-auth'), - ngStrap = require('angular-strap'); - -// Note: ngHttpAuth seems to have no module.exports. -ngHttpAuth = 'http-auth-interceptor'; - -var LoginService = require('./login.service'); -var LoginConfig = require('./login.config'); - -module.exports = angular.module('accountant.login', [ - ngHttpAuth, - ngStorage -]) - - .service('LoginService', LoginService) - - .config(LoginConfig) - - .run(function($rootScope, LoginService) { - var onAuthLoginRequired = $rootScope.$on('event:auth-loginRequired', LoginService.loginModal); - - $rootScope.$on('$destroy', function() { - onAuthLoginRequired = angular.noop(); - }); - }) - - .name; diff --git a/src/login/login.config.js b/src/login/login.config.ts similarity index 100% rename from src/login/login.config.js rename to src/login/login.config.ts diff --git a/src/login/login.module.ts b/src/login/login.module.ts new file mode 100644 index 0000000..8e4f4b9 --- /dev/null +++ b/src/login/login.module.ts @@ -0,0 +1,39 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule } from '@angular/forms'; +import { HttpClientModule } from '@angular/common/http'; +import { HTTP_INTERCEPTORS } from '@angular/common/http'; + +import { NgLoggerModule } from '@nsalaun/ng-logger'; + +import { AuthInterceptor } from './authInterceptor'; +import { LoginService } from './login.service'; +import { LoginFormComponent } from './loginForm.component'; +import { LoginModalComponent } from './loginModal.component'; + +@NgModule({ + imports: [ + HttpClientModule, + CommonModule, + ReactiveFormsModule, + NgLoggerModule, + ], + providers: [ + LoginService, + { + provide: HTTP_INTERCEPTORS, + useClass: AuthInterceptor, + multi: true + } + ], + declarations: [ + LoginModalComponent, + LoginFormComponent, + ], + entryComponents: [ + LoginModalComponent, + ] +}) +export class LoginModule {}; diff --git a/src/login/login.service.js b/src/login/login.service.js deleted file mode 100644 index 8140b79..0000000 --- a/src/login/login.service.js +++ /dev/null @@ -1,45 +0,0 @@ -var base64 = require('base64util'); - -var loginTmpl = require('./login.tmpl.html'); - -module.exports = function($storage, $http, authService, $modal) { - var login = function(email, password) { - // Encode authentication data. - var authdata = base64.encode(email + ':' + password); - - return $http.post('/api/user/login', {}, { - ignoreAuthModule: true, - headers: { - 'authorization': 'Basic ' + authdata - } - }).then(function (result) { - $storage.session.set('refresh_token', result.data.refresh_token); - $storage.session.set('access_token', result.data.access_token); - - authService.loginConfirmed(); - }, function(result) { - loginModal(); - }); - }; - - var loginModal = function () { - $storage.session.clear(); - - $modal({ - templateUrl: loginTmpl, - controller: function($scope, $login) { - $scope.$login = function() { - $scope.$hide(); - $login($scope.email, $scope.password); - }; - }, - locals: { - $login: login, - } - }); - }; - - return { - 'loginModal': loginModal, - }; -}; diff --git a/src/login/login.service.ts b/src/login/login.service.ts new file mode 100644 index 0000000..7418807 --- /dev/null +++ b/src/login/login.service.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; + +import { Observable} from 'rxjs/Rx'; + +import * as base64 from 'base64util'; + +import { Logger } from '@nsalaun/ng-logger'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; + +import { Token } from './token'; +import { LoginModalComponent } from './loginModal.component'; +import { Login } from './login'; + +@Injectable() +export class LoginService { + + constructor( + private httpClient: HttpClient, + private logger: Logger, + private ngbModal: NgbModal, + ) {} + + public readonly url: string = '/api/user/login'; + + login(): Observable { + let modal = this.ngbModal.open(LoginModalComponent); + + sessionStorage.clear(); + + let observable: Observable = Observable.fromPromise(modal.result); + + return observable.flatMap((login: Login) => + this.doLogin(login) + ).map((token: Token): Token => { + this.accessToken = token.access_token; + return token; + }); + } + + logout() { + sessionStorage.clear(); + } + + doLogin(login: Login): Observable { + var authdata = base64.encode( + `${login.email}:${login.password}` + ); + + let headers = new HttpHeaders() + headers = headers.set('Authorization', `Basic ${authdata}`); + + this.logger.log("Headers", headers); + + return this.httpClient.post(this.url, {}, { + headers: headers + }); + } + + get accessToken(): string { + return sessionStorage.getItem('access_token'); + } + + set accessToken(token: string) { + sessionStorage.setItem('access_token', token); + } +}; diff --git a/src/login/login.ts b/src/login/login.ts new file mode 100644 index 0000000..3424c59 --- /dev/null +++ b/src/login/login.ts @@ -0,0 +1,6 @@ +// vim: set tw=80 ts=2 sw=2 sts=2: + +export class Login { + email: string; + password: string; +} diff --git a/src/login/loginForm.component.ts b/src/login/loginForm.component.ts new file mode 100644 index 0000000..1dc2f3a --- /dev/null +++ b/src/login/loginForm.component.ts @@ -0,0 +1,70 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : +import { Component, OnInit, OnChanges, Input, Output, EventEmitter } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; + +import { Login } from './login'; + +@Component({ + selector: 'login-form', + exportAs: 'loginForm', + template: ` +
+
+ + +
+ + +
+

The email is required.

+
+
+
+ +
+ + +
+ + +
+

The password is required.

+
+
+
+
+ ` +}) + +export class LoginFormComponent { + public form: FormGroup; + @Input('login-form') private login: Login + @Output('submit') submitEventEmitter: EventEmitter = new EventEmitter(); + + constructor(private formBuilder : FormBuilder) {} + + ngOnInit() { + this.form = this.formBuilder.group({ + email: ['', Validators.required], + password: ['', Validators.required] + }) + } + + submit() { + if(this.form.valid) { + this.submitEventEmitter.emit(); + } + } + + get email() { + return this.form.get('email'); + } + + get password() { + return this.form.get('password'); + } +} diff --git a/src/login/loginModal.component.ts b/src/login/loginModal.component.ts new file mode 100644 index 0000000..8ff7abd --- /dev/null +++ b/src/login/loginModal.component.ts @@ -0,0 +1,51 @@ +// vim: set tw=80 ts=2 sw=2 sts=2: +import { Component, Input, ViewChild } from '@angular/core'; + +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import { Login } from './login'; +import { LoginFormComponent } from './loginForm.component'; + +@Component({ + selector: 'login-modal', + template: ` + + + + + + ` +}) +export class LoginModalComponent { + @ViewChild('loginForm') loginForm: LoginFormComponent; + + constructor(private activeModal: NgbActiveModal) { + + } + + submit(): void { + let formModel = this.loginForm.form.value; + let login: Login = new Login(); + + login.email = formModel.email; + login.password = formModel.password; + + this.activeModal.close(login); + } + + cancel(): void { + this.activeModal.dismiss("closed"); + } +} diff --git a/src/login/token.ts b/src/login/token.ts new file mode 100644 index 0000000..bd6902d --- /dev/null +++ b/src/login/token.ts @@ -0,0 +1,6 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : + +export class Token { + access_token: string; + refresh_token: string; +} diff --git a/src/main.less b/src/main.less deleted file mode 100644 index 8fe1882..0000000 --- a/src/main.less +++ /dev/null @@ -1,41 +0,0 @@ -@import '~font-awesome/less/font-awesome'; - -@import '~angular-ui-notification/src/angular-ui-notification'; - -@import (inline) '~c3/c3.css'; - -@import (inline) '~bootstrap-additions/dist/bootstrap-additions.css'; - -.italic { - font-style: italic; -} - -.stroke { - text-decoration: line-through; -} - -.c3-ygrid-line.zeroline line { - stroke: orange; -} - -.c3-ygrid-line.overdraft line { - stroke: #FF0000; -} - -// Needed for modal backdrop opacity. -.modal-backdrop.am-fade { - opacity: .5; - transition: opacity .15s linear; - &.ng-enter { - opacity: 0; - &.ng-enter-active { - opacity: .5; - } - } - &.ng-leave { - opacity: .5; - &.ng-leave-active { - opacity: 0; - } - } -} diff --git a/src/main.scss b/src/main.scss new file mode 100644 index 0000000..abef40c --- /dev/null +++ b/src/main.scss @@ -0,0 +1,25 @@ +$fa-font-path: '~font-awesome/fonts'; + +@import '~font-awesome/scss/font-awesome'; + +@import '~bootstrap/scss/bootstrap'; + +@import '~c3/c3'; + +@import '~ngx-toastr/toastr'; + +.italic { + font-style: italic; +} + +.stroke { + text-decoration: line-through; +} + +.c3-ygrid-line.zeroline line { + stroke: orange; +} + +.c3-ygrid-line.overdraft line { + stroke: #FF0000; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..07b1480 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,7 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : + +import { AppModule } from './app.module'; + +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +platformBrowserDynamic().bootstrapModule(AppModule); diff --git a/src/operations/balance-chart.component.js b/src/operations/balance-chart.component.js deleted file mode 100644 index 6ab3fb0..0000000 --- a/src/operations/balance-chart.component.js +++ /dev/null @@ -1,190 +0,0 @@ -// 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'), - c3 = require('c3'); - -var angular = require('angular'); - -var ngResource = require('angular-resource'); - -module.exports = angular.module('balanceChartModule', [ - ngResource -]) - - .component('balanceChart', { - template: '
', - bindings: { - account: '<', - onUpdate: '&' - }, - controller: function($routeParams, Balances, $element) { - var vm = this; - - vm.loadData = function() { - Balances.query({ - id: $routeParams.accountId - }, function(results) { - var headers = [['date', 'balances', 'expenses', 'revenues']]; - - var rows = results.map(function(result) { - return [ - result.operation_date, - result.balance, - result.expenses, - result.revenues - ]; - }); - - vm.chart.unload(); - - vm.chart.load({ - rows: headers.concat(rows) - }); - - var x = vm.chart.x(); - var balances = x.balances; - - vm.onUpdate(balances[0], balances[balances.length - 1]); - }); - }; - - vm.$onInit = function() { - var tomorrow = moment().endOf('day').valueOf(); - - vm.chart = c3.generate({ - bindto: $element[0].children[0], - size: { - height: 450, - }, - data: { - x: 'date', - rows: [], - axes: { - expenses: 'y2', - revenues: 'y2' - }, - type: 'bar', - types: { - balances: 'area' - }, - groups: [ - ['expenses', 'revenues'] - ], - // Disable for the moment because there is an issue when - // using subchart line is not refreshed after subset - // selection. - //regions: { - // balances: [{ - // start: tomorrow, - // style: 'dashed' - // }] - //} - }, - regions: [{ - start: tomorrow, - }], - axis: { - x: { - type: 'timeseries', - tick: { - format: '%Y-%m-%d', - rotate: 50, - } - }, - y: { - label: { - text: 'Amount', - position: 'outer-middle' - } - }, - y2: { - show: true, - label: { - text: 'Amount', - position: 'outer-middle' - } - } - }, - grid: { - x: { - show: true, - }, - y: { - show: true, - } - }, - tooltip: { - format: { - value: function(value, ratio, id, index) { - return value + '€'; - } - } - }, - subchart: { - show: true, - onbrush: function(domain) { - vm.onUpdate({minDate: domain[0], maxDate: domain[1]}); - } - } - }); - - vm.loadData(); - }; - - vm.setLines = function(account) { - if(vm.chart) { - vm.chart.ygrids([ - { value: 0, axis: 'y2' }, - { value: 0, axis: 'y', class: 'zeroline'}, - ]); - - vm.chart.ygrids.add({ - value: account.authorized_overdraft, - axis: 'y', - class: 'overdraft' - }); - } - }; - - vm.$onChanges = function(changes) { - if('account' in changes) { - if('$promise' in vm.account && vm.account.$resolved === false) { - vm.account.$promise.then(function(account) { - vm.setLines(account); - return account; - }); - } else { - vm.setLines(vm.account); - } - } - }; - } - }) - - .factory('Balances', function($resource) { - return $resource( - '/api/account/:id/daily_balances', { - id: '@id' - } - ); - }) - - .name; diff --git a/src/operations/balanceChart.component.ts b/src/operations/balanceChart.component.ts new file mode 100644 index 0000000..3773418 --- /dev/null +++ b/src/operations/balanceChart.component.ts @@ -0,0 +1,177 @@ +// vim: set tw=80 ts=2 sw=2 sts=2: + +import * as moment from 'moment'; +import * as c3 from 'c3'; + +import { + Component, ElementRef, + Inject, Input, Output, EventEmitter, + OnInit, OnChanges +} from '@angular/core'; + +import { Account } from '../accounts/account'; +import { DailyBalanceService } from '../accounts/dailyBalance.service'; + +class DateRange { + minDate: Date; + maxDate: Date; +} + +@Component({ + selector: 'balance-chart', + template: '
' +}) +export class BalanceChartComponent implements OnInit, OnChanges { + @Input() account: Account; + @Output() onUpdate: EventEmitter = new EventEmitter(); + + private chart: c3.ChartAPI; + private balances: number[]; + + constructor( + private elementRef: ElementRef, + private dailyBalanceService: DailyBalanceService, + ) { + } + + loadData(account: Account) { + this.dailyBalanceService.query( + account.id + ).subscribe((results) => { + var headers: any[][] = [['date', 'balances', 'expenses', 'revenues']]; + + var rows = results.map(function(result) { + return [ + result.operation_date, + result.balance, + result.expenses, + result.revenues + ]; + }); + + this.chart.unload(); + + this.chart.load({ + rows: headers.concat(rows) + }); + + var x: any; + + x = this.chart.x(); + + var balances = x.balances; + + this.onUpdate.emit({ + minDate: balances[0], + maxDate: balances[balances.length - 1] + }); + }); + }; + + ngOnInit() { + var tomorrow = moment().endOf('day').valueOf(); + + this.chart = c3.generate({ + bindto: this.elementRef.nativeElement.children[0], + size: { + height: 450, + }, + data: { + x: 'date', + rows: [], + axes: { + expenses: 'y2', + revenues: 'y2' + }, + type: 'bar', + types: { + balances: 'area' + }, + groups: [ + ['expenses', 'revenues'] + ], + // Disable for the moment because there is an issue when + // using subchart line is not refreshed after subset + // selection. + //regions: { + // balances: [{ + // start: tomorrow, + // style: 'dashed' + // }] + //} + }, + regions: [{ + start: tomorrow, + }], + axis: { + x: { + type: 'timeseries', + tick: { + format: '%Y-%m-%d', + rotate: 50, + } + }, + y: { + label: { + text: 'Amount', + position: 'outer-middle' + } + }, + y2: { + show: true, + label: { + text: 'Amount', + position: 'outer-middle' + } + } + }, + grid: { + x: { + show: true, + }, + y: { + show: true, + } + }, + tooltip: { + format: { + value: function(value, ratio, id, index) { + return value + '€'; + } + } + }, + subchart: { + show: true, + onbrush: (domain) => { + this.onUpdate.emit({minDate: domain[0], maxDate: domain[1]}); + } + } + }); + }; + + setLines(account: Account) { + if(this.chart) { + this.chart.ygrids([ + { value: 0, axis: 'y2' }, + { value: 0, axis: 'y', class: 'zeroline'}, + ]); + + if(account) { + this.chart.ygrids.add({ + value: account.authorized_overdraft, + axis: 'y', + class: 'overdraft' + }); + } + } + }; + + ngOnChanges(changes) { + if('account' in changes && changes.account.currentValue) { + this.loadData(changes.account.currentValue); + this.setLines(changes.account.currentValue); + } else { + this.setLines(this.account); + } + }; +} diff --git a/src/operations/category-chart.component.js b/src/operations/category-chart.component.js deleted file mode 100644 index 05e6413..0000000 --- a/src/operations/category-chart.component.js +++ /dev/null @@ -1,137 +0,0 @@ -// 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'), - c3 = require('c3'); - -var angular = require('angular'); - -var ngResource = require('angular-resource'); - -module.exports = angular.module('categoryChartModule', [ - ngResource -]) - - .component('categoryChart', { - template: '
', - bindings: { - minDate: '<', - maxDate: '<' - }, - controller: function($routeParams, $element, Categories, Incomes) { - var vm = this; - - vm.loadData = function() { - Categories.query({ - id: $routeParams.accountId, - begin: vm.minDate ? moment(vm.minDate).format('YYYY-MM-DD') : null, - end: vm.maxDate ? moment(vm.maxDate).format('YYYY-MM-DD') : null - }, function(results) { - var expenses=[], - revenues=[], - colors={}, - names={}; - - var revenuesColor = 'green', - expensesColor = 'orange'; - - angular.forEach(results, function(result) { - - if(result.revenues > 0) { - var revenuesName = 'revenues-' + result.category; - - revenues.push([revenuesName, result.revenues]); - names[revenuesName] = result.category; - colors[revenuesName] = revenuesColor; - } - - if(result.expenses < 0) { - var expensesName = 'expenses-' + result.category; - - expenses.splice(0, 0, [expensesName, -result.expenses]); - names[expensesName] = result.category; - colors[expensesName] = expensesColor; - } - }); - - vm.chart.unload(); - - vm.chart.load({ - columns: revenues.concat(expenses), - names: names, - colors: colors - }); - }); - }; - - vm.$onInit = function() { - vm.chart = c3.generate({ - bindto: $element[0].children[0], - data: { - columns: [], - type: 'donut', - order: null, - }, - tooltip: { - format: { - value: function(value, ratio, id, index) { - return value + '€'; - } - } - }, - donut: { - label: { - format: function(value) { - return value + '€'; - } - } - }, - legend: { - show: false - } - }); - - //vm.loadData(); - }; - - vm.$onChanges = function() { - vm.loadData(); - }; - - } - }) - - .factory('Categories', function($resource) { - return $resource( - '/api/account/:id/category', { - id: '@id' - } - ); - }) - - .factory('Incomes', function($resource) { - return $resource( - '/api/account/:id/income', { - id: '@id' - } - ); - }) - - .name; diff --git a/src/operations/category.service.ts b/src/operations/category.service.ts new file mode 100644 index 0000000..c7559df --- /dev/null +++ b/src/operations/category.service.ts @@ -0,0 +1,39 @@ +// vim: set tw=80 ts=2 sw=2 sts=2: + +import * as moment from 'moment'; + +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs/Rx'; + +import { HttpClient, HttpParams } from "@angular/common/http"; + +import { Category } from './category'; + +@Injectable() +export class CategoryService { + constructor( + private http: HttpClient + ) {} + + formatDate(date: Date|string) { + if(date instanceof Date) { + return moment(date).format('YYYY-MM-DD'); + } + + return date; + } + + query(id: number, minDate: Date = null, maxDate: Date = null): Observable { + let params: HttpParams = new HttpParams(); + + if(minDate) { + params = params.set('begin', this.formatDate(minDate)); + } + + if(maxDate) { + params = params.set('end', this.formatDate(maxDate)); + } + + return this.http.get(`/api/account/${id}/category`, { params: params}); + } +} diff --git a/src/operations/category.ts b/src/operations/category.ts new file mode 100644 index 0000000..3e523f9 --- /dev/null +++ b/src/operations/category.ts @@ -0,0 +1,7 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : + +export class Category { + category: string; + expenses: number; + revenues: number; +} diff --git a/src/operations/categoryChart.component.ts b/src/operations/categoryChart.component.ts new file mode 100644 index 0000000..5b0bd2b --- /dev/null +++ b/src/operations/categoryChart.component.ts @@ -0,0 +1,107 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : + +import * as c3 from 'c3'; + +import { + Component, ElementRef, + Inject, Input, Output, + OnInit, OnChanges +} from '@angular/core'; + +import { Account } from '../accounts/account'; +import { CategoryService } from './category.service'; + +@Component({ + selector: 'category-chart', + template: '
' +}) +export class CategoryChartComponent implements OnInit, OnChanges { + @Input() minDate: Date; + @Input() maxDate: Date; + @Input() account: Account; + + chart: c3.ChartAPI; + + constructor( + private elementRef: ElementRef, + private categoryService: CategoryService, + ) {} + + loadData(account: Account) { + this.categoryService.query( + account.id, + this.minDate, + this.maxDate + ).subscribe((results) => { + var expenses=[], + revenues=[], + colors={}, + names={}; + + var revenuesColor = 'green', + expensesColor = 'orange'; + + for(let result of results) { + if(result.revenues > 0) { + var revenuesName = 'revenues-' + result.category; + + revenues.push([revenuesName, result.revenues]); + names[revenuesName] = result.category; + colors[revenuesName] = revenuesColor; + } + + if(result.expenses < 0) { + var expensesName = 'expenses-' + result.category; + + expenses.splice(0, 0, [expensesName, -result.expenses]); + names[expensesName] = result.category; + colors[expensesName] = expensesColor; + } + }; + + this.chart.unload(); + + this.chart.load({ + columns: revenues.concat(expenses), + names: names, + colors: colors + }); + }); + }; + + ngOnInit() { + this.chart = c3.generate({ + bindto: this.elementRef.nativeElement.children[0], + data: { + columns: [], + type: 'donut', + order: null, + }, + tooltip: { + format: { + value: function(value, ratio, id, index) { + return value + '€'; + } + } + }, + donut: { + label: { + format: function(value) { + return value + '€'; + } + } + }, + legend: { + show: false + } + }); + }; + + ngOnChanges(changes) { + if('account' in changes && changes.account.currentValue) { + this.loadData(changes.account.currentValue); + } else if (this.account) { + this.loadData(this.account); + } + }; +} diff --git a/src/operations/index.js b/src/operations/index.js deleted file mode 100644 index 1b97b02..0000000 --- a/src/operations/index.js +++ /dev/null @@ -1,54 +0,0 @@ -// 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'); - -var angular = require('angular'); - -var ngResource = require('angular-resource'), - ngMessages = require('angular-messages'), - ngUiNotification = require('angular-ui-notification'), - ngStrap = require('angular-strap'); - -var balanceChartModule = require('./balance-chart.component.js'), - categoryChartModule = require('./category-chart.component.js'), - accountModule = require('../accounts'); - -var OperationFactory = require('./operation.factory'); -var OperationConfig = require('./operation.config'); -var OperationController = require('./operation.controller'); - -module.exports = angular.module('accountant.operations', [ - ngResource, - ngMessages, - ngUiNotification, - ngStrap, - accountModule, - balanceChartModule, - categoryChartModule -]) - - .config(OperationConfig) - - .factory('Operation', OperationFactory) - - .controller('OperationController', OperationController) - - .name; diff --git a/src/operations/operation.config.js b/src/operations/operation.config.js deleted file mode 100644 index 0617c65..0000000 --- a/src/operations/operation.config.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = function($resourceProvider) { - // Keep trailing slashes to avoid redirect by flask.. - $resourceProvider.defaults.stripTrailingSlashes = false; -}; diff --git a/src/operations/operation.controller.js b/src/operations/operation.controller.js deleted file mode 100644 index 1830434..0000000 --- a/src/operations/operation.controller.js +++ /dev/null @@ -1,149 +0,0 @@ -var operationFormTmpl = require('./operation.form.tmpl.html'), - operationDeleteTmpl = require('./operation.delete.tmpl.html'); - -module.exports = function($routeParams, $modal, Notification, Operation, - Account) { - - var vm = this; - - /* - * Add an empty operation. - */ - vm.add = function() { - var operation = new Operation({ - // eslint-disable-next-line camelcase - account_id: $routeParams.accountId - }); - - return vm.modify(operation); - }; - - /* - * Load operations. - */ - vm.load = function(minDate, maxDate) { - vm.minDate = minDate; - vm.maxDate = maxDate; - - return Operation.query({ - // eslint-disable-next-line camelcase - account_id: $routeParams.accountId, - begin: minDate ? moment(minDate).format('YYYY-MM-DD') : null, - end: maxDate ? moment(maxDate).format('YYYY-MM-DD') : null - }); - }; - - /* - * Toggle pointed indicator for an operation. - */ - vm.togglePointed = function(operation, rowform) { - operation.pointed = !operation.pointed; - - vm.save(operation); - }; - - /* - * Toggle cancel indicator for an operation. - */ - vm.toggleCanceled = function(operation) { - operation.canceled = !operation.canceled; - - vm.save(operation); - }; - - /* - * Save an operation and return a promise. - */ - vm.save = function(operation) { - operation.confirmed = true; - - return operation.$save().then(function(operation) { - Notification.success('Operation #' + operation.id + ' saved.'); - - vm.operations = vm.load(); - - return operation; - }, function(result){ - Notification.error( - 'Error while saving operation: ' + result.message - ); - }); - }; - - /* - * Delete an operation and return a promise. - */ - vm.confirmDelete = function(operation) { - var title = "Delete operation #" + operation.id; - - $modal({ - templateUrl: operationDeleteTmpl, - controller: function($scope, title, operation, $delete) { - $scope.title = title; - $scope.operation = operation; - $scope.$delete = function() { - $scope.$hide(); - $delete($scope.operation); - }; - }, - locals: { - title: title, - operation: operation, - $delete: vm.delete - } - }); - }; - - vm.delete = function(operation) { - var id = operation.id; - - return operation.$delete().then(function() { - Notification.success('Operation #' + id + ' deleted.'); - - vm.operations = vm.load(); - - return operation; - }, function(result) { - Notification.error( - 'An error occurred while trying to delete operation #' + - id + ':
' + result - ); - }); - }; - - /* - * Open the popup to modify the operation, save it on confirm. - * @returns a promise. - */ - vm.modify = function(operation) { - // FIXME Alexis Lahouze 2017-06-15 i18n - var title = "Operation"; - - if (operation.id) { - title = title + " #" + operation.id; - } - - $modal({ - templateUrl: operationFormTmpl, - controller: function($scope, title, operation, $save) { - $scope.title = title; - $scope.operation = operation; - $scope.$save = function() { - $scope.$hide(); - $save($scope.operation); - }; - }, - locals: { - title: title, - operation: operation, - $save: vm.save - } - }); - }; - - vm.onUpdate = function(minDate, maxDate) { - vm.operations = vm.load(minDate, maxDate); - }; - - vm.account = Account.get({id: $routeParams.accountId}); -}; diff --git a/src/operations/operation.delete.tmpl.html b/src/operations/operation.delete.tmpl.html deleted file mode 100644 index 904ae6d..0000000 --- a/src/operations/operation.delete.tmpl.html +++ /dev/null @@ -1,25 +0,0 @@ - - diff --git a/src/operations/operation.factory.js b/src/operations/operation.factory.js deleted file mode 100644 index 6e79a00..0000000 --- a/src/operations/operation.factory.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = function($resource) { - return $resource( - '/api/operation/:id', { - id: '@id' - } - ); -}; diff --git a/src/operations/operation.form.tmpl.html b/src/operations/operation.form.tmpl.html deleted file mode 100644 index c40557f..0000000 --- a/src/operations/operation.form.tmpl.html +++ /dev/null @@ -1,77 +0,0 @@ - - - - diff --git a/src/operations/operation.module.ts b/src/operations/operation.module.ts new file mode 100644 index 0000000..33c5f74 --- /dev/null +++ b/src/operations/operation.module.ts @@ -0,0 +1,57 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule } from '@angular/forms'; +import { HttpClientModule } from '@angular/common/http'; +import { RouterModule } from '@angular/router'; + +import { NgLoggerModule, Level } from '@nsalaun/ng-logger'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { ToastrModule } from 'ngx-toastr'; +import { TextMaskModule } from 'angular2-text-mask'; + +import { BalanceChartComponent } from './balanceChart.component'; +import { CategoryChartComponent } from './categoryChart.component'; +import { OperationRowComponent } from './operationRow.component'; +import { CategoryService } from './category.service'; +import { OperationService } from './operation.service'; +import { OperationListComponent } from './operationList.component'; +import { OperationDeleteModalComponent } from './operationDeleteModal.component'; +import { OperationFormComponent } from './operationForm.component'; +import { OperationEditModalComponent } from './operationEditModal.component' +import { OperationListState } from './operation.states' + +@NgModule({ + imports: [ + HttpClientModule, + CommonModule, + ReactiveFormsModule, + RouterModule.forChild([ + OperationListState + ]), + NgLoggerModule, + ToastrModule, + NgbModule, + TextMaskModule + ], + providers: [ + CategoryService, + OperationService, + ], + declarations: [ + BalanceChartComponent, + CategoryChartComponent, + OperationRowComponent, + OperationListComponent, + OperationDeleteModalComponent, + OperationFormComponent, + OperationEditModalComponent, + ], + entryComponents: [ + OperationDeleteModalComponent, + OperationEditModalComponent, + OperationListComponent, + ] +}) +export class OperationModule {} diff --git a/src/operations/operation.service.ts b/src/operations/operation.service.ts new file mode 100644 index 0000000..7504612 --- /dev/null +++ b/src/operations/operation.service.ts @@ -0,0 +1,71 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : + +import * as moment from 'moment'; + +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; + +import { Observable } from 'rxjs/Rx'; + +import { Operation } from './operation'; + +@Injectable() +export class OperationService { + constructor( + private http: HttpClient, + ) {} + + formatDate(date: Date|string) { + if(date instanceof Date) { + return moment(date).format('YYYY-MM-DD'); + } + + return date; + } + + private url(id?: Number): string { + if(id) { + return `/api/operation/${id}`; + } + + return `/api/operation`; + } + + query( + accountId: number, + minDate: Date|string = null, + maxDate: Date|string = null + ): Observable { + let params = new HttpParams(); + + params = params.set('account_id', `${accountId}`); + + if(minDate) { + params = params.set('begin', this.formatDate(minDate)); + } + + if(maxDate) { + params = params.set('end', this.formatDate(maxDate)); + } + + return this.http.get(this.url(), { + params: params + }); + } + + get(id: number): Observable { + return this.http.get(this.url(id)); + } + + create(operation: Operation): Observable { + return this.http.post(this.url(), operation); + } + + update(operation: Operation): Observable { + return this.http.post(this.url(operation.id), operation); + } + + delete(operation: Operation): Observable { + return this.http.delete(this.url(operation.id)); + } +} diff --git a/src/operations/operation.states.ts b/src/operations/operation.states.ts new file mode 100644 index 0000000..7c0de6a --- /dev/null +++ b/src/operations/operation.states.ts @@ -0,0 +1,8 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : + +import { OperationListComponent } from './operationList.component'; + +export const OperationListState = { + path: 'account/:accountId/operations', + component: OperationListComponent +} diff --git a/src/operations/operation.ts b/src/operations/operation.ts new file mode 100644 index 0000000..5d176c0 --- /dev/null +++ b/src/operations/operation.ts @@ -0,0 +1,15 @@ +// vim: set tw=80 ts=2 sw=2 sts=2: + +export class Operation { + id: number; + operation_date: string; + label: string; + value: number; + category: string; + scheduled_operation_id: number; + account_id: number; + balance: number; + confirmed: boolean; + pointed: boolean; + cancelled: boolean +} diff --git a/src/operations/operationDeleteModal.component.ts b/src/operations/operationDeleteModal.component.ts new file mode 100644 index 0000000..f3f9570 --- /dev/null +++ b/src/operations/operationDeleteModal.component.ts @@ -0,0 +1,45 @@ +// vim: set tw=80 ts=2 sw=2 sts=2: +import { Component, Input } from '@angular/core'; + +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import { Operation } from './operation'; + +@Component({ + selector: 'operation-delete-modal', + template: ` + + + + + + ` +}) +export class OperationDeleteModalComponent { + @Input() operation: Operation + + constructor(private activeModal: NgbActiveModal) {} + + submit(): void { + this.activeModal.close(this.operation); + } + + cancel(): void { + this.activeModal.dismiss("closed"); + } +} diff --git a/src/operations/operationEditModal.component.ts b/src/operations/operationEditModal.component.ts new file mode 100644 index 0000000..eee4260 --- /dev/null +++ b/src/operations/operationEditModal.component.ts @@ -0,0 +1,63 @@ +// vim: set tw=80 ts=2 sw=2 sts=2: +import { Component, Input, ViewChild } from '@angular/core'; + +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import { Operation } from './operation'; +import { OperationFormComponent } from './operationForm.component'; + +@Component({ + selector: 'operation-edit-modal', + template: ` + + + + + + ` +}) +export class OperationEditModalComponent { + @Input() operation: Operation; + @ViewChild('operationForm') operationForm: OperationFormComponent; + + valid: boolean = false; + + constructor(private activeModal: NgbActiveModal) {} + + title(): string { + if(this.operation.id) { + return "Operation #" + this.operation.id; + } else { + return "New operation"; + } + } + + submit(): void { + let formModel = this.operationForm.form.value; + let operation = Object.assign({}, this.operation); + + operation.id = this.operation.id; + operation.operation_date = formModel.operationDate; + operation.label = formModel.label; + operation.value = formModel.value; + operation.category = formModel.category; + + this.activeModal.close(operation); + } + + cancel(): void { + this.activeModal.dismiss("closed"); + } +} diff --git a/src/operations/operationForm.component.ts b/src/operations/operationForm.component.ts new file mode 100644 index 0000000..ac71bae --- /dev/null +++ b/src/operations/operationForm.component.ts @@ -0,0 +1,122 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : +import { Component, OnInit, OnChanges, Input, Output, EventEmitter } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; + +import { Operation } from './operation'; + +@Component({ + selector: 'operation-form', + exportAs: 'operationForm', + template: ` +
+
+ + +
+ + +
+

The operation date is required.

+
+
+
+ +
+ + +
+ + +
+

The operation label is required.

+
+
+
+ +
+ + +
+ + +
+

The operation value is required.

+
+
+
+ +
+ + +
+ + +
+

The operation category is required.

+
+
+
+
+ ` +}) +export class OperationFormComponent implements OnInit { + public form: FormGroup; + @Input() operation: Operation; + @Output() submitEventEmitter: EventEmitter = new EventEmitter(); + + //dateMask = [/\d{4}/, '-', /0[1-9]|1[0-2]/, '-', /[0-2]\d|3[0-1]/]; + dateMask = ['2', '0', /\d/, /\d/, '-', /[0-1]/, /\d/, '-', /[0-3]/, /\d/]; + + constructor(private formBuilder: FormBuilder) {} + + ngOnInit() { + this.form = this.formBuilder.group({ + operationDate: ['', Validators.required], + label: ['', Validators.required], + value: ['', Validators.required], + category: ['', Validators.required], + }); + + this.form.patchValue({ + operationDate: this.operation.operation_date, + label: this.operation.label, + value: this.operation.value, + category: this.operation.category, + }); + } + + submit() { + if(this.form.valid) { + this.submitEventEmitter.emit(); + } + } + + get operationDate() { + return this.form.get('operationDate'); + } + + get label() { + return this.form.get('label'); + } + + get value() { + return this.form.get('value'); + } + + get category() { + return this.form.get('category'); + } +} diff --git a/src/operations/operationList.component.ts b/src/operations/operationList.component.ts new file mode 100644 index 0000000..ba8f151 --- /dev/null +++ b/src/operations/operationList.component.ts @@ -0,0 +1,150 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : +import { Component, Inject, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { Observable } from 'rxjs/Rx'; + +import { Logger } from '@nsalaun/ng-logger'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ToastrService } from 'ngx-toastr'; + +import { Account } from '../accounts/account'; +import { AccountService } from '../accounts/account.service'; +import { Operation } from './operation'; +import { OperationService } from './operation.service'; +import { OperationEditModalComponent } from './operationEditModal.component'; + +@Component({ + selector: 'operation-list', + template: ` +
+
+
+ +
+ +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
#Date d'op.Libellé de l'opérationMontantSoldeCatégorieActions
+ +
+
+
+ ` +}) +export class OperationListComponent implements OnInit { + private account: Account; + private minDate: Date; + private maxDate: Date; + private operations: Operation[]; + + constructor( + private toastrService: ToastrService, + private operationService: OperationService, + private accountService: AccountService, + private logger: Logger, + private ngbModal: NgbModal, + private route: ActivatedRoute + ) {} + + ngOnInit() { + this.accountService.get( + +this.route.snapshot.paramMap.get('accountId') + ).subscribe(account => { + this.account = account + }); + } + + /* + * Add an empty operation. + */ + add() { + var operation = new Operation(); + operation.account_id = this.account.id; + + // FIXME Alexis Lahouze 2017-06-15 i18n + const modal = this.ngbModal.open(OperationEditModalComponent, { + size: 'lg' + }); + + modal.componentInstance.operation = operation; + + modal.result.then((operation: Operation) => { + this.save(operation); + }, (reason) => { + }); + }; + + /* + * Load operations. + */ + load(minDate, maxDate) { + this.minDate = minDate; + this.maxDate = maxDate; + + return this.operationService.query( + this.account.id, + minDate, + maxDate + ).subscribe((operations: Operation[]) => { + this.operations = operations.reverse(); + }); + }; + + /* + * Save an operation and return a promise. + */ + save(operation) { + operation.confirmed = true; + + return this.operationService.create(operation).subscribe( + (operation) => { + this.toastrService.success('Operation #' + operation.id + ' saved.'); + + this.load(this.minDate, this.maxDate); + }, (result) => { + this.toastrService.error( + 'Error while saving operation: ' + result.message + ); + } + ); + }; + + onUpdate(dateRange) { + this.load(dateRange.minDate, dateRange.maxDate); + }; +}; diff --git a/src/operations/operationRow.component.ts b/src/operations/operationRow.component.ts new file mode 100644 index 0000000..0d9db50 --- /dev/null +++ b/src/operations/operationRow.component.ts @@ -0,0 +1,153 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : +import { CurrencyPipe } from '@angular/common'; +import { Component, Inject, Input, Output, EventEmitter } from '@angular/core'; + +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ToastrService } from 'ngx-toastr'; + +import { Account } from '../accounts/account'; +import { Operation } from './operation'; +import { OperationService } from './operation.service'; +import { OperationDeleteModalComponent } from './operationDeleteModal.component'; +import { OperationEditModalComponent } from './operationEditModal.component'; + +@Component({ + selector: 'tr[operation-row]', + host: { + "[id]": "operation.id", + "[class.stroke]": "operation.canceled", + "[class.italic]": "!operation.confirmed", + "[class.warning]": "operation.balance < 0", + "[class.danger]": "operation.balance < account.authorized_overdraft" + }, + template: ` +{{ operation.id }} + +{{ operation.operation_date | date:"yyyy-MM-dd" }} + +{{ operation.label }} + +{{ operation.value | currency:'EUR':true }} + + + {{ operation.balance | currency:'EUR':true }} + + +{{ operation.category }} + + +
+ + + + + + + + + + + +
+ + ` +}) +export class OperationRowComponent { + @Input('operation-row') operation: Operation; + @Input() account: Account; + @Output() needsReload: EventEmitter = new EventEmitter(); + + constructor( + private operationService: OperationService, + private toastrService: ToastrService, + private ngbModal: NgbModal, + ) {} + + togglePointed(operation, rowform) { + operation.pointed = !operation.pointed; + + this.save(operation); + }; + + toggleCanceled(operation) { + operation.canceled = !operation.canceled; + + this.save(operation); + }; + + save(operation) { + operation.confirmed = true; + + return this.operationService.update(operation).subscribe((operation) => { + this.toastrService.success('Operation #' + operation.id + ' saved.'); + + this.needsReload.emit(); + }, (result) => { + this.toastrService.error( + 'Error while saving operation: ' + result.message + ); + }); + } + + confirmDelete(operation) { + const modal = this.ngbModal.open(OperationDeleteModalComponent); + + modal.componentInstance.operation = this.operation; + + var id = operation.id; + + modal.result.then((operation: Operation) => { + this.delete(operation); + }, (reason) => { + }) + }; + + delete(operation) { + var id = operation.id; + + return this.operationService.delete(operation).subscribe(() => { + this.toastrService.success('Operation #' + id + ' deleted.'); + + this.needsReload.emit(); + }, (result) => { + this.toastrService.error( + 'An error occurred while trying to delete operation #' + + id + ':
' + result + ); + }); + }; + + modify(operation) { + // FIXME Alexis Lahouze 2017-06-15 i18n + const modal = this.ngbModal.open(OperationEditModalComponent, { + size: 'lg' + }); + + modal.componentInstance.operation = operation; + + modal.result.then((operation: Operation) => { + this.save(operation); + }, (reason) => { + }); + }; +} diff --git a/src/operations/operations.html b/src/operations/operations.html deleted file mode 100644 index 7b9f153..0000000 --- a/src/operations/operations.html +++ /dev/null @@ -1,115 +0,0 @@ - - -
-
-
- -
-
- -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Date d'op.Libellé de l'opérationMontantSoldeCatégorieActions
- -
- {{ operation.operation_date | date:"yyyy-MM-dd" }} - - {{ operation.label }} - - {{ operation.value | currency:"€" }} - - {{ operation.balance | currency:"€" }} - - {{ operation.category }} - -
- - - - - - - - - - - -
-
-
-
diff --git a/src/scheduler/index.js b/src/scheduler/index.js deleted file mode 100644 index e0cc799..0000000 --- a/src/scheduler/index.js +++ /dev/null @@ -1,43 +0,0 @@ -// 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 angular = require('angular'); - -var ngMessages = require('angular-messages'), - ngUiNotification = require('angular-ui-notification'), - ngStrap = require('angular-strap'); - -var ScheduleConfig = require('./schedule.config.js'); -var ScheduleController = require('./schedule.controller.js'); -var ScheduleFactory = require('./schedule.factory.js'); - -module.exports = angular.module('accountant.scheduler', [ - ngMessages, - ngUiNotification, - ngStrap -]) - - .config(ScheduleConfig) - - .factory('ScheduledOperation', ScheduleFactory) - - .controller('SchedulerController', ScheduleController) - - .name; diff --git a/src/scheduler/schedule.config.js b/src/scheduler/schedule.config.js deleted file mode 100644 index 0617c65..0000000 --- a/src/scheduler/schedule.config.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = function($resourceProvider) { - // Keep trailing slashes to avoid redirect by flask.. - $resourceProvider.defaults.stripTrailingSlashes = false; -}; diff --git a/src/scheduler/schedule.controller.js b/src/scheduler/schedule.controller.js deleted file mode 100644 index 7a3663f..0000000 --- a/src/scheduler/schedule.controller.js +++ /dev/null @@ -1,127 +0,0 @@ -var scheduleFormTmpl = require('./schedule.form.tmpl.html'), - scheduleDeleteTmpl = require('./schedule.delete.tmpl.html'); - -module.exports= function($rootScope, $routeParams, Notification, ScheduledOperation, $log, $modal) { - var vm = this; - - // Operation store. - vm.operations = []; - - /* - * Add a new operation at the beginning of th array. - */ - vm.add = function() { - var operation = new ScheduledOperation({ - // eslint-disable-next-line camelcase - account_id: $routeParams.accountId - }); - - return vm.modify(operation); - }; - - /* - * Load operations. - */ - vm.load = function() { - return ScheduledOperation.query({ - // eslint-disable-next-line camelcase - account_id: $routeParams.accountId - }); - }; - - /* - * Save operation. - */ - vm.save = function(operation) { - return operation.$save().then(function(operation) { - Notification.success('Scheduled operation #' + operation.id + ' saved.'); - - vm.operations = vm.load(); - - return operation; - }, function(result){ - $log.error('Error while saving scheduled operation', operation, result); - - Notification.error( - 'Error while saving scheduled operation: ' + result.message - ); - }); - }; - - /* - * Delete an operation and return a promise. - */ - vm.confirmDelete = function(operation) { - var title = "Delete operation #" + operation.id; - - $modal({ - templateUrl: scheduleDeleteTmpl, - controller: function($scope, title, operation, $delete) { - $scope.title = title; - $scope.operation = operation; - $scope.$delete = function() { - $scope.$hide(); - $delete($scope.operation); - }; - }, - locals: { - title: title, - operation: operation, - $delete: vm.delete - } - }); - }; - - /* - * Delete operation. - */ - vm.delete = function(operation) { - var id = operation.id; - - return operation.$delete().then(function() { - Notification.success('Scheduled operation #' + id + ' deleted.'); - - vm.operations = vm.load(); - - return operation; - }, function(result) { - Notification.error( - 'An error occurred while trying to delete scheduled operation #' + - id + ':
' + result - ); - }); - }; - - /* - * Open the popup to modify the operation, save it on confirm. - * @returns a promise. - */ - vm.modify = function(operation) { - // FIXME Alexis Lahouze 2017-06-15 i18n - var title = "Operation"; - - if (operation.id) { - title = title + " #" + operation.id; - } - - $modal({ - templateUrl: scheduleFormTmpl, - controller: function($scope, title, operation, $save) { - $scope.title = title; - $scope.operation = operation; - $scope.$save = function() { - $scope.$hide(); - $save($scope.operation); - }; - }, - locals: { - title: title, - operation: operation, - $save: vm.save - } - }); - }; - - // Load operations on controller initialization. - vm.operations = vm.load(); -}; diff --git a/src/scheduler/schedule.delete.tmpl.html b/src/scheduler/schedule.delete.tmpl.html deleted file mode 100644 index 904ae6d..0000000 --- a/src/scheduler/schedule.delete.tmpl.html +++ /dev/null @@ -1,25 +0,0 @@ - - diff --git a/src/scheduler/schedule.factory.js b/src/scheduler/schedule.factory.js deleted file mode 100644 index aa00231..0000000 --- a/src/scheduler/schedule.factory.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = function($resource) { - return $resource( - '/api/scheduled_operation/:id', { - id: '@id' - } - ); -}; diff --git a/src/scheduler/schedule.form.tmpl.html b/src/scheduler/schedule.form.tmpl.html deleted file mode 100644 index df1cda7..0000000 --- a/src/scheduler/schedule.form.tmpl.html +++ /dev/null @@ -1,89 +0,0 @@ - - diff --git a/src/scheduler/schedule.module.ts b/src/scheduler/schedule.module.ts new file mode 100644 index 0000000..a6b980f --- /dev/null +++ b/src/scheduler/schedule.module.ts @@ -0,0 +1,51 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule } from '@angular/forms'; +import { HttpClientModule } from '@angular/common/http'; +import { RouterModule } from '@angular/router'; + +import { NgLoggerModule, Level } from '@nsalaun/ng-logger'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { ToastrModule } from 'ngx-toastr'; +import { TextMaskModule } from 'angular2-text-mask'; + +import { ScheduleService } from './schedule.service'; +import { ScheduleDeleteModalComponent } from './scheduleDeleteModal.component'; +import { ScheduleEditModalComponent } from './scheduleEditModal.component'; +import { ScheduleFormComponent } from './scheduleForm.component'; +import { ScheduleRowComponent } from './scheduleRow.component'; +import { ScheduleListComponent } from './scheduleList.component'; +import { ScheduleListState } from './schedule.states'; + +@NgModule({ + imports: [ + HttpClientModule, + CommonModule, + ReactiveFormsModule, + RouterModule.forChild([ + ScheduleListState + ]), + NgLoggerModule, + ToastrModule, + NgbModule, + TextMaskModule + ], + providers: [ + ScheduleService, + ], + declarations: [ + ScheduleDeleteModalComponent, + ScheduleEditModalComponent, + ScheduleFormComponent, + ScheduleListComponent, + ScheduleRowComponent + ], + entryComponents: [ + ScheduleDeleteModalComponent, + ScheduleEditModalComponent, + ScheduleListComponent, + ] +}) +export class ScheduleModule {} diff --git a/src/scheduler/schedule.service.ts b/src/scheduler/schedule.service.ts new file mode 100644 index 0000000..1d6ced2 --- /dev/null +++ b/src/scheduler/schedule.service.ts @@ -0,0 +1,47 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : + +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; + +import { Observable } from 'rxjs/Rx'; + +import { Schedule } from './schedule'; + +@Injectable() +export class ScheduleService { + constructor( + private http: HttpClient + ) {} + + private url(id?: number): string { + if(id) { + return `/api/scheduled_operation/${id}`; + } + + return `/api/scheduled_operation`; + } + + query(accountId: number): Observable { + let params = new HttpParams().set('account_id', `${accountId}`); + + return this.http.get(this.url(), { params: params }); + } + + get(accountId: number, id: number): Observable { + let params = new HttpParams().set('account_id', `${accountId}`); + + return this.http.get(this.url(id), { params: params }); + } + + create(schedule: Schedule): Observable { + return this.http.post(this.url(), schedule); + } + + update(schedule: Schedule): Observable { + return this.http.post(this.url(schedule.id), schedule); + } + + delete(schedule: Schedule): Observable { + return this.http.delete(this.url(schedule.id)); + } +} diff --git a/src/scheduler/schedule.states.ts b/src/scheduler/schedule.states.ts new file mode 100644 index 0000000..74d95fc --- /dev/null +++ b/src/scheduler/schedule.states.ts @@ -0,0 +1,8 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : + +import { ScheduleListComponent } from './scheduleList.component'; + +export const ScheduleListState = { + path: 'account/:accountId/scheduler', + component: ScheduleListComponent, +} diff --git a/src/scheduler/schedule.ts b/src/scheduler/schedule.ts new file mode 100644 index 0000000..57a9a47 --- /dev/null +++ b/src/scheduler/schedule.ts @@ -0,0 +1,13 @@ +// vim: set tw=80 ts=2 sw=2 sts=2: + +export class Schedule { + id: number; + start_date: string; + stop_date: string; + day: number; + frequency: number; + label: string; + value: number; + category: string; + account_id: number +} diff --git a/src/scheduler/scheduleDeleteModal.component.ts b/src/scheduler/scheduleDeleteModal.component.ts new file mode 100644 index 0000000..7e21276 --- /dev/null +++ b/src/scheduler/scheduleDeleteModal.component.ts @@ -0,0 +1,49 @@ +// vim: set tw=80 ts=2 sw=2 sts=2: +import { Component, Input } from '@angular/core'; + +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import { Schedule } from './schedule'; + +@Component({ + selector: 'schedule-delete-modal', + template: ` + + + + + + ` +}) +export class ScheduleDeleteModalComponent { + @Input() schedule: Schedule + + constructor(public activeModal: NgbActiveModal) {} + + title(): string { + return "Delete schedule #" + this.schedule.id; + } + + submit(): void { + this.activeModal.close(this.schedule); + } + + cancel(): void { + this.activeModal.dismiss("closed"); + } +} diff --git a/src/scheduler/scheduleEditModal.component.ts b/src/scheduler/scheduleEditModal.component.ts new file mode 100644 index 0000000..d099c6c --- /dev/null +++ b/src/scheduler/scheduleEditModal.component.ts @@ -0,0 +1,66 @@ +// vim: set tw=80 ts=2 sw=2 sts=2: +import { Component, Input, ViewChild } from '@angular/core'; + +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import { Schedule } from './schedule'; +import { ScheduleFormComponent } from './scheduleForm.component'; + +@Component({ + selector: 'schedule-edit-modal', + template: ` + + + + + + ` +}) +export class ScheduleEditModalComponent { + @Input() schedule: Schedule; + @ViewChild('scheduleForm') scheduleForm: ScheduleFormComponent; + + valid: boolean = false; + + constructor(private activeModal: NgbActiveModal) {} + + title(): string { + if(this.schedule.id) { + return "Schedule #" + this.schedule.id; + } else { + return "New schedule"; + } + } + + submit(): void { + let formModel = this.scheduleForm.form.value; + let schedule = Object.assign({}, this.schedule); + + schedule.id = this.schedule.id; + schedule.start_date = formModel.startDate; + schedule.stop_date = formModel.stopDate; + schedule.day = formModel.day; + schedule.frequency = formModel.frequency; + schedule.label = formModel.label; + schedule.value = formModel.value; + schedule.category = formModel.category; + + this.activeModal.close(schedule); + } + + cancel(): void { + this.activeModal.dismiss("closed"); + } +} diff --git a/src/scheduler/scheduleForm.component.ts b/src/scheduler/scheduleForm.component.ts new file mode 100644 index 0000000..3169536 --- /dev/null +++ b/src/scheduler/scheduleForm.component.ts @@ -0,0 +1,189 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : +import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; + +import { Schedule } from './schedule'; + +@Component({ + selector: 'schedule-form', + exportAs: 'scheduleForm', + template: ` +
+
+ + +
+ + +
+

The start date is required.

+
+
+
+ +
+ + +
+ + +
+

The stop date is required.

+
+
+
+ +
+ + +
+ + +
+

The day is required.

+

The day must be greater than 0.

+

The day must be less than or equal to 31.

+
+
+
+ +
+ + +
+ + +
+

The frequency is required.

+

The frequency must be positive.

+
+
+
+ +
+ + +
+ + +
+

The label is required.

+
+
+
+ +
+ + +
+ + +
+

The value is required.

+
+
+
+ +
+ + +
+ + +
+

The category is required.

+
+
+
+
+ ` +}) +export class ScheduleFormComponent implements OnInit { + public form: FormGroup; + @Input() schedule: Schedule; + @Output('submit') submitEventEmitter: EventEmitter = new EventEmitter(); + + //dateMask = [/\d{4}/, '-', /0[1-9]|1[0-2]/, '-', /[0-2]\d|3[0-1]/]; + dateMask = ['2', '0', /\d/, /\d/, '-', /[0-1]/, /\d/, '-', /[0-3]/, /\d/]; + + constructor(private formBuilder: FormBuilder) {} + + ngOnInit() { + this.form = this.formBuilder.group({ + startDate: ['', Validators.required], + stopDate: ['', Validators.required], + day: ['', [Validators.required, Validators.min(1), Validators.max(31)]], + frequency: ['', [Validators.required, Validators.min(0)]], + label: ['', Validators.required], + value: ['', Validators.required], + category: ['', Validators.required], + }); + + this.form.patchValue({ + startDate: this.schedule.start_date, + stopDate: this.schedule.stop_date, + day: this.schedule.day, + frequency: this.schedule.frequency, + label: this.schedule.label, + value: this.schedule.value, + category: this.schedule.category, + }); + } + + submit() { + if(this.form.valid) { + this.submitEventEmitter.emit(); + } + } + + get startDate() { + return this.form.get('startDate'); + } + + get stopDate() { + return this.form.get('stopDate'); + } + + get day() { + return this.form.get('day'); + } + + get frequency() { + return this.form.get('frequency'); + } + + get label() { + return this.form.get('label'); + } + + get value() { + return this.form.get('value'); + } + + get category() { + return this.form.get('category'); + } +} diff --git a/src/scheduler/scheduleList.component.ts b/src/scheduler/scheduleList.component.ts new file mode 100644 index 0000000..3a1ba83 --- /dev/null +++ b/src/scheduler/scheduleList.component.ts @@ -0,0 +1,115 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : +import { Component, Inject, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { Observable } from 'rxjs/Rx'; + +import { Logger } from '@nsalaun/ng-logger'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ToastrService } from 'ngx-toastr'; + +import { ScheduleEditModalComponent } from './scheduleEditModal.component'; +import { ScheduleService } from './schedule.service'; +import { Schedule } from './schedule'; + +@Component({ + selector: 'schedule-list', + template: ` +
+ + + + + + + + + + + + + + + + + + + + + + +
Date de débutDate de finJourFréq.Libellé de l'opérationMontantCatégorieActions
+ +
+
+ ` +}) +export class ScheduleListComponent implements OnInit { + accountId: number; + schedules = []; + + constructor( + private toastrService: ToastrService, + private scheduleService: ScheduleService, + private logger: Logger, + private ngbModal: NgbModal, + private route: ActivatedRoute, + ) {} + + ngOnInit() { + this.logger.log("ngOnInit"); + this.accountId = +this.route.snapshot.paramMap.get('accountId') + // Load operations on controller initialization. + this.load(); + } + + /* + * Add a new operation at the beginning of th array. + */ + add() { + var schedule = new Schedule(); + schedule.account_id = this.accountId; + + const modal = this.ngbModal.open(ScheduleEditModalComponent, { + size: 'lg' + }); + + modal.componentInstance.schedule = schedule; + + modal.result.then((schedule: Schedule) => { + this.save(schedule); + }, (reason) => function(reason) { + }); + }; + + load() { + this.logger.log("Loading schedules for accountId", this.accountId); + if(!this.accountId) { + return; + } + + this.scheduleService.query(this.accountId) + .subscribe((schedules: Schedule[]) => { + this.logger.log("Schedules loaded.", schedules); + this.schedules = schedules; + }, (reason) => { + this.logger.log("Got error", reason); + } + ); + }; + + save(schedule: Schedule) { + return this.scheduleService.create(schedule).subscribe((schedule: Schedule) => { + this.toastrService.success('Schedule #' + schedule.id + ' saved.'); + + this.load(); + }, (result) => { + this.toastrService.error( + 'Error while saving schedule: ' + result.message + ); + }); + }; +}; diff --git a/src/scheduler/scheduleRow.component.ts b/src/scheduler/scheduleRow.component.ts new file mode 100644 index 0000000..b26cfef --- /dev/null +++ b/src/scheduler/scheduleRow.component.ts @@ -0,0 +1,115 @@ +// vim: set tw=80 ts=2 sw=2 sts=2 : +import { CurrencyPipe } from '@angular/common'; +import { Component, Inject, Input, Output, EventEmitter } from '@angular/core'; + +import { Logger } from '@nsalaun/ng-logger'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ToastrService } from 'ngx-toastr'; + +import { ScheduleDeleteModalComponent } from './scheduleDeleteModal.component'; +import { ScheduleEditModalComponent } from './scheduleEditModal.component'; +import { ScheduleService } from './schedule.service'; +import { Schedule } from './schedule'; + +@Component({ + selector: 'tr[schedule-row]', + host: { + "[id]": "schedule.id", + }, + template: ` +{{ schedule.start_date | date: "yyyy-MM-dd" }} + + +{{ schedule.stop_date | date: "yyyy-MM-dd" }} + +{{ schedule.day }} + +{{ schedule.frequency }} + +{{ schedule.label }} + +{{ schedule.value | currency:"EUR":true }} + +{{ schedule.category }} + + +
+ + + + + +
+ + ` +}) +export class ScheduleRowComponent { + @Input('schedule-row') schedule: Schedule; + @Output() needsReload: EventEmitter = new EventEmitter(); + + constructor( + private scheduleService: ScheduleService, + private logger: Logger, + private toastrService: ToastrService, + private ngbModal: NgbModal + ) {} + + save(schedule: Schedule) { + return this.scheduleService.update(schedule).subscribe((schedule: Schedule) => { + this.toastrService.success('Schedule #' + schedule.id + ' saved.'); + + this.needsReload.emit(); + }, result => { + this.toastrService.error( + 'Error while saving schedule: ' + result.message + ); + }); + } + + confirmDelete() { + const modal = this.ngbModal.open(ScheduleDeleteModalComponent); + + modal.componentInstance.schedule = this.schedule; + + modal.result.then((schedule: Schedule) => { + this.delete(schedule); + }, (reason) => function(reason) { + }); + } + + delete(schedule: Schedule) { + var id = schedule.id; + + return this.scheduleService.delete(schedule).subscribe(() => { + this.toastrService.success('Schedule #' + id + ' deleted.'); + + this.needsReload.emit(); + }, result => { + this.toastrService.error( + 'An error occurred while trying to delete schedule #' + id + ':
' + + result.message + ); + }); + } + + modify() { + const modal = this.ngbModal.open(ScheduleEditModalComponent, { + size: 'lg' + }); + + modal.componentInstance.schedule = this.schedule; + + modal.result.then((schedule: Schedule) => { + this.save(schedule); + }, (reason) => function(reason) { + }); + } +} diff --git a/src/scheduler/scheduler.html b/src/scheduler/scheduler.html deleted file mode 100644 index d68e3e7..0000000 --- a/src/scheduler/scheduler.html +++ /dev/null @@ -1,92 +0,0 @@ - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Date de débutDate de finJourFréq.Libellé de l'opérationMontantCatégorieActions
- -
- {{ operation.start_date | date: "yyyy-MM-dd" }} - - {{ operation.stop_date | date: "yyyy-MM-dd" }} - - {{ operation.day }} - - {{ operation.frequency }} - - {{ operation.label }} - - {{ operation.value | currency : "€" }} - - {{ operation.category }} - -
- - - - - -
-
-
diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..99f35dc --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "commonjs", + "moduleResolution": "node", + "sourceMap": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "lib": [ "es2015", "dom" ], + "noImplicitAny": false, + "suppressImplicitAnyIndexErrors": true + } +} diff --git a/webpack.config.js b/webpack.config.js index 6323694..a633140 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -5,8 +5,11 @@ const webpack = require('webpack'); module.exports = { context: path.resolve(__dirname, 'src'), - entry: './app.js', + entry: './main.ts', devtool: 'source-map', + resolve: { + extensions: ['.js', '.jsx', '.ts', '.tsx', '.html'], + }, module: { rules: [{ enforce: 'pre', @@ -29,7 +32,6 @@ module.exports = { enforce: 'pre', test: /\.jsx?$/, //include: path.resolve(__dirname, 'src'), - exclude: /(node_modules|bootstrap)/, loader: 'eslint-loader', options: { useEslintrc: false, @@ -54,6 +56,10 @@ module.exports = { test: /\.jsx?$/, exclude: /node_modules/, loader: 'babel-loader' + }, { + test: /\.tsx?$/, + exclude: /node_modules/, + loader: 'ts-loader' }, { test: /\.html$/, use: [ @@ -73,6 +79,13 @@ module.exports = { 'style-loader', 'css-loader', ] + }, { + test: /\.scss$/, + use: [ + 'style-loader', + 'css-loader', + 'sass-loader', + ] }, { test: /\.(png|woff|woff2|eot|ttf|svg)$/, loader: 'url-loader?limit=100000' @@ -86,6 +99,10 @@ module.exports = { new webpack.ProvidePlugin({ "window.jQuery": "jquery" }), + new webpack.ContextReplacementPlugin( + /angular(\\|\/)core(\\|\/)@angular/, + path.resolve(__dirname, './') + ) ], output: { path: path.resolve(__dirname, 'build'),