├── .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 | [![Build Status](https://travis-ci.org/gabriel-hahn/billing-cycle-reactjs.svg?branch=master)](https://travis-ci.org/gabriel-hahn/billing-cycle-reactjs) [![Codacy Badge](https://api.codacy.com/project/badge/Grade/e0d561f0d2a5434590bba42c161261f8)](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) [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/gabriel-hahn/billing-cycle-reactjs/pulls) [![Bugs](https://img.shields.io/github/issues/gabriel-hahn/billing-cycle-reactjs/bug.svg)](https://github.com/gabriel-hahn/billing-cycle-reactjs/issues?utf8=?&q=is%3Aissue+is%3Aopen+label%3Abug) [![The MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](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](https://placehold.it/15/eff1f9/000000?text=+) `#eff1f9` 20 | - ![#383f53](https://placehold.it/15/383f53/000000?text=+) `#383f53` 21 | - ![#67b1bd](https://placehold.it/15/67b1bd/000000?text=+) `#67b1bd` 22 | - ![#d43763](https://placehold.it/15/d43763/000000?text=+) `#d43763` 23 | - ![#4aa7ee](https://placehold.it/15/4aa7ee/000000?text=+) `#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 | --------------------------------------------------------------------------------