diff --git a/financial-tracker/api-gateway/app/app.py b/financial-tracker/api-gateway/app/app.py index 67d640d46436493486b5925f70776080adfc7d61..c3961c4e73dc3dc6d3f76873775ab59aded6259c 100644 --- a/financial-tracker/api-gateway/app/app.py +++ b/financial-tracker/api-gateway/app/app.py @@ -95,6 +95,43 @@ def me(): app.logger.error(f"Error contacting user-service: {e}") return jsonify({"error": "User service unavailable"}), 503 +# Configure transaction service URL +TRANSACTION_SERVICE_URL = os.getenv('TRANSACTION_SERVICE_URL', 'http://transaction-service:5002') + +@app.route('/transactions', methods=["GET", "POST"]) +def handle_transactions(): + """ + Forward GET/POST transaction requests to the transaction-service. + """ + try: + if request.method == "GET": + response = requests.get(f"{TRANSACTION_SERVICE_URL}/transactions", params=request.args) + else: + response = requests.post(f"{TRANSACTION_SERVICE_URL}/transactions", json=request.get_json()) + + return jsonify(response.json()), response.status_code + + except requests.exceptions.RequestException as e: + app.logger.error(f"Error contacting transaction-service: {e}") + return jsonify({"error": "Transaction service unavailable"}), 503 + + +@app.route('/transactions/<int:tx_id>', methods=["PUT", "DELETE"]) +def handle_transaction_by_id(tx_id): + """ + Forward PUT/DELETE requests to transaction-service for specific transaction. + """ + try: + if request.method == "PUT": + response = requests.put(f"{TRANSACTION_SERVICE_URL}/transactions/{tx_id}", json=request.get_json()) + else: + response = requests.delete(f"{TRANSACTION_SERVICE_URL}/transactions/{tx_id}") + + return jsonify(response.json()), response.status_code + + 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', methods=['POST']) def add_expense(): @@ -230,6 +267,114 @@ def fetch_categories(): return jsonify({"error": "Transaction service unavailable"}), 503 +# INCOME ROUTES +@app.route('/income', methods=['POST']) +@jwt_required() +def add_income(): + """Forward request to transaction-service to add an income.""" + token = request.headers.get("Authorization") + 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 income addition request: {data}") + + response = requests.post(f"{TRANSACTION_SERVICE_URL}/income", headers=headers, json=request.get_json()) + return jsonify(response.json()), response.status_code + except requests.exceptions.RequestException: + app.logger.error(f"Error contacting transaction-service: {e}") + return jsonify({"error": "Transaction service unavailable"}), 503 + +@app.route('/income', methods=['GET']) +@jwt_required() +def fetch_income(): + """Fetch income for the logged-in user.""" + token = request.headers.get("Authorization") + + if not token: + return jsonify({"error": "Authorization token is missing"}), 400 + try: + headers = {"Authorization": token} + response = requests.get(f"{TRANSACTION_SERVICE_URL}/income", headers=headers) + app.logger.info(f"Response from transaction-service (income): {response.status_code} - {response.json()}") + return jsonify(response.json()), response.status_code + except requests.exceptions.RequestException: + return jsonify({"error": "Transaction service unavailable"}), 503 + +@app.route('/income/<income_id>', methods=['PUT']) +@jwt_required() +def update_income(income_id): + """Forward request to transaction-service to edit an income (excluding currency).""" + token = request.headers.get("Authorization") + + 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() + # Ensure currency field is not included in the update request + if "currency" in data: + app.logger.warning(f"Attempt to modify currency field in income {income_id}") + return jsonify({"error": "Currency field cannot be updated"}), 400 + + app.logger.info(f"Forwarding income update request for ID {income_id}: {data}") + + response = requests.put(f"{TRANSACTION_SERVICE_URL}/income/{income_id}", headers=headers, json=request.get_json()) + return jsonify(response.json()), response.status_code + except requests.exceptions.RequestException: + app.logger.error(f"Error contacting transaction-service: {e}") + return jsonify({"error": "Transaction service unavailable"}), 503 + +@app.route('/income/<income_id>', methods=['DELETE']) +@jwt_required() +def delete_income(income_id): + """Forward request to transaction-service to delete an income.""" + token = request.headers.get("Authorization") + if not token: + app.logger.error("Authorization token is missing.") + return jsonify({"error": "Authorization token is missing"}), 400 + try: + headers = {"Authorization": token} + response = requests.delete(f"{TRANSACTION_SERVICE_URL}/income/{income_id}", headers=headers) + + if response.status_code == 200: + app.logger.info(f"Income with ID {income_id} deleted successfully.") + return jsonify({"message": "Income deleted successfully."}), 200 + + else: + app.logger.error(f"Failed to delete income with ID {income_id}.") + return jsonify({"error": "Failed to delete income"}), response.status_code + + except requests.exceptions.RequestException: + app.logger.error(f"Error contacting transaction-service: {e}") + return jsonify({"error": "Transaction service unavailable"}), 503 + +@app.route('/income/sources', methods=['GET']) +@jwt_required() +def get_income_sources(): + """Fetch available income sources from transaction-service.""" + token = request.headers.get("Authorization") + if not token: + return jsonify({"error": "Authorization token is missing"}), 400 + + try: + user_id = get_jwt_identity() # Extract user_id from JWT token + headers = {"Authorization": token} + response = requests.get(f"{TRANSACTION_SERVICE_URL}/income/sources", headers=headers) + + # Add CORS headers to the response + response_data = jsonify(response.json()) + response_data.headers.add("Access-Control-Allow-Origin", "http://localhost:3000") + response_data.headers.add("Access-Control-Allow-Headers", "Content-Type, Authorization") + response_data.headers.add("Access-Control-Allow-Methods", "GET, OPTIONS") + + return jsonify(response.json()), response.status_code + except requests.exceptions.RequestException: + return jsonify({"error": "Transaction service unavailable"}), 503 + @app.route('/budgets', methods=['POST']) @jwt_required() def add_budget(): @@ -331,3 +476,4 @@ def fetch_budgets(): if __name__ == '__main__': app.run(host='0.0.0.0', port=8000, debug=True) + diff --git a/financial-tracker/docker-compose.yml b/financial-tracker/docker-compose.yml index 04833088ae3ec4398cbd74ee74285a133f37efb6..66cca04e3a3dec9920d14bbf7260eb7a602de0b3 100644 --- a/financial-tracker/docker-compose.yml +++ b/financial-tracker/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3.8' + services: api-gateway: diff --git a/financial-tracker/frontend/.dockerignore b/financial-tracker/frontend/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..01c0af06357b2cbe08c90e7b6422138e0e3ebc6d --- /dev/null +++ b/financial-tracker/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +build +.dockerignore +Dockerfile +npm-debug.log diff --git a/financial-tracker/frontend/Dockerfile b/financial-tracker/frontend/Dockerfile index cc0921d62435390f4dc454cb07645abcded30952..d1f85e592f0123d788592ddfd853b0c24d8b4568 100644 --- a/financial-tracker/frontend/Dockerfile +++ b/financial-tracker/frontend/Dockerfile @@ -21,3 +21,5 @@ EXPOSE 3000 # Start the React app CMD ["npm", "start"] + + diff --git a/financial-tracker/frontend/package-lock.json b/financial-tracker/frontend/package-lock.json index dee52157b450d06e5b5d8f23863adb289323aa3d..24bd576d4d7432126b44ccab8c26f0024d8e18ff 100644 --- a/financial-tracker/frontend/package-lock.json +++ b/financial-tracker/frontend/package-lock.json @@ -19,6 +19,7 @@ "react": "^19.0.0", "react-chartjs-2": "^5.3.0", "react-dom": "^19.0.0", + "react-icons": "^5.5.0", "react-paginate": "^8.3.0", "react-router-dom": "^7.4.0", "react-scripts": "5.0.1", @@ -14311,6 +14312,15 @@ "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==", "license": "MIT" }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/financial-tracker/frontend/package.json b/financial-tracker/frontend/package.json index d81507da563b72f58c878bd7636a9b6be837ace0..ffb0bc3b18555309b54a760b824218b8097ef72c 100644 --- a/financial-tracker/frontend/package.json +++ b/financial-tracker/frontend/package.json @@ -14,6 +14,7 @@ "react": "^19.0.0", "react-chartjs-2": "^5.3.0", "react-dom": "^19.0.0", + "react-icons": "^5.5.0", "react-paginate": "^8.3.0", "react-router-dom": "^7.4.0", "react-scripts": "5.0.1", diff --git a/financial-tracker/frontend/src/App.js b/financial-tracker/frontend/src/App.js index 2cf786b0ff5012f5aa99feb4facdf6bdafcf9859..6dbc470864737ab6f2a9049b2928793c09670f6c 100644 --- a/financial-tracker/frontend/src/App.js +++ b/financial-tracker/frontend/src/App.js @@ -5,6 +5,10 @@ import Home from "./pages/Home"; import Register from "./pages/Register"; import Login from "./pages/Login"; import Dashboard from "./pages/Dashboard"; +import Income from "./pages/Income"; + + + import Expenses from "./pages/Expenses"; import Budget from "./pages/Budget"; @@ -34,6 +38,7 @@ function App() { {token ? ( <> <li><Link to="/dashboard">Dashboard</Link></li> + <li><Link to="/income">Income</Link></li> {/* 👈 Add this line */} <li><Link to="/expenses">Expenses</Link></li> <li><Link to="/budget">Budget</Link></li> <li> @@ -56,6 +61,7 @@ function App() { <Route path="/register" element={<Register />} /> <Route path="/login" element={<Login setToken={setToken} />} /> <Route path="/dashboard" element={<Dashboard setToken={setToken} />} /> + <Route path="/income" element={<Income />} /> <Route path="/expenses" element={<Expenses />} /> <Route path="/budget" element={<Budget />} /> </Routes> diff --git a/financial-tracker/frontend/src/pages/Income.js b/financial-tracker/frontend/src/pages/Income.js new file mode 100644 index 0000000000000000000000000000000000000000..fed1f8fbcc73507bb9a248ca2a032c4df9a10b5e --- /dev/null +++ b/financial-tracker/frontend/src/pages/Income.js @@ -0,0 +1,689 @@ +import React, { useState, useEffect } from "react"; +import axios from "axios"; +import { jwtDecode } from 'jwt-decode'; +import ReactPaginate from "react-paginate"; +import IncomeChart from "./IncomeChart"; +import "../styles/Income.css"; + + + +const Incomes = () => { + const token = localStorage.getItem("token"); + + const [incomes, setIncomes] = useState([]); + const [filteredIncomes, setFilteredIncomes] = useState([]); + const [currentPage, setCurrentPage] = useState(0); + const incomesPerPage = 5; + const offset = currentPage * incomesPerPage; + const currentIncomes = filteredIncomes.slice(offset, offset + incomesPerPage); + const [sourceOptions, setSourceOptions] = useState([]); + const [sources, setSources] = useState([]); + const [isSessionValid, setIsSessionValid] = useState(true); + + /*const SourceEnum = [ + "SALARY", + "BUSINESS", + "FREELANCE", + "INVESTMENTS", + "GIFTS", + "LOANS", + "OTHER" + ];*/ + + const [newIncome, setNewIncome] = useState({ + date: "", + source: "", + amount: "", + description: "", + recurring: false, + recurring_type: "", + frequency: "", + interval: "", + end_repeat: "never", + end_date: "", + }); + + const [editIncome, setEditIncome] = useState({ + id: null, + date: "", + source: "", + amount: "", + description: "", + recurring: false, + recurring_type: "", + frequency: "", + interval: "", + end_repeat: "never", + end_date: "", + }); + + const [filters, setFilters] = useState({ + source: "", + minAmount: "", + maxAmount: "", + startDate: "", + endDate: "", + }); + + const [sortConfig, setSortConfig] = useState({ key: "", direction: "asc" }); + const [showFilters, setShowFilters] = useState(false); + const [chartType, setChartType] = useState("bar"); + const [timeRange, setTimeRange] = useState("monthly"); + const [isModalOpen, setIsModalOpen] = useState(false); + const [deleteMessage, setDeleteMessage] = useState(""); + const [successMessage, setSuccessMessage] = useState(""); + + 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) { + setIsSessionValid(true); // Token exists, session is valid + } else { + setIsSessionValid(false); // No token, session invalid + alert("Your session has expired. Please log in again."); + window.location.href = "/login"; // Redirect to login page + } + }, []); + + useEffect(() => { + fetchIncomes(); + fetchSources(); + }, []); + + const fetchIncomes = async () => { + const token = localStorage.getItem("token"); + try { + const res = await axios.get("http://localhost:8000/income", { + headers: { Authorization: `Bearer ${token}` }, + }); + setIncomes(res.data.incomes); + setFilteredIncomes(res.data.incomes); + } catch (err) { + console.error("Error fetching incomes:", err); + } + }; + + const fetchSources = async () => { + const token = localStorage.getItem("token"); + try { + console.log("Token from localStorage:", 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/income/sources", { + headers: { Authorization: `Bearer ${token}` }, + }); + + console.log("Sources Response:", response.data); + + if (Array.isArray(response.data.sources)) { + setSources(response.data.sources); + } else { + console.error("Sources data is not in expected format", response.data); + } + } catch (err) { + console.error("Error fetching sources:", err); + } + }; + + + const handleAddIncome = async (e) => { + e.preventDefault(); + const token = localStorage.getItem("token"); + if (!token) { + alert("Please log in to add income."); + return; + } + try { + const res = await axios.post( + "http://localhost:8000/income", + { + ...newIncome, + currency: "GBP", // or default from user profile + }, + { + headers: { Authorization: `Bearer ${token}` }, + } + ); + setIncomes([...incomes, res.data.income]); + setFilteredIncomes([...filteredIncomes, res.data.income]); + setNewIncome({ + date: "", + source: "", + amount: "", + description: "", + recurring: false, + recurring_type: "", + frequency: "", + interval: "", + end_repeat: "never", + end_date: "", + }); + setSuccessMessage("Income added successfully."); + // Wait 1.5 seconds before refreshing + setTimeout(() => { + window.location.reload(); + }, 1500); + } catch (err) { + console.error("Failed to add income:", err); + alert( + `Error adding income: ${ + err.response ? err.response.data.error : err.message + }` + ); + + } + }; + + + const handleEdit = (income) => { + const { currency, ...cleanedIncome } = income; + setEditIncome(cleanedIncome); + openModal(); + }; + + + const handleSaveEdit = async (e, id) => { + e.preventDefault(); + console.log("Saving changes for ID:", id); + const token = localStorage.getItem("token"); + + const { + date, + source, + amount, + description, + recurring, + recurring_type, + frequency, + interval, + end_repeat, + end_date + } = editIncome; + + const payload = { + date, + source, + amount: parseFloat(amount), // ensure number + description, + recurring, + recurring_type, + frequency, + interval, + end_repeat, + end_date, + }; + try { + const { currency, ...incomeData } = editIncome; + if (editIncome.end_repeat === "on_date" && !editIncome.end_date) { + alert("Please specify an end date."); + return; + } + if (!editIncome.end_date) { + delete editIncome.end_date; + } + + console.log("Sending to API:", editIncome); + + const res = await axios.put( + `http://localhost:8000/income/${id}`, + payload, + { headers: { Authorization: `Bearer ${token}` } } + ); + setIncomes(incomes.map((i) => (i.id === id ? res.data.income : i))); + setFilteredIncomes(filteredIncomes.map((i) => (i.id === id ? res.data.income : i))); + setSuccessMessage("Income updated successfully."); + // Wait 1.5 seconds before refreshing + setTimeout(() => { + window.location.reload(); + }, 1500); + closeModal(); + } catch (err) { + console.error("Failed to update income:", err); + alert( + `Error updating income: ${ + err.response ? err.response.data.error || err.response.data.message : err.message + }` + ); + } + }; + + const handleDelete = async (id) => { + const confirmDelete = window.confirm("Delete this income?"); + if (!confirmDelete) return; + const token = localStorage.getItem("token"); + try { + await axios.delete(`http://localhost:8000/income/${id}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const updated = incomes.filter((i) => i.id !== id); + setIncomes(updated); + setFilteredIncomes(updated); + setDeleteMessage("Income deleted successfully."); + // Wait 1.5 seconds before refreshing + setTimeout(() => { + window.location.reload(); + }, 1500); + } catch (err) { + console.error("Failed to delete income:", err); + setDeleteMessage("Error deleting income. Please try again."); + } + }; + + const handleChange = (e, stateSetter) => { + const { name, type, value, checked } = e.target; + stateSetter((prev) => ({ + ...prev, + [name]: type === "checkbox" ? checked : value, + })); + }; + + const sortData = (key) => { + let direction = "asc"; + if (sortConfig.key === key && sortConfig.direction === "asc") direction = "desc"; + const sorted = [...filteredIncomes].sort((a, b) => { + if (key === "date") return direction === "asc" ? new Date(a.date) - new Date(b.date) : new Date(b.date) - new Date(a.date); + if (key === "amount") return direction === "asc" ? a.amount - b.amount : b.amount - a.amount; + if (key === "source") return direction === "asc" ? a.source.localeCompare(b.source) : b.source.localeCompare(a.source); + return 0; + }); + setFilteredIncomes(sorted); + setSortConfig({ key, direction }); + }; + + const handlePageClick = (event) => { + setCurrentPage(event.selected); + }; + + const applyFilters = () => { + let filtered = incomes.filter((income) => { + return ( + (!filters.source || income.source.toLowerCase().includes(filters.source.toLowerCase())) && + (!filters.minAmount || income.amount >= parseFloat(filters.minAmount)) && + (!filters.maxAmount || income.amount <= parseFloat(filters.maxAmount)) && + (!filters.startDate || income.date >= filters.startDate) && + (!filters.endDate || income.date <= filters.endDate) + ); + }); + setFilteredIncomes(filtered); + }; + + return ( + <div className="income-container"> + <h1>Income</h1> + + {successMessage && <div className="success-message">{successMessage}</div>} + {deleteMessage && <div className="delete-message">{deleteMessage}</div>} + + {/* Add Income Form */} + <form className="income-form" onSubmit={handleAddIncome}> + <div className="income-inputs"> + <div className="income-row"> + <div className="input-group"> + <label>Source</label> + <input + type="text" + name="source" + placeholder="Source" + value={newIncome.source} + onChange={(e) => handleChange(e, setNewIncome)} required + /> + </div> + + <div className="input-group"> + <label>Amount</label> + <input type="number" name="amount" step="0.01" value={newIncome.amount} onChange={(e) => handleChange(e, setNewIncome)} required /> + </div> + </div> + <div className="income-row"> + <div className="input-group"> + <label>Date</label> + <input type="date" name="date" value={newIncome.date} onChange={(e) => handleChange(e, setNewIncome)} required /> + </div> + <div className="input-group"> + <label>Description</label> + <input type="text" name="description" value={newIncome.description} onChange={(e) => handleChange(e, setNewIncome)} /> + </div> + </div> + + <div className="checkbox-container"> + <label className="checkbox-label"> + <input type="checkbox" name="recurring" checked={newIncome.recurring} onChange={(e) => handleChange(e, setNewIncome)} /> + Recurring Income + </label> + </div> + </div> + + {newIncome.recurring && ( + <div className="recurring-options"> + <div className="recurring-left"> + <label>Recurring Type</label> + <select name="recurring_type" value={newIncome.recurring_type} onChange={(e) => handleChange(e, setNewIncome)}> + <option value="">Select...</option> + <option value="daily">Daily</option> + <option value="weekly">Weekly</option> + <option value="every_two_weeks">Every Two Weeks</option> + <option value="monthly">Monthly</option> + <option value="yearly">Yearly</option> + <option value="custom">Custom</option> + </select> + + {newIncome.recurring_type === "custom" && ( + <> + <label>Frequency</label> + <select name="frequency" value={newIncome.frequency} onChange={(e) => handleChange(e, setNewIncome)}> + <option value="">Select...</option> + <option value="daily">Daily</option> + <option value="weekly">Weekly</option> + <option value="monthly">Monthly</option> + <option value="yearly">Yearly</option> + </select> + <label>Interval</label> + <input type="number" name="interval" value={newIncome.interval} onChange={(e) => handleChange(e, setNewIncome)} /> + </> + )} + </div> + <div className="recurring-right"> + <label>End Repeat</label> + <select name="end_repeat" value={newIncome.end_repeat} onChange={(e) => handleChange(e, setNewIncome)}> + <option value="never">Never</option> + <option value="on_date">On Date</option> + </select> + + {newIncome.end_repeat === "on_date" && ( + <input type="date" name="end_date" value={newIncome.end_date} onChange={(e) => handleChange(e, setNewIncome)} /> + )} + </div> + </div> + )} + + <button type="submit" className="add-income-btn">Add Income</button> + </form> + + {/* Filters */} + <div className="filter-toggle"> + <button onClick={() => setShowFilters((prev) => !prev)}> + {showFilters ? "Hide Filters" : "Show Filters"} + </button> + </div> + {showFilters && ( + <div className="filter-dropdown"> + {/* First Column: Source*/} + <div className="filter-column"> + <label htmlFor="source">Source</label> + <select + name="source" + value={filters.source} + //value={newIncome.source} + onChange={(e) => setFilters({ ...filters, source: e.target.value })} + required + > + <option value="">All Sources</option> + {sources.map((src) => ( + <option key={src} value={src}> + {src} + </option> + ))} + </select> + </div> + + {/* Second Column: Min/Max Amount */} + <div className="filter-column"> + <label htmlFor="minAmount">Min Amount</label> + <input + id="minAmount" + type="number" + placeholder="Min Amount" + value={filters.minAmount} + onChange={(e) => setFilters({ ...filters, minAmount: e.target.value })} + step="0.01" + /> + + <label htmlFor="maxAmount">Max Amount</label> + <input + id="maxAmount" + type="number" + placeholder="Max Amount" + value={filters.maxAmount} + onChange={(e) => setFilters({ ...filters, maxAmount: e.target.value })} + step="0.01" + /> + </div> + + {/* Third Column: Start/End Date */} + <div className="filter-column"> + <label htmlFor="startDate">Start Date (dd/mm/yyyy)</label> + <input + id="startDate" + type="date" + value={filters.startDate} + onChange={(e) => setFilters({ ...filters, startDate: e.target.value })} + /> + + <label htmlFor="end_date">End Date (dd/mm/yyyy)</label> + <input + id="end_date" + type="date" + value={filters.end_date} + onChange={(e) => setFilters({ ...filters, end_date: e.target.value })} + /> + </div> + {/* Apply Filters Button (Centered) */} + <div className="apply-filters-container"> + <button className="apply-filters-btn" onClick={applyFilters}> + Apply Filters + </button> + </div> + </div> + )} + + + {/* Income Table */} + <table className="income-table"> + <thead> + <tr> + <th onClick={() => sortData("date")}>Date</th> + <th onClick={() => sortData("source")}>Source</th> + <th onClick={() => sortData("amount")}>Amount (£)</th> + <th>Description</th> + <th>Recurring</th> + <th>Actions</th> + </tr> + </thead> + <tbody> + {currentIncomes.map((income) => ( + <tr key={income.id}> + <td>{income.date}</td> + <td>{income.source}</td> + <td>£{parseFloat(income.amount).toFixed(2)}</td> + <td>{income.description}</td> + <td>{income.recurring ? "Yes" : "No"}</td> + <td> + <button className="edit-btn" onClick={() => handleEdit(income)}>Edit</button> + <button className="delete-btn" onClick={() => handleDelete(income.id)}>Delete</button> + </td> + </tr> + ))} + </tbody> + </table> + + {/* Pagination */} + <ReactPaginate + previousLabel={"<< Previous"} + nextLabel={"Next >>"} + breakLabel={"..."} + pageCount={Math.max(1, Math.ceil(filteredIncomes.length / incomesPerPage))} + marginPagesDisplayed={2} + pageRangeDisplayed={5} + onPageChange={handlePageClick} + containerClassName={"pagination"} + activeClassName={"active"} + /> + + {/* Charts */} + <div className="income-summary"> + <h2>Income Summary</h2> + <p>This section helps you visualize your income streams across time and sources.</p> + </div> + + <div className="spending-distribution"> + <h3>Income Distribution</h3> + <p> + This chart displays the distribution of your income across various sources. Depending on the selected + chart type, you can view this data in different ways. + </p> + + {/* Chart Type Selector for Bar/Pie Chart */} + <div className="chart-type-selector"> + <label htmlFor="chartType">Select Chart Type: </label> + <select value={chartType} onChange={(e) => setChartType(e.target.value)}> + <option value="bar">Bar Chart</option> + <option value="pie">Pie Chart</option> + </select> + </div> + + <div className="chart-item"> + <IncomeChart type={chartType} incomes={incomes} /> + </div> + </div> + + <div className="spending-trend"> + <h3>Income Trend</h3> + <p> + This section provides a detailed view of your income trends over different time periods—daily, weekly, + monthly, or yearly. By visualizing your income across various sources over these time ranges, you + can easily identify patterns, fluctuations, and long-term trends in your earning. These insights + empower you to make smarter financial decisions, manage your budget effectively, and track how + your income evolves over time. + </p> + + {/* Time Range Selector for the Line Chart */} + <div className="time-range-selector"> + <label htmlFor="timeRange">Select Time Range: </label> + <select value={timeRange} onChange={(e) => setTimeRange(e.target.value)}> + <option value="daily">Days</option> + <option value="weekly">Weeks</option> + <option value="monthly">Months</option> + <option value="yearly">Years</option> + </select> + </div> + <div className="chart-item"> + <IncomeChart type="line" timeRange={timeRange} incomes={incomes} /> + </div> + </div> + {isModalOpen && ( + <div className={`modal-overlay ${isModalOpen ? "show" : ""}`}> + <div className="modal-content"> + <h3>Edit Income</h3> + <form onSubmit={(e) => handleSaveEdit(e, editIncome.id)} className="income-form"> + <div className="income-row"> + <div className="input-group"> + <label>Source</label> + <input type="text" name="source" value={editIncome.source} onChange={(e) => handleChange(e, setEditIncome)} required /> + </div> + <div className="input-group"> + <label>Amount</label> + <input type="number" name="amount" step="0.01" value={editIncome.amount} onChange={(e) => handleChange(e, setEditIncome)} required /> + </div> + </div> + <div className="income-row"> + <div className="input-group"> + <label>Date</label> + <input type="date" name="date" value={editIncome.date} onChange={(e) => handleChange(e, setEditIncome)} required /> + </div> + + <div className="input-group"> + <label>Description</label> + <input type="text" name="description" value={editIncome.description} onChange={(e) => handleChange(e, setEditIncome)} /> + </div> + </div> + + + <div className="checkbox-container"> + <div className="checkbox-label"> + <input + type="checkbox" + name="recurring" + checked={editIncome.recurring} + onChange={(e) => handleChange(e, setEditIncome)} + /> + <label htmlFor="recurring">Recurring Income</label> + </div> + </div> + + {editIncome.recurring && ( + <div className="recurring-options"> + <div className="recurring-left"> + <label>Recurring Type</label> + <select name="recurring_type" value={editIncome.recurring_type} onChange={(e) => handleChange(e, setEditIncome)}> + <option value="">Select...</option> + <option value="daily">Daily</option> + <option value="weekly">Weekly</option> + <option value="every_two_weeks">Every Two Weeks</option> + <option value="monthly">Monthly</option> + <option value="yearly">Yearly</option> + <option value="custom">Custom</option> + </select> + + {editIncome.recurring_type === "custom" && ( + <> + + <label>Frequency</label> + <select name="frequency" value={editIncome.frequency} onChange={(e) => handleChange(e, setEditIncome)}> + <option value="">Select...</option> + <option value="daily">Daily</option> + <option value="weekly">Weekly</option> + <option value="monthly">Monthly</option> + <option value="yearly">Yearly</option> + </select> + <label>Interval</label> + <input type="number" name="interval" value={editIncome.interval} onChange={(e) => handleChange(e, setEditIncome)} /> + </> + )} + </div> + + <div className="recurring-right"> + <label>End Repeat</label> + <select name="end_repeat" value={editIncome.end_repeat} onChange={(e) => handleChange(e, setEditIncome)}> + <option value="never">Never</option> + <option value="on_date">On Date</option> + </select> + + {editIncome.end_repeat === "on_date" && ( + <input type="date" name="end_date" value={editIncome.end_date} onChange={(e) => handleChange(e, setEditIncome)} /> + )} + </div> + </div> + )} + <button type="submit" className="add-income-btn">Save Changes</button> + <button type="button" className="close-modal-btn" onClick={closeModal}>Cancel</button> + </form> + </div> + </div> + )} + + </div> + ); +}; + +export default Incomes; + + + diff --git a/financial-tracker/frontend/src/pages/IncomeChart.js b/financial-tracker/frontend/src/pages/IncomeChart.js new file mode 100644 index 0000000000000000000000000000000000000000..83fc4a3e9732a05a6e9ae6e6b449d7c0cfbe0e99 --- /dev/null +++ b/financial-tracker/frontend/src/pages/IncomeChart.js @@ -0,0 +1,218 @@ +import React, { useEffect, useState } from "react"; +import { Pie, Bar, Line } from "react-chartjs-2"; +import { + Chart, + ArcElement, + Tooltip, + Legend, + CategoryScale, + LinearScale, + BarElement, + PointElement, + LineElement, + LineController, +} from "chart.js"; +import "../styles/IncomeChart.css"; + +Chart.register( + ArcElement, + Tooltip, + Legend, + CategoryScale, + LinearScale, + BarElement, + PointElement, + LineElement, + LineController +); + +const IncomeChart = ({ incomes, type, timeRange }) => { + const [sourceTotals, setSourceTotals] = useState({}); + const [timeSeriesData, setTimeSeriesData] = useState({}); + + const colorPalette = [ + "#36A2EB", "#FF6384", "#FFCE56", "#4CAF50", "#FF9800", "#9C27B0", + "#FF5733", "#33FF57", "#3357FF", "#FF33A2", "#A233FF", "#57FF33", + "#FFD700", "#800080", "#00BFFF", "#00FF00", "#FF1493", "#8A2BE2", + "#FF6347", "#7CFC00", "#FF4500", "#32CD32", "#ADFF2F", "#FF8C00", + ]; + + const groupIncomesByTime = (incomes, range) => { + const grouped = {}; + + incomes.forEach((income) => { + const date = new Date(income.date); + let timeKey; + + if (range === "daily") { + timeKey = date.toISOString().split("T")[0]; + } else if (range === "weekly") { + const weekNumber = getWeekNumber(date); + timeKey = `${date.getFullYear()}-W${weekNumber}`; + } else if (range === "monthly") { + timeKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; + } else if (range === "yearly") { + timeKey = `${date.getFullYear()}`; + } + + if (!grouped[income.source]) { + grouped[income.source] = {}; + } + grouped[income.source][timeKey] = + (grouped[income.source][timeKey] || 0) + income.amount; + }); + + return grouped; + }; + + const getWeekNumber = (date) => { + const startOfYear = new Date(date.getFullYear(), 0, 1); + const pastDays = Math.floor((date - startOfYear) / (24 * 60 * 60 * 1000)); + return Math.ceil((pastDays + startOfYear.getDay() + 1) / 7); + }; + + useEffect(() => { + if (incomes.length > 0) { + const totals = incomes.reduce((acc, income) => { + acc[income.source] = (acc[income.source] || 0) + income.amount; + return acc; + }, {}); + setSourceTotals(totals); + + const groupedData = groupIncomesByTime(incomes, timeRange); + setTimeSeriesData(groupedData); + } + }, [incomes, timeRange]); + + const dataColors = Object.keys(sourceTotals).map((_, index) => { + return colorPalette[index % colorPalette.length]; + }); + + const pieData = { + labels: Object.keys(sourceTotals), + datasets: [ + { + label: "Income by Source", + data: Object.values(sourceTotals), + backgroundColor: dataColors, + }, + ], + }; + + const barData = { + labels: Object.keys(sourceTotals), + datasets: [ + { + label: "Income Amount", + data: Object.values(sourceTotals), + backgroundColor: dataColors, + }, + ], + }; + + const timeLabels = [ + ...new Set( + Object.values(timeSeriesData).flatMap((data) => Object.keys(data)) + ), + ].sort(); + + const lineDatasets = Object.keys(timeSeriesData).map((source, index) => ({ + label: source, + data: timeLabels.map((label) => timeSeriesData[source]?.[label] || 0), + borderColor: colorPalette[index % colorPalette.length], + backgroundColor: colorPalette[index % colorPalette.length], + fill: false, + tension: 0.1, + })); + + const lineData = { + labels: timeLabels, + datasets: lineDatasets, + }; + + const options = { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true, + title: { + display: true, + text: "Amount Earned", + }, + }, + x: { + title: { + display: true, + text: + type === "line" + ? `Time (${timeRange.charAt(0).toUpperCase() + timeRange.slice(1)})` + : "Sources", + }, + }, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + mode: "index", + intersect: false, + }, + }, + }; + + return ( + <div className="expense-chart-container"> + {/* Pie Chart Section */} + {type === "pie" && ( + <div className="chart-section"> + <div className="text-container"> + <h4>Pie Chart: Income Breakdown</h4> + <p> + This pie chart illustrates the distribution of your income sources. + Each slice shows how much a particular source contributes to your total income. + </p> + </div> + <div className="chart"> + <Pie data={pieData} /> + </div> + </div> + )} + + {/* Bar Chart Section */} + {type === "bar" && ( + <div className="chart-section"> + <div className="text-container"> + <h4>Bar Chart: Income by Source</h4> + <p> + This bar chart compares how much you’ve earned from different sources. + It gives a clear view of your top income contributors. + </p> + </div> + <div className="chart"> + <Bar data={barData} options={options} /> + </div> + </div> + )} + + {/* Line Chart Section */} + {type === "line" && ( + <div className="chart-section"> + <div className="text-container"> + <h4>Line Chart: Income Trends Over Time</h4> + <p> + The line chart shows how your income from each source has varied over time. + This helps track your financial growth or identify inconsistencies. + </p> + </div> + <div className="chart"> + <Line data={lineData} options={options} /> + </div> + </div> + )} + </div> + ); +}; + +export default IncomeChart; diff --git a/financial-tracker/frontend/src/pages/Register.js b/financial-tracker/frontend/src/pages/Register.js index 6c9c327de06922455d8decc3c4fcdb090416dd17..9c46a58e966eada3295331b2f481b930a32fe16f 100644 --- a/financial-tracker/frontend/src/pages/Register.js +++ b/financial-tracker/frontend/src/pages/Register.js @@ -1,12 +1,18 @@ -import React from "react"; +import React, { useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { Formik, Form, Field, ErrorMessage } from "formik"; import * as Yup from "yup"; import axios from "axios"; import "../styles/Register.css"; +import { FaEye, FaEyeSlash } from "react-icons/fa"; + const Register = () => { const navigate = useNavigate(); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + + // Define validation schema using Yup const validationSchema = Yup.object().shape({ @@ -35,7 +41,7 @@ const Register = () => { {/* Register Form */} <div className="register-container"> <h2>Register</h2> - + <Formik initialValues={{ firstName: "", lastName: "", email: "", password: "" }} validationSchema={validationSchema} @@ -60,30 +66,89 @@ const Register = () => { {status?.error && <p className="error-message">{status.error}</p>} {status?.success && <p className="success-message">{status.success}</p>} + <div className="form-group"> - <label htmlFor="firstName">First Name:</label> + <label htmlFor="firstName"> + First Name <span className="required-asterisk">*</span> + </label> <Field type="text" name="firstName" className="form-control" /> <ErrorMessage name="firstName" component="div" className="error" /> </div> <div className="form-group"> - <label htmlFor="lastName">Last Name:</label> + <label htmlFor="lastName"> + Last Name <span className="required-asterisk">*</span> + </label> <Field type="text" name="lastName" className="form-control" /> <ErrorMessage name="lastName" component="div" className="error" /> </div> <div className="form-group"> - <label htmlFor="email">Email:</label> + <label htmlFor="email"> + Email <span className="required-asterisk">*</span> + </label> <Field type="email" name="email" className="form-control" /> <ErrorMessage name="email" component="div" className="error" /> </div> - + <div className="form-group"> - <label htmlFor="password">Password:</label> - <Field type="password" name="password" className="form-control" /> - <ErrorMessage name="password" component="div" className="error" /> - </div> + <label htmlFor="password"> + Password <span className="required-asterisk">*</span> + </label> + <Field name="password"> + {({ field, form }) => { + // Update local password state every time the field changes + const handlePasswordChange = (e) => { + setPassword(e.target.value); + form.setFieldValue("password", e.target.value); // Keep Formik in sync + }; + + return ( + <> + <div className="password-wrapper"> + <input + {...field} + type={showPassword ? "text" : "password"} + className="form-control" + onChange={handlePasswordChange} + placeholder="Enter your password" + /> + <span + className="toggle-password" + onClick={() => setShowPassword((prev) => !prev)} + title={showPassword ? "Hide password" : "Show password"} + > + {showPassword ? <FaEyeSlash /> : <FaEye />} + </span> + + + </div> + + {/* Dynamic password feedback */} + <ul className="password-rules"> + <li className={password.length >= 8 ? "valid" : "invalid"}> + {password.length >= 8 ? "✔" : "✖"} At least 8 characters + </li> + <li className={/[A-Z]/.test(password) ? "valid" : "invalid"}> + {/[A-Z]/.test(password) ? "✔" : "✖"} One uppercase letter + </li> + <li className={/[a-z]/.test(password) ? "valid" : "invalid"}> + {/[a-z]/.test(password) ? "✔" : "✖"} One lowercase letter + </li> + <li className={/\d/.test(password) ? "valid" : "invalid"}> + {/\d/.test(password) ? "✔" : "✖"} One number + </li> + <li className={/[@$!%*?&_]/.test(password) ? "valid" : "invalid"}> + {/[@$!%*?&_]/.test(password) ? "✔" : "✖"} One special character + </li> + </ul> + </> + ); + }} + </Field> + </div> + <button type="submit" className="btn" disabled={isSubmitting}> {isSubmitting ? "Registering..." : "Register"} </button> diff --git a/financial-tracker/frontend/src/styles/Income.css b/financial-tracker/frontend/src/styles/Income.css new file mode 100644 index 0000000000000000000000000000000000000000..f9b1f9c367c360895d52df1bc24f9b6ec822be92 --- /dev/null +++ b/financial-tracker/frontend/src/styles/Income.css @@ -0,0 +1,617 @@ +/* Main Container */ +.income-container { + width: 80%; + margin: auto; +} + + +/* Income font */ +h1 { + font-size: 2.5em; + text-align: center; +} + +.income-summary h2 { + font-size: 2em; + margin-top: 15px; + font-weight: bold; + text-align: center; +} + + + +.spending-distribution h3, +.spending-trend h3 { + font-size: 1.5em; + margin-top: 15px; + font-weight: bold; + text-align: left; +} + +.income-summary p { + font-size: 1.3em; + margin-top: 10px; + line-height: 1.5; + text-align: justify; +} + +.spending-distribution p, +.spending-trend p { + font-size: 1.1em; + margin-top: 10px; + line-height: 1.5; + text-align: justify; +} + +/* Modal Overlay */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 999; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease; +} + +.modal-overlay.show { + opacity: 1; + pointer-events: auto; +} + + +/* Modal Content */ +.modal-content { + background-color: #fff; + padding: 20px; + border-radius: 10px; + width: 500px; + max-width: 100%; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + text-align: center; +} + +.modal-content .input-group label { + font-weight: bold; +} + +.modal-content .income-row { + align-items: flex-start; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 15px; + width: 100%; +} + +.modal-content .income-row .input-group { + box-sizing: border-box; + flex: 1 1 calc(50% - 15px); + min-width: 200px; + max-width: 250px; + box-sizing: border-box; +} + +.modal-content .input-group input, +.modal-content .input-group select { + width: 80%; + padding: 8px; + border: 1px solid #ccc; + border-radius: 5px; + max-width: 250px; +} + +.modal-content .custom-recurring label, +.modal-content .recurring-left label, +.modal-content .recurring-right label { + margin-top: 10px; + font-weight: bold; +} + +.modal-content .recurring-options { + display: flex; + justify-content: space-between; + gap: 20px; + margin-top: 20px; + width: 100%; +} + + +.modal-content .recurring-left, +.modal-content .recurring-right { + display: flex; + flex-direction: column; + gap: 10px; + flex: 1; +} + +.modal-content .input-group { + margin-bottom: 15px; +} + +.modal-content .custom-recurring { + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.modal-content .custom-recurring input, +.modal-content .custom-recurring select { + width: 100%; +} + + +/* income Form */ +.income-form { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + font-weight: bold; + margin-bottom: 20px; + padding: 20px; + border-radius: 10px; + background-color: #f9f9f9; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.income-inputs { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 10px; +} + +.input-group { + flex: 1; + min-width: calc(50% - 5px); +} + +.income-inputs input, +.income-inputs select { + padding: 8px; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 5px; + width: calc(50% - 5px); + box-sizing: border-box; +} + +.income-inputs input[type="checkbox"] { + width: auto; +} + +.income-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + width: 100%; +} + +.income-row .input-group { + flex: 1; + min-width: calc(50% - 5px); +} + +.income-inputs label { + font-weight: bold; +} + +.checkbox-container { + display: flex; + justify-content: center; + align-items: center; + width: 100%; +} + +.checkbox-label { + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: bold; + gap: 5px; +} +/* Recurring Options */ +.recurring-options { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + width: 100%; + max-width: 600px; + background-color: #f8f8f8; + padding: 15px; + border-radius: 5px; + border: 1px solid #ccc; +} + +.recurring-options label { + display: block; + font-size: 14px; + font-weight: bold; + margin-bottom: 5px; +} + +.recurring-left, +.recurring-right { + display: flex; + flex-direction: column; + gap: 10px; +} + +.recurring-options select, +.recurring-options input { + width: 100%; + padding: 8px; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 5px; + background-color: #fff; + box-sizing: border-box; +} + +.recurring-options input[type="date"], +.recurring-options input[type="number"] { + appearance: none; + width: 100%; +} + + +/* Custom Recurrence */ +.custom-recurring { + display: flex; + flex-direction: column; + gap: 10px; +} + +/* End Repeat Section */ +.end-repeat { + margin-top: 10px; +} + +/* Add / Submit Button */ +.add-income-btn { + padding: 10px 15px; + font-size: 14px; + background-color: #007bff; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + margin-top: 20px; +} + +.add-income-btn:hover { + background-color: #0056b3; +} + +/* Close Button */ +.close-modal-btn { + padding: 10px 15px; + font-size: 14px; + background-color: #dc3545; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + margin-top: 10px; +} + +.close-modal-btn:hover { + background-color: #c82333; +} + +/* income Table */ +.income-table { + width: 100%; + max-width: 800px; + margin: auto; + border-collapse: collapse; + margin-top: 20px; +} + +th.sortable { + cursor: pointer; +} + +.income-table th, +.income-table td { + border: 1px solid #ddd; + padding: 10px; + text-align: center; +} + +.income-table th { + background-color: #f4f4f4; +} + +.income-table tr:nth-child(even) { + background-color: #f9f9f9; +} + +.income-table tr:hover { + background-color: #f1f1f1; +} + +/* Action Buttons */ +.income-table button { + padding: 5px 10px; + font-size: 12px; + border: none; + cursor: pointer; + margin: 2px; + border-radius: 3px; +} + +.income-table button:first-child { + background-color: #ffc107; + color: black; +} + +.income-table button:last-child { + background-color: #dc3545; + color: white; +} + +/* Filter Dropdown */ +.filter-dropdown { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 20px; + align-items: flex-start; + margin-top: 20px; + max-width: 600px; + margin-left: auto; + margin-right: auto; +} + +/* Each column */ +.filter-column { + display: flex; + flex-direction: column; + gap: 10px; + flex: 1; + min-width: 180px; +} + +/* Input and select styling */ +.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; +} + +.filter-column select { + cursor: pointer; +} + +/* Apply Filters Button */ +.apply-filters-container { + width: 100%; + display: flex; + justify-content: center; +} + +/* 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; +} + +/* Responsive: Stack on small screens */ +@media (max-width: 600px) { + .income-inputs { + flex-direction: column; + width: 100%; + } + + .income-inputs input, + .income-inputs select { + width: 100%; + } + + .income-table { + font-size: 12px; + } + + .income-table th, + .income-table td { + padding: 8px; + } + + .filter-dropdown { + flex-direction: column; + align-items: center; + } + + .filter-column { + width: 100%; + } + + .income-inputs { + grid-template-columns: 1fr; + } + + .recurring-options { + grid-template-columns: 1fr; + } + + .chart-type-selector, + .time-range-selector { + flex-direction: column; + align-items: flex-start; + } + + .chart-type-selector select, + .time-range-selector select { + width: 100%; + } +} + +.delete-message { + padding: 10px; + margin-top: 10px; + background-color: #f44336; + color: white; + border-radius: 5px; + text-align: center; +} + +.success-message { + padding: 10px; + margin-top: 10px; + background-color: #4CAF50; + color: white; + border-radius: 5px; + text-align: center; +} + +/* 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; +} + + +/* Chart Type Selector */ +.chart-type-selector { + display: flex; + align-items: center; + gap: 10px; + margin-top: 20px; + margin-bottom: 20px; + font-size: 16px; +} + +.chart-type-selector label { + font-weight: bold; +} + +.chart-type-selector select { + padding: 8px; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 5px; + background-color: #f8f8f8; + cursor: pointer; + transition: border-color 0.3s; +} + +.chart-type-selector select:focus { + border-color: #007bff; + outline: none; +} + +/* Time Range Selector */ +.time-range-selector { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 20px; + font-size: 16px; +} + +.time-range-selector label { + font-weight: bold; +} + +.time-range-selector select { + padding: 8px; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 5px; + background-color: #f8f8f8; + cursor: pointer; + transition: border-color 0.3s; +} + +.time-range-selector select:focus { + border-color: #007bff; + outline: none; +} + + diff --git a/financial-tracker/frontend/src/styles/IncomeChart.css b/financial-tracker/frontend/src/styles/IncomeChart.css new file mode 100644 index 0000000000000000000000000000000000000000..d808affb8ad33154cc34ee38e39f0d3e1af59e82 --- /dev/null +++ b/financial-tracker/frontend/src/styles/IncomeChart.css @@ -0,0 +1,74 @@ +.charts-container { + display: flex; + gap: 20px; + justify-content: center; + margin-top: 20px; + } + + .chart-item { + flex: 1; + max-width: 100%; + } + + .chart canvas { + width: 100% !important; + height: 100% !important; + min-height: 350px !important; + } + + .income-chart-container { + margin: 20px; + } + + .chart-section { + margin-bottom: 30px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: auto; + min-height: 450px; + } + + .chart-section .text-container { + width: 100%; + max-width: 800px; + text-align: left; + } + + .chart-section h4 { + margin-top: 15px; + font-size: 1.2em; + font-weight: bold; + text-align: center; + } + + .chart-section p { + font-size: 1em; + margin-top: 10px; + line-height: 1.5; + text-align: center !important; + } + + .chart { + margin-top: 20px; + height: 400px !important; + min-height: 400px; + max-height: 500px; + position: relative; + } + + .line-chart-container { + margin-top: 30px; + padding: 15px; + background-color: #f1f1f1; + border-radius: 10px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + } + + .line-chart-container h3 { + font-size: 1.5rem; + margin-bottom: 15px; + color: #333; + } + \ No newline at end of file diff --git a/financial-tracker/frontend/src/styles/Register.css b/financial-tracker/frontend/src/styles/Register.css index 3f7ccaed2a6685e8d478d6b4bb938d4f38f038c7..34fa00a4ad1cb9b96c010065a9e313f0bdde767f 100644 --- a/financial-tracker/frontend/src/styles/Register.css +++ b/financial-tracker/frontend/src/styles/Register.css @@ -97,3 +97,35 @@ button:hover { .login-link a:hover { text-decoration: underline; } + +.password-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.password-wrapper input { + width: 100%; + padding-right: 2.5rem; +} + +.toggle-password { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + font-size: 1.2rem; + color: rgba(0, 0, 0, 0.3); + cursor: pointer; + transition: color 0.2s; +} + +.toggle-password:hover { + color: rgba(0, 0, 0, 0.6); +} + +.required-asterisk { + color: red; + margin-left: 0.2rem; +} + diff --git a/financial-tracker/package-lock.json b/financial-tracker/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..bc227805b354553200e0a7bc37123691ce8c2f08 --- /dev/null +++ b/financial-tracker/package-lock.json @@ -0,0 +1,378 @@ +{ + "name": "financial-tracker", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "bootstrap": "^5.3.5", + "react-bootstrap": "^2.10.9" + } + }, + "node_modules/@babel/runtime": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.7", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.7.tgz", + "integrity": "sha512-GQygZaGlmYjmYM+tiNBA5C6acmiDWF52Nqd40bBp0Znk4M4hP+LTmI0lpI1BuKMw45T8RIhrAsICIfKwZvi2Gg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@restart/hooks": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", + "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.9.4.tgz", + "integrity": "sha512-N4C7haUc3vn4LTwVUPlkJN8Ach/+yIMvRuTVIhjilNHqegY60SGLrzud6errOMNJwSnmYFnt1J0H/k8FE3A4KA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@popperjs/core": "^2.11.8", + "@react-aria/ssr": "^3.5.0", + "@restart/hooks": "^0.5.0", + "@types/warning": "^3.0.3", + "dequal": "^2.0.3", + "dom-helpers": "^5.2.0", + "uncontrollable": "^8.0.4", + "warning": "^4.0.3" + }, + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + } + }, + "node_modules/@restart/ui/node_modules/@restart/hooks": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.5.1.tgz", + "integrity": "sha512-EMoH04NHS1pbn07iLTjIjgttuqb7qu4+/EyhAx27MHpoENcB2ZdSsLTNxmKD+WEPnZigo62Qc8zjGnNxoSE/5Q==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui/node_modules/uncontrollable": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz", + "integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.14.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.0.tgz", + "integrity": "sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/warning": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", + "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==", + "license": "MIT" + }, + "node_modules/bootstrap": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.5.tgz", + "integrity": "sha512-ct1CHKtiobRimyGzmsSldEtM03E8fcEX4Tb3dGXz1V8faRwM50+vfHwTzOxB3IlKO7m+9vTH3s/3C6T2EAPeTA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types-extra": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", + "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", + "license": "MIT", + "dependencies": { + "react-is": "^16.3.2", + "warning": "^4.0.0" + }, + "peerDependencies": { + "react": ">=0.14.0" + } + }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-bootstrap": { + "version": "2.10.9", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.9.tgz", + "integrity": "sha512-TJUCuHcxdgYpOqeWmRApM/Dy0+hVsxNRFvq2aRFQuxhNi/+ivOxC5OdWIeHS3agxvzJ4Ev4nDw2ZdBl9ymd/JQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@restart/hooks": "^0.4.9", + "@restart/ui": "^1.9.4", + "@types/prop-types": "^15.7.12", + "@types/react-transition-group": "^4.4.6", + "classnames": "^2.3.2", + "dom-helpers": "^5.2.1", + "invariant": "^2.2.4", + "prop-types": "^15.8.1", + "prop-types-extra": "^1.1.0", + "react-transition-group": "^4.4.5", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + }, + "peerDependencies": { + "@types/react": ">=16.14.8", + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", + "license": "MIT" + }, + "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/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT", + "peer": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + }, + "peerDependencies": { + "react": ">=15.0.0" + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + } + } +} diff --git a/financial-tracker/package.json b/financial-tracker/package.json new file mode 100644 index 0000000000000000000000000000000000000000..72a87f2d71eefe128a39a311e2e4f714ecdd9faa --- /dev/null +++ b/financial-tracker/package.json @@ -0,0 +1,6 @@ +{ + "dependencies": { + "bootstrap": "^5.3.5", + "react-bootstrap": "^2.10.9" + } +} diff --git a/financial-tracker/requirements.txt b/financial-tracker/requirements.txt index b865926eae7539ee6a8aa5e8a4b5604f38cb7bdf..b8d9b5e4abe655328aa8a22037c9704581d8fc06 100644 Binary files a/financial-tracker/requirements.txt and b/financial-tracker/requirements.txt differ diff --git a/financial-tracker/transaction-service/Dockerfile b/financial-tracker/transaction-service/Dockerfile index 1c043517201d3b681be99761e927181f217cb741..e9e7b574dd32be04b6e02f5de089e86e5cef09e4 100644 --- a/financial-tracker/transaction-service/Dockerfile +++ b/financial-tracker/transaction-service/Dockerfile @@ -10,9 +10,11 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Copy only the current service's code -COPY ./transaction-service/app /app +COPY ./transaction-service/app/app.py ./app.py # Expose the service port EXPOSE 5002 CMD ["python", "app.py"] +CMD ["sh", "-c", "echo Starting Transaction Service && ls -l && python app.py"] + diff --git a/financial-tracker/transaction-service/app/app.py b/financial-tracker/transaction-service/app/app.py index c78d6bda411483a425d1ed2fa1e0c740cf4a8fea..beaadaac746eb0f7848604975aa8378f09ec7ab5 100644 --- a/financial-tracker/transaction-service/app/app.py +++ b/financial-tracker/transaction-service/app/app.py @@ -1,3 +1,5 @@ +from flask import Flask, jsonify, request +from datetime import datetime from datetime import datetime, timedelta from dateutil.relativedelta import relativedelta from flask import Flask, request, jsonify @@ -169,10 +171,308 @@ class Expense(db.Model): "end_date": self.end_date.strftime('%Y-%m-%d') if self.end_date else None } +class Income(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, nullable=False) + amount = db.Column(db.Float, nullable=False) + currency = db.Column(db.Enum(CurrencyEnum), nullable=False) + source = db.Column(db.String(100), nullable=False) + date = db.Column(db.Date, nullable=False) + description = db.Column(db.String(255)) + + #recurring fields + recurring = db.Column(db.Boolean, default=False, nullable=False) + recurring_type = db.Column(db.Enum(RecurringTypeEnum), nullable=True) + frequency = db.Column(db.Enum(FrequencyEnum), nullable=True) + interval = db.Column(db.Integer, nullable=True) + end_repeat = db.Column(db.Enum(EndRepeatEnum), nullable=True) + end_date = db.Column(db.Date, nullable=True) + + def to_dict(self): + return { + "id": self.id, + "user_id": self.user_id, + "amount": self.amount, + "currency": self.currency.value, # Convert Enum to its string value + "source": self.source, + "date": self.date.strftime('%Y-%m-%d'), + "description": self.description, + "recurring": self.recurring, + "recurring_type": self.recurring_type.value if self.recurring_type else None, + "frequency": self.frequency.value if self.frequency else None, + "interval": self.interval, + "end_repeat": self.end_repeat.value if self.end_repeat else None, + "end_date": self.end_date.strftime('%Y-%m-%d') if self.end_date else None + } + + +#INCOMES +@app.route('/income', methods=['POST']) +@jwt_required() +def add_income(): + """Add a new income for the logged-in user""" + data = request.get_json() + app.logger.info(f"Received income data: {data}") + + required_fields = ["amount", "currency", "source", "date", "description", "recurring"] + missing_fields = [field for field in required_fields if field not in data] + + if missing_fields: + return jsonify({"error": f"Missing fields: {', '.join(missing_fields)}"}), 400 + + try: + user_id = int(get_jwt_identity()) + if not user_id: + app.logger.error("User ID is missing or invalid.") + return jsonify({"error": "User authentication failed"}), 401 + try: + income_date = datetime.strptime(data["date"], '%Y-%m-%d').date() + if income_date > datetime.today().date(): + app.logger.error(f"Invalid date: {income_date}. Future incomes are not allowed.") + return jsonify({"error": "Income date cannot be in the future"}), 400 + except ValueError: + app.logger.error(f"Invalid date format: {data['date']}") + return jsonify({"error": "Invalid date format. Use YYYY-MM-DD"}), 400 + + + # Convert optional enums + recurring_type = data.get("recurring_type") or None + frequency = data.get("frequency") or None + interval = data.get("interval") or None + interval = int(interval) if interval not in [None, ""] else None + end_repeat = data.get("end_repeat") or None + end_date = data.get("end_date") or None + + # Convert end_date to a Date object if provided + if end_date: + try: + end_date = datetime.strptime(end_date, '%Y-%m-%d').date() + if end_date < income_date: + app.logger.error("End date cannot be before the expense date.") + return jsonify({"error": "End date must be after the expense date"}), 400 + except ValueError: + app.logger.error(f"Invalid end_date format: {end_date}") + return jsonify({"error": "Invalid end_date format. Use YYYY-MM-DD"}), 400 + + + if data["recurring"] and recurring_type == "custom" and (not frequency or not interval): + return jsonify({"error": "Custom recurrence requires frequency and interval"}), 400 + + new_income = Income( + user_id=user_id, + amount=data["amount"], + currency=CurrencyEnum[data["currency"]], + source=data["source"], + date=income_date, + description=data["description"], + recurring=data["recurring"], + recurring_type=RecurringTypeEnum(recurring_type) if recurring_type else None, + frequency=FrequencyEnum(frequency) if frequency else None, + interval=interval, + end_repeat=EndRepeatEnum(end_repeat) if end_repeat else None, + end_date=end_date + ) + + db.session.add(new_income) + db.session.commit() + + return jsonify({"message": "Income added", "income": new_income.to_dict()}), 201 + + except Exception as e: + app.logger.error(f"Error adding income: {str(e)}", exc_info=True) + return jsonify({"error": "An error occurred while adding income"}), 500 + +@app.route('/income', methods=['GET']) +@jwt_required() +def get_income(): + """Retrieve all income for the logged-in user""" + try: + user_id = int(get_jwt_identity()) + 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 + + incomes = Income.query.filter_by(user_id=user_id).all() + app.logger.info(f"Found {len(incomes)} incomes for user_id {user_id}") + + income_list = [income.to_dict() for income in incomes] + return jsonify({"incomes": income_list}), 200 + except Exception as e: + app.logger.error(f"Error fetching incomes: {str(e)}", exc_info=True) + return jsonify({"error": "Failed to retrieve incomes"}), 500 + + +@app.route('/income/<int:income_id>', methods=['PUT']) +@jwt_required() +def edit_income(income_id): + """Edit an income by ID for the logged-in user""" + try: + user_id = int(get_jwt_identity()) + 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 + + income = Income.query.filter_by(id=income_id, user_id=user_id).first() -@app.route('/transactions', methods=['GET']) -def get_transactions(): - return jsonify({"message": "Transaction service active"}), 200 + if not income: + app.logger.error(f"Income not found for ID {income_id} and User ID {user_id}") + return jsonify({"error": "Income not found"}), 404 + + data = request.get_json() + app.logger.info(f"Received data: {data}") + + if "date" in data: + try: + income.date = datetime.strptime(data["date"], '%Y-%m-%d').date() + except ValueError: + return jsonify({"error": "Invalid date format. Use YYYY-MM-DD"}), 400 + + if "end_date" in data: + end_date_raw = data["end_date"] + if end_date_raw: + try: + end_date = datetime.strptime(end_date_raw, "%Y-%m-%d").date() + if income.date and end_date < income.date: + return jsonify({"error": "End date must be after the income date"}), 400 + income.end_date = end_date + except ValueError: + return jsonify({"error": "Invalid end_date format. Use YYYY-MM-DD"}), 400 + else: + income.end_date = None + + + # Validate custom interval if recurring_type is custom + if income.recurring and income.recurring_type == RecurringTypeEnum.CUSTOM: + if "interval" in data: + try: + interval_val = int(data["interval"]) + if interval_val <= 0: + return jsonify({"error": "Interval must be a positive number"}), 400 + income.interval = interval_val + except ValueError: + return jsonify({"error": "Interval must be an integer"}), 400 + # Set recurring_type enum + if "recurring_type" in data: + rt = data["recurring_type"] + if rt is not None: + rt = rt.lower() + mapping = { + "daily": RecurringTypeEnum.DAILY, + "weekly": RecurringTypeEnum.WEEKLY, + "every_two_weeks": RecurringTypeEnum.FORTNIGHTLY, + "monthly": RecurringTypeEnum.MONTHLY, + "yearly": RecurringTypeEnum.YEARLY, + "custom": RecurringTypeEnum.CUSTOM, + } + income.recurring_type = mapping.get(rt) + if income.recurring_type is None: + return jsonify({"error": "Invalid recurring_type"}), 400 + else: + income.recurring_type = None + + # Set frequency enum (only for custom) + # Set frequency enum (only for custom) + if income.recurring_type == RecurringTypeEnum.CUSTOM and "frequency" in data: + freq = data["frequency"] + if freq is not None: + freq = freq.lower() + freq_mapping = { + "daily": FrequencyEnum.DAILY, + "weekly": FrequencyEnum.WEEKLY, + "monthly": FrequencyEnum.MONTHLY, + "yearly": FrequencyEnum.YEARLY, + } + income.frequency = freq_mapping.get(freq) + if income.frequency is None: + return jsonify({"error": "Invalid frequency"}), 400 + else: + income.frequency = None + + # Set end_repeat enum + # Set end_repeat enum + if "end_repeat" in data: + er = data["end_repeat"] + if er is not None: + er = er.lower() + if er == "never": + income.end_repeat = EndRepeatEnum.NEVER + income.end_date = None + elif er == "on_date": + income.end_repeat = EndRepeatEnum.ON_DATE + else: + return jsonify({"error": "Invalid end_repeat value"}), 400 + else: + income.end_repeat = None + + + if "amount" in data: + income.amount = data["amount"] + if "source" in data: + income.source = data["source"] + if "description" in data: + income.description = data["description"] + if "recurring" in data: + income.recurring = data["recurring"] + + db.session.commit() + app.logger.info(f"Income updated: {income.to_dict()}") + return jsonify({"message": "Income updated", "income": income.to_dict()}), 200 + + except Exception as e: + app.logger.error(f"Error updating income: {str(e)}", exc_info=True) + return jsonify({"error": "An error occurred while updating income"}), 500 + +@app.route('/income/<int:income_id>', methods=['DELETE']) +@jwt_required() +def delete_income(income_id): + """Delete an expense by ID for the logged-in user""" + try: + user_id = int(get_jwt_identity()) + 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 + + income = Income.query.filter_by(id=income_id, user_id=user_id).first() + + if not income: + app.logger.error(f"Income not found for ID {income_id} and User ID {user_id}") + return jsonify({"error": "Income not found"}), 404 + + db.session.delete(income) + db.session.commit() + + app.logger.info(f"Income deleted: {income.to_dict()}") + return jsonify({"message": "Income deleted"}), 200 + + except Exception as e: + app.logger.error(f"Error deleting income: {str(e)}", exc_info=True) + return jsonify({"error": "An error occurred while deleting income"}), 500 + +@app.route('/income/sources', methods=['GET']) +@jwt_required() +def get_income_sources(): + """Retrieve distinct income sources for the logged-in user""" + try: + user_id = int(get_jwt_identity()) + 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 + + sources = db.session.query(Income.source).filter_by(user_id=user_id).distinct().all() + sources_list = [source[0] for source in sources] + app.logger.info(f"Source list: {sources_list}") + return jsonify({"sources": sources_list}), 200 + #return jsonify({"sources": [s[0] for s in sources]}), 200 + except Exception as e: + app.logger.error(f"Error fetching income sources: {str(e)}", exc_info=True) + return jsonify({"error": "Could not fetch sources"}), 500 # EXPENSES ROUTES