78 Commits

Author SHA1 Message Date
8b63a9d5df Change @Input handling. 2018-06-14 21:48:34 +02:00
8eaa3b9039 Cleanup. 2018-06-14 21:19:17 +02:00
b3f0199036 Externalize operation delete modal template. 2018-06-14 21:18:51 +02:00
6efe5a897c Externalize operation row template. 2018-06-14 12:05:58 +02:00
2ea1b38454 Fix property initialization. 2018-06-14 12:03:09 +02:00
161eb42942 Cleanup code. 2018-06-14 11:59:35 +02:00
296c89ce20 Externalize operation list template. 2018-06-14 11:54:35 +02:00
d2e8a3f7ef Clenaup dependencies. 2018-06-14 11:52:41 +02:00
4071893ed5 Improve Operation loading in edit component. 2018-06-14 11:50:03 +02:00
1859efb98c Fix component initialization. 2018-06-14 11:34:04 +02:00
0e001cf680 Externalize operation edit template. 2018-06-14 11:22:07 +02:00
b753b59080 Fix accounts route. 2018-06-14 11:09:19 +02:00
01d77b22c3 Fix Operations routes. 2018-06-14 11:02:12 +02:00
54c44afd47 Homogeneise operation edit selector. 2018-06-14 10:41:38 +02:00
d4d3baba31 Remove unused form component. 2018-06-14 09:28:51 +02:00
d082dae44c Add missing dateMask. 2018-06-14 09:27:48 +02:00
f33d7f1437 Rename component and route. 2018-06-14 09:24:18 +02:00
65dcd7f453 Cleanup. 2018-06-14 09:19:15 +02:00
5f8cf9abbf Implement Operation edit component. 2018-06-14 09:14:06 +02:00
f3d71fd081 Bootstrap new operation page. 2018-06-13 23:04:10 +02:00
d3e73ba739 Cleanup. 2018-06-13 22:09:52 +02:00
bbac3e67bc Simplify routing configuration. 2018-06-13 22:09:10 +02:00
f36a0bfa92 Change route configuration. 2018-06-13 22:08:26 +02:00
45207d6500 Prepare Edit and New separation. 2018-06-13 21:37:40 +02:00
c92b1bed11 Fix routing. 2018-06-13 21:18:53 +02:00
ffbae85e11 Debug. 2018-06-13 21:18:07 +02:00
100e7d2d8b Fix chart height. 2018-06-13 21:17:39 +02:00
280d5b8bb8 Rewrote category chart. 2018-06-11 00:25:01 +02:00
9b9a64fb52 Remove unused event and logger. 2018-06-10 18:39:12 +02:00
f913d52842 Reload balance chart on parameter change. 2018-06-10 18:37:11 +02:00
a4c676ee8e Import cleanup. 2018-06-10 18:11:13 +02:00
088fab2d51 Fix needsReload. 2018-06-10 18:11:03 +02:00
3be01f1240 Semicolon cleanup. 2018-06-10 18:09:07 +02:00
8d6465de71 Use queryParameter for dates. 2018-06-10 18:07:05 +02:00
89c1c4f64c Simplify loading using parameter instead of injected account object. 2018-06-10 17:51:45 +02:00
f9d26ed888 Update query parameter when changing selection range in graph. 2018-06-10 17:37:20 +02:00
d69ace6292 Use query parameters in Category chart component. 2018-06-10 17:26:26 +02:00
cf2453c4c6 Modify categoryService to accept string as dates. 2018-06-10 17:23:47 +02:00
e9cc86d064 Formats and colors. 2018-06-10 15:42:29 +02:00
94e103b365 Handle x band for future period. 2018-06-10 15:42:08 +02:00
adb025c308 Change disaply texts. 2018-06-10 14:51:05 +02:00
0efdbc5378 Remove xAxis title. 2018-06-10 14:49:35 +02:00
abfaab8743 Fix currency pipes. 2018-06-10 14:22:43 +02:00
d0159b3fb6 Fix jqwidets style import. 2018-06-10 14:21:55 +02:00
efe9b6340d Double-quotes to single ones. 2018-06-10 14:21:24 +02:00
7ca6a9827a Improve style depencencies. 2018-06-10 14:21:06 +02:00
e641759ff7 Cleanup unused c3 styles, add jqwidgets style. 2018-06-10 14:10:05 +02:00
e25c788123 Remove unused c3 library, add missing jqwidgets-scripts library. 2018-06-10 14:09:31 +02:00
c617cf2cab Cleanup, separate axis for expenses and revenues. 2018-06-10 11:18:59 +02:00
4d9faf7406 Add expenses and revenues graphs, change graph colors. 2018-06-10 11:06:26 +02:00
ebcddbbfa5 Change chart sizes. 2018-06-10 10:04:14 +02:00
f19403f0ea Use jqWidgets for balance chart. 2018-06-10 10:03:45 +02:00
a915d33a54 Add DailyBalance type. 2018-06-10 10:00:27 +02:00
d30a8951f6 Externalize HTML templates in separate files. 2018-06-10 00:30:56 +02:00
408a1e71b7 Externalize HTML templates in separate files. 2018-06-10 00:13:32 +02:00
11d73abae4 Remove unnneeded semicolon. 2018-06-09 23:56:16 +02:00
062d623649 Add typeRoots. 2018-06-09 23:55:49 +02:00
53a78fa09e Add missing null-loader, upgrade @types/node. 2018-06-09 23:55:13 +02:00
d813bf4c89 Add missing modes. 2018-06-09 23:54:22 +02:00
d6f895c535 Webpack 4 chunk configuration. 2018-06-09 23:54:01 +02:00
df85d72217 Cleanup. 2018-06-09 23:53:29 +02:00
0599d39690 Fix CSS loader. 2018-06-09 23:53:09 +02:00
247ece861e Fix includes and excludes. 2018-06-09 23:51:46 +02:00
156d5099d5 Fix typo in typescript test. 2018-06-09 23:50:49 +02:00
b83b38e2b9 Replace var by const. 2018-06-09 23:49:46 +02:00
9dd489e6c4 Change category graph type. 2018-06-09 23:34:03 +02:00
d63c5dbffe Upgrades. 2018-06-09 23:19:39 +02:00
79bea27c14 Add missing semicolons. 2018-06-09 23:19:39 +02:00
4aea098e3d Change property visibility. 2018-06-09 23:19:39 +02:00
406ae3aae0 Cleanup app module. 2018-06-09 23:19:39 +02:00
994604ddbf Externalize app component html template. 2018-06-09 23:19:39 +02:00
c71c015616 Cleanup main.ts. 2018-06-09 23:19:39 +02:00
1f4b2aa2f5 Simplify polyfills. 2018-06-09 23:19:39 +02:00
2262e0ae49 Cleanup unneeded login config. 2018-06-09 23:19:16 +02:00
eca1cacfeb Improve webpack command invokation. 2017-11-15 21:56:38 +01:00
c799c62621 Upgrades. 2017-11-15 21:55:33 +01:00
658a55b810 Indent. 2017-11-07 22:45:59 +01:00
b50d841862 Improve webpack configuration. 2017-11-07 22:41:52 +01:00
55 changed files with 1226 additions and 1176 deletions

3
.gitignore vendored
View File

@ -81,4 +81,5 @@ tags
# End of https://www.gitignore.io/api/vim,node # End of https://www.gitignore.io/api/vim,node
/build /dist

10
config/helpers.js Normal file
View File

@ -0,0 +1,10 @@
var path = require('path');
var _root = path.resolve(__dirname, '..');
function root(args) {
args = Array.prototype.slice.call(arguments, 0);
return path.join.apply(path, [_root].concat(args));
}
exports.root = root;

119
config/webpack.common.js Normal file
View File

@ -0,0 +1,119 @@
// vim: set tw=80 ts=2 sw=2 sts=2:
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const helpers = require('./helpers');
module.exports = {
entry: {
'polyfills': './src/polyfills.ts',
'vendor': './src/vendor.ts',
'app': './src/main.ts',
'styles': './src/main.scss'
},
resolve: {
extensions: ['.ts', '.js'],
},
module: {
rules: [{
enforce: 'pre',
test: /webpack\.config\.js$/,
include: helpers.root('src', 'app'),
loader: 'eslint-loader',
options: {
useEslintrc: false,
emitWarning: true,
emitError: true,
failOnWarning: true,
failOnError: true,
baseConfig: 'webpack',
rules: {
indent: ['error', 4]
},
},
}, {
// Javascript
enforce: 'pre',
test: /\.jsx?$/,
include: helpers.root('src', 'app'),
loader: 'eslint-loader',
options: {
useEslintrc: false,
emitWarning: false,
emitError: true,
failOnWarning: false,
failOnError: true,
baseConfig: 'angular',
rules: {
indent: ['error', 4]
},
plugins: [
'angular',
'html',
'security',
'this',
'jquery',
'promise'
]
},
// }, {
// test: /\.jsx?$/,
// exclude: /node_modules/,
// loader: 'babel-loader'
}, {
test: /\.ts$/,
use: [
{
loader: 'awesome-typescript-loader',
options: { configFileName: helpers.root('src', 'tsconfig.json') }
},
'angular2-template-loader'
]
}, {
test: /\.html$/,
include: helpers.root('src'),
loader: 'html-loader'
}, {
test: /\.css$/,
//include: helpers.root('src'),
use: [
'style-loader',
'css-loader',
'resolve-url-loader'
]
}, {
test: /\.scss$/,
use: [
'style-loader',
'css-loader',
'resolve-url-loader',
'sass-loader?sourceMap'
]
}, {
test: /\.(png|jpe?g|gif|svg|woff|woff2|ttf|eot|ico)$/,
loader: 'file-loader?name=assets/[name].[hash].[ext]'
}]
},
optimization: {
splitChunks: {
name: true
}
},
plugins: [
// Workaround for angular/angular#11580
new webpack.ContextReplacementPlugin(
// The (\\|\/) piece accounts for path separators in *nix and Windows
/angular(\\|\/)core(\\|\/)@angular/,
helpers.root('./src'), // location of your src
{} // a map of your routes
),
new HtmlWebpackPlugin({
template: 'src/index.ejs'
})
],
};

34
config/webpack.dev.js Normal file
View File

@ -0,0 +1,34 @@
// vim: set tw=80 ts=2 sw=2 sts=2:
const webpackMerge = require('webpack-merge');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const commonConfig = require('./webpack.common.js');
const helpers = require('./helpers');
module.exports = webpackMerge(commonConfig, {
devtool: 'cheap-module-eval-source-map',
mode: 'development',
output: {
path: helpers.root('dist'),
publicPath: '/',
filename: '[name].js',
chunkFilename: '[id].chunk.js'
},
plugins: [
new ExtractTextPlugin('[name].css')
],
devServer: {
proxy: {
'/api': {
target: 'http://localhost:5000',
secure: false
}
},
historyApiFallback: true,
stats: 'minimal'
}
});

41
config/webpack.prod.js Normal file
View File

@ -0,0 +1,41 @@
/* jshint esversion: 6 */
const webpack = require('webpack');
const webpackMerge = require('webpack-merge');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const commonConfig = require('./webpack.common.js');
const helpers = require('./helpers');
const ENV = process.env.NODE_ENV = process.env.ENV = 'production';
module.exports = webpackMerge(commonConfig, {
devtool: 'source-map',
mode: 'production',
output: {
path: helpers.root('dist'),
publicPath: '/',
filename: '[name].[hash].js',
chunkFilename: '[id].[hash].chunk.js'
},
plugins: [
new webpack.NoEmitOnErrorsPlugin(),
new webpack.optimize.UglifyJsPlugin({ // https://github.com/angular/angular/issues/10618
mangle: {
keep_fnames: true
}
}),
new ExtractTextPlugin('[name].[hash].css'),
new webpack.DefinePlugin({
'process.env': {
'ENV': JSON.stringify(ENV)
}
}),
new webpack.LoaderOptionsPlugin({
htmlLoader: {
minimize: false // workaround for ng2
}
})
]
});

View File

@ -4,68 +4,89 @@
"repository": "https://git.lahouze.org/xals/accountant", "repository": "https://git.lahouze.org/xals/accountant",
"license": "AGPL-1.0", "license": "AGPL-1.0",
"devDependencies": { "devDependencies": {
"@angular/cli": "^6.0.7",
"@angular/compiler-cli": "^6.0.3",
"@ngtools/webpack": "^6.1.0-beta.1",
"angular2-template-loader": "^0.6.2",
"awesome-typescript-loader": "^5.0.0",
"babel-core": "^6.26.0", "babel-core": "^6.26.0",
"babel-eslint": "^8.0.1", "babel-eslint": "^8.2.2",
"babel-loader": "^7.1.2", "babel-loader": "^7.1.4",
"css-loader": "^0.28.5", "copy-webpack-plugin": "^4.5.1",
"ejs-loader": "^0.3.0", "css-loader": "^0.28.10",
"eslint": "^4.10.0", "ejs-loader": "^0.3.1",
"eslint": "^4.18.2",
"eslint-config-angular": "^0.5.0", "eslint-config-angular": "^0.5.0",
"eslint-config-webpack": "^1.2.5", "eslint-config-webpack": "^1.2.5",
"eslint-loader": "^1.9.0", "eslint-loader": "^2.0.0",
"eslint-plugin-angular": "^3.1.0", "eslint-plugin-angular": "^3.3.0",
"eslint-plugin-html": "^3.2.0", "eslint-plugin-html": "^4.0.2",
"eslint-plugin-jquery": "^1.2.1", "eslint-plugin-jquery": "^1.3.0",
"eslint-plugin-promise": "^3.6.0", "eslint-plugin-promise": "^3.7.0",
"eslint-plugin-security": "^1.4.0", "eslint-plugin-security": "^1.4.0",
"eslint-plugin-this": "^0.2.2", "eslint-plugin-this": "^0.2.2",
"extract-text-webpack-plugin": "^3.0.2", "extract-text-webpack-plugin": "^4.0.0-beta.0",
"file-loader": "^1.1.5", "file-loader": "^1.1.11",
"html-loader": "^0.5.1", "html-loader": "^0.5.5",
"html-webpack-plugin": "^2.30.1", "html-webpack-plugin": "^3.0.6",
"htmllint-loader": "^2.1.4", "htmllint-loader": "^2.1.4",
"imports-loader": "^0.7.1", "imports-loader": "^0.8.0",
"less": "^3.0.0-alpha.3", "less": "^3.0.1",
"less-loader": "^4.0.5", "less-loader": "^4.1.0",
"loglevel": "^1.5.1", "loglevel": "^1.6.1",
"ngtemplate-loader": "^2.0.1", "ngtemplate-loader": "^2.0.1",
"node-sass": "^4.5.3", "node-sass": "^4.7.2",
"sass-loader": "^6.0.6", "null-loader": "^0.1.1",
"style-loader": "^0.19.0", "raw-loader": "^0.5.1",
"ts-loader": "^3.1.0", "resolve-url-loader": "^2.3.0",
"typescript": "^2.4.2", "sass-loader": "^7.0.2",
"url-loader": "^0.6.2", "script-ext-html-webpack-plugin": "^2.0.1",
"webpack": "^3.8.1", "style-loader": "^0.21.0",
"webpack-dev-server": "^2.9.3" "ts-loader": "^4.0.1",
"typescript": "~2.7.2",
"typescript-eslint-parser": "^15.0.0",
"url-loader": "^1.0.1",
"webpack": "^4.8.0",
"webpack-cli": "^3.0.1",
"webpack-dev-server": "^3.1.0",
"webpack-merge": "^4.1.1"
}, },
"dependencies": { "dependencies": {
"@angular/animations": "^4.4.6", "@angular/animations": "^6.0.3",
"@angular/common": "^4.4.6", "@angular/common": "^6.0.3",
"@angular/compiler": "^4.4.6", "@angular/compiler": "^6.0.3",
"@angular/core": "^4.4.6", "@angular/core": "^6.0.3",
"@angular/forms": "^4.4.6", "@angular/forms": "^6.0.3",
"@angular/http": "^4.4.6", "@angular/http": "^6.0.3",
"@angular/platform-browser": "^4.4.6", "@angular/platform-browser": "^6.0.3",
"@angular/platform-browser-dynamic": "^4.4.6", "@angular/platform-browser-dynamic": "^6.0.3",
"@angular/router": "^4.4.6", "@angular/router": "^6.0.3",
"@ng-bootstrap/ng-bootstrap": "^1.0.0-beta.2", "@ng-bootstrap/ng-bootstrap": "^2.0.0",
"@nsalaun/ng-logger": "^2.0.2", "@nsalaun/ng-logger": "^5.0.0",
"@types/c3": "^0.4.45", "@swimlane/ngx-charts": "^8.0.2",
"@types/node": "^8.0.47", "@types/d3": "^5.0.0",
"angular2-text-mask": "^8.0.3", "@types/geojson": "^7946.0.3",
"base64util": "^1.0.2", "@types/node": "^10.3.2",
"bootstrap": "4.0.0-beta", "@types/underscore": "^1.8.8",
"c3": "^0.4.17", "angular2-text-mask": "^9.0.0",
"base64util": "^2.0.0-f",
"bootstrap": "4.1.1",
"d3": "^5.0.0",
"font-awesome": "^4.7.0", "font-awesome": "^4.7.0",
"jquery": "^3.2.1", "jquery": "^3.3.1",
"moment": "^2.19.1", "jqwidgets-scripts": "^5.7.2",
"ngx-toastr": "^6.5.0", "moment": "^2.21.0",
"reflect-metadata": "^0.1.10", "ng2-nvd3": "^2.0.0",
"rxjs": "^5.5.2", "ngx-toastr": "^8.3.0",
"zone.js": "^0.8.17" "reflect-metadata": "^0.1.12",
"rxjs": "^6.2.0",
"rxjs-compat": "^6.2.0",
"underscore": "^1.8.3",
"zone.js": "^0.8.20"
}, },
"scripts": { "scripts": {
"build": "webpack --config webpack.config.js", "build": "rm -Rf dist && webpack --config config/webpack.prod.js --progress --profile --bail",
"dev": "webpack-dev-server --debug --devtool eval --config webpack.config.js --progress --colors --hot --content-base build" "test": "karma start",
"dev": "webpack-dev-server --config config/webpack.dev.js --inline --progress --colors --hot --info --debug --devtool eval-cheap-module-source-map"
} }
} }

View File

@ -3,13 +3,13 @@
import { AccountBalances } from './accountBalances'; import { AccountBalances } from './accountBalances';
export class Account { export class Account {
id: number; public id: number;
name: string; public name: string;
authorized_overdraft: number; public authorized_overdraft: number;
balances: AccountBalances; public balances: AccountBalances;
constructor() { public constructor() {
this.authorized_overdraft = 0; this.authorized_overdraft = 0;
} }
} }

View File

@ -1,6 +1,6 @@
// vim: set tw=80 ts=2 sw=2 sts=2 : // vim: set tw=80 ts=2 sw=2 sts=2 :
export class AccountBalances { export class AccountBalances {
current: number; public current: number;
pointed: number; public pointed: number;
future: number; public future: number;
} }

View File

@ -0,0 +1,20 @@
<div class="modal-header">
<h3 class="modal-title" id="modal-title">{{ title() }}</h3>
</div>
<div class="modal-body" id="modal-body">
<p>
Do you really want to delete account #{{ account.id }} with name:<br/>
{{ account.name }}
</p>
</div>
<div class="modal-footer">
<button class="btn btn-danger" (click)="submit()">
Yes
</button>
<button class="btn btn-default" (click)="cancel()">
No
</button>
</div>

View File

@ -7,28 +7,7 @@ import { Account } from './account';
@Component({ @Component({
selector: 'account-delete-modal', selector: 'account-delete-modal',
template: ` templateUrl: './accountDeleteModal.component.html'
<div class="modal-header">
<h3 class="modal-title" id="modal-title">{{ title() }}</h3>
</div>
<div class="modal-body" id="modal-body">
<p>
Do you really want to delete account #{{ account.id }} with name:<br/>
{{ account.name }}
</p>
</div>
<div class="modal-footer">
<button class="btn btn-danger" (click)="submit()">
Yes
</button>
<button class="btn btn-default" (click)="cancel()">
No
</button>
</div>
`
}) })
export class AccountDeleteModalComponent { export class AccountDeleteModalComponent {
@Input() account: Account @Input() account: Account

View File

@ -0,0 +1,17 @@
<div class="modal-header">
<h3 class="modal-title" id="modal-title">{{ title() }}</h3>
</div>
<div class="modal-body" id="modal-body">
<account-form [account]="account" (submit)="submit()" #accountForm="accountForm"></account-form>
</div>
<div class="modal-footer">
<button class="btn btn-primary" [disabled]="!accountForm.form.valid" (click)="submit()">
Save
</button>
<button class="btn btn-default" (click)="cancel()">
Cancel
</button>
</div>

View File

@ -8,25 +8,7 @@ import { AccountFormComponent } from './accountForm.component';
@Component({ @Component({
selector: 'account-edit-modal', selector: 'account-edit-modal',
template: ` templateUrl: './accountEditModal.component.html'
<div class="modal-header">
<h3 class="modal-title" id="modal-title">{{ title() }}</h3>
</div>
<div class="modal-body" id="modal-body">
<account-form [account]="account" (submit)="submit()" #accountForm="accountForm"></account-form>
</div>
<div class="modal-footer">
<button class="btn btn-primary" [disabled]="!accountForm.form.valid" (click)="submit()">
Save
</button>
<button class="btn btn-default" (click)="cancel()">
Cancel
</button>
</div>
`
}) })
export class AccountEditModalComponent { export class AccountEditModalComponent {
@Input() account: Account; @Input() account: Account;

View File

@ -0,0 +1,47 @@
<form novalidate
(keyup.enter)="submit()" [formGroup]="form">
<div class="form-group row">
<label class="col-sm-4 control-label" for="name">
Account name
</label>
<div class="col-sm-8"
[class.has-danger]="name.errors">
<input class="form-control"
id="name" formControlName="name"
placeholder="Account name">
<div class="help-block text-danger" *ngIf="name.errors">
<p *ngIf="name.errors.required">The account name is required.</p>
</div>
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 control-label" for="authorized-overdraft">
Authorized overdraft
</label>
<div class="col-sm-8"
[class.has-danger]="authorizedOverdraft.errors">
<div class="input-group">
<input class="form-control"
id="authorized-overdraft" formControlName="authorizedOverdraft"
placeholder="Authorized overdraft">
<div class="input-group-addon">.00€</div>
</div>
<div class="help-block text-danger" *ngIf="authorizedOverdraft.errors">
<p *ngIf="authorizedOverdraft.errors.required">
The authorized overdraft is required.
</p>
<p *ngIf="authorizedOverdraft.errors.max">
The authorized overdraft must be less than or equal to 0.
</p>
</div>
</div>
</div>
</form>

View File

@ -7,54 +7,7 @@ import { Account } from './account';
@Component({ @Component({
selector: 'account-form', selector: 'account-form',
exportAs: 'accountForm', exportAs: 'accountForm',
template: ` templateUrl: './accountForm.component.html'
<form novalidate
(keyup.enter)="submit()" [formGroup]="form">
<div class="form-group row">
<label class="col-sm-4 control-label" for="name">
Account name
</label>
<div class="col-sm-8"
[class.has-danger]="name.errors">
<input class="form-control"
id="name" formControlName="name"
placeholder="Account name">
<div class="help-block text-danger" *ngIf="name.errors">
<p *ngIf="name.errors.required">The account name is required.</p>
</div>
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 control-label" for="authorized-overdraft">
Authorized overdraft
</label>
<div class="col-sm-8"
[class.has-danger]="authorizedOverdraft.errors">
<div class="input-group">
<input class="form-control"
id="authorized-overdraft" formControlName="authorizedOverdraft"
placeholder="Authorized overdraft">
<div class="input-group-addon">.00€</div>
</div>
<div class="help-block text-danger" *ngIf="authorizedOverdraft.errors">
<p *ngIf="authorizedOverdraft.errors.required">
The authorized overdraft is required.
</p>
<p *ngIf="authorizedOverdraft.errors.max">
The authorized overdraft must be less than or equal to 0.
</p>
</div>
</div>
</div>
</form>
`
}) })
export class AccountFormComponent implements OnInit { export class AccountFormComponent implements OnInit {
public form: FormGroup; public form: FormGroup;

View File

@ -0,0 +1,27 @@
<div class="row">
<table class="table table-sm table-striped table-condensed table-hover">
<thead>
<tr>
<th>Nom du compte</th>
<th>Solde courant</th>
<th>Solde pointé</th>
<th>Découvert autorisé</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="5">
<button class="btn btn-success" (click)="add()">
Ajouter
</button>
</td>
</tr>
<tr *ngFor="let account of accounts"
[account-row]="account" (needsReload)="load()">
</tr>
</tbody>
</table>
</div>

View File

@ -13,35 +13,7 @@ import { AccountEditModalComponent } from './accountEditModal.component';
@Component({ @Component({
selector: 'account-list', selector: 'account-list',
template: ` templateUrl: './accountList.component.html',
<div class="row">
<table class="table table-sm table-striped table-condensed table-hover">
<thead>
<tr>
<th>Nom du compte</th>
<th>Solde courant</th>
<th>Solde pointé</th>
<th>Découvert autorisé</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="5">
<button class="btn btn-success" (click)="add()">
Ajouter
</button>
</td>
</tr>
<tr *ngFor="let account of accounts"
[account-row]="account" (needsReload)="load()">
</tr>
</tbody>
</table>
</div>
`,
}) })
export class AccountListComponent implements OnInit { export class AccountListComponent implements OnInit {
accounts: Account[]; accounts: Account[];

View File

@ -0,0 +1,41 @@
<td>
<a [routerLink]="[account.id, 'operations']">{{ account.name }}</a>
</td>
<td>
<span (ngClass)="valueClass(accountBalances?.current)">
{{ accountBalances?.current | currency:'EUR':'symbol' }}
</span>
</td>
<td>
<span (ngClass)="valueClass(accountBalances?.pointed)">
{{ accountBalances?.pointed | currency:'EUR':'symbol' }}
</span>
</td>
<td>{{ account.authorized_overdraft | currency:'EUR':'symbol' }}</td>
<td>
<div class="btn-group btn-group-sm">
<!-- Edit account. -->
<button type="button" class="btn btn-success"
(click)="modify()">
<span class="fa fa-pencil-square-o"></span>
</button>
<!-- Delete account, with confirm. -->
<button type="button" class="btn btn-danger"
(click)="confirmDelete()">
<span class="fa fa-trash-o"></span>
</button>
<!-- Open account scheduler. -->
<a class="btn btn-secondary"
[hidden]="!account.id"
[routerLink]="[account.id, 'scheduler']">
<span class="fa fa-clock-o"></span>
</a>
</div>
</td>

View File

@ -20,54 +20,13 @@ import { AccountEditModalComponent } from './accountEditModal.component';
"[class.warning]": "warning", "[class.warning]": "warning",
"[class.danger]": "danger" "[class.danger]": "danger"
}, },
template: ` templateUrl: './accountRow.component.html'
<td>
<a [routerLink]="['/account', account.id, 'operations']">{{ account.name }}</a>
</td>
<td>
<span (ngClass)="valueClass(account, accountBalances?.current)">
{{ accountBalances?.current | currency:"EUR":true }}
</span>
</td>
<td>
<span (ngClass)="valueClass(account, accountBalances?.pointed)">
{{ accountBalances?.pointed | currency:"EUR":true }}
</span>
</td>
<td>{{ account.authorized_overdraft | currency:"EUR":true }}</td>
<td>
<div class="btn-group btn-group-sm">
<!-- Edit account. -->
<button type="button" class="btn btn-success"
(click)="modify()">
<span class="fa fa-pencil-square-o"></span>
</button>
<!-- Delete account, with confirm. -->
<button type="button" class="btn btn-secondary"
(click)="confirmDelete()">
<span class="fa fa-trash-o"></span>
</button>
<!-- Open account scheduler. -->
<a class="btn btn-secondary"
[hidden]="!account.id"
[routerLink]="['/account', account.id, 'scheduler']">
<span class="fa fa-clock-o"></span>
</a>
</div>
</td>
`
}) })
export class AccountRowComponent implements OnInit { export class AccountRowComponent implements OnInit {
@Input('account-row') account: Account; @Input('account-row') account: Account;
@Output() needsReload: EventEmitter<void> = new EventEmitter<void>(); @Output() needsReload: EventEmitter<void> = new EventEmitter<void>();
private accountBalances: AccountBalances; public accountBalances: AccountBalances;
constructor( constructor(
private accountService: AccountService, private accountService: AccountService,

View File

@ -1,8 +1,8 @@
// vim: set tw=80 ts=2 sw=2 sts=2 : // vim: set tw=80 ts=2 sw=2 sts=2 :
export class DailyBalance { export class DailyBalance {
operation_date: string; public operation_date: string;
balance: number; public balance: number;
expenses: number; public expenses: number;
revenues: number; public revenues: number;
} }

8
src/app.component.html Normal file
View File

@ -0,0 +1,8 @@
<!-- Navbar -->
<nav class="navbar fixed-top navbar-dark bg-dark">
<a class="navbar-brand" routerLink="/accounts">&nbsp;Accountant</a>
</nav>
<div class="container-fluid">
<router-outlet></router-outlet>
</div>

View File

@ -4,15 +4,6 @@ import { Component } from '@angular/core';
@Component({ @Component({
selector: 'accountant', selector: 'accountant',
template: ` templateUrl: './app.component.html'
<!-- Navbar -->
<nav class="navbar fixed-top navbar-dark bg-dark">
<a class="navbar-brand" routerLink="/accounts">&nbsp;Accountant</a>
</nav>
<div class="container-fluid">
<router-outlet></router-outlet>
</div>
`
}) })
export class AppComponent { } export class AppComponent { }

View File

@ -1,6 +1,4 @@
// vim: set tw=80 ts=2 sw=2 sts=2: // vim: set tw=80 ts=2 sw=2 sts=2:
import 'zone.js';
import 'reflect-metadata';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule } from '@angular/platform-browser';
@ -32,7 +30,8 @@ import { ApiBaseURL, LogLevel } from './app.config';
} }
], { ], {
enableTracing: true, enableTracing: true,
useHash: true useHash: true,
onSameUrlNavigation: 'reload'
}), }),
LoginModule, LoginModule,
NgLoggerModule.forRoot(LogLevel), NgLoggerModule.forRoot(LogLevel),

View File

@ -40,7 +40,7 @@ export class AuthInterceptor implements HttpInterceptor {
let loginService = this.injector.get(LoginService); let loginService = this.injector.get(LoginService);
if(request.url == loginService.url) { if(request.url == loginService.url) {
this.logger.log("Login URL, do not handle."); this.logger.log('Login URL, do not handle.');
return next.handle(request); return next.handle(request);
} }
@ -61,7 +61,7 @@ export class AuthInterceptor implements HttpInterceptor {
return observable.catch( return observable.catch(
(error, caught): Observable<any> => { (error, caught): Observable<any> => {
this.logger.error("Error", error, caught); this.logger.error('Error', error, caught);
if(!(error instanceof HttpErrorResponse) || error.status != 401) { if(!(error instanceof HttpErrorResponse) || error.status != 401) {
return Observable.throw(error); return Observable.throw(error);
@ -74,12 +74,12 @@ export class AuthInterceptor implements HttpInterceptor {
} }
if(!this.observable) { if(!this.observable) {
this.logger.log("No current login observable.") this.logger.log('No current login observable.');
this.observable = loginService.login(); this.observable = loginService.login();
} }
return this.observable.flatMap((token: Token): Observable<HttpEvent<any>> => { return this.observable.flatMap((token: Token): Observable<HttpEvent<any>> => {
this.logger.log("Logged in, access_token:", token.access_token); this.logger.log('Logged in, access_token:', token.access_token);
this.observable = null; this.observable = null;
return this.intercept(request, next, ++pass); return this.intercept(request, next, ++pass);
}).catch((error) => { }).catch((error) => {

View File

@ -1,31 +0,0 @@
module.exports = function($httpProvider, $storageProvider) {
// Define interceptors.
$httpProvider.interceptors.push(function($storage) {
return {
request: function(config) {
var access_token = $storage.session.get('access_token');
if (access_token) {
//var tokenType = $storage.get('token_type');
var tokenType = 'Bearer';
var authorization = tokenType + ' ' + access_token;
config.headers.authorization = authorization;
}
return config;
},
};
});
// Configure storage
// Set global prefix for stored keys
$storageProvider.setPrefix('accountant');
// Change the default storage engine
// Defaults to 'local'
$storageProvider.setDefaultStorageEngine('session');
// Change the enabled storage engines
// Defaults to ['memory', 'cookie', 'session', 'local']
$storageProvider.setEnabledStorageEngines(['local', 'session']);
};

View File

@ -1,38 +0,0 @@
<!-- vim: set tw=80 ts=2 sw=2 sts=2: -->
<div class="modal top am-fade" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="modal-title">Authentification requise</h3>
</div>
<form class="form-horizontal" ng-submit="$login()">
<div class="modal-body" id="modal-body">
<div class="form-group">
<label for="email" class="col-sm-4 control-label">Adresse email</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="email" ng-model="email"
placeholder="Nom d'utilisateur">
</div>
</div>
<div class="form-group">
<label for="password" class="col-sm-4 control-label">Mot de passe</label>
<div class="col-sm-8">
<input type="password" class="form-control" id="password"
ng-model="password" placeholder="Mot de passe">
</div>
</div>
</div>
<div class="modal-footer">
<input class="btn btn-primary" type="submit" value="OK"/>
<button class="btn btn-default" type="button" ng-click="$hide()">
Annuler
</button>
</div>
</form>
</div>
</div>
</div>

View File

@ -1,6 +1,6 @@
// vim: set tw=80 ts=2 sw=2 sts=2: // vim: set tw=80 ts=2 sw=2 sts=2:
export class Login { export class Login {
email: string; public email: string;
password: string; public password: string;
} }

View File

@ -0,0 +1,29 @@
<form novalidate (keyup.enter)="submit()" [formGroup]="form">
<div class="form-group row">
<label for="email" class="col-sm-4 control-label">Adresse email</label>
<div class="col-sm-8"
[class.has-danger]="email.errors">
<input type="text" class="form-control" id="email"
formControlName="email" placeholder="Nom d'utilisateur">
<div class="help-block text-danger" *ngIf="email.errors">
<p *ngIf="email.errors.required">The email is required.</p>
</div>
</div>
</div>
<div class="form-group row">
<label for="password" class="col-sm-4 control-label">Mot de passe</label>
<div class="col-sm-8"
[class.has-danger]="password.errors">
<input type="password" class="form-control" id="password"
formControlName="password" placeholder="Mot de passe">
<div class="help-block text-danger" *ngIf="password.errors">
<p *ngIf="password.errors.required">The password is required.</p>
</div>
</div>
</div>
</form>

View File

@ -7,37 +7,7 @@ import { Login } from './login';
@Component({ @Component({
selector: 'login-form', selector: 'login-form',
exportAs: 'loginForm', exportAs: 'loginForm',
template: ` templateUrl: './loginForm.component.html'
<form novalidate (keyup.enter)="submit()" [formGroup]="form">
<div class="form-group row">
<label for="email" class="col-sm-4 control-label">Adresse email</label>
<div class="col-sm-8"
[class.has-danger]="email.errors">
<input type="text" class="form-control" id="email"
formControlName="email" placeholder="Nom d'utilisateur">
<div class="help-block text-danger" *ngIf="email.errors">
<p *ngIf="email.errors.required">The email is required.</p>
</div>
</div>
</div>
<div class="form-group row">
<label for="password" class="col-sm-4 control-label">Mot de passe</label>
<div class="col-sm-8"
[class.has-danger]="password.errors">
<input type="password" class="form-control" id="password"
formControlName="password" placeholder="Mot de passe">
<div class="help-block text-danger" *ngIf="password.errors">
<p *ngIf="password.errors.required">The password is required.</p>
</div>
</div>
</div>
</form>
`
}) })
export class LoginFormComponent { export class LoginFormComponent {

View File

@ -0,0 +1,17 @@
<div class="modal-header">
<h3 class="modal-title" id="modal-title">Authentification requise</h3>
</div>
<div class="modal-body" id="modal-body">
<login-form (submit)="submit()" #loginForm="loginForm"></login-form>
</div>
<div class="modal-footer">
<button class="btn btn-succes" [disabled]="!loginForm.form.valid" (click)="submit()">
Login
</button>
<button class="btn btn-default" (click)="cancel()">
Cancel
</button>
</div>

View File

@ -2,31 +2,15 @@
import { Component, Input, ViewChild } from '@angular/core'; import { Component, Input, ViewChild } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
//import { jqxWindowComponent } from 'jqwidgets-scripts/jqwidgets-ts/angular_jqxwindow';
import { jqxButtonComponent } from 'jqwidgets-scripts/jqwidgets-ts/angular_jqxbuttons';
import { Login } from './login'; import { Login } from './login';
import { LoginFormComponent } from './loginForm.component'; import { LoginFormComponent } from './loginForm.component';
@Component({ @Component({
selector: 'login-modal', selector: 'login-modal',
template: ` templateUrl: './loginModal.component.html'
<div class="modal-header">
<h3 class="modal-title" id="modal-title">Authentification requise</h3>
</div>
<div class="modal-body" id="modal-body">
<login-form (submit)="submit()" #loginForm="loginForm"></login-form>
</div>
<div class="modal-footer">
<button class="btn btn-primary" [disabled]="!loginForm.form.valid" (click)="submit()">
Login
</button>
<button class="btn btn-default" (click)="cancel()">
Cancel
</button>
</div>
`
}) })
export class LoginModalComponent { export class LoginModalComponent {
@ViewChild('loginForm') loginForm: LoginFormComponent; @ViewChild('loginForm') loginForm: LoginFormComponent;

View File

@ -1,6 +1,6 @@
// vim: set tw=80 ts=2 sw=2 sts=2 : // vim: set tw=80 ts=2 sw=2 sts=2 :
export class Token { export class Token {
access_token: string; public access_token: string;
refresh_token: string; public refresh_token: string;
} }

View File

@ -4,7 +4,7 @@ $fa-font-path: '~font-awesome/fonts';
@import '~bootstrap/scss/bootstrap'; @import '~bootstrap/scss/bootstrap';
@import '~c3/c3'; @import '~jqwidgets-scripts/jqwidgets/styles/jqx.base';
@import '~ngx-toastr/toastr'; @import '~ngx-toastr/toastr';
@ -15,11 +15,3 @@ $fa-font-path: '~font-awesome/fonts';
.stroke { .stroke {
text-decoration: line-through; text-decoration: line-through;
} }
.c3-ygrid-line.zeroline line {
stroke: orange;
}
.c3-ygrid-line.overdraft line {
stroke: #FF0000;
}

View File

@ -1,7 +1,7 @@
// vim: set tw=80 ts=2 sw=2 sts=2 : // vim: set tw=80 ts=2 sw=2 sts=2 :
import { AppModule } from './app.module';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app.module';
platformBrowserDynamic().bootstrapModule(AppModule); platformBrowserDynamic().bootstrapModule(AppModule);

View File

@ -1,177 +1,203 @@
// vim: set tw=80 ts=2 sw=2 sts=2: // vim: set tw=80 ts=2 sw=2 sts=2:
import * as moment from 'moment'; import * as moment from 'moment';
import * as c3 from 'c3';
import { import { Component, ViewChild, Input, OnInit } from '@angular/core';
Component, ElementRef, import { ActivatedRoute, Router, ParamMap } from '@angular/router';
Inject, Input, Output, EventEmitter,
OnInit, OnChanges import { Logger } from '@nsalaun/ng-logger';
} from '@angular/core'; import { jqxChartComponent } from 'jqwidgets-scripts/jqwidgets-ts/angular_jqxchart';
import { Account } from '../accounts/account'; import { Account } from '../accounts/account';
import { DailyBalance } from './dailyBalance';
import { DailyBalanceService } from '../accounts/dailyBalance.service'; import { DailyBalanceService } from '../accounts/dailyBalance.service';
class DateRange {
minDate: Date;
maxDate: Date;
}
@Component({ @Component({
selector: 'balance-chart', selector: 'balance-chart',
template: '<div></div>' template: `
<jqxChart #balanceChart
[width]="'100%'"
[height]="400"
[title]="'Balance evolution'"
[description]="''"
[source]="data"
[xAxis]="xAxis"
[valueAxis]="valueAxis"
[seriesGroups]="seriesGroups"
(onRangeSelectionChanged)="select($event)">
</jqxChart>
`
}) })
export class BalanceChartComponent implements OnInit, OnChanges { export class BalanceChartComponent implements OnInit {
@Input() account: Account; private _account: Account;
@Output() onUpdate: EventEmitter<DateRange> = new EventEmitter<DateRange>(); @ViewChild('balanceChart') chart: jqxChartComponent;
private chart: c3.ChartAPI; public data;
private balances: number[];
public xAxis: any = {
type: 'date',
dataField: 'operation_date',
displayText: 'Date',
baseUnit: 'day',
bands: [{
fillColor: 'blue',
opacity: 0.10
}],
rangeSelector: {
size: 80,
padding: { /*left: 0, right: 0,*/top: 0, bottom: 0 },
backgroundColor: 'white',
dataField: 'balance',
//baseUnit: 'month',
baseUnit: 'day',
gridLines: { visible: false },
serieType: 'line',
//labels: {
// formatFunction: (value: any): any => {
// return this.months[value.getMonth()] + '\'' + value.getFullYear().toString().substring(2);
// }
//}
}
};
public valueAxis: any = {
title: {
visible: false
},
};
public seriesGroups: any = [{
type: 'stackedcolumn',
valueAxis: {
title: { text: 'Expenses/revenues' },
position: 'right',
visible: true,
gridLines: { visible: false },
labels: {
horizontalAlignment: 'left',
formatSettings: {
sufix: '€',
decimalPlaces: 2
},
}
},
series: [{
dataField: 'expenses',
displayText: 'Expenses',
fillColor: 'tomato'
}, {
dataField: 'revenues',
displayText: 'Revenues',
fillColor: 'yellowgreen'
}]
}, {
type: 'stepline',
valueAxis: {
title: { text: 'Balance' },
//gridLines: { visible: false },
labels: {
formatSettings: {
sufix: '€',
decimalPlaces: 2
}
}
},
series: [{
dataField: 'balance',
displayText: 'Balance',
fillColor: 'steelblue'
}],
bands: [{
minValue: 0, maxValue: 0, fillColor: 'orange', lineWidth: 1
}, {
minValue: 0, maxValue: 0, fillColor: 'red', lineWidth: 1
}]
}];
constructor( constructor(
private elementRef: ElementRef, private router: Router,
private activatedRoute: ActivatedRoute,
private logger: Logger,
private dailyBalanceService: DailyBalanceService, private dailyBalanceService: DailyBalanceService,
) { ) {
this.data = [];
} }
loadData(account: Account) { loadData() {
this.logger.info('Loading data.');
let accountId = this.activatedRoute.snapshot.paramMap.get('accountId');
this.dailyBalanceService.query( this.dailyBalanceService.query(
account.id +accountId
).subscribe((results) => { ).subscribe((results) => {
var headers: any[][] = [['date', 'balances', 'expenses', 'revenues']]; this.data = results;
var rows = results.map(function(result) { let lastResult = results[results.length -1];
return [
result.operation_date, this.updateXBands(results[0].operation_date, lastResult.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) { setLines(account: Account) {
if(this.chart) {
this.chart.ygrids([
{ value: 0, axis: 'y2' },
{ value: 0, axis: 'y', class: 'zeroline'},
]);
if (account) { if (account) {
this.chart.ygrids.add({ this.seriesGroups[1].bands[1].minValue = account.authorized_overdraft;
value: account.authorized_overdraft, this.seriesGroups[1].bands[1].maxValue = account.authorized_overdraft;
axis: 'y', }
class: 'overdraft' }
ngOnInit() {
this.activatedRoute.queryParamMap.subscribe((params: ParamMap) => {
let fromDay = params.get('from');
let toDay = params.get('to');
this.xAxis.minValue = moment(fromDay).toDate();
this.xAxis.maxValue = moment(toDay).toDate();
if(this.chart && this.chart.host) {
this.chart.refresh();
}
});
this.loadData();
}
@Input()
set account(account: Account) {
this._account = account;
this.setLines(account);
}
get account(): Account {
return this._account;
}
updateXBands(minDate, maxDate) {
if(moment(maxDate) > moment()) {
if(moment(minDate) < moment()) {
this.xAxis.bands[0].minValue = moment().toDate();
} else {
this.xAxis.bands[0].minValue = moment(minDate).toDate();
}
this.xAxis.bands[0].maxValue = moment(maxDate).toDate();
}
}
select(event: any) {
let args = event.args;
this.updateXBands(args.minValue, args.maxValue);
let accountId = this.activatedRoute.snapshot.paramMap.get('accountId');
this.router.navigate(['account', accountId, 'operations'], {
queryParams: {
from: moment(args.minValue).format('YYYY-MM-DD'),
to: moment(args.maxValue).format('YYYY-MM-DD')
}
}); });
} }
} }
};
ngOnChanges(changes) {
if('account' in changes && changes.account.currentValue) {
this.loadData(changes.account.currentValue);
this.setLines(changes.account.currentValue);
} else {
this.setLines(this.account);
}
};
}

View File

@ -23,7 +23,7 @@ export class CategoryService {
return date; return date;
} }
query(id: number, minDate: Date = null, maxDate: Date = null): Observable<Category[]> { query(id: number, minDate: Date|string = null, maxDate: Date|string = null): Observable<Category[]> {
let params: HttpParams = new HttpParams(); let params: HttpParams = new HttpParams();
if(minDate) { if(minDate) {

View File

@ -1,7 +1,8 @@
// vim: set tw=80 ts=2 sw=2 sts=2 : // vim: set tw=80 ts=2 sw=2 sts=2 :
export class Category { export class Category {
category: string; public category: string;
expenses: number; public expenses: number;
revenues: number; public revenues: number;
public income: number;
} }

View File

@ -1,107 +1,141 @@
// vim: set tw=80 ts=2 sw=2 sts=2 : // vim: set tw=80 ts=2 sw=2 sts=2 :
import * as c3 from 'c3'; import { Component, ViewChild, OnInit } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router';
import { import { Logger } from '@nsalaun/ng-logger';
Component, ElementRef, import { jqxChartComponent } from 'jqwidgets-scripts/jqwidgets-ts/angular_jqxchart';
Inject, Input, Output,
OnInit, OnChanges
} from '@angular/core';
import { Account } from '../accounts/account'; import * as _ from 'underscore';
import { Category } from './category';
import { CategoryService } from './category.service'; import { CategoryService } from './category.service';
@Component({ @Component({
selector: 'category-chart', selector: 'category-chart',
template: '<div></div>' template: `
<jqxChart #categoryChart
[width]="'100%'"
[height]="400"
[title]="'Categories'"
[description]="''"
[showLegend]="false"
[seriesGroups]="seriesGroups">
</jqxChart>
`
}) })
export class CategoryChartComponent implements OnInit, OnChanges { export class CategoryChartComponent implements OnInit {
@Input() minDate: Date; @ViewChild('categoryChart') chart: jqxChartComponent;
@Input() maxDate: Date;
@Input() account: Account;
chart: c3.ChartAPI; public seriesGroups: any = [{
type: 'donut',
source: [],
//showLabels: true,
series: [{
dataField: 'value',
displayText: 'category',
initialAngle: 90,
radius: 130,
innerRadius: 90,
formatSettings: { sufix: '€', decimalPlaces: 2 },
radiusDataField: 'category',
colorFunction: (value, itemIndex, series, group) => {
if(group.source[itemIndex].type === 'expenses') {
return 'tomato';
}
return 'yellowgreen';
},
opacity: 0.5
}]
}, {
type: 'donut',
source: [],
showLabels: true,
series: [{
dataField: 'value',
displayText: 'name',
initialAngle: 90,
labelRadius: 50,
radius: 85,
innerRadius: 75,
formatSettings: { sufix: '€', decimalPlaces: 2 },
colorFunction: (value, itemIndex, series, group) => {
if(group.source[itemIndex].name === 'Expenses') {
return 'tomato';
}
return 'yellowgreen';
}
}]
}];
constructor( constructor(
private elementRef: ElementRef, private activatedRoute: ActivatedRoute,
private logger: Logger,
private categoryService: CategoryService, private categoryService: CategoryService,
) {} ) {
//this.data = [];
}
loadData() {
let accountId = this.activatedRoute.snapshot.paramMap.get('accountId');
let fromDay = this.activatedRoute.snapshot.queryParamMap.get('from');
let toDay = this.activatedRoute.snapshot.queryParamMap.get('to');
loadData(account: Account) {
this.categoryService.query( this.categoryService.query(
account.id, +accountId,
this.minDate, fromDay,
this.maxDate toDay
).subscribe((results) => { ).subscribe((results: Category[]) => {
var expenses=[], let expenses = _.filter(results, function(item: Category) {
revenues=[], return item.expenses < 0;
colors={}, }).map(function(item: Category) {
names={}; return {
category: item.category,
var revenuesColor = 'green', value: -item.expenses,
expensesColor = 'orange'; type: 'expenses'
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
});
}); });
expenses = _.sortBy(expenses, 'value').reverse();
let revenues = _.filter(results, function(item: Category) {
return item.revenues > 0;
}).map(function(item: Category) {
return {
category: item.category,
value: item.revenues,
type: 'revenues'
}; };
});
revenues = _.sortBy(revenues, 'value');
this.seriesGroups[0].source = expenses.concat(revenues);
let totals = [
{name: 'Expenses', value: 0},
{name: 'Revenues', value: 0}
];
results.forEach(function(item: Category) {
totals[0].value -= item.expenses;
totals[1].value += item.revenues;
});
this.seriesGroups[1].source = totals;
if(this.chart && this.chart.host) {
this.chart.refresh();
}
});
}
ngOnInit() { ngOnInit() {
this.chart = c3.generate({ this.activatedRoute.queryParamMap.subscribe(() => {this.loadData();});
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) { this.loadData();
if('account' in changes && changes.account.currentValue) {
this.loadData(changes.account.currentValue);
} else if (this.account) {
this.loadData(this.account);
} }
};
} }

View File

@ -0,0 +1,9 @@
// vim: set tw=80 ts=2 sw=2 sts=2 :
export class DailyBalance {
public operation_date: string;
public expenses: number;
public revenues: number;
public income: number;
public balance: number;
}

View File

@ -2,37 +2,39 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http'; import { HttpClientModule } from '@angular/common/http';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { NgLoggerModule, Level } from '@nsalaun/ng-logger'; import { NgLoggerModule, Level } from '@nsalaun/ng-logger';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { NgxChartsModule } from '@swimlane/ngx-charts';
import { ToastrModule } from 'ngx-toastr'; import { ToastrModule } from 'ngx-toastr';
import { TextMaskModule } from 'angular2-text-mask'; import { TextMaskModule } from 'angular2-text-mask';
import { jqxChartComponent } from 'jqwidgets-scripts/jqwidgets-ts/angular_jqxchart';
import { BalanceChartComponent } from './balanceChart.component'; import { BalanceChartComponent } from './balanceChart.component';
import { CategoryChartComponent } from './categoryChart.component'; import { CategoryChartComponent } from './categoryChart.component';
import { OperationRowComponent } from './operationRow.component'; import { OperationRowComponent } from './operationRow.component';
import { CategoryService } from './category.service'; import { CategoryService } from './category.service';
import { OperationService } from './operation.service'; import { OperationService } from './operation.service';
import { OperationEditComponent } from './operationEdit.component';
import { OperationListComponent } from './operationList.component'; import { OperationListComponent } from './operationList.component';
import { OperationDeleteModalComponent } from './operationDeleteModal.component'; import { OperationDeleteModalComponent } from './operationDeleteModal.component';
import { OperationFormComponent } from './operationForm.component'; import { OperationRoutes } from './operation.states';
import { OperationEditModalComponent } from './operationEditModal.component'
import { OperationListState } from './operation.states'
@NgModule({ @NgModule({
imports: [ imports: [
HttpClientModule, HttpClientModule,
CommonModule, CommonModule,
ReactiveFormsModule, FormsModule,
RouterModule.forChild([ RouterModule.forChild(
OperationListState OperationRoutes
]), ),
NgLoggerModule, NgLoggerModule,
ToastrModule, ToastrModule,
NgbModule, NgbModule,
NgxChartsModule,
TextMaskModule TextMaskModule
], ],
providers: [ providers: [
@ -40,17 +42,16 @@ import { OperationListState } from './operation.states'
OperationService, OperationService,
], ],
declarations: [ declarations: [
jqxChartComponent,
BalanceChartComponent, BalanceChartComponent,
CategoryChartComponent, CategoryChartComponent,
OperationRowComponent, OperationRowComponent,
OperationEditComponent,
OperationListComponent, OperationListComponent,
OperationDeleteModalComponent, OperationDeleteModalComponent,
OperationFormComponent,
OperationEditModalComponent,
], ],
entryComponents: [ entryComponents: [
OperationDeleteModalComponent, OperationDeleteModalComponent,
OperationEditModalComponent,
OperationListComponent, OperationListComponent,
] ]
}) })

View File

@ -1,8 +1,17 @@
// vim: set tw=80 ts=2 sw=2 sts=2 : // vim: set tw=80 ts=2 sw=2 sts=2 :
import { OperationListComponent } from './operationList.component'; import { Routes } from '@angular/router';
export const OperationListState = { import { OperationListComponent } from './operationList.component';
path: 'account/:accountId/operations', import { OperationEditComponent } from './operationEdit.component';
component: OperationListComponent
} export const OperationRoutes: Routes = [{
path: 'accounts/:accountId/operations',
component: OperationListComponent,
}, {
path: 'accounts/:accountId/operations/new',
component: OperationEditComponent,
}, {
path: 'accounts/:accountId/operations/:operationId/edit',
component: OperationEditComponent,
}];

View File

@ -1,15 +1,15 @@
// vim: set tw=80 ts=2 sw=2 sts=2: // vim: set tw=80 ts=2 sw=2 sts=2:
export class Operation { export class Operation {
id: number; public id: number;
operation_date: string; public operation_date: string;
label: string; public label: string;
value: number; public value: number;
category: string; public category: string;
scheduled_operation_id: number; public scheduled_operation_id: number;
account_id: number; public account_id: number;
balance: number; public balance: number;
confirmed: boolean; public confirmed: boolean;
pointed: boolean; public pointed: boolean;
cancelled: boolean public canceled: boolean
} }

View File

@ -0,0 +1,22 @@
<!-- vim: set tw=80 ts=2 sw=2 sts=2 : -->
<div class="modal-header">
<h3 class="modal-title" id="modal-title">Delete Operation #{{ operation.id }}</h3>
</div>
<div class="modal-body" id="modal-body">
<p>
Do you really want to delete operation #{{ operation.id }} with label:<br/>
{{ operation.label }}
</p>
</div>
<div class="modal-footer">
<button class="btn btn-danger" (click)="submit()">
Yes
</button>
<button class="btn btn-default" (click)="cancel()">
No
</button>
</div>

View File

@ -7,28 +7,7 @@ import { Operation } from './operation';
@Component({ @Component({
selector: 'operation-delete-modal', selector: 'operation-delete-modal',
template: ` templateUrl: './operationDeleteModal.component.html'
<div class="modal-header">
<h3 class="modal-title" id="modal-title">Delete Operation #{{ operation.id }}</h3>
</div>
<div class="modal-body" id="modal-body">
<p>
Do you really want to delete operation #{{ operation.id }} with label:<br/>
{{ operation.label }}
</p>
</div>
<div class="modal-footer">
<button class="btn btn-danger" (click)="submit()">
Yes
</button>
<button class="btn btn-default" (click)="cancel()">
No
</button>
</div>
`
}) })
export class OperationDeleteModalComponent { export class OperationDeleteModalComponent {
@Input() operation: Operation @Input() operation: Operation

View File

@ -0,0 +1,79 @@
<!-- vim: set tw=80 ts=2 sw=2 sts=2 :-->
<div>
<div class="row">
<form novalidate (keyup.enter)="submit()" #form="ngForm">
<div class="form-group">
<label for="operation-date">Date</label>
<input class="form-control"
[class.has-danger]="operationDate.errors"
id="operation-date" name="operationDate"
[(ngModel)]="operation.operation_date" #operationDate="ngModel"
[textMask]="{mask: dateMask}"
placeholder="Operation date" required>
<div class="help-block text-danger" *ngIf="operationDate.errors">
<small class="form-text" *ngIf="operationDate.errors.required">
The operation date is required.
</small>
</div>
</div>
<div class="form-group">
<label for="label">Label</label>
<input class="form-control"
[class.has-danger]="label.errors"
id="label" name="label"
[(ngModel)]="operation.label" #label="ngModel"
placeholder="Label" required>
<div class="help-block text-danger" *ngIf="label.errors">
<small class="form-text" *ngIf="label.errors.required">
The operation label is required.
</small>
</div>
</div>
<div class="form-group">
<label for="value">Montant</label>
<input class="form-control"
[class.has-errors]="value.errors"
id="value" name="value"
[(ngModel)]="operation.value" #value="ngModel"
type="number" placeholder="Value" required>
<div class="help-block text-danger" *ngIf="value.errors">
<small class="form-text" *ngIf="value.errors.required">
The operation value is required.
</small>
</div>
</div>
<div class="form-group">
<label for="category">Catégorie</label>
<input class="form-control"
[class.has-errors]="category.errors"
id="category" name="category"
[(ngModel)]="operation.category" #category="ngModel"
placeholder="Category" required>
<div class="help-block text-danger" *ngIf="category.errors">
<small class="form-text" *ngIf="category.errors.required">
The operation category is required.
</small>
</div>
</div>
<button class="btn btn-primary" [disabled]="!form.valid" (click)="submit()">
Save
</button>
<button class="btn btn-default" (click)="cancel()">
Cancel
</button>
</form>
</div>
</div>

View File

@ -0,0 +1,86 @@
// vim: set tw=80 ts=2 sw=2 sts=2 :
import { Location } from '@angular/common';
import { Component } from '@angular/core';
import { ActivatedRoute, Router, Params } from '@angular/router';
import { Logger } from '@nsalaun/ng-logger';
import { ToastrService } from 'ngx-toastr';
import { Operation } from './operation';
import { OperationService } from './operation.service';
@Component({
selector: 'operation-edit',
templateUrl: './operationEdit.component.html'
})
export class OperationEditComponent {
public operation: Operation = new Operation();
//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 location: Location,
private router: Router,
private route: ActivatedRoute,
private logger: Logger,
private toastrService: ToastrService,
private operationService: OperationService
) {}
ngOnInit() {
this.route.paramMap.subscribe((params: Params) => {
let operationId = params.get('operationId');
if (operationId) {
this.logger.info('Loading operation with id', operationId);
// Load Operation
this.operationService.get(
+operationId
).subscribe((operation: Operation) => {
this.operation = operation;
this.logger.info(operation);
});
} else {
this.logger.info('Initialize new operation');
let accountId = params.get('accountId');
this.operation = new Operation();
this.operation.account_id = +accountId;
this.logger.info(this.operation);
}
});
}
submit(): void {
this.save(this.operation);
}
/*
* 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.location.back();
}, (result) => {
this.toastrService.error(
'Error while saving operation: ' + result.message
);
}
);
}
cancel(): void {
this.location.back();
}
}

View File

@ -1,63 +0,0 @@
// 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: `
<div class="modal-header">
<h3 class="modal-title" id="modal-title">{{ title() }}</h3>
</div>
<div class="modal-body" id="modal-body">
<operation-form [operation]="operation" (submit)="submit()" #operationForm="operationForm"></operation-form>
</div>
<div class="modal-footer">
<button class="btn btn-primary" [disabled]="!operationForm.form.valid" (click)="submit()">
Save
</button>
<button class="btn btn-default" (click)="cancel()">
Cancel
</button>
</div>
`
})
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");
}
}

View File

@ -1,122 +0,0 @@
// 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: `
<form novalidate (keyup.enter)="submit()" [formGroup]="form">
<div class="form-group row">
<label class="col-sm-4 control-label" for="operation-date">Date</label>
<div class="col-sm-8"
[class.has-danger]="operationDate.errors">
<input class="form-control"
id="operation-date" formControlName="operationDate"
[textMask]="{mask: dateMask}"
placeholder="Operation date">
<div class="help-block text-danger" *ngIf="operationDate.errors">
<p *ngIf="operationDate.errors.required">The operation date is required.</p>
</div>
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 control-label" for="label">Label</label>
<div class="col-sm-8"
[class.has-danger]="label.errors">
<input class="form-control"
id="label" formControlName="label"
placeholder="Label">
<div class="help-block text-danger" *ngIf="label.errors">
<p *ngIf="label.errors.required">The operation label is required.</p>
</div>
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 control-label" for="value">Montant</label>
<div class="col-sm-8"
[class.has-errors]="value.errors">
<input class="form-control"
id="value" formControlName="value"
type="number" placeholder="Value">
<div class="help-block text-danger" *ngIf="value.errors">
<p *ngIf="value.errors.required">The operation value is required.</p>
</div>
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 control-label" for="category">Catégorie</label>
<div class="col-sm-8"
[class.has-errors]="category.errors">
<input class="form-control"
id="category" formControlName="category"
placeholder="Category">
<div class="help-block text-danger" *ngIf="category.errors">
<p *ngIf="category.errors.required">The operation category is required.</p>
</div>
</div>
</div>
</form>
`
})
export class OperationFormComponent implements OnInit {
public form: FormGroup;
@Input() operation: Operation;
@Output() submitEventEmitter: EventEmitter<void> = new EventEmitter<void>();
//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');
}
}

View File

@ -0,0 +1,46 @@
<!-- vim: set tw=80 ts=2 sw=2 sts=2 : -->
<div>
<div class="row">
<div class="col-md-8">
<balance-chart [account]="account"></balance-chart>
</div>
<div class="col-md-4">
<category-chart></category-chart>
</div>
</div>
<div class="row">
<table class="table table-striped table-condensed table-hover">
<thead>
<tr>
<th>#</th>
<th>Date d'op.</th>
<th>Libell&eacute; de l'op&eacute;ration</th>
<th>Montant</th>
<th>Solde</th>
<th>Cat&eacute;gorie</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6">
<a class="btn btn-success"
[routerLink]="['new']">
Ajouter
</a>
</td>
</tr>
<tr [operation-row]="operation"
[account]="account"
(needsReload)="loadData()"
*ngFor="let operation of operations">
</tr>
</tbody>
</table>
</div>
</div>

View File

@ -1,8 +1,9 @@
// vim: set tw=80 ts=2 sw=2 sts=2 : // 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 * as moment from 'moment';
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router, Params } from '@angular/router';
import { Logger } from '@nsalaun/ng-logger'; import { Logger } from '@nsalaun/ng-logger';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
@ -12,65 +13,14 @@ import { Account } from '../accounts/account';
import { AccountService } from '../accounts/account.service'; import { AccountService } from '../accounts/account.service';
import { Operation } from './operation'; import { Operation } from './operation';
import { OperationService } from './operation.service'; import { OperationService } from './operation.service';
import { OperationEditModalComponent } from './operationEditModal.component';
@Component({ @Component({
selector: 'operation-list', selector: 'operation-list',
template: ` templateUrl: './operationList.component.html'
<div>
<div class="row">
<div class="col-md-9">
<balance-chart (onUpdate)="onUpdate($event)"
[account]="account"></balance-chart>
</div>
<div class="col-md-3">
<category-chart
[minDate]="minDate"
[maxDate]="maxDate"
[account]="account"></category-chart>
</div>
</div>
<div class="row">
<table class="table table-striped table-condensed table-hover">
<thead>
<tr>
<th>#</th>
<th>Date d'op.</th>
<th>Libell&eacute; de l'op&eacute;ration</th>
<th>Montant</th>
<th>Solde</th>
<th>Cat&eacute;gorie</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6">
<button class="btn btn-success" (click)="add()">
Ajouter
</button>
</td>
</tr>
<tr [operation-row]="operation"
[account]="account"
(needsReload)="load(minDate, maxDate)"
*ngFor="let operation of operations">
</tr>
</tbody>
</table>
</div>
</div>
`
}) })
export class OperationListComponent implements OnInit { export class OperationListComponent implements OnInit {
private account: Account; private account: Account;
private minDate: Date; public operations: Operation[];
private maxDate: Date;
private operations: Operation[];
constructor( constructor(
private toastrService: ToastrService, private toastrService: ToastrService,
@ -78,73 +28,49 @@ export class OperationListComponent implements OnInit {
private accountService: AccountService, private accountService: AccountService,
private logger: Logger, private logger: Logger,
private ngbModal: NgbModal, private ngbModal: NgbModal,
private route: ActivatedRoute private route: ActivatedRoute,
private router: Router
) {} ) {}
ngOnInit() { ngOnInit() {
this.accountService.get( this.route.queryParamMap.subscribe((params: Params) => {
+this.route.snapshot.paramMap.get('accountId') if (params.get('from') && params.get('to')) {
).subscribe(account => { this.loadData();
this.account = account } else {
this.router.navigate([], {
queryParams: {
from: moment().startOf('month').format('YYYY-MM-DD'),
to: moment().endOf('month').format('YYYY-MM-DD')
}
}); });
} }
/*
* 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; this.route.paramMap.subscribe((params: Params) => {
let accountId = params.get('accountId');
modal.result.then((operation: Operation) => { this.accountService.get(
this.save(operation); +accountId
}, (reason) => { ).subscribe(account => {
this.account = account;
}); });
}; });
}
/* /*
* Load operations. * Load operations.
*/ */
load(minDate, maxDate) { loadData() {
this.minDate = minDate; let accountId = this.route.snapshot.paramMap.get('accountId');
this.maxDate = maxDate; let fromDay = this.route.snapshot.queryParamMap.get('from');
let toDay = this.route.snapshot.queryParamMap.get('to');
return this.operationService.query( return this.operationService.query(
this.account.id, +accountId,
minDate, fromDay,
maxDate toDay
).subscribe((operations: Operation[]) => { ).subscribe((operations: Operation[]) => {
this.operations = operations.reverse(); 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);
};
};

View File

@ -0,0 +1,51 @@
<!-- vim: set tw=80 ts=2 sw=2 sts=2 : -->
<td>{{ operation.id }}</td>
<td>{{ operation.operation_date | date:"yyyy-MM-dd" }}</td>
<td>{{ operation.label }}</td>
<td>{{ operation.value | currency:'EUR':'symbol' }}</td>
<td [class.text-warning]="operation.balance < 0"
[class.text-danger]="operation.balance < account.authorized_overdraft">
{{ operation.balance | currency:'EUR':'symbol' }}
</td>
<td>{{ operation.category }}</td>
<td>
<div class="btn-group btn-group-sm">
<!-- Edit operation, for non-canceled operation. -->
<a class="btn btn-success"
*ngIf="!operation.canceled"
[routerLink]="[operation.id, 'edit']"
title="edit">
<span class="fa fa-pencil-square-o"></span>
</a>
<!-- Toggle pointed operation, for non-canceled operations. -->
<button type="button" class="btn btn-secondary"
*ngIf="!operation.canceled"
(click)="togglePointed(operation)"
[class.active]="operation.pointed" title="point">
<span class="fa" [class.fa-check-square-o]="operation.pointed"
[class.fa-square-o]="!operation.pointed"></span>
</button>
<!-- Toggle canceled operation. -->
<button type="button" class="btn btn-warning"
(click)="toggleCanceled(operation)"
*ngIf="operation.scheduled_operation_id"
[class.active]="operation.canceled" title="cancel">
<span class="fa fa-remove"></span>
</button>
<!-- Delete operation, with confirm. -->
<button type="button" class="btn btn-danger"
(click)="confirmDelete(operation)"
*ngIf="operation.id && !operation.scheduled_operation_id">
<span class="fa fa-trash-o"></span>
</button>
</div>
</td>

View File

@ -1,7 +1,9 @@
// vim: set tw=80 ts=2 sw=2 sts=2 : // vim: set tw=80 ts=2 sw=2 sts=2 :
import { CurrencyPipe } from '@angular/common'; import { CurrencyPipe } from '@angular/common';
import { Component, Inject, Input, Output, EventEmitter } from '@angular/core'; import { Component, Inject, Input } from '@angular/core';
import { Router } from '@angular/router';
import { Logger } from '@nsalaun/ng-logger';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
@ -9,7 +11,6 @@ import { Account } from '../accounts/account';
import { Operation } from './operation'; import { Operation } from './operation';
import { OperationService } from './operation.service'; import { OperationService } from './operation.service';
import { OperationDeleteModalComponent } from './operationDeleteModal.component'; import { OperationDeleteModalComponent } from './operationDeleteModal.component';
import { OperationEditModalComponent } from './operationEditModal.component';
@Component({ @Component({
selector: 'tr[operation-row]', selector: 'tr[operation-row]',
@ -20,80 +21,31 @@ import { OperationEditModalComponent } from './operationEditModal.component';
"[class.warning]": "operation.balance < 0", "[class.warning]": "operation.balance < 0",
"[class.danger]": "operation.balance < account.authorized_overdraft" "[class.danger]": "operation.balance < account.authorized_overdraft"
}, },
template: ` templateUrl: './operationRow.component.html'
<td>{{ operation.id }}</td>
<td>{{ operation.operation_date | date:"yyyy-MM-dd" }}</td>
<td>{{ operation.label }}</td>
<td>{{ operation.value | currency:'EUR':true }}</td>
<td [class.text-warning]="operation.balance < 0"
[class.text-danger]="operation.balance < account.authorized_overdraft">
{{ operation.balance | currency:'EUR':true }}
</td>
<td>{{ operation.category }}</td>
<td>
<div class="btn-group btn-group-sm">
<!-- Edit operation, for non-canceled operation. -->
<button type="button" class="btn btn-success"
*ngIf="!operation.canceled"
(click)="modify(operation)" title="edit">
<span class="fa fa-pencil-square-o"></span>
</button>
<!-- Toggle pointed operation, for non-canceled operations. -->
<button type="button" class="btn btn-secondary"
*ngIf="!operation.canceled"
(click)="togglePointed(operation)"
[class.active]="operation.pointed" title="point">
<span class="fa" [class.fa-check-square-o]="operation.pointed"
[class.fa-square-o]="!operation.pointed"></span>
</button>
<!-- Toggle canceled operation. -->
<button type="button" class="btn btn-warning"
(click)="toggleCanceled(operation)"
*ngIf="operation.scheduled_operation_id"
[class.active]="operation.canceled" title="cancel">
<span class="fa fa-remove"></span>
</button>
<!-- Delete operation, with confirm. -->
<button type="button" class="btn btn-danger"
(click)="confirmDelete(operation)"
*ngIf="operation.id && !operation.scheduled_operation_id">
<span class="fa fa-trash-o"></span>
</button>
</div>
</td>
`
}) })
export class OperationRowComponent { export class OperationRowComponent {
@Input('operation-row') operation: Operation; @Input('operation-row') operation: Operation = new Operation();
@Input() account: Account; @Input() account: Account = new Account();
@Output() needsReload: EventEmitter<void> = new EventEmitter<void>();
constructor( constructor(
private operationService: OperationService, private operationService: OperationService,
private toastrService: ToastrService, private toastrService: ToastrService,
private logger: Logger,
private ngbModal: NgbModal, private ngbModal: NgbModal,
private router: Router
) {} ) {}
togglePointed(operation, rowform) { togglePointed(operation, rowform) {
operation.pointed = !operation.pointed; operation.pointed = !operation.pointed;
this.save(operation); this.save(operation);
}; }
toggleCanceled(operation) { toggleCanceled(operation) {
operation.canceled = !operation.canceled; operation.canceled = !operation.canceled;
this.save(operation); this.save(operation);
}; }
save(operation) { save(operation) {
operation.confirmed = true; operation.confirmed = true;
@ -101,7 +53,8 @@ export class OperationRowComponent {
return this.operationService.update(operation).subscribe((operation) => { return this.operationService.update(operation).subscribe((operation) => {
this.toastrService.success('Operation #' + operation.id + ' saved.'); this.toastrService.success('Operation #' + operation.id + ' saved.');
this.needsReload.emit(); this.logger.info('Reload route', this.router.url);
this.router.navigateByUrl(this.router.url);
}, (result) => { }, (result) => {
this.toastrService.error( this.toastrService.error(
'Error while saving operation: ' + result.message 'Error while saving operation: ' + result.message
@ -114,13 +67,11 @@ export class OperationRowComponent {
modal.componentInstance.operation = this.operation; modal.componentInstance.operation = this.operation;
var id = operation.id;
modal.result.then((operation: Operation) => { modal.result.then((operation: Operation) => {
this.delete(operation); this.delete(operation);
}, (reason) => { }, () => {
}) });
}; }
delete(operation) { delete(operation) {
var id = operation.id; var id = operation.id;
@ -128,26 +79,13 @@ export class OperationRowComponent {
return this.operationService.delete(operation).subscribe(() => { return this.operationService.delete(operation).subscribe(() => {
this.toastrService.success('Operation #' + id + ' deleted.'); this.toastrService.success('Operation #' + id + ' deleted.');
this.needsReload.emit(); this.logger.info('Reload route', this.router.url);
this.router.navigateByUrl(this.router.url);
}, (result) => { }, (result) => {
this.toastrService.error( this.toastrService.error(
'An error occurred while trying to delete operation #' + 'An error occurred while trying to delete operation #' +
id + ':<br />' + result id + ':<br />' + 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) => {
});
};
} }

3
src/polyfills.ts Normal file
View File

@ -0,0 +1,3 @@
import 'core-js/es6';
import 'core-js/es7/reflect';
import 'zone.js/dist/zone';

View File

@ -8,6 +8,9 @@
"experimentalDecorators": true, "experimentalDecorators": true,
"lib": [ "es2015", "dom" ], "lib": [ "es2015", "dom" ],
"noImplicitAny": false, "noImplicitAny": false,
"suppressImplicitAnyIndexErrors": true "suppressImplicitAnyIndexErrors": true,
"typeRoots": [
"../node_modules/@types"
]
} }
} }

13
src/vendor.ts Normal file
View File

@ -0,0 +1,13 @@
// Angular
import '@angular/platform-browser';
import '@angular/platform-browser-dynamic';
import '@angular/core';
import '@angular/common';
import '@angular/http';
import '@angular/router';
// RxJS
import 'rxjs';
// Other vendors for example jQuery, Lodash or Bootstrap
// You can import js, ts, css, sass, ...

View File

@ -1,136 +1 @@
/* jshint esversion: 6 */ module.exports = require('./config/webpack.dev.js');
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
module.exports = {
context: path.resolve(__dirname, 'src'),
entry: {
"main": [
'./main.ts'
],
"styles": [
'./main.scss'
]
},
devtool: 'source-map',
resolve: {
extensions: ['.js', '.jsx', '.ts', '.tsx', '.html'],
},
module: {
rules: [{
enforce: 'pre',
test: /webpack\.config\.js$/,
include: path.resolve(__dirname),
loader: 'eslint-loader',
options: {
useEslintrc: false,
emitWarning: true,
emitError: true,
failOnWarning: true,
failOnError: true,
baseConfig: 'webpack',
rules: {
indent: ['error', 4]
},
},
}, {
// Javascript
enforce: 'pre',
test: /\.jsx?$/,
//include: path.resolve(__dirname, 'src'),
loader: 'eslint-loader',
options: {
useEslintrc: false,
emitWarning: false,
emitError: true,
failOnWarning: false,
failOnError: true,
baseConfig: 'angular',
rules: {
indent: ['error', 4]
},
plugins: [
'angular',
'html',
'security',
'this',
'jquery',
'promise'
]
},
}, {
test: /\.jsx?$/,
exclude: /node_modules/,
loader: 'babel-loader'
}, {
test: /\.tsx?$/,
exclude: /node_modules/,
loader: 'ts-loader'
}, {
test: /\.html$/,
use: [
'ngtemplate-loader?relativeTo=/accountant-ui/src',
'html-loader'
]
}, {
test: /\.less$/,
use: [
'style-loader',
'css-loader',
'less-loader',
]
}, {
test: /\.css$/,
use: [
'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'
}]
},
plugins: [
new webpack.ProvidePlugin({
"window.jQuery": "jquery"
}),
new webpack.ContextReplacementPlugin(
/angular(\\|\/)core(\\|\/)@angular/,
path.resolve(__dirname, './')
),
new HtmlWebpackPlugin({
title: 'Accountant',
template: 'index.ejs',
hash: false,
inject: true,
compile: true,
minify: false,
chunks: 'all'
})
],
output: {
path: path.resolve(__dirname, 'build'),
filename: '[name].bundle.js',
chunkFilename: '[name].chunk.js'
//publicPath: 'js'
},
devServer: {
proxy: {
'/api': {
target: 'http://localhost:5000',
secure: false
}
},
hot: true,
noInfo: false,
quiet: false,
}
};