├── .editorconfig
├── .travis.yml
├── LICENSE
├── README.md
├── api
├── .babelrc
├── .dockerignore
├── .eslintrc.js
├── .gitignore
├── .sequelizerc
├── Dockerfile
├── docker-compose.yml
├── jest.config.js
├── nodemon.json
├── package-lock.json
├── package.json
└── src
│ ├── __tests__
│ ├── dummy.js
│ └── services
│ │ ├── credits.tests.js
│ │ ├── debits.tests.js
│ │ ├── email.tests.js
│ │ └── session.tests.js
│ ├── app.js
│ ├── app
│ ├── routes
│ │ ├── controllers
│ │ │ ├── CreditsController.js
│ │ │ ├── DebitsController.js
│ │ │ ├── SessionController.js
│ │ │ └── TransactionsController.js
│ │ ├── index.js
│ │ └── middleware
│ │ │ └── auth.js
│ └── services
│ │ ├── CreditsService.js
│ │ ├── DebitsService.js
│ │ ├── EmailService.js
│ │ ├── SessionService.js
│ │ └── TransactionsService.js
│ ├── config
│ ├── database.js
│ └── email.js
│ ├── database
│ ├── migrations
│ │ ├── 20200310230045-users.js
│ │ ├── 20200319150256-create-credits.js
│ │ └── 20200319192816-create-debits.js
│ └── models
│ │ ├── Credit.js
│ │ ├── Debit.js
│ │ ├── User.js
│ │ └── index.js
│ ├── server.js
│ └── utils
│ └── error.js
└── app
├── .eslintrc.js
├── .gitignore
├── jest.config.js
├── package.json
├── public
├── images
│ ├── icons-114.png
│ ├── icons-144.png
│ ├── icons-180.png
│ └── icons-512.png
├── index.html
├── manifest.json
└── worker.js
├── src
├── App.tsx
├── __tests__
│ ├── components
│ │ ├── access.spec.tsx
│ │ ├── amount.spec.tsx
│ │ ├── barchart.spec.tsx
│ │ ├── linechart.spec.tsx
│ │ ├── navbar.spec.tsx
│ │ ├── pageloading.spec.tsx
│ │ ├── piechart.spec.tsx
│ │ └── transactionmodal.spec.tsx
│ ├── mocks
│ │ ├── geral.ts
│ │ ├── props.ts
│ │ ├── sagas.ts
│ │ ├── state.ts
│ │ └── transactions.ts
│ ├── pages
│ │ ├── dashboard.spec.tsx
│ │ ├── login.spec.tsx
│ │ ├── overview.spec.tsx
│ │ ├── resetPassword.spec.tsx
│ │ └── settings.spec.tsx
│ ├── reducers
│ │ ├── transactions.spec.ts
│ │ └── users.spec.ts
│ ├── sagas
│ │ ├── transactions.spec.ts
│ │ └── users.spec.ts
│ └── utils
│ │ ├── currency.spec.ts
│ │ ├── format.spec.ts
│ │ └── settings.spec.ts
├── assets
│ └── piggy-bank.json
├── components
│ ├── Access
│ │ └── index.tsx
│ ├── Amount
│ │ ├── index.tsx
│ │ └── styles.ts
│ ├── BarChart
│ │ ├── index.tsx
│ │ └── styles.ts
│ ├── LineChart
│ │ ├── index.tsx
│ │ └── styles.ts
│ ├── Navbar
│ │ ├── index.tsx
│ │ └── styles.ts
│ ├── PageLoading
│ │ ├── index.tsx
│ │ └── styles.ts
│ ├── PieChart
│ │ ├── index.tsx
│ │ └── styles.ts
│ ├── SandwichMenu
│ │ ├── index.tsx
│ │ └── styles.ts
│ ├── TransactionModal
│ │ ├── index.tsx
│ │ └── styles.ts
│ └── TransactionTable
│ │ ├── index.tsx
│ │ └── styles.ts
├── config
│ └── highcharts.ts
├── enums
│ ├── settings.ts
│ └── transactions.ts
├── index.tsx
├── interfaces
│ ├── charts.ts
│ ├── settings.ts
│ ├── store.ts
│ ├── transaction.ts
│ └── user.ts
├── pages
│ ├── Dashboard
│ │ ├── Overview
│ │ │ ├── index.tsx
│ │ │ └── styles.ts
│ │ ├── Report
│ │ │ ├── index.tsx
│ │ │ └── styles.ts
│ │ ├── Settings
│ │ │ ├── index.tsx
│ │ │ └── styles.ts
│ │ ├── index.tsx
│ │ └── styles.ts
│ ├── Login
│ │ ├── index.tsx
│ │ └── styles.ts
│ └── ResetPassword
│ │ ├── index.tsx
│ │ └── styles.ts
├── react-app-env.d.ts
├── routes
│ └── Routes.tsx
├── serviceWorker.ts
├── services
│ └── api.ts
├── setupTests.ts
├── store
│ ├── ducks
│ │ ├── index.ts
│ │ ├── transactions.ts
│ │ └── users.ts
│ ├── index.ts
│ └── sagas
│ │ ├── index.ts
│ │ ├── transactions.ts
│ │ └── users.ts
├── styles
│ ├── global.ts
│ └── variables.ts
└── utils
│ ├── currency.ts
│ ├── date.ts
│ ├── format.ts
│ └── settings.ts
├── tsconfig.json
├── tsconfig.production.json
└── yarn.lock
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | quote_type = single
6 | indent_size = 2
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "10.16.0"
4 | cache:
5 | directories:
6 | - /api/node_modules
7 | - /app/node_modules
8 |
9 | before_install:
10 | - cd api && npm install
11 | - cd .. && cd app && yarn
12 |
13 | script:
14 | - yarn build
15 | - cd .. && cd api && npm run build
16 |
17 | branches:
18 | only:
19 | - master
20 |
21 | after_success:
22 | - npm run test
23 | - cd .. && cd app && yarn test
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Gabriel Hahn Schaeffer
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ConMoney
2 |
3 | [](https://travis-ci.org/gabriel-hahn/billing-cycle-reactjs) [](https://www.codacy.com/manual/gabriel_hahn/billing-cycle-reactjs?utm_source=github.com&utm_medium=referral&utm_content=gabriel-hahn/billing-cycle-reactjs&utm_campaign=Badge_Grade) [](https://github.com/gabriel-hahn/billing-cycle-reactjs/pulls) [](https://github.com/gabriel-hahn/billing-cycle-reactjs/issues?utf8=?&q=is%3Aissue+is%3Aopen+label%3Abug) [](http://opensource.org/licenses/MIT)
4 |
5 |
6 |
7 |
8 |
9 | ConMoney developed to manage the money of users and showing the balance of your credits and debits :globe_with_meridians: :moneybag:
10 |
11 | Using ConMoney to manage your credits and debits, you will have a better control of your money, your bills and how much money you saved in the last few months.
12 |
13 | NodeJS |Express|Sequelize|PG |React |Redux|Typescript|Highcharts|Docker |Docker Compose|
14 | -------|-------|---------|------|------|-----|----------|----------|-------|--------------|
15 | 10.16.0|4.17.1 |5.21.5 |7.18.2|16.8.5|4.0.1|3.3.4 |8.0.4 |19.03.4|1.24.1 |
16 |
17 | ### Colors:
18 |
19 | -  `#eff1f9`
20 | -  `#383f53`
21 | -  `#67b1bd`
22 | -  `#d43763`
23 | -  `#4aa7ee`
24 |
25 | ## Getting Started
26 |
27 | > I recommend use [NPM](https://www.npmjs.com/) or [Yarn](https://yarnpkg.com/) as package management and install all dependencies using it, running ```yarn``` or ```npm install``` inside each folder (app and api).
28 |
29 | > If you have some issues related to permissions, just add the sudo command before yarn/npm command, as ```sudo yarn```.
30 |
31 | ### Environment Variables
32 |
33 | One important thing before start running this project locally is set up all environment variables. You can set them for back-end creating a ```.env``` file on api folder root and following this structure:
34 |
35 | ```
36 | APP_SECRET=You can set some application secret here to create user password encryption
37 | APP_DOMAIN=App Domain with port, probably http://locahost:3000 at this moment.
38 | EMAIL_DOMAIN=E-mail used to send 'Forgot password' to the user's email.
39 | EMAIL_PASS=E-mail password.
40 | POSTGRES_HOST=Docker host
41 | POSTGRES_DB=Database name
42 | POSTGRES_USER=Database user
43 | POSTGRES_PASSWORD=Database password
44 | ```
45 |
46 | You should do the same on front-end, adding an ```.env``` file to app folder root:
47 |
48 | ```
49 | REACT_APP_API_URL=Api domain with port, probably http://localhost:3333 running it locally.
50 | ```
51 |
52 | ### Back-end
53 |
54 | Inside api folder, you should run the following command which will use docker to start up our server:
55 |
56 | ```$
57 | docker-compose up
58 | ```
59 |
60 | ### Front-end
61 |
62 | Inside app folder, you should run the following command, opening the project at ```localhost:3000```:
63 |
64 | ```$
65 | yarn start
66 | ```
67 |
68 | > You can replace the command above for ```npm run start``` if you are using NPM as package management.
69 |
70 | ## Tests
71 |
72 | You can run ```yarn test``` inside app folder for Front-end tests. The same you can do on api folder for Back-end tests with ```npm run test```.
73 |
74 | ## Contributing
75 |
76 | Please read [CONTRIBUTING.md](https://gist.github.com/PurpleBooth/b24679402957c63ec426) for details on our code of conduct, and the process for submitting pull requests to us.
77 |
78 | ## Versioning
79 |
80 | We use [SemVer](http://semver.org/) for versioning. For the versions available, see the [tags on this repository](https://github.com/gabriel-hahn/billing-cycle-reactjs/tags).
81 |
82 | ## Authors
83 |
84 | [Gabriel Hahn Schaeffer](https://github.com/gabriel-hahn/)
85 |
86 | See also the list of [contributors](https://github.com/gabriel-hahn/billing-cycle-reactjs/contributors) who participated in this project.
87 |
88 | ## License
89 |
90 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE) file for details
91 |
--------------------------------------------------------------------------------
/api/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["minify"],
3 | "plugins": [
4 | ["transform-object-rest-spread", { "useBuiltIns": true }]
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/api/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
--------------------------------------------------------------------------------
/api/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "env": {
3 | "commonjs": true,
4 | "es6": true,
5 | "node": true,
6 | "jest": true
7 | },
8 | "extends": "airbnb-base",
9 | "globals": {
10 | "Atomics": "readonly",
11 | "SharedArrayBuffer": "readonly"
12 | },
13 | "parserOptions": {
14 | "ecmaVersion": 2018
15 | },
16 | "rules": {
17 | "import/no-dynamic-require": 0,
18 | "class-methods-use-this": 0,
19 | "no-path-concat": 0,
20 | "func-names": 0,
21 | }
22 | };
23 |
--------------------------------------------------------------------------------
/api/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | coverage
3 | dist
4 | .env
5 |
--------------------------------------------------------------------------------
/api/.sequelizerc:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | config: path.resolve('src', 'config', 'database.js'),
5 | 'models-path': path.resolve('src', 'database', 'models'),
6 | 'migrations-path': path.resolve('src', 'database', 'migrations'),
7 | };
8 |
--------------------------------------------------------------------------------
/api/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:alpine
2 |
3 | WORKDIR /usr/app
4 |
5 | COPY package*.json ./
6 | RUN npm install
7 |
8 | COPY . .
9 |
10 | EXPOSE 3333
11 |
12 | CMD ["npm", "run", "start"]
13 |
--------------------------------------------------------------------------------
/api/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 |
3 | services:
4 | db:
5 | image: postgres
6 | restart: always
7 | environment:
8 | POSTGRES_DB: ${POSTGRES_DB}
9 | POSTGRES_HOST: ${POSTGRES_HOST}
10 | POSTGRES_USER: ${POSTGRES_USER}
11 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
12 | ports:
13 | - 5432:5432
14 | volumes:
15 | - database_data:/var/lib/postgresql/data
16 | app:
17 | build: .
18 | command: npm run start
19 | ports:
20 | - 3333:3333
21 | volumes:
22 | - .:/usr/app
23 | links:
24 | - db
25 | volumes:
26 | database_data:
27 |
--------------------------------------------------------------------------------
/api/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "watch": ["src"],
3 | "ext": "js",
4 | "execMap": {
5 | "ts": "sucrase-node src/server.js"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/api/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "billing-cycle-api",
3 | "version": "0.1.0",
4 | "main": "src/app.js",
5 | "scripts": {
6 | "start": "node src/server.js --ignore __tests__",
7 | "start:dev": "nodemon src/server.js --ignore __tests__",
8 | "build": "rm -rf dist/ && rm -rf coverage/ && ./node_modules/.bin/babel ./ --out-dir dist/ --ignore ./node_modules --copy-files",
9 | "publish:heroku": "heroku container:push web -a billing-cycle-api && heroku container:release web -a billing-cycle-api",
10 | "test": "./node_modules/.bin/jest"
11 | },
12 | "keywords": [],
13 | "author": "Gabriel Hahn Schaeffer ",
14 | "license": "MIT",
15 | "dependencies": {
16 | "bcryptjs": "^2.4.3",
17 | "cors": "^2.8.5",
18 | "dotenv": "^8.2.0",
19 | "express": "^4.17.1",
20 | "helmet": "^3.23.3",
21 | "jsonwebtoken": "^8.5.1",
22 | "nodemailer": "^6.5.0",
23 | "pg": "^8.5.0",
24 | "sequelize": "^6.3.5"
25 | },
26 | "devDependencies": {
27 | "babel-cli": "^6.26.0",
28 | "babel-loader": "^8.2.2",
29 | "babel-plugin-transform-object-rest-spread": "^6.26.0",
30 | "babel-polyfill": "^6.26.0",
31 | "babel-preset-env": "^1.7.0",
32 | "babel-preset-minify": "^0.5.1",
33 | "babel-register": "^6.26.0",
34 | "eslint": "^6.8.0",
35 | "eslint-config-airbnb-base": "^14.2.1",
36 | "eslint-plugin-import": "^2.22.1",
37 | "jest": "^25.5.4",
38 | "nodemon": "2.0.2",
39 | "prettier": "1.16.4",
40 | "sequelize-cli": "^5.5.1",
41 | "sinon": "^9.2.4",
42 | "sucrase": "3.12.1"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/api/src/__tests__/dummy.js:
--------------------------------------------------------------------------------
1 | const { Credit, Debit, User } = require('../database/models');
2 |
3 | const USER = new User({
4 | id: 3,
5 | name: 'Gabriel Hahn Schaeffer',
6 | email: 'gabriel_hahn@hotmail.com',
7 | password: '123456',
8 | password_hash: 'some_hash',
9 | });
10 |
11 | const CREDIT = new Credit({
12 | id: 5,
13 | user_id: 1,
14 | description: 'Hamburguer John',
15 | date: '2020-03-20T08:10:09.000Z',
16 | value: 19.9,
17 | createdAt: '2020-03-20T13:38:53.511Z',
18 | updatedAt: '2020-03-20T13:38:53.511Z',
19 | });
20 |
21 | const CREDIT_ARRAY = [
22 | new Credit({
23 | id: 5,
24 | user_id: 1,
25 | description: 'Hamburguer John',
26 | date: '2020-03-20T08:10:09.000Z',
27 | value: 19.9,
28 | createdAt: '2020-03-20T13:38:53.511Z',
29 | updatedAt: '2020-03-20T13:38:53.511Z',
30 | }),
31 | new Credit({
32 | id: 2,
33 | user_id: 2,
34 | description: 'Academia',
35 | date: '2020-03-22T10:15:09.000Z',
36 | value: 85.00,
37 | createdAt: '2020-03-20T13:38:53.511Z',
38 | updatedAt: '2020-03-20T13:38:53.511Z',
39 | }),
40 | ];
41 |
42 | const DEBIT = new Debit({
43 | id: 5,
44 | user_id: 1,
45 | description: 'Hamburguer John',
46 | date: '2020-03-20T08:10:09.000Z',
47 | value: 19.9,
48 | createdAt: '2020-03-20T13:38:53.511Z',
49 | updatedAt: '2020-03-20T13:38:53.511Z',
50 | });
51 |
52 | const DEBIT_ARRAY = [
53 | new Debit({
54 | id: 5,
55 | user_id: 1,
56 | description: 'Hamburguer John',
57 | date: '2020-03-20T08:10:09.000Z',
58 | value: 19.9,
59 | createdAt: '2020-03-20T13:38:53.511Z',
60 | updatedAt: '2020-03-20T13:38:53.511Z',
61 | }),
62 | new Debit({
63 | id: 2,
64 | user_id: 2,
65 | description: 'Academia',
66 | date: '2020-03-22T10:15:09.000Z',
67 | value: 85.00,
68 | createdAt: '2020-03-20T13:38:53.511Z',
69 | updatedAt: '2020-03-20T13:38:53.511Z',
70 | }),
71 | ];
72 |
73 | module.exports = {
74 | USER,
75 | CREDIT,
76 | CREDIT_ARRAY,
77 | DEBIT,
78 | DEBIT_ARRAY,
79 | };
80 |
--------------------------------------------------------------------------------
/api/src/__tests__/services/credits.tests.js:
--------------------------------------------------------------------------------
1 | const sinon = require('sinon');
2 |
3 | const { Credit, Debit, User } = require('../../database/models');
4 | const CreditsService = require('../../app/services/CreditsService');
5 | const {
6 | USER,
7 | DEBIT,
8 | CREDIT,
9 | CREDIT_ARRAY,
10 | } = require('../dummy');
11 |
12 | describe('Credits Service', () => {
13 | beforeEach(() => {
14 | sinon.restore();
15 | });
16 |
17 | describe('Credit create errors', () => {
18 | it('Should return user does not exist', async () => {
19 | sinon.stub(User, 'findByPk').resolves(null);
20 | const response = await CreditsService.store(CREDIT);
21 |
22 | expect(response.error).toBeTruthy();
23 | expect(response.error.status).toBe(404);
24 | expect(response.error.message).toBe('User does not exist!');
25 | });
26 |
27 | it('Should return credit does not exist - Show method', async () => {
28 | sinon.stub(Credit, 'findByPk').resolves(null);
29 | const response = await CreditsService.show(5);
30 |
31 | expect(response.error).toBeTruthy();
32 | expect(response.error.status).toBe(404);
33 | expect(response.error.message).toBe('Credit does not exist!');
34 | });
35 |
36 | it('Should return credit does not exist - Destroy method', async () => {
37 | sinon.stub(Credit, 'findByPk').resolves(null);
38 | const response = await CreditsService.destroy(5);
39 |
40 | expect(response.error).toBeTruthy();
41 | expect(response.error.status).toBe(404);
42 | expect(response.error.message).toBe('Credit does not exist!');
43 | });
44 | });
45 |
46 | describe('Credits index', () => {
47 | it('Should return an empty array', async () => {
48 | sinon.stub(Credit, 'findAll').resolves([]);
49 |
50 | const response = await CreditsService.index();
51 |
52 | expect(response.error).toBeFalsy();
53 | expect(response).toBeTruthy();
54 | expect(response).toEqual([]);
55 | });
56 |
57 | it('Should return an array of credits', async () => {
58 | sinon.stub(Credit, 'findAll').resolves(CREDIT_ARRAY);
59 |
60 | const response = await CreditsService.index();
61 |
62 | expect(response.error).toBeFalsy();
63 | expect(response).toBeTruthy();
64 | expect(response).toEqual(CREDIT_ARRAY);
65 | });
66 | });
67 |
68 | describe('Credits store', () => {
69 | it('Should return a new credit created', async () => {
70 | sinon.stub(User, 'findByPk').resolves(USER);
71 | sinon.stub(Credit, 'create').resolves(CREDIT);
72 |
73 | const response = await CreditsService.store(CREDIT);
74 |
75 | expect(response.error).toBeFalsy();
76 | expect(response).toEqual(CREDIT);
77 | });
78 | });
79 |
80 | describe('Credits show', () => {
81 | it('Should return a credit by ID', async () => {
82 | sinon.stub(Credit, 'findByPk').resolves(CREDIT);
83 |
84 | const response = await CreditsService.show(5);
85 |
86 | expect(response.error).toBeFalsy();
87 | expect(response).toEqual(CREDIT);
88 | });
89 | });
90 |
91 | describe('Credits destroy', () => {
92 | it('Should delete a credit by ID', async () => {
93 | sinon.stub(Credit, 'findByPk').resolves(CREDIT);
94 | sinon.stub(CREDIT, 'destroy').resolves(null);
95 |
96 | const response = await CreditsService.destroy(5);
97 |
98 | expect(response.error).toBeFalsy();
99 | expect(response).toEqual(CREDIT);
100 | });
101 | });
102 |
103 | describe('Credits update', () => {
104 | it('Should update a credit', async () => {
105 | sinon.stub(Credit, 'findByPk').resolves(CREDIT);
106 | sinon.stub(CREDIT, 'update').resolves(CREDIT);
107 |
108 | const response = await CreditsService.update(CREDIT);
109 |
110 | expect(response.error).toBeFalsy();
111 | expect(response).toEqual(CREDIT);
112 | });
113 |
114 | it('Should return new credit by debit updated', async () => {
115 | sinon.stub(Credit, 'findByPk').resolves(null);
116 | sinon.stub(Credit, 'create').resolves(CREDIT);
117 | sinon.stub(Debit, 'destroy').resolves(DEBIT);
118 | const response = await CreditsService.update(CREDIT);
119 |
120 | expect(response.error).toBeFalsy();
121 | expect(response).toEqual(CREDIT);
122 | });
123 | });
124 | });
125 |
--------------------------------------------------------------------------------
/api/src/__tests__/services/debits.tests.js:
--------------------------------------------------------------------------------
1 | const sinon = require('sinon');
2 |
3 | const { Debit, Credit, User } = require('../../database/models');
4 | const DebitsService = require('../../app/services/DebitsService');
5 | const {
6 | USER,
7 | CREDIT,
8 | DEBIT,
9 | DEBIT_ARRAY,
10 | } = require('../dummy');
11 |
12 | describe('Debits Service', () => {
13 | beforeEach(() => {
14 | sinon.restore();
15 | });
16 |
17 | describe('Debit create errors', () => {
18 | it('Should return user does not exist', async () => {
19 | sinon.stub(User, 'findByPk').resolves(null);
20 | const response = await DebitsService.store(DEBIT);
21 |
22 | expect(response.error).toBeTruthy();
23 | expect(response.error.status).toBe(404);
24 | expect(response.error.message).toBe('User does not exist!');
25 | });
26 |
27 | it('Should return Debit does not exist - Show method', async () => {
28 | sinon.stub(Debit, 'findByPk').resolves(null);
29 | const response = await DebitsService.show(5);
30 |
31 | expect(response.error).toBeTruthy();
32 | expect(response.error.status).toBe(404);
33 | expect(response.error.message).toBe('Debit does not exist!');
34 | });
35 |
36 | it('Should return Debit does not exist - Destroy method', async () => {
37 | sinon.stub(Debit, 'findByPk').resolves(null);
38 | const response = await DebitsService.destroy(5);
39 |
40 | expect(response.error).toBeTruthy();
41 | expect(response.error.status).toBe(404);
42 | expect(response.error.message).toBe('Debit does not exist!');
43 | });
44 | });
45 |
46 | describe('Debits index', () => {
47 | it('Should return an empty array', async () => {
48 | sinon.stub(Debit, 'findAll').resolves([]);
49 |
50 | const response = await DebitsService.index();
51 |
52 | expect(response.error).toBeFalsy();
53 | expect(response).toBeTruthy();
54 | expect(response).toEqual([]);
55 | });
56 |
57 | it('Should return an array of Debits', async () => {
58 | sinon.stub(Debit, 'findAll').resolves(DEBIT_ARRAY);
59 |
60 | const response = await DebitsService.index();
61 |
62 | expect(response.error).toBeFalsy();
63 | expect(response).toBeTruthy();
64 | expect(response).toEqual(DEBIT_ARRAY);
65 | });
66 | });
67 |
68 | describe('Debits store', () => {
69 | it('Should return a new Debit created', async () => {
70 | sinon.stub(User, 'findByPk').resolves(USER);
71 | sinon.stub(Debit, 'create').resolves(DEBIT);
72 |
73 | const response = await DebitsService.store(DEBIT);
74 |
75 | expect(response.error).toBeFalsy();
76 | expect(response).toEqual(DEBIT);
77 | });
78 | });
79 |
80 | describe('Debits show', () => {
81 | it('Should return a Debit by ID', async () => {
82 | sinon.stub(Debit, 'findByPk').resolves(DEBIT);
83 |
84 | const response = await DebitsService.show(5);
85 |
86 | expect(response.error).toBeFalsy();
87 | expect(response).toEqual(DEBIT);
88 | });
89 | });
90 |
91 | describe('Debits destroy', () => {
92 | it('Should delete a Debit by ID', async () => {
93 | sinon.stub(Debit, 'findByPk').resolves(DEBIT);
94 | sinon.stub(DEBIT, 'destroy').resolves(null);
95 |
96 | const response = await DebitsService.destroy(5);
97 |
98 | expect(response.error).toBeFalsy();
99 | expect(response).toEqual(DEBIT);
100 | });
101 | });
102 |
103 | describe('Debits update', () => {
104 | it('Should update a Debit', async () => {
105 | sinon.stub(Debit, 'findByPk').resolves(DEBIT);
106 | sinon.stub(DEBIT, 'update').resolves(DEBIT);
107 |
108 | const response = await DebitsService.update(DEBIT);
109 |
110 | expect(response.error).toBeFalsy();
111 | expect(response).toEqual(DEBIT);
112 | });
113 |
114 | it('Should return new debit by credit updated', async () => {
115 | sinon.stub(Debit, 'findByPk').resolves(null);
116 | sinon.stub(Debit, 'create').resolves(DEBIT);
117 | sinon.stub(Credit, 'destroy').resolves(CREDIT);
118 | const response = await DebitsService.update(DEBIT);
119 |
120 | expect(response.error).toBeFalsy();
121 | expect(response).toEqual(DEBIT);
122 | });
123 | });
124 | });
125 |
--------------------------------------------------------------------------------
/api/src/__tests__/services/email.tests.js:
--------------------------------------------------------------------------------
1 | const sinon = require('sinon');
2 |
3 | const EmailTransporter = require('../../config/email');
4 | const EmailService = require('../../app/services/EmailService');
5 |
6 | describe('Session Service', () => {
7 | process.env.APP_SECRET = 'my_secret_key';
8 |
9 | beforeEach(() => {
10 | sinon.restore();
11 | });
12 |
13 | describe('Email creator errors', () => {
14 | it('Should send email correctly', async () => {
15 | sinon.stub(EmailTransporter, 'sendMail').resolves(null);
16 |
17 | await EmailService.sendResetPassword('gabriel_hahn@hotmail.com', 'new_hash');
18 | });
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/api/src/__tests__/services/session.tests.js:
--------------------------------------------------------------------------------
1 | const sinon = require('sinon');
2 |
3 | const { User } = require('../../database/models');
4 | const SessionService = require('../../app/services/SessionService');
5 | const EmailService = require('../../app/services/EmailService');
6 | const { USER } = require('../dummy');
7 |
8 | describe('Session Service', () => {
9 | process.env.APP_SECRET = 'my_secret_key';
10 |
11 | beforeEach(() => {
12 | sinon.restore();
13 | });
14 |
15 | describe('User create errors', () => {
16 | it('Should return user already exists error', async () => {
17 | sinon.stub(User, 'findOne').resolves(USER);
18 | const response = await SessionService.create(USER);
19 |
20 | expect(response.error).toBeTruthy();
21 | expect(response.error.status).toBe(409);
22 | expect(response.error.message).toBe('User already exists');
23 | });
24 |
25 | it('Should return fill all fields error', async () => {
26 | const response = await SessionService.create({ name: 'Gabriel' });
27 |
28 | expect(response.error).toBeTruthy();
29 | expect(response.error.status).toBe(400);
30 | expect(response.error.message).toBe('You should fill all fields');
31 | });
32 | });
33 |
34 | describe('User sessions errors', () => {
35 | it('Should return invalid credentials error (invalid email)', async () => {
36 | sinon.stub(User, 'findOne').resolves(null);
37 | const response = await SessionService.store(USER);
38 |
39 | expect(response.error).toBeTruthy();
40 | expect(response.error.status).toBe(401);
41 | expect(response.error.message).toBe('Invalid credentials');
42 | });
43 |
44 | it('Should return invalid credentials error (invalid password)', async () => {
45 | sinon.stub(User, 'findOne').resolves(USER);
46 | sinon.stub(User.prototype, 'checkPassword').resolves(false);
47 | const response = await SessionService.store(USER);
48 |
49 | expect(response.error).toBeTruthy();
50 | expect(response.error.status).toBe(401);
51 | expect(response.error.message).toBe('Invalid credentials');
52 | });
53 | });
54 |
55 | describe('User creation', () => {
56 | it('Should create a token', async () => {
57 | sinon.stub(User, 'findOne').resolves(null);
58 | sinon.stub(User, 'create').resolves(USER);
59 |
60 | const response = await SessionService.create(USER);
61 |
62 | expect(response.user).toBeTruthy();
63 | expect(response.token).toBeTruthy();
64 | });
65 | });
66 |
67 | describe('Session creation', () => {
68 | it('Should create a new session', async () => {
69 | sinon.stub(User, 'findOne').resolves(USER);
70 | sinon.stub(User.prototype, 'checkPassword').resolves(true);
71 |
72 | const response = await SessionService.store(USER);
73 |
74 | expect(response.user).toBeTruthy();
75 | expect(response.token).toBeTruthy();
76 | });
77 | });
78 |
79 | describe('Reset password request', () => {
80 | it('Should return user not found', async () => {
81 | sinon.stub(User, 'findOne').resolves(null);
82 |
83 | const response = await SessionService.resetPasswordRequest({ email: 'gabriel_hahn@hotmail.com' });
84 |
85 | expect(response.error.status).toEqual(404);
86 | expect(response.error.message).toEqual('User not found');
87 | });
88 |
89 | it('Should return success on send e-mail for password reset', async () => {
90 | USER.save = async () => { };
91 |
92 | sinon.stub(User, 'findOne').resolves(USER);
93 | sinon.stub(EmailService, 'sendResetPassword').resolves(null);
94 |
95 | const response = await SessionService.resetPasswordRequest({ email: 'gabriel_hahn@hotmail.com' });
96 |
97 | expect(response.status).toEqual(200);
98 | expect(response.message).toEqual('Please, check your email and set a new password for your account');
99 | });
100 | });
101 |
102 | describe('Update user password', () => {
103 | it('Should return user not found', async () => {
104 | sinon.stub(User, 'findOne').resolves(null);
105 |
106 | const response = await SessionService.updateUserPassword({ email: 'gabriel_hahn@hotmail.com' });
107 |
108 | expect(response.error.status).toEqual(404);
109 | expect(response.error.message).toEqual('User not found');
110 | });
111 |
112 | it('Should return invalid token', async () => {
113 | sinon.stub(User, 'findOne').resolves(USER);
114 | sinon.stub(User.prototype, 'checkPassword').resolves(false);
115 |
116 | const response = await SessionService.updateUserPassword({ email: 'gabriel_hahn@hotmail.com', password: '123', tempToken: 'token' });
117 |
118 | expect(response.error.status).toEqual(401);
119 | expect(response.error.message).toEqual('Invalid reset token. Please, request a new password reset');
120 | });
121 |
122 | it('Should return success on update users password', async () => {
123 | USER.save = async () => { };
124 | USER.generateToken = () => 'token_test';
125 |
126 | sinon.stub(User, 'findOne').resolves(USER);
127 | sinon.stub(User.prototype, 'checkPassword').resolves(true);
128 |
129 | const response = await SessionService.updateUserPassword({ email: 'gabriel_hahn@hotmail.com', password: '123', tempToken: 'token' });
130 |
131 | expect(response.user).toEqual(USER);
132 | expect(response.token).toEqual('token_test');
133 | });
134 | });
135 | });
136 |
--------------------------------------------------------------------------------
/api/src/app.js:
--------------------------------------------------------------------------------
1 | require('dotenv').config({ path: `${__dirname}/../.env` });
2 |
3 | const express = require('express');
4 | const cors = require('cors');
5 | const helmet = require('helmet');
6 | const routes = require('./app/routes');
7 |
8 | class AppController {
9 | constructor() {
10 | this.express = express();
11 |
12 | this.middlewares();
13 | this.routes();
14 | }
15 |
16 | middlewares() {
17 | this.express.use(express.json());
18 | this.express.use(helmet());
19 |
20 | this.express.use(cors({
21 | origin: process.env.APP_DOMAIN,
22 | }));
23 | }
24 |
25 | routes() {
26 | this.express.use(routes);
27 | }
28 | }
29 |
30 | module.exports = new AppController().express;
31 |
--------------------------------------------------------------------------------
/api/src/app/routes/controllers/CreditsController.js:
--------------------------------------------------------------------------------
1 | const CreditsService = require('../../services/CreditsService');
2 | const { handleProcessError } = require('../../../utils/error');
3 |
4 | class CreditsController {
5 | async index(req, res) {
6 | const { startDate, endDate } = req.query;
7 | const { userid } = req.headers;
8 |
9 | const credits = await CreditsService.index(startDate, endDate, userid);
10 |
11 | return res.json(credits);
12 | }
13 |
14 | async store(req, res) {
15 | const credit = await CreditsService.store(req.body);
16 |
17 | return credit.error ? handleProcessError(res, credit) : res.json(credit);
18 | }
19 |
20 | async destroy(req, res) {
21 | const { id } = req.params;
22 |
23 | const credit = await CreditsService.destroy(id);
24 |
25 | return credit.error ? handleProcessError(res, credit) : res.json(credit);
26 | }
27 |
28 | async show(req, res) {
29 | const { id } = req.params;
30 |
31 | const credit = await CreditsService.show(id);
32 |
33 | return credit.error ? handleProcessError(res, credit) : res.json(credit);
34 | }
35 |
36 | async update(req, res) {
37 | const credit = await CreditsService.update(req.body);
38 |
39 | return credit.error ? handleProcessError(res, credit) : res.json(credit);
40 | }
41 |
42 | async getAllByCurrentMonth(req, res) {
43 | const { userid } = req.headers;
44 | const credits = await CreditsService.getAllByCurrentMonth(userid);
45 |
46 | return credits.error ? handleProcessError(res, credits) : res.json(credits);
47 | }
48 | }
49 |
50 | module.exports = new CreditsController();
51 |
--------------------------------------------------------------------------------
/api/src/app/routes/controllers/DebitsController.js:
--------------------------------------------------------------------------------
1 | const DebitsService = require('../../services/DebitsService');
2 | const { handleProcessError } = require('../../../utils/error');
3 |
4 | class DebitsController {
5 | async index(req, res) {
6 | const { startDate, endDate } = req.query;
7 | const { userid } = req.headers;
8 |
9 | const debits = await DebitsService.index(startDate, endDate, userid);
10 |
11 | return res.json(debits);
12 | }
13 |
14 | async store(req, res) {
15 | const debit = await DebitsService.store(req.body);
16 |
17 | return debit.error ? handleProcessError(res, debit) : res.json(debit);
18 | }
19 |
20 | async destroy(req, res) {
21 | const { id } = req.params;
22 |
23 | const debit = await DebitsService.destroy(id);
24 |
25 | return debit.error ? handleProcessError(res, debit) : res.json(debit);
26 | }
27 |
28 | async show(req, res) {
29 | const { id } = req.params;
30 |
31 | const debit = await DebitsService.show(id);
32 |
33 | return debit.error ? handleProcessError(res, debit) : res.json(debit);
34 | }
35 |
36 | async update(req, res) {
37 | const debit = await DebitsService.update(req.body);
38 |
39 | return debit.error ? handleProcessError(res, debit) : res.json(debit);
40 | }
41 |
42 | async getAllByCurrentMonth(req, res) {
43 | const { userid } = req.headers;
44 | const debits = await DebitsService.getAllByCurrentMonth(userid);
45 |
46 | return debits.error ? handleProcessError(res, debits) : res.json(debits);
47 | }
48 | }
49 |
50 | module.exports = new DebitsController();
51 |
--------------------------------------------------------------------------------
/api/src/app/routes/controllers/SessionController.js:
--------------------------------------------------------------------------------
1 | const SessionService = require('../../services/SessionService');
2 | const { handleProcessError } = require('../../../utils/error');
3 |
4 | class SessionController {
5 | async create(req, res) {
6 | const user = await SessionService.create(req.body);
7 |
8 | return user.error ? handleProcessError(res, user) : res.json(user);
9 | }
10 |
11 | async store(req, res) {
12 | const user = await SessionService.store(req.body);
13 |
14 | return user.error ? handleProcessError(res, user) : res.json(user);
15 | }
16 |
17 | async resetPassword(req, res) {
18 | const user = await SessionService.resetPasswordRequest(req.body);
19 |
20 | return user.error ? handleProcessError(res, user) : res.json(user);
21 | }
22 |
23 | async resetSuccess(req, res) {
24 | const user = await SessionService.updateUserPassword(req.body);
25 |
26 | return user.error ? handleProcessError(res, user) : res.json(user);
27 | }
28 | }
29 |
30 | module.exports = new SessionController();
31 |
--------------------------------------------------------------------------------
/api/src/app/routes/controllers/TransactionsController.js:
--------------------------------------------------------------------------------
1 | const TransactionsService = require('../../services/TransactionsService');
2 |
3 | class TransactionsController {
4 | async cashFlow(req, res) {
5 | const { userid } = req.headers;
6 |
7 | const cashFlow = await TransactionsService.cashFlow(userid);
8 |
9 | return res.json(cashFlow);
10 | }
11 |
12 | async completeCashFlow(req, res) {
13 | const { userid } = req.headers;
14 |
15 | const cashFlow = await TransactionsService.completeCashFlow(userid);
16 |
17 | return res.json(cashFlow);
18 | }
19 | }
20 |
21 | module.exports = new TransactionsController();
22 |
--------------------------------------------------------------------------------
/api/src/app/routes/index.js:
--------------------------------------------------------------------------------
1 | const routes = require('express');
2 | const authMiddleware = require('./middleware/auth');
3 |
4 | const SessionController = require('./controllers/SessionController');
5 | const DebitsController = require('./controllers/DebitsController');
6 | const CreditsController = require('./controllers/CreditsController');
7 | const TransactionsController = require('./controllers/TransactionsController');
8 |
9 | const router = routes.Router();
10 |
11 | router.get('/healthycheck', (req, res) => {
12 | res.status(200).send({ message: 'Server ON' });
13 | });
14 |
15 | router.post('/user', SessionController.create);
16 | router.post('/sessions', SessionController.store);
17 |
18 | router.post('/reset', SessionController.resetPassword);
19 | router.post('/resetSuccess', SessionController.resetSuccess);
20 |
21 | router.use(authMiddleware);
22 |
23 | router.get('/overview', (req, res) => {
24 | res.status(200).send({ message: 'Success !' });
25 | });
26 |
27 | router.get('/debits', DebitsController.index);
28 | router.post('/debit', DebitsController.store);
29 | router.put('/debit', DebitsController.update);
30 | router.get('/debits/allByCurrentMonth', DebitsController.getAllByCurrentMonth);
31 | router.get('/debit/:id', DebitsController.show);
32 | router.delete('/debit/:id', DebitsController.destroy);
33 |
34 | router.get('/credits', CreditsController.index);
35 | router.post('/credit', CreditsController.store);
36 | router.put('/credit', CreditsController.update);
37 | router.get('/credits/allByCurrentMonth', CreditsController.getAllByCurrentMonth);
38 | router.get('/credit/:id', CreditsController.show);
39 | router.delete('/credit/:id', CreditsController.destroy);
40 |
41 | router.get('/transactions/cashFlow', TransactionsController.cashFlow);
42 | router.get('/transactions/completeCashFlow', TransactionsController.completeCashFlow);
43 |
44 | module.exports = router;
45 |
--------------------------------------------------------------------------------
/api/src/app/routes/middleware/auth.js:
--------------------------------------------------------------------------------
1 | const jwt = require('jsonwebtoken');
2 | const { promisify } = require('util');
3 |
4 | module.exports = async (req, res, next) => {
5 | const authHeader = req.headers.authorization;
6 |
7 | if (!authHeader) {
8 | return res.status(401).json({ message: 'Token not provided' });
9 | }
10 |
11 | const [, token] = authHeader.split(' ');
12 |
13 | try {
14 | const decoded = await promisify(jwt.verify)(token, process.env.APP_SECRET);
15 |
16 | if (req.headers.userid && req.headers.userid != decoded.id) {
17 | return res.status(401).json({ message: 'Request not allowed' });
18 | }
19 |
20 | req.userId = decoded.id;
21 | } catch (err) {
22 | return res.status(401).json({ message: 'Token invalid' });
23 | }
24 |
25 | return next();
26 | };
27 |
--------------------------------------------------------------------------------
/api/src/app/services/CreditsService.js:
--------------------------------------------------------------------------------
1 | const {
2 | Credit,
3 | Debit,
4 | User,
5 | Sequelize,
6 | } = require('../../database/models');
7 |
8 | const order = [
9 | ['date', 'DESC'],
10 | ];
11 |
12 | class CreditsService {
13 | async index(startDate, endDate, userId) {
14 | const newEndDate = new Date(endDate);
15 | newEndDate.setDate(newEndDate.getDate() + 1);
16 |
17 | const where = {
18 | user_id: userId,
19 | date: {
20 | [Sequelize.Op.and]: {
21 | [Sequelize.Op.gte]: startDate,
22 | [Sequelize.Op.lte]: newEndDate,
23 | },
24 | },
25 | };
26 |
27 | const credit = await Credit.findAll({ where, order });
28 |
29 | return credit;
30 | }
31 |
32 | async store(newCredit) {
33 | const { user_id: userId } = newCredit;
34 | const storeCredit = { ...newCredit, user_id: userId };
35 |
36 | const user = await User.findByPk(userId);
37 |
38 | if (!user) {
39 | return { error: { status: 404, message: 'User does not exist!' } };
40 | }
41 |
42 | const credit = await Credit.create(storeCredit);
43 |
44 | return credit;
45 | }
46 |
47 | async show(id) {
48 | const credit = await Credit.findByPk(id);
49 |
50 | if (!credit) {
51 | return { error: { status: 404, message: 'Credit does not exist!' } };
52 | }
53 |
54 | return credit;
55 | }
56 |
57 | async destroy(id) {
58 | const credit = await Credit.findByPk(id);
59 |
60 | if (!credit) {
61 | return { error: { status: 404, message: 'Credit does not exist!' } };
62 | }
63 |
64 | await credit.destroy();
65 |
66 | return credit;
67 | }
68 |
69 | async update(newCredit) {
70 | const credit = await Credit.findByPk(newCredit.id);
71 |
72 | if (!credit) {
73 | const newCreditObject = { ...newCredit, id: null };
74 | const creditCreated = await Credit.create(newCreditObject);
75 |
76 | await Debit.destroy({ where: { id: newCredit.id } });
77 |
78 | return creditCreated;
79 | }
80 |
81 | const creditUpdated = await credit.update(newCredit);
82 |
83 | return creditUpdated;
84 | }
85 |
86 | async getAllByCurrentMonth(userId) {
87 | const currentDay = new Date();
88 | const firstDayCurrentMonth = new Date(currentDay.getFullYear(), currentDay.getMonth(), 1);
89 |
90 | const where = {
91 | user_id: userId,
92 | date: {
93 | [Sequelize.Op.and]: {
94 | [Sequelize.Op.gte]: firstDayCurrentMonth,
95 | [Sequelize.Op.lte]: currentDay,
96 | },
97 | },
98 | };
99 |
100 | const credits = await Credit.findAll({ where, order });
101 |
102 | return credits;
103 | }
104 | }
105 |
106 | module.exports = new CreditsService();
107 |
--------------------------------------------------------------------------------
/api/src/app/services/DebitsService.js:
--------------------------------------------------------------------------------
1 | const { Debit, Credit, User, Sequelize } = require('../../database/models');
2 |
3 | const order = [['date', 'DESC']];
4 |
5 | class DebitsService {
6 | async index(startDate, endDate, userId) {
7 | const newEndDate = new Date(endDate);
8 | newEndDate.setDate(newEndDate.getDate() + 1);
9 |
10 | const where = {
11 | user_id: userId,
12 | date: {
13 | [Sequelize.Op.and]: {
14 | [Sequelize.Op.gte]: startDate,
15 | [Sequelize.Op.lte]: newEndDate
16 | }
17 | }
18 | };
19 |
20 | const debit = await Debit.findAll({ where, order });
21 |
22 | return debit;
23 | }
24 |
25 | async store(newDebit) {
26 | const { user_id: userId } = newDebit;
27 | const storeDebit = { ...newDebit, user_id: userId };
28 |
29 | const user = await User.findByPk(userId);
30 |
31 | if (!user) {
32 | return { error: { status: 404, message: 'User does not exist!' } };
33 | }
34 |
35 | const debit = await Debit.create(storeDebit);
36 |
37 | return debit;
38 | }
39 |
40 | async show(id) {
41 | const debit = await Debit.findByPk(id);
42 |
43 | if (!debit) {
44 | return { error: { status: 404, message: 'Debit does not exist!' } };
45 | }
46 |
47 | return debit;
48 | }
49 |
50 | async destroy(id) {
51 | const debit = await Debit.findByPk(id);
52 |
53 | if (!debit) {
54 | return { error: { status: 404, message: 'Debit does not exist!' } };
55 | }
56 |
57 | await debit.destroy();
58 |
59 | return debit;
60 | }
61 |
62 | async update(newDebit) {
63 | const debit = await Debit.findByPk(newDebit.id);
64 |
65 | if (!debit) {
66 | const newDebitObject = { ...newDebit, id: null };
67 | const debitCreated = await Debit.create(newDebitObject);
68 |
69 | await Credit.destroy({ where: { id: newDebit.id } });
70 |
71 | return debitCreated;
72 | }
73 |
74 | const debitUpdated = await debit.update(newDebit);
75 |
76 | return debitUpdated;
77 | }
78 |
79 | async getAllByCurrentMonth(userId) {
80 | const currentDay = new Date();
81 | const firstDayCurrentMonth = new Date(
82 | currentDay.getFullYear(),
83 | currentDay.getMonth(),
84 | 1
85 | );
86 |
87 | const where = {
88 | user_id: userId,
89 | date: {
90 | [Sequelize.Op.and]: {
91 | [Sequelize.Op.gte]: firstDayCurrentMonth,
92 | [Sequelize.Op.lte]: currentDay
93 | }
94 | }
95 | };
96 |
97 | const debits = await Debit.findAll({ where, order });
98 |
99 | return debits;
100 | }
101 | }
102 |
103 | module.exports = new DebitsService();
104 |
--------------------------------------------------------------------------------
/api/src/app/services/EmailService.js:
--------------------------------------------------------------------------------
1 | const EmailTransporter = require('../../config/email');
2 |
3 | const mailData = {
4 | from: 'no-reply.conmoneymail@gmail.com',
5 | subject: 'ConMoney - Reset Password',
6 | };
7 |
8 | class EmailService {
9 | async sendResetPassword(email, newHash) {
10 | mailData.to = email;
11 | mailData.html = `
12 | Request password reset
13 |
14 | Hi! To reset the password of your ConMoney account, please access the following link and set the new password:
15 |
16 | Create a new password
17 |
18 |
19 |
20 | If you have any problem, please contact conmoneymail@gmail.com. Thanks for using ConMoney
21 | `;
22 |
23 | await EmailTransporter.sendMail(mailData);
24 | }
25 | }
26 |
27 | module.exports = new EmailService();
28 |
--------------------------------------------------------------------------------
/api/src/app/services/SessionService.js:
--------------------------------------------------------------------------------
1 | const EmailService = require('./EmailService');
2 | const { User } = require('../../database/models');
3 |
4 | class SessionService {
5 | async create({ email, password, name }) {
6 | if (!email || !password || !name) {
7 | return { error: { status: 400, message: 'You should fill all fields' } };
8 | }
9 |
10 | const user = await User.findOne({ where: { email } });
11 |
12 | if (user) {
13 | return { error: { status: 409, message: 'User already exists' } };
14 | }
15 |
16 | const newUser = await User.create({ email, password, name });
17 |
18 | return { user: newUser, token: newUser.generateToken() };
19 | }
20 |
21 | async store({ email, password }) {
22 | const user = await User.findOne({ where: { email } });
23 |
24 | if (!user || !(await user.checkPassword(password))) {
25 | return { error: { status: 401, message: 'Invalid credentials' } };
26 | }
27 |
28 | return { user, token: user.generateToken() };
29 | }
30 |
31 | async resetPasswordRequest({ email }) {
32 | const user = await User.findOne({ where: { email } });
33 |
34 | if (!user) {
35 | return { error: { status: 404, message: 'User not found' } };
36 | }
37 |
38 | const randomPassword = Math.random().toString(36).substring(2);
39 | user.password = randomPassword;
40 |
41 | await user.save();
42 |
43 | await EmailService.sendResetPassword(email, randomPassword);
44 |
45 | return { status: 200, message: 'Please, check your email and set a new password for your account' };
46 | }
47 |
48 | async updateUserPassword({ email, password, tempToken }) {
49 | const user = await User.findOne({ where: { email } });
50 |
51 | if (!user) {
52 | return { error: { status: 404, message: 'User not found' } };
53 | }
54 |
55 | if (!(await user.checkPassword(tempToken))) {
56 | return { error: { status: 401, message: 'Invalid reset token. Please, request a new password reset' } };
57 | }
58 |
59 | user.password = password;
60 |
61 | await user.save();
62 |
63 | return { user, token: user.generateToken() };
64 | }
65 | }
66 |
67 | module.exports = new SessionService();
68 |
--------------------------------------------------------------------------------
/api/src/app/services/TransactionsService.js:
--------------------------------------------------------------------------------
1 | const { Debit, Credit, Sequelize } = require('../../database/models');
2 |
3 | const today = new Date();
4 | const threeMonthsAgo = new Date(today.getFullYear(), today.getMonth() - 3, today.getDate());
5 |
6 | class TransactionsService {
7 | async cashFlow(userId) {
8 | const where = {
9 | user_id: userId,
10 | date: {
11 | [Sequelize.Op.and]: {
12 | [Sequelize.Op.gte]: threeMonthsAgo,
13 | [Sequelize.Op.lte]: today,
14 | },
15 | },
16 | };
17 |
18 | const debits = await Debit.findAll({ where });
19 | const credits = await Credit.findAll({ where });
20 |
21 | const cashFlow = {};
22 |
23 | credits.forEach((transaction) => {
24 | const transactionMonth = transaction.date.getMonth();
25 | const value = cashFlow[transactionMonth] || 0;
26 |
27 | cashFlow[transactionMonth] = (transaction.value + value);
28 | });
29 |
30 | debits.forEach((transaction) => {
31 | const transactionMonth = transaction.date.getMonth();
32 | const value = cashFlow[transactionMonth] || 0;
33 |
34 | cashFlow[transactionMonth] = (value - transaction.value);
35 | });
36 |
37 | return cashFlow;
38 | }
39 |
40 | async completeCashFlow(userId) {
41 | const where = {
42 | user_id: userId,
43 | };
44 |
45 | const totalDebit = await Debit.findOne({
46 | attributes: [
47 | [Sequelize.fn('SUM', Sequelize.col('value')), 'total'],
48 | ],
49 | where,
50 | });
51 |
52 | const totalCredit = await Credit.findOne({
53 | attributes: [
54 | [Sequelize.fn('SUM', Sequelize.col('value')), 'total'],
55 | ],
56 | where,
57 | });
58 |
59 | return { debit: totalDebit.dataValues.total, credit: totalCredit.dataValues.total };
60 | }
61 | }
62 |
63 | module.exports = new TransactionsService();
64 |
--------------------------------------------------------------------------------
/api/src/config/database.js:
--------------------------------------------------------------------------------
1 | require('dotenv').config();
2 |
3 | module.exports = {
4 | dialect: 'postgres',
5 | host: process.env.POSTGRES_HOST,
6 | username: process.env.POSTGRES_USER,
7 | password: process.env.POSTGRES_PASSWORD,
8 | database: process.env.POSTGRES_DB,
9 | operatorAliases: false,
10 | dialectOptions: {
11 | ssl: {
12 | rejectUnauthorized: false
13 | }
14 | },
15 | define: {
16 | timestamps: true,
17 | underscored: true,
18 | underscoredAll: true
19 | }
20 | };
21 |
--------------------------------------------------------------------------------
/api/src/config/email.js:
--------------------------------------------------------------------------------
1 | const nodemailer = require('nodemailer');
2 |
3 | const EmailTransporter = nodemailer.createTransport({
4 | service: 'gmail',
5 | auth: {
6 | user: process.env.EMAIL_DOMAIN,
7 | pass: process.env.EMAIL_PASS,
8 | },
9 | });
10 |
11 | module.exports = EmailTransporter;
12 |
--------------------------------------------------------------------------------
/api/src/database/migrations/20200310230045-users.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: (queryInterface, Sequelize) => queryInterface.createTable('users', {
3 | id: {
4 | type: Sequelize.INTEGER,
5 | primaryKey: true,
6 | autoIncrement: true,
7 | allowNull: false,
8 | },
9 | name: {
10 | type: Sequelize.STRING,
11 | allowNull: false,
12 | },
13 | email: {
14 | type: Sequelize.STRING,
15 | unique: true,
16 | allowNull: false,
17 | },
18 | password_hash: {
19 | type: Sequelize.STRING,
20 | allowNull: false,
21 | },
22 | created_at: {
23 | type: Sequelize.DATE,
24 | allowNull: false,
25 | },
26 | updated_at: {
27 | type: Sequelize.DATE,
28 | allowNull: false,
29 | },
30 | }),
31 |
32 | down: (queryInterface) => queryInterface.dropTable('users'),
33 | };
34 |
--------------------------------------------------------------------------------
/api/src/database/migrations/20200319150256-create-credits.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: (queryInterface, Sequelize) => queryInterface.createTable('credits', {
3 | id: {
4 | allowNull: false,
5 | autoIncrement: true,
6 | primaryKey: true,
7 | type: Sequelize.INTEGER,
8 | },
9 | user_id: {
10 | allowNull: false,
11 | type: Sequelize.INTEGER,
12 | references: {
13 | model: 'users',
14 | key: 'id',
15 | },
16 | onUpdate: 'CASCADE',
17 | onDelete: 'CASCADE',
18 | },
19 | type: {
20 | allowNull: false,
21 | type: Sequelize.ENUM,
22 | values: [
23 | 'SERVICES',
24 | 'OTHERS',
25 | 'INCOME',
26 | 'SALES',
27 | 'SALARY',
28 | ],
29 | defaultValue: 'OTHERS',
30 | },
31 | description: {
32 | allowNull: true,
33 | type: Sequelize.STRING,
34 | },
35 | date: {
36 | allowNull: false,
37 | type: Sequelize.DATE,
38 | defaultValue: Sequelize.fn('now'),
39 | },
40 | value: {
41 | allowNull: false,
42 | type: Sequelize.DOUBLE,
43 | },
44 | created_at: {
45 | allowNull: false,
46 | type: Sequelize.DATE,
47 | },
48 | updated_at: {
49 | allowNull: false,
50 | type: Sequelize.DATE,
51 | },
52 | }),
53 | down: (queryInterface) => queryInterface.dropTable('credits'),
54 | };
55 |
--------------------------------------------------------------------------------
/api/src/database/migrations/20200319192816-create-debits.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: (queryInterface, Sequelize) => queryInterface.createTable('debits', {
3 | id: {
4 | allowNull: false,
5 | autoIncrement: true,
6 | primaryKey: true,
7 | type: Sequelize.INTEGER,
8 | },
9 | user_id: {
10 | allowNull: false,
11 | type: Sequelize.INTEGER,
12 | references: {
13 | model: 'users',
14 | key: 'id',
15 | },
16 | onUpdate: 'CASCADE',
17 | onDelete: 'CASCADE',
18 | },
19 | type: {
20 | allowNull: false,
21 | type: Sequelize.ENUM,
22 | values: [
23 | 'FOOD',
24 | 'LEISURE',
25 | 'SERVICES',
26 | 'EDUCATION',
27 | 'OTHERS',
28 | 'ELECTRONICS',
29 | 'HEALTH',
30 | 'SHOPPING',
31 | 'DEBT',
32 | ],
33 | defaultValue: 'OTHERS',
34 | },
35 | description: {
36 | allowNull: true,
37 | type: Sequelize.STRING,
38 | },
39 | date: {
40 | allowNull: false,
41 | type: Sequelize.DATE,
42 | defaultValue: Sequelize.fn('now'),
43 | },
44 | value: {
45 | allowNull: false,
46 | type: Sequelize.DOUBLE,
47 | },
48 | created_at: {
49 | allowNull: false,
50 | type: Sequelize.DATE,
51 | },
52 | updated_at: {
53 | allowNull: false,
54 | type: Sequelize.DATE,
55 | },
56 | }),
57 | down: (queryInterface) => queryInterface.dropTable('debits'),
58 | };
59 |
--------------------------------------------------------------------------------
/api/src/database/models/Credit.js:
--------------------------------------------------------------------------------
1 | module.exports = (sequelize, DataTypes) => {
2 | const Credit = sequelize.define('Credit', {
3 | user_id: DataTypes.INTEGER,
4 | description: DataTypes.STRING,
5 | date: DataTypes.DATE,
6 | value: DataTypes.DOUBLE,
7 | type: DataTypes.STRING,
8 | }, {});
9 |
10 | Credit.associate = (models) => {
11 | Credit.belongsTo(models.User, { as: 'user', foreignKey: 'user_id' });
12 | };
13 |
14 | return Credit;
15 | };
16 |
--------------------------------------------------------------------------------
/api/src/database/models/Debit.js:
--------------------------------------------------------------------------------
1 | module.exports = (sequelize, DataTypes) => {
2 | const Debit = sequelize.define('Debit', {
3 | user_id: DataTypes.INTEGER,
4 | description: DataTypes.STRING,
5 | date: DataTypes.DATE,
6 | value: DataTypes.DOUBLE,
7 | type: DataTypes.STRING,
8 | }, {});
9 |
10 | Debit.associate = (models) => {
11 | Debit.belongsTo(models.User, { as: 'user', foreignKey: 'user_id' });
12 | };
13 |
14 | return Debit;
15 | };
16 |
--------------------------------------------------------------------------------
/api/src/database/models/User.js:
--------------------------------------------------------------------------------
1 | const bcrypt = require('bcryptjs');
2 | const jwt = require('jsonwebtoken');
3 |
4 | module.exports = (sequelize, DataTypes) => {
5 | const User = sequelize.define('User', {
6 | name: DataTypes.STRING,
7 | email: DataTypes.STRING,
8 | password: DataTypes.VIRTUAL,
9 | password_hash: DataTypes.STRING,
10 | }, {
11 | hooks: {
12 | beforeSave: async (user) => {
13 | const userHash = user;
14 |
15 | if (userHash.password) {
16 | userHash.password_hash = await bcrypt.hash(userHash.password, 8);
17 | }
18 | },
19 | },
20 | });
21 |
22 | User.prototype.checkPassword = function (password) {
23 | return bcrypt.compare(password, this.password_hash);
24 | };
25 |
26 | User.prototype.generateToken = function () {
27 | return jwt.sign({ id: this.id }, process.env.APP_SECRET);
28 | };
29 |
30 | User.prototype.toJSON = function () {
31 | const values = { ...this.get() };
32 |
33 | delete values.password;
34 | delete values.password_hash;
35 |
36 | return values;
37 | };
38 |
39 | return User;
40 | };
41 |
--------------------------------------------------------------------------------
/api/src/database/models/index.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 | const Sequelize = require('sequelize');
4 |
5 | const basename = path.basename(__filename);
6 | const config = require(`${__dirname}/../../config/database.js`);
7 |
8 | const db = {};
9 | let sequelize;
10 |
11 | if (config.use_env_variable) {
12 | sequelize = new Sequelize(process.env[config.use_env_variable], config);
13 | } else {
14 | sequelize = new Sequelize(
15 | config.database,
16 | config.username,
17 | config.password,
18 | config
19 | );
20 | }
21 |
22 | fs.readdirSync(__dirname)
23 | .filter(
24 | file =>
25 | file.indexOf('.') !== 0 && file !== basename && file.slice(-3) === '.js'
26 | )
27 | .forEach(file => {
28 | const model = require(path.join(__dirname, file))(
29 | sequelize,
30 | Sequelize.DataTypes
31 | );
32 | db[model.name] = model;
33 | });
34 |
35 | Object.keys(db).forEach(modelName => {
36 | if (db[modelName].associate) {
37 | db[modelName].associate(db);
38 | }
39 | });
40 |
41 | db.sequelize = sequelize;
42 | db.Sequelize = Sequelize;
43 |
44 | module.exports = db;
45 |
--------------------------------------------------------------------------------
/api/src/server.js:
--------------------------------------------------------------------------------
1 | const app = require('./app');
2 |
3 | app.listen(process.env.PORT || 3333);
4 |
--------------------------------------------------------------------------------
/api/src/utils/error.js:
--------------------------------------------------------------------------------
1 | const handleProcessError = (res, data) => {
2 | const { status, message } = data.error;
3 |
4 | return res.status(status).json({ message });
5 | };
6 |
7 | module.exports = { handleProcessError };
8 |
--------------------------------------------------------------------------------
/app/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es6: true,
5 | jest: true,
6 | },
7 | extends: [
8 | 'react-app',
9 | 'airbnb',
10 | 'plugin:@typescript-eslint/recommended',
11 | 'prettier/@typescript-eslint',
12 | ],
13 | globals: {
14 | Atomics: 'readonly',
15 | SharedArrayBuffer: 'readonly',
16 | },
17 | parserOptions: {
18 | ecmaFeatures: {
19 | jsx: true,
20 | },
21 | ecmaVersion: 2018,
22 | sourceType: 'module',
23 | },
24 | plugins: ['react', 'import', 'jsx-a11y'],
25 | rules: {
26 | 'react/jsx-filename-extension': [
27 | 'error',
28 | {
29 | extensions: ['.tsx'],
30 | },
31 | ],
32 | 'import/extensions': [
33 | 'error', 'never'
34 | ],
35 | "prefer-destructuring": ["error", {"object": true, "array": false}],
36 | 'import/prefer-default-export': 'off',
37 | 'import/no-cycle': 'off',
38 | 'react/prop-types': 'off',
39 | 'import/extensions': 'off',
40 | 'no-nested-ternary': 'off',
41 | 'no-restricted-globals': 'off',
42 | 'react/no-array-index-key': 'off',
43 | '@typescript-eslint/camelcase': 'off',
44 | 'react/jsx-one-expression-per-line': 'off',
45 | '@typescript-eslint/explicit-function-return-type': 'off',
46 | '@typescript-eslint/explicit-member-accessibility': 'off'
47 | },
48 | settings: {
49 | 'import/parsers': {
50 | '@typescript-eslint/parser': ['.ts', '.tsx'],
51 | },
52 | 'import/resolver': {
53 | typescript: {},
54 | },
55 | },
56 | };
57 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | # Runtime data
9 | pids
10 | *.pid
11 | *.seed
12 | *.pid.lock
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
24 | .grunt
25 |
26 | # Bower dependency directory (https://bower.io/)
27 | bower_components
28 |
29 | # node-waf configuration
30 | .lock-wscript
31 |
32 | # Compiled binary addons (https://nodejs.org/api/addons.html)
33 | build/Release
34 |
35 | # Dependency directories
36 | node_modules/
37 | jspm_packages/
38 |
39 | # TypeScript v1 declaration files
40 | typings/
41 |
42 | # Optional npm cache directory
43 | .npm
44 |
45 | # Optional eslint cache
46 | .eslintcache
47 |
48 | # Optional REPL history
49 | .node_repl_history
50 |
51 | # Output of 'npm pack'
52 | *.tgz
53 |
54 | # Yarn Integrity file
55 | .yarn-integrity
56 |
57 | # dotenv environment variables file
58 | .env
59 |
60 | # next.js build output
61 | .next
62 |
63 | /node_modules
64 | /.pnp
65 | .pnp.js
66 |
67 | # testing
68 | /coverage
69 |
70 | # production
71 | /build
72 |
73 | # misc
74 | .DS_Store
75 | .env.local
76 | .env.development.local
77 | .env.test.local
78 | .env.production.local
79 |
80 | npm-debug.log*
81 | yarn-debug.log*
82 | yarn-error.log*
83 |
--------------------------------------------------------------------------------
/app/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | transform: {
3 | '^.+\\.tsx?$': 'ts-jest',
4 | },
5 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
6 | moduleFileExtensions: [
7 | 'ts',
8 | 'tsx',
9 | 'js',
10 | 'jsx',
11 | 'json',
12 | 'node',
13 | ],
14 | snapshotSerializers: ['enzyme-to-json/serializer'],
15 | setupTestFrameworkScriptFile: '/src/setupTests.ts',
16 | };
17 |
--------------------------------------------------------------------------------
/app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "billing-cycle-app",
3 | "description": "Billing Cycle Application",
4 | "version": "1.0.0",
5 | "homepage": "https://www.conmoney.online",
6 | "dependencies": {
7 | "@fortawesome/fontawesome-svg-core": "^1.2.27",
8 | "@fortawesome/free-solid-svg-icons": "^5.12.1",
9 | "@fortawesome/react-fontawesome": "^0.1.9",
10 | "@matharumanpreet00/react-daterange-picker": "^1.0.5",
11 | "@types/jest": "^25.1.4",
12 | "@types/node": "^13.11.1",
13 | "@types/react": "16.8.8",
14 | "@types/react-dom": "16.8.3",
15 | "axios": "^0.21.1",
16 | "highcharts": "^9.0.0",
17 | "highcharts-react-official": "^3.0.0",
18 | "react": "^16.8.5",
19 | "react-dom": "^16.8.5",
20 | "react-lottie": "^1.2.3",
21 | "react-redux": "^7.1.1",
22 | "react-redux-toastr": "^7.6.4",
23 | "react-router-dom": "^5.1.2",
24 | "react-scripts": "2.1.8",
25 | "react-spinners-kit": "^1.9.1",
26 | "redux": "^4.0.1",
27 | "redux-saga": "^1.0.2",
28 | "styled-components": "^5.0.1",
29 | "typesafe-actions": "^3.2.1",
30 | "typescript": "3.3.4000"
31 | },
32 | "devDependencies": {
33 | "@types/enzyme": "^3.10.5",
34 | "@types/enzyme-adapter-react-16": "^1.0.6",
35 | "@types/react-lottie": "^1.2.3",
36 | "@types/react-redux": "^7.0.5",
37 | "@types/react-redux-toastr": "^7.6.0",
38 | "@types/react-router-dom": "^5.1.3",
39 | "@types/styled-components": "^5.0.1",
40 | "@typescript-eslint/eslint-plugin": "^1.5.0",
41 | "@typescript-eslint/parser": "^1.5.0",
42 | "axios-mock-adapter": "^1.17.0",
43 | "enzyme": "^3.11.0",
44 | "enzyme-adapter-react-16": "^1.15.2",
45 | "enzyme-to-json": "^3.4.4",
46 | "eslint-config-airbnb": "^17.1.0",
47 | "eslint-config-prettier": "^4.1.0",
48 | "eslint-import-resolver-typescript": "^1.1.1",
49 | "eslint-plugin-import": "^2.16.0",
50 | "eslint-plugin-jsx-a11y": "^6.2.1",
51 | "eslint-plugin-prettier": "^3.0.1",
52 | "eslint-plugin-react": "^7.12.4",
53 | "jest-canvas-mock": "^2.2.0",
54 | "prettier": "^1.16.4",
55 | "redux-mock-store": "^1.5.4",
56 | "ts-jest": "^25.2.1"
57 | },
58 | "scripts": {
59 | "start": "react-scripts start",
60 | "build": "./node_modules/.bin/tsc -p tsconfig.production.json && react-scripts build",
61 | "test": "react-scripts test --testPathIgnorePatterns=src/__tests__/mocks",
62 | "eject": "react-scripts eject",
63 | "publish:heroku": "cd ../ && git subtree push --prefix app billing-cycle-app master || true"
64 | },
65 | "jest": {
66 | "collectCoverageFrom": [
67 | "src/components/**/*.tsx",
68 | "src/pages/**/**/*.tsx",
69 | "src/store/**/*.ts",
70 | "src/utils/*.ts",
71 | "!src/store/**/index.ts",
72 | "!src/store/index.ts"
73 | ]
74 | },
75 | "eslintConfig": {
76 | "extends": "react-app"
77 | },
78 | "browserslist": {
79 | "production": [
80 | ">0.2%",
81 | "not dead",
82 | "not op_mini all"
83 | ],
84 | "development": [
85 | "last 1 chrome version",
86 | "last 1 firefox version",
87 | "last 1 safari version"
88 | ]
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/app/public/images/icons-114.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabriel-hahn/billing-cycle-reactjs/d31bd70ea9f6e2bf6399c57e6ad645b4036579c0/app/public/images/icons-114.png
--------------------------------------------------------------------------------
/app/public/images/icons-144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabriel-hahn/billing-cycle-reactjs/d31bd70ea9f6e2bf6399c57e6ad645b4036579c0/app/public/images/icons-144.png
--------------------------------------------------------------------------------
/app/public/images/icons-180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabriel-hahn/billing-cycle-reactjs/d31bd70ea9f6e2bf6399c57e6ad645b4036579c0/app/public/images/icons-180.png
--------------------------------------------------------------------------------
/app/public/images/icons-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabriel-hahn/billing-cycle-reactjs/d31bd70ea9f6e2bf6399c57e6ad645b4036579c0/app/public/images/icons-512.png
--------------------------------------------------------------------------------
/app/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | ConMoney
21 |
22 |
23 |
24 |
25 |
26 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/app/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "ConMoney",
3 | "name": "ConMoney - Know where your money is",
4 | "icons": [
5 | {
6 | "src": "./images/icons-512.png",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "./images/icons-512.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "./images/icons-512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": "/",
22 | "display": "standalone",
23 | "orientation": "portrait",
24 | "theme_color": "#383f53",
25 | "background_color": "#383f53"
26 | }
27 |
--------------------------------------------------------------------------------
/app/public/worker.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable array-callback-return */
2 | /* eslint-disable consistent-return */
3 | const CACHE_NAME = 'pwa-cache-v1';
4 | const urlsToCache = [
5 | '/',
6 | '/index.html',
7 | '/images/*',
8 | '/static/js/*.js',
9 | ];
10 |
11 | // Install a service worker
12 | self.addEventListener('install', (event) => {
13 | event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(urlsToCache)));
14 | });
15 |
16 | // Cache and return requests
17 | self.addEventListener('fetch', (event) => {
18 | event.respondWith(caches.match(event.request).then(response => response || fetch(event.request)));
19 | });
20 |
21 | // Update a service worker
22 | self.addEventListener('activate', (event) => {
23 | const cacheWhitelist = [CACHE_NAME];
24 |
25 | event.waitUntil(caches.keys().then(cacheNames => Promise.all(
26 | cacheNames.map((cacheName) => {
27 | if (cacheWhitelist.indexOf(cacheName) === -1) {
28 | return caches.delete(cacheName);
29 | }
30 | }),
31 | )));
32 | });
33 |
--------------------------------------------------------------------------------
/app/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Provider } from 'react-redux';
3 | import ReduxToastr from 'react-redux-toastr';
4 |
5 | import GlobalStyle from './styles/global';
6 |
7 | import Routes from './routes/Routes';
8 | import store from './store';
9 |
10 | function App() {
11 | return (
12 |
13 |
14 |
15 |
16 |
17 | );
18 | }
19 |
20 | export default App;
21 |
--------------------------------------------------------------------------------
/app/src/__tests__/components/access.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Provider } from 'react-redux';
3 | import createStore from 'redux-mock-store';
4 | import { mount, ReactWrapper } from 'enzyme';
5 | import { BrowserRouter } from 'react-router-dom';
6 |
7 | import { Private, Public, RoutePropsInterface } from '../../components/Access';
8 | import { INITIAL_STATE } from '../mocks/state';
9 | import { StoreInterface } from '../../interfaces/store';
10 |
11 | const state: StoreInterface = {
12 | ...INITIAL_STATE,
13 | users: {
14 | data: {
15 | token: 'hash_test',
16 | email: 'gabriel_hahn@hotmail.com',
17 | },
18 | loading: false,
19 | error: null,
20 | },
21 | };
22 |
23 | const mockStore = createStore();
24 | const store = mockStore(state);
25 | const emptyStore = mockStore(INITIAL_STATE);
26 |
27 | export const DefaultComponent = () => Component Default
;
28 |
29 | const props: RoutePropsInterface = {
30 | component: DefaultComponent,
31 | path: '/',
32 | };
33 |
34 | let wrapperPrivate: ReactWrapper;
35 | let wrapperPublic: ReactWrapper;
36 | let wrapperPrivateEmptyStore: ReactWrapper;
37 | let wrapperPublicEmptyStore: ReactWrapper;
38 |
39 | beforeEach(() => {
40 | wrapperPrivate = mount(
41 |
42 |
43 |
44 |
45 | ,
46 | );
47 |
48 | wrapperPrivateEmptyStore = mount(
49 |
50 |
51 |
52 |
53 | ,
54 | );
55 |
56 | wrapperPublic = mount(
57 |
58 |
59 |
60 |
61 | ,
62 | );
63 |
64 | wrapperPublicEmptyStore = mount(
65 |
66 |
67 |
68 |
69 | ,
70 | );
71 | });
72 |
73 | afterEach(() => {
74 | wrapperPrivate.unmount();
75 | wrapperPrivateEmptyStore.unmount();
76 | wrapperPublic.unmount();
77 | wrapperPublicEmptyStore.unmount();
78 | });
79 |
80 | describe('Access Private Component', () => {
81 | describe('Smoke tests', () => {
82 | it('Should render the private component correctly', () => {
83 | expect(wrapperPrivate.exists());
84 | });
85 | });
86 |
87 | describe('Render private component by user state', () => {
88 | it('Should render the component when have state with token', () => {
89 | expect(wrapperPrivate.find('DefaultComponent').length).toEqual(1);
90 | expect(wrapperPrivate.find('Redirect').length).toEqual(0);
91 | });
92 |
93 | it('Should redirect to root route when user state does not exist', () => {
94 | expect(wrapperPrivateEmptyStore.find('DefaultComponent').length).toEqual(0);
95 | expect(wrapperPrivateEmptyStore.find('Redirect').length).toEqual(1);
96 | });
97 | });
98 | });
99 |
100 | describe('Access Public Component', () => {
101 | describe('Smoke tests', () => {
102 | it('Should render the public component correctly', () => {
103 | expect(wrapperPublic.exists());
104 | });
105 | });
106 |
107 | describe('Render public component by user state', () => {
108 | it('Should render the public component when the state does not exist', () => {
109 | expect(wrapperPublic.find('DefaultComponent').length).toEqual(0);
110 | expect(wrapperPublic.find('Redirect').length).toEqual(1);
111 | });
112 |
113 | it('Should redirect to overview route when user state exists', () => {
114 | expect(wrapperPublicEmptyStore.find('DefaultComponent').length).toEqual(1);
115 | expect(wrapperPublicEmptyStore.find('Redirect').length).toEqual(0);
116 | });
117 | });
118 | });
119 |
--------------------------------------------------------------------------------
/app/src/__tests__/components/amount.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Provider } from 'react-redux';
3 | import createStore from 'redux-mock-store';
4 | import { mount, ReactWrapper } from 'enzyme';
5 |
6 | import { INITIAL_STATE } from '../mocks/state';
7 | import Amount, { AmountPropsInterface } from '../../components/Amount';
8 |
9 | const props: AmountPropsInterface = {
10 | value: 123.50,
11 | color: '#333',
12 | description: 'Description for test',
13 | loading: false,
14 | showDate: true,
15 | };
16 |
17 | const mockStore = createStore();
18 | const store = mockStore(INITIAL_STATE);
19 |
20 | let wrapper: ReactWrapper;
21 |
22 | beforeEach(() => {
23 | wrapper = mount(
24 |
25 |
26 | ,
27 | );
28 | });
29 |
30 | afterEach(() => {
31 | wrapper.unmount();
32 | });
33 |
34 | describe('Amount component', () => {
35 | describe('Smoke tests', () => {
36 | it('Should render the amount component correctly', () => {
37 | expect(wrapper.exists());
38 | });
39 |
40 | it('Should render 1 description', () => {
41 | expect(wrapper.find('Description').length).toEqual(1);
42 | });
43 |
44 | it('Should render 1 Value', () => {
45 | expect(wrapper.find('Value').length).toEqual(1);
46 | });
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/app/src/__tests__/components/barchart.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { shallow, ShallowWrapper } from 'enzyme';
3 |
4 | import BarChart from '../../components/BarChart';
5 |
6 | let wrapper: ShallowWrapper;
7 |
8 | beforeEach(() => {
9 | wrapper = shallow();
10 | });
11 |
12 | afterEach(() => {
13 | wrapper.unmount();
14 | });
15 |
16 | describe('Barchart component', () => {
17 | describe('Smoke tests', () => {
18 | it('Should render the BarChart component correctly', () => {
19 | expect(wrapper.exists());
20 | });
21 |
22 | it('Should render loading when data is not done', () => {
23 | expect(wrapper.find('Loading').length).toEqual(1);
24 | });
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/app/src/__tests__/components/linechart.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { shallow, ShallowWrapper } from 'enzyme';
3 |
4 | import LineChart from '../../components/LineChart';
5 |
6 | let wrapper: ShallowWrapper;
7 |
8 | beforeEach(() => {
9 | wrapper = shallow();
10 | });
11 |
12 | afterEach(() => {
13 | wrapper.unmount();
14 | });
15 |
16 | describe('LineChart component', () => {
17 | describe('Smoke tests', () => {
18 | it('Should render the LineChart component correctly', () => {
19 | expect(wrapper.exists());
20 | });
21 |
22 | it('Should render loading when data is not done', () => {
23 | expect(wrapper.find('Loading').length).toEqual(1);
24 | });
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/app/src/__tests__/components/navbar.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Provider } from 'react-redux';
3 | import { BrowserRouter } from 'react-router-dom';
4 | import { mount, ReactWrapper } from 'enzyme';
5 | import createStore from 'redux-mock-store';
6 |
7 | import { INITIAL_STATE } from '../mocks/state';
8 | import Navbar, { NavbarPropsInterface } from '../../components/Navbar';
9 |
10 | const mockStore = createStore();
11 | const store = mockStore(INITIAL_STATE);
12 | const props: NavbarPropsInterface = {
13 | onLogout: () => {},
14 | };
15 |
16 | let wrapper: ReactWrapper;
17 |
18 | beforeEach(() => {
19 | wrapper = mount(
20 |
21 |
22 |
23 |
24 | ,
25 | );
26 | });
27 |
28 | afterEach(() => {
29 | wrapper.unmount();
30 | });
31 |
32 | describe('Navbar component', () => {
33 | describe('Smoke tests', () => {
34 | it('Should render the navbar component correctly', () => {
35 | expect(wrapper.exists());
36 | });
37 |
38 | it('Should render 1 PagesList', () => {
39 | expect(wrapper.find('PagesList').length).toEqual(1);
40 | });
41 |
42 | it('Should render 1 Logout', () => {
43 | expect(wrapper.find('Logout').length).toEqual(1);
44 | });
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/app/src/__tests__/components/pageloading.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { RotateSpinner } from 'react-spinners-kit';
3 | import { shallow, ShallowWrapper } from 'enzyme';
4 |
5 | import PageLoading from '../../components/PageLoading';
6 |
7 | let wrapper: ShallowWrapper;
8 |
9 | beforeEach(() => {
10 | wrapper = shallow();
11 | });
12 |
13 | afterEach(() => {
14 | wrapper.unmount();
15 | });
16 |
17 | describe('PageLoading component', () => {
18 | describe('Smoke tests', () => {
19 | it('Should render the PageLoading component correctly', () => {
20 | expect(wrapper.exists());
21 | });
22 |
23 | it('Should render loading component', () => {
24 | expect(wrapper.find(RotateSpinner).length).toEqual(1);
25 | });
26 | });
27 | });
28 |
--------------------------------------------------------------------------------
/app/src/__tests__/components/piechart.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { shallow, ShallowWrapper } from 'enzyme';
3 |
4 | import PieChart from '../../components/PieChart';
5 |
6 | let wrapper: ShallowWrapper;
7 |
8 | beforeEach(() => {
9 | wrapper = shallow();
10 | });
11 |
12 | afterEach(() => {
13 | wrapper.unmount();
14 | });
15 |
16 | describe('PieChart component', () => {
17 | describe('Smoke tests', () => {
18 | it('Should render the PieChart component correctly', () => {
19 | expect(wrapper.exists());
20 | });
21 |
22 | it('Should render loading when data is not done', () => {
23 | expect(wrapper.find('Loading').length).toEqual(1);
24 | });
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/app/src/__tests__/components/transactionmodal.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Provider } from 'react-redux';
3 | import createStore from 'redux-mock-store';
4 | import { mount, ReactWrapper } from 'enzyme';
5 |
6 | import { wrapperUpdateFunction } from '../mocks/geral';
7 | import { INITIAL_STATE } from '../mocks/state';
8 | import TransactionModal, { TransactionModalPropsInterface } from '../../components/TransactionModal';
9 |
10 | const props: TransactionModalPropsInterface = {
11 | onClose: jest.fn(),
12 | };
13 |
14 | const mockStore = createStore();
15 | const store = mockStore(INITIAL_STATE);
16 |
17 | let wrapper: ReactWrapper;
18 |
19 | beforeEach(() => {
20 | wrapper = mount(
21 |
22 |
23 | ,
24 | );
25 | });
26 |
27 | afterEach(() => {
28 | wrapper.unmount();
29 | });
30 |
31 | describe('Transaction modal component', () => {
32 | describe('Smoke tests', () => {
33 | it('Should render the transaction modal component correctly', () => {
34 | expect(wrapper.exists());
35 | });
36 |
37 | it('Should render 1 InputValue', () => {
38 | expect(wrapper.find('InputValue').length).toEqual(1);
39 | });
40 |
41 | it('Should render 1 InputDescription', () => {
42 | expect(wrapper.find('InputDescription').length).toEqual(1);
43 | });
44 |
45 | it('Should render 1 SelectType', () => {
46 | expect(wrapper.find('SelectType').length).toEqual(1);
47 | });
48 |
49 | it('Should render 1 FormContainer', () => {
50 | expect(wrapper.find('FormContainer').length).toEqual(1);
51 | });
52 |
53 | it('Should render 1 ButtonsContainer', () => {
54 | expect(wrapper.find('ButtonsContainer').length).toEqual(1);
55 | });
56 | });
57 |
58 | describe('Events and state tests', () => {
59 | it('Should render one Debit (first option) button', () => {
60 | expect(wrapper.find('button').first().text()).toEqual('Debit');
61 | });
62 |
63 | it('Should Debit button be selected by default', () => {
64 | expect(wrapper.find('button').first().props().selected).toBeTruthy();
65 | });
66 |
67 | it('Should render one Credit (second option) button', () => {
68 | expect(wrapper.find('button').get(1).props.children).toEqual('Credit');
69 | });
70 |
71 | it('Should Credit button not selected by default', () => {
72 | expect(wrapper.find('button').get(1).props.selected).toBeFalsy();
73 | });
74 |
75 | it('Should change the button selection to Credit when it is clicked', async () => {
76 | const creditButton = wrapper.find('button').get(1);
77 |
78 | await wrapperUpdateFunction(creditButton.props.onClick, wrapper);
79 |
80 | expect(wrapper.find('button').first().props().selected).toBeFalsy();
81 | expect(wrapper.find('button').get(1).props.selected).toBeTruthy();
82 | });
83 |
84 | it('Should call "onClose" prop method when click on "Close" button', async () => {
85 | const closeButton = wrapper.find('button').last();
86 |
87 | await wrapperUpdateFunction(closeButton.props().onClick, wrapper);
88 |
89 | expect(props.onClose).toHaveBeenCalledTimes(1);
90 | });
91 | });
92 | });
93 |
--------------------------------------------------------------------------------
/app/src/__tests__/mocks/geral.ts:
--------------------------------------------------------------------------------
1 | import { act } from 'react-dom/test-utils';
2 |
3 | export const wrapperUpdateFunction = async (fn, wrapper) => {
4 | await act(async () => {
5 | fn();
6 | });
7 |
8 | wrapper.update();
9 | };
10 |
--------------------------------------------------------------------------------
/app/src/__tests__/mocks/props.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | import { createMemoryHistory, createLocation } from 'history';
3 | import { match } from 'react-router';
4 |
5 | const history = createMemoryHistory();
6 | const path = '/route/:id';
7 |
8 | const matchObj: match<{ id: string }> = {
9 | isExact: false,
10 | path,
11 | url: path.replace(':id', '1'),
12 | params: { id: '1' },
13 | };
14 |
15 | const location = createLocation(matchObj.url);
16 |
17 | export const props = {
18 | location,
19 | match: matchObj,
20 | history,
21 | };
22 |
--------------------------------------------------------------------------------
/app/src/__tests__/mocks/sagas.ts:
--------------------------------------------------------------------------------
1 | import { runSaga } from 'redux-saga';
2 |
3 | export const runSagaTest = async (method, param, dispatched) => {
4 | await runSaga(
5 | {
6 | dispatch: action => dispatched.push(action),
7 | },
8 | () => method(param),
9 | ).toPromise();
10 | };
11 |
12 | export const runSagaTestState = async (method, param, dispatched, state) => {
13 | await runSaga(
14 | {
15 | dispatch: action => dispatched.push(action),
16 | getState: () => ({ ...state }),
17 | },
18 | () => method(param),
19 | ).toPromise();
20 | };
21 |
--------------------------------------------------------------------------------
/app/src/__tests__/mocks/state.ts:
--------------------------------------------------------------------------------
1 | import { StoreInterface } from '../../interfaces/store';
2 | import { TransactionType } from '../../interfaces/transaction';
3 | import { CreditType, DebitType } from '../../enums/transactions';
4 |
5 | export const INITIAL_STATE: StoreInterface = {
6 | users: {
7 | data: null,
8 | error: null,
9 | loading: false,
10 | },
11 | transactions: {
12 | data: [],
13 | modalOpen: false,
14 | error: null,
15 | loading: {
16 | addLoading: false,
17 | allLoading: false,
18 | deleteLoading: false,
19 | editLoading: false,
20 | },
21 | },
22 | };
23 |
24 | export const INITIAL_STATE_FILLED: StoreInterface = {
25 | users: {
26 | data: {
27 | id: 5,
28 | email: 'gabriel_hahn@hotmail',
29 | },
30 | error: null,
31 | loading: false,
32 | },
33 | transactions: {
34 | data: [
35 | {
36 | date: new Date().toString(),
37 | type: DebitType.Education,
38 | category: TransactionType.DEBIT,
39 | value: 100.00,
40 | },
41 | {
42 | date: new Date().toString(),
43 | category: TransactionType.CREDIT,
44 | type: CreditType.Income,
45 | value: 150.00,
46 | },
47 | ],
48 | modalOpen: false,
49 | error: null,
50 | loading: {
51 | addLoading: false,
52 | allLoading: false,
53 | deleteLoading: false,
54 | editLoading: false,
55 | },
56 | },
57 | };
58 |
--------------------------------------------------------------------------------
/app/src/__tests__/mocks/transactions.ts:
--------------------------------------------------------------------------------
1 | import { TransactionInterface, TransactionType, TransactionsRangeDateInterface } from '../../interfaces/transaction';
2 | import { CreditType, DebitType } from '../../enums/transactions';
3 |
4 | export const TRANSACTIONS: TransactionInterface[] = [
5 | {
6 | id: 1,
7 | user_id: 1,
8 | date: new Date().toString(),
9 | value: 1500,
10 | type: CreditType.Income,
11 | description: 'Cartão de Crédito',
12 | },
13 | {
14 | id: 2,
15 | user_id: 2,
16 | date: new Date().toString(),
17 | value: 1500,
18 | type: DebitType.Education,
19 | description: 'Vendas',
20 | },
21 | ];
22 |
23 | export const TRANSACTIONS_CREDIT: TransactionInterface[] = TRANSACTIONS.map(
24 | (credit: TransactionInterface) => ({ ...credit, category: TransactionType.CREDIT }),
25 | );
26 |
27 | export const TRANSACTIONS_DEBIT: TransactionInterface[] = TRANSACTIONS.map(
28 | (debit: TransactionInterface) => ({ ...debit, category: TransactionType.DEBIT }),
29 | );
30 |
31 | export const RANGE: TransactionsRangeDateInterface = {
32 | startDate: '2020-02-02',
33 | endDate: '2020-03-02',
34 | };
35 |
--------------------------------------------------------------------------------
/app/src/__tests__/pages/dashboard.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Provider } from 'react-redux';
3 | import { mount, ReactWrapper } from 'enzyme';
4 | import createStore from 'redux-mock-store';
5 | import { BrowserRouter } from 'react-router-dom';
6 |
7 | import { props } from '../mocks/props';
8 | import { INITIAL_STATE } from '../mocks/state';
9 |
10 | import Dashboard from '../../pages/Dashboard';
11 | import NavBar from '../../components/Navbar';
12 | import TransactionModal from '../../components/TransactionModal';
13 |
14 | const mockStore = createStore();
15 | const store = mockStore(INITIAL_STATE);
16 |
17 | let wrapper: ReactWrapper;
18 |
19 | beforeEach(() => {
20 | wrapper = mount(
21 |
22 |
23 |
24 |
25 | ,
26 | );
27 | });
28 |
29 | afterEach(() => {
30 | wrapper.unmount();
31 | });
32 |
33 | describe('Login Page', () => {
34 | describe('Smoke tests', () => {
35 | it('Should render the dashboard page correctly', () => {
36 | expect(wrapper.exists());
37 | });
38 |
39 | it('Should render 1 Navbar', () => {
40 | expect(wrapper.find(NavBar).length).toEqual(1);
41 | });
42 |
43 | it('Should not render TransactionModal as initial state', () => {
44 | expect(wrapper.find(TransactionModal).length).toEqual(0);
45 | });
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/app/src/__tests__/pages/login.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Provider } from 'react-redux';
3 | import { mount, ReactWrapper } from 'enzyme';
4 | import createStore from 'redux-mock-store';
5 |
6 | import { props } from '../mocks/props';
7 | import { INITIAL_STATE } from '../mocks/state';
8 | import { wrapperUpdateFunction } from '../mocks/geral';
9 | import Login from '../../pages/Login';
10 |
11 | const mockStore = createStore();
12 | const store = mockStore(INITIAL_STATE);
13 |
14 | let wrapper: ReactWrapper;
15 |
16 | beforeEach(() => {
17 | wrapper = mount(
18 |
19 |
20 | ,
21 | );
22 | });
23 |
24 | afterEach(() => {
25 | wrapper.unmount();
26 | });
27 |
28 | describe('Login Page', () => {
29 | describe('Smoke tests', () => {
30 | it('Should render the login page correctly', () => {
31 | expect(wrapper.exists());
32 | });
33 |
34 | it('Should render 1 form', () => {
35 | expect(wrapper.find('form').length).toEqual(1);
36 | });
37 |
38 | it('Should render 1 RegisterButton', () => {
39 | expect(wrapper.find('RegisterButton').length).toEqual(1);
40 | });
41 |
42 | it('Should not show name field when is login', () => {
43 | expect(wrapper.find('input[name="name"]').length).toEqual(0);
44 | });
45 | });
46 |
47 | describe('Event tests', () => {
48 | it('Should show name, email and password fields when click on "Register" button', async () => {
49 | const registerButton = wrapper.find('RegisterButton').last();
50 | await wrapperUpdateFunction(registerButton.props().onClick, wrapper);
51 |
52 | expect(wrapper.find('Input').length).toEqual(3);
53 | expect(wrapper.find('input[name="name"]').length).toEqual(1);
54 | expect(wrapper.find('input[name="email"]').length).toEqual(1);
55 | expect(wrapper.find('input[name="password"]').length).toEqual(1);
56 | });
57 |
58 | it('Should show only email field when click on "Forgot password" button', async () => {
59 | const forgotPasswordButton = wrapper.find('ForgotButton').last();
60 | await wrapperUpdateFunction(forgotPasswordButton.props().onClick, wrapper);
61 |
62 | expect(wrapper.find('Input').length).toEqual(1);
63 | expect(wrapper.find('input[name="email"]').length).toEqual(1);
64 | expect(wrapper.find('input[name="name"]').length).toEqual(0);
65 | expect(wrapper.find('input[name="password"]').length).toEqual(0);
66 | });
67 |
68 | it('Should show only name and email fields when click on "Login" button - Which in this case will be the register button itself', async () => {
69 | const forgotPasswordButton = wrapper.find('ForgotButton').last();
70 | await wrapperUpdateFunction(forgotPasswordButton.props().onClick, wrapper);
71 |
72 | const loginButton = wrapper.find('RegisterButton').last();
73 | await wrapperUpdateFunction(loginButton.props().onClick, wrapper);
74 |
75 | expect(wrapper.find('Input').length).toEqual(2);
76 | expect(wrapper.find('input[name="email"]').length).toEqual(1);
77 | expect(wrapper.find('input[name="password"]').length).toEqual(1);
78 | expect(wrapper.find('input[name="name"]').length).toEqual(0);
79 | });
80 | });
81 | });
82 |
--------------------------------------------------------------------------------
/app/src/__tests__/pages/overview.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Provider } from 'react-redux';
3 | import { mount, ReactWrapper } from 'enzyme';
4 | import createStore from 'redux-mock-store';
5 |
6 | import { INITIAL_STATE } from '../mocks/state';
7 | import Overview from '../../pages/Dashboard/Overview';
8 | import Amount from '../../components/Amount';
9 | import TransactionTable from '../../components/TransactionTable';
10 |
11 | const mockStore = createStore();
12 | const store = mockStore(INITIAL_STATE);
13 |
14 | let wrapper: ReactWrapper;
15 |
16 | beforeEach(() => {
17 | wrapper = mount(
18 |
19 |
20 | ,
21 | );
22 | });
23 |
24 | afterEach(() => {
25 | wrapper.unmount();
26 | });
27 |
28 | describe('Overview Page', () => {
29 | describe('Smoke tests', () => {
30 | it('Should render the Overview page correctly', () => {
31 | expect(wrapper.exists());
32 | });
33 |
34 | it('Should render 4 Amounts', () => {
35 | expect(wrapper.find(Amount).length).toEqual(4);
36 | });
37 |
38 | it('Should render 1 TransactionTable', () => {
39 | expect(wrapper.find(TransactionTable).length).toEqual(1);
40 | });
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/app/src/__tests__/pages/resetPassword.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Provider } from 'react-redux';
3 | import { mount, ReactWrapper } from 'enzyme';
4 | import createStore from 'redux-mock-store';
5 |
6 | import { props } from '../mocks/props';
7 | import { INITIAL_STATE } from '../mocks/state';
8 | import ResetPassword from '../../pages/ResetPassword';
9 |
10 | const mockStore = createStore();
11 | const store = mockStore(INITIAL_STATE);
12 |
13 | let wrapper: ReactWrapper;
14 |
15 | jest.mock('react-router-dom', () => ({
16 | ...jest.requireActual('react-router-dom'),
17 | useParams: () => ({
18 | token: 'token-test',
19 | }),
20 | useRouteMatch: () => ({ url: '/token-test' }),
21 | }));
22 |
23 | beforeEach(() => {
24 | wrapper = mount(
25 |
26 | ,
27 | ,
28 | );
29 | });
30 |
31 | afterEach(() => {
32 | wrapper.unmount();
33 | });
34 |
35 | describe('ResetPassword Page', () => {
36 | describe('Smoke tests', () => {
37 | it('Should render the reset page page correctly', () => {
38 | expect(wrapper.exists());
39 | });
40 |
41 | it('Should render 1 form', () => {
42 | expect(wrapper.find('form').length).toEqual(1);
43 | });
44 |
45 | it('Should render 1 LoginButton', () => {
46 | expect(wrapper.find('LoginButton').length).toEqual(1);
47 | });
48 |
49 | it('Should render 3 inputs to reset password', () => {
50 | expect(wrapper.find('Input').length).toEqual(3);
51 | });
52 |
53 | it('Should render 1 AnimationContainer', () => {
54 | expect(wrapper.find('AnimationContainer').length).toEqual(1);
55 | });
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/app/src/__tests__/pages/settings.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Provider } from 'react-redux';
3 | import { mount, ReactWrapper } from 'enzyme';
4 | import createStore from 'redux-mock-store';
5 |
6 | import { INITIAL_STATE } from '../mocks/state';
7 | import Settings from '../../pages/Dashboard/Settings';
8 |
9 | const mockStore = createStore();
10 | const store = mockStore(INITIAL_STATE);
11 |
12 | let wrapper: ReactWrapper;
13 |
14 | beforeEach(() => {
15 | wrapper = mount(
16 |
17 |
18 | ,
19 | );
20 | });
21 |
22 | afterEach(() => {
23 | wrapper.unmount();
24 | });
25 |
26 | describe('Settings Page', () => {
27 | describe('Smoke tests', () => {
28 | it('Should render the settings page correctly', () => {
29 | expect(wrapper.exists());
30 | });
31 |
32 | it('Should render 2 SelectItem', () => {
33 | expect(wrapper.find('SelectItem').length).toEqual(2);
34 | });
35 |
36 | it('Should render 2 SelectTitle', () => {
37 | expect(wrapper.find('SelectTitle').length).toEqual(2);
38 | });
39 |
40 | it('Should render 2 Select', () => {
41 | expect(wrapper.find('Select').length).toEqual(2);
42 | });
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/app/src/__tests__/reducers/users.spec.ts:
--------------------------------------------------------------------------------
1 | import usersReducer, { Creators as UsersActions, Types as UsersTypes } from '../../store/ducks/users';
2 | import { UserInterface, UserActionInterface, UserStateInterface } from '../../interfaces/user';
3 |
4 | const INITIAL_STATE: UserStateInterface = {
5 | data: null,
6 | error: null,
7 | loading: false,
8 | };
9 |
10 | const USER: UserInterface = {
11 | id: 1,
12 | name: 'Gabriel Hahn Schaeffer',
13 | email: 'gabriel_hahn@hotmail.com',
14 | password: '123456',
15 | token: 'token_hash',
16 | };
17 |
18 | let action: UserActionInterface;
19 |
20 | describe('Users Reducer', () => {
21 | beforeEach(() => {
22 | action = {
23 | type: null,
24 | payload: {
25 | user: USER,
26 | error: null,
27 | },
28 | };
29 | });
30 |
31 | it('Should be able to set a new login request', () => {
32 | action.type = UsersTypes.LOGIN_REQUEST;
33 | action.payload.user = null;
34 |
35 | const state = usersReducer(INITIAL_STATE, action);
36 |
37 | expect(state.error).toBeNull();
38 | expect(state.loading).toBeTruthy();
39 | expect(state.data).toBeNull();
40 | });
41 |
42 | it('Should return correct action to request login creator', () => {
43 | const creator = UsersActions.loginRequest(USER);
44 |
45 | expect(creator.payload.user).toEqual(USER);
46 | expect(creator.type).toEqual(UsersTypes.LOGIN_REQUEST);
47 | });
48 |
49 | it('Should be able to set a new login success', () => {
50 | action.type = UsersTypes.LOGIN_SUCCESS;
51 | action.payload.user = USER;
52 |
53 | const state = usersReducer(INITIAL_STATE, action);
54 |
55 | expect(state.error).toBeNull();
56 | expect(state.loading).toBeFalsy();
57 | expect(state.data).toEqual(USER);
58 | });
59 |
60 | it('Should return correct action to success login creator', () => {
61 | const creator = UsersActions.loginSuccess(USER);
62 |
63 | expect(creator.payload.user).toEqual(USER);
64 | expect(creator.type).toEqual(UsersTypes.LOGIN_SUCCESS);
65 | });
66 |
67 | it('Should be able to set a new register request', () => {
68 | action.type = UsersTypes.REGISTER_REQUEST;
69 | action.payload.user = null;
70 |
71 | const state = usersReducer(INITIAL_STATE, action);
72 |
73 | expect(state.error).toBeNull();
74 | expect(state.loading).toBeTruthy();
75 | expect(state.data).toBeNull();
76 | });
77 |
78 | it('Should return correct action to register creator', () => {
79 | const creator = UsersActions.registerRequest(USER);
80 |
81 | expect(creator.payload.user).toEqual(USER);
82 | expect(creator.type).toEqual(UsersTypes.REGISTER_REQUEST);
83 | });
84 |
85 | it('Should return correct action to logout creator', () => {
86 | const creator = UsersActions.logoutRequest();
87 |
88 | expect(creator.type).toEqual(UsersTypes.LOGOUT_REQUEST);
89 | });
90 |
91 | it('Should be able to set logout success', () => {
92 | action.type = UsersTypes.LOGOUT_SUCCESS;
93 | action.payload.user = null;
94 | INITIAL_STATE.data = USER;
95 |
96 | const state = usersReducer(INITIAL_STATE, action);
97 | const finalData = { ...USER, token: null };
98 |
99 | expect(state.error).toBeNull();
100 | expect(state.loading).toBeFalsy();
101 | expect(state.data).toEqual(finalData);
102 | });
103 |
104 | it('Should return correct action to logout creator', () => {
105 | const creator = UsersActions.logoutSuccess();
106 |
107 | expect(creator.type).toEqual(UsersTypes.LOGOUT_SUCCESS);
108 | });
109 |
110 | it('Should return correct action to error login creator', () => {
111 | INITIAL_STATE.data = null;
112 |
113 | action.type = UsersTypes.LOGIN_ERROR;
114 | action.payload.user = null;
115 | action.payload.error = 'Error test message login reducer';
116 |
117 | const state = usersReducer(INITIAL_STATE, action);
118 |
119 | expect(state.error).toEqual('Error test message login reducer');
120 | expect(state.loading).toBeFalsy();
121 | expect(state.data).toBeNull();
122 | });
123 |
124 | it('Should return correct action to login error creator', () => {
125 | const creator = UsersActions.loginError('Error test message');
126 |
127 | expect(creator.type).toEqual(UsersTypes.LOGIN_ERROR);
128 | });
129 | });
130 |
--------------------------------------------------------------------------------
/app/src/__tests__/sagas/users.spec.ts:
--------------------------------------------------------------------------------
1 | import MockAdapter from 'axios-mock-adapter';
2 |
3 | import api from '../../services/api';
4 | import { runSagaTest } from '../mocks/sagas';
5 | import { login, register } from '../../store/sagas/users';
6 | import { Types as UserTypes } from '../../store/ducks/users';
7 | import { UserInterface, UserActionInterface } from '../../interfaces/user';
8 |
9 | const apiMock = new MockAdapter(api);
10 |
11 | const USER: UserInterface = {
12 | id: 1,
13 | name: 'Gabriel Hahn Schaeffer',
14 | email: 'gabriel_hahn@hotmail.com',
15 | token: 'token_hash',
16 | password: '123456',
17 | };
18 |
19 | let dispatched;
20 |
21 | beforeEach(() => {
22 | dispatched = [];
23 | });
24 |
25 | afterEach(() => {
26 | apiMock.reset();
27 | });
28 |
29 | describe('Users Saga', () => {
30 | describe('Login', () => {
31 | it('Should be able to login', async () => {
32 | const apiResponse = { user: USER, token: 'token_hash' };
33 | const param: UserActionInterface = {
34 | type: null,
35 | payload: { user: USER, error: null },
36 | };
37 |
38 | apiMock.onPost('/sessions').reply(200, apiResponse);
39 | await runSagaTest(login, param, dispatched);
40 |
41 | expect(dispatched[0].type).toEqual(UserTypes.LOGIN_SUCCESS);
42 | expect(dispatched[0].payload.user).toEqual({ ...USER, password: undefined });
43 | });
44 |
45 | it('Should return a error', async () => {
46 | const apiResponse = {
47 | message: 'Invalid credentials',
48 | };
49 | const param: UserActionInterface = {
50 | type: null,
51 | payload: { user: USER, error: null },
52 | };
53 |
54 | apiMock.onPost('/sessions').reply(401, apiResponse);
55 | await runSagaTest(login, param, dispatched);
56 |
57 | expect(dispatched[0].type).toEqual(UserTypes.LOGIN_ERROR);
58 | expect(dispatched[0].payload.error).toEqual('Invalid credentials');
59 |
60 | expect(dispatched[1].type).toEqual('@ReduxToastr/toastr/ADD');
61 | expect(dispatched[1].payload.type).toEqual('error');
62 | expect(dispatched[1].payload.title).toEqual('Sign in failed');
63 | expect(dispatched[1].payload.message).toEqual('Invalid credentials');
64 | });
65 | });
66 |
67 | describe('Register', () => {
68 | it('Should be able to register', async () => {
69 | const apiResponse = { user: USER, token: 'token_hash' };
70 | const param: UserActionInterface = {
71 | type: null,
72 | payload: { user: USER, error: null },
73 | };
74 |
75 | apiMock.onPost('/user').reply(200, apiResponse);
76 | await runSagaTest(register, param, dispatched);
77 |
78 | expect(dispatched[0].type).toEqual(UserTypes.LOGIN_SUCCESS);
79 | expect(dispatched[0].payload.user).toEqual({ ...USER, password: undefined });
80 | });
81 |
82 | it('Should return a network error', async () => {
83 | const apiResponse = {
84 | message: 'Something wrong happened, try again in few minutes',
85 | };
86 | const param: UserActionInterface = {
87 | type: null,
88 | payload: { user: USER, error: null },
89 | };
90 |
91 | apiMock.onPost('/user').reply(401, apiResponse);
92 | await runSagaTest(register, param, dispatched);
93 |
94 | expect(dispatched[0].type).toEqual(UserTypes.LOGIN_ERROR);
95 | expect(dispatched[0].payload.error).toEqual('Error on register');
96 |
97 | expect(dispatched[1].type).toEqual('@ReduxToastr/toastr/ADD');
98 | expect(dispatched[1].payload.type).toEqual('error');
99 | expect(dispatched[1].payload.title).toEqual('Register failed');
100 | expect(dispatched[1].payload.message).toEqual('Something wrong happened, try again in few minutes');
101 | });
102 | });
103 | });
104 |
--------------------------------------------------------------------------------
/app/src/__tests__/utils/currency.spec.ts:
--------------------------------------------------------------------------------
1 | import {
2 | formatCurrencyWithType,
3 | formatCurrency,
4 | getMoneyPersistFormat,
5 | getJustDigitsFromString,
6 | formatCurrencyForInputs,
7 | } from '../../utils/currency';
8 |
9 | fdescribe('Currency utils', () => {
10 | it('Should format number to currency with type BRL as default', () => {
11 | const value = 234.56;
12 | const valueFormatted = formatCurrencyWithType(value);
13 |
14 | expect(valueFormatted).toEqual('R$234.56');
15 | });
16 |
17 | it('Should format number to currency without type', () => {
18 | const value = 3443.00;
19 | const valueFormatted = formatCurrency(value);
20 |
21 | expect(valueFormatted).toEqual('3,443.00');
22 | });
23 |
24 | it('Should return just the numbers of a provided string', () => {
25 | const value = 'R$2FASDd..3/4';
26 | const onlyNumbers = getJustDigitsFromString(value);
27 |
28 | expect(onlyNumbers).toEqual(234);
29 | });
30 |
31 | it('Should format string as currency - Full format', () => {
32 | const value = '3234.65';
33 | const valueFormatted = formatCurrencyForInputs(value);
34 |
35 | expect(valueFormatted).toEqual('3.234,65');
36 | });
37 |
38 | it('Should format string as currency - Decimal 1 number', () => {
39 | const value = '3';
40 | const valueFormatted = formatCurrencyForInputs(value);
41 |
42 | expect(valueFormatted).toEqual('0,03');
43 | });
44 |
45 | it('Should format string as currency - Decimal 2 numbers', () => {
46 | const value = '23';
47 | const valueFormatted = formatCurrencyForInputs(value);
48 |
49 | expect(valueFormatted).toEqual('0,23');
50 | });
51 |
52 | it('Should format string as currency - Decimal 4 numbers', () => {
53 | const value = '4023';
54 | const valueFormatted = formatCurrencyForInputs(value);
55 |
56 | expect(valueFormatted).toEqual('40,23');
57 | });
58 |
59 | it('Should format string as currency - 1 Int number', () => {
60 | const value = '321';
61 | const valueFormatted = formatCurrencyForInputs(value);
62 |
63 | expect(valueFormatted).toEqual('3,21');
64 | });
65 |
66 | it('Should return empty when the value contains only characteres', () => {
67 | const value = ',ke';
68 | const valueFormatted = formatCurrencyForInputs(value);
69 |
70 | expect(valueFormatted).toBe('');
71 | });
72 |
73 | it('Should format value as persist value - Number only with dots', () => {
74 | const value = '3.255,05';
75 | const valueFormatted = getMoneyPersistFormat(value);
76 |
77 | expect(valueFormatted).toEqual('3255.05');
78 | });
79 | });
80 |
--------------------------------------------------------------------------------
/app/src/__tests__/utils/format.spec.ts:
--------------------------------------------------------------------------------
1 | import { KeyValueNumberInterface, KeyValueStringInterface } from '../../interfaces/charts';
2 | import {
3 | capitalize,
4 | formatToChartDateObject,
5 | formatToChartNumberObject,
6 | formatToChartStringObject,
7 | } from '../../utils/format';
8 |
9 | describe('Format utils', () => {
10 | it('Should return correct options formatted with string - number object', () => {
11 | const options: KeyValueNumberInterface = {
12 | '2/2/2020': 4123,
13 | };
14 |
15 | const optionsFormatted = formatToChartDateObject(options);
16 |
17 | expect(optionsFormatted).toEqual([{ name: '2/2/2020', y: 4123 }]);
18 | });
19 |
20 | it('Should return correct array of options formatted with string - number object', () => {
21 | const options: KeyValueNumberInterface = {
22 | '2/2/2020': 4123,
23 | '4/12/2020': 421,
24 | };
25 |
26 | const optionsFormatted = formatToChartDateObject(options);
27 |
28 | expect(optionsFormatted).toEqual([{ name: '2/2/2020', y: 4123 }, { name: '4/12/2020', y: 421 }]);
29 | });
30 |
31 | it('Should format to chart number format correctly', () => {
32 | const options: KeyValueNumberInterface = {
33 | '2/2/2020': 4123,
34 | };
35 |
36 | const optionsFormatted = formatToChartNumberObject(options);
37 |
38 | expect(optionsFormatted).toEqual([{ name: '2/2/2020', y: 4123 }]);
39 | });
40 |
41 | it('Should format to chart string format correctly', () => {
42 | const options: KeyValueStringInterface = {
43 | '2/2/2020': '4123',
44 | };
45 |
46 | const optionsFormatted = formatToChartStringObject(options);
47 |
48 | expect(optionsFormatted).toEqual([{ name: '2/2/2020', y: 4123 }]);
49 | });
50 |
51 | it('Should format to chart string format correctly - With zero when not contains value', () => {
52 | const options: KeyValueStringInterface = {
53 | '2/2/2020': '',
54 | };
55 |
56 | const optionsFormatted = formatToChartStringObject(options);
57 |
58 | expect(optionsFormatted).toEqual([{ name: '2/2/2020', y: 0 }]);
59 | });
60 |
61 | it('Should capitalize the provided string - lowercase', () => {
62 | const stringParam = 'test';
63 | const stringCapitalized = capitalize(stringParam);
64 |
65 | expect(stringCapitalized).toEqual('Test');
66 | });
67 |
68 | it('Should capitalize the provided string - uppercase', () => {
69 | const stringParam = 'TEST';
70 | const stringCapitalized = capitalize(stringParam);
71 |
72 | expect(stringCapitalized).toEqual('Test');
73 | });
74 | });
75 |
--------------------------------------------------------------------------------
/app/src/__tests__/utils/settings.spec.ts:
--------------------------------------------------------------------------------
1 | import { getSettings, saveSettings } from '../../utils/settings';
2 | import { SettingInterface } from '../../interfaces/settings';
3 | import { CurrencyType, DateFormatType } from '../../enums/settings';
4 |
5 | beforeEach(() => {
6 | localStorage.clear();
7 | });
8 |
9 | describe('Settings utils', () => {
10 | describe('Save settings', () => {
11 | it('Should save settings correctly', () => {
12 | const settings: SettingInterface = {
13 | currency: CurrencyType.REAIS,
14 | dateFormat: DateFormatType.PT,
15 | };
16 |
17 | saveSettings(settings);
18 |
19 | expect(getSettings().currency).toEqual(CurrencyType.REAIS);
20 | expect(getSettings().dateFormat).toEqual(DateFormatType.PT);
21 | });
22 | });
23 |
24 | describe('Get settings', () => {
25 | it('Should return default settings', () => {
26 | const settings = getSettings();
27 |
28 | expect(settings.currency).toEqual(CurrencyType.DOLAR);
29 | expect(settings.dateFormat).toEqual(DateFormatType.EN);
30 | });
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/app/src/components/Access/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useSelector } from 'react-redux';
3 | import { Route, Redirect } from 'react-router-dom';
4 |
5 | import { StoreInterface } from '../../interfaces/store';
6 |
7 | export interface RoutePropsInterface {
8 | component: any;
9 | path: string;
10 | exact?: boolean;
11 | }
12 |
13 | export const Private: React.FC = ({ component: Component, ...rest }) => {
14 | const userState = useSelector((state: StoreInterface) => state.users.data);
15 |
16 | return (
17 | (userState && userState.token
20 | ? ()
21 | : ()
22 | )}
23 | />
24 | );
25 | };
26 |
27 | export const Public: React.FC = ({ component: Component, ...rest }) => {
28 | const userState = useSelector((state: StoreInterface) => state.users.data);
29 |
30 | return (
31 | (userState && userState.token
34 | ? ()
35 | : ()
36 | )}
37 | />
38 | );
39 | };
40 |
--------------------------------------------------------------------------------
/app/src/components/Amount/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { memo } from 'react';
2 | import { useSelector } from 'react-redux';
3 | import { RotateSpinner } from 'react-spinners-kit';
4 |
5 | import { getSettings } from '../../utils/settings';
6 | import { formatCurrency } from '../../utils/currency';
7 | import { globalVariables } from '../../styles/variables';
8 |
9 | import {
10 | Container,
11 | Description,
12 | Value,
13 | DateItem,
14 | } from './styles';
15 | import { StoreInterface } from '../../interfaces/store';
16 |
17 | export interface AmountPropsInterface {
18 | description?: string;
19 | color: string;
20 | value: number;
21 | loading?: boolean;
22 | showDate?: boolean;
23 | }
24 |
25 | const Amount: React.FC = ({
26 | description,
27 | color,
28 | value,
29 | loading,
30 | showDate,
31 | }) => {
32 | const dateRange = useSelector((state: StoreInterface) => state.transactions.currentDateRange);
33 | const currencyFormat = getSettings().currency;
34 |
35 | return (
36 |
37 | { loading ? (
38 |
39 | ) : (
40 | <>
41 |
42 | {description} ({currencyFormat})
43 |
44 |
45 | { value ? formatCurrency(value) : '0,00' }
46 |
47 | { showDate && dateRange && (
48 |
49 | {dateRange.startDate} - {dateRange.endDate}
50 |
51 | ) }
52 | >
53 | )}
54 |
55 | );
56 | };
57 |
58 | export default memo(Amount);
59 |
--------------------------------------------------------------------------------
/app/src/components/Amount/styles.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | import { globalVariables, device } from '../../styles/variables';
4 | import { AmountPropsInterface } from './index';
5 |
6 | export const Container = styled.div`
7 | display: flex;
8 | justify-content: center;
9 | flex-direction: column;
10 | align-items: center;
11 | position: relative;
12 | height: 18vh;
13 | width: 35em;
14 | border-radius: 5px;
15 | margin: 0.6em 1.3em;
16 | background: ${globalVariables.white};
17 | border: 1px solid rgba(0, 0, 0, 0.1);
18 | color: ${(props: AmountPropsInterface) => (props.color)};
19 |
20 | @media ${device.laptop} {
21 | margin: 0.5em 1em;
22 | }
23 |
24 | @media ${device.tabletG} {
25 | margin: 0.4em 0.6em;
26 | }
27 |
28 | @media ${device.mobileG} {
29 | width: 30em;
30 | height: 22vh;
31 | }
32 |
33 | @media ${device.mobileL} {
34 | width: 15em;
35 | }
36 | `;
37 |
38 | export const DateItem = styled.p`
39 | font-size: 12px;
40 |
41 | @media ${device.laptopM} {
42 | font-size: 12px;
43 | margin-top: 5px;
44 | }
45 |
46 | @media ${device.laptopM} {
47 | font-size: 10px;
48 | margin-top: 12px;
49 | }
50 | `;
51 |
52 | export const Description = styled.p`
53 | position: absolute;
54 | top: 10px;
55 | left: 10px;
56 |
57 | @media ${device.laptopM} {
58 | font-size: 14px;
59 | }
60 |
61 | @media ${device.laptop} {
62 | font-size: 10px;
63 | }
64 | `;
65 |
66 | export const Value = styled.h2`
67 | font-size: 2em;
68 | font-weight: 100;
69 |
70 | @media ${device.laptopM} {
71 | margin-top: 10px;
72 | font-size: 1.8em;
73 | }
74 |
75 | @media ${device.laptop} {
76 | font-size: 1.4em;
77 | }
78 |
79 | @media ${device.tabletG} {
80 | font-size: 1.2em;
81 | }
82 |
83 | @media ${device.mobileG} {
84 | font-size: 1.8em;
85 | margin-top: 14px;
86 | }
87 |
88 | @media ${device.heightMobileLM} {
89 | font-size: 1em;
90 | margin-top: 20px;
91 | }
92 | `;
93 |
94 | Value.displayName = 'Value';
95 | Description.displayName = 'Description';
96 |
--------------------------------------------------------------------------------
/app/src/components/BarChart/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import Highcharts from 'highcharts';
3 | import HighchartsReact from 'highcharts-react-official';
4 | import { RotateSpinner } from 'react-spinners-kit';
5 | import api from '../../services/api';
6 |
7 | import { CashFlowInterface } from '../../interfaces/transaction';
8 | import { getMonthDescriptionByMonth } from '../../utils/date';
9 | import { barChartConfig } from '../../config/highcharts';
10 | import { KeyValueStringInterface, ChartInterface } from '../../interfaces/charts';
11 | import { formatToChartStringObject } from '../../utils/format';
12 | import { globalVariables } from '../../styles/variables';
13 | import { ChartDataProps } from '../../pages/Dashboard/Report';
14 |
15 | import { Loading } from './styles';
16 |
17 | const BarChart: React.FC = ({ onEmpty }) => {
18 | let cashFlowFormatted: ChartInterface[];
19 | const [chartOptions, setChartOptions] = useState();
20 |
21 | const formatTransactions = (cashFlow: CashFlowInterface) => {
22 | const flowData: KeyValueStringInterface = {};
23 |
24 | Object.entries(cashFlow).forEach((monthData) => {
25 | const month = getMonthDescriptionByMonth(monthData[0]);
26 |
27 | flowData[month] = monthData[1].toFixed(2);
28 | });
29 |
30 | cashFlowFormatted = formatToChartStringObject(flowData);
31 |
32 | setChartOptions(barChartConfig(cashFlowFormatted));
33 | };
34 |
35 | const getTransactions = async () => {
36 | const { data: cashFlow } = await api.get('transactions/cashFlow');
37 |
38 | if (Object.keys(cashFlow).length === 0) {
39 | onEmpty();
40 |
41 | return;
42 | }
43 |
44 | formatTransactions(cashFlow);
45 | };
46 |
47 | useEffect(() => {
48 | getTransactions();
49 | }, []);
50 |
51 | return (
52 | <>
53 | { chartOptions ? (
54 |
58 | ) : (
59 |
60 |
61 |
62 | ) }
63 | >
64 | );
65 | };
66 |
67 | export default BarChart;
68 |
--------------------------------------------------------------------------------
/app/src/components/BarChart/styles.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | import { device } from '../../styles/variables';
4 |
5 | export const Loading = styled.div`
6 | display: flex;
7 | justify-content: center;
8 | align-items: center;
9 | width: 50vw;
10 |
11 | @media ${device.laptopM}, ${device.heightMobileL}, ${device.minlaptopG} {
12 | width: unset;
13 | }
14 | `;
15 |
16 | Loading.displayName = 'Loading';
17 |
--------------------------------------------------------------------------------
/app/src/components/LineChart/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import Highcharts from 'highcharts';
3 | import HighchartsReact from 'highcharts-react-official';
4 | import { RotateSpinner } from 'react-spinners-kit';
5 | import api from '../../services/api';
6 |
7 | import { lineChartConfig } from '../../config/highcharts';
8 | import { dateThreeMonthBefore } from '../../utils/date';
9 | import { formatToChartDateObject } from '../../utils/format';
10 | import { TransactionInterface } from '../../interfaces/transaction';
11 | import { ChartInterface, KeyValueNumberInterface } from '../../interfaces/charts';
12 | import { globalVariables } from '../../styles/variables';
13 | import { ChartDataProps } from '../../pages/Dashboard/Report';
14 |
15 | import { Loading } from './styles';
16 |
17 | const LineChart: React.FC = ({ onEmpty }) => {
18 | let debitsFormatted: ChartInterface[];
19 | let creditsFormatted: ChartInterface[];
20 | let debits: TransactionInterface[];
21 | let credits: TransactionInterface[];
22 |
23 | const [chartOptions, setChartOptions] = useState();
24 |
25 | const formatTransactions = () => {
26 | const totalDebits: KeyValueNumberInterface = { };
27 | const totalCredits: KeyValueNumberInterface = { };
28 |
29 | debits.forEach((debit) => {
30 | const value = totalDebits[debit.date] || 0;
31 | const total = debit.value ? (value + debit.value) : value;
32 |
33 | totalDebits[debit.date] = total;
34 | });
35 |
36 | credits.forEach((credit) => {
37 | const value = totalCredits[credit.date] || 0;
38 | const total = credit.value ? (value + credit.value) : value;
39 |
40 | totalCredits[credit.date] = total;
41 | });
42 |
43 | debitsFormatted = formatToChartDateObject(totalDebits);
44 | creditsFormatted = formatToChartDateObject(totalCredits);
45 |
46 | setChartOptions(lineChartConfig(creditsFormatted, debitsFormatted));
47 | };
48 |
49 | const getAllByLastThreeMonths = async () => {
50 | const startDate = dateThreeMonthBefore();
51 | const endDate = new Date();
52 |
53 | const { data: debitData } = await api.get('debits', { params: { startDate, endDate } });
54 | const { data: creditData } = await api.get('credits', { params: { startDate, endDate } });
55 |
56 | if (debitData.length === 0 && creditData.length === 0) {
57 | onEmpty();
58 |
59 | return;
60 | }
61 |
62 | debits = debitData.reverse();
63 | credits = creditData.reverse();
64 |
65 | formatTransactions();
66 | };
67 |
68 | useEffect(() => {
69 | getAllByLastThreeMonths();
70 | }, []);
71 |
72 | return (
73 | <>
74 | { chartOptions ? (
75 |
79 | ) : (
80 |
81 |
82 |
83 | ) }
84 | >
85 | );
86 | };
87 |
88 | export default LineChart;
89 |
--------------------------------------------------------------------------------
/app/src/components/LineChart/styles.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const Loading = styled.div`
4 | display: flex;
5 | justify-content: center;
6 | align-items: center;
7 | `;
8 |
9 | Loading.displayName = 'Loading';
10 |
--------------------------------------------------------------------------------
/app/src/components/Navbar/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { Link, useLocation } from 'react-router-dom';
3 | import { useDispatch } from 'react-redux';
4 |
5 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
6 | import {
7 | faBookOpen,
8 | faChartLine,
9 | faSignOutAlt,
10 | faCog,
11 | } from '@fortawesome/free-solid-svg-icons';
12 |
13 | import {
14 | Container,
15 | Page,
16 | Title,
17 | Logout,
18 | PagesList,
19 | } from './styles';
20 |
21 | import { Creators as UsersTypes } from '../../store/ducks/users';
22 |
23 | import SandwichMenu from '../SandwichMenu';
24 |
25 | enum PageType {
26 | REPORT = 'report',
27 | OVERVIEW = 'overview',
28 | SETTINGS = 'settings',
29 | }
30 |
31 | export interface NavbarPropsInterface {
32 | onLogout: () => void;
33 | }
34 |
35 | export interface StylesProps {
36 | selected?: boolean;
37 | sandwichMenuSelected?: boolean;
38 | }
39 |
40 | const Navbar: React.FC = ({ onLogout }) => {
41 | const [sandwichMenuSelected, setSandwichMenuSelected] = useState(false);
42 | const [selected, setSelected] = useState(PageType.OVERVIEW);
43 |
44 | const location = useLocation();
45 | const dispatch = useDispatch();
46 |
47 | useEffect(() => {
48 | const path = location.pathname.split('/')[2];
49 |
50 | setSelected(path as PageType);
51 | }, []);
52 |
53 | const handleLogout = () => {
54 | dispatch(UsersTypes.logoutRequest());
55 |
56 | onLogout();
57 | };
58 |
59 | const handleSandwichMenuClicked = (clicked: boolean) => {
60 | setSandwichMenuSelected(clicked);
61 | };
62 |
63 | return (
64 | <>
65 |
66 |
67 |
68 | setSelected(PageType.OVERVIEW)}
71 | >
72 |
73 |
74 | Overview
75 |
76 |
77 | setSelected(PageType.REPORT)}
80 | >
81 |
82 |
83 | Reports
84 |
85 |
86 | setSelected(PageType.SETTINGS)}
89 | >
90 |
91 |
92 | Settings
93 |
94 |
95 |
96 |
97 |
98 |
99 | Logout
100 |
101 |
102 | >
103 | );
104 | };
105 |
106 | export default Navbar;
107 |
--------------------------------------------------------------------------------
/app/src/components/Navbar/styles.ts:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from 'styled-components';
2 |
3 | import { globalVariables, device } from '../../styles/variables';
4 | import { StylesProps } from './index';
5 |
6 | const fadeFromSide = keyframes`
7 | from {
8 | opacity: 0;
9 | }
10 | to {
11 | opacity: 1;
12 | }
13 | `;
14 |
15 | export const Container = styled.div`
16 | display: flex;
17 | flex-direction: column;
18 | justify-content: space-between;
19 | min-width: 200px;
20 | background: ${globalVariables.mainBlue};
21 | color: ${globalVariables.white};
22 |
23 | @media ${device.laptop} {
24 | min-width: 150px;
25 | }
26 |
27 | @media ${device.mobileLL} {
28 | display: ${(props: StylesProps) => (props.sandwichMenuSelected ? 'flex' : 'none')};
29 | width: 60px;
30 | min-width: 60px;
31 | animation-iteration-count: infinite;
32 | animation-direction: alternate;
33 | animation: ${fadeFromSide} 0.3s;
34 | }
35 | `;
36 |
37 | export const Title = styled.p`
38 | display: block;
39 |
40 | @media ${device.mobileLL} {
41 | display: none;
42 | }
43 | `;
44 |
45 | export const PagesList = styled.ul`
46 |
47 | `;
48 |
49 | export const Page = styled.li`
50 | display: flex;
51 | height: 4em;
52 | border: 1px solid ${globalVariables.mainBlueHover};
53 | transition: all 0.3s;
54 | background: ${(props: StylesProps) => (props.selected ? `${globalVariables.mainBlueHover}` : 'transparent')};
55 |
56 | &:hover {
57 | background: ${globalVariables.mainBlueHover};
58 | }
59 |
60 | & > a {
61 | display: flex;
62 | height: 100%;
63 | width: 100%;
64 | justify-content: space-between;
65 | align-items: center;
66 | text-decoration: none;
67 | transition: all 0.3s;
68 | color: ${(props: StylesProps) => (props.selected ? `${globalVariables.white}` : `${globalVariables.navbarIcon}`)};
69 | border-left: ${(props: StylesProps) => (props.selected ? `2px solid ${globalVariables.mainGreen}` : 'none')};
70 |
71 | &:hover {
72 | color: ${globalVariables.white};
73 | }
74 |
75 | p {
76 | font-size: 1.2em;
77 | margin-right: 35px;
78 | /* margin: 0 35px 0 30px; */
79 |
80 | @media ${device.laptop} {
81 | font-size: 1em;
82 | margin-right: 20px;
83 | }
84 |
85 | @media ${device.tablet} {
86 | font-size: 0.85em;
87 | }
88 | }
89 |
90 | svg {
91 | font-size: 1.5em;
92 | margin-left: 15px;
93 | }
94 | }
95 | `;
96 |
97 | export const Logout = styled.button`
98 | background: transparent;
99 | color: ${globalVariables.navbarIcon};
100 | border: 1px solid ${globalVariables.mainBlueHover};
101 | height: 5.3em;
102 | display: flex;
103 | justify-content: space-between;
104 | align-items: center;
105 | transition: all 0.3s;
106 |
107 | &:hover {
108 | background: ${globalVariables.mainBlueHover};
109 | color: ${globalVariables.white};
110 | }
111 |
112 | p {
113 | font-size: 1.9em;
114 | margin-right: 35px;
115 |
116 | @media ${device.laptop} {
117 | font-size: 1.6em;
118 | margin-right: 20px;
119 | }
120 |
121 | @media ${device.tablet} {
122 | font-size: 1.4em;
123 | }
124 | }
125 |
126 | svg {
127 | font-size: 2.5em;
128 | margin-left: 15px;
129 | }
130 | `;
131 |
132 | PagesList.displayName = 'PagesList';
133 | Logout.displayName = 'Logout';
134 |
--------------------------------------------------------------------------------
/app/src/components/PageLoading/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { RotateSpinner } from 'react-spinners-kit';
4 |
5 | import { globalVariables } from '../../styles/variables';
6 | import { Container } from './styles';
7 |
8 | const PageLoading = () => (
9 |
10 |
11 |
12 | );
13 |
14 | export default PageLoading;
15 |
--------------------------------------------------------------------------------
/app/src/components/PageLoading/styles.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | import { globalVariables } from '../../styles/variables';
4 |
5 | export const Container = styled.div`
6 | height: 100vh;
7 | width: 100vw;
8 | background: ${globalVariables.white};
9 | display: flex;
10 | justify-content: center;
11 | align-items: center;
12 | overflow-y: hidden;
13 | `;
14 |
--------------------------------------------------------------------------------
/app/src/components/PieChart/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import Highcharts from 'highcharts';
3 | import HighchartsReact from 'highcharts-react-official';
4 | import { RotateSpinner } from 'react-spinners-kit';
5 | import api from '../../services/api';
6 |
7 | import { pieChartConfig } from '../../config/highcharts';
8 | import { TransactionInterface } from '../../interfaces/transaction';
9 | import { ChartInterface, KeyValueNumberInterface } from '../../interfaces/charts';
10 | import { formatToChartNumberObject, capitalize } from '../../utils/format';
11 | import { globalVariables } from '../../styles/variables';
12 |
13 | import { Loading } from './styles';
14 |
15 | const PieChart: React.FC = () => {
16 | let debitsFormatted: ChartInterface[];
17 | let debits: TransactionInterface[];
18 |
19 | const [chartOptions, setChartOptions] = useState();
20 |
21 | const formatTransactions = () => {
22 | const totalDebits: KeyValueNumberInterface = { };
23 |
24 | debits.forEach((debit) => {
25 | const sumValue = totalDebits[debit.type] || 0;
26 |
27 | totalDebits[capitalize(debit.type)] = debit.value ? (debit.value + sumValue) : 0;
28 | });
29 |
30 | debitsFormatted = formatToChartNumberObject(totalDebits);
31 |
32 | setChartOptions(pieChartConfig(debitsFormatted));
33 | };
34 |
35 | const getAllDataByCurrentMonth = async () => {
36 | const { data: debitData } = await api.get('debits/allByCurrentMonth');
37 |
38 | debits = debitData;
39 |
40 | formatTransactions();
41 | };
42 |
43 | useEffect(() => {
44 | getAllDataByCurrentMonth();
45 | }, []);
46 |
47 | return (
48 | <>
49 | { chartOptions ? (
50 |
54 | ) : (
55 |
56 |
57 |
58 | ) }
59 | >
60 | );
61 | };
62 |
63 | export default PieChart;
64 |
--------------------------------------------------------------------------------
/app/src/components/PieChart/styles.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | import { device } from '../../styles/variables';
4 |
5 | export const Loading = styled.div`
6 | display: flex;
7 | justify-content: center;
8 | align-items: center;
9 | width: 50vw;
10 |
11 | @media ${device.laptopM}, ${device.heightMobileL}, ${device.minlaptopG} {
12 | width: unset;
13 | }
14 | `;
15 |
16 | Loading.displayName = 'Loading';
17 |
--------------------------------------------------------------------------------
/app/src/components/SandwichMenu/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 |
3 | import { SandwichIconContainer, SmallNavBarContainer } from './styles';
4 |
5 | export interface StylesProps {
6 | sandwichClicked: boolean;
7 | }
8 |
9 | export interface PropsInterface {
10 | onClick: (clicked: boolean) => void;
11 | }
12 |
13 | const SandwichMenu: React.FC = ({ onClick }) => {
14 | const [sandwichClicked, setSandwichClicked] = useState(false);
15 |
16 | const toggleSandwichMenu = () => {
17 | setSandwichClicked(!sandwichClicked);
18 |
19 | onClick(!sandwichClicked);
20 | };
21 |
22 | return (
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | );
31 | };
32 |
33 | export default SandwichMenu;
34 |
--------------------------------------------------------------------------------
/app/src/components/SandwichMenu/styles.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | import { globalVariables, device } from '../../styles/variables';
4 | import { StylesProps } from './index';
5 |
6 | export const SandwichIconContainer = styled.div`
7 | display: flex;
8 | flex-direction: column;
9 | justify-content: center;
10 | align-items: center;
11 | height: 30px;
12 | width: 50px;
13 |
14 | & > span {
15 | display: block;
16 | border-radius: 5px;
17 | height: 3px;
18 | width: 25px;
19 | margin: ${(props: StylesProps) => (props.sandwichClicked ? '0' : '2px 0')};
20 | background: ${globalVariables.white};
21 | transition: all 0.3s;
22 | }
23 |
24 | & > span:nth-of-type(1) {
25 | position: ${(props: StylesProps) => (props.sandwichClicked ? 'absolute' : 'unset')};
26 | transform: ${(props: StylesProps) => (props.sandwichClicked ? 'rotate(-45deg)' : 'unset')};
27 | }
28 |
29 | & > span:nth-of-type(2) {
30 | opacity: ${(props: StylesProps) => (props.sandwichClicked ? '0' : '1')};
31 | }
32 |
33 | & > span:nth-of-type(3) {
34 | position: ${(props: StylesProps) => (props.sandwichClicked ? 'absolute' : 'unset')};
35 | transform: ${(props: StylesProps) => (props.sandwichClicked ? 'rotate(45deg)' : 'unset')};
36 | }
37 | `;
38 |
39 | export const SmallNavBarContainer = styled.div`
40 | cursor: pointer;
41 | display: none;
42 | justify-content: center;
43 | align-items: center;
44 | height: 50px;
45 | width: 50px;
46 | border-radius: 50%;
47 | background: ${globalVariables.mainBlue};
48 | box-shadow: 1.5px 1.5px 10px 0 rgba(0,0,0,0.3);
49 | z-index: 2;
50 |
51 | @media ${device.mobileLL} {
52 | display: flex;
53 | position: fixed;
54 | bottom: 5.5em;
55 | right: 1em;
56 | width: 60px;
57 | height: 60px;
58 | }
59 | `;
60 |
--------------------------------------------------------------------------------
/app/src/components/TransactionModal/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, FormEvent } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 |
4 | import { Creators as TransactionsActions } from '../../store/ducks/transactions';
5 | import { currentDateInputFormat, toBarFormat } from '../../utils/date';
6 | import { formatCurrencyForInputs, getMoneyPersistFormat, formatCurrency } from '../../utils/currency';
7 | import { CreditType, DebitType } from '../../enums/transactions';
8 |
9 | import {
10 | Container,
11 | ModalContainer,
12 | Button,
13 | ButtonsContainer,
14 | FormContainer,
15 | InputValue,
16 | InputDate,
17 | SelectType,
18 | InputDescription,
19 | InputContainer,
20 | ButtonActions,
21 | ButtonsFormContainer,
22 | } from './styles';
23 | import { TransactionType, TransactionInterface } from '../../interfaces/transaction';
24 | import { StoreInterface } from '../../interfaces/store';
25 |
26 | export interface TransactionModalPropsInterface {
27 | onClose: () => void;
28 | }
29 |
30 | export interface StylesProps {
31 | transparent?: boolean;
32 | fullWidth?: boolean;
33 | debit?: boolean;
34 | credit?: boolean;
35 | selected?: boolean;
36 | disabled?: boolean;
37 | }
38 |
39 | const TransactionModal: React.FC = ({ onClose }) => {
40 | const [money, setMoney] = useState('');
41 | const [transaction, setTransaction] = useState({
42 | type: CreditType.Others,
43 | category: TransactionType.DEBIT,
44 | date: currentDateInputFormat(),
45 | description: '',
46 | value: 0,
47 | });
48 |
49 | const transactionSelected = useSelector((store: StoreInterface) => (
50 | store.transactions.transactionSelected));
51 |
52 | const transactionType = transaction.category === TransactionType.CREDIT ? CreditType : DebitType;
53 | const dispatch = useDispatch();
54 |
55 | useEffect(() => {
56 | if (transactionSelected) {
57 | const transactionState = { ...transactionSelected };
58 | const value = formatCurrency(transactionState.value || 0);
59 |
60 | setMoney(transactionState.value ? formatCurrencyForInputs(value) : '');
61 |
62 | transactionState.date = currentDateInputFormat(new Date(transactionState.date));
63 |
64 | setTransaction(transactionState);
65 | }
66 | }, [transactionSelected]);
67 |
68 | const handleCloseModal = () => {
69 | onClose();
70 | };
71 |
72 | const handleAddTransaction = (e: FormEvent) => {
73 | e.preventDefault();
74 |
75 | const localeDate = toBarFormat(transaction.date);
76 |
77 | transaction.date = new Date(localeDate).toISOString();
78 | transaction.value = +getMoneyPersistFormat(money);
79 |
80 | dispatch(transaction.id
81 | ? TransactionsActions.updateTransactionRequest(transaction)
82 | : TransactionsActions.addTransactionRequest(transaction));
83 |
84 | onClose();
85 | };
86 |
87 | const handleTransactionChanged = (e: FormEvent) => {
88 | setTransaction({ ...transaction, [e.currentTarget.name]: e.currentTarget.value });
89 | };
90 |
91 | const handleMoneyChanged = (e: FormEvent) => {
92 | const value = formatCurrencyForInputs(e.currentTarget.value);
93 |
94 | setMoney(value);
95 | };
96 |
97 | const handleCreditClick = () => {
98 | setTransaction({ ...transaction, category: TransactionType.CREDIT, type: CreditType.Others });
99 | };
100 |
101 | const handleDebitClick = () => {
102 | setTransaction({ ...transaction, category: TransactionType.DEBIT, type: DebitType.Others });
103 | };
104 |
105 | const handleTypeChanged = (e: FormEvent) => {
106 | const { value } = e.currentTarget;
107 | const type = transaction.category === TransactionType.CREDIT
108 | ? (value as CreditType) : (value as DebitType);
109 |
110 | setTransaction({ ...transaction, type });
111 | };
112 |
113 | return transaction && (
114 |
115 |
116 |
117 |
118 |
122 |
123 |
124 | { Object.keys(transactionType).map((type: string) => (
125 |
126 | )) }
127 |
128 |
129 |
130 |
131 |
132 |
139 |
146 |
147 |
148 | {`${transaction.id ? 'Update' : 'Add'} Transaction`}
149 | Close
150 |
151 |
152 |
153 | );
154 | };
155 |
156 | export default TransactionModal;
157 |
--------------------------------------------------------------------------------
/app/src/components/TransactionModal/styles.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | import { globalVariables } from '../../styles/variables';
4 | import { StylesProps } from './index';
5 |
6 | export const Container = styled.form`
7 | position: absolute;
8 | background: rgba(0, 0, 0, 0.55);
9 | height: 100%;
10 | width: 100%;
11 | z-index: 2;
12 | display: flex;
13 | justify-content: center;
14 | align-items: center;
15 | `;
16 |
17 | export const ModalContainer = styled.div`
18 | display: flex;
19 | flex-direction: column;
20 | background: ${globalVariables.white};
21 | height: 330px;
22 | width: 300px;
23 | padding: 10px;
24 | border-radius: 5px;
25 | `;
26 |
27 | export const InputContainer = styled.div`
28 | display: flex;
29 | overflow: hidden;
30 | `;
31 |
32 | export const Input = styled.input`
33 | height: 50px;
34 | border: 0;
35 | outline: 0;
36 | border-bottom: 0.2px solid ${globalVariables.mediumGrey};
37 | font-size: 16px;
38 | `;
39 |
40 | export const InputValue = styled(Input).attrs({
41 | placeholder: '0,00',
42 | name: 'value',
43 | inputMode: 'numeric'
44 | })`
45 | margin: 5px 0 5px 5px;
46 | `;
47 |
48 | export const InputDate = styled(Input).attrs({
49 | placeholder: 'Date',
50 | name: 'date',
51 | type: 'date',
52 | })`
53 | width: 50%;
54 | `;
55 |
56 | export const InputDescription = styled(Input).attrs({
57 | placeholder: 'Description',
58 | name: 'description',
59 | type: 'text',
60 | })`
61 | margin: 5px 0 5px 5px;
62 | `;
63 |
64 | export const FormContainer = styled.div`
65 | height: 11em;
66 | display: flex;
67 | flex-direction: column;
68 | `;
69 |
70 | export const ButtonsContainer = styled.div`
71 | height: 2em;
72 | display: flex;
73 | justify-content: space-between;
74 | margin: 0.5em 0;
75 | `;
76 |
77 | export const ButtonsFormContainer = styled.div`
78 | display: flex;
79 | flex-direction: column;
80 | `;
81 |
82 | export const ButtonActions = styled.button.attrs({
83 | type: 'button',
84 | })`
85 | height: 2em;
86 | width: inherit;
87 | margin-bottom: 5px;
88 | background: ${(props: StylesProps) => (props.transparent ? `${globalVariables.white}` : (props.disabled ? `${globalVariables.mainBlueLigth}` : `${globalVariables.mainBlue}`))};
89 | color: ${(props: StylesProps) => (props.transparent ? `${globalVariables.mainBlue}` : `${globalVariables.white}`)};
90 | pointer-events: ${(props: StylesProps) => (props.disabled ? 'none' : 'all')};
91 | font-size: 16px;
92 | border-radius: 5px;
93 | transition: all 0.3s;
94 | border: 1px solid rgba(0, 0, 0, 0.1);
95 |
96 | &:hover {
97 | background: ${(props: StylesProps) => (props.transparent ? `${globalVariables.white}` : `${globalVariables.mainBlueHover}`)};
98 | }
99 | `;
100 |
101 | export const Button = styled.button.attrs({
102 | type: 'button',
103 | })`
104 | height: 2em;
105 | width: 135px;
106 | font-size: 16px;
107 | border: 1px solid rgba(0, 0, 0, 0.1);
108 | border-radius: 5px;
109 | background: ${(props: StylesProps) => (props.selected && props.debit ? `${globalVariables.mainPink}` : (props.selected && props.credit ? `${globalVariables.mainGreen}` : `${globalVariables.white}`))};
110 | color: ${(props: StylesProps) => (props.selected ? `${globalVariables.white}` : `${globalVariables.mainBlue}`)};
111 | transition: all 0.3s;
112 | `;
113 |
114 | export const SelectType = styled.select.attrs({
115 | placeholder: 'Classification',
116 | })`
117 | height: 50px;
118 | flex: 1;
119 | margin-right: 10px;
120 | background: ${globalVariables.white};
121 | border: 0;
122 | outline: 0;
123 | border-bottom: 1px solid ${globalVariables.mainGreen};
124 | font-size: 16px;
125 | `;
126 |
127 | InputValue.displayName = 'InputValue';
128 | InputDescription.displayName = 'InputDescription';
129 | SelectType.displayName = 'SelectType';
130 | FormContainer.displayName = 'FormContainer';
131 | ButtonsContainer.displayName = 'ButtonsContainer';
132 |
--------------------------------------------------------------------------------
/app/src/components/TransactionTable/styles.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | import { globalVariables, device } from '../../styles/variables';
4 | import { StylePropsInterface } from './index';
5 |
6 | export const Container = styled.div`
7 | display: flex;
8 | flex-direction: column;
9 |
10 | @media ${device.mobileG} {
11 | width: 30em;
12 | height: 33vh;
13 | display: flex;
14 | align-items: center;
15 | }
16 |
17 | @media ${device.mobileL} {
18 | margin: 0 0.8em;
19 | width: 20em;
20 | }
21 | `;
22 |
23 | export const DatePicker = styled.div`
24 | position: absolute;
25 | box-shadow: 1.5px 1.5px 10px 0 rgba(0,0,0,0.3);
26 | `;
27 |
28 | export const ContainerDate = styled.div`
29 | display: flex;
30 | justify-content: space-between;
31 | margin-bottom: 5px;
32 | height: 1.8em;
33 |
34 | @media ${device.laptop} {
35 | margin-bottom: unset;
36 | }
37 |
38 | @media ${device.mobileG} {
39 | display: none;
40 | }
41 | `;
42 |
43 | export const CategoryHead = styled.th`
44 | text-align: end !important;
45 | `;
46 |
47 | export const OptionButton = styled.button`
48 | background: ${globalVariables.darkGrey};
49 | color: ${globalVariables.white};
50 | border-radius: 5px;
51 | border: none;
52 | width: 5em;
53 | height: 100%;
54 | font-size: 1em;
55 | margin-right: 5px;
56 | transition: all 0.3s;
57 |
58 | &:hover {
59 | background: ${globalVariables.darkGreyHover};
60 | }
61 |
62 | @media ${device.laptop} {
63 | height: 80%;
64 | font-size: 0.9em;
65 | }
66 |
67 | @media ${device.tabletG} {
68 | height: 70%;
69 | font-size: 0.8em;
70 | }
71 | `;
72 |
73 | export const PaginationButton = styled(OptionButton)`
74 | width: 6em;
75 | background: ${(props: StylePropsInterface) => (props.disabled ? `${globalVariables.darkGrey}` : `${globalVariables.darkGrey}`)};
76 | pointer-events: ${(props: StylePropsInterface) => (props.disabled ? 'none' : 'all')};
77 | `;
78 |
79 | export const ContainerTable = styled.table`
80 | background: ${globalVariables.white};
81 | width: 100%;
82 | border-radius: 5px;
83 | border-collapse: collapse;
84 | font-size: 1em;
85 |
86 | @media ${device.mobileG} {
87 | display: none;
88 | }
89 |
90 | tbody {
91 | max-height: 45vh;
92 |
93 | td, th {
94 | font-size: 1em;
95 | font-weight: 100;
96 | border: 1px solid #ddd;
97 | padding: 8px;
98 | color: #171717;
99 | height: 12px;
100 |
101 | @media ${device.laptopL} {
102 | padding: 7px;
103 | }
104 |
105 | @media ${device.laptop} {
106 | padding: 6px;
107 | font-size: 0.8em;
108 | }
109 |
110 | @media ${device.tabletG} {
111 | padding: 5.2px;
112 | font-size: 0.65em;
113 | }
114 | }
115 |
116 | th {
117 | text-align: left;
118 | background-color: ${globalVariables.darkGrey};
119 | color: white;
120 | }
121 |
122 | tr:nth-child(even){
123 | background-color: ${globalVariables.ligthGrey};
124 | }
125 |
126 | tr:hover {
127 | background-color: #dbdbdb;
128 | }
129 |
130 | td:nth-of-type(1) {
131 | width: 60%;
132 | }
133 |
134 | td:nth-of-type(2),
135 | td:nth-of-type(3) {
136 | width: 15%;
137 | }
138 |
139 | td:nth-of-type(4),
140 | td:nth-of-type(5) {
141 | width: 5%;
142 | }
143 | }
144 | `;
145 |
146 | export const CategoryColumn = styled.td`
147 | text-align: center;
148 |
149 | & > * {
150 | font-size: 16px;
151 | color: ${(props: StylePropsInterface) => (props.credit ? `${globalVariables.mainGreen}` : `${globalVariables.mainBlueLigthHover}`)};
152 | }
153 | `;
154 |
155 | export const ActionsButton = styled.div`
156 | height: 1.2em;
157 | width: 2.6em;
158 | font-size: 14px;
159 | color: ${globalVariables.mainBlueLigth};
160 | display: flex;
161 | cursor: pointer;
162 | justify-content: space-between;
163 | transition: all 0.3s;
164 |
165 | & > :hover {
166 | color: ${globalVariables.mainBlueLigthHover};
167 | }
168 | `;
169 |
170 | export const SmallTableContainer = styled.div`
171 | background-color: ${globalVariables.white};
172 | width: 15em;
173 | border-radius: 5px;
174 | border: 1px solid rgba(0,0,0,0.1);
175 | display: none;
176 | margin: 0 0.8em 1em;
177 |
178 | @media ${device.mobileG} {
179 | display: block;
180 | width: 100%;
181 | }
182 |
183 | @media ${device.mobileL} {
184 | width: 15em;
185 | }
186 | `;
187 |
188 | export const SmallTableText = styled.p`
189 | font-size: 14px;
190 | margin: 10px;
191 | height: 20px;
192 | text-align: center;
193 | color: #3e3e3e;
194 | `;
195 |
196 | export const SmallTableItemContainer = styled.div`
197 | margin-left: 5px;
198 | `;
199 |
200 | export const SmallTableItem = styled.div`
201 | display: flex;
202 | justify-content: space-between;
203 | margin: 0 8px 10px 8px;
204 | `;
205 |
206 | export const SmallTableDescription = styled.p`
207 | color: ${globalVariables.fontGrayColor};
208 | font-size: 14px;;
209 | white-space: nowrap;
210 | text-overflow: ellipsis;
211 | width: 120px;
212 | `;
213 |
214 | export const SmallTableDate = styled.p`
215 | color: ${globalVariables.fontGrayColor};
216 | font-size: 10px;
217 | `;
218 |
219 | export const SmallTableCurrency = styled.p`
220 | color: ${globalVariables.fontGrayColor};
221 | font-size: 12px;
222 | `;
223 |
--------------------------------------------------------------------------------
/app/src/config/highcharts.ts:
--------------------------------------------------------------------------------
1 | import { ChartInterface } from '../interfaces/charts';
2 | import { globalVariables } from '../styles/variables';
3 |
4 | export const lineChartConfig = (
5 | creditsData: ChartInterface[], debitsData: ChartInterface[],
6 | ) => (
7 | {
8 | title: {
9 | text: 'Credits and Debits',
10 | },
11 |
12 | subtitle: {
13 | text: 'Last 3 months',
14 | },
15 |
16 | colors: [
17 | globalVariables.mainBlue,
18 | globalVariables.mainGreen,
19 | globalVariables.mainPink,
20 | ],
21 |
22 | yAxis: {
23 | title: {
24 | text: 'Amount',
25 | },
26 | },
27 |
28 | xAxis: {
29 | labels: {
30 | enabled: false,
31 | },
32 | },
33 |
34 | chart: {
35 | borderRadius: 5,
36 | },
37 |
38 | legend: {
39 | layout: 'vertical',
40 | align: 'right',
41 | verticalAlign: 'middle',
42 | },
43 |
44 | plotOptions: {
45 | series: {
46 | label: {
47 | connectorAllowed: false,
48 | },
49 | pointStart: 2010,
50 | },
51 | },
52 |
53 | series: [{
54 | name: 'Credits',
55 | data: creditsData,
56 | }, {
57 | name: 'Debits',
58 | data: debitsData,
59 | }],
60 |
61 | responsive: {
62 | rules: [{
63 | condition: {
64 | maxWidth: '90vw',
65 | },
66 | chartOptions: {
67 | legend: {
68 | layout: 'horizontal',
69 | align: 'center',
70 | verticalAlign: 'bottom',
71 | },
72 | },
73 | }],
74 | },
75 | }
76 | );
77 |
78 | export const pieChartConfig = (debitsData: ChartInterface[]) => (
79 | {
80 | chart: {
81 | plotBackgroundColor: null,
82 | plotBorderWidth: null,
83 | plotShadow: false,
84 | type: 'pie',
85 | borderRadius: 5,
86 | },
87 | colors: [
88 | globalVariables.mainBlue,
89 | globalVariables.mainGreen,
90 | globalVariables.mainPink,
91 | ],
92 | title: {
93 | text: 'Debits',
94 | },
95 | subtitle: {
96 | text: 'Current month (%)',
97 | },
98 | tooltip: {
99 | pointFormat: '{series.name}: {point.percentage:.1f}%',
100 | },
101 | accessibility: {
102 | point: {
103 | valueSuffix: '%',
104 | },
105 | },
106 | plotOptions: {
107 | pie: {
108 | allowPointSelect: true,
109 | cursor: 'pointer',
110 | dataLabels: {
111 | enabled: false,
112 | },
113 | showInLegend: true,
114 | },
115 | },
116 | series: [{
117 | name: 'Percent',
118 | colorByPoint: true,
119 | data: debitsData,
120 | }],
121 | }
122 | );
123 |
124 | export const barChartConfig = (transactionsData: ChartInterface[]) => (
125 | {
126 | chart: {
127 | inverted: true,
128 | polar: false,
129 | borderRadius: 5,
130 | },
131 |
132 | title: {
133 | text: 'Cash flow',
134 | },
135 |
136 | colors: [
137 | globalVariables.mainBlue,
138 | globalVariables.mainGreen,
139 | globalVariables.mainPink,
140 | ],
141 |
142 | subtitle: {
143 | text: 'Last 3 months',
144 | },
145 |
146 | xAxis: {
147 | categories: transactionsData.map(transaction => transaction.name),
148 | },
149 |
150 | series: [{
151 | type: 'column',
152 | colorByPoint: true,
153 | data: transactionsData.map(transaction => transaction.y),
154 | showInLegend: false,
155 | }],
156 | }
157 | );
158 |
--------------------------------------------------------------------------------
/app/src/enums/settings.ts:
--------------------------------------------------------------------------------
1 | export enum CurrencyType {
2 | DOLAR = '$',
3 | EURO = '€',
4 | REAIS = 'R$',
5 | }
6 |
7 | export enum DateFormatType {
8 | EN = 'EN',
9 | PT = 'PT',
10 | }
11 |
--------------------------------------------------------------------------------
/app/src/enums/transactions.ts:
--------------------------------------------------------------------------------
1 | export enum CreditType {
2 | Services = 'SERVICES',
3 | Others = 'OTHERS',
4 | Income = 'INCOME',
5 | Sales = 'SALES',
6 | Salary = 'SALARY'
7 | }
8 |
9 | export enum DebitType {
10 | Food = 'FOOD',
11 | Leisure = 'LEISURE',
12 | Services = 'SERVICES',
13 | Education = 'EDUCATION',
14 | Others = 'OTHERS',
15 | Electronics = 'ELECTRONICS',
16 | Health = 'HEALTH',
17 | Shopping = 'SHOPPING',
18 | Debt = 'DEBT',
19 | }
20 |
--------------------------------------------------------------------------------
/app/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 | import * as serviceWorker from './serviceWorker';
5 |
6 | ReactDOM.render(, document.getElementById('root'));
7 |
8 | serviceWorker.register();
9 |
--------------------------------------------------------------------------------
/app/src/interfaces/charts.ts:
--------------------------------------------------------------------------------
1 | export interface ChartInterface {
2 | y: number | undefined;
3 | name: string;
4 | }
5 |
6 | export interface KeyValueNumberInterface {
7 | [key: string]: number | undefined;
8 | }
9 |
10 | export interface KeyValueStringInterface {
11 | [key: string]: string | undefined;
12 | }
13 |
--------------------------------------------------------------------------------
/app/src/interfaces/settings.ts:
--------------------------------------------------------------------------------
1 | import { CurrencyType, DateFormatType } from '../enums/settings';
2 |
3 | export interface SettingInterface {
4 | currency: CurrencyType;
5 | dateFormat: DateFormatType;
6 | }
7 |
--------------------------------------------------------------------------------
/app/src/interfaces/store.ts:
--------------------------------------------------------------------------------
1 | import { UserStateInterface } from './user';
2 | import { TransactionStateInterface } from './transaction';
3 |
4 | export interface StoreInterface {
5 | users: UserStateInterface,
6 | transactions: TransactionStateInterface,
7 | }
8 |
--------------------------------------------------------------------------------
/app/src/interfaces/transaction.ts:
--------------------------------------------------------------------------------
1 | import { CreditType, DebitType } from '../enums/transactions';
2 |
3 | export interface TransactionsRangeDateInterface {
4 | startDate: string;
5 | endDate: string;
6 | }
7 |
8 | export interface CashFlowInterface {
9 | [key: number]: number;
10 | }
11 |
12 | export interface TransactionsActionsInterface {
13 | type: string,
14 | payload: {
15 | category?: TransactionType,
16 | transactions: TransactionInterface[],
17 | transaction?: TransactionInterface,
18 | dateRange?: TransactionsRangeDateInterface,
19 | range?: TransactionsRangeDateInterface,
20 | total?: number,
21 | error?: string,
22 | },
23 | }
24 |
25 | export enum TransactionType {
26 | DEBIT = 'Debit',
27 | CREDIT = 'Credit',
28 | }
29 |
30 | export interface TransactionInterface {
31 | id?: number;
32 | user_id?: number | null;
33 | value?: number;
34 | date: string;
35 | type: CreditType | DebitType;
36 | category?: TransactionType;
37 | description?: string;
38 | createdAt?: string;
39 | updatedAt?: string;
40 | }
41 |
42 | export interface TransactionLoading {
43 | allLoading: boolean,
44 | addLoading: boolean,
45 | editLoading: boolean,
46 | deleteLoading: boolean,
47 | }
48 |
49 | export interface TransactionStateInterface {
50 | transactionSelected?: TransactionInterface | null,
51 | currentDateRange?: TransactionsRangeDateInterface | null,
52 | totalAllTransactions: number | null,
53 | data: TransactionInterface[];
54 | loading: TransactionLoading;
55 | modalOpen: boolean;
56 | error: string | null;
57 | }
58 |
--------------------------------------------------------------------------------
/app/src/interfaces/user.ts:
--------------------------------------------------------------------------------
1 | export interface UserInterface {
2 | id?: number;
3 | name?: string;
4 | email: string;
5 | password?: string;
6 | token?: string;
7 | tempToken?: string | undefined;
8 | }
9 |
10 | export interface UserActionInterface {
11 | type: string,
12 | payload: {
13 | user: UserInterface,
14 | error: string,
15 | },
16 | }
17 |
18 | export interface UserStateInterface {
19 | data: UserInterface | null;
20 | error: string | null;
21 | loading: boolean;
22 | }
23 |
--------------------------------------------------------------------------------
/app/src/pages/Dashboard/Overview/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useMemo } from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 | import api from '../../../services/api';
4 |
5 | import { StoreInterface } from '../../../interfaces/store';
6 | import { globalVariables } from '../../../styles/variables';
7 |
8 | import Amount from '../../../components/Amount';
9 | import TransactionTable from '../../../components/TransactionTable';
10 |
11 | import { Creators as TransactionsActions } from '../../../store/ducks/transactions';
12 | import { Container, AmountContainer, TransactionsContainer } from './styles';
13 | import { TransactionInterface, TransactionType } from '../../../interfaces/transaction';
14 |
15 | interface TotalTransactionInterface {
16 | debit: number,
17 | credit: number,
18 | }
19 |
20 | const Overview = () => {
21 | const [loading, setLoading] = useState(true);
22 | const [totalLoading, setTotalLoading] = useState(true);
23 | const [totalTransactions, setTotalTransactions] = useState(0);
24 | const dispatch = useDispatch();
25 |
26 | const transactions = useSelector((state: StoreInterface) => state.transactions);
27 |
28 | const memoizedTotalCredit = useMemo(() => (transactions.data
29 | .filter((transaction: TransactionInterface) => transaction.category === TransactionType.CREDIT)
30 | .reduce((total, credits) => total + (credits.value || 0), 0)), [transactions.data]);
31 |
32 | const memoizedTotalDebit = useMemo(() => (transactions.data
33 | .filter((transaction: TransactionInterface) => transaction.category === TransactionType.DEBIT)
34 | .reduce((total, credits) => total + (credits.value || 0), 0)), [transactions.data]);
35 |
36 | const currentBalance = memoizedTotalCredit - memoizedTotalDebit;
37 |
38 | const handleTransactionsTotalRequest = async () => {
39 | if (transactions.totalAllTransactions) {
40 | setTotalTransactions(transactions.totalAllTransactions);
41 | setTotalLoading(false);
42 |
43 | return;
44 | }
45 |
46 | const { data } = await api.get('/transactions/completeCashFlow');
47 | const balance = data.credit - data.debit;
48 |
49 | dispatch(TransactionsActions.addTotalTransactions(balance));
50 | setTotalTransactions(balance);
51 | setTotalLoading(false);
52 | };
53 |
54 | useEffect(() => {
55 | handleTransactionsTotalRequest();
56 | }, []);
57 |
58 | useEffect(() => {
59 | if (!transactions.loading.allLoading) {
60 | setLoading(false);
61 | }
62 | }, [memoizedTotalCredit, memoizedTotalDebit, currentBalance, transactions.loading.allLoading]);
63 |
64 | return (
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 | );
77 | };
78 |
79 | export default Overview;
80 |
--------------------------------------------------------------------------------
/app/src/pages/Dashboard/Overview/styles.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | import { device } from '../../../styles/variables';
4 |
5 | export const Container = styled.div`
6 | display: flex;
7 | flex-direction: column;
8 |
9 | @media ${device.mobileLL} {
10 | align-items: center;
11 | justify-content: center;
12 | width: 100%;
13 | }
14 | `;
15 |
16 | export const AmountContainer = styled.div`
17 | display: flex;
18 | margin: 1em;
19 |
20 | @media ${device.mobileG} {
21 | flex-direction: column;
22 | justify-content: center;
23 | align-items: center;
24 | margin: 0.7em;
25 | }
26 | `;
27 |
28 | export const TransactionsContainer = styled.div`
29 | margin: 1em 2.25em;
30 |
31 | @media ${device.mobileG} {
32 | margin: 0 0.7em 2em;
33 | display: flex;
34 | overflow-y: hidden;
35 | }
36 | `;
37 |
--------------------------------------------------------------------------------
/app/src/pages/Dashboard/Report/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 |
3 | import LineChart from '../../../components/LineChart';
4 | import BarChart from '../../../components/BarChart';
5 | import PieChart from '../../../components/PieChart';
6 |
7 | import { Container, NoChartsTitle, BottomContainer } from './styles';
8 |
9 | export interface ChartDataProps {
10 | onEmpty: () => void;
11 | }
12 |
13 | const Report = () => {
14 | const [emptyChart, setEmptyChart] = useState(false);
15 |
16 | const onEmpty = () => {
17 | setEmptyChart(true);
18 | };
19 |
20 | return (
21 |
22 | { emptyChart ? (
23 | No data yet. Please add some transactions first.
24 | ) : (
25 | <>
26 |
27 |
28 |
29 |
30 |
31 | >
32 | ) }
33 |
34 | );
35 | };
36 |
37 | export default Report;
38 |
--------------------------------------------------------------------------------
/app/src/pages/Dashboard/Report/styles.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | import { device, globalVariables } from '../../../styles/variables';
4 |
5 | export const Container = styled.div`
6 | padding: 2.25em;
7 | max-width: 90vw;
8 | flex: 1;
9 | display: inline-block;
10 |
11 | @media ${device.heightMobileL}, ${device.minlaptopG} {
12 | overflow-y: visible;
13 | }
14 | `;
15 |
16 | export const BottomContainer = styled.div`
17 | display: flex;
18 | justify-content: space-between;
19 | flex: 1;
20 | margin: 2em 0;
21 | height: 300px;
22 |
23 | & > :last-child {
24 | margin-left: 2em;
25 | }
26 |
27 | @media ${device.laptopM}, ${device.heightMobileL}, ${device.minlaptopG} {
28 | flex-direction: column;
29 | justify-content: unset;
30 | height: 52em;
31 |
32 | & > :last-child {
33 | margin-top: 2em;
34 | margin-left: 0;
35 | }
36 | }
37 | `;
38 |
39 | export const NoChartsTitle = styled.p`
40 | font-size: 18px;
41 | color: ${globalVariables.fontBlackColor};
42 | `;
43 |
--------------------------------------------------------------------------------
/app/src/pages/Dashboard/Settings/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState, FormEvent } from 'react';
2 |
3 | import { SettingInterface } from '../../../interfaces/settings';
4 | import { CurrencyType, DateFormatType } from '../../../enums/settings';
5 | import { getSettings, saveSettings } from '../../../utils/settings';
6 |
7 | import {
8 | Container,
9 | SelectsContainer,
10 | SelectItem,
11 | SelectTitle,
12 | Select,
13 | } from './styles';
14 |
15 | const Settings = () => {
16 | const settings = getSettings();
17 |
18 | const [currency, setCurrency] = useState(settings.currency);
19 | const [dateFormat, setDateFormat] = useState(settings.dateFormat);
20 |
21 | const handleSettingsAutoSave = () => {
22 | const newSettings: SettingInterface = {
23 | dateFormat,
24 | currency,
25 | };
26 |
27 | saveSettings(newSettings);
28 | };
29 |
30 | useEffect(() => {
31 | if ((settings.currency !== currency) || (settings.dateFormat !== dateFormat)) {
32 | handleSettingsAutoSave();
33 | }
34 | }, [currency, dateFormat]);
35 |
36 | const handleCurrencyChanged = (e: FormEvent) => {
37 | const { value } = e.currentTarget;
38 |
39 | setCurrency((value as CurrencyType));
40 | };
41 |
42 | const handleDateFormatChanged = (e: FormEvent) => {
43 | const { value } = e.currentTarget;
44 |
45 | setDateFormat((value as DateFormatType));
46 | };
47 |
48 | return (
49 |
50 |
51 |
52 | Currency
53 |
58 |
59 |
60 | Date format
61 |
66 |
67 |
68 |
69 | );
70 | };
71 |
72 | export default Settings;
73 |
--------------------------------------------------------------------------------
/app/src/pages/Dashboard/Settings/styles.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | import { globalVariables } from '../../../styles/variables';
4 |
5 | export const Container = styled.div`
6 | padding: 2.25em;
7 | max-width: 90vw;
8 | flex: 1;
9 | display: inline-block;
10 | `;
11 |
12 | export const SelectsContainer = styled.div`
13 | width: 100%;
14 | display: flex;
15 | justify-content: space-between;
16 |
17 | & > :last-child {
18 | margin-left: 2em;
19 | }
20 | `;
21 |
22 | export const SelectItem = styled.div`
23 | display: flex;
24 | flex: 1;
25 | height: 70px;
26 | flex-direction: column;
27 | `;
28 |
29 | export const SelectTitle = styled.p`
30 | color: ${globalVariables.fontBlackColor};
31 | margin-bottom: 10px;
32 | `;
33 |
34 | export const Select = styled.select`
35 | height: 50px;
36 | flex: 1;
37 | background: ${globalVariables.white};
38 | border: 0;
39 | outline: 0;
40 | border-bottom: 1px solid ${globalVariables.mainGreen};
41 | font-size: 14px;
42 | `;
43 |
44 | SelectItem.displayName = 'SelectItem';
45 | SelectTitle.displayName = 'SelectTitle';
46 | Select.displayName = 'Select';
47 |
--------------------------------------------------------------------------------
/app/src/pages/Dashboard/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 | import { RouteComponentProps, Route } from 'react-router-dom';
4 |
5 | import Navbar from '../../components/Navbar';
6 | import TransactionModal from '../../components/TransactionModal';
7 |
8 | import Overview from './Overview';
9 | import Report from './Report';
10 | import Settings from './Settings';
11 |
12 | import { StoreInterface } from '../../interfaces/store';
13 | import { getSettings } from '../../utils/settings';
14 | import { Creators as TransactionsActions } from '../../store/ducks/transactions';
15 | import { Container, AddTransactionContainer, PlusText } from './styles';
16 |
17 | const Dashboard: React.FC = ({ match, history }) => {
18 | const dispatch = useDispatch();
19 | const modalOpen = useSelector((state: StoreInterface) => state.transactions.modalOpen);
20 |
21 | useEffect(() => {
22 | getSettings();
23 | });
24 |
25 | const handleLogout = () => {
26 | history.push('/');
27 | };
28 |
29 | const handleTransactionModalToggle = () => {
30 | dispatch(TransactionsActions.transactionModalToggle());
31 | };
32 |
33 | return (
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | { modalOpen && }
42 |
43 |
44 |
45 |
46 |
47 | );
48 | };
49 |
50 | export default Dashboard;
51 |
--------------------------------------------------------------------------------
/app/src/pages/Dashboard/styles.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | import { globalVariables, device } from '../../styles/variables';
4 |
5 | export const Container = styled.div`
6 | display: flex;
7 | height: 100vh;
8 | background: ${globalVariables.ligthGrey};
9 | position: relative;
10 |
11 | @media ${device.mobileG} {
12 | height: 55em;
13 | }
14 | `;
15 |
16 | export const AddTransactionContainer = styled.div`
17 | position: absolute;
18 | right: 2em;
19 | bottom: 2em;
20 | background: ${globalVariables.mainGreen};
21 | height: 70px;
22 | width: 70px;
23 | border-radius: 50%;
24 | display: flex;
25 | justify-content: center;
26 | align-items: center;
27 | cursor: pointer;
28 | box-shadow: 1.5px 1.5px 10px 0 rgba(0,0,0,0.3);
29 | transition: all 0.3s;
30 | z-index: 2;
31 |
32 | &:hover {
33 | background: ${globalVariables.mainGreenHover};
34 | margin-bottom: 3px;
35 | }
36 |
37 | @media ${device.laptop} {
38 | height: 60px;
39 | width: 60px;
40 | }
41 |
42 | @media ${device.mobileG} {
43 | right: 0.9em;
44 | bottom: 0.9em;
45 |
46 | position: fixed;
47 | }
48 | `;
49 |
50 | export const PlusText = styled.p`
51 | &::before {
52 | content: "+";
53 | font-size: 40px;
54 | color: ${globalVariables.white};
55 |
56 | @media ${device.mobileG} {
57 | font-size: 35px;
58 | }
59 |
60 | @media ${device.mobileG} {
61 | font-size: 30px;
62 | }
63 | }
64 | `;
65 |
66 | AddTransactionContainer.displayName = 'AddTransactionContainer';
67 |
--------------------------------------------------------------------------------
/app/src/pages/Login/index.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | useState,
3 | useRef,
4 | useEffect,
5 | FormEvent,
6 | } from 'react';
7 | import { useDispatch, useSelector } from 'react-redux';
8 | import { RouteComponentProps } from 'react-router-dom';
9 | import Lottie, { Options } from 'react-lottie';
10 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
11 | import { faUser, faLock, faFont } from '@fortawesome/free-solid-svg-icons';
12 | import { RotateSpinner } from 'react-spinners-kit';
13 | import api from '../../services/api';
14 |
15 | import pigAnimation from '../../assets/piggy-bank.json';
16 | import { Creators as UsersTypes } from '../../store/ducks/users';
17 |
18 | import {
19 | Container,
20 | FormContainer,
21 | FormInputs,
22 | AnimationContainer,
23 | Animation,
24 | Title,
25 | Input,
26 | LoginButton,
27 | RegisterButton,
28 | IconsContainer,
29 | ForgotButton,
30 | ResetTitle,
31 | } from './styles';
32 | import { UserInterface } from '../../interfaces/user';
33 | import { StoreInterface } from '../../interfaces/store';
34 |
35 | const defaultOptions: Options = {
36 | loop: true,
37 | autoplay: true,
38 | animationData: pigAnimation,
39 | rendererSettings: {
40 | preserveAspectRatio: 'xMidYMid slice',
41 | },
42 | };
43 |
44 | const Login: React.FC = ({ history }) => {
45 | const [name, setName] = useState('');
46 | const [email, setEmail] = useState('');
47 | const [password, setPassword] = useState('');
48 | const [isLogin, setIsLogin] = useState(true);
49 | const [enableLoginButton, setEnableLoginButton] = useState(false);
50 | const [isForgotPassword, setIsForgotPassword] = useState(false);
51 | const [isForgotRequest, setIsForgotRequest] = useState(false);
52 | const formEl = useRef(null);
53 |
54 | const dispatch = useDispatch();
55 | const { data: userState, loading } = useSelector((state: StoreInterface) => state.users);
56 |
57 | useEffect(() => {
58 | if (userState && userState.token) {
59 | history.push('/dashboard/overview');
60 | }
61 | }, [userState]);
62 |
63 | const handleFormSubit = (e: FormEvent) => {
64 | e.preventDefault();
65 |
66 | if (isForgotPassword) {
67 | setIsForgotRequest(true);
68 |
69 | api.post('/reset', { email });
70 | } else {
71 | const user: UserInterface = { email, password, name };
72 |
73 | dispatch(isLogin
74 | ? UsersTypes.loginRequest(user)
75 | : UsersTypes.registerRequest(user));
76 | }
77 | };
78 |
79 | const checkFormIsValid = () => {
80 | setEnableLoginButton(formEl.current ? formEl.current.checkValidity() : enableLoginButton);
81 | };
82 |
83 | const handleNameChange = (e: FormEvent) => {
84 | setName(e.currentTarget.value);
85 | checkFormIsValid();
86 | };
87 |
88 | const handleEmailChange = (e: FormEvent) => {
89 | setEmail(e.currentTarget.value.trim());
90 | checkFormIsValid();
91 | };
92 |
93 | const handlePasswordChange = (e: FormEvent) => {
94 | setPassword(e.currentTarget.value.trim());
95 | checkFormIsValid();
96 | };
97 |
98 | const handleLoginChange = () => {
99 | setIsLogin(!isLogin);
100 | setIsForgotPassword(false);
101 | };
102 |
103 | const handleForgotPassword = () => {
104 | setIsForgotPassword(!isForgotPassword);
105 | setIsLogin(isForgotPassword);
106 | };
107 |
108 | return (
109 |
110 |
111 |
112 |
115 |
116 |
117 |
118 | {isLogin || !isForgotPassword ? 'Welcome' : 'Password reset' }
119 | { isForgotRequest && !loading && (
120 | Please, check your e-mail and set the new password.
121 | ) }
122 |
123 | {!isLogin && !isForgotPassword && !isForgotRequest && (
124 |
125 |
126 |
127 |
128 |
129 |
130 | )}
131 | { !isForgotRequest && (
132 |
133 |
134 |
135 |
136 |
137 |
138 | )}
139 | { !isForgotPassword && !isForgotRequest && (
140 |
141 |
142 |
143 |
144 |
145 |
146 | )}
147 | { !isForgotRequest && (
148 |
149 | { loading ? : (isLogin ? 'Login' : (isForgotPassword ? 'Reset' : 'Register')) }
150 |
151 | ) }
152 |
153 | { !isForgotRequest && (
154 | <>
155 | { isLogin ? 'Register' : 'Login' }
156 | { isLogin && (
157 | Forgot password?
158 | ) }
159 | >
160 | )}
161 |
162 |
163 | );
164 | };
165 |
166 | export default Login;
167 |
--------------------------------------------------------------------------------
/app/src/pages/Login/styles.ts:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from 'styled-components';
2 |
3 | import { globalVariables, device } from '../../styles/variables';
4 |
5 | interface PropsInterface {
6 | disabled?: boolean;
7 | }
8 |
9 | export const Container = styled.div`
10 | display: flex;
11 | height: 100%;
12 |
13 | @media ${device.mobileL} {
14 | flex-direction: column;
15 | align-items: center;
16 | justify-content: center;
17 | }
18 | `;
19 |
20 | export const FormContainer = styled.div`
21 | background: ${globalVariables.white};
22 | display: flex;
23 | flex-direction: column;
24 | align-items: center;
25 | justify-content: center;
26 | flex: 1;
27 | position: relative;
28 |
29 | @media ${device.mobileL} {
30 | justify-content: unset;
31 | }
32 | `;
33 |
34 | export const AnimationContainer = styled.div`
35 | background: ${globalVariables.mainBlue};
36 | flex: 1;
37 | display: flex;
38 | justify-content: center;
39 | align-items: center;
40 | overflow-y: hidden;
41 |
42 | @media ${device.mobileL} {
43 | justify-content: center;
44 | align-items: flex-end;
45 | background: ${globalVariables.white};
46 | }
47 |
48 | @media ${device.mobileL} {
49 | margin-bottom: 20px;
50 | }
51 | `;
52 |
53 | const UpAndDownAnimation = keyframes`
54 | from {
55 | top: -10px;
56 | }
57 | to {
58 | top: 10px;
59 | }
60 | `;
61 |
62 | export const Animation = styled.div`
63 | position: relative;
64 | animation-duration: 2s;
65 | animation-iteration-count: infinite;
66 | animation-direction: alternate;
67 | animation-name: ${UpAndDownAnimation};
68 | height: auto;
69 | width: 55%;
70 |
71 | @media ${device.tabletG} {
72 | width: 65%;
73 | }
74 | `;
75 |
76 | export const Title = styled.h3`
77 | color: ${globalVariables.mainBlue};
78 | font-size: 36px;
79 | font-weight: 100;
80 | margin-bottom: 20px;
81 |
82 | @media ${device.tablet} {
83 | font-size: 32px;
84 | }
85 |
86 | @media ${device.mobileL} {
87 | display: none;
88 | }
89 | `;
90 |
91 | export const ResetTitle = styled.h3`
92 | color: ${globalVariables.mainBlue};
93 | font-size: 18px;
94 | font-weight: 100;
95 |
96 | @media ${device.tablet} {
97 | font-size: 22px;
98 | }
99 | `;
100 |
101 | export const Input = styled.input`
102 | margin: 5px;
103 | padding: 15px 15px 15px 40px;
104 | width: 300px;
105 | border: 1px solid ${globalVariables.mainBlue};
106 | border-radius: 5px;
107 | font-size: 16px;
108 |
109 | &::-webkit-input-placeholder {
110 | color: ${globalVariables.mainBlue};
111 | font-size: 16px;
112 | }
113 |
114 | @media ${device.tablet} {
115 | width: 280px;
116 | padding: 14px 14px 10px 40px;
117 | }
118 | `;
119 |
120 | const sharedButtonStyle = styled.button`
121 | font-size: 16px;
122 | text-transform: uppercase;
123 | display: inline-block;
124 | max-width: 300px;
125 | margin: 5px;
126 |
127 | @media ${device.tablet} {
128 | max-width: 280px;
129 | padding: 5px 0;
130 | height: 40px;
131 | }
132 | `;
133 |
134 | export const RegisterButton = styled(sharedButtonStyle)`
135 | background: ${globalVariables.white};
136 | color: ${globalVariables.mainBlue};
137 | padding: 8px 115px;
138 | border: none;
139 | transition: all 0.3s;
140 | margin: 0;
141 |
142 | &:hover {
143 | color: ${globalVariables.mainBlueHover};
144 | }
145 |
146 | @media ${device.mobileL} {
147 | height: unset;
148 | }
149 | `;
150 |
151 | export const LoginButton = styled(sharedButtonStyle)`
152 | background: ${(props: PropsInterface) => (props.disabled ? `${globalVariables.mainBlueLigth}` : `${globalVariables.mainBlue}`)};
153 | pointer-events: ${(props: PropsInterface) => (props.disabled ? 'none' : 'all')};
154 | color: ${globalVariables.white};
155 | padding: 12px 115px;
156 | border-radius: 5px;
157 | transition: all 0.3s;
158 | display: flex;
159 | justify-content: center;
160 | height: 45px;
161 |
162 | &:hover {
163 | background: ${globalVariables.mainBlueHover};
164 | }
165 | `;
166 |
167 | export const FormInputs = styled.form`
168 | display: flex;
169 | flex-direction: column;
170 |
171 | @media ${device.tablet} {
172 | width: 290px;
173 | }
174 | `;
175 |
176 | export const IconsContainer = styled.div`
177 | position: absolute;
178 | margin: 20px;
179 | color: ${globalVariables.mainBlue};
180 |
181 | @media ${device.tablet} {
182 | margin: 16.5px;
183 | }
184 | `;
185 |
186 | export const ForgotButton = styled(sharedButtonStyle)`
187 | background: ${globalVariables.white};
188 | color: ${globalVariables.mainBlue};
189 | padding: 8px 15px;
190 | border: none;
191 | transition: all 0.3s;
192 | margin: 0;
193 | position: absolute;
194 | bottom: 20px;
195 | text-transform: unset;
196 |
197 | &:hover {
198 | color: ${globalVariables.mainBlueHover};
199 | }
200 |
201 | @media ${device.mobileL} {
202 | height: unset;
203 | }
204 | `;
205 |
206 | RegisterButton.displayName = 'RegisterButton';
207 | ForgotButton.displayName = 'ForgotButton';
208 | Input.displayName = 'Input';
209 |
--------------------------------------------------------------------------------
/app/src/pages/ResetPassword/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, FormEvent } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import { RouteComponentProps, useParams } from 'react-router-dom';
4 | import Lottie, { Options } from 'react-lottie';
5 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
6 | import { faUser, faLock } from '@fortawesome/free-solid-svg-icons';
7 | import { RotateSpinner } from 'react-spinners-kit';
8 |
9 | import pigAnimation from '../../assets/piggy-bank.json';
10 | import { Creators as UsersTypes } from '../../store/ducks/users';
11 |
12 | import {
13 | Container,
14 | FormContainer,
15 | FormInputs,
16 | AnimationContainer,
17 | Animation,
18 | Title,
19 | Input,
20 | LoginButton,
21 | IconsContainer,
22 | } from './styles';
23 | import { UserInterface } from '../../interfaces/user';
24 | import { StoreInterface } from '../../interfaces/store';
25 |
26 | const defaultOptions: Options = {
27 | loop: true,
28 | autoplay: true,
29 | animationData: pigAnimation,
30 | rendererSettings: {
31 | preserveAspectRatio: 'xMidYMid slice',
32 | },
33 | };
34 |
35 | const ResetPassword: React.FC = ({ history }) => {
36 | const [email, setEmail] = useState('');
37 | const [password, setPassword] = useState('');
38 | const [confirmPassword, setConfirmPassword] = useState('');
39 | const { token: tempToken } = useParams();
40 |
41 | const dispatch = useDispatch();
42 | const { data: userState, loading } = useSelector((state: StoreInterface) => state.users);
43 |
44 | useEffect(() => {
45 | if (userState && userState.token) {
46 | history.push('/dashboard/overview');
47 | }
48 | }, [userState]);
49 |
50 | const handleFormSubit = (e: FormEvent) => {
51 | e.preventDefault();
52 |
53 | const user: UserInterface = {
54 | email,
55 | password,
56 | tempToken,
57 | };
58 |
59 | dispatch(UsersTypes.resetPasswordRequest(user));
60 | };
61 |
62 | const handleEmailChange = (e: FormEvent) => {
63 | setEmail(e.currentTarget.value.trim());
64 | };
65 |
66 | const handlePasswordChange = (e: FormEvent) => {
67 | setPassword(e.currentTarget.value.trim());
68 | };
69 |
70 | const handleConfirmPasswordChange = (e: FormEvent) => {
71 | setConfirmPassword(e.currentTarget.value.trim());
72 | };
73 |
74 | return (
75 |
76 |
77 |
78 |
81 |
82 |
83 |
84 | Reset password
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 | { loading ? : 'Reset now' }
106 |
107 |
108 |
109 |
110 | );
111 | };
112 |
113 | export default ResetPassword;
114 |
--------------------------------------------------------------------------------
/app/src/pages/ResetPassword/styles.ts:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from 'styled-components';
2 |
3 | import { globalVariables, device } from '../../styles/variables';
4 |
5 | export const Container = styled.div`
6 | display: flex;
7 | height: 100%;
8 |
9 | @media ${device.mobileL} {
10 | flex-direction: column;
11 | align-items: center;
12 | justify-content: center;
13 | }
14 | `;
15 |
16 | export const FormContainer = styled.div`
17 | background: ${globalVariables.white};
18 | display: flex;
19 | flex-direction: column;
20 | align-items: center;
21 | justify-content: center;
22 | flex: 1;
23 |
24 | @media ${device.mobileL} {
25 | justify-content: unset;
26 | }
27 | `;
28 |
29 | export const AnimationContainer = styled.div`
30 | background: ${globalVariables.mainBlue};
31 | flex: 1;
32 | display: flex;
33 | justify-content: center;
34 | align-items: center;
35 | overflow-y: hidden;
36 |
37 | @media ${device.mobileL} {
38 | justify-content: center;
39 | align-items: flex-end;
40 | background: ${globalVariables.white};
41 | }
42 |
43 | @media ${device.mobileL} {
44 | margin-bottom: 20px;
45 | }
46 | `;
47 |
48 | const UpAndDownAnimation = keyframes`
49 | from {
50 | top: -10px;
51 | }
52 | to {
53 | top: 10px;
54 | }
55 | `;
56 |
57 | export const Animation = styled.div`
58 | position: relative;
59 | animation-duration: 2s;
60 | animation-iteration-count: infinite;
61 | animation-direction: alternate;
62 | animation-name: ${UpAndDownAnimation};
63 | height: auto;
64 | width: 55%;
65 |
66 | @media ${device.tabletG} {
67 | width: 65%;
68 | }
69 | `;
70 |
71 | export const Title = styled.h3`
72 | color: ${globalVariables.mainBlue};
73 | font-size: 34px;
74 | font-weight: 100;
75 | margin-bottom: 15px;
76 |
77 | @media ${device.tablet} {
78 | font-size: 30px;
79 | }
80 |
81 | @media ${device.mobileL} {
82 | display: none;
83 | }
84 | `;
85 |
86 | export const Input = styled.input`
87 | margin: 5px;
88 | padding: 15px 15px 15px 40px;
89 | width: 300px;
90 | border: 1px solid ${globalVariables.mainBlue};
91 | border-radius: 5px;
92 | font-size: 14px;
93 |
94 | &::-webkit-input-placeholder {
95 | color: ${globalVariables.mainBlue};
96 | font-size: 14px;
97 | }
98 |
99 | @media ${device.tablet} {
100 | font-size: 12px;
101 | width: 280px;
102 | padding: 14px 14px 10px 40px;
103 |
104 | &::-webkit-input-placeholder {
105 | font-size: 12px;
106 | }
107 | }
108 | `;
109 |
110 | const sharedButtonStyle = styled.button`
111 | font-size: 14px;
112 | text-transform: uppercase;
113 | display: inline-block;
114 | max-width: 300px;
115 | margin: 5px;
116 |
117 | @media ${device.tablet} {
118 | max-width: 280px;
119 | font-size: 12px;
120 | padding: 5px 0;
121 | height: 40px;
122 | }
123 | `;
124 |
125 | export const LoginButton = styled(sharedButtonStyle)`
126 | background: ${globalVariables.mainBlue};
127 | color: ${globalVariables.white};
128 | padding: 12px 15px;
129 | border-radius: 5px;
130 | transition: all 0.3s;
131 | display: flex;
132 | justify-content: center;
133 | height: 45px;
134 |
135 | &:hover {
136 | background: ${globalVariables.mainBlueHover};
137 | }
138 | `;
139 |
140 | export const FormInputs = styled.form`
141 | display: flex;
142 | flex-direction: column;
143 |
144 | @media ${device.tablet} {
145 | width: 290px;
146 | }
147 | `;
148 |
149 | export const IconsContainer = styled.div`
150 | position: absolute;
151 | margin: 20px;
152 | color: ${globalVariables.mainBlue};
153 |
154 | @media ${device.tablet} {
155 | margin: 16.5px;
156 | }
157 | `;
158 |
159 | AnimationContainer.displayName = 'AnimationContainer';
160 | LoginButton.displayName = 'LoginButton';
161 | Input.displayName = 'Input';
162 |
--------------------------------------------------------------------------------
/app/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/app/src/routes/Routes.tsx:
--------------------------------------------------------------------------------
1 | import React, { lazy, Suspense } from 'react';
2 | import { BrowserRouter, Switch } from 'react-router-dom';
3 |
4 | import { Private, Public } from '../components/Access';
5 | import PageLoading from '../components/PageLoading';
6 |
7 | const Login = lazy(() => import('../pages/Login'));
8 | const Dashboard = lazy(() => import('../pages/Dashboard'));
9 | const ResetPassword = lazy(() => import('../pages/ResetPassword'));
10 |
11 | const Routes: React.FC = () => (
12 |
13 |
14 | }>
15 |
16 |
17 |
18 |
19 |
20 |
21 | );
22 |
23 | export default Routes;
24 |
--------------------------------------------------------------------------------
/app/src/serviceWorker.ts:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.0/8 are considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | type Config = {
24 | onSuccess?: (registration: ServiceWorkerRegistration) => void;
25 | onUpdate?: (registration: ServiceWorkerRegistration) => void;
26 | };
27 |
28 | export function register(config?: Config) {
29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
30 | // The URL constructor is available in all browsers that support SW.
31 | const publicUrl = new URL(
32 | process.env.PUBLIC_URL,
33 | window.location.href
34 | );
35 | if (publicUrl.origin !== window.location.origin) {
36 | // Our service worker won't work if PUBLIC_URL is on a different origin
37 | // from what our page is served on. This might happen if a CDN is used to
38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
39 | return;
40 | }
41 |
42 | window.addEventListener('load', () => {
43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
44 |
45 | if (isLocalhost) {
46 | // This is running on localhost. Let's check if a service worker still exists or not.
47 | checkValidServiceWorker(swUrl, config);
48 |
49 | // Add some additional logging to localhost, pointing developers to the
50 | // service worker/PWA documentation.
51 | navigator.serviceWorker.ready.then(() => {
52 | console.log(
53 | 'This web app is being served cache-first by a service ' +
54 | 'worker. To learn more, visit https://bit.ly/CRA-PWA'
55 | );
56 | });
57 | } else {
58 | // Is not localhost. Just register service worker
59 | registerValidSW(swUrl, config);
60 | }
61 | });
62 | }
63 | }
64 |
65 | function registerValidSW(swUrl: string, config?: Config) {
66 | navigator.serviceWorker
67 | .register(swUrl)
68 | .then(registration => {
69 | registration.onupdatefound = () => {
70 | const installingWorker = registration.installing;
71 | if (installingWorker == null) {
72 | return;
73 | }
74 | installingWorker.onstatechange = () => {
75 | if (installingWorker.state === 'installed') {
76 | if (navigator.serviceWorker.controller) {
77 | // At this point, the updated precached content has been fetched,
78 | // but the previous service worker will still serve the older
79 | // content until all client tabs are closed.
80 | console.log(
81 | 'New content is available and will be used when all ' +
82 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
83 | );
84 |
85 | // Execute callback
86 | if (config && config.onUpdate) {
87 | config.onUpdate(registration);
88 | }
89 | } else {
90 | // At this point, everything has been precached.
91 | // It's the perfect time to display a
92 | // "Content is cached for offline use." message.
93 | console.log('Content is cached for offline use.');
94 |
95 | // Execute callback
96 | if (config && config.onSuccess) {
97 | config.onSuccess(registration);
98 | }
99 | }
100 | }
101 | };
102 | };
103 | })
104 | .catch(error => {
105 | console.error('Error during service worker registration:', error);
106 | });
107 | }
108 |
109 | function checkValidServiceWorker(swUrl: string, config?: Config) {
110 | // Check if the service worker can be found. If it can't reload the page.
111 | fetch(swUrl, {
112 | headers: { 'Service-Worker': 'script' }
113 | })
114 | .then(response => {
115 | // Ensure service worker exists, and that we really are getting a JS file.
116 | const contentType = response.headers.get('content-type');
117 | if (
118 | response.status === 404 ||
119 | (contentType != null && contentType.indexOf('javascript') === -1)
120 | ) {
121 | // No service worker found. Probably a different app. Reload the page.
122 | navigator.serviceWorker.ready.then(registration => {
123 | registration.unregister().then(() => {
124 | window.location.reload();
125 | });
126 | });
127 | } else {
128 | // Service worker found. Proceed as normal.
129 | registerValidSW(swUrl, config);
130 | }
131 | })
132 | .catch(() => {
133 | console.log(
134 | 'No internet connection found. App is running in offline mode.'
135 | );
136 | });
137 | }
138 |
139 | export function unregister() {
140 | if ('serviceWorker' in navigator) {
141 | navigator.serviceWorker.ready
142 | .then(registration => {
143 | registration.unregister();
144 | })
145 | .catch(error => {
146 | console.error(error.message);
147 | });
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/app/src/services/api.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | import store from '../store';
4 | import { UserStateInterface } from '../interfaces/user';
5 |
6 | const api = axios.create({
7 | baseURL: process.env.REACT_APP_API_URL,
8 | });
9 |
10 | api.interceptors.request.use((config) => {
11 | const { data } = (store.getState().users as UserStateInterface);
12 | const headers = { ...config.headers };
13 |
14 | if (data) {
15 | headers.Authorization = `Bearer ${data.token}`;
16 | headers.UserId = data.id;
17 | }
18 |
19 | return { ...config, headers };
20 | });
21 |
22 | export default api;
23 |
--------------------------------------------------------------------------------
/app/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | import 'jest-canvas-mock';
3 |
4 | import Enzyme from 'enzyme';
5 | import Adapter from 'enzyme-adapter-react-16';
6 |
7 | Enzyme.configure({ adapter: new Adapter() });
8 |
9 | interface StoreInterface {
10 | [key: string]: string
11 | }
12 |
13 | // Local storage mock
14 | const localStorageMock = (() => {
15 | let store: StoreInterface = {};
16 |
17 | return {
18 | getItem: (key: string) => store[key] || null,
19 | setItem: (key: string, value: string) => {
20 | store[key] = value.toString();
21 | },
22 | clear: () => {
23 | store = {};
24 | },
25 | };
26 | })();
27 |
28 | Object.defineProperty(window, 'localStorage', {
29 | value: localStorageMock,
30 | });
31 |
--------------------------------------------------------------------------------
/app/src/store/ducks/index.ts:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 |
3 | import { reducer as toastr } from 'react-redux-toastr';
4 | import users from './users';
5 | import transactions from './transactions';
6 |
7 | const reducers = combineReducers({
8 | toastr,
9 | users,
10 | transactions,
11 | });
12 |
13 | export default reducers;
14 |
--------------------------------------------------------------------------------
/app/src/store/ducks/users.ts:
--------------------------------------------------------------------------------
1 | import {
2 | UserStateInterface,
3 | UserInterface,
4 | UserActionInterface,
5 | } from '../../interfaces/user';
6 |
7 | export const Types = {
8 | REGISTER_REQUEST: '@users/REGISTER_REQUEST',
9 | LOGIN_REQUEST: '@users/LOGIN_REQUEST',
10 | LOGIN_SUCCESS: '@users/LOGIN_SUCCESS',
11 | LOGIN_ERROR: '@users/LOGIN_ERROR',
12 | LOGOUT_REQUEST: '@users/LOGOUT_REQUEST',
13 | LOGOUT_SUCCESS: '@users/LOGOUT_SUCCESS',
14 | RESET_PASSWORD_REQUEST: '@users/RESET_PASSWORD_REQUEST',
15 | };
16 |
17 | const userDataParser = (): UserInterface | null => {
18 | const userData = localStorage.getItem('@bc:user');
19 |
20 | return userData ? (JSON.parse(userData) as UserInterface) : null;
21 | };
22 |
23 | const INITIAL_STATE: UserStateInterface = {
24 | data: userDataParser(),
25 | error: null,
26 | loading: false,
27 | };
28 |
29 | export default function Users(state = INITIAL_STATE, action: UserActionInterface) {
30 | switch (action.type) {
31 | case Types.LOGIN_REQUEST:
32 | case Types.REGISTER_REQUEST:
33 | case Types.RESET_PASSWORD_REQUEST:
34 | return { ...state, loading: true, error: null };
35 | case Types.LOGIN_SUCCESS:
36 | return {
37 | ...state,
38 | loading: false,
39 | error: null,
40 | data: action.payload.user,
41 | };
42 | case Types.LOGIN_ERROR:
43 | return { ...state, loading: false, error: action.payload.error };
44 | case Types.LOGOUT_SUCCESS:
45 | return state.data ? { ...state, data: { ...state.data, token: null } } : state;
46 | default:
47 | return state;
48 | }
49 | }
50 |
51 | export const Creators = {
52 | loginRequest: (user: UserInterface) => ({
53 | type: Types.LOGIN_REQUEST,
54 | payload: { user },
55 | }),
56 | resetPasswordRequest: (user: UserInterface) => ({
57 | type: Types.RESET_PASSWORD_REQUEST,
58 | payload: { user },
59 | }),
60 | registerRequest: (user: UserInterface) => ({ type: Types.REGISTER_REQUEST, payload: { user } }),
61 | loginSuccess: (user: UserInterface) => ({ type: Types.LOGIN_SUCCESS, payload: { user } }),
62 | loginError: (error: string) => ({ type: Types.LOGIN_ERROR, payload: { error } }),
63 | logoutRequest: () => ({ type: Types.LOGOUT_REQUEST }),
64 | logoutSuccess: () => ({ type: Types.LOGOUT_SUCCESS }),
65 | };
66 |
--------------------------------------------------------------------------------
/app/src/store/index.ts:
--------------------------------------------------------------------------------
1 | import { createStore, compose, applyMiddleware } from 'redux';
2 | import createSagaMiddleware, { SagaMiddleware } from 'redux-saga';
3 |
4 | import reducers from './ducks';
5 | import sagas from './sagas';
6 |
7 | const middlewares: SagaMiddleware[] = [];
8 | const sagaMiddleware: SagaMiddleware = createSagaMiddleware();
9 |
10 | middlewares.push(sagaMiddleware);
11 |
12 | const composer = compose(applyMiddleware(...middlewares));
13 | const store = createStore(reducers, composer);
14 |
15 | sagaMiddleware.run(sagas);
16 |
17 | export default store;
18 |
--------------------------------------------------------------------------------
/app/src/store/sagas/index.ts:
--------------------------------------------------------------------------------
1 | import { all, takeLatest } from 'redux-saga/effects';
2 |
3 | import { Types as UsersTypes } from '../ducks/users';
4 | import { Types as TransactionsTypes } from '../ducks/transactions';
5 |
6 | import {
7 | login,
8 | register,
9 | logout,
10 | resetPasswordRequest,
11 | } from './users';
12 |
13 | import {
14 | loadAllByDate,
15 | addTransaction,
16 | deleteTransaction,
17 | updateTransaction,
18 | } from './transactions';
19 |
20 | export default function* rootSaga() {
21 | yield all([
22 | takeLatest(UsersTypes.LOGIN_REQUEST, login),
23 | takeLatest(UsersTypes.REGISTER_REQUEST, register),
24 | takeLatest(UsersTypes.LOGOUT_REQUEST, logout),
25 | takeLatest(UsersTypes.RESET_PASSWORD_REQUEST, resetPasswordRequest),
26 |
27 | takeLatest(TransactionsTypes.GET_TRANSACTIONS_REQUEST, loadAllByDate),
28 | takeLatest(TransactionsTypes.ADD_TRANSACTION_REQUEST, addTransaction),
29 | takeLatest(TransactionsTypes.DELETE_TRANSACTION_REQUEST, deleteTransaction),
30 | takeLatest(TransactionsTypes.UPDATE_TRANSACTION_REQUEST, updateTransaction),
31 | ]);
32 | }
33 |
--------------------------------------------------------------------------------
/app/src/store/sagas/transactions.ts:
--------------------------------------------------------------------------------
1 | import { call, put, select } from 'redux-saga/effects';
2 | import { actions as toastrActions } from 'react-redux-toastr';
3 | import api from '../../services/api';
4 |
5 | import { getSettings } from '../../utils/settings';
6 | import { dateOneMonthBeforeFormat, currentDateFormat, formatFromPtToEn } from '../../utils/date';
7 | import { TransactionsActionsInterface, TransactionInterface, TransactionType } from '../../interfaces/transaction';
8 |
9 | import { Creators as TransactionsActions } from '../ducks/transactions';
10 | import { StoreInterface } from '../../interfaces/store';
11 | import { DateFormatType } from '../../enums/settings';
12 |
13 | const CREDITS_PATH = '/credits';
14 | const CREDIT_PATH = '/credit';
15 | const DEBITS_PATH = '/debits';
16 | const DEBIT_PATH = '/debit';
17 |
18 | export function* loadAllByDate({ payload: { range, category } }: TransactionsActionsInterface) {
19 | try {
20 | const { dateFormat } = getSettings();
21 |
22 | let endDate = range ? range.endDate : currentDateFormat();
23 | let startDate = range ? range.startDate : dateOneMonthBeforeFormat();
24 | let transactions: TransactionInterface[] = [];
25 |
26 | if (dateFormat === DateFormatType.PT) {
27 | endDate = formatFromPtToEn(endDate);
28 | startDate = formatFromPtToEn(startDate);
29 | }
30 |
31 | if (category) {
32 | const path = category === TransactionType.CREDIT ? CREDITS_PATH : DEBITS_PATH;
33 | const { data } = yield call(api.get, path, { params: { startDate, endDate } });
34 |
35 | transactions = data.map((transaction: TransactionInterface) => ({ ...transaction, category }));
36 | } else {
37 | const { data: debitsData } = yield call(api.get, DEBITS_PATH, { params: { startDate, endDate } });
38 | const { data: creditsData } = yield call(api.get, CREDITS_PATH, { params: { startDate, endDate } });
39 |
40 | const debits: TransactionInterface[] = debitsData.map((transaction: TransactionInterface) => (
41 | { ...transaction, category: TransactionType.DEBIT }
42 | ));
43 | const credits: TransactionInterface[] = creditsData.map((transaction: TransactionInterface) => (
44 | { ...transaction, category: TransactionType.CREDIT }
45 | ));
46 |
47 | transactions = [...debits, ...credits];
48 | }
49 |
50 | const transactionsSorted = transactions
51 | .slice()
52 | .sort((a: TransactionInterface, b: TransactionInterface) => (
53 | +new Date(b.date) - +new Date(a.date)));
54 |
55 | yield put(TransactionsActions.getTransactionsSuccess(transactionsSorted));
56 | } catch (err) {
57 | yield put(TransactionsActions.transactionsError('Request error'));
58 | yield put(toastrActions.add({
59 | type: 'error',
60 | title: 'Error on retrieve transactions',
61 | message: err.response.data.message,
62 | options: {
63 | timeOut: 4000,
64 | },
65 | }));
66 | }
67 | }
68 |
69 | export function* addTransaction({ payload: { transaction } }: TransactionsActionsInterface) {
70 | try {
71 | if (transaction) {
72 | const addData = transaction;
73 | const user = yield select((state: StoreInterface) => state.users.data);
74 | const path = addData.category === TransactionType.CREDIT ? CREDIT_PATH : DEBIT_PATH;
75 |
76 | addData.user_id = user.id;
77 |
78 | const { data } = yield call(api.post, path, addData);
79 |
80 | data.category = transaction.category;
81 |
82 | yield put(TransactionsActions.addTransactionSuccess(data));
83 | }
84 | } catch (err) {
85 | yield put(TransactionsActions.transactionsError('Request error'));
86 | yield put(toastrActions.add({
87 | type: 'error',
88 | title: 'Error on add new transaction',
89 | message: err.response.data.message,
90 | options: {
91 | timeOut: 4000,
92 | },
93 | }));
94 | }
95 | }
96 |
97 | export function* deleteTransaction({ payload: { transaction } }: TransactionsActionsInterface) {
98 | try {
99 | if (transaction) {
100 | const path = transaction.category === TransactionType.CREDIT ? CREDIT_PATH : DEBIT_PATH;
101 | const { data } = yield call(api.delete, `${path}/${transaction.id}`);
102 |
103 | yield put(TransactionsActions.deleteTransactionSuccess(data));
104 | }
105 | } catch (err) {
106 | yield put(TransactionsActions.transactionsError('Delete transaction error'));
107 | yield put(toastrActions.add({
108 | type: 'error',
109 | title: 'Error on delete transaction',
110 | message: err.response.data.message,
111 | options: {
112 | timeOut: 4000,
113 | },
114 | }));
115 | }
116 | }
117 |
118 | export function* updateTransaction({ payload: { transaction } }: TransactionsActionsInterface) {
119 | try {
120 | if (transaction) {
121 | const path = transaction.category === TransactionType.CREDIT ? CREDIT_PATH : DEBIT_PATH;
122 | const { data } = yield call(api.put, path, transaction);
123 |
124 | data.category = transaction.category;
125 |
126 | yield put(TransactionsActions.updateTransactionSuccess(data));
127 | }
128 | } catch (err) {
129 | yield put(TransactionsActions.transactionsError('Update transaction error'));
130 | yield put(toastrActions.add({
131 | type: 'error',
132 | title: 'Error on update transaction',
133 | message: err.response.data.message,
134 | options: {
135 | timeOut: 4000,
136 | },
137 | }));
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/app/src/store/sagas/users.ts:
--------------------------------------------------------------------------------
1 | import { call, put } from 'redux-saga/effects';
2 | import { actions as toastrActions } from 'react-redux-toastr';
3 | import api from '../../services/api';
4 |
5 | import { UserActionInterface, UserInterface } from '../../interfaces/user';
6 |
7 | import { Creators as UsersActions } from '../ducks/users';
8 |
9 | export function* login({ payload }: UserActionInterface) {
10 | try {
11 | const { email, password } = payload.user;
12 |
13 | const { data } = yield call(api.post, '/sessions', { email, password });
14 |
15 | const user: UserInterface = {
16 | name: data.user.name,
17 | email: data.user.email,
18 | id: data.user.id,
19 | token: data.token,
20 | };
21 |
22 | localStorage.setItem('@bc:user', JSON.stringify(user));
23 |
24 | yield put(UsersActions.loginSuccess(user));
25 | } catch (err) {
26 | yield put(UsersActions.loginError('Invalid credentials'));
27 | yield put(toastrActions.add({
28 | type: 'error',
29 | title: 'Sign in failed',
30 | message: err.response.data.message,
31 | options: {
32 | timeOut: 4000,
33 | },
34 | }));
35 | }
36 | }
37 |
38 | export function* register({ payload }: UserActionInterface) {
39 | try {
40 | const { email, password, name } = payload.user;
41 |
42 | const { data } = yield call(api.post, '/user', { email, password, name });
43 |
44 | const user: UserInterface = {
45 | name: data.user.name,
46 | email: data.user.email,
47 | id: data.user.id,
48 | token: data.token,
49 | };
50 |
51 | localStorage.setItem('@bc:user', JSON.stringify(user));
52 |
53 | yield put(UsersActions.loginSuccess(user));
54 | } catch (err) {
55 | yield put(UsersActions.loginError('Error on register'));
56 | yield put(toastrActions.add({
57 | type: 'error',
58 | title: 'Register failed',
59 | message: err.response.data.message,
60 | options: {
61 | timeOut: 4000,
62 | },
63 | }));
64 | }
65 | }
66 |
67 | export function* logout() {
68 | try {
69 | localStorage.removeItem('@bc:user');
70 |
71 | yield put(UsersActions.logoutSuccess());
72 | } catch (err) {
73 | yield put(toastrActions.add({
74 | type: 'error',
75 | title: 'Error on logout',
76 | message: 'Something is wrong with logout, try again in few minutes!',
77 | options: {
78 | timeOut: 4000,
79 | },
80 | }));
81 | }
82 | }
83 |
84 | export function* resetPasswordRequest({ payload }: UserActionInterface) {
85 | try {
86 | const { email, password, tempToken } = payload.user;
87 |
88 | const { data } = yield call(api.post, '/resetSuccess', { email, password, tempToken });
89 |
90 | const user: UserInterface = {
91 | name: data.user.name,
92 | email: data.user.email,
93 | id: data.user.id,
94 | token: data.token,
95 | };
96 |
97 | localStorage.setItem('@bc:user', JSON.stringify(user));
98 |
99 | yield put(UsersActions.loginSuccess(user));
100 | } catch (err) {
101 | yield put(UsersActions.loginError('Error on reset password'));
102 | yield put(toastrActions.add({
103 | type: 'error',
104 | title: 'Password reset failed',
105 | message: err.response.data.message,
106 | options: {
107 | timeOut: 4000,
108 | },
109 | }));
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/app/src/styles/global.ts:
--------------------------------------------------------------------------------
1 | import { createGlobalStyle } from 'styled-components';
2 |
3 | import { device } from './variables';
4 | import 'react-redux-toastr/lib/css/react-redux-toastr.min.css';
5 |
6 | export default createGlobalStyle`
7 | * {
8 | padding: 0;
9 | margin: 0;
10 | outline: 0;
11 | box-sizing: border-box;
12 | overflow: hidden;
13 |
14 | @media ${device.laptopM}, ${device.heightMobileLM} {
15 | overflow-y: visible;
16 | }
17 | }
18 |
19 | body {
20 | color: #000;
21 | /* font-size: 62.5%; */
22 | font-family: Arial, Helvetica, sans-serif;
23 | text-rendering: optimizeLegibility !important;
24 | -webkit-font-smoothing: antialiased !important;
25 | }
26 |
27 | html, body, #root {
28 | height: 100%;
29 | }
30 |
31 | input, button {
32 | font-family: Arial, Helvetica, sans-serif;
33 | }
34 |
35 | button {
36 | cursor: pointer;
37 | }
38 | `;
39 |
--------------------------------------------------------------------------------
/app/src/styles/variables.ts:
--------------------------------------------------------------------------------
1 | export const globalVariables = {
2 | white: '#FFF',
3 | mainBlue: '#383f53',
4 | mainBlueHover: '#2e3445',
5 | mainBlueLigth: '#8d94a8',
6 | mainBlueLigthHover: '#747a8a',
7 | mainGreen: '#67b1bd',
8 | mainGreenHover: '#5797a1',
9 |
10 | ligthGrey: '#eff1f9',
11 | mediumGrey: '#828ba3',
12 | darkGrey: '#707e92',
13 | darkGreyHover: '#5a6575',
14 | fontGrayColor: '#636773',
15 | fontBlackColor: '#171717',
16 |
17 | ligthBlue: '#4aa7ee',
18 | ligthBlueHover: '#4398d9',
19 |
20 | mainPink: '#d43763',
21 | mainPinkHover: '#b02c51',
22 | ligthPink: '#a669a2',
23 | ligthPinkHover: '#82527f',
24 |
25 | navbarIcon: '#979ebb',
26 | };
27 |
28 | const size = {
29 | mobileL: '445px',
30 | mobileLM: '499px',
31 | mobileLL: '600px',
32 | mobileG: '662px',
33 | tabletP: '688px',
34 | tablet: '768px',
35 | tabletG: '810px',
36 | laptop: '1024px',
37 | laptopM: '1300px',
38 | laptopL: '1440px',
39 | laptopG: '1500px',
40 | desktop: '2560px',
41 | };
42 |
43 | export const device = {
44 | mobileL: `(max-width: ${size.mobileL})`,
45 | mobileLM: `(max-width: ${size.mobileLM})`,
46 | mobileLL: `(max-width: ${size.mobileLL})`,
47 | mobileG: `(max-width: ${size.mobileG})`,
48 | tablet: `(max-width: ${size.tablet})`,
49 | tabletP: `(max-width: ${size.tabletP})`,
50 | tabletG: `(max-width: ${size.tabletG})`,
51 | laptop: `(max-width: ${size.laptop})`,
52 | laptopM: `(max-width: ${size.laptopM})`,
53 | laptopL: `(max-width: ${size.laptopL})`,
54 | minlaptopG: `(min-width: ${size.laptopG})`,
55 | desktop: `(max-width: ${size.desktop})`,
56 | desktopL: `(max-width: ${size.desktop})`,
57 | heightMobileL: `(max-height: ${size.mobileL})`,
58 | heightMobileLM: `(max-height: ${size.mobileLM})`,
59 | };
60 |
--------------------------------------------------------------------------------
/app/src/utils/currency.ts:
--------------------------------------------------------------------------------
1 | export const formatCurrencyWithType = (currency: number): string => (
2 | Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(currency));
3 |
4 | export const formatCurrency = (currency: number): string => {
5 | const currencyFixed = formatCurrencyWithType(currency);
6 |
7 | return currencyFixed.replace('R$', '');
8 | };
9 |
10 | export const getJustDigitsFromString = (valueStr: string): number => {
11 | const auxValue = valueStr.match(/\d+/g);
12 |
13 | return auxValue ? parseInt(auxValue.join(''), 10) : 0;
14 | };
15 |
16 | export const formatCurrencyForInputs = (valueStr: string): string => {
17 | const justDigits = getJustDigitsFromString(valueStr);
18 |
19 | if (!justDigits) {
20 | return '';
21 | }
22 |
23 | const value = justDigits.toString();
24 |
25 | if (value.length === 1) {
26 | return `0,0${value}`;
27 | }
28 |
29 | if (value.length === 2) {
30 | return `0,${value}`;
31 | }
32 |
33 | if (value.length === 3) {
34 | return `${value.substr(0, 1)},${value.substr(1, 3)}`;
35 | }
36 |
37 | if (value.length <= 5) {
38 | const decimals = value.substr(value.length - 2, value.length);
39 | const rest = value.substr(0, value.length - 2);
40 |
41 | return `${rest},${decimals}`;
42 | }
43 |
44 | const decimals = value.substr(value.length - 2, value.length);
45 | const rest = value.substr(0, value.length - 2).split('').reverse().join('');
46 | const arrSufix = rest.match(/.{1,3}/g);
47 | const arrOrder = arrSufix ? arrSufix.map(item => item.split('').reverse().join('')) : arrSufix;
48 |
49 | const sufix = arrOrder ? arrOrder.reverse().join('.') : '';
50 |
51 | return `${sufix},${decimals}`;
52 | };
53 |
54 | export const getMoneyPersistFormat = (value: string) => value.replace('.', '').replace(',', '.');
55 |
--------------------------------------------------------------------------------
/app/src/utils/date.ts:
--------------------------------------------------------------------------------
1 | import { getSettings } from './settings';
2 |
3 | const MONTH = 30;
4 | const THREE_MONTHS = 90;
5 |
6 | // In some cases isn't possible use .toISOString because it takes the default timezone, creating
7 | // some issues with users from other timezones.
8 | const formatFromLocaleFormatToDateInput = (dateStr: string): string => {
9 | const dateArr = dateStr.split('/');
10 |
11 | return `${dateArr[2]}-${dateArr[1]}-${dateArr[0]}`;
12 | };
13 |
14 | export const getLanguageState = () => {
15 | const { dateFormat } = getSettings();
16 |
17 | return dateFormat;
18 | };
19 |
20 | export const toLocaleDateString = (date: Date) => date.toLocaleDateString(getLanguageState());
21 |
22 | export const currentDateInputFormat = (date?: Date) => {
23 | let dateFormat = date;
24 |
25 | if (!dateFormat) {
26 | dateFormat = new Date();
27 | }
28 |
29 | const localeStringDateFormat = dateFormat.toLocaleDateString(getLanguageState());
30 |
31 | return formatFromLocaleFormatToDateInput(localeStringDateFormat);
32 | };
33 |
34 | export const currentDateFormat = () => {
35 | const date = new Date().toLocaleDateString(getLanguageState());
36 |
37 | return date;
38 | };
39 |
40 | export const dateOneMonthBefore = (): Date => {
41 | const date = new Date();
42 |
43 | date.setDate(date.getDate() - MONTH);
44 |
45 | return date;
46 | };
47 |
48 | export const dateThreeMonthBefore = (): Date => {
49 | const date = new Date();
50 |
51 | date.setDate(date.getDate() - THREE_MONTHS);
52 |
53 | return date;
54 | };
55 |
56 | export const dateOneMonthBeforeFormat = (): string => {
57 | const date = new Date();
58 |
59 | date.setDate(date.getDate() - MONTH);
60 |
61 | return date.toLocaleDateString(getLanguageState());
62 | };
63 |
64 | export const getMonthDescriptionByMonth = (month: string) => {
65 | const months = [
66 | 'January',
67 | 'February',
68 | 'March',
69 | 'April',
70 | 'May',
71 | 'June',
72 | 'July',
73 | 'August',
74 | 'September',
75 | 'October',
76 | 'November',
77 | 'December',
78 | ];
79 |
80 | return months[parseInt(month, 10)];
81 | };
82 |
83 | export const toBarFormat = (date: string) => {
84 | const arr = date.split('-');
85 |
86 | return `${arr[1]}/${arr[2]}/${arr[0]}`;
87 | };
88 |
89 | export const formatFromPtToEn = (dateStr: string): string => {
90 | const dateArr = dateStr.split('/');
91 | const regexRemoveInitialZeros = /^(0+)/g;
92 |
93 | const day = dateArr[0].replace(regexRemoveInitialZeros, '');
94 | const month = dateArr[1].replace(regexRemoveInitialZeros, '');
95 | const year = dateArr[2];
96 |
97 | return `${month}/${day}/${year}`;
98 | };
99 |
--------------------------------------------------------------------------------
/app/src/utils/format.ts:
--------------------------------------------------------------------------------
1 | import { toLocaleDateString } from './date';
2 | import { KeyValueNumberInterface, KeyValueStringInterface } from '../interfaces/charts';
3 |
4 | export const formatToChartDateObject = (transactions: KeyValueNumberInterface) => (
5 | Object.entries(transactions).map((itemArr) => {
6 | const date = toLocaleDateString(new Date(itemArr[0]));
7 |
8 | return {
9 | y: itemArr[1],
10 | name: date,
11 | };
12 | }));
13 |
14 | export const formatToChartNumberObject = (transactions: KeyValueNumberInterface) => (
15 | Object.entries(transactions).map(itemArr => ({
16 | y: itemArr[1],
17 | name: itemArr[0],
18 | })));
19 |
20 | export const formatToChartStringObject = (transactions: KeyValueStringInterface) => (
21 | Object.entries(transactions).map(itemArr => ({
22 | y: itemArr[1] ? +itemArr[1] : 0,
23 | name: itemArr[0],
24 | })));
25 |
26 | export const capitalize = (str: string) => {
27 | const firstLetter = str.toLowerCase().charAt(0).toUpperCase();
28 | const strCapitalized = str.toLowerCase().slice(1);
29 |
30 | return firstLetter.concat(strCapitalized);
31 | };
32 |
--------------------------------------------------------------------------------
/app/src/utils/settings.ts:
--------------------------------------------------------------------------------
1 | import { SettingInterface } from '../interfaces/settings';
2 | import { CurrencyType, DateFormatType } from '../enums/settings';
3 |
4 | export const saveSettings = (settings: SettingInterface): void => {
5 | localStorage.setItem('@bc:settings', JSON.stringify(settings));
6 | };
7 |
8 | export const getSettings = (): SettingInterface => {
9 | const settings = localStorage.getItem('@bc:settings');
10 | const defaultSettings: SettingInterface = {
11 | currency: CurrencyType.DOLAR,
12 | dateFormat: DateFormatType.EN,
13 | };
14 |
15 | return settings ? (JSON.parse(settings) as SettingInterface) : defaultSettings;
16 | };
17 |
--------------------------------------------------------------------------------
/app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "types": [
5 | "node",
6 | ],
7 | "typeRoots": [
8 | "./node_modules/@types"
9 | ],
10 | "lib": [
11 | "dom",
12 | "dom.iterable",
13 | "esnext"
14 | ],
15 | "allowJs": true,
16 | "skipLibCheck": true,
17 | "esModuleInterop": true,
18 | "allowSyntheticDefaultImports": true,
19 | "strict": true,
20 | "forceConsistentCasingInFileNames": true,
21 | "module": "esnext",
22 | "noImplicitAny": false,
23 | "moduleResolution": "node",
24 | "resolveJsonModule": true,
25 | "isolatedModules": true,
26 | "noEmit": true,
27 | "jsx": "preserve"
28 | },
29 | "include": [
30 | "src/*"
31 | ],
32 | "exclude": [
33 | "src/__tests__/*"
34 | ],
35 | }
36 |
--------------------------------------------------------------------------------
/app/tsconfig.production.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "skipLibCheck": true,
5 | "jsx": "react"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------