diff --git a/financial-tracker/api-gateway/app/app.py b/financial-tracker/api-gateway/app/app.py index a98550772383027c587b9eb45c45d37e75212103..67d640d46436493486b5925f70776080adfc7d61 100644 --- a/financial-tracker/api-gateway/app/app.py +++ b/financial-tracker/api-gateway/app/app.py @@ -14,6 +14,7 @@ CORS(app, resources={r"/*": {"origins": "http://localhost:3000"}}) # Configuration USER_SERVICE_URL = os.getenv('USER_SERVICE_URL', 'http://user-service:5001') TRANSACTION_SERVICE_URL = os.getenv("TRANSACTION_SERVICE_URL", "http://transaction-service:5002") +BUDGET_SERVICE_URL = os.getenv('BUDGET_SERVICE_URL', 'http://budget-service:5003') app.config["JWT_SECRET_KEY"] = os.getenv( "JWT_SECRET_KEY", "3b5e41af18179f530c5881a5191e15f0ab35eed2fefdc068fda254eed3fb1ecb" ) @@ -187,6 +188,21 @@ def delete_expense(expense_id): except requests.exceptions.RequestException as e: app.logger.error(f"Error contacting transaction-service: {e}") return jsonify({"error": "Transaction service unavailable"}), 503 + + +@app.route('/expenses/monthly', methods=['GET']) +@jwt_required() +def get_monthly_expenses(): + """Forward request to the monthly expenses endpoint.""" + token = request.headers.get("Authorization") + if not token: + return jsonify({"error": "Missing authorization token"}), 401 + + # Forward the request to the actual expense service + response = requests.get(f"{TRANSACTION_SERVICE_URL}/expenses/monthly", headers={"Authorization": token}) + + # Return the response back to the client + return jsonify(response.json()), response.status_code @app.route('/categories', methods=['GET']) @@ -214,5 +230,104 @@ def fetch_categories(): return jsonify({"error": "Transaction service unavailable"}), 503 +@app.route('/budgets', methods=['POST']) +@jwt_required() +def add_budget(): + """Forward request to budget-service to add a budget.""" + token = request.headers.get("Authorization") # Get token from headers + + if not token: + app.logger.error("Authorization token is missing.") + return jsonify({"error": "Authorization token is missing"}), 400 + + try: + headers = {"Authorization": token, "Content-Type": "application/json"} + data = request.get_json() + app.logger.info(f"Forwarding budget addition request: {data}") + + response = requests.post(f"{BUDGET_SERVICE_URL}/budgets", headers=headers, json=data) + return jsonify(response.json()), response.status_code + + except requests.exceptions.RequestException as e: + app.logger.error(f"Error contacting budget-service: {e}") + return jsonify({"error": "Budget service unavailable"}), 503 + + +@app.route('/budgets/<int:budget_id>', methods=['PUT']) +@jwt_required() +def edit_budget(budget_id): + """Forward request to budget-service to edit a budget.""" + token = request.headers.get("Authorization") # Get token from headers + + if not token: + app.logger.error("Authorization token is missing.") + return jsonify({"error": "Authorization token is missing."}), 400 + + try: + headers = {"Authorization": token, "Content-Type": "application/json"} + data = request.get_json() + + app.logger.info(f"Forwarding budget update request for ID {budget_id}: {data}") + + response = requests.put(f"{BUDGET_SERVICE_URL}/budgets/{budget_id}", headers=headers, json=data) + return jsonify(response.json()), response.status_code + + except requests.exceptions.RequestException as e: + app.logger.error(f"Error contacting budget-service: {e}") + return jsonify({"error": "Budget service unavailable."}), 503 + + +@app.route('/budgets/<int:budget_id>', methods=['DELETE']) +@jwt_required() +def delete_budget(budget_id): + """Forward request to budget-service to delete a budget.""" + token = request.headers.get("Authorization") # Get token from headers + + if not token: + app.logger.error("Authorization token is missing.") + return jsonify({"error": "Authorization token is missing."}), 400 + + try: + headers = {"Authorization": token} # Forward token + response = requests.delete(f"{BUDGET_SERVICE_URL}/budgets/{budget_id}", headers=headers) + + if response.status_code == 200: + app.logger.info(f"Budget with ID {budget_id} deleted successfully.") + return jsonify({"message": "Budget deleted successfully."}), 200 + else: + app.logger.error(f"Failed to delete budget with ID {budget_id}.") + return jsonify({"error": "Failed to delete budget."}), response.status_code + + except requests.exceptions.RequestException as e: + app.logger.error(f"Error contacting budget-service: {e}") + return jsonify({"error": "Budget service unavailable."}), 503 + + +@app.route('/budgets', methods=['GET']) +@jwt_required() +def fetch_budgets(): + """Forward request to budget-service to fetch budgets for the logged-in user.""" + token = request.headers.get("Authorization") # Get token from headers + + if not token: + app.logger.error("Authorization token is missing.") + return jsonify({"error": "Authorization token is missing."}), 400 + + try: + headers = {"Authorization": token} # Forward token + response = requests.get(f"{BUDGET_SERVICE_URL}/budgets", headers=headers) + + if response.status_code == 200: + app.logger.info("Successfully fetched budgets.") + return jsonify(response.json()), response.status_code + else: + app.logger.error(f"Failed to fetch budgets: {response.status_code}") + return jsonify({"error": "Failed to fetch budgets"}), response.status_code + + except requests.exceptions.RequestException as e: + app.logger.error(f"Error contacting budget-service: {e}") + return jsonify({"error": "Budget service unavailable"}), 503 + + if __name__ == '__main__': app.run(host='0.0.0.0', port=8000, debug=True) diff --git a/financial-tracker/budget-service/app/app.py b/financial-tracker/budget-service/app/app.py index 94dd4d9890fdafbaecea6a295c0ab7cf5a9a6f8d..7b6122766d3fb2e896eceb2f8b4e28368a8b88c0 100644 --- a/financial-tracker/budget-service/app/app.py +++ b/financial-tracker/budget-service/app/app.py @@ -1,10 +1,221 @@ +from datetime import datetime, timedelta from flask import Flask, jsonify, request +from flask_cors import CORS +from flask_jwt_extended import ( + jwt_required, get_jwt_identity, JWTManager +) +from flask_sqlalchemy import SQLAlchemy +import logging +import os +# Initialise Flask app app = Flask(__name__) +app.debug = True +CORS(app) +# Configure logging +logging.basicConfig(level=logging.INFO) +app.logger.setLevel(logging.INFO) + +# Configuration +app.config["SQLALCHEMY_DATABASE_URI"] = os.getenv( + "DATABASE_URL", "postgresql://user:password@budget-db:5432/budget_db" +) +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config["JWT_SECRET_KEY"] = os.getenv( + "JWT_SECRET_KEY", "3b5e41af18179f530c5881a5191e15f0ab35eed2fefdc068fda254eed3fb1ecb" +) +app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1) +app.config["JWT_TOKEN_LOCATION"] = ["headers"] +app.config["JWT_HEADER_NAME"] = "Authorization" # Default header for JWT +app.config["JWT_HEADER_TYPE"] = "Bearer" + +# Initialise Flask-JWT-Extended & SQLAlchemy +jwt = JWTManager(app) +db = SQLAlchemy(app) + +# DATABASE MODELS +class Budget(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, nullable=False) + category = db.Column(db.String(100), nullable=False) + budget_limit = db.Column(db.Float, nullable=False) + spent = db.Column(db.Float, default=0.0) + + def to_dict(self): + return { + "id": self.id, + "user_id": self.user_id, + "category": self.category, + "budget_limit": self.budget_limit, + "spent": self.spent + } + + +# BUDGET ROUTES @app.route('/budgets', methods=['GET']) +@jwt_required() def get_budgets(): - return jsonify({"message": "Budget service active"}), 200 + """Retrieve all budgets for the logged-in user""" + try: + user_id = get_jwt_identity() # Extract user_id from JWT token + user_id = int(user_id) + app.logger.info(f"Retrieved user_id from JWT: {user_id}") # Log user_id + + if not user_id: + app.logger.error("User ID is None or invalid.") + return jsonify({"error": "Invalid user authentication"}), 401 + + budgets = Budget.query.filter_by(user_id=user_id).all() + app.logger.info(f"Found {len(budgets)} budgets for user_id {user_id}") + + budgets_list = [budget.to_dict() for budget in budgets] + return jsonify({"budgets": budgets_list}), 200 + + except Exception as e: + app.logger.error(f"Error retrieving budgets: {e}", exc_info=True) + return jsonify({"error": "An error occurred while fetching budgets"}), 500 + + +@app.route('/budgets', methods=['POST']) +@jwt_required() +def add_budget(): + """Add a new budget for the logged-in user""" + data = request.get_json() + app.logger.info(f"Received data: {data}") + + # Required fields check + required_fields = ["category", "budget_limit"] + missing_fields = [field for field in required_fields if field not in data] + + if missing_fields: + app.logger.error(f"Missing required fields: {missing_fields}") + return jsonify({"error": f"Missing required fields: {', '.join(missing_fields)}"}), 400 + + # Validate budget limit + try: + budget_limit = float(data["budget_limit"]) + if budget_limit <= 0: + app.logger.error("Budget limit must be greater than 0.") + return jsonify({"error": "Budget limit must be a positive number."}), 400 + except (ValueError, TypeError): + app.logger.error("Invalid budget limit value.") + return jsonify({"error": "Invalid budget limit value."}), 400 + + try: + user_id = get_jwt_identity() + user_id = int(user_id) + app.logger.info(f"User ID: {user_id}") + + if not user_id: + app.logger.error("User ID is missing or invalid.") + return jsonify({"error": "User authentication failed."}), 401 + + # Create a new budget object + new_budget = Budget( + user_id=user_id, + category=data["category"], + budget_limit=budget_limit + ) + + # Add budget to the database + db.session.add(new_budget) + db.session.commit() + + app.logger.info(f"Budget added: {new_budget.to_dict()}") + return jsonify({"message": "Budget added", "budget": new_budget.to_dict()}), 201 + + except Exception as e: + app.logger.error(f"Error while adding budget: {str(e)}", exc_info=True) + return jsonify({"error": "An error occurred while adding the budget."}), 500 + + +@app.route('/budgets/<int:budget_id>', methods=['PUT']) +@jwt_required() +def edit_budget(budget_id): + """Edit a budget by ID for the logged-in user""" + try: + user_id = get_jwt_identity() + user_id = int(user_id) + app.logger.info(f"User ID: {user_id}") + + if not user_id: + app.logger.error("User ID is missing or invalid.") + return jsonify({"error": "User authentication failed"}), 401 + + budget = Budget.query.filter_by(id=budget_id, user_id=user_id).first() + + if not budget: + app.logger.error(f"Budget not found for ID {budget_id} and User ID {user_id}") + return jsonify({"error": "Budget not found"}), 404 + + data = request.json + app.logger.info(f"Received data: {data}") + + # Update fields + budget.category = data.get("category", budget.category) # update category + try: + new_budget_limit = float(data["budget_limit"]) # Ensure budget limit is a float + if new_budget_limit <= 0: + app.logger.error("Budget limit must be greater than 0.") + return jsonify({"error": "Budget limit must be a positive number."}), 400 + budget.budget_limit = new_budget_limit # Update budget limit + except (ValueError, TypeError): + app.logger.error("Invalid budget limit value.") + return jsonify({"error": "Invalid budget limit value."}), 400 + + db.session.commit() + + app.logger.info(f"Budget updated: {budget.to_dict()}") + return jsonify({"message": "Budget updated successfully", "budget": budget.to_dict()}), 200 + + except Exception as e: + app.logger.error(f"Error updating budget: {str(e)}", exc_info=True) + return jsonify({"error": "An error occurred while updating the budget"}), 500 + + +@app.route('/budgets/<int:budget_id>', methods=['DELETE']) +@jwt_required() +def delete_budget(budget_id): + """Delete a budget by ID for the logged-in user""" + try: + user_id = get_jwt_identity() + user_id = int(user_id) + app.logger.info(f"User ID: {user_id}") + + if not user_id: + app.logger.error("User ID is missing or invalid.") + return jsonify({"error": "User authentication failed"}), 401 + + budget = Budget.query.filter_by(id=budget_id, user_id=user_id).first() + + if not budget: + app.logger.error(f"Budget not found for ID {budget_id} and User ID {user_id}") + return jsonify({"error": "Budget not found"}), 404 + + db.session.delete(budget) + db.session.commit() + + app.logger.info(f"Budget deleted: {budget.to_dict()}") + return jsonify({"message": "Budget deleted successfully"}), 200 + + except Exception as e: + app.logger.error(f"Error deleting budget: {str(e)}", exc_info=True) + return jsonify({"error": "An error occurred while deleting the budget"}), 500 + + +# HEALTH CHECK ROUTE +@app.route('/health') +def health(): + """Health check endpoint to verify service is running.""" + app.logger.info("Health check requested") + return jsonify({"status": "Budget service is running"}), 200 + +# INITIALIZATION & RUNNING APP if __name__ == '__main__': + # Create all tables in the database (if they don't exist) + with app.app_context(): + db.create_all() # Creates the tables for all models + app.logger.info("Starting Budget Service...") app.run(host='0.0.0.0', port=5003) diff --git a/financial-tracker/docker-compose.yml b/financial-tracker/docker-compose.yml index 130d285ac21641f15e5d5bb625cbec6b3a850247..04833088ae3ec4398cbd74ee74285a133f37efb6 100644 --- a/financial-tracker/docker-compose.yml +++ b/financial-tracker/docker-compose.yml @@ -65,6 +65,10 @@ services: - budget-db networks: - app-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5003/health"] + interval: 30s + retries: 3 analytics-service: build: diff --git a/financial-tracker/frontend/package-lock.json b/financial-tracker/frontend/package-lock.json index d109621253c524b888e4c90e15211c45de14c131..dee52157b450d06e5b5d8f23863adb289323aa3d 100644 --- a/financial-tracker/frontend/package-lock.json +++ b/financial-tracker/frontend/package-lock.json @@ -22,6 +22,7 @@ "react-paginate": "^8.3.0", "react-router-dom": "^7.4.0", "react-scripts": "5.0.1", + "recharts": "^2.15.2", "web-vitals": "^2.1.4", "yup": "^1.6.1" } @@ -3671,6 +3672,69 @@ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "license": "MIT" }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "8.56.12", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz", @@ -5710,6 +5774,15 @@ "wrap-ansi": "^7.0.0" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -6446,6 +6519,127 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -6540,6 +6734,12 @@ "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -6772,6 +6972,16 @@ "utila": "~0.4" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dom-serializer": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", @@ -8003,6 +8213,15 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", + "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -9399,6 +9618,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ipaddr.js": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", @@ -14232,6 +14460,37 @@ } } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -14267,6 +14526,44 @@ "node": ">=8.10.0" } }, + "node_modules/recharts": { + "version": "2.15.2", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.2.tgz", + "integrity": "sha512-xv9lVztv3ingk7V3Jf05wfAZbM9Q2umJzu5t/cfnAK7LUslNrGT7LPBr74G+ok8kSCeFMaePmWMg0rcYOnczTw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/recursive-readdir": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz", @@ -16329,6 +16626,12 @@ "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", "license": "MIT" }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tiny-warning": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", @@ -16864,6 +17167,28 @@ "node": ">= 0.8" } }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", diff --git a/financial-tracker/frontend/package.json b/financial-tracker/frontend/package.json index bf17e4b0261820c73a6d4e022a3e06dffcd2bcdc..d81507da563b72f58c878bd7636a9b6be837ace0 100644 --- a/financial-tracker/frontend/package.json +++ b/financial-tracker/frontend/package.json @@ -17,6 +17,7 @@ "react-paginate": "^8.3.0", "react-router-dom": "^7.4.0", "react-scripts": "5.0.1", + "recharts": "^2.15.2", "web-vitals": "^2.1.4", "yup": "^1.6.1" }, diff --git a/financial-tracker/frontend/src/App.js b/financial-tracker/frontend/src/App.js index 15b508747874982d406ddce4337af6b06f4de5cd..2cf786b0ff5012f5aa99feb4facdf6bdafcf9859 100644 --- a/financial-tracker/frontend/src/App.js +++ b/financial-tracker/frontend/src/App.js @@ -6,6 +6,7 @@ import Register from "./pages/Register"; import Login from "./pages/Login"; import Dashboard from "./pages/Dashboard"; import Expenses from "./pages/Expenses"; +import Budget from "./pages/Budget"; function App() { const [token, setToken] = useState(localStorage.getItem("token")); @@ -34,6 +35,7 @@ function App() { <> <li><Link to="/dashboard">Dashboard</Link></li> <li><Link to="/expenses">Expenses</Link></li> + <li><Link to="/budget">Budget</Link></li> <li> <Link to="/" onClick={() => { localStorage.removeItem("token"); setToken(null); }}> Logout @@ -55,6 +57,7 @@ function App() { <Route path="/login" element={<Login setToken={setToken} />} /> <Route path="/dashboard" element={<Dashboard setToken={setToken} />} /> <Route path="/expenses" element={<Expenses />} /> + <Route path="/budget" element={<Budget />} /> </Routes> </div> </Router> diff --git a/financial-tracker/frontend/src/pages/Budget.js b/financial-tracker/frontend/src/pages/Budget.js new file mode 100644 index 0000000000000000000000000000000000000000..090693c83f9d661f27a250c27bc406042418d6f3 --- /dev/null +++ b/financial-tracker/frontend/src/pages/Budget.js @@ -0,0 +1,620 @@ +import React, { useState, useEffect } from "react"; +import axios from "axios"; +import { jwtDecode } from 'jwt-decode'; +import ReactPaginate from 'react-paginate'; +import BudgetChart from "./BudgetChart"; +import "../styles/Budget.css"; + +const Budget = () => { + const [budgets, setBudgets] = useState([]); + const [isSessionValid, setIsSessionValid] = useState(true); + const [filteredBudgets, setFilteredBudgets] = useState([]); + const [sortConfig, setSortConfig] = useState({ key: null, direction: "asc" }); + const budgetsPerPage = 5; + const [currentPage, setCurrentPage] = useState(0); + const offset = currentPage * budgetsPerPage; + const currentBudgets = filteredBudgets.slice(offset, offset + budgetsPerPage); + const [newBudget, setNewBudget] = useState({ category: "", budget_limit: "" }); + const [successMessage, setSuccessMessage] = useState(""); + const [deleteMessage, setDeleteMessage] = useState(""); + const [categories, setCategories] = useState([]); + const [totalSpent, setTotalSpent] = useState(0); + const [totalBudget, setTotalBudget] = useState(0); + const [remainingBudget, setRemainingBudget] = useState(0); + const [isModalOpen, setIsModalOpen] = useState(false); + const [editBudget, setEditBudget] = useState({ + id: null, + category: "", + budget_limit: 0.0 + }); + const [showFilters, setShowFilters] = useState(false); + const [filters, setFilters] = useState({ + status: "", + }); + const [budgetStatusCounts, setBudgetStatusCounts] = useState({ good: 0, warning: 0, overBudget: 0 }); + const percentageSpent = ((totalSpent / totalBudget) * 100).toFixed(2); + + const openModal = () => { + setIsModalOpen(true); + }; + + const closeModal = () => { + setIsModalOpen(false); + }; + + // Check if token exists in localStorage to determine if session is valid + useEffect(() => { + const token = localStorage.getItem("token"); + + if (token) { + // Validate the token's expiration date or refresh it if necessary + const decoded = jwtDecode(token); + const now = new Date(); + const expirationDate = new Date(decoded.exp * 1000); + if (now > expirationDate) { + // Token has expired; refresh it or redirect to login page + alert("Your session has expired. Please log in again."); + window.location.href = "/login"; + } else { + setIsSessionValid(true); + } + } else { + setIsSessionValid(false); + } + }, []); + + useEffect(() => { + const totalBudgetAmount = budgets.reduce((acc, item) => acc + Number(item.budget_limit), 0); + const totalSpentAmount = budgets.reduce((acc, item) => acc + Number(item.spent), 0); + + setTotalBudget(totalBudgetAmount); + setTotalSpent(totalSpentAmount); + setRemainingBudget(totalBudgetAmount - totalSpentAmount); + + console.log("Total Budget:", totalBudgetAmount); + console.log("Total Spent:", totalSpentAmount); + console.log("Remaining Budget:", totalBudgetAmount - totalSpentAmount); + + if (budgets.length > 0) { + const statusCounts = calculateBudgetStatus(budgets); + setBudgetStatusCounts(statusCounts); + } + }, [budgets]); + + + const fetchBudgets = async () => { + try { + const token = localStorage.getItem("token"); + + if (!token) { + console.error("No token found, user might not be logged in."); + return; + } + + const decoded = jwtDecode(token); + console.log("Decoded JWT:", decoded); + + // Use 'sub' as user_id + const user_id = decoded.sub; + if (!user_id) { + console.error("User ID is undefined. Check JWT structure."); + return; + } + + const response = await axios.get("http://localhost:8000/budgets", { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (Array.isArray(response.data.budgets)) { + console.log("Budgets fetched:", response.data.budgets); + setBudgets(response.data.budgets); + setFilteredBudgets(response.data.budgets); + await fetchCurrentMonthExpenses(); + } else { + console.error("Expected an array but got:", response.data); + setBudgets([]); + setFilteredBudgets([]); + } + } catch (error) { + console.error("Error fetching budgets:", error); + setBudgets([]); + setFilteredBudgets([]); + } + }; + + const fetchCurrentMonthExpenses = async () => { + try { + const token = localStorage.getItem("token"); + + if (!token) { + console.error("No token found, user might not be logged in."); + return; + } + + const response = await axios.get("http://localhost:8000/expenses/monthly", { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (response.status === 200) { + console.log("Fetched monthly aggregated expenses:", response.data); + + const spentMapping = {}; + let totalSpent = 0; + + response.data.expenses.forEach((item) => { + + if (!spentMapping[item.category]) { + spentMapping[item.category] = 0; + } + spentMapping[item.category] += item.amount; + totalSpent += item.amount; + }); + + console.log("Spent mapping after accumulation:", spentMapping); + + + setBudgets((prevBudgets) => + prevBudgets.map((budget) => ({ + ...budget, + spent: spentMapping[budget.category] || 0, + })) + ); + + setFilteredBudgets((prevFiltered) => + prevFiltered.map((budget) => ({ + ...budget, + spent: spentMapping[budget.category] || 0, + })) + ); + + // Calculate total budget + const totalBudget = budgets.reduce((sum, budget) => sum + budget.amount, 0); + + // Calculate remaining budget + const remainingBudget = totalBudget - totalSpent; + + + setTotalSpent(totalSpent); + setTotalBudget(totalBudget); + setRemainingBudget(remainingBudget); + + console.log("Total Budget:", totalBudget); + console.log("Total Spent:", totalSpent); + console.log("Remaining Budget:", remainingBudget); + } + } catch (error) { + console.error("Error fetching monthly expenses:", error); + } + }; + + // Only fetch data if session is valid + useEffect(() => { + if (isSessionValid) { + fetchCategories(); + fetchBudgets(); + fetchCurrentMonthExpenses(); + } + }, [isSessionValid]); + + const addBudget = async () => { + try { + const token = localStorage.getItem("token"); + if (!token) return console.error("No token found, user might not be logged in."); + + // Check if the category already exists + const categoryExists = budgets.find(b => b.category === newBudget.category); + if (categoryExists) { + alert("A budget for this category already exists. Please select a different category."); + return; // Prevent the addition of the new budget + } else { + const response = await axios.post("http://localhost:8000/budgets", newBudget, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (response.status === 201) { + setSuccessMessage("Budget added successfully. Refreshing budget page..."); + fetchBudgets(); // Refresh the budget list + setNewBudget({ category: "", budget_limit: "" }); // Reset input fields + } + } + } catch (error) { + console.error("Error adding budget:", error); + } + }; + + const handleEdit = (budget) => { + setEditBudget({ + id: budget.id, + category: budget.category, + budget_limit: budget.budget_limit + }); + openModal(); + }; + + const handleSaveEdit = async (e, id) => { + e.preventDefault(); + + try { + const updatedBudget = { + category: editBudget.category, + budget_limit: parseFloat(editBudget.budget_limit), + }; + + const token = localStorage.getItem("token"); + + const response = await axios.put( + `http://localhost:8000/budgets/${id}`, + updatedBudget, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + } + ); + + if (response.status === 200) { + setBudgets((prevBudgets) => + prevBudgets.map((budget) => + budget.id === id ? { ...budget, ...updatedBudget } : budget + ) + ); + + setFilteredBudgets((prevFiltered) => + prevFiltered.map((budget) => + budget.id === id ? { ...budget, ...updatedBudget } : budget + ) + ); + + closeModal(); + setSuccessMessage("Budget updated successfully. Refreshing budget page..."); + // Wait 1.5 seconds before refreshing + setTimeout(() => { + window.location.reload(); + }, 1500); + } + } catch (error) { + console.error("Error updating budget:", error); + alert(`Error updating budget: ${error.response ? error.response.data.error || error.response.data.message : error.message}`); + } + }; + + const handleDelete = async (id) => { + const confirmDelete = window.confirm("Are you sure you want to delete this budget?"); + + if (confirmDelete) { + try { + const token = localStorage.getItem("token"); + const response = await axios.delete(`http://localhost:8000/budgets/${id}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (response.status === 200) { + setBudgets((prevBudgets) => prevBudgets.filter(budget => budget.id !== id)); + setDeleteMessage("Budget deleted successfully. Refreshing budget page..."); + setTimeout(() => { + window.location.reload(); + }, 1500); + } + } catch (error) { + console.error("Error deleting budget:", error); + alert("Error deleting budget. Please try again."); + } + } + }; + + const fetchCategories = async () => { + try { + const token = localStorage.getItem("token"); + + if (!token) { + console.error("No token found, user might not be logged in."); + return; + } + + const response = await axios.get("http://localhost:8000/categories", { + headers: { Authorization: `Bearer ${token}` }, + }); + + console.log("Categories Response:", response.data); + + if (Array.isArray(response.data.categories)) { + setCategories(response.data.categories); + } else { + console.error("Categories data is not in expected format", response.data); + } + } catch (error) { + console.error("Error fetching categories:", error); + } + }; + + const sortData = (key) => { + let direction = "asc"; + if (sortConfig.key === key && sortConfig.direction === "asc") { + direction = "desc"; + } + setSortConfig({ key, direction }); + + const sortedBudgets = [...budgets].sort((a, b) => { + if (a[key] < b[key]) return direction === "asc" ? -1 : 1; + if (a[key] > b[key]) return direction === "asc" ? 1 : -1; + return 0; + }); + + setBudgets(sortedBudgets); + }; + + const handleBudgetChange = (e) => { + setNewBudget({ ...newBudget, [e.target.name]: e.target.value }); + }; + + const getStatus = (budget) => { + const remaining = budget.budget_limit - budget.spent; + if (remaining < 0) return "🔴 Over budget"; + if (remaining < budget.budget_limit * 0.2) return "🟡 Warning (near limit)"; + return "🟢 Good"; + }; + + const applyFilters = () => { + console.log("Applying filters with current filters:", filters); + let filtered = budgets.filter(budget => { + const status = getStatus(budget); + return ( + (!filters.status || status === filters.status) + ); + }); + + setFilteredBudgets(filtered); + console.log("Filtered Budgets:", filtered); + }; + + const calculateBudgetStatus = (budgets) => { + if (!budgets || budgets.length === 0) { + return { + good: 0, + warning: 0, + overBudget: 0, + }; + } + + const statusCounts = { good: 0, warning: 0, overBudget: 0 }; + + budgets.forEach(budget => { + const remaining = budget.budget_limit - budget.spent; + if (remaining < 0) { + statusCounts.overBudget += 1; + } else if (remaining < budget.budget_limit * 0.2) { + statusCounts.warning += 1; + } else { + statusCounts.good += 1; + } + }); + + return statusCounts; + }; + + return ( + <div className="budget-container"> + <h1>Budget</h1> + + {/* Delete Message */} + {deleteMessage && <div className="delete-message">{deleteMessage}</div>} + + {/* Success Message */} + {successMessage && <div className="success-message">{successMessage}</div>} + + {/* Add/Set Budget Form */} + <form onSubmit={addBudget} className="budget-form"> + <div className="budget-inputs"> + <div className="input-group"> + <label htmlFor="category">Category</label> + <select + name="category" + value={newBudget.category} + onChange={handleBudgetChange} + required + > + <option value="" disabled>Select a category</option> + {categories.map((cat) => ( + <option key={cat} value={cat}>{cat}</option> + ))} + </select> + </div> + <div className="input-group"> + <label htmlFor="budget_limit">Budget Limit (£)</label> + <input + type="number" + name="budget_limit" + placeholder="Budget Limit (£)" + value={newBudget.budget_limit} + onChange={handleBudgetChange} + step="0.01" + required + /> + </div> + </div> + <button type="submit" className="add-budget-btn">Add Budget</button> + </form> + + {/* Filter Toggle */} + <div className="filter-toggle"> + <button onClick={() => setShowFilters((prev) => !prev)}> + {showFilters ? "Hide Filters" : "Show Filters"} + </button> + </div> + + {showFilters && ( + <div className="filter-dropdown"> + <div className="filter-column"> + <label htmlFor="status">Budget Status</label> + <select + id="status" + value={filters.status} + onChange={(e) => setFilters({ ...filters, status: e.target.value })} + > + <option value="">All Statuses</option> + <option value="🔴 Over budget">🔴 Over budget</option> + <option value="🟡 Warning (near limit)">🟡 Warning (near limit)</option> + <option value="🟢 Good">🟢 Good</option> + </select> + </div> + <div className="apply-filters-container"> + <button className="apply-filters-btn" onClick={applyFilters}> + Apply Filters + </button> + </div> + </div> + )} + + + {/* Modal Content */} + <div className={`modal-overlay ${isModalOpen ? "show" : ""}`}> + <div className="modal-content"> + <h3>Edit Budget</h3> + <form onSubmit={(e) => handleSaveEdit(e, editBudget.id)} className="budget-form"> + <div className="budget-inputs"> + <div className="input-group"> + <label htmlFor="category">Category</label> + <input + type="text" + id="category" + value={editBudget.category} + className="disabled-input" + disabled + /> + </div> + <div className="input-group"> + <label htmlFor="budget_limit">Budget Limit (£)</label> + <input + type="number" + id="budget_limit" + value={editBudget.budget_limit || ""} + onChange={(e) => setEditBudget({ ...editBudget, budget_limit: e.target.value })} + step="0.01" + required + /> + </div> + </div> + <button type="submit" className="add-budget-btn">Save Changes</button> + <button type="button" className="close-modal-btn" onClick={closeModal}>Cancel</button> + </form> + </div> + </div> + + + {/* Budget Table once required */} + <table className="budget-table"> + <thead> + <tr> + <th>Category</th> + <th onClick={() => sortData("budget_limit")} className="sortable"> + Budget (£) {sortConfig.key === "budget_limit" && (sortConfig.direction === "asc" ? "↑" : "↓")} + </th> + <th onClick={() => sortData("spent")} className="sortable"> + Spent (£) {sortConfig.key === "spent" && (sortConfig.direction === "asc" ? "↑" : "↓")} + </th> + <th>Remaining (£)</th> + <th>Status</th> + <th>Actions</th> + </tr> + </thead> + <tbody> + {currentBudgets.length > 0 ? ( + currentBudgets.map((budget) => { + const remaining = (budget.budget_limit - budget.spent).toFixed(2); + return ( + <tr key={budget.id}> + <td>{budget.category}</td> + <td>£{budget.budget_limit.toFixed(2)}</td> + <td>£{budget.spent.toFixed(2)}</td> + <td>£{remaining}</td> + <td>{getStatus(budget)}</td> + <td> + <button onClick={() => handleEdit(budget)} className="edit-budget-btn">Edit</button> + <button onClick={() => handleDelete(budget.id)}>Delete</button> + </td> + </tr> + ); + }) + ) : ( + <tr> + <td colSpan="6" style={{ textAlign: "center" }}>No budgets found.</td> + </tr> + )} + </tbody> + </table> + + {/* Pagination */} + {budgets.length > 0 && ( + <ReactPaginate + previousLabel={"<< Previous"} + nextLabel={"Next >>"} + breakLabel={"..."} + pageCount={Math.max(1, Math.ceil(budgets.length / budgetsPerPage))} + marginPagesDisplayed={2} + pageRangeDisplayed={5} + onPageChange={(data) => setCurrentPage(data.selected)} + containerClassName={"pagination"} + activeClassName={"active"} + /> + )} + + {/* Budget Overview Section */} + <div className="budget-overview"> + <h2>Budget Overview</h2> + + <div className="overview-stats"> + <div className="stat"> + <p><strong>Total Budget:</strong> <span className="amount">£{totalBudget.toFixed(2)}</span></p> + <small className="description">This is the total amount allocated for your budgeted categories.</small> + </div> + + <div className="stat"> + <p><strong>Total Spent:</strong> <span className="amount">£{totalSpent.toFixed(2)}</span></p> + <small className="description">This reflects the total amount spent across all budget categories.</small> + </div> + + <div className="stat"> + <p><strong>Remaining Budget:</strong> <span className="amount">£{remainingBudget.toFixed(2)}</span></p> + <small className="description">The remaining amount available in your budget.</small> + </div> + + <div className="stat"> + <p><strong>Number of Budget Categories:</strong> <span className="amount">{budgets.length}</span></p> + <small className="description">Total number of budget categories currently set.</small> + </div> + + <div className="stat"> + <p><strong>Percentage of Budget Used:</strong> <span className="amount">{percentageSpent}%</span></p> + <small className="description">The percentage of the budget that has been spent.</small> + </div> + </div> + + {/* Budget Status Summary */} + <div className="budget-status-summary"> + <h3>Budget Status Summary</h3> + <div className="status-item good"> + <p><strong>Good Budgets:</strong> <span className="amount">{budgetStatusCounts.good}</span></p> + <small className="description">Budgets that are currently under their limit.</small> + </div> + + <div className="status-item warning"> + <p><strong>Warning Budgets:</strong> <span className="amount">{budgetStatusCounts.warning}</span></p> + <small className="description">Budgets that are near their limit (within 20%).</small> + </div> + + <div className="status-item over-budget"> + <p><strong>Over Budget:</strong> <span className="amount">{budgetStatusCounts.overBudget}</span></p> + <small className="description">Budgets that have exceeded their allocated limit.</small> + </div> + </div> + </div> + + <div className="chart-item"> + <BudgetChart budgets={budgets} /> + </div> + </div> + ); +}; + + +export default Budget; \ No newline at end of file diff --git a/financial-tracker/frontend/src/pages/BudgetChart.js b/financial-tracker/frontend/src/pages/BudgetChart.js new file mode 100644 index 0000000000000000000000000000000000000000..ae4a6109c6f541b5772c09b90a8ad1b7eb42c336 --- /dev/null +++ b/financial-tracker/frontend/src/pages/BudgetChart.js @@ -0,0 +1,79 @@ +import React from 'react'; +import { Bar } from 'react-chartjs-2'; +import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js'; + +// Register necessary components +ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend); + +const BudgetChart = ({ budgets }) => { + // Ensure budgets is provided and is an array + if (!budgets || !Array.isArray(budgets) || budgets.length === 0) { + console.error("Invalid budgets data provided to BudgetChart:", budgets); + return <div>No data available for chart.</div>; + } + + // Prepare the data for Chart.js + const chartData = { + labels: budgets.map(item => item.category), + datasets: [ + { + label: 'Spent', + data: budgets.map(item => item.spent || 0), + backgroundColor: '#FF6B6B', + }, + { + label: 'Budget', + data: budgets.map(item => item.budget_limit || 0), + backgroundColor: '#4ECDC4', + }, + ], + }; + + const options = { + responsive: true, + plugins: { + legend: { + position: 'right', + }, + title: { + display: true, + }, + }, + scales: { + x: { + title: { + display: true, + text: 'Categories', + }, + }, + y: { + title: { + display: true, + text: 'Amount Spent', + }, + beginAtZero: true, + }, + }, + }; + + return ( + <div className="budget-chart-container"> + <div className="chart-section"> + <div className="text-container"> + <h4>Bar Chart: Budget vs. Spending</h4> + <p> + This bar chart compares your budgeted amounts against your actual spending. + Each pair of bars represents a category, with one bar denoting the budget + limit and the other showing the amount spent. This visualisation helps you + quickly assess where you're over or under budget. + </p> + </div> + <div className="chart"> + <Bar data={chartData} options={options} /> + </div> + </div> + </div> + ); +}; + +export default BudgetChart; \ No newline at end of file diff --git a/financial-tracker/frontend/src/styles/Budget.css b/financial-tracker/frontend/src/styles/Budget.css new file mode 100644 index 0000000000000000000000000000000000000000..5d4c629a20b99990955625989a367c88fd24cbbf --- /dev/null +++ b/financial-tracker/frontend/src/styles/Budget.css @@ -0,0 +1,378 @@ +/* Budget Container */ +.budget-container { + max-width: 900px; + margin: 0 auto; +} + +.budget-overview h2 { + font-size: 2em; + margin-top: 15px; + font-weight: bold; + text-align: center; +} + +.budget-overview p { + font-size: 1.3em; + margin-top: 10px; + line-height: 1.5; + text-align: center; +} + +.budget-overview { + padding: 20px; + background-color: #f1f1f1; + border-radius: 8px; + margin-top: 20px; +} + +.overview-stats { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + margin-bottom: 15px; +} + +.stat { + flex: 1; + margin: 10px; + padding: 15px; + background-color: #ffffff; + border: 1px solid #e0e0e0; + border-radius: 8px; + text-align: center; +} + +.amount { + font-size: 1.5em; + color: #4ECDC4; +} + +.description { + color: #666666; + font-size: 0.9em; +} + +/* Budget Status Summary */ +.budget-status-summary h3 { + font-size: 1.5em; + margin-top: 15px; + font-weight: bold; + text-align: center; +} + +.status-item { + margin: 10px 0; + padding: 10px; + background-color: #ffffff; + border: 1px solid #e0e0e0; + border-radius: 5px; +} + +.good { + border-left: 5px solid #4CAF50; +} + +.warning { + border-left: 5px solid #FFA500; +} + +.over-budget { + border-left: 5px solid #FF6B6B; +} + +/* Budget Form */ +.budget-form { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + margin-bottom: 20px; + padding: 20px; + border-radius: 10px; + background-color: #f9f9f9; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.budget-inputs { + display: flex; + justify-content: space-between; + gap: 10px; + width: 100%; +} + +.input-group { + flex: 1; + min-width: calc(50% - 5px); +} + +.budget-inputs input, +.budget-inputs select { + padding: 8px; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 5px; + width: calc(50% - 5px); + box-sizing: border-box; +} + +.budget-inputs label { + font-weight: bold; +} + +.modal-content .disabled-input { + cursor: not-allowed; + background-color: #f0f0f0; + pointer-events: none; +} + +/* Add / Submit Button */ +.add-budget-btn { + padding: 10px 15px; + font-size: 14px; + background-color: #007bff; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + margin-top: 20px; +} + +.add-budget-btn:hover { + background-color: #0056b3; +} + +/* Input and select styling for filter section */ +.filter-column label { + font-size: 14px; + font-weight: bold; + margin-bottom: 5px; +} + +.filter-column select, +.filter-column input { + width: 100%; + padding: 8px; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 5px; + box-sizing: border-box; + background-color: #f8f8f8; +} + +/* Styling the dropdown with a pointer cursor */ +.filter-column select { + cursor: pointer; +} + +/* Apply Filters Button Container */ +.apply-filters-container { + width: 100%; + display: flex; + justify-content: center; +} + +/* Styling the Apply Filters Button */ +.apply-filters-btn { + width: 100%; + max-width: 600px; + padding: 12px 20px; + background-color: #28a745; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 14px; + margin-top: 15px; + transition: background-color 0.3s; +} + +.apply-filters-btn:hover { + background-color: #218838; +} + +/* Additional styles for toggling filter visibility */ +.filter-toggle { + margin-bottom: 15px; +} + +/* Styles for the filter dropdown when shown */ +.filter-dropdown { + align-items: flex-start; + border: 1px solid #ccc; + border-radius: 5px; + background-color: #ffffff; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + flex-wrap: wrap; + gap: 20px; + justify-content: space-between; + margin-top: 10px; + margin-left: auto; + margin-right: auto; + padding: 15px; +} + +/* Adding some spacing to the filter columns */ +.filter-column { + margin-bottom: 15px; /* Margin below each filter column */ +} + +/* Budget Table */ +.budget-table { + width: 100%; + border-collapse: collapse; + margin: auto; + margin-top: 20px; +} + +.budget-table th, .budget-table td { + border: 1px solid #ddd; + padding: 10px; +} + +.budget-table th { + background-color: #f4f4f4; +} + +.budget-table th.sortable { + cursor: pointer; +} + +.budget-table th.sortable:hover { + background-color: #e0e0e0; +} + +/* Action Buttons */ +.budget-table button { + padding: 5px 10px; + font-size: 12px; + border: none; + cursor: pointer; + margin: 2px; + border-radius: 3px; +} + +.budget-table button:first-child { + background-color: #ffc107; + color: black; +} + +.budget-table button:last-child { + background-color: #dc3545; + color: white; +} + +/* Dropdown for Category */ +.budget-form select { + padding: 8px; +} + + +/* Success Message */ +.success-message { + padding: 10px; + margin-top: 10px; + background-color: #4CAF50; + color: white; + border-radius: 5px; +} + +/* Error Message */ +.error-message { + padding: 10px; + margin-top: 10px; + background-color: #dc3545; + color: white; +} + + +/* Pagination */ +.pagination { + display: flex; + justify-content: center; + align-items: center; + list-style-type: none; + padding: 10px 0; + margin-top: 20px; +} + +.pagination li { + margin: 0 10px; + cursor: pointer; + font-size: 14px; + padding: 8px 12px; + border-radius: 5px; + transition: background-color 0.3s ease, color 0.3s ease; +} + +.pagination li:hover { + background-color: #007bff; + color: white; +} + +.pagination .active { + background-color: #007bff; + color: white; + font-weight: bold; + border-radius: 5px; + padding: 8px 12px; +} + +.pagination li.disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.pagination .previous, .pagination .next { + font-weight: bold; +} + +/* Adding styles for Previous and Next buttons */ +.pagination .previous, +.pagination .next { + padding: 8px 12px; + background-color: #007bff; + color: white; + border-radius: 5px; + font-size: 14px; + cursor: pointer; + transition: background-color 0.3s ease; +} + +.pagination .previous:hover, +.pagination .next:hover { + background-color: #0056b3; +} + +/* Responsive: Stack on small screens */ +@media (max-width: 600px) { + /* Budget Inputs */ + .budget-inputs { + flex-direction: column; + width: 100%; + } + + .budget-inputs input, + .budget-inputs select { + width: 100%; + } + + /* Budget Table */ + .budget-table { + font-size: 12px; + } + + .budget-table th, + .budget-table td { + padding: 8px; + } + + /* Filter Dropdown */ + .filter-dropdown { + flex-direction: column; + align-items: center; + } + + .filter-column { + width: 100%; + } +} \ No newline at end of file diff --git a/financial-tracker/requirements.txt b/financial-tracker/requirements.txt index 272c6bd5bd8b2e078244000e54df9a4bcd41c2c3..b865926eae7539ee6a8aa5e8a4b5604f38cb7bdf 100644 Binary files a/financial-tracker/requirements.txt and b/financial-tracker/requirements.txt differ diff --git a/financial-tracker/transaction-service/app/app.py b/financial-tracker/transaction-service/app/app.py index bb654fe16c940a660e8f77b057f9d882596fa774..c78d6bda411483a425d1ed2fa1e0c740cf4a8fea 100644 --- a/financial-tracker/transaction-service/app/app.py +++ b/financial-tracker/transaction-service/app/app.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta +from dateutil.relativedelta import relativedelta from flask import Flask, request, jsonify from flask_cors import CORS from flask_jwt_extended import ( @@ -157,7 +158,7 @@ class Expense(db.Model): "id": self.id, "user_id": self.user_id, "amount": self.amount, - "currency": self.currency.value, # Convert Enum to its string value + "currency": self.currency.value, "category": self.category, "date": self.date.strftime('%Y-%m-%d'), "description": self.description, @@ -277,7 +278,6 @@ def add_expense(): return jsonify({"error": "An error occurred while adding the expense"}), 500 - @app.route('/expenses', methods=['GET']) @jwt_required() def get_expenses(): @@ -461,6 +461,44 @@ def delete_expense(expense_id): return jsonify({"error": "An error occurred while deleting the expense"}), 500 + +@app.route('/expenses/monthly', methods=['GET']) +@jwt_required() +def get_monthly_expenses(): + """Retrieve expenses for the logged-in user for the current month""" + try: + user_id = get_jwt_identity() + user_id = int(user_id) + app.logger.info(f"Retrieved user_id from JWT: {user_id}") + + if not user_id: + app.logger.error("User ID is None or invalid.") + return jsonify({"error": "Invalid user authentication"}), 401 + + # Get the start and end dates for the current month + now = datetime.now() + start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + end_date = (start_date + relativedelta(months=1)).replace(day=1) + + app.logger.info(f"Fetching expenses from {start_date} to {end_date}") + + # Query expenses for the current month + expenses = Expense.query.filter( + Expense.user_id == user_id, + Expense.date >= start_date, + Expense.date < end_date + ).all() + + app.logger.info(f"Found {len(expenses)} monthly expenses for user_id {user_id}") + + expenses_list = [expense.to_dict() for expense in expenses] + return jsonify({"expenses": expenses_list}), 200 + + except Exception as e: + app.logger.error(f"Error retrieving monthly expenses: {e}", exc_info=True) + return jsonify({"error": "An error occurred while fetching monthly expenses"}), 500 + + @app.route('/categories', methods=['GET']) @jwt_required() def get_categories(): @@ -492,7 +530,7 @@ def health(): return jsonify({"status": "User service is running"}), 200 -# INITIALIZATION & RUNNING APP +# INITIALISATION & RUNNING APP if __name__ == "__main__": # Create all tables in the database (if they don't exist) with app.app_context():