├── public
├── _redirects
├── robots.txt
├── favicon.ico
├── img
│ ├── Arch.png
│ ├── screens
│ │ ├── Arch.png
│ │ ├── home.png
│ │ ├── cover.png
│ │ ├── splash.png
│ │ ├── types.png
│ │ ├── analytics.png
│ │ ├── onboarding.png
│ │ ├── home_success.png
│ │ └── types_details.png
│ └── spreadsheetSS.png
├── logo192.png
├── logo512.png
├── manifest.json
└── index.html
├── src
├── context
│ ├── types
│ │ ├── Global.js
│ │ ├── Modal.js
│ │ ├── Search.js
│ │ ├── index.js
│ │ └── User.js
│ ├── reducers
│ │ ├── Search.js
│ │ ├── Modal.js
│ │ └── User.js
│ └── index.js
├── services
│ ├── index.js
│ ├── auth.js
│ ├── configSpreadsheet.js
│ └── headerData.js
├── rsc
│ ├── img
│ │ ├── emptybox.png
│ │ ├── iosInstall.png
│ │ ├── androidInstall.png
│ │ ├── loginScreenshot.png
│ │ ├── spreadsheetScreenshot.png
│ │ ├── bg.svg
│ │ └── logo.svg
│ └── icons
│ │ └── github.svg
├── lib
│ └── utils
│ │ ├── colors.js
│ │ ├── toast.js
│ │ ├── index.js
│ │ ├── common.js
│ │ ├── currency.js
│ │ ├── constants.js
│ │ └── date.js
├── layouts
│ ├── index.js
│ ├── NoHeader
│ │ ├── index.js
│ │ └── styles.js
│ ├── Header
│ │ ├── index.js
│ │ └── styles.js
│ └── Master
│ │ ├── styles.js
│ │ └── index.js
├── setupTests.js
├── config
│ ├── errors.js
│ ├── sheet.js
│ └── localStorage.js
├── utils
│ ├── math.js
│ └── login.js
├── screens
│ ├── Main
│ │ ├── styles.js
│ │ └── index.js
│ ├── index.js
│ ├── Type
│ │ ├── styles.js
│ │ └── index.js
│ ├── Data
│ │ ├── styles.js
│ │ ├── Chart.js
│ │ ├── index.js
│ │ └── Table.js
│ ├── Splash
│ │ ├── styles.js
│ │ └── index.js
│ ├── Onboarding
│ │ └── styles.js
│ ├── Config
│ │ ├── styles.js
│ │ ├── index.js
│ │ └── schedule.js
│ ├── Analytics
│ │ ├── styles.js
│ │ └── index.js
│ └── Guide
│ │ ├── styles.js
│ │ └── index.js
├── components
│ ├── Loading
│ │ ├── styles.js
│ │ └── index.js
│ ├── Icons
│ │ ├── Navigate.js
│ │ ├── Delete.js
│ │ ├── Close.js
│ │ ├── Menu.js
│ │ ├── Info.js
│ │ ├── Home.js
│ │ ├── MenuSearch.js
│ │ ├── Search.js
│ │ ├── index.js
│ │ ├── PieChart.js
│ │ ├── SignOut.js
│ │ ├── Calendar.js
│ │ ├── Dollar.js
│ │ ├── ArrowIndicator.js
│ │ ├── LineChart.js
│ │ └── Gear.js
│ ├── Dropdown
│ │ ├── index.js
│ │ └── styles.js
│ ├── Button
│ │ ├── index.js
│ │ └── styles.js
│ ├── Modal
│ │ ├── index.js
│ │ └── styles.js
│ ├── Tabs
│ │ ├── index.js
│ │ └── styles.js
│ ├── HeaderInfo
│ │ ├── styles.js
│ │ └── index.js
│ ├── BigModal
│ │ ├── index.js
│ │ └── styles.js
│ ├── Footer
│ │ ├── styles.js
│ │ └── index.js
│ ├── Checkbox
│ │ ├── index.js
│ │ └── styles.js
│ ├── BarChart
│ │ ├── styles.js
│ │ └── index.js
│ ├── MonthLegend
│ │ ├── index.js
│ │ └── styles.js
│ ├── Tooltip
│ │ ├── styles.js
│ │ └── index.js
│ ├── RadioButton
│ │ ├── index.js
│ │ └── styles.js
│ ├── Header
│ │ ├── styles.js
│ │ └── index.js
│ ├── LineChart
│ │ ├── styles.js
│ │ └── index.js
│ ├── HeaderBox
│ │ ├── index.js
│ │ └── styles.js
│ ├── ChartLegend
│ │ ├── styles.js
│ │ └── index.js
│ ├── index.js
│ ├── DetailItem
│ │ ├── styles.js
│ │ └── index.js
│ ├── Search
│ │ ├── styles.js
│ │ └── index.js
│ ├── Input
│ │ ├── index.js
│ │ └── styles.js
│ └── PieChart
│ │ └── index.js
├── App.js
├── index.js
├── index.css
├── router
│ └── index.js
└── serviceWorker.js
├── babel.config.json
├── jsconfig.json
├── .gitignore
├── package.json
└── README.md
/public/_redirects:
--------------------------------------------------------------------------------
1 | /* /index.html 200
2 |
--------------------------------------------------------------------------------
/src/context/types/Global.js:
--------------------------------------------------------------------------------
1 | export const RESET = "RESET";
2 |
--------------------------------------------------------------------------------
/src/services/index.js:
--------------------------------------------------------------------------------
1 | export * from "./spreadsheet";
2 | export * from "./auth";
3 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JoaquinBeceiro/bills-tracker/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/img/Arch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JoaquinBeceiro/bills-tracker/HEAD/public/img/Arch.png
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JoaquinBeceiro/bills-tracker/HEAD/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JoaquinBeceiro/bills-tracker/HEAD/public/logo512.png
--------------------------------------------------------------------------------
/src/rsc/img/emptybox.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JoaquinBeceiro/bills-tracker/HEAD/src/rsc/img/emptybox.png
--------------------------------------------------------------------------------
/public/img/screens/Arch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JoaquinBeceiro/bills-tracker/HEAD/public/img/screens/Arch.png
--------------------------------------------------------------------------------
/public/img/screens/home.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JoaquinBeceiro/bills-tracker/HEAD/public/img/screens/home.png
--------------------------------------------------------------------------------
/src/rsc/img/iosInstall.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JoaquinBeceiro/bills-tracker/HEAD/src/rsc/img/iosInstall.png
--------------------------------------------------------------------------------
/public/img/screens/cover.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JoaquinBeceiro/bills-tracker/HEAD/public/img/screens/cover.png
--------------------------------------------------------------------------------
/public/img/screens/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JoaquinBeceiro/bills-tracker/HEAD/public/img/screens/splash.png
--------------------------------------------------------------------------------
/public/img/screens/types.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JoaquinBeceiro/bills-tracker/HEAD/public/img/screens/types.png
--------------------------------------------------------------------------------
/public/img/spreadsheetSS.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JoaquinBeceiro/bills-tracker/HEAD/public/img/spreadsheetSS.png
--------------------------------------------------------------------------------
/babel.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["@babel/env"],
3 | "plugins": ["@babel/plugin-proposal-export-namespace-from"]
4 | }
5 |
--------------------------------------------------------------------------------
/public/img/screens/analytics.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JoaquinBeceiro/bills-tracker/HEAD/public/img/screens/analytics.png
--------------------------------------------------------------------------------
/src/rsc/img/androidInstall.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JoaquinBeceiro/bills-tracker/HEAD/src/rsc/img/androidInstall.png
--------------------------------------------------------------------------------
/src/rsc/img/loginScreenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JoaquinBeceiro/bills-tracker/HEAD/src/rsc/img/loginScreenshot.png
--------------------------------------------------------------------------------
/public/img/screens/onboarding.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JoaquinBeceiro/bills-tracker/HEAD/public/img/screens/onboarding.png
--------------------------------------------------------------------------------
/public/img/screens/home_success.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JoaquinBeceiro/bills-tracker/HEAD/public/img/screens/home_success.png
--------------------------------------------------------------------------------
/public/img/screens/types_details.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JoaquinBeceiro/bills-tracker/HEAD/public/img/screens/types_details.png
--------------------------------------------------------------------------------
/src/rsc/img/spreadsheetScreenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JoaquinBeceiro/bills-tracker/HEAD/src/rsc/img/spreadsheetScreenshot.png
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "jsx": "react",
4 | "baseUrl": "./src"
5 | },
6 | "include": ["./src"]
7 | }
8 |
--------------------------------------------------------------------------------
/src/context/types/Modal.js:
--------------------------------------------------------------------------------
1 | export const MODAL_SHOW = "MODAL_SHOW";
2 | export const MODAL_HIDE = "MODAL_HIDE";
3 | export const MODAL_RESET = "MODAL_RESET";
4 |
--------------------------------------------------------------------------------
/src/lib/utils/colors.js:
--------------------------------------------------------------------------------
1 | import { COLORS } from "./constants";
2 |
3 | export const addColors = (types) =>
4 | types.map((e, idx) => ({ ...e, color: COLORS[idx % COLORS.length] }));
5 |
--------------------------------------------------------------------------------
/src/layouts/index.js:
--------------------------------------------------------------------------------
1 | export { default as NoHeaderLayout } from "./NoHeader";
2 | export { default as MasterLayout } from "./Master";
3 | export { default as HeaderLayout } from "./Header";
4 |
--------------------------------------------------------------------------------
/src/context/types/Search.js:
--------------------------------------------------------------------------------
1 | export const SEARCH_SHOW = "SEARCH_SHOW";
2 | export const SEARCH_HIDE = "SEARCH_HIDE";
3 | export const SEARCH_RESET = "SEARCH_RESET";
4 | export const SEARCH_INPUT = "SEARCH_INPUT";
5 |
--------------------------------------------------------------------------------
/src/layouts/NoHeader/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Content } from "./styles";
3 |
4 | const Onboarding = ({ children }) => {
5 | return {children} ;
6 | };
7 |
8 | export default Onboarding;
9 |
--------------------------------------------------------------------------------
/src/context/types/index.js:
--------------------------------------------------------------------------------
1 | import * as User from "./User";
2 | import * as Global from "./Global";
3 | import * as Modal from "./Modal";
4 | import * as Search from "./Search";
5 |
6 | export default {
7 | User,
8 | Global,
9 | Modal,
10 | Search
11 | };
12 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom/extend-expect';
6 |
--------------------------------------------------------------------------------
/src/lib/utils/toast.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { toast } from "react-toastify";
3 |
4 | const success = (message) => toast.success(<>{message}>);
5 | const error = (message) => toast.error(<>{message}>);
6 | const dark = (message) => toast.dark(<>{message}>);
7 |
8 | export default { success, error, dark };
9 |
--------------------------------------------------------------------------------
/src/config/errors.js:
--------------------------------------------------------------------------------
1 | const authErrors = {
2 | idpiframe_initialization_failed:
3 | "There was an error trying to authenticate the origin with Google Srvices.",
4 | default: "There was an error trying to authenticate with Google Srvices.",
5 | };
6 |
7 | export const getAuthErrorMessage = (code) =>
8 | authErrors[code] || authErrors["default"];
9 |
--------------------------------------------------------------------------------
/src/utils/math.js:
--------------------------------------------------------------------------------
1 | import Mexp from "math-expression-evaluator";
2 |
3 | export const isMath = (string) => {
4 | return /\d+(\+|-|\*|\/)\d+/.test(string)
5 | }
6 |
7 | export const evaluateMath = (string) => {
8 | const mexp = new Mexp();
9 | return mexp.eval(string)
10 | }
11 | export const isNumber = (string) => {
12 | return /^[0-9]+$/.test(string)
13 | }
--------------------------------------------------------------------------------
/src/context/types/User.js:
--------------------------------------------------------------------------------
1 | export const SET_USER_START = "SET_USER_START";
2 | export const SET_USER_SUCCESS = "SET_USER_SUCCESS";
3 | export const SET_USER_FINISH = "SET_USER_FINISH";
4 | export const SET_APP = "SET_APP";
5 |
6 | export const GET_DOC_START = "GET_DOC_START";
7 | export const GET_DOC_SUCCESS = "GET_DOC_SUCCESS";
8 | export const SET_DOC_ERROR = "SET_DOC_ERROR";
9 |
--------------------------------------------------------------------------------
/src/lib/utils/index.js:
--------------------------------------------------------------------------------
1 | import * as Constants from "./constants";
2 | import * as Currency from "./currency";
3 | import * as Date from "./date";
4 | import * as Toast from "./toast";
5 | import * as Colors from "./colors";
6 | import * as Common from "./common";
7 |
8 | export default {
9 | Constants,
10 | Currency,
11 | Date,
12 | Toast,
13 | Colors,
14 | Common,
15 | };
16 |
--------------------------------------------------------------------------------
/src/screens/Main/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | const ShowTable = styled.div`
4 | position: fixed;
5 | bottom: 0;
6 | left: 0;
7 | width: 100%;
8 | height: 50px;
9 | background-color: #000;
10 | color: #fff;
11 | text-align: center;
12 | font-weight: bolder;
13 | padding: 5px;
14 | cursor: pointer;
15 | `;
16 |
17 | export { ShowTable };
18 |
--------------------------------------------------------------------------------
/src/screens/index.js:
--------------------------------------------------------------------------------
1 | export { default as MainScreen } from "./Main";
2 | export { default as TypeScreen } from "./Type";
3 | export { default as AnalyticsScreen } from "./Analytics";
4 | export { default as OnboardingScreen } from "./Onboarding";
5 | export { default as SplashScreen } from "./Splash";
6 | export { default as GuideScreen } from "./Guide";
7 | export { default as ConfigScreen } from "./Config";
8 |
--------------------------------------------------------------------------------
/src/layouts/NoHeader/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | const Content = styled.div`
4 | margin-top: 5px;
5 | background: #ffffff;
6 | border-radius: 30px 30px 0px 0px;
7 | flex: 1;
8 | width: 100%;
9 | padding: 30px;
10 | display: flex;
11 | flex-direction: column;
12 | justify-content: space-between;
13 | overflow-y: auto;
14 | `;
15 |
16 | export { Content };
17 |
--------------------------------------------------------------------------------
/src/layouts/Header/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Container, Content } from "./styles";
3 | import { HeaderInfoComponent } from "components";
4 |
5 | const Header = ({ children, headerBox }) => {
6 | return (
7 |
8 |
9 | {children}
10 |
11 | );
12 | };
13 |
14 | export default Header;
15 |
--------------------------------------------------------------------------------
/src/components/Loading/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | const Container = styled.div`
4 | position: fixed;
5 | top: 0px;
6 | left: 0px;
7 | height: 100vh;
8 | width: 100%;
9 | display: flex;
10 | justify-content: center;
11 | align-items: center;
12 | background-color: #00000050;
13 | flex-direction: column;
14 | z-index: 9999;
15 | `;
16 |
17 | export { Container };
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 | .netlify
25 |
26 | .env
--------------------------------------------------------------------------------
/src/components/Icons/Navigate.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const Navigate = () => (
4 |
11 |
15 |
16 | );
17 |
18 | export default Navigate;
19 |
--------------------------------------------------------------------------------
/src/components/Icons/Delete.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const Delete = () => (
4 |
5 |
6 |
7 |
8 |
9 | );
10 |
11 | export default Delete;
12 |
--------------------------------------------------------------------------------
/src/components/Loading/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Lottie from "react-lottie-player";
3 | import bouncingCoin from "../../rsc/lottie/bouncingCoin.json";
4 |
5 | import { Container } from "./styles";
6 |
7 | const Loading = () => {
8 | return (
9 |
10 |
15 |
16 | );
17 | };
18 |
19 | export default Loading;
20 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Router from "router";
3 | import AppContextProvider from "context";
4 | import { GoogleOAuthProvider } from "@react-oauth/google";
5 |
6 | function App() {
7 | const clientID = process.env.REACT_APP_GOOGLE_OAUTH_CLIENT_ID;
8 | return (
9 |
10 |
11 |
12 |
13 |
14 | );
15 | }
16 |
17 | export default App;
18 |
--------------------------------------------------------------------------------
/src/components/Icons/Close.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const Close = () => (
4 |
11 |
15 |
16 | );
17 |
18 | export default Close;
19 |
--------------------------------------------------------------------------------
/src/components/Icons/Menu.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const Menu = () => (
4 |
5 |
6 |
7 | );
8 |
9 | export default Menu;
10 |
--------------------------------------------------------------------------------
/src/rsc/img/bg.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/components/Dropdown/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import * as S from "./styles";
3 |
4 | const Dropdown = ({
5 | type = "default",
6 | placeholder,
7 | options,
8 | onChange,
9 | disabled,
10 | value,
11 | }) => {
12 | const defaultProps = {
13 | onChange,
14 | disabled,
15 | };
16 |
17 | return {
18 | default: (
19 |
25 | ),
26 | }[type];
27 | };
28 |
29 | export default Dropdown;
30 |
--------------------------------------------------------------------------------
/src/components/Button/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import * as S from "./styles";
3 |
4 | const Button = ({ type = "default", text, action, disabled }) => {
5 | const handleClick = () => {
6 | action && action();
7 | };
8 |
9 | const defaultProps = {
10 | onClick: handleClick,
11 | disabled,
12 | };
13 |
14 | return {
15 | default: {text} ,
16 | secondary: {text} ,
17 | text: {text} ,
18 | }[type];
19 | };
20 |
21 | export default Button;
22 |
--------------------------------------------------------------------------------
/src/screens/Type/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | export const Container = styled.div`
4 | width: 100%;
5 | padding-bottom: 50px;
6 | `;
7 |
8 | export const TitleContainer = styled.div`
9 | display: flex;
10 | justify-content: space-between;
11 | > div {
12 | flex: 1;
13 | :last-child {
14 | display: flex;
15 | }
16 | }
17 | `;
18 |
19 | export const Title = styled.h2`
20 | font-family: Roboto;
21 | font-style: normal;
22 | font-weight: normal;
23 | font-size: 18px;
24 | `;
25 |
26 |
27 | export const ShowAllContainer = styled.div`
28 | margin-top: 10px;
29 | `;
30 |
--------------------------------------------------------------------------------
/src/components/Modal/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import * as S from "./styles";
3 | import { ButtonComponent } from "components";
4 |
5 | const Modal = ({ title, content, actions }) => {
6 | return (
7 |
8 |
9 | {title}
10 | {content}
11 |
12 | {actions.map((action, key) => (
13 |
14 | ))}
15 |
16 |
17 |
18 | );
19 | };
20 | export default Modal;
21 |
--------------------------------------------------------------------------------
/src/components/Tabs/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import * as S from "./styles";
3 | import clsx from "clsx";
4 |
5 | const Tabs = ({ items, action }) => {
6 | return (
7 |
8 |
9 | {items.map(({ label, active, disabled }) => (
10 | !disabled && action(label)}
14 | >
15 | {label}
16 |
17 | ))}
18 |
19 |
20 | );
21 | };
22 |
23 | export default Tabs;
24 |
--------------------------------------------------------------------------------
/src/components/HeaderInfo/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | export const Container = styled.div`
4 | max-width: 100%;
5 | .carousel-slider {
6 | padding-bottom: 15px;
7 | }
8 | .control-dots {
9 | display: flex;
10 | justify-content: center;
11 | margin-top: 10px;
12 | bottom: -10px;
13 | }
14 | `;
15 |
16 | export const Indicator = styled.li`
17 | width: ${({ isSelected }) => (isSelected ? "13.6px" : "6.8px")};
18 | height: 6.8px;
19 | background: ${({ isSelected }) => (isSelected ? "#fff" : "#d9d9d9")};
20 | border-radius: 6px;
21 | margin: 3px;
22 | transition: all 0.5s ease;
23 | `;
24 |
--------------------------------------------------------------------------------
/src/components/Icons/Info.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const Info = () => (
4 |
11 |
15 |
19 |
20 | );
21 |
22 | export default Info;
23 |
--------------------------------------------------------------------------------
/src/config/sheet.js:
--------------------------------------------------------------------------------
1 | const sheetHeaders = ["Date", "Who", "Amount", "Type", "Detail"];
2 | const sheetHeadersConfig = [
3 | "Name",
4 | "Type",
5 | "Frequency",
6 | "Amount",
7 | "Description",
8 | "Date",
9 | ];
10 | const defaultTypes = ["Supermercado", "Farmacia", "Vestimenta", "Otros"];
11 | const docName = "BillsTracker";
12 | const sheetTitle = "Bills";
13 | const sheetTitleConfig = "Config";
14 | const sheetScope = "profile email https://www.googleapis.com/auth/spreadsheets";
15 |
16 | export {
17 | sheetHeaders,
18 | defaultTypes,
19 | docName,
20 | sheetTitle,
21 | sheetScope,
22 | sheetTitleConfig,
23 | sheetHeadersConfig,
24 | };
25 |
--------------------------------------------------------------------------------
/src/screens/Data/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | const AbsoluteContainerStyled = styled.div`
4 | position: absolute;
5 | display: flex;
6 | justify-content: center;
7 | align-items: center;
8 | background-color: #eee;
9 | top: 0;
10 | bottom: 0;
11 | left: 0;
12 | width: 100%;
13 | font-size: 10px;
14 | & .close {
15 | position: fixed;
16 | bottom: 0;
17 | width: 100%;
18 | height: 50px;
19 | background-color: #000;
20 | color: #fff;
21 | text-align: center;
22 | font-weight: bolder;
23 | padding: 5px;
24 | cursor: pointer;
25 | }
26 | `;
27 |
28 | export { AbsoluteContainerStyled };
29 |
--------------------------------------------------------------------------------
/src/components/BigModal/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import * as S from "./styles";
3 |
4 | const BigModal = ({ title, subTitle, children, handleClose }) => {
5 | return (
6 |
7 | e.preventDefault()} >
8 | {
9 | subTitle ?
10 |
11 | {title}
12 | {subTitle}
13 | :
14 | {title}
15 | }
16 | {children}
17 |
18 |
19 | );
20 | };
21 | export default BigModal;
22 |
--------------------------------------------------------------------------------
/src/components/Footer/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | const Container = styled.div`
4 | position: fixed;
5 | bottom: 0;
6 | height: 60px;
7 | width: 100%;
8 | max-width: inherit;
9 | background: #ffffff;
10 | box-shadow: 0px -1px 4px rgba(196, 196, 196, 0.5);
11 | display: flex;
12 | justify-content: center;
13 | align-items: center;
14 | `;
15 |
16 | const MenuItem = styled.button`
17 | border: none;
18 | background: none;
19 | display: flex;
20 | flex: 1;
21 | justify-content: center;
22 | align-items: center;
23 | height: 100%;
24 | path {
25 | fill: #aeaeae;
26 | }
27 | &.active path {
28 | fill: #000;
29 | }
30 | `;
31 |
32 | export { Container, MenuItem };
33 |
--------------------------------------------------------------------------------
/src/components/Checkbox/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import * as S from "./styles";
3 | import { v4 as uuidv4 } from "uuid";
4 |
5 | const Checkbox = ({ label, checked, onChange, disabled, color }) => {
6 | const id = uuidv4();
7 | return (
8 |
9 |
10 |
19 |
20 | {label}
21 |
22 |
23 | );
24 | };
25 |
26 | export default Checkbox;
27 |
--------------------------------------------------------------------------------
/src/layouts/Header/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | const Container = styled.div`
4 | display: flex;
5 | flex: 1;
6 | flex-direction: column;
7 | align-items: center;
8 | max-height: 100%;
9 | max-width: 100%;
10 | `;
11 |
12 | const Content = styled.div`
13 | margin-top: 5px;
14 | background: #ffffff;
15 | border-radius: 30px 30px 0px 0px;
16 | width: 100%;
17 | padding: 25px 30px 80px 30px;
18 | display: flex;
19 | flex-direction: column;
20 | justify-content: space-between;
21 | height: 100%;
22 | overflow-y: auto;
23 | margin-bottom: 0px;
24 | min-height: calc(100vh - 190px);
25 | @media (display-mode: browser) {
26 | min-height: auto;
27 | }
28 | `;
29 |
30 | export { Container, Content };
31 |
--------------------------------------------------------------------------------
/src/lib/utils/common.js:
--------------------------------------------------------------------------------
1 | import Utils from "lib/utils";
2 |
3 | export const isJsonString = (str) => {
4 | try {
5 | JSON.parse(str);
6 | } catch (e) {
7 | return false;
8 | }
9 | return true;
10 | };
11 |
12 | export const getSpreadsheetId = (url) => {
13 | if (url) {
14 | if (url.slice(0, 4) === "http") {
15 | const matches = /\/([\w-_]{18,})(.*?gid=(\d+))?/.exec(url);
16 | return matches[1];
17 | } else {
18 | return url;
19 | }
20 | } else {
21 | return null;
22 | }
23 | };
24 |
25 | export const getTotalRecords = records => {
26 | const { moneyToNumber, formatMoney } = Utils.Currency;
27 | const total = records.reduce((prev, cur) => prev + moneyToNumber(cur.Amount), 0);
28 | return formatMoney(total);
29 | }
--------------------------------------------------------------------------------
/src/context/reducers/Search.js:
--------------------------------------------------------------------------------
1 | import { DispatchTypes } from "../";
2 |
3 | export const searchInitialState = {
4 | show: false,
5 | input: "",
6 | };
7 |
8 | const SearchReducer = (currentState, action) => {
9 | switch (action.type) {
10 | case DispatchTypes.Search.SEARCH_INPUT:
11 | currentState.input = action.input;
12 | return { ...currentState };
13 | case DispatchTypes.Search.SEARCH_SHOW:
14 | currentState.show = true;
15 | return { ...currentState };
16 | case DispatchTypes.Search.SEARCH_HIDE:
17 | currentState.show = false;
18 | return { ...currentState };
19 | case DispatchTypes.Search.SEARCH_RESET:
20 | return searchInitialState;
21 | default:
22 | return currentState;
23 | }
24 | };
25 |
26 | export default SearchReducer;
27 |
--------------------------------------------------------------------------------
/src/components/Icons/Home.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const Home = () => (
4 |
11 |
15 |
19 |
20 | );
21 |
22 | export default Home;
23 |
--------------------------------------------------------------------------------
/src/components/Icons/MenuSearch.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const MenuSearch = () => (
4 |
11 |
15 |
16 | );
17 |
18 | export default MenuSearch;
19 |
--------------------------------------------------------------------------------
/src/components/Icons/Search.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const Search = () => (
4 |
11 |
15 |
16 | );
17 |
18 | export default Search;
19 |
--------------------------------------------------------------------------------
/src/context/reducers/Modal.js:
--------------------------------------------------------------------------------
1 | import { DispatchTypes } from "../";
2 |
3 | export const modalInitialState = {
4 | show: false,
5 | title: "",
6 | content: "",
7 | actions: [],
8 | };
9 |
10 | const ModalReducer = (currentState, action) => {
11 | switch (action.type) {
12 | case DispatchTypes.Modal.MODAL_SHOW:
13 | currentState.show = true;
14 | currentState.title = action.title;
15 | currentState.content = action.content;
16 | currentState.actions = action.actions;
17 | return { ...currentState };
18 | case DispatchTypes.Modal.MODAL_HIDE:
19 | currentState.show = false;
20 | return { ...currentState };
21 | case DispatchTypes.Modal.MODAL_RESET:
22 | return modalInitialState;
23 | default:
24 | return currentState;
25 | }
26 | };
27 |
28 | export default ModalReducer;
29 |
--------------------------------------------------------------------------------
/src/components/Icons/index.js:
--------------------------------------------------------------------------------
1 | export { default as SignOutIcon } from "./SignOut";
2 | export { default as PieChartIcon } from "./PieChart";
3 | export { default as LineChartIcon } from "./LineChart";
4 | export { default as HomeIcon } from "./Home";
5 | export { default as ArrowIndicatorIcon } from "./ArrowIndicator";
6 | export { default as DollarIcon } from "./Dollar";
7 | export { default as CalendarIcon } from "./Calendar";
8 | export { default as NavigateIcon } from "./Navigate";
9 | export { default as DeleteIcon } from "./Delete";
10 | export { default as InfoIcon } from "./Info";
11 | export { default as MenuIcon } from "./Menu";
12 | export { default as GearIcon } from "./Gear";
13 | export { default as SearchIcon } from "./Search";
14 | export { default as MenuSearchIcon } from "./MenuSearch";
15 | export { default as CloseIcon } from "./Close";
16 |
--------------------------------------------------------------------------------
/src/components/BarChart/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | export const Tooltip = styled.div`
4 | background: #ffffff;
5 | border-radius: 4px;
6 | padding: 9px 4px;
7 | filter: drop-shadow(0px 0px 7px rgba(0, 0, 0, 0.25));
8 | min-width: 50px;
9 | text-align: center;
10 | font-family: Roboto;
11 | font-style: normal;
12 | font-weight: 300;
13 | font-size: 11px;
14 | :before {
15 | content: "";
16 | position: absolute;
17 | left: calc(50% - 8px);
18 | bottom: -8px;
19 | width: 0;
20 | height: 0;
21 | border-left: 8px solid transparent;
22 | border-right: 8px solid transparent;
23 | border-top: 8px solid #fff;
24 | }
25 | `;
26 |
27 | export const LoadingContainer = styled.div`
28 | display: flex;
29 | justify-content: center;
30 | align-items: center;
31 | height: 100%;
32 | `;
33 |
--------------------------------------------------------------------------------
/src/components/Tabs/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | export const Container = styled.div`
4 | display: flex;
5 | width: 100%;
6 | `;
7 |
8 | export const Menu = styled.ul`
9 | display: flex;
10 | justify-content: space-between;
11 | width: 100%;
12 | padding: 0;
13 | margin: 0;
14 | `;
15 |
16 | export const MenuItem = styled.li`
17 | display: flex;
18 | color: #444;
19 | font-family: Roboto;
20 | font-size: 14px;
21 | font-style: normal;
22 | font-weight: 300;
23 | line-height: normal;
24 | text-transform: uppercase;
25 | text-underline-offset: 4px;
26 | cursor: pointer;
27 |
28 | &.active {
29 | color: #000;
30 | font-weight: 400;
31 | text-decoration: underline;
32 | }
33 |
34 | &.disabled {
35 | color: #ccc;
36 | font-weight: 300;
37 | cursor: not-allowed;
38 | }
39 | `;
40 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import "./index.css";
4 | import App from "./App";
5 | import * as serviceWorker from "./serviceWorker";
6 | import "typeface-roboto";
7 |
8 | let vh = Math.abs(window.visualViewport.height);
9 | document.documentElement.style.setProperty("--vh", `${vh}px`);
10 | window.addEventListener("resize", () => {
11 | document.documentElement.style.setProperty("--vh", `${vh}px`);
12 | });
13 | // Then we set the value in the --vh custom property to the root of the document
14 |
15 | ReactDOM.render( , document.getElementById("root"));
16 |
17 | // If you want your app to work offline and load faster, you can change
18 | // unregister() to register() below. Note this comes with some pitfalls.
19 | // Learn more about service workers: https://bit.ly/CRA-PWA
20 | serviceWorker.register();
21 |
--------------------------------------------------------------------------------
/src/screens/Splash/styles.js:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from "styled-components";
2 |
3 | const fadeIn = keyframes`
4 | 0% {
5 | opacity: 0;
6 | }
7 | 100% {
8 | opacity: 1;
9 | }
10 | `;
11 |
12 | const Container = styled.div`
13 | display: flex;
14 | background: linear-gradient(
15 | 180deg,
16 | rgba(255, 255, 255, 0.1) 0%,
17 | rgba(255, 255, 255, 0) 100%
18 | ),
19 | #38b44e;
20 | width: 100%;
21 | min-height: 100vh;
22 | justify-content: center;
23 | align-items: center;
24 | flex-direction: column;
25 | color: white;
26 | `;
27 |
28 | const Content = styled.div`
29 | animation: 2s ${fadeIn} ease-out;
30 | `;
31 |
32 | const Title = styled.h1`
33 | font-family: Roboto;
34 | font-style: normal;
35 | font-weight: 500;
36 | font-size: 36px;
37 | `;
38 |
39 | export { Container, Content, Title };
40 |
--------------------------------------------------------------------------------
/src/components/MonthLegend/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import * as S from "./styles";
3 | import Utils from "lib/utils";
4 | import { CalendarIcon, NavigateIcon } from "components";
5 |
6 | const MonthLegend = ({ month, year, amount, action }) => {
7 | const { formatMoney } = Utils.Currency;
8 | const { monthToText } = Utils.Date;
9 |
10 | const monthText = `${monthToText(month - 1).substring(0, 3)}.`;
11 |
12 | return (
13 |
14 |
15 |
16 |
17 |
18 | {monthText}, {year}
19 |
20 | {`$${formatMoney(amount)}`}
21 |
22 |
23 |
24 |
25 | );
26 | };
27 |
28 | export default MonthLegend;
29 |
--------------------------------------------------------------------------------
/src/lib/utils/currency.js:
--------------------------------------------------------------------------------
1 | export const moneyToNumber = (value) =>
2 | parseInt(value.replace("$", "").replace(".", "").replace(",", ""));
3 |
4 | export const formatMoney = (value) => {
5 | return Number(value.toFixed(1)).toLocaleString();
6 | };
7 |
8 | export const formatSymbol = (num, digits = 1) => {
9 | const lookup = [
10 | { value: 1, symbol: "" },
11 | { value: 1e3, symbol: "k" },
12 | { value: 1e6, symbol: "M" },
13 | { value: 1e9, symbol: "G" },
14 | { value: 1e12, symbol: "T" },
15 | { value: 1e15, symbol: "P" },
16 | { value: 1e18, symbol: "E" },
17 | ];
18 | const rx = /\.0+$|(\.[0-9]*[1-9])0+$/;
19 | var item = lookup
20 | .slice()
21 | .reverse()
22 | .find(function (item) {
23 | return num >= item.value;
24 | });
25 | return item
26 | ? (num / item.value).toFixed(digits).replace(rx, "$1") + item.symbol
27 | : "0";
28 | };
29 |
--------------------------------------------------------------------------------
/src/components/Tooltip/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | export const Container = styled.div`
4 | position: relative;
5 | `;
6 |
7 | export const Tooltip = styled.div`
8 | display: ${({ open }) => (open ? "block" : "none")};
9 | position: absolute;
10 | background-color: #fff;
11 | padding: 5px 4px;
12 | border-radius: 4px;
13 | left: 100%;
14 | top: 0;
15 | margin-left: 5px;
16 | width: 100%;
17 | min-width: 140px;
18 | box-shadow: 0px 0px 7px 0px rgba(0, 0, 0, 0.25);
19 |
20 | h2 {
21 | color: #000;
22 | text-align: center;
23 | font-size: 12px;
24 | font-family: Roboto;
25 | font-weight: 600;
26 | margin: 0 0 3px 0;
27 | }
28 |
29 | p {
30 | color: #000;
31 | font-size: 12px;
32 | font-family: Roboto;
33 | font-weight: 300;
34 | margin: 0;
35 | line-height: 12px;
36 | text-align: center;
37 | }
38 | `;
39 |
--------------------------------------------------------------------------------
/src/components/RadioButton/index.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment } from "react";
2 | import * as S from "./styles";
3 |
4 | const RadioButton = ({ options, selected, onChange, disabled, name }) => {
5 | return (
6 |
7 | {options.map(({ label, value }) => (
8 |
9 |
10 |
19 |
20 | {label}
21 |
22 |
23 | ))}
24 |
25 | );
26 | };
27 |
28 | export default RadioButton;
29 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | }
4 |
5 | body {
6 | margin: 0;
7 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
8 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
9 | sans-serif;
10 | -webkit-font-smoothing: antialiased;
11 | -moz-osx-font-smoothing: grayscale;
12 | background-color: #38b44e;
13 | }
14 |
15 | @media (display-mode: browser) {
16 |
17 | body,
18 | #root {
19 | height: var(--vh);
20 | max-height: var(--vh);
21 | overflow-y: hidden;
22 | }
23 |
24 | }
25 |
26 | #root {
27 | display: flex;
28 | justify-content: center;
29 | }
30 |
31 | #root>div {
32 | width: 100%;
33 | max-width: 700px;
34 | }
35 |
36 | code {
37 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
38 | monospace;
39 | }
40 |
41 | .MuiTablePagination-root {
42 | font-size: 10px !important;
43 | }
--------------------------------------------------------------------------------
/src/screens/Onboarding/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | export const Content = styled.div`
4 | height: calc(100vh - 125px);
5 | @media (display-mode: browser) {
6 | height: 100%;
7 | }
8 | display: flex;
9 | flex-direction: column;
10 | > div:first-child {
11 | flex: 1;
12 | display: flex;
13 | flex-direction: column;
14 | align-items: center;
15 | }
16 | .googleButton {
17 | height: 60px;
18 | margin-top: 40px;
19 | span {
20 | text-align: center;
21 | width: 100%;
22 | color: #757575;
23 | font-weight: 500;
24 | font-size: 14px;
25 | }
26 | }
27 |
28 | .buttonDisabled {
29 | span {
30 | opacity: 0.4;
31 | }
32 | }
33 | `;
34 |
35 | export const GoogleDisclaimer = styled.p`
36 | margin-top: 30px;
37 | color: #aeaeae;
38 | font-weight: 400;
39 | font-size: 12px;
40 | text-align: center;
41 | `;
42 |
--------------------------------------------------------------------------------
/src/components/Button/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | const Button = styled.button`
4 | border-radius: 15px;
5 | width: 100%;
6 | height: 60px;
7 | border: none;
8 | color: white;
9 | text-transform: uppercase;
10 | font-family: Roboto;
11 | font-style: normal;
12 | font-weight: 500;
13 | font-size: 18px;
14 | cursor: pointer;
15 | &:focus {
16 | outline: none;
17 | }
18 | &:disabled {
19 | background-color: #c4c4c4;
20 | cursor: not-allowed;
21 | }
22 | `;
23 |
24 | export const Default = styled(Button)`
25 | background: #38b44e;
26 | &:hover {
27 | background-color: #6fc97f;
28 | }
29 | `;
30 |
31 | export const Secondary = styled(Button)`
32 | background: #333333;
33 | color: #ffffff;
34 | &:hover {
35 | background-color: #6fc97f;
36 | }
37 | `;
38 |
39 | export const Text = styled(Button)`
40 | background: none;
41 | color: #333;
42 | `;
43 |
--------------------------------------------------------------------------------
/src/components/MonthLegend/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | export const Container = styled.div`
4 | display: flex;
5 | background: #ffffff;
6 | box-shadow: 0px 0px 7px rgba(0, 0, 0, 0.25);
7 | border-radius: 6px;
8 | padding: 12px 10px;
9 | margin-bottom: 17px;
10 | `;
11 |
12 | export const IconContainer = styled.div``;
13 |
14 | export const DateContainer = styled.div`
15 | flex: 1;
16 | display: flex;
17 | align-items: center;
18 | font-family: Roboto;
19 | font-weight: 300;
20 | font-size: 12px;
21 | color: #333333;
22 | padding: 0 8px;
23 | `;
24 |
25 | export const AmountContainer = styled.div`
26 | display: flex;
27 | align-items: center;
28 | color: #5455ff;
29 | font-family: Roboto;
30 | font-weight: normal;
31 | font-size: 18px;
32 | `;
33 |
34 | export const NavigateContainer = styled.div`
35 | display: flex;
36 | align-items: center;
37 | padding-left: 8px;
38 | `;
39 |
--------------------------------------------------------------------------------
/src/services/auth.js:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 |
3 | const baseURL = process.env.REACT_APP_BASE_URI;
4 |
5 | export const getRefreshToken = async (code) => {
6 | try {
7 | const { data } = await axios.get(`${baseURL}/oauth2callback?code=${code}`);
8 | return data;
9 | } catch (error) {
10 | console.log("ERROR:: ", error);
11 | return null;
12 | }
13 | };
14 |
15 | export const getNewTokens = async ({
16 | access_token,
17 | expiry_date,
18 | id_token,
19 | refresh_token,
20 | scope,
21 | token_type,
22 | }) => {
23 | try {
24 | const tokens = {
25 | access_token,
26 | expiry_date,
27 | id_token,
28 | refresh_token,
29 | scope,
30 | token_type,
31 | };
32 | const { data } = await axios.get(`${baseURL}/refresh`, {
33 | params: { tokens },
34 | });
35 | return data;
36 | } catch (error) {
37 | console.log("ERROR: ", error);
38 | return null;
39 | }
40 | };
41 |
--------------------------------------------------------------------------------
/src/components/Header/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | const Main = styled.div`
4 | height: 60px;
5 | color: white;
6 | display: flex;
7 | align-items: center;
8 | top: 0px;
9 | z-index: 999;
10 | width: 100%;
11 | ${({ colorChange }) => colorChange && `background-color: #38b44e;`}
12 | transition: background-color 0.5s ease;
13 | `;
14 |
15 | const TitleContainer = styled.div`
16 | display: flex;
17 | flex-direction: column;
18 | flex: 1;
19 | text-align: center;
20 | h1 {
21 | font-weight: bold;
22 | font-size: 18px;
23 | margin: 0;
24 | }
25 |
26 | h2 {
27 | font-weight: normal;
28 | font-size: 13px;
29 | margin: 0;
30 | }
31 | `;
32 |
33 | const ActionContainer = styled.div`
34 | width: 50px;
35 | height: 100%;
36 | display: flex;
37 | justify-content: center;
38 | align-items: center;
39 | svg path {
40 | fill: white;
41 | }
42 | `;
43 |
44 | export { Main, TitleContainer, ActionContainer };
45 |
--------------------------------------------------------------------------------
/src/components/LineChart/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | export const Tooltip = styled.div`
4 | background: #ffffff;
5 | border-radius: 4px;
6 | padding: 9px 4px;
7 | filter: drop-shadow(0px 0px 7px rgba(0, 0, 0, 0.25));
8 | min-width: 50px;
9 | text-align: center;
10 | font-family: Roboto;
11 | font-style: normal;
12 | font-weight: 300;
13 | font-size: 11px;
14 | p {
15 | margin: 0 0 10px 0;
16 | font-size: 16px;
17 | font-weight: 600;
18 | }
19 | ul {
20 | list-style: none;
21 | margin: 0;
22 | padding: 0;
23 | li {
24 | display: flex;
25 | justify-content: space-between;
26 | gap: 10px;
27 | span {
28 | font-weight: 300;
29 | :first-child {
30 | font-weight: 400;
31 | }
32 | }
33 | }
34 | }
35 | `;
36 |
37 | export const LoadingContainer = styled.div`
38 | display: flex;
39 | justify-content: center;
40 | align-items: center;
41 | height: 100%;
42 | `;
43 |
--------------------------------------------------------------------------------
/src/layouts/Master/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import bg from "../../rsc/img/bg.svg";
3 |
4 | const Container = styled.div`
5 | /* padding-bottom: ${({ footer }) => (footer ? "60px" : "0px;")}; */
6 | display: flex;
7 | flex-direction: column;
8 | height: 100vh;
9 | min-height: 100vh;
10 | @media (display-mode: browser) {
11 | height: var(--vh);
12 | max-height: var(--vh);
13 | }
14 | background-color: #38b44e;
15 | z-index: 1;
16 | background: url(${bg});
17 | background-size: cover;
18 | `;
19 |
20 | const Content = styled.div`
21 | display: flex;
22 | height: ${({ footer }) =>
23 | footer ? "calc(var(--vh) - 110px)" : "calc(var(--vh) - 60px)"};
24 | flex: 1;
25 | @media (display-mode: browser) {
26 | height: ${({ footer }) =>
27 | footer ? "calc(var(--vh) - 120px)" : "calc(var(--vh) - 60px)"};
28 | max-height: ${({ footer }) =>
29 | footer ? "calc(var(--vh) - 120px)" : "calc(var(--vh) - 60px)"};
30 | }
31 | `;
32 |
33 | export { Container, Content };
34 |
--------------------------------------------------------------------------------
/src/components/Modal/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | export const Container = styled.div`
4 | display: flex;
5 | position: fixed;
6 | background-color: #00000035;
7 | justify-content: center;
8 | align-items: center;
9 | top: 0px;
10 | left: 0px;
11 | height: 100vh;
12 | width: 100vw;
13 | z-index: 999;
14 | `;
15 |
16 | export const Modal = styled.div`
17 | flex-direction: column;
18 | display: flex;
19 | background-color: #ffffff;
20 | padding: 23px;
21 | border-radius: 7px;
22 | max-width: 90vw;
23 | `;
24 |
25 | export const Title = styled.h3`
26 | text-align: center;
27 | `;
28 |
29 | export const Content = styled.div`
30 | text-align: center;
31 | `;
32 |
33 | export const ActionsContainer = styled.div`
34 | margin-top: 20px;
35 | display: flex;
36 | justify-content: center;
37 | flex-direction: column;
38 | align-items: center;
39 | button {
40 | margin: 5px 0;
41 | height: 30px;
42 | font-weight: 500;
43 | font-size: 12px;
44 | max-width: 150px;
45 | }
46 | `;
47 |
--------------------------------------------------------------------------------
/src/components/Tooltip/index.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useRef } from "react";
2 | import { Container, Tooltip } from "./styles";
3 |
4 | const CustomTooltip = ({ children, infoTitle, infoText }) => {
5 | const [open, setOpen] = useState(false);
6 |
7 | const ref = useRef(null);
8 |
9 | const handleOpen = () => {
10 | setOpen(!open);
11 | };
12 |
13 | useEffect(() => {
14 | const handleClickOutside = (event) => {
15 | if (ref.current && !ref.current.contains(event.target)) {
16 | setOpen(false);
17 | }
18 | };
19 | document.addEventListener("mousedown", handleClickOutside);
20 | return () => {
21 | document.removeEventListener("mousedown", handleClickOutside);
22 | };
23 | }, []);
24 |
25 | return (
26 |
27 | {children}
28 |
29 | {infoTitle && {infoTitle} }
30 | {infoText && {infoText}
}
31 |
32 |
33 | );
34 | };
35 |
36 | export default CustomTooltip;
37 |
--------------------------------------------------------------------------------
/src/config/localStorage.js:
--------------------------------------------------------------------------------
1 | export const getUserSession = () => JSON.parse(localStorage.getItem("user"));
2 |
3 | export const setUserSession = (user) => {
4 | const userFromStorage = getUserSession();
5 | const newUser = {
6 | ...userFromStorage,
7 | ...user,
8 | };
9 | if (userFromStorage?.name) {
10 | newUser.name = userFromStorage.name;
11 | }
12 |
13 | const userObject = JSON.stringify(newUser);
14 | localStorage.setItem("user", userObject);
15 | };
16 |
17 | export const deleteUserSession = () => {
18 | localStorage.removeItem("data");
19 | localStorage.removeItem("user");
20 | };
21 |
22 | export const setSheetData = (data) => {
23 | const dataObject = JSON.stringify(data);
24 | localStorage.setItem("data", dataObject);
25 | };
26 |
27 | export const getSheetData = () => JSON.parse(localStorage.getItem("data"));
28 |
29 | export const setSheetConfig = (data) => {
30 | const dataObject = JSON.stringify(data);
31 | localStorage.setItem("config", dataObject);
32 | };
33 |
34 | export const getSheetConfig = () => JSON.parse(localStorage.getItem("config"));
35 |
--------------------------------------------------------------------------------
/src/screens/Config/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | export const Container = styled.div`
4 | width: 100%;
5 | padding: 20px 0 50px 0;
6 | flex: 1;
7 | `;
8 |
9 | export const Content = styled.div`
10 | display: flex;
11 | flex-direction: column;
12 | gap: 20px;
13 | height: 100%;
14 | `;
15 |
16 | export const TableContainer = styled.div`
17 | display: flex;
18 | flex-direction: column;
19 | flex: 1;
20 | `;
21 |
22 | export const Form = styled.div`
23 | display: flex;
24 | flex-direction: column;
25 | justify-content: space-between;
26 | gap: 20px;
27 | `;
28 |
29 | export const NoData = styled.div`
30 | display: flex;
31 | justify-content: center;
32 | align-items: center;
33 | padding: 40px;
34 | text-align: center;
35 | flex-direction: column;
36 | color: #7e7e7e;
37 | font-family: Roboto;
38 | font-size: 13px;
39 | font-style: normal;
40 | font-weight: 300;
41 |
42 | img {
43 | width: 100%;
44 | }
45 |
46 | p {
47 | &.strong {
48 | font-weight: 900;
49 | }
50 | margin: 0;
51 | }
52 | `;
53 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Bills Tracker",
3 | "name": "Bills Tracker",
4 | "description": "Track and share expenses with a Google Drive Spreadsheet.",
5 | "orientation": "portrait",
6 | "display": "fullscreen",
7 | "icons": [
8 | {
9 | "src": "favicon.ico",
10 | "sizes": "48x48",
11 | "type": "image/x-icon"
12 | },
13 | {
14 | "src": "logo192.png",
15 | "type": "image/png",
16 | "sizes": "192x192"
17 | },
18 | {
19 | "src": "logo512.png",
20 | "type": "image/png",
21 | "sizes": "512x512"
22 | }
23 | ],
24 | "start_url": ".",
25 | "theme_color": "#ffffff",
26 | "background_color": "#38B44E",
27 | "screenshots": [
28 | {
29 | "src": "img/screens/cover.png",
30 | "sizes": "1080x1080",
31 | "type": "image/png",
32 | "form_factor": "narrow",
33 | "label": "Cover"
34 | },
35 | {
36 | "src": "img/screens/home.png",
37 | "sizes": "360x640",
38 | "type": "image/png",
39 | "form_factor": "wide",
40 | "label": "Home"
41 | }
42 | ]
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/Icons/PieChart.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const PieChart = () => (
4 |
11 |
15 |
16 | );
17 |
18 | export default PieChart;
19 |
--------------------------------------------------------------------------------
/src/components/Icons/SignOut.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const SignOut = () => (
4 |
11 |
15 |
16 | );
17 |
18 | export default SignOut;
19 |
--------------------------------------------------------------------------------
/src/context/index.js:
--------------------------------------------------------------------------------
1 | import React, { useReducer, createContext } from "react";
2 | import UserReducer, { userInitialState } from "./reducers/User";
3 | import ModalReducer, { modalInitialState } from "./reducers/Modal";
4 | import SearchReducer, { searchInitialState } from "./reducers/Search";
5 | import Types from "./types";
6 |
7 | export const GlobalContext = createContext();
8 |
9 | export const DispatchTypes = Types;
10 |
11 | const AppContextProvider = ({ children }) => {
12 | const [userState, userDispatch] = useReducer(UserReducer, userInitialState);
13 | const [modalState, modalDispatch] = useReducer(
14 | ModalReducer,
15 | modalInitialState
16 | );
17 | const [searchState, searchDispatch] = useReducer(
18 | SearchReducer,
19 | searchInitialState
20 | );
21 |
22 | const values = {
23 | globalUser: [userState, userDispatch],
24 | globalModal: [modalState, modalDispatch],
25 | globalSearch: [searchState, searchDispatch],
26 | };
27 |
28 | return (
29 | {children}
30 | );
31 | };
32 |
33 | export default AppContextProvider;
34 |
--------------------------------------------------------------------------------
/src/components/HeaderBox/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | Container,
4 | Content,
5 | PrimaryValue,
6 | SecondaryValue,
7 | Title,
8 | Info,
9 | } from "./styles";
10 | import { ArrowIndicatorIcon, InfoIcon, TooltipComponent } from "components";
11 |
12 | const HeaderBox = ({
13 | primaryValue,
14 | secondaryValue,
15 | title,
16 | arrowIcon,
17 | info,
18 | }) => {
19 | return (
20 |
21 |
22 | {info && (
23 |
24 |
25 |
26 |
27 |
28 | )}
29 | {title && {title} }
30 | {primaryValue && {primaryValue} }
31 | {secondaryValue && (
32 |
33 | {arrowIcon && (
34 |
35 | )}
36 | {secondaryValue}
37 |
38 | )}
39 |
40 |
41 | );
42 | };
43 |
44 | export default HeaderBox;
45 |
--------------------------------------------------------------------------------
/src/components/Icons/Calendar.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const Calendar = () => (
4 |
11 |
18 |
25 |
32 |
39 |
40 | );
41 |
42 | export default Calendar;
43 |
--------------------------------------------------------------------------------
/src/screens/Analytics/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | export const Container = styled.div`
4 | width: 100%;
5 | padding-bottom: 50px;
6 | `;
7 |
8 | export const TitleContainer = styled.div`
9 | display: flex;
10 | justify-content: space-between;
11 | > div {
12 | flex: 1;
13 | :last-child {
14 | display: flex;
15 | }
16 | }
17 | `;
18 |
19 | export const Title = styled.h2`
20 | font-family: Roboto;
21 | font-style: normal;
22 | font-weight: normal;
23 | font-size: 18px;
24 | `;
25 |
26 | export const LeggendsContainer = styled.div`
27 | margin-top: 25px;
28 | `;
29 |
30 | export const SubTitleContainer = styled.div`
31 | margin: 20px 0px;
32 | `;
33 |
34 | export const SubTitle = styled.h3`
35 | font-family: Roboto;
36 | font-style: normal;
37 | font-weight: bold;
38 | font-size: 24px;
39 | margin: 0px;
40 | `;
41 |
42 | export const ResumeTotal = styled.h4`
43 | font-family: Roboto;
44 | font-weight: 300;
45 | font-size: 10px;
46 | span {
47 | font-weight: 500;
48 | color: #6fcf97;
49 | }
50 | `;
51 |
52 | export const CategoryList = styled.ul`
53 | margin: 0;
54 | padding: 0;
55 | list-style: none;
56 | display: flex;
57 | gap: 10px;
58 | flex-flow: row wrap;
59 | `;
60 |
--------------------------------------------------------------------------------
/src/components/BigModal/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | export const Container = styled.div`
4 | display: flex;
5 | position: fixed;
6 | background-color: #00000035;
7 | justify-content: center;
8 | align-items: flex-end;
9 | top: 0px;
10 | left: 0px;
11 | height: 100vh;
12 | width: 100vw;
13 | z-index: 1;
14 | `;
15 |
16 | export const Modal = styled.div`
17 | flex-direction: column;
18 | display: flex;
19 | background-color: #ffffff;
20 | padding: 23px 10px;
21 | border-radius: 30px 30px 0px 0px;
22 | width: 100vw;
23 | z-index: 2;
24 | max-height: 80vh;
25 | `;
26 |
27 | export const Title = styled.h3`
28 | text-align: center;
29 | margin-bottom: 30px;
30 | `;
31 |
32 | export const Content = styled.div`
33 | overflow-y: scroll;
34 | flex: 1;
35 | padding: 0 10px 20px 10px;
36 | `;
37 |
38 | export const TitleWithSubtitleContainer = styled.div`
39 | display: flex;
40 | justify-content: space-between;
41 | margin-bottom: 30px;
42 | `;
43 |
44 | export const TitleWithSubtitle = styled.div`
45 | text-align: left;
46 | font-weight: 400;
47 | font-size: 18px;
48 | `;
49 |
50 | export const SubTitle = styled.div`
51 | text-align: right;
52 | font-weight: 300;
53 | font-size: 18px;
54 | color: #7E7E7E;
55 | `;
--------------------------------------------------------------------------------
/src/lib/utils/constants.js:
--------------------------------------------------------------------------------
1 | export const RADIAN = Math.PI / 180;
2 |
3 | export const COLORS = [
4 | "#f44336",
5 | "#9c27b0",
6 | "#3f51b5",
7 | "#03a9f4",
8 | "#009688",
9 | "#8bc34a",
10 | "#ffeb3b",
11 | "#ff9800",
12 | "#795548",
13 | "#607d8b",
14 | ];
15 |
16 | export const MONTHS = [
17 | "January",
18 | "February",
19 | "March",
20 | "April",
21 | "May",
22 | "June",
23 | "July",
24 | "August",
25 | "September",
26 | "October",
27 | "November",
28 | "December",
29 | ];
30 |
31 | export const LAST_12_MONTHS_OPTION = {
32 | label: "Last 12M",
33 | value: "last12months",
34 | };
35 |
36 | export const SCHEDULE = "Schedule";
37 | export const PROFILE = "Profile";
38 | export const BUDGET = "Budget";
39 | export const TYPES = "Types";
40 |
41 | export const MENU_ITEMS = [
42 | { label: SCHEDULE, active: true },
43 | { label: PROFILE, active: false, disabled: true },
44 | { label: BUDGET, active: false, disabled: true },
45 | { label: TYPES, active: false, disabled: true },
46 | ];
47 |
48 | export const SCHEDULE_FREQUENCY = [
49 | { key: "0", value: "Daily", description: "" },
50 | { key: "1", value: "Weekly", description: "" },
51 | { key: "2", value: "Monthly", description: "" },
52 | { key: "3", value: "Yearly", description: "" },
53 | ];
54 |
--------------------------------------------------------------------------------
/src/components/ChartLegend/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | export const Container = styled.div`
4 | display: flex;
5 | `;
6 |
7 | export const ChartContainer = styled.div`
8 | display: flex;
9 | height: 50px;
10 | width: 50px;
11 | `;
12 |
13 | export const Content = styled.div`
14 | display: flex;
15 | flex: 1;
16 | padding-left: 10px;
17 | border-bottom: 1px solid #c4c4c4;
18 | `;
19 |
20 | export const TitleContainer = styled.div`
21 | flex: 1;
22 | justify-content: center;
23 | display: flex;
24 | flex-direction: column;
25 | `;
26 |
27 | export const Title = styled.div`
28 | font-family: Roboto;
29 | font-style: normal;
30 | font-weight: normal;
31 | font-size: 12px;
32 | `;
33 |
34 | export const SubTitle = styled.div`
35 | font-family: Roboto;
36 | font-style: normal;
37 | font-weight: 300;
38 | font-size: 10px;
39 | `;
40 |
41 | export const DataContainer = styled.div`
42 | justify-content: center;
43 | display: flex;
44 | flex-direction: column;
45 | font-family: Roboto;
46 | text-align: right;
47 | span:first-child {
48 | font-style: normal;
49 | font-weight: normal;
50 | font-size: 12px;
51 | }
52 | span:last-child {
53 | font-style: normal;
54 | font-weight: 300;
55 | font-size: 10px;
56 | }
57 | `;
58 |
--------------------------------------------------------------------------------
/src/components/Dropdown/styles.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 | import Select from "react-select";
4 |
5 | const SelectComponent = (props) => (
6 |
7 | );
8 |
9 | const Dropdown = styled(SelectComponent)`
10 | border: none;
11 | border-style: unset;
12 | & .Select__control {
13 | border: 1px solid #aaaaaa;
14 | border-radius: 0px;
15 | }
16 |
17 | & .Select__value-container {
18 | text-align: right;
19 | justify-content: flex-end;
20 | min-width: 70px;
21 | }
22 |
23 | & .Select__indicator-separator,
24 | .Select__clear-indicator {
25 | display: none;
26 | }
27 |
28 | & .Select__control--is-focused {
29 | box-shadow: none;
30 | }
31 |
32 | & .Select__option {
33 | text-align: right;
34 | }
35 |
36 | font-family: Roboto;
37 | border: none;
38 | border-radius: 7px;
39 | width: 100%;
40 | font-style: normal;
41 | font-weight: normal;
42 | font-size: 11px;
43 | ::placeholder {
44 | color: #7e7e7e;
45 | }
46 | &:focus {
47 | outline: none;
48 | ::placeholder {
49 | color: #000;
50 | }
51 | }
52 | `;
53 |
54 | export const Default = styled(Dropdown)`
55 | display: flex;
56 | justify-content: flex-end;
57 | z-index: 400;
58 | `;
59 |
--------------------------------------------------------------------------------
/src/components/index.js:
--------------------------------------------------------------------------------
1 | export * from "./Icons";
2 | export { default as HeaderComponent } from "./Header";
3 | export { default as InputComponent } from "./Input";
4 | export { default as ButtonComponent } from "./Button";
5 | export { default as FooterComponent } from "./Footer";
6 | export { default as HeaderBoxComponent } from "./HeaderBox";
7 | export { default as LoadingComponent } from "./Loading";
8 | export { default as ModalComponent } from "./Modal";
9 | export { default as PieChartComponent } from "./PieChart";
10 | export { default as BarChartComponent } from "./BarChart";
11 | export { default as LineChartComponent } from "./LineChart";
12 | export { default as ChartLegendComponent } from "./ChartLegend";
13 | export { default as DropdownComponent } from "./Dropdown";
14 | export { default as BigModalComponent } from "./BigModal";
15 | export { default as DetailItemComponent } from "./DetailItem";
16 | export { default as MonthLegendComponent } from "./MonthLegend";
17 | export { default as CheckboxComponent } from "./Checkbox";
18 | export { default as HeaderInfoComponent } from "./HeaderInfo";
19 | export { default as TooltipComponent } from "./Tooltip";
20 | export { default as RadioButtonComponent } from "./RadioButton";
21 | export { default as TabsComponent } from "./Tabs";
22 | export { default as SearchComponent } from "./Search";
23 |
--------------------------------------------------------------------------------
/src/components/Icons/Dollar.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const Dollar = () => (
4 |
11 |
15 |
16 | );
17 |
18 | export default Dollar;
19 |
--------------------------------------------------------------------------------
/src/components/DetailItem/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | export const Container = styled.div`
4 | display: flex;
5 | border-bottom: 1px solid #aeaeae;
6 | margin-bottom: 10px;
7 | padding: 10px 0;
8 | flex-direction: row;
9 | `;
10 |
11 | export const Row = styled.div`
12 | display: flex;
13 | `;
14 |
15 | export const Title = styled.h3`
16 | display: flex;
17 | flex: 1;
18 | font-family: Roboto;
19 | font-style: normal;
20 | font-weight: normal;
21 | font-size: 13px;
22 | margin-bottom: 0px;
23 | `;
24 |
25 | export const Amount = styled.div`
26 | display: flex;
27 | font-family: Roboto;
28 | font-style: normal;
29 | font-weight: normal;
30 | font-size: 13px;
31 | `;
32 |
33 | export const Date = styled.div`
34 | display: flex;
35 | flex: 1;
36 | font-family: Roboto;
37 | font-style: normal;
38 | font-weight: 300;
39 | font-size: 13px;
40 | `;
41 |
42 | export const SubTitle = styled.div`
43 | display: flex;
44 | font-family: Roboto;
45 | font-style: normal;
46 | font-weight: 300;
47 | font-size: 13px;
48 | `;
49 |
50 | export const Content = styled.div`
51 | display: flex;
52 | flex-direction: column;
53 | flex: 1;
54 | `;
55 |
56 | export const ActionContainer = styled.div`
57 | display: flex;
58 | flex-direction: column;
59 | justify-content: center;
60 | align-items: center;
61 | padding-left: 18px;
62 | `;
--------------------------------------------------------------------------------
/src/utils/login.js:
--------------------------------------------------------------------------------
1 | import { checkCredentials, createDoc } from "services";
2 | import { setUserSession } from "config/localStorage";
3 | import Utils from "lib/utils";
4 |
5 | const setDoc = async ({
6 | access_token,
7 | refresh_token,
8 | expires_at,
9 | spreadsheetId,
10 | name,
11 | }) => {
12 | try {
13 | const user = {
14 | access_token,
15 | refresh_token,
16 | expires_at,
17 | spreadsheetId,
18 | name,
19 | };
20 | const newDoc = await createDoc(
21 | access_token,
22 | refresh_token,
23 | expires_at,
24 | spreadsheetId
25 | );
26 | setUserSession(user);
27 | return newDoc;
28 | } catch (error) {
29 | throw error;
30 | }
31 | };
32 |
33 | export const checkUser = async ({
34 | access_token,
35 | expires_at,
36 | refresh_token,
37 | id_token,
38 | spreadsheetId,
39 | name,
40 | createDoc,
41 | }) => {
42 | const normalizedId = createDoc
43 | ? null
44 | : Utils.Common.getSpreadsheetId(spreadsheetId);
45 | try {
46 | const user = {
47 | access_token,
48 | expires_at,
49 | refresh_token,
50 | id_token,
51 | spreadsheetId: normalizedId,
52 | name,
53 | };
54 | const valid = await checkCredentials(user);
55 | if (valid) {
56 | return await setDoc(user);
57 | } else {
58 | return false;
59 | }
60 | } catch (error) {
61 | console.log("ERROR: ", error);
62 | return false;
63 | }
64 | };
65 |
--------------------------------------------------------------------------------
/src/components/BarChart/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | BarChart,
4 | Bar,
5 | XAxis,
6 | CartesianGrid,
7 | Tooltip,
8 | ResponsiveContainer,
9 | } from "recharts";
10 | import * as S from "./styles";
11 | import Utils from "lib/utils";
12 |
13 | const { formatMoney } = Utils.Currency;
14 |
15 | const CustomTooltip = (props) => {
16 | const amount = props?.payload[0]?.value || 0;
17 | return {`$${formatMoney(amount)}`} ;
18 | };
19 |
20 | const BarChartComponent = ({ data, isLoading }) => {
21 | const chartHeight = 210;
22 |
23 | return (
24 |
25 | {isLoading ? (
26 | Loading...
27 | ) : (
28 |
29 |
30 |
35 |
36 |
42 |
43 |
44 | )}
45 |
46 | );
47 | };
48 |
49 | export default BarChartComponent;
50 |
--------------------------------------------------------------------------------
/src/components/HeaderInfo/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { HeaderBoxComponent } from "components";
3 | import * as S from "./styles";
4 | import { Carousel } from "react-responsive-carousel";
5 | import "react-responsive-carousel/lib/styles/carousel.min.css"; // requires a loader
6 |
7 | const HeaderInfo = ({ data }) => {
8 | return (
9 |
10 | (
17 |
18 | )}
19 | >
20 | {data ? (
21 | data
22 | .filter((item) => item !== null)
23 | .map(
24 | (
25 | { primaryValue, secondaryValue, title, arrowIcon, info },
26 | index
27 | ) => (
28 |
36 | )
37 | )
38 | ) : (
39 |
40 | )}
41 |
42 |
43 | );
44 | };
45 |
46 | export default HeaderInfo;
47 |
--------------------------------------------------------------------------------
/src/components/Icons/ArrowIndicator.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const ArrowIndicator = ({ up = true }) => (
4 |
11 |
12 | {up ? (
13 |
17 | ) : (
18 |
22 | )}
23 |
24 | );
25 |
26 | export default ArrowIndicator;
27 |
--------------------------------------------------------------------------------
/src/components/Footer/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Container, MenuItem } from "./styles";
3 | import { useHistory, useLocation } from "react-router-dom";
4 | import { PieChartIcon, LineChartIcon, HomeIcon, GearIcon } from "../";
5 |
6 | const Footer = () => {
7 | const history = useHistory();
8 | const location = useLocation();
9 |
10 | const isActive = (navItem) => location.pathname.includes(navItem);
11 |
12 | const handleNavigate = (route) => {
13 | history.push(route);
14 | };
15 |
16 | const SHOW_CONFIG_MENU = process.env.REACT_APP_SHOW_CONFIG_MENU === "true";
17 |
18 | return (
19 |
20 | handleNavigate("/types")}
22 | className={isActive("types") ? "active" : ""}
23 | >
24 |
25 |
26 | handleNavigate("/home")}
28 | className={isActive("home") ? "active" : ""}
29 | >
30 |
31 |
32 | handleNavigate("/analytics")}
34 | className={isActive("analytics") ? "active" : ""}
35 | >
36 |
37 |
38 |
39 | {SHOW_CONFIG_MENU && (
40 | handleNavigate("/config")}
42 | className={isActive("config") ? "active" : ""}
43 | >
44 |
45 |
46 | )}
47 |
48 | );
49 | };
50 |
51 | export default Footer;
52 |
--------------------------------------------------------------------------------
/src/components/Checkbox/styles.js:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from "styled-components";
2 |
3 | const rotate = keyframes`
4 | from {
5 | opacity: 0;
6 | transform: rotate(0deg);
7 | }
8 | to {
9 | opacity: 1;
10 | transform: rotate(45deg);
11 | }
12 | `;
13 |
14 | export const Cotnainer = styled.div`
15 | position: relative;
16 | `;
17 |
18 | export const Label = styled.label`
19 | display: flex;
20 | width: fit-content;
21 | gap: 10px;
22 | cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
23 | align-items: center;
24 | `;
25 |
26 | export const CheckBox = styled.input`
27 | height: 0;
28 | width: 0;
29 | opacity: 0;
30 | z-index: -1;
31 | `;
32 |
33 | export const Indicator = styled.div`
34 | width: 12px;
35 | height: 12px;
36 | background: ${(props) =>
37 | props.checked ? props.color : "#d9d9d9"} !important;
38 | background-color: ${(props) =>
39 | props.checked ? props.color : "#d9d9d9"} !important;
40 | position: relative;
41 | border: none;
42 | border-radius: 2px;
43 |
44 | ${Label}:hover & {
45 | background: #ccc;
46 | }
47 |
48 | &::after {
49 | content: "";
50 | position: absolute;
51 | display: none;
52 | }
53 |
54 | ${CheckBox}:checked + &::after {
55 | display: block;
56 | top: 0.1em;
57 | left: 0.28em;
58 | width: 35%;
59 | height: 70%;
60 | border: solid #fff;
61 | border-width: 0 0.15em 0.15em 0;
62 | animation-name: ${rotate};
63 | animation-duration: 0.3s;
64 | animation-fill-mode: forwards;
65 | }
66 |
67 | &::disabled {
68 | cursor: not-allowed;
69 | }
70 | `;
71 |
--------------------------------------------------------------------------------
/src/components/HeaderBox/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | const Container = styled.div`
4 | display: flex;
5 | justify-content: center;
6 | align-items: center;
7 | min-height: 80px;
8 | `;
9 |
10 | const Content = styled.div`
11 | background: #ffffff;
12 | border-radius: 7px;
13 | padding: 7px 15px;
14 | margin: 7px;
15 | display: flex;
16 | width: fit-content;
17 | flex-direction: column;
18 | text-align: left;
19 | position: relative;
20 | `;
21 |
22 | const Title = styled.h2`
23 | font-family: Roboto;
24 | font-style: normal;
25 | font-weight: normal;
26 | font-size: 12px;
27 | color: #333;
28 | line-height: 28px;
29 | margin: 0px;
30 | `;
31 |
32 | const PrimaryValue = styled.h3`
33 | font-family: Roboto;
34 | font-style: normal;
35 | font-weight: bold;
36 | font-size: 22px;
37 | color: #333;
38 | line-height: 28px;
39 | margin: 0px;
40 | `;
41 |
42 | const SecondaryValue = styled.h3`
43 | font-family: Roboto;
44 | font-style: normal;
45 | font-weight: 300;
46 | font-size: 12px;
47 | color: #aaaaaa;
48 | display: flex;
49 | align-items: center;
50 | > svg {
51 | margin-right: 3px;
52 | }
53 | `;
54 |
55 | const Info = styled.div`
56 | background-color: #999999;
57 | border-radius: 50%;
58 | width: 12px;
59 | height: 12px;
60 | position: absolute;
61 | right: 0px;
62 | top: 7px;
63 | display: flex;
64 | justify-content: center;
65 | align-items: center;
66 |
67 | svg {
68 | width: 3px;
69 | path {
70 | fill: #fff;
71 | }
72 | }
73 | `;
74 |
75 | export { Container, Content, PrimaryValue, SecondaryValue, Title, Info };
76 |
--------------------------------------------------------------------------------
/src/components/DetailItem/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import * as S from "./styles";
3 | import { DeleteIcon } from "components";
4 | import "react-loading-skeleton/dist/skeleton.css";
5 | import Skeleton from "react-loading-skeleton";
6 |
7 | const DetailItem = ({
8 | title,
9 | description,
10 | amount,
11 | subTitle,
12 | deleteAction,
13 | isLoading,
14 | }) => {
15 | const SkeletonComp = () => (
16 |
17 | );
18 |
19 | return (
20 |
21 |
22 |
23 | {isLoading ? : title}
24 | {isLoading ? : amount}
25 |
26 |
27 | {isLoading ? : description}
28 | {isLoading ? : subTitle}
29 |
30 |
31 | {deleteAction && (
32 |
33 | {isLoading ? (
34 |
40 | ) : (
41 | {
43 | e.preventDefault();
44 | e.stopPropagation();
45 | deleteAction();
46 | }}
47 | >
48 |
49 |
50 | )}
51 |
52 | )}
53 |
54 | );
55 | };
56 |
57 | export default DetailItem;
58 |
--------------------------------------------------------------------------------
/src/context/reducers/User.js:
--------------------------------------------------------------------------------
1 | import { DispatchTypes } from "../";
2 |
3 | const internalUserInitialState = {
4 | error: null,
5 | user: null,
6 | loading: false,
7 | doc: null,
8 | app: null,
9 | };
10 |
11 | export const userInitialState = { ...internalUserInitialState };
12 |
13 | const UserReducer = (currentState, action) => {
14 | switch (action.type) {
15 | case DispatchTypes.User.SET_USER_START:
16 | currentState.loading = true;
17 | return { ...currentState };
18 | case DispatchTypes.User.SET_USER_SUCCESS:
19 | currentState.user = action.user;
20 | currentState.loading = false;
21 | return { ...currentState };
22 | case DispatchTypes.User.SET_USER_FINISH:
23 | currentState.loading = false;
24 | return { ...currentState };
25 | case DispatchTypes.User.GET_DOC_START:
26 | currentState.loading = true;
27 | return { ...currentState };
28 | case DispatchTypes.User.GET_DOC_SUCCESS:
29 | currentState.doc = action.doc;
30 | currentState.loading = false;
31 | return { ...currentState };
32 | case DispatchTypes.User.GET_DOC_ERROR:
33 | currentState.error = action.error;
34 | currentState.loading = false;
35 | return { ...currentState };
36 | case DispatchTypes.User.SET_APP:
37 | currentState.loading = false;
38 | currentState.app = action.app;
39 | return { ...currentState };
40 | case DispatchTypes.Global.RESET:
41 | currentState = {
42 | error: null,
43 | user: null,
44 | loading: false,
45 | doc: null,
46 | };
47 | return { ...currentState };
48 | default:
49 | return currentState;
50 | }
51 | };
52 |
53 | export default UserReducer;
54 |
--------------------------------------------------------------------------------
/src/router/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
3 | import { MasterLayout } from "layouts";
4 | import {
5 | SplashScreen,
6 | OnboardingScreen,
7 | MainScreen,
8 | TypeScreen,
9 | AnalyticsScreen,
10 | GuideScreen,
11 | ConfigScreen,
12 | } from "screens";
13 |
14 | const RouterComponent = ({ children }) => (
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | );
53 |
54 | export default RouterComponent;
55 |
--------------------------------------------------------------------------------
/src/components/RadioButton/styles.js:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from "styled-components";
2 |
3 | const rotate = keyframes`
4 | from {
5 | opacity: 0;
6 | transform: rotate(0deg);
7 | }
8 | to {
9 | opacity: 1;
10 | transform: rotate(45deg);
11 | }
12 | `;
13 |
14 | export const Container = styled.div`
15 | position: relative;
16 | display: flex;
17 | flex-direction: column;
18 | gap: 15px;
19 | margin-top: 10px;
20 | `;
21 |
22 | export const Label = styled.label`
23 | display: flex;
24 | width: fit-content;
25 | gap: 10px;
26 | cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
27 | align-items: center;
28 | display: flex;
29 | align-items: flex-start;
30 | `;
31 |
32 | export const RadioButton = styled.input`
33 | height: 0;
34 | width: 0;
35 | opacity: 0;
36 | z-index: -1;
37 | `;
38 |
39 | export const Indicator = styled.div`
40 | margin-top: 5px;
41 | padding-right: 12px;
42 | width: 12px;
43 | height: 12px;
44 | background: ${(props) =>
45 | props.checked ? props.color : "#d9d9d9"} !important;
46 | background-color: ${(props) =>
47 | props.checked ? props.color : "#d9d9d9"} !important;
48 | position: relative;
49 | border: none;
50 | border-radius: 2px;
51 |
52 | ${Label}:hover & {
53 | background: #ccc;
54 | }
55 |
56 | &::after {
57 | content: "";
58 | position: absolute;
59 | display: none;
60 | }
61 |
62 | ${RadioButton}:checked + &::after {
63 | display: block;
64 | top: 0.1em;
65 | left: 0.28em;
66 | width: 35%;
67 | height: 70%;
68 | border: solid #fff;
69 | border-width: 0 0.15em 0.15em 0;
70 | animation-name: ${rotate};
71 | animation-duration: 0.3s;
72 | animation-fill-mode: forwards;
73 | }
74 |
75 | &::disabled {
76 | cursor: not-allowed;
77 | }
78 | `;
79 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bills-tracker",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@devexpress/dx-react-chart": "^2.7.1",
7 | "@devexpress/dx-react-chart-material-ui": "^2.7.1",
8 | "@devexpress/dx-react-core": "^2.7.1",
9 | "@material-ui/core": "^4.9.9",
10 | "@material-ui/lab": "^4.0.0-alpha.48",
11 | "@react-oauth/google": "^0.8.0",
12 | "@testing-library/jest-dom": "^4.2.4",
13 | "@testing-library/react": "^9.3.2",
14 | "@testing-library/user-event": "^7.1.2",
15 | "firebase": "^9.14.0",
16 | "google-auth-library": "^8.0.2",
17 | "google-spreadsheet": "^4.1.1",
18 | "math-expression-evaluator": "^2.0.5",
19 | "react": "^16.13.1",
20 | "react-dom": "^16.13.1",
21 | "react-google-button": "^0.7.2",
22 | "react-google-login": "^5.2.2",
23 | "react-loading-skeleton": "^3.3.1",
24 | "react-lottie-player": "^1.5.4",
25 | "react-responsive-carousel": "^3.2.23",
26 | "react-router-dom": "^5.2.0",
27 | "react-scripts": "3.4.0",
28 | "react-select": "^4.3.1",
29 | "react-swipeable": "^7.0.0",
30 | "react-toastify": "^7.0.3",
31 | "recharts": "^2.0.10",
32 | "styled-components": "^5.1.0",
33 | "typeface-roboto": "0.0.75",
34 | "uuid": "^9.0.0"
35 | },
36 | "scripts": {
37 | "start": "react-scripts start -e .env",
38 | "build": "react-scripts build",
39 | "test": "react-scripts test",
40 | "eject": "react-scripts eject",
41 | "start-sw": "http-server ./build"
42 | },
43 | "eslintConfig": {
44 | "extends": "react-app"
45 | },
46 | "browserslist": {
47 | "production": [
48 | ">0.2%",
49 | "not dead",
50 | "not op_mini all"
51 | ],
52 | "development": [
53 | "last 1 chrome version",
54 | "last 1 firefox version",
55 | "last 1 safari version"
56 | ]
57 | },
58 | "devDependencies": {
59 | "dotenv": "^8.2.0",
60 | "http-proxy-middleware": "^1.0.3",
61 | "http-server": "^14.1.1",
62 | "netlify-lambda": "^1.6.3",
63 | "npm-run-all": "^4.1.5"
64 | }
65 | }
--------------------------------------------------------------------------------
/src/components/Icons/LineChart.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const LineChart = () => (
4 |
11 |
15 |
16 | );
17 |
18 | export default LineChart;
19 |
--------------------------------------------------------------------------------
/src/components/Search/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | export const Container = styled.div`
4 | display: flex;
5 | position: fixed;
6 | background-color: #00000075;
7 | justify-content: center;
8 | top: 0px;
9 | left: 0px;
10 | height: 100vh;
11 | width: 100vw;
12 | z-index: 9999;
13 | padding: 25px;
14 | `;
15 |
16 | export const Content = styled.div`
17 | display: flex;
18 | flex-direction: column;
19 | height: fit-content;
20 | max-height: 100%;
21 | width: 100%;
22 | gap: 15px;
23 | z-index: 99;
24 | `;
25 |
26 | export const SearchInputContainer = styled.div`
27 | border-radius: 6px;
28 | background: #fff;
29 | height: 34px;
30 | padding: 7px 8px;
31 | display: flex;
32 | gap: 6px;
33 | align-items: center;
34 | `;
35 |
36 | export const SearchInput = styled.input`
37 | font-family: Roboto;
38 | border: none;
39 | outline: none;
40 | font-size: 14px;
41 | color: #222;
42 | font-weight: normal;
43 | flex: 1;
44 | ::placeholder {
45 | font-weight: 200;
46 | color: #7e7e7e;
47 | }
48 | &:focus {
49 | outline: none;
50 | ::placeholder {
51 | color: #000;
52 | }
53 | }
54 | `;
55 |
56 | export const SearchResult = styled.div`
57 | border-radius: 6px;
58 | background: #fff;
59 | padding: 15px;
60 | height: fit-content;
61 | max-height: 100%;
62 | overflow-y: scroll;
63 | `;
64 |
65 | export const CloseContainer = styled.div`
66 | height: 100%;
67 | display: flex;
68 | justify-content: center;
69 | align-items: center;
70 | padding-left: 6px;
71 | `;
72 |
73 | export const NoResultsMessage = styled.div`
74 | display: flex;
75 | justify-content: center;
76 | align-items: center;
77 | font-weight: 200;
78 | color: #7e7e7e;
79 | flex: 1;
80 | flex-direction: column;
81 | gap: 20px;
82 | height: 100%;
83 | > div {
84 | height: 170px;
85 | }
86 | img {
87 | width: 100%;
88 | padding: 0 30px;
89 | }
90 | `;
91 |
92 | export const CountContainer = styled.div`
93 | color: white;
94 | display: flex;
95 | justify-content: space-between;
96 | p {
97 | margin: 0;
98 | }
99 | > div:last-child {
100 | text-align: right;
101 | }
102 | `;
103 |
--------------------------------------------------------------------------------
/src/components/Icons/Gear.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const Gear = () => (
4 |
11 |
15 |
16 | );
17 |
18 | export default Gear;
19 |
20 |
--------------------------------------------------------------------------------
/src/layouts/Master/index.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-use-before-define */
2 | import React, { useContext, useEffect, useCallback } from "react";
3 | import { Content, Container } from "./styles";
4 | import {
5 | HeaderComponent,
6 | FooterComponent,
7 | ModalComponent,
8 | SearchComponent,
9 | } from "components";
10 | import { GlobalContext, DispatchTypes } from "context";
11 | import { getUserSession } from "config/localStorage";
12 | import { withRouter } from "react-router";
13 | import { checkUser } from "utils/login";
14 |
15 | const Master = ({
16 | children,
17 | footer = true,
18 | title,
19 | subTitle,
20 | allowSignOut,
21 | history,
22 | }) => {
23 | const context = useContext(GlobalContext);
24 | const [modalState] = context.globalModal;
25 | const [searchState] = context.globalSearch;
26 | const [, userDispatch] = context.globalUser;
27 |
28 | const createDoc = useCallback(
29 | async (user) => {
30 | userDispatch({
31 | type: DispatchTypes.User.GET_DOC_START,
32 | });
33 | try {
34 | const newDoc = await checkUser(user);
35 | if (newDoc) {
36 | userDispatch({
37 | type: DispatchTypes.User.GET_DOC_SUCCESS,
38 | doc: newDoc,
39 | });
40 | }
41 | } catch (error) {
42 | userDispatch({
43 | type: DispatchTypes.User.GET_DOC_ERROR,
44 | error,
45 | });
46 | }
47 | },
48 | [userDispatch]
49 | );
50 |
51 | useEffect(() => {
52 | const userFromStorage = getUserSession();
53 | if (userFromStorage) {
54 | userDispatch({
55 | type: DispatchTypes.User.SET_USER_SUCCESS,
56 | user: userFromStorage,
57 | });
58 | createDoc(userFromStorage);
59 | } else {
60 | history.push("/onboarding");
61 | }
62 | }, [createDoc, history, userDispatch]);
63 |
64 | return (
65 |
66 |
71 | {children}
72 | {footer && }
73 | {modalState.show && (
74 |
79 | )}
80 | {searchState.show && }
81 |
82 | );
83 | };
84 |
85 | export default withRouter(Master);
86 |
--------------------------------------------------------------------------------
/src/screens/Data/Chart.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Paper from "@material-ui/core/Paper";
3 | import {
4 | Chart,
5 | ArgumentAxis,
6 | ValueAxis,
7 | BarSeries,
8 | Title,
9 | Tooltip,
10 | } from "@devexpress/dx-react-chart-material-ui";
11 |
12 | import {
13 | Animation,
14 | EventTracker,
15 | ValueScale,
16 | } from "@devexpress/dx-react-chart";
17 |
18 | const cleanAmmount = (amount) => amount.replace(/[$,.]/g, "");
19 |
20 | const formatData = (data) => {
21 | const result = [];
22 | const formattedData = [...data].reduce((result, currentValue) => {
23 | (result[currentValue["Date"].substring(3, 10)] =
24 | result[currentValue["Date"].substring(3, 10)] || []).push({
25 | Amount: cleanAmmount(currentValue.Amount),
26 | Date: currentValue.Date,
27 | Detail: currentValue.Detail,
28 | Type: currentValue.Type,
29 | Who: currentValue.Who,
30 | });
31 | return result;
32 | }, []);
33 |
34 | const keysForClean = [
35 | "get Amount",
36 | "set Amount",
37 | "get Date",
38 | "set Date",
39 | "get Detail",
40 | "set Detail",
41 | "get Type",
42 | "set Type",
43 | "get Who",
44 | "set Who",
45 | "_rawData",
46 | "_rowNumber",
47 | "_sheet",
48 | ];
49 |
50 | const avoidKeys = ["Amount", "Date", "Detail", "Type", "Who"];
51 |
52 | keysForClean.forEach((item) => {
53 | delete formattedData[item];
54 | });
55 |
56 | Object.keys(formattedData).map((date) => {
57 | if (!avoidKeys.includes(date)) {
58 | result.push({
59 | date: date,
60 | amount: formattedData[date]
61 | .map(({ Amount }) => parseInt(Amount))
62 | .reduce((a, b) => a + b, 0),
63 | });
64 | }
65 | });
66 |
67 | return result;
68 | };
69 |
70 | const LabelResumed = (props) => (
71 |
75 | );
76 |
77 | const ChartComponent = (props) => {
78 | const { data } = props;
79 |
80 | const chartData = formatData(data);
81 |
82 | return (
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 | );
95 | };
96 |
97 | export default ChartComponent;
98 |
--------------------------------------------------------------------------------
/src/components/Input/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import * as S from "./styles";
3 |
4 | import {
5 | DollarIcon,
6 | CheckboxComponent,
7 | RadioButtonComponent,
8 | } from "components";
9 |
10 | const Input = ({
11 | name,
12 | title,
13 | value,
14 | onChange,
15 | type = "text",
16 | placeholder = "",
17 | options = [],
18 | disabled,
19 | isSearchable,
20 | }) => {
21 | const handleChangeInput = (e) => onChange(e.target.name, e.target.value);
22 | const handleChangeDropdown = ({ value }) => onChange(name, value);
23 |
24 | const defaultProps = {
25 | name,
26 | placeholder,
27 | onChange:
28 | type === "creatableDropdown" || type === "dropdown"
29 | ? handleChangeDropdown
30 | : handleChangeInput,
31 | value,
32 | disabled,
33 | isSearchable,
34 | };
35 |
36 | return (
37 |
38 |
39 | {type === "money" ? (
40 |
41 | ) : (
42 | {title}
43 | )}
44 |
45 |
46 | {
47 | {
48 | text: ,
49 | date: ,
50 | money: (
51 |
56 | ),
57 | creatableDropdown: (
58 |
63 | ),
64 | dropdown: ,
65 | bigtext: ,
66 | textarea: ,
67 | checkbox: (
68 |
75 | ),
76 | option: (
77 |
84 | ),
85 | }[type]
86 | }
87 |
88 |
89 | );
90 | };
91 |
92 | export default Input;
93 |
--------------------------------------------------------------------------------
/src/components/LineChart/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | LineChart,
4 | Line,
5 | XAxis,
6 | CartesianGrid,
7 | Tooltip,
8 | ResponsiveContainer,
9 | } from "recharts";
10 |
11 | import * as S from "./styles";
12 | import Utils from "lib/utils";
13 |
14 | const { formatMoney } = Utils.Currency;
15 | const { monthToText } = Utils.Date;
16 |
17 | const CustomTooltip = (props) => {
18 | const categories = props?.categories?.map(({ key }) => key);
19 | const data = { ...props?.payload[0]?.payload };
20 | const name = props?.payload[0]?.payload?.name || "";
21 | const year = props?.payload[0]?.payload?.year || "";
22 | if (data) {
23 | const monthText = `${monthToText(parseInt(name) - 1)?.substring(0, 3)}.`;
24 | return (
25 |
26 |
27 | {monthText} {year}
28 |
29 |
30 | {Object.keys(data)
31 | .sort((a, b) => (data[a] > data[b] ? -1 : 1))
32 | .filter((e) => categories.includes(e))
33 | .map((e, index) => (
34 |
35 | {e}
36 | {`$${formatMoney(data[e] || 0)}`}
37 |
38 | ))}
39 |
40 |
41 | );
42 | } else {
43 | return <>>;
44 | }
45 | };
46 |
47 | const LineChartComponent = ({ data, isLoading, categories }) => {
48 | const chartHeight = 210;
49 | return (
50 |
51 | {isLoading ? (
52 | Loading...
53 | ) : (
54 |
55 |
56 |
67 |
68 |
74 | {categories.map(({ key, color }) => (
75 |
82 | ))}
83 |
84 | )}
85 |
86 | );
87 | };
88 |
89 | export default LineChartComponent;
90 |
--------------------------------------------------------------------------------
/src/screens/Splash/index.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useEffect, useState, useCallback } from "react";
2 | import { GlobalContext, DispatchTypes } from "context";
3 | import { withRouter } from "react-router";
4 | import { Container, Content, Title } from "./styles";
5 | import Logo from "rsc/img/logo.svg";
6 | import { getUserSession } from "config/localStorage";
7 | import { checkUser } from "utils/login";
8 |
9 | const Splash = (props) => {
10 | const [newRoute, setNewRoute] = useState(null);
11 | const [splashFinish, setSplashFinish] = useState(false);
12 |
13 | const { history } = props;
14 |
15 | const context = useContext(GlobalContext);
16 | const [userState, userDispatch] = context.globalUser;
17 |
18 | useEffect(() => {
19 | userDispatch({
20 | type: DispatchTypes.User.SET_USER_START,
21 | });
22 | const userFromStorage = getUserSession();
23 | if (userFromStorage) {
24 | userDispatch({
25 | type: DispatchTypes.User.SET_USER_SUCCESS,
26 | user: userFromStorage,
27 | });
28 | } else {
29 | userDispatch({
30 | type: DispatchTypes.User.SET_USER_FINISH,
31 | });
32 | }
33 | setTimeout(() => {
34 | setSplashFinish(true);
35 | }, 1000);
36 | }, [userDispatch]);
37 |
38 | const createDoc = useCallback(
39 | async (user) => {
40 | userDispatch({
41 | type: DispatchTypes.User.GET_DOC_START,
42 | });
43 | try {
44 | const newDoc = await checkUser(user);
45 | if (newDoc) {
46 | userDispatch({
47 | type: DispatchTypes.User.GET_DOC_SUCCESS,
48 | doc: newDoc,
49 | });
50 | } else {
51 | setNewRoute("/onboarding");
52 | }
53 | } catch (error) {
54 | userDispatch({
55 | type: DispatchTypes.User.GET_DOC_ERROR,
56 | error,
57 | });
58 | }
59 | },
60 | [userDispatch]
61 | );
62 |
63 | useEffect(() => {
64 | if (userState) {
65 | const { user, doc, loading } = userState;
66 | if (!loading) {
67 | if (user && doc === null) {
68 | createDoc(user);
69 | } else if (doc !== null) {
70 | setNewRoute("/home");
71 | } else {
72 | setNewRoute("/onboarding");
73 | }
74 | }
75 | }
76 | }, [createDoc, userState]);
77 |
78 | useEffect(() => {
79 | if (newRoute && splashFinish) {
80 | history.push(newRoute);
81 | }
82 | }, [newRoute, splashFinish, history]);
83 |
84 | return (
85 |
86 |
87 |
88 | BillsTracker
89 |
90 |
91 | );
92 | };
93 |
94 | export default withRouter(Splash);
95 |
--------------------------------------------------------------------------------
/src/components/Header/index.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useContext } from "react";
2 | import { Main, TitleContainer, ActionContainer } from "./styles";
3 | import { SignOutIcon, MenuSearchIcon } from "../";
4 | import { deleteUserSession } from "config/localStorage";
5 | import { useHistory } from "react-router-dom";
6 | import { GlobalContext, DispatchTypes } from "context";
7 | import { googleLogout } from "@react-oauth/google";
8 |
9 | const Header = ({
10 | title = "BillsTracker",
11 | subTitle = "Home",
12 | allowSignOut = true,
13 | }) => {
14 | const context = useContext(GlobalContext);
15 | const [, userDispatch] = context.globalUser;
16 | const [, modalDispatch] = context.globalModal;
17 | const [, searchDispatch] = context.globalSearch;
18 |
19 | const history = useHistory();
20 |
21 | const signOutAction = () => {
22 | googleLogout();
23 | deleteUserSession();
24 | userDispatch({
25 | type: DispatchTypes.Global.RESET,
26 | });
27 | history.push("/");
28 | };
29 |
30 | const [colorChange, setColorchange] = useState(false);
31 |
32 | const changeNavbarColor = () => {
33 | setColorchange(window.scrollY >= 10);
34 | };
35 |
36 | window.addEventListener("scroll", changeNavbarColor);
37 |
38 | const handleSignOut = () => {
39 | modalDispatch({
40 | type: DispatchTypes.Modal.MODAL_SHOW,
41 | title: "Are you sure?",
42 | content: "If you sign out, you will need to setup the credentials again.",
43 | actions: [
44 | {
45 | type: "secondary",
46 | text: "Cancel",
47 | action: () => {
48 | modalDispatch({ type: DispatchTypes.Modal.MODAL_HIDE });
49 | },
50 | },
51 | {
52 | type: "text",
53 | text: "Sign Out",
54 | action: () => {
55 | modalDispatch({ type: DispatchTypes.Modal.MODAL_HIDE });
56 | signOutAction();
57 | },
58 | },
59 | ],
60 | });
61 | };
62 |
63 | const handleSearch = () => {
64 | console.log("SHOW SEARCH");
65 | searchDispatch({
66 | type: DispatchTypes.Search.SEARCH_SHOW,
67 | });
68 | };
69 |
70 | return (
71 |
72 | {allowSignOut && (
73 |
74 |
75 |
76 |
77 |
78 | )}
79 |
80 | {title}
81 | {subTitle}
82 |
83 | {allowSignOut && (
84 | handleSignOut()}>
85 |
86 |
87 | )}
88 |
89 | );
90 | };
91 |
92 | export default Header;
93 |
--------------------------------------------------------------------------------
/src/screens/Guide/styles.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | export const Content = styled.div`
4 | height: calc(100vh - 125px);
5 | @media (display-mode: browser) {
6 | height: 100%;
7 | }
8 | display: flex;
9 | flex-direction: column;
10 | > div:first-child {
11 | flex: 1;
12 | }
13 | `;
14 |
15 | export const Container = styled.div`
16 | display: flex;
17 | flex-direction: column;
18 | height: 100%;
19 | overflow-y: scroll;
20 | width: 100%;
21 | `;
22 |
23 | export const ImageContainer = styled.div`
24 | margin: 20px 0;
25 | & img {
26 | width: 100%;
27 | }
28 | `;
29 |
30 | export const Title = styled.h1`
31 | font-family: "Roboto";
32 | font-weight: 400;
33 | font-size: 21px;
34 | `;
35 |
36 | export const Pharagraph = styled.p`
37 | font-family: "Roboto";
38 | font-weight: 300;
39 | font-size: 14px;
40 | `;
41 |
42 | export const Mark = styled.span`
43 | color: #38b44e;
44 | `;
45 |
46 | export const Strong = styled.p`
47 | font-family: "Roboto";
48 | font-weight: 500;
49 | font-size: 14px;
50 | `;
51 |
52 | export const OrderList = styled.ol`
53 | font-family: "Roboto";
54 | font-weight: 300;
55 | font-size: 14px;
56 | margin: 0;
57 | padding: 0 0 0 20px;
58 | `;
59 |
60 | export const Link = styled.a`
61 | color: #38b44e;
62 | cursor: pointer;
63 | `;
64 |
65 | export const Footer = styled.div`
66 | display: flex;
67 | justify-content: space-between;
68 | align-items: center;
69 | `;
70 |
71 | export const StepsContainer = styled.ul`
72 | display: flex;
73 | justify-content: center;
74 | align-items: center;
75 | flex: 3;
76 | margin: 0;
77 | padding: 0;
78 | height: 100%;
79 | `;
80 |
81 | export const Step = styled.li`
82 | display: block;
83 | background: ${(props) => (props.active ? "#38B44E" : "#C4C4C4")};
84 | border-radius: 50%;
85 | width: 12px;
86 | height: 12px;
87 | margin: 6px;
88 | `;
89 |
90 | export const Navigator = styled.div`
91 | color: #38b44e;
92 | font-family: "Roboto";
93 | font-weight: 500;
94 | font-size: 14px;
95 | flex: 1;
96 | text-align: ${(props) => props.next && "right"};
97 | height: 100%;
98 | cursor: pointer;
99 | `;
100 |
101 | export const GithubLink = styled.a`
102 | background: #333333;
103 | border-radius: 7px;
104 | font-family: "Roboto";
105 | font-weight: 400;
106 | font-size: 14px;
107 | line-height: 14px;
108 | align-items: center;
109 | text-align: center;
110 | color: #ffffff;
111 | text-decoration: none;
112 | padding: 6px;
113 | display: flex;
114 | justify-content: center;
115 | align-items: center;
116 | width: 60%;
117 | min-width: 180px;
118 | margin: 10px auto;
119 | & img {
120 | padding-right: 6px;
121 | }
122 | `;
123 |
--------------------------------------------------------------------------------
/src/lib/utils/date.js:
--------------------------------------------------------------------------------
1 | import { MONTHS } from "./constants";
2 |
3 | const addLeftValue = (value) => (`${value}`.length === 1 ? `0${value}` : value);
4 |
5 | const getDateSeparator = (date) => {
6 | return [...date].filter((c) => isNaN(parseInt(c)))[0];
7 | };
8 |
9 | export const todayDate = () => {
10 | const nDate = new Date();
11 | const day = addLeftValue(nDate.getDate());
12 | const month = addLeftValue(nDate.getMonth() + 1);
13 | const year = nDate.getFullYear();
14 | return `${year}-${month}-${day}`;
15 | };
16 |
17 | export const dateParser = (date) => {
18 | const splitDate = split(date);
19 | const newDate = new Date(`${splitDate[2]}-${splitDate[1]}-${splitDate[0]}`);
20 | return newDate;
21 | };
22 |
23 | export const nowYear = () => {
24 | const nowDate = new Date();
25 | return nowDate.getFullYear();
26 | };
27 |
28 | export const nowMonth = () => {
29 | const nowDate = new Date();
30 | const monthPlus = nowDate.getMonth() + 1;
31 | return monthPlus <= 9 ? `0${monthPlus}` : monthPlus;
32 | };
33 |
34 | export const pastMonthYear = () => {
35 | const nowDate = new Date();
36 | const nowMonth = nowDate.getMonth();
37 | const nowYear = nowDate.getFullYear();
38 |
39 | if (nowMonth === 0) {
40 | return {
41 | month: 12,
42 | year: nowYear - 1,
43 | };
44 | } else {
45 | const newMonth = nowMonth;
46 | return {
47 | month: newMonth <= 9 ? `0${newMonth}` : newMonth,
48 | year: nowYear,
49 | };
50 | }
51 | };
52 |
53 | export const monthToText = (month) => MONTHS[month];
54 |
55 | export const dateToText = (date) => {
56 | const splitDate = split(date);
57 | const day = splitDate[0];
58 | const month = parseInt(splitDate[1]) - 1;
59 | const year = splitDate[2];
60 |
61 | return `${day} ${monthToText(month)} ${year}`;
62 | };
63 |
64 | export const dateSort = (a, b) => {
65 | const dateASplit = split(a.Date);
66 | const dateBSplit = split(b.Date);
67 | const aNumber = parseInt(`${dateASplit[2]}${dateASplit[1]}${dateASplit[0]}`);
68 | const bNumber = parseInt(`${dateBSplit[2]}${dateBSplit[1]}${dateBSplit[0]}`);
69 |
70 | return bNumber - aNumber;
71 | };
72 |
73 | export const split = (date) => {
74 | if (date) {
75 | const dateSplitted = date.split(getDateSeparator(date));
76 | return dateSplitted[2].length === 4 ? dateSplitted : dateSplitted.reverse();
77 | }
78 | };
79 |
80 | export const month12Ago = () => {
81 | const today = new Date();
82 | const ago = new Date(today.setMonth(today.getMonth() - 11));
83 | const month = ago.getMonth();
84 | const year = ago.getFullYear();
85 | const newDate = new Date(year, month, 1);
86 | return newDate;
87 | };
88 |
89 | export const nextMonth = () => {
90 | const today = new Date();
91 | const next = new Date(today.setMonth(today.getMonth() + 2));
92 | const month = next.getMonth();
93 | return month;
94 | };
95 |
--------------------------------------------------------------------------------
/src/rsc/icons/github.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/src/services/configSpreadsheet.js:
--------------------------------------------------------------------------------
1 | import { sheetTitleConfig, sheetHeadersConfig } from "config/sheet";
2 | import { setSheetConfig, getSheetConfig } from "config/localStorage";
3 |
4 | const getSheet = async (doc) => {
5 | const sheet = doc.sheetsByTitle[sheetTitleConfig];
6 | if (sheet) {
7 | return sheet;
8 | } else {
9 | const newSheet = await doc.addSheet({
10 | title: sheetTitleConfig,
11 | headerValues: sheetHeadersConfig,
12 | });
13 | return newSheet;
14 | }
15 | };
16 |
17 | export const storeSheetData = async (doc) => {
18 | if (doc) {
19 | const sheet = await getSheet(doc);
20 | if (sheet) {
21 | const fetchedRows = await sheet.getRows();
22 | const mappedData = fetchedRows.map(({ _rawData, _rowNumber }) => {
23 | return {
24 | Name: _rawData[0],
25 | Type: _rawData[1],
26 | Frequency: _rawData[2],
27 | Amount: _rawData[3],
28 | Description: _rawData[4],
29 | Id: _rowNumber,
30 | };
31 | });
32 | setSheetConfig(mappedData);
33 | return mappedData;
34 | } else {
35 | return null;
36 | }
37 | } else {
38 | return null;
39 | }
40 | };
41 |
42 | export const getLocalSheetData = async (doc) => {
43 | const data = getSheetConfig();
44 | if (data) {
45 | return data;
46 | } else {
47 | return await storeSheetData(doc);
48 | }
49 | };
50 |
51 | export const addRow = async (doc, name, type, frequency, amount, description) => {
52 | if (doc) {
53 | const timestamp = new Date().getTime();
54 | const sheet = await getSheet(doc);
55 | const newRow = {
56 | Name: name,
57 | Type: type,
58 | Frequency: frequency,
59 | Amount: `$${amount}`,
60 | Description: description,
61 | Date: timestamp,
62 | };
63 | await sheet.addRow(newRow);
64 | const newData = await getLocalSheetData(doc);
65 | const lastId = newData[newData.length - 1]?.Id || 1;
66 | newData.push({ ...newRow, Id: lastId + 1 });
67 | setSheetConfig(newData);
68 | return newData;
69 | } else {
70 | return null;
71 | }
72 | };
73 |
74 | export const deleteRow = async (doc, id) => {
75 | if (doc) {
76 | const sheet = await getSheet(doc);
77 | if (sheet) {
78 | const fetchedRows = await sheet.getRows();
79 | const findById = fetchedRows.find(({ _rowNumber }) => _rowNumber === id);
80 | if (findById) {
81 | try {
82 | await findById.delete();
83 | const newData = await storeSheetData(doc);
84 | return newData;
85 | } catch (error) {
86 | console.log("Error (config deleteRow)", error);
87 | return false;
88 | }
89 | } else {
90 | return false;
91 | }
92 | } else {
93 | return false;
94 | }
95 | } else {
96 | return false;
97 | }
98 | };
99 |
--------------------------------------------------------------------------------
/src/components/ChartLegend/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { PieChart, Pie, Cell } from "recharts";
3 | import * as S from "./styles";
4 | import Utils from "lib/utils";
5 |
6 | const ChartLegend = ({ title, value, count, color, total, action }) => {
7 | const { formatMoney } = Utils.Currency;
8 |
9 | const data = [
10 | { name: "Value", value, color },
11 | { name: "Total", value: total - value, color: "#C4C4C4" },
12 | ];
13 |
14 | const percent = ((value * 100) / total).toFixed(1);
15 |
16 | return (
17 |
18 |
19 |
20 |
29 |
33 |
34 |
35 |
44 | {data.map(({ color }, index) => (
45 | |
46 | ))}
47 |
48 |
49 |
50 |
51 |
52 | {title}
53 | {`${percent}% of budget`}
54 |
55 |
56 | {`$${formatMoney(value)}`}
57 | {`${count} ${
58 | count > 1 ? "transactions" : "transaction"
59 | }`}
60 |
61 |
62 |
63 | );
64 | };
65 |
66 | export default ChartLegend;
67 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
28 |
29 |
33 |
34 |
43 | BillsTracker
44 |
50 |
54 |
55 |
56 |
61 |
65 |
66 |
67 | You need to enable JavaScript to run this app.
68 |
69 |
79 |
80 |
81 |
--------------------------------------------------------------------------------
/src/screens/Config/index.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useContext, useEffect, useCallback } from "react";
2 | import { NoHeaderLayout } from "layouts";
3 | import { TabsComponent, LoadingComponent } from "components";
4 | import * as S from "./styles";
5 | import Schedule from "./schedule";
6 | import Utils from "lib/utils";
7 | import { GlobalContext, DispatchTypes } from "context";
8 | import { storeSheetData } from "services/configSpreadsheet";
9 | import { getTypes } from "services";
10 |
11 | const Config = () => {
12 | const context = useContext(GlobalContext);
13 | const [userState] = context.globalUser;
14 | const [, modalDispatch] = context.globalModal;
15 |
16 | const { doc, loading } = userState;
17 |
18 | const [mainLoading, setMainLoading] = useState(true);
19 | const [menuItems, setMenuItems] = useState(Utils.Constants.MENU_ITEMS);
20 | const [billsTypes, setBillsTypes] = useState([]);
21 |
22 | const alertModal = useCallback(
23 | (title, content, actions) => {
24 | modalDispatch({
25 | type: DispatchTypes.Modal.MODAL_SHOW,
26 | title,
27 | content,
28 | actions: [
29 | {
30 | text: "Ok",
31 | action: () => {
32 | modalDispatch({ type: DispatchTypes.Modal.MODAL_HIDE });
33 | actions && actions();
34 | },
35 | },
36 | ],
37 | });
38 | },
39 | [modalDispatch]
40 | );
41 |
42 | const getStartData = useCallback(async () => {
43 | setMainLoading(true);
44 | try {
45 | await storeSheetData(doc);
46 | const types = await getTypes(doc);
47 | const typesFormatted = types.map((type) => ({
48 | value: type,
49 | label: type,
50 | }));
51 | setBillsTypes(typesFormatted);
52 | } catch (e) {
53 | console.log("getStartData ERROR", e);
54 | alertModal("Error", "There was an error. Please try again later.");
55 | setMainLoading(false);
56 | }
57 |
58 | setMainLoading(false);
59 | }, [alertModal, doc]);
60 |
61 | useEffect(() => {
62 | if (doc) {
63 | getStartData();
64 | }
65 | }, [getStartData, doc]);
66 |
67 | const menuAction = (key) => {
68 | const newMenuItems = Utils.Constants.MENU_ITEMS.map((item) => ({
69 | ...item,
70 | active: key === item.label,
71 | }));
72 | setMenuItems(newMenuItems);
73 | };
74 |
75 | const activeItem = menuItems.find(({ active }) => active).label;
76 |
77 | const isLoading = loading || mainLoading;
78 |
79 | return (
80 | <>
81 |
82 |
83 |
84 | {activeItem === Utils.Constants.SCHEDULE && (
85 |
93 | )}
94 | {activeItem === Utils.Constants.PROFILE && <>PROFILE>}
95 | {activeItem === Utils.Constants.BUDGET && <>BUDGET>}
96 |
97 |
98 | {isLoading && }
99 | >
100 | );
101 | };
102 |
103 | export default Config;
104 |
--------------------------------------------------------------------------------
/src/components/Search/index.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useEffect, useState, useCallback } from "react";
2 | import * as S from "./styles";
3 | import { GlobalContext, DispatchTypes } from "context";
4 | import { SearchIcon, CloseIcon, DetailItemComponent } from "components";
5 | import { getItemsByText } from "services";
6 | import Utils from "lib/utils";
7 | import EmptyBox from "rsc/img/emptybox.png";
8 |
9 | const { dateToText } = Utils.Date;
10 |
11 | const Search = () => {
12 | const context = useContext(GlobalContext);
13 | const [userState] = context.globalUser;
14 | const [searchValue, searchDispatch] = context.globalSearch;
15 | const [results, setResults] = useState([]);
16 | const [count, setCount] = useState(0);
17 | const [total, setTotal] = useState(0);
18 |
19 | const { doc, loading } = userState;
20 |
21 | const handleCloseSearch = (e) => {
22 | e.preventDefault();
23 | e.stopPropagation();
24 | searchDispatch({ type: DispatchTypes.Search.SEARCH_HIDE });
25 | };
26 |
27 | const handleSearchInput = (e) => {
28 | const value = e.target.value;
29 | if (value === "") {
30 | setCount(0);
31 | }
32 | searchDispatch({
33 | type: DispatchTypes.Search.SEARCH_INPUT,
34 | input: value,
35 | });
36 | };
37 |
38 | const getItems = async (doc, text) => {
39 | const items = await getItemsByText(doc, text);
40 | if (items) {
41 | setResults(items.results);
42 | setCount(items.count);
43 | setTotal(items.total);
44 | }
45 | };
46 |
47 | useEffect(() => {
48 | if (doc) {
49 | getItems(doc, searchValue.input);
50 | }
51 | }, [doc, searchValue.input]);
52 |
53 | const searchInput = useCallback((inputElement) => {
54 | if (inputElement) {
55 | inputElement.focus();
56 | }
57 | }, []);
58 |
59 | const handleStopPropagation = (e) => {
60 | e.stopPropagation();
61 | };
62 |
63 | const Result = () => {
64 | if (searchValue.input === "") {
65 | return Start typing for search ;
66 | }
67 |
68 | if (results.length === 0) {
69 | return (
70 |
71 |
72 |
73 |
74 | We could not find any matches.
75 |
76 | );
77 | }
78 |
79 | return (
80 |
81 | {results.map(({ Id, Amount, Date, Detail, Type }) => (
82 |
89 | ))}
90 |
91 | );
92 | };
93 |
94 | return (
95 |
96 |
97 | {!loading && (
98 |
99 |
100 |
107 |
108 |
109 |
110 |
111 | )}
112 |
113 | {count !== 0 && (
114 |
115 |
116 |
Showing
117 |
{results.length}/{count}
118 |
119 |
120 |
Total
121 |
${total}
122 |
123 |
124 | )}
125 |
126 |
127 |
128 |
129 |
130 |
131 | );
132 | };
133 | export default Search;
134 |
--------------------------------------------------------------------------------
/src/components/PieChart/index.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useCallback } from "react";
2 | import { PieChart, Pie, Cell, ResponsiveContainer, Sector } from "recharts";
3 |
4 | import Utils from "lib/utils";
5 |
6 | const { RADIAN } = Utils.Constants;
7 | const { formatMoney } = Utils.Currency;
8 | const { monthToText } = Utils.Date;
9 |
10 | const renderActiveShape = (props) => {
11 | const {
12 | cx,
13 | cy,
14 | midAngle,
15 | innerRadius,
16 | outerRadius,
17 | startAngle,
18 | endAngle,
19 | fill,
20 | percent,
21 | value,
22 | } = props;
23 | const sin = Math.sin(-RADIAN * midAngle);
24 | const cos = Math.cos(-RADIAN * midAngle);
25 | const sx = cx + (outerRadius + 6) * cos;
26 | const sy = cy + (outerRadius + 6) * sin;
27 | const mx = cx + (outerRadius + 15) * cos;
28 | const my = cy + (outerRadius + 15) * sin;
29 | const ex = mx + (cos >= 0 ? 1 : -1) * 22;
30 | const ey = my;
31 | const textAnchor = cos >= 0 ? "start" : "end";
32 |
33 | return (
34 |
35 |
44 |
53 |
58 |
59 | = 0 ? 1 : -1) * 6}
61 | y={ey}
62 | textAnchor={textAnchor}
63 | fill="#333"
64 | fontSize="11"
65 | >
66 | {`$${formatMoney(value)}`}
67 |
68 | = 0 ? 1 : -1) * 6}
70 | y={ey - 5}
71 | dy={18}
72 | textAnchor={textAnchor}
73 | fill="#999"
74 | fontSize="11"
75 | >
76 | {`${(percent * 100).toFixed(1)}%`}
77 |
78 |
79 | );
80 | };
81 |
82 | const PieChartComponent = ({ data, total, month, year, isLoading }) => {
83 | const chartHeight = 285;
84 |
85 | const textMonth = monthToText(parseInt(month)).toLowerCase();
86 |
87 | const [activeIndex, setActiveIndex] = useState(0);
88 | const onPieEnter = useCallback(
89 | (_, index) => {
90 | setActiveIndex(index);
91 | },
92 | [setActiveIndex]
93 | );
94 |
95 | return (
96 |
97 |
98 |
99 |
107 | {data[activeIndex]?.name}
108 |
109 |
117 | ${formatMoney(total)}
118 |
119 |
127 | {`${textMonth} ${year}`}
128 |
129 |
130 |
140 | {data.map(({ color }, index) => (
141 | |
142 | ))}
143 |
144 |
145 |
146 | );
147 | };
148 |
149 | export default PieChartComponent;
150 |
--------------------------------------------------------------------------------
/src/rsc/img/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/screens/Data/index.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useState, useEffect } from "react";
2 | import PropTypes from "prop-types";
3 | import { makeStyles } from "@material-ui/core/styles";
4 | import AppBar from "@material-ui/core/AppBar";
5 | import Tabs from "@material-ui/core/Tabs";
6 | import Tab from "@material-ui/core/Tab";
7 | import Typography from "@material-ui/core/Typography";
8 | import Box from "@material-ui/core/Box";
9 | import styled from "styled-components";
10 | import { GoogleSpreadsheet } from "google-spreadsheet";
11 | import { UserContext } from "context";
12 | import { dateParser } from "config/date";
13 |
14 | import Table from "./Table";
15 | import Chart from "./Chart";
16 |
17 | function TabPanel(props) {
18 | const { children, value, index, ...other } = props;
19 |
20 | return (
21 |
28 | {value === index && (
29 |
30 | {children}
31 |
32 | )}
33 |
34 | );
35 | }
36 |
37 | TabPanel.propTypes = {
38 | children: PropTypes.node,
39 | index: PropTypes.any.isRequired,
40 | value: PropTypes.any.isRequired,
41 | };
42 |
43 | function a11yProps(index) {
44 | return {
45 | id: `simple-tab-${index}`,
46 | "aria-controls": `simple-tabpanel-${index}`,
47 | };
48 | }
49 |
50 | const useStyles = makeStyles((theme) => ({
51 | root: {
52 | width: "100%",
53 | fontSize: "10px",
54 | display: "flex",
55 | margin: 0,
56 | height: "100%",
57 | padding: 0,
58 | flexDirection: "column",
59 | },
60 | }));
61 |
62 | const AbsoluteContainer = styled.div`
63 | position: absolute;
64 | display: flex;
65 | background-color: #eee;
66 | top: 0;
67 | bottom: 0;
68 | left: 0;
69 | width: 100%;
70 | font-size: 10px;
71 | & .close {
72 | position: fixed;
73 | bottom: 0;
74 | width: 100%;
75 | height: 50px;
76 | background-color: #000;
77 | color: #fff;
78 | text-align: center;
79 | font-weight: bolder;
80 | padding: 5px;
81 | cursor: pointer;
82 | z-index: 100;
83 | }
84 | `;
85 |
86 | const Data = (props) => {
87 | // const { closeAction } = props;
88 |
89 | // const [loading, setLoading] = useState(true);
90 | // const [data, setData] = useState(null);
91 |
92 | // const userContext = useContext(UserContext);
93 | // const { jsonFile, spreadsheetId } = userContext.user;
94 |
95 | // const loadData = async () => {
96 | // setLoading(true);
97 | // try {
98 | // const doc = new GoogleSpreadsheet(spreadsheetId);
99 | // await doc.useServiceAccountAuth(jsonFile);
100 | // await doc.loadInfo();
101 |
102 | // const sheet = doc.sheetsByIndex[0];
103 | // const fetchedRows = await sheet.getRows();
104 | // const sortedRows = fetchedRows.sort(
105 | // (a, b) => dateParser(b.Date) - dateParser(a.Date)
106 | // );
107 | // setData(sortedRows);
108 | // setLoading(false);
109 | // } catch (e) {
110 | // alert(`Hubo un error en la autenticación: ${e.message}`);
111 | // userContext.newUser(null);
112 | // setLoading(false);
113 | // }
114 | // };
115 |
116 | // useEffect(() => {
117 | // loadData();
118 | // }, []);
119 |
120 | // const classes = useStyles();
121 | // const [value, setValue] = React.useState(0);
122 |
123 | // const handleClose = () => {
124 | // closeAction();
125 | // };
126 |
127 | // const handleChange = (event, newValue) => {
128 | // setValue(newValue);
129 | // };
130 |
131 | return (
132 |
133 | {/*
134 | CLOSE
135 |
136 |
137 |
138 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
*/}
155 |
156 | );
157 | };
158 |
159 | export default Data;
160 |
--------------------------------------------------------------------------------
/src/screens/Data/Table.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 |
3 | import { AbsoluteContainerStyled } from "./styles";
4 |
5 | import { makeStyles } from "@material-ui/core/styles";
6 | import Container from "@material-ui/core/Container";
7 | import Table from "@material-ui/core/Table";
8 | import TableBody from "@material-ui/core/TableBody";
9 | import TableCell from "@material-ui/core/TableCell";
10 | import TableContainer from "@material-ui/core/TableContainer";
11 | import TableHead from "@material-ui/core/TableHead";
12 | import TablePagination from "@material-ui/core/TablePagination";
13 | import TableRow from "@material-ui/core/TableRow";
14 |
15 | const columns = [
16 | { id: "rowIndex", label: "Id" },
17 | { id: "Date", label: "Date" },
18 | { id: "Who", label: "Who" },
19 | { id: "Amount", label: "Amount" },
20 | { id: "Type", label: "Type" },
21 | ];
22 |
23 | const useStyles = makeStyles({
24 | root: {
25 | width: "100%",
26 | justifyContent: "center",
27 | alignItems: "center",
28 | fontSize: "10px",
29 | display: "flex",
30 | margin: 0,
31 | height: "100%",
32 | padding: 0,
33 | },
34 | container: {
35 | maxHeight: window.innerHeight - 170 + "px",
36 | },
37 | tableCell: {
38 | fontSize: "10px",
39 | padding: "2px",
40 | },
41 | paginator: {
42 | fontSize: "10px",
43 | padding: 0,
44 | "& div": {
45 | padding: 0,
46 | },
47 | },
48 | });
49 |
50 | const DataTable = (props) => {
51 | const { data, closeAction } = props;
52 | const classes = useStyles();
53 |
54 | const [page, setPage] = useState(0);
55 | const [rowsPerPage, setRowsPerPage] = useState(100);
56 |
57 | const handleChangePage = (event, newPage) => {
58 | setPage(newPage);
59 | };
60 |
61 | const handleChangeRowsPerPage = (event) => {
62 | setRowsPerPage(+event.target.value);
63 | setPage(0);
64 | };
65 |
66 | const handleClose = () => {
67 | closeAction();
68 | };
69 |
70 | return (
71 |
72 |
73 | CLOSE
74 |
75 |
76 | {data ? (
77 |
87 |
88 |
89 |
90 |
91 | {columns.map((column) => (
92 |
98 | {column.label}
99 |
100 | ))}
101 |
102 |
103 |
104 | {data
105 | .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
106 | .map((row) => {
107 | return (
108 |
109 | {columns.map((column) => {
110 | const value = row[column.id];
111 | return (
112 |
117 | {column.format && typeof value === "number"
118 | ? column.format(value)
119 | : value}
120 |
121 | );
122 | })}
123 |
124 | );
125 | })}
126 |
127 |
128 |
129 |
139 |
140 | ) : (
141 | Loading...
142 | )}
143 |
144 |
145 | );
146 | };
147 |
148 | export default DataTable;
149 |
--------------------------------------------------------------------------------
/src/serviceWorker.js:
--------------------------------------------------------------------------------
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 | export function register(config) {
24 | if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
25 | // The URL constructor is available in all browsers that support SW.
26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
27 | if (publicUrl.origin !== window.location.origin) {
28 | // Our service worker won't work if PUBLIC_URL is on a different origin
29 | // from what our page is served on. This might happen if a CDN is used to
30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
31 | return;
32 | }
33 |
34 | window.addEventListener("load", () => {
35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
36 |
37 | if (isLocalhost) {
38 | // This is running on localhost. Let's check if a service worker still exists or not.
39 | checkValidServiceWorker(swUrl, config);
40 |
41 | // Add some additional logging to localhost, pointing developers to the
42 | // service worker/PWA documentation.
43 | navigator.serviceWorker.ready.then(() => {
44 | console.log(
45 | "This web app is being served cache-first by a service " +
46 | "worker. To learn more, visit https://bit.ly/CRA-PWA"
47 | );
48 | });
49 | } else {
50 | // Is not localhost. Just register service worker
51 | registerValidSW(swUrl, config);
52 | }
53 | });
54 | }
55 | }
56 |
57 | function registerValidSW(swUrl, config) {
58 | navigator.serviceWorker
59 | .register(swUrl)
60 | .then((registration) => {
61 | registration.onupdatefound = () => {
62 | const installingWorker = registration.installing;
63 | if (installingWorker == null) {
64 | return;
65 | }
66 | installingWorker.onstatechange = () => {
67 | if (installingWorker.state === "installed") {
68 | if (navigator.serviceWorker.controller) {
69 | // At this point, the updated precached content has been fetched,
70 | // but the previous service worker will still serve the older
71 | // content until all client tabs are closed.
72 | console.log(
73 | "New content is available and will be used when all " +
74 | "tabs for this page are closed. See https://bit.ly/CRA-PWA."
75 | );
76 |
77 | // Execute callback
78 | if (config && config.onUpdate) {
79 | config.onUpdate(registration);
80 | }
81 | } else {
82 | // At this point, everything has been precached.
83 | // It's the perfect time to display a
84 | // "Content is cached for offline use." message.
85 | console.log("Content is cached for offline use.");
86 |
87 | // Execute callback
88 | if (config && config.onSuccess) {
89 | config.onSuccess(registration);
90 | }
91 | }
92 | }
93 | };
94 | };
95 | })
96 | .catch((error) => {
97 | console.error("Error during service worker registration:", error);
98 | });
99 | }
100 |
101 | function checkValidServiceWorker(swUrl, config) {
102 | // Check if the service worker can be found. If it can't reload the page.
103 | fetch(swUrl, {
104 | headers: { "Service-Worker": "script" },
105 | })
106 | .then((response) => {
107 | // Ensure service worker exists, and that we really are getting a JS file.
108 | const contentType = response.headers.get("content-type");
109 | if (
110 | response.status === 404 ||
111 | (contentType != null && contentType.indexOf("javascript") === -1)
112 | ) {
113 | // No service worker found. Probably a different app. Reload the page.
114 | navigator.serviceWorker.ready.then((registration) => {
115 | registration.unregister().then(() => {
116 | window.location.reload();
117 | });
118 | });
119 | } else {
120 | // Service worker found. Proceed as normal.
121 | registerValidSW(swUrl, config);
122 | }
123 | })
124 | .catch(() => {
125 | console.log(
126 | "No internet connection found. App is running in offline mode."
127 | );
128 | });
129 | }
130 |
131 | export function unregister() {
132 | if ("serviceWorker" in navigator) {
133 | navigator.serviceWorker.ready
134 | .then((registration) => {
135 | registration.unregister();
136 | })
137 | .catch((error) => {
138 | console.error(error.message);
139 | });
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Bills Tracker
2 |
3 |
4 |
5 |
6 | ### This is a project based on a progressive web app to Track and share expenses with a Google Drive Spreadsheet in a very simple way.
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | ## Table of Contents
16 |
17 | - [Objectives](#Objectives)
18 | - [Screenshots](#Screenshots)
19 | - [Architecture](#Architecture)
20 | - [Development Requirements](#development-requirements)
21 | - [Onboarding](#Onboarding)
22 | - [Information](#Information)
23 | - [Example](#Example)
24 | - [Next steps](#Next-steps)
25 | - [Author](#Author)
26 | - [Contributors ✨](#Contributors-✨)
27 |
28 | ## Objectives
29 |
30 | - Save bills to a spreadsheet quickly and easily
31 | - Separate types of expenses
32 | - Show all the records with graphs
33 | - Share your expenses with others on the same spreadsheet
34 | - Identify how are you spending your money!
35 |
36 | ## Screenshots
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | ## Architecture
49 |
50 | 
51 |
52 | ## Development Requirements
53 |
54 | _These instructions will allow you to obtain a copy of the running project on your local machine for development and testing purposes._
55 |
56 | _The first thing you need to do is create a fork of this project and clone it. To start using the software it is only necessary to have [Node.js](https://nodejs.org/en/download) installed on your system._
57 |
58 | _Once the fork is cloned and Node installed.js on your system, you can install the project dependencies by following these steps;_
59 |
60 | _Installs project dependencies_
61 |
62 | ```
63 | npm install
64 | ```
65 |
66 | _Add an .env file to the project root and enter your credentials_
67 |
68 | ```
69 | touch .env
70 | ```
71 |
72 | ```
73 | REACT_APP_REDIRECT_URI=
74 | REACT_APP_BASE_URI=
75 | REACT_APP_GOOGLE_OAUTH_CLIENT_ID=
76 | REACT_APP_GOOGLE_OAUTH_CLIENT_SECRET=
77 | ```
78 |
79 | _Run the project_
80 |
81 | ```
82 | npm start
83 | ```
84 |
85 | ## Onboarding
86 |
87 | ### 1. Create a spreadsheet
88 |
89 | - Create a new Google Spreadsheet or copy from [here](https://docs.google.com/spreadsheets/d/1zR8NCRoiVZszVN1FlqUdSk9r9jfn_h_eR3gYCgJuvqY/copy).
90 | - Copy the spreadsheet ID or URL, you will need it later.
91 | - The ID is on the URL of the spreadsheet.
92 | https://docs.google.com/spreadsheets/d/1qffzsCf2siRv-loAAMLeGzsSsmwcT3odSfmXBASO0fg/edit#gid=0. You can also use the full URL.
93 |
94 | ### 2. Onboarding process
95 |
96 | #### Insert data into onboarding fields.
97 |
98 | - Insert you name in the "NAME" field
99 | - Insert the spreadsheet ID or URL
100 | - Click "Login" and magic!
101 |
102 | ## Information
103 |
104 | **You can use the app with shared data if 2 or more users enter the same SpreadsheetID/URL using different names.**
105 |
106 | BillsTracker don’t save or track any information about you.
107 | All the data entered in the application belongs only and solely to the user, BillsTracker does not store any type of information since we do not have a database to do so.
108 | The code of the app is public and open source, we don’t have any back-end, it’s just front-end.
109 |
110 | If you want to collaborate or support the project in any way, feel free to do so through the GitHub profile
111 |
112 | ## Example
113 |
114 | - I have a functional example on [Netlify](http://pwa.billstracker.app/)
115 |
116 | ## Next steps
117 |
118 | - [x] Validate inputs
119 | - [x] Show graphs
120 | - [x] Editable expense type dropdown values
121 | - [x] Improve the code
122 | - [x] Google login
123 | - [x] Show important information for the user on the home page
124 | - [ ] Monthly, weekly and daily recurring expenses automatically
125 | - [ ] Ability to create a monthly budget
126 | - [ ] Add income
127 | - [ ] Financial health analysis in plain text for the user
128 | - [ ] Improve categories
129 |
130 | ## Author
131 |
132 | **Joaquin Beceiro**
133 |
134 | - [GitHub](https://github.com/JoaquinBeceiro)
135 | - [Web](https://JoaquinBeceiro.com.uy)
136 |
137 | ## Contributors ✨
138 |
139 |
146 |
147 | ## Expressions of gratitude 🎁
148 |
149 | - Tell others about this project 📢
150 | - Invite someone on the team for a beer 🍺 or coffee ☕.
151 | - Give thanks publicly 🤓.
152 | - Star to the project ⭐
153 |
154 | [](https://cafecito.app/joaquinbeceiro)
155 |
--------------------------------------------------------------------------------
/src/screens/Guide/index.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { NoHeaderLayout } from "layouts";
3 | import * as S from "./styles";
4 | import SpreadsheetScreenshot from "rsc/img/spreadsheetScreenshot.png";
5 | import LoginScreenshot from "rsc/img/loginScreenshot.png";
6 | import IosInstall from "rsc/img/iosInstall.png";
7 | import AndroidInstall from "rsc/img/androidInstall.png";
8 | import GithubIcon from "rsc/icons/github.svg";
9 | import { useHistory } from "react-router-dom";
10 | import { useSwipeable } from "react-swipeable";
11 |
12 | const Guide = () => {
13 | const history = useHistory();
14 |
15 | const [activeStep, setActiveStep] = useState(1);
16 |
17 | const Step1 = (
18 |
19 | Install
20 |
21 | If you are on IOS , open the site on Safari, click on
22 | the Share button and select “Add to Home Screen” from the popup. Lastly,
23 | tap "Add" in the top right corner.
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | If you are on Andriod , open the site on Chrome, press
32 | the “three dot” icon in the upper right to open the menu. Select “Add to
33 | Home screen” and then press the “Add” button in the popup.
34 |
35 |
36 |
37 |
38 |
39 |
40 | );
41 |
42 | const Step2 = (
43 |
44 | Create a spreadsheet manually
45 |
46 | You can select "Create a new spreadsheet on your drive" to create
47 | it automatically or "Use an existing one" to select your own
48 | spreadsheet.
49 |
50 |
51 | Create/Find a new Google Spreadsheet or copy from{" "}
52 |
56 | here
57 |
58 | . Copy the spreadsheet ID/URL, you will need it later.
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | The ID is on the URL of the spreadsheet.
67 | https://docs.google.com/spreadsheets/d/
68 | 1qffzsCf2siRv-loAAMLeGzsSsmwcT3odSfmXBASO0fg /edit#gid=0
69 |
70 |
71 | You can also use the full URL.
72 |
73 | );
74 |
75 | const Step3 = (
76 |
77 | Onboarding process
78 | Insert data into onboarding fields.
79 |
80 | Insert your name in the "NAME" field
81 |
82 | Insert the Spreadsheet ID or URL or create a new one checking "Create
83 | a new spreadsheet"
84 |
85 |
86 |
87 |
88 | Click "Login" and magic!
89 |
90 |
91 | );
92 |
93 | const Step4 = (
94 |
95 | Information
96 |
97 | You can use the app with shared data if 2 or more users enter the same
98 | SpreadsheetID.
99 |
100 |
101 | BillsTracker don’t save or track any information about you.{" "}
102 |
103 |
104 | All the data entered in the application belongs only and solely to the
105 | user, BillsTracker does not store any type of information since we do
106 | not have a database to do so.
107 |
108 |
109 | The code of the app is public and open source, we just use back-end for
110 | login purpose, the rest it’s just front-end.
111 |
112 |
113 | If you want to collaborate or support the project in any way, feel free
114 | to do so through the GitHub profile
115 |
116 |
117 |
118 | Go to GitHub!
119 |
120 |
121 | );
122 |
123 | const steps = [
124 | { number: 1, step: Step1 },
125 | { number: 2, step: Step2 },
126 | { number: 3, step: Step3 },
127 | { number: 4, step: Step4 },
128 | ];
129 |
130 | const handleNextStep = () => {
131 | if (activeStep !== steps[steps.length - 1].number) {
132 | setActiveStep(activeStep + 1);
133 | } else {
134 | history.push("/onboarding");
135 | }
136 | };
137 |
138 | const handlePrevStep = () => {
139 | if (activeStep !== 1) {
140 | setActiveStep(activeStep - 1);
141 | }
142 | };
143 |
144 | const handlers = useSwipeable({
145 | onSwiped: (eventData) => {
146 | if (eventData.dir === "Left") {
147 | handleNextStep();
148 | }
149 |
150 | if (eventData.dir === "Right") {
151 | handlePrevStep();
152 | }
153 | },
154 | });
155 |
156 | return (
157 |
158 |
159 | {steps.find(({ number }) => number === activeStep).step}
160 |
161 |
162 | {activeStep !== 1 && "Previous"}
163 |
164 |
165 | {steps.map(({ number }, index) => (
166 |
170 | ))}
171 |
172 |
173 | {activeStep !== steps[steps.length - 1].number ? "Next" : "Finish"}
174 |
175 |
176 |
177 |
178 | );
179 | };
180 |
181 | export default Guide;
182 |
--------------------------------------------------------------------------------
/src/services/headerData.js:
--------------------------------------------------------------------------------
1 | import {
2 | getLocalSheetData,
3 | getTotalByMonth,
4 | getAllMonthByYear,
5 | } from "./spreadsheet";
6 | import Utils from "lib/utils";
7 |
8 | const { split, nowMonth, nowYear, pastMonthYear } = Utils.Date;
9 | const { moneyToNumber, formatMoney } = Utils.Currency;
10 |
11 | export const totalsHeaderData = async (doc) => {
12 | if (doc) {
13 | const pastMonthYearValue = pastMonthYear();
14 |
15 | const totalThisMonth = await getTotalByMonth(doc, nowMonth(), nowYear());
16 | const totalPastMonth = await getTotalByMonth(
17 | doc,
18 | pastMonthYearValue.month,
19 | pastMonthYearValue.year
20 | );
21 |
22 | return {
23 | title: "Total month",
24 | primaryValue: `$${totalThisMonth}`,
25 | secondaryValue: `$${totalPastMonth} past month`,
26 | arrowIcon: {
27 | up: moneyToNumber(totalThisMonth) > moneyToNumber(totalPastMonth),
28 | },
29 | info: "Total for the month compared to the total for the previous month",
30 | };
31 | } else {
32 | return null;
33 | }
34 | };
35 |
36 | export const avgHeaderData = async (doc) => {
37 | if (doc) {
38 | const todayDate = new Date().getDate();
39 |
40 | const fetchedRows = await getLocalSheetData();
41 | const totalsFiltered = fetchedRows.filter((e) => {
42 | const dateSplitted = split(e.Date);
43 | return (
44 | dateSplitted[2] === nowYear().toString() &&
45 | dateSplitted[1] === nowMonth().toString()
46 | );
47 | });
48 |
49 | const totalValue = totalsFiltered.reduce((acc, val) => {
50 | return acc + moneyToNumber(val.Amount);
51 | }, 0);
52 |
53 | const totalsFilteredPreviousMonth = fetchedRows.filter((e) => {
54 | const dateSplitted = split(e.Date);
55 | return (
56 | dateSplitted[2] === pastMonthYear().year.toString() &&
57 | dateSplitted[1] === pastMonthYear().month.toString() &&
58 | dateSplitted[0] <= todayDate
59 | );
60 | });
61 |
62 | const totalPreviousValue = totalsFilteredPreviousMonth.reduce(
63 | (acc, val) => {
64 | return acc + moneyToNumber(val.Amount);
65 | },
66 | 0
67 | );
68 |
69 | const avgThisMonth = formatMoney(totalValue / todayDate);
70 | const avgPreviousMonth = formatMoney(totalPreviousValue / todayDate);
71 |
72 | return {
73 | title: "AVG per day",
74 | primaryValue: `$${avgThisMonth}`,
75 | secondaryValue: `$${avgPreviousMonth} past month`,
76 | arrowIcon: {
77 | up: moneyToNumber(avgThisMonth) > moneyToNumber(avgPreviousMonth),
78 | },
79 | info: "Average per day compared to the previous month on the same day of the month",
80 | };
81 | } else {
82 | return null;
83 | }
84 | };
85 |
86 | export const categoryHeaderData = async (doc) => {
87 | if (doc) {
88 | const todayDate = new Date().getDate();
89 |
90 | const fetchedRows = await getLocalSheetData();
91 | const totalsFiltered = fetchedRows.filter((e) => {
92 | const dateSplitted = split(e.Date);
93 | return (
94 | dateSplitted[2] === nowYear().toString() &&
95 | dateSplitted[1] === nowMonth().toString()
96 | );
97 | });
98 |
99 | const totalsFilteredPreviousMonth = fetchedRows.filter((e) => {
100 | const dateSplitted = split(e.Date);
101 | return (
102 | dateSplitted[2] === pastMonthYear().year.toString() &&
103 | dateSplitted[1] === pastMonthYear().month.toString() &&
104 | dateSplitted[0] <= todayDate
105 | );
106 | });
107 |
108 | const totalThisValue = totalsFiltered.reduce((acc, val) => {
109 | const newObj = { ...acc };
110 | const newAcc = {
111 | ...acc,
112 | [val.Type]: (newObj[val.Type] | 0) + moneyToNumber(val.Amount),
113 | };
114 | return newAcc;
115 | }, {});
116 |
117 | const totalPreviousValue = totalsFilteredPreviousMonth.reduce(
118 | (acc, val) => {
119 | const newObj = { ...acc };
120 | const newAcc = {
121 | ...acc,
122 | [val.Type]: (newObj[val.Type] | 0) + moneyToNumber(val.Amount),
123 | };
124 | return newAcc;
125 | },
126 | {}
127 | );
128 |
129 | const mergedCategories = [
130 | ...new Set([
131 | ...Object.keys(totalThisValue),
132 | ...Object.keys(totalPreviousValue),
133 | ]),
134 | ];
135 |
136 | const categoriesDiff = mergedCategories.length
137 | ? mergedCategories.map((category) => ({
138 | category,
139 | diff:
140 | (totalThisValue[category] | 0) - (totalPreviousValue[category] | 0),
141 | }))
142 | : [{ category: "", diff: 0 }];
143 |
144 | const maxDiff = categoriesDiff.reduce((prev, current) => {
145 | return prev?.diff > current?.diff ? prev : current;
146 | });
147 |
148 | const category = maxDiff.category;
149 |
150 | const upIcon =
151 | (totalThisValue[category] | 0) > (totalPreviousValue[category] | 0);
152 |
153 | const primaryValue = formatMoney(totalThisValue[category] | 0);
154 | const secondaryValue = formatMoney(totalPreviousValue[category] | 0);
155 |
156 | let returnObject = {};
157 | if (category) {
158 | returnObject = {
159 | title: category,
160 | primaryValue: `$${primaryValue}`,
161 | secondaryValue: `$${secondaryValue} past month`,
162 | arrowIcon: { up: upIcon },
163 | info: "The category that increased the most compared to the previous month on the same day of the month",
164 | };
165 | } else {
166 | returnObject = null;
167 | }
168 |
169 | return returnObject;
170 | } else {
171 | return null;
172 | }
173 | };
174 |
175 | export const yearHeaderData = async (doc) => {
176 | if (doc) {
177 | const currentYear = await getAllMonthByYear(doc, nowYear());
178 |
179 | const totalThisYear = currentYear.reduce(
180 | (acc, current) => acc + current.value,
181 | 0
182 | );
183 |
184 | const yearMonthsLength = currentYear.length - 1 || 1;
185 |
186 | const avgMonth =
187 | currentYear
188 | .slice(0, -1)
189 | .reduce((acc, current) => acc + current.value, 0) / yearMonthsLength;
190 |
191 | const primaryValue = formatMoney(totalThisYear);
192 | const secondaryValue = formatMoney(avgMonth);
193 |
194 | return {
195 | title: "Total year",
196 | primaryValue: `$${primaryValue}`,
197 | secondaryValue: `$${secondaryValue} avg month`,
198 | info: "Total of the current year and AVG per month of the current year excluding current month",
199 | };
200 | } else {
201 | return null;
202 | }
203 | };
204 |
--------------------------------------------------------------------------------
/src/screens/Config/schedule.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useCallback, useEffect } from "react";
2 | import * as S from "./styles";
3 | import {
4 | DetailItemComponent,
5 | ButtonComponent,
6 | InputComponent,
7 | } from "components";
8 | import Utils from "lib/utils";
9 | import {
10 | addRow,
11 | deleteRow,
12 | getLocalSheetData,
13 | } from "services/configSpreadsheet";
14 | import EmptyBox from "rsc/img/emptybox.png";
15 |
16 | const { formatMoney, moneyToNumber } = Utils.Currency;
17 |
18 | const SCHEDULE_FREQUENCY_OPTIONS = Utils.Constants.SCHEDULE_FREQUENCY.map(
19 | ({ key, value }) => ({
20 | value: key,
21 | label: value,
22 | })
23 | );
24 |
25 | const defaultForm = {
26 | name: "",
27 | type: "",
28 | frequency: 0,
29 | amount: "",
30 | description: "",
31 | };
32 |
33 | const Schedule = ({
34 | doc,
35 | setMainLoading,
36 | DispatchTypes,
37 | modalDispatch,
38 | isLoading,
39 | billsTypes,
40 | }) => {
41 | const [showForm, setShowForm] = useState(false);
42 | const [form, setForm] = useState(defaultForm);
43 | const [schedules, setSchedules] = useState(null);
44 |
45 | const getStartData = useCallback(async () => {
46 | const data = await getLocalSheetData(doc);
47 | setSchedules(data);
48 | }, [doc]);
49 |
50 | useEffect(() => {
51 | if (doc && !isLoading) {
52 | getStartData();
53 | }
54 | }, [doc, getStartData, isLoading]);
55 |
56 | const handleAddNew = () => {
57 | setShowForm(true);
58 | };
59 |
60 | const hideForm = () => {
61 | setShowForm(false);
62 | };
63 |
64 | const onChange = (name, value) => {
65 | if (name && value) {
66 | setForm({
67 | ...form,
68 | [name]: value,
69 | });
70 | }
71 | };
72 |
73 | const addSchedule = async () => {
74 | setMainLoading(true);
75 | try {
76 | const { name, type, frequency, amount, description } = form;
77 | await addRow(doc, name, type, frequency, amount, description);
78 | setForm(defaultForm);
79 | getStartData();
80 | hideForm();
81 | } catch (error) {
82 | console.log("ERROR (addSchedule)", error);
83 | } finally {
84 | setMainLoading(false);
85 | }
86 | };
87 |
88 | const deleteRecord = async (id) => {
89 | modalDispatch({
90 | type: DispatchTypes.Modal.MODAL_SHOW,
91 | title: "Confirmation",
92 | content: "Do you really want to delete this record?",
93 | actions: [
94 | {
95 | type: "secondary",
96 | text: "Delete",
97 | action: async () => {
98 | modalDispatch({ type: DispatchTypes.Modal.MODAL_HIDE });
99 | setMainLoading(true);
100 | await deleteRow(doc, id);
101 | getStartData();
102 | setMainLoading(false);
103 | },
104 | },
105 | {
106 | type: "text",
107 | text: "Cancel",
108 | action: () => {
109 | modalDispatch({ type: DispatchTypes.Modal.MODAL_HIDE });
110 | },
111 | },
112 | ],
113 | });
114 | };
115 |
116 | if (showForm) {
117 | return (
118 |
119 |
120 |
128 |
137 |
151 |
158 |
166 |
167 |
168 |
169 |
170 | );
171 | }
172 |
173 | const SkeletonLoading = (isLoading) => {
174 | return !isLoading ? (
175 |
176 |
177 | You have not created any schedule yet.
178 | You can easily create and manage your schedules on this page.
179 |
180 | ) : (
181 | <>
182 |
183 |
184 |
185 | >
186 | );
187 | };
188 |
189 | return (
190 |
191 |
192 | {schedules === null || schedules.length === 0
193 | ? SkeletonLoading(isLoading)
194 | : schedules.map(
195 | ({ Name, Type, Amount, Frequency, Description, Id }) => {
196 | const priceFormatted = `$${formatMoney(moneyToNumber(Amount))}`;
197 | const typeSchedule = Utils.Constants.SCHEDULE_FREQUENCY.find(
198 | ({ key }) => key === Frequency
199 | ).value;
200 | return (
201 | deleteRecord(Id)}
208 | />
209 | );
210 | }
211 | )}
212 |
213 |
218 |
219 | );
220 | };
221 |
222 | export default Schedule;
223 |
--------------------------------------------------------------------------------
/src/components/Input/styles.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 | import CreatableSelect from "react-select/creatable";
4 | import Select from "react-select";
5 |
6 | const InputContainer = styled.div`
7 | box-sizing: border-box;
8 | margin-bottom: 20px;
9 | border-bottom: 1px solid #c4c4c4;
10 | width: 100%;
11 | display: flex;
12 | align-items: center;
13 | color: #333333;
14 | label {
15 | font-weight: 300;
16 | font-size: 11px;
17 | color: #000;
18 | }
19 |
20 | &.text {
21 | height: 45px;
22 | overflow: hidden;
23 | }
24 | &.date {
25 | height: 45px;
26 | color: #333333;
27 | z-index: 300;
28 | input {
29 | background-color: transparent;
30 | z-index: 300;
31 | }
32 | }
33 |
34 | &.checkbox,
35 | &.bigtext,
36 | &.option {
37 | margin-bottom: 0;
38 | }
39 |
40 | &.textarea,
41 | &.checkbox,
42 | &.option,
43 | &.bigtext {
44 | border: none;
45 | flex-direction: column;
46 | align-items: flex-start;
47 | div:first-child {
48 | min-height: 25px;
49 | }
50 | label {
51 | margin-bottom: 0px;
52 | }
53 | }
54 |
55 | &.money {
56 | border-color: #38b44e;
57 | }
58 | &.option {
59 | label {
60 | font-size: 14px;
61 | }
62 | }
63 |
64 | > div {
65 | display: flex;
66 | min-height: 45px;
67 | :first-child {
68 | align-items: center;
69 | }
70 | :last-child {
71 | flex: 1;
72 | align-items: center;
73 | width: 100%;
74 | }
75 | }
76 |
77 | &.bigtext:focus-within {
78 | margin-bottom: 24px;
79 | }
80 |
81 | &.text:focus-within,
82 | &.date:focus-within,
83 | &.type:focus-within,
84 | &.money:focus-within,
85 | &.dropdown:focus-within {
86 | border-bottom: 2px solid #38b44e;
87 | margin-bottom: 20px;
88 | }
89 |
90 | ${(props) =>
91 | props.disabled &&
92 | `
93 | label {
94 | color: #999;
95 | }
96 |
97 | input::placeholder,
98 | textarea::placeholder
99 | {
100 | color: #ccc;
101 | }
102 | `}
103 | `;
104 |
105 | const InputBox = styled.input`
106 | font-family: Roboto;
107 | border: 0px;
108 | width: 100%;
109 | height: 100%;
110 | text-align: right;
111 | flex: 1;
112 | font-weight: normal;
113 | font-size: 18px;
114 | ::placeholder {
115 | color: #7e7e7e;
116 | }
117 | &:focus {
118 | outline: none;
119 | ::placeholder {
120 | color: #000;
121 | }
122 | }
123 | `;
124 |
125 | const TextMoney = styled(InputBox)`
126 | color: #38b44e;
127 | font-size: 32px;
128 | font-weight: bold;
129 | &:focus {
130 | outline: none;
131 | ::placeholder {
132 | color: #7e7e7e;
133 | }
134 | }
135 | `;
136 |
137 | const Date = styled(InputBox)`
138 | min-height: 45px;
139 | color: #333333;
140 | font-style: normal;
141 | font-weight: normal;
142 | font-size: 18px;
143 | text-align: right;
144 | ::-webkit-calendar-picker-indicator {
145 | color: #333333;
146 | margin-left: 10px;
147 | text-align: right;
148 | }
149 | ::-webkit-date-and-time-value {
150 | text-align: right;
151 | }
152 | `;
153 |
154 | const CreateButtonContainer = styled.div`
155 | display: flex;
156 | justify-content: flex-end;
157 | `;
158 |
159 | const CreateButton = styled.div`
160 | width: fit-content;
161 | border-radius: 9px;
162 | padding: 4px 14px;
163 | border: none;
164 | color: white;
165 | font-family: Roboto;
166 | font-style: normal;
167 | font-weight: 500;
168 | font-size: 14px;
169 | cursor: pointer;
170 | background-color: #6fc97f;
171 | `;
172 |
173 | const CreatableSelectComponent = (props) => (
174 | (
177 |
178 |
179 | Create type {`"${input}"`}
180 |
181 |
182 | )}
183 | {...props}
184 | />
185 | );
186 |
187 | const SelectComponent = (props) => {
188 | return ;
189 | };
190 |
191 | const CreatableDropdownBox = styled(CreatableSelectComponent)`
192 | border: none;
193 | border-style: unset;
194 | z-index: 500;
195 | & .Select__control {
196 | border: none;
197 | border-style: unset;
198 | }
199 |
200 | & .Select__value-container {
201 | text-align: right;
202 | justify-content: flex-end;
203 | }
204 |
205 | & .Select__indicator-separator,
206 | .Select__clear-indicator {
207 | display: none;
208 | }
209 |
210 | & .Select__control--is-focused {
211 | box-shadow: none;
212 | }
213 |
214 | & .Select__option {
215 | text-align: right;
216 | }
217 |
218 | font-family: Roboto;
219 | border: none;
220 | border-radius: 7px;
221 | width: 100%;
222 | font-weight: normal;
223 | font-size: 13px;
224 | ::placeholder {
225 | color: #7e7e7e;
226 | }
227 | &:focus {
228 | outline: none;
229 | ::placeholder {
230 | color: #000;
231 | }
232 | }
233 | `;
234 |
235 | const DropdownBox = styled(SelectComponent)`
236 | border: none;
237 | border-style: unset;
238 | z-index: 400;
239 | & .Select__control {
240 | border: none;
241 | border-style: unset;
242 | }
243 |
244 | & .Select__value-container {
245 | text-align: right;
246 | justify-content: flex-end;
247 | }
248 |
249 | & .Select__indicator-separator,
250 | .Select__clear-indicator {
251 | display: none;
252 | }
253 |
254 | & .Select__control--is-focused {
255 | box-shadow: none;
256 | }
257 |
258 | & .Select__option {
259 | text-align: right;
260 | }
261 |
262 | font-family: Roboto;
263 | border: none;
264 | border-radius: 7px;
265 | width: 100%;
266 | font-weight: normal;
267 | font-size: 13px;
268 | ::placeholder {
269 | color: #7e7e7e;
270 | }
271 | &:focus {
272 | outline: none;
273 | ::placeholder {
274 | color: #000;
275 | }
276 | }
277 | `;
278 |
279 | const BigTextBox = styled.textarea`
280 | overflow: hidden;
281 | font-family: Roboto;
282 | resize: vertical;
283 | border: none;
284 | border: 1px solid #dedede;
285 | border-radius: 7px;
286 | width: 100%;
287 | padding: 10px;
288 | font-weight: normal;
289 | font-size: 13px;
290 | margin-bottom: 20px;
291 | ::placeholder {
292 | color: #7e7e7e;
293 | }
294 | &:focus {
295 | outline: none;
296 | border-bottom: 2px solid #38b44e;
297 | margin-bottom: 15px;
298 | ::placeholder {
299 | color: #000;
300 | }
301 | }
302 | `;
303 |
304 | const TextAreaBox = styled(BigTextBox)`
305 | min-height: 88px;
306 | `;
307 |
308 | export {
309 | InputContainer,
310 | InputBox,
311 | DropdownBox,
312 | CreatableDropdownBox,
313 | BigTextBox,
314 | TextAreaBox,
315 | TextMoney,
316 | Date,
317 | };
318 |
--------------------------------------------------------------------------------
/src/screens/Main/index.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useEffect, useState, useCallback } from "react";
2 | import { GlobalContext, DispatchTypes } from "context";
3 | import { LoadingComponent, ButtonComponent, InputComponent } from "components";
4 | import { HeaderLayout } from "layouts";
5 | import { getTypes, addRow, createDoc, HeaderData } from "services";
6 | import Utils from "lib/utils";
7 | import { useHistory } from "react-router-dom";
8 | import { isMath, evaluateMath, isNumber } from "utils/math";
9 |
10 | const Main = () => {
11 | const history = useHistory();
12 |
13 | const { todayDate } = Utils.Date;
14 |
15 | const [mainLoading, setMainLoading] = useState(true);
16 | const [billsTypes, setBillsTypes] = useState([]);
17 | const [checked, setChecked] = useState(false);
18 | const [headerData, setHeaderData] = useState(null);
19 |
20 | const defaultForm = {
21 | amount: "",
22 | type: "",
23 | date: todayDate(),
24 | description: "",
25 | };
26 | const [form, setForm] = useState(defaultForm);
27 |
28 | const clearForm = () => setForm(defaultForm);
29 |
30 | const addBillDisabled = !(form.amount && form.type && form.date);
31 |
32 | const onChange = (name, value) => {
33 | setForm({
34 | ...form,
35 | [name]: value,
36 | });
37 | };
38 |
39 | const context = useContext(GlobalContext);
40 | const [userState, userDispatch] = context.globalUser;
41 | const [, modalDispatch] = context.globalModal;
42 | const { user, doc, loading } = userState;
43 |
44 | const alertModal = useCallback(
45 | (title, content, actions) => {
46 | modalDispatch({
47 | type: DispatchTypes.Modal.MODAL_SHOW,
48 | title,
49 | content,
50 | actions: [
51 | {
52 | text: "Ok",
53 | action: () => {
54 | modalDispatch({ type: DispatchTypes.Modal.MODAL_HIDE });
55 | actions && actions();
56 | },
57 | },
58 | ],
59 | });
60 | },
61 | [modalDispatch]
62 | );
63 |
64 | const getStartData = useCallback(
65 | async (doc) => {
66 | setMainLoading(true);
67 | try {
68 | const types = await getTypes(doc);
69 |
70 | const typesFormatted = types.map((type) => ({
71 | value: type,
72 | label: type,
73 | }));
74 |
75 | const headerData = await HeaderData(doc);
76 |
77 | setBillsTypes(typesFormatted);
78 | setHeaderData(headerData);
79 |
80 | setMainLoading(false);
81 | } catch (e) {
82 | console.log("getStartData ERROR", e);
83 | alertModal("Error", "There was an error. Please try again later.");
84 | setMainLoading(false);
85 | }
86 | },
87 | [alertModal]
88 | );
89 |
90 | const createDocHandler = useCallback(async () => {
91 | try {
92 | const { access_token, refresh_token, expires_at, spreadsheetId } = user;
93 | const normalizedId = Utils.Common.getSpreadsheetId(spreadsheetId);
94 |
95 | const newDoc = await createDoc(
96 | access_token,
97 | refresh_token,
98 | expires_at,
99 | normalizedId
100 | );
101 | if (newDoc) {
102 | userDispatch({
103 | type: DispatchTypes.User.GET_DOC_SUCCESS,
104 | doc: newDoc,
105 | });
106 | }
107 | } catch (e) {
108 | setMainLoading(false);
109 | history.push("/");
110 | console.log("createDoc ERROR", e);
111 | }
112 | }, [history, user, userDispatch]);
113 |
114 | useEffect(() => {
115 | if (doc === null && user && !checked) {
116 | setChecked(true);
117 | createDocHandler();
118 | }
119 | }, [doc, user, checked, createDocHandler]);
120 |
121 | useEffect(() => {
122 | if (!loading && doc) {
123 | getStartData(doc);
124 | }
125 | }, [doc, loading, getStartData]);
126 |
127 | const addBill = async () => {
128 | const { type, date, description } = form;
129 | let { amount } = form;
130 |
131 | const isAmountMath = isMath(amount);
132 | const isAmountNumber = isNumber(amount);
133 |
134 | if( isAmountMath ){
135 | try {
136 | amount = Math.round( evaluateMath(amount) );
137 | } catch (error){
138 | amount = "";
139 | }
140 | }
141 |
142 | if( amount.length === 0 || !((isAmountNumber && !isAmountMath) || (!isAmountNumber && isAmountMath))){
143 | alertModal(
144 | "Error",
145 | "There was an error trying to add a new bill. The amount field is not valid."
146 | );
147 | setMainLoading(false);
148 | return;
149 | }
150 |
151 | if (window.gtag) {
152 | window.gtag("event", "add_bill", {
153 | ...form,
154 | });
155 | }
156 |
157 | if (amount && type && date && user.name) {
158 | setMainLoading(true);
159 | try {
160 | const addAction = await addRow(
161 | doc,
162 | date,
163 | user.name,
164 | amount,
165 | type,
166 | description
167 | );
168 | if (addAction) {
169 | const modalActionOk = () => getStartData(doc);
170 | alertModal(
171 | "Bill Added",
172 | "Your bill was successfully added!",
173 | modalActionOk
174 | );
175 | clearForm();
176 | } else {
177 | console.log("addBill ERROR", addAction);
178 | alertModal(
179 | "Error",
180 | "There was an error trying to add a new bill. Please try again later."
181 | );
182 | setMainLoading(false);
183 | }
184 | setMainLoading(false);
185 | } catch (error) {
186 | console.log("addBill ERROR", error);
187 | alertModal(
188 | "Error",
189 | "There was an error trying to add a new bill. Please try again later."
190 | );
191 | setMainLoading(false);
192 | }
193 | } else {
194 | console.log("addBill ERROR no user", user);
195 | alertModal(
196 | "Error",
197 | "There was an error trying to add a new bill. Please try again later."
198 | );
199 | }
200 | };
201 |
202 | const screenLoading = mainLoading || loading;
203 |
204 | return (
205 | <>
206 |
207 |
214 |
223 |
230 |
238 |
239 |
244 |
245 |
246 |
247 | {screenLoading && }
248 | >
249 | );
250 | };
251 |
252 | export default Main;
253 |
--------------------------------------------------------------------------------
/src/screens/Type/index.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useEffect, useState, useCallback } from "react";
2 | import { NoHeaderLayout } from "layouts";
3 | import {
4 | PieChartComponent,
5 | ChartLegendComponent,
6 | DropdownComponent,
7 | LoadingComponent,
8 | BigModalComponent,
9 | DetailItemComponent,
10 | ButtonComponent,
11 | } from "components";
12 | import * as S from "./styles";
13 | import { GlobalContext, DispatchTypes } from "context";
14 | import {
15 | getByTypesMonth,
16 | getMonthYears,
17 | getDetailsByTypeDate,
18 | getDetailsByMonth,
19 | deleteRow,
20 | } from "services";
21 | import Utils from "lib/utils";
22 | import { useLocation } from "react-router-dom";
23 |
24 | const Type = () => {
25 | const location = useLocation();
26 |
27 | const { nowYear, nowMonth, dateToText, monthToText } = Utils.Date;
28 |
29 | const locationStateMonth = parseInt(location?.state?.defaultMonth) - 1;
30 |
31 | const defaultMonth = isNaN(locationStateMonth)
32 | ? parseInt(nowMonth()) - 1
33 | : locationStateMonth;
34 | const defaultYear = location?.state?.defaultYear || nowYear().toString();
35 |
36 | const { addColors } = Utils.Colors;
37 | const { MONTHS } = Utils.Constants;
38 |
39 | const [mainLoading, setMainLoading] = useState(true);
40 | const [selectedDate, setSelectedDate] = useState({
41 | month: defaultMonth,
42 | year: defaultYear,
43 | });
44 |
45 | const [monthsOptions, setMonthsOptions] = useState([]);
46 | const [data, setData] = useState([]);
47 | const context = useContext(GlobalContext);
48 | const [userState] = context.globalUser;
49 | const [, modalDispatch] = context.globalModal;
50 | const { doc, loading } = userState;
51 | const [showDetail, setShowDetail] = useState("");
52 | const [detailData, setDetailData] = useState([]);
53 | const [modalTitle, setModalTitle] = useState("");
54 | const [modalSubtitle, setModalSubtitle] = useState("");
55 |
56 | const getStartData = useCallback(
57 | async (doc) => {
58 | setMainLoading(true);
59 | const selectedMonth = parseInt(selectedDate.month) + 1;
60 | const typesMonth = await getByTypesMonth(
61 | doc,
62 | selectedMonth,
63 | selectedDate.year
64 | );
65 | const typesMonthWithColors = addColors(typesMonth);
66 | const monthYears = await getMonthYears(doc);
67 |
68 | const grouppedYears = [...new Set(monthYears.map(({ year }) => year))];
69 | const grouppedMonths = grouppedYears.map((year) => ({
70 | label: year,
71 | options: monthYears
72 | .filter((item) => item.year === year)
73 | .map((item) => ({
74 | value: { month: parseInt(item.month) - 1, year: item.year },
75 | label: `${MONTHS[parseInt(item.month) - 1]}`,
76 | })),
77 | }));
78 |
79 | setMonthsOptions(grouppedMonths);
80 |
81 | setData(typesMonthWithColors);
82 | setMainLoading(false);
83 | },
84 | [MONTHS, addColors, selectedDate.month, selectedDate.year]
85 | );
86 |
87 | const getDetailData = async (type) => {
88 | setMainLoading(true);
89 | setModalTitle(type);
90 | const selectedMonth = parseInt(selectedDate.month) + 1;
91 | const data = await getDetailsByTypeDate(
92 | doc,
93 | selectedMonth,
94 | selectedDate.year,
95 | type
96 | );
97 | setDetailData(data);
98 | setMainLoading(false);
99 | setShowDetail("single");
100 | const subTitle = `${data.length} transactions`;
101 | setModalSubtitle(subTitle);
102 | };
103 |
104 | const total = data.reduce((prev, cur) => prev + cur.value, 0);
105 |
106 | const onChangeDate = (value) => {
107 | if (
108 | `${value.month}-${value.year}` !==
109 | `${selectedDate.month}-${selectedDate.year}`
110 | ) {
111 | const newValue = { ...value };
112 | setSelectedDate(newValue);
113 | }
114 | };
115 |
116 | const screenLoading = mainLoading || loading;
117 |
118 | useEffect(() => {
119 | if (!loading && doc) {
120 | getStartData(doc);
121 | }
122 | }, [getStartData, doc, loading, selectedDate]);
123 |
124 | const flattedOptions = monthsOptions
125 | .map(({ options }) => options)
126 | .flat()
127 | .find(
128 | ({ value }) =>
129 | parseInt(value.year) === parseInt(selectedDate.year) &&
130 | parseInt(value.month) === parseInt(selectedDate.month)
131 | );
132 |
133 | const handleCloseDetail = () => {
134 | setShowDetail("");
135 | setDetailData([]);
136 | setModalTitle("");
137 | setModalSubtitle("");
138 | };
139 |
140 | const showAllDetails = async () => {
141 | setMainLoading(true);
142 |
143 | const title = `${monthToText(parseInt(selectedDate.month))} ${
144 | selectedDate.year
145 | }`;
146 | setModalTitle(title);
147 |
148 | const selectedMonth = parseInt(selectedDate.month) + 1;
149 | const data = await getDetailsByMonth(doc, selectedMonth, selectedDate.year);
150 | setShowDetail("all");
151 | setDetailData(data);
152 | const subTitle = `${data.length} transactions`;
153 | setModalSubtitle(subTitle);
154 | setMainLoading(false);
155 | };
156 |
157 | const deleteRecord = async (id, type) => {
158 | modalDispatch({
159 | type: DispatchTypes.Modal.MODAL_SHOW,
160 | title: "Confirmation",
161 | content: "Do you really want to delete this record?",
162 | actions: [
163 | {
164 | type: "secondary",
165 | text: "Delete",
166 | action: async () => {
167 | modalDispatch({ type: DispatchTypes.Modal.MODAL_HIDE });
168 | setMainLoading(true);
169 | await deleteRow(doc, id);
170 | getStartData(doc);
171 | if (showDetail === "all") {
172 | showAllDetails();
173 | } else {
174 | getDetailData(type);
175 | }
176 | setMainLoading(false);
177 | },
178 | },
179 | {
180 | type: "text",
181 | text: "Cancel",
182 | action: () => {
183 | modalDispatch({ type: DispatchTypes.Modal.MODAL_HIDE });
184 | },
185 | },
186 | ],
187 | });
188 | };
189 |
190 | return (
191 | <>
192 |
193 |
194 |
195 | Expenses
196 | {
200 | onChangeDate(value);
201 | }}
202 | placeholder="Select"
203 | value={flattedOptions}
204 | />
205 |
206 |
213 |
214 | {data.map((item, index) => (
215 | getDetailData(item.name)}
223 | />
224 | ))}
225 |
226 |
231 |
232 |
233 |
234 |
235 | {showDetail && (
236 | handleCloseDetail()}
240 | >
241 | {detailData.map(({ Amount, Date, Detail, Type, Id }, index) => (
242 | deleteRecord(Id, Type)}
249 | />
250 | ))}
251 |
252 | )}
253 | {screenLoading && }
254 | >
255 | );
256 | };
257 |
258 | export default Type;
259 |
--------------------------------------------------------------------------------
/src/screens/Analytics/index.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useEffect, useState, useCallback } from "react";
2 | import { NoHeaderLayout } from "layouts";
3 | import {
4 | DropdownComponent,
5 | LoadingComponent,
6 | BarChartComponent,
7 | MonthLegendComponent,
8 | LineChartComponent,
9 | CheckboxComponent,
10 | } from "components";
11 | import * as S from "./styles";
12 | import { GlobalContext } from "context";
13 | import {
14 | getYears,
15 | getAllMonthByYear,
16 | getLast12Months,
17 | getLast12MonthsByType,
18 | getAllMonthByTypeByYear,
19 | } from "services";
20 | import Utils from "lib/utils";
21 | import { useHistory } from "react-router-dom";
22 |
23 | const Analytics = () => {
24 | const history = useHistory();
25 | const context = useContext(GlobalContext);
26 | const [userState] = context.globalUser;
27 | const { doc, loading } = userState;
28 |
29 | const { formatSymbol } = Utils.Currency;
30 | const { LAST_12_MONTHS_OPTION } = Utils.Constants;
31 | const { nextMonth } = Utils.Date;
32 | const { addColors } = Utils.Colors;
33 |
34 | const [mainLoading, setMainLoading] = useState(false);
35 | const [selectedYear, setSelectedYear] = useState(LAST_12_MONTHS_OPTION.value);
36 | const [yearsOption, setYearsOption] = useState([]);
37 | const [data, setData] = useState([]);
38 | const [legendData, setLegendData] = useState([]);
39 | const [chartCategories, setChartCategories] = useState([]);
40 | const [showTypes, setShowTypes] = useState(false);
41 | const [totalYear, setTotalYear] = useState(0);
42 |
43 | const getStartData = useCallback(
44 | async (doc) => {
45 | setMainLoading(true);
46 | const years = await getYears(doc);
47 | const newYearsOptions = years.map((y) => ({ label: y, value: y }));
48 | newYearsOptions.push(LAST_12_MONTHS_OPTION);
49 | setYearsOption(newYearsOptions);
50 |
51 | const getChartDataTypes = () => {
52 | if (selectedYear === "last12months") {
53 | return getLast12MonthsByType(doc);
54 | } else {
55 | return getAllMonthByTypeByYear(doc, selectedYear);
56 | }
57 | };
58 |
59 | const getChartData = () => {
60 | if (selectedYear === "last12months") {
61 | return getLast12Months(doc);
62 | } else {
63 | return getAllMonthByYear(doc, selectedYear);
64 | }
65 | };
66 |
67 | const newChartData = showTypes
68 | ? await getChartDataTypes()
69 | : await getChartData();
70 |
71 | const arrayStartsFrom = selectedYear === "last12months" ? nextMonth() : 1;
72 |
73 | const newArray =
74 | selectedYear === "last12months"
75 | ? newChartData.map(({ name }) => parseInt(name))
76 | : Array.from({ length: 12 }, (_, i) =>
77 | i + arrayStartsFrom > 12
78 | ? (i + arrayStartsFrom + 1) % 13
79 | : i + arrayStartsFrom
80 | );
81 |
82 | const chartDataWithAllMonths = newArray.map((index) => {
83 | const findElement = newChartData.find(
84 | ({ name }) => parseInt(name) === index
85 | );
86 |
87 | const newName = index < 10 ? `0${index}` : `${index}`;
88 |
89 | if (showTypes) {
90 | if (findElement) {
91 | const keys = Object.keys(findElement);
92 | const newKeys = keys.filter((e) => e !== "name" && e !== "year");
93 | const newValues = newKeys
94 | .map((e) => ({
95 | [e]: findElement[e].value,
96 | }))
97 | .reduce(
98 | (prev, curr) => ({
99 | ...prev,
100 | ...curr,
101 | }),
102 | {}
103 | );
104 | const newObject = {
105 | ...newValues,
106 | name: newName,
107 | year: findElement[newKeys[0]]?.year,
108 | };
109 | return newObject;
110 | } else {
111 | return { name: newName };
112 | }
113 | } else {
114 | return findElement
115 | ? findElement
116 | : { value: 0, count: 0, year: 0, name: newName };
117 | }
118 | });
119 |
120 | const yearData = await getChartData();
121 | const leggendsData = newArray
122 | .map((index) => {
123 | const findElement = yearData.find(
124 | ({ name }) => parseInt(name) === index
125 | );
126 | const newName = index < 10 ? `0${index}` : `${index}`;
127 | return findElement
128 | ? findElement
129 | : { value: 0, count: 0, year: 0, name: newName };
130 | })
131 | .filter(({ value }) => value > 0)
132 | .sort((a, b) =>
133 | parseInt(`${b.year}${b.name}`) > parseInt(`${a.year}${a.name}`)
134 | ? 1
135 | : -1
136 | );
137 | const newTotalYear = newArray
138 | .map((index) => {
139 | const findElement = yearData.find(
140 | ({ name }) => parseInt(name) === index
141 | );
142 | const newName = index < 10 ? `0${index}` : `${index}`;
143 | return findElement
144 | ? findElement
145 | : { value: 0, count: 0, year: 0, name: newName };
146 | })
147 | .filter(({ value }) => value > 0)
148 | .reduce((acc, cur) => acc + cur.value, 0);
149 |
150 | setTotalYear(newTotalYear);
151 | setLegendData(leggendsData);
152 | setData(chartDataWithAllMonths);
153 | setMainLoading(false);
154 | },
155 | [LAST_12_MONTHS_OPTION, nextMonth, selectedYear, showTypes]
156 | );
157 |
158 | useEffect(() => {
159 | const categories = showTypes
160 | ? addColors(
161 | [...new Set(data.map((e) => Object.keys(e)).flat())]
162 | .filter((e) => e !== "name" && e !== "year")
163 | .map((e, index) => ({
164 | key: e,
165 | checked: index <= 2,
166 | }))
167 | )
168 | : [];
169 | if (categories) {
170 | setChartCategories(categories);
171 | }
172 | }, [addColors, data, showTypes]);
173 |
174 | useEffect(() => {
175 | if (doc) {
176 | getStartData(doc);
177 | }
178 | }, [doc, getStartData, selectedYear]);
179 |
180 | const onChangeYear = (newYear) => {
181 | setSelectedYear(newYear);
182 | };
183 |
184 | const yearSelectedValueOption = yearsOption.find(
185 | ({ value }) => value === selectedYear.toString()
186 | );
187 |
188 | const chartData = showTypes
189 | ? data
190 | : data.map(({ value, name }) => ({
191 | name,
192 | amount: value,
193 | }));
194 |
195 | const handleCategoryChange = (key) => {
196 | const newCategories = [...chartCategories].map((category) => ({
197 | ...category,
198 | checked: category.key === key ? !category.checked : category.checked,
199 | }));
200 | setChartCategories(newCategories);
201 | };
202 |
203 | const screenLoading = mainLoading || loading;
204 |
205 | return (
206 | <>
207 |
208 |
209 |
210 | Expenses
211 | {
215 | onChangeYear(value);
216 | }}
217 | placeholder="Select"
218 | value={yearSelectedValueOption}
219 | />
220 |
221 |
222 | {`Summary of ${
223 | selectedYear === "last12months" ? "last 12 months" : selectedYear
224 | }`}
225 |
226 | ${formatSymbol(totalYear)} this{" "}
227 | {selectedYear === "last12months" ? "period" : "year"}
228 |
229 |
230 | <>
231 | setShowTypes(!showTypes)}
235 | label="By type"
236 | />
237 | >
238 | {showTypes ? (
239 | <>
240 | checked)}
244 | />
245 |
246 | {chartCategories.map(({ key, checked, color }, index) => (
247 |
248 | handleCategoryChange(key)}
252 | color={color}
253 | />
254 |
255 | ))}
256 |
257 | >
258 | ) : (
259 |
260 | )}
261 |
262 | {legendData.map(({ name, value, year }, index) => (
263 | {
269 | history.push({
270 | pathname: "/types",
271 | state: { defaultMonth: name, defaultYear: year },
272 | });
273 | }}
274 | />
275 | ))}
276 |
277 |
278 |
279 |
280 | {screenLoading && }
281 | >
282 | );
283 | };
284 |
285 | export default Analytics;
286 |
--------------------------------------------------------------------------------