├── .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 {"Avatar"}; 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 |
51 |
52 | 55 | 61 | 62 | 63 | 64 |
65 |
66 | 69 | 75 | 76 | 77 | 78 |
79 |
80 | 83 | 89 | 90 | 91 | 92 |
93 | 94 |

{helperText}

95 | 96 |
97 | 105 |
106 |
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 | 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 | 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 | 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 | 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 |
    53 |
    54 | 57 | 62 | 63 | 64 | 65 | 66 | 67 | 68 |
    69 | 70 |
    71 | 74 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 |
    94 |
    95 | 98 |
    99 |
    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 |
    77 |
    78 | 83 | 90 | 102 | 103 | 104 | 105 |
    106 | 107 |

    108 | {isError && error?.response?.data?.error?.message} 109 |

    110 | 111 |
    112 | 120 |
    121 |
    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 | 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 | 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 | 37 | 38 | 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 | 64 | 65 | 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 |
    65 |
    66 | 69 | 76 | 77 | 78 | 79 |
    80 |
    81 | 84 | 90 | 91 | 92 | 93 |
    94 | 95 |

    {helperText}

    96 | 97 |
    98 | 104 |
    105 |
    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 | 62 |
    63 |
    64 | 65 |

    66 | Page {page} of{" "} 67 | {Math.ceil(data?.data?.total / data?.data?.limit) || 0} 68 |

    69 | 70 |
    71 | 74 | 75 | 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 | 101 | 102 | 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 | 37 | 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 | 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 {"Avatar"}; 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 | 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 | 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 |
    47 |

    48 | 49 |

    50 |
    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 | 17 | 23 | 29 | 35 | 41 | 42 | 48 | 49 | 50 | {children} 51 |
    15 | ID 16 | 21 | Time 22 | 27 | Debit 28 | 33 | Credit 34 | 39 | Amount 40 | 46 | Narration 47 |
    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 |