├── .envexample
├── .eslintrc.json
├── .gitignore
├── .prettierrc.json
├── README.md
├── assets
├── dark_logo_banner.png
├── light_logo_banner.png
└── logo.png
├── backend
├── constants
│ ├── ledgerTypes.js
│ └── policies.js
├── controllers
│ ├── entryController.js
│ ├── ledgerController.js
│ ├── statementController.js
│ └── userController.js
├── middlewares
│ ├── authMiddleware.js
│ └── errorMiddleware.js
├── models
│ ├── entryModel.js
│ ├── ledgerModel.js
│ └── userModel.js
├── routes
│ ├── api.js
│ └── v1
│ │ ├── authRoutes.js
│ │ ├── entryRoutes.js
│ │ ├── index.js
│ │ ├── ledgerRoutes.js
│ │ ├── profileRoutes.js
│ │ └── statementRoutes.js
├── server.js
└── util
│ ├── entryValidationSchema.js
│ ├── ledgerValidationSchema.js
│ ├── tokenGenerator.js
│ └── userValidationSchema.js
├── build.sh
├── frontend
├── .gitignore
├── index.html
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
│ ├── favicon.ico
│ ├── logo-dark.png
│ ├── logo-light.png
│ ├── logo.png
│ ├── manifest.json
│ └── robots.txt
├── src
│ ├── App.css
│ ├── App.jsx
│ ├── app
│ │ └── store.js
│ ├── components
│ │ ├── ActivityHeatMap.jsx
│ │ ├── Alert.jsx
│ │ ├── AuthProtectedRoute.jsx
│ │ ├── Avatar.jsx
│ │ ├── ChangePasswordSettings.jsx
│ │ ├── DeleteAccountSettings.jsx
│ │ ├── Entry.jsx
│ │ ├── Footer.jsx
│ │ ├── Header.jsx
│ │ ├── Loading.jsx
│ │ ├── MicroStatement.jsx
│ │ ├── MiniLoading.jsx
│ │ ├── NormalizationSettings.jsx
│ │ ├── Posting.jsx
│ │ ├── PreferenceSettings.jsx
│ │ └── TrialBalanceItem.jsx
│ ├── constants
│ │ ├── amountFormat.js
│ │ ├── api.js
│ │ ├── currency.js
│ │ ├── ledgerTypes.js
│ │ ├── policies.js
│ │ └── theme.js
│ ├── features
│ │ ├── auth
│ │ │ └── authSlice.js
│ │ └── preference
│ │ │ └── preferenceSlice.js
│ ├── hooks
│ │ ├── useActivityHeatMapData.js
│ │ ├── useDebounce.js
│ │ ├── useEntryDataHook.js
│ │ ├── useFetch.js
│ │ ├── useLedgerDataHook.js
│ │ ├── useMicroStatementData.js
│ │ └── useTrialBalanceData.js
│ ├── main.jsx
│ ├── pages
│ │ ├── CreateEntry.jsx
│ │ ├── CreateLedger.jsx
│ │ ├── EditEntry.jsx
│ │ ├── EditLedger.jsx
│ │ ├── EditProfile.jsx
│ │ ├── Export.jsx
│ │ ├── Home.jsx
│ │ ├── Journal.jsx
│ │ ├── Login.jsx
│ │ ├── Profile.jsx
│ │ ├── Register.jsx
│ │ ├── SearchEntry.jsx
│ │ ├── SelectLedger.jsx
│ │ ├── Settings.jsx
│ │ ├── TrialBalance.jsx
│ │ ├── ViewEntry.jsx
│ │ └── ViewLedger.jsx
│ └── util
│ │ ├── amountFormat.js
│ │ ├── authConfig.js
│ │ ├── balanceIsNegative.js
│ │ ├── entryValidationSchema.js
│ │ ├── ledgerValidationSchema.js
│ │ ├── preferenceValidationSchema.js
│ │ ├── timeFormat.js
│ │ └── userValidationSchema.js
├── tailwind.config.js
└── vite.config.js
├── frontend2
├── .gitignore
├── README.md
├── eslint.config.js
├── index.html
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
│ ├── favicon.ico
│ ├── logo-dark.png
│ ├── logo-light.png
│ ├── logo.png
│ ├── manifest.json
│ └── robots.txt
├── src
│ ├── App.jsx
│ ├── app
│ │ └── store.js
│ ├── assets
│ │ └── react.svg
│ ├── components
│ │ ├── ActivityHeatMap.jsx
│ │ ├── Amount.jsx
│ │ ├── Avatar.jsx
│ │ ├── Button.jsx
│ │ ├── Entry.jsx
│ │ ├── EntryTable.jsx
│ │ ├── Footer.jsx
│ │ ├── Header.jsx
│ │ ├── LedgerTable.jsx
│ │ ├── Sidebar.jsx
│ │ ├── Time.jsx
│ │ └── form
│ │ │ ├── FilterSelectInput.jsx
│ │ │ ├── FilterTextInput.jsx
│ │ │ ├── Input.jsx
│ │ │ ├── SelectInput.jsx
│ │ │ └── Textarea.jsx
│ ├── constants
│ │ ├── amountFormat.js
│ │ ├── api.js
│ │ ├── currency.js
│ │ ├── ledgerTypes.js
│ │ ├── policies.js
│ │ └── theme.js
│ ├── features
│ │ ├── authSlice.js
│ │ └── preferenceSlice.js
│ ├── hooks
│ │ ├── useActivityHeatMapData.js
│ │ ├── useDebounce.js
│ │ ├── useEntryDataHook.js
│ │ ├── useLedgerDataHook.js
│ │ ├── useMicroStatementData.js
│ │ ├── useTrialBalanceData.js
│ │ └── useUserDataHook.js
│ ├── index.css
│ ├── layouts
│ │ ├── AuthLayout.jsx
│ │ └── MainLayout.jsx
│ ├── main.jsx
│ ├── pages
│ │ ├── About.jsx
│ │ ├── CreateEntry.jsx
│ │ ├── CreateLedger.jsx
│ │ ├── CreateMenu.jsx
│ │ ├── Dashboard.jsx
│ │ ├── EditLedger.jsx
│ │ ├── EditProfile.jsx
│ │ ├── Journal.jsx
│ │ ├── Landing.jsx
│ │ ├── Login.jsx
│ │ ├── Page404.jsx
│ │ ├── Profile.jsx
│ │ ├── Register.jsx
│ │ ├── SelectLedger.jsx
│ │ ├── TrialBalance.jsx
│ │ ├── ViewEntry.jsx
│ │ └── ViewLedger.jsx
│ └── util
│ │ ├── amountFormat.js
│ │ ├── authConfig.js
│ │ ├── balanceIsNegative.js
│ │ ├── entryValidationSchema.js
│ │ ├── ledgerValidationSchema.js
│ │ ├── preferenceValidationSchema.js
│ │ ├── timeFormat.js
│ │ └── userValidationSchema.js
├── tailwind.config.js
└── vite.config.js
├── package-lock.json
├── package.json
└── test
├── Lucafy.postman_collection.json
└── randomEntryGenerator.js
/.envexample:
--------------------------------------------------------------------------------
1 | NODE_ENV = development
2 | PORT = 3001
3 | MONGODB_URI = ""
4 | SECRET_KEY = "abcdef"
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true
5 | },
6 | "extends": ["plugin:react/recommended", "airbnb", "prettier"],
7 | "parserOptions": {
8 | "ecmaFeatures": {
9 | "jsx": true
10 | },
11 | "ecmaVersion": "latest",
12 | "sourceType": "module"
13 | },
14 | "plugins": ["react"],
15 | "rules": {
16 | "linebreak-style": 0,
17 | "no-console":"off"
18 | }
19 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 | .pnpm-debug.log*
9 |
10 | # Diagnostic reports (https://nodejs.org/api/report.html)
11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12 |
13 | # Runtime data
14 | pids
15 | *.pid
16 | *.seed
17 | *.pid.lock
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 | *.lcov
25 |
26 | # nyc test coverage
27 | .nyc_output
28 |
29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30 | .grunt
31 |
32 | # Bower dependency directory (https://bower.io/)
33 | bower_components
34 |
35 | # node-waf configuration
36 | .lock-wscript
37 |
38 | # Compiled binary addons (https://nodejs.org/api/addons.html)
39 | build/Release
40 |
41 | # Dependency directories
42 | node_modules/
43 | jspm_packages/
44 |
45 | # Snowpack dependency directory (https://snowpack.dev/)
46 | web_modules/
47 |
48 | # TypeScript cache
49 | *.tsbuildinfo
50 |
51 | # Optional npm cache directory
52 | .npm
53 |
54 | # Optional eslint cache
55 | .eslintcache
56 |
57 | # Optional stylelint cache
58 | .stylelintcache
59 |
60 | # Microbundle cache
61 | .rpt2_cache/
62 | .rts2_cache_cjs/
63 | .rts2_cache_es/
64 | .rts2_cache_umd/
65 |
66 | # Optional REPL history
67 | .node_repl_history
68 |
69 | # Output of 'npm pack'
70 | *.tgz
71 |
72 | # Yarn Integrity file
73 | .yarn-integrity
74 |
75 | # dotenv environment variable files
76 | .env
77 | .env.development.local
78 | .env.test.local
79 | .env.production.local
80 | .env.local
81 |
82 | # parcel-bundler cache (https://parceljs.org/)
83 | .cache
84 | .parcel-cache
85 |
86 | # Next.js build output
87 | .next
88 | out
89 |
90 | # Nuxt.js build / generate output
91 | .nuxt
92 | dist
93 |
94 | # Gatsby files
95 | .cache/
96 | # Comment in the public line in if your project uses Gatsby and not Next.js
97 | # https://nextjs.org/blog/next-9-1#public-directory-support
98 | # public
99 |
100 | # vuepress build output
101 | .vuepress/dist
102 |
103 | # vuepress v2.x temp and cache directory
104 | .temp
105 | .cache
106 |
107 | # Docusaurus cache and generated files
108 | .docusaurus
109 |
110 | # Serverless directories
111 | .serverless/
112 |
113 | # FuseBox cache
114 | .fusebox/
115 |
116 | # DynamoDB Local files
117 | .dynamodb/
118 |
119 | # TernJS port file
120 | .tern-port
121 |
122 | # Stores VSCode versions used for testing VSCode extensions
123 | .vscode-test
124 |
125 | # yarn v2
126 | .yarn/cache
127 | .yarn/unplugged
128 | .yarn/build-state.yml
129 | .yarn/install-state.gz
130 | .pnp.*
131 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "arrowParens": "always",
3 | "bracketSameLine": false,
4 | "bracketSpacing": true,
5 | "embeddedLanguageFormatting": "auto",
6 | "insertPragma": false,
7 | "printWidth": 80,
8 | "proseWrap": "preserve",
9 | "quoteProps": "as-needed",
10 | "requirePragma": false,
11 | "semi": true,
12 | "singleQuote": false,
13 | "tabWidth": 2,
14 | "trailingComma": "es5",
15 | "useTabs": false
16 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Lucafy ⛰️
4 |
5 | A web based simple double entry system book keeping application.
6 |
7 | Demo 🚀
8 | ·
9 | Report Bug 😓
10 |
11 |
12 |
13 |
14 |
15 |
16 | ## About
17 | Lucafy is an open-source, web based, MERN stack, bookkeeping application
18 | that uses double entry accounting. It lets you create journal entries, ledgers
19 | and view trial balance and other statements.
20 |
21 | ## Getting Started
22 | ### Setup
23 | - Clone repository
24 | - Create `.env` file (use the `.envexample` for reference) and add your credentials.
25 | - **Install Dependencies:** Run `npm i` in the main folder for *backend dependencies* and run the same command inside frontend folder for *frontend dependencies*
26 | - Run `npm run dev` in the main folder
27 |
28 | ### Self-Hosted (Server)
29 | 🚧 Work in progress
30 |
31 | ## Demo
32 | 🚧 Work in progress
33 |
34 | ## Contribution
35 | Just send me a pull request. Mention your discord or instagram id.
36 |
37 | (if the instructions were unclear, please let me know)
38 |
39 | ## Contact
40 | Send me a message on discord or instagram. Check out my [Profile Readme](https://github.com/captainAyan)
41 |
--------------------------------------------------------------------------------
/assets/dark_logo_banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/captainAyan/lucafy/a6935f1d04ba046c17ff727812e42260fa68697d/assets/dark_logo_banner.png
--------------------------------------------------------------------------------
/assets/light_logo_banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/captainAyan/lucafy/a6935f1d04ba046c17ff727812e42260fa68697d/assets/light_logo_banner.png
--------------------------------------------------------------------------------
/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/captainAyan/lucafy/a6935f1d04ba046c17ff727812e42260fa68697d/assets/logo.png
--------------------------------------------------------------------------------
/backend/constants/ledgerTypes.js:
--------------------------------------------------------------------------------
1 | exports.INCOME = "income";
2 | exports.EXPENDITURE = "expenditure";
3 | exports.ASSET = "asset";
4 | exports.LIABILITY = "liability";
5 | exports.EQUITY = "equity";
6 |
--------------------------------------------------------------------------------
/backend/constants/policies.js:
--------------------------------------------------------------------------------
1 | exports.LEDGER_LIMIT = 20;
2 | exports.ENTRY_LIMIT = 100;
3 |
4 | exports.PER_MINUTE_REQUEST_LIMIT = 100;
5 | exports.DEFAULT_PAGINATION_LIMIT = 10;
6 |
7 | exports.USER_FIRST_NAME_MAX_LENGTH = 100;
8 | exports.USER_MIDDLE_NAME_MAX_LENGTH = 100;
9 | exports.USER_LAST_NAME_MAX_LENGTH = 100;
10 | exports.USER_EMAIL_MAX_LENGTH = 100;
11 | exports.USER_PASSWORD_MIN_LENGTH = 6;
12 | exports.USER_PASSWORD_MAX_LENGTH = 200;
13 | exports.USER_BIO_MAX_LENGTH = 200;
14 | exports.USER_ORGANIZATION_MAX_LENGTH = 100;
15 | exports.USER_JOB_TITLE_MAX_LENGTH = 100;
16 | exports.USER_LOCATION_MAX_LENGTH = 200;
17 |
18 | exports.LEDGER_NAME_MAX_LENGTH = 50;
19 | exports.LEDGER_DESCRIPTION_MAX_LENGTH = 200;
20 |
21 | exports.ENTRY_NARRATION_MAX_LENGTH = 200;
22 |
--------------------------------------------------------------------------------
/backend/controllers/ledgerController.js:
--------------------------------------------------------------------------------
1 | const { StatusCodes } = require("http-status-codes");
2 | const asyncHandler = require("express-async-handler");
3 |
4 | const Ledger = require("../models/ledgerModel");
5 | const { ErrorResponse } = require("../middlewares/errorMiddleware");
6 | const { createSchema, editSchema } = require("../util/ledgerValidationSchema");
7 | const { LEDGER_LIMIT, PAGINATION_LIMIT } = require("../constants/policies");
8 |
9 | const getLedgers = asyncHandler(async (req, res, next) => {
10 | const PAGE =
11 | parseInt(req.query.page, 10) > 0 ? parseInt(req.query.page, 10) : 0;
12 |
13 | const ledgers = await Ledger.find({ user_id: req.user.id })
14 | .sort("-created_at")
15 | .select(["-user_id", "-balance"])
16 | .skip(PAGE * PAGINATION_LIMIT)
17 | .limit(PAGINATION_LIMIT);
18 |
19 | const response = {
20 | skip: PAGE * PAGINATION_LIMIT,
21 | limit: PAGINATION_LIMIT,
22 | total: await Ledger.find({ user_id: req.user.id }).count(),
23 | ledgers,
24 | };
25 |
26 | res.status(StatusCodes.OK).json(response);
27 | });
28 |
29 | const getAllLedgers = asyncHandler(async (req, res, next) => {
30 | const ledgers = await Ledger.find({ user_id: req.user.id })
31 | .sort("-created_at")
32 | .select(["-user_id", "-balance"]);
33 |
34 | const response = {
35 | ledgers,
36 | };
37 |
38 | res.status(StatusCodes.OK).json(response);
39 | });
40 |
41 | const getLedger = asyncHandler(async (req, res, next) => {
42 | const { id } = req.params;
43 |
44 | let ledger;
45 |
46 | try {
47 | ledger = await Ledger.findOne({ _id: id, user_id: req.user.id }).select([
48 | "-user_id",
49 | "-balance",
50 | ]);
51 | } catch (error) {
52 | // for invalid mongodb objectId
53 | throw new ErrorResponse("Ledger not found", StatusCodes.NOT_FOUND);
54 | }
55 |
56 | if (!ledger) {
57 | throw new ErrorResponse("Ledger not found", StatusCodes.NOT_FOUND);
58 | }
59 |
60 | res.status(StatusCodes.OK).json(ledger);
61 | });
62 |
63 | const createLedger = asyncHandler(async (req, res, next) => {
64 | const { error } = createSchema.validate(req.body);
65 |
66 | if (error) {
67 | throw new ErrorResponse("Invalid input error", StatusCodes.BAD_REQUEST);
68 | }
69 |
70 | const totalLedgers = await Ledger.find({ user_id: req.user.id }).count();
71 |
72 | if (totalLedgers === LEDGER_LIMIT) {
73 | throw new ErrorResponse("Ledger limit reached", StatusCodes.FORBIDDEN);
74 | }
75 |
76 | const l = await Ledger.create({
77 | ...req.body,
78 | user_id: req.user.id,
79 | });
80 |
81 | const ledger = await Ledger.findById(l.id).select(["-user_id", "-balance"]);
82 |
83 | res.status(StatusCodes.CREATED).json(ledger);
84 | });
85 |
86 | const editLedger = asyncHandler(async (req, res, next) => {
87 | const { error } = editSchema.validate(req.body);
88 |
89 | if (error) {
90 | throw new ErrorResponse("Invalid input error", StatusCodes.BAD_REQUEST);
91 | }
92 |
93 | const { id } = req.params;
94 |
95 | let ledger;
96 |
97 | try {
98 | ledger = await Ledger.findOne({ _id: id, user_id: req.user.id }).select([
99 | "-user_id",
100 | "-balance",
101 | ]);
102 | } catch (error) {
103 | // for invalid mongodb objectId
104 | throw new ErrorResponse("Ledger not found", StatusCodes.NOT_FOUND);
105 | }
106 |
107 | if (!ledger) {
108 | throw new ErrorResponse("Ledger not found", StatusCodes.NOT_FOUND);
109 | }
110 |
111 | const { name, type, description } = req.body;
112 |
113 | ledger.name = name;
114 | ledger.type = type;
115 | ledger.description = description;
116 |
117 | ledger.save();
118 |
119 | res.status(StatusCodes.OK).json(ledger);
120 | });
121 |
122 | module.exports = {
123 | createLedger,
124 | getLedgers,
125 | getAllLedgers,
126 | getLedger,
127 | editLedger,
128 | };
129 |
--------------------------------------------------------------------------------
/backend/middlewares/authMiddleware.js:
--------------------------------------------------------------------------------
1 | const jwt = require("jsonwebtoken");
2 | const { StatusCodes } = require("http-status-codes");
3 | const asyncHandler = require("express-async-handler");
4 |
5 | const User = require("../models/userModel");
6 | const { ErrorResponse } = require("./errorMiddleware");
7 |
8 | const protect = asyncHandler(async (req, res, next) => {
9 | if (
10 | req.headers.authorization &&
11 | req.headers.authorization.startsWith("Bearer ")
12 | ) {
13 | try {
14 | const token = req.headers.authorization.split(" ")[1];
15 | const decoded = jwt.verify(token, process.env.SECRET_KEY);
16 |
17 | const user = await User.findById(decoded.id).select("-password");
18 | if (!user) {
19 | throw new ErrorResponse("User does not exist", StatusCodes.BAD_REQUEST);
20 | }
21 |
22 | req.user = user;
23 | next();
24 | } catch (err) {
25 | throw new ErrorResponse("Not authenticated", StatusCodes.UNAUTHORIZED);
26 | }
27 | } else {
28 | throw new ErrorResponse("Not authenticated", StatusCodes.UNAUTHORIZED);
29 | }
30 | });
31 |
32 | module.exports = { protect };
33 |
--------------------------------------------------------------------------------
/backend/middlewares/errorMiddleware.js:
--------------------------------------------------------------------------------
1 | const { StatusCodes } = require("http-status-codes");
2 |
3 | class ErrorResponse extends Error {
4 | constructor(message, status) {
5 | super(message);
6 | this.status = status;
7 | }
8 | }
9 |
10 | const errorHandler = (err, req, res, _) => {
11 | const isProd = process.env.NODE_ENV === "production";
12 |
13 | res.status(err.status || StatusCodes.INTERNAL_SERVER_ERROR).send({
14 | error: {
15 | message: isProd ? "Internal server error" : err.message,
16 | stack: isProd ? null : err.stack,
17 | type: err.type || 0,
18 | code: err.code || 0,
19 | },
20 | });
21 | };
22 |
23 | module.exports = {
24 | ErrorResponse,
25 | errorHandler,
26 | };
27 |
--------------------------------------------------------------------------------
/backend/models/entryModel.js:
--------------------------------------------------------------------------------
1 | const mongoose = require("mongoose");
2 | const { ENTRY_NARRATION_MAX_LENGTH } = require("../constants/policies");
3 |
4 | const entrySchema = new mongoose.Schema(
5 | {
6 | debit_ledger: {
7 | type: mongoose.Schema.Types.ObjectId,
8 | required: true,
9 | ref: "Ledger",
10 | },
11 | credit_ledger: {
12 | type: mongoose.Schema.Types.ObjectId,
13 | required: true,
14 | ref: "Ledger",
15 | },
16 | narration: {
17 | type: String,
18 | required: true,
19 | trim: true,
20 | minlength: 1,
21 | maxlength: ENTRY_NARRATION_MAX_LENGTH,
22 | },
23 | user_id: {
24 | type: mongoose.Schema.Types.ObjectId,
25 | required: true,
26 | ref: "User",
27 | },
28 | amount: {
29 | type: Number,
30 | required: true,
31 | },
32 | },
33 | { timestamps: { createdAt: "created_at", updatedAt: "updated_at" } }
34 | );
35 |
36 | entrySchema.virtual("id").get(function () {
37 | return this._id.toHexString();
38 | });
39 |
40 | entrySchema.set("toJSON", {
41 | virtuals: true,
42 | });
43 |
44 | module.exports = mongoose.model("Entry", entrySchema);
45 |
--------------------------------------------------------------------------------
/backend/models/ledgerModel.js:
--------------------------------------------------------------------------------
1 | const mongoose = require("mongoose");
2 | const {
3 | INCOME,
4 | EXPENDITURE,
5 | ASSET,
6 | LIABILITY,
7 | EQUITY,
8 | } = require("../constants/ledgerTypes");
9 |
10 | const {
11 | LEDGER_NAME_MAX_LENGTH,
12 | LEDGER_DESCRIPTION_MAX_LENGTH,
13 | } = require("../constants/policies");
14 |
15 | const ledgerSchema = new mongoose.Schema(
16 | {
17 | name: {
18 | type: String,
19 | required: true,
20 | trim: true,
21 | minlength: 1,
22 | maxlength: LEDGER_NAME_MAX_LENGTH,
23 | },
24 | type: {
25 | type: String,
26 | required: true,
27 | enum: [INCOME, EXPENDITURE, ASSET, LIABILITY, EQUITY],
28 | },
29 | description: {
30 | type: String,
31 | required: true,
32 | trim: true,
33 | minlength: 1,
34 | maxlength: LEDGER_DESCRIPTION_MAX_LENGTH,
35 | },
36 | balance: {
37 | type: Number,
38 | required: true,
39 | default: 0,
40 | },
41 | user_id: {
42 | type: mongoose.Schema.Types.ObjectId,
43 | required: true,
44 | ref: "User",
45 | },
46 | },
47 | { timestamps: { createdAt: "created_at", updatedAt: "updated_at" } }
48 | );
49 |
50 | ledgerSchema.virtual("id").get(function () {
51 | return this._id;
52 | });
53 |
54 | ledgerSchema.set("toJSON", {
55 | virtuals: true,
56 | });
57 |
58 | module.exports = mongoose.model("Ledger", ledgerSchema);
59 |
--------------------------------------------------------------------------------
/backend/models/userModel.js:
--------------------------------------------------------------------------------
1 | const mongoose = require("mongoose");
2 | const {
3 | USER_FIRST_NAME_MAX_LENGTH,
4 | USER_MIDDLE_NAME_MAX_LENGTH,
5 | USER_LAST_NAME_MAX_LENGTH,
6 | USER_EMAIL_MAX_LENGTH,
7 | USER_PASSWORD_MAX_LENGTH,
8 | USER_PASSWORD_MIN_LENGTH,
9 | USER_LOCATION_MAX_LENGTH,
10 | USER_JOB_TITLE_MAX_LENGTH,
11 | USER_ORGANIZATION_MAX_LENGTH,
12 | USER_BIO_MAX_LENGTH,
13 | } = require("../constants/policies");
14 |
15 | const userSchema = new mongoose.Schema({
16 | firstName: {
17 | type: String,
18 | required: true,
19 | trim: true,
20 | minlength: 1,
21 | maxlength: USER_FIRST_NAME_MAX_LENGTH,
22 | },
23 | middleName: {
24 | type: String,
25 | default: "",
26 | trim: true,
27 | maxlength: USER_MIDDLE_NAME_MAX_LENGTH,
28 | },
29 | lastName: {
30 | type: String,
31 | required: true,
32 | trim: true,
33 | minlength: 1,
34 | maxlength: USER_LAST_NAME_MAX_LENGTH,
35 | },
36 | email: {
37 | type: String,
38 | required: true,
39 | unique: true,
40 | lowercase: true,
41 | trim: true,
42 | minlength: 1,
43 | maxlength: USER_EMAIL_MAX_LENGTH,
44 | },
45 | password: {
46 | type: String,
47 | required: true,
48 | minlength: USER_PASSWORD_MIN_LENGTH,
49 | maxlength: USER_PASSWORD_MAX_LENGTH,
50 | select: false,
51 | },
52 | bio: {
53 | type: String,
54 | default: "",
55 | trim: true,
56 | maxlength: USER_BIO_MAX_LENGTH,
57 | },
58 | organization: {
59 | type: String,
60 | default: "",
61 | trim: true,
62 | maxlength: USER_ORGANIZATION_MAX_LENGTH,
63 | },
64 | jobTitle: {
65 | type: String,
66 | default: "",
67 | trim: true,
68 | maxlength: USER_JOB_TITLE_MAX_LENGTH,
69 | },
70 | location: {
71 | type: String,
72 | default: "",
73 | trim: true,
74 | maxlength: USER_LOCATION_MAX_LENGTH,
75 | },
76 | });
77 |
78 | module.exports = mongoose.model("User", userSchema);
79 |
--------------------------------------------------------------------------------
/backend/routes/api.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 |
3 | const router = express.Router();
4 |
5 | router.use("/v1", require("./v1/index"));
6 |
7 | module.exports = router;
8 |
--------------------------------------------------------------------------------
/backend/routes/v1/authRoutes.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 |
3 | const router = express.Router();
4 | const {
5 | login,
6 | register,
7 | changePassword,
8 | } = require("../../controllers/userController");
9 | const { protect } = require("../../middlewares/authMiddleware");
10 |
11 | router.post("/login", login);
12 | router.post("/register", register);
13 | router.put("/changepassword", protect, changePassword);
14 |
15 | module.exports = router;
16 |
--------------------------------------------------------------------------------
/backend/routes/v1/entryRoutes.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 |
3 | const router = express.Router();
4 | const {
5 | createEntry,
6 | getEntry,
7 | getEntries,
8 | editEntry,
9 | normalizeEntries,
10 | normalizeEntry,
11 | searchEntryByNarration,
12 | } = require("../../controllers/entryController");
13 | const { protect } = require("../../middlewares/authMiddleware");
14 |
15 | router.post("/", protect, createEntry);
16 | router.get("/", protect, getEntries);
17 | router.get("/search", protect, searchEntryByNarration);
18 | router.get("/:id", protect, getEntry);
19 | router.put("/normalize", protect, normalizeEntries);
20 | router.put("/:id", protect, editEntry);
21 | router.put("/normalize/:id", protect, normalizeEntry);
22 |
23 | module.exports = router;
24 |
--------------------------------------------------------------------------------
/backend/routes/v1/index.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 |
3 | const router = express.Router();
4 |
5 | router.use("/auth", require("./authRoutes"));
6 | router.use("/profile", require("./profileRoutes"));
7 | router.use("/ledger", require("./ledgerRoutes"));
8 | router.use("/entry", require("./entryRoutes"));
9 | router.use("/statement", require("./statementRoutes"));
10 |
11 | module.exports = router;
12 |
--------------------------------------------------------------------------------
/backend/routes/v1/ledgerRoutes.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 |
3 | const router = express.Router();
4 | const {
5 | createLedger,
6 | getLedger,
7 | getLedgers,
8 | getAllLedgers,
9 | editLedger,
10 | } = require("../../controllers/ledgerController");
11 | const { protect } = require("../../middlewares/authMiddleware");
12 |
13 | router.post("/", protect, createLedger);
14 | router.get("/", protect, getLedgers);
15 | router.get("/all", protect, getAllLedgers);
16 | router.get("/:id", protect, getLedger);
17 | router.put("/:id", protect, editLedger);
18 |
19 | module.exports = router;
20 |
--------------------------------------------------------------------------------
/backend/routes/v1/profileRoutes.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 |
3 | const router = express.Router();
4 | const {
5 | getProfile,
6 | editProfile,
7 | deleteProfile,
8 | } = require("../../controllers/userController");
9 | const { protect } = require("../../middlewares/authMiddleware");
10 |
11 | router.get("/", protect, getProfile);
12 | router.put("/", protect, editProfile);
13 | router.delete("/", protect, deleteProfile);
14 |
15 | module.exports = router;
16 |
--------------------------------------------------------------------------------
/backend/routes/v1/statementRoutes.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 |
3 | const router = express.Router();
4 | const {
5 | viewLedgerStatement,
6 | viewTrialBalance,
7 | viewMicroStatement,
8 | viewCalendarHeatmap,
9 | exportJournalStatement,
10 | } = require("../../controllers/statementController");
11 | const { protect } = require("../../middlewares/authMiddleware");
12 |
13 | router.get("/ledger/:id", protect, viewLedgerStatement);
14 | router.get("/trial-balance", protect, viewTrialBalance);
15 | router.get("/micro-statement", protect, viewMicroStatement);
16 | router.get("/calendar-heatmap", protect, viewCalendarHeatmap);
17 | router.get("/export", protect, exportJournalStatement);
18 |
19 | module.exports = router;
20 |
--------------------------------------------------------------------------------
/backend/server.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const mongoose = require("mongoose");
3 | const path = require("path");
4 | const { StatusCodes } = require("http-status-codes");
5 | const morgan = require("morgan");
6 | const rateLimit = require("express-rate-limit");
7 | const helmet = require("helmet");
8 | const cors = require("cors");
9 |
10 | const {
11 | errorHandler,
12 | ErrorResponse,
13 | } = require("./middlewares/errorMiddleware");
14 | const { PER_MINUTE_REQUEST_LIMIT } = require("./constants/policies");
15 |
16 | require("dotenv").config();
17 |
18 | const port = process.env.PORT;
19 | const app = express();
20 | app.use(express.json());
21 | app.use(express.urlencoded({ extended: false }));
22 |
23 | const db = process.env.MONGODB_URI;
24 | mongoose
25 | .connect(db, { useNewUrlParser: true, useUnifiedTopology: true })
26 | .then(() => console.log("MongoDB Connected"))
27 | .catch((err) => console.log(err));
28 |
29 | app.use(morgan("tiny"));
30 |
31 | app.use(helmet());
32 |
33 | app.use(cors());
34 |
35 | const limiter = rateLimit({
36 | windowMs: 10 * 60 * 1000, // 10 minutes
37 | max: process.env.NODE_ENV === "production" ? PER_MINUTE_REQUEST_LIMIT : false,
38 | standardHeaders: true,
39 | legacyHeaders: false,
40 | handler: (req, res, next) => {
41 | throw new ErrorResponse("Too many requests", StatusCodes.TOO_MANY_REQUESTS);
42 | },
43 | });
44 | app.use(limiter); // limits all paths
45 |
46 | app.use("/api", require("./routes/api"));
47 |
48 | // Serve frontend
49 | if (process.env.NODE_ENV === "production") {
50 | app.use(express.static(path.join(__dirname, "../frontend/build")));
51 |
52 | app.get("*", (req, res) =>
53 | res.sendFile(
54 | path.resolve(__dirname, "../", "frontend", "build", "index.html")
55 | )
56 | );
57 | } else {
58 | app.get("/", (req, res) => res.send("Please set to production"));
59 | }
60 |
61 | app.use("*", (req, res, next) => {
62 | throw new ErrorResponse("Not found", StatusCodes.NOT_FOUND);
63 | });
64 |
65 | app.use(errorHandler);
66 |
67 | app.listen(port, () => console.log(`Server started on port ${port}`));
68 |
69 | module.exports = app;
70 |
--------------------------------------------------------------------------------
/backend/util/entryValidationSchema.js:
--------------------------------------------------------------------------------
1 | const Joi = require("joi");
2 | Joi.objectId = require("joi-objectid")(Joi);
3 |
4 | const { ENTRY_NARRATION_MAX_LENGTH } = require("../constants/policies");
5 |
6 | const createSchema = Joi.object({
7 | debit_ledger_id: Joi.objectId().required(),
8 | credit_ledger_id: Joi.objectId().required(),
9 | amount: Joi.number().greater(0).integer().required(),
10 | narration: Joi.string().min(1).max(ENTRY_NARRATION_MAX_LENGTH).required(),
11 | });
12 |
13 | const editSchema = Joi.object({
14 | narration: Joi.string().min(1).max(ENTRY_NARRATION_MAX_LENGTH).required(),
15 | });
16 |
17 | module.exports = { createSchema, editSchema };
18 |
--------------------------------------------------------------------------------
/backend/util/ledgerValidationSchema.js:
--------------------------------------------------------------------------------
1 | const Joi = require("joi");
2 | const {
3 | INCOME,
4 | EXPENDITURE,
5 | ASSET,
6 | LIABILITY,
7 | EQUITY,
8 | } = require("../constants/ledgerTypes");
9 |
10 | const {
11 | LEDGER_NAME_MAX_LENGTH,
12 | LEDGER_DESCRIPTION_MAX_LENGTH,
13 | } = require("../constants/policies");
14 |
15 | const createSchema = Joi.object({
16 | name: Joi.string().min(1).max(LEDGER_NAME_MAX_LENGTH).required(),
17 | type: Joi.string()
18 | .valid(INCOME, EXPENDITURE, ASSET, LIABILITY, EQUITY)
19 | .required(),
20 | description: Joi.string()
21 | .min(1)
22 | .max(LEDGER_DESCRIPTION_MAX_LENGTH)
23 | .required(),
24 | });
25 |
26 | const editSchema = Joi.object({
27 | name: Joi.string().min(1).max(LEDGER_NAME_MAX_LENGTH).required(),
28 | type: Joi.string()
29 | .valid(INCOME, EXPENDITURE, ASSET, LIABILITY, EQUITY)
30 | .required(),
31 | description: Joi.string()
32 | .min(1)
33 | .max(LEDGER_DESCRIPTION_MAX_LENGTH)
34 | .required(),
35 | });
36 |
37 | module.exports = { createSchema, editSchema };
38 |
--------------------------------------------------------------------------------
/backend/util/tokenGenerator.js:
--------------------------------------------------------------------------------
1 | const jwt = require("jsonwebtoken");
2 |
3 | module.exports = (data) =>
4 | jwt.sign(data, process.env.SECRET_KEY, {
5 | expiresIn: "30d",
6 | });
7 |
--------------------------------------------------------------------------------
/backend/util/userValidationSchema.js:
--------------------------------------------------------------------------------
1 | const Joi = require("joi");
2 |
3 | const {
4 | USER_FIRST_NAME_MAX_LENGTH,
5 | USER_LAST_NAME_MAX_LENGTH,
6 | USER_EMAIL_MAX_LENGTH,
7 | USER_PASSWORD_MIN_LENGTH,
8 | USER_PASSWORD_MAX_LENGTH,
9 | USER_MIDDLE_NAME_MAX_LENGTH,
10 | USER_BIO_MAX_LENGTH,
11 | USER_ORGANIZATION_MAX_LENGTH,
12 | USER_JOB_TITLE_MAX_LENGTH,
13 | USER_LOCATION_MAX_LENGTH,
14 | } = require("../constants/policies");
15 |
16 | const createSchema = Joi.object({
17 | firstName: Joi.string().min(1).max(USER_FIRST_NAME_MAX_LENGTH).required(),
18 | lastName: Joi.string().min(1).max(USER_LAST_NAME_MAX_LENGTH).required(),
19 | email: Joi.string().email().min(1).max(USER_EMAIL_MAX_LENGTH).required(),
20 | password: Joi.string()
21 | .min(USER_PASSWORD_MIN_LENGTH)
22 | .max(USER_PASSWORD_MAX_LENGTH)
23 | .required(),
24 | });
25 |
26 | const editSchema = Joi.object({
27 | firstName: Joi.string().min(1).max(USER_FIRST_NAME_MAX_LENGTH).required(),
28 | middleName: Joi.string()
29 | .max(USER_MIDDLE_NAME_MAX_LENGTH)
30 | .allow("")
31 | .optional(),
32 | lastName: Joi.string().min(1).max(USER_LAST_NAME_MAX_LENGTH).required(),
33 | email: Joi.string().email().min(1).max(USER_EMAIL_MAX_LENGTH).required(),
34 | bio: Joi.string().max(USER_BIO_MAX_LENGTH).allow("").optional(),
35 | organization: Joi.string()
36 | .max(USER_ORGANIZATION_MAX_LENGTH)
37 | .allow("")
38 | .optional(),
39 | jobTitle: Joi.string().max(USER_JOB_TITLE_MAX_LENGTH).allow("").optional(),
40 | location: Joi.string().max(USER_LOCATION_MAX_LENGTH).allow("").optional(),
41 | });
42 |
43 | const passwordChangeSchema = Joi.object({
44 | oldPassword: Joi.string().required(),
45 | newPassword: Joi.string()
46 | .min(USER_PASSWORD_MIN_LENGTH)
47 | .max(USER_PASSWORD_MAX_LENGTH)
48 | .required(),
49 | });
50 |
51 | module.exports = { createSchema, editSchema, passwordChangeSchema };
52 |
--------------------------------------------------------------------------------
/build.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | chmod +x ./build.sh
3 |
4 | NPM_CONFIG_PRODUCTION=false
5 |
6 | echo "INSTALL BACKEND DEPENDENCIES"
7 | npm install
8 |
9 | echo "INSTALLING FRONTEND DEPENDENCIES"
10 |
11 | cd frontend
12 | npm install
13 |
14 | echo "INSTALLING FRONTEND DEV DEPENDENCIES"
15 | npm install --only=dev
16 |
17 | echo "BUILDING FRONTEND"
18 |
19 | npm run build
--------------------------------------------------------------------------------
/frontend/.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 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 | Lucafy - Bookkeeping App
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@reduxjs/toolkit": "^1.8.5",
7 | "@tanstack/react-query": "^4.28.0",
8 | "@tanstack/react-query-devtools": "^4.28.0",
9 | "@testing-library/jest-dom": "^5.16.5",
10 | "@testing-library/react": "^13.3.0",
11 | "@testing-library/user-event": "^13.5.0",
12 | "alea": "^1.0.1",
13 | "axios": "^0.27.2",
14 | "formik": "^2.2.9",
15 | "js-file-download": "^0.4.12",
16 | "react": "^18.2.0",
17 | "react-calendar-heatmap": "^1.9.0",
18 | "react-confirm-alert": "^3.0.6",
19 | "react-dom": "^18.2.0",
20 | "react-redux": "^8.0.2",
21 | "react-router-dom": "^6.3.0",
22 | "react-scripts": "5.0.1",
23 | "react-tooltip": "^4.2.21",
24 | "web-vitals": "^2.1.4",
25 | "yup": "^1.1.1"
26 | },
27 | "devDependencies": {
28 | "@tailwindcss/line-clamp": "^0.4.2",
29 | "@vitejs/plugin-react": "^4.3.1",
30 | "autoprefixer": "^10.4.2",
31 | "daisyui": "^2.6.0",
32 | "postcss": "^8.4.7",
33 | "react-scripts": "^5.0.0",
34 | "tailwindcss": "^3.0.23",
35 | "vite": "^5.3.4"
36 | },
37 | "scripts": {
38 | "dev": "vite",
39 | "build": "vite build",
40 | "lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0",
41 | "preview": "vite preview"
42 | },
43 | "eslintConfig": {
44 | "extends": [
45 | "react-app",
46 | "react-app/jest"
47 | ]
48 | },
49 | "browserslist": {
50 | "production": [
51 | ">0.2%",
52 | "not dead",
53 | "not op_mini all"
54 | ],
55 | "development": [
56 | "last 1 chrome version",
57 | "last 1 firefox version",
58 | "last 1 safari version"
59 | ]
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/frontend/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: [require("tailwindcss"), require("autoprefixer")],
3 | };
4 |
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/captainAyan/lucafy/a6935f1d04ba046c17ff727812e42260fa68697d/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/frontend/public/logo-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/captainAyan/lucafy/a6935f1d04ba046c17ff727812e42260fa68697d/frontend/public/logo-dark.png
--------------------------------------------------------------------------------
/frontend/public/logo-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/captainAyan/lucafy/a6935f1d04ba046c17ff727812e42260fa68697d/frontend/public/logo-light.png
--------------------------------------------------------------------------------
/frontend/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/captainAyan/lucafy/a6935f1d04ba046c17ff727812e42260fa68697d/frontend/public/logo.png
--------------------------------------------------------------------------------
/frontend/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Lucafy",
3 | "name": "Lucafy Bookeeping App",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo.png",
12 | "type": "image/png",
13 | "sizes": "64x64"
14 | }
15 | ],
16 | "start_url": ".",
17 | "display": "standalone",
18 | "theme_color": "#000000",
19 | "background_color": "#ffffff"
20 | }
21 |
--------------------------------------------------------------------------------
/frontend/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/frontend/src/App.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | .react-calendar-heatmap text {
6 | font-size: 8px;
7 | fill: #aaa;
8 | }
9 |
10 | .react-calendar-heatmap .react-calendar-heatmap-small-text {
11 | font-size: 4px;
12 | }
13 |
14 |
15 | /*
16 | * Default color scale
17 | */
18 |
19 | .react-calendar-heatmap {
20 | fill: rgba(0, 0, 0, 0.15);
21 | }
22 |
23 | .react-calendar-heatmap .color-empty {
24 | fill: rgba(0, 0, 0, 0.15);
25 | }
26 |
27 | /*
28 | * Colo palette
29 | */
30 |
31 | .react-calendar-heatmap .color-palette-1 {
32 | fill: #8b5cf6;
33 | }
34 | .react-calendar-heatmap .color-palette-2 {
35 | fill: #a855f7;
36 | }
37 | .react-calendar-heatmap .color-palette-3 {
38 | fill: #d946ef;
39 | }
40 | .react-calendar-heatmap .color-palette-4 {
41 | fill: #ec4899;
42 | }
43 | .react-calendar-heatmap .color-palette-5 {
44 | fill: #f43f5e;
45 | }
--------------------------------------------------------------------------------
/frontend/src/app/store.js:
--------------------------------------------------------------------------------
1 | import { configureStore } from "@reduxjs/toolkit";
2 |
3 | import preferenceReducer from "../features/preference/preferenceSlice";
4 | import authReducer from "../features/auth/authSlice";
5 |
6 | export const store = configureStore({
7 | reducer: {
8 | preference: preferenceReducer,
9 | auth: authReducer,
10 | },
11 | });
12 |
--------------------------------------------------------------------------------
/frontend/src/components/ActivityHeatMap.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 | import CalendarHeatmap from "react-calendar-heatmap";
3 | import ReactTooltip from "react-tooltip";
4 | import { useSelector } from "react-redux";
5 | import useActivityHeatMapData from "../hooks/useActivityHeatMapData";
6 |
7 | const today = new Date();
8 |
9 | function shiftDate(date, numDays) {
10 | const newDate = new Date(date);
11 | newDate.setDate(newDate.getDate() + numDays);
12 |
13 | var d = newDate,
14 | month = "" + (d.getMonth() + 1),
15 | day = "" + d.getDate(),
16 | year = d.getFullYear();
17 |
18 | if (month.length < 2) month = "0" + month;
19 | if (day.length < 2) day = "0" + day;
20 |
21 | return [year, month, day].join("-");
22 | }
23 |
24 | export default function ActivityHeatMap() {
25 | const { token } = useSelector((state) => state.auth);
26 |
27 | const [heatmap, setHeatmap] = useState([]);
28 | const { data } = useActivityHeatMapData(token);
29 |
30 | useEffect(() => {
31 | if (data) {
32 | const hm_default = [];
33 | const hm_actual = [];
34 |
35 | for (let index = 0; index <= 154; index++) {
36 | hm_default.push({
37 | date: shiftDate(today, -index),
38 | count: 0,
39 | });
40 | }
41 |
42 | for (const entry of data?.data) {
43 | hm_actual.push({
44 | count: entry.frequency,
45 | date: entry.date,
46 | });
47 | }
48 |
49 | const map = new Map();
50 | hm_default.forEach((item) => map.set(item.date, item));
51 | hm_actual.forEach((item) =>
52 | map.set(item.date, { ...map.get(item.date), ...item })
53 | );
54 | const hm = Array.from(map.values());
55 |
56 | setHeatmap(hm);
57 | }
58 | }, [data]);
59 |
60 | return (
61 | <>
62 | Activity
63 |
64 | {
70 | if (value?.count === 0) return `color-empty`;
71 | else if (value?.count > 5) return `color-palette-5`;
72 | else return `color-palette-${value?.count}`;
73 | }}
74 | tooltipDataAttrs={(value) => {
75 | return {
76 | "data-tip": `${value?.count} ${
77 | value?.count > 1 ? "entries" : "entry"
78 | } on ${value?.date}`,
79 | };
80 | }}
81 | />
82 |
83 |
84 | >
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/frontend/src/components/Alert.jsx:
--------------------------------------------------------------------------------
1 | export default function Alert(props) {
2 | const { message } = props;
3 | return (
4 |
5 |
6 | {message}
7 |
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/frontend/src/components/AuthProtectedRoute.jsx:
--------------------------------------------------------------------------------
1 | import { useSelector } from "react-redux";
2 | import { Navigate } from "react-router-dom";
3 |
4 | export default function AuthProtectedRoute({ children }) {
5 | const { token } = useSelector((state) => state.auth);
6 | if (!token) {
7 | return ;
8 | }
9 | return children;
10 | }
11 |
--------------------------------------------------------------------------------
/frontend/src/components/Avatar.jsx:
--------------------------------------------------------------------------------
1 | import Alea from "alea";
2 | import { useRef } from "react";
3 |
4 | export default function Avatar({ width, cell, color, seed = "", ...attr }) {
5 | const canvas = useRef(document.createElement("canvas"));
6 | canvas.current.width = width * cell;
7 | canvas.current.height = width * cell;
8 | const ctx = canvas.current.getContext("2d");
9 |
10 | const prng = new Alea(seed);
11 |
12 | for (var i = 0; i <= Math.floor(cell / 2); i++) {
13 | for (var j = 0; j <= cell; j++) {
14 | if (Math.floor(prng() * 9) > 4) {
15 | try {
16 | ctx.fillStyle = color;
17 | } catch (e) {
18 | ctx.fillStyle = "#000000";
19 | }
20 | } else {
21 | ctx.fillStyle = "#ffffff";
22 | }
23 |
24 | // from left
25 | ctx.fillRect(i * width, j * width, width, width);
26 | // from right
27 | ctx.fillRect(cell * width - width - i * width, j * width, width, width);
28 | }
29 | }
30 |
31 | const pngUrl = canvas.current.toDataURL();
32 |
33 | return ;
34 | }
35 |
--------------------------------------------------------------------------------
/frontend/src/components/ChangePasswordSettings.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { useSelector } from "react-redux";
3 | import axios from "axios";
4 | import { Formik, Form, ErrorMessage, Field } from "formik";
5 |
6 | import { CHANGE_PASSWORD_URL } from "../constants/api";
7 | import authConfig from "../util/authConfig";
8 | import { ChangePasswordSchema } from "../util/userValidationSchema";
9 |
10 | export default function ChangePasswordSettings() {
11 | const initialFormData = {
12 | oldPassword: "",
13 | newPassword: "",
14 | confirmNewPassword: "",
15 | };
16 | const [helperText, setHelperText] = useState("");
17 | const [isLoading, setIsLoading] = useState(false);
18 | const [buttonLabel, setButtonLabel] = useState("Save");
19 |
20 | const { token } = useSelector((state) => state.auth);
21 |
22 | const handleSubmit = (data, formResetFn) => {
23 | const { oldPassword, newPassword } = data;
24 | setIsLoading(true);
25 | axios
26 | .put(CHANGE_PASSWORD_URL, { oldPassword, newPassword }, authConfig(token))
27 | .then(() => {
28 | setHelperText("");
29 | setButtonLabel("Saved 🎉");
30 | formResetFn();
31 | })
32 | .catch((error) => setHelperText(error.response.data.error.message))
33 | .finally(() => setIsLoading(false));
34 | };
35 |
36 | return (
37 |
38 |
39 |
40 |
Change Password
41 |
42 |
43 |
47 | handleSubmit(values, resetForm)
48 | }
49 | >
50 |
107 |
108 |
109 |
110 | );
111 | }
112 |
--------------------------------------------------------------------------------
/frontend/src/components/DeleteAccountSettings.jsx:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { useState } from "react";
3 | import { useDispatch, useSelector } from "react-redux";
4 |
5 | import { confirmAlert } from "react-confirm-alert";
6 | import "react-confirm-alert/src/react-confirm-alert.css";
7 |
8 | import { DELETE_PROFILE_URL } from "../constants/api";
9 | import authConfig from "../util/authConfig";
10 | import { logout } from "../features/auth/authSlice";
11 |
12 | export default function DeleteAccountSettings() {
13 | const [isLoading, setIsLoading] = useState(false);
14 | const [helperText, setHelperText] = useState("");
15 |
16 | const dispatch = useDispatch();
17 |
18 | const { token } = useSelector((state) => state.auth);
19 |
20 | const handleDelete = async () => {
21 | setIsLoading(true);
22 | axios
23 | .delete(DELETE_PROFILE_URL, authConfig(token))
24 | .then(() => {
25 | setHelperText("");
26 | localStorage.setItem("token", "");
27 | dispatch(logout());
28 | })
29 | .catch((error) => {
30 | setHelperText(error.response.data.error.message);
31 | })
32 | .finally(() => setIsLoading(false));
33 | };
34 |
35 | const confirm = async () =>
36 | confirmAlert({
37 | title: "Delete Account",
38 | message:
39 | "Are you sure about deleting your account? Once deleted, your account cannot be recovered.",
40 | buttons: [
41 | {
42 | label: "Yes",
43 | onClick: () => handleDelete(),
44 | },
45 | { label: "No" },
46 | ],
47 | });
48 |
49 | return (
50 |
51 |
52 |
53 |
Delete Account
54 |
55 |
56 |
57 | ⚠️ Once deleted, your account can not be recovered.
58 |
59 |
60 |
{helperText}
61 |
62 |
68 | Delete
69 |
70 |
71 |
72 | );
73 | }
74 |
--------------------------------------------------------------------------------
/frontend/src/components/Entry.jsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 | import timeFormat from "../util/timeFormat";
3 | import amountFormat from "../util/amountFormat";
4 |
5 | export default function Entry(props) {
6 | const {
7 | id,
8 | debit_ledger: debit,
9 | credit_ledger: credit,
10 | amount,
11 | narration,
12 | created_at,
13 | currencySymbol,
14 | currencyFormat,
15 | } = props;
16 | const time = timeFormat(created_at);
17 | const formattedAmount = amountFormat(amount, currencyFormat, currencySymbol);
18 |
19 | return (
20 |
21 |
22 |
23 |
24 | #{id}
25 |
26 | {time}
27 |
28 |
29 |
30 |
31 |
32 |
33 | {debit.name} A/c
34 |
35 |
36 |
37 |
38 |
39 |
40 | {credit.name} A/c
41 |
42 |
43 |
44 |
45 |
46 | {formattedAmount}
47 |
48 |
49 |
50 |
51 |
({narration})
52 |
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/frontend/src/components/Footer.jsx:
--------------------------------------------------------------------------------
1 | export default function Footer() {
2 | const year = new Date().getFullYear();
3 | return (
4 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/frontend/src/components/Loading.jsx:
--------------------------------------------------------------------------------
1 | export default function Loading() {
2 | return (
3 |
4 |
11 |
15 |
19 |
20 |
Loading...
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/frontend/src/components/MicroStatement.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { useSelector } from "react-redux";
3 | import amountFormat from "../util/amountFormat";
4 | import useMicroStatementData from "../hooks/useMicroStatementData";
5 |
6 | export default function MicroStatement() {
7 | const { token } = useSelector((state) => state.auth);
8 | const { amountFormat: currencyFormat, currency } = useSelector(
9 | (state) => state.preference
10 | );
11 |
12 | const [statement, setStatement] = useState({});
13 | const { data } = useMicroStatementData(token);
14 |
15 | useEffect(() => {
16 | setStatement(data?.data);
17 | }, [data]);
18 |
19 | return (
20 |
21 |
22 |
Assets
23 |
24 | {amountFormat(statement?.asset || 0, currencyFormat, currency)}
25 |
26 |
Total Assets
27 |
28 |
29 |
30 |
Net Income
31 |
38 | {amountFormat(
39 | statement?.income - statement?.expenditure || 0,
40 | currencyFormat,
41 | currency
42 | )}
43 |
44 |
Income less Expenditures
45 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/frontend/src/components/MiniLoading.jsx:
--------------------------------------------------------------------------------
1 | export default function MiniLoading() {
2 | return (
3 |
4 |
11 |
15 |
19 |
20 |
Loading...
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/frontend/src/components/NormalizationSettings.jsx:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { useState } from "react";
3 | import { useSelector } from "react-redux";
4 |
5 | import { confirmAlert } from "react-confirm-alert";
6 | import "react-confirm-alert/src/react-confirm-alert.css";
7 |
8 | import { NORMALIZE_ENTRIES_URL } from "../constants/api";
9 | import authConfig from "../util/authConfig";
10 |
11 | export default function NormalizationSettings() {
12 | const [isLoading, setIsLoading] = useState(false);
13 | const [buttonLabel, setButtonLabel] = useState("Normalize");
14 | const [helperText, setHelperText] = useState("");
15 |
16 | const { token } = useSelector((state) => state.auth);
17 |
18 | const handleNormalize = async () => {
19 | setIsLoading(true);
20 | axios
21 | .put(NORMALIZE_ENTRIES_URL, {}, authConfig(token))
22 | .then(() => {
23 | setButtonLabel("Normalized 🎉");
24 | setHelperText("");
25 | })
26 | .catch((error) => {
27 | setHelperText(error.response.data.error.message);
28 | })
29 | .finally(() => setIsLoading(false));
30 | };
31 |
32 | const confirm = async () =>
33 | confirmAlert({
34 | title: "Normalize Entries",
35 | message:
36 | "Are you sure about normalizing all the entries? Once normalized, your entries cannot be recovered.",
37 | buttons: [
38 | {
39 | label: "Yes",
40 | onClick: () => handleNormalize(),
41 | },
42 | { label: "No" },
43 | ],
44 | });
45 |
46 | return (
47 |
48 |
49 |
50 |
Normalization
51 |
52 |
53 |
54 | The maximum number of entries you can create is 100. Once that limit
55 | is reached, you can still utilize this application by{" "}
56 | Normalization of the entries.
57 |
58 |
59 | Normalization will get rid of all the individual entries, and store
60 | the current ledger balances into the system. Upon creation of new
61 | entries, the ledger balance will be computed by taking into account
62 | the stored normalized balances and as usual, the entries.
63 |
64 |
65 |
66 | Pros :
67 |
68 | Use the app even though the limit of 100 entries has been reached.
69 |
70 |
No loss in accuracy of the balances.
71 |
72 |
73 |
74 | Cons :
75 |
76 | Old transaction details won't be displayed in the journal.{" "}
77 |
78 | (You can still export a report of all the transaction available at
79 | that time)
80 |
81 |
82 |
83 | Activities occurred before normalization will not show up in the
84 | activity heatmap.
85 |
86 |
87 |
88 |
89 | Note: It's a good idea to export a report before normalizing
90 | the accounts, so that you don't lose old transaction details.
91 |
92 |
93 |
{helperText}
94 |
95 |
101 | {buttonLabel}
102 |
103 |
104 |
105 | );
106 | }
107 |
--------------------------------------------------------------------------------
/frontend/src/components/Posting.jsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 | import amountFormat from "../util/amountFormat";
3 | import timeFormat from "../util/timeFormat";
4 |
5 | export default function Posting(props) {
6 | const { entry, ledger, currencyFormat, currencySymbol } = props;
7 |
8 | const account =
9 | entry.debit_ledger.id === ledger.id
10 | ? entry.credit_ledger
11 | : entry.debit_ledger;
12 |
13 | const time = timeFormat(entry.created_at);
14 | const amount = amountFormat(entry.amount, currencyFormat, currencySymbol);
15 |
16 | const toOrBy = entry.debit_ledger.id === ledger.id ? "To" : "By";
17 |
18 | return (
19 |
20 |
21 |
22 |
23 | #{entry.id}
24 |
25 | {time}
26 |
27 |
28 |
29 |
30 |
31 | {toOrBy} ·{" "}
32 | {account.name} A/c
33 |
34 |
35 |
36 |
37 | {amount}
38 |
39 |
40 |
41 |
({entry.narration})
42 |
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/frontend/src/components/PreferenceSettings.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | RUPEE,
3 | DOLLAR,
4 | EURO,
5 | NAIRA,
6 | NEW_SHEKEL,
7 | POUND,
8 | RUBLE,
9 | TAKA,
10 | WON,
11 | YEN,
12 | } from "../constants/currency";
13 | import { INDIAN, INTERNATIONAL } from "../constants/amountFormat";
14 |
15 | import { setPreference } from "../features/preference/preferenceSlice";
16 | import { useDispatch, useSelector } from "react-redux";
17 | import { useState } from "react";
18 | import { ErrorMessage, Field, Form, Formik } from "formik";
19 | import PreferenceSchema from "../util/preferenceValidationSchema";
20 |
21 | export default function PreferenceSettings() {
22 | const dispatch = useDispatch();
23 | const preference = useSelector((state) => state.preference);
24 |
25 | const initialFormData = {
26 | amountFormat: preference.amountFormat,
27 | currency: preference.currency,
28 | };
29 |
30 | const [buttonLabel, setButtonLabel] = useState("Save");
31 |
32 | const handleSubmit = (values) => {
33 | const { amountFormat, currency } = values;
34 | const preference = { amountFormat, currency };
35 |
36 | dispatch(setPreference(preference));
37 | setButtonLabel("Saved 🎉");
38 | };
39 |
40 | return (
41 |
42 |
43 |
44 |
Preference
45 |
46 |
47 |
handleSubmit(values)}
51 | >
52 |
100 |
101 |
102 |
103 | );
104 | }
105 |
--------------------------------------------------------------------------------
/frontend/src/components/TrialBalanceItem.jsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 | import amountFormat from "../util/amountFormat";
3 | import balanceIsNegative from "../util/balanceIsNegative";
4 |
5 | export default function TrialBalanceItem(props) {
6 | const { ledger, balance, currencyFormat, currencySymbol } = props;
7 |
8 | const isNegative = balanceIsNegative(ledger.type, balance); // ledger balance
9 | const amount = amountFormat(balance, currencyFormat, currencySymbol);
10 |
11 | return (
12 |
13 |
14 |
15 |
16 | #{ledger.id}
17 |
18 |
19 |
20 |
21 |
22 |
23 | {ledger.name} A/c
24 |
25 |
26 |
27 |
{ledger.type}
28 |
29 |
30 |
35 | {amount}
36 |
37 |
38 |
39 |
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/frontend/src/constants/amountFormat.js:
--------------------------------------------------------------------------------
1 | export const INDIAN = "ind";
2 | export const INTERNATIONAL = "int";
3 |
--------------------------------------------------------------------------------
/frontend/src/constants/api.js:
--------------------------------------------------------------------------------
1 | export const DOMAIN =
2 | !process.env.NODE_ENV || process.env.NODE_ENV === "development"
3 | ? "http://localhost:3001"
4 | : "";
5 |
6 | export const VERSION = "v1";
7 | export const BASE_URL = `${DOMAIN}/api/${VERSION}`;
8 |
9 | export const BASE_AUTH_URL = `${BASE_URL}/auth`;
10 | export const REGISTER_URL = `${BASE_AUTH_URL}/register`;
11 | export const LOGIN_URL = `${BASE_AUTH_URL}/login`;
12 | export const CHANGE_PASSWORD_URL = `${BASE_AUTH_URL}/changepassword`;
13 |
14 | export const BASE_PROFILE_URL = `${BASE_URL}/profile`;
15 | export const GET_PROFILE_URL = `${BASE_PROFILE_URL}/`;
16 | export const EDIT_PROFILE_URL = `${BASE_PROFILE_URL}/`;
17 | export const DELETE_PROFILE_URL = `${BASE_PROFILE_URL}/`;
18 |
19 | export const BASE_LEDGER_URL = `${BASE_URL}/ledger`;
20 | export const CREATE_LEDGER_URL = `${BASE_LEDGER_URL}/`;
21 | export const GET_LEDGER_URL = `${BASE_LEDGER_URL}/`;
22 | export const GET_ALL_LEDGER_URL = `${BASE_LEDGER_URL}/all`;
23 | export const EDIT_LEDGER_URL = `${BASE_LEDGER_URL}/`;
24 |
25 | export const BASE_ENTRY_URL = `${BASE_URL}/entry`;
26 | export const CREATE_ENTRY_URL = `${BASE_ENTRY_URL}/`;
27 | export const GET_ENTRY_URL = `${BASE_ENTRY_URL}/`;
28 | export const NORMALIZE_ENTRIES_URL = `${BASE_ENTRY_URL}/normalize`;
29 | export const EDIT_ENTRY_URL = `${BASE_ENTRY_URL}/`;
30 | export const NORMALIZE_ENTRY_URL = `${BASE_ENTRY_URL}/normalize/`;
31 | export const SEARCH_ENTRY_URL = `${BASE_ENTRY_URL}/search/`;
32 |
33 | export const BASE_STATEMENT_URL = `${BASE_URL}/statement`;
34 | export const GET_LEDGER_STATEMENT_URL = `${BASE_STATEMENT_URL}/ledger/`;
35 | export const GET_TRIAL_BALANCE_URL = `${BASE_STATEMENT_URL}/trial-balance`;
36 | export const GET_MICRO_STATEMENT_URL = `${BASE_STATEMENT_URL}/micro-statement`;
37 | export const GET_CALENDAR_HEATMAP_URL = `${BASE_STATEMENT_URL}/calendar-heatmap`;
38 | export const GET_EXPORT_JOURNAL_URL = `${BASE_STATEMENT_URL}/export`;
39 |
--------------------------------------------------------------------------------
/frontend/src/constants/currency.js:
--------------------------------------------------------------------------------
1 | export const RUPEE = "₹";
2 | export const DOLLAR = "$";
3 | export const EURO = "€";
4 | export const NAIRA = "₦";
5 | export const NEW_SHEKEL = "₪";
6 | export const POUND = "£";
7 | export const RUBLE = "₽";
8 | export const TAKA = "৳";
9 | export const WON = "₩";
10 | export const YEN = "¥";
11 |
--------------------------------------------------------------------------------
/frontend/src/constants/ledgerTypes.js:
--------------------------------------------------------------------------------
1 | export const INCOME = "income";
2 | export const EXPENDITURE = "expenditure";
3 | export const ASSET = "asset";
4 | export const LIABILITY = "liability";
5 | export const EQUITY = "equity";
6 |
--------------------------------------------------------------------------------
/frontend/src/constants/policies.js:
--------------------------------------------------------------------------------
1 | export const USER_FIRST_NAME_MAX_LENGTH = 100;
2 | export const USER_LAST_NAME_MAX_LENGTH = 100;
3 | export const USER_EMAIL_MAX_LENGTH = 100;
4 | export const USER_PASSWORD_MIN_LENGTH = 6;
5 | export const USER_PASSWORD_MAX_LENGTH = 200;
6 |
7 | export const LEDGER_NAME_MAX_LENGTH = 50;
8 | export const LEDGER_DESCRIPTION_MAX_LENGTH = 200;
9 |
10 | export const ENTRY_NARRATION_MAX_LENGTH = 200;
11 |
--------------------------------------------------------------------------------
/frontend/src/constants/theme.js:
--------------------------------------------------------------------------------
1 | export const DARK = "dark";
2 | export const LIGHT = "light";
3 |
--------------------------------------------------------------------------------
/frontend/src/features/auth/authSlice.js:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 |
3 | const token = localStorage.getItem("token");
4 |
5 | const initialState = {
6 | user: null,
7 | token: token ? token : null,
8 | };
9 |
10 | export const authSlice = createSlice({
11 | name: "auth",
12 | initialState,
13 | reducers: {
14 | login: (state, action) => {
15 | const { token, ...user } = action.payload;
16 | state = { user, token };
17 | return state;
18 | },
19 | register: (state, action) => {
20 | const { token, ...user } = action.payload;
21 | state = { user, token };
22 | return state;
23 | },
24 | logout: (state) => {
25 | state = { user: null, token: null };
26 | return state;
27 | },
28 | updateUser: (state, action) => {
29 | state = { user: action.payload, token: state.token };
30 | return state;
31 | },
32 | },
33 | });
34 |
35 | export const { login, register, logout, updateUser } = authSlice.actions;
36 | export default authSlice.reducer;
37 |
--------------------------------------------------------------------------------
/frontend/src/features/preference/preferenceSlice.js:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 |
3 | import { RUPEE } from "../../constants/currency";
4 | import { INDIAN } from "../../constants/amountFormat";
5 | import { LIGHT } from "../../constants/theme";
6 |
7 | const preference = JSON.parse(localStorage.getItem("preference"));
8 |
9 | export const preferenceSlice = createSlice({
10 | name: "preference",
11 | initialState: {
12 | amountFormat: preference?.amountFormat || INDIAN,
13 | currency: preference?.currency || RUPEE,
14 | theme: preference?.theme || LIGHT,
15 | },
16 | reducers: {
17 | setPreference: (state, action) => {
18 | state.amountFormat = action.payload.amountFormat;
19 | state.currency = action.payload.currency;
20 |
21 | localStorage.setItem("preference", JSON.stringify(state));
22 | },
23 | setTheme: (state, action) => {
24 | state.theme = action.payload;
25 | localStorage.setItem("preference", JSON.stringify(state));
26 | },
27 | },
28 | });
29 |
30 | export const { setPreference, setTheme } = preferenceSlice.actions;
31 | export default preferenceSlice.reducer;
32 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useActivityHeatMapData.js:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import axios from "axios";
3 | import authConfig from "../util/authConfig";
4 | import { GET_CALENDAR_HEATMAP_URL } from "../constants/api";
5 |
6 | export default function useActivityHeatMapData(token) {
7 | return useQuery(["activity-heatmap"], () =>
8 | axios.get(GET_CALENDAR_HEATMAP_URL, authConfig(token))
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useDebounce.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export default function useDebounce(value, delay) {
4 | const [debouncedValue, setDebouncedValue] = useState(value);
5 |
6 | useEffect(() => {
7 | const handler = setTimeout(() => setDebouncedValue(value), delay);
8 | return () => clearTimeout(handler);
9 | }, [value, delay]);
10 |
11 | return debouncedValue;
12 | }
13 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useEntryDataHook.js:
--------------------------------------------------------------------------------
1 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2 | import axios from "axios";
3 | import authConfig from "../util/authConfig";
4 | import {
5 | CREATE_ENTRY_URL,
6 | EDIT_ENTRY_URL,
7 | GET_ENTRY_URL,
8 | NORMALIZE_ENTRY_URL,
9 | SEARCH_ENTRY_URL,
10 | } from "../constants/api";
11 |
12 | export function useEntryDataHook(token, id) {
13 | return useQuery(["entry", id], () =>
14 | axios.get(`${GET_ENTRY_URL}${id}`, authConfig(token))
15 | );
16 | }
17 |
18 | export function useAddEntryHook(token) {
19 | const queryClient = useQueryClient();
20 | return useMutation(
21 | (entry) => axios.post(CREATE_ENTRY_URL, entry, authConfig(token)),
22 | {
23 | onSuccess: (data) => {
24 | queryClient.invalidateQueries("journal");
25 | queryClient.setQueryData(["entry", data?.data?.id], data);
26 | },
27 | }
28 | );
29 | }
30 |
31 | export function useEditEntryHook(token, id) {
32 | const queryClient = useQueryClient();
33 | return useMutation(
34 | (entry) => axios.put(`${EDIT_ENTRY_URL}${id}`, entry, authConfig(token)),
35 | {
36 | onSuccess: (data) => {
37 | queryClient.setQueryData(["entry", id], (oldQueryData) => {
38 | return { ...oldQueryData, data: { ...data?.data } };
39 | });
40 | },
41 | }
42 | );
43 | }
44 |
45 | export function useEntryNormalizationHook(token, id) {
46 | const queryClient = useQueryClient();
47 |
48 | return useMutation(
49 | () => axios.put(`${NORMALIZE_ENTRY_URL}${id}`, null, authConfig(token)),
50 | {
51 | onSuccess: () => {
52 | queryClient.invalidateQueries(["entry", id]);
53 | queryClient.invalidateQueries("journal");
54 | },
55 | }
56 | );
57 | }
58 |
59 | export function useJournalDataHook(token, page) {
60 | const query = new URLSearchParams({ page });
61 | return useQuery(["journal", page], () =>
62 | axios.get(`${GET_ENTRY_URL}?${query}`, authConfig(token))
63 | );
64 | }
65 |
66 | export function useSearchDataHook(token, keyword, page) {
67 | const query = new URLSearchParams({ page, search: keyword });
68 | return useQuery(["entry-search", keyword, page], () =>
69 | axios.get(`${SEARCH_ENTRY_URL}?${query}`, authConfig(token))
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useFetch.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import axios from "axios";
3 |
4 | /**
5 | *
6 | * @param {AxiosRequestConfig} request
7 | * @returns
8 | */
9 | export default function useFetch(request) {
10 | const [isLoading, setIsLoading] = useState(false);
11 | const [data, setData] = useState([]);
12 | const [error, setError] = useState(null);
13 |
14 | useEffect(() => {
15 | setIsLoading(true);
16 | const fetchData = async () => {
17 | try {
18 | const { data } = await axios(request);
19 | setData(data);
20 | } catch (error) {
21 | setError(error);
22 | } finally {
23 | setIsLoading(false);
24 | }
25 | };
26 |
27 | fetchData();
28 | }, []);
29 |
30 | return { isLoading, data, error };
31 | }
32 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useLedgerDataHook.js:
--------------------------------------------------------------------------------
1 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2 | import axios from "axios";
3 |
4 | import {
5 | CREATE_LEDGER_URL,
6 | EDIT_LEDGER_URL,
7 | GET_ALL_LEDGER_URL,
8 | GET_LEDGER_STATEMENT_URL,
9 | GET_LEDGER_URL,
10 | } from "../constants/api";
11 | import authConfig from "../util/authConfig";
12 |
13 | export function useAllLedgerDataHook(token) {
14 | return useQuery(["ledgers"], () =>
15 | axios.get(`${GET_ALL_LEDGER_URL}`, authConfig(token))
16 | );
17 | }
18 |
19 | export function useAddLedgerHook(token) {
20 | const queryClient = useQueryClient();
21 | return useMutation(
22 | (ledger) => axios.post(CREATE_LEDGER_URL, ledger, authConfig(token)),
23 | {
24 | onSuccess: (data) => {
25 | queryClient.invalidateQueries("ledgers");
26 | queryClient.setQueryData(["ledger", data?.data?.id], data);
27 | },
28 | }
29 | );
30 | }
31 |
32 | export function useLedgerStatementDataHook(token, id, page) {
33 | const query = new URLSearchParams({ page });
34 | return useQuery(["ledger-statement", id, page], () =>
35 | axios.get(`${GET_LEDGER_STATEMENT_URL}${id}?${query}`, authConfig(token))
36 | );
37 | }
38 |
39 | export function useLedgerDataHook(token, id) {
40 | return useQuery(["ledger", id], () =>
41 | axios.get(`${GET_LEDGER_URL}${id}`, authConfig(token))
42 | );
43 | }
44 |
45 | export function useEditLedgerHook(token, id) {
46 | const queryClient = useQueryClient();
47 | return useMutation(
48 | (ledger) => axios.put(`${EDIT_LEDGER_URL}${id}`, ledger, authConfig(token)),
49 | {
50 | onSuccess: (data) => {
51 | queryClient.setQueryData(["ledger", id], (oldQueryData) => {
52 | return { ...oldQueryData, data: { ...data?.data } };
53 | });
54 | },
55 | }
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useMicroStatementData.js:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import axios from "axios";
3 | import authConfig from "../util/authConfig";
4 | import { GET_MICRO_STATEMENT_URL } from "../constants/api";
5 |
6 | export default function useMicroStatementData(token) {
7 | return useQuery(["micro-statement"], () =>
8 | axios.get(GET_MICRO_STATEMENT_URL, authConfig(token))
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useTrialBalanceData.js:
--------------------------------------------------------------------------------
1 | import { GET_TRIAL_BALANCE_URL } from "../constants/api";
2 | import authConfig from "../util/authConfig";
3 | import useFetch from "./useFetch";
4 |
5 | export default function useTrialBalanceData(token) {
6 | return useFetch({
7 | url: GET_TRIAL_BALANCE_URL,
8 | method: "GET",
9 | ...authConfig(token),
10 | });
11 | }
12 |
--------------------------------------------------------------------------------
/frontend/src/main.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import App from "./App";
4 | import { store } from "./app/store";
5 | import { Provider } from "react-redux";
6 |
7 | const root = ReactDOM.createRoot(document.getElementById("root"));
8 | root.render(
9 |
10 |
11 |
12 |
13 |
14 | );
15 |
--------------------------------------------------------------------------------
/frontend/src/pages/EditEntry.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 | import { useSelector } from "react-redux";
3 | import { useParams } from "react-router-dom";
4 | import { Formik, Form, ErrorMessage, Field } from "formik";
5 |
6 | import MiniLoading from "../components/MiniLoading";
7 | import { useEditEntryHook, useEntryDataHook } from "../hooks/useEntryDataHook";
8 | import Alert from "../components/Alert";
9 | import { ENTRY_NARRATION_MAX_LENGTH } from "../constants/policies";
10 | import { EntryEditSchema } from "../util/entryValidationSchema";
11 |
12 | export default function EditEntry() {
13 | const { token } = useSelector((state) => state.auth);
14 | const { id } = useParams();
15 |
16 | const handleSubmit = async (data) => {
17 | editEntry(data);
18 | };
19 |
20 | const {
21 | mutate: editEntry,
22 | isLoading,
23 | isError,
24 | error,
25 | isSuccess,
26 | } = useEditEntryHook(token, id);
27 |
28 | const {
29 | isLoading: isFetching,
30 | error: fetchingError,
31 | isError: isFetchingError,
32 | data: fetchedData,
33 | } = useEntryDataHook(token, id);
34 |
35 | const initialFormData = {
36 | narration: fetchedData?.data?.narration,
37 | };
38 |
39 | /**
40 | * Following is the code for fixing an uncontrolled input error, that appeared
41 | * after using enableReinitialize prop on Formik component.
42 | *
43 | * Solution Link:
44 | * https://github.com/jaredpalmer/formik/issues/811#issuecomment-1059814695
45 | */
46 | const [index, setIndex] = useState(0);
47 | useEffect(() => {
48 | setIndex(index + 1);
49 | }, [fetchedData]);
50 |
51 | return (
52 |
53 |
54 |
55 |
56 |
57 |
Edit Entry
58 |
59 |
60 | {isFetchingError && (
61 |
62 | )}
63 |
64 |
65 | #{id}
66 |
67 |
68 |
handleSubmit(values)}
74 | >
75 | {({ values }) => (
76 |
122 | )}
123 |
124 |
125 |
126 |
127 |
128 | );
129 | }
130 |
--------------------------------------------------------------------------------
/frontend/src/pages/Export.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { useSelector } from "react-redux";
3 | import fileDownload from "js-file-download";
4 | import axios from "axios";
5 | import { GET_EXPORT_JOURNAL_URL } from "../constants/api";
6 | import authConfig from "../util/authConfig";
7 |
8 | export default function Export() {
9 | const { user, token } = useSelector((state) => state.auth);
10 | const [isLoading, setIsLoading] = useState(false);
11 |
12 | const handleExport = async () => {
13 | setIsLoading(true);
14 | const { data: csv } = await axios.get(
15 | GET_EXPORT_JOURNAL_URL,
16 | authConfig(token)
17 | );
18 | fileDownload(csv, `journal_export_${user.id}_${new Date().getTime()}.csv`);
19 | setIsLoading(false);
20 | };
21 |
22 | return (
23 |
24 |
25 |
26 |
27 |
28 |
Export
29 |
30 |
31 |
32 | Download all journal entries in csv format.
33 |
34 |
35 |
41 | Export
42 |
43 |
44 |
45 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/frontend/src/pages/Home.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Link } from "react-router-dom";
3 | import { useSelector } from "react-redux";
4 |
5 | import MicroStatement from "../components/MicroStatement";
6 | import ActivityHeatMap from "../components/ActivityHeatMap";
7 |
8 | export default function Home() {
9 | const { user } = useSelector((state) => state.auth);
10 |
11 | return (
12 |
13 |
14 |
15 |
Home
16 |
17 | Welcome back,{" "}
18 | {user?.firstName}
19 |
20 |
21 |
22 |
23 |
24 |
Create Entry
25 |
26 |
27 |
28 |
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/frontend/src/pages/Journal.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 | import { useSelector } from "react-redux";
3 | import { useNavigate, useSearchParams } from "react-router-dom";
4 |
5 | import Entry from "../components/Entry";
6 | import Loading from "../components/Loading";
7 | import { useJournalDataHook } from "../hooks/useEntryDataHook";
8 |
9 | export default function Journal() {
10 | const navigate = useNavigate();
11 |
12 | const { token } = useSelector((state) => state.auth);
13 | const { amountFormat, currency } = useSelector((state) => state.preference);
14 | const [searchParams] = useSearchParams();
15 | const [page, setPage] = useState(parseInt(searchParams.get("page")) || 1);
16 |
17 | useEffect(() => {
18 | navigate(`?page=${page}`);
19 | }, [navigate, page]);
20 |
21 | const { data, isLoading } = useJournalDataHook(token, page - 1);
22 |
23 | return (
24 |
25 |
26 |
27 |
Journal
28 |
29 | Page {page} of{" "}
30 | {Math.ceil(data?.data?.total / data?.data?.limit) || 0}
31 |
32 |
33 |
34 | setPage(page - 1)}>
35 | «
36 |
37 | Page {page}
38 | setPage(page + 1)}>
39 | »
40 |
41 |
42 |
43 |
{isLoading ? : null}
44 |
45 | {data?.data?.entries?.map((entry) => {
46 | return (
47 |
53 | );
54 | })}
55 |
56 | {!isLoading && data?.data?.entries?.length === 0 ? (
57 |
No Data
58 | ) : null}
59 |
60 |
61 | setPage(page - 1)}>
62 | «
63 |
64 | Page {page}
65 | setPage(page + 1)}>
66 | »
67 |
68 |
69 |
70 |
71 |
72 | );
73 | }
74 |
--------------------------------------------------------------------------------
/frontend/src/pages/Login.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import { Link, useNavigate } from "react-router-dom";
3 | import { useSelector, useDispatch } from "react-redux";
4 | import { Formik, Form, ErrorMessage, Field } from "formik";
5 |
6 | import { login } from "../features/auth/authSlice";
7 | import axios from "axios";
8 | import { LOGIN_URL } from "../constants/api";
9 | import { LoginSchema } from "../util/userValidationSchema";
10 |
11 | export default function Login() {
12 | const navigate = useNavigate();
13 | const dispatch = useDispatch();
14 |
15 | const [isLoading, setIsLoading] = useState(false);
16 | const [responseData, setResponseData] = useState();
17 | const [errorData, setErrorData] = useState();
18 |
19 | const [helperText, setHelperText] = useState("");
20 |
21 | const { token, user } = useSelector((state) => state.auth);
22 |
23 | useEffect(() => {
24 | if (errorData && !user) {
25 | setHelperText(errorData.message);
26 | }
27 |
28 | if (responseData && !user) {
29 | localStorage.setItem("token", responseData.token);
30 | dispatch(login(responseData));
31 | }
32 |
33 | if (token && user) {
34 | navigate("/");
35 | }
36 | }, [user, token, errorData, responseData, navigate, dispatch]);
37 |
38 | const handleSubmit = (userData) => {
39 | setIsLoading(true);
40 | axios
41 | .post(LOGIN_URL, userData)
42 | .then(({ data }) => setResponseData(data))
43 | .catch((error) => setErrorData(error.response.data.error))
44 | .finally(() => setIsLoading(false));
45 | };
46 |
47 | return (
48 |
49 |
50 |
51 |
52 |
53 |
Login
54 |
55 |
56 |
handleSubmit(values)}
63 | >
64 |
106 |
107 |
108 |
109 |
110 | Register
111 |
112 |
113 |
114 |
115 |
116 |
117 | );
118 | }
119 |
--------------------------------------------------------------------------------
/frontend/src/pages/Profile.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Link } from "react-router-dom";
3 | import { useSelector } from "react-redux";
4 | import Avatar from "../components/Avatar";
5 |
6 | export default function Profile() {
7 | const { user } = useSelector((state) => state.auth);
8 |
9 | return (
10 |
11 |
12 |
13 |
14 |
21 |
22 |
23 |
24 |
25 | {user?.firstName}
26 |
27 |
28 |
29 | {user?.lastName}
30 |
31 |
{user?.email}
32 |
33 |
34 | Edit
35 |
36 |
37 |
38 |
39 |
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/frontend/src/pages/SearchEntry.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 | import { useSelector } from "react-redux";
3 | import { useNavigate, useSearchParams } from "react-router-dom";
4 |
5 | import Entry from "../components/Entry";
6 | import Loading from "../components/Loading";
7 | import { useSearchDataHook } from "../hooks/useEntryDataHook";
8 | import useDebounce from "../hooks/useDebounce";
9 |
10 | export default function SearchEntry() {
11 | const navigate = useNavigate();
12 |
13 | const { token } = useSelector((state) => state.auth);
14 | const { amountFormat, currency } = useSelector((state) => state.preference);
15 | const [searchParams] = useSearchParams();
16 | const [page, setPage] = useState(parseInt(searchParams.get("page")) || 1);
17 |
18 | const [keyword, setKeyword] = useState("");
19 | const debouncedSearchKeyword = useDebounce(keyword, 500);
20 |
21 | useEffect(() => {
22 | navigate(`?${new URLSearchParams({ page, search: keyword }).toString()}`);
23 | }, [navigate, page, keyword]);
24 |
25 | const { data, isLoading, refetch } = useSearchDataHook(
26 | token,
27 | debouncedSearchKeyword,
28 | page - 1
29 | );
30 |
31 | return (
32 |
33 |
34 |
35 |
Search Entry
36 |
37 |
38 |
39 |
setKeyword(e.target.value)}
44 | value={keyword}
45 | />
46 |
refetch()}>
47 |
54 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | Page {page} of{" "}
67 | {Math.ceil(data?.data?.total / data?.data?.limit) || 0}
68 |
69 |
70 |
71 | setPage(page - 1)}>
72 | «
73 |
74 | Page {page}
75 | setPage(page + 1)}>
76 | »
77 |
78 |
79 |
80 |
{isLoading ? : null}
81 |
82 | {data?.data?.entries?.map((entry) => {
83 | return (
84 |
90 | );
91 | })}
92 |
93 | {!isLoading && data?.data?.entries?.length === 0 ? (
94 |
No Data
95 | ) : null}
96 |
97 |
98 | setPage(page - 1)}>
99 | «
100 |
101 | Page {page}
102 | setPage(page + 1)}>
103 | »
104 |
105 |
106 |
107 |
108 |
109 | );
110 | }
111 |
--------------------------------------------------------------------------------
/frontend/src/pages/SelectLedger.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { useEffect } from "react";
3 | import { useSelector } from "react-redux";
4 | import { Link } from "react-router-dom";
5 | import { useAllLedgerDataHook } from "../hooks/useLedgerDataHook";
6 | import MiniLoading from "../components/MiniLoading";
7 |
8 | export default function SelectLedger() {
9 | const { token } = useSelector((state) => state.auth);
10 | const [selectedLedgerId, setSelectedLedgerId] = useState("");
11 | const [ledgers, setLedgers] = useState([]);
12 |
13 | const { data, isLoading, isSuccess } = useAllLedgerDataHook(token);
14 |
15 | useEffect(() => {
16 | if (isSuccess) {
17 | setLedgers([...data?.data?.ledgers]);
18 | setSelectedLedgerId(data?.data?.ledgers[0]?.id); // default
19 | }
20 | }, [data, isSuccess]);
21 |
22 | const onChange = (e) => setSelectedLedgerId(e.target.value);
23 |
24 | return (
25 |
26 |
27 |
28 |
29 |
30 |
Select Ledger
31 |
32 |
33 |
34 | Ledger
35 | {isLoading ? : null}
36 |
37 |
44 | {ledgers.map((item) => {
45 | return (
46 |
47 | {item.name} A/c
48 |
49 | );
50 | })}
51 |
52 |
53 |
54 |
55 |
59 | View
60 |
61 |
62 |
63 |
64 |
65 |
66 | );
67 | }
68 |
--------------------------------------------------------------------------------
/frontend/src/pages/Settings.jsx:
--------------------------------------------------------------------------------
1 | import PreferenceSettings from "../components/PreferenceSettings";
2 | import ChangePasswordSettings from "../components/ChangePasswordSettings";
3 | import NormalizationSettings from "../components/NormalizationSettings";
4 | import DeleteAccountSettings from "../components/DeleteAccountSettings";
5 |
6 | export default function Settings() {
7 | return (
8 |
9 |
10 |
11 |
Settings
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/frontend/src/pages/TrialBalance.jsx:
--------------------------------------------------------------------------------
1 | import { useSelector } from "react-redux";
2 | import Alert from "../components/Alert";
3 | import Loading from "../components/Loading";
4 | import TrialBalanceItem from "../components/TrialBalanceItem";
5 | import useTrialBalanceData from "../hooks/useTrialBalanceData";
6 |
7 | export default function TrialBalance() {
8 | const { token } = useSelector((state) => state.auth);
9 | const { amountFormat: currencyFormat, currency } = useSelector(
10 | (state) => state.preference
11 | );
12 |
13 | const {
14 | isLoading,
15 | data: trialBalanceItems,
16 | error,
17 | } = useTrialBalanceData(token);
18 |
19 | return (
20 |
21 |
22 |
23 |
Trial Balance
24 |
25 | {error ?
: null}
26 |
27 |
{isLoading ? : null}
28 |
29 | {trialBalanceItems.map((item) => {
30 | return (
31 |
38 | );
39 | })}
40 |
41 | {!isLoading && trialBalanceItems.length === 0 ? (
42 |
No Data
43 | ) : null}
44 |
45 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/frontend/src/pages/ViewEntry.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 | import { Link, useParams } from "react-router-dom";
3 | import { useSelector } from "react-redux";
4 |
5 | import Loading from "../components/Loading";
6 | import timeFormat from "../util/timeFormat";
7 | import amountFormat from "../util/amountFormat";
8 | import Alert from "../components/Alert";
9 | import {
10 | useEntryDataHook,
11 | useEntryNormalizationHook,
12 | } from "../hooks/useEntryDataHook";
13 |
14 | export default function ViewEntry() {
15 | const { token } = useSelector((state) => state.auth);
16 | const { amountFormat: currencyFormat, currency } = useSelector(
17 | (state) => state.preference
18 | );
19 | const { id } = useParams();
20 | const [entry, setEntry] = useState({});
21 |
22 | const { isLoading, error, isError, data } = useEntryDataHook(token, id);
23 |
24 | const {
25 | mutate: normalizeEntry,
26 | isLoading: normalizationIsLoading,
27 | isError: normalizationIsError,
28 | error: normalizationError,
29 | isSuccess: normalizationIsSuccess,
30 | } = useEntryNormalizationHook(token, id);
31 |
32 | useEffect(() => {
33 | setEntry(data?.data);
34 | }, [data]);
35 |
36 | return (
37 |
38 |
39 |
40 |
Entry
41 |
42 | {isLoading ? (
43 |
44 |
45 |
46 | ) : isError ? (
47 |
48 | ) : (
49 | <>
50 | {/* Loading is done and there isn't any errors */}
51 |
52 |
53 |
54 | #{id}
55 |
56 | {timeFormat(entry?.created_at)}
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | {entry?.debit_ledger?.name || "-"} A/c
65 |
66 |
67 |
68 |
69 |
70 |
71 | {entry?.credit_ledger?.name || "-"} A/c
72 |
73 |
74 |
75 |
76 |
77 | {amountFormat(
78 | entry?.amount || 0,
79 | currencyFormat,
80 | currency
81 | )}
82 |
83 |
84 |
85 |
86 |
87 | ({entry?.narration})
88 |
89 |
90 |
91 |
92 | Edit
93 |
94 |
95 |
96 |
normalizeEntry()}
101 | >
102 | {normalizationIsSuccess ? "Normalized 🎉" : "Normalize"}
103 |
104 |
105 |
106 | {normalizationIsError &&
107 | normalizationError?.response?.data?.error?.message}
108 |
109 |
110 |
111 | >
112 | )}
113 |
114 |
115 |
116 | );
117 | }
118 |
--------------------------------------------------------------------------------
/frontend/src/util/amountFormat.js:
--------------------------------------------------------------------------------
1 | import { INDIAN, INTERNATIONAL } from "../constants/amountFormat";
2 |
3 | export default function amountFormat(amount, format, symbol) {
4 | const a = Math.abs(amount).toString();
5 |
6 | let result = "";
7 |
8 | // comma format
9 | if (format === INDIAN) {
10 | let lastThree = a.substring(a.length - 3);
11 | let otherNumbers = a.substring(0, a.length - 3);
12 | if (otherNumbers !== "") lastThree = "," + lastThree;
13 | result = otherNumbers.replace(/\B(?=(\d{2})+(?!\d))/g, ",") + lastThree;
14 | } else if (format === INTERNATIONAL) {
15 | result = a.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
16 | }
17 |
18 | // currency symbol
19 | result = `${symbol} ${result}`;
20 |
21 | return result;
22 | }
23 |
--------------------------------------------------------------------------------
/frontend/src/util/authConfig.js:
--------------------------------------------------------------------------------
1 | export default function authConfig(token) {
2 | return {
3 | headers: {
4 | Authorization: `Bearer ${token}`,
5 | },
6 | };
7 | }
8 |
--------------------------------------------------------------------------------
/frontend/src/util/balanceIsNegative.js:
--------------------------------------------------------------------------------
1 | import {
2 | ASSET,
3 | EQUITY,
4 | EXPENDITURE,
5 | INCOME,
6 | LIABILITY,
7 | } from "../constants/ledgerTypes";
8 |
9 | /**
10 | * The ledger balance in the API response can be positive or negative. The +ve
11 | * balance is the expected balance for a debit type (expenditure, and asset)
12 | * ledger, and -ve balance is the expected balance for a credit type (equity,
13 | * liability, and income) account.
14 | *
15 | * @return
16 | * +ve balance in EXPENDITURE, and ASSET = false
17 | * -ve balance in EQUITY, LIABILITY, and INCOME = false
18 | * else = true
19 | */
20 |
21 | export default function balanceIsNegative(type, balance) {
22 | return (
23 | ((type === EXPENDITURE || type === ASSET) && balance < 0) ||
24 | ((type === INCOME || type === LIABILITY || type === EQUITY) && 0 < balance)
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/frontend/src/util/entryValidationSchema.js:
--------------------------------------------------------------------------------
1 | import * as Yup from "yup";
2 | import { ENTRY_NARRATION_MAX_LENGTH } from "../constants/policies";
3 |
4 | export const EntryCreateSchema = Yup.object().shape({
5 | debit_ledger_id: Yup.string().label("Debit Ledger Id").required(),
6 | credit_ledger_id: Yup.string().label("Credit Ledger Id").required(),
7 | amount: Yup.number().label("Amount").integer().positive().min(1).required(),
8 | narration: Yup.string()
9 | .label("Narration")
10 | .min(1)
11 | .max(ENTRY_NARRATION_MAX_LENGTH)
12 | .required(),
13 | });
14 |
15 | export const EntryEditSchema = Yup.object().shape({
16 | narration: Yup.string()
17 | .label("Narration")
18 | .min(1)
19 | .max(ENTRY_NARRATION_MAX_LENGTH)
20 | .required(),
21 | });
22 |
--------------------------------------------------------------------------------
/frontend/src/util/ledgerValidationSchema.js:
--------------------------------------------------------------------------------
1 | import * as Yup from "yup";
2 | import {
3 | LEDGER_NAME_MAX_LENGTH,
4 | LEDGER_DESCRIPTION_MAX_LENGTH,
5 | } from "../constants/policies";
6 | import {
7 | INCOME,
8 | EXPENDITURE,
9 | ASSET,
10 | LIABILITY,
11 | EQUITY,
12 | } from "../constants/ledgerTypes";
13 |
14 | export default Yup.object().shape({
15 | name: Yup.string()
16 | .label("Name")
17 | .min(1)
18 | .max(LEDGER_NAME_MAX_LENGTH)
19 | .required(),
20 | type: Yup.string()
21 | .label("Type")
22 | .oneOf([INCOME, EXPENDITURE, ASSET, LIABILITY, EQUITY])
23 | .required(),
24 | description: Yup.string()
25 | .label("Description")
26 | .min(1)
27 | .max(LEDGER_DESCRIPTION_MAX_LENGTH)
28 | .required(),
29 | });
30 |
--------------------------------------------------------------------------------
/frontend/src/util/preferenceValidationSchema.js:
--------------------------------------------------------------------------------
1 | import * as Yup from "yup";
2 | import {
3 | RUPEE,
4 | DOLLAR,
5 | EURO,
6 | NAIRA,
7 | NEW_SHEKEL,
8 | POUND,
9 | RUBLE,
10 | TAKA,
11 | WON,
12 | YEN,
13 | } from "../constants/currency";
14 | import { INDIAN, INTERNATIONAL } from "../constants/amountFormat";
15 |
16 | export default Yup.object().shape({
17 | amountFormat: Yup.string()
18 | .label("Amount Format")
19 | .oneOf([INDIAN, INTERNATIONAL])
20 | .required(),
21 | currency: Yup.string()
22 | .label("Currency")
23 | .oneOf([
24 | RUPEE,
25 | DOLLAR,
26 | EURO,
27 | NAIRA,
28 | NEW_SHEKEL,
29 | POUND,
30 | RUBLE,
31 | TAKA,
32 | WON,
33 | YEN,
34 | ])
35 | .required(),
36 | });
37 |
--------------------------------------------------------------------------------
/frontend/src/util/timeFormat.js:
--------------------------------------------------------------------------------
1 | export default function timeFormat(date) {
2 | const dateObj = new Date(date);
3 | const dateString = [
4 | dateObj.getDate().toString().padStart(2, "0"),
5 | (dateObj.getMonth() + 1).toString().padStart(2, "0"),
6 | dateObj.getFullYear(),
7 | ].join("/");
8 |
9 | const timeString = [
10 | dateObj.getHours().toString().padStart(2, "0"),
11 | dateObj.getMinutes().toString().padStart(2, "0"),
12 | ].join(":");
13 |
14 | return `${dateString} - ${timeString}`;
15 | }
16 |
--------------------------------------------------------------------------------
/frontend/src/util/userValidationSchema.js:
--------------------------------------------------------------------------------
1 | import * as Yup from "yup";
2 | import {
3 | USER_EMAIL_MAX_LENGTH,
4 | USER_FIRST_NAME_MAX_LENGTH,
5 | USER_LAST_NAME_MAX_LENGTH,
6 | USER_PASSWORD_MAX_LENGTH,
7 | USER_PASSWORD_MIN_LENGTH,
8 | } from "../constants/policies";
9 |
10 | export const RegisterSchema = Yup.object().shape({
11 | firstName: Yup.string()
12 | .label("First Name")
13 | .min(1)
14 | .max(USER_FIRST_NAME_MAX_LENGTH)
15 | .required(),
16 | lastName: Yup.string()
17 | .label("Last Name")
18 | .min(1)
19 | .max(USER_LAST_NAME_MAX_LENGTH)
20 | .required(),
21 | email: Yup.string()
22 | .label("Email")
23 | .min(1)
24 | .max(USER_EMAIL_MAX_LENGTH)
25 | .email()
26 | .required(),
27 | password: Yup.string()
28 | .label("Password")
29 | .min(USER_PASSWORD_MIN_LENGTH)
30 | .max(USER_PASSWORD_MAX_LENGTH)
31 | .required(),
32 | confirmPassword: Yup.string()
33 | .label("Confirmation Password")
34 | .min(USER_PASSWORD_MIN_LENGTH)
35 | .max(USER_PASSWORD_MAX_LENGTH)
36 | .required()
37 | .test(
38 | "confirmation-password-matching",
39 | "Confirmation Password not matching",
40 | (confirmationPassword, { parent: { password } }) =>
41 | password === confirmationPassword
42 | ),
43 | });
44 |
45 | export const LoginSchema = Yup.object().shape({
46 | email: Yup.string()
47 | .label("Email")
48 | .min(1)
49 | .max(USER_EMAIL_MAX_LENGTH)
50 | .email()
51 | .required(),
52 | password: Yup.string()
53 | .label("Password")
54 | .min(USER_PASSWORD_MIN_LENGTH)
55 | .max(USER_PASSWORD_MAX_LENGTH)
56 | .required(),
57 | });
58 |
59 | export const ProfileSchema = Yup.object().shape({
60 | firstName: Yup.string()
61 | .label("First Name")
62 | .min(1)
63 | .max(USER_FIRST_NAME_MAX_LENGTH)
64 | .required(),
65 | lastName: Yup.string()
66 | .label("Last Name")
67 | .min(1)
68 | .max(USER_LAST_NAME_MAX_LENGTH)
69 | .required(),
70 | email: Yup.string()
71 | .label("Email")
72 | .min(1)
73 | .max(USER_EMAIL_MAX_LENGTH)
74 | .email()
75 | .required(),
76 | });
77 |
78 | export const ChangePasswordSchema = Yup.object().shape({
79 | oldPassword: Yup.string()
80 | .label("Old Password")
81 | .min(USER_PASSWORD_MIN_LENGTH)
82 | .max(USER_PASSWORD_MAX_LENGTH)
83 | .required(),
84 | newPassword: Yup.string()
85 | .label("New Password")
86 | .min(USER_PASSWORD_MIN_LENGTH)
87 | .max(USER_PASSWORD_MAX_LENGTH)
88 | .required(),
89 | confirmNewPassword: Yup.string()
90 | .label("Confirmation New Password")
91 | .min(USER_PASSWORD_MIN_LENGTH)
92 | .max(USER_PASSWORD_MAX_LENGTH)
93 | .required()
94 | .test(
95 | "confirmation-new-password-matching",
96 | "Confirmation New Password not matching",
97 | (confirmNewPassword, { parent: { newPassword } }) =>
98 | newPassword === confirmNewPassword
99 | ),
100 | });
101 |
--------------------------------------------------------------------------------
/frontend/tailwind.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | content: ["./src/**/*.{js,ts,jsx,tsx}"],
3 | plugins: [require("daisyui"), require("@tailwindcss/line-clamp")],
4 | themes: ["light", "dark"],
5 | };
6 |
--------------------------------------------------------------------------------
/frontend/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | server: {
8 | // this ensures that the browser opens upon server start
9 | open: true,
10 | // this sets a default port to 3000
11 | port: 3000,
12 | },
13 | });
14 |
--------------------------------------------------------------------------------
/frontend2/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/frontend2/README.md:
--------------------------------------------------------------------------------
1 | # React + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
--------------------------------------------------------------------------------
/frontend2/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import react from 'eslint-plugin-react'
4 | import reactHooks from 'eslint-plugin-react-hooks'
5 | import reactRefresh from 'eslint-plugin-react-refresh'
6 |
7 | export default [
8 | { ignores: ['dist'] },
9 | {
10 | files: ['**/*.{js,jsx}'],
11 | languageOptions: {
12 | ecmaVersion: 2020,
13 | globals: globals.browser,
14 | parserOptions: {
15 | ecmaVersion: 'latest',
16 | ecmaFeatures: { jsx: true },
17 | sourceType: 'module',
18 | },
19 | },
20 | settings: { react: { version: '18.3' } },
21 | plugins: {
22 | react,
23 | 'react-hooks': reactHooks,
24 | 'react-refresh': reactRefresh,
25 | },
26 | rules: {
27 | ...js.configs.recommended.rules,
28 | ...react.configs.recommended.rules,
29 | ...react.configs['jsx-runtime'].rules,
30 | ...reactHooks.configs.recommended.rules,
31 | 'react/jsx-no-target-blank': 'off',
32 | 'react-refresh/only-export-components': [
33 | 'warn',
34 | { allowConstantExport: true },
35 | ],
36 | },
37 | },
38 | ]
39 |
--------------------------------------------------------------------------------
/frontend2/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 | Lucafy - Bookkeeping App
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend2/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend2",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "lint": "eslint src --ext js,jsx --report-unusual-disable-directives --max-warnings 0",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@emotion/react": "^11.14.0",
14 | "@emotion/styled": "^11.14.0",
15 | "@mui/icons-material": "^6.4.1",
16 | "@reduxjs/toolkit": "^2.5.0",
17 | "@tanstack/react-query": "^5.74.11",
18 | "@tanstack/react-query-devtools": "^5.74.11",
19 | "alea": "^1.0.1",
20 | "apexcharts": "^4.4.0",
21 | "axios": "^1.7.9",
22 | "formik": "^2.4.6",
23 | "js-file-download": "^0.4.12",
24 | "react": "^18.3.1",
25 | "react-apexcharts": "^1.7.0",
26 | "react-calendar-heatmap": "^1.10.0",
27 | "react-dom": "^18.3.1",
28 | "react-paginate": "^8.3.0",
29 | "react-redux": "^9.2.0",
30 | "react-router-dom": "^7.6.0",
31 | "react-toastify": "^11.0.5",
32 | "react-tooltip": "^4.5.1",
33 | "yup": "^1.6.1"
34 | },
35 | "devDependencies": {
36 | "@eslint/js": "^9.17.0",
37 | "@tailwindcss/vite": "^4.0.0",
38 | "@types/react": "^18.3.18",
39 | "@types/react-dom": "^18.3.5",
40 | "@vitejs/plugin-react": "^4.3.4",
41 | "autoprefixer": "^10.4.20",
42 | "eslint": "^9.17.0",
43 | "eslint-plugin-react": "^7.37.2",
44 | "eslint-plugin-react-hooks": "^5.0.0",
45 | "eslint-plugin-react-refresh": "^0.4.16",
46 | "globals": "^15.14.0",
47 | "mockdate": "^3.0.5",
48 | "postcss": "^8.5.1",
49 | "tailwindcss": "^4.0.0",
50 | "vite": "^6.0.5"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/frontend2/postcss.config.js:
--------------------------------------------------------------------------------
1 | import tailwindcss from "@tailwindcss/vite";
2 |
3 | export default {
4 | plugins: [tailwindcss],
5 | };
6 |
--------------------------------------------------------------------------------
/frontend2/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/captainAyan/lucafy/a6935f1d04ba046c17ff727812e42260fa68697d/frontend2/public/favicon.ico
--------------------------------------------------------------------------------
/frontend2/public/logo-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/captainAyan/lucafy/a6935f1d04ba046c17ff727812e42260fa68697d/frontend2/public/logo-dark.png
--------------------------------------------------------------------------------
/frontend2/public/logo-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/captainAyan/lucafy/a6935f1d04ba046c17ff727812e42260fa68697d/frontend2/public/logo-light.png
--------------------------------------------------------------------------------
/frontend2/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/captainAyan/lucafy/a6935f1d04ba046c17ff727812e42260fa68697d/frontend2/public/logo.png
--------------------------------------------------------------------------------
/frontend2/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Lucafy",
3 | "name": "Lucafy Bookeeping App",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo.png",
12 | "type": "image/png",
13 | "sizes": "64x64"
14 | }
15 | ],
16 | "start_url": ".",
17 | "display": "standalone",
18 | "theme_color": "#000000",
19 | "background_color": "#ffffff"
20 | }
21 |
--------------------------------------------------------------------------------
/frontend2/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/frontend2/src/App.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { BrowserRouter, Routes, Route } from "react-router-dom";
3 | import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
4 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
5 | import { useDispatch, useSelector } from "react-redux";
6 | import axios from "axios";
7 | import { ToastContainer } from "react-toastify";
8 |
9 | import MainLayout from "./layouts/MainLayout";
10 | import AuthLayout from "./layouts/AuthLayout";
11 | import Landing from "./pages/Landing";
12 | import Login from "./pages/Login";
13 | import Register from "./pages/Register";
14 | import Dashboard from "./pages/Dashboard";
15 | import Journal from "./pages/Journal";
16 | import ViewEntry from "./pages/ViewEntry";
17 | import ViewLedger from "./pages/ViewLedger";
18 | import SelectLedger from "./pages/SelectLedger";
19 | import CreateEntry from "./pages/CreateEntry";
20 | import CreateLedger from "./pages/CreateLedger";
21 | import EditLedger from "./pages/EditLedger";
22 | import CreateMenu from "./pages/CreateMenu";
23 | import About from "./pages/About";
24 | import Page404 from "./pages/Page404";
25 | import { logout, updateUser } from "./features/authSlice";
26 | import { GET_PROFILE_URL } from "./constants/api";
27 | import authConfig from "./util/authConfig";
28 | import TrialBalance from "./pages/TrialBalance";
29 | import Profile from "./pages/Profile";
30 | import EditProfile from "./pages/EditProfile";
31 |
32 | const queryClient = new QueryClient({
33 | defaultOptions: {
34 | queries: {
35 | staleTime: 1000 * 60 * 1, // 1 minutes
36 | cacheTime: 1000 * 60 * 2, // optional: keep in cache longer
37 | },
38 | },
39 | });
40 |
41 | function App() {
42 | const dispatch = useDispatch();
43 |
44 | const { theme } = useSelector((state) => state.preference);
45 | const { token } = useSelector((state) => state.auth);
46 |
47 | // const queryClient = new QueryClient();
48 |
49 | useEffect(() => {
50 | // syncing user
51 | if (token)
52 | axios
53 | .get(GET_PROFILE_URL, authConfig(token))
54 | .then((response) => dispatch(updateUser(response.data)))
55 | .catch(() => {
56 | localStorage.setItem("token", "");
57 | dispatch(logout());
58 | });
59 | }, [token, dispatch]);
60 |
61 | return (
62 |
63 |
64 |
65 |
66 | } />
67 |
68 | }>
69 | } />
70 | } />
71 | } />
72 |
73 |
74 | }>
75 | } />
76 | } />
77 | } />
78 |
79 | } />
80 |
81 | } />
82 | } />
83 |
84 | } />
85 | } />
86 | } />
87 |
88 | } />
89 |
90 | } />
91 | } />
92 |
93 |
94 | } />
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 | );
103 | }
104 |
105 | export default App;
106 |
--------------------------------------------------------------------------------
/frontend2/src/app/store.js:
--------------------------------------------------------------------------------
1 | import { configureStore } from "@reduxjs/toolkit";
2 |
3 | import preferenceReducer from "../features/preferenceSlice";
4 | import authReducer from "../features/authSlice";
5 |
6 | export const store = configureStore({
7 | reducer: {
8 | preference: preferenceReducer,
9 | auth: authReducer,
10 | },
11 | });
12 |
--------------------------------------------------------------------------------
/frontend2/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend2/src/components/ActivityHeatMap.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import CalendarHeatmap from "react-calendar-heatmap";
3 | import ReactTooltip from "react-tooltip";
4 | import { useSelector } from "react-redux";
5 | import useActivityHeatMapData from "../hooks/useActivityHeatMapData";
6 |
7 | // Testing with mockdate
8 | // import MockDate from "mockdate";
9 | // MockDate.set("2025-05-19T12:00:00");
10 |
11 | function shiftDate(date, numDays) {
12 | const newDate = new Date(date);
13 | newDate.setDate(newDate.getDate() + numDays);
14 |
15 | var d = newDate,
16 | month = "" + (d.getMonth() + 1),
17 | day = "" + d.getDate(),
18 | year = d.getFullYear();
19 |
20 | if (month.length < 2) month = "0" + month;
21 | if (day.length < 2) day = "0" + day;
22 |
23 | return [year, month, day].join("-");
24 | }
25 |
26 | function createFrequencyTable(timestamps) {
27 | const frequencyMap = {};
28 |
29 | timestamps.forEach((timestamp) => {
30 | // Extract the date part from the timestamp (e.g., 'YYYY-MM-DD')
31 | const date = new Date(timestamp);
32 |
33 | // the following works, but doesn't change the timezone to the local one
34 | // const date = new Date(timestamp).toISOString().split('T')[0];
35 | // the following code keeps the locale correct
36 | const dateString = `${date.getFullYear()}-${(date.getMonth() + 1)
37 | .toString()
38 | .padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")}`;
39 |
40 | // Update the frequency count for that date
41 | frequencyMap[dateString] = (frequencyMap[dateString] || 0) + 1;
42 | });
43 |
44 | // Convert the frequency map to an array of objects
45 | const frequencyTable = Object.entries(frequencyMap).map(
46 | ([date, frequency]) => ({
47 | date,
48 | frequency,
49 | })
50 | );
51 |
52 | return frequencyTable;
53 | }
54 |
55 | export default function ActivityHeatMap() {
56 | const { token } = useSelector((state) => state.auth);
57 |
58 | const [heatmap, setHeatmap] = useState([]);
59 | const { data } = useActivityHeatMapData(token);
60 |
61 | const today = new Date();
62 | const endDate = shiftDate(today, 6 - new Date().getDay());
63 | const startDate = shiftDate(endDate, -24 * 7);
64 |
65 | useEffect(() => {
66 | if (data) {
67 | const frequencyMap = new Map();
68 |
69 | // Add default values (0 frequency) for the past 155 days
70 | for (let index = 0; index <= 7 * 24; index++) {
71 | const date = shiftDate(endDate, -index);
72 | frequencyMap.set(date, 0); // Set frequency as 0 for each date
73 | }
74 |
75 | const hm_actual = createFrequencyTable(data?.data?.timestamps);
76 |
77 | // Merge actual frequency data into the map
78 | hm_actual.forEach((item) => {
79 | frequencyMap.set(
80 | item.date,
81 | (frequencyMap.get(item.date) || 0) + item.frequency
82 | );
83 | });
84 |
85 | const heatmapArray = Array.from(frequencyMap, ([date, frequency]) => ({
86 | date,
87 | frequency,
88 | }));
89 |
90 | setHeatmap(heatmapArray);
91 | }
92 | }, [data]);
93 |
94 | return (
95 | <>
96 | {
103 | if (value?.frequency === 0) return `color-empty`;
104 | else if (value?.frequency > 4) return `color-palette-5`;
105 | else return `color-palette-${value?.frequency}`;
106 | }}
107 | tooltipDataAttrs={(value) => {
108 | return {
109 | "data-tip": `${value?.frequency} ${
110 | value?.frequency > 1 || value?.frequency === 0
111 | ? "entries"
112 | : "entry"
113 | } on ${value?.date}`,
114 | };
115 | }}
116 | />
117 |
118 | >
119 | );
120 | }
121 |
--------------------------------------------------------------------------------
/frontend2/src/components/Amount.jsx:
--------------------------------------------------------------------------------
1 | import { useSelector } from "react-redux";
2 |
3 | import { amountFormatLong, amountFormatShort } from "../util/amountFormat";
4 |
5 | export default function Amount({
6 | amount,
7 | isCreditBalance = false,
8 | shortFormat = false,
9 | }) {
10 | const correctedAmount = isCreditBalance ? amount * -1 : amount;
11 | const { amountFormat, currency } = useSelector((state) => state.preference);
12 | const formattedAmount = shortFormat
13 | ? amountFormatShort(correctedAmount, amountFormat, currency)
14 | : amountFormatLong(correctedAmount, amountFormat, currency);
15 |
16 | return <>{formattedAmount}>;
17 | }
18 |
--------------------------------------------------------------------------------
/frontend2/src/components/Avatar.jsx:
--------------------------------------------------------------------------------
1 | import Alea from "alea";
2 | import { useRef } from "react";
3 |
4 | export default function Avatar({ width, cell, color, seed = "", ...attr }) {
5 | const canvas = useRef(document.createElement("canvas"));
6 | canvas.current.width = width * cell;
7 | canvas.current.height = width * cell;
8 | const ctx = canvas.current.getContext("2d");
9 |
10 | const prng = new Alea(seed);
11 |
12 | for (var i = 0; i <= Math.floor(cell / 2); i++) {
13 | for (var j = 0; j <= cell; j++) {
14 | if (Math.floor(prng() * 9) > 4) {
15 | try {
16 | ctx.fillStyle = color;
17 | } catch (e) {
18 | ctx.fillStyle = "#000000";
19 | }
20 | } else {
21 | ctx.fillStyle = "#ffffff";
22 | }
23 |
24 | // from left
25 | ctx.fillRect(i * width, j * width, width, width);
26 | // from right
27 | ctx.fillRect(cell * width - width - i * width, j * width, width, width);
28 | }
29 | }
30 |
31 | const pngUrl = canvas.current.toDataURL();
32 |
33 | return ;
34 | }
35 |
--------------------------------------------------------------------------------
/frontend2/src/components/Button.jsx:
--------------------------------------------------------------------------------
1 | export default function Button({
2 | children,
3 | className = "",
4 | variant = "primary",
5 | isLoading = false,
6 | ...attr
7 | }) {
8 | const baseStyles =
9 | "inline-flex items-center justify-center rounded-lg font-medium text-sm uppercase cursor-pointer disabled:cursor-not-allowed duration-300 focus:ring-4 focus:outline-none";
10 |
11 | const variants = {
12 | primary:
13 | "bg-indigo-500 text-white ring-indigo-200 hover:bg-indigo-600 disabled:bg-indigo-300",
14 | secondary:
15 | "bg-white text-indigo-500 ring-indigo-200 border-1 border-indigo-500 ring-indigo-200 hover:bg-indigo-600 hover:text-white disabled:bg-indigo-300",
16 | };
17 |
18 | return (
19 |
23 | {isLoading && (
24 |
30 |
38 |
43 |
44 | )}
45 |
46 | {children}
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/frontend2/src/components/Entry.jsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 | import Amount from "../components/Amount";
3 | import Time from "./Time";
4 |
5 | export default function Entry({
6 | id,
7 | debit_ledger: debit,
8 | credit_ledger: credit,
9 | amount,
10 | narration,
11 | created_at,
12 | className,
13 | }) {
14 | return (
15 |
16 |
17 |
21 | #{id}
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | {debit?.name} A/c
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | To {credit?.name} A/c
42 |
43 |
44 |
45 |
46 |
51 |
52 |
53 |
({narration})
54 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/frontend2/src/components/EntryTable.jsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 |
3 | import Amount from "./Amount";
4 | import Time from "./Time";
5 |
6 | export function EntryTable({ children }) {
7 | return (
8 |
9 |
10 |
11 |
15 | ID
16 |
17 |
21 | Time
22 |
23 |
27 | Debit
28 |
29 |
33 | Credit
34 |
35 |
39 | Amount
40 |
41 |
42 |
46 | Narration
47 |
48 |
49 |
50 | {children}
51 |
52 | );
53 | }
54 |
55 | export function EntryTableRow({
56 | id,
57 | debit_ledger: debit,
58 | credit_ledger: credit,
59 | amount,
60 | narration,
61 | created_at,
62 | }) {
63 | return (
64 |
65 |
66 |
67 | {"#" + String(id).slice(-6)}
68 |
69 |
70 |
71 |
72 |
73 |
74 |
79 | {debit?.name} A/c
80 |
81 |
82 |
83 |
88 | {credit?.name} A/c
89 |
90 |
91 |
92 |
93 |
94 | {narration}
95 |
96 | );
97 | }
98 |
--------------------------------------------------------------------------------
/frontend2/src/components/Footer.jsx:
--------------------------------------------------------------------------------
1 | export default function Footer() {
2 | return (
3 |
6 | );
7 | }
8 |
--------------------------------------------------------------------------------
/frontend2/src/components/Header.jsx:
--------------------------------------------------------------------------------
1 | import { useSelector } from "react-redux";
2 | import MenuIcon from "@mui/icons-material/Menu";
3 | import AddIcon from "@mui/icons-material/Add";
4 | import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
5 | import { NavLink } from "react-router-dom";
6 |
7 | import Avatar from "./Avatar";
8 |
9 | function NavLinkButton({ children, className = "", ...attr }) {
10 | return (
11 |
15 | {children}
16 |
17 | );
18 | }
19 |
20 | /**
21 | * This header is for the authenticated layout
22 | */
23 | export function MainHeader({ toggleMenu, className }) {
24 | const { user } = useSelector((state) => state.auth);
25 |
26 | return (
27 |
63 | );
64 | }
65 |
66 | export function AuthHeader({ className }) {
67 | return (
68 |
69 |
70 |
71 |
72 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/frontend2/src/components/LedgerTable.jsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 |
3 | import Amount from "./Amount";
4 | import { ASSET, EXPENDITURE } from "../constants/ledgerTypes";
5 |
6 | /**
7 | * Ledger table is used for creating Trial Balance, Profit and Loss statement,
8 | * and Balance Sheet; as all of them are essential tables of ledgers
9 | */
10 |
11 | export function LedgerTable({ children, debitBalance, creditBalance }) {
12 | return (
13 |
14 |
15 |
16 |
20 | ID
21 |
22 |
26 | Ledger
27 |
28 |
32 | Dr.
33 |
34 |
38 | Cr.
39 |
40 |
41 |
42 |
43 | {children}
44 |
45 |
46 |
47 |
48 | TOTAL
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | );
60 | }
61 |
62 | export function LedgerTableRow({ id, name, type, description, balance }) {
63 | return (
64 |
65 |
66 |
67 | {"#" + String(id).slice(-6)}
68 |
69 |
70 |
71 |
76 | {name} A/c
77 |
78 |
79 |
80 | {type === ASSET || type === EXPENDITURE ? (
81 |
82 | ) : (
83 | "-"
84 | )}
85 |
86 |
87 | {type === ASSET || type === EXPENDITURE ? (
88 | "-"
89 | ) : (
90 |
91 | )}
92 |
93 |
94 | );
95 | }
96 |
--------------------------------------------------------------------------------
/frontend2/src/components/Sidebar.jsx:
--------------------------------------------------------------------------------
1 | import DashboardOutlinedIcon from "@mui/icons-material/DashboardOutlined";
2 | import FaceOutlinedIcon from "@mui/icons-material/FaceOutlined";
3 | import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
4 | import PowerSettingsNewOutlinedIcon from "@mui/icons-material/PowerSettingsNewOutlined";
5 | import KeyboardArrowRightOutlinedIcon from "@mui/icons-material/KeyboardArrowRightOutlined";
6 | import BalanceOutlinedIcon from "@mui/icons-material/BalanceOutlined";
7 | import LibraryBooksOutlinedIcon from "@mui/icons-material/LibraryBooksOutlined";
8 | import ReceiptLongOutlinedIcon from "@mui/icons-material/ReceiptLongOutlined";
9 | import { Link, NavLink } from "react-router-dom";
10 |
11 | function SidebarButton({ children, icon, title, to }) {
12 | return (
13 |
14 | {({ isActive }) => (
15 |
20 | {icon}
21 | {title}
22 |
23 |
24 |
25 | {children}
26 |
27 | )}
28 |
29 | );
30 | }
31 |
32 | export default function Sidebar({ className }) {
33 | return (
34 |
37 |
38 | }
41 | title="Dashboard"
42 | />
43 | }
46 | title="Journal"
47 | />
48 | }
51 | title="Ledgers"
52 | />
53 | }
56 | title="Trial Balance"
57 | />
58 |
59 |
60 | }
63 | title="Profile"
64 | />
65 | }
68 | title="Settings"
69 | />
70 | }
73 | title="Logout"
74 | />
75 |
76 |
77 |
78 |
79 | About Us
80 |
81 |
·
82 |
83 | Privacy Policy
84 |
85 |
·
86 |
87 | Terms & Conditions
88 |
89 |
·
90 |
95 | Report Issue
96 |
97 |
98 |
99 | );
100 | }
101 |
--------------------------------------------------------------------------------
/frontend2/src/components/Time.jsx:
--------------------------------------------------------------------------------
1 | import timeFormat from "../util/timeFormat";
2 |
3 | export default function Time({ time }) {
4 | const _time = timeFormat(time);
5 | return <>{_time}>;
6 | }
7 |
--------------------------------------------------------------------------------
/frontend2/src/components/form/FilterSelectInput.jsx:
--------------------------------------------------------------------------------
1 | import { Field } from "formik";
2 |
3 | export default function FilterSelectInput({
4 | label = "",
5 | name = "",
6 | autofocus = false, // the html attribute is autoFocus not autofocus
7 | children,
8 | className = "",
9 | labelClassName = "",
10 | inputClassName = "",
11 | ...attr
12 | }) {
13 | return (
14 |
15 |
19 | {label}
20 |
21 |
27 | {children}
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/frontend2/src/components/form/FilterTextInput.jsx:
--------------------------------------------------------------------------------
1 | import { Field } from "formik";
2 |
3 | export default function FilterTextInput({
4 | // label = "",
5 | placeholder = "",
6 | type = "text",
7 | name = "",
8 | autofocus = false, // the html attribute is autoFocus not autofocus
9 | children,
10 | className = "",
11 | inputClassName = "",
12 | // labelClassName = "",
13 | ...attr
14 | }) {
15 | return (
16 |
17 | {/*
21 | {label}
22 | */}
23 |
24 |
25 |
32 | {children}
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/frontend2/src/components/form/Input.jsx:
--------------------------------------------------------------------------------
1 | import { ErrorMessage, Field } from "formik";
2 |
3 | export default function Input({
4 | label = "",
5 | type = "text",
6 | name = "",
7 | placeholder = "",
8 | autofocus = false, // the html attribute is autoFocus not autofocus
9 | className = "",
10 | labelClassName = "",
11 | inputClassName = "",
12 | errorClassName = "",
13 | ...attr
14 | }) {
15 | return (
16 |
17 |
21 | {label}
22 |
23 |
30 |
31 |
32 |
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/frontend2/src/components/form/SelectInput.jsx:
--------------------------------------------------------------------------------
1 | import { ErrorMessage, Field } from "formik";
2 |
3 | export default function SelectInput({
4 | label = "",
5 | type = "text",
6 | name = "",
7 | autofocus = false, // the html attribute is autoFocus not autofocus
8 | className = "",
9 | labelClassName = "",
10 | inputClassName = "",
11 | errorClassName = "",
12 | children,
13 | ...attr
14 | }) {
15 | return (
16 |
17 |
21 | {label}
22 |
23 |
30 | {children}
31 |
32 |
33 |
34 |
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/frontend2/src/components/form/Textarea.jsx:
--------------------------------------------------------------------------------
1 | import { ErrorMessage, Field } from "formik";
2 | export default function Textarea({
3 | label = "",
4 | name = "",
5 | placeholder = "",
6 | autofocus = false, // the html attribute is autoFocus not autofocus
7 | className = "",
8 | labelClassName = "",
9 | inputClassName = "",
10 | errorClassName = "",
11 | ...attr
12 | }) {
13 | return (
14 |
15 |
19 | {label}
20 |
21 |
29 |
30 |
31 |
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/frontend2/src/constants/amountFormat.js:
--------------------------------------------------------------------------------
1 | export const INDIAN = "ind";
2 | export const INTERNATIONAL = "int";
3 |
--------------------------------------------------------------------------------
/frontend2/src/constants/api.js:
--------------------------------------------------------------------------------
1 | export const DOMAIN =
2 | !process.env.NODE_ENV || process.env.NODE_ENV === "development"
3 | ? "http://localhost:3001"
4 | : "";
5 |
6 | export const VERSION = "v1";
7 | export const BASE_URL = `${DOMAIN}/api/${VERSION}`;
8 |
9 | export const BASE_AUTH_URL = `${BASE_URL}/auth`;
10 | export const REGISTER_URL = `${BASE_AUTH_URL}/register`;
11 | export const LOGIN_URL = `${BASE_AUTH_URL}/login`;
12 | export const CHANGE_PASSWORD_URL = `${BASE_AUTH_URL}/changepassword`;
13 |
14 | export const BASE_PROFILE_URL = `${BASE_URL}/profile`;
15 | export const GET_PROFILE_URL = `${BASE_PROFILE_URL}/`;
16 | export const EDIT_PROFILE_URL = `${BASE_PROFILE_URL}/`;
17 | export const DELETE_PROFILE_URL = `${BASE_PROFILE_URL}/`;
18 |
19 | export const BASE_LEDGER_URL = `${BASE_URL}/ledger`;
20 | export const CREATE_LEDGER_URL = `${BASE_LEDGER_URL}/`;
21 | export const GET_LEDGER_URL = `${BASE_LEDGER_URL}/`;
22 | export const GET_ALL_LEDGER_URL = `${BASE_LEDGER_URL}/all`;
23 | export const EDIT_LEDGER_URL = `${BASE_LEDGER_URL}/`;
24 |
25 | export const BASE_ENTRY_URL = `${BASE_URL}/entry`;
26 | export const CREATE_ENTRY_URL = `${BASE_ENTRY_URL}/`;
27 | export const GET_ENTRY_URL = `${BASE_ENTRY_URL}/`;
28 | export const NORMALIZE_ENTRIES_URL = `${BASE_ENTRY_URL}/normalize`;
29 | export const EDIT_ENTRY_URL = `${BASE_ENTRY_URL}/`;
30 | export const NORMALIZE_ENTRY_URL = `${BASE_ENTRY_URL}/normalize/`;
31 | export const SEARCH_ENTRY_URL = `${BASE_ENTRY_URL}/search/`;
32 |
33 | export const BASE_STATEMENT_URL = `${BASE_URL}/statement`;
34 | export const GET_LEDGER_STATEMENT_URL = `${BASE_STATEMENT_URL}/ledger/`;
35 | export const GET_TRIAL_BALANCE_URL = `${BASE_STATEMENT_URL}/trial-balance`;
36 | export const GET_MICRO_STATEMENT_URL = `${BASE_STATEMENT_URL}/micro-statement`;
37 | export const GET_CALENDAR_HEATMAP_URL = `${BASE_STATEMENT_URL}/calendar-heatmap`;
38 | export const GET_EXPORT_JOURNAL_URL = `${BASE_STATEMENT_URL}/export`;
39 |
--------------------------------------------------------------------------------
/frontend2/src/constants/currency.js:
--------------------------------------------------------------------------------
1 | export const RUPEE = "₹";
2 | export const DOLLAR = "$";
3 | export const EURO = "€";
4 | export const NAIRA = "₦";
5 | export const NEW_SHEKEL = "₪";
6 | export const POUND = "£";
7 | export const RUBLE = "₽";
8 | export const TAKA = "৳";
9 | export const WON = "₩";
10 | export const YEN = "¥";
11 |
--------------------------------------------------------------------------------
/frontend2/src/constants/ledgerTypes.js:
--------------------------------------------------------------------------------
1 | export const INCOME = "income";
2 | export const EXPENDITURE = "expenditure";
3 | export const ASSET = "asset";
4 | export const LIABILITY = "liability";
5 | export const EQUITY = "equity";
6 |
--------------------------------------------------------------------------------
/frontend2/src/constants/policies.js:
--------------------------------------------------------------------------------
1 | export const USER_FIRST_NAME_MAX_LENGTH = 100;
2 | export const USER_MIDDLE_NAME_MAX_LENGTH = 100;
3 | export const USER_LAST_NAME_MAX_LENGTH = 100;
4 | export const USER_EMAIL_MAX_LENGTH = 100;
5 | export const USER_PASSWORD_MIN_LENGTH = 6;
6 | export const USER_PASSWORD_MAX_LENGTH = 200;
7 | export const USER_BIO_MAX_LENGTH = 200;
8 | export const USER_ORGANIZATION_MAX_LENGTH = 100;
9 | export const USER_JOB_TITLE_MAX_LENGTH = 100;
10 | export const USER_LOCATION_MAX_LENGTH = 200;
11 |
12 | export const LEDGER_NAME_MAX_LENGTH = 50;
13 | export const LEDGER_DESCRIPTION_MAX_LENGTH = 200;
14 |
15 | export const ENTRY_NARRATION_MAX_LENGTH = 200;
16 |
--------------------------------------------------------------------------------
/frontend2/src/constants/theme.js:
--------------------------------------------------------------------------------
1 | export const DARK = "dark";
2 | export const LIGHT = "light";
3 |
--------------------------------------------------------------------------------
/frontend2/src/features/authSlice.js:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 |
3 | const token = localStorage.getItem("token");
4 |
5 | const initialState = {
6 | user: null,
7 | token: token ? token : null,
8 | };
9 |
10 | export const authSlice = createSlice({
11 | name: "auth",
12 | initialState,
13 | reducers: {
14 | login: (state, action) => {
15 | const { token, ...user } = action.payload;
16 | state = { user, token };
17 | return state;
18 | },
19 | register: (state, action) => {
20 | const { token, ...user } = action.payload;
21 | state = { user, token };
22 | return state;
23 | },
24 | logout: (state) => {
25 | state = { user: null, token: null };
26 | return state;
27 | },
28 | updateUser: (state, action) => {
29 | state = { user: action.payload, token: state.token };
30 | return state;
31 | },
32 | },
33 | });
34 |
35 | export const { login, register, logout, updateUser } = authSlice.actions;
36 | export default authSlice.reducer;
37 |
--------------------------------------------------------------------------------
/frontend2/src/features/preferenceSlice.js:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 |
3 | import { RUPEE } from "../constants/currency";
4 | import { INDIAN } from "../constants/amountFormat";
5 | import { LIGHT } from "../constants/theme";
6 |
7 | const preference = JSON.parse(localStorage.getItem("preference"));
8 |
9 | export const preferenceSlice = createSlice({
10 | name: "preference",
11 | initialState: {
12 | amountFormat: preference?.amountFormat || INDIAN,
13 | currency: preference?.currency || RUPEE,
14 | theme: preference?.theme || LIGHT,
15 | },
16 | reducers: {
17 | setPreference: (state, action) => {
18 | state.amountFormat = action.payload.amountFormat;
19 | state.currency = action.payload.currency;
20 |
21 | localStorage.setItem("preference", JSON.stringify(state));
22 | },
23 | setTheme: (state, action) => {
24 | state.theme = action.payload;
25 | localStorage.setItem("preference", JSON.stringify(state));
26 | },
27 | },
28 | });
29 |
30 | export const { setPreference, setTheme } = preferenceSlice.actions;
31 | export default preferenceSlice.reducer;
32 |
--------------------------------------------------------------------------------
/frontend2/src/hooks/useActivityHeatMapData.js:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import axios from "axios";
3 | import authConfig from "../util/authConfig";
4 | import { GET_CALENDAR_HEATMAP_URL } from "../constants/api";
5 |
6 | export default function useActivityHeatMapData(token) {
7 | return useQuery({
8 | queryKey: ["activity-heatmap"],
9 | queryFn: () => axios.get(GET_CALENDAR_HEATMAP_URL, authConfig(token)),
10 | });
11 | }
12 |
--------------------------------------------------------------------------------
/frontend2/src/hooks/useDebounce.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export default function useDebounce(value, delay) {
4 | const [debouncedValue, setDebouncedValue] = useState(value);
5 |
6 | useEffect(() => {
7 | const handler = setTimeout(() => setDebouncedValue(value), delay);
8 | return () => clearTimeout(handler);
9 | }, [value, delay]);
10 |
11 | return debouncedValue;
12 | }
13 |
--------------------------------------------------------------------------------
/frontend2/src/hooks/useEntryDataHook.js:
--------------------------------------------------------------------------------
1 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2 | import axios from "axios";
3 | import authConfig from "../util/authConfig";
4 | import {
5 | CREATE_ENTRY_URL,
6 | EDIT_ENTRY_URL,
7 | GET_ENTRY_URL,
8 | NORMALIZE_ENTRY_URL,
9 | } from "../constants/api";
10 |
11 | export function useEntryDataHook(token, id) {
12 | return useQuery({
13 | queryKey: ["entry", id],
14 | queryFn: () => axios.get(`${GET_ENTRY_URL}${id}`, authConfig(token)),
15 | });
16 | }
17 |
18 | export function useAddEntryHook(token) {
19 | const queryClient = useQueryClient();
20 | return useMutation({
21 | mutationFn: (entry) =>
22 | axios.post(CREATE_ENTRY_URL, entry, authConfig(token)),
23 | onSuccess: (data) => {
24 | queryClient.invalidateQueries(["journal"]);
25 | queryClient.setQueryData(["entry", data?.data?.id], data);
26 | },
27 | });
28 | }
29 |
30 | export function useEditEntryHook(token, id) {
31 | const queryClient = useQueryClient();
32 |
33 | return useMutation({
34 | mutationKey: ["entry", id],
35 | mutationFn: (entry) =>
36 | axios.put(`${EDIT_ENTRY_URL}${id}`, entry, authConfig(token)),
37 | onSuccess: (data) => {
38 | queryClient.invalidateQueries(["journal"]);
39 | queryClient.setQueryData(["entry", id], data);
40 | },
41 | });
42 | }
43 |
44 | export function useEntryNormalizationHook(token, id) {
45 | const queryClient = useQueryClient();
46 |
47 | return useMutation(
48 | () => axios.put(`${NORMALIZE_ENTRY_URL}${id}`, null, authConfig(token)),
49 | {
50 | onSuccess: () => {
51 | queryClient.invalidateQueries(["entry", id]);
52 | queryClient.invalidateQueries("journal");
53 | },
54 | }
55 | );
56 | }
57 |
58 | export function useJournalDataHook(
59 | token,
60 | page = 1,
61 | order = "newest",
62 | limit = 10,
63 | keyword = ""
64 | ) {
65 | const query = new URLSearchParams({ page, order, limit, keyword });
66 |
67 | return useQuery({
68 | queryKey: ["journal", query.toString()],
69 | queryFn: () => axios.get(`${GET_ENTRY_URL}?${query}`, authConfig(token)),
70 | });
71 | }
72 |
--------------------------------------------------------------------------------
/frontend2/src/hooks/useLedgerDataHook.js:
--------------------------------------------------------------------------------
1 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2 | import axios from "axios";
3 |
4 | import {
5 | CREATE_LEDGER_URL,
6 | EDIT_LEDGER_URL,
7 | GET_ALL_LEDGER_URL,
8 | GET_LEDGER_STATEMENT_URL,
9 | GET_LEDGER_URL,
10 | } from "../constants/api";
11 | import authConfig from "../util/authConfig";
12 |
13 | export function useAllLedgerDataHook(token) {
14 | return useQuery({
15 | queryKey: ["ledgers"],
16 | queryFn: () => axios.get(`${GET_ALL_LEDGER_URL}`, authConfig(token)),
17 | });
18 | }
19 |
20 | export function useAddLedgerHook(token) {
21 | const queryClient = useQueryClient();
22 | return useMutation({
23 | mutationFn: (ledger) =>
24 | axios.post(CREATE_LEDGER_URL, ledger, authConfig(token)),
25 | onSuccess: (data) => {
26 | queryClient.invalidateQueries("ledgers");
27 | queryClient.setQueryData(["ledger", data?.data?.id], data);
28 | },
29 | });
30 | }
31 |
32 | export function useLedgerStatementDataHook(
33 | token,
34 | id,
35 | page = 1,
36 | order = "newest",
37 | limit = 10
38 | ) {
39 | const query = new URLSearchParams({ page, order, limit });
40 | return useQuery({
41 | queryKey: ["ledger-statement", id, query.toString()],
42 | queryFn: () =>
43 | axios.get(`${GET_LEDGER_STATEMENT_URL}${id}?${query}`, authConfig(token)),
44 | });
45 | }
46 |
47 | export function useLedgerDataHook(token, id) {
48 | return useQuery({
49 | queryKey: ["ledger", id],
50 | queryFn: () => axios.get(`${GET_LEDGER_URL}${id}`, authConfig(token)),
51 | });
52 | }
53 |
54 | export function useEditLedgerHook(token, id) {
55 | const queryClient = useQueryClient();
56 | return useMutation({
57 | mutationKey: ["ledger", id],
58 | mutationFn: (ledger) =>
59 | axios.put(`${EDIT_LEDGER_URL}${id}`, ledger, authConfig(token)),
60 | onSuccess: (data) => {
61 | queryClient.invalidateQueries(["ledger-statement", id]);
62 | queryClient.setQueryData(["ledger", id], data);
63 | },
64 | });
65 | }
66 |
--------------------------------------------------------------------------------
/frontend2/src/hooks/useMicroStatementData.js:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import axios from "axios";
3 | import authConfig from "../util/authConfig";
4 | import { GET_MICRO_STATEMENT_URL } from "../constants/api";
5 |
6 | export default function useMicroStatementData(token) {
7 | return useQuery({
8 | queryKey: ["micro-statement"],
9 | queryFn: () => axios.get(GET_MICRO_STATEMENT_URL, authConfig(token)),
10 | });
11 | }
12 |
--------------------------------------------------------------------------------
/frontend2/src/hooks/useTrialBalanceData.js:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import axios from "axios";
3 |
4 | import { GET_TRIAL_BALANCE_URL } from "../constants/api";
5 | import authConfig from "../util/authConfig";
6 |
7 | export default function useTrialBalanceData(token) {
8 | return useQuery({
9 | queryKey: ["trial-balance"],
10 | queryFn: () => axios.get(GET_TRIAL_BALANCE_URL, authConfig(token)),
11 | });
12 | }
13 |
--------------------------------------------------------------------------------
/frontend2/src/hooks/useUserDataHook.js:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from "@tanstack/react-query";
2 | import axios from "axios";
3 |
4 | import { EDIT_PROFILE_URL } from "../constants/api";
5 | import authConfig from "../util/authConfig";
6 |
7 | export function useEditUserProfileHook(token) {
8 | const queryClient = useQueryClient();
9 | return useMutation({
10 | mutationKey: ["user", "me"],
11 | mutationFn: (userData) =>
12 | axios.put(`${EDIT_PROFILE_URL}`, userData, authConfig(token)),
13 | onSuccess: (data) => {
14 | queryClient.setQueryData(["user", "me"], data);
15 | },
16 | });
17 | }
18 |
--------------------------------------------------------------------------------
/frontend2/src/index.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 |
3 |
4 | .react-calendar-heatmap text {
5 | font-size: 8px;
6 | fill: #aaa;
7 | }
8 |
9 | .react-calendar-heatmap .react-calendar-heatmap-small-text {
10 | font-size: 4px;
11 | }
12 |
13 |
14 | /*
15 | * Default color scale
16 | */
17 |
18 | .react-calendar-heatmap {
19 | fill: rgba(0, 0, 0, 0.15);
20 | }
21 |
22 | .react-calendar-heatmap .color-empty {
23 | fill: rgba(0, 0, 0, 0.15);
24 | }
25 |
26 | /*
27 | * Color palette
28 | */
29 |
30 | .react-calendar-heatmap .color-palette-1 {
31 | fill: #8b5cf6;
32 | }
33 | .react-calendar-heatmap .color-palette-2 {
34 | fill: #a855f7;
35 | }
36 | .react-calendar-heatmap .color-palette-3 {
37 | fill: #d946ef;
38 | }
39 | .react-calendar-heatmap .color-palette-4 {
40 | fill: #ec4899;
41 | }
42 | .react-calendar-heatmap .color-palette-5 {
43 | fill: #f43f5e;
44 | }
--------------------------------------------------------------------------------
/frontend2/src/layouts/AuthLayout.jsx:
--------------------------------------------------------------------------------
1 | import { Navigate, Outlet } from "react-router-dom";
2 | import { useSelector } from "react-redux";
3 | import Footer from "../components/Footer";
4 | import { AuthHeader } from "../components/Header";
5 |
6 | export default function AuthLayout() {
7 | // Redirect to dashboard page
8 | const { token } = useSelector((state) => state.auth);
9 | if (token) {
10 | return ;
11 | }
12 |
13 | return (
14 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/frontend2/src/layouts/MainLayout.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 | import { Navigate, Outlet } from "react-router-dom";
3 | import { useSelector } from "react-redux";
4 |
5 | import { MainHeader } from "../components/Header";
6 | import Sidebar from "../components/Sidebar";
7 | import Footer from "../components/Footer";
8 |
9 | export default function MainLayout() {
10 | const [menuVisible, setMenuVisibility] = useState(false);
11 |
12 | const toggleMenu = () => {
13 | setMenuVisibility(!menuVisible);
14 | };
15 |
16 | useEffect(() => {
17 | const handleResize = () => {
18 | if (menuVisible && innerWidth >= 1024) setMenuVisibility(false);
19 | };
20 |
21 | // Add event listener on mount
22 | window.addEventListener("resize", handleResize);
23 |
24 | // Clean up the event listener on unmount
25 | return () => window.removeEventListener("resize", handleResize);
26 | }, [menuVisible]);
27 |
28 | // Redirect to login page
29 | const { token } = useSelector((state) => state.auth);
30 | if (!token) {
31 | return ;
32 | }
33 |
34 | return (
35 |
36 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/frontend2/src/main.jsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from "react";
2 | import { createRoot } from "react-dom/client";
3 | import App from "./App";
4 | import "./index.css";
5 |
6 | import { store } from "./app/store";
7 | import { Provider } from "react-redux";
8 |
9 | createRoot(document.getElementById("root")).render(
10 |
11 |
12 |
13 |
14 |
15 | );
16 |
--------------------------------------------------------------------------------
/frontend2/src/pages/CreateLedger.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { useSelector } from "react-redux";
3 | import { Form, Formik } from "formik";
4 | import { toast } from "react-toastify";
5 |
6 | import {
7 | INCOME,
8 | EXPENDITURE,
9 | ASSET,
10 | LIABILITY,
11 | EQUITY,
12 | } from "../constants/ledgerTypes";
13 | import Input from "../components/form/Input";
14 | import Textarea from "../components/form/Textarea";
15 | import SelectInput from "../components/form/SelectInput";
16 | import Button from "../components/Button";
17 | import { useAddLedgerHook } from "../hooks/useLedgerDataHook";
18 | import { LEDGER_DESCRIPTION_MAX_LENGTH } from "../constants/policies";
19 | import LedgerSchema from "../util/ledgerValidationSchema";
20 |
21 | export default function CreateLedger() {
22 | const { token } = useSelector((state) => state.auth);
23 |
24 | const addLedger = useAddLedgerHook(token);
25 |
26 | const notifyLedgerSaveSuccess = () => toast.success("Ledger saved");
27 | const notifyLedgerSaveError = () => toast.error("Cannot save ledger");
28 |
29 | useEffect(() => {
30 | if (addLedger?.isSuccess) notifyLedgerSaveSuccess();
31 | if (addLedger?.isError) notifyLedgerSaveError();
32 | }, [addLedger?.isSuccess, addLedger?.isError]);
33 |
34 | return (
35 | <>
36 | Create Entry
37 |
38 |
39 |
{
48 | addLedger.mutate(values);
49 | resetForm();
50 | }}
51 | >
52 | {({ values }) => (
53 |
115 | )}
116 |
117 |
118 | >
119 | );
120 | }
121 |
--------------------------------------------------------------------------------
/frontend2/src/pages/CreateMenu.jsx:
--------------------------------------------------------------------------------
1 | import AddIcon from "@mui/icons-material/Add";
2 |
3 | import Button from "../components/Button";
4 | import { Link } from "react-router-dom";
5 |
6 | export default function CreateMenu() {
7 | return (
8 | <>
9 | Create
10 |
11 |
12 |
13 |
14 | Add
15 |
16 | Entry
17 |
18 |
19 |
20 |
21 | Create
22 |
23 |
24 |
25 |
26 |
27 |
28 | Create
29 |
30 | Ledgers
31 |
32 |
33 |
34 |
35 |
36 | Create
37 |
38 |
39 |
40 |
41 |
42 |
43 | Create
44 |
45 | Book
46 |
47 |
48 |
49 | Create
50 |
51 |
52 |
53 | >
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/frontend2/src/pages/Landing.jsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 |
3 | export default function Landing() {
4 | return (
5 |
6 |
Landing Page
7 |
This is the landing page
8 |
9 |
10 | Login
11 |
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/frontend2/src/pages/Login.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 | import { Link, useNavigate } from "react-router-dom";
3 | import { useSelector, useDispatch } from "react-redux";
4 | import { Formik, Form } from "formik";
5 | import axios from "axios";
6 |
7 | import { login } from "../features/authSlice";
8 | import { LOGIN_URL } from "../constants/api";
9 | import { LoginSchema } from "../util/userValidationSchema";
10 | import Button from "../components/Button";
11 | import Input from "../components/form/Input";
12 |
13 | export default function Login() {
14 | const navigate = useNavigate();
15 | const dispatch = useDispatch();
16 |
17 | const [isLoading, setIsLoading] = useState(false);
18 | const [responseData, setResponseData] = useState();
19 | const [errorData, setErrorData] = useState();
20 |
21 | const [helperText, setHelperText] = useState("");
22 |
23 | const { token, user } = useSelector((state) => state.auth);
24 |
25 | useEffect(() => {
26 | if (errorData && !user) {
27 | setHelperText(errorData.message);
28 | }
29 |
30 | if (responseData && !user) {
31 | localStorage.setItem("token", responseData.token);
32 | dispatch(login(responseData));
33 | }
34 |
35 | if (token && user) {
36 | navigate("/");
37 | }
38 | }, [user, token, errorData, responseData, navigate, dispatch]);
39 |
40 | const handleSubmit = (userData) => {
41 | setIsLoading(true);
42 | axios
43 | .post(LOGIN_URL, userData)
44 | .then(({ data }) => setResponseData(data))
45 | .catch((error) => setErrorData(error.response.data.error))
46 | .finally(() => setIsLoading(false));
47 | };
48 |
49 | return (
50 |
51 |
100 |
101 | );
102 | }
103 |
--------------------------------------------------------------------------------
/frontend2/src/pages/Page404.jsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 |
3 | import Button from "../components/Button";
4 |
5 | export default function Page404() {
6 | return (
7 |
8 |
9 |
10 |
11 |
404
12 |
13 | Page not found
14 |
15 |
16 | Sorry, we couldn't find the page you're looking for.
17 |
18 |
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/frontend2/src/pages/Profile.jsx:
--------------------------------------------------------------------------------
1 | import { useSelector } from "react-redux";
2 | import { Link } from "react-router-dom";
3 |
4 | import Button from "../components/Button";
5 |
6 | function DataView({ title = "", data = "" }) {
7 | return (
8 |
9 |
{title}
10 |
11 | {data || No Data }
12 |
13 |
14 | );
15 | }
16 |
17 | export default function Profile() {
18 | const { user } = useSelector((state) => state.auth);
19 |
20 | return (
21 | <>
22 | Profile
23 |
24 |
25 |
26 |
27 | Personal Detail
28 |
29 |
30 | This information will be displayed publicly
31 |
32 |
33 |
34 |
35 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
Profession
48 |
49 | These details help your colleagues look you up
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | Edit
63 |
64 |
65 |
66 | >
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/frontend2/src/pages/Register.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 | import { Link, useNavigate } from "react-router-dom";
3 | import { useSelector, useDispatch } from "react-redux";
4 | import { Formik, Form } from "formik";
5 | import axios from "axios";
6 |
7 | import { register } from "../features/authSlice";
8 | import { REGISTER_URL } from "../constants/api";
9 | import { RegisterSchema } from "../util/userValidationSchema";
10 | import Button from "../components/Button";
11 | import Input from "../components/form/Input";
12 |
13 | export default function Register() {
14 | const navigate = useNavigate();
15 | const dispatch = useDispatch();
16 |
17 | const [isLoading, setIsLoading] = useState(false);
18 | const [responseData, setResponseData] = useState();
19 | const [errorData, setErrorData] = useState();
20 |
21 | const [helperText, setHelperText] = useState("");
22 |
23 | const { token, user } = useSelector((state) => state.auth);
24 |
25 | useEffect(() => {
26 | if (errorData && !user) {
27 | setHelperText(errorData.message);
28 | }
29 |
30 | if (responseData && !user) {
31 | localStorage.setItem("token", responseData.token);
32 | dispatch(register(responseData));
33 | }
34 |
35 | if (token && user) {
36 | navigate("/");
37 | }
38 | }, [user, token, errorData, responseData, navigate, dispatch]);
39 |
40 | const handleSubmit = (userData) => {
41 | const { confirmPassword, ...actualUserData } = userData;
42 |
43 | setIsLoading(true);
44 | axios
45 | .post(REGISTER_URL, actualUserData)
46 | .then(({ data }) => setResponseData(data))
47 | .catch((error) => setErrorData(error.response.data.error))
48 | .finally(() => setIsLoading(false));
49 | };
50 |
51 | return (
52 |
53 |
128 |
129 | );
130 | }
131 |
--------------------------------------------------------------------------------
/frontend2/src/pages/SelectLedger.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 | import { useSelector } from "react-redux";
3 | import { Formik, Form } from "formik";
4 |
5 | import { useAllLedgerDataHook } from "../hooks/useLedgerDataHook";
6 | import Button from "../components/Button";
7 | import { useNavigate } from "react-router-dom";
8 | import FilterSelectInput from "../components/form/FilterSelectInput";
9 |
10 | export default function SelectLedger() {
11 | const { token } = useSelector((state) => state.auth);
12 |
13 | const { isLoading, error, isSuccess, isError, data } =
14 | useAllLedgerDataHook(token);
15 |
16 | const [ledgers, setLedgers] = useState([]);
17 |
18 | useEffect(() => {
19 | if (isSuccess) setLedgers(data?.data?.ledgers);
20 | }, [data, isSuccess]);
21 |
22 | const navigate = useNavigate();
23 |
24 | return (
25 | <>
26 | Select Ledger
27 |
28 |
29 | {/* Loading view */}
30 | {isLoading &&
Loading... }
31 | {/* Error view */}
32 | {isError && (
33 |
34 |
😢
35 |
36 | There was an error.
37 |
38 |
39 | {isError && error?.response?.data?.error?.message}
40 |
41 |
42 | )}
43 |
44 | {/* Ledger selection */}
45 | {data && !isLoading && (
46 |
{
52 | navigate(`/ledger/${values.ledger}`);
53 | }}
54 | >
55 |
56 |
57 |
63 | {ledgers?.map((ledger) => (
64 |
65 | {ledger.name} A/c
66 |
67 | ))}
68 |
69 |
70 |
71 |
77 | View
78 |
79 |
80 |
81 |
82 |
83 | )}
84 |
85 | >
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/frontend2/src/pages/TrialBalance.jsx:
--------------------------------------------------------------------------------
1 | import { useSelector } from "react-redux";
2 | import { LedgerTable, LedgerTableRow } from "../components/LedgerTable";
3 | import useTrialBalanceData from "../hooks/useTrialBalanceData";
4 | import {
5 | ASSET,
6 | EQUITY,
7 | EXPENDITURE,
8 | INCOME,
9 | LIABILITY,
10 | } from "../constants/ledgerTypes";
11 |
12 | export default function TrialBalance() {
13 | const { token } = useSelector((state) => state.auth);
14 |
15 | const { isLoading, isSuccess, data, isError, error } =
16 | useTrialBalanceData(token);
17 |
18 | return (
19 | <>
20 | Trial Balance
21 |
22 |
23 | {isLoading &&
Loading... }
24 |
25 |
26 |
27 | As on date:
28 | 01/02/2025
29 |
30 |
31 |
32 |
35 | d.ledger.type === ASSET || d.ledger.type === EXPENDITURE
36 | ? drBal + d.balance
37 | : drBal,
38 | 0
39 | )}
40 | creditBalance={data?.data?.reduce(
41 | (crBal, d) =>
42 | d.ledger.type === LIABILITY ||
43 | d.ledger.type === INCOME ||
44 | d.ledger.type === EQUITY
45 | ? crBal + d.balance
46 | : crBal,
47 | 0
48 | )}
49 | >
50 | {data?.data?.map((d) => {
51 | return (
52 |
57 | );
58 | })}
59 |
60 |
61 | >
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/frontend2/src/util/amountFormat.js:
--------------------------------------------------------------------------------
1 | import { INDIAN, INTERNATIONAL } from "../constants/amountFormat";
2 |
3 | export function amountFormatLong(amount, format, currencySymbol) {
4 | const isNegative = amount < 0;
5 | const a = Math.abs(amount).toString();
6 |
7 | let result = "";
8 |
9 | // comma format
10 | if (format === INDIAN) {
11 | let lastThree = a.substring(a.length - 3);
12 | let otherNumbers = a.substring(0, a.length - 3);
13 | if (otherNumbers !== "") lastThree = "," + lastThree;
14 | result = otherNumbers.replace(/\B(?=(\d{2})+(?!\d))/g, ",") + lastThree;
15 | } else if (format === INTERNATIONAL) {
16 | result = a.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
17 | }
18 |
19 | // currency symbol
20 | result = `${currencySymbol}${result}`;
21 |
22 | // parathesis for negative amount
23 | if (isNegative) result = `(${result})`;
24 |
25 | return result;
26 | }
27 |
28 | export function amountFormatShort(amount, format, currencySymbol) {
29 | const isNegative = amount < 0;
30 | const a = Math.abs(amount).toString();
31 |
32 | let result = "";
33 |
34 | if (format === INDIAN) {
35 | if (a >= 1_00_00_000) {
36 | result = (a / 1_00_00_000).toFixed(1) + "Cr";
37 | } else if (a >= 1_00_000) {
38 | result = (a / 1_00_000).toFixed(1) + "L";
39 | } else if (a >= 1_000) {
40 | result = (a / 1_000).toFixed(1) + "K";
41 | } else {
42 | result = a;
43 | }
44 | } else if (format === INTERNATIONAL) {
45 | if (a >= 1_000_000_000) {
46 | result = (a / 1_000_000_000).toFixed(1) + "B";
47 | } else if (a >= 1_000_000) {
48 | result = (a / 1_000_000).toFixed(1) + "M";
49 | } else if (a >= 1_000) {
50 | result = (a / 1_000).toFixed(1) + "K";
51 | } else {
52 | result = a;
53 | }
54 | }
55 |
56 | result = `${currencySymbol}${result}`;
57 |
58 | // parathesis for negative amount
59 | if (isNegative) result = `(${result})`;
60 |
61 | return result;
62 | }
63 |
--------------------------------------------------------------------------------
/frontend2/src/util/authConfig.js:
--------------------------------------------------------------------------------
1 | export default function authConfig(token) {
2 | return {
3 | headers: {
4 | Authorization: `Bearer ${token}`,
5 | },
6 | };
7 | }
8 |
--------------------------------------------------------------------------------
/frontend2/src/util/balanceIsNegative.js:
--------------------------------------------------------------------------------
1 | import {
2 | ASSET,
3 | EQUITY,
4 | EXPENDITURE,
5 | INCOME,
6 | LIABILITY,
7 | } from "../constants/ledgerTypes";
8 |
9 | /**
10 | * The ledger balance in the API response can be positive or negative. The +ve
11 | * balance is the expected balance for a debit type (expenditure, and asset)
12 | * ledger, and -ve balance is the expected balance for a credit type (equity,
13 | * liability, and income) account.
14 | *
15 | * @return
16 | * +ve balance in EXPENDITURE, and ASSET = false
17 | * -ve balance in EQUITY, LIABILITY, and INCOME = false
18 | * else = true
19 | */
20 |
21 | export default function balanceIsNegative(type, balance) {
22 | return (
23 | ((type === EXPENDITURE || type === ASSET) && balance < 0) ||
24 | ((type === INCOME || type === LIABILITY || type === EQUITY) && 0 < balance)
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/frontend2/src/util/entryValidationSchema.js:
--------------------------------------------------------------------------------
1 | import * as Yup from "yup";
2 | import { ENTRY_NARRATION_MAX_LENGTH } from "../constants/policies";
3 |
4 | export const EntryCreateSchema = Yup.object().shape({
5 | debit_ledger_id: Yup.string().label("Debit Ledger Id").required(),
6 | credit_ledger_id: Yup.string().label("Credit Ledger Id").required(),
7 | amount: Yup.number().label("Amount").integer().positive().min(1).required(),
8 | narration: Yup.string()
9 | .label("Narration")
10 | .min(1)
11 | .max(ENTRY_NARRATION_MAX_LENGTH)
12 | .required(),
13 | });
14 |
15 | export const EntryEditSchema = Yup.object().shape({
16 | narration: Yup.string()
17 | .label("Narration")
18 | .min(1)
19 | .max(ENTRY_NARRATION_MAX_LENGTH)
20 | .required(),
21 | });
22 |
--------------------------------------------------------------------------------
/frontend2/src/util/ledgerValidationSchema.js:
--------------------------------------------------------------------------------
1 | import * as Yup from "yup";
2 | import {
3 | LEDGER_NAME_MAX_LENGTH,
4 | LEDGER_DESCRIPTION_MAX_LENGTH,
5 | } from "../constants/policies";
6 | import {
7 | INCOME,
8 | EXPENDITURE,
9 | ASSET,
10 | LIABILITY,
11 | EQUITY,
12 | } from "../constants/ledgerTypes";
13 |
14 | export default Yup.object().shape({
15 | name: Yup.string()
16 | .label("Name")
17 | .min(1)
18 | .max(LEDGER_NAME_MAX_LENGTH)
19 | .required(),
20 | type: Yup.string()
21 | .label("Type")
22 | .oneOf([INCOME, EXPENDITURE, ASSET, LIABILITY, EQUITY])
23 | .required(),
24 | description: Yup.string()
25 | .label("Description")
26 | .min(1)
27 | .max(LEDGER_DESCRIPTION_MAX_LENGTH)
28 | .required(),
29 | });
30 |
--------------------------------------------------------------------------------
/frontend2/src/util/preferenceValidationSchema.js:
--------------------------------------------------------------------------------
1 | import * as Yup from "yup";
2 | import {
3 | RUPEE,
4 | DOLLAR,
5 | EURO,
6 | NAIRA,
7 | NEW_SHEKEL,
8 | POUND,
9 | RUBLE,
10 | TAKA,
11 | WON,
12 | YEN,
13 | } from "../constants/currency";
14 | import { INDIAN, INTERNATIONAL } from "../constants/amountFormat";
15 |
16 | export default Yup.object().shape({
17 | amountFormat: Yup.string()
18 | .label("Amount Format")
19 | .oneOf([INDIAN, INTERNATIONAL])
20 | .required(),
21 | currency: Yup.string()
22 | .label("Currency")
23 | .oneOf([
24 | RUPEE,
25 | DOLLAR,
26 | EURO,
27 | NAIRA,
28 | NEW_SHEKEL,
29 | POUND,
30 | RUBLE,
31 | TAKA,
32 | WON,
33 | YEN,
34 | ])
35 | .required(),
36 | });
37 |
--------------------------------------------------------------------------------
/frontend2/src/util/timeFormat.js:
--------------------------------------------------------------------------------
1 | export default function timeFormat(date) {
2 | const dateObj = new Date(date);
3 | const dateString = [
4 | dateObj.getDate().toString().padStart(2, "0"),
5 | (dateObj.getMonth() + 1).toString().padStart(2, "0"),
6 | dateObj.getFullYear(),
7 | ].join("/");
8 |
9 | const timeString = [
10 | dateObj.getHours().toString().padStart(2, "0"),
11 | dateObj.getMinutes().toString().padStart(2, "0"),
12 | ].join(":");
13 |
14 | return `${dateString} - ${timeString}`;
15 | }
16 |
--------------------------------------------------------------------------------
/frontend2/src/util/userValidationSchema.js:
--------------------------------------------------------------------------------
1 | import * as Yup from "yup";
2 | import {
3 | USER_FIRST_NAME_MAX_LENGTH,
4 | USER_LAST_NAME_MAX_LENGTH,
5 | USER_MIDDLE_NAME_MAX_LENGTH,
6 | USER_EMAIL_MAX_LENGTH,
7 | USER_PASSWORD_MAX_LENGTH,
8 | USER_PASSWORD_MIN_LENGTH,
9 | USER_BIO_MAX_LENGTH,
10 | USER_ORGANIZATION_MAX_LENGTH,
11 | USER_JOB_TITLE_MAX_LENGTH,
12 | USER_LOCATION_MAX_LENGTH,
13 | } from "../constants/policies";
14 |
15 | export const RegisterSchema = Yup.object().shape({
16 | firstName: Yup.string()
17 | .label("First Name")
18 | .min(1)
19 | .max(USER_FIRST_NAME_MAX_LENGTH)
20 | .required(),
21 | lastName: Yup.string()
22 | .label("Last Name")
23 | .min(1)
24 | .max(USER_LAST_NAME_MAX_LENGTH)
25 | .required(),
26 | email: Yup.string()
27 | .label("Email")
28 | .min(1)
29 | .max(USER_EMAIL_MAX_LENGTH)
30 | .email()
31 | .required(),
32 | password: Yup.string()
33 | .label("Password")
34 | .min(USER_PASSWORD_MIN_LENGTH)
35 | .max(USER_PASSWORD_MAX_LENGTH)
36 | .required(),
37 | confirmPassword: Yup.string()
38 | .label("Confirmation Password")
39 | .min(USER_PASSWORD_MIN_LENGTH)
40 | .max(USER_PASSWORD_MAX_LENGTH)
41 | .required()
42 | .test(
43 | "confirmation-password-matching",
44 | "Confirmation Password not matching",
45 | (confirmationPassword, { parent: { password } }) =>
46 | password === confirmationPassword
47 | ),
48 | });
49 |
50 | export const LoginSchema = Yup.object().shape({
51 | email: Yup.string()
52 | .label("Email")
53 | .min(1)
54 | .max(USER_EMAIL_MAX_LENGTH)
55 | .email()
56 | .required(),
57 | password: Yup.string()
58 | .label("Password")
59 | .min(USER_PASSWORD_MIN_LENGTH)
60 | .max(USER_PASSWORD_MAX_LENGTH)
61 | .required(),
62 | });
63 |
64 | export const ProfileSchema = Yup.object().shape({
65 | firstName: Yup.string()
66 | .label("First Name")
67 | .min(1)
68 | .max(USER_FIRST_NAME_MAX_LENGTH)
69 | .required("First Name is required"),
70 |
71 | middleName: Yup.string()
72 | .label("Middle Name")
73 | .max(USER_MIDDLE_NAME_MAX_LENGTH)
74 | .optional()
75 | .default(""),
76 |
77 | lastName: Yup.string()
78 | .label("Last Name")
79 | .min(1)
80 | .max(USER_LAST_NAME_MAX_LENGTH)
81 | .required("Last Name is required"),
82 |
83 | email: Yup.string()
84 | .label("Email")
85 | .min(1)
86 | .max(USER_EMAIL_MAX_LENGTH)
87 | .email("Enter a valid email")
88 | .required("Email is required"),
89 |
90 | bio: Yup.string()
91 | .label("Bio")
92 | .max(USER_BIO_MAX_LENGTH)
93 | .optional()
94 | .default(""),
95 |
96 | organization: Yup.string()
97 | .label("Organization")
98 | .max(USER_ORGANIZATION_MAX_LENGTH)
99 | .optional()
100 | .default(""),
101 |
102 | jobTitle: Yup.string()
103 | .label("Job Title")
104 | .max(USER_JOB_TITLE_MAX_LENGTH)
105 | .optional()
106 | .default(""),
107 |
108 | location: Yup.string()
109 | .label("Location")
110 | .max(USER_LOCATION_MAX_LENGTH)
111 | .optional()
112 | .default(""),
113 | });
114 |
115 | export const ChangePasswordSchema = Yup.object().shape({
116 | oldPassword: Yup.string()
117 | .label("Old Password")
118 | .min(USER_PASSWORD_MIN_LENGTH)
119 | .max(USER_PASSWORD_MAX_LENGTH)
120 | .required(),
121 | newPassword: Yup.string()
122 | .label("New Password")
123 | .min(USER_PASSWORD_MIN_LENGTH)
124 | .max(USER_PASSWORD_MAX_LENGTH)
125 | .required(),
126 | confirmNewPassword: Yup.string()
127 | .label("Confirmation New Password")
128 | .min(USER_PASSWORD_MIN_LENGTH)
129 | .max(USER_PASSWORD_MAX_LENGTH)
130 | .required()
131 | .test(
132 | "confirmation-new-password-matching",
133 | "Confirmation New Password not matching",
134 | (confirmNewPassword, { parent: { newPassword } }) =>
135 | newPassword === confirmNewPassword
136 | ),
137 | });
138 |
--------------------------------------------------------------------------------
/frontend2/tailwind.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | content: ["./src/**/*.{js,ts,jsx,tsx}"],
3 | plugins: [],
4 | themes: ["light", "dark"],
5 | };
6 |
--------------------------------------------------------------------------------
/frontend2/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import tailwindcss from "@tailwindcss/vite";
4 |
5 | // https://vite.dev/config/
6 | export default defineConfig({
7 | server: { port: 3002 },
8 | plugins: [react(), tailwindcss()],
9 | });
10 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "accounting",
3 | "version": "1.0.0",
4 | "description": "A web based simple double entry system accounting application",
5 | "main": "backend/server.js",
6 | "scripts": {
7 | "start": "node backend/server.js",
8 | "server": "nodemon backend/server.js",
9 | "client": "npm run dev --prefix frontend",
10 | "dev": "concurrently \"npm run server\" \"npm run client\"",
11 | "test": "echo \"Error: no test specified\" && exit 1",
12 | "pretty": "prettier --write .",
13 | "heroku-postbuild": "NPM_CONFIG_PRODUCTION=false npm install --prefix frontend && npm run build --prefix frontend"
14 | },
15 | "author": "Captainayan",
16 | "license": "MIT",
17 | "dependencies": {
18 | "bcryptjs": "^2.4.3",
19 | "cors": "^2.8.5",
20 | "dotenv": "^16.4.7",
21 | "eslist": "^1.0.0-beta.1",
22 | "express": "^4.21.2",
23 | "express-async-handler": "^1.2.0",
24 | "express-rate-limit": "^6.11.2",
25 | "helmet": "^5.1.1",
26 | "http-status-codes": "^2.3.0",
27 | "joi": "^17.13.3",
28 | "joi-objectid": "^4.0.2",
29 | "json2csv": "^5.0.7",
30 | "jsonwebtoken": "^8.5.1",
31 | "mongoose": "^6.13.8",
32 | "morgan": "^1.10.0"
33 | },
34 | "devDependencies": {
35 | "axios": "^0.27.2",
36 | "chai": "^4.5.0",
37 | "concurrently": "^7.6.0",
38 | "eslint": "^8.21.0",
39 | "eslint-config-airbnb": "^19.0.4",
40 | "eslint-config-prettier": "^8.10.0",
41 | "eslint-plugin-import": "^2.31.0",
42 | "eslint-plugin-jsx-a11y": "^6.10.2",
43 | "eslint-plugin-react": "^7.37.4",
44 | "eslint-plugin-react-hooks": "^4.6.2",
45 | "newman": "^5.3.2",
46 | "nodemon": "^2.0.22",
47 | "prettier": "^2.8.8"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/test/randomEntryGenerator.js:
--------------------------------------------------------------------------------
1 | /**
2 | * This scripts lets you generate 10 entries using random ledgers.
3 | *
4 | * It fetches the list of all the ledgers and then creates 10 entries by
5 | * selecting ledgers randomly from that list.
6 | */
7 |
8 | (async function () {
9 | const axios = require("axios");
10 |
11 | const testAuthToken =
12 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjYyZmFiNmJlNmNmODY4ZjNlN2MzMmVjZSIsImlhdCI6MTY2MDczMzM2MCwiZXhwIjoxNjYzMzI1MzYwfQ.hracnf9HIMkS-ryDWet6nstQvveymrsoSY5hkMR7qOQ";
13 |
14 | const { ledgers } = (
15 | await axios.get("http://localhost:3001/api/v1/ledger/all", {
16 | headers: {
17 | Authorization: "Bearer " + testAuthToken,
18 | },
19 | })
20 | ).data;
21 |
22 | for (let i = 0; i < 10; i++) {
23 | try {
24 | const debitLedger = ledgers[Math.floor(Math.random() * ledgers.length)];
25 | const creditLedger = ledgers[Math.floor(Math.random() * ledgers.length)];
26 |
27 | const debit_ledger_id = debitLedger._id;
28 | const credit_ledger_id = creditLedger._id;
29 | const amount = Math.floor(Math.random() * 100) * 10;
30 | const narration = `This is test generated entry No.${i}`;
31 |
32 | if (debit_ledger_id === credit_ledger_id) {
33 | i -= 1;
34 | continue;
35 | }
36 |
37 | const entry = (
38 | await axios.post(
39 | "http://localhost:3001/api/v1/entry",
40 | {
41 | debit_ledger_id,
42 | credit_ledger_id,
43 | amount,
44 | narration,
45 | },
46 | {
47 | headers: {
48 | Authorization: "Bearer " + testAuthToken,
49 | "Content-Type": "application/json",
50 | },
51 | }
52 | )
53 | ).data;
54 |
55 | console.log(`
56 | ${debitLedger.name} A/c ....................Dr Rs.${amount}
57 | To.${creditLedger.name} A/c Rs.${amount}
58 | (${narration})
59 | `);
60 | } catch (e) {
61 | console.log(e.message);
62 | }
63 | }
64 | })();
65 |
--------------------------------------------------------------------------------