From 533f2d53980dd99924a2ffe09755e8331f2c32b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 17:32:36 +0000 Subject: [PATCH 1/3] Initial plan From 1a854391279ad2808cb61f220736d710e2e72af7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 17:45:12 +0000 Subject: [PATCH 2/3] Add backend API updates, CSS, and JavaScript files Co-authored-by: Krishmal2004 <184955509+Krishmal2004@users.noreply.github.com> --- app.py | 473 ++++++++++++++++++- config.py | 56 +++ database.sql | 81 ++++ requirements.txt | 6 + static/css/auth.css | 528 +++++++++++++++++++++ static/css/components.css | 919 +++++++++++++++++++++++++++++++++++++ static/css/dashboard.css | 669 +++++++++++++++++++++++++++ static/css/main.css | 299 ++++++++++++ static/js/auth.js | 302 ++++++++++++ static/js/cards.js | 388 ++++++++++++++++ static/js/charts.js | 463 +++++++++++++++++++ static/js/dashboard.js | 378 +++++++++++++++ static/js/expenses.js | 457 ++++++++++++++++++ static/js/main.js | 472 +++++++++++++++++++ static/js/notifications.js | 399 ++++++++++++++++ 15 files changed, 5869 insertions(+), 21 deletions(-) create mode 100644 config.py create mode 100644 database.sql create mode 100644 static/css/auth.css create mode 100644 static/css/components.css create mode 100644 static/css/dashboard.css create mode 100644 static/css/main.css create mode 100644 static/js/auth.js create mode 100644 static/js/cards.js create mode 100644 static/js/charts.js create mode 100644 static/js/dashboard.js create mode 100644 static/js/expenses.js create mode 100644 static/js/main.js create mode 100644 static/js/notifications.js diff --git a/app.py b/app.py index f88d978..d437f39 100644 --- a/app.py +++ b/app.py @@ -1,15 +1,26 @@ from flask import Flask, render_template, request, jsonify, session, redirect, url_for from flask_sqlalchemy import SQLAlchemy +from flask_cors import CORS from werkzeug.security import generate_password_hash, check_password_hash from datetime import datetime, timedelta +from decimal import Decimal import os +import logging from functools import wraps +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + # Initialize Flask app app = Flask(__name__) app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'your-secret-key-change-in-production') app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:///expense_tracker.db') app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=24) + +# Enable CORS +CORS(app, supports_credentials=True) # Initialize Database db = SQLAlchemy(app) @@ -29,27 +40,37 @@ class User(db.Model): address = db.Column(db.String(255), nullable=True) city = db.Column(db.String(120), nullable=True) state = db.Column(db.String(120), nullable=True) + monthly_salary = db.Column(db.Float, default=0.0) + profile_picture = db.Column(db.String(255), nullable=True) + notification_email = db.Column(db.Boolean, default=True) + notification_budget = db.Column(db.Boolean, default=True) created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) # Relationships bank_cards = db.relationship('BankCard', backref='user', lazy=True, cascade='all, delete-orphan') expenses = db.relationship('Expense', backref='user', lazy=True, cascade='all, delete-orphan') + notifications = db.relationship('Notification', backref='user', lazy=True, cascade='all, delete-orphan') def set_password(self, password): - self. password_hash = generate_password_hash(password) + self.password_hash = generate_password_hash(password) def check_password(self, password): return check_password_hash(self.password_hash, password) def to_dict(self): return { - 'id': self. id, + 'id': self.id, 'full_name': self.full_name, 'email': self.email, 'phone': self.phone, 'address': self.address, 'city': self.city, 'state': self.state, + 'monthly_salary': self.monthly_salary, + 'profile_picture': self.profile_picture, + 'notification_email': self.notification_email, + 'notification_budget': self.notification_budget, 'created_at': self.created_at.strftime('%Y-%m-%d') } @@ -60,9 +81,13 @@ class BankCard(db.Model): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) card_name = db.Column(db.String(120), nullable=False) + card_holder_name = db.Column(db.String(120), nullable=True) card_number = db.Column(db.String(255), nullable=False) expiry_date = db.Column(db.String(10), nullable=False) - cvv = db.Column(db. String(10), nullable=False) + cvv = db.Column(db.String(10), nullable=False) + card_type = db.Column(db.String(10), default='debit') # 'credit' or 'debit' + bank_name = db.Column(db.String(100), nullable=True) + balance = db.Column(db.Float, default=0.0) created_at = db.Column(db.DateTime, default=datetime.utcnow) # Relationships @@ -78,8 +103,13 @@ def to_dict(self): return { 'id': self.id, 'card_name': self.card_name, + 'card_holder_name': self.card_holder_name, 'card_number': self.get_masked_number(), + 'card_last_four': self.card_number[-4:] if len(self.card_number) >= 4 else '', 'expiry_date': self.expiry_date, + 'card_type': self.card_type, + 'bank_name': self.bank_name, + 'balance': self.balance, 'created_at': self.created_at.strftime('%Y-%m-%d') } @@ -89,22 +119,44 @@ class Expense(db.Model): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - card_id = db.Column(db.Integer, db.ForeignKey('bank_cards.id'), nullable=False) - category = db.Column(db.String(50), nullable=False) + card_id = db.Column(db.Integer, db.ForeignKey('bank_cards.id'), nullable=True) amount = db.Column(db.Float, nullable=False) + category = db.Column(db.String(50), nullable=False) description = db.Column(db.String(255), nullable=True) expense_date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + payment_method = db.Column(db.String(50), default='cash') # 'cash' or 'card' created_at = db.Column(db.DateTime, default=datetime.utcnow) def to_dict(self): return { 'id': self.id, 'card_id': self.card_id, - 'card_name': self.bank_card.card_name, + 'card_name': self.bank_card.card_name if self.bank_card else 'Cash', 'category': self.category, 'amount': self.amount, - 'description': self. description, + 'description': self.description, 'expense_date': self.expense_date.strftime('%Y-%m-%d'), + 'payment_method': self.payment_method, + 'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') + } + + +class Notification(db.Model): + __tablename__ = 'notifications' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + message = db.Column(db.Text, nullable=False) + type = db.Column(db.String(20), default='info') # 'warning', 'info', 'success' + is_read = db.Column(db.Boolean, default=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + def to_dict(self): + return { + 'id': self.id, + 'message': self.message, + 'type': self.type, + 'is_read': self.is_read, 'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') } @@ -165,6 +217,34 @@ def profile(): return render_template("profile.html") +@app.route("/cards") +def cards_page(): + if 'user_id' not in session: + return redirect(url_for('login_page')) + return render_template("cards.html") + + +@app.route("/add-expense") +def add_expense_page(): + if 'user_id' not in session: + return redirect(url_for('login_page')) + return render_template("add-expense.html") + + +@app.route("/settings") +def settings_page(): + if 'user_id' not in session: + return redirect(url_for('login_page')) + return render_template("settings.html") + + +@app.route("/reports") +def reports_page(): + if 'user_id' not in session: + return redirect(url_for('login_page')) + return render_template("reports.html") + + # =========================== # AUTHENTICATION API ROUTES # =========================== @@ -283,13 +363,13 @@ def change_password(): user = get_current_user() data = request.get_json() - if not user.check_password(data. get('current_password')): + if not user.check_password(data.get('current_password')): return jsonify({'error': 'Current password is incorrect'}), 401 if data.get('new_password') != data.get('confirm_password'): return jsonify({'error': 'New passwords do not match'}), 400 - if len(data. get('new_password', '')) < 8: + if len(data.get('new_password', '')) < 8: return jsonify({'error': 'Password must be at least 8 characters'}), 400 user.set_password(data['new_password']) @@ -302,6 +382,23 @@ def change_password(): return jsonify({'error': str(e)}), 500 +@app.route("/api/user/salary", methods=['PUT']) +@login_required +def update_salary(): + try: + user = get_current_user() + data = request.get_json() + + user.monthly_salary = float(data.get('monthly_salary', 0)) + db.session.commit() + + return jsonify({'message': 'Salary updated', 'monthly_salary': user.monthly_salary}), 200 + + except Exception as e: + db.session.rollback() + return jsonify({'error': str(e)}), 500 + + # =========================== # BANK CARD API ROUTES # =========================== @@ -317,19 +414,23 @@ def add_card(): return jsonify({'error': 'Missing required fields'}), 400 # Validate card number (basic check) - if len(data['card_number']. replace(' ', '')) != 16: + if len(data['card_number'].replace(' ', '')) != 16: return jsonify({'error': 'Invalid card number'}), 400 card = BankCard( user_id=user.id, card_name=data['card_name'], - card_number=data['card_number']. replace(' ', ''), + card_holder_name=data.get('card_holder_name', ''), + card_number=data['card_number'].replace(' ', ''), expiry_date=data['expiry_date'], - cvv=data['cvv'] + cvv=data['cvv'], + card_type=data.get('card_type', 'debit'), + bank_name=data.get('bank_name', ''), + balance=float(data.get('balance', 0)) ) - db.session. add(card) - db. session.commit() + db.session.add(card) + db.session.commit() return jsonify({ 'message': 'Card added successfully', @@ -424,26 +525,36 @@ def add_expense(): user = get_current_user() data = request.get_json() - if not all(k in data for k in ['card_id', 'category', 'amount']): + if not all(k in data for k in ['category', 'amount']): return jsonify({'error': 'Missing required fields'}), 400 - # Verify card belongs to user - card = BankCard.query.filter_by(id=data['card_id'], user_id=user.id). first() - if not card: - return jsonify({'error': 'Card not found'}), 404 + payment_method = data.get('payment_method', 'cash') + card_id = data.get('card_id') + + # Verify card belongs to user if card_id provided + if card_id and payment_method == 'card': + card = BankCard.query.filter_by(id=card_id, user_id=user.id).first() + if not card: + return jsonify({'error': 'Card not found'}), 404 + else: + card_id = None expense = Expense( user_id=user.id, - card_id=data['card_id'], + card_id=card_id, category=data['category'], amount=float(data['amount']), description=data.get('description', ''), + payment_method=payment_method, expense_date=datetime.fromisoformat(data.get('expense_date', datetime.utcnow().isoformat())) ) db.session.add(expense) db.session.commit() + # Check budget warning + check_budget_warning(user.id) + return jsonify({ 'message': 'Expense added successfully', 'expense': expense.to_dict() @@ -579,7 +690,7 @@ def delete_expense(expense_id): if not expense: return jsonify({'error': 'Expense not found'}), 404 - db. session.delete(expense) + db.session.delete(expense) db.session.commit() return jsonify({'message': 'Expense deleted successfully'}), 200 @@ -589,6 +700,326 @@ def delete_expense(expense_id): return jsonify({'error': str(e)}), 500 +# =========================== +# ANALYTICS API ROUTES +# =========================== + +@app.route("/api/analytics/monthly", methods=['GET']) +@login_required +def get_monthly_analytics(): + """Get monthly spending data for the past 12 months""" + try: + user = get_current_user() + now = datetime.now() + monthly_data = [] + + for i in range(11, -1, -1): + # Calculate month and year + target_date = now - timedelta(days=i*30) + month = target_date.month + year = target_date.year + + start_date = datetime(year, month, 1) + if month == 12: + end_date = datetime(year + 1, 1, 1) + else: + end_date = datetime(year, month + 1, 1) + + expenses = Expense.query.filter( + Expense.user_id == user.id, + Expense.expense_date >= start_date, + Expense.expense_date < end_date + ).all() + + total = sum(e.amount for e in expenses) + monthly_data.append({ + 'month': month, + 'year': year, + 'month_name': start_date.strftime('%b'), + 'total': total + }) + + return jsonify({'monthly_data': monthly_data}), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route("/api/analytics/category", methods=['GET']) +@login_required +def get_category_analytics(): + """Get category-wise expense breakdown""" + try: + user = get_current_user() + month = request.args.get('month', datetime.now().month, type=int) + year = request.args.get('year', datetime.now().year, type=int) + + start_date = datetime(year, month, 1) + if month == 12: + end_date = datetime(year + 1, 1, 1) + else: + end_date = datetime(year, month + 1, 1) + + expenses = Expense.query.filter( + Expense.user_id == user.id, + Expense.expense_date >= start_date, + Expense.expense_date < end_date + ).all() + + by_category = {} + for expense in expenses: + if expense.category not in by_category: + by_category[expense.category] = 0 + by_category[expense.category] += expense.amount + + category_data = [{'category': k, 'amount': v} for k, v in by_category.items()] + + return jsonify({ + 'month': month, + 'year': year, + 'total': sum(e.amount for e in expenses), + 'categories': category_data + }), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route("/api/analytics/trends", methods=['GET']) +@login_required +def get_spending_trends(): + """Get spending trends and insights""" + try: + user = get_current_user() + now = datetime.now() + + # Current month data + current_start = datetime(now.year, now.month, 1) + if now.month == 12: + current_end = datetime(now.year + 1, 1, 1) + else: + current_end = datetime(now.year, now.month + 1, 1) + + current_expenses = Expense.query.filter( + Expense.user_id == user.id, + Expense.expense_date >= current_start, + Expense.expense_date < current_end + ).all() + + current_total = sum(e.amount for e in current_expenses) + + # Previous month data + if now.month == 1: + prev_start = datetime(now.year - 1, 12, 1) + prev_end = current_start + else: + prev_start = datetime(now.year, now.month - 1, 1) + prev_end = current_start + + prev_expenses = Expense.query.filter( + Expense.user_id == user.id, + Expense.expense_date >= prev_start, + Expense.expense_date < prev_end + ).all() + + prev_total = sum(e.amount for e in prev_expenses) + + # Calculate change percentage + if prev_total > 0: + change_percent = ((current_total - prev_total) / prev_total) * 100 + else: + change_percent = 100 if current_total > 0 else 0 + + # Top spending category + by_category = {} + for expense in current_expenses: + if expense.category not in by_category: + by_category[expense.category] = 0 + by_category[expense.category] += expense.amount + + top_category = max(by_category, key=by_category.get) if by_category else None + + # Budget warning + budget_warning = False + if user.monthly_salary > 0: + budget_percent = (current_total / user.monthly_salary) * 100 + budget_warning = budget_percent >= 80 + else: + budget_percent = 0 + + return jsonify({ + 'current_month_total': current_total, + 'previous_month_total': prev_total, + 'change_percent': round(change_percent, 2), + 'top_category': top_category, + 'top_category_amount': by_category.get(top_category, 0) if top_category else 0, + 'budget_percent': round(budget_percent, 2), + 'budget_warning': budget_warning, + 'monthly_salary': user.monthly_salary, + 'remaining_budget': user.monthly_salary - current_total if user.monthly_salary > 0 else 0 + }), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route("/api/dashboard/summary", methods=['GET']) +@login_required +def get_dashboard_summary(): + """Get comprehensive dashboard summary""" + try: + user = get_current_user() + now = datetime.now() + + # Current month + current_start = datetime(now.year, now.month, 1) + if now.month == 12: + current_end = datetime(now.year + 1, 1, 1) + else: + current_end = datetime(now.year, now.month + 1, 1) + + monthly_expenses = Expense.query.filter( + Expense.user_id == user.id, + Expense.expense_date >= current_start, + Expense.expense_date < current_end + ).all() + + total_expenses = sum(e.amount for e in monthly_expenses) + + # Get all cards balance + cards = BankCard.query.filter_by(user_id=user.id).all() + total_card_balance = sum(c.balance for c in cards) + + # Transaction count + all_expenses = Expense.query.filter_by(user_id=user.id).all() + transaction_count = len(all_expenses) + + # Recent transactions (last 5) + recent_expenses = Expense.query.filter_by(user_id=user.id).order_by( + Expense.expense_date.desc() + ).limit(5).all() + + # Category breakdown + by_category = {} + for expense in monthly_expenses: + if expense.category not in by_category: + by_category[expense.category] = 0 + by_category[expense.category] += expense.amount + + return jsonify({ + 'user': user.to_dict(), + 'monthly_salary': user.monthly_salary, + 'total_expenses': total_expenses, + 'remaining_balance': user.monthly_salary - total_expenses if user.monthly_salary > 0 else 0, + 'total_card_balance': total_card_balance, + 'transaction_count': transaction_count, + 'recent_transactions': [e.to_dict() for e in recent_expenses], + 'category_breakdown': by_category, + 'cards_count': len(cards) + }), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +# =========================== +# NOTIFICATION API ROUTES +# =========================== + +@app.route("/api/notifications", methods=['GET']) +@login_required +def get_notifications(): + try: + user = get_current_user() + notifications = Notification.query.filter_by(user_id=user.id).order_by( + Notification.created_at.desc() + ).limit(20).all() + + return jsonify({ + 'notifications': [n.to_dict() for n in notifications] + }), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route("/api/notifications//read", methods=['PUT']) +@login_required +def mark_notification_read(notification_id): + try: + user = get_current_user() + notification = Notification.query.filter_by( + id=notification_id, user_id=user.id + ).first() + + if not notification: + return jsonify({'error': 'Notification not found'}), 404 + + notification.is_read = True + db.session.commit() + + return jsonify({'message': 'Notification marked as read'}), 200 + + except Exception as e: + db.session.rollback() + return jsonify({'error': str(e)}), 500 + + +@app.route("/api/notifications/read-all", methods=['PUT']) +@login_required +def mark_all_notifications_read(): + try: + user = get_current_user() + Notification.query.filter_by(user_id=user.id, is_read=False).update({'is_read': True}) + db.session.commit() + + return jsonify({'message': 'All notifications marked as read'}), 200 + + except Exception as e: + db.session.rollback() + return jsonify({'error': str(e)}), 500 + + +def check_budget_warning(user_id): + """Check if user exceeded 80% of budget and create notification""" + user = User.query.get(user_id) + if not user or user.monthly_salary <= 0: + return + + now = datetime.now() + current_start = datetime(now.year, now.month, 1) + if now.month == 12: + current_end = datetime(now.year + 1, 1, 1) + else: + current_end = datetime(now.year, now.month + 1, 1) + + expenses = Expense.query.filter( + Expense.user_id == user.id, + Expense.expense_date >= current_start, + Expense.expense_date < current_end + ).all() + + total = sum(e.amount for e in expenses) + budget_percent = (total / user.monthly_salary) * 100 + + if budget_percent >= 80: + # Check if warning already sent this month + existing = Notification.query.filter( + Notification.user_id == user.id, + Notification.type == 'warning', + Notification.created_at >= current_start + ).first() + + if not existing: + notification = Notification( + user_id=user.id, + message=f'Warning: You have spent {budget_percent:.1f}% of your monthly budget!', + type='warning' + ) + db.session.add(notification) + db.session.commit() + + # =========================== # ERROR HANDLERS # =========================== diff --git a/config.py b/config.py new file mode 100644 index 0000000..c252d73 --- /dev/null +++ b/config.py @@ -0,0 +1,56 @@ +""" +Configuration settings for the Expense Tracker application +""" +import os +from datetime import timedelta + + +class Config: + """Base configuration""" + SECRET_KEY = os.environ.get('SECRET_KEY', 'your-secret-key-change-in-production') + SQLALCHEMY_TRACK_MODIFICATIONS = False + PERMANENT_SESSION_LIFETIME = timedelta(hours=24) + + # Session configuration + SESSION_COOKIE_SECURE = False # Set to True in production with HTTPS + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = 'Lax' + + +class DevelopmentConfig(Config): + """Development configuration""" + DEBUG = True + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', 'sqlite:///expense_tracker.db') + + +class ProductionConfig(Config): + """Production configuration""" + DEBUG = False + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') + SESSION_COOKIE_SECURE = True + + # Additional security settings for production + @classmethod + def init_app(cls, app): + # Log to stderr + import logging + from logging import StreamHandler + file_handler = StreamHandler() + file_handler.setLevel(logging.INFO) + app.logger.addHandler(file_handler) + + +class TestingConfig(Config): + """Testing configuration""" + TESTING = True + SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' + WTF_CSRF_ENABLED = False + + +# Configuration dictionary +config = { + 'development': DevelopmentConfig, + 'production': ProductionConfig, + 'testing': TestingConfig, + 'default': DevelopmentConfig +} diff --git a/database.sql b/database.sql new file mode 100644 index 0000000..e182546 --- /dev/null +++ b/database.sql @@ -0,0 +1,81 @@ +-- Database Schema for Expense Tracker Application +-- This file creates the MySQL/MariaDB database structure + +-- Create database +CREATE DATABASE IF NOT EXISTS expense_tracker; +USE expense_tracker; + +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id INT PRIMARY KEY AUTO_INCREMENT, + full_name VARCHAR(120) NOT NULL, + email VARCHAR(120) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + phone VARCHAR(20), + address VARCHAR(255), + city VARCHAR(120), + state VARCHAR(120), + monthly_salary DECIMAL(10,2) DEFAULT 0.00, + profile_picture VARCHAR(255), + notification_email BOOLEAN DEFAULT TRUE, + notification_budget BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_email (email) +); + +-- Bank cards table +CREATE TABLE IF NOT EXISTS bank_cards ( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT NOT NULL, + card_name VARCHAR(120) NOT NULL, + card_holder_name VARCHAR(120), + card_number VARCHAR(255) NOT NULL, + expiry_date VARCHAR(10) NOT NULL, + cvv VARCHAR(10) NOT NULL, + card_type ENUM('credit', 'debit') DEFAULT 'debit', + bank_name VARCHAR(100), + balance DECIMAL(10,2) DEFAULT 0.00, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id) +); + +-- Expenses table +CREATE TABLE IF NOT EXISTS expenses ( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT NOT NULL, + card_id INT, + amount DECIMAL(10,2) NOT NULL, + category VARCHAR(50) NOT NULL, + description TEXT, + expense_date DATE NOT NULL, + payment_method VARCHAR(50) DEFAULT 'cash', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (card_id) REFERENCES bank_cards(id) ON DELETE SET NULL, + INDEX idx_user_id (user_id), + INDEX idx_expense_date (expense_date), + INDEX idx_category (category) +); + +-- Notifications table +CREATE TABLE IF NOT EXISTS notifications ( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT NOT NULL, + message TEXT NOT NULL, + type ENUM('warning', 'info', 'success') DEFAULT 'info', + is_read BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id), + INDEX idx_is_read (is_read) +); + +-- Sample data for testing (optional) +-- Insert a test user (password: 'password123') +-- INSERT INTO users (full_name, email, password_hash, monthly_salary) VALUES +-- ('John Doe', 'john@example.com', 'pbkdf2:sha256:600000$...', 5000.00); + +-- Insert sample categories (for reference) +-- Categories: Food, Transport, Shopping, Bills, Entertainment, Healthcare, Education, Others diff --git a/requirements.txt b/requirements.txt index e69de29..457a2ed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,6 @@ +Flask==2.3.3 +Flask-SQLAlchemy==3.1.1 +Flask-CORS==4.0.0 +Werkzeug==2.3.7 +SQLAlchemy==2.0.21 +python-dotenv==1.0.0 diff --git a/static/css/auth.css b/static/css/auth.css new file mode 100644 index 0000000..838dd3c --- /dev/null +++ b/static/css/auth.css @@ -0,0 +1,528 @@ +/* =========================== + Auth CSS - Login/Signup Styles + Expense Tracker Application + =========================== */ + +/* Auth Background */ +.auth-page { + min-height: 100vh; + background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%); + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +/* Landing Page Background */ +.landing-page { + min-height: 100vh; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +/* Auth Container */ +.auth-container { + width: 100%; + max-width: 440px; +} + +/* Auth Card */ +.auth-card { + background: var(--bg-primary); + border-radius: var(--border-radius-xl); + box-shadow: var(--shadow-xl); + padding: 2.5rem; + animation: fadeInUp 0.5s ease; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Auth Logo */ +.auth-logo { + text-align: center; + margin-bottom: 2rem; +} + +.auth-logo i { + font-size: 3rem; + color: var(--primary-color); + margin-bottom: 0.5rem; +} + +.auth-logo h1 { + font-size: var(--font-size-2xl); + color: var(--text-primary); + font-weight: 700; +} + +.auth-logo p { + color: var(--text-secondary); + font-size: var(--font-size-sm); +} + +/* Auth Form */ +.auth-form { + margin-top: 1.5rem; +} + +.auth-form .form-group { + margin-bottom: 1.25rem; +} + +.auth-form label { + display: block; + font-size: var(--font-size-sm); + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.auth-form .input-group { + position: relative; + display: flex; + align-items: center; +} + +.auth-form .input-icon { + position: absolute; + left: 1rem; + color: var(--text-light); + font-size: var(--font-size-sm); + pointer-events: none; +} + +.auth-form input { + width: 100%; + padding: 0.875rem 1rem 0.875rem 2.75rem; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + font-size: var(--font-size-base); + background: var(--bg-secondary); + color: var(--text-primary); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.auth-form input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px var(--primary-light); + background: var(--bg-primary); +} + +.auth-form input::placeholder { + color: var(--text-light); +} + +/* Password Toggle */ +.password-toggle { + position: absolute; + right: 1rem; + background: none; + border: none; + color: var(--text-light); + cursor: pointer; + padding: 0.25rem; + transition: color var(--transition-fast); +} + +.password-toggle:hover { + color: var(--text-secondary); +} + +/* Form Options Row */ +.auth-options { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.auth-options .checkbox-wrapper { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.auth-options .checkbox-wrapper input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--primary-color); +} + +.auth-options .checkbox-wrapper label { + font-size: var(--font-size-sm); + color: var(--text-secondary); + margin-bottom: 0; +} + +.auth-options .forgot-link { + font-size: var(--font-size-sm); + color: var(--primary-color); + font-weight: 500; +} + +.auth-options .forgot-link:hover { + text-decoration: underline; +} + +/* Auth Button */ +.auth-btn { + width: 100%; + padding: 0.875rem 1.5rem; + background: var(--primary-color); + color: var(--text-white); + border: none; + border-radius: var(--border-radius); + font-size: var(--font-size-base); + font-weight: 600; + cursor: pointer; + transition: background var(--transition-fast), transform var(--transition-fast); + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.auth-btn:hover { + background: var(--primary-dark); + transform: translateY(-1px); +} + +.auth-btn:active { + transform: translateY(0); +} + +.auth-btn:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; +} + +/* Auth Footer */ +.auth-footer { + text-align: center; + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border-color); +} + +.auth-footer p { + font-size: var(--font-size-sm); + color: var(--text-secondary); + margin-bottom: 0; +} + +.auth-footer a { + color: var(--primary-color); + font-weight: 600; +} + +/* Social Login Divider */ +.auth-divider { + display: flex; + align-items: center; + margin: 1.5rem 0; +} + +.auth-divider::before, +.auth-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border-color); +} + +.auth-divider span { + padding: 0 1rem; + font-size: var(--font-size-sm); + color: var(--text-light); +} + +/* Error Message */ +.auth-error { + background: #fef2f2; + border: 1px solid #fecaca; + color: var(--danger-color); + padding: 0.75rem 1rem; + border-radius: var(--border-radius); + font-size: var(--font-size-sm); + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.auth-error i { + font-size: var(--font-size-base); +} + +/* Success Message */ +.auth-success { + background: #f0fdf4; + border: 1px solid #bbf7d0; + color: var(--success-color); + padding: 0.75rem 1rem; + border-radius: var(--border-radius); + font-size: var(--font-size-sm); + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Terms Checkbox */ +.terms-checkbox { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin-bottom: 1.5rem; +} + +.terms-checkbox input { + width: 18px; + height: 18px; + margin-top: 2px; + accent-color: var(--primary-color); +} + +.terms-checkbox label { + font-size: var(--font-size-sm); + color: var(--text-secondary); + line-height: 1.5; +} + +.terms-checkbox a { + color: var(--primary-color); +} + +/* Landing Page Styles */ +.landing-header { + padding: 1rem 0; + position: fixed; + width: 100%; + top: 0; + z-index: 100; + background: transparent; + transition: background var(--transition-normal); +} + +.landing-header.scrolled { + background: rgba(255, 255, 255, 0.95); + box-shadow: var(--shadow-md); +} + +.landing-nav { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 1200px; + margin: 0 auto; + padding: 0 1rem; +} + +.landing-logo { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-white); + font-size: var(--font-size-xl); + font-weight: 700; +} + +.landing-logo i { + font-size: var(--font-size-2xl); +} + +.landing-header.scrolled .landing-logo { + color: var(--primary-color); +} + +.landing-nav-links { + display: flex; + align-items: center; + gap: 1rem; +} + +.landing-nav-links a { + color: var(--text-white); + font-weight: 500; + padding: 0.5rem 1rem; + border-radius: var(--border-radius); + transition: background var(--transition-fast); +} + +.landing-nav-links a:hover { + background: rgba(255, 255, 255, 0.1); + text-decoration: none; +} + +.landing-nav-links .btn-signup { + background: var(--text-white); + color: var(--primary-color); +} + +.landing-nav-links .btn-signup:hover { + background: var(--bg-secondary); +} + +.landing-header.scrolled .landing-nav-links a { + color: var(--text-primary); +} + +.landing-header.scrolled .landing-nav-links .btn-signup { + background: var(--primary-color); + color: var(--text-white); +} + +/* Landing Hero */ +.landing-hero { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 6rem 1rem 3rem; +} + +.landing-hero-content { + max-width: 700px; +} + +.landing-hero h1 { + font-size: 3.5rem; + color: var(--text-white); + font-weight: 700; + margin-bottom: 1.5rem; + line-height: 1.2; +} + +.landing-hero p { + font-size: var(--font-size-xl); + color: rgba(255, 255, 255, 0.9); + margin-bottom: 2rem; +} + +.landing-hero-buttons { + display: flex; + gap: 1rem; + justify-content: center; + flex-wrap: wrap; +} + +.landing-hero-buttons .btn-primary { + background: var(--text-white); + color: var(--primary-color); + padding: 1rem 2rem; + font-size: var(--font-size-lg); +} + +.landing-hero-buttons .btn-primary:hover { + background: var(--bg-secondary); + transform: translateY(-2px); +} + +.landing-hero-buttons .btn-secondary { + background: transparent; + color: var(--text-white); + border: 2px solid var(--text-white); + padding: 1rem 2rem; + font-size: var(--font-size-lg); +} + +.landing-hero-buttons .btn-secondary:hover { + background: rgba(255, 255, 255, 0.1); +} + +/* Features Section */ +.landing-features { + padding: 5rem 0; + background: var(--bg-primary); +} + +.landing-features h2 { + text-align: center; + font-size: var(--font-size-3xl); + margin-bottom: 3rem; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 2rem; + max-width: 1200px; + margin: 0 auto; + padding: 0 1rem; +} + +.feature-card { + text-align: center; + padding: 2rem; + border-radius: var(--border-radius-lg); + transition: transform var(--transition-normal), box-shadow var(--transition-normal); +} + +.feature-card:hover { + transform: translateY(-5px); + box-shadow: var(--shadow-lg); +} + +.feature-card i { + font-size: 3rem; + color: var(--primary-color); + margin-bottom: 1rem; +} + +.feature-card h3 { + font-size: var(--font-size-xl); + margin-bottom: 0.5rem; +} + +.feature-card p { + color: var(--text-secondary); +} + +/* Landing Footer */ +.landing-footer { + background: var(--bg-dark); + color: var(--text-white); + padding: 2rem 0; + text-align: center; +} + +.landing-footer p { + color: rgba(255, 255, 255, 0.7); + margin-bottom: 0; +} + +/* Responsive */ +@media (max-width: 768px) { + .auth-card { + padding: 1.5rem; + } + + .landing-hero h1 { + font-size: 2.5rem; + } + + .landing-hero p { + font-size: var(--font-size-base); + } + + .landing-nav-links { + display: none; + } + + .landing-hero-buttons .btn-primary, + .landing-hero-buttons .btn-secondary { + padding: 0.875rem 1.5rem; + font-size: var(--font-size-base); + } +} diff --git a/static/css/components.css b/static/css/components.css new file mode 100644 index 0000000..9c3af99 --- /dev/null +++ b/static/css/components.css @@ -0,0 +1,919 @@ +/* =========================== + Components CSS - Reusable Components + Expense Tracker Application + =========================== */ + +/* =========================== + BUTTONS + =========================== */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.625rem 1.25rem; + font-size: var(--font-size-sm); + font-weight: 500; + border-radius: var(--border-radius); + border: none; + cursor: pointer; + text-decoration: none; + transition: all var(--transition-fast); +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-primary { + background: var(--primary-color); + color: var(--text-white); +} + +.btn-primary:hover:not(:disabled) { + background: var(--primary-dark); +} + +.btn-secondary { + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--bg-tertiary); +} + +.btn-success { + background: var(--success-color); + color: var(--text-white); +} + +.btn-success:hover:not(:disabled) { + background: var(--secondary-dark); +} + +.btn-danger { + background: var(--danger-color); + color: var(--text-white); +} + +.btn-danger:hover:not(:disabled) { + background: var(--danger-dark); +} + +.btn-warning { + background: var(--warning-color); + color: var(--text-white); +} + +.btn-warning:hover:not(:disabled) { + background: var(--accent-dark); +} + +.btn-outline-primary { + background: transparent; + color: var(--primary-color); + border: 1px solid var(--primary-color); +} + +.btn-outline-primary:hover:not(:disabled) { + background: var(--primary-color); + color: var(--text-white); +} + +.btn-outline-danger { + background: transparent; + color: var(--danger-color); + border: 1px solid var(--danger-color); +} + +.btn-outline-danger:hover:not(:disabled) { + background: var(--danger-color); + color: var(--text-white); +} + +.btn-lg { + padding: 0.875rem 1.5rem; + font-size: var(--font-size-base); +} + +.btn-sm { + padding: 0.375rem 0.75rem; + font-size: var(--font-size-xs); +} + +.btn-icon { + width: 36px; + height: 36px; + padding: 0; +} + +.btn-block { + width: 100%; +} + +/* =========================== + CARDS + =========================== */ + +.card { + background: var(--bg-primary); + border-radius: var(--border-radius-lg); + box-shadow: var(--shadow-sm); + overflow: hidden; +} + +.card-header { + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +.card-header h3, +.card-header h4 { + margin-bottom: 0; +} + +.card-body { + padding: 1.5rem; +} + +.card-footer { + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-color); + background: var(--bg-secondary); +} + +/* Credit/Debit Card Display */ +.credit-card { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: var(--border-radius-lg); + padding: 1.5rem; + color: var(--text-white); + position: relative; + overflow: hidden; + min-height: 200px; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.credit-card.debit { + background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); +} + +.credit-card::before { + content: ''; + position: absolute; + top: -50%; + right: -50%; + width: 100%; + height: 100%; + background: rgba(255, 255, 255, 0.1); + border-radius: 50%; +} + +.credit-card-chip { + width: 50px; + height: 40px; + background: linear-gradient(135deg, #ffd700 0%, #ffb347 100%); + border-radius: 6px; +} + +.credit-card-number { + font-size: var(--font-size-xl); + letter-spacing: 0.1em; + font-family: 'Courier New', monospace; + margin: 1rem 0; +} + +.credit-card-info { + display: flex; + justify-content: space-between; +} + +.credit-card-holder, +.credit-card-expiry { + font-size: var(--font-size-sm); +} + +.credit-card-holder span, +.credit-card-expiry span { + display: block; + font-size: var(--font-size-xs); + opacity: 0.7; + text-transform: uppercase; + margin-bottom: 0.25rem; +} + +.credit-card-type { + position: absolute; + top: 1.5rem; + right: 1.5rem; + font-size: var(--font-size-2xl); +} + +/* =========================== + FORMS + =========================== */ + +.form-group { + margin-bottom: 1.25rem; +} + +.form-label { + display: block; + font-size: var(--font-size-sm); + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.form-label.required::after { + content: '*'; + color: var(--danger-color); + margin-left: 0.25rem; +} + +.form-control { + width: 100%; + padding: 0.75rem 1rem; + font-size: var(--font-size-base); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + background: var(--bg-primary); + color: var(--text-primary); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.form-control:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px var(--primary-light); +} + +.form-control::placeholder { + color: var(--text-light); +} + +.form-control:disabled { + background: var(--bg-secondary); + cursor: not-allowed; +} + +.form-control.is-invalid { + border-color: var(--danger-color); +} + +.form-control.is-valid { + border-color: var(--success-color); +} + +textarea.form-control { + min-height: 100px; + resize: vertical; +} + +.form-select { + appearance: none; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + background-size: 16px 12px; + padding-right: 2.5rem; +} + +.input-group { + display: flex; + align-items: stretch; +} + +.input-group .form-control { + flex: 1; + border-radius: var(--border-radius) 0 0 var(--border-radius); +} + +.input-group-text { + display: flex; + align-items: center; + padding: 0.75rem 1rem; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-left: none; + border-radius: 0 var(--border-radius) var(--border-radius) 0; + color: var(--text-secondary); +} + +.form-text { + font-size: var(--font-size-xs); + color: var(--text-light); + margin-top: 0.25rem; +} + +.form-error { + font-size: var(--font-size-xs); + color: var(--danger-color); + margin-top: 0.25rem; +} + +/* Checkbox and Radio */ +.form-check { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.form-check-input { + width: 18px; + height: 18px; + accent-color: var(--primary-color); +} + +.form-check-label { + font-size: var(--font-size-sm); + color: var(--text-secondary); +} + +/* =========================== + BADGES + =========================== */ + +.badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.625rem; + font-size: var(--font-size-xs); + font-weight: 500; + border-radius: 9999px; +} + +.badge-primary { + background: var(--primary-light); + color: var(--primary-dark); +} + +.badge-success { + background: #d1fae5; + color: var(--success-color); +} + +.badge-warning { + background: #fef3c7; + color: var(--accent-dark); +} + +.badge-danger { + background: #fee2e2; + color: var(--danger-color); +} + +.badge-info { + background: #dbeafe; + color: var(--info-color); +} + +/* =========================== + ALERTS + =========================== */ + +.alert { + padding: 1rem 1.25rem; + border-radius: var(--border-radius); + margin-bottom: 1rem; + display: flex; + align-items: flex-start; + gap: 0.75rem; +} + +.alert i { + font-size: var(--font-size-lg); + margin-top: 0.125rem; +} + +.alert-content { + flex: 1; +} + +.alert-title { + font-weight: 600; + margin-bottom: 0.25rem; +} + +.alert-text { + font-size: var(--font-size-sm); + margin-bottom: 0; +} + +.alert-success { + background: #f0fdf4; + border: 1px solid #bbf7d0; + color: #166534; +} + +.alert-danger { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; +} + +.alert-warning { + background: #fffbeb; + border: 1px solid #fde68a; + color: #92400e; +} + +.alert-info { + background: #eff6ff; + border: 1px solid #bfdbfe; + color: #1e40af; +} + +.alert-close { + background: none; + border: none; + font-size: var(--font-size-lg); + color: inherit; + opacity: 0.5; + cursor: pointer; + padding: 0; +} + +.alert-close:hover { + opacity: 1; +} + +/* =========================== + TABLES + =========================== */ + +.table { + width: 100%; + border-collapse: collapse; +} + +.table th, +.table td { + padding: 1rem; + text-align: left; + border-bottom: 1px solid var(--border-color); +} + +.table th { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--text-secondary); + background: var(--bg-secondary); +} + +.table tbody tr:hover { + background: var(--bg-secondary); +} + +.table-responsive { + overflow-x: auto; +} + +/* =========================== + MODALS + =========================== */ + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + opacity: 0; + visibility: hidden; + transition: all var(--transition-normal); +} + +.modal-overlay.show { + opacity: 1; + visibility: visible; +} + +.modal { + background: var(--bg-primary); + border-radius: var(--border-radius-lg); + width: 100%; + max-width: 500px; + max-height: 90vh; + overflow: hidden; + transform: scale(0.9); + transition: transform var(--transition-normal); +} + +.modal-overlay.show .modal { + transform: scale(1); +} + +.modal-header { + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h3 { + margin-bottom: 0; +} + +.modal-close { + background: none; + border: none; + font-size: var(--font-size-xl); + color: var(--text-light); + cursor: pointer; + padding: 0.25rem; + transition: color var(--transition-fast); +} + +.modal-close:hover { + color: var(--text-primary); +} + +.modal-body { + padding: 1.5rem; + overflow-y: auto; + max-height: calc(90vh - 140px); +} + +.modal-footer { + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-color); + display: flex; + justify-content: flex-end; + gap: 0.75rem; +} + +/* =========================== + TOAST NOTIFICATIONS + =========================== */ + +.toast-container { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 3000; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.toast { + background: var(--bg-primary); + border-radius: var(--border-radius); + box-shadow: var(--shadow-lg); + padding: 1rem 1.25rem; + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 300px; + max-width: 400px; + animation: slideIn 0.3s ease; +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +.toast.hide { + animation: slideOut 0.3s ease forwards; +} + +@keyframes slideOut { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(100%); + opacity: 0; + } +} + +.toast-icon { + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-sm); + flex-shrink: 0; +} + +.toast-success .toast-icon { + background: #d1fae5; + color: var(--success-color); +} + +.toast-error .toast-icon { + background: #fee2e2; + color: var(--danger-color); +} + +.toast-warning .toast-icon { + background: #fef3c7; + color: var(--warning-color); +} + +.toast-info .toast-icon { + background: #dbeafe; + color: var(--info-color); +} + +.toast-content { + flex: 1; +} + +.toast-title { + font-weight: 600; + font-size: var(--font-size-sm); +} + +.toast-message { + font-size: var(--font-size-sm); + color: var(--text-secondary); +} + +.toast-close { + background: none; + border: none; + color: var(--text-light); + cursor: pointer; + padding: 0.25rem; + font-size: var(--font-size-base); +} + +.toast-close:hover { + color: var(--text-primary); +} + +/* =========================== + PAGINATION + =========================== */ + +.pagination { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.pagination-item { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: var(--border-radius); + background: var(--bg-primary); + border: 1px solid var(--border-color); + color: var(--text-secondary); + font-size: var(--font-size-sm); + cursor: pointer; + transition: all var(--transition-fast); +} + +.pagination-item:hover { + border-color: var(--primary-color); + color: var(--primary-color); +} + +.pagination-item.active { + background: var(--primary-color); + border-color: var(--primary-color); + color: var(--text-white); +} + +.pagination-item:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* =========================== + PROGRESS BAR + =========================== */ + +.progress { + height: 8px; + background: var(--bg-tertiary); + border-radius: 9999px; + overflow: hidden; +} + +.progress-bar { + height: 100%; + border-radius: 9999px; + transition: width var(--transition-normal); +} + +.progress-bar.primary { + background: var(--primary-color); +} + +.progress-bar.success { + background: var(--success-color); +} + +.progress-bar.warning { + background: var(--warning-color); +} + +.progress-bar.danger { + background: var(--danger-color); +} + +/* =========================== + TABS + =========================== */ + +.tabs { + border-bottom: 1px solid var(--border-color); + margin-bottom: 1.5rem; +} + +.tabs-list { + display: flex; + gap: 0.25rem; +} + +.tab-item { + padding: 0.75rem 1.25rem; + font-size: var(--font-size-sm); + font-weight: 500; + color: var(--text-secondary); + background: none; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + transition: all var(--transition-fast); +} + +.tab-item:hover { + color: var(--text-primary); +} + +.tab-item.active { + color: var(--primary-color); + border-bottom-color: var(--primary-color); +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* =========================== + AVATAR + =========================== */ + +.avatar { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: var(--font-size-sm); + background: var(--primary-light); + color: var(--primary-color); +} + +.avatar-sm { + width: 32px; + height: 32px; + font-size: var(--font-size-xs); +} + +.avatar-lg { + width: 56px; + height: 56px; + font-size: var(--font-size-lg); +} + +.avatar-xl { + width: 80px; + height: 80px; + font-size: var(--font-size-2xl); +} + +.avatar img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; +} + +/* =========================== + DROPDOWN + =========================== */ + +.dropdown { + position: relative; +} + +.dropdown-toggle { + cursor: pointer; +} + +.dropdown-content { + position: absolute; + top: 100%; + left: 0; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + box-shadow: var(--shadow-lg); + min-width: 180px; + padding: 0.5rem 0; + z-index: 1000; + display: none; +} + +.dropdown-content.show { + display: block; +} + +.dropdown-content.right { + left: auto; + right: 0; +} + +.dropdown-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 1rem; + font-size: var(--font-size-sm); + color: var(--text-secondary); + text-decoration: none; + transition: background var(--transition-fast); +} + +.dropdown-item:hover { + background: var(--bg-secondary); + color: var(--text-primary); +} + +.dropdown-divider { + height: 1px; + background: var(--border-color); + margin: 0.5rem 0; +} + +/* =========================== + TOOLTIP + =========================== */ + +[data-tooltip] { + position: relative; +} + +[data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + padding: 0.5rem 0.75rem; + background: var(--bg-dark); + color: var(--text-white); + font-size: var(--font-size-xs); + border-radius: var(--border-radius); + white-space: nowrap; + opacity: 0; + visibility: hidden; + transition: all var(--transition-fast); + z-index: 1000; +} + +[data-tooltip]:hover::after { + opacity: 1; + visibility: visible; + bottom: calc(100% + 8px); +} diff --git a/static/css/dashboard.css b/static/css/dashboard.css new file mode 100644 index 0000000..9d2f8d1 --- /dev/null +++ b/static/css/dashboard.css @@ -0,0 +1,669 @@ +/* =========================== + Dashboard CSS - Dashboard Styles + Expense Tracker Application + =========================== */ + +/* Dashboard Layout */ +.dashboard-wrapper { + display: flex; + min-height: 100vh; +} + +/* Sidebar */ +.sidebar { + width: var(--sidebar-width); + background: var(--bg-primary); + border-right: 1px solid var(--border-color); + position: fixed; + top: 0; + left: 0; + height: 100vh; + overflow-y: auto; + z-index: 1000; + transition: transform var(--transition-normal); +} + +.sidebar-header { + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + gap: 0.75rem; +} + +.sidebar-logo { + display: flex; + align-items: center; + gap: 0.75rem; + text-decoration: none; + color: var(--text-primary); +} + +.sidebar-logo i { + font-size: 1.75rem; + color: var(--primary-color); +} + +.sidebar-logo span { + font-size: var(--font-size-xl); + font-weight: 700; +} + +/* Sidebar Navigation */ +.sidebar-nav { + padding: 1rem 0; +} + +.nav-section { + margin-bottom: 1rem; +} + +.nav-section-title { + padding: 0.5rem 1.5rem; + font-size: var(--font-size-xs); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-light); + font-weight: 600; +} + +.nav-link { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1.5rem; + color: var(--text-secondary); + text-decoration: none; + transition: all var(--transition-fast); + border-left: 3px solid transparent; +} + +.nav-link i { + width: 20px; + text-align: center; +} + +.nav-link:hover { + background: var(--bg-secondary); + color: var(--text-primary); +} + +.nav-link.active { + background: var(--primary-light); + color: var(--primary-dark); + border-left-color: var(--primary-color); + font-weight: 500; +} + +.nav-link.active i { + color: var(--primary-color); +} + +.nav-link.logout { + color: var(--danger-color); +} + +.nav-link.logout:hover { + background: #fef2f2; +} + +/* Sidebar Footer */ +.sidebar-footer { + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-color); + margin-top: auto; +} + +/* Main Content Area */ +.main-content { + flex: 1; + margin-left: var(--sidebar-width); + min-height: 100vh; + background: var(--bg-secondary); +} + +/* Top Header */ +.top-header { + background: var(--bg-primary); + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + z-index: 100; +} + +.header-left { + display: flex; + align-items: center; + gap: 1rem; +} + +.menu-toggle { + display: none; + background: none; + border: none; + font-size: var(--font-size-xl); + color: var(--text-primary); + cursor: pointer; + padding: 0.5rem; +} + +.page-title h1 { + font-size: var(--font-size-xl); + font-weight: 600; + margin-bottom: 0; +} + +.page-title p { + font-size: var(--font-size-sm); + color: var(--text-secondary); + margin-bottom: 0; +} + +.header-right { + display: flex; + align-items: center; + gap: 1rem; +} + +/* Notification Bell */ +.notification-bell { + position: relative; + background: none; + border: none; + font-size: var(--font-size-lg); + color: var(--text-secondary); + cursor: pointer; + padding: 0.5rem; + border-radius: var(--border-radius); + transition: background var(--transition-fast); +} + +.notification-bell:hover { + background: var(--bg-secondary); +} + +.notification-badge { + position: absolute; + top: 0; + right: 0; + width: 18px; + height: 18px; + background: var(--danger-color); + color: var(--text-white); + font-size: 10px; + font-weight: 600; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +/* User Profile Dropdown */ +.user-dropdown { + position: relative; +} + +.user-dropdown-toggle { + display: flex; + align-items: center; + gap: 0.75rem; + background: none; + border: none; + cursor: pointer; + padding: 0.5rem; + border-radius: var(--border-radius); + transition: background var(--transition-fast); +} + +.user-dropdown-toggle:hover { + background: var(--bg-secondary); +} + +.user-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--primary-light); + display: flex; + align-items: center; + justify-content: center; + color: var(--primary-color); + font-weight: 600; +} + +.user-info { + text-align: left; +} + +.user-name { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--text-primary); +} + +.user-email { + font-size: var(--font-size-xs); + color: var(--text-secondary); +} + +.dropdown-menu { + position: absolute; + top: 100%; + right: 0; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + box-shadow: var(--shadow-lg); + min-width: 200px; + padding: 0.5rem 0; + display: none; + z-index: 1000; +} + +.dropdown-menu.show { + display: block; + animation: fadeIn 0.2s ease; +} + +.dropdown-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + color: var(--text-secondary); + text-decoration: none; + transition: background var(--transition-fast); +} + +.dropdown-item:hover { + background: var(--bg-secondary); + color: var(--text-primary); +} + +.dropdown-item.danger { + color: var(--danger-color); +} + +.dropdown-divider { + height: 1px; + background: var(--border-color); + margin: 0.5rem 0; +} + +/* Dashboard Content */ +.dashboard-content { + padding: 1.5rem; +} + +/* Welcome Banner */ +.welcome-banner { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%); + border-radius: var(--border-radius-lg); + padding: 2rem; + color: var(--text-white); + margin-bottom: 1.5rem; +} + +.welcome-banner h2 { + font-size: var(--font-size-2xl); + margin-bottom: 0.5rem; +} + +.welcome-banner p { + opacity: 0.9; + margin-bottom: 0; +} + +/* Stats Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1.5rem; + margin-bottom: 1.5rem; +} + +.stat-card { + background: var(--bg-primary); + border-radius: var(--border-radius-lg); + padding: 1.5rem; + box-shadow: var(--shadow-sm); + transition: transform var(--transition-fast), box-shadow var(--transition-fast); +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.stat-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; +} + +.stat-card-icon { + width: 48px; + height: 48px; + border-radius: var(--border-radius); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-xl); +} + +.stat-card-icon.primary { + background: var(--primary-light); + color: var(--primary-color); +} + +.stat-card-icon.success { + background: #d1fae5; + color: var(--success-color); +} + +.stat-card-icon.warning { + background: #fef3c7; + color: var(--warning-color); +} + +.stat-card-icon.info { + background: #dbeafe; + color: var(--info-color); +} + +.stat-card-trend { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: var(--font-size-xs); + font-weight: 500; +} + +.stat-card-trend.up { + color: var(--success-color); +} + +.stat-card-trend.down { + color: var(--danger-color); +} + +.stat-card-value { + font-size: var(--font-size-2xl); + font-weight: 700; + color: var(--text-primary); + margin-bottom: 0.25rem; +} + +.stat-card-label { + font-size: var(--font-size-sm); + color: var(--text-secondary); +} + +/* Quick Actions */ +.quick-actions { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.quick-action-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + color: var(--text-primary); + font-weight: 500; + cursor: pointer; + text-decoration: none; + transition: all var(--transition-fast); +} + +.quick-action-btn:hover { + border-color: var(--primary-color); + color: var(--primary-color); +} + +.quick-action-btn.primary { + background: var(--primary-color); + border-color: var(--primary-color); + color: var(--text-white); +} + +.quick-action-btn.primary:hover { + background: var(--primary-dark); +} + +/* Charts Grid */ +.charts-grid { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 1.5rem; + margin-bottom: 1.5rem; +} + +.chart-card { + background: var(--bg-primary); + border-radius: var(--border-radius-lg); + padding: 1.5rem; + box-shadow: var(--shadow-sm); +} + +.chart-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.chart-card-header h3 { + font-size: var(--font-size-lg); + margin-bottom: 0; +} + +.chart-container { + position: relative; + height: 300px; +} + +/* Transactions Table */ +.transactions-card { + background: var(--bg-primary); + border-radius: var(--border-radius-lg); + box-shadow: var(--shadow-sm); +} + +.transactions-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--border-color); +} + +.transactions-header h3 { + font-size: var(--font-size-lg); + margin-bottom: 0; +} + +.transactions-table { + width: 100%; + border-collapse: collapse; +} + +.transactions-table th, +.transactions-table td { + padding: 1rem 1.5rem; + text-align: left; +} + +.transactions-table th { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--text-secondary); + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); +} + +.transactions-table td { + border-bottom: 1px solid var(--border-color); +} + +.transactions-table tr:last-child td { + border-bottom: none; +} + +.transactions-table tr:hover { + background: var(--bg-secondary); +} + +.transaction-category { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.category-icon { + width: 36px; + height: 36px; + border-radius: var(--border-radius); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-sm); +} + +.category-food { background: #fef3c7; color: #f59e0b; } +.category-transport { background: #dbeafe; color: #3b82f6; } +.category-shopping { background: #fce7f3; color: #ec4899; } +.category-bills { background: #e0e7ff; color: #6366f1; } +.category-entertainment { background: #d1fae5; color: #10b981; } +.category-healthcare { background: #fee2e2; color: #ef4444; } +.category-education { background: #cffafe; color: #06b6d4; } +.category-others { background: var(--bg-tertiary); color: var(--text-secondary); } + +.transaction-amount { + font-weight: 600; +} + +.transaction-amount.expense { + color: var(--danger-color); +} + +.transaction-actions { + display: flex; + gap: 0.5rem; +} + +.transaction-actions button { + background: none; + border: none; + padding: 0.25rem 0.5rem; + color: var(--text-light); + cursor: pointer; + transition: color var(--transition-fast); +} + +.transaction-actions button:hover { + color: var(--text-primary); +} + +.transaction-actions button.delete:hover { + color: var(--danger-color); +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 3rem; +} + +.empty-state i { + font-size: 4rem; + color: var(--text-light); + margin-bottom: 1rem; +} + +.empty-state h4 { + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.empty-state p { + color: var(--text-light); + margin-bottom: 1rem; +} + +/* Responsive */ +@media (max-width: 1200px) { + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + + .charts-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .sidebar { + transform: translateX(-100%); + } + + .sidebar.open { + transform: translateX(0); + } + + .main-content { + margin-left: 0; + } + + .menu-toggle { + display: block; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .quick-actions { + flex-wrap: wrap; + } + + .user-info { + display: none; + } + + .transactions-table th:nth-child(3), + .transactions-table td:nth-child(3) { + display: none; + } +} + +/* Sidebar Overlay */ +.sidebar-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 999; +} + +.sidebar-overlay.show { + display: block; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} diff --git a/static/css/main.css b/static/css/main.css new file mode 100644 index 0000000..9aa299a --- /dev/null +++ b/static/css/main.css @@ -0,0 +1,299 @@ +/* =========================== + Main CSS - Global Styles + Expense Tracker Application + =========================== */ + +/* CSS Variables for theming */ +:root { + --primary-color: #3b82f6; + --primary-dark: #2563eb; + --primary-light: #93c5fd; + --secondary-color: #10b981; + --secondary-dark: #059669; + --accent-color: #f59e0b; + --accent-dark: #d97706; + --danger-color: #ef4444; + --danger-dark: #dc2626; + --warning-color: #f59e0b; + --success-color: #10b981; + --info-color: #3b82f6; + + --text-primary: #1f2937; + --text-secondary: #6b7280; + --text-light: #9ca3af; + --text-white: #ffffff; + + --bg-primary: #ffffff; + --bg-secondary: #f3f4f6; + --bg-tertiary: #e5e7eb; + --bg-dark: #1f2937; + + --border-color: #e5e7eb; + --border-radius: 8px; + --border-radius-lg: 12px; + --border-radius-xl: 16px; + + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + + --font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-size-xs: 0.75rem; + --font-size-sm: 0.875rem; + --font-size-base: 1rem; + --font-size-lg: 1.125rem; + --font-size-xl: 1.25rem; + --font-size-2xl: 1.5rem; + --font-size-3xl: 1.875rem; + + --transition-fast: 150ms ease; + --transition-normal: 300ms ease; + --transition-slow: 500ms ease; + + --sidebar-width: 260px; + --header-height: 70px; +} + +/* Reset and Base Styles */ +*, *::before, *::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + font-size: 16px; + scroll-behavior: smooth; +} + +body { + font-family: var(--font-family); + font-size: var(--font-size-base); + line-height: 1.6; + color: var(--text-primary); + background-color: var(--bg-secondary); + min-height: 100vh; +} + +/* Typography */ +h1, h2, h3, h4, h5, h6 { + font-weight: 600; + line-height: 1.3; + margin-bottom: 0.5rem; +} + +h1 { font-size: var(--font-size-3xl); } +h2 { font-size: var(--font-size-2xl); } +h3 { font-size: var(--font-size-xl); } +h4 { font-size: var(--font-size-lg); } +h5 { font-size: var(--font-size-base); } +h6 { font-size: var(--font-size-sm); } + +p { + margin-bottom: 1rem; +} + +a { + color: var(--primary-color); + text-decoration: none; + transition: color var(--transition-fast); +} + +a:hover { + color: var(--primary-dark); + text-decoration: underline; +} + +/* Utility Classes */ +.text-primary { color: var(--primary-color) !important; } +.text-secondary { color: var(--text-secondary) !important; } +.text-success { color: var(--success-color) !important; } +.text-danger { color: var(--danger-color) !important; } +.text-warning { color: var(--warning-color) !important; } +.text-muted { color: var(--text-light) !important; } +.text-white { color: var(--text-white) !important; } + +.bg-primary { background-color: var(--primary-color) !important; } +.bg-secondary { background-color: var(--bg-secondary) !important; } +.bg-success { background-color: var(--success-color) !important; } +.bg-danger { background-color: var(--danger-color) !important; } +.bg-warning { background-color: var(--warning-color) !important; } +.bg-white { background-color: var(--bg-primary) !important; } + +.fw-bold { font-weight: 600 !important; } +.fw-semibold { font-weight: 500 !important; } +.fw-normal { font-weight: 400 !important; } + +.text-center { text-align: center !important; } +.text-left { text-align: left !important; } +.text-right { text-align: right !important; } + +/* Spacing */ +.mt-1 { margin-top: 0.25rem !important; } +.mt-2 { margin-top: 0.5rem !important; } +.mt-3 { margin-top: 1rem !important; } +.mt-4 { margin-top: 1.5rem !important; } +.mt-5 { margin-top: 2rem !important; } + +.mb-1 { margin-bottom: 0.25rem !important; } +.mb-2 { margin-bottom: 0.5rem !important; } +.mb-3 { margin-bottom: 1rem !important; } +.mb-4 { margin-bottom: 1.5rem !important; } +.mb-5 { margin-bottom: 2rem !important; } + +.p-1 { padding: 0.25rem !important; } +.p-2 { padding: 0.5rem !important; } +.p-3 { padding: 1rem !important; } +.p-4 { padding: 1.5rem !important; } +.p-5 { padding: 2rem !important; } + +/* Container */ +.container { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 0 1rem; +} + +.container-fluid { + width: 100%; + padding: 0 1rem; +} + +/* Grid System */ +.row { + display: flex; + flex-wrap: wrap; + margin: -0.5rem; +} + +.col { + flex: 1; + padding: 0.5rem; +} + +.col-12 { width: 100%; padding: 0.5rem; } +.col-6 { width: 50%; padding: 0.5rem; } +.col-4 { width: 33.333%; padding: 0.5rem; } +.col-3 { width: 25%; padding: 0.5rem; } + +@media (max-width: 768px) { + .col-md-12 { width: 100% !important; } + .col-md-6 { width: 50% !important; } +} + +@media (max-width: 576px) { + .col-sm-12 { width: 100% !important; } +} + +/* Flexbox Utilities */ +.d-flex { display: flex !important; } +.d-block { display: block !important; } +.d-none { display: none !important; } + +.flex-column { flex-direction: column !important; } +.flex-wrap { flex-wrap: wrap !important; } + +.justify-content-start { justify-content: flex-start !important; } +.justify-content-end { justify-content: flex-end !important; } +.justify-content-center { justify-content: center !important; } +.justify-content-between { justify-content: space-between !important; } +.justify-content-around { justify-content: space-around !important; } + +.align-items-start { align-items: flex-start !important; } +.align-items-end { align-items: flex-end !important; } +.align-items-center { align-items: center !important; } +.align-items-stretch { align-items: stretch !important; } + +.gap-1 { gap: 0.25rem !important; } +.gap-2 { gap: 0.5rem !important; } +.gap-3 { gap: 1rem !important; } +.gap-4 { gap: 1.5rem !important; } + +/* Loading Spinner */ +.spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid var(--border-color); + border-radius: 50%; + border-top-color: var(--primary-color); + animation: spin 0.8s linear infinite; +} + +.spinner-lg { + width: 40px; + height: 40px; + border-width: 3px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Loading Overlay */ +.loading-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +/* Hidden utility */ +.hidden { + display: none !important; +} + +/* Visibility */ +@media (max-width: 768px) { + .hide-mobile { display: none !important; } +} + +@media (min-width: 769px) { + .hide-desktop { display: none !important; } +} + +/* Scrollbar Styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-secondary); +} + +::-webkit-scrollbar-thumb { + background: var(--text-light); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* Selection */ +::selection { + background: var(--primary-light); + color: var(--text-primary); +} + +/* Focus Visible */ +:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; +} + +/* Print Styles */ +@media print { + .no-print { + display: none !important; + } +} diff --git a/static/js/auth.js b/static/js/auth.js new file mode 100644 index 0000000..2f5ffcd --- /dev/null +++ b/static/js/auth.js @@ -0,0 +1,302 @@ +/** + * Auth JavaScript - Login/Signup Handling + * Expense Tracker Application + */ + +// =========================== +// LOGIN FUNCTIONALITY +// =========================== + +function initLoginPage() { + const loginForm = document.getElementById('loginForm'); + const emailInput = document.getElementById('email'); + const passwordInput = document.getElementById('password'); + const rememberCheck = document.getElementById('remember'); + const submitBtn = loginForm?.querySelector('button[type="submit"]'); + const errorDiv = document.getElementById('loginError'); + + // Check for remembered email + const rememberedEmail = localStorage.getItem('rememberedEmail'); + if (rememberedEmail && emailInput) { + emailInput.value = rememberedEmail; + if (rememberCheck) rememberCheck.checked = true; + } + + // Toggle password visibility + const passwordToggle = document.querySelector('.password-toggle'); + if (passwordToggle) { + passwordToggle.addEventListener('click', () => { + const type = passwordInput.type === 'password' ? 'text' : 'password'; + passwordInput.type = type; + passwordToggle.innerHTML = type === 'password' + ? '' + : ''; + }); + } + + // Form submission + if (loginForm) { + loginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + + // Clear previous errors + hideError(errorDiv); + + // Get form values + const email = emailInput.value.trim(); + const password = passwordInput.value; + + // Validate + if (!email || !password) { + showError(errorDiv, 'Please fill in all fields'); + return; + } + + if (!App.isValidEmail(email)) { + showError(errorDiv, 'Please enter a valid email address'); + return; + } + + // Show loading state + setButtonLoading(submitBtn, true); + + try { + const response = await App.apiRequest('/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }) + }); + + // Remember email if checked + if (rememberCheck?.checked) { + localStorage.setItem('rememberedEmail', email); + } else { + localStorage.removeItem('rememberedEmail'); + } + + // Show success and redirect + showToast('Login successful! Redirecting...', 'success'); + setTimeout(() => { + window.location.href = '/dashboard'; + }, 500); + + } catch (error) { + showError(errorDiv, error.message || 'Invalid email or password'); + setButtonLoading(submitBtn, false); + } + }); + } +} + +// =========================== +// SIGNUP FUNCTIONALITY +// =========================== + +function initSignupPage() { + const signupForm = document.getElementById('signupForm'); + const fullNameInput = document.getElementById('fullName'); + const emailInput = document.getElementById('email'); + const passwordInput = document.getElementById('password'); + const confirmPasswordInput = document.getElementById('confirmPassword'); + const termsCheck = document.getElementById('terms'); + const submitBtn = signupForm?.querySelector('button[type="submit"]'); + const errorDiv = document.getElementById('signupError'); + + // Password strength indicator + if (passwordInput) { + passwordInput.addEventListener('input', () => { + updatePasswordStrength(passwordInput.value); + }); + } + + // Password match validation + if (confirmPasswordInput) { + confirmPasswordInput.addEventListener('input', () => { + validatePasswordMatch(passwordInput.value, confirmPasswordInput.value); + }); + } + + // Toggle password visibility + document.querySelectorAll('.password-toggle').forEach(toggle => { + toggle.addEventListener('click', function() { + const input = this.previousElementSibling; + const type = input.type === 'password' ? 'text' : 'password'; + input.type = type; + this.innerHTML = type === 'password' + ? '' + : ''; + }); + }); + + // Form submission + if (signupForm) { + signupForm.addEventListener('submit', async (e) => { + e.preventDefault(); + + // Clear previous errors + hideError(errorDiv); + + // Get form values + const fullName = fullNameInput.value.trim(); + const email = emailInput.value.trim(); + const password = passwordInput.value; + const confirmPassword = confirmPasswordInput.value; + + // Validate + if (!fullName || !email || !password || !confirmPassword) { + showError(errorDiv, 'Please fill in all fields'); + return; + } + + if (!App.isValidEmail(email)) { + showError(errorDiv, 'Please enter a valid email address'); + return; + } + + if (password.length < 8) { + showError(errorDiv, 'Password must be at least 8 characters long'); + return; + } + + if (password !== confirmPassword) { + showError(errorDiv, 'Passwords do not match'); + return; + } + + if (!termsCheck?.checked) { + showError(errorDiv, 'Please accept the Terms & Conditions'); + return; + } + + // Show loading state + setButtonLoading(submitBtn, true); + + try { + const response = await App.apiRequest('/auth/signup', { + method: 'POST', + body: JSON.stringify({ + full_name: fullName, + email, + password + }) + }); + + // Show success and redirect + showToast('Account created successfully!', 'success'); + setTimeout(() => { + window.location.href = '/dashboard'; + }, 500); + + } catch (error) { + showError(errorDiv, error.message || 'An error occurred during signup'); + setButtonLoading(submitBtn, false); + } + }); + } +} + +// =========================== +// PASSWORD STRENGTH CHECKER +// =========================== + +function updatePasswordStrength(password) { + const strengthBar = document.querySelector('.password-strength-bar'); + const strengthText = document.querySelector('.password-strength-text'); + + if (!strengthBar || !strengthText) return; + + let strength = 0; + let label = 'Weak'; + let color = '#ef4444'; + + // Length check + if (password.length >= 8) strength += 25; + if (password.length >= 12) strength += 15; + + // Lowercase check + if (/[a-z]/.test(password)) strength += 15; + + // Uppercase check + if (/[A-Z]/.test(password)) strength += 15; + + // Number check + if (/[0-9]/.test(password)) strength += 15; + + // Special character check + if (/[^a-zA-Z0-9]/.test(password)) strength += 15; + + // Determine label and color + if (strength >= 80) { + label = 'Strong'; + color = '#10b981'; + } else if (strength >= 50) { + label = 'Medium'; + color = '#f59e0b'; + } + + strengthBar.style.width = `${Math.min(strength, 100)}%`; + strengthBar.style.backgroundColor = color; + strengthText.textContent = label; + strengthText.style.color = color; +} + +function validatePasswordMatch(password, confirmPassword) { + const matchIndicator = document.querySelector('.password-match'); + + if (!matchIndicator) return; + + if (confirmPassword.length === 0) { + matchIndicator.textContent = ''; + return; + } + + if (password === confirmPassword) { + matchIndicator.innerHTML = ' Passwords match'; + matchIndicator.style.color = '#10b981'; + } else { + matchIndicator.innerHTML = ' Passwords do not match'; + matchIndicator.style.color = '#ef4444'; + } +} + +// =========================== +// HELPER FUNCTIONS +// =========================== + +function showError(errorDiv, message) { + if (!errorDiv) return; + errorDiv.innerHTML = ` ${message}`; + errorDiv.style.display = 'flex'; +} + +function hideError(errorDiv) { + if (!errorDiv) return; + errorDiv.style.display = 'none'; +} + +function setButtonLoading(button, loading) { + if (!button) return; + + if (loading) { + button.disabled = true; + button.dataset.originalText = button.innerHTML; + button.innerHTML = ' Please wait...'; + } else { + button.disabled = false; + button.innerHTML = button.dataset.originalText || 'Submit'; + } +} + +// =========================== +// INITIALIZATION +// =========================== + +document.addEventListener('DOMContentLoaded', () => { + // Initialize based on current page + if (document.getElementById('loginForm')) { + initLoginPage(); + } + + if (document.getElementById('signupForm')) { + initSignupPage(); + } +}); diff --git a/static/js/cards.js b/static/js/cards.js new file mode 100644 index 0000000..b57f12c --- /dev/null +++ b/static/js/cards.js @@ -0,0 +1,388 @@ +/** + * Cards JavaScript - Card Management + * Expense Tracker Application + */ + +// =========================== +// CARDS STATE +// =========================== + +let cardsData = { + cards: [], + totalBalance: 0 +}; + +// =========================== +// CARDS INITIALIZATION +// =========================== + +async function initCardsPage() { + showCardsLoading(true); + + try { + await loadCards(); + renderCards(); + setupCardEvents(); + } catch (error) { + console.error('Cards initialization error:', error); + showToast('Failed to load cards', 'error'); + } finally { + showCardsLoading(false); + } +} + +// =========================== +// DATA LOADING +// =========================== + +async function loadCards() { + try { + const response = await App.apiRequest('/cards'); + cardsData.cards = response.cards || []; + cardsData.totalBalance = cardsData.cards.reduce((sum, card) => sum + (card.balance || 0), 0); + return response; + } catch (error) { + console.error('Error loading cards:', error); + throw error; + } +} + +// =========================== +// RENDER FUNCTIONS +// =========================== + +function renderCards() { + const container = document.getElementById('cardsContainer'); + if (!container) return; + + // Update total balance + const totalBalanceEl = document.getElementById('totalBalance'); + if (totalBalanceEl) { + totalBalanceEl.textContent = App.formatCurrency(cardsData.totalBalance); + } + + if (cardsData.cards.length === 0) { + container.innerHTML = ` +
+ +

No cards added yet

+

Add your credit or debit cards to track your spending

+ +
+ `; + return; + } + + container.innerHTML = ` +
+ ${cardsData.cards.map(card => renderCardItem(card)).join('')} +
+ `; +} + +function renderCardItem(card) { + const cardTypeClass = card.card_type === 'credit' ? '' : 'debit'; + const cardIcon = card.card_type === 'credit' ? 'fa-cc-visa' : 'fa-cc-mastercard'; + + return ` +
+
+
+
+ +
+
${card.card_number}
+
+
+ Card Holder + ${card.card_holder_name || card.card_name} +
+
+ Expires + ${card.expiry_date} +
+
+
+
+
+ Card Name + ${card.card_name} +
+
+ Bank + ${card.bank_name || 'N/A'} +
+
+ Type + + ${App.capitalize(card.card_type)} + +
+
+ Balance + ${App.formatCurrency(card.balance || 0)} +
+
+ + +
+
+
+ `; +} + +// =========================== +// CARD ACTIONS +// =========================== + +function showAddCardModal() { + const modal = document.getElementById('cardModal'); + const form = document.getElementById('cardForm'); + const title = document.getElementById('cardModalTitle'); + + if (modal && form && title) { + title.textContent = 'Add New Card'; + form.reset(); + form.dataset.mode = 'add'; + form.dataset.cardId = ''; + modal.classList.add('show'); + } +} + +function showEditCardModal(cardId) { + const card = cardsData.cards.find(c => c.id === cardId); + if (!card) return; + + const modal = document.getElementById('cardModal'); + const form = document.getElementById('cardForm'); + const title = document.getElementById('cardModalTitle'); + + if (modal && form && title) { + title.textContent = 'Edit Card'; + form.dataset.mode = 'edit'; + form.dataset.cardId = cardId; + + // Populate form + document.getElementById('cardName').value = card.card_name || ''; + document.getElementById('cardHolderName').value = card.card_holder_name || ''; + document.getElementById('cardNumber').value = card.card_last_four ? `**** **** **** ${card.card_last_four}` : ''; + document.getElementById('expiryDate').value = card.expiry_date || ''; + document.getElementById('cvv').value = '***'; + document.getElementById('cardType').value = card.card_type || 'debit'; + document.getElementById('bankName').value = card.bank_name || ''; + document.getElementById('cardBalance').value = card.balance || 0; + + modal.classList.add('show'); + } +} + +function closeCardModal() { + const modal = document.getElementById('cardModal'); + if (modal) { + modal.classList.remove('show'); + } +} + +async function saveCard(formData) { + const form = document.getElementById('cardForm'); + const mode = form.dataset.mode; + const cardId = form.dataset.cardId; + + try { + if (mode === 'add') { + await App.apiRequest('/cards', { + method: 'POST', + body: JSON.stringify(formData) + }); + showToast('Card added successfully', 'success'); + } else { + await App.apiRequest(`/cards/${cardId}`, { + method: 'PUT', + body: JSON.stringify(formData) + }); + showToast('Card updated successfully', 'success'); + } + + closeCardModal(); + await loadCards(); + renderCards(); + } catch (error) { + showToast(error.message || 'Failed to save card', 'error'); + } +} + +function editCard(cardId) { + showEditCardModal(cardId); +} + +async function deleteCard(cardId) { + if (!confirm('Are you sure you want to delete this card? All expenses linked to this card will be updated.')) { + return; + } + + try { + await App.apiRequest(`/cards/${cardId}`, { method: 'DELETE' }); + showToast('Card deleted successfully', 'success'); + await loadCards(); + renderCards(); + } catch (error) { + showToast(error.message || 'Failed to delete card', 'error'); + } +} + +// =========================== +// CARD NUMBER FORMATTING +// =========================== + +function formatCardNumber(input) { + let value = input.value.replace(/\s/g, '').replace(/\D/g, ''); + let formattedValue = ''; + + for (let i = 0; i < value.length; i++) { + if (i > 0 && i % 4 === 0) { + formattedValue += ' '; + } + formattedValue += value[i]; + } + + input.value = formattedValue.substring(0, 19); // 16 digits + 3 spaces +} + +function formatExpiryDate(input) { + let value = input.value.replace(/\D/g, ''); + + if (value.length >= 2) { + value = value.substring(0, 2) + '/' + value.substring(2, 4); + } + + input.value = value; +} + +// =========================== +// EVENT HANDLERS +// =========================== + +function setupCardEvents() { + // Add card button + const addCardBtn = document.getElementById('addCardBtn'); + if (addCardBtn) { + addCardBtn.addEventListener('click', showAddCardModal); + } + + // Card form submission + const cardForm = document.getElementById('cardForm'); + if (cardForm) { + cardForm.addEventListener('submit', async (e) => { + e.preventDefault(); + + const formData = { + card_name: document.getElementById('cardName').value, + card_holder_name: document.getElementById('cardHolderName').value, + card_number: document.getElementById('cardNumber').value.replace(/\s/g, ''), + expiry_date: document.getElementById('expiryDate').value, + cvv: document.getElementById('cvv').value, + card_type: document.getElementById('cardType').value, + bank_name: document.getElementById('bankName').value, + balance: parseFloat(document.getElementById('cardBalance').value) || 0 + }; + + // Skip validation for edit mode (card number will be masked) + if (cardForm.dataset.mode === 'add') { + if (formData.card_number.length !== 16) { + showToast('Please enter a valid 16-digit card number', 'error'); + return; + } + + if (formData.cvv.length < 3) { + showToast('Please enter a valid CVV', 'error'); + return; + } + } + + await saveCard(formData); + }); + } + + // Card number formatting + const cardNumberInput = document.getElementById('cardNumber'); + if (cardNumberInput) { + cardNumberInput.addEventListener('input', function() { + formatCardNumber(this); + }); + } + + // Expiry date formatting + const expiryInput = document.getElementById('expiryDate'); + if (expiryInput) { + expiryInput.addEventListener('input', function() { + formatExpiryDate(this); + }); + } + + // CVV input - numbers only + const cvvInput = document.getElementById('cvv'); + if (cvvInput) { + cvvInput.addEventListener('input', function() { + this.value = this.value.replace(/\D/g, '').substring(0, 4); + }); + } + + // Modal close + const modalClose = document.querySelector('#cardModal .modal-close'); + const modalCancel = document.querySelector('#cardModal [data-dismiss="modal"]'); + + if (modalClose) { + modalClose.addEventListener('click', closeCardModal); + } + + if (modalCancel) { + modalCancel.addEventListener('click', closeCardModal); + } + + // Close modal on overlay click + const modalOverlay = document.getElementById('cardModal'); + if (modalOverlay) { + modalOverlay.addEventListener('click', (e) => { + if (e.target === modalOverlay) { + closeCardModal(); + } + }); + } +} + +// =========================== +// LOADING STATE +// =========================== + +function showCardsLoading(show) { + const loader = document.getElementById('cardsLoader'); + if (loader) { + loader.style.display = show ? 'flex' : 'none'; + } +} + +// =========================== +// INITIALIZATION +// =========================== + +document.addEventListener('DOMContentLoaded', () => { + if (document.getElementById('cardsContainer')) { + initCardsPage(); + } +}); + +// Export for use in other modules +window.Cards = { + init: initCardsPage, + load: loadCards, + add: showAddCardModal, + edit: editCard, + delete: deleteCard, + getAll: () => cardsData.cards +}; diff --git a/static/js/charts.js b/static/js/charts.js new file mode 100644 index 0000000..7016fa8 --- /dev/null +++ b/static/js/charts.js @@ -0,0 +1,463 @@ +/** + * Charts JavaScript - Chart.js Integration + * Expense Tracker Application + */ + +// =========================== +// CHART INSTANCES +// =========================== + +let charts = { + monthlyChart: null, + categoryChart: null, + trendChart: null +}; + +// Chart.js default configuration +const chartDefaults = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom', + labels: { + usePointStyle: true, + padding: 20, + font: { + size: 12, + family: "'Inter', sans-serif" + } + } + }, + tooltip: { + backgroundColor: 'rgba(0, 0, 0, 0.8)', + titleFont: { + family: "'Inter', sans-serif" + }, + bodyFont: { + family: "'Inter', sans-serif" + }, + padding: 12, + cornerRadius: 8, + callbacks: { + label: function(context) { + const value = context.raw; + return `${context.label}: ${App.formatCurrency(value)}`; + } + } + } + } +}; + +// =========================== +// CHART INITIALIZATION +// =========================== + +function initCharts() { + // Check if Chart.js is loaded + if (typeof Chart === 'undefined') { + console.error('Chart.js is not loaded'); + return; + } + + // Set global defaults + Chart.defaults.font.family = "'Inter', sans-serif"; + + // Initialize charts based on available canvases + initMonthlyChart(); + initCategoryChart(); + initTrendChart(); +} + +function initMonthlyChart() { + const canvas = document.getElementById('monthlyChart'); + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + const dashboardData = window.Dashboard?.getData?.() || {}; + const monthlyData = dashboardData.monthlyData || []; + + // Destroy existing chart if it exists + if (charts.monthlyChart) { + charts.monthlyChart.destroy(); + } + + charts.monthlyChart = new Chart(ctx, { + type: 'bar', + data: { + labels: monthlyData.map(d => d.month_name), + datasets: [{ + label: 'Monthly Expenses', + data: monthlyData.map(d => d.total), + backgroundColor: App.CHART_COLORS[0], + borderRadius: 6, + borderSkipped: false, + }] + }, + options: { + ...chartDefaults, + scales: { + x: { + grid: { + display: false + } + }, + y: { + beginAtZero: true, + ticks: { + callback: function(value) { + return '$' + value.toLocaleString(); + } + }, + grid: { + color: 'rgba(0, 0, 0, 0.05)' + } + } + }, + plugins: { + ...chartDefaults.plugins, + legend: { + display: false + } + } + } + }); +} + +function initCategoryChart() { + const canvas = document.getElementById('categoryChart'); + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + const dashboardData = window.Dashboard?.getData?.() || {}; + const categoryBreakdown = dashboardData.summary?.category_breakdown || {}; + + const labels = Object.keys(categoryBreakdown); + const data = Object.values(categoryBreakdown); + + // Destroy existing chart if it exists + if (charts.categoryChart) { + charts.categoryChart.destroy(); + } + + if (labels.length === 0) { + // Show empty state + canvas.parentElement.innerHTML = ` +
+ +

No expense data to display

+
+ `; + return; + } + + charts.categoryChart = new Chart(ctx, { + type: 'doughnut', + data: { + labels: labels, + datasets: [{ + data: data, + backgroundColor: App.CHART_COLORS.slice(0, labels.length), + borderWidth: 0, + hoverOffset: 4 + }] + }, + options: { + ...chartDefaults, + cutout: '65%', + plugins: { + ...chartDefaults.plugins, + legend: { + position: 'right', + labels: { + usePointStyle: true, + padding: 15, + generateLabels: function(chart) { + const data = chart.data; + const total = data.datasets[0].data.reduce((a, b) => a + b, 0); + + return data.labels.map((label, i) => { + const value = data.datasets[0].data[i]; + const percent = ((value / total) * 100).toFixed(1); + + return { + text: `${label} (${percent}%)`, + fillStyle: data.datasets[0].backgroundColor[i], + hidden: false, + index: i + }; + }); + } + } + } + } + } + }); +} + +function initTrendChart() { + const canvas = document.getElementById('trendChart'); + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + const dashboardData = window.Dashboard?.getData?.() || {}; + const monthlyData = dashboardData.monthlyData || []; + + // Destroy existing chart if it exists + if (charts.trendChart) { + charts.trendChart.destroy(); + } + + charts.trendChart = new Chart(ctx, { + type: 'line', + data: { + labels: monthlyData.map(d => d.month_name), + datasets: [{ + label: 'Spending Trend', + data: monthlyData.map(d => d.total), + borderColor: App.CHART_COLORS[0], + backgroundColor: 'rgba(59, 130, 246, 0.1)', + fill: true, + tension: 0.4, + pointRadius: 4, + pointHoverRadius: 6, + pointBackgroundColor: App.CHART_COLORS[0], + pointBorderColor: '#fff', + pointBorderWidth: 2 + }] + }, + options: { + ...chartDefaults, + scales: { + x: { + grid: { + display: false + } + }, + y: { + beginAtZero: true, + ticks: { + callback: function(value) { + return '$' + value.toLocaleString(); + } + }, + grid: { + color: 'rgba(0, 0, 0, 0.05)' + } + } + }, + plugins: { + ...chartDefaults.plugins, + legend: { + display: false + } + } + } + }); +} + +// =========================== +// CHART UPDATE FUNCTIONS +// =========================== + +function updateCharts() { + initMonthlyChart(); + initCategoryChart(); + initTrendChart(); +} + +function updateMonthlyChart(data) { + if (!charts.monthlyChart) return; + + charts.monthlyChart.data.labels = data.map(d => d.month_name); + charts.monthlyChart.data.datasets[0].data = data.map(d => d.total); + charts.monthlyChart.update(); +} + +function updateCategoryChart(data) { + if (!charts.categoryChart) return; + + const labels = Object.keys(data); + const values = Object.values(data); + + charts.categoryChart.data.labels = labels; + charts.categoryChart.data.datasets[0].data = values; + charts.categoryChart.data.datasets[0].backgroundColor = App.CHART_COLORS.slice(0, labels.length); + charts.categoryChart.update(); +} + +// =========================== +// REPORTS PAGE CHARTS +// =========================== + +function initReportsCharts() { + initComparisonChart(); + initCategoryBreakdownChart(); +} + +function initComparisonChart() { + const canvas = document.getElementById('comparisonChart'); + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + + // This would be populated with data from the API + const sampleData = { + labels: ['Week 1', 'Week 2', 'Week 3', 'Week 4'], + currentMonth: [500, 750, 600, 800], + previousMonth: [450, 600, 700, 650] + }; + + new Chart(ctx, { + type: 'line', + data: { + labels: sampleData.labels, + datasets: [ + { + label: 'This Month', + data: sampleData.currentMonth, + borderColor: App.CHART_COLORS[0], + backgroundColor: 'rgba(59, 130, 246, 0.1)', + fill: true, + tension: 0.4 + }, + { + label: 'Last Month', + data: sampleData.previousMonth, + borderColor: App.CHART_COLORS[2], + backgroundColor: 'transparent', + borderDash: [5, 5], + tension: 0.4 + } + ] + }, + options: { + ...chartDefaults, + scales: { + x: { + grid: { + display: false + } + }, + y: { + beginAtZero: true, + ticks: { + callback: function(value) { + return '$' + value; + } + } + } + } + } + }); +} + +function initCategoryBreakdownChart() { + const canvas = document.getElementById('categoryBreakdownChart'); + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + + // Sample data - would be replaced with actual API data + const categories = ['Food', 'Transport', 'Shopping', 'Bills', 'Entertainment']; + const amounts = [450, 200, 350, 500, 150]; + + new Chart(ctx, { + type: 'bar', + data: { + labels: categories, + datasets: [{ + label: 'Amount', + data: amounts, + backgroundColor: App.CHART_COLORS.slice(0, categories.length), + borderRadius: 6 + }] + }, + options: { + ...chartDefaults, + indexAxis: 'y', + scales: { + x: { + beginAtZero: true, + ticks: { + callback: function(value) { + return '$' + value; + } + } + }, + y: { + grid: { + display: false + } + } + }, + plugins: { + ...chartDefaults.plugins, + legend: { + display: false + } + } + } + }); +} + +// =========================== +// EXPORT DATA AS CSV +// =========================== + +function exportDataAsCSV() { + const expenses = window.Expenses?.getAll?.() || []; + + if (expenses.length === 0) { + showToast('No data to export', 'warning'); + return; + } + + const headers = ['Date', 'Category', 'Description', 'Amount', 'Payment Method']; + const rows = expenses.map(e => [ + e.expense_date, + e.category, + e.description || '', + e.amount.toFixed(2), + e.payment_method + ]); + + const csvContent = [ + headers.join(','), + ...rows.map(row => row.map(cell => `"${cell}"`).join(',')) + ].join('\n'); + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + + link.setAttribute('href', url); + link.setAttribute('download', `expenses_${new Date().toISOString().split('T')[0]}.csv`); + link.style.visibility = 'hidden'; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + showToast('Data exported successfully', 'success'); +} + +// =========================== +// INITIALIZATION +// =========================== + +document.addEventListener('DOMContentLoaded', () => { + // Export button + const exportBtn = document.getElementById('exportBtn'); + if (exportBtn) { + exportBtn.addEventListener('click', exportDataAsCSV); + } +}); + +// Export for use in other modules +window.Charts = { + init: initCharts, + update: updateCharts, + initReports: initReportsCharts, + exportCSV: exportDataAsCSV +}; diff --git a/static/js/dashboard.js b/static/js/dashboard.js new file mode 100644 index 0000000..d006509 --- /dev/null +++ b/static/js/dashboard.js @@ -0,0 +1,378 @@ +/** + * Dashboard JavaScript - Dashboard Data Loading + * Expense Tracker Application + */ + +// =========================== +// DASHBOARD STATE +// =========================== + +let dashboardData = { + user: null, + summary: null, + recentTransactions: [], + monthlyData: [], + categoryData: [] +}; + +// =========================== +// DASHBOARD INITIALIZATION +// =========================== + +async function initDashboard() { + // Show loading + showDashboardLoading(true); + + try { + // Load all dashboard data + await Promise.all([ + loadUserData(), + loadDashboardSummary(), + loadMonthlyData(), + loadCategoryData() + ]); + + // Render dashboard components + renderWelcomeBanner(); + renderStatCards(); + renderRecentTransactions(); + + // Initialize charts + if (typeof initCharts === 'function') { + initCharts(); + } + + // Setup event listeners + setupDashboardEvents(); + + } catch (error) { + console.error('Dashboard initialization error:', error); + showToast('Failed to load dashboard data', 'error'); + } finally { + showDashboardLoading(false); + } +} + +// =========================== +// DATA LOADING FUNCTIONS +// =========================== + +async function loadUserData() { + try { + const response = await App.apiRequest('/user/profile'); + dashboardData.user = response; + return response; + } catch (error) { + console.error('Error loading user data:', error); + throw error; + } +} + +async function loadDashboardSummary() { + try { + const response = await App.apiRequest('/dashboard/summary'); + dashboardData.summary = response; + dashboardData.recentTransactions = response.recent_transactions || []; + return response; + } catch (error) { + console.error('Error loading dashboard summary:', error); + throw error; + } +} + +async function loadMonthlyData() { + try { + const response = await App.apiRequest('/analytics/monthly'); + dashboardData.monthlyData = response.monthly_data || []; + return response; + } catch (error) { + console.error('Error loading monthly data:', error); + return { monthly_data: [] }; + } +} + +async function loadCategoryData() { + try { + const response = await App.apiRequest('/analytics/category'); + dashboardData.categoryData = response.categories || []; + return response; + } catch (error) { + console.error('Error loading category data:', error); + return { categories: [] }; + } +} + +// =========================== +// RENDER FUNCTIONS +// =========================== + +function renderWelcomeBanner() { + const banner = document.getElementById('welcomeBanner'); + if (!banner || !dashboardData.user) return; + + const firstName = dashboardData.user.full_name?.split(' ')[0] || 'User'; + const greeting = getGreeting(); + + banner.innerHTML = ` +

${greeting}, ${firstName}!

+

Here's an overview of your expenses this month

+ `; +} + +function getGreeting() { + const hour = new Date().getHours(); + if (hour < 12) return 'Good morning'; + if (hour < 18) return 'Good afternoon'; + return 'Good evening'; +} + +function renderStatCards() { + const summary = dashboardData.summary; + if (!summary) return; + + // Monthly Salary + const salaryCard = document.getElementById('monthlySalary'); + if (salaryCard) { + salaryCard.textContent = App.formatCurrency(summary.monthly_salary || 0); + } + + // Total Expenses + const expensesCard = document.getElementById('totalExpenses'); + if (expensesCard) { + expensesCard.textContent = App.formatCurrency(summary.total_expenses || 0); + } + + // Remaining Balance + const balanceCard = document.getElementById('remainingBalance'); + if (balanceCard) { + const balance = summary.remaining_balance || 0; + balanceCard.textContent = App.formatCurrency(balance); + balanceCard.classList.toggle('text-danger', balance < 0); + balanceCard.classList.toggle('text-success', balance > 0); + } + + // Transaction Count + const countCard = document.getElementById('transactionCount'); + if (countCard) { + countCard.textContent = summary.transaction_count || 0; + } + + // Update trend indicators if available + updateTrendIndicators(); +} + +function updateTrendIndicators() { + // This would compare with previous month data + // For now, we'll show static indicators +} + +function renderRecentTransactions() { + const container = document.getElementById('recentTransactions'); + if (!container) return; + + const transactions = dashboardData.recentTransactions; + + if (transactions.length === 0) { + container.innerHTML = ` +
+ +

No transactions yet

+

Start tracking your expenses by adding your first transaction

+ + Add Expense + +
+ `; + return; + } + + const tableHtml = ` + + + + + + + + + + + + ${transactions.map(tx => renderTransactionRow(tx)).join('')} + +
CategoryDescriptionDateAmountActions
+ `; + + container.innerHTML = tableHtml; +} + +function renderTransactionRow(transaction) { + const categoryInfo = App.getCategoryInfo(transaction.category); + + return ` + + +
+
+ +
+ ${categoryInfo.label} +
+ + ${transaction.description || '-'} + ${App.formatDate(transaction.expense_date)} + + -${App.formatCurrency(transaction.amount)} + + + + + + + `; +} + +// =========================== +// ACTION FUNCTIONS +// =========================== + +async function deleteTransaction(id) { + if (!confirm('Are you sure you want to delete this transaction?')) { + return; + } + + try { + await App.apiRequest(`/expenses/${id}`, { method: 'DELETE' }); + showToast('Transaction deleted successfully', 'success'); + + // Reload dashboard data + await loadDashboardSummary(); + renderStatCards(); + renderRecentTransactions(); + + // Update charts + if (typeof updateCharts === 'function') { + await loadCategoryData(); + await loadMonthlyData(); + updateCharts(); + } + } catch (error) { + showToast(error.message || 'Failed to delete transaction', 'error'); + } +} + +function editTransaction(id) { + // Redirect to edit page or open modal + window.location.href = `/add-expense?edit=${id}`; +} + +// =========================== +// EVENT HANDLERS +// =========================== + +function setupDashboardEvents() { + // User dropdown toggle + const userDropdown = document.querySelector('.user-dropdown-toggle'); + const dropdownMenu = document.querySelector('.user-dropdown .dropdown-menu'); + + if (userDropdown && dropdownMenu) { + userDropdown.addEventListener('click', (e) => { + e.stopPropagation(); + dropdownMenu.classList.toggle('show'); + }); + } + + // Sidebar toggle for mobile + const menuToggle = document.getElementById('menuToggle'); + const sidebar = document.querySelector('.sidebar'); + const overlay = document.querySelector('.sidebar-overlay'); + + if (menuToggle && sidebar) { + menuToggle.addEventListener('click', () => { + sidebar.classList.toggle('open'); + overlay?.classList.toggle('show'); + }); + + overlay?.addEventListener('click', () => { + sidebar.classList.remove('open'); + overlay.classList.remove('show'); + }); + } + + // Logout button + const logoutBtns = document.querySelectorAll('[data-logout]'); + logoutBtns.forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + App.logout(); + }); + }); + + // Update user info in header + updateUserHeader(); +} + +function updateUserHeader() { + const user = dashboardData.user; + if (!user) return; + + // Update user name + const userNameEl = document.querySelector('.user-name'); + if (userNameEl) { + userNameEl.textContent = user.full_name || 'User'; + } + + // Update user email + const userEmailEl = document.querySelector('.user-email'); + if (userEmailEl) { + userEmailEl.textContent = user.email || ''; + } + + // Update avatar + const avatarEl = document.querySelector('.user-avatar'); + if (avatarEl && user.full_name) { + avatarEl.textContent = App.getInitials(user.full_name); + } +} + +// =========================== +// LOADING STATE +// =========================== + +function showDashboardLoading(show) { + const loader = document.getElementById('dashboardLoader'); + if (loader) { + loader.style.display = show ? 'flex' : 'none'; + } +} + +// =========================== +// EXPORT DASHBOARD DATA +// =========================== + +function getDashboardData() { + return dashboardData; +} + +// =========================== +// INITIALIZATION +// =========================== + +document.addEventListener('DOMContentLoaded', () => { + if (document.querySelector('.dashboard-wrapper')) { + initDashboard(); + } +}); + +// Export for use in other modules +window.Dashboard = { + init: initDashboard, + getData: getDashboardData, + refresh: initDashboard, + deleteTransaction, + editTransaction +}; diff --git a/static/js/expenses.js b/static/js/expenses.js new file mode 100644 index 0000000..f1a66d9 --- /dev/null +++ b/static/js/expenses.js @@ -0,0 +1,457 @@ +/** + * Expenses JavaScript - Expense Management + * Expense Tracker Application + */ + +// =========================== +// EXPENSES STATE +// =========================== + +let expensesData = { + expenses: [], + cards: [], + editingExpense: null, + filters: { + month: new Date().getMonth() + 1, + year: new Date().getFullYear(), + category: '' + } +}; + +// =========================== +// EXPENSES PAGE INITIALIZATION +// =========================== + +async function initExpensesPage() { + showExpensesLoading(true); + + try { + await Promise.all([ + loadExpenses(), + loadUserCards() + ]); + + // Check if editing + const urlParams = new URLSearchParams(window.location.search); + const editId = urlParams.get('edit'); + + if (editId) { + await loadExpenseForEdit(editId); + } + + renderExpenseForm(); + setupExpenseEvents(); + } catch (error) { + console.error('Expenses initialization error:', error); + showToast('Failed to load page data', 'error'); + } finally { + showExpensesLoading(false); + } +} + +// =========================== +// DATA LOADING +// =========================== + +async function loadExpenses() { + try { + const { month, year, category } = expensesData.filters; + let endpoint = `/expenses?month=${month}&year=${year}`; + + if (category) { + endpoint += `&category=${category}`; + } + + const response = await App.apiRequest(endpoint); + expensesData.expenses = response.expenses || []; + return response; + } catch (error) { + console.error('Error loading expenses:', error); + throw error; + } +} + +async function loadUserCards() { + try { + const response = await App.apiRequest('/cards'); + expensesData.cards = response.cards || []; + return response; + } catch (error) { + console.error('Error loading cards:', error); + expensesData.cards = []; + } +} + +async function loadExpenseForEdit(expenseId) { + try { + const expense = expensesData.expenses.find(e => e.id === parseInt(expenseId)); + if (expense) { + expensesData.editingExpense = expense; + } + } catch (error) { + console.error('Error loading expense for edit:', error); + } +} + +// =========================== +// RENDER FUNCTIONS +// =========================== + +function renderExpenseForm() { + const form = document.getElementById('expenseForm'); + if (!form) return; + + // Populate categories dropdown + const categorySelect = document.getElementById('category'); + if (categorySelect) { + categorySelect.innerHTML = App.EXPENSE_CATEGORIES.map(cat => + `` + ).join(''); + } + + // Populate cards dropdown + const cardSelect = document.getElementById('cardSelect'); + if (cardSelect) { + let options = ''; + expensesData.cards.forEach(card => { + options += ``; + }); + cardSelect.innerHTML = options; + } + + // Set default date to today + const dateInput = document.getElementById('expenseDate'); + if (dateInput && !expensesData.editingExpense) { + dateInput.valueAsDate = new Date(); + } + + // Populate form if editing + if (expensesData.editingExpense) { + populateEditForm(expensesData.editingExpense); + } +} + +function populateEditForm(expense) { + const form = document.getElementById('expenseForm'); + if (!form) return; + + // Update form title + const formTitle = document.querySelector('.form-title'); + if (formTitle) { + formTitle.textContent = 'Edit Expense'; + } + + // Populate fields + document.getElementById('amount').value = expense.amount; + document.getElementById('category').value = expense.category; + document.getElementById('description').value = expense.description || ''; + document.getElementById('expenseDate').value = expense.expense_date; + + // Set payment method + const paymentMethod = expense.payment_method || 'cash'; + const paymentRadios = document.querySelectorAll('input[name="paymentMethod"]'); + paymentRadios.forEach(radio => { + radio.checked = radio.value === paymentMethod; + }); + + // Set card if applicable + if (expense.card_id) { + document.getElementById('cardSelect').value = expense.card_id; + toggleCardSelect(true); + } + + // Store editing state + form.dataset.editId = expense.id; +} + +function toggleCardSelect(show) { + const cardSelectWrapper = document.getElementById('cardSelectWrapper'); + if (cardSelectWrapper) { + cardSelectWrapper.style.display = show ? 'block' : 'none'; + } +} + +// =========================== +// EXPENSE ACTIONS +// =========================== + +async function saveExpense(formData) { + const form = document.getElementById('expenseForm'); + const editId = form?.dataset.editId; + + try { + if (editId) { + await App.apiRequest(`/expenses/${editId}`, { + method: 'PUT', + body: JSON.stringify(formData) + }); + showToast('Expense updated successfully', 'success'); + } else { + await App.apiRequest('/expenses', { + method: 'POST', + body: JSON.stringify(formData) + }); + showToast('Expense added successfully', 'success'); + } + + // Redirect or reset form + if (editId) { + window.location.href = '/dashboard'; + } else { + resetExpenseForm(); + } + } catch (error) { + showToast(error.message || 'Failed to save expense', 'error'); + } +} + +async function deleteExpense(expenseId) { + if (!confirm('Are you sure you want to delete this expense?')) { + return; + } + + try { + await App.apiRequest(`/expenses/${expenseId}`, { method: 'DELETE' }); + showToast('Expense deleted successfully', 'success'); + await loadExpenses(); + renderExpensesList(); + } catch (error) { + showToast(error.message || 'Failed to delete expense', 'error'); + } +} + +function resetExpenseForm() { + const form = document.getElementById('expenseForm'); + if (form) { + form.reset(); + form.dataset.editId = ''; + document.getElementById('expenseDate').valueAsDate = new Date(); + toggleCardSelect(false); + + // Reset form title + const formTitle = document.querySelector('.form-title'); + if (formTitle) { + formTitle.textContent = 'Add New Expense'; + } + } +} + +// =========================== +// EVENT HANDLERS +// =========================== + +function setupExpenseEvents() { + // Form submission + const expenseForm = document.getElementById('expenseForm'); + if (expenseForm) { + expenseForm.addEventListener('submit', async (e) => { + e.preventDefault(); + + const amount = parseFloat(document.getElementById('amount').value); + const category = document.getElementById('category').value; + const description = document.getElementById('description').value; + const expenseDate = document.getElementById('expenseDate').value; + const paymentMethod = document.querySelector('input[name="paymentMethod"]:checked')?.value || 'cash'; + const cardId = document.getElementById('cardSelect')?.value; + + // Validation + if (!amount || amount <= 0) { + showToast('Please enter a valid amount', 'error'); + return; + } + + if (!category) { + showToast('Please select a category', 'error'); + return; + } + + if (!expenseDate) { + showToast('Please select a date', 'error'); + return; + } + + const formData = { + amount, + category, + description, + expense_date: expenseDate, + payment_method: paymentMethod, + card_id: paymentMethod === 'card' && cardId ? parseInt(cardId) : null + }; + + await saveExpense(formData); + }); + } + + // Payment method toggle + const paymentRadios = document.querySelectorAll('input[name="paymentMethod"]'); + paymentRadios.forEach(radio => { + radio.addEventListener('change', function() { + toggleCardSelect(this.value === 'card'); + }); + }); + + // Amount input - format as currency + const amountInput = document.getElementById('amount'); + if (amountInput) { + amountInput.addEventListener('blur', function() { + if (this.value) { + this.value = parseFloat(this.value).toFixed(2); + } + }); + } + + // Quick add buttons (category shortcuts) + const quickAddButtons = document.querySelectorAll('[data-category]'); + quickAddButtons.forEach(btn => { + btn.addEventListener('click', function() { + const category = this.dataset.category; + document.getElementById('category').value = category; + }); + }); +} + +// =========================== +// EXPENSES LIST (for reports page) +// =========================== + +function renderExpensesList() { + const container = document.getElementById('expensesList'); + if (!container) return; + + if (expensesData.expenses.length === 0) { + container.innerHTML = ` +
+ +

No expenses found

+

Try adjusting your filters or add a new expense

+
+ `; + return; + } + + const tableHtml = ` + + + + + + + + + + + + + ${expensesData.expenses.map(expense => ` + + + + + + + + + `).join('')} + +
DateCategoryDescriptionPaymentAmountActions
${App.formatDate(expense.expense_date)} + + ${expense.category} + + ${expense.description || '-'}${expense.card_name || 'Cash'}-${App.formatCurrency(expense.amount)} + + +
+ `; + + container.innerHTML = tableHtml; +} + +function getCategoryBadgeColor(category) { + const colorMap = { + 'Food': 'warning', + 'Transport': 'info', + 'Shopping': 'danger', + 'Bills': 'primary', + 'Entertainment': 'success', + 'Healthcare': 'danger', + 'Education': 'info', + 'Others': 'secondary' + }; + return colorMap[category] || 'secondary'; +} + +// =========================== +// FILTER FUNCTIONS +// =========================== + +async function applyFilters() { + const monthSelect = document.getElementById('filterMonth'); + const yearSelect = document.getElementById('filterYear'); + const categorySelect = document.getElementById('filterCategory'); + + if (monthSelect) expensesData.filters.month = parseInt(monthSelect.value); + if (yearSelect) expensesData.filters.year = parseInt(yearSelect.value); + if (categorySelect) expensesData.filters.category = categorySelect.value; + + showExpensesLoading(true); + await loadExpenses(); + renderExpensesList(); + showExpensesLoading(false); +} + +function resetFilters() { + expensesData.filters = { + month: new Date().getMonth() + 1, + year: new Date().getFullYear(), + category: '' + }; + + // Reset filter inputs + const monthSelect = document.getElementById('filterMonth'); + const yearSelect = document.getElementById('filterYear'); + const categorySelect = document.getElementById('filterCategory'); + + if (monthSelect) monthSelect.value = expensesData.filters.month; + if (yearSelect) yearSelect.value = expensesData.filters.year; + if (categorySelect) categorySelect.value = ''; + + applyFilters(); +} + +// =========================== +// LOADING STATE +// =========================== + +function showExpensesLoading(show) { + const loader = document.getElementById('expensesLoader'); + if (loader) { + loader.style.display = show ? 'flex' : 'none'; + } +} + +// =========================== +// INITIALIZATION +// =========================== + +document.addEventListener('DOMContentLoaded', () => { + if (document.getElementById('expenseForm')) { + initExpensesPage(); + } +}); + +// Export for use in other modules +window.Expenses = { + init: initExpensesPage, + load: loadExpenses, + save: saveExpense, + delete: deleteExpense, + reset: resetExpenseForm, + applyFilters, + resetFilters, + getAll: () => expensesData.expenses +}; diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..ad080f8 --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,472 @@ +/** + * Main JavaScript - Global Functions and Utilities + * Expense Tracker Application + */ + +// API Base URL +const API_BASE = '/api'; + +// =========================== +// UTILITY FUNCTIONS +// =========================== + +/** + * Make an API request + * @param {string} endpoint - API endpoint + * @param {object} options - Fetch options + * @returns {Promise} API response + */ +async function apiRequest(endpoint, options = {}) { + const defaultOptions = { + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'same-origin' + }; + + const config = { ...defaultOptions, ...options }; + + try { + const response = await fetch(`${API_BASE}${endpoint}`, config); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'An error occurred'); + } + + return data; + } catch (error) { + console.error('API Error:', error); + throw error; + } +} + +/** + * Format currency amount + * @param {number} amount - Amount to format + * @param {string} currency - Currency code (default: USD) + * @returns {string} Formatted currency string + */ +function formatCurrency(amount, currency = 'USD') { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency + }).format(amount); +} + +/** + * Format date + * @param {string|Date} date - Date to format + * @param {object} options - Intl.DateTimeFormat options + * @returns {string} Formatted date string + */ +function formatDate(date, options = {}) { + const defaultOptions = { + year: 'numeric', + month: 'short', + day: 'numeric' + }; + + return new Intl.DateTimeFormat('en-US', { ...defaultOptions, ...options }) + .format(new Date(date)); +} + +/** + * Format relative time (e.g., "2 hours ago") + * @param {string|Date} date - Date to format + * @returns {string} Relative time string + */ +function formatRelativeTime(date) { + const now = new Date(); + const past = new Date(date); + const diffMs = now - past; + const diffSeconds = Math.floor(diffMs / 1000); + const diffMinutes = Math.floor(diffSeconds / 60); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffSeconds < 60) { + return 'Just now'; + } else if (diffMinutes < 60) { + return `${diffMinutes} minute${diffMinutes > 1 ? 's' : ''} ago`; + } else if (diffHours < 24) { + return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; + } else if (diffDays < 7) { + return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`; + } else { + return formatDate(date); + } +} + +/** + * Debounce function + * @param {function} func - Function to debounce + * @param {number} wait - Wait time in milliseconds + * @returns {function} Debounced function + */ +function debounce(func, wait = 300) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} + +/** + * Get initials from name + * @param {string} name - Full name + * @returns {string} Initials + */ +function getInitials(name) { + if (!name) return '?'; + return name + .split(' ') + .map(word => word[0]) + .join('') + .toUpperCase() + .substring(0, 2); +} + +/** + * Capitalize first letter + * @param {string} str - String to capitalize + * @returns {string} Capitalized string + */ +function capitalize(str) { + if (!str) return ''; + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); +} + +/** + * Validate email format + * @param {string} email - Email to validate + * @returns {boolean} Is valid email + */ +function isValidEmail(email) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); +} + +/** + * Generate random ID + * @returns {string} Random ID + */ +function generateId() { + return Math.random().toString(36).substring(2) + Date.now().toString(36); +} + +// =========================== +// DOM UTILITIES +// =========================== + +/** + * Get element by selector + * @param {string} selector - CSS selector + * @returns {Element} DOM element + */ +function $(selector) { + return document.querySelector(selector); +} + +/** + * Get all elements by selector + * @param {string} selector - CSS selector + * @returns {NodeList} DOM elements + */ +function $$(selector) { + return document.querySelectorAll(selector); +} + +/** + * Create element with attributes and children + * @param {string} tag - HTML tag name + * @param {object} attrs - Element attributes + * @param {array} children - Child elements or text + * @returns {Element} Created element + */ +function createElement(tag, attrs = {}, children = []) { + const element = document.createElement(tag); + + Object.entries(attrs).forEach(([key, value]) => { + if (key === 'className') { + element.className = value; + } else if (key === 'dataset') { + Object.entries(value).forEach(([dataKey, dataValue]) => { + element.dataset[dataKey] = dataValue; + }); + } else if (key.startsWith('on')) { + element.addEventListener(key.substring(2).toLowerCase(), value); + } else { + element.setAttribute(key, value); + } + }); + + children.forEach(child => { + if (typeof child === 'string') { + element.appendChild(document.createTextNode(child)); + } else if (child instanceof Element) { + element.appendChild(child); + } + }); + + return element; +} + +/** + * Show loading spinner + * @param {Element} container - Container element + */ +function showLoading(container) { + const loader = createElement('div', { className: 'loading-overlay' }, [ + createElement('div', { className: 'spinner spinner-lg' }) + ]); + container.appendChild(loader); +} + +/** + * Hide loading spinner + * @param {Element} container - Container element + */ +function hideLoading(container) { + const loader = container.querySelector('.loading-overlay'); + if (loader) { + loader.remove(); + } +} + +// =========================== +// LOCAL STORAGE HELPERS +// =========================== + +/** + * Get item from localStorage with JSON parsing + * @param {string} key - Storage key + * @param {*} defaultValue - Default value if key doesn't exist + * @returns {*} Stored value or default + */ +function getStorageItem(key, defaultValue = null) { + try { + const item = localStorage.getItem(key); + return item ? JSON.parse(item) : defaultValue; + } catch (error) { + console.error('Error reading from localStorage:', error); + return defaultValue; + } +} + +/** + * Set item in localStorage with JSON stringification + * @param {string} key - Storage key + * @param {*} value - Value to store + */ +function setStorageItem(key, value) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.error('Error writing to localStorage:', error); + } +} + +/** + * Remove item from localStorage + * @param {string} key - Storage key + */ +function removeStorageItem(key) { + try { + localStorage.removeItem(key); + } catch (error) { + console.error('Error removing from localStorage:', error); + } +} + +// =========================== +// SESSION & AUTH MANAGEMENT +// =========================== + +/** + * Check if user is authenticated + * @returns {Promise} Is authenticated + */ +async function isAuthenticated() { + try { + const response = await apiRequest('/auth/verify'); + return response.authenticated; + } catch (error) { + return false; + } +} + +/** + * Get current user info + * @returns {Promise} User data + */ +async function getCurrentUser() { + try { + const response = await apiRequest('/user/profile'); + return response; + } catch (error) { + return null; + } +} + +/** + * Redirect to login if not authenticated + */ +async function requireAuth() { + const authenticated = await isAuthenticated(); + if (!authenticated) { + window.location.href = '/login'; + } +} + +/** + * Redirect to dashboard if already authenticated + */ +async function redirectIfAuth() { + const authenticated = await isAuthenticated(); + if (authenticated) { + window.location.href = '/dashboard'; + } +} + +/** + * Logout user + */ +async function logout() { + try { + await apiRequest('/auth/logout', { method: 'POST' }); + window.location.href = '/login'; + } catch (error) { + console.error('Logout error:', error); + window.location.href = '/login'; + } +} + +// =========================== +// AUTO LOGOUT FUNCTIONALITY +// =========================== + +let inactivityTimeout; +const INACTIVITY_LIMIT = 30 * 60 * 1000; // 30 minutes + +function resetInactivityTimer() { + clearTimeout(inactivityTimeout); + inactivityTimeout = setTimeout(() => { + showToast('Session expired due to inactivity', 'warning'); + setTimeout(() => logout(), 2000); + }, INACTIVITY_LIMIT); +} + +function setupAutoLogout() { + // Reset timer on user activity + ['mousedown', 'mousemove', 'keydown', 'scroll', 'touchstart'].forEach(event => { + document.addEventListener(event, resetInactivityTimer, true); + }); + + // Start initial timer + resetInactivityTimer(); +} + +// =========================== +// CATEGORY HELPERS +// =========================== + +const EXPENSE_CATEGORIES = [ + { value: 'Food', label: 'Food & Dining', icon: 'fa-utensils', color: 'category-food' }, + { value: 'Transport', label: 'Transport', icon: 'fa-car', color: 'category-transport' }, + { value: 'Shopping', label: 'Shopping', icon: 'fa-shopping-bag', color: 'category-shopping' }, + { value: 'Bills', label: 'Bills & Utilities', icon: 'fa-file-invoice', color: 'category-bills' }, + { value: 'Entertainment', label: 'Entertainment', icon: 'fa-film', color: 'category-entertainment' }, + { value: 'Healthcare', label: 'Healthcare', icon: 'fa-heartbeat', color: 'category-healthcare' }, + { value: 'Education', label: 'Education', icon: 'fa-graduation-cap', color: 'category-education' }, + { value: 'Others', label: 'Others', icon: 'fa-ellipsis-h', color: 'category-others' } +]; + +function getCategoryInfo(categoryValue) { + return EXPENSE_CATEGORIES.find(cat => cat.value === categoryValue) || EXPENSE_CATEGORIES[7]; +} + +function getCategoryIcon(category) { + const info = getCategoryInfo(category); + return info ? info.icon : 'fa-ellipsis-h'; +} + +function getCategoryColor(category) { + const info = getCategoryInfo(category); + return info ? info.color : 'category-others'; +} + +// =========================== +// CHART COLORS +// =========================== + +const CHART_COLORS = [ + '#3b82f6', // Blue + '#10b981', // Green + '#f59e0b', // Yellow + '#ef4444', // Red + '#8b5cf6', // Purple + '#ec4899', // Pink + '#06b6d4', // Cyan + '#f97316', // Orange +]; + +// =========================== +// INITIALIZATION +// =========================== + +document.addEventListener('DOMContentLoaded', () => { + // Setup auto logout for authenticated pages + const isAuthPage = window.location.pathname === '/login' || + window.location.pathname === '/signup' || + window.location.pathname === '/'; + + if (!isAuthPage) { + setupAutoLogout(); + } + + // Close dropdowns when clicking outside + document.addEventListener('click', (e) => { + const dropdowns = $$('.dropdown-menu.show, .dropdown-content.show'); + dropdowns.forEach(dropdown => { + if (!dropdown.parentElement.contains(e.target)) { + dropdown.classList.remove('show'); + } + }); + }); +}); + +// Export functions for use in other modules +window.App = { + apiRequest, + formatCurrency, + formatDate, + formatRelativeTime, + debounce, + getInitials, + capitalize, + isValidEmail, + generateId, + $, + $$, + createElement, + showLoading, + hideLoading, + getStorageItem, + setStorageItem, + removeStorageItem, + isAuthenticated, + getCurrentUser, + requireAuth, + redirectIfAuth, + logout, + EXPENSE_CATEGORIES, + getCategoryInfo, + getCategoryIcon, + getCategoryColor, + CHART_COLORS +}; diff --git a/static/js/notifications.js b/static/js/notifications.js new file mode 100644 index 0000000..93b851a --- /dev/null +++ b/static/js/notifications.js @@ -0,0 +1,399 @@ +/** + * Notifications JavaScript - Notification Handling + * Expense Tracker Application + */ + +// =========================== +// TOAST NOTIFICATIONS +// =========================== + +/** + * Show a toast notification + * @param {string} message - Message to display + * @param {string} type - Type: 'success', 'error', 'warning', 'info' + * @param {number} duration - Duration in milliseconds + */ +function showToast(message, type = 'info', duration = 4000) { + // Create toast container if it doesn't exist + let container = document.querySelector('.toast-container'); + if (!container) { + container = document.createElement('div'); + container.className = 'toast-container'; + document.body.appendChild(container); + } + + // Create toast element + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + + // Get icon based on type + const icons = { + success: 'fa-check', + error: 'fa-times', + warning: 'fa-exclamation', + info: 'fa-info' + }; + + toast.innerHTML = ` +
+ +
+
+ ${message} +
+ + `; + + // Add to container + container.appendChild(toast); + + // Auto remove after duration + setTimeout(() => { + toast.classList.add('hide'); + setTimeout(() => toast.remove(), 300); + }, duration); + + // Remove on click + toast.addEventListener('click', () => { + toast.classList.add('hide'); + setTimeout(() => toast.remove(), 300); + }); +} + +// =========================== +// NOTIFICATIONS PANEL +// =========================== + +let notificationsData = { + notifications: [], + unreadCount: 0 +}; + +async function loadNotifications() { + try { + const response = await App.apiRequest('/notifications'); + notificationsData.notifications = response.notifications || []; + notificationsData.unreadCount = notificationsData.notifications.filter(n => !n.is_read).length; + updateNotificationBadge(); + return response; + } catch (error) { + console.error('Error loading notifications:', error); + return { notifications: [] }; + } +} + +function updateNotificationBadge() { + const badge = document.querySelector('.notification-badge'); + if (badge) { + if (notificationsData.unreadCount > 0) { + badge.textContent = notificationsData.unreadCount > 99 ? '99+' : notificationsData.unreadCount; + badge.style.display = 'flex'; + } else { + badge.style.display = 'none'; + } + } +} + +function renderNotificationsPanel() { + const panel = document.getElementById('notificationsPanel'); + if (!panel) return; + + if (notificationsData.notifications.length === 0) { + panel.innerHTML = ` +
+ +

No notifications

+
+ `; + return; + } + + panel.innerHTML = ` +
+

Notifications

+ +
+
+ ${notificationsData.notifications.map(notification => ` +
+
+ +
+
+

${notification.message}

+ ${App.formatRelativeTime(notification.created_at)} +
+
+ `).join('')} +
+ `; +} + +function getNotificationIcon(type) { + const icons = { + warning: 'fa-exclamation-triangle', + info: 'fa-info-circle', + success: 'fa-check-circle' + }; + return icons[type] || icons.info; +} + +async function markNotificationRead(notificationId) { + try { + await App.apiRequest(`/notifications/${notificationId}/read`, { method: 'PUT' }); + + // Update local state + const notification = notificationsData.notifications.find(n => n.id === notificationId); + if (notification && !notification.is_read) { + notification.is_read = true; + notificationsData.unreadCount--; + updateNotificationBadge(); + renderNotificationsPanel(); + } + } catch (error) { + console.error('Error marking notification as read:', error); + } +} + +async function markAllNotificationsRead() { + try { + await App.apiRequest('/notifications/read-all', { method: 'PUT' }); + + // Update local state + notificationsData.notifications.forEach(n => n.is_read = true); + notificationsData.unreadCount = 0; + updateNotificationBadge(); + renderNotificationsPanel(); + + showToast('All notifications marked as read', 'success'); + } catch (error) { + showToast('Failed to mark notifications as read', 'error'); + } +} + +function toggleNotificationsPanel() { + const panel = document.getElementById('notificationsPanel'); + if (panel) { + panel.classList.toggle('show'); + + if (panel.classList.contains('show')) { + loadNotifications().then(() => renderNotificationsPanel()); + } + } +} + +// =========================== +// BUDGET WARNING NOTIFICATIONS +// =========================== + +function checkBudgetWarning(summary) { + if (!summary || !summary.monthly_salary || summary.monthly_salary <= 0) return; + + const budgetPercent = (summary.total_expenses / summary.monthly_salary) * 100; + + if (budgetPercent >= 100) { + showToast( + `You've exceeded your monthly budget! Current spending: ${App.formatCurrency(summary.total_expenses)}`, + 'error', + 6000 + ); + } else if (budgetPercent >= 80) { + showToast( + `Warning: You've used ${budgetPercent.toFixed(1)}% of your monthly budget`, + 'warning', + 5000 + ); + } +} + +// =========================== +// NOTIFICATION STYLES (Inline) +// =========================== + +const notificationStyles = ` + .notifications-panel { + position: absolute; + top: 100%; + right: 0; + width: 360px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-lg); + box-shadow: var(--shadow-xl); + z-index: 1000; + display: none; + max-height: 400px; + overflow: hidden; + } + + .notifications-panel.show { + display: block; + animation: fadeIn 0.2s ease; + } + + .notifications-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-bottom: 1px solid var(--border-color); + } + + .notifications-header h4 { + margin: 0; + font-size: var(--font-size-base); + } + + .notifications-list { + overflow-y: auto; + max-height: 320px; + } + + .notification-item { + display: flex; + gap: 0.75rem; + padding: 1rem; + border-bottom: 1px solid var(--border-color); + cursor: pointer; + transition: background var(--transition-fast); + } + + .notification-item:hover { + background: var(--bg-secondary); + } + + .notification-item.unread { + background: rgba(59, 130, 246, 0.05); + } + + .notification-item.unread::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--primary-color); + } + + .notification-icon { + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .notification-warning { + background: #fef3c7; + color: var(--warning-color); + } + + .notification-info { + background: #dbeafe; + color: var(--info-color); + } + + .notification-success { + background: #d1fae5; + color: var(--success-color); + } + + .notification-content { + flex: 1; + min-width: 0; + } + + .notification-message { + font-size: var(--font-size-sm); + color: var(--text-primary); + margin: 0; + line-height: 1.4; + } + + .notification-time { + font-size: var(--font-size-xs); + color: var(--text-light); + } + + .notifications-empty { + padding: 2rem; + text-align: center; + color: var(--text-light); + } + + .notifications-empty i { + font-size: 2rem; + margin-bottom: 0.5rem; + } +`; + +// Inject notification styles +function injectNotificationStyles() { + const styleEl = document.createElement('style'); + styleEl.textContent = notificationStyles; + document.head.appendChild(styleEl); +} + +// =========================== +// EVENT HANDLERS +// =========================== + +function setupNotificationEvents() { + // Notification bell click + const notificationBell = document.querySelector('.notification-bell'); + if (notificationBell) { + notificationBell.addEventListener('click', (e) => { + e.stopPropagation(); + toggleNotificationsPanel(); + }); + } + + // Close panel when clicking outside + document.addEventListener('click', (e) => { + const panel = document.getElementById('notificationsPanel'); + const bell = document.querySelector('.notification-bell'); + + if (panel && !panel.contains(e.target) && e.target !== bell) { + panel.classList.remove('show'); + } + }); +} + +// =========================== +// INITIALIZATION +// =========================== + +document.addEventListener('DOMContentLoaded', () => { + injectNotificationStyles(); + setupNotificationEvents(); + + // Load notifications on authenticated pages + const isAuthPage = window.location.pathname === '/login' || + window.location.pathname === '/signup' || + window.location.pathname === '/'; + + if (!isAuthPage) { + loadNotifications(); + } +}); + +// Export for use in other modules +window.Notifications = { + show: showToast, + load: loadNotifications, + markRead: markNotificationRead, + markAllRead: markAllNotificationsRead, + checkBudget: checkBudgetWarning +}; + +// Make showToast globally available +window.showToast = showToast; From ca0def5d37752c5295c3b4f3a7fb71be916a66ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 17:51:51 +0000 Subject: [PATCH 3/3] Add HTML templates, README, and .gitignore Co-authored-by: Krishmal2004 <184955509+Krishmal2004@users.noreply.github.com> --- .gitignore | 58 ++++ README.md | 199 ++++++++++++- app.py | 5 +- instance/expense_tracker.db | Bin 20480 -> 24576 bytes templates/add-expense.html | 321 +++++++++++++++++++++ templates/cards.html | 250 +++++++++++++++++ templates/dashboard.html | 295 ++++++++++++++------ templates/index.html | 168 ++++++----- templates/login.html | 86 ++++-- templates/reports.html | 539 ++++++++++++++++++++++++++++++++++++ templates/settings.html | 444 +++++++++++++++++++++++++++++ templates/signup.html | 137 ++++++--- 12 files changed, 2260 insertions(+), 242 deletions(-) create mode 100644 .gitignore create mode 100644 templates/add-expense.html create mode 100644 templates/cards.html create mode 100644 templates/reports.html create mode 100644 templates/settings.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b278583 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +ENV/ +env/ +.venv/ + +# Database +instance/ +*.db +*.sqlite +*.sqlite3 + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Environment variables +.env +.env.local +.env.*.local + +# Logs +*.log +logs/ + +# OS +.DS_Store +Thumbs.db + +# Node (if any frontend build tools used) +node_modules/ +npm-debug.log +yarn-debug.log +yarn-error.log diff --git a/README.md b/README.md index 8508601..310371e 100644 --- a/README.md +++ b/README.md @@ -1 +1,198 @@ -# Expense-Tracker \ No newline at end of file +# Expense Tracker + +A comprehensive web-based monthly expense tracker application that helps users monitor their spending habits, manage budgets, and gain insights into their financial patterns. + +## Features + +- **User Authentication**: Secure signup, login, and session management +- **Dashboard**: Overview of monthly expenses, balance, and recent transactions +- **Card Management**: Add and manage multiple credit/debit cards +- **Expense Tracking**: Record expenses with categories, descriptions, and payment methods +- **Analytics & Reports**: Visual charts and spending insights +- **Budget Alerts**: Notifications when spending exceeds 80% of monthly budget +- **Export Data**: Download expense data as CSV + +## Tech Stack + +- **Backend**: Flask (Python) +- **Database**: SQLite (default) / MySQL +- **Frontend**: HTML5, CSS3, JavaScript +- **Charts**: Chart.js +- **Icons**: Font Awesome + +## Project Structure + +``` +/ +├── app.py # Main Flask application +├── config.py # Configuration settings +├── database.sql # MySQL database schema +├── requirements.txt # Python dependencies +├── static/ +│ ├── css/ +│ │ ├── main.css # Global styles +│ │ ├── auth.css # Login/Signup styles +│ │ ├── dashboard.css # Dashboard styles +│ │ └── components.css # Reusable components +│ ├── js/ +│ │ ├── main.js # Global utilities +│ │ ├── auth.js # Authentication handling +│ │ ├── dashboard.js # Dashboard functionality +│ │ ├── cards.js # Card management +│ │ ├── expenses.js # Expense management +│ │ ├── charts.js # Chart.js integration +│ │ └── notifications.js # Toast notifications +│ └── images/ +├── templates/ +│ ├── index.html # Landing page +│ ├── login.html # Login page +│ ├── signup.html # Signup page +│ ├── dashboard.html # Main dashboard +│ ├── cards.html # Card management +│ ├── add-expense.html # Add expense form +│ ├── settings.html # User settings +│ └── reports.html # Analytics & reports +└── README.md +``` + +## Installation + +### Prerequisites + +- Python 3.8+ +- pip (Python package manager) + +### Setup + +1. **Clone the repository** + ```bash + git clone https://github.com/your-username/expense-tracker.git + cd expense-tracker + ``` + +2. **Create a virtual environment** + ```bash + python -m venv venv + + # On Windows + venv\Scripts\activate + + # On macOS/Linux + source venv/bin/activate + ``` + +3. **Install dependencies** + ```bash + pip install -r requirements.txt + ``` + +4. **Set environment variables (optional)** + ```bash + export SECRET_KEY="your-secret-key" + export DATABASE_URL="sqlite:///expense_tracker.db" + ``` + +5. **Run the application** + ```bash + python app.py + ``` + +6. **Access the application** + Open your browser and navigate to `http://localhost:5000` + +## Configuration + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `SECRET_KEY` | Flask secret key for sessions | Auto-generated | +| `DATABASE_URL` | Database connection string | SQLite | + +### MySQL Setup (Optional) + +If you want to use MySQL instead of SQLite: + +1. Create a MySQL database +2. Run the `database.sql` script to create tables +3. Update `DATABASE_URL` environment variable: + ``` + DATABASE_URL=mysql://user:password@localhost/expense_tracker + ``` + +## API Endpoints + +### Authentication +- `POST /api/auth/signup` - User registration +- `POST /api/auth/login` - User login +- `POST /api/auth/logout` - User logout +- `GET /api/auth/verify` - Verify session + +### User +- `GET /api/user/profile` - Get user profile +- `PUT /api/user/profile` - Update profile +- `PUT /api/user/salary` - Update monthly salary +- `POST /api/user/change-password` - Change password + +### Cards +- `GET /api/cards` - List all cards +- `POST /api/cards` - Add new card +- `PUT /api/cards/` - Update card +- `DELETE /api/cards/` - Delete card + +### Expenses +- `GET /api/expenses` - List expenses +- `POST /api/expenses` - Add expense +- `PUT /api/expenses/` - Update expense +- `DELETE /api/expenses/` - Delete expense +- `GET /api/expenses/monthly` - Monthly summary + +### Analytics +- `GET /api/analytics/monthly` - Monthly spending data +- `GET /api/analytics/category` - Category breakdown +- `GET /api/analytics/trends` - Spending trends +- `GET /api/dashboard/summary` - Dashboard summary + +### Notifications +- `GET /api/notifications` - List notifications +- `PUT /api/notifications//read` - Mark as read +- `PUT /api/notifications/read-all` - Mark all as read + +## Security Features + +- Password hashing using Werkzeug +- Session-based authentication +- CSRF protection ready +- Input validation on frontend and backend +- SQL injection prevention via SQLAlchemy ORM +- XSS protection + +## Screenshots + +### Landing Page +Clean, modern landing page with login/signup options + +### Dashboard +Overview of expenses with charts and recent transactions + +### Add Expense +Easy-to-use form with category selection + +### Reports +Detailed analytics with filtering options + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. + +## Support + +If you encounter any issues or have questions, please open an issue on GitHub. \ No newline at end of file diff --git a/app.py b/app.py index d437f39..4ba24b3 100644 --- a/app.py +++ b/app.py @@ -212,9 +212,8 @@ def dashboard(): @app.route("/profile") def profile(): - if 'user_id' not in session: - return redirect(url_for('login_page')) - return render_template("profile.html") + # Redirect to settings page + return redirect(url_for('settings_page')) @app.route("/cards") diff --git a/instance/expense_tracker.db b/instance/expense_tracker.db index 9b0e2da98be4b8bc0e00006f9824c5311351574d..225c9530d8085e57da660b119aca01226c16deb6 100644 GIT binary patch delta 211 zcmZozz}Rqrae}lUD+2=q8xX?)>qH%6aaIPsvQA#05Gz+71K)YRYF;hw1zdd_8zs0r zn)q4S#nsgrn`287lX6n?@=G$)GLsWaGV}9_Q8;YQL9UJ=t_mTJPCl**C{hXVz delta 61 zcmZoTz}T>Wae}lU3j+fKD-go~%S0VxQ5FWhHceij5DWJL2EOxr)x28V3pNW1=x}dd Iz@6X%05h)(BLDyZ diff --git a/templates/add-expense.html b/templates/add-expense.html new file mode 100644 index 0000000..0d9ad51 --- /dev/null +++ b/templates/add-expense.html @@ -0,0 +1,321 @@ + + + + + + Add Expense | Expense Tracker + + + + + + + + + + + +
+ + + + + + + +
+ +
+
+ +
+

Add New Expense

+

Record your expense transaction

+
+
+
+ + +
+
+
+
+
+ +
+ +
+ + USD +
+
+ + +
+ +
+ + + + + + + + +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + +
+
+ + + + + +
+ +
+
+
+
+
+
+
+
+ + + + + + + diff --git a/templates/cards.html b/templates/cards.html new file mode 100644 index 0000000..6e4d4b9 --- /dev/null +++ b/templates/cards.html @@ -0,0 +1,250 @@ + + + + + + My Cards | Expense Tracker + + + + + + + + + +
+
+
+ +
+ + + + + + + +
+ +
+
+ +
+

My Cards

+

Manage your credit and debit cards

+
+
+ +
+ +
+
+ + +
+ +
+

Total Balance Across All Cards

+
$0.00
+
+ + +
+ +
+
+
+
+ + + + + + + + + + diff --git a/templates/dashboard.html b/templates/dashboard.html index 4fad27f..c92397a 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -3,116 +3,235 @@ - Dashboard | Nexus Tracker - + Dashboard | Expense Tracker + - - + + + + - -
-