Initial commit

This commit is contained in:
iulian 2025-12-26 00:52:56 +00:00
commit 983cee0320
322 changed files with 57174 additions and 0 deletions

View file

@ -0,0 +1,65 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
.venv/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Project specific
data/*.db
data/*.db-journal
uploads/*
!uploads/.gitkeep
*.log
# Git
.git/
.gitignore
# Environment
.env
.env.local
# Testing
.pytest_cache/
.coverage
htmlcov/
# Documentation
*.md
!README.md
# Docker
Dockerfile
docker-compose.yml
.dockerignore

View file

@ -0,0 +1,4 @@
SECRET_KEY=change-this-to-a-random-secret-key
DATABASE_URL=sqlite:///data/fina.db
REDIS_URL=redis://localhost:6379/0
FLASK_ENV=development

19
backup/fina-1/.gitignore vendored Normal file
View file

@ -0,0 +1,19 @@
*.pyc
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
.venv
.env
data/
uploads/
*.db
*.sqlite
.DS_Store
.vscode/
.idea/
*.log

View file

@ -0,0 +1,132 @@
=== FINA Backup Summary ===
Backup Date: Wed Dec 17 10:40:16 PM GMT 2025
Backup Location: /home/iulian/projects/fina/backup/fina-1
Files Backed Up:
67
Directory Structure:
.:
app
backup
BACKUP_INFO.txt
docker-compose.yml
Dockerfile
instance
new theme
README.md
requirements.txt
run.py
./app:
__init__.py
models.py
routes
static
templates
utils.py
./app/routes:
admin.py
auth.py
documents.py
expenses.py
main.py
settings.py
./app/static:
icons
js
manifest.json
sw.js
./app/static/icons:
apple-touch-icon.png
avatars
create_logo.py
create_round_logo.py
favicon.png
icon-192x192.png
icon-512x512.png
icon-96x96.png
logo.png
logo.png.base64
./app/static/icons/avatars:
avatar-1.svg
avatar-2.svg
avatar-3.svg
avatar-4.svg
avatar-5.svg
avatar-6.svg
./app/static/js:
app.js
dashboard.js
documents.js
i18n.js
pwa.js
reports.js
settings.js
transactions.js
./app/templates:
auth
base.html
dashboard.html
documents.html
landing.html
reports.html
settings.html
transactions.html
./app/templates/auth:
backup_codes.html
login.html
register.html
setup_2fa.html
./backup:
fina-1
./backup/fina-1:
./instance:
./new theme:
stitch_expense_tracking_dashboard
stitch_expense_tracking_dashboard(1)
stitch_expense_tracking_dashboard(2)
stitch_expense_tracking_dashboard(3)
stitch_expense_tracking_dashboard(3) (2)
stitch_expense_tracking_dashboard(4)
./new theme/stitch_expense_tracking_dashboard:
code.html
screen.png
./new theme/stitch_expense_tracking_dashboard(1):
code.html
screen.png
./new theme/stitch_expense_tracking_dashboard(2):
code.html
screen.png
./new theme/stitch_expense_tracking_dashboard(3):
code.html
screen.png
./new theme/stitch_expense_tracking_dashboard(3) (2):
code.html
screen.png
./new theme/stitch_expense_tracking_dashboard(4):
code.html
screen.png
Excluded from backup:
- .venv (virtual environment)
- __pycache__ (Python cache)
- data (database files)
- uploads (user uploaded files)

26
backup/fina-1/Dockerfile Normal file
View file

@ -0,0 +1,26 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY app/ ./app/
COPY run.py .
# Create necessary directories with proper permissions
RUN mkdir -p data uploads instance && \
chmod 755 data uploads instance
# Expose port
EXPOSE 5103
# Run the application
CMD ["python", "run.py"]

36
backup/fina-1/README.md Normal file
View file

@ -0,0 +1,36 @@
# FINA - Personal Finance Tracker
A modern, secure PWA for tracking expenses with multi-user support, visual analytics, and comprehensive financial management.
## Features
- 💰 Expense tracking with custom categories and tags
- 📊 Interactive analytics dashboard
- 🔐 Secure authentication with optional 2FA
- 👥 Multi-user support with role-based access
- 🌍 Multi-language (English, Romanian)
- 💱 Multi-currency support (USD, EUR, GBP, RON)
- 📱 Progressive Web App (PWA)
- 🎨 Modern glassmorphism UI
- 📤 CSV import/export
- 📎 Receipt attachments
## Quick Start
```bash
docker-compose up -d
```
Access the app at `http://localhost:5103`
## Tech Stack
- Backend: Flask (Python)
- Database: SQLite
- Cache: Redis
- Frontend: Tailwind CSS, Chart.js
- Deployment: Docker
## License
MIT

View file

@ -0,0 +1,86 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_bcrypt import Bcrypt
import redis
import os
from datetime import timedelta
db = SQLAlchemy()
bcrypt = Bcrypt()
login_manager = LoginManager()
redis_client = None
def create_app():
app = Flask(__name__)
# Configuration
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:///data/fina.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
app.config['UPLOAD_FOLDER'] = os.path.abspath('uploads')
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)
app.config['WTF_CSRF_TIME_LIMIT'] = None
# Initialize extensions
db.init_app(app)
bcrypt.init_app(app)
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
# Redis connection
global redis_client
try:
redis_url = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
redis_client = redis.from_url(redis_url, decode_responses=True)
except Exception as e:
print(f"Redis connection failed: {e}")
redis_client = None
# Create upload directories
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'documents'), exist_ok=True)
os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'avatars'), exist_ok=True)
os.makedirs('data', exist_ok=True)
# Register blueprints
from app.routes import auth, main, expenses, admin, documents, settings
app.register_blueprint(auth.bp)
app.register_blueprint(main.bp)
app.register_blueprint(expenses.bp)
app.register_blueprint(admin.bp)
app.register_blueprint(documents.bp)
app.register_blueprint(settings.bp)
# Serve uploaded files
from flask import send_from_directory, url_for
@app.route('/uploads/<path:filename>')
def uploaded_file(filename):
"""Serve uploaded files (avatars, documents)"""
upload_dir = os.path.join(app.root_path, '..', app.config['UPLOAD_FOLDER'])
return send_from_directory(upload_dir, filename)
# Add avatar_url filter for templates
@app.template_filter('avatar_url')
def avatar_url_filter(avatar_path):
"""Generate correct URL for avatar (either static or uploaded)"""
if avatar_path.startswith('icons/'):
# Default avatar in static folder
return url_for('static', filename=avatar_path)
else:
# Uploaded avatar
return '/' + avatar_path
# Create database tables
with app.app_context():
db.create_all()
return app
from app.models import User
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))

131
backup/fina-1/app/models.py Normal file
View file

@ -0,0 +1,131 @@
from app import db
from flask_login import UserMixin
from datetime import datetime
import json
class User(db.Model, UserMixin):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(128), nullable=False)
is_admin = db.Column(db.Boolean, default=False)
totp_secret = db.Column(db.String(32), nullable=True)
two_factor_enabled = db.Column(db.Boolean, default=False)
backup_codes = db.Column(db.Text, nullable=True) # JSON array of hashed backup codes
language = db.Column(db.String(5), default='en')
currency = db.Column(db.String(3), default='USD')
avatar = db.Column(db.String(255), default='icons/avatars/avatar-1.svg')
created_at = db.Column(db.DateTime, default=datetime.utcnow)
expenses = db.relationship('Expense', backref='user', lazy='dynamic', cascade='all, delete-orphan')
categories = db.relationship('Category', backref='user', lazy='dynamic', cascade='all, delete-orphan')
documents = db.relationship('Document', backref='user', lazy='dynamic', cascade='all, delete-orphan')
def __repr__(self):
return f'<User {self.username}>'
class Category(db.Model):
__tablename__ = 'categories'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), nullable=False)
color = db.Column(db.String(7), default='#2b8cee')
icon = db.Column(db.String(50), default='category')
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
expenses = db.relationship('Expense', backref='category', lazy='dynamic')
def __repr__(self):
return f'<Category {self.name}>'
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'color': self.color,
'icon': self.icon,
'created_at': self.created_at.isoformat()
}
class Expense(db.Model):
__tablename__ = 'expenses'
id = db.Column(db.Integer, primary_key=True)
amount = db.Column(db.Float, nullable=False)
currency = db.Column(db.String(3), default='USD')
description = db.Column(db.String(200), nullable=False)
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
tags = db.Column(db.Text, default='[]') # JSON array of tags
receipt_path = db.Column(db.String(255), nullable=True)
date = db.Column(db.DateTime, default=datetime.utcnow)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f'<Expense {self.description} - {self.amount} {self.currency}>'
def get_tags(self):
try:
return json.loads(self.tags)
except:
return []
def set_tags(self, tags_list):
self.tags = json.dumps(tags_list)
def to_dict(self):
return {
'id': self.id,
'amount': self.amount,
'currency': self.currency,
'description': self.description,
'category_id': self.category_id,
'category_name': self.category.name if self.category else None,
'category_color': self.category.color if self.category else None,
'tags': self.get_tags(),
'receipt_path': self.receipt_path,
'date': self.date.isoformat(),
'created_at': self.created_at.isoformat()
}
class Document(db.Model):
"""
Model for storing user documents (bank statements, receipts, invoices, etc.)
Security: All queries filtered by user_id to ensure users only see their own documents
"""
__tablename__ = 'documents'
id = db.Column(db.Integer, primary_key=True)
filename = db.Column(db.String(255), nullable=False)
original_filename = db.Column(db.String(255), nullable=False)
file_path = db.Column(db.String(500), nullable=False)
file_size = db.Column(db.Integer, nullable=False) # in bytes
file_type = db.Column(db.String(50), nullable=False) # PDF, CSV, XLSX, etc.
mime_type = db.Column(db.String(100), nullable=False)
document_category = db.Column(db.String(100), nullable=True) # Bank Statement, Invoice, Receipt, Contract, etc.
status = db.Column(db.String(50), default='uploaded') # uploaded, processing, analyzed, error
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f'<Document {self.filename} - {self.user_id}>'
def to_dict(self):
return {
'id': self.id,
'filename': self.original_filename,
'file_size': self.file_size,
'file_type': self.file_type,
'document_category': self.document_category,
'status': self.status,
'created_at': self.created_at.isoformat(),
'updated_at': self.updated_at.isoformat()
}

View file

@ -0,0 +1,110 @@
from flask import Blueprint, request, jsonify
from flask_login import login_required, current_user
from app import db, bcrypt
from app.models import User, Expense, Category
from functools import wraps
bp = Blueprint('admin', __name__, url_prefix='/api/admin')
def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_admin:
return jsonify({'success': False, 'message': 'Admin access required'}), 403
return f(*args, **kwargs)
return decorated_function
@bp.route('/users', methods=['GET'])
@login_required
@admin_required
def get_users():
users = User.query.all()
return jsonify({
'users': [{
'id': user.id,
'username': user.username,
'email': user.email,
'is_admin': user.is_admin,
'language': user.language,
'currency': user.currency,
'two_factor_enabled': user.two_factor_enabled,
'created_at': user.created_at.isoformat()
} for user in users]
})
@bp.route('/users', methods=['POST'])
@login_required
@admin_required
def create_user():
data = request.get_json()
if not data.get('username') or not data.get('email') or not data.get('password'):
return jsonify({'success': False, 'message': 'Missing required fields'}), 400
# Check if user exists
if User.query.filter_by(email=data['email']).first():
return jsonify({'success': False, 'message': 'Email already exists'}), 400
if User.query.filter_by(username=data['username']).first():
return jsonify({'success': False, 'message': 'Username already exists'}), 400
# Create user
password_hash = bcrypt.generate_password_hash(data['password']).decode('utf-8')
user = User(
username=data['username'],
email=data['email'],
password_hash=password_hash,
is_admin=data.get('is_admin', False),
language=data.get('language', 'en'),
currency=data.get('currency', 'USD')
)
db.session.add(user)
db.session.commit()
# Create default categories
from app.utils import create_default_categories
create_default_categories(user.id)
return jsonify({
'success': True,
'user': {
'id': user.id,
'username': user.username,
'email': user.email
}
}), 201
@bp.route('/users/<int:user_id>', methods=['DELETE'])
@login_required
@admin_required
def delete_user(user_id):
if user_id == current_user.id:
return jsonify({'success': False, 'message': 'Cannot delete yourself'}), 400
user = User.query.get(user_id)
if not user:
return jsonify({'success': False, 'message': 'User not found'}), 404
db.session.delete(user)
db.session.commit()
return jsonify({'success': True, 'message': 'User deleted'})
@bp.route('/stats', methods=['GET'])
@login_required
@admin_required
def get_stats():
total_users = User.query.count()
total_expenses = Expense.query.count()
total_categories = Category.query.count()
return jsonify({
'total_users': total_users,
'total_expenses': total_expenses,
'total_categories': total_categories
})

View file

@ -0,0 +1,360 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, session, send_file, make_response
from flask_login import login_user, logout_user, login_required, current_user
from app import db, bcrypt
from app.models import User
import pyotp
import qrcode
import io
import base64
import secrets
import json
from datetime import datetime
bp = Blueprint('auth', __name__, url_prefix='/auth')
def generate_backup_codes(count=10):
"""Generate backup codes for 2FA"""
codes = []
for _ in range(count):
# Generate 8-character alphanumeric code
code = ''.join(secrets.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(8))
# Format as XXXX-XXXX for readability
formatted_code = f"{code[:4]}-{code[4:]}"
codes.append(formatted_code)
return codes
def hash_backup_codes(codes):
"""Hash backup codes for secure storage"""
return [bcrypt.generate_password_hash(code).decode('utf-8') for code in codes]
def verify_backup_code(user, code):
"""Verify a backup code and mark it as used"""
if not user.backup_codes:
return False
stored_codes = json.loads(user.backup_codes)
for i, hashed_code in enumerate(stored_codes):
if bcrypt.check_password_hash(hashed_code, code):
# Remove used code
stored_codes.pop(i)
user.backup_codes = json.dumps(stored_codes)
db.session.commit()
return True
return False
@bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
data = request.get_json() if request.is_json else request.form
username = data.get('username')
password = data.get('password')
two_factor_code = data.get('two_factor_code')
remember = data.get('remember', False)
# Accept both username and email
user = User.query.filter((User.username == username) | (User.email == username)).first()
if user and bcrypt.check_password_hash(user.password_hash, password):
# Check 2FA if enabled
if user.two_factor_enabled:
if not two_factor_code:
if request.is_json:
return {'success': False, 'requires_2fa': True}, 200
session['pending_user_id'] = user.id
return render_template('auth/two_factor.html')
# Try TOTP code first
totp = pyotp.TOTP(user.totp_secret)
is_valid = totp.verify(two_factor_code)
# If TOTP fails, try backup code (format: XXXX-XXXX or XXXXXXXX)
if not is_valid:
is_valid = verify_backup_code(user, two_factor_code)
if not is_valid:
if request.is_json:
return {'success': False, 'message': 'Invalid 2FA code'}, 401
flash('Invalid 2FA code', 'error')
return render_template('auth/login.html')
login_user(user, remember=remember)
session.permanent = remember
if request.is_json:
return {'success': True, 'redirect': url_for('main.dashboard')}
next_page = request.args.get('next')
return redirect(next_page if next_page else url_for('main.dashboard'))
if request.is_json:
return {'success': False, 'message': 'Invalid username or password'}, 401
flash('Invalid username or password', 'error')
return render_template('auth/login.html')
@bp.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
data = request.get_json() if request.is_json else request.form
username = data.get('username')
email = data.get('email')
password = data.get('password')
language = data.get('language', 'en')
currency = data.get('currency', 'USD')
# Check if user exists
if User.query.filter_by(email=email).first():
if request.is_json:
return {'success': False, 'message': 'Email already registered'}, 400
flash('Email already registered', 'error')
return render_template('auth/register.html')
if User.query.filter_by(username=username).first():
if request.is_json:
return {'success': False, 'message': 'Username already taken'}, 400
flash('Username already taken', 'error')
return render_template('auth/register.html')
# Check if this is the first user (make them admin)
is_first_user = User.query.count() == 0
# Create user
password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
user = User(
username=username,
email=email,
password_hash=password_hash,
is_admin=is_first_user,
language=language,
currency=currency
)
db.session.add(user)
db.session.commit()
# Create default categories
from app.utils import create_default_categories
create_default_categories(user.id)
login_user(user)
if request.is_json:
return {'success': True, 'redirect': url_for('main.dashboard')}
flash('Registration successful!', 'success')
return redirect(url_for('main.dashboard'))
return render_template('auth/register.html')
@bp.route('/logout')
@login_required
def logout():
logout_user()
return redirect(url_for('auth.login'))
@bp.route('/setup-2fa', methods=['GET', 'POST'])
@login_required
def setup_2fa():
if request.method == 'POST':
data = request.get_json() if request.is_json else request.form
code = data.get('code')
if not current_user.totp_secret:
secret = pyotp.random_base32()
current_user.totp_secret = secret
totp = pyotp.TOTP(current_user.totp_secret)
if totp.verify(code):
# Generate backup codes
backup_codes_plain = generate_backup_codes(10)
backup_codes_hashed = hash_backup_codes(backup_codes_plain)
current_user.two_factor_enabled = True
current_user.backup_codes = json.dumps(backup_codes_hashed)
db.session.commit()
# Store plain backup codes in session for display
session['backup_codes'] = backup_codes_plain
if request.is_json:
return {'success': True, 'message': '2FA enabled successfully', 'backup_codes': backup_codes_plain}
flash('2FA enabled successfully', 'success')
return redirect(url_for('auth.show_backup_codes'))
if request.is_json:
return {'success': False, 'message': 'Invalid code'}, 400
flash('Invalid code', 'error')
# Generate QR code
if not current_user.totp_secret:
current_user.totp_secret = pyotp.random_base32()
db.session.commit()
totp = pyotp.TOTP(current_user.totp_secret)
provisioning_uri = totp.provisioning_uri(
name=current_user.email,
issuer_name='FINA'
)
# Generate QR code image
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(provisioning_uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
qr_code_base64 = base64.b64encode(buf.getvalue()).decode()
return render_template('auth/setup_2fa.html',
qr_code=qr_code_base64,
secret=current_user.totp_secret)
@bp.route('/backup-codes', methods=['GET'])
@login_required
def show_backup_codes():
"""Display backup codes after 2FA setup"""
backup_codes = session.get('backup_codes', [])
if not backup_codes:
flash('No backup codes available', 'error')
return redirect(url_for('main.settings'))
return render_template('auth/backup_codes.html',
backup_codes=backup_codes,
username=current_user.username)
@bp.route('/backup-codes/download', methods=['GET'])
@login_required
def download_backup_codes_pdf():
"""Download backup codes as PDF"""
backup_codes = session.get('backup_codes', [])
if not backup_codes:
flash('No backup codes available', 'error')
return redirect(url_for('main.settings'))
try:
from reportlab.lib.pagesizes import letter
from reportlab.lib.units import inch
from reportlab.pdfgen import canvas
from reportlab.lib import colors
# Create PDF in memory
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=letter)
width, height = letter
# Title
c.setFont("Helvetica-Bold", 24)
c.drawCentredString(width/2, height - 1*inch, "FINA")
c.setFont("Helvetica-Bold", 18)
c.drawCentredString(width/2, height - 1.5*inch, "Two-Factor Authentication")
c.drawCentredString(width/2, height - 1.9*inch, "Backup Codes")
# User info
c.setFont("Helvetica", 12)
c.drawString(1*inch, height - 2.5*inch, f"User: {current_user.username}")
c.drawString(1*inch, height - 2.8*inch, f"Email: {current_user.email}")
c.drawString(1*inch, height - 3.1*inch, f"Generated: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}")
# Warning message
c.setFillColorRGB(0.8, 0.2, 0.2)
c.setFont("Helvetica-Bold", 11)
c.drawString(1*inch, height - 3.7*inch, "IMPORTANT: Store these codes in a secure location!")
c.setFillColorRGB(0, 0, 0)
c.setFont("Helvetica", 10)
c.drawString(1*inch, height - 4.0*inch, "Each code can only be used once. Use them if you lose access to your authenticator app.")
# Backup codes in two columns
c.setFont("Courier-Bold", 14)
y_position = height - 4.8*inch
x_left = 1.5*inch
x_right = 4.5*inch
for i, code in enumerate(backup_codes):
if i % 2 == 0:
c.drawString(x_left, y_position, f"{i+1:2d}. {code}")
else:
c.drawString(x_right, y_position, f"{i+1:2d}. {code}")
y_position -= 0.4*inch
# Footer
c.setFont("Helvetica", 8)
c.setFillColorRGB(0.5, 0.5, 0.5)
c.drawCentredString(width/2, 0.5*inch, "Keep this document secure and do not share these codes with anyone.")
c.save()
buffer.seek(0)
# Clear backup codes from session after download
session.pop('backup_codes', None)
# Create response with PDF
response = make_response(buffer.getvalue())
response.headers['Content-Type'] = 'application/pdf'
response.headers['Content-Disposition'] = f'attachment; filename=FINA_BackupCodes_{current_user.username}_{datetime.utcnow().strftime("%Y%m%d")}.pdf'
return response
except ImportError:
# If reportlab is not installed, return codes as text file
text_content = f"FINA - Two-Factor Authentication Backup Codes\n\n"
text_content += f"User: {current_user.username}\n"
text_content += f"Email: {current_user.email}\n"
text_content += f"Generated: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}\n\n"
text_content += "IMPORTANT: Store these codes in a secure location!\n"
text_content += "Each code can only be used once.\n\n"
text_content += "Backup Codes:\n"
text_content += "-" * 40 + "\n"
for i, code in enumerate(backup_codes, 1):
text_content += f"{i:2d}. {code}\n"
text_content += "-" * 40 + "\n"
text_content += "\nKeep this document secure and do not share these codes with anyone."
# Clear backup codes from session
session.pop('backup_codes', None)
response = make_response(text_content)
response.headers['Content-Type'] = 'text/plain'
response.headers['Content-Disposition'] = f'attachment; filename=FINA_BackupCodes_{current_user.username}_{datetime.utcnow().strftime("%Y%m%d")}.txt'
return response
@bp.route('/disable-2fa', methods=['POST'])
@login_required
def disable_2fa():
current_user.two_factor_enabled = False
current_user.backup_codes = None
db.session.commit()
if request.is_json:
return {'success': True, 'message': '2FA disabled'}
flash('2FA disabled', 'success')
return redirect(url_for('main.settings'))

View file

@ -0,0 +1,222 @@
from flask import Blueprint, request, jsonify, send_file, current_app
from flask_login import login_required, current_user
from app import db
from app.models import Document
from werkzeug.utils import secure_filename
import os
import mimetypes
from datetime import datetime
bp = Blueprint('documents', __name__, url_prefix='/api/documents')
# Max file size: 10MB
MAX_FILE_SIZE = 10 * 1024 * 1024
# Allowed file types for documents
ALLOWED_DOCUMENT_TYPES = {
'pdf': 'application/pdf',
'csv': 'text/csv',
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'xls': 'application/vnd.ms-excel',
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg'
}
def allowed_document(filename):
"""Check if file type is allowed"""
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_DOCUMENT_TYPES.keys()
def get_file_type_icon(file_type):
"""Get material icon name for file type"""
icons = {
'pdf': 'picture_as_pdf',
'csv': 'table_view',
'xlsx': 'table_view',
'xls': 'table_view',
'png': 'image',
'jpg': 'image',
'jpeg': 'image'
}
return icons.get(file_type.lower(), 'description')
@bp.route('/', methods=['GET'])
@login_required
def get_documents():
"""
Get all documents for current user
Security: Filters by current_user.id
"""
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
search = request.args.get('search', '')
# Security: Only get documents for current user
query = Document.query.filter_by(user_id=current_user.id)
if search:
query = query.filter(Document.original_filename.ilike(f'%{search}%'))
pagination = query.order_by(Document.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return jsonify({
'documents': [doc.to_dict() for doc in pagination.items],
'total': pagination.total,
'pages': pagination.pages,
'current_page': page
})
@bp.route('/', methods=['POST'])
@login_required
def upload_document():
"""
Upload a new document
Security: Associates document with current_user.id
"""
if 'file' not in request.files:
return jsonify({'success': False, 'message': 'No file provided'}), 400
file = request.files['file']
if not file or not file.filename:
return jsonify({'success': False, 'message': 'No file selected'}), 400
if not allowed_document(file.filename):
return jsonify({
'success': False,
'message': 'Invalid file type. Allowed: PDF, CSV, XLS, XLSX, PNG, JPG'
}), 400
# Check file size
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)
if file_size > MAX_FILE_SIZE:
return jsonify({
'success': False,
'message': f'File too large. Maximum size: {MAX_FILE_SIZE // (1024*1024)}MB'
}), 400
# Generate secure filename
original_filename = secure_filename(file.filename)
file_ext = original_filename.rsplit('.', 1)[1].lower()
timestamp = datetime.utcnow().timestamp()
filename = f"{current_user.id}_{timestamp}_{original_filename}"
# Create documents directory if it doesn't exist
documents_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'documents')
os.makedirs(documents_dir, exist_ok=True)
# Save file
file_path = os.path.join(documents_dir, filename)
file.save(file_path)
# Get document category from form data
document_category = request.form.get('category', 'Other')
# Create document record - Security: user_id is current_user.id
document = Document(
filename=filename,
original_filename=original_filename,
file_path=file_path,
file_size=file_size,
file_type=file_ext.upper(),
mime_type=ALLOWED_DOCUMENT_TYPES.get(file_ext, 'application/octet-stream'),
document_category=document_category,
status='uploaded',
user_id=current_user.id
)
db.session.add(document)
db.session.commit()
return jsonify({
'success': True,
'message': 'Document uploaded successfully',
'document': document.to_dict()
}), 201
@bp.route('/<int:document_id>/download', methods=['GET'])
@login_required
def download_document(document_id):
"""
Download a document
Security: Checks document belongs to current_user
"""
# Security: Filter by user_id
document = Document.query.filter_by(id=document_id, user_id=current_user.id).first()
if not document:
return jsonify({'success': False, 'message': 'Document not found'}), 404
if not os.path.exists(document.file_path):
return jsonify({'success': False, 'message': 'File not found on server'}), 404
return send_file(
document.file_path,
mimetype=document.mime_type,
as_attachment=True,
download_name=document.original_filename
)
@bp.route('/<int:document_id>', methods=['DELETE'])
@login_required
def delete_document(document_id):
"""
Delete a document
Security: Checks document belongs to current_user
"""
# Security: Filter by user_id
document = Document.query.filter_by(id=document_id, user_id=current_user.id).first()
if not document:
return jsonify({'success': False, 'message': 'Document not found'}), 404
# Delete physical file
if os.path.exists(document.file_path):
try:
os.remove(document.file_path)
except Exception as e:
print(f"Error deleting file: {e}")
# Delete database record
db.session.delete(document)
db.session.commit()
return jsonify({'success': True, 'message': 'Document deleted successfully'})
@bp.route('/<int:document_id>/status', methods=['PUT'])
@login_required
def update_document_status(document_id):
"""
Update document status (e.g., mark as analyzed)
Security: Checks document belongs to current_user
"""
# Security: Filter by user_id
document = Document.query.filter_by(id=document_id, user_id=current_user.id).first()
if not document:
return jsonify({'success': False, 'message': 'Document not found'}), 404
data = request.get_json()
new_status = data.get('status')
if new_status not in ['uploaded', 'processing', 'analyzed', 'error']:
return jsonify({'success': False, 'message': 'Invalid status'}), 400
document.status = new_status
db.session.commit()
return jsonify({
'success': True,
'message': 'Document status updated',
'document': document.to_dict()
})

View file

@ -0,0 +1,349 @@
from flask import Blueprint, request, jsonify, send_file, current_app
from flask_login import login_required, current_user
from app import db
from app.models import Expense, Category
from werkzeug.utils import secure_filename
import os
import csv
import io
from datetime import datetime
bp = Blueprint('expenses', __name__, url_prefix='/api/expenses')
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'pdf'}
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@bp.route('/', methods=['GET'])
@login_required
def get_expenses():
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
category_id = request.args.get('category_id', type=int)
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
search = request.args.get('search', '')
query = Expense.query.filter_by(user_id=current_user.id)
if category_id:
query = query.filter_by(category_id=category_id)
if start_date:
query = query.filter(Expense.date >= datetime.fromisoformat(start_date))
if end_date:
query = query.filter(Expense.date <= datetime.fromisoformat(end_date))
if search:
query = query.filter(Expense.description.ilike(f'%{search}%'))
pagination = query.order_by(Expense.date.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return jsonify({
'expenses': [expense.to_dict() for expense in pagination.items],
'total': pagination.total,
'pages': pagination.pages,
'current_page': page
})
@bp.route('/', methods=['POST'])
@login_required
def create_expense():
data = request.form if request.files else request.get_json()
# Validate required fields
if not data.get('amount') or not data.get('category_id') or not data.get('description'):
return jsonify({'success': False, 'message': 'Missing required fields'}), 400
# Handle receipt upload
receipt_path = None
if 'receipt' in request.files:
file = request.files['receipt']
if file and file.filename and allowed_file(file.filename):
filename = secure_filename(f"{current_user.id}_{datetime.utcnow().timestamp()}_{file.filename}")
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
file.save(filepath)
receipt_path = filename
# Create expense
expense = Expense(
amount=float(data.get('amount')),
currency=data.get('currency', current_user.currency),
description=data.get('description'),
category_id=int(data.get('category_id')),
user_id=current_user.id,
receipt_path=receipt_path,
date=datetime.fromisoformat(data.get('date')) if data.get('date') else datetime.utcnow()
)
# Handle tags
if data.get('tags'):
if isinstance(data.get('tags'), str):
import json
tags = json.loads(data.get('tags'))
else:
tags = data.get('tags')
expense.set_tags(tags)
db.session.add(expense)
db.session.commit()
return jsonify({
'success': True,
'expense': expense.to_dict()
}), 201
@bp.route('/<int:expense_id>', methods=['PUT'])
@login_required
def update_expense(expense_id):
expense = Expense.query.filter_by(id=expense_id, user_id=current_user.id).first()
if not expense:
return jsonify({'success': False, 'message': 'Expense not found'}), 404
data = request.form if request.files else request.get_json()
# Update fields
if data.get('amount'):
expense.amount = float(data.get('amount'))
if data.get('currency'):
expense.currency = data.get('currency')
if data.get('description'):
expense.description = data.get('description')
if data.get('category_id'):
expense.category_id = int(data.get('category_id'))
if data.get('date'):
expense.date = datetime.fromisoformat(data.get('date'))
if data.get('tags') is not None:
if isinstance(data.get('tags'), str):
import json
tags = json.loads(data.get('tags'))
else:
tags = data.get('tags')
expense.set_tags(tags)
# Handle receipt upload
if 'receipt' in request.files:
file = request.files['receipt']
if file and file.filename and allowed_file(file.filename):
# Delete old receipt
if expense.receipt_path:
old_path = os.path.join(current_app.config['UPLOAD_FOLDER'], expense.receipt_path)
if os.path.exists(old_path):
os.remove(old_path)
filename = secure_filename(f"{current_user.id}_{datetime.utcnow().timestamp()}_{file.filename}")
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
file.save(filepath)
expense.receipt_path = filename
db.session.commit()
return jsonify({
'success': True,
'expense': expense.to_dict()
})
@bp.route('/<int:expense_id>', methods=['DELETE'])
@login_required
def delete_expense(expense_id):
expense = Expense.query.filter_by(id=expense_id, user_id=current_user.id).first()
if not expense:
return jsonify({'success': False, 'message': 'Expense not found'}), 404
# Delete receipt file
if expense.receipt_path:
receipt_path = os.path.join(current_app.config['UPLOAD_FOLDER'], expense.receipt_path)
if os.path.exists(receipt_path):
os.remove(receipt_path)
db.session.delete(expense)
db.session.commit()
return jsonify({'success': True, 'message': 'Expense deleted'})
@bp.route('/categories', methods=['GET'])
@login_required
def get_categories():
categories = Category.query.filter_by(user_id=current_user.id).all()
return jsonify({
'categories': [cat.to_dict() for cat in categories]
})
@bp.route('/categories', methods=['POST'])
@login_required
def create_category():
data = request.get_json()
if not data.get('name'):
return jsonify({'success': False, 'message': 'Name is required'}), 400
category = Category(
name=data.get('name'),
color=data.get('color', '#2b8cee'),
icon=data.get('icon', 'category'),
user_id=current_user.id
)
db.session.add(category)
db.session.commit()
return jsonify({
'success': True,
'category': category.to_dict()
}), 201
@bp.route('/categories/<int:category_id>', methods=['PUT'])
@login_required
def update_category(category_id):
category = Category.query.filter_by(id=category_id, user_id=current_user.id).first()
if not category:
return jsonify({'success': False, 'message': 'Category not found'}), 404
data = request.get_json()
if data.get('name'):
category.name = data.get('name')
if data.get('color'):
category.color = data.get('color')
if data.get('icon'):
category.icon = data.get('icon')
db.session.commit()
return jsonify({
'success': True,
'category': category.to_dict()
})
@bp.route('/categories/<int:category_id>', methods=['DELETE'])
@login_required
def delete_category(category_id):
category = Category.query.filter_by(id=category_id, user_id=current_user.id).first()
if not category:
return jsonify({'success': False, 'message': 'Category not found'}), 404
# Check if category has expenses
if category.expenses.count() > 0:
return jsonify({'success': False, 'message': 'Cannot delete category with expenses'}), 400
db.session.delete(category)
db.session.commit()
return jsonify({'success': True, 'message': 'Category deleted'})
@bp.route('/export/csv', methods=['GET'])
@login_required
def export_csv():
expenses = Expense.query.filter_by(user_id=current_user.id).order_by(Expense.date.desc()).all()
output = io.StringIO()
writer = csv.writer(output)
# Write header
writer.writerow(['Date', 'Description', 'Amount', 'Currency', 'Category', 'Tags'])
# Write data
for expense in expenses:
writer.writerow([
expense.date.strftime('%Y-%m-%d %H:%M:%S'),
expense.description,
expense.amount,
expense.currency,
expense.category.name,
', '.join(expense.get_tags())
])
output.seek(0)
return send_file(
io.BytesIO(output.getvalue().encode('utf-8')),
mimetype='text/csv',
as_attachment=True,
download_name=f'fina_expenses_{datetime.utcnow().strftime("%Y%m%d")}.csv'
)
@bp.route('/import/csv', methods=['POST'])
@login_required
def import_csv():
if 'file' not in request.files:
return jsonify({'success': False, 'message': 'No file provided'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'success': False, 'message': 'No file selected'}), 400
if not file.filename.endswith('.csv'):
return jsonify({'success': False, 'message': 'File must be CSV'}), 400
try:
stream = io.StringIO(file.stream.read().decode('utf-8'))
reader = csv.DictReader(stream)
imported_count = 0
errors = []
for row in reader:
try:
# Find or create category
category_name = row.get('Category', 'Uncategorized')
category = Category.query.filter_by(user_id=current_user.id, name=category_name).first()
if not category:
category = Category(name=category_name, user_id=current_user.id)
db.session.add(category)
db.session.flush()
# Parse date
date_str = row.get('Date', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'))
expense_date = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S')
# Create expense
expense = Expense(
amount=float(row['Amount']),
currency=row.get('Currency', current_user.currency),
description=row['Description'],
category_id=category.id,
user_id=current_user.id,
date=expense_date
)
# Handle tags
if row.get('Tags'):
tags = [tag.strip() for tag in row['Tags'].split(',')]
expense.set_tags(tags)
db.session.add(expense)
imported_count += 1
except Exception as e:
errors.append(f"Row error: {str(e)}")
db.session.commit()
return jsonify({
'success': True,
'imported': imported_count,
'errors': errors
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'Import failed: {str(e)}'}), 500

View file

@ -0,0 +1,289 @@
from flask import Blueprint, render_template, request, jsonify
from flask_login import login_required, current_user
from app import db
from app.models import Expense, Category
from sqlalchemy import func, extract
from datetime import datetime, timedelta
from collections import defaultdict
bp = Blueprint('main', __name__)
@bp.route('/')
def index():
if current_user.is_authenticated:
return render_template('dashboard.html')
return render_template('landing.html')
@bp.route('/dashboard')
@login_required
def dashboard():
return render_template('dashboard.html')
@bp.route('/transactions')
@login_required
def transactions():
return render_template('transactions.html')
@bp.route('/reports')
@login_required
def reports():
return render_template('reports.html')
@bp.route('/settings')
@login_required
def settings():
return render_template('settings.html')
@bp.route('/documents')
@login_required
def documents():
return render_template('documents.html')
@bp.route('/api/dashboard-stats')
@login_required
def dashboard_stats():
now = datetime.utcnow()
# Current month stats
current_month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
# Previous month stats
if now.month == 1:
prev_month_start = now.replace(year=now.year-1, month=12, day=1)
else:
prev_month_start = current_month_start.replace(month=current_month_start.month-1)
# Total spent this month
current_month_total = db.session.query(func.sum(Expense.amount)).filter(
Expense.user_id == current_user.id,
Expense.date >= current_month_start,
Expense.currency == current_user.currency
).scalar() or 0
# Previous month total
prev_month_total = db.session.query(func.sum(Expense.amount)).filter(
Expense.user_id == current_user.id,
Expense.date >= prev_month_start,
Expense.date < current_month_start,
Expense.currency == current_user.currency
).scalar() or 0
# Calculate percentage change
if prev_month_total > 0:
percent_change = ((current_month_total - prev_month_total) / prev_month_total) * 100
else:
percent_change = 100 if current_month_total > 0 else 0
# Active categories
active_categories = Category.query.filter_by(user_id=current_user.id).count()
# Total transactions this month
total_transactions = Expense.query.filter(
Expense.user_id == current_user.id,
Expense.date >= current_month_start
).count()
# Category breakdown
category_stats = db.session.query(
Category.name,
Category.color,
func.sum(Expense.amount).label('total')
).join(Expense).filter(
Expense.user_id == current_user.id,
Expense.date >= current_month_start,
Expense.currency == current_user.currency
).group_by(Category.id).all()
# Monthly breakdown (last 6 months)
monthly_data = []
for i in range(5, -1, -1):
month_date = now - timedelta(days=30*i)
month_start = month_date.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if month_date.month == 12:
month_end = month_date.replace(year=month_date.year+1, month=1, day=1)
else:
month_end = month_date.replace(month=month_date.month+1, day=1)
month_total = db.session.query(func.sum(Expense.amount)).filter(
Expense.user_id == current_user.id,
Expense.date >= month_start,
Expense.date < month_end,
Expense.currency == current_user.currency
).scalar() or 0
monthly_data.append({
'month': month_start.strftime('%b'),
'total': float(month_total)
})
return jsonify({
'total_spent': float(current_month_total),
'percent_change': round(percent_change, 1),
'active_categories': active_categories,
'total_transactions': total_transactions,
'currency': current_user.currency,
'category_breakdown': [
{'name': stat[0], 'color': stat[1], 'amount': float(stat[2])}
for stat in category_stats
],
'monthly_data': monthly_data
})
@bp.route('/api/recent-transactions')
@login_required
def recent_transactions():
limit = request.args.get('limit', 10, type=int)
expenses = Expense.query.filter_by(user_id=current_user.id)\
.order_by(Expense.date.desc())\
.limit(limit)\
.all()
return jsonify({
'transactions': [expense.to_dict() for expense in expenses]
})
@bp.route('/api/reports-stats')
@login_required
def reports_stats():
"""
Generate comprehensive financial reports
Security: Only returns data for current_user (enforced by user_id filter)
"""
period = request.args.get('period', '30') # days
category_filter = request.args.get('category_id', type=int)
try:
days = int(period)
except ValueError:
days = 30
now = datetime.utcnow()
period_start = now - timedelta(days=days)
# Query with security filter
query = Expense.query.filter(
Expense.user_id == current_user.id,
Expense.date >= period_start
)
if category_filter:
query = query.filter_by(category_id=category_filter)
expenses = query.all()
# Total spent in period
total_spent = sum(exp.amount for exp in expenses if exp.currency == current_user.currency)
# Previous period comparison
prev_period_start = period_start - timedelta(days=days)
prev_expenses = Expense.query.filter(
Expense.user_id == current_user.id,
Expense.date >= prev_period_start,
Expense.date < period_start,
Expense.currency == current_user.currency
).all()
prev_total = sum(exp.amount for exp in prev_expenses)
percent_change = 0
if prev_total > 0:
percent_change = ((total_spent - prev_total) / prev_total) * 100
elif total_spent > 0:
percent_change = 100
# Top category
category_totals = {}
for exp in expenses:
if exp.currency == current_user.currency:
cat_name = exp.category.name
category_totals[cat_name] = category_totals.get(cat_name, 0) + exp.amount
top_category = max(category_totals.items(), key=lambda x: x[1]) if category_totals else ('None', 0)
# Average daily spending
avg_daily = total_spent / days if days > 0 else 0
prev_avg_daily = prev_total / days if days > 0 else 0
avg_change = 0
if prev_avg_daily > 0:
avg_change = ((avg_daily - prev_avg_daily) / prev_avg_daily) * 100
elif avg_daily > 0:
avg_change = 100
# Savings rate (placeholder - would need income data)
savings_rate = 18.5 # Placeholder
# Category breakdown for pie chart
category_breakdown = []
for cat_name, amount in sorted(category_totals.items(), key=lambda x: x[1], reverse=True):
category = Category.query.filter_by(user_id=current_user.id, name=cat_name).first()
if category:
percentage = (amount / total_spent * 100) if total_spent > 0 else 0
category_breakdown.append({
'name': cat_name,
'color': category.color,
'amount': float(amount),
'percentage': round(percentage, 1)
})
# Daily spending trend (last 30 days)
daily_trend = []
for i in range(min(30, days)):
day_date = now - timedelta(days=i)
day_start = day_date.replace(hour=0, minute=0, second=0, microsecond=0)
day_end = day_start + timedelta(days=1)
day_total = db.session.query(func.sum(Expense.amount)).filter(
Expense.user_id == current_user.id,
Expense.date >= day_start,
Expense.date < day_end,
Expense.currency == current_user.currency
).scalar() or 0
daily_trend.insert(0, {
'date': day_date.strftime('%d %b'),
'amount': float(day_total)
})
# Monthly comparison (last 6 months)
monthly_comparison = []
for i in range(5, -1, -1):
month_date = now - timedelta(days=30*i)
month_start = month_date.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if month_date.month == 12:
month_end = month_date.replace(year=month_date.year+1, month=1, day=1)
else:
month_end = month_date.replace(month=month_date.month+1, day=1)
month_total = db.session.query(func.sum(Expense.amount)).filter(
Expense.user_id == current_user.id,
Expense.date >= month_start,
Expense.date < month_end,
Expense.currency == current_user.currency
).scalar() or 0
monthly_comparison.append({
'month': month_start.strftime('%b'),
'amount': float(month_total)
})
return jsonify({
'total_spent': float(total_spent),
'percent_change': round(percent_change, 1),
'top_category': {'name': top_category[0], 'amount': float(top_category[1])},
'avg_daily': float(avg_daily),
'avg_daily_change': round(avg_change, 1),
'savings_rate': savings_rate,
'category_breakdown': category_breakdown,
'daily_trend': daily_trend,
'monthly_comparison': monthly_comparison,
'currency': current_user.currency,
'period_days': days
})

View file

@ -0,0 +1,241 @@
from flask import Blueprint, request, jsonify, current_app
from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
from app import db, bcrypt
from app.models import User
import os
from datetime import datetime
bp = Blueprint('settings', __name__, url_prefix='/api/settings')
# Allowed avatar image types
ALLOWED_AVATAR_TYPES = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
MAX_AVATAR_SIZE = 20 * 1024 * 1024 # 20MB
def allowed_avatar(filename):
"""Check if file extension is allowed for avatars"""
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_AVATAR_TYPES
@bp.route('/profile', methods=['GET'])
@login_required
def get_profile():
"""
Get current user profile information
Security: Returns only current user's data
"""
return jsonify({
'success': True,
'profile': {
'username': current_user.username,
'email': current_user.email,
'language': current_user.language,
'currency': current_user.currency,
'avatar': current_user.avatar,
'is_admin': current_user.is_admin,
'two_factor_enabled': current_user.two_factor_enabled,
'created_at': current_user.created_at.isoformat()
}
})
@bp.route('/profile', methods=['PUT'])
@login_required
def update_profile():
"""
Update user profile information
Security: Updates only current user's profile
"""
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'No data provided'}), 400
try:
# Update language
if 'language' in data:
if data['language'] in ['en', 'ro']:
current_user.language = data['language']
else:
return jsonify({'success': False, 'error': 'Invalid language'}), 400
# Update currency
if 'currency' in data:
current_user.currency = data['currency']
# Update username (check uniqueness)
if 'username' in data and data['username'] != current_user.username:
existing = User.query.filter_by(username=data['username']).first()
if existing:
return jsonify({'success': False, 'error': 'Username already taken'}), 400
current_user.username = data['username']
# Update email (check uniqueness)
if 'email' in data and data['email'] != current_user.email:
existing = User.query.filter_by(email=data['email']).first()
if existing:
return jsonify({'success': False, 'error': 'Email already taken'}), 400
current_user.email = data['email']
db.session.commit()
return jsonify({
'success': True,
'message': 'Profile updated successfully',
'profile': {
'username': current_user.username,
'email': current_user.email,
'language': current_user.language,
'currency': current_user.currency,
'avatar': current_user.avatar
}
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/avatar', methods=['POST'])
@login_required
def upload_avatar():
"""
Upload custom avatar image
Security: Associates avatar with current_user.id, validates file type and size
"""
if 'avatar' not in request.files:
return jsonify({'success': False, 'error': 'No file provided'}), 400
file = request.files['avatar']
if not file or not file.filename:
return jsonify({'success': False, 'error': 'No file selected'}), 400
if not allowed_avatar(file.filename):
return jsonify({
'success': False,
'error': 'Invalid file type. Allowed: PNG, JPG, JPEG, GIF, WEBP'
}), 400
# Check file size
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)
if file_size > MAX_AVATAR_SIZE:
return jsonify({
'success': False,
'error': f'File too large. Maximum size: {MAX_AVATAR_SIZE // (1024*1024)}MB'
}), 400
try:
# Delete old custom avatar if exists (not default avatars)
if current_user.avatar and not current_user.avatar.startswith('icons/avatars/'):
old_path = os.path.join(current_app.root_path, 'static', current_user.avatar)
if os.path.exists(old_path):
os.remove(old_path)
# Generate secure filename
file_ext = file.filename.rsplit('.', 1)[1].lower()
timestamp = int(datetime.utcnow().timestamp())
filename = f"user_{current_user.id}_{timestamp}.{file_ext}"
# Create avatars directory in uploads
avatars_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'avatars')
os.makedirs(avatars_dir, exist_ok=True)
# Save file
file_path = os.path.join(avatars_dir, filename)
file.save(file_path)
# Update user avatar (store relative path from static folder)
current_user.avatar = f"uploads/avatars/{filename}"
db.session.commit()
return jsonify({
'success': True,
'message': 'Avatar uploaded successfully',
'avatar': current_user.avatar
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/avatar/default', methods=['PUT'])
@login_required
def set_default_avatar():
"""
Set avatar to one of the default avatars
Security: Updates only current user's avatar
"""
data = request.get_json()
if not data or 'avatar' not in data:
return jsonify({'success': False, 'error': 'Avatar path required'}), 400
avatar_path = data['avatar']
# Validate it's a default avatar
if not avatar_path.startswith('icons/avatars/avatar-'):
return jsonify({'success': False, 'error': 'Invalid avatar selection'}), 400
try:
# Delete old custom avatar if exists (not default avatars)
if current_user.avatar and not current_user.avatar.startswith('icons/avatars/'):
old_path = os.path.join(current_app.root_path, 'static', current_user.avatar)
if os.path.exists(old_path):
os.remove(old_path)
current_user.avatar = avatar_path
db.session.commit()
return jsonify({
'success': True,
'message': 'Avatar updated successfully',
'avatar': current_user.avatar
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/password', methods=['PUT'])
@login_required
def change_password():
"""
Change user password
Security: Requires current password verification
"""
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'No data provided'}), 400
current_password = data.get('current_password')
new_password = data.get('new_password')
if not current_password or not new_password:
return jsonify({'success': False, 'error': 'Current and new password required'}), 400
# Verify current password
if not bcrypt.check_password_hash(current_user.password_hash, current_password):
return jsonify({'success': False, 'error': 'Current password is incorrect'}), 400
if len(new_password) < 6:
return jsonify({'success': False, 'error': 'Password must be at least 6 characters'}), 400
try:
current_user.password_hash = bcrypt.generate_password_hash(new_password).decode('utf-8')
db.session.commit()
return jsonify({
'success': True,
'message': 'Password changed successfully'
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
<circle cx="50" cy="50" r="50" fill="#3B82F6"/>
<circle cx="50" cy="35" r="15" fill="white"/>
<path d="M25 75 Q25 55 50 55 Q75 55 75 75" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 240 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
<circle cx="50" cy="50" r="50" fill="#10B981"/>
<circle cx="50" cy="35" r="15" fill="white"/>
<path d="M25 75 Q25 55 50 55 Q75 55 75 75" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 240 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
<circle cx="50" cy="50" r="50" fill="#F59E0B"/>
<circle cx="50" cy="35" r="15" fill="white"/>
<path d="M25 75 Q25 55 50 55 Q75 55 75 75" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 240 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
<circle cx="50" cy="50" r="50" fill="#8B5CF6"/>
<circle cx="50" cy="35" r="15" fill="white"/>
<path d="M25 75 Q25 55 50 55 Q75 55 75 75" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 240 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
<circle cx="50" cy="50" r="50" fill="#EF4444"/>
<circle cx="50" cy="35" r="15" fill="white"/>
<path d="M25 75 Q25 55 50 55 Q75 55 75 75" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 240 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
<circle cx="50" cy="50" r="50" fill="#EC4899"/>
<circle cx="50" cy="35" r="15" fill="white"/>
<path d="M25 75 Q25 55 50 55 Q75 55 75 75" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 240 B

View file

@ -0,0 +1,87 @@
from PIL import Image, ImageDraw, ImageFont
import os
def create_fina_logo(size):
# Create image with transparent background
img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Background circle (dark blue gradient effect)
center = size // 2
for i in range(10):
radius = size // 2 - i * 2
alpha = 255 - i * 20
color = (0, 50 + i * 5, 80 + i * 8, alpha)
draw.ellipse([center - radius, center - radius, center + radius, center + radius], fill=color)
# White inner circle
inner_radius = int(size * 0.42)
draw.ellipse([center - inner_radius, center - inner_radius, center + inner_radius, center + inner_radius],
fill=(245, 245, 245, 255))
# Shield (cyan/turquoise)
shield_size = int(size * 0.25)
shield_x = int(center - shield_size * 0.5)
shield_y = int(center - shield_size * 0.3)
# Draw shield shape
shield_points = [
(shield_x, shield_y),
(shield_x + shield_size, shield_y),
(shield_x + shield_size, shield_y + int(shield_size * 0.7)),
(shield_x + shield_size // 2, shield_y + int(shield_size * 1.2)),
(shield_x, shield_y + int(shield_size * 0.7))
]
draw.polygon(shield_points, fill=(64, 224, 208, 200))
# Coins (orange/golden)
coin_radius = int(size * 0.08)
coin_x = int(center + shield_size * 0.3)
coin_y = int(center - shield_size * 0.1)
# Draw 3 stacked coins
for i in range(3):
y_offset = coin_y + i * int(coin_radius * 0.6)
# Coin shadow
draw.ellipse([coin_x - coin_radius + 2, y_offset - coin_radius + 2,
coin_x + coin_radius + 2, y_offset + coin_radius + 2],
fill=(100, 70, 0, 100))
# Coin body (gradient effect)
for j in range(5):
r = coin_radius - j
brightness = 255 - j * 20
draw.ellipse([coin_x - r, y_offset - r, coin_x + r, y_offset + r],
fill=(255, 180 - j * 10, 50 - j * 5, 255))
# Text "FINA"
try:
# Try to use a bold font
font_size = int(size * 0.15)
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size)
except:
font = ImageFont.load_default()
text = "FINA"
text_bbox = draw.textbbox((0, 0), text, font=font)
text_width = text_bbox[2] - text_bbox[0]
text_height = text_bbox[3] - text_bbox[1]
text_x = center - text_width // 2
text_y = int(center + inner_radius * 0.5)
# Text with cyan color
draw.text((text_x, text_y), text, fill=(64, 200, 224, 255), font=font)
return img
# Create logos
logo_512 = create_fina_logo(512)
logo_512.save('logo.png')
logo_512.save('icon-512x512.png')
logo_192 = create_fina_logo(192)
logo_192.save('icon-192x192.png')
logo_64 = create_fina_logo(64)
logo_64.save('favicon.png')
print("FINA logos created successfully!")

View file

@ -0,0 +1,112 @@
from PIL import Image, ImageDraw, ImageFont
import os
def create_fina_logo_round(size):
# Create image with transparent background
img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
center = size // 2
# Outer border circle (light blue/cyan ring)
border_width = int(size * 0.05)
draw.ellipse([0, 0, size, size], fill=(100, 180, 230, 255))
draw.ellipse([border_width, border_width, size - border_width, size - border_width],
fill=(0, 0, 0, 0))
# Background circle (dark blue gradient effect)
for i in range(15):
radius = (size // 2 - border_width) - i * 2
alpha = 255
color = (0, 50 + i * 3, 80 + i * 5, alpha)
draw.ellipse([center - radius, center - radius, center + radius, center + radius], fill=color)
# White inner circle
inner_radius = int(size * 0.38)
draw.ellipse([center - inner_radius, center - inner_radius, center + inner_radius, center + inner_radius],
fill=(245, 245, 245, 255))
# Shield (cyan/turquoise) - smaller for round design
shield_size = int(size * 0.22)
shield_x = int(center - shield_size * 0.6)
shield_y = int(center - shield_size * 0.4)
# Draw shield shape
shield_points = [
(shield_x, shield_y),
(shield_x + shield_size, shield_y),
(shield_x + shield_size, shield_y + int(shield_size * 0.7)),
(shield_x + shield_size // 2, shield_y + int(shield_size * 1.2)),
(shield_x, shield_y + int(shield_size * 0.7))
]
draw.polygon(shield_points, fill=(64, 224, 208, 220))
# Coins (orange/golden) - adjusted position
coin_radius = int(size * 0.07)
coin_x = int(center + shield_size * 0.35)
coin_y = int(center - shield_size * 0.15)
# Draw 3 stacked coins
for i in range(3):
y_offset = coin_y + i * int(coin_radius * 0.55)
# Coin shadow
draw.ellipse([coin_x - coin_radius + 2, y_offset - coin_radius + 2,
coin_x + coin_radius + 2, y_offset + coin_radius + 2],
fill=(100, 70, 0, 100))
# Coin body (gradient effect)
for j in range(5):
r = coin_radius - j
brightness = 255 - j * 20
draw.ellipse([coin_x - r, y_offset - r, coin_x + r, y_offset + r],
fill=(255, 180 - j * 10, 50 - j * 5, 255))
# Text "FINA"
try:
font_size = int(size * 0.13)
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size)
except:
font = ImageFont.load_default()
text = "FINA"
text_bbox = draw.textbbox((0, 0), text, font=font)
text_width = text_bbox[2] - text_bbox[0]
text_height = text_bbox[3] - text_bbox[1]
text_x = center - text_width // 2
text_y = int(center + inner_radius * 0.45)
# Text with cyan color
draw.text((text_x, text_y), text, fill=(43, 140, 238, 255), font=font)
return img
# Create all logo sizes
print("Creating round FINA logos...")
# Main logo for web app
logo_512 = create_fina_logo_round(512)
logo_512.save('logo.png')
logo_512.save('icon-512x512.png')
print("✓ Created logo.png (512x512)")
# PWA icon
logo_192 = create_fina_logo_round(192)
logo_192.save('icon-192x192.png')
print("✓ Created icon-192x192.png")
# Favicon
logo_64 = create_fina_logo_round(64)
logo_64.save('favicon.png')
print("✓ Created favicon.png (64x64)")
# Small icon for notifications
logo_96 = create_fina_logo_round(96)
logo_96.save('icon-96x96.png')
print("✓ Created icon-96x96.png")
# Apple touch icon
logo_180 = create_fina_logo_round(180)
logo_180.save('apple-touch-icon.png')
print("✓ Created apple-touch-icon.png (180x180)")
print("\nAll round FINA logos created successfully!")
print("Logos are circular/round shaped for PWA, notifications, and web app use.")

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1 @@
# Placeholder - the actual logo will be saved from the attachment

View file

@ -0,0 +1,168 @@
// Global utility functions
// Toast notifications
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
info: 'bg-primary',
warning: 'bg-yellow-500'
};
toast.className = `${colors[type]} text-white px-6 py-3 rounded-lg shadow-lg flex items-center gap-3 animate-slide-in`;
toast.innerHTML = `
<span class="material-symbols-outlined text-[20px]">
${type === 'success' ? 'check_circle' : type === 'error' ? 'error' : 'info'}
</span>
<span>${message}</span>
`;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Format currency
function formatCurrency(amount, currency = 'USD') {
const symbols = {
'USD': '$',
'EUR': '€',
'GBP': '£',
'RON': 'lei'
};
const symbol = symbols[currency] || currency;
const formatted = parseFloat(amount).toFixed(2);
if (currency === 'RON') {
return `${formatted} ${symbol}`;
}
return `${symbol}${formatted}`;
}
// Format date
function formatDate(dateString) {
const date = new Date(dateString);
const now = new Date();
const diff = now - date;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return 'Today';
if (days === 1) return 'Yesterday';
if (days < 7) return `${days} days ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
// API helper
async function apiCall(url, options = {}) {
try {
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'Content-Type': options.body instanceof FormData ? undefined : 'application/json',
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('API call failed:', error);
showToast('An error occurred. Please try again.', 'error');
throw error;
}
}
// Theme management
function initTheme() {
// Check for saved theme preference or default to system preference
const savedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedTheme === 'dark' || (!savedTheme && systemPrefersDark)) {
document.documentElement.classList.add('dark');
updateThemeUI(true);
} else {
document.documentElement.classList.remove('dark');
updateThemeUI(false);
}
}
function toggleTheme() {
const isDark = document.documentElement.classList.contains('dark');
if (isDark) {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
updateThemeUI(false);
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
updateThemeUI(true);
}
// Dispatch custom event for other components to react to theme change
window.dispatchEvent(new CustomEvent('theme-changed', { detail: { isDark: !isDark } }));
}
function updateThemeUI(isDark) {
const themeIcon = document.getElementById('theme-icon');
const themeText = document.getElementById('theme-text');
if (themeIcon && themeText) {
if (isDark) {
themeIcon.textContent = 'dark_mode';
themeText.textContent = 'Dark Mode';
} else {
themeIcon.textContent = 'light_mode';
themeText.textContent = 'Light Mode';
}
}
}
// Mobile menu toggle
document.addEventListener('DOMContentLoaded', () => {
// Initialize theme
initTheme();
// Theme toggle button
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', toggleTheme);
}
// Mobile menu
const menuToggle = document.getElementById('menu-toggle');
const sidebar = document.getElementById('sidebar');
if (menuToggle && sidebar) {
menuToggle.addEventListener('click', () => {
sidebar.classList.toggle('hidden');
sidebar.classList.toggle('flex');
sidebar.classList.toggle('absolute');
sidebar.classList.toggle('z-50');
sidebar.style.left = '0';
});
// Close sidebar when clicking outside on mobile
document.addEventListener('click', (e) => {
if (window.innerWidth < 1024) {
if (!sidebar.contains(e.target) && !menuToggle.contains(e.target)) {
sidebar.classList.add('hidden');
sidebar.classList.remove('flex');
}
}
});
}
});

View file

@ -0,0 +1,236 @@
// Dashboard JavaScript
let categoryChart, monthlyChart;
// Load dashboard data
async function loadDashboardData() {
try {
const stats = await apiCall('/api/dashboard-stats');
// Update KPI cards
document.getElementById('total-spent').textContent = formatCurrency(stats.total_spent, stats.currency);
document.getElementById('active-categories').textContent = stats.active_categories;
document.getElementById('total-transactions').textContent = stats.total_transactions;
// Update percent change
const percentChange = document.getElementById('percent-change');
const isPositive = stats.percent_change >= 0;
percentChange.className = `${isPositive ? 'bg-red-500/10 text-red-400' : 'bg-green-500/10 text-green-400'} text-xs font-semibold px-2 py-1 rounded-full flex items-center gap-1`;
percentChange.innerHTML = `
<span class="material-symbols-outlined text-[14px]">${isPositive ? 'trending_up' : 'trending_down'}</span>
${Math.abs(stats.percent_change)}%
`;
// Load charts
loadCategoryChart(stats.category_breakdown);
loadMonthlyChart(stats.monthly_data);
// Load recent transactions
loadRecentTransactions();
} catch (error) {
console.error('Failed to load dashboard data:', error);
}
}
// Category pie chart
function loadCategoryChart(data) {
const ctx = document.getElementById('category-chart').getContext('2d');
if (categoryChart) {
categoryChart.destroy();
}
if (data.length === 0) {
const isDark = document.documentElement.classList.contains('dark');
ctx.fillStyle = isDark ? '#92adc9' : '#64748b';
ctx.font = '14px Inter';
ctx.textAlign = 'center';
ctx.fillText('No data available', ctx.canvas.width / 2, ctx.canvas.height / 2);
return;
}
categoryChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: data.map(d => d.name),
datasets: [{
data: data.map(d => d.amount),
backgroundColor: data.map(d => d.color),
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: document.documentElement.classList.contains('dark') ? '#92adc9' : '#64748b',
padding: 15,
font: { size: 12 }
}
}
}
}
});
}
// Monthly bar chart
function loadMonthlyChart(data) {
const ctx = document.getElementById('monthly-chart').getContext('2d');
if (monthlyChart) {
monthlyChart.destroy();
}
monthlyChart = new Chart(ctx, {
type: 'bar',
data: {
labels: data.map(d => d.month),
datasets: [{
label: 'Spending',
data: data.map(d => d.total),
backgroundColor: '#2b8cee',
borderRadius: 8
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
ticks: { color: document.documentElement.classList.contains('dark') ? '#92adc9' : '#64748b' },
grid: { color: document.documentElement.classList.contains('dark') ? '#233648' : '#e2e8f0' }
},
x: {
ticks: { color: document.documentElement.classList.contains('dark') ? '#92adc9' : '#64748b' },
grid: { display: false }
}
}
}
});
}
// Load recent transactions
async function loadRecentTransactions() {
try {
const data = await apiCall('/api/recent-transactions?limit=5');
const container = document.getElementById('recent-transactions');
if (data.transactions.length === 0) {
container.innerHTML = '<p class="text-[#92adc9] text-sm text-center py-8">No transactions yet</p>';
return;
}
container.innerHTML = data.transactions.map(tx => `
<div class="flex items-center justify-between p-4 rounded-lg bg-slate-50 dark:bg-[#111a22] border border-border-light dark:border-[#233648] hover:border-primary/30 transition-colors">
<div class="flex items-center gap-3 flex-1">
<div class="w-10 h-10 rounded-lg flex items-center justify-center" style="background: ${tx.category_color}20;">
<span class="material-symbols-outlined text-[20px]" style="color: ${tx.category_color};">payments</span>
</div>
<div class="flex-1">
<p class="text-text-main dark:text-white font-medium text-sm">${tx.description}</p>
<p class="text-text-muted dark:text-[#92adc9] text-xs">${tx.category_name} ${formatDate(tx.date)}</p>
</div>
</div>
<div class="text-right">
<p class="text-text-main dark:text-white font-semibold">${formatCurrency(tx.amount, tx.currency)}</p>
${tx.tags.length > 0 ? `<p class="text-text-muted dark:text-[#92adc9] text-xs">${tx.tags.join(', ')}</p>` : ''}
</div>
</div>
`).join('');
} catch (error) {
console.error('Failed to load transactions:', error);
}
}
// Expense modal
const expenseModal = document.getElementById('expense-modal');
const addExpenseBtn = document.getElementById('add-expense-btn');
const closeModalBtn = document.getElementById('close-modal');
const expenseForm = document.getElementById('expense-form');
// Load categories for dropdown
async function loadCategories() {
try {
const data = await apiCall('/api/expenses/categories');
const select = expenseForm.querySelector('[name="category_id"]');
select.innerHTML = '<option value="">Select category...</option>' +
data.categories.map(cat => `<option value="${cat.id}">${cat.name}</option>`).join('');
} catch (error) {
console.error('Failed to load categories:', error);
}
}
// Open modal
addExpenseBtn.addEventListener('click', () => {
expenseModal.classList.remove('hidden');
loadCategories();
// Set today's date as default
const dateInput = expenseForm.querySelector('[name="date"]');
dateInput.value = new Date().toISOString().split('T')[0];
});
// Close modal
closeModalBtn.addEventListener('click', () => {
expenseModal.classList.add('hidden');
expenseForm.reset();
});
// Close modal on outside click
expenseModal.addEventListener('click', (e) => {
if (e.target === expenseModal) {
expenseModal.classList.add('hidden');
expenseForm.reset();
}
});
// Submit expense form
expenseForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(expenseForm);
// Convert tags to array
const tagsString = formData.get('tags');
if (tagsString) {
const tags = tagsString.split(',').map(t => t.trim()).filter(t => t);
formData.set('tags', JSON.stringify(tags));
}
// Convert date to ISO format
const date = new Date(formData.get('date'));
formData.set('date', date.toISOString());
try {
const result = await apiCall('/api/expenses/', {
method: 'POST',
body: formData
});
if (result.success) {
showToast('Expense added successfully!', 'success');
expenseModal.classList.add('hidden');
expenseForm.reset();
loadDashboardData();
}
} catch (error) {
console.error('Failed to add expense:', error);
}
});
// Initialize dashboard
document.addEventListener('DOMContentLoaded', () => {
loadDashboardData();
// Refresh data every 5 minutes
setInterval(loadDashboardData, 5 * 60 * 1000);
});

View file

@ -0,0 +1,442 @@
// Documents Page Functionality
let currentPage = 1;
const itemsPerPage = 10;
let searchQuery = '';
let allDocuments = [];
// Initialize documents page
document.addEventListener('DOMContentLoaded', () => {
loadDocuments();
setupEventListeners();
});
// Setup event listeners
function setupEventListeners() {
// File input change
const fileInput = document.getElementById('file-input');
if (fileInput) {
fileInput.addEventListener('change', handleFileSelect);
}
// Drag and drop
const uploadArea = document.getElementById('upload-area');
if (uploadArea) {
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('!border-primary', '!bg-primary/5');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('!border-primary', '!bg-primary/5');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('!border-primary', '!bg-primary/5');
const files = e.dataTransfer.files;
handleFiles(files);
});
}
// Search input
const searchInput = document.getElementById('search-input');
if (searchInput) {
let debounceTimer;
searchInput.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
searchQuery = e.target.value.toLowerCase();
currentPage = 1;
loadDocuments();
}, 300);
});
}
}
// Handle file select from input
function handleFileSelect(e) {
const files = e.target.files;
handleFiles(files);
}
// Handle file upload
async function handleFiles(files) {
if (files.length === 0) return;
const allowedTypes = ['pdf', 'csv', 'xlsx', 'xls', 'png', 'jpg', 'jpeg'];
const maxSize = 10 * 1024 * 1024; // 10MB
for (const file of files) {
const ext = file.name.split('.').pop().toLowerCase();
if (!allowedTypes.includes(ext)) {
showNotification('error', `${file.name}: Unsupported file type. Only PDF, CSV, XLS, XLSX, PNG, JPG allowed.`);
continue;
}
if (file.size > maxSize) {
showNotification('error', `${file.name}: File size exceeds 10MB limit.`);
continue;
}
await uploadFile(file);
}
// Reset file input
const fileInput = document.getElementById('file-input');
if (fileInput) fileInput.value = '';
// Reload documents list
loadDocuments();
}
// Upload file to server
async function uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/documents/', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok) {
showNotification('success', `${file.name} uploaded successfully!`);
} else {
showNotification('error', result.error || 'Upload failed');
}
} catch (error) {
console.error('Upload error:', error);
showNotification('error', 'An error occurred during upload');
}
}
// Load documents from API
async function loadDocuments() {
try {
const params = new URLSearchParams({
page: currentPage,
per_page: itemsPerPage
});
if (searchQuery) {
params.append('search', searchQuery);
}
const data = await apiCall(`/api/documents/?${params.toString()}`);
allDocuments = data.documents;
displayDocuments(data.documents);
updatePagination(data.pagination);
} catch (error) {
console.error('Error loading documents:', error);
document.getElementById('documents-list').innerHTML = `
<tr>
<td colspan="5" class="px-6 py-8 text-center text-text-muted dark:text-[#92adc9]">
<span data-translate="documents.errorLoading">Failed to load documents. Please try again.</span>
</td>
</tr>
`;
}
}
// Display documents in table
function displayDocuments(documents) {
const tbody = document.getElementById('documents-list');
if (documents.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="5" class="px-6 py-8 text-center text-text-muted dark:text-[#92adc9]">
<span data-translate="documents.noDocuments">No documents found. Upload your first document!</span>
</td>
</tr>
`;
return;
}
tbody.innerHTML = documents.map(doc => {
const statusConfig = getStatusConfig(doc.status);
const fileIcon = getFileIcon(doc.file_type);
return `
<tr class="hover:bg-slate-50 dark:hover:bg-white/[0.02] transition-colors">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-[20px] ${fileIcon.color}">${fileIcon.icon}</span>
<div class="flex flex-col">
<span class="text-text-main dark:text-white font-medium">${escapeHtml(doc.original_filename)}</span>
<span class="text-xs text-text-muted dark:text-[#92adc9]">${formatFileSize(doc.file_size)}</span>
</div>
</div>
</td>
<td class="px-6 py-4 text-text-main dark:text-white">
${formatDate(doc.created_at)}
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-slate-100 dark:bg-white/10 text-text-main dark:text-white">
${doc.document_category || 'Other'}
</span>
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${statusConfig.className}">
${statusConfig.hasIcon ? `<span class="material-symbols-outlined text-[14px]">${statusConfig.icon}</span>` : ''}
<span data-translate="documents.status${doc.status.charAt(0).toUpperCase() + doc.status.slice(1)}">${doc.status}</span>
</span>
</td>
<td class="px-6 py-4">
<div class="flex items-center justify-end gap-2">
<button onclick="downloadDocument(${doc.id})" class="p-2 text-text-muted dark:text-[#92adc9] hover:text-primary hover:bg-primary/10 rounded-lg transition-colors" title="Download">
<span class="material-symbols-outlined text-[20px]">download</span>
</button>
<button onclick="deleteDocument(${doc.id})" class="p-2 text-text-muted dark:text-[#92adc9] hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-lg transition-colors" title="Delete">
<span class="material-symbols-outlined text-[20px]">delete</span>
</button>
</div>
</td>
</tr>
`;
}).join('');
}
// Get status configuration
function getStatusConfig(status) {
const configs = {
uploaded: {
className: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400',
icon: 'upload',
hasIcon: true
},
processing: {
className: 'bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-400 animate-pulse',
icon: 'sync',
hasIcon: true
},
analyzed: {
className: 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400',
icon: 'verified',
hasIcon: true
},
error: {
className: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400',
icon: 'error',
hasIcon: true
}
};
return configs[status] || configs.uploaded;
}
// Get file icon
function getFileIcon(fileType) {
const icons = {
pdf: { icon: 'picture_as_pdf', color: 'text-red-500' },
csv: { icon: 'table_view', color: 'text-green-500' },
xlsx: { icon: 'table_view', color: 'text-green-600' },
xls: { icon: 'table_view', color: 'text-green-600' },
png: { icon: 'image', color: 'text-blue-500' },
jpg: { icon: 'image', color: 'text-blue-500' },
jpeg: { icon: 'image', color: 'text-blue-500' }
};
return icons[fileType?.toLowerCase()] || { icon: 'description', color: 'text-gray-500' };
}
// Update pagination
function updatePagination(pagination) {
const { page, pages, total, per_page } = pagination;
// Update count display
const start = (page - 1) * per_page + 1;
const end = Math.min(page * per_page, total);
document.getElementById('page-start').textContent = total > 0 ? start : 0;
document.getElementById('page-end').textContent = end;
document.getElementById('total-count').textContent = total;
// Update pagination buttons
const paginationDiv = document.getElementById('pagination');
if (pages <= 1) {
paginationDiv.innerHTML = '';
return;
}
let buttons = '';
// Previous button
buttons += `
<button onclick="changePage(${page - 1})"
class="px-3 py-1.5 rounded-lg text-sm font-medium ${page === 1 ? 'bg-gray-100 dark:bg-white/5 text-text-muted dark:text-[#92adc9]/50 cursor-not-allowed' : 'bg-card-light dark:bg-card-dark text-text-main dark:text-white hover:bg-slate-100 dark:hover:bg-white/10 border border-border-light dark:border-[#233648]'} transition-colors"
${page === 1 ? 'disabled' : ''}>
<span class="material-symbols-outlined text-[18px]">chevron_left</span>
</button>
`;
// Page numbers
const maxButtons = 5;
let startPage = Math.max(1, page - Math.floor(maxButtons / 2));
let endPage = Math.min(pages, startPage + maxButtons - 1);
if (endPage - startPage < maxButtons - 1) {
startPage = Math.max(1, endPage - maxButtons + 1);
}
for (let i = startPage; i <= endPage; i++) {
buttons += `
<button onclick="changePage(${i})"
class="px-3 py-1.5 rounded-lg text-sm font-medium ${i === page ? 'bg-primary text-white' : 'bg-card-light dark:bg-card-dark text-text-main dark:text-white hover:bg-slate-100 dark:hover:bg-white/10 border border-border-light dark:border-[#233648]'} transition-colors">
${i}
</button>
`;
}
// Next button
buttons += `
<button onclick="changePage(${page + 1})"
class="px-3 py-1.5 rounded-lg text-sm font-medium ${page === pages ? 'bg-gray-100 dark:bg-white/5 text-text-muted dark:text-[#92adc9]/50 cursor-not-allowed' : 'bg-card-light dark:bg-card-dark text-text-main dark:text-white hover:bg-slate-100 dark:hover:bg-white/10 border border-border-light dark:border-[#233648]'} transition-colors"
${page === pages ? 'disabled' : ''}>
<span class="material-symbols-outlined text-[18px]">chevron_right</span>
</button>
`;
paginationDiv.innerHTML = buttons;
}
// Change page
function changePage(page) {
currentPage = page;
loadDocuments();
}
// Download document
async function downloadDocument(id) {
try {
const response = await fetch(`/api/documents/${id}/download`);
if (!response.ok) {
throw new Error('Download failed');
}
const blob = await response.blob();
const contentDisposition = response.headers.get('Content-Disposition');
const filename = contentDisposition
? contentDisposition.split('filename=')[1].replace(/"/g, '')
: `document_${id}`;
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showNotification('success', 'Document downloaded successfully!');
} catch (error) {
console.error('Download error:', error);
showNotification('error', 'Failed to download document');
}
}
// Delete document
async function deleteDocument(id) {
if (!confirm('Are you sure you want to delete this document? This action cannot be undone.')) {
return;
}
try {
const response = await fetch(`/api/documents/${id}`, {
method: 'DELETE'
});
const result = await response.json();
if (response.ok) {
showNotification('success', 'Document deleted successfully!');
loadDocuments();
} else {
showNotification('error', result.error || 'Failed to delete document');
}
} catch (error) {
console.error('Delete error:', error);
showNotification('error', 'An error occurred while deleting');
}
}
// Format file size
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
// Format date
function formatDate(dateString) {
const date = new Date(dateString);
const now = new Date();
const diff = now - date;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
const hours = Math.floor(diff / (1000 * 60 * 60));
if (hours === 0) {
const minutes = Math.floor(diff / (1000 * 60));
return minutes <= 1 ? 'Just now' : `${minutes}m ago`;
}
return `${hours}h ago`;
} else if (days === 1) {
return 'Yesterday';
} else if (days < 7) {
return `${days}d ago`;
} else {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Show notification
function showNotification(type, message) {
// Create notification element
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 animate-slideIn ${
type === 'success'
? 'bg-green-500 text-white'
: 'bg-red-500 text-white'
}`;
notification.innerHTML = `
<span class="material-symbols-outlined text-[20px]">
${type === 'success' ? 'check_circle' : 'error'}
</span>
<span class="text-sm font-medium">${escapeHtml(message)}</span>
`;
document.body.appendChild(notification);
// Remove after 3 seconds
setTimeout(() => {
notification.classList.add('animate-slideOut');
setTimeout(() => {
document.body.removeChild(notification);
}, 300);
}, 3000);
}

View file

@ -0,0 +1,356 @@
// Multi-language support
const translations = {
en: {
// Navigation
'nav.dashboard': 'Dashboard',
'nav.transactions': 'Transactions',
'nav.reports': 'Reports',
'nav.admin': 'Admin',
'nav.settings': 'Settings',
'nav.logout': 'Log out',
// Dashboard
'dashboard.total_spent': 'Total Spent',
'dashboard.active_categories': 'Active Categories',
'dashboard.total_transactions': 'Total Transactions',
'dashboard.vs_last_month': 'vs last month',
'dashboard.categories_in_use': 'categories in use',
'dashboard.this_month': 'this month',
'dashboard.spending_by_category': 'Spending by Category',
'dashboard.monthly_trend': 'Monthly Trend',
'dashboard.recent_transactions': 'Recent Transactions',
'dashboard.view_all': 'View All',
// Login
'login.title': 'Welcome Back',
'login.tagline': 'Track your expenses, manage your finances',
'login.remember_me': 'Remember me',
'login.sign_in': 'Sign In',
'login.no_account': "Don't have an account?",
'login.register': 'Register',
// Register
'register.title': 'Create Account',
'register.tagline': 'Start managing your finances today',
'register.create_account': 'Create Account',
'register.have_account': 'Already have an account?',
'register.login': 'Login',
// Forms
'form.email': 'Email',
'form.password': 'Password',
'form.username': 'Username',
'form.language': 'Language',
'form.currency': 'Currency',
'form.amount': 'Amount',
'form.description': 'Description',
'form.category': 'Category',
'form.date': 'Date',
'form.tags': 'Tags (comma separated)',
'form.receipt': 'Receipt (optional)',
'form.2fa_code': '2FA Code',
// Actions
'actions.add_expense': 'Add Expense',
'actions.save': 'Save Expense',
// Modal
'modal.add_expense': 'Add Expense',
// Reports
'reports.title': 'Financial Reports',
'reports.export': 'Export CSV',
'reports.analysisPeriod': 'Analysis Period:',
'reports.last30Days': 'Last 30 Days',
'reports.quarter': 'Quarter',
'reports.ytd': 'YTD',
'reports.allCategories': 'All Categories',
'reports.generate': 'Generate Report',
'reports.totalSpent': 'Total Spent',
'reports.topCategory': 'Top Category',
'reports.avgDaily': 'Avg. Daily',
'reports.savingsRate': 'Savings Rate',
'reports.vsLastMonth': 'vs last period',
'reports.spentThisPeriod': 'spent this period',
'reports.placeholder': 'Placeholder',
'reports.spendingTrend': 'Spending Trend',
'reports.categoryBreakdown': 'Category Breakdown',
'reports.monthlySpending': 'Monthly Spending',
// User
'user.admin': 'Admin',
'user.user': 'User',
// Documents
'nav.documents': 'Documents',
'documents.title': 'Documents',
'documents.uploadTitle': 'Upload Documents',
'documents.dragDrop': 'Drag & drop files here or click to browse',
'documents.uploadDesc': 'Upload bank statements, invoices, or receipts.',
'documents.supportedFormats': 'Supported formats: CSV, PDF, XLS, XLSX, PNG, JPG (Max 10MB)',
'documents.yourFiles': 'Your Files',
'documents.searchPlaceholder': 'Search by name...',
'documents.tableDocName': 'Document Name',
'documents.tableUploadDate': 'Upload Date',
'documents.tableType': 'Type',
'documents.tableStatus': 'Status',
'documents.tableActions': 'Actions',
'documents.statusUploaded': 'Uploaded',
'documents.statusProcessing': 'Processing',
'documents.statusAnalyzed': 'Analyzed',
'documents.statusError': 'Error',
'documents.showing': 'Showing',
'documents.of': 'of',
'documents.documents': 'documents',
'documents.noDocuments': 'No documents found. Upload your first document!',
'documents.errorLoading': 'Failed to load documents. Please try again.',
// Settings
'settings.title': 'Settings',
'settings.avatar': 'Profile Avatar',
'settings.uploadAvatar': 'Upload Custom',
'settings.avatarDesc': 'PNG, JPG, GIF, WEBP. Max 20MB',
'settings.defaultAvatars': 'Or choose a default avatar:',
'settings.profile': 'Profile Information',
'settings.saveProfile': 'Save Profile',
'settings.changePassword': 'Change Password',
'settings.currentPassword': 'Current Password',
'settings.newPassword': 'New Password',
'settings.confirmPassword': 'Confirm New Password',
'settings.updatePassword': 'Update Password',
'settings.twoFactor': 'Two-Factor Authentication',
'settings.twoFactorEnabled': '2FA is currently enabled for your account',
'settings.twoFactorDisabled': 'Add an extra layer of security to your account',
'settings.enabled': 'Enabled',
'settings.disabled': 'Disabled',
'settings.regenerateCodes': 'Regenerate Backup Codes',
'settings.enable2FA': 'Enable 2FA',
'settings.disable2FA': 'Disable 2FA',
// Two-Factor Authentication
'twofa.setupTitle': 'Setup Two-Factor Authentication',
'twofa.setupDesc': 'Scan the QR code with your authenticator app',
'twofa.step1': 'Step 1: Scan QR Code',
'twofa.step1Desc': 'Open your authenticator app (Google Authenticator, Authy, etc.) and scan this QR code:',
'twofa.manualEntry': "Can't scan? Enter code manually",
'twofa.enterManually': 'Enter this code in your authenticator app:',
'twofa.step2': 'Step 2: Verify Code',
'twofa.step2Desc': 'Enter the 6-digit code from your authenticator app:',
'twofa.enable': 'Enable 2FA',
'twofa.infoText': "After enabling 2FA, you'll receive backup codes that you can use if you lose access to your authenticator app. Keep them in a safe place!",
'twofa.setupSuccess': 'Two-Factor Authentication Enabled!',
'twofa.backupCodesDesc': 'Save these backup codes in a secure location',
'twofa.important': 'Important!',
'twofa.backupCodesWarning': "Each backup code can only be used once. Store them securely - you'll need them if you lose access to your authenticator app.",
'twofa.yourBackupCodes': 'Your Backup Codes',
'twofa.downloadPDF': 'Download as PDF',
'twofa.print': 'Print Codes',
'twofa.continueToSettings': 'Continue to Settings',
'twofa.howToUse': 'How to use backup codes:',
'twofa.useWhen': "Use a backup code when you can't access your authenticator app",
'twofa.enterCode': 'Enter the code in the 2FA field when logging in',
'twofa.oneTimeUse': 'Each code works only once - it will be deleted after use',
'twofa.regenerate': 'You can regenerate codes anytime from Settings',
// Actions
'actions.cancel': 'Cancel'
},
ro: {
// Navigation
'nav.dashboard': 'Tablou de bord',
'nav.transactions': 'Tranzacții',
'nav.reports': 'Rapoarte',
'nav.admin': 'Admin',
'nav.settings': 'Setări',
'nav.logout': 'Deconectare',
// Dashboard
'dashboard.total_spent': 'Total Cheltuit',
'dashboard.active_categories': 'Categorii Active',
'dashboard.total_transactions': 'Total Tranzacții',
'dashboard.vs_last_month': 'față de luna trecută',
'dashboard.categories_in_use': 'categorii în uz',
'dashboard.this_month': 'luna aceasta',
'dashboard.spending_by_category': 'Cheltuieli pe Categorii',
'dashboard.monthly_trend': 'Tendință Lunară',
'dashboard.recent_transactions': 'Tranzacții Recente',
'dashboard.view_all': 'Vezi Toate',
// Login
'login.title': 'Bine ai revenit',
'login.tagline': 'Urmărește-ți cheltuielile, gestionează-ți finanțele',
'login.remember_me': 'Ține-mă minte',
'login.sign_in': 'Conectare',
'login.no_account': 'Nu ai un cont?',
'login.register': 'Înregistrare',
// Register
'register.title': 'Creare Cont',
'register.tagline': 'Începe să îți gestionezi finanțele astăzi',
'register.create_account': 'Creează Cont',
'register.have_account': 'Ai deja un cont?',
'register.login': 'Conectare',
// Forms
'form.email': 'Email',
'form.password': 'Parolă',
'form.username': 'Nume utilizator',
'form.language': 'Limbă',
'form.currency': 'Monedă',
'form.amount': 'Sumă',
'form.description': 'Descriere',
'form.category': 'Categorie',
'form.date': 'Dată',
'form.tags': 'Etichete (separate prin virgulă)',
'form.receipt': 'Chitanță (opțional)',
'form.2fa_code': 'Cod 2FA',
// Actions
'actions.add_expense': 'Adaugă Cheltuială',
'actions.save': 'Salvează Cheltuiala',
// Modal
'modal.add_expense': 'Adaugă Cheltuială',
// Reports
'reports.title': 'Rapoarte Financiare',
'reports.export': 'Exportă CSV',
'reports.analysisPeriod': 'Perioadă de Analiză:',
'reports.last30Days': 'Ultimele 30 Zile',
'reports.quarter': 'Trimestru',
'reports.ytd': 'An Curent',
'reports.allCategories': 'Toate Categoriile',
'reports.generate': 'Generează Raport',
'reports.totalSpent': 'Total Cheltuit',
'reports.topCategory': 'Categorie Principală',
'reports.avgDaily': 'Medie Zilnică',
'reports.savingsRate': 'Rată Economii',
'reports.vsLastMonth': 'față de perioada anterioară',
'reports.spentThisPeriod': 'cheltuit în această perioadă',
'reports.placeholder': 'Substituent',
'reports.spendingTrend': 'Tendință Cheltuieli',
'reports.categoryBreakdown': 'Defalcare pe Categorii',
'reports.monthlySpending': 'Cheltuieli Lunare',
// User
'user.admin': 'Administrator',
'user.user': 'Utilizator',
// Documents
'nav.documents': 'Documente',
'documents.title': 'Documente',
'documents.uploadTitle': 'Încarcă Documente',
'documents.dragDrop': 'Trage și plasează fișiere aici sau click pentru a căuta',
'documents.uploadDesc': 'Încarcă extrase de cont, facturi sau chitanțe.',
'documents.supportedFormats': 'Formate suportate: CSV, PDF, XLS, XLSX, PNG, JPG (Max 10MB)',
'documents.yourFiles': 'Fișierele Tale',
'documents.searchPlaceholder': 'Caută după nume...',
'documents.tableDocName': 'Nume Document',
'documents.tableUploadDate': 'Data Încărcării',
'documents.tableType': 'Tip',
'documents.tableStatus': 'Stare',
'documents.tableActions': 'Acțiuni',
'documents.statusUploaded': 'Încărcat',
'documents.statusProcessing': 'În procesare',
'documents.statusAnalyzed': 'Analizat',
'documents.statusError': 'Eroare',
'documents.showing': 'Afișare',
'documents.of': 'din',
'documents.documents': 'documente',
'documents.noDocuments': 'Nu s-au găsit documente. Încarcă primul tău document!',
'documents.errorLoading': 'Eroare la încărcarea documentelor. Te rugăm încearcă din nou.',
// Settings
'settings.title': 'Setări',
'settings.avatar': 'Avatar Profil',
'settings.uploadAvatar': 'Încarcă Personalizat',
'settings.avatarDesc': 'PNG, JPG, GIF, WEBP. Max 20MB',
'settings.defaultAvatars': 'Sau alege un avatar prestabilit:',
'settings.profile': 'Informații Profil',
'settings.saveProfile': 'Salvează Profil',
'settings.changePassword': 'Schimbă Parola',
'settings.currentPassword': 'Parola Curentă',
'settings.newPassword': 'Parolă Nouă',
'settings.confirmPassword': 'Confirmă Parola Nouă',
'settings.updatePassword': 'Actualizează Parola',
'settings.twoFactor': 'Autentificare Doi Factori',
'settings.twoFactorEnabled': '2FA este activată pentru contul tău',
'settings.twoFactorDisabled': 'Adaugă un nivel suplimentar de securitate contului tău',
'settings.enabled': 'Activat',
'settings.disabled': 'Dezactivat',
'settings.regenerateCodes': 'Regenerează Coduri Backup',
'settings.enable2FA': 'Activează 2FA',
'settings.disable2FA': 'Dezactivează 2FA',
// Two-Factor Authentication
'twofa.setupTitle': 'Configurare Autentificare Doi Factori',
'twofa.setupDesc': 'Scanează codul QR cu aplicația ta de autentificare',
'twofa.step1': 'Pasul 1: Scanează Codul QR',
'twofa.step1Desc': 'Deschide aplicația ta de autentificare (Google Authenticator, Authy, etc.) și scanează acest cod QR:',
'twofa.manualEntry': 'Nu poți scana? Introdu codul manual',
'twofa.enterManually': 'Introdu acest cod în aplicația ta de autentificare:',
'twofa.step2': 'Pasul 2: Verifică Codul',
'twofa.step2Desc': 'Introdu codul de 6 cifre din aplicația ta de autentificare:',
'twofa.enable': 'Activează 2FA',
'twofa.infoText': 'După activarea 2FA, vei primi coduri de backup pe care le poți folosi dacă pierzi accesul la aplicația ta de autentificare. Păstrează-le într-un loc sigur!',
'twofa.setupSuccess': 'Autentificare Doi Factori Activată!',
'twofa.backupCodesDesc': 'Salvează aceste coduri de backup într-o locație sigură',
'twofa.important': 'Important!',
'twofa.backupCodesWarning': 'Fiecare cod de backup poate fi folosit o singură dată. Păstrează-le în siguranță - vei avea nevoie de ele dacă pierzi accesul la aplicația ta de autentificare.',
'twofa.yourBackupCodes': 'Codurile Tale de Backup',
'twofa.downloadPDF': 'Descarcă ca PDF',
'twofa.print': 'Tipărește Coduri',
'twofa.continueToSettings': 'Continuă la Setări',
'twofa.howToUse': 'Cum să folosești codurile de backup:',
'twofa.useWhen': 'Folosește un cod de backup când nu poți accesa aplicația ta de autentificare',
'twofa.enterCode': 'Introdu codul în câmpul 2FA când te autentifici',
'twofa.oneTimeUse': 'Fiecare cod funcționează o singură dată - va fi șters după folosire',
'twofa.regenerate': 'Poți regenera coduri oricând din Setări',
// Actions
'actions.cancel': 'Anulează'
}
};
// Get current language from localStorage or default to 'en'
function getCurrentLanguage() {
return localStorage.getItem('language') || 'en';
}
// Set language
function setLanguage(lang) {
if (translations[lang]) {
localStorage.setItem('language', lang);
translatePage(lang);
}
}
// Translate all elements on page
function translatePage(lang) {
const elements = document.querySelectorAll('[data-translate]');
elements.forEach(element => {
const key = element.getAttribute('data-translate');
const translation = translations[lang][key];
if (translation) {
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
element.placeholder = translation;
} else {
element.textContent = translation;
}
}
});
}
// Initialize translations on page load
document.addEventListener('DOMContentLoaded', () => {
const currentLang = getCurrentLanguage();
translatePage(currentLang);
});
// Export functions
if (typeof module !== 'undefined' && module.exports) {
module.exports = { getCurrentLanguage, setLanguage, translatePage, translations };
}

View file

@ -0,0 +1,54 @@
// PWA Service Worker Registration
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/static/sw.js')
.then(registration => {
console.log('ServiceWorker registered:', registration);
})
.catch(error => {
console.log('ServiceWorker registration failed:', error);
});
});
}
// Install prompt
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
// Show install button if you have one
const installBtn = document.getElementById('install-btn');
if (installBtn) {
installBtn.style.display = 'block';
installBtn.addEventListener('click', () => {
installBtn.style.display = 'none';
deferredPrompt.prompt();
deferredPrompt.userChoice.then((choiceResult) => {
if (choiceResult.outcome === 'accepted') {
console.log('User accepted the install prompt');
}
deferredPrompt = null;
});
});
}
});
// Check if app is installed
window.addEventListener('appinstalled', () => {
console.log('FINA has been installed');
showToast('FINA installed successfully!', 'success');
});
// Online/Offline status
window.addEventListener('online', () => {
showToast('You are back online', 'success');
});
window.addEventListener('offline', () => {
showToast('You are offline. Some features may be limited.', 'warning');
});

View file

@ -0,0 +1,367 @@
// Reports page JavaScript
let currentPeriod = 30;
let categoryFilter = '';
let trendChart = null;
let categoryChart = null;
let monthlyChart = null;
// Load reports data
async function loadReportsData() {
try {
const params = new URLSearchParams({
period: currentPeriod,
...(categoryFilter && { category_id: categoryFilter })
});
const data = await apiCall(`/api/reports-stats?${params}`);
displayReportsData(data);
} catch (error) {
console.error('Failed to load reports data:', error);
showToast('Failed to load reports', 'error');
}
}
// Display reports data
function displayReportsData(data) {
// Update KPI cards
document.getElementById('total-spent').textContent = formatCurrency(data.total_spent, data.currency);
// Spending change indicator
const spentChange = document.getElementById('spent-change');
const changeValue = data.percent_change;
const isIncrease = changeValue > 0;
spentChange.className = `flex items-center font-medium px-1.5 py-0.5 rounded ${
isIncrease
? 'text-red-500 dark:text-red-400 bg-red-500/10'
: 'text-green-500 dark:text-green-400 bg-green-500/10'
}`;
spentChange.innerHTML = `
<span class="material-symbols-outlined text-[14px] mr-0.5">${isIncrease ? 'trending_up' : 'trending_down'}</span>
${Math.abs(changeValue).toFixed(1)}%
`;
// Top category
document.getElementById('top-category').textContent = data.top_category.name;
document.getElementById('top-category-amount').textContent = formatCurrency(data.top_category.amount, data.currency);
// Average daily
document.getElementById('avg-daily').textContent = formatCurrency(data.avg_daily, data.currency);
// Average change indicator
const avgChange = document.getElementById('avg-change');
const avgChangeValue = data.avg_daily_change;
const isAvgIncrease = avgChangeValue > 0;
avgChange.className = `flex items-center font-medium px-1.5 py-0.5 rounded ${
isAvgIncrease
? 'text-red-500 dark:text-red-400 bg-red-500/10'
: 'text-green-500 dark:text-green-400 bg-green-500/10'
}`;
avgChange.innerHTML = `
<span class="material-symbols-outlined text-[14px] mr-0.5">${isAvgIncrease ? 'trending_up' : 'trending_down'}</span>
${Math.abs(avgChangeValue).toFixed(1)}%
`;
// Savings rate
document.getElementById('savings-rate').textContent = `${data.savings_rate}%`;
// Update charts
updateTrendChart(data.daily_trend);
updateCategoryChart(data.category_breakdown);
updateMonthlyChart(data.monthly_comparison);
}
// Update trend chart
function updateTrendChart(dailyData) {
const ctx = document.getElementById('trend-chart');
if (!ctx) return;
// Get theme
const isDark = document.documentElement.classList.contains('dark');
const textColor = isDark ? '#94a3b8' : '#64748b';
const gridColor = isDark ? '#334155' : '#e2e8f0';
if (trendChart) {
trendChart.destroy();
}
trendChart = new Chart(ctx, {
type: 'line',
data: {
labels: dailyData.map(d => d.date),
datasets: [{
label: 'Daily Spending',
data: dailyData.map(d => d.amount),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: isDark ? '#1e293b' : '#ffffff',
pointBorderColor: '#3b82f6',
pointBorderWidth: 2,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: isDark ? '#1e293b' : '#ffffff',
titleColor: isDark ? '#f8fafc' : '#0f172a',
bodyColor: isDark ? '#94a3b8' : '#64748b',
borderColor: isDark ? '#334155' : '#e2e8f0',
borderWidth: 1,
padding: 12,
displayColors: false,
callbacks: {
label: function(context) {
return formatCurrency(context.parsed.y, 'USD');
}
}
}
},
scales: {
x: {
grid: {
color: gridColor,
drawBorder: false
},
ticks: {
color: textColor,
maxRotation: 45,
minRotation: 0
}
},
y: {
grid: {
color: gridColor,
drawBorder: false
},
ticks: {
color: textColor,
callback: function(value) {
return '$' + value.toFixed(0);
}
}
}
}
}
});
}
// Update category pie chart
function updateCategoryChart(categories) {
const ctx = document.getElementById('category-pie-chart');
if (!ctx) return;
const isDark = document.documentElement.classList.contains('dark');
if (categoryChart) {
categoryChart.destroy();
}
if (categories.length === 0) {
categoryChart = null;
document.getElementById('category-legend').innerHTML = '<p class="col-span-2 text-center text-text-muted dark:text-[#92adc9]">No data available</p>';
return;
}
categoryChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: categories.map(c => c.name),
datasets: [{
data: categories.map(c => c.amount),
backgroundColor: categories.map(c => c.color),
borderWidth: 2,
borderColor: isDark ? '#1a2632' : '#ffffff'
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: isDark ? '#1e293b' : '#ffffff',
titleColor: isDark ? '#f8fafc' : '#0f172a',
bodyColor: isDark ? '#94a3b8' : '#64748b',
borderColor: isDark ? '#334155' : '#e2e8f0',
borderWidth: 1,
padding: 12,
callbacks: {
label: function(context) {
const label = context.label || '';
const value = formatCurrency(context.parsed, 'USD');
const percentage = categories[context.dataIndex].percentage;
return `${label}: ${value} (${percentage}%)`;
}
}
}
}
}
});
// Update legend
const legendHTML = categories.slice(0, 6).map(cat => `
<div class="flex items-center gap-2">
<span class="size-3 rounded-full" style="background-color: ${cat.color}"></span>
<span class="text-text-muted dark:text-[#92adc9] flex-1 truncate">${cat.name}</span>
<span class="font-semibold text-text-main dark:text-white">${cat.percentage}%</span>
</div>
`).join('');
document.getElementById('category-legend').innerHTML = legendHTML;
}
// Update monthly chart
function updateMonthlyChart(monthlyData) {
const ctx = document.getElementById('monthly-chart');
if (!ctx) return;
const isDark = document.documentElement.classList.contains('dark');
const textColor = isDark ? '#94a3b8' : '#64748b';
const gridColor = isDark ? '#334155' : '#e2e8f0';
if (monthlyChart) {
monthlyChart.destroy();
}
monthlyChart = new Chart(ctx, {
type: 'bar',
data: {
labels: monthlyData.map(d => d.month),
datasets: [{
label: 'Monthly Spending',
data: monthlyData.map(d => d.amount),
backgroundColor: '#3b82f6',
borderRadius: 6,
hoverBackgroundColor: '#2563eb'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: isDark ? '#1e293b' : '#ffffff',
titleColor: isDark ? '#f8fafc' : '#0f172a',
bodyColor: isDark ? '#94a3b8' : '#64748b',
borderColor: isDark ? '#334155' : '#e2e8f0',
borderWidth: 1,
padding: 12,
displayColors: false,
callbacks: {
label: function(context) {
return formatCurrency(context.parsed.y, 'USD');
}
}
}
},
scales: {
x: {
grid: {
display: false
},
ticks: {
color: textColor
}
},
y: {
grid: {
color: gridColor,
drawBorder: false
},
ticks: {
color: textColor,
callback: function(value) {
return '$' + value.toFixed(0);
}
}
}
}
}
});
}
// Load categories for filter
async function loadCategoriesFilter() {
try {
const data = await apiCall('/api/expenses/categories');
const select = document.getElementById('category-filter');
const categoriesHTML = data.categories.map(cat =>
`<option value="${cat.id}">${cat.name}</option>`
).join('');
select.innerHTML = '<option value="">All Categories</option>' + categoriesHTML;
} catch (error) {
console.error('Failed to load categories:', error);
}
}
// Period button handlers
document.querySelectorAll('.period-btn').forEach(btn => {
btn.addEventListener('click', () => {
// Remove active class from all buttons
document.querySelectorAll('.period-btn').forEach(b => {
b.classList.remove('active', 'text-white', 'dark:text-white', 'bg-white/10', 'shadow-sm');
b.classList.add('text-text-muted', 'dark:text-[#92adc9]', 'hover:text-text-main', 'dark:hover:text-white', 'hover:bg-white/5');
});
// Add active class to clicked button
btn.classList.add('active', 'text-white', 'dark:text-white', 'bg-white/10', 'shadow-sm');
btn.classList.remove('text-text-muted', 'dark:text-[#92adc9]', 'hover:text-text-main', 'dark:hover:text-white', 'hover:bg-white/5');
currentPeriod = btn.dataset.period;
loadReportsData();
});
});
// Category filter handler
document.getElementById('category-filter').addEventListener('change', (e) => {
categoryFilter = e.target.value;
});
// Generate report button
document.getElementById('generate-report-btn').addEventListener('click', () => {
loadReportsData();
});
// Export report button
document.getElementById('export-report-btn').addEventListener('click', () => {
window.location.href = '/api/expenses/export/csv';
});
// Handle theme changes - reload charts with new theme colors
function handleThemeChange() {
if (trendChart || categoryChart || monthlyChart) {
loadReportsData();
}
}
// Listen for theme toggle events
window.addEventListener('theme-changed', handleThemeChange);
// Listen for storage changes (for multi-tab sync)
window.addEventListener('storage', (e) => {
if (e.key === 'theme') {
handleThemeChange();
}
});
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
loadReportsData();
loadCategoriesFilter();
});

View file

@ -0,0 +1,265 @@
// Settings Page Functionality
document.addEventListener('DOMContentLoaded', () => {
setupAvatarHandlers();
setupProfileHandlers();
setupPasswordHandlers();
});
// Avatar upload and selection
function setupAvatarHandlers() {
const uploadBtn = document.getElementById('upload-avatar-btn');
const avatarInput = document.getElementById('avatar-upload');
const currentAvatar = document.getElementById('current-avatar');
const sidebarAvatar = document.getElementById('sidebar-avatar');
const defaultAvatarBtns = document.querySelectorAll('.default-avatar-btn');
// Trigger file input when upload button clicked
if (uploadBtn && avatarInput) {
uploadBtn.addEventListener('click', () => {
avatarInput.click();
});
// Handle file selection
avatarInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
// Validate file type
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
showNotification('error', 'Invalid file type. Please use PNG, JPG, GIF, or WEBP.');
return;
}
// Validate file size (20MB)
if (file.size > 20 * 1024 * 1024) {
showNotification('error', 'File too large. Maximum size is 20MB.');
return;
}
// Upload avatar
const formData = new FormData();
formData.append('avatar', file);
try {
const response = await fetch('/api/settings/avatar', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok && result.success) {
// Update avatar displays
const avatarUrl = result.avatar.startsWith('icons/')
? `/static/${result.avatar}?t=${Date.now()}`
: `/${result.avatar}?t=${Date.now()}`;
currentAvatar.src = avatarUrl;
if (sidebarAvatar) sidebarAvatar.src = avatarUrl;
showNotification('success', result.message || 'Avatar updated successfully!');
} else {
showNotification('error', result.error || 'Failed to upload avatar');
}
} catch (error) {
console.error('Upload error:', error);
showNotification('error', 'An error occurred during upload');
}
// Reset input
avatarInput.value = '';
});
}
// Handle default avatar selection
defaultAvatarBtns.forEach(btn => {
btn.addEventListener('click', async () => {
const avatarPath = btn.getAttribute('data-avatar');
try {
const response = await fetch('/api/settings/avatar/default', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ avatar: avatarPath })
});
const result = await response.json();
if (response.ok && result.success) {
// Update avatar displays
const avatarUrl = result.avatar.startsWith('icons/')
? `/static/${result.avatar}?t=${Date.now()}`
: `/${result.avatar}?t=${Date.now()}`;
currentAvatar.src = avatarUrl;
if (sidebarAvatar) sidebarAvatar.src = avatarUrl;
// Update active state
defaultAvatarBtns.forEach(b => b.classList.remove('border-primary'));
btn.classList.add('border-primary');
showNotification('success', result.message || 'Avatar updated successfully!');
} else {
showNotification('error', result.error || 'Failed to update avatar');
}
} catch (error) {
console.error('Update error:', error);
showNotification('error', 'An error occurred');
}
});
});
}
// Profile update handlers
function setupProfileHandlers() {
const saveBtn = document.getElementById('save-profile-btn');
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
const username = document.getElementById('username').value.trim();
const email = document.getElementById('email').value.trim();
const language = document.getElementById('language').value;
const currency = document.getElementById('currency').value;
if (!username || !email) {
showNotification('error', 'Username and email are required');
return;
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
showNotification('error', 'Please enter a valid email address');
return;
}
try {
const response = await fetch('/api/settings/profile', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username,
email,
language,
currency
})
});
const result = await response.json();
if (response.ok && result.success) {
showNotification('success', result.message || 'Profile updated successfully!');
// Update language if changed
const currentLang = getCurrentLanguage();
if (language !== currentLang) {
setLanguage(language);
// Reload page to apply translations
setTimeout(() => {
window.location.reload();
}, 1000);
}
} else {
showNotification('error', result.error || 'Failed to update profile');
}
} catch (error) {
console.error('Update error:', error);
showNotification('error', 'An error occurred');
}
});
}
}
// Password change handlers
function setupPasswordHandlers() {
const changeBtn = document.getElementById('change-password-btn');
if (changeBtn) {
changeBtn.addEventListener('click', async () => {
const currentPassword = document.getElementById('current-password').value;
const newPassword = document.getElementById('new-password').value;
const confirmPassword = document.getElementById('confirm-password').value;
if (!currentPassword || !newPassword || !confirmPassword) {
showNotification('error', 'All password fields are required');
return;
}
if (newPassword.length < 6) {
showNotification('error', 'New password must be at least 6 characters');
return;
}
if (newPassword !== confirmPassword) {
showNotification('error', 'New passwords do not match');
return;
}
try {
const response = await fetch('/api/settings/password', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword
})
});
const result = await response.json();
if (response.ok && result.success) {
showNotification('success', result.message || 'Password changed successfully!');
// Clear form
document.getElementById('current-password').value = '';
document.getElementById('new-password').value = '';
document.getElementById('confirm-password').value = '';
} else {
showNotification('error', result.error || 'Failed to change password');
}
} catch (error) {
console.error('Change password error:', error);
showNotification('error', 'An error occurred');
}
});
}
}
// Show notification
function showNotification(type, message) {
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 animate-slideIn ${
type === 'success'
? 'bg-green-500 text-white'
: 'bg-red-500 text-white'
}`;
notification.innerHTML = `
<span class="material-symbols-outlined text-[20px]">
${type === 'success' ? 'check_circle' : 'error'}
</span>
<span class="text-sm font-medium">${escapeHtml(message)}</span>
`;
document.body.appendChild(notification);
setTimeout(() => {
notification.classList.add('animate-slideOut');
setTimeout(() => {
document.body.removeChild(notification);
}, 300);
}, 3000);
}
// Escape HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

View file

@ -0,0 +1,287 @@
// Transactions page JavaScript
let currentPage = 1;
let filters = {
category_id: '',
start_date: '',
end_date: '',
search: ''
};
// Load transactions
async function loadTransactions() {
try {
const params = new URLSearchParams({
page: currentPage,
...filters
});
const data = await apiCall(`/api/expenses/?${params}`);
displayTransactions(data.expenses);
displayPagination(data.pages, data.current_page, data.total || data.expenses.length);
} catch (error) {
console.error('Failed to load transactions:', error);
}
}
// Display transactions
function displayTransactions(transactions) {
const container = document.getElementById('transactions-list');
if (transactions.length === 0) {
container.innerHTML = `
<tr>
<td colspan="7" class="p-12 text-center">
<span class="material-symbols-outlined text-6xl text-[#92adc9] mb-4 block">receipt_long</span>
<p class="text-[#92adc9] text-lg">No transactions found</p>
</td>
</tr>
`;
return;
}
container.innerHTML = transactions.map(tx => {
const txDate = new Date(tx.date);
const dateStr = txDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
const timeStr = txDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
// Get category color
const categoryColors = {
'Food': { bg: 'bg-green-500/10', text: 'text-green-400', border: 'border-green-500/20', dot: 'bg-green-400' },
'Transport': { bg: 'bg-orange-500/10', text: 'text-orange-400', border: 'border-orange-500/20', dot: 'bg-orange-400' },
'Entertainment': { bg: 'bg-purple-500/10', text: 'text-purple-400', border: 'border-purple-500/20', dot: 'bg-purple-400' },
'Shopping': { bg: 'bg-blue-500/10', text: 'text-blue-400', border: 'border-blue-500/20', dot: 'bg-blue-400' },
'Healthcare': { bg: 'bg-red-500/10', text: 'text-red-400', border: 'border-red-500/20', dot: 'bg-red-400' },
'Bills': { bg: 'bg-yellow-500/10', text: 'text-yellow-400', border: 'border-yellow-500/20', dot: 'bg-yellow-400' },
'Education': { bg: 'bg-pink-500/10', text: 'text-pink-400', border: 'border-pink-500/20', dot: 'bg-pink-400' },
'Other': { bg: 'bg-gray-500/10', text: 'text-gray-400', border: 'border-gray-500/20', dot: 'bg-gray-400' }
};
const catColor = categoryColors[tx.category_name] || categoryColors['Other'];
// Status icon (completed/pending)
const isCompleted = true; // For now, all are completed
const statusIcon = isCompleted
? '<span class="material-symbols-outlined text-[16px]">check</span>'
: '<span class="material-symbols-outlined text-[16px]">schedule</span>';
const statusClass = isCompleted
? 'bg-green-500/20 text-green-400'
: 'bg-yellow-500/20 text-yellow-400';
const statusTitle = isCompleted ? 'Completed' : 'Pending';
return `
<tr class="group hover:bg-gray-50 dark:hover:bg-white/[0.02] transition-colors relative border-l-2 border-transparent hover:border-primary">
<td class="p-5">
<div class="flex items-center gap-3">
<div class="size-10 rounded-full flex items-center justify-center shrink-0" style="background: ${tx.category_color}20;">
<span class="material-symbols-outlined text-[20px]" style="color: ${tx.category_color};">payments</span>
</div>
<div class="flex flex-col">
<span class="text-text-main dark:text-white font-medium group-hover:text-primary transition-colors">${tx.description}</span>
<span class="text-text-muted dark:text-[#92adc9] text-xs">${tx.tags.length > 0 ? tx.tags.join(', ') : 'Expense'}</span>
</div>
</div>
</td>
<td class="p-5">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${catColor.bg} ${catColor.text} border ${catColor.border}">
<span class="size-1.5 rounded-full ${catColor.dot}"></span>
${tx.category_name}
</span>
</td>
<td class="p-5 text-text-muted dark:text-[#92adc9]">
${dateStr}
<span class="block text-xs opacity-60">${timeStr}</span>
</td>
<td class="p-5">
<div class="flex items-center gap-2 text-text-main dark:text-white">
<span class="material-symbols-outlined text-text-muted dark:text-[#92adc9] text-[18px]">credit_card</span>
<span> ${tx.currency}</span>
</div>
</td>
<td class="p-5 text-right">
<span class="text-text-main dark:text-white font-semibold">${formatCurrency(tx.amount, tx.currency)}</span>
</td>
<td class="p-5 text-center">
<span class="inline-flex items-center justify-center size-6 rounded-full ${statusClass}" title="${statusTitle}">
${statusIcon}
</span>
</td>
<td class="p-5 text-right">
<div class="flex items-center justify-end gap-1">
${tx.receipt_path ? `
<button onclick="window.open('${tx.receipt_path}')" class="text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white p-1 rounded hover:bg-gray-100 dark:hover:bg-white/10 transition-colors" title="View Receipt">
<span class="material-symbols-outlined text-[18px]">attach_file</span>
</button>
` : ''}
<button onclick="editTransaction(${tx.id})" class="text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white p-1 rounded hover:bg-gray-100 dark:hover:bg-white/10 transition-colors" title="Edit">
<span class="material-symbols-outlined text-[18px]">edit</span>
</button>
<button onclick="deleteTransaction(${tx.id})" class="text-text-muted dark:text-[#92adc9] hover:text-red-400 p-1 rounded hover:bg-red-100 dark:hover:bg-white/10 transition-colors" title="Delete">
<span class="material-symbols-outlined text-[18px]">delete</span>
</button>
</div>
</td>
</tr>
`;
}).join('');
}
// Display pagination
function displayPagination(totalPages, current, totalItems = 0) {
const container = document.getElementById('pagination');
// Update pagination info
const perPage = 10;
const start = (current - 1) * perPage + 1;
const end = Math.min(current * perPage, totalItems);
document.getElementById('page-start').textContent = totalItems > 0 ? start : 0;
document.getElementById('page-end').textContent = end;
document.getElementById('total-count').textContent = totalItems;
if (totalPages <= 1) {
container.innerHTML = '';
return;
}
let html = '';
// Previous button
const prevDisabled = current <= 1;
html += `
<button
onclick="changePage(${current - 1})"
class="flex items-center gap-1 px-3 py-1.5 bg-background-light dark:bg-[#111a22] border border-border-light dark:border-[#233648] rounded-md text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white hover:border-text-muted dark:hover:border-[#92adc9] transition-colors text-sm ${prevDisabled ? 'opacity-50 cursor-not-allowed' : ''}"
${prevDisabled ? 'disabled' : ''}
>
<span class="material-symbols-outlined text-[16px]">chevron_left</span>
Previous
</button>
`;
// Next button
const nextDisabled = current >= totalPages;
html += `
<button
onclick="changePage(${current + 1})"
class="flex items-center gap-1 px-3 py-1.5 bg-background-light dark:bg-[#111a22] border border-border-light dark:border-[#233648] rounded-md text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white hover:border-text-muted dark:hover:border-[#92adc9] transition-colors text-sm ${nextDisabled ? 'opacity-50 cursor-not-allowed' : ''}"
${nextDisabled ? 'disabled' : ''}
>
Next
<span class="material-symbols-outlined text-[16px]">chevron_right</span>
</button>
`;
container.innerHTML = html;
}
// Change page
function changePage(page) {
currentPage = page;
loadTransactions();
}
// Delete transaction
async function deleteTransaction(id) {
if (!confirm('Are you sure you want to delete this transaction?')) {
return;
}
try {
await apiCall(`/api/expenses/${id}`, { method: 'DELETE' });
showToast('Transaction deleted', 'success');
loadTransactions();
} catch (error) {
console.error('Failed to delete transaction:', error);
}
}
// Load categories for filter
async function loadCategoriesFilter() {
try {
const data = await apiCall('/api/expenses/categories');
const select = document.getElementById('filter-category');
select.innerHTML = '<option value="">Category</option>' +
data.categories.map(cat => `<option value="${cat.id}">${cat.name}</option>`).join('');
} catch (error) {
console.error('Failed to load categories:', error);
}
}
// Toggle advanced filters
function toggleAdvancedFilters() {
const advFilters = document.getElementById('advanced-filters');
advFilters.classList.toggle('hidden');
}
// Filter event listeners
document.getElementById('filter-category').addEventListener('change', (e) => {
filters.category_id = e.target.value;
currentPage = 1;
loadTransactions();
});
document.getElementById('filter-start-date').addEventListener('change', (e) => {
filters.start_date = e.target.value;
currentPage = 1;
loadTransactions();
});
document.getElementById('filter-end-date').addEventListener('change', (e) => {
filters.end_date = e.target.value;
currentPage = 1;
loadTransactions();
});
document.getElementById('filter-search').addEventListener('input', (e) => {
filters.search = e.target.value;
currentPage = 1;
loadTransactions();
});
// More filters button
document.getElementById('more-filters-btn').addEventListener('click', toggleAdvancedFilters);
// Date filter button (same as more filters for now)
document.getElementById('date-filter-btn').addEventListener('click', toggleAdvancedFilters);
// Export CSV
document.getElementById('export-csv-btn').addEventListener('click', () => {
window.location.href = '/api/expenses/export/csv';
});
// Import CSV
document.getElementById('import-csv-btn').addEventListener('click', () => {
document.getElementById('csv-file-input').click();
});
document.getElementById('csv-file-input').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const result = await apiCall('/api/expenses/import/csv', {
method: 'POST',
body: formData
});
showToast(`Imported ${result.imported} transactions`, 'success');
if (result.errors.length > 0) {
console.warn('Import errors:', result.errors);
}
loadTransactions();
} catch (error) {
console.error('Failed to import CSV:', error);
}
e.target.value = ''; // Reset file input
});
// Initialize
document.addEventListener('DOMContentLoaded', () => {
loadTransactions();
loadCategoriesFilter();
});

View file

@ -0,0 +1,63 @@
{
"name": "FINA",
"short_name": "FINA",
"description": "Personal Finance Tracker - Track your expenses, manage your finances",
"start_url": "/",
"display": "standalone",
"background_color": "#111a22",
"theme_color": "#2b8cee",
"orientation": "portrait-primary",
"icons": [
{
"src": "/static/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png",
"purpose": "any"
}
],
"categories": ["finance", "productivity", "utilities"],
"shortcuts": [
{
"name": "Add Expense",
"short_name": "Add",
"description": "Quickly add a new expense",
"url": "/dashboard?action=add",
"icons": [
{
"src": "/static/icons/icon-96x96.png",
"sizes": "96x96"
}
]
},
{
"name": "View Reports",
"short_name": "Reports",
"description": "View spending reports",
"url": "/reports",
"icons": [
{
"src": "/static/icons/icon-96x96.png",
"sizes": "96x96"
}
]
}
]
}

View file

@ -0,0 +1,89 @@
const CACHE_NAME = 'fina-v1';
const urlsToCache = [
'/',
'/static/js/app.js',
'/static/js/pwa.js',
'/static/manifest.json',
'https://cdn.tailwindcss.com',
'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap',
'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap'
];
// Install event - cache resources
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(urlsToCache))
.then(() => self.skipWaiting())
);
});
// Activate event - clean up old caches
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
}).then(() => self.clients.claim())
);
});
// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', event => {
// Skip non-GET requests
if (event.request.method !== 'GET') {
return;
}
event.respondWith(
caches.match(event.request)
.then(response => {
// Cache hit - return response
if (response) {
return response;
}
// Clone the request
const fetchRequest = event.request.clone();
return fetch(fetchRequest).then(response => {
// Check if valid response
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// Clone the response
const responseToCache = response.clone();
caches.open(CACHE_NAME)
.then(cache => {
cache.put(event.request, responseToCache);
});
return response;
}).catch(() => {
// Return offline page or fallback
return new Response('You are offline', {
headers: { 'Content-Type': 'text/plain' }
});
});
})
);
});
// Background sync for offline expense creation
self.addEventListener('sync', event => {
if (event.tag === 'sync-expenses') {
event.waitUntil(syncExpenses());
}
});
async function syncExpenses() {
// Implement offline expense sync logic
console.log('Syncing expenses...');
}

View file

@ -0,0 +1,126 @@
{% extends "base.html" %}
{% block title %}Backup Codes - FINA{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center p-4 bg-background-light dark:bg-background-dark">
<div class="max-w-2xl w-full">
<!-- Success Header -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 bg-green-100 dark:bg-green-500/20 rounded-full mb-4">
<span class="material-symbols-outlined text-green-600 dark:text-green-400 text-[32px]">verified_user</span>
</div>
<h1 class="text-2xl font-bold text-text-main dark:text-white mb-2" data-translate="twofa.setupSuccess">Two-Factor Authentication Enabled!</h1>
<p class="text-text-muted dark:text-[#92adc9]" data-translate="twofa.backupCodesDesc">Save these backup codes in a secure location</p>
</div>
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-[#233648] rounded-2xl p-6 md:p-8 shadow-sm">
<!-- Warning Alert -->
<div class="bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/30 rounded-lg p-4 mb-6">
<div class="flex gap-3">
<span class="material-symbols-outlined text-yellow-600 dark:text-yellow-400 text-[20px] flex-shrink-0">warning</span>
<div class="flex-1">
<h3 class="font-semibold text-yellow-800 dark:text-yellow-400 mb-1" data-translate="twofa.important">Important!</h3>
<p class="text-sm text-yellow-700 dark:text-yellow-300" data-translate="twofa.backupCodesWarning">Each backup code can only be used once. Store them securely - you'll need them if you lose access to your authenticator app.</p>
</div>
</div>
</div>
<!-- Backup Codes Grid -->
<div class="bg-background-light dark:bg-background-dark border border-border-light dark:border-[#233648] rounded-xl p-6 mb-6">
<h3 class="text-sm font-medium text-text-muted dark:text-[#92adc9] uppercase tracking-wide mb-4" data-translate="twofa.yourBackupCodes">Your Backup Codes</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
{% for code in backup_codes %}
<div class="flex items-center gap-3 bg-card-light dark:bg-card-dark border border-border-light dark:border-[#233648] rounded-lg p-3">
<span class="text-text-muted dark:text-[#92adc9] text-sm font-medium">{{ loop.index }}.</span>
<code class="flex-1 text-primary font-mono font-bold text-base tracking-wider">{{ code }}</code>
<button onclick="copyCode('{{ code }}')" class="text-text-muted dark:text-[#92adc9] hover:text-primary transition-colors" title="Copy">
<span class="material-symbols-outlined text-[20px]">content_copy</span>
</button>
</div>
{% endfor %}
</div>
</div>
<!-- Actions -->
<div class="flex flex-col sm:flex-row gap-3">
<a href="{{ url_for('auth.download_backup_codes_pdf') }}" class="flex-1 inline-flex items-center justify-center gap-2 px-6 py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary/90 transition-colors">
<span class="material-symbols-outlined text-[20px]">download</span>
<span data-translate="twofa.downloadPDF">Download as PDF</span>
</a>
<button onclick="printCodes()" class="flex-1 inline-flex items-center justify-center gap-2 px-6 py-3 bg-background-light dark:bg-background-dark border border-border-light dark:border-[#233648] text-text-main dark:text-white rounded-lg font-medium hover:bg-slate-100 dark:hover:bg-white/5 transition-colors">
<span class="material-symbols-outlined text-[20px]">print</span>
<span data-translate="twofa.print">Print Codes</span>
</button>
</div>
<div class="mt-6 text-center">
<a href="{{ url_for('main.settings') }}" class="inline-flex items-center gap-2 text-text-muted dark:text-[#92adc9] hover:text-primary transition-colors text-sm font-medium">
<span data-translate="twofa.continueToSettings">Continue to Settings</span>
<span class="material-symbols-outlined text-[16px]">arrow_forward</span>
</a>
</div>
</div>
<!-- Info Section -->
<div class="mt-6 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-4">
<div class="flex gap-3">
<span class="material-symbols-outlined text-blue-600 dark:text-blue-400 text-[20px] flex-shrink-0">info</span>
<div class="text-sm text-blue-700 dark:text-blue-300">
<p class="font-medium mb-1" data-translate="twofa.howToUse">How to use backup codes:</p>
<ul class="list-disc list-inside space-y-1 text-blue-600 dark:text-blue-400">
<li data-translate="twofa.useWhen">Use a backup code when you can't access your authenticator app</li>
<li data-translate="twofa.enterCode">Enter the code in the 2FA field when logging in</li>
<li data-translate="twofa.oneTimeUse">Each code works only once - it will be deleted after use</li>
<li data-translate="twofa.regenerate">You can regenerate codes anytime from Settings</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<script>
function copyCode(code) {
navigator.clipboard.writeText(code).then(() => {
// Show success notification
const notification = document.createElement('div');
notification.className = 'fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 bg-green-500 text-white animate-slideIn';
notification.innerHTML = `
<span class="material-symbols-outlined text-[20px]">check_circle</span>
<span class="text-sm font-medium">Code copied to clipboard!</span>
`;
document.body.appendChild(notification);
setTimeout(() => {
notification.classList.add('animate-slideOut');
setTimeout(() => document.body.removeChild(notification), 300);
}, 2000);
});
}
function printCodes() {
window.print();
}
</script>
<style>
@media print {
body * {
visibility: hidden;
}
.bg-card-light, .bg-card-dark {
visibility: visible;
position: absolute;
left: 0;
top: 0;
}
.bg-card-light *, .bg-card-dark * {
visibility: visible;
}
button, .mt-6, .bg-blue-50, .dark\:bg-blue-500\/10 {
display: none !important;
}
}
</style>
{% endblock %}

View file

@ -0,0 +1,212 @@
{% extends "base.html" %}
{% block title %}Login - FINA{% endblock %}
{% block extra_css %}
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<style>
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: 'liga';
}
.radial-blue-bg {
background: radial-gradient(circle, #1e3a8a 0%, #1e293b 50%, #0f172a 100%);
position: relative;
overflow: hidden;
}
.radial-blue-bg::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background:
repeating-conic-gradient(from 0deg at 50% 50%,
transparent 0deg,
rgba(43, 140, 238, 0.1) 2deg,
transparent 4deg);
animation: rotate 60s linear infinite;
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.login-input {
background: #1e293b;
border: 1px solid #334155;
border-radius: 25px;
padding: 12px 20px;
font-size: 14px;
color: #e2e8f0;
transition: all 0.3s ease;
}
.login-input:focus {
outline: none;
border-color: #3b82f6;
background: #0f172a;
color: #fff;
}
.login-input::placeholder {
color: #64748b;
}
.login-btn {
background: #3b82f6;
color: white;
border: none;
border-radius: 25px;
padding: 12px 40px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 8px;
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
}
.login-btn:hover {
background: #2563eb;
transform: translateX(2px);
box-shadow: 0 0 30px rgba(59, 130, 246, 0.5);
}
</style>
{% endblock %}
{% block body %}
<div class="min-h-screen flex">
<!-- Left Side - Logo with Radial Background -->
<div class="hidden lg:flex lg:w-1/2 radial-blue-bg items-center justify-center relative">
<div class="relative z-10">
<img src="{{ url_for('static', filename='icons/logo.png') }}" alt="FINA Logo" class="w-96 h-96 rounded-full shadow-2xl">
</div>
</div>
<!-- Right Side - Login Form -->
<div class="w-full lg:w-1/2 flex items-center justify-center bg-slate-900 p-8">
<div class="w-full max-w-md">
<!-- Mobile Logo -->
<div class="lg:hidden flex justify-center mb-8">
<img src="{{ url_for('static', filename='icons/logo.png') }}" alt="FINA Logo" class="w-24 h-24 rounded-full shadow-lg">
</div>
<!-- Login Header -->
<h1 class="text-3xl font-bold text-white mb-8">Login Here!</h1>
<!-- Login Form -->
<form id="login-form" class="space-y-6">
<!-- Username Field -->
<div class="flex items-center gap-3">
<span class="material-icons text-slate-400 text-[24px]">person</span>
<input type="text" name="username" required autofocus
class="login-input flex-1"
placeholder="username or email">
</div>
<!-- Password Field -->
<div class="flex items-center gap-3">
<span class="material-icons text-slate-400 text-[24px]">lock</span>
<div class="flex-1 relative">
<input type="password" name="password" id="password" required
class="login-input w-full pr-12"
placeholder="password">
<button type="button" onclick="togglePassword()" class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 hover:text-blue-400">
<span class="material-icons text-[20px]" id="eye-icon">visibility</span>
</button>
</div>
</div>
<!-- 2FA Field (hidden by default) -->
<div id="2fa-field" class="hidden flex items-center gap-3">
<span class="material-icons text-slate-400 text-[24px]">security</span>
<input type="text" name="two_factor_code"
class="login-input flex-1"
placeholder="2FA code">
</div>
<!-- Remember Password & Login Button -->
<div class="flex items-center justify-between">
<label class="flex items-center text-sm text-slate-300">
<input type="checkbox" name="remember" id="remember" class="mr-2 rounded border-slate-500 bg-slate-700 text-blue-600">
<span>Remember Password</span>
</label>
<button type="submit" class="login-btn">
<span>LOGIN</span>
<span class="material-icons text-[18px]">arrow_forward</span>
</button>
</div>
</form>
<!-- Register Link -->
<div class="mt-8 text-center text-sm text-slate-300">
Don't have an account?
<a href="{{ url_for('auth.register') }}" class="text-blue-400 hover:text-blue-300 hover:underline font-medium">Create your account <span class="underline">here</span>!</a>
</div>
</div>
</div>
</div>
<script>
function togglePassword() {
const passwordInput = document.getElementById('password');
const eyeIcon = document.getElementById('eye-icon');
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
eyeIcon.textContent = 'visibility_off';
} else {
passwordInput.type = 'password';
eyeIcon.textContent = 'visibility';
}
}
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData);
try {
const response = await fetch('/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.requires_2fa) {
document.getElementById('2fa-field').classList.remove('hidden');
showToast('Please enter your 2FA code', 'info');
} else if (result.success) {
window.location.href = result.redirect;
} else {
showToast(result.message || 'Login failed', 'error');
}
} catch (error) {
showToast('An error occurred', 'error');
}
});
</script>
{% endblock %}

View file

@ -0,0 +1,93 @@
{% extends "base.html" %}
{% block title %}Register - FINA{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center p-4 bg-background-dark">
<div class="max-w-md w-full">
<!-- Logo/Brand -->
<div class="text-center mb-8">
<img src="{{ url_for('static', filename='icons/logo.png') }}" alt="FINA Logo" class="w-32 h-32 mx-auto mb-4 rounded-full shadow-lg shadow-primary/30">
<h1 class="text-4xl font-bold text-white mb-2">FINA</h1>
<p class="text-[#92adc9]" data-translate="register.tagline">Start managing your finances today</p>
</div>
<!-- Register Form -->
<div class="bg-card-dark border border-[#233648] rounded-2xl p-8">
<h2 class="text-2xl font-bold text-white mb-6" data-translate="register.title">Create Account</h2>
<form id="register-form" class="space-y-4">
<div>
<label class="text-[#92adc9] text-sm mb-2 block" data-translate="form.username">Username</label>
<input type="text" name="username" required class="w-full bg-[#111a22] border border-[#233648] rounded-lg px-4 py-3 text-white focus:border-primary focus:ring-1 focus:ring-primary">
</div>
<div>
<label class="text-[#92adc9] text-sm mb-2 block" data-translate="form.email">Email</label>
<input type="email" name="email" required class="w-full bg-[#111a22] border border-[#233648] rounded-lg px-4 py-3 text-white focus:border-primary focus:ring-1 focus:ring-primary">
</div>
<div>
<label class="text-[#92adc9] text-sm mb-2 block" data-translate="form.password">Password</label>
<input type="password" name="password" required minlength="8" class="w-full bg-[#111a22] border border-[#233648] rounded-lg px-4 py-3 text-white focus:border-primary focus:ring-1 focus:ring-primary">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-[#92adc9] text-sm mb-2 block" data-translate="form.language">Language</label>
<select name="language" class="w-full bg-[#111a22] border border-[#233648] rounded-lg px-4 py-3 text-white focus:border-primary focus:ring-1 focus:ring-primary">
<option value="en">English</option>
<option value="ro">Română</option>
</select>
</div>
<div>
<label class="text-[#92adc9] text-sm mb-2 block" data-translate="form.currency">Currency</label>
<select name="currency" class="w-full bg-[#111a22] border border-[#233648] rounded-lg px-4 py-3 text-white focus:border-primary focus:ring-1 focus:ring-primary">
<option value="USD">USD ($)</option>
<option value="EUR">EUR (€)</option>
<option value="GBP">GBP (£)</option>
<option value="RON">RON (lei)</option>
</select>
</div>
</div>
<button type="submit" class="w-full bg-primary hover:bg-primary/90 text-white py-3 rounded-lg font-semibold transition-colors" data-translate="register.create_account">Create Account</button>
</form>
<div class="mt-6 text-center">
<p class="text-[#92adc9] text-sm">
<span data-translate="register.have_account">Already have an account?</span>
<a href="{{ url_for('auth.login') }}" class="text-primary hover:underline ml-1" data-translate="register.login">Login</a>
</p>
</div>
</div>
</div>
</div>
<script>
document.getElementById('register-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData);
try {
const response = await fetch('/auth/register', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
window.location.href = result.redirect;
} else {
showToast(result.message || 'Registration failed', 'error');
}
} catch (error) {
showToast('An error occurred', 'error');
}
});
</script>
{% endblock %}

View file

@ -0,0 +1,100 @@
{% extends "base.html" %}
{% block title %}Setup 2FA - FINA{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center p-4 bg-background-light dark:bg-background-dark">
<div class="max-w-md w-full">
<!-- Header -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 bg-primary/10 rounded-full mb-4">
<span class="material-symbols-outlined text-primary text-[32px]">lock</span>
</div>
<h1 class="text-2xl font-bold text-text-main dark:text-white mb-2" data-translate="twofa.setupTitle">Setup Two-Factor Authentication</h1>
<p class="text-text-muted dark:text-[#92adc9]" data-translate="twofa.setupDesc">Scan the QR code with your authenticator app</p>
</div>
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-[#233648] rounded-2xl p-6 md:p-8 shadow-sm">
<!-- Instructions -->
<div class="mb-6">
<h3 class="text-sm font-semibold text-text-main dark:text-white mb-3" data-translate="twofa.step1">Step 1: Scan QR Code</h3>
<p class="text-sm text-text-muted dark:text-[#92adc9] mb-4" data-translate="twofa.step1Desc">Open your authenticator app (Google Authenticator, Authy, etc.) and scan this QR code:</p>
<!-- QR Code -->
<div class="bg-white p-4 rounded-xl flex justify-center border border-border-light">
<img src="data:image/png;base64,{{ qr_code }}" alt="2FA QR Code" class="max-w-full h-auto" style="max-width: 200px;">
</div>
</div>
<!-- Manual Entry -->
<div class="mb-6">
<details class="group">
<summary class="cursor-pointer list-none flex items-center justify-between text-sm font-medium text-text-main dark:text-white mb-2">
<span data-translate="twofa.manualEntry">Can't scan? Enter code manually</span>
<span class="material-symbols-outlined text-[20px] group-open:rotate-180 transition-transform">expand_more</span>
</summary>
<div class="mt-3 bg-background-light dark:bg-background-dark border border-border-light dark:border-[#233648] rounded-lg p-4">
<p class="text-xs text-text-muted dark:text-[#92adc9] mb-2" data-translate="twofa.enterManually">Enter this code in your authenticator app:</p>
<div class="flex items-center gap-2">
<code id="secret-code" class="flex-1 text-primary font-mono text-sm break-all">{{ secret }}</code>
<button onclick="copySecret()" class="p-2 text-text-muted dark:text-[#92adc9] hover:text-primary transition-colors" title="Copy">
<span class="material-symbols-outlined text-[20px]">content_copy</span>
</button>
</div>
</div>
</details>
</div>
<!-- Verification -->
<form method="POST" class="space-y-4">
<div>
<h3 class="text-sm font-semibold text-text-main dark:text-white mb-3" data-translate="twofa.step2">Step 2: Verify Code</h3>
<p class="text-sm text-text-muted dark:text-[#92adc9] mb-3" data-translate="twofa.step2Desc">Enter the 6-digit code from your authenticator app:</p>
<input type="text" name="code" maxlength="6" pattern="[0-9]{6}" required
class="w-full bg-background-light dark:bg-background-dark border border-border-light dark:border-[#233648] rounded-lg px-4 py-3 text-text-main dark:text-white text-center text-2xl tracking-widest font-mono focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none transition-all"
placeholder="000000"
autocomplete="off">
</div>
<button type="submit" class="w-full bg-primary hover:bg-primary/90 text-white py-3 rounded-lg font-semibold transition-colors flex items-center justify-center gap-2">
<span class="material-symbols-outlined text-[20px]">verified_user</span>
<span data-translate="twofa.enable">Enable 2FA</span>
</button>
</form>
<div class="mt-6 text-center">
<a href="{{ url_for('main.settings') }}" class="text-text-muted dark:text-[#92adc9] hover:text-primary transition-colors text-sm" data-translate="actions.cancel">Cancel</a>
</div>
</div>
<!-- Info -->
<div class="mt-6 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-4">
<div class="flex gap-3">
<span class="material-symbols-outlined text-blue-600 dark:text-blue-400 text-[20px] flex-shrink-0">info</span>
<p class="text-sm text-blue-700 dark:text-blue-300" data-translate="twofa.infoText">After enabling 2FA, you'll receive backup codes that you can use if you lose access to your authenticator app. Keep them in a safe place!</p>
</div>
</div>
</div>
</div>
<script>
function copySecret() {
const secretCode = document.getElementById('secret-code').textContent;
navigator.clipboard.writeText(secretCode).then(() => {
// Show success notification
const notification = document.createElement('div');
notification.className = 'fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 bg-green-500 text-white animate-slideIn';
notification.innerHTML = `
<span class="material-symbols-outlined text-[20px]">check_circle</span>
<span class="text-sm font-medium">Secret code copied!</span>
`;
document.body.appendChild(notification);
setTimeout(() => {
notification.classList.add('animate-slideOut');
setTimeout(() => document.body.removeChild(notification), 300);
}, 2000);
});
}
</script>
{% endblock %}

View file

@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="FINA - Track your expenses, manage your finances">
<meta name="theme-color" content="#111a22">
<title>{% block title %}FINA - Personal Finance Tracker{% endblock %}</title>
<!-- PWA Manifest -->
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<!-- Icons -->
<link rel="icon" type="image/png" href="{{ url_for('static', filename='icons/favicon.png') }}">
<link rel="icon" type="image/png" sizes="96x96" href="{{ url_for('static', filename='icons/icon-96x96.png') }}">
<link rel="icon" type="image/png" sizes="192x192" href="{{ url_for('static', filename='icons/icon-192x192.png') }}">
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='icons/icon-512x512.png') }}">
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='icons/apple-touch-icon.png') }}">
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script>
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"primary": "#2b8cee",
"background-light": "#f6f7f8",
"background-dark": "#111a22",
"card-dark": "#1a2632",
"card-light": "#ffffff",
"sidebar-light": "#ffffff",
"border-light": "#e2e8f0",
"text-main": "#0f172a",
"text-muted": "#64748b",
},
fontFamily: {
"display": ["Inter", "sans-serif"]
},
borderRadius: {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"2xl": "1rem",
"full": "9999px"
},
},
},
}
</script>
<style>
/* Dark theme scrollbar */
.dark ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.dark ::-webkit-scrollbar-track {
background: transparent;
}
.dark ::-webkit-scrollbar-thumb {
background: #324d67;
border-radius: 4px;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #4b6a88;
}
/* Light theme scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbarlight dark:bg-background-dark text-text-main dark:text-white font-display overflow-hidden transition-colors duration-200
background: #94a3b8;
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.glass {
background: rgba(26, 38, 50, 0.7);
backdrop-filter: blur(10px);
border: 1px solid rgba(35, 54, 72, 0.5);
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body class="bg-background-dark text-white font-display overflow-hidden">
{% block body %}{% endblock %}
<!-- Toast Notification Container -->
<div id="toast-container" class="fixed top-4 right-4 z-50 space-y-2"></div>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<script src="{{ url_for('static', filename='js/pwa.js') }}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,218 @@
{% extends "base.html" %}
{% block title %}Dashboard - FINA{% endblock %}
{% block body %}
<div class="flex h-screen w-full">
<!-- Side Navigation -->
<aside id="sidebar" class="hidden lg:flex w-64 flex-col bg-sidebar-light dark:bg-background-dark border-r border-border-light dark:border-[#233648] transition-all duration-300 shadow-sm dark:shadow-none">
<div class="p-6 flex flex-col h-full justify-between">
<div class="flex flex-col gap-8">
<!-- User Profile -->
<div class="flex gap-3 items-center">
<img src="{{ current_user.avatar | avatar_url }}" alt="{{ current_user.username }}" class="size-10 rounded-full border-2 border-primary/30 object-cover">
<div class="flex flex-col">
<h1 class="text-text-main dark:text-white text-base font-bold leading-none">{{ current_user.username }}</h1>
<p class="text-text-muted dark:text-[#92adc9] text-xs font-normal mt-1">
{% if current_user.is_admin %}Admin{% else %}User{% endif %}
</p>
</div>
</div>
<!-- Navigation Links -->
<nav class="flex flex-col gap-2">
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-primary/10 text-primary border border-primary/10" href="{{ url_for('main.dashboard') }}">
<span class="material-symbols-outlined text-[20px]">dashboard</span>
<span class="text-sm font-medium" data-translate="nav.dashboard">Dashboard</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-slate-50 dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.transactions') }}">
<span class="material-symbols-outlined text-[20px]">receipt_long</span>
<span class="text-sm font-medium" data-translate="nav.transactions">Transactions</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-slate-50 dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.reports') }}">
<span class="material-symbols-outlined text-[20px]">pie_chart</span>
<span class="text-sm font-medium" data-translate="nav.reports">Reports</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-slate-50 dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.documents') }}">
<span class="material-symbols-outlined text-[20px]">folder_open</span>
<span class="text-sm font-medium" data-translate="nav.documents">Documents</span>
</a>
{% if current_user.is_admin %}
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-slate-50 dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="/admin">
<span class="material-symbols-outlined text-[20px]">admin_panel_settings</span>
<span class="text-sm font-medium" data-translate="nav.admin">Admin</span>
</a>
{% endif %}
</nav>
</div>
<!-- Bottom Links -->
<div class="flex flex-col gap-2">
<button id="theme-toggle" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-slate-50 dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors">
<span class="material-symbols-outlined text-[20px]" id="theme-icon">light_mode</span>
<span class="text-sm font-medium" id="theme-text">Light Mode</span>
</button>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-slate-50 dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.settings') }}">
<span class="material-symbols-outlined text-[20px]">settings</span>
<span class="text-sm font-medium" data-translate="nav.settings">Settings</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-slate-50 dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('auth.logout') }}">
<span class="material-symbols-outlined text-[20px]">logout</span>
<span class="text-sm font-medium" data-translate="nav.logout">Log out</span>
</a>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 flex flex-col h-full overflow-hidden relative">
<!-- Top Header -->
<header class="h-16 flex items-center justify-between px-6 lg:px-8 border-b border-border-light dark:border-[#233648] bg-white/80 dark:bg-background-dark/95 backdrop-blur z-10 shrink-0 shadow-sm dark:shadow-none">
<div class="flex items-center gap-4">
<button id="menu-toggle" class="lg:hidden text-text-main dark:text-white">
<span class="material-symbols-outlined">menu</span>
</button>
<h2 class="text-text-main dark:text-white text-lg font-bold" data-translate="nav.dashboard">Dashboard</h2>
</div>
<div class="flex items-center gap-6">
<!-- Search Bar -->
<div class="hidden md:flex items-center bg-slate-50 dark:bg-card-dark rounded-lg h-10 px-3 border border-border-light dark:border-[#233648] focus-within:border-primary transition-colors w-64">
<span class="material-symbols-outlined text-text-muted dark:text-[#92adc9] text-[20px]">search</span>
<input id="search-input" class="bg-transparent border-none text-text-main dark:text-white text-sm placeholder-slate-400 dark:placeholder-[#5f7a96] focus:ring-0 w-full ml-2" placeholder="Search expenses..." type="text"/>
</div>
<!-- Actions -->
<div class="flex items-center gap-3">
<button id="add-expense-btn" class="bg-primary hover:bg-primary/90 text-white h-9 px-4 rounded-lg text-sm font-semibold shadow-md shadow-primary/20 transition-all flex items-center gap-2">
<span class="material-symbols-outlined text-[18px]">add</span>
<span class="hidden sm:inline" data-translate="actions.add_expense">Add Expense</span>
</button>
</div>
</div>
</header>
<!-- Scrollable Content -->
<div class="flex-1 overflow-y-auto p-6 lg:p-8 scroll-smooth bg-[#f8fafc] dark:bg-background-dark">
<div class="max-w-7xl mx-auto flex flex-col gap-8 pb-10">
<!-- KPI Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 lg:gap-6">
<!-- Total Spent -->
<div class="p-6 rounded-xl bg-white dark:bg-card-dark border border-border-light dark:border-[#233648] flex flex-col justify-between relative overflow-hidden group shadow-sm dark:shadow-none">
<div class="absolute top-0 right-0 p-4 opacity-5 dark:opacity-10 group-hover:opacity-10 dark:group-hover:opacity-20 transition-opacity">
<span class="material-symbols-outlined text-6xl text-primary">payments</span>
</div>
<div>
<p class="text-text-muted dark:text-[#92adc9] text-sm font-medium" data-translate="dashboard.total_spent">Total Spent</p>
<h3 id="total-spent" class="text-text-main dark:text-white text-3xl font-bold mt-2 tracking-tight">$0.00</h3>
</div>
<div class="flex items-center gap-2 mt-4">
<span id="percent-change" class="bg-green-500/10 text-green-600 dark:text-green-400 text-xs font-semibold px-2 py-1 rounded-full flex items-center gap-1">
<span class="material-symbols-outlined text-[14px]">trending_up</span>
0%
</span>
<span class="text-text-muted dark:text-[#5f7a96] text-xs" data-translate="dashboard.vs_last_month">vs last month</span>
</div>
</div>
<!-- Active Categories -->
<div class="p-6 rounded-xl bg-white dark:bg-card-dark border border-border-light dark:border-[#233648] flex flex-col justify-between shadow-sm dark:shadow-none">
<div>
<p class="text-text-muted dark:text-[#92adc9] text-sm font-medium" data-translate="dashboard.active_categories">Active Categories</p>
<h3 id="active-categories" class="text-text-main dark:text-white text-3xl font-bold mt-2 tracking-tight">0</h3>
</div>
<p class="text-text-muted dark:text-[#5f7a96] text-xs mt-6" data-translate="dashboard.categories_in_use">categories in use</p>
</div>
<!-- Total Transactions -->
<div class="p-6 rounded-xl bg-white dark:bg-card-dark border border-border-light dark:border-[#233648] flex flex-col justify-between shadow-sm dark:shadow-none">
<div>
<p class="text-text-muted dark:text-[#92adc9] text-sm font-medium" data-translate="dashboard.total_transactions">Total Transactions</p>
<h3 id="total-transactions" class="text-text-main dark:text-white text-3xl font-bold mt-2 tracking-tight">0</h3>
</div>
<p class="text-text-muted dark:text-[#5f7a96] text-xs mt-6" data-translate="dashboard.this_month">this month</p>
</div>
</div>
<!-- Charts Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-6">
<!-- Spending by Category -->
<div class="p-6 rounded-xl bg-white dark:bg-card-dark border border-border-light dark:border-[#233648] shadow-sm dark:shadow-none">
<h3 class="text-text-main dark:text-white text-lg font-bold mb-4" data-translate="dashboard.spending_by_category">Spending by Category</h3>
<canvas id="category-chart" class="w-full" style="max-height: 300px;"></canvas>
</div>
<!-- Monthly Trend -->
<div class="p-6 rounded-xl bg-white dark:bg-card-dark border border-border-light dark:border-[#233648] shadow-sm dark:shadow-none">
<h3 class="text-text-main dark:text-white text-lg font-bold mb-4" data-translate="dashboard.monthly_trend">Monthly Trend</h3>
<canvas id="monthly-chart" class="w-full" style="max-height: 300px;"></canvas>
</div>
</div>
<!-- Recent Transactions -->
<div class="p-6 rounded-xl bg-white dark:bg-card-dark border border-border-light dark:border-[#233648] shadow-sm dark:shadow-none">
<div class="flex justify-between items-center mb-4">
<h3 class="text-text-main dark:text-white text-lg font-bold" data-translate="dashboard.recent_transactions">Recent Transactions</h3>
<a href="{{ url_for('main.transactions') }}" class="text-primary text-sm hover:underline" data-translate="dashboard.view_all">View All</a>
</div>
<div id="recent-transactions" class="space-y-3">
<!-- Transactions will be loaded here -->
</div>
</div>
</div>
</div>
</main>
</div>
<!-- Add Expense Modal -->
<div id="expense-modal" class="hidden fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div class="bg-white dark:bg-card-dark border border-border-light dark:border-[#233648] rounded-2xl max-w-md w-full max-h-[90vh] overflow-y-auto shadow-xl">
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<h3 class="text-text-main dark:text-white text-xl font-bold" data-translate="modal.add_expense">Add Expense</h3>
<button id="close-modal" class="text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<form id="expense-form" class="space-y-4">
<div>
<label class="text-text-muted dark:text-[#92adc9] text-sm mb-2 block" data-translate="form.amount">Amount</label>
<input type="number" step="0.01" name="amount" required class="w-full bg-slate-50 dark:bg-[#111a22] border border-border-light dark:border-[#233648] rounded-lg px-4 py-2 text-text-main dark:text-white focus:border-primary focus:ring-1 focus:ring-primary">
</div>
<div>
<label class="text-text-muted dark:text-[#92adc9] text-sm mb-2 block" data-translate="form.description">Description</label>
<input type="text" name="description" required class="w-full bg-slate-50 dark:bg-[#111a22] border border-border-light dark:border-[#233648] rounded-lg px-4 py-2 text-text-main dark:text-white focus:border-primary focus:ring-1 focus:ring-primary">
</div>
<div>
<label class="text-text-muted dark:text-[#92adc9] text-sm mb-2 block" data-translate="form.category">Category</label>
<select name="category_id" required class="w-full bg-slate-50 dark:bg-[#111a22] border border-border-light dark:border-[#233648] rounded-lg px-4 py-2 text-text-main dark:text-white focus:border-primary focus:ring-1 focus:ring-primary">
<option value="">Select category...</option>
</select>
</div>
<div>
<label class="text-text-muted dark:text-[#92adc9] text-sm mb-2 block" data-translate="form.date">Date</label>
<input type="date" name="date" required class="w-full bg-slate-50 dark:bg-[#111a22] border border-border-light dark:border-[#233648] rounded-lg px-4 py-2 text-text-main dark:text-white focus:border-primary focus:ring-1 focus:ring-primary">
</div>
<div>
<label class="text-text-muted dark:text-[#92adc9] text-sm mb-2 block" data-translate="form.tags">Tags (comma separated)</label>
<input type="text" name="tags" class="w-full bg-slate-50 dark:bg-[#111a22] border border-border-light dark:border-[#233648] rounded-lg px-4 py-2 text-text-main dark:text-white focus:border-primary focus:ring-1 focus:ring-primary">
</div>
<div>
<label class="text-text-muted dark:text-[#92adc9] text-sm mb-2 block" data-translate="form.receipt">Receipt (optional)</label>
<input type="file" name="receipt" accept="image/*,.pdf" class="w-full bg-slate-50 dark:bg-[#111a22] border border-border-light dark:border-[#233648] rounded-lg px-4 py-2 text-text-main dark:text-white focus:border-primary focus:ring-1 focus:ring-primary file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-primary file:text-white hover:file:bg-primary/90">
</div>
<button type="submit" class="w-full bg-primary hover:bg-primary/90 text-white py-3 rounded-lg font-semibold transition-colors shadow-md" data-translate="actions.save">Save Expense</button>
</form>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
{% endblock %}

View file

@ -0,0 +1,127 @@
{% extends "base.html" %}
{% block title %}Documents - FINA{% endblock %}
{% block body %}
<div class="flex h-screen w-full">
<!-- Sidebar -->
<aside id="sidebar" class="hidden lg:flex w-64 flex-col bg-sidebar-light dark:bg-background-dark border-r border-border-light dark:border-[#233648]">
<div class="p-6 flex flex-col h-full justify-between">
<div class="flex flex-col gap-8">
<div class="flex gap-3 items-center">
<img src="{{ current_user.avatar | avatar_url }}" alt="{{ current_user.username }}" class="size-10 rounded-full border-2 border-primary/30 object-cover">
<div class="flex flex-col">
<h1 class="text-text-main dark:text-white text-base font-bold leading-none">{{ current_user.username }}</h1>
<p class="text-text-muted dark:text-[#92adc9] text-xs font-normal mt-1">
{% if current_user.is_admin %}<span data-translate="user.admin">Admin</span>{% else %}<span data-translate="user.user">User</span>{% endif %}
</p>
</div>
</div>
<nav class="flex flex-col gap-2">
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.dashboard') }}">
<span class="material-symbols-outlined text-[20px]">dashboard</span>
<span class="text-sm font-medium" data-translate="nav.dashboard">Dashboard</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.transactions') }}">
<span class="material-symbols-outlined text-[20px]">receipt_long</span>
<span class="text-sm font-medium" data-translate="nav.transactions">Transactions</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.reports') }}">
<span class="material-symbols-outlined text-[20px]">pie_chart</span>
<span class="text-sm font-medium" data-translate="nav.reports">Reports</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-primary/20 text-primary border border-primary/10" href="{{ url_for('main.documents') }}">
<span class="material-symbols-outlined text-[20px]">folder_open</span>
<span class="text-sm font-medium" data-translate="nav.documents">Documents</span>
</a>
</nav>
</div>
<div class="flex flex-col gap-2">
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.settings') }}">
<span class="material-symbols-outlined text-[20px]">settings</span>
<span class="text-sm font-medium" data-translate="nav.settings">Settings</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('auth.logout') }}">
<span class="material-symbols-outlined text-[20px]">logout</span>
<span class="text-sm font-medium" data-translate="nav.logout">Log out</span>
</a>
</div>
</div>
</aside>
<main class="flex-1 flex flex-col h-full overflow-hidden relative bg-background-light dark:bg-background-dark">
<header class="h-16 flex items-center justify-between px-6 lg:px-8 border-b border-border-light dark:border-[#233648] bg-card-light/95 dark:bg-background-dark/80 backdrop-blur z-10 shrink-0">
<div class="flex items-center gap-4">
<button id="menu-toggle" class="lg:hidden text-text-main dark:text-white">
<span class="material-symbols-outlined">menu</span>
</button>
<h2 class="text-text-main dark:text-white text-lg font-bold" data-translate="documents.title">Documents</h2>
</div>
</header>
<div class="flex-1 overflow-y-auto p-6 lg:p-8 scroll-smooth">
<div class="max-w-7xl mx-auto flex flex-col gap-8 pb-10">
<!-- Upload Section -->
<div class="flex flex-col gap-4">
<h3 class="text-base font-semibold text-text-main dark:text-white" data-translate="documents.uploadTitle">Upload Documents</h3>
<div id="upload-area" class="bg-card-light dark:bg-card-dark border-2 border-dashed border-border-light dark:border-[#233648] rounded-xl p-10 flex flex-col items-center justify-center text-center hover:border-primary/50 hover:bg-slate-50 dark:hover:bg-white/[0.02] transition-all cursor-pointer group relative overflow-hidden">
<input id="file-input" type="file" class="absolute inset-0 opacity-0 cursor-pointer z-10" accept=".pdf,.csv,.xlsx,.xls,.png,.jpg,.jpeg" multiple />
<div class="bg-primary/10 p-4 rounded-full text-primary mb-4 group-hover:scale-110 transition-transform duration-300">
<span class="material-symbols-outlined text-[32px]">cloud_upload</span>
</div>
<h3 class="text-lg font-semibold text-text-main dark:text-white mb-1" data-translate="documents.dragDrop">Drag & drop files here or click to browse</h3>
<p class="text-text-muted dark:text-[#92adc9] text-sm max-w-sm leading-relaxed">
<span data-translate="documents.uploadDesc">Upload bank statements, invoices, or receipts.</span><br/>
<span class="text-xs text-text-muted/70 dark:text-[#92adc9]/70" data-translate="documents.supportedFormats">Supported formats: CSV, PDF, XLS, XLSX, PNG, JPG (Max 10MB)</span>
</p>
</div>
</div>
<!-- Documents List -->
<div class="flex flex-col gap-4">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<h3 class="text-base font-semibold text-text-main dark:text-white" data-translate="documents.yourFiles">Your Files</h3>
<div class="flex flex-col sm:flex-row gap-3 w-full md:w-auto">
<div class="relative flex-1 min-w-[240px]">
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-text-muted dark:text-[#92adc9] text-[20px]">search</span>
<input id="search-input" type="text" class="w-full bg-card-light dark:bg-card-dark border border-border-light dark:border-[#233648] rounded-lg py-2 pl-10 pr-4 text-sm text-text-main dark:text-white focus:ring-1 focus:ring-primary focus:border-primary outline-none transition-all placeholder:text-text-muted/50 dark:placeholder:text-[#92adc9]/50" placeholder="Search by name..." data-translate="documents.searchPlaceholder" />
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-[#233648] rounded-xl overflow-hidden shadow-sm">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="text-xs text-text-muted dark:text-[#92adc9] uppercase bg-slate-50 dark:bg-white/5 border-b border-border-light dark:border-[#233648]">
<tr>
<th class="px-6 py-4 font-medium" data-translate="documents.tableDocName">Document Name</th>
<th class="px-6 py-4 font-medium" data-translate="documents.tableUploadDate">Upload Date</th>
<th class="px-6 py-4 font-medium" data-translate="documents.tableType">Type</th>
<th class="px-6 py-4 font-medium" data-translate="documents.tableStatus">Status</th>
<th class="px-6 py-4 font-medium text-right" data-translate="documents.tableActions">Actions</th>
</tr>
</thead>
<tbody id="documents-list" class="divide-y divide-border-light dark:divide-[#233648]">
<!-- Documents will be loaded here -->
</tbody>
</table>
</div>
<div class="bg-slate-50 dark:bg-white/5 border-t border-border-light dark:border-[#233648] p-4 flex items-center justify-between">
<span class="text-sm text-text-muted dark:text-[#92adc9]">
<span data-translate="documents.showing">Showing</span> <span id="page-start" class="text-text-main dark:text-white font-medium">1</span>-<span id="page-end" class="text-text-main dark:text-white font-medium">5</span> <span data-translate="documents.of">of</span> <span id="total-count" class="text-text-main dark:text-white font-medium">0</span> <span data-translate="documents.documents">documents</span>
</span>
<div id="pagination" class="flex gap-2">
<!-- Pagination buttons will be added here -->
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="{{ url_for('static', filename='js/documents.js') }}"></script>
{% endblock %}

View file

@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FINA - Personal Finance Manager</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<style>
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px;
display: inline-block;
}
</style>
<script>
tailwind.config = {
darkMode: 'class'
}
</script>
</head>
<body class="bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 min-h-screen text-white">
<!-- Navigation -->
<nav class="bg-slate-800/50 backdrop-blur-sm border-b border-slate-700/50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<span class="text-2xl font-bold text-blue-400">FINA</span>
</div>
<div class="flex items-center space-x-4">
<a href="/auth/login" class="text-slate-300 hover:text-blue-400 transition">Login</a>
<a href="/auth/register" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition">Get Started</a>
</div>
</div>
</div>
</nav>
<!-- Hero Section -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
<div class="text-center">
<h1 class="text-5xl font-bold text-white mb-6">
Take Control of Your Finances
</h1>
<p class="text-xl text-slate-300 mb-8 max-w-2xl mx-auto">
FINA helps you track expenses, manage budgets, and achieve your financial goals with ease.
</p>
<div class="flex justify-center space-x-4">
<a href="/auth/register" class="bg-blue-600 text-white px-8 py-3 rounded-lg text-lg font-semibold hover:bg-blue-700 transition shadow-lg shadow-blue-500/50">
Start Free
</a>
<a href="/auth/login" class="bg-slate-700 text-white px-8 py-3 rounded-lg text-lg font-semibold border-2 border-slate-600 hover:bg-slate-600 transition">
Sign In
</a>
</div>
</div>
<!-- Features -->
<div class="grid md:grid-cols-3 gap-8 mt-20">
<div class="bg-slate-800/50 backdrop-blur-sm p-8 rounded-xl border border-slate-700/50 hover:border-blue-500/50 transition">
<span class="material-icons text-blue-400 text-5xl mb-4">account_balance_wallet</span>
<h3 class="text-2xl font-bold mb-3 text-white">Track Expenses</h3>
<p class="text-slate-300">Monitor your spending habits and categorize expenses effortlessly.</p>
</div>
<div class="bg-slate-800/50 backdrop-blur-sm p-8 rounded-xl border border-slate-700/50 hover:border-blue-500/50 transition">
<span class="material-icons text-blue-400 text-5xl mb-4">insights</span>
<h3 class="text-2xl font-bold mb-3 text-white">Visual Reports</h3>
<p class="text-slate-300">Get insights with beautiful charts and detailed financial reports.</p>
</div>
<div class="bg-slate-800/50 backdrop-blur-sm p-8 rounded-xl border border-slate-700/50 hover:border-blue-500/50 transition">
<span class="material-icons text-blue-400 text-5xl mb-4">description</span>
<h3 class="text-2xl font-bold mb-3 text-white">Document Management</h3>
<p class="text-slate-300">Store and organize receipts and financial documents securely.</p>
</div>
</div>
<!-- Additional Features -->
<div class="mt-16 bg-slate-800/50 backdrop-blur-sm rounded-xl border border-slate-700/50 p-8">
<h2 class="text-3xl font-bold text-center mb-8 text-white">Why Choose FINA?</h2>
<div class="grid md:grid-cols-2 gap-6">
<div class="flex items-start space-x-3">
<span class="material-icons text-green-400">check_circle</span>
<div>
<h4 class="font-semibold text-white">Secure & Private</h4>
<p class="text-slate-300">Your financial data is encrypted and protected with 2FA.</p>
</div>
</div>
<div class="flex items-start space-x-3">
<span class="material-icons text-green-400">check_circle</span>
<div>
<h4 class="font-semibold text-white">Easy to Use</h4>
<p class="text-slate-300">Intuitive interface designed for everyone.</p>
</div>
</div>
<div class="flex items-start space-x-3">
<span class="material-icons text-green-400">check_circle</span>
<div>
<h4 class="font-semibold text-white">Mobile Ready</h4>
<p class="text-slate-300">Access your finances from any device, anywhere.</p>
</div>
</div>
<div class="flex items-start space-x-3">
<span class="material-icons text-green-400">check_circle</span>
<div>
<h4 class="font-semibold text-white">Free to Use</h4>
<p class="text-slate-300">No hidden fees, completely free personal finance management.</p>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<footer class="bg-slate-800/50 backdrop-blur-sm mt-20 py-8 border-t border-slate-700/50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-slate-400">
<p>&copy; 2025 FINA. All rights reserved.</p>
</div>
</footer>
</body>
</html>

View file

@ -0,0 +1,214 @@
{% extends "base.html" %}
{% block title %}Reports - FINA{% endblock %}
{% block extra_css %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
{% endblock %}
{% block body %}
<div class="flex h-screen w-full">
<!-- Sidebar -->
<aside id="sidebar" class="hidden lg:flex w-64 flex-col bg-sidebar-light dark:bg-background-dark border-r border-border-light dark:border-[#233648]">
<div class="p-6 flex flex-col h-full justify-between">
<div class="flex flex-col gap-8">
<div class="flex gap-3 items-center">
<img src="{{ current_user.avatar | avatar_url }}" alt="{{ current_user.username }}" class="size-10 rounded-full border-2 border-primary/30 object-cover">
<div class="flex flex-col">
<h1 class="text-text-main dark:text-white text-base font-bold leading-none">{{ current_user.username }}</h1>
<p class="text-text-muted dark:text-[#92adc9] text-xs font-normal mt-1">
{% if current_user.is_admin %}<span data-translate="user.admin">Admin</span>{% else %}<span data-translate="user.user">User</span>{% endif %}
</p>
</div>
</div>
<nav class="flex flex-col gap-2">
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.dashboard') }}">
<span class="material-symbols-outlined text-[20px]">dashboard</span>
<span class="text-sm font-medium" data-translate="nav.dashboard">Dashboard</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.transactions') }}">
<span class="material-symbols-outlined text-[20px]">receipt_long</span>
<span class="text-sm font-medium" data-translate="nav.transactions">Transactions</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-primary/20 text-primary border border-primary/10" href="{{ url_for('main.reports') }}">
<span class="material-symbols-outlined text-[20px]">pie_chart</span>
<span class="text-sm font-medium" data-translate="nav.reports">Reports</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.documents') }}">
<span class="material-symbols-outlined text-[20px]">folder_open</span>
<span class="text-sm font-medium" data-translate="nav.documents">Documents</span>
</a>
</nav>
</div>
<div class="flex flex-col gap-2">
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.settings') }}">
<span class="material-symbols-outlined text-[20px]">settings</span>
<span class="text-sm font-medium" data-translate="nav.settings">Settings</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('auth.logout') }}">
<span class="material-symbols-outlined text-[20px]">logout</span>
<span class="text-sm font-medium" data-translate="nav.logout">Log out</span>
</a>
</div>
</div>
</aside>
<main class="flex-1 flex flex-col h-full overflow-hidden relative bg-background-light dark:bg-background-dark">
<header class="h-16 flex items-center justify-between px-6 lg:px-8 border-b border-border-light dark:border-[#233648] bg-card-light/95 dark:bg-background-dark/80 backdrop-blur z-10 shrink-0">
<div class="flex items-center gap-4">
<button id="menu-toggle" class="lg:hidden text-text-main dark:text-white">
<span class="material-symbols-outlined">menu</span>
</button>
<h2 class="text-text-main dark:text-white text-lg font-bold" data-translate="reports.title">Financial Reports</h2>
</div>
<div class="flex items-center gap-3">
<button id="export-report-btn" class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white hover:bg-background-light dark:hover:bg-white/5 rounded-lg border border-transparent hover:border-border-light dark:hover:border-[#233648] transition-all">
<span class="material-symbols-outlined text-[18px]">download</span>
<span class="hidden sm:inline" data-translate="reports.export">Export CSV</span>
</button>
</div>
</header>
<div class="flex-1 overflow-y-auto p-6 lg:p-8 scroll-smooth">
<div class="max-w-7xl mx-auto flex flex-col gap-6 pb-10">
<!-- Period Selection -->
<div class="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 bg-card-light dark:bg-card-dark p-4 rounded-xl border border-border-light dark:border-[#233648] shadow-sm">
<div class="flex items-center gap-3">
<h3 class="text-sm font-semibold text-text-muted dark:text-[#92adc9] uppercase tracking-wider" data-translate="reports.analysisPeriod">Analysis Period:</h3>
<div class="flex bg-background-light dark:bg-background-dark rounded-lg p-1 border border-border-light dark:border-[#233648]">
<button class="period-btn active px-3 py-1 text-sm font-medium rounded transition-colors" data-period="30">
<span data-translate="reports.last30Days">Last 30 Days</span>
</button>
<button class="period-btn px-3 py-1 text-sm font-medium rounded transition-colors" data-period="90">
<span data-translate="reports.quarter">Quarter</span>
</button>
<button class="period-btn px-3 py-1 text-sm font-medium rounded transition-colors" data-period="365">
<span data-translate="reports.ytd">YTD</span>
</button>
</div>
</div>
<div class="flex flex-wrap items-center gap-3 w-full lg:w-auto">
<select id="category-filter" class="px-3 py-2 bg-background-light dark:bg-background-dark border border-border-light dark:border-[#233648] rounded-lg text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white hover:border-primary/50 transition-colors text-sm w-full lg:w-48">
<option value=""><span data-translate="reports.allCategories">All Categories</span></option>
</select>
<button id="generate-report-btn" class="flex-1 sm:flex-none bg-primary hover:bg-blue-600 text-white h-10 px-4 rounded-lg text-sm font-semibold shadow-lg shadow-primary/20 transition-all flex items-center justify-center gap-2">
<span class="material-symbols-outlined text-[18px]">autorenew</span>
<span data-translate="reports.generate">Generate Report</span>
</button>
</div>
</div>
<!-- KPI Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div class="bg-card-light dark:bg-card-dark p-5 rounded-xl border border-border-light dark:border-[#233648] shadow-sm hover:border-primary/30 transition-colors group">
<div class="flex justify-between items-start mb-4">
<div class="flex flex-col">
<span class="text-text-muted dark:text-[#92adc9] text-xs font-medium uppercase tracking-wider" data-translate="reports.totalSpent">Total Spent</span>
<h4 id="total-spent" class="text-2xl font-bold text-text-main dark:text-white mt-1">$0.00</h4>
</div>
<div class="p-2 bg-primary/10 rounded-lg text-primary group-hover:bg-primary group-hover:text-white transition-colors">
<span class="material-symbols-outlined text-[20px]">payments</span>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<span id="spent-change" class="flex items-center font-medium px-1.5 py-0.5 rounded"></span>
<span class="text-text-muted dark:text-[#92adc9]" data-translate="reports.vsLastMonth">vs last period</span>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-5 rounded-xl border border-border-light dark:border-[#233648] shadow-sm hover:border-accent/30 transition-colors group">
<div class="flex justify-between items-start mb-4">
<div class="flex flex-col">
<span class="text-text-muted dark:text-[#92adc9] text-xs font-medium uppercase tracking-wider" data-translate="reports.topCategory">Top Category</span>
<h4 id="top-category" class="text-2xl font-bold text-text-main dark:text-white mt-1">None</h4>
</div>
<div class="p-2 bg-accent/10 rounded-lg text-accent group-hover:bg-accent group-hover:text-white transition-colors">
<span class="material-symbols-outlined text-[20px]">category</span>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<span id="top-category-amount" class="text-text-main dark:text-white font-semibold">$0</span>
<span class="text-text-muted dark:text-[#92adc9]" data-translate="reports.spentThisPeriod">spent this period</span>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-5 rounded-xl border border-border-light dark:border-[#233648] shadow-sm hover:border-warning/30 transition-colors group">
<div class="flex justify-between items-start mb-4">
<div class="flex flex-col">
<span class="text-text-muted dark:text-[#92adc9] text-xs font-medium uppercase tracking-wider" data-translate="reports.avgDaily">Avg. Daily</span>
<h4 id="avg-daily" class="text-2xl font-bold text-text-main dark:text-white mt-1">$0.00</h4>
</div>
<div class="p-2 bg-warning/10 rounded-lg text-warning group-hover:bg-warning group-hover:text-white transition-colors">
<span class="material-symbols-outlined text-[20px]">calendar_today</span>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<span id="avg-change" class="flex items-center font-medium px-1.5 py-0.5 rounded"></span>
<span class="text-text-muted dark:text-[#92adc9]" data-translate="reports.vsLastMonth">vs last period</span>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-5 rounded-xl border border-border-light dark:border-[#233648] shadow-sm hover:border-success/30 transition-colors group">
<div class="flex justify-between items-start mb-4">
<div class="flex flex-col">
<span class="text-text-muted dark:text-[#92adc9] text-xs font-medium uppercase tracking-wider" data-translate="reports.savingsRate">Savings Rate</span>
<h4 id="savings-rate" class="text-2xl font-bold text-text-main dark:text-white mt-1">0%</h4>
</div>
<div class="p-2 bg-success/10 rounded-lg text-success group-hover:bg-success group-hover:text-white transition-colors">
<span class="material-symbols-outlined text-[20px]">savings</span>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<span class="text-success flex items-center font-medium bg-success/10 px-1.5 py-0.5 rounded">
<span class="material-symbols-outlined text-[14px] mr-0.5">arrow_upward</span>
<span data-translate="reports.placeholder">Placeholder</span>
</span>
<span class="text-text-muted dark:text-[#92adc9]" data-translate="reports.vsLastMonth">vs last period</span>
</div>
</div>
</div>
<!-- Charts Row -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Spending Trend Chart -->
<div class="lg:col-span-2 bg-card-light dark:bg-card-dark p-6 rounded-xl border border-border-light dark:border-[#233648] shadow-sm flex flex-col">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-bold text-text-main dark:text-white" data-translate="reports.spendingTrend">Spending Trend</h3>
</div>
<div class="flex-1 min-h-[300px]">
<canvas id="trend-chart"></canvas>
</div>
</div>
<!-- Category Breakdown -->
<div class="lg:col-span-1 bg-card-light dark:bg-card-dark p-6 rounded-xl border border-border-light dark:border-[#233648] shadow-sm flex flex-col">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-bold text-text-main dark:text-white" data-translate="reports.categoryBreakdown">Category Breakdown</h3>
</div>
<div class="flex-1 flex items-center justify-center">
<canvas id="category-pie-chart" class="max-h-[300px]"></canvas>
</div>
<div id="category-legend" class="mt-6 grid grid-cols-2 gap-3 text-sm">
<!-- Legend items will be populated by JS -->
</div>
</div>
</div>
<!-- Monthly Comparison -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-xl border border-border-light dark:border-[#233648] shadow-sm">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-bold text-text-main dark:text-white" data-translate="reports.monthlySpending">Monthly Spending</h3>
</div>
<div class="h-64">
<canvas id="monthly-chart"></canvas>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="{{ url_for('static', filename='js/reports.js') }}"></script>
{% endblock %}

View file

@ -0,0 +1,223 @@
{% extends "base.html" %}
{% block title %}Settings - FINA{% endblock %}
{% block body %}
<div class="flex h-screen w-full">
<!-- Sidebar -->
<aside id="sidebar" class="hidden lg:flex w-64 flex-col bg-sidebar-light dark:bg-background-dark border-r border-border-light dark:border-[#233648]">
<div class="p-6 flex flex-col h-full justify-between">
<div class="flex flex-col gap-8">
<div class="flex gap-3 items-center">
<img id="sidebar-avatar" src="{{ current_user.avatar | avatar_url }}" alt="{{ current_user.username }}" class="size-10 rounded-full border-2 border-primary/30 object-cover">
<div class="flex flex-col">
<h1 class="text-text-main dark:text-white text-base font-bold leading-none">{{ current_user.username }}</h1>
<p class="text-text-muted dark:text-[#92adc9] text-xs font-normal mt-1">
{% if current_user.is_admin %}<span data-translate="user.admin">Admin</span>{% else %}<span data-translate="user.user">User</span>{% endif %}
</p>
</div>
</div>
<nav class="flex flex-col gap-2">
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.dashboard') }}">
<span class="material-symbols-outlined text-[20px]">dashboard</span>
<span class="text-sm font-medium" data-translate="nav.dashboard">Dashboard</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.transactions') }}">
<span class="material-symbols-outlined text-[20px]">receipt_long</span>
<span class="text-sm font-medium" data-translate="nav.transactions">Transactions</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.reports') }}">
<span class="material-symbols-outlined text-[20px]">pie_chart</span>
<span class="text-sm font-medium" data-translate="nav.reports">Reports</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.documents') }}">
<span class="material-symbols-outlined text-[20px]">folder_open</span>
<span class="text-sm font-medium" data-translate="nav.documents">Documents</span>
</a>
</nav>
</div>
<div class="flex flex-col gap-2">
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-primary/20 text-primary border border-primary/10" href="{{ url_for('main.settings') }}">
<span class="material-symbols-outlined text-[20px]">settings</span>
<span class="text-sm font-medium" data-translate="nav.settings">Settings</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('auth.logout') }}">
<span class="material-symbols-outlined text-[20px]">logout</span>
<span class="text-sm font-medium" data-translate="nav.logout">Log out</span>
</a>
</div>
</div>
</aside>
<main class="flex-1 flex flex-col h-full overflow-hidden relative bg-background-light dark:bg-background-dark">
<header class="h-16 flex items-center justify-between px-6 lg:px-8 border-b border-border-light dark:border-[#233648] bg-card-light/95 dark:bg-background-dark/80 backdrop-blur z-10 shrink-0">
<div class="flex items-center gap-4">
<button id="menu-toggle" class="lg:hidden text-text-main dark:text-white">
<span class="material-symbols-outlined">menu</span>
</button>
<h2 class="text-text-main dark:text-white text-lg font-bold" data-translate="settings.title">Settings</h2>
</div>
</header>
<div class="flex-1 overflow-y-auto p-6 lg:p-8 scroll-smooth">
<div class="max-w-4xl mx-auto flex flex-col gap-6 pb-10">
<!-- Avatar Section -->
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-[#233648] rounded-xl p-6 shadow-sm">
<h3 class="text-lg font-semibold text-text-main dark:text-white mb-4" data-translate="settings.avatar">Profile Avatar</h3>
<div class="flex flex-col md:flex-row gap-6">
<div class="flex flex-col items-center gap-4">
<img id="current-avatar" src="{{ current_user.avatar | avatar_url }}" alt="Current Avatar" class="size-24 rounded-full border-4 border-primary/20 object-cover shadow-md">
<input type="file" id="avatar-upload" class="hidden" accept="image/png,image/jpeg,image/jpg,image/gif,image/webp">
<button id="upload-avatar-btn" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors flex items-center gap-2">
<span class="material-symbols-outlined text-[18px]">upload</span>
<span data-translate="settings.uploadAvatar">Upload Custom</span>
</button>
<p class="text-xs text-text-muted dark:text-[#92adc9] text-center max-w-[200px]" data-translate="settings.avatarDesc">PNG, JPG, GIF, WEBP. Max 20MB</p>
</div>
<div class="flex-1">
<p class="text-sm text-text-muted dark:text-[#92adc9] mb-3" data-translate="settings.defaultAvatars">Or choose a default avatar:</p>
<div class="grid grid-cols-3 sm:grid-cols-6 gap-3">
<button class="default-avatar-btn p-2 rounded-lg border-2 border-transparent hover:border-primary transition-all" data-avatar="icons/avatars/avatar-1.svg">
<img src="{{ url_for('static', filename='icons/avatars/avatar-1.svg') }}" alt="Avatar 1" class="w-full h-full rounded-full">
</button>
<button class="default-avatar-btn p-2 rounded-lg border-2 border-transparent hover:border-primary transition-all" data-avatar="icons/avatars/avatar-2.svg">
<img src="{{ url_for('static', filename='icons/avatars/avatar-2.svg') }}" alt="Avatar 2" class="w-full h-full rounded-full">
</button>
<button class="default-avatar-btn p-2 rounded-lg border-2 border-transparent hover:border-primary transition-all" data-avatar="icons/avatars/avatar-3.svg">
<img src="{{ url_for('static', filename='icons/avatars/avatar-3.svg') }}" alt="Avatar 3" class="w-full h-full rounded-full">
</button>
<button class="default-avatar-btn p-2 rounded-lg border-2 border-transparent hover:border-primary transition-all" data-avatar="icons/avatars/avatar-4.svg">
<img src="{{ url_for('static', filename='icons/avatars/avatar-4.svg') }}" alt="Avatar 4" class="w-full h-full rounded-full">
</button>
<button class="default-avatar-btn p-2 rounded-lg border-2 border-transparent hover:border-primary transition-all" data-avatar="icons/avatars/avatar-5.svg">
<img src="{{ url_for('static', filename='icons/avatars/avatar-5.svg') }}" alt="Avatar 5" class="w-full h-full rounded-full">
</button>
<button class="default-avatar-btn p-2 rounded-lg border-2 border-transparent hover:border-primary transition-all" data-avatar="icons/avatars/avatar-6.svg">
<img src="{{ url_for('static', filename='icons/avatars/avatar-6.svg') }}" alt="Avatar 6" class="w-full h-full rounded-full">
</button>
</div>
</div>
</div>
</div>
<!-- Profile Settings -->
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-[#233648] rounded-xl p-6 shadow-sm">
<h3 class="text-lg font-semibold text-text-main dark:text-white mb-4" data-translate="settings.profile">Profile Information</h3>
<div class="flex flex-col gap-4">
<div>
<label class="block text-sm font-medium text-text-main dark:text-white mb-2" data-translate="form.username">Username</label>
<input type="text" id="username" value="{{ current_user.username }}" class="w-full bg-background-light dark:bg-background-dark border border-border-light dark:border-[#233648] rounded-lg px-4 py-2.5 text-text-main dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all">
</div>
<div>
<label class="block text-sm font-medium text-text-main dark:text-white mb-2" data-translate="form.email">Email</label>
<input type="email" id="email" value="{{ current_user.email }}" class="w-full bg-background-light dark:bg-background-dark border border-border-light dark:border-[#233648] rounded-lg px-4 py-2.5 text-text-main dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all">
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-text-main dark:text-white mb-2" data-translate="form.language">Language</label>
<select id="language" class="w-full bg-background-light dark:bg-background-dark border border-border-light dark:border-[#233648] rounded-lg px-4 py-2.5 text-text-main dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all">
<option value="en" {% if current_user.language == 'en' %}selected{% endif %}>English</option>
<option value="ro" {% if current_user.language == 'ro' %}selected{% endif %}>Română</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-text-main dark:text-white mb-2" data-translate="form.currency">Currency</label>
<select id="currency" class="w-full bg-background-light dark:bg-background-dark border border-border-light dark:border-[#233648] rounded-lg px-4 py-2.5 text-text-main dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all">
<option value="USD" {% if current_user.currency == 'USD' %}selected{% endif %}>USD ($)</option>
<option value="EUR" {% if current_user.currency == 'EUR' %}selected{% endif %}>EUR (€)</option>
<option value="RON" {% if current_user.currency == 'RON' %}selected{% endif %}>RON (lei)</option>
<option value="GBP" {% if current_user.currency == 'GBP' %}selected{% endif %}>GBP (£)</option>
</select>
</div>
</div>
<button id="save-profile-btn" class="w-full md:w-auto px-6 py-2.5 bg-primary text-white rounded-lg font-medium hover:bg-primary/90 transition-colors flex items-center justify-center gap-2">
<span class="material-symbols-outlined text-[18px]">save</span>
<span data-translate="settings.saveProfile">Save Profile</span>
</button>
</div>
</div>
<!-- Password Change -->
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-[#233648] rounded-xl p-6 shadow-sm">
<h3 class="text-lg font-semibold text-text-main dark:text-white mb-4" data-translate="settings.changePassword">Change Password</h3>
<div class="flex flex-col gap-4">
<div>
<label class="block text-sm font-medium text-text-main dark:text-white mb-2" data-translate="settings.currentPassword">Current Password</label>
<input type="password" id="current-password" class="w-full bg-background-light dark:bg-background-dark border border-border-light dark:border-[#233648] rounded-lg px-4 py-2.5 text-text-main dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all">
</div>
<div>
<label class="block text-sm font-medium text-text-main dark:text-white mb-2" data-translate="settings.newPassword">New Password</label>
<input type="password" id="new-password" class="w-full bg-background-light dark:bg-background-dark border border-border-light dark:border-[#233648] rounded-lg px-4 py-2.5 text-text-main dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all">
</div>
<div>
<label class="block text-sm font-medium text-text-main dark:text-white mb-2" data-translate="settings.confirmPassword">Confirm New Password</label>
<input type="password" id="confirm-password" class="w-full bg-background-light dark:bg-background-dark border border-border-light dark:border-[#233648] rounded-lg px-4 py-2.5 text-text-main dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all">
</div>
<button id="change-password-btn" class="w-full md:w-auto px-6 py-2.5 bg-primary text-white rounded-lg font-medium hover:bg-primary/90 transition-colors flex items-center justify-center gap-2">
<span class="material-symbols-outlined text-[18px]">lock_reset</span>
<span data-translate="settings.updatePassword">Update Password</span>
</button>
</div>
</div>
<!-- 2FA Settings -->
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-[#233648] rounded-xl p-6 shadow-sm">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-text-main dark:text-white mb-1" data-translate="settings.twoFactor">Two-Factor Authentication</h3>
<p class="text-sm text-text-muted dark:text-[#92adc9]">
{% if current_user.two_factor_enabled %}
<span data-translate="settings.twoFactorEnabled">2FA is currently enabled for your account</span>
{% else %}
<span data-translate="settings.twoFactorDisabled">Add an extra layer of security to your account</span>
{% endif %}
</p>
</div>
<span class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium {% if current_user.two_factor_enabled %}bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400{% else %}bg-slate-100 dark:bg-white/10 text-text-muted dark:text-[#92adc9]{% endif %}">
<span class="material-symbols-outlined text-[16px]">{% if current_user.two_factor_enabled %}verified_user{% else %}lock{% endif %}</span>
<span data-translate="{% if current_user.two_factor_enabled %}settings.enabled{% else %}settings.disabled{% endif %}">{% if current_user.two_factor_enabled %}Enabled{% else %}Disabled{% endif %}</span>
</span>
</div>
<div class="flex flex-col sm:flex-row gap-3">
{% if current_user.two_factor_enabled %}
<a href="{{ url_for('auth.setup_2fa') }}" class="inline-flex items-center justify-center gap-2 px-4 py-2 bg-background-light dark:bg-background-dark border border-border-light dark:border-[#233648] text-text-main dark:text-white rounded-lg text-sm font-medium hover:bg-slate-100 dark:hover:bg-white/5 transition-colors">
<span class="material-symbols-outlined text-[18px]">refresh</span>
<span data-translate="settings.regenerateCodes">Regenerate Backup Codes</span>
</a>
<form method="POST" action="{{ url_for('auth.disable_2fa') }}" class="inline-block">
<button type="submit" onclick="return confirm('Are you sure you want to disable 2FA?')" class="inline-flex items-center justify-center gap-2 px-4 py-2 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/30 text-red-600 dark:text-red-400 rounded-lg text-sm font-medium hover:bg-red-100 dark:hover:bg-red-500/20 transition-colors">
<span class="material-symbols-outlined text-[18px]">lock_open</span>
<span data-translate="settings.disable2FA">Disable 2FA</span>
</button>
</form>
{% else %}
<a href="{{ url_for('auth.setup_2fa') }}" class="inline-flex items-center justify-center gap-2 px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors">
<span class="material-symbols-outlined text-[18px]">lock</span>
<span data-translate="settings.enable2FA">Enable 2FA</span>
</a>
{% endif %}
</div>
</div>
</div>
</div>
</main>
</div>
<script src="{{ url_for('static', filename='js/settings.js') }}"></script>
{% endblock %}

View file

@ -0,0 +1,168 @@
{% extends "base.html" %}
{% block title %}Transactions - FINA{% endblock %}
{% block body %}
<div class="flex h-screen w-full">
<!-- Sidebar (reuse from dashboard) -->
<aside id="sidebar" class="hidden lg:flex w-64 flex-col bg-sidebar-light dark:bg-background-dark border-r border-border-light dark:border-[#233648]">
<div class="p-6 flex flex-col h-full justify-between">
<div class="flex flex-col gap-8">
<div class="flex gap-3 items-center">
<img src="{{ current_user.avatar | avatar_url }}" alt="{{ current_user.username }}" class="size-10 rounded-full border-2 border-primary/30 object-cover">
<div class="flex flex-col">
<h1 class="text-text-main dark:text-white text-base font-bold leading-none">{{ current_user.username }}</h1>
<p class="text-text-muted dark:text-[#92adc9] text-xs font-normal mt-1">
{% if current_user.is_admin %}Admin{% else %}User{% endif %}
</p>
</div>
</div>
<nav class="flex flex-col gap-2">
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.dashboard') }}">
<span class="material-symbols-outlined text-[20px]">dashboard</span>
<span class="text-sm font-medium">Dashboard</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-primary/20 text-primary border border-primary/10" href="{{ url_for('main.transactions') }}">
<span class="material-symbols-outlined text-[20px]">receipt_long</span>
<span class="text-sm font-medium">Transactions</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.reports') }}">
<span class="material-symbols-outlined text-[20px]">pie_chart</span>
<span class="text-sm font-medium">Reports</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.documents') }}">
<span class="material-symbols-outlined text-[20px]">folder_open</span>
<span class="text-sm font-medium">Documents</span>
</a>
</nav>
</div>
<div class="flex flex-col gap-2">
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.settings') }}">
<span class="material-symbols-outlined text-[20px]">settings</span>
<span class="text-sm font-medium">Settings</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted dark:text-[#92adc9] hover:bg-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('auth.logout') }}">
<span class="material-symbols-outlined text-[20px]">logout</span>
<span class="text-sm font-medium">Log out</span>
</a>
</div>
</div>
</aside>
<main class="flex-1 flex flex-col h-full overflow-hidden relative bg-background-light dark:bg-background-dark">
<header class="h-16 flex items-center justify-between px-6 lg:px-8 border-b border-border-light dark:border-[#233648] bg-card-light/95 dark:bg-background-dark/95 backdrop-blur z-10 shrink-0">
<div class="flex items-center gap-4">
<button id="menu-toggle" class="lg:hidden text-text-main dark:text-white">
<span class="material-symbols-outlined">menu</span>
</button>
<h2 class="text-text-main dark:text-white text-lg font-bold">Transactions</h2>
</div>
<div class="flex items-center gap-3">
<button id="export-csv-btn" class="bg-background-light dark:bg-[#1a2632] border border-border-light dark:border-[#233648] text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white h-9 px-4 rounded-lg text-sm font-medium transition-colors flex items-center gap-2">
<span class="material-symbols-outlined text-[18px]">download</span>
<span class="hidden sm:inline">Export CSV</span>
</button>
<button id="import-csv-btn" class="bg-background-light dark:bg-[#1a2632] border border-border-light dark:border-[#233648] text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white h-9 px-4 rounded-lg text-sm font-medium transition-colors flex items-center gap-2">
<span class="material-symbols-outlined text-[18px]">upload</span>
<span class="hidden sm:inline">Import CSV</span>
</button>
<button id="add-expense-btn" class="bg-primary hover:bg-primary/90 text-white h-9 px-4 rounded-lg text-sm font-semibold shadow-lg shadow-primary/20 transition-all flex items-center gap-2">
<span class="material-symbols-outlined text-[18px]">add</span>
<span class="hidden sm:inline">Add Expense</span>
</button>
</div>
</header>
<div class="flex-1 overflow-y-auto p-6 lg:p-8 bg-background-light dark:bg-background-dark">
<div class="max-w-7xl mx-auto">
<!-- Transactions Card -->
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-[#233648] rounded-xl overflow-hidden">
<!-- Header with Search and Filters -->
<div class="p-6 border-b border-border-light dark:border-[#233648]">
<div class="flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center">
<!-- Search Bar -->
<div class="relative flex-1 max-w-md">
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-text-muted dark:text-[#92adc9] text-[20px]">search</span>
<input
type="text"
id="filter-search"
placeholder="Search transactions..."
class="w-full bg-background-light dark:bg-[#111a22] border border-border-light dark:border-[#233648] rounded-lg pl-10 pr-4 py-2 text-text-main dark:text-white text-sm placeholder-text-muted dark:placeholder-[#5f7a96] focus:outline-none focus:ring-2 focus:ring-primary/50"
>
</div>
<!-- Filter Buttons -->
<div class="flex flex-wrap gap-2">
<button id="date-filter-btn" class="flex items-center gap-2 px-3 py-2 bg-background-light dark:bg-[#111a22] border border-border-light dark:border-[#233648] rounded-lg text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white hover:border-primary/50 transition-colors text-sm">
<span class="material-symbols-outlined text-[18px]">calendar_today</span>
<span>Date</span>
</button>
<select id="filter-category" class="px-3 py-2 bg-background-light dark:bg-[#111a22] border border-border-light dark:border-[#233648] rounded-lg text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white hover:border-primary/50 transition-colors text-sm">
<option value="">Category</option>
</select>
<button id="more-filters-btn" class="flex items-center gap-2 px-3 py-2 bg-background-light dark:bg-[#111a22] border border-border-light dark:border-[#233648] rounded-lg text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white hover:border-primary/50 transition-colors text-sm">
<span class="material-symbols-outlined text-[18px]">tune</span>
<span>Filters</span>
</button>
</div>
</div>
<!-- Advanced Filters (Hidden by default) -->
<div id="advanced-filters" class="hidden mt-4 pt-4 border-t border-border-light dark:border-[#233648]">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="text-text-muted dark:text-[#92adc9] text-sm mb-2 block">Start Date</label>
<input type="date" id="filter-start-date" class="w-full bg-background-light dark:bg-[#111a22] border border-border-light dark:border-[#233648] rounded-lg px-4 py-2 text-text-main dark:text-white text-sm">
</div>
<div>
<label class="text-text-muted dark:text-[#92adc9] text-sm mb-2 block">End Date</label>
<input type="date" id="filter-end-date" class="w-full bg-background-light dark:bg-[#111a22] border border-border-light dark:border-[#233648] rounded-lg px-4 py-2 text-text-main dark:text-white text-sm">
</div>
</div>
</div>
</div>
<!-- Transactions Table -->
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-background-light dark:bg-[#0f1419]">
<tr class="border-b border-border-light dark:border-[#233648]">
<th class="p-5 text-left text-text-muted dark:text-[#92adc9] text-sm font-medium">Transaction</th>
<th class="p-5 text-left text-text-muted dark:text-[#92adc9] text-sm font-medium">Category</th>
<th class="p-5 text-left text-text-muted dark:text-[#92adc9] text-sm font-medium">Date</th>
<th class="p-5 text-left text-text-muted dark:text-[#92adc9] text-sm font-medium">Payment</th>
<th class="p-5 text-right text-text-muted dark:text-[#92adc9] text-sm font-medium">Amount</th>
<th class="p-5 text-center text-text-muted dark:text-[#92adc9] text-sm font-medium">Status</th>
<th class="p-5 text-right text-text-muted dark:text-[#92adc9] text-sm font-medium">Actions</th>
</tr>
</thead>
<tbody id="transactions-list" class="divide-y divide-border-light dark:divide-[#233648]">
<!-- Transactions will be loaded here -->
</tbody>
</table>
</div>
<!-- Pagination Footer -->
<div class="p-4 border-t border-border-light dark:border-[#233648] flex flex-col sm:flex-row gap-4 justify-between items-center bg-card-light dark:bg-card-dark">
<span class="text-sm text-text-muted dark:text-[#92adc9]">
Showing <span id="page-start" class="text-text-main dark:text-white font-medium">1</span> to
<span id="page-end" class="text-text-main dark:text-white font-medium">10</span> of
<span id="total-count" class="text-text-main dark:text-white font-medium">0</span> results
</span>
<div id="pagination" class="flex gap-2">
<!-- Pagination buttons will be added here -->
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- Hidden file input for CSV import -->
<input type="file" id="csv-file-input" accept=".csv" class="hidden">
<script src="{{ url_for('static', filename='js/transactions.js') }}"></script>
{% endblock %}

View file

@ -0,0 +1,41 @@
from app import db
from app.models import Category
def create_default_categories(user_id):
"""Create default categories for a new user"""
default_categories = [
{'name': 'Food & Dining', 'color': '#ff6b6b', 'icon': 'restaurant'},
{'name': 'Transportation', 'color': '#4ecdc4', 'icon': 'directions_car'},
{'name': 'Shopping', 'color': '#95e1d3', 'icon': 'shopping_bag'},
{'name': 'Entertainment', 'color': '#f38181', 'icon': 'movie'},
{'name': 'Bills & Utilities', 'color': '#aa96da', 'icon': 'receipt'},
{'name': 'Healthcare', 'color': '#fcbad3', 'icon': 'medical_services'},
{'name': 'Education', 'color': '#a8d8ea', 'icon': 'school'},
{'name': 'Other', 'color': '#92adc9', 'icon': 'category'}
]
for cat_data in default_categories:
category = Category(
name=cat_data['name'],
color=cat_data['color'],
icon=cat_data['icon'],
user_id=user_id
)
db.session.add(category)
db.session.commit()
def format_currency(amount, currency='USD'):
"""Format amount with currency symbol"""
symbols = {
'USD': '$',
'EUR': '',
'GBP': '£',
'RON': 'lei'
}
symbol = symbols.get(currency, currency)
if currency == 'RON':
return f"{amount:,.2f} {symbol}"
return f"{symbol}{amount:,.2f}"

View file

@ -0,0 +1,37 @@
#version: '3.8'
services:
web:
build: .
container_name: fina
ports:
- "5103:5103"
volumes:
- ./data:/app/data:rw
- ./uploads:/app/uploads:rw
environment:
- FLASK_ENV=production
- SECRET_KEY=${SECRET_KEY:-change-this-secret-key-in-production}
- REDIS_URL=redis://redis:6379/0
- DATABASE_URL=sqlite:////app/data/fina.db
depends_on:
- redis
restart: unless-stopped
networks:
- fina-network
redis:
image: redis:7-alpine
container_name: fina-redis
restart: unless-stopped
networks:
- fina-network
volumes:
- redis-data:/data
volumes:
redis-data:
networks:
fina-network:
driver: bridge

View file

@ -0,0 +1,348 @@
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Expense Tracking Dashboard</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script id="tailwind-config">
tailwind.config = {
theme: {
extend: {
colors: {
"primary": "#2b8cee",
"background-light": "#f6f7f8",
"background-dark": "#111a22",
"card-dark": "#1a2632",
"card-light": "#ffffff",
"sidebar-light": "#ffffff",
"border-light": "#e2e8f0",
"text-main": "#0f172a",
"text-muted": "#64748b",
},
fontFamily: {
"display": ["Inter", "sans-serif"]
},
borderRadius: {"DEFAULT": "0.25rem", "lg": "0.5rem", "xl": "0.75rem", "2xl": "1rem", "full": "9999px"},
},
},
}
</script>
<style>::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
</style>
</head>
<body class="bg-background-light text-text-main font-display overflow-hidden">
<div class="flex h-screen w-full">
<aside class="hidden lg:flex w-64 flex-col bg-sidebar-light border-r border-border-light shadow-sm z-20">
<div class="p-6 flex flex-col h-full justify-between">
<div class="flex flex-col gap-8">
<div class="flex gap-3 items-center">
<div class="bg-center bg-no-repeat bg-cover rounded-full size-10 border border-border-light" data-alt="User profile picture showing a smiling professional" style='background-image: url("https://lh3.googleusercontent.com/aida-public/AB6AXuANUUrJ2rc1XWSNKLt215RxpZflCxewvvZq_ZylsIILmZIjcBUGlPtBIQNmsPHhZs6WjM660ExRzDkFkwHPzxwS6ta2zQ9lFfEMpKK9Ii7RTs6B-lCsDP94jjuZnAzZsrS-ZR_DLQjI16FAzAz_GvyrW9fSGpXcjzLFztbygeR64wagIlFwfYRd3RdlEG2GWH2aDXCrEO86UxzdHzBi13r2tqFH35vfLFwcg2kcbuLP4kkwWk_kese2hD4N0GgXuehsBv8AUzsQ6DU");'>
</div>
<div class="flex flex-col">
<h1 class="text-text-main text-base font-bold leading-none">Alex Morgan</h1>
<p class="text-text-muted text-xs font-normal mt-1">Free Plan</p>
</div>
</div>
<nav class="flex flex-col gap-2">
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-primary/10 text-primary border border-primary/10" href="#">
<span class="material-symbols-outlined text-[20px]" data-weight="fill">dashboard</span>
<span class="text-sm font-medium">Dashboard</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-slate-50 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">receipt_long</span>
<span class="text-sm font-medium">Transactions</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-slate-50 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">pie_chart</span>
<span class="text-sm font-medium">Reports</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-slate-50 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">folder_open</span>
<span class="text-sm font-medium">Documents</span>
</a>
</nav>
</div>
<div class="flex flex-col gap-2">
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-slate-50 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">settings</span>
<span class="text-sm font-medium">Settings</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-slate-50 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">logout</span>
<span class="text-sm font-medium">Log out</span>
</a>
</div>
</div>
</aside>
<main class="flex-1 flex flex-col h-full overflow-hidden relative bg-[#f8fafc]">
<header class="h-16 flex items-center justify-between px-6 lg:px-8 border-b border-border-light bg-white/80 backdrop-blur z-10 shrink-0 shadow-sm">
<div class="flex items-center gap-4">
<button class="lg:hidden text-text-main">
<span class="material-symbols-outlined">menu</span>
</button>
<h2 class="text-text-main text-lg font-bold">Dashboard</h2>
</div>
<div class="flex items-center gap-6">
<div class="hidden md:flex items-center bg-slate-50 rounded-lg h-10 px-3 border border-border-light focus-within:border-primary transition-colors w-64">
<span class="material-symbols-outlined text-text-muted text-[20px]">search</span>
<input class="bg-transparent border-none text-text-main text-sm placeholder-slate-400 focus:ring-0 w-full ml-2" placeholder="Search expenses..." type="text"/>
</div>
<div class="flex items-center gap-3">
<button class="bg-primary hover:bg-primary/90 text-white h-9 px-4 rounded-lg text-sm font-semibold shadow-md shadow-primary/20 transition-all flex items-center gap-2">
<span class="material-symbols-outlined text-[18px]">add</span>
<span class="hidden sm:inline">Add Expense</span>
</button>
<button class="size-9 rounded-lg bg-white border border-border-light flex items-center justify-center text-text-muted hover:text-text-main hover:bg-slate-50 transition-colors relative shadow-sm">
<span class="material-symbols-outlined text-[20px]">notifications</span>
<span class="absolute top-2 right-2.5 size-1.5 bg-red-500 rounded-full border border-white"></span>
</button>
</div>
</div>
</header>
<div class="flex-1 overflow-y-auto p-6 lg:p-8 scroll-smooth">
<div class="max-w-7xl mx-auto flex flex-col gap-8 pb-10">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 lg:gap-6">
<div class="p-6 rounded-xl bg-white border border-border-light flex flex-col justify-between relative overflow-hidden group shadow-sm">
<div class="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<span class="material-symbols-outlined text-6xl text-primary">payments</span>
</div>
<div>
<p class="text-text-muted text-sm font-medium">Total Spent</p>
<h3 class="text-text-main text-3xl font-bold mt-2 tracking-tight">$2,450.00</h3>
</div>
<div class="flex items-center gap-2 mt-4">
<span class="bg-green-500/10 text-green-600 text-xs font-semibold px-2 py-1 rounded-full flex items-center gap-1">
<span class="material-symbols-outlined text-[14px]">trending_up</span>
12%
</span>
<span class="text-text-muted text-xs">vs last month</span>
</div>
</div>
<div class="p-6 rounded-xl bg-white border border-border-light flex flex-col justify-between shadow-sm">
<div>
<p class="text-text-muted text-sm font-medium">Active Categories</p>
<h3 class="text-text-main text-3xl font-bold mt-2 tracking-tight">8</h3>
</div>
<div class="w-full bg-slate-100 h-1.5 rounded-full mt-6 overflow-hidden">
<div class="bg-purple-500 h-full rounded-full" style="width: 65%"></div>
</div>
<p class="text-text-muted text-xs mt-2">65% of budget utilized</p>
</div>
<div class="p-6 rounded-xl bg-white border border-border-light flex flex-col justify-between shadow-sm">
<div>
<p class="text-text-muted text-sm font-medium">Total Transactions</p>
<h3 class="text-text-main text-3xl font-bold mt-2 tracking-tight">42</h3>
</div>
<div class="flex items-center gap-2 mt-4">
<span class="bg-primary/10 text-primary text-xs font-semibold px-2 py-1 rounded-full flex items-center gap-1">
<span class="material-symbols-outlined text-[14px]">add</span>
5 New
</span>
<span class="text-text-muted text-xs">this week</span>
</div>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div class="xl:col-span-2 bg-white border border-border-light rounded-xl p-6 flex flex-col shadow-sm">
<div class="flex justify-between items-start mb-6">
<div>
<h3 class="text-text-main text-lg font-bold">Monthly Trends</h3>
<p class="text-text-muted text-sm">Income vs Expense over time</p>
</div>
<button class="text-text-muted hover:text-text-main text-sm bg-slate-50 border border-slate-200 px-3 py-1.5 rounded-lg transition-colors hover:bg-slate-100">Last 6 Months</button>
</div>
<div class="flex-1 flex items-end gap-3 sm:gap-6 min-h-[240px] px-2 pb-2">
<div class="flex flex-col items-center gap-2 flex-1 group cursor-pointer">
<div class="w-full max-w-[40px] bg-slate-50 border border-slate-100 rounded-t-sm relative h-[140px] group-hover:bg-slate-100 transition-all">
<div class="absolute bottom-0 w-full bg-primary/50 h-[60%] rounded-t-sm"></div>
</div>
<span class="text-text-muted text-xs font-medium">Jan</span>
</div>
<div class="flex flex-col items-center gap-2 flex-1 group cursor-pointer">
<div class="w-full max-w-[40px] bg-slate-50 border border-slate-100 rounded-t-sm relative h-[180px] group-hover:bg-slate-100 transition-all">
<div class="absolute bottom-0 w-full bg-primary/60 h-[45%] rounded-t-sm"></div>
</div>
<span class="text-text-muted text-xs font-medium">Feb</span>
</div>
<div class="flex flex-col items-center gap-2 flex-1 group cursor-pointer">
<div class="w-full max-w-[40px] bg-slate-50 border border-slate-100 rounded-t-sm relative h-[160px] group-hover:bg-slate-100 transition-all">
<div class="absolute bottom-0 w-full bg-primary/70 h-[70%] rounded-t-sm"></div>
</div>
<span class="text-text-muted text-xs font-medium">Mar</span>
</div>
<div class="flex flex-col items-center gap-2 flex-1 group cursor-pointer">
<div class="w-full max-w-[40px] bg-slate-50 border border-slate-100 rounded-t-sm relative h-[200px] group-hover:bg-slate-100 transition-all">
<div class="absolute bottom-0 w-full bg-primary h-[55%] rounded-t-sm"></div>
</div>
<span class="text-text-muted text-xs font-medium">Apr</span>
</div>
<div class="flex flex-col items-center gap-2 flex-1 group cursor-pointer">
<div class="w-full max-w-[40px] bg-slate-50 border border-slate-100 rounded-t-sm relative h-[150px] group-hover:bg-slate-100 transition-all">
<div class="absolute bottom-0 w-full bg-primary/80 h-[80%] rounded-t-sm"></div>
</div>
<span class="text-text-muted text-xs font-medium">May</span>
</div>
<div class="flex flex-col items-center gap-2 flex-1 group cursor-pointer">
<div class="w-full max-w-[40px] bg-slate-50 border border-slate-100 rounded-t-sm relative h-[190px] group-hover:bg-slate-100 transition-all">
<div class="absolute bottom-0 w-full bg-primary h-[65%] rounded-t-sm"></div>
</div>
<span class="text-text-muted text-xs font-medium">Jun</span>
</div>
</div>
</div>
<div class="bg-white border border-border-light rounded-xl p-6 flex flex-col shadow-sm">
<h3 class="text-text-main text-lg font-bold mb-1">Expense Distribution</h3>
<p class="text-text-muted text-sm mb-6">Breakdown by category</p>
<div class="flex-1 flex items-center justify-center relative">
<div class="size-52 rounded-full relative" style="background: conic-gradient(
#2b8cee 0% 35%,
#a855f7 35% 60%,
#0bda5b 60% 80%,
#f59e0b 80% 90%,
#ef4444 90% 100%
);">
<div class="absolute inset-4 bg-white rounded-full flex flex-col items-center justify-center z-10 shadow-[inset_0_2px_4px_rgba(0,0,0,0.05)]">
<span class="text-text-muted text-xs font-medium">Total</span>
<span class="text-text-main text-xl font-bold">$2,450</span>
</div>
</div>
</div>
<div class="mt-6 grid grid-cols-2 gap-y-2 gap-x-4">
<div class="flex items-center gap-2">
<span class="size-2.5 rounded-full bg-primary"></span>
<span class="text-text-muted text-xs">House</span>
</div>
<div class="flex items-center gap-2">
<span class="size-2.5 rounded-full bg-purple-500"></span>
<span class="text-text-muted text-xs">Mortgage</span>
</div>
<div class="flex items-center gap-2">
<span class="size-2.5 rounded-full bg-green-500"></span>
<span class="text-text-muted text-xs">Car</span>
</div>
<div class="flex items-center gap-2">
<span class="size-2.5 rounded-full bg-orange-500"></span>
<span class="text-text-muted text-xs">Food</span>
</div>
</div>
</div>
</div>
<div>
<div class="flex items-center justify-between mb-4">
<h3 class="text-text-main text-lg font-bold">Expense Categories</h3>
<a class="text-primary text-sm font-medium hover:text-primary/80" href="#">View All</a>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="bg-white p-5 rounded-lg border border-border-light hover:border-primary/50 transition-colors group shadow-sm">
<div class="flex justify-between items-start mb-4">
<div class="size-10 rounded-lg bg-green-500/10 flex items-center justify-center text-green-600 group-hover:bg-green-500 group-hover:text-white transition-colors">
<span class="material-symbols-outlined">directions_car</span>
</div>
<span class="bg-slate-100 text-text-muted text-[10px] font-bold px-2 py-1 rounded-full border border-slate-200">3 txns</span>
</div>
<div class="flex flex-col">
<span class="text-text-muted text-sm font-medium">Car Expenses</span>
<span class="text-text-main text-xl font-bold mt-1">$450.00</span>
</div>
<div class="w-full bg-slate-100 h-1 rounded-full mt-4">
<div class="bg-green-500 h-full rounded-full" style="width: 18%"></div>
</div>
</div>
<div class="bg-white p-5 rounded-lg border border-border-light hover:border-primary/50 transition-colors group shadow-sm">
<div class="flex justify-between items-start mb-4">
<div class="size-10 rounded-lg bg-primary/10 flex items-center justify-center text-primary group-hover:bg-primary group-hover:text-white transition-colors">
<span class="material-symbols-outlined">home</span>
</div>
<span class="bg-slate-100 text-text-muted text-[10px] font-bold px-2 py-1 rounded-full border border-slate-200">5 txns</span>
</div>
<div class="flex flex-col">
<span class="text-text-muted text-sm font-medium">House Expenses-Bills</span>
<span class="text-text-main text-xl font-bold mt-1">$1,200.00</span>
</div>
<div class="w-full bg-slate-100 h-1 rounded-full mt-4">
<div class="bg-primary h-full rounded-full" style="width: 45%"></div>
</div>
</div>
<div class="bg-white p-5 rounded-lg border border-border-light hover:border-primary/50 transition-colors group shadow-sm">
<div class="flex justify-between items-start mb-4">
<div class="size-10 rounded-lg bg-orange-500/10 flex items-center justify-center text-orange-600 group-hover:bg-orange-500 group-hover:text-white transition-colors">
<span class="material-symbols-outlined">restaurant</span>
</div>
<span class="bg-slate-100 text-text-muted text-[10px] font-bold px-2 py-1 rounded-full border border-slate-200">12 txns</span>
</div>
<div class="flex flex-col">
<span class="text-text-muted text-sm font-medium">Food &amp; Drink</span>
<span class="text-text-main text-xl font-bold mt-1">$350.00</span>
</div>
<div class="w-full bg-slate-100 h-1 rounded-full mt-4">
<div class="bg-orange-500 h-full rounded-full" style="width: 14%"></div>
</div>
</div>
<div class="bg-white p-5 rounded-lg border border-border-light hover:border-primary/50 transition-colors group shadow-sm">
<div class="flex justify-between items-start mb-4">
<div class="size-10 rounded-lg bg-red-500/10 flex items-center justify-center text-red-600 group-hover:bg-red-500 group-hover:text-white transition-colors">
<span class="material-symbols-outlined">smoking_rooms</span>
</div>
<span class="bg-slate-100 text-text-muted text-[10px] font-bold px-2 py-1 rounded-full border border-slate-200">8 txns</span>
</div>
<div class="flex flex-col">
<span class="text-text-muted text-sm font-medium">Smoking</span>
<span class="text-text-main text-xl font-bold mt-1">$120.00</span>
</div>
<div class="w-full bg-slate-100 h-1 rounded-full mt-4">
<div class="bg-red-500 h-full rounded-full" style="width: 5%"></div>
</div>
</div>
<div class="bg-white p-5 rounded-lg border border-border-light hover:border-primary/50 transition-colors group shadow-sm">
<div class="flex justify-between items-start mb-4">
<div class="size-10 rounded-lg bg-purple-500/10 flex items-center justify-center text-purple-600 group-hover:bg-purple-500 group-hover:text-white transition-colors">
<span class="material-symbols-outlined">account_balance</span>
</div>
<span class="bg-slate-100 text-text-muted text-[10px] font-bold px-2 py-1 rounded-full border border-slate-200">1 txn</span>
</div>
<div class="flex flex-col">
<span class="text-text-muted text-sm font-medium">Mortgage</span>
<span class="text-text-main text-xl font-bold mt-1">$800.00</span>
</div>
<div class="w-full bg-slate-100 h-1 rounded-full mt-4">
<div class="bg-purple-500 h-full rounded-full" style="width: 32%"></div>
</div>
</div>
<div class="bg-white p-5 rounded-lg border border-border-light hover:border-primary/50 transition-colors group shadow-sm">
<div class="flex justify-between items-start mb-4">
<div class="size-10 rounded-lg bg-cyan-500/10 flex items-center justify-center text-cyan-600 group-hover:bg-cyan-500 group-hover:text-white transition-colors">
<span class="material-symbols-outlined">shopping_cart</span>
</div>
<span class="bg-slate-100 text-text-muted text-[10px] font-bold px-2 py-1 rounded-full border border-slate-200">4 txns</span>
</div>
<div class="flex flex-col">
<span class="text-text-muted text-sm font-medium">Supermarket</span>
<span class="text-text-main text-xl font-bold mt-1">$200.00</span>
</div>
<div class="w-full bg-slate-100 h-1 rounded-full mt-4">
<div class="bg-cyan-500 h-full rounded-full" style="width: 8%"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

View file

@ -0,0 +1,472 @@
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Transactions - Expense Tracker</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script id="tailwind-config">
tailwind.config = {
theme: {
extend: {
colors: {
"primary": "#3b82f6",
"background-dark": "#0f172a",
"card-dark": "#1e293b",
"border-dark": "#334155",
"text-main": "#f8fafc",
"text-muted": "#94a3b8",
"accent": "#6366f1",
"success": "#10b981",
},
fontFamily: {
"display": ["Inter", "sans-serif"]
},
borderRadius: {"DEFAULT": "0.25rem", "lg": "0.5rem", "xl": "0.75rem", "2xl": "1rem", "full": "9999px"},
},
},
}
</script>
<style>
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #0f172a;
}
::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #475569;
}
</style>
</head>
<body class="bg-background-dark text-text-main font-display overflow-hidden antialiased">
<div class="flex h-screen w-full">
<aside class="hidden lg:flex w-64 flex-col bg-card-dark border-r border-border-dark shadow-sm z-20">
<div class="p-6 flex flex-col h-full justify-between">
<div class="flex flex-col gap-8">
<div class="flex gap-3 items-center">
<div class="bg-center bg-no-repeat bg-cover rounded-full size-10 border border-border-dark" data-alt="User profile picture showing a smiling professional" style='background-image: url("https://lh3.googleusercontent.com/aida-public/AB6AXuANUUrJ2rc1XWSNKLt215RxpZflCxewvvZq_ZylsIILmZIjcBUGlPtBIQNmsPHhZs6WjM660ExRzDkFkwHPzxwS6ta2zQ9lFfEMpKK9Ii7RTs6B-lCsDP94jjuZnAzZsrS-ZR_DLQjI16FAzAz_GvyrW9fSGpXcjzLFztbygeR64wagIlFwfYRd3RdlEG2GWH2aDXCrEO86UxzdHzBi13r2tqFH35vfLFwcg2kcbuLP4kkwWk_kese2hD4N0GgXuehsBv8AUzsQ6DU");'>
</div>
<div class="flex flex-col">
<h1 class="text-text-main text-base font-bold leading-none">Alex Morgan</h1>
<p class="text-text-muted text-xs font-normal mt-1">Premium Plan</p>
</div>
</div>
<nav class="flex flex-col gap-2">
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-white/5 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">dashboard</span>
<span class="text-sm font-medium">Dashboard</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-primary/10 text-primary border border-primary/10" href="#">
<span class="material-symbols-outlined text-[20px]" data-weight="fill">receipt_long</span>
<span class="text-sm font-medium">Transactions</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-white/5 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">pie_chart</span>
<span class="text-sm font-medium">Reports</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-white/5 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">folder_open</span>
<span class="text-sm font-medium">Documents</span>
</a>
</nav>
</div>
<div class="flex flex-col gap-2">
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-white/5 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">settings</span>
<span class="text-sm font-medium">Settings</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-white/5 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">logout</span>
<span class="text-sm font-medium">Log out</span>
</a>
</div>
</div>
</aside>
<main class="flex-1 flex flex-col h-full overflow-hidden relative bg-background-dark">
<header class="h-16 flex items-center justify-between px-6 lg:px-8 border-b border-border-dark bg-background-dark/80 backdrop-blur z-10 shrink-0">
<div class="flex items-center gap-4">
<button class="lg:hidden text-text-main">
<span class="material-symbols-outlined">menu</span>
</button>
<h2 class="text-text-main text-lg font-bold">Transactions</h2>
</div>
<div class="flex items-center gap-6">
<div class="flex items-center gap-3">
<div class="hidden sm:flex flex-col items-end mr-2">
<span class="text-[10px] text-text-muted uppercase font-semibold tracking-wider">Total Balance</span>
<span class="text-sm font-bold text-text-main">$12,450.00</span>
</div>
<button class="size-9 rounded-lg bg-card-dark border border-border-dark flex items-center justify-center text-text-muted hover:text-text-main hover:bg-white/5 transition-colors relative shadow-sm">
<span class="material-symbols-outlined text-[20px]">notifications</span>
<span class="absolute top-2 right-2.5 size-1.5 bg-primary rounded-full border border-card-dark"></span>
</button>
</div>
</div>
</header>
<div class="flex-1 overflow-y-auto p-6 lg:p-8 scroll-smooth">
<div class="max-w-7xl mx-auto flex flex-col gap-6 pb-10">
<div class="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 bg-card-dark p-4 rounded-xl border border-border-dark shadow-sm">
<div class="relative w-full lg:w-96 group">
<span class="material-symbols-outlined absolute left-3 top-2.5 text-text-muted group-focus-within:text-primary transition-colors">search</span>
<input class="bg-background-dark border border-border-dark text-text-main text-sm rounded-lg focus:ring-1 focus:ring-primary focus:border-primary block w-full pl-10 p-2.5 placeholder-text-muted transition-all" placeholder="Search transactions..." type="text"/>
</div>
<div class="flex flex-wrap items-center gap-2 w-full lg:w-auto">
<button class="flex items-center gap-2 px-3 py-2 bg-background-dark border border-border-dark rounded-lg text-text-muted hover:text-text-main hover:border-primary/50 transition-colors text-sm">
<span class="material-symbols-outlined text-[18px]">calendar_month</span>
<span class="hidden sm:inline">Date</span>
</button>
<button class="flex items-center gap-2 px-3 py-2 bg-background-dark border border-border-dark rounded-lg text-text-muted hover:text-text-main hover:border-primary/50 transition-colors text-sm">
<span class="material-symbols-outlined text-[18px]">category</span>
<span class="hidden sm:inline">Category</span>
</button>
<button class="flex items-center gap-2 px-3 py-2 bg-background-dark border border-border-dark rounded-lg text-text-muted hover:text-text-main hover:border-primary/50 transition-colors text-sm">
<span class="material-symbols-outlined text-[18px]">tune</span>
<span class="hidden sm:inline">Filters</span>
</button>
<div class="w-px h-6 bg-border-dark mx-1 hidden sm:block"></div>
<button class="flex-1 sm:flex-none bg-primary hover:bg-blue-600 text-white h-10 px-4 rounded-lg text-sm font-semibold shadow-lg shadow-primary/20 transition-all flex items-center justify-center gap-2">
<span class="material-symbols-outlined text-[18px]">add</span>
<span>Add New</span>
</button>
</div>
</div>
<div class="bg-card-dark border border-border-dark rounded-xl overflow-hidden shadow-sm">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead class="bg-background-dark/50">
<tr class="border-b border-border-dark text-text-muted text-xs uppercase font-medium">
<th class="p-5 font-semibold">Transaction</th>
<th class="p-5 font-semibold">Category</th>
<th class="p-5 font-semibold">Date</th>
<th class="p-5 font-semibold">Payment Method</th>
<th class="p-5 font-semibold text-right">Amount</th>
<th class="p-5 font-semibold text-center">Status</th>
<th class="p-5"></th>
</tr>
</thead>
<tbody class="divide-y divide-border-dark/40 text-sm">
<tr class="group hover:bg-white/[0.02] transition-colors relative">
<td class="p-5">
<div class="flex items-center gap-3">
<div class="size-10 rounded-full bg-white flex items-center justify-center shrink-0">
<img alt="Apple" class="size-6 object-contain" onerror="this.src='data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOTRhM2I4IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTMgMjF2LThhMiAyIDAgMCAxIDItMmgyLjVhMiAyIDAgMCAxIDIgMnY4Ii8+PHBhdGggZD0iTTIyIDIxdi04YTIgMiAwIDAgMC0yLTJoLTIuNWEyIDIgMCAwIDAtMiAydjgiLz48cGF0aCBkPSJNMTAgMjFWMiIvPjwvc3ZnPg=='" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDSywgcsLzmEYNZ3a2R0LVhZ5nIjwShhMnk8omZ09Iwn4xM8ehTZjG2xLTprdwpoDgB8QuqXXEuWJCVv_P52bRcD9q3ay9jpg8mf7irNNVpNir8XfSuMaeYOXt05rQzprKMEZ6zNd-XzmJri1cjF1QtATRcGtieIPrjI__7t32sdZ-Jrp_PU40niUj6zIQp-5l7PyBOGx3FSJlk1ZHObpj6CIvpc_k-R09RMtSx0rWHFyOqxQj9vj1kUzkDmj7p7js6qkTjVwSdyCs"/>
</div>
<div class="flex flex-col">
<span class="text-text-main font-medium group-hover:text-primary transition-colors">Apple Store</span>
<span class="text-text-muted text-xs">Electronics</span>
</div>
</div>
<div class="absolute left-0 top-0 bottom-0 w-1 bg-primary opacity-0 group-hover:opacity-100 transition-opacity"></div>
</td>
<td class="p-5">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-blue-500/10 text-blue-400 border border-blue-500/20">
<span class="size-1.5 rounded-full bg-blue-400"></span>
Tech &amp; Gadgets
</span>
</td>
<td class="p-5 text-text-muted">
Oct 24, 2023
<span class="block text-xs opacity-60">10:42 AM</span>
</td>
<td class="p-5">
<div class="flex items-center gap-2 text-text-main">
<span class="material-symbols-outlined text-text-muted text-[18px]">credit_card</span>
<span>•••• 4242</span>
</div>
</td>
<td class="p-5 text-right">
<span class="text-text-main font-semibold">-$1,299.00</span>
</td>
<td class="p-5 text-center">
<span class="inline-flex items-center justify-center size-6 rounded-full bg-success/20 text-success" title="Completed">
<span class="material-symbols-outlined text-[16px]">check</span>
</span>
</td>
<td class="p-5 text-right">
<button class="text-text-muted hover:text-text-main p-1 rounded hover:bg-white/10 transition-colors">
<span class="material-symbols-outlined text-[20px]">more_vert</span>
</button>
</td>
</tr>
<tr class="group hover:bg-white/[0.02] transition-colors relative">
<td class="p-5">
<div class="flex items-center gap-3">
<div class="size-10 rounded-full bg-white flex items-center justify-center shrink-0">
<img alt="Spotify" class="size-6 object-contain" onerror="this.src='data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOTRhM2I4IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTMgMjF2LThhMiAyIDAgMCAxIDItMmgyLjVhMiAyIDAgMCAxIDIgMnY4Ii8+PHBhdGggZD0iTTIyIDIxdi04YTIgMiAwIDAgMC0yLTJoLTIuNWEyIDIgMCAwIDAtMiAydjgiLz48cGF0aCBkPSJNMTAgMjFWMiIvPjwvc3ZnPg=='" src="https://lh3.googleusercontent.com/aida-public/AB6AXuC-GaSMPiYW37DPKB7OY0R_P419ISN-taoKpps9EcturmlE9dFo5NsKsQrC0EoVV9KsbdPhe1cQM9V-NMRckBPc1ajb8chzMvTrindVM0NyzsuBk_j1-jNjW-Ij3a4iX2DktsZZWpAvNYm8mcGXO366NXgo8I6JM2-zokOy1SQ4A4Qj0JxwEFD16bDmDoVPNrL1YSaT12bIlWe0EB-wx6NoTHqnysfnV0JxOdyOTBUXkkFe4VbeBJ8L7wSiWVjky6ytN5Yd40U4ul4"/>
</div>
<div class="flex flex-col">
<span class="text-text-main font-medium group-hover:text-primary transition-colors">Spotify Premium</span>
<span class="text-text-muted text-xs">Subscription</span>
</div>
</div>
</td>
<td class="p-5">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-purple-500/10 text-purple-400 border border-purple-500/20">
<span class="size-1.5 rounded-full bg-purple-400"></span>
Entertainment
</span>
</td>
<td class="p-5 text-text-muted">
Oct 23, 2023
<span class="block text-xs opacity-60">09:00 AM</span>
</td>
<td class="p-5">
<div class="flex items-center gap-2 text-text-main">
<span class="material-symbols-outlined text-text-muted text-[18px]">credit_card</span>
<span>•••• 8812</span>
</div>
</td>
<td class="p-5 text-right">
<span class="text-text-main font-semibold">-$14.99</span>
</td>
<td class="p-5 text-center">
<span class="inline-flex items-center justify-center size-6 rounded-full bg-success/20 text-success" title="Completed">
<span class="material-symbols-outlined text-[16px]">check</span>
</span>
</td>
<td class="p-5 text-right">
<button class="text-text-muted hover:text-text-main p-1 rounded hover:bg-white/10 transition-colors">
<span class="material-symbols-outlined text-[20px]">more_vert</span>
</button>
</td>
</tr>
<tr class="group hover:bg-white/[0.02] transition-colors relative">
<td class="p-5">
<div class="flex items-center gap-3">
<div class="size-10 rounded-full bg-white flex items-center justify-center shrink-0">
<img alt="Whole Foods" class="size-6 object-contain" onerror="this.src='data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOTRhM2I4IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTMgMjF2LThhMiAyIDAgMCAxIDItMmgyLjVhMiAyIDAgMCAxIDIgMnY4Ii8+PHBhdGggZD0iTTIyIDIxdi04YTIgMiAwIDAgMC0yLTJoLTIuNWEyIDIgMCAwIDAtMiAydjgiLz48cGF0aCBkPSJNMTAgMjFWMiIvPjwvc3ZnPg=='" src="https://lh3.googleusercontent.com/aida-public/AB6AXuC3oEKd_VksMaTsFZgiZoGe1gr1j1afyTTobXLNV9mFhBLtZT8R9CpX68AME1MDsxTOu1pxNF14-ZwV1gIQAOY1VDQzlZtOAi05hYHOq33kZE6Y2PzsnZX8ee8FRGhYT987t5UdAnNgYq468DGG3xX18kUqUs5JHW9vvf4oabNfhJkmfDaZO1smYZC7NZmnHt6lBrWQLH3BEdHETOiCTSjAL-gRYeBbJU9aLnOtVdXlPewGsd37oFwbwW-fgdcaRg8evpO74F-De0E"/>
</div>
<div class="flex flex-col">
<span class="text-text-main font-medium group-hover:text-primary transition-colors">Whole Foods Market</span>
<span class="text-text-muted text-xs">Groceries</span>
</div>
</div>
</td>
<td class="p-5">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-green-500/10 text-green-400 border border-green-500/20">
<span class="size-1.5 rounded-full bg-green-400"></span>
Food &amp; Drink
</span>
</td>
<td class="p-5 text-text-muted">
Oct 22, 2023
<span class="block text-xs opacity-60">06:15 PM</span>
</td>
<td class="p-5">
<div class="flex items-center gap-2 text-text-main">
<span class="material-symbols-outlined text-text-muted text-[18px]">account_balance_wallet</span>
<span>Apple Pay</span>
</div>
</td>
<td class="p-5 text-right">
<span class="text-text-main font-semibold">-$84.32</span>
</td>
<td class="p-5 text-center">
<span class="inline-flex items-center justify-center size-6 rounded-full bg-yellow-500/20 text-yellow-400" title="Pending">
<span class="material-symbols-outlined text-[16px]">schedule</span>
</span>
</td>
<td class="p-5 text-right">
<button class="text-text-muted hover:text-text-main p-1 rounded hover:bg-white/10 transition-colors">
<span class="material-symbols-outlined text-[20px]">more_vert</span>
</button>
</td>
</tr>
<tr class="group hover:bg-white/[0.02] transition-colors relative">
<td class="p-5">
<div class="flex items-center gap-3">
<div class="size-10 rounded-full bg-white flex items-center justify-center shrink-0">
<img alt="Uber" class="size-6 object-contain" onerror="this.src='data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOTRhM2I4IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTMgMjF2LThhMiAyIDAgMCAxIDItMmgyLjVhMiAyIDAgMCAxIDIgMnY4Ii8+PHBhdGggZD0iTTIyIDIxdi04YTIgMiAwIDAgMC0yLTJoLTIuNWEyIDIgMCAwIDAtMiAydjgiLz48cGF0aCBkPSJNMTAgMjFWMiIvPjwvc3ZnPg=='" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCx0TiKqOyZxXQwssH0TuOkidRxBViRF8Vo8bhMWsxzLgKUJ7dNWdrln9i6arER8wYVb8b2_bvHU8AqlMaEcJpRj03qTp3P8qWfa53K5PsJOS_eJJIo2ctokCQ620SMMTQEvaRW578hW6JRrObJFMy1GoQSd1DNWCrUE4BCd0gaGuZ3S7mI1rrXBn4PGz4HZlq4dMdRPBOWAzAjU39NBvEB_5TjTYOXfkten6M7Bqv2MQLeEVWVYb69mq69RWXFqjmpWC_HAVcjDCY"/>
</div>
<div class="flex flex-col">
<span class="text-text-main font-medium group-hover:text-primary transition-colors">Uber Trip</span>
<span class="text-text-muted text-xs">Transportation</span>
</div>
</div>
</td>
<td class="p-5">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-orange-500/10 text-orange-400 border border-orange-500/20">
<span class="size-1.5 rounded-full bg-orange-400"></span>
Travel
</span>
</td>
<td class="p-5 text-text-muted">
Oct 21, 2023
<span class="block text-xs opacity-60">11:30 PM</span>
</td>
<td class="p-5">
<div class="flex items-center gap-2 text-text-main">
<span class="material-symbols-outlined text-text-muted text-[18px]">credit_card</span>
<span>•••• 4242</span>
</div>
</td>
<td class="p-5 text-right">
<span class="text-text-main font-semibold">-$24.50</span>
</td>
<td class="p-5 text-center">
<span class="inline-flex items-center justify-center size-6 rounded-full bg-success/20 text-success" title="Completed">
<span class="material-symbols-outlined text-[16px]">check</span>
</span>
</td>
<td class="p-5 text-right">
<button class="text-text-muted hover:text-text-main p-1 rounded hover:bg-white/10 transition-colors">
<span class="material-symbols-outlined text-[20px]">more_vert</span>
</button>
</td>
</tr>
<tr class="group hover:bg-white/[0.02] transition-colors relative">
<td class="p-5">
<div class="flex items-center gap-3">
<div class="size-10 rounded-full bg-white flex items-center justify-center shrink-0">
<span class="font-bold text-gray-800">U</span>
</div>
<div class="flex flex-col">
<span class="text-text-main font-medium group-hover:text-primary transition-colors">Upwork Inc.</span>
<span class="text-text-muted text-xs">Freelance</span>
</div>
</div>
</td>
<td class="p-5">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-teal-500/10 text-teal-400 border border-teal-500/20">
<span class="size-1.5 rounded-full bg-teal-400"></span>
Income
</span>
</td>
<td class="p-5 text-text-muted">
Oct 20, 2023
<span class="block text-xs opacity-60">02:00 PM</span>
</td>
<td class="p-5">
<div class="flex items-center gap-2 text-text-main">
<span class="material-symbols-outlined text-text-muted text-[18px]">account_balance</span>
<span>Direct Deposit</span>
</div>
</td>
<td class="p-5 text-right">
<span class="text-success font-semibold">+$3,450.00</span>
</td>
<td class="p-5 text-center">
<span class="inline-flex items-center justify-center size-6 rounded-full bg-success/20 text-success" title="Completed">
<span class="material-symbols-outlined text-[16px]">check</span>
</span>
</td>
<td class="p-5 text-right">
<button class="text-text-muted hover:text-text-main p-1 rounded hover:bg-white/10 transition-colors">
<span class="material-symbols-outlined text-[20px]">more_vert</span>
</button>
</td>
</tr>
<tr class="group hover:bg-white/[0.02] transition-colors relative">
<td class="p-5">
<div class="flex items-center gap-3">
<div class="size-10 rounded-full bg-white flex items-center justify-center shrink-0">
<img alt="Netflix" class="size-6 object-contain" onerror="this.src='data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOTRhM2I4IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTMgMjF2LThhMiAyIDAgMCAxIDItMmgyLjVhMiAyIDAgMCAxIDIgMnY4Ii8+PHBhdGggZD0iTTIyIDIxdi04YTIgMiAwIDAgMC0yLTJoLTIuNWEyIDIgMCAwIDAtMiAydjgiLz48cGF0aCBkPSJNMTAgMjFWMiIvPjwvc3ZnPg=='" src="https://lh3.googleusercontent.com/aida-public/AB6AXuC8f9CCAkiZ9Ij5PjVYp04uFxma55OLSlNeI2UHPAH55fj1MKj09PGSGu-qMwu1VnB1SyptVIMMZIAAdbMDcDIg4SCOjSsiye0D9IPUEyU-9rtJODyME2G7QxqW4PeZxLPWOnESpUSLwdo6v9DjubLn3dSUXO1hvg9Iloat0mjFkqTtLG_VfrivzBqBALPQiK5Bu_1WJ_drF0emq7Ft1ne1CX1xo1pdsZC0w693X4Fb6S9UR5ErAFjTj-YivOOrjQbZXe7UhCHtIWI"/>
</div>
<div class="flex flex-col">
<span class="text-text-main font-medium group-hover:text-primary transition-colors">Netflix</span>
<span class="text-text-muted text-xs">Subscription</span>
</div>
</div>
</td>
<td class="p-5">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-purple-500/10 text-purple-400 border border-purple-500/20">
<span class="size-1.5 rounded-full bg-purple-400"></span>
Entertainment
</span>
</td>
<td class="p-5 text-text-muted">
Oct 18, 2023
<span class="block text-xs opacity-60">11:00 AM</span>
</td>
<td class="p-5">
<div class="flex items-center gap-2 text-text-main">
<span class="material-symbols-outlined text-text-muted text-[18px]">credit_card</span>
<span>•••• 8812</span>
</div>
</td>
<td class="p-5 text-right">
<span class="text-text-main font-semibold">-$19.99</span>
</td>
<td class="p-5 text-center">
<span class="inline-flex items-center justify-center size-6 rounded-full bg-success/20 text-success" title="Completed">
<span class="material-symbols-outlined text-[16px]">check</span>
</span>
</td>
<td class="p-5 text-right">
<button class="text-text-muted hover:text-text-main p-1 rounded hover:bg-white/10 transition-colors">
<span class="material-symbols-outlined text-[20px]">more_vert</span>
</button>
</td>
</tr>
<tr class="group hover:bg-white/[0.02] transition-colors relative">
<td class="p-5">
<div class="flex items-center gap-3">
<div class="size-10 rounded-full bg-white flex items-center justify-center shrink-0">
<img alt="Starbucks" class="size-6 object-contain" onerror="this.src='data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOTRhM2I4IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTMgMjF2LThhMiAyIDAgMCAxIDItMmgyLjVhMiAyIDAgMCAxIDIgMnY4Ii8+PHBhdGggZD0iTTIyIDIxdi04YTIgMiAwIDAgMC0yLTJoLTIuNWEyIDIgMCAwIDAtMiAydjgiLz48cGF0aCBkPSJNMTAgMjFWMiIvPjwvc3ZnPg=='" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCTGBqffwTY09cdnZveNpeRYFuAOmJ6IFoihvN-tDUi8ZpL-n2JUf8fx4TmASiqo9NiN6Wq9Ix3MeLmXgFLGR7bumZ1D0hH28DiQItCeffxJIOzGQiH3dXv3Y3GHLIrLitIReCxc8hnkcJr-IBs86XbBCjihJmn9mJ3CyAhvSSZFWLTX-8r5T7uMvjBFxpUQIoSYNChmKdIWZkZ033GN4vROz88VIfSR2e4OlNMiqJ2fCMEvez70HzQO1ifPEpvk4u4TXWqRpix9zg"/>
</div>
<div class="flex flex-col">
<span class="text-text-main font-medium group-hover:text-primary transition-colors">Starbucks</span>
<span class="text-text-muted text-xs">Coffee</span>
</div>
</div>
</td>
<td class="p-5">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-green-500/10 text-green-400 border border-green-500/20">
<span class="size-1.5 rounded-full bg-green-400"></span>
Food &amp; Drink
</span>
</td>
<td class="p-5 text-text-muted">
Oct 18, 2023
<span class="block text-xs opacity-60">08:15 AM</span>
</td>
<td class="p-5">
<div class="flex items-center gap-2 text-text-main">
<span class="material-symbols-outlined text-text-muted text-[18px]">account_balance_wallet</span>
<span>Apple Pay</span>
</div>
</td>
<td class="p-5 text-right">
<span class="text-text-main font-semibold">-$6.50</span>
</td>
<td class="p-5 text-center">
<span class="inline-flex items-center justify-center size-6 rounded-full bg-success/20 text-success" title="Completed">
<span class="material-symbols-outlined text-[16px]">check</span>
</span>
</td>
<td class="p-5 text-right">
<button class="text-text-muted hover:text-text-main p-1 rounded hover:bg-white/10 transition-colors">
<span class="material-symbols-outlined text-[20px]">more_vert</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="p-4 border-t border-border-dark flex flex-col sm:flex-row gap-4 justify-between items-center bg-card-dark">
<span class="text-sm text-text-muted">Showing <span class="text-text-main font-medium">1</span> to <span class="text-text-main font-medium">7</span> of <span class="text-text-main font-medium">124</span> results</span>
<div class="flex gap-2">
<button class="flex items-center gap-1 px-3 py-1.5 bg-background-dark border border-border-dark rounded-md text-text-muted hover:text-text-main hover:border-text-muted transition-colors text-sm disabled:opacity-50" disabled="">
<span class="material-symbols-outlined text-[16px]">chevron_left</span>
Previous
</button>
<button class="flex items-center gap-1 px-3 py-1.5 bg-background-dark border border-border-dark rounded-md text-text-muted hover:text-text-main hover:border-text-muted transition-colors text-sm">
Next
<span class="material-symbols-outlined text-[16px]">chevron_right</span>
</button>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

View file

@ -0,0 +1,414 @@
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Reports - Expense Tracker</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script id="tailwind-config">
tailwind.config = {
theme: {
extend: {
colors: {
"primary": "#3b82f6",
"background-dark": "#0f172a",
"card-dark": "#1e293b",
"border-dark": "#334155",
"text-main": "#f8fafc",
"text-muted": "#94a3b8",
"accent": "#6366f1",
"success": "#10b981",
"warning": "#f59e0b",
"danger": "#ef4444",
},
fontFamily: {
"display": ["Inter", "sans-serif"]
},
borderRadius: {"DEFAULT": "0.25rem", "lg": "0.5rem", "xl": "0.75rem", "2xl": "1rem", "full": "9999px"},
},
},
}
</script>
<style>
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #0f172a;
}
::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #475569;
}
</style>
</head>
<body class="bg-background-dark text-text-main font-display overflow-hidden antialiased">
<div class="flex h-screen w-full">
<aside class="hidden lg:flex w-64 flex-col bg-card-dark border-r border-border-dark shadow-sm z-20">
<div class="p-6 flex flex-col h-full justify-between">
<div class="flex flex-col gap-8">
<div class="flex gap-3 items-center">
<div class="bg-center bg-no-repeat bg-cover rounded-full size-10 border border-border-dark" data-alt="User profile picture showing a smiling professional" style='background-image: url("https://lh3.googleusercontent.com/aida-public/AB6AXuANUUrJ2rc1XWSNKLt215RxpZflCxewvvZq_ZylsIILmZIjcBUGlPtBIQNmsPHhZs6WjM660ExRzDkFkwHPzxwS6ta2zQ9lFfEMpKK9Ii7RTs6B-lCsDP94jjuZnAzZsrS-ZR_DLQjI16FAzAz_GvyrW9fSGpXcjzLFztbygeR64wagIlFwfYRd3RdlEG2GWH2aDXCrEO86UxzdHzBi13r2tqFH35vfLFwcg2kcbuLP4kkwWk_kese2hD4N0GgXuehsBv8AUzsQ6DU");'>
</div>
<div class="flex flex-col">
<h1 class="text-text-main text-base font-bold leading-none">Alex Morgan</h1>
<p class="text-text-muted text-xs font-normal mt-1">Premium Plan</p>
</div>
</div>
<nav class="flex flex-col gap-2">
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-white/5 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">dashboard</span>
<span class="text-sm font-medium">Dashboard</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-white/5 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">receipt_long</span>
<span class="text-sm font-medium">Transactions</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-primary/10 text-primary border border-primary/10" href="#">
<span class="material-symbols-outlined text-[20px]" data-weight="fill">pie_chart</span>
<span class="text-sm font-medium">Reports</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-white/5 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">folder_open</span>
<span class="text-sm font-medium">Documents</span>
</a>
</nav>
</div>
<div class="flex flex-col gap-2">
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-white/5 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">settings</span>
<span class="text-sm font-medium">Settings</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-white/5 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">logout</span>
<span class="text-sm font-medium">Log out</span>
</a>
</div>
</div>
</aside>
<main class="flex-1 flex flex-col h-full overflow-hidden relative bg-background-dark">
<header class="h-16 flex items-center justify-between px-6 lg:px-8 border-b border-border-dark bg-background-dark/80 backdrop-blur z-10 shrink-0">
<div class="flex items-center gap-4">
<button class="lg:hidden text-text-main">
<span class="material-symbols-outlined">menu</span>
</button>
<h2 class="text-text-main text-lg font-bold">Financial Reports</h2>
</div>
<div class="flex items-center gap-6">
<div class="flex items-center gap-3">
<button class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-text-muted hover:text-text-main hover:bg-white/5 rounded-lg border border-transparent hover:border-border-dark transition-all">
<span class="material-symbols-outlined text-[18px]">download</span>
<span class="hidden sm:inline">Export CSV</span>
</button>
<div class="w-px h-6 bg-border-dark mx-1 hidden sm:block"></div>
<button class="size-9 rounded-lg bg-card-dark border border-border-dark flex items-center justify-center text-text-muted hover:text-text-main hover:bg-white/5 transition-colors relative shadow-sm">
<span class="material-symbols-outlined text-[20px]">notifications</span>
<span class="absolute top-2 right-2.5 size-1.5 bg-primary rounded-full border border-card-dark"></span>
</button>
</div>
</div>
</header>
<div class="flex-1 overflow-y-auto p-6 lg:p-8 scroll-smooth">
<div class="max-w-7xl mx-auto flex flex-col gap-6 pb-10">
<div class="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 bg-card-dark p-4 rounded-xl border border-border-dark shadow-sm">
<div class="flex items-center gap-3">
<h3 class="text-sm font-semibold text-text-muted uppercase tracking-wider">Analysis Period:</h3>
<div class="flex bg-background-dark rounded-lg p-1 border border-border-dark">
<button class="px-3 py-1 text-sm font-medium rounded text-text-main bg-white/10 shadow-sm">Last 30 Days</button>
<button class="px-3 py-1 text-sm font-medium rounded text-text-muted hover:text-text-main hover:bg-white/5 transition-colors">Quarter</button>
<button class="px-3 py-1 text-sm font-medium rounded text-text-muted hover:text-text-main hover:bg-white/5 transition-colors">YTD</button>
<button class="px-3 py-1 text-sm font-medium rounded text-text-muted hover:text-text-main hover:bg-white/5 transition-colors">Custom</button>
</div>
</div>
<div class="flex flex-wrap items-center gap-3 w-full lg:w-auto">
<div class="relative group">
<button class="flex items-center gap-2 px-3 py-2 bg-background-dark border border-border-dark rounded-lg text-text-muted hover:text-text-main hover:border-primary/50 transition-colors text-sm w-full lg:w-48 justify-between">
<span class="flex items-center gap-2">
<span class="material-symbols-outlined text-[18px]">category</span>
All Categories
</span>
<span class="material-symbols-outlined text-[18px]">expand_more</span>
</button>
</div>
<button class="flex-1 sm:flex-none bg-primary hover:bg-blue-600 text-white h-10 px-4 rounded-lg text-sm font-semibold shadow-lg shadow-primary/20 transition-all flex items-center justify-center gap-2">
<span class="material-symbols-outlined text-[18px]">autorenew</span>
<span>Generate Report</span>
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div class="bg-card-dark p-5 rounded-xl border border-border-dark shadow-sm hover:border-primary/30 transition-colors group">
<div class="flex justify-between items-start mb-4">
<div class="flex flex-col">
<span class="text-text-muted text-xs font-medium uppercase tracking-wider">Total Spent</span>
<h4 class="text-2xl font-bold text-text-main mt-1">$4,250.00</h4>
</div>
<div class="p-2 bg-primary/10 rounded-lg text-primary group-hover:bg-primary group-hover:text-white transition-colors">
<span class="material-symbols-outlined text-[20px]">payments</span>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<span class="text-success flex items-center font-medium bg-success/10 px-1.5 py-0.5 rounded">
<span class="material-symbols-outlined text-[14px] mr-0.5">trending_down</span>
12%
</span>
<span class="text-text-muted">vs last month</span>
</div>
</div>
<div class="bg-card-dark p-5 rounded-xl border border-border-dark shadow-sm hover:border-accent/30 transition-colors group">
<div class="flex justify-between items-start mb-4">
<div class="flex flex-col">
<span class="text-text-muted text-xs font-medium uppercase tracking-wider">Top Category</span>
<h4 class="text-2xl font-bold text-text-main mt-1">Housing</h4>
</div>
<div class="p-2 bg-accent/10 rounded-lg text-accent group-hover:bg-accent group-hover:text-white transition-colors">
<span class="material-symbols-outlined text-[20px]">home</span>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<span class="text-text-main font-semibold">$1,850</span>
<span class="text-text-muted">spent this month</span>
</div>
</div>
<div class="bg-card-dark p-5 rounded-xl border border-border-dark shadow-sm hover:border-warning/30 transition-colors group">
<div class="flex justify-between items-start mb-4">
<div class="flex flex-col">
<span class="text-text-muted text-xs font-medium uppercase tracking-wider">Avg. Daily</span>
<h4 class="text-2xl font-bold text-text-main mt-1">$141.66</h4>
</div>
<div class="p-2 bg-warning/10 rounded-lg text-warning group-hover:bg-warning group-hover:text-white transition-colors">
<span class="material-symbols-outlined text-[20px]">calendar_today</span>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<span class="text-danger flex items-center font-medium bg-danger/10 px-1.5 py-0.5 rounded">
<span class="material-symbols-outlined text-[14px] mr-0.5">trending_up</span>
5%
</span>
<span class="text-text-muted">vs last month</span>
</div>
</div>
<div class="bg-card-dark p-5 rounded-xl border border-border-dark shadow-sm hover:border-success/30 transition-colors group">
<div class="flex justify-between items-start mb-4">
<div class="flex flex-col">
<span class="text-text-muted text-xs font-medium uppercase tracking-wider">Savings Rate</span>
<h4 class="text-2xl font-bold text-text-main mt-1">18.5%</h4>
</div>
<div class="p-2 bg-success/10 rounded-lg text-success group-hover:bg-success group-hover:text-white transition-colors">
<span class="material-symbols-outlined text-[20px]">savings</span>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<span class="text-success flex items-center font-medium bg-success/10 px-1.5 py-0.5 rounded">
<span class="material-symbols-outlined text-[14px] mr-0.5">arrow_upward</span>
2.1%
</span>
<span class="text-text-muted">vs last month</span>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 bg-card-dark p-6 rounded-xl border border-border-dark shadow-sm flex flex-col">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-bold text-text-main">Spending Trend</h3>
<button class="text-text-muted hover:text-text-main transition-colors">
<span class="material-symbols-outlined">more_horiz</span>
</button>
</div>
<div class="flex-1 min-h-[300px] w-full relative flex items-end gap-2 px-4 pb-4 border-b border-border-dark/50">
<div class="absolute inset-0 top-10 left-4 right-4 bg-gradient-to-b from-primary/20 to-transparent opacity-50" style="clip-path: polygon(0 80%, 10% 60%, 20% 70%, 30% 50%, 40% 65%, 50% 40%, 60% 55%, 70% 30%, 80% 45%, 90% 20%, 100% 40%, 100% 100%, 0 100%);"></div>
<div class="absolute inset-0 top-10 left-4 right-4 h-full pointer-events-none">
<svg class="w-full h-full overflow-visible" preserveAspectRatio="none" viewBox="0 0 100 100">
<path d="M0 80 L10 60 L20 70 L30 50 L40 65 L50 40 L60 55 L70 30 L80 45 L90 20 L100 40" fill="none" stroke="#3b82f6" stroke-linecap="round" stroke-linejoin="round" stroke-width="0.5" vector-effect="non-scaling-stroke"></path>
<circle cx="0" cy="80" fill="#1e293b" r="1" stroke="#3b82f6" stroke-width="0.5"></circle>
<circle cx="10" cy="60" fill="#1e293b" r="1" stroke="#3b82f6" stroke-width="0.5"></circle>
<circle cx="20" cy="70" fill="#1e293b" r="1" stroke="#3b82f6" stroke-width="0.5"></circle>
<circle cx="30" cy="50" fill="#1e293b" r="1" stroke="#3b82f6" stroke-width="0.5"></circle>
<circle cx="40" cy="65" fill="#1e293b" r="1" stroke="#3b82f6" stroke-width="0.5"></circle>
<circle cx="50" cy="40" fill="#1e293b" r="1" stroke="#3b82f6" stroke-width="0.5"></circle>
<circle cx="60" cy="55" fill="#1e293b" r="1" stroke="#3b82f6" stroke-width="0.5"></circle>
<circle cx="70" cy="30" fill="#1e293b" r="1" stroke="#3b82f6" stroke-width="0.5"></circle>
<circle cx="80" cy="45" fill="#1e293b" r="1" stroke="#3b82f6" stroke-width="0.5"></circle>
<circle cx="90" cy="20" fill="#1e293b" r="1" stroke="#3b82f6" stroke-width="0.5"></circle>
<circle cx="100" cy="40" fill="#1e293b" r="1" stroke="#3b82f6" stroke-width="0.5"></circle>
</svg>
</div>
<div class="absolute bottom-[-24px] left-0 w-full flex justify-between text-xs text-text-muted px-4">
<span>1 Nov</span>
<span>5 Nov</span>
<span>10 Nov</span>
<span>15 Nov</span>
<span>20 Nov</span>
<span>25 Nov</span>
<span>30 Nov</span>
</div>
<div class="absolute inset-0 flex flex-col justify-between pointer-events-none pb-8 pt-6 px-4">
<div class="w-full h-px bg-border-dark/30 border-t border-dashed border-border-dark"></div>
<div class="w-full h-px bg-border-dark/30 border-t border-dashed border-border-dark"></div>
<div class="w-full h-px bg-border-dark/30 border-t border-dashed border-border-dark"></div>
<div class="w-full h-px bg-border-dark/30 border-t border-dashed border-border-dark"></div>
<div class="w-full h-px bg-border-dark/30 border-t border-dashed border-border-dark"></div>
</div>
</div>
</div>
<div class="lg:col-span-1 bg-card-dark p-6 rounded-xl border border-border-dark shadow-sm flex flex-col">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-bold text-text-main">Category Breakdown</h3>
<button class="text-text-muted hover:text-text-main transition-colors">
<span class="material-symbols-outlined">filter_list</span>
</button>
</div>
<div class="flex flex-col items-center justify-center flex-1 gap-6">
<div class="relative size-48 rounded-full border-[16px] border-card-dark shadow-inner" style="background: conic-gradient(#3b82f6 0% 35%, #6366f1 35% 60%, #10b981 60% 80%, #f59e0b 80% 92%, #ef4444 92% 100%);">
<div class="absolute inset-0 m-6 bg-card-dark rounded-full flex flex-col items-center justify-center">
<span class="text-xs text-text-muted uppercase font-medium">Total</span>
<span class="text-xl font-bold text-text-main">$4,250</span>
</div>
</div>
<div class="w-full grid grid-cols-2 gap-3 text-sm">
<div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-primary"></span>
<span class="text-text-muted flex-1">Housing</span>
<span class="font-semibold text-text-main">35%</span>
</div>
<div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-accent"></span>
<span class="text-text-muted flex-1">Food</span>
<span class="font-semibold text-text-main">25%</span>
</div>
<div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-success"></span>
<span class="text-text-muted flex-1">Investments</span>
<span class="font-semibold text-text-main">20%</span>
</div>
<div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-warning"></span>
<span class="text-text-muted flex-1">Transport</span>
<span class="font-semibold text-text-main">12%</span>
</div>
<div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-danger"></span>
<span class="text-text-muted flex-1">Others</span>
<span class="font-semibold text-text-main">8%</span>
</div>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-card-dark p-6 rounded-xl border border-border-dark shadow-sm">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-bold text-text-main">Monthly Spending</h3>
<div class="flex gap-2">
<span class="flex items-center gap-1.5 text-xs text-text-muted">
<span class="size-2 rounded-full bg-primary"></span> 2023
</span>
<span class="flex items-center gap-1.5 text-xs text-text-muted">
<span class="size-2 rounded-full bg-border-dark"></span> 2022
</span>
</div>
</div>
<div class="h-64 w-full flex items-end justify-between gap-2 px-2">
<div class="flex flex-col items-center gap-2 w-full group cursor-pointer">
<div class="w-full flex gap-1 items-end justify-center h-full relative">
<div class="w-3 bg-border-dark rounded-t-sm h-[40%] opacity-50 group-hover:opacity-70 transition-all"></div>
<div class="w-3 bg-primary rounded-t-sm h-[55%] group-hover:bg-blue-400 transition-all relative">
<div class="absolute -top-8 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10">$2,400</div>
</div>
</div>
<span class="text-xs text-text-muted">Jun</span>
</div>
<div class="flex flex-col items-center gap-2 w-full group cursor-pointer">
<div class="w-full flex gap-1 items-end justify-center h-full relative">
<div class="w-3 bg-border-dark rounded-t-sm h-[45%] opacity-50 group-hover:opacity-70 transition-all"></div>
<div class="w-3 bg-primary rounded-t-sm h-[60%] group-hover:bg-blue-400 transition-all relative">
<div class="absolute -top-8 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10">$2,600</div>
</div>
</div>
<span class="text-xs text-text-muted">Jul</span>
</div>
<div class="flex flex-col items-center gap-2 w-full group cursor-pointer">
<div class="w-full flex gap-1 items-end justify-center h-full relative">
<div class="w-3 bg-border-dark rounded-t-sm h-[50%] opacity-50 group-hover:opacity-70 transition-all"></div>
<div class="w-3 bg-primary rounded-t-sm h-[45%] group-hover:bg-blue-400 transition-all relative">
<div class="absolute -top-8 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10">$2,100</div>
</div>
</div>
<span class="text-xs text-text-muted">Aug</span>
</div>
<div class="flex flex-col items-center gap-2 w-full group cursor-pointer">
<div class="w-full flex gap-1 items-end justify-center h-full relative">
<div class="w-3 bg-border-dark rounded-t-sm h-[55%] opacity-50 group-hover:opacity-70 transition-all"></div>
<div class="w-3 bg-primary rounded-t-sm h-[75%] group-hover:bg-blue-400 transition-all relative">
<div class="absolute -top-8 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10">$3,200</div>
</div>
</div>
<span class="text-xs text-text-muted">Sep</span>
</div>
<div class="flex flex-col items-center gap-2 w-full group cursor-pointer">
<div class="w-full flex gap-1 items-end justify-center h-full relative">
<div class="w-3 bg-border-dark rounded-t-sm h-[60%] opacity-50 group-hover:opacity-70 transition-all"></div>
<div class="w-3 bg-primary rounded-t-sm h-[65%] group-hover:bg-blue-400 transition-all relative">
<div class="absolute -top-8 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10">$2,800</div>
</div>
</div>
<span class="text-xs text-text-muted">Oct</span>
</div>
<div class="flex flex-col items-center gap-2 w-full group cursor-pointer">
<div class="w-full flex gap-1 items-end justify-center h-full relative">
<div class="w-3 bg-border-dark rounded-t-sm h-[48%] opacity-50 group-hover:opacity-70 transition-all"></div>
<div class="w-3 bg-primary rounded-t-sm h-[85%] group-hover:bg-blue-400 transition-all relative">
<div class="absolute -top-8 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10">$4,250</div>
</div>
</div>
<span class="text-xs font-semibold text-primary">Nov</span>
</div>
</div>
</div>
<div class="bg-card-dark p-6 rounded-xl border border-border-dark shadow-sm flex flex-col">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-bold text-text-main">Smart Recommendations</h3>
<button class="text-xs text-primary hover:text-blue-400 font-medium transition-colors">View All</button>
</div>
<div class="flex flex-col gap-4">
<div class="flex gap-4 p-4 rounded-lg bg-background-dark border border-border-dark hover:border-primary/30 transition-all group cursor-pointer">
<div class="bg-yellow-500/10 p-3 rounded-lg h-fit text-yellow-500">
<span class="material-symbols-outlined text-[24px]">lightbulb</span>
</div>
<div class="flex flex-col gap-1">
<h4 class="text-sm font-semibold text-text-main group-hover:text-primary transition-colors">Reduce Subscription Costs</h4>
<p class="text-xs text-text-muted leading-relaxed">You have 4 active streaming subscriptions totaling $68/mo. Consolidating could save you up to $25/mo.</p>
</div>
</div>
<div class="flex gap-4 p-4 rounded-lg bg-background-dark border border-border-dark hover:border-primary/30 transition-all group cursor-pointer">
<div class="bg-green-500/10 p-3 rounded-lg h-fit text-green-500">
<span class="material-symbols-outlined text-[24px]">trending_up</span>
</div>
<div class="flex flex-col gap-1">
<h4 class="text-sm font-semibold text-text-main group-hover:text-primary transition-colors">Increase Savings Goal</h4>
<p class="text-xs text-text-muted leading-relaxed">Your spending in "Dining Out" decreased by 15%. Consider moving the surplus $120 to your emergency fund.</p>
</div>
</div>
<div class="flex gap-4 p-4 rounded-lg bg-background-dark border border-border-dark hover:border-primary/30 transition-all group cursor-pointer">
<div class="bg-red-500/10 p-3 rounded-lg h-fit text-red-500">
<span class="material-symbols-outlined text-[24px]">warning</span>
</div>
<div class="flex flex-col gap-1">
<h4 class="text-sm font-semibold text-text-main group-hover:text-primary transition-colors">Unusual Activity Detected</h4>
<p class="text-xs text-text-muted leading-relaxed">A transaction of $450 at "TechGadgets Inc" is 200% higher than your average for this category.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

View file

@ -0,0 +1,414 @@
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Reports - Expense Tracker</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script id="tailwind-config">
tailwind.config = {
theme: {
extend: {
colors: {
"primary": "#3b82f6",
"background-dark": "#0f172a",
"card-dark": "#1e293b",
"border-dark": "#334155",
"text-main": "#f8fafc",
"text-muted": "#94a3b8",
"accent": "#6366f1",
"success": "#10b981",
"warning": "#f59e0b",
"danger": "#ef4444",
},
fontFamily: {
"display": ["Inter", "sans-serif"]
},
borderRadius: {"DEFAULT": "0.25rem", "lg": "0.5rem", "xl": "0.75rem", "2xl": "1rem", "full": "9999px"},
},
},
}
</script>
<style>
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #0f172a;
}
::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #475569;
}
</style>
</head>
<body class="bg-background-dark text-text-main font-display overflow-hidden antialiased">
<div class="flex h-screen w-full">
<aside class="hidden lg:flex w-64 flex-col bg-card-dark border-r border-border-dark shadow-sm z-20">
<div class="p-6 flex flex-col h-full justify-between">
<div class="flex flex-col gap-8">
<div class="flex gap-3 items-center">
<div class="bg-center bg-no-repeat bg-cover rounded-full size-10 border border-border-dark" data-alt="User profile picture showing a smiling professional" style='background-image: url("https://lh3.googleusercontent.com/aida-public/AB6AXuANUUrJ2rc1XWSNKLt215RxpZflCxewvvZq_ZylsIILmZIjcBUGlPtBIQNmsPHhZs6WjM660ExRzDkFkwHPzxwS6ta2zQ9lFfEMpKK9Ii7RTs6B-lCsDP94jjuZnAzZsrS-ZR_DLQjI16FAzAz_GvyrW9fSGpXcjzLFztbygeR64wagIlFwfYRd3RdlEG2GWH2aDXCrEO86UxzdHzBi13r2tqFH35vfLFwcg2kcbuLP4kkwWk_kese2hD4N0GgXuehsBv8AUzsQ6DU");'>
</div>
<div class="flex flex-col">
<h1 class="text-text-main text-base font-bold leading-none">Alex Morgan</h1>
<p class="text-text-muted text-xs font-normal mt-1">Premium Plan</p>
</div>
</div>
<nav class="flex flex-col gap-2">
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-white/5 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">dashboard</span>
<span class="text-sm font-medium">Dashboard</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-white/5 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">receipt_long</span>
<span class="text-sm font-medium">Transactions</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-primary/10 text-primary border border-primary/10" href="#">
<span class="material-symbols-outlined text-[20px]" data-weight="fill">pie_chart</span>
<span class="text-sm font-medium">Reports</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-white/5 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">folder_open</span>
<span class="text-sm font-medium">Documents</span>
</a>
</nav>
</div>
<div class="flex flex-col gap-2">
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-white/5 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">settings</span>
<span class="text-sm font-medium">Settings</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-white/5 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">logout</span>
<span class="text-sm font-medium">Log out</span>
</a>
</div>
</div>
</aside>
<main class="flex-1 flex flex-col h-full overflow-hidden relative bg-background-dark">
<header class="h-16 flex items-center justify-between px-6 lg:px-8 border-b border-border-dark bg-background-dark/80 backdrop-blur z-10 shrink-0">
<div class="flex items-center gap-4">
<button class="lg:hidden text-text-main">
<span class="material-symbols-outlined">menu</span>
</button>
<h2 class="text-text-main text-lg font-bold">Financial Reports</h2>
</div>
<div class="flex items-center gap-6">
<div class="flex items-center gap-3">
<button class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-text-muted hover:text-text-main hover:bg-white/5 rounded-lg border border-transparent hover:border-border-dark transition-all">
<span class="material-symbols-outlined text-[18px]">download</span>
<span class="hidden sm:inline">Export CSV</span>
</button>
<div class="w-px h-6 bg-border-dark mx-1 hidden sm:block"></div>
<button class="size-9 rounded-lg bg-card-dark border border-border-dark flex items-center justify-center text-text-muted hover:text-text-main hover:bg-white/5 transition-colors relative shadow-sm">
<span class="material-symbols-outlined text-[20px]">notifications</span>
<span class="absolute top-2 right-2.5 size-1.5 bg-primary rounded-full border border-card-dark"></span>
</button>
</div>
</div>
</header>
<div class="flex-1 overflow-y-auto p-6 lg:p-8 scroll-smooth">
<div class="max-w-7xl mx-auto flex flex-col gap-6 pb-10">
<div class="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 bg-card-dark p-4 rounded-xl border border-border-dark shadow-sm">
<div class="flex items-center gap-3">
<h3 class="text-sm font-semibold text-text-muted uppercase tracking-wider">Analysis Period:</h3>
<div class="flex bg-background-dark rounded-lg p-1 border border-border-dark">
<button class="px-3 py-1 text-sm font-medium rounded text-text-main bg-white/10 shadow-sm">Last 30 Days</button>
<button class="px-3 py-1 text-sm font-medium rounded text-text-muted hover:text-text-main hover:bg-white/5 transition-colors">Quarter</button>
<button class="px-3 py-1 text-sm font-medium rounded text-text-muted hover:text-text-main hover:bg-white/5 transition-colors">YTD</button>
<button class="px-3 py-1 text-sm font-medium rounded text-text-muted hover:text-text-main hover:bg-white/5 transition-colors">Custom</button>
</div>
</div>
<div class="flex flex-wrap items-center gap-3 w-full lg:w-auto">
<div class="relative group">
<button class="flex items-center gap-2 px-3 py-2 bg-background-dark border border-border-dark rounded-lg text-text-muted hover:text-text-main hover:border-primary/50 transition-colors text-sm w-full lg:w-48 justify-between">
<span class="flex items-center gap-2">
<span class="material-symbols-outlined text-[18px]">category</span>
All Categories
</span>
<span class="material-symbols-outlined text-[18px]">expand_more</span>
</button>
</div>
<button class="flex-1 sm:flex-none bg-primary hover:bg-blue-600 text-white h-10 px-4 rounded-lg text-sm font-semibold shadow-lg shadow-primary/20 transition-all flex items-center justify-center gap-2">
<span class="material-symbols-outlined text-[18px]">autorenew</span>
<span>Generate Report</span>
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div class="bg-card-dark p-5 rounded-xl border border-border-dark shadow-sm hover:border-primary/30 transition-colors group">
<div class="flex justify-between items-start mb-4">
<div class="flex flex-col">
<span class="text-text-muted text-xs font-medium uppercase tracking-wider">Total Spent</span>
<h4 class="text-2xl font-bold text-text-main mt-1">$4,250.00</h4>
</div>
<div class="p-2 bg-primary/10 rounded-lg text-primary group-hover:bg-primary group-hover:text-white transition-colors">
<span class="material-symbols-outlined text-[20px]">payments</span>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<span class="text-success flex items-center font-medium bg-success/10 px-1.5 py-0.5 rounded">
<span class="material-symbols-outlined text-[14px] mr-0.5">trending_down</span>
12%
</span>
<span class="text-text-muted">vs last month</span>
</div>
</div>
<div class="bg-card-dark p-5 rounded-xl border border-border-dark shadow-sm hover:border-accent/30 transition-colors group">
<div class="flex justify-between items-start mb-4">
<div class="flex flex-col">
<span class="text-text-muted text-xs font-medium uppercase tracking-wider">Top Category</span>
<h4 class="text-2xl font-bold text-text-main mt-1">Housing</h4>
</div>
<div class="p-2 bg-accent/10 rounded-lg text-accent group-hover:bg-accent group-hover:text-white transition-colors">
<span class="material-symbols-outlined text-[20px]">home</span>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<span class="text-text-main font-semibold">$1,850</span>
<span class="text-text-muted">spent this month</span>
</div>
</div>
<div class="bg-card-dark p-5 rounded-xl border border-border-dark shadow-sm hover:border-warning/30 transition-colors group">
<div class="flex justify-between items-start mb-4">
<div class="flex flex-col">
<span class="text-text-muted text-xs font-medium uppercase tracking-wider">Avg. Daily</span>
<h4 class="text-2xl font-bold text-text-main mt-1">$141.66</h4>
</div>
<div class="p-2 bg-warning/10 rounded-lg text-warning group-hover:bg-warning group-hover:text-white transition-colors">
<span class="material-symbols-outlined text-[20px]">calendar_today</span>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<span class="text-danger flex items-center font-medium bg-danger/10 px-1.5 py-0.5 rounded">
<span class="material-symbols-outlined text-[14px] mr-0.5">trending_up</span>
5%
</span>
<span class="text-text-muted">vs last month</span>
</div>
</div>
<div class="bg-card-dark p-5 rounded-xl border border-border-dark shadow-sm hover:border-success/30 transition-colors group">
<div class="flex justify-between items-start mb-4">
<div class="flex flex-col">
<span class="text-text-muted text-xs font-medium uppercase tracking-wider">Savings Rate</span>
<h4 class="text-2xl font-bold text-text-main mt-1">18.5%</h4>
</div>
<div class="p-2 bg-success/10 rounded-lg text-success group-hover:bg-success group-hover:text-white transition-colors">
<span class="material-symbols-outlined text-[20px]">savings</span>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<span class="text-success flex items-center font-medium bg-success/10 px-1.5 py-0.5 rounded">
<span class="material-symbols-outlined text-[14px] mr-0.5">arrow_upward</span>
2.1%
</span>
<span class="text-text-muted">vs last month</span>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 bg-card-dark p-6 rounded-xl border border-border-dark shadow-sm flex flex-col">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-bold text-text-main">Spending Trend</h3>
<button class="text-text-muted hover:text-text-main transition-colors">
<span class="material-symbols-outlined">more_horiz</span>
</button>
</div>
<div class="flex-1 min-h-[300px] w-full relative flex items-end gap-2 px-4 pb-4 border-b border-border-dark/50">
<div class="absolute inset-0 top-10 left-4 right-4 bg-gradient-to-b from-primary/20 to-transparent opacity-50" style="clip-path: polygon(0 80%, 10% 60%, 20% 70%, 30% 50%, 40% 65%, 50% 40%, 60% 55%, 70% 30%, 80% 45%, 90% 20%, 100% 40%, 100% 100%, 0 100%);"></div>
<div class="absolute inset-0 top-10 left-4 right-4 h-full pointer-events-none">
<svg class="w-full h-full overflow-visible" preserveAspectRatio="none" viewBox="0 0 100 100">
<path d="M0 80 L10 60 L20 70 L30 50 L40 65 L50 40 L60 55 L70 30 L80 45 L90 20 L100 40" fill="none" stroke="#3b82f6" stroke-linecap="round" stroke-linejoin="round" stroke-width="0.5" vector-effect="non-scaling-stroke"></path>
<circle cx="0" cy="80" fill="#1e293b" r="1" stroke="#3b82f6" stroke-width="0.5"></circle>
<circle cx="10" cy="60" fill="#1e293b" r="1" stroke="#3b82f6" stroke-width="0.5"></circle>
<circle cx="20" cy="70" fill="#1e293b" r="1" stroke="#3b82f6" stroke-width="0.5"></circle>
<circle cx="30" cy="50" fill="#1e293b" r="1" stroke="#3b82f6" stroke-width="0.5"></circle>
<circle cx="40" cy="65" fill="#1e293b" r="1" stroke="#3b82f6" stroke-width="0.5"></circle>
<circle cx="50" cy="40" fill="#1e293b" r="1" stroke="#3b82f6" stroke-width="0.5"></circle>
<circle cx="60" cy="55" fill="#1e293b" r="1" stroke="#3b82f6" stroke-width="0.5"></circle>
<circle cx="70" cy="30" fill="#1e293b" r="1" stroke="#3b82f6" stroke-width="0.5"></circle>
<circle cx="80" cy="45" fill="#1e293b" r="1" stroke="#3b82f6" stroke-width="0.5"></circle>
<circle cx="90" cy="20" fill="#1e293b" r="1" stroke="#3b82f6" stroke-width="0.5"></circle>
<circle cx="100" cy="40" fill="#1e293b" r="1" stroke="#3b82f6" stroke-width="0.5"></circle>
</svg>
</div>
<div class="absolute bottom-[-24px] left-0 w-full flex justify-between text-xs text-text-muted px-4">
<span>1 Nov</span>
<span>5 Nov</span>
<span>10 Nov</span>
<span>15 Nov</span>
<span>20 Nov</span>
<span>25 Nov</span>
<span>30 Nov</span>
</div>
<div class="absolute inset-0 flex flex-col justify-between pointer-events-none pb-8 pt-6 px-4">
<div class="w-full h-px bg-border-dark/30 border-t border-dashed border-border-dark"></div>
<div class="w-full h-px bg-border-dark/30 border-t border-dashed border-border-dark"></div>
<div class="w-full h-px bg-border-dark/30 border-t border-dashed border-border-dark"></div>
<div class="w-full h-px bg-border-dark/30 border-t border-dashed border-border-dark"></div>
<div class="w-full h-px bg-border-dark/30 border-t border-dashed border-border-dark"></div>
</div>
</div>
</div>
<div class="lg:col-span-1 bg-card-dark p-6 rounded-xl border border-border-dark shadow-sm flex flex-col">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-bold text-text-main">Category Breakdown</h3>
<button class="text-text-muted hover:text-text-main transition-colors">
<span class="material-symbols-outlined">filter_list</span>
</button>
</div>
<div class="flex flex-col items-center justify-center flex-1 gap-6">
<div class="relative size-48 rounded-full border-[16px] border-card-dark shadow-inner" style="background: conic-gradient(#3b82f6 0% 35%, #6366f1 35% 60%, #10b981 60% 80%, #f59e0b 80% 92%, #ef4444 92% 100%);">
<div class="absolute inset-0 m-6 bg-card-dark rounded-full flex flex-col items-center justify-center">
<span class="text-xs text-text-muted uppercase font-medium">Total</span>
<span class="text-xl font-bold text-text-main">$4,250</span>
</div>
</div>
<div class="w-full grid grid-cols-2 gap-3 text-sm">
<div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-primary"></span>
<span class="text-text-muted flex-1">Housing</span>
<span class="font-semibold text-text-main">35%</span>
</div>
<div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-accent"></span>
<span class="text-text-muted flex-1">Food</span>
<span class="font-semibold text-text-main">25%</span>
</div>
<div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-success"></span>
<span class="text-text-muted flex-1">Investments</span>
<span class="font-semibold text-text-main">20%</span>
</div>
<div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-warning"></span>
<span class="text-text-muted flex-1">Transport</span>
<span class="font-semibold text-text-main">12%</span>
</div>
<div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-danger"></span>
<span class="text-text-muted flex-1">Others</span>
<span class="font-semibold text-text-main">8%</span>
</div>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-card-dark p-6 rounded-xl border border-border-dark shadow-sm">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-bold text-text-main">Monthly Spending</h3>
<div class="flex gap-2">
<span class="flex items-center gap-1.5 text-xs text-text-muted">
<span class="size-2 rounded-full bg-primary"></span> 2023
</span>
<span class="flex items-center gap-1.5 text-xs text-text-muted">
<span class="size-2 rounded-full bg-border-dark"></span> 2022
</span>
</div>
</div>
<div class="h-64 w-full flex items-end justify-between gap-2 px-2">
<div class="flex flex-col items-center gap-2 w-full group cursor-pointer">
<div class="w-full flex gap-1 items-end justify-center h-full relative">
<div class="w-3 bg-border-dark rounded-t-sm h-[40%] opacity-50 group-hover:opacity-70 transition-all"></div>
<div class="w-3 bg-primary rounded-t-sm h-[55%] group-hover:bg-blue-400 transition-all relative">
<div class="absolute -top-8 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10">$2,400</div>
</div>
</div>
<span class="text-xs text-text-muted">Jun</span>
</div>
<div class="flex flex-col items-center gap-2 w-full group cursor-pointer">
<div class="w-full flex gap-1 items-end justify-center h-full relative">
<div class="w-3 bg-border-dark rounded-t-sm h-[45%] opacity-50 group-hover:opacity-70 transition-all"></div>
<div class="w-3 bg-primary rounded-t-sm h-[60%] group-hover:bg-blue-400 transition-all relative">
<div class="absolute -top-8 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10">$2,600</div>
</div>
</div>
<span class="text-xs text-text-muted">Jul</span>
</div>
<div class="flex flex-col items-center gap-2 w-full group cursor-pointer">
<div class="w-full flex gap-1 items-end justify-center h-full relative">
<div class="w-3 bg-border-dark rounded-t-sm h-[50%] opacity-50 group-hover:opacity-70 transition-all"></div>
<div class="w-3 bg-primary rounded-t-sm h-[45%] group-hover:bg-blue-400 transition-all relative">
<div class="absolute -top-8 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10">$2,100</div>
</div>
</div>
<span class="text-xs text-text-muted">Aug</span>
</div>
<div class="flex flex-col items-center gap-2 w-full group cursor-pointer">
<div class="w-full flex gap-1 items-end justify-center h-full relative">
<div class="w-3 bg-border-dark rounded-t-sm h-[55%] opacity-50 group-hover:opacity-70 transition-all"></div>
<div class="w-3 bg-primary rounded-t-sm h-[75%] group-hover:bg-blue-400 transition-all relative">
<div class="absolute -top-8 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10">$3,200</div>
</div>
</div>
<span class="text-xs text-text-muted">Sep</span>
</div>
<div class="flex flex-col items-center gap-2 w-full group cursor-pointer">
<div class="w-full flex gap-1 items-end justify-center h-full relative">
<div class="w-3 bg-border-dark rounded-t-sm h-[60%] opacity-50 group-hover:opacity-70 transition-all"></div>
<div class="w-3 bg-primary rounded-t-sm h-[65%] group-hover:bg-blue-400 transition-all relative">
<div class="absolute -top-8 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10">$2,800</div>
</div>
</div>
<span class="text-xs text-text-muted">Oct</span>
</div>
<div class="flex flex-col items-center gap-2 w-full group cursor-pointer">
<div class="w-full flex gap-1 items-end justify-center h-full relative">
<div class="w-3 bg-border-dark rounded-t-sm h-[48%] opacity-50 group-hover:opacity-70 transition-all"></div>
<div class="w-3 bg-primary rounded-t-sm h-[85%] group-hover:bg-blue-400 transition-all relative">
<div class="absolute -top-8 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10">$4,250</div>
</div>
</div>
<span class="text-xs font-semibold text-primary">Nov</span>
</div>
</div>
</div>
<div class="bg-card-dark p-6 rounded-xl border border-border-dark shadow-sm flex flex-col">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-bold text-text-main">Smart Recommendations</h3>
<button class="text-xs text-primary hover:text-blue-400 font-medium transition-colors">View All</button>
</div>
<div class="flex flex-col gap-4">
<div class="flex gap-4 p-4 rounded-lg bg-background-dark border border-border-dark hover:border-primary/30 transition-all group cursor-pointer">
<div class="bg-yellow-500/10 p-3 rounded-lg h-fit text-yellow-500">
<span class="material-symbols-outlined text-[24px]">lightbulb</span>
</div>
<div class="flex flex-col gap-1">
<h4 class="text-sm font-semibold text-text-main group-hover:text-primary transition-colors">Reduce Subscription Costs</h4>
<p class="text-xs text-text-muted leading-relaxed">You have 4 active streaming subscriptions totaling $68/mo. Consolidating could save you up to $25/mo.</p>
</div>
</div>
<div class="flex gap-4 p-4 rounded-lg bg-background-dark border border-border-dark hover:border-primary/30 transition-all group cursor-pointer">
<div class="bg-green-500/10 p-3 rounded-lg h-fit text-green-500">
<span class="material-symbols-outlined text-[24px]">trending_up</span>
</div>
<div class="flex flex-col gap-1">
<h4 class="text-sm font-semibold text-text-main group-hover:text-primary transition-colors">Increase Savings Goal</h4>
<p class="text-xs text-text-muted leading-relaxed">Your spending in "Dining Out" decreased by 15%. Consider moving the surplus $120 to your emergency fund.</p>
</div>
</div>
<div class="flex gap-4 p-4 rounded-lg bg-background-dark border border-border-dark hover:border-primary/30 transition-all group cursor-pointer">
<div class="bg-red-500/10 p-3 rounded-lg h-fit text-red-500">
<span class="material-symbols-outlined text-[24px]">warning</span>
</div>
<div class="flex flex-col gap-1">
<h4 class="text-sm font-semibold text-text-main group-hover:text-primary transition-colors">Unusual Activity Detected</h4>
<p class="text-xs text-text-muted leading-relaxed">A transaction of $450 at "TechGadgets Inc" is 200% higher than your average for this category.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

View file

@ -0,0 +1,379 @@
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Documents - Expense Tracker</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script id="tailwind-config">
tailwind.config = {
theme: {
extend: {
colors: {
"primary": "#3b82f6",
"background-dark": "#0f172a",
"card-dark": "#1e293b",
"border-dark": "#334155",
"text-main": "#f8fafc",
"text-muted": "#94a3b8",
"accent": "#6366f1",
"success": "#10b981",
"warning": "#f59e0b",
"danger": "#ef4444",
},
fontFamily: {
"display": ["Inter", "sans-serif"]
},
borderRadius: {"DEFAULT": "0.25rem", "lg": "0.5rem", "xl": "0.75rem", "2xl": "1rem", "full": "9999px"},
},
},
}
</script>
<style>
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #0f172a;
}
::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #475569;
}
</style>
</head>
<body class="bg-background-dark text-text-main font-display overflow-hidden antialiased">
<div class="flex h-screen w-full">
<aside class="hidden lg:flex w-64 flex-col bg-card-dark border-r border-border-dark shadow-sm z-20">
<div class="p-6 flex flex-col h-full justify-between">
<div class="flex flex-col gap-8">
<div class="flex gap-3 items-center">
<div class="bg-center bg-no-repeat bg-cover rounded-full size-10 border border-border-dark" data-alt="User profile picture showing a smiling professional" style='background-image: url("https://lh3.googleusercontent.com/aida-public/AB6AXuANUUrJ2rc1XWSNKLt215RxpZflCxewvvZq_ZylsIILmZIjcBUGlPtBIQNmsPHhZs6WjM660ExRzDkFkwHPzxwS6ta2zQ9lFfEMpKK9Ii7RTs6B-lCsDP94jjuZnAzZsrS-ZR_DLQjI16FAzAz_GvyrW9fSGpXcjzLFztbygeR64wagIlFwfYRd3RdlEG2GWH2aDXCrEO86UxzdHzBi13r2tqFH35vfLFwcg2kcbuLP4kkwWk_kese2hD4N0GgXuehsBv8AUzsQ6DU");'>
</div>
<div class="flex flex-col">
<h1 class="text-text-main text-base font-bold leading-none">Alex Morgan</h1>
<p class="text-text-muted text-xs font-normal mt-1">Premium Plan</p>
</div>
</div>
<nav class="flex flex-col gap-2">
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-white/5 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">dashboard</span>
<span class="text-sm font-medium">Dashboard</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-white/5 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">receipt_long</span>
<span class="text-sm font-medium">Transactions</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-white/5 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">pie_chart</span>
<span class="text-sm font-medium">Reports</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-primary/10 text-primary border border-primary/10" href="#">
<span class="material-symbols-outlined text-[20px]" data-weight="fill">folder_open</span>
<span class="text-sm font-medium">Documents</span>
</a>
</nav>
</div>
<div class="flex flex-col gap-2">
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-white/5 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">settings</span>
<span class="text-sm font-medium">Settings</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-muted hover:bg-white/5 hover:text-text-main transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">logout</span>
<span class="text-sm font-medium">Log out</span>
</a>
</div>
</div>
</aside>
<main class="flex-1 flex flex-col h-full overflow-hidden relative bg-background-dark">
<header class="h-16 flex items-center justify-between px-6 lg:px-8 border-b border-border-dark bg-background-dark/80 backdrop-blur z-10 shrink-0">
<div class="flex items-center gap-4">
<button class="lg:hidden text-text-main">
<span class="material-symbols-outlined">menu</span>
</button>
<h2 class="text-text-main text-lg font-bold">Documents</h2>
</div>
<div class="flex items-center gap-6">
<div class="flex items-center gap-3">
<div class="hidden sm:flex items-center text-xs text-text-muted bg-card-dark border border-border-dark px-2 py-1 rounded gap-2">
<span class="size-2 rounded-full bg-success animate-pulse"></span>
<span>System Status: Optimal</span>
</div>
<div class="w-px h-6 bg-border-dark mx-1 hidden sm:block"></div>
<button class="size-9 rounded-lg bg-card-dark border border-border-dark flex items-center justify-center text-text-muted hover:text-text-main hover:bg-white/5 transition-colors relative shadow-sm">
<span class="material-symbols-outlined text-[20px]">notifications</span>
<span class="absolute top-2 right-2.5 size-1.5 bg-primary rounded-full border border-card-dark"></span>
</button>
</div>
</div>
</header>
<div class="flex-1 overflow-y-auto p-6 lg:p-8 scroll-smooth">
<div class="max-w-7xl mx-auto flex flex-col gap-8 pb-10">
<div class="flex flex-col gap-4">
<h3 class="text-base font-semibold text-text-main">Upload Documents</h3>
<div class="bg-card-dark border-2 border-dashed border-border-dark rounded-xl p-10 flex flex-col items-center justify-center text-center hover:border-primary/50 hover:bg-white/[0.02] transition-all cursor-pointer group relative overflow-hidden">
<input class="absolute inset-0 opacity-0 cursor-pointer z-10" type="file"/>
<div class="bg-primary/10 p-4 rounded-full text-primary mb-4 group-hover:scale-110 transition-transform duration-300">
<span class="material-symbols-outlined text-[32px]">cloud_upload</span>
</div>
<h3 class="text-lg font-semibold text-text-main mb-1">Drag &amp; drop files here or click to browse</h3>
<p class="text-text-muted text-sm max-w-sm leading-relaxed">
Upload bank statements, invoices, or receipts. <br/>
<span class="text-xs text-text-muted/70">Supported formats: CSV, PDF, XLS (Max 10MB)</span>
</p>
</div>
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<h3 class="text-base font-semibold text-text-main">Your Files</h3>
<div class="flex flex-col sm:flex-row gap-3 w-full md:w-auto">
<div class="relative flex-1 min-w-[240px]">
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-text-muted text-[20px]">search</span>
<input class="w-full bg-card-dark border border-border-dark rounded-lg py-2 pl-10 pr-4 text-sm text-text-main focus:ring-1 focus:ring-primary focus:border-primary outline-none transition-all placeholder:text-text-muted/50" placeholder="Search by name..." type="text"/>
</div>
<div class="flex gap-2">
<button class="px-3 py-2 bg-card-dark border border-border-dark rounded-lg text-text-muted hover:text-text-main text-sm font-medium flex items-center gap-2 transition-colors">
<span class="material-symbols-outlined text-[18px]">filter_list</span>
Filter
</button>
<button class="px-3 py-2 bg-card-dark border border-border-dark rounded-lg text-text-muted hover:text-text-main text-sm font-medium flex items-center gap-2 transition-colors">
<span class="material-symbols-outlined text-[18px]">sort</span>
Sort
</button>
</div>
</div>
</div>
<div class="bg-card-dark border border-border-dark rounded-xl overflow-hidden shadow-sm">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="text-xs text-text-muted uppercase bg-white/5 border-b border-border-dark">
<tr>
<th class="px-6 py-4 font-medium">Document Name</th>
<th class="px-6 py-4 font-medium">Upload Date</th>
<th class="px-6 py-4 font-medium">Type</th>
<th class="px-6 py-4 font-medium">Status</th>
<th class="px-6 py-4 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-border-dark">
<tr class="hover:bg-white/[0.02] transition-colors group">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded bg-primary/10 text-primary">
<span class="material-symbols-outlined text-[20px]">picture_as_pdf</span>
</div>
<div class="flex flex-col">
<span class="font-medium text-text-main">Chase_Statement_Oct2023.pdf</span>
<span class="text-xs text-text-muted">2.4 MB</span>
</div>
</div>
</td>
<td class="px-6 py-4 text-text-muted">Nov 01, 2023</td>
<td class="px-6 py-4 text-text-muted">Bank Statement</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-success/10 text-success border border-success/20">
<span class="size-1.5 rounded-full bg-success"></span>
Analyzed
</span>
<div class="relative group/tooltip">
<span class="material-symbols-outlined text-[16px] text-success cursor-help">verified</span>
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-max px-2 py-1 bg-gray-800 text-white text-[10px] rounded opacity-0 group-hover/tooltip:opacity-100 transition-opacity pointer-events-none">
Added to reports
</div>
</div>
</div>
</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end gap-2">
<button class="p-1.5 text-text-muted hover:text-primary hover:bg-primary/10 rounded transition-colors" title="View">
<span class="material-symbols-outlined text-[18px]">visibility</span>
</button>
<button class="p-1.5 text-text-muted hover:text-text-main hover:bg-white/5 rounded transition-colors" title="Download">
<span class="material-symbols-outlined text-[18px]">download</span>
</button>
<button class="p-1.5 text-text-muted hover:text-danger hover:bg-danger/10 rounded transition-colors" title="Delete">
<span class="material-symbols-outlined text-[18px]">delete</span>
</button>
</div>
</td>
</tr>
<tr class="hover:bg-white/[0.02] transition-colors group">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded bg-green-500/10 text-green-500">
<span class="material-symbols-outlined text-[20px]">table_view</span>
</div>
<div class="flex flex-col">
<span class="font-medium text-text-main">Uber_Receipts_Q3.csv</span>
<span class="text-xs text-text-muted">845 KB</span>
</div>
</div>
</td>
<td class="px-6 py-4 text-text-muted">Nov 05, 2023</td>
<td class="px-6 py-4 text-text-muted">Expense Report</td>
<td class="px-6 py-4">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-accent/10 text-accent border border-accent/20">
<span class="size-1.5 rounded-full bg-accent animate-pulse"></span>
Processing
</span>
</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end gap-2">
<button class="p-1.5 text-text-muted/50 cursor-not-allowed rounded" disabled="" title="View">
<span class="material-symbols-outlined text-[18px]">visibility</span>
</button>
<button class="p-1.5 text-text-muted hover:text-text-main hover:bg-white/5 rounded transition-colors" title="Download">
<span class="material-symbols-outlined text-[18px]">download</span>
</button>
<button class="p-1.5 text-text-muted hover:text-danger hover:bg-danger/10 rounded transition-colors" title="Delete">
<span class="material-symbols-outlined text-[18px]">delete</span>
</button>
</div>
</td>
</tr>
<tr class="hover:bg-white/[0.02] transition-colors group">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded bg-primary/10 text-primary">
<span class="material-symbols-outlined text-[20px]">picture_as_pdf</span>
</div>
<div class="flex flex-col">
<span class="font-medium text-text-main">Amex_Statement_Sep.pdf</span>
<span class="text-xs text-text-muted">1.8 MB</span>
</div>
</div>
</td>
<td class="px-6 py-4 text-text-muted">Oct 15, 2023</td>
<td class="px-6 py-4 text-text-muted">Credit Card</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-danger/10 text-danger border border-danger/20">
<span class="material-symbols-outlined text-[14px]">error</span>
Error
</span>
<span class="text-xs text-text-muted hidden xl:inline">Format unrecognized</span>
</div>
</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end gap-2">
<button class="p-1.5 text-text-muted hover:text-primary hover:bg-primary/10 rounded transition-colors" title="Retry">
<span class="material-symbols-outlined text-[18px]">refresh</span>
</button>
<button class="p-1.5 text-text-muted hover:text-text-main hover:bg-white/5 rounded transition-colors" title="Download">
<span class="material-symbols-outlined text-[18px]">download</span>
</button>
<button class="p-1.5 text-text-muted hover:text-danger hover:bg-danger/10 rounded transition-colors" title="Delete">
<span class="material-symbols-outlined text-[18px]">delete</span>
</button>
</div>
</td>
</tr>
<tr class="hover:bg-white/[0.02] transition-colors group">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded bg-primary/10 text-primary">
<span class="material-symbols-outlined text-[20px]">picture_as_pdf</span>
</div>
<div class="flex flex-col">
<span class="font-medium text-text-main">Housing_Contract_2023.pdf</span>
<span class="text-xs text-text-muted">4.2 MB</span>
</div>
</div>
</td>
<td class="px-6 py-4 text-text-muted">Sep 28, 2023</td>
<td class="px-6 py-4 text-text-muted">Contract</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-success/10 text-success border border-success/20">
<span class="size-1.5 rounded-full bg-success"></span>
Analyzed
</span>
<div class="relative group/tooltip">
<span class="material-symbols-outlined text-[16px] text-success cursor-help">verified</span>
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-max px-2 py-1 bg-gray-800 text-white text-[10px] rounded opacity-0 group-hover/tooltip:opacity-100 transition-opacity pointer-events-none">
Added to reports
</div>
</div>
</div>
</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end gap-2">
<button class="p-1.5 text-text-muted hover:text-primary hover:bg-primary/10 rounded transition-colors" title="View">
<span class="material-symbols-outlined text-[18px]">visibility</span>
</button>
<button class="p-1.5 text-text-muted hover:text-text-main hover:bg-white/5 rounded transition-colors" title="Download">
<span class="material-symbols-outlined text-[18px]">download</span>
</button>
<button class="p-1.5 text-text-muted hover:text-danger hover:bg-danger/10 rounded transition-colors" title="Delete">
<span class="material-symbols-outlined text-[18px]">delete</span>
</button>
</div>
</td>
</tr>
<tr class="hover:bg-white/[0.02] transition-colors group">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded bg-green-500/10 text-green-500">
<span class="material-symbols-outlined text-[20px]">table_view</span>
</div>
<div class="flex flex-col">
<span class="font-medium text-text-main">Investments_Q2.xlsx</span>
<span class="text-xs text-text-muted">1.1 MB</span>
</div>
</div>
</td>
<td class="px-6 py-4 text-text-muted">Sep 12, 2023</td>
<td class="px-6 py-4 text-text-muted">Investment</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-success/10 text-success border border-success/20">
<span class="size-1.5 rounded-full bg-success"></span>
Analyzed
</span>
<div class="relative group/tooltip">
<span class="material-symbols-outlined text-[16px] text-success cursor-help">verified</span>
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-max px-2 py-1 bg-gray-800 text-white text-[10px] rounded opacity-0 group-hover/tooltip:opacity-100 transition-opacity pointer-events-none">
Added to reports
</div>
</div>
</div>
</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end gap-2">
<button class="p-1.5 text-text-muted hover:text-primary hover:bg-primary/10 rounded transition-colors" title="View">
<span class="material-symbols-outlined text-[18px]">visibility</span>
</button>
<button class="p-1.5 text-text-muted hover:text-text-main hover:bg-white/5 rounded transition-colors" title="Download">
<span class="material-symbols-outlined text-[18px]">download</span>
</button>
<button class="p-1.5 text-text-muted hover:text-danger hover:bg-danger/10 rounded transition-colors" title="Delete">
<span class="material-symbols-outlined text-[18px]">delete</span>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="bg-white/5 border-t border-border-dark p-4 flex items-center justify-between">
<span class="text-sm text-text-muted">Showing <span class="text-text-main font-medium">1-5</span> of <span class="text-text-main font-medium">24</span> documents</span>
<div class="flex gap-2">
<button class="px-3 py-1.5 text-sm text-text-muted hover:text-text-main hover:bg-white/5 rounded transition-colors disabled:opacity-50" disabled="">Previous</button>
<button class="px-3 py-1.5 text-sm text-text-muted hover:text-text-main hover:bg-white/5 rounded transition-colors">Next</button>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

View file

@ -0,0 +1,375 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Expense Tracking Dashboard</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"primary": "#2b8cee",
"background-light": "#f6f7f8",
"background-dark": "#111a22",
"card-dark": "#1a2632", // Slightly lighter than background-dark
"card-light": "#ffffff",
},
fontFamily: {
"display": ["Inter", "sans-serif"]
},
borderRadius: {"DEFAULT": "0.25rem", "lg": "0.5rem", "xl": "0.75rem", "2xl": "1rem", "full": "9999px"},
},
},
}
</script>
<style>
/* Custom scrollbar for modern feel */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #324d67;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #4b6a88;
}
</style>
</head>
<body class="bg-background-light dark:bg-background-dark text-slate-900 dark:text-white font-display overflow-hidden">
<div class="flex h-screen w-full">
<!-- Side Navigation -->
<aside class="hidden lg:flex w-64 flex-col bg-[#111a22] border-r border-[#233648]">
<div class="p-6 flex flex-col h-full justify-between">
<div class="flex flex-col gap-8">
<!-- User Profile Brief -->
<div class="flex gap-3 items-center">
<div class="bg-center bg-no-repeat bg-cover rounded-full size-10 border border-[#233648]" data-alt="User profile picture showing a smiling professional" style='background-image: url("https://lh3.googleusercontent.com/aida-public/AB6AXuANUUrJ2rc1XWSNKLt215RxpZflCxewvvZq_ZylsIILmZIjcBUGlPtBIQNmsPHhZs6WjM660ExRzDkFkwHPzxwS6ta2zQ9lFfEMpKK9Ii7RTs6B-lCsDP94jjuZnAzZsrS-ZR_DLQjI16FAzAz_GvyrW9fSGpXcjzLFztbygeR64wagIlFwfYRd3RdlEG2GWH2aDXCrEO86UxzdHzBi13r2tqFH35vfLFwcg2kcbuLP4kkwWk_kese2hD4N0GgXuehsBv8AUzsQ6DU");'>
</div>
<div class="flex flex-col">
<h1 class="text-white text-base font-bold leading-none">Alex Morgan</h1>
<p class="text-[#92adc9] text-xs font-normal mt-1">Free Plan</p>
</div>
</div>
<!-- Navigation Links -->
<nav class="flex flex-col gap-2">
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-primary/20 text-primary border border-primary/10" href="#">
<span class="material-symbols-outlined text-[20px]" data-weight="fill">dashboard</span>
<span class="text-sm font-medium">Dashboard</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-[#92adc9] hover:bg-[#233648] hover:text-white transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">receipt_long</span>
<span class="text-sm font-medium">Transactions</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-[#92adc9] hover:bg-[#233648] hover:text-white transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">pie_chart</span>
<span class="text-sm font-medium">Reports</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-[#92adc9] hover:bg-[#233648] hover:text-white transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">folder_open</span>
<span class="text-sm font-medium">Documents</span>
</a>
</nav>
</div>
<!-- Bottom Links -->
<div class="flex flex-col gap-2">
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-[#92adc9] hover:bg-[#233648] hover:text-white transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">settings</span>
<span class="text-sm font-medium">Settings</span>
</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-[#92adc9] hover:bg-[#233648] hover:text-white transition-colors" href="#">
<span class="material-symbols-outlined text-[20px]">logout</span>
<span class="text-sm font-medium">Log out</span>
</a>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 flex flex-col h-full overflow-hidden relative">
<!-- Top Header -->
<header class="h-16 flex items-center justify-between px-6 lg:px-8 border-b border-[#233648] bg-background-dark/95 backdrop-blur z-10 shrink-0">
<div class="flex items-center gap-4">
<button class="lg:hidden text-white">
<span class="material-symbols-outlined">menu</span>
</button>
<h2 class="text-white text-lg font-bold">Dashboard</h2>
</div>
<div class="flex items-center gap-6">
<!-- Search Bar -->
<div class="hidden md:flex items-center bg-[#1a2632] rounded-lg h-10 px-3 border border-[#233648] focus-within:border-primary transition-colors w-64">
<span class="material-symbols-outlined text-[#92adc9] text-[20px]">search</span>
<input class="bg-transparent border-none text-white text-sm placeholder-[#5f7a96] focus:ring-0 w-full ml-2" placeholder="Search expenses..." type="text"/>
</div>
<!-- Actions -->
<div class="flex items-center gap-3">
<button class="bg-primary hover:bg-primary/90 text-white h-9 px-4 rounded-lg text-sm font-semibold shadow-lg shadow-primary/20 transition-all flex items-center gap-2">
<span class="material-symbols-outlined text-[18px]">add</span>
<span class="hidden sm:inline">Add Expense</span>
</button>
<button class="size-9 rounded-lg bg-[#1a2632] border border-[#233648] flex items-center justify-center text-[#92adc9] hover:text-white hover:bg-[#233648] transition-colors relative">
<span class="material-symbols-outlined text-[20px]">notifications</span>
<span class="absolute top-2 right-2.5 size-1.5 bg-red-500 rounded-full border border-[#1a2632]"></span>
</button>
</div>
</div>
</header>
<!-- Scrollable Content -->
<div class="flex-1 overflow-y-auto p-6 lg:p-8 scroll-smooth">
<div class="max-w-7xl mx-auto flex flex-col gap-8 pb-10">
<!-- KPI Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 lg:gap-6">
<!-- Total Spent -->
<div class="p-6 rounded-xl bg-[#1a2632] border border-[#233648] flex flex-col justify-between relative overflow-hidden group">
<div class="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<span class="material-symbols-outlined text-6xl text-primary">payments</span>
</div>
<div>
<p class="text-[#92adc9] text-sm font-medium">Total Spent</p>
<h3 class="text-white text-3xl font-bold mt-2 tracking-tight">$2,450.00</h3>
</div>
<div class="flex items-center gap-2 mt-4">
<span class="bg-green-500/10 text-green-400 text-xs font-semibold px-2 py-1 rounded-full flex items-center gap-1">
<span class="material-symbols-outlined text-[14px]">trending_up</span>
12%
</span>
<span class="text-[#5f7a96] text-xs">vs last month</span>
</div>
</div>
<!-- Active Categories -->
<div class="p-6 rounded-xl bg-[#1a2632] border border-[#233648] flex flex-col justify-between">
<div>
<p class="text-[#92adc9] text-sm font-medium">Active Categories</p>
<h3 class="text-white text-3xl font-bold mt-2 tracking-tight">8</h3>
</div>
<div class="w-full bg-[#233648] h-1.5 rounded-full mt-6 overflow-hidden">
<div class="bg-purple-500 h-full rounded-full" style="width: 65%"></div>
</div>
<p class="text-[#5f7a96] text-xs mt-2">65% of budget utilized</p>
</div>
<!-- Total Transactions -->
<div class="p-6 rounded-xl bg-[#1a2632] border border-[#233648] flex flex-col justify-between">
<div>
<p class="text-[#92adc9] text-sm font-medium">Total Transactions</p>
<h3 class="text-white text-3xl font-bold mt-2 tracking-tight">42</h3>
</div>
<div class="flex items-center gap-2 mt-4">
<span class="bg-primary/10 text-primary text-xs font-semibold px-2 py-1 rounded-full flex items-center gap-1">
<span class="material-symbols-outlined text-[14px]">add</span>
5 New
</span>
<span class="text-[#5f7a96] text-xs">this week</span>
</div>
</div>
</div>
<!-- Charts Section -->
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- Bar Chart: Monthly Trends -->
<div class="xl:col-span-2 bg-[#1a2632] border border-[#233648] rounded-xl p-6 flex flex-col">
<div class="flex justify-between items-start mb-6">
<div>
<h3 class="text-white text-lg font-bold">Monthly Trends</h3>
<p class="text-[#92adc9] text-sm">Income vs Expense over time</p>
</div>
<button class="text-[#92adc9] hover:text-white text-sm bg-[#233648] px-3 py-1.5 rounded-lg transition-colors">Last 6 Months</button>
</div>
<div class="flex-1 flex items-end gap-3 sm:gap-6 min-h-[240px] px-2 pb-2">
<!-- Bars generation -->
<div class="flex flex-col items-center gap-2 flex-1 group cursor-pointer">
<div class="w-full max-w-[40px] bg-[#233648] rounded-t-sm relative h-[140px] group-hover:bg-[#2d445a] transition-all">
<div class="absolute bottom-0 w-full bg-primary/50 h-[60%] rounded-t-sm"></div>
</div>
<span class="text-[#92adc9] text-xs font-medium">Jan</span>
</div>
<div class="flex flex-col items-center gap-2 flex-1 group cursor-pointer">
<div class="w-full max-w-[40px] bg-[#233648] rounded-t-sm relative h-[180px] group-hover:bg-[#2d445a] transition-all">
<div class="absolute bottom-0 w-full bg-primary/60 h-[45%] rounded-t-sm"></div>
</div>
<span class="text-[#92adc9] text-xs font-medium">Feb</span>
</div>
<div class="flex flex-col items-center gap-2 flex-1 group cursor-pointer">
<div class="w-full max-w-[40px] bg-[#233648] rounded-t-sm relative h-[160px] group-hover:bg-[#2d445a] transition-all">
<div class="absolute bottom-0 w-full bg-primary/70 h-[70%] rounded-t-sm"></div>
</div>
<span class="text-[#92adc9] text-xs font-medium">Mar</span>
</div>
<div class="flex flex-col items-center gap-2 flex-1 group cursor-pointer">
<div class="w-full max-w-[40px] bg-[#233648] rounded-t-sm relative h-[200px] group-hover:bg-[#2d445a] transition-all">
<div class="absolute bottom-0 w-full bg-primary h-[55%] rounded-t-sm"></div>
</div>
<span class="text-[#92adc9] text-xs font-medium">Apr</span>
</div>
<div class="flex flex-col items-center gap-2 flex-1 group cursor-pointer">
<div class="w-full max-w-[40px] bg-[#233648] rounded-t-sm relative h-[150px] group-hover:bg-[#2d445a] transition-all">
<div class="absolute bottom-0 w-full bg-primary/80 h-[80%] rounded-t-sm"></div>
</div>
<span class="text-[#92adc9] text-xs font-medium">May</span>
</div>
<div class="flex flex-col items-center gap-2 flex-1 group cursor-pointer">
<div class="w-full max-w-[40px] bg-[#233648] rounded-t-sm relative h-[190px] group-hover:bg-[#2d445a] transition-all">
<div class="absolute bottom-0 w-full bg-primary h-[65%] rounded-t-sm"></div>
</div>
<span class="text-[#92adc9] text-xs font-medium">Jun</span>
</div>
</div>
</div>
<!-- Pie Chart: Expense Distribution -->
<div class="bg-[#1a2632] border border-[#233648] rounded-xl p-6 flex flex-col">
<h3 class="text-white text-lg font-bold mb-1">Expense Distribution</h3>
<p class="text-[#92adc9] text-sm mb-6">Breakdown by category</p>
<div class="flex-1 flex items-center justify-center relative">
<!-- CSS Conic Gradient Pie Chart -->
<div class="size-52 rounded-full relative" style="background: conic-gradient(
#2b8cee 0% 35%,
#a855f7 35% 60%,
#0bda5b 60% 80%,
#f59e0b 80% 90%,
#ef4444 90% 100%
);">
<!-- Inner hole for donut effect -->
<div class="absolute inset-4 bg-[#1a2632] rounded-full flex flex-col items-center justify-center z-10">
<span class="text-[#92adc9] text-xs font-medium">Total</span>
<span class="text-white text-xl font-bold">$2,450</span>
</div>
</div>
</div>
<!-- Legend -->
<div class="mt-6 grid grid-cols-2 gap-y-2 gap-x-4">
<div class="flex items-center gap-2">
<span class="size-2.5 rounded-full bg-primary"></span>
<span class="text-[#92adc9] text-xs">House</span>
</div>
<div class="flex items-center gap-2">
<span class="size-2.5 rounded-full bg-purple-500"></span>
<span class="text-[#92adc9] text-xs">Mortgage</span>
</div>
<div class="flex items-center gap-2">
<span class="size-2.5 rounded-full bg-green-500"></span>
<span class="text-[#92adc9] text-xs">Car</span>
</div>
<div class="flex items-center gap-2">
<span class="size-2.5 rounded-full bg-orange-500"></span>
<span class="text-[#92adc9] text-xs">Food</span>
</div>
</div>
</div>
</div>
<!-- Categories Grid -->
<div>
<div class="flex items-center justify-between mb-4">
<h3 class="text-white text-lg font-bold">Expense Categories</h3>
<a class="text-primary text-sm font-medium hover:text-primary/80" href="#">View All</a>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Category Card 1 -->
<div class="bg-[#1a2632] p-5 rounded-lg border border-[#233648] hover:border-primary/50 transition-colors group">
<div class="flex justify-between items-start mb-4">
<div class="size-10 rounded-lg bg-green-500/10 flex items-center justify-center text-green-500 group-hover:bg-green-500 group-hover:text-white transition-colors">
<span class="material-symbols-outlined">directions_car</span>
</div>
<span class="bg-[#233648] text-[#92adc9] text-[10px] font-bold px-2 py-1 rounded-full">3 txns</span>
</div>
<div class="flex flex-col">
<span class="text-[#92adc9] text-sm font-medium">Car Expenses</span>
<span class="text-white text-xl font-bold mt-1">$450.00</span>
</div>
<div class="w-full bg-[#233648] h-1 rounded-full mt-4">
<div class="bg-green-500 h-full rounded-full" style="width: 18%"></div>
</div>
</div>
<!-- Category Card 2 -->
<div class="bg-[#1a2632] p-5 rounded-lg border border-[#233648] hover:border-primary/50 transition-colors group">
<div class="flex justify-between items-start mb-4">
<div class="size-10 rounded-lg bg-primary/10 flex items-center justify-center text-primary group-hover:bg-primary group-hover:text-white transition-colors">
<span class="material-symbols-outlined">home</span>
</div>
<span class="bg-[#233648] text-[#92adc9] text-[10px] font-bold px-2 py-1 rounded-full">5 txns</span>
</div>
<div class="flex flex-col">
<span class="text-[#92adc9] text-sm font-medium">House Expenses-Bills</span>
<span class="text-white text-xl font-bold mt-1">$1,200.00</span>
</div>
<div class="w-full bg-[#233648] h-1 rounded-full mt-4">
<div class="bg-primary h-full rounded-full" style="width: 45%"></div>
</div>
</div>
<!-- Category Card 3 -->
<div class="bg-[#1a2632] p-5 rounded-lg border border-[#233648] hover:border-primary/50 transition-colors group">
<div class="flex justify-between items-start mb-4">
<div class="size-10 rounded-lg bg-orange-500/10 flex items-center justify-center text-orange-500 group-hover:bg-orange-500 group-hover:text-white transition-colors">
<span class="material-symbols-outlined">restaurant</span>
</div>
<span class="bg-[#233648] text-[#92adc9] text-[10px] font-bold px-2 py-1 rounded-full">12 txns</span>
</div>
<div class="flex flex-col">
<span class="text-[#92adc9] text-sm font-medium">Food &amp; Drink</span>
<span class="text-white text-xl font-bold mt-1">$350.00</span>
</div>
<div class="w-full bg-[#233648] h-1 rounded-full mt-4">
<div class="bg-orange-500 h-full rounded-full" style="width: 14%"></div>
</div>
</div>
<!-- Category Card 4 -->
<div class="bg-[#1a2632] p-5 rounded-lg border border-[#233648] hover:border-primary/50 transition-colors group">
<div class="flex justify-between items-start mb-4">
<div class="size-10 rounded-lg bg-red-500/10 flex items-center justify-center text-red-500 group-hover:bg-red-500 group-hover:text-white transition-colors">
<span class="material-symbols-outlined">smoking_rooms</span>
</div>
<span class="bg-[#233648] text-[#92adc9] text-[10px] font-bold px-2 py-1 rounded-full">8 txns</span>
</div>
<div class="flex flex-col">
<span class="text-[#92adc9] text-sm font-medium">Smoking</span>
<span class="text-white text-xl font-bold mt-1">$120.00</span>
</div>
<div class="w-full bg-[#233648] h-1 rounded-full mt-4">
<div class="bg-red-500 h-full rounded-full" style="width: 5%"></div>
</div>
</div>
<!-- Category Card 5 -->
<div class="bg-[#1a2632] p-5 rounded-lg border border-[#233648] hover:border-primary/50 transition-colors group">
<div class="flex justify-between items-start mb-4">
<div class="size-10 rounded-lg bg-purple-500/10 flex items-center justify-center text-purple-500 group-hover:bg-purple-500 group-hover:text-white transition-colors">
<span class="material-symbols-outlined">account_balance</span>
</div>
<span class="bg-[#233648] text-[#92adc9] text-[10px] font-bold px-2 py-1 rounded-full">1 txn</span>
</div>
<div class="flex flex-col">
<span class="text-[#92adc9] text-sm font-medium">Mortgage</span>
<span class="text-white text-xl font-bold mt-1">$800.00</span>
</div>
<div class="w-full bg-[#233648] h-1 rounded-full mt-4">
<div class="bg-purple-500 h-full rounded-full" style="width: 32%"></div>
</div>
</div>
<!-- Category Card 6 -->
<div class="bg-[#1a2632] p-5 rounded-lg border border-[#233648] hover:border-primary/50 transition-colors group">
<div class="flex justify-between items-start mb-4">
<div class="size-10 rounded-lg bg-cyan-500/10 flex items-center justify-center text-cyan-500 group-hover:bg-cyan-500 group-hover:text-white transition-colors">
<span class="material-symbols-outlined">shopping_cart</span>
</div>
<span class="bg-[#233648] text-[#92adc9] text-[10px] font-bold px-2 py-1 rounded-full">4 txns</span>
</div>
<div class="flex flex-col">
<span class="text-[#92adc9] text-sm font-medium">Supermarket</span>
<span class="text-white text-xl font-bold mt-1">$200.00</span>
</div>
<div class="w-full bg-[#233648] h-1 rounded-full mt-4">
<div class="bg-cyan-500 h-full rounded-full" style="width: 8%"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

View file

@ -0,0 +1,12 @@
Flask==3.0.0
Flask-Login==0.6.3
Flask-SQLAlchemy==3.1.1
Flask-Bcrypt==1.0.1
Flask-WTF==1.2.1
redis==5.0.1
pyotp==2.9.0
qrcode==7.4.2
Pillow==10.1.0
python-dotenv==1.0.0
Werkzeug==3.0.1
reportlab==4.0.7

8
backup/fina-1/run.py Normal file
View file

@ -0,0 +1,8 @@
from app import create_app
import os
app = create_app()
if __name__ == '__main__':
port = int(os.environ.get('PORT', 5103))
app.run(host='0.0.0.0', port=port, debug=False)

View file

@ -0,0 +1,65 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
.venv/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Project specific
data/*.db
data/*.db-journal
uploads/*
!uploads/.gitkeep
*.log
# Git
.git/
.gitignore
# Environment
.env
.env.local
# Testing
.pytest_cache/
.coverage
htmlcov/
# Documentation
*.md
!README.md
# Docker
Dockerfile
docker-compose.yml
.dockerignore

View file

@ -0,0 +1,4 @@
SECRET_KEY=change-this-to-a-random-secret-key
DATABASE_URL=sqlite:///data/fina.db
REDIS_URL=redis://localhost:6379/0
FLASK_ENV=development

19
backup/fina-2/.gitignore vendored Normal file
View file

@ -0,0 +1,19 @@
*.pyc
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
.venv
.env
data/
uploads/
*.db
*.sqlite
.DS_Store
.vscode/
.idea/
*.log

View file

@ -0,0 +1,46 @@
BACKUP INFORMATION
==================
Backup Date: December 19, 2025
Backup Name: fina-2
Source: /home/iulian/projects/fina
CHANGES IN THIS VERSION:
- Fixed currency filtering issues when user changes preferred currency
- Removed currency filters from all database queries (dashboard, reports, transactions)
- All expenses now display regardless of stored currency
- Display amounts use user's current preferred currency setting
- Added Smart Recommendations feature to reports page
- Backend analyzes spending patterns (30-day vs 60-day comparison)
- Generates personalized insights (budget alerts, category changes, unusual transactions)
- Bilingual recommendations (EN/RO)
- Fixed category cards to use dynamic user currency
- Fixed recent transactions to use dynamic user currency
- Fixed transactions page to load and use user currency
- Fixed payment column to show correct currency
- All charts now use user's preferred currency in tooltips and labels
- Monthly trend chart displays all expenses combined
SECURITY:
- All queries filter by current_user.id
- @login_required decorators on all routes
- No data leakage between users
FILES EXCLUDED FROM BACKUP:
- __pycache__ directories
- *.pyc files
- data/ directory (database files)
- instance/ directory
- uploads/ directory
- backup/ directory
- .git/ directory
- node_modules/
- .venv/ and venv/ directories
- *.sqlite files
RESTORE INSTRUCTIONS:
1. Copy all files from this backup to project directory
2. Create virtual environment: python -m venv .venv
3. Install dependencies: pip install -r requirements.txt
4. Set up .env file with your configuration
5. Run migrations if needed
6. Build Docker containers: docker compose up -d --build

26
backup/fina-2/Dockerfile Normal file
View file

@ -0,0 +1,26 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY app/ ./app/
COPY run.py .
# Create necessary directories with proper permissions
RUN mkdir -p data uploads instance && \
chmod 755 data uploads instance
# Expose port
EXPOSE 5103
# Run the application
CMD ["python", "run.py"]

36
backup/fina-2/README.md Normal file
View file

@ -0,0 +1,36 @@
# FINA - Personal Finance Tracker
A modern, secure PWA for tracking expenses with multi-user support, visual analytics, and comprehensive financial management.
## Features
- 💰 Expense tracking with custom categories and tags
- 📊 Interactive analytics dashboard
- 🔐 Secure authentication with optional 2FA
- 👥 Multi-user support with role-based access
- 🌍 Multi-language (English, Romanian)
- 💱 Multi-currency support (USD, EUR, GBP, RON)
- 📱 Progressive Web App (PWA)
- 🎨 Modern glassmorphism UI
- 📤 CSV import/export
- 📎 Receipt attachments
## Quick Start
```bash
docker-compose up -d
```
Access the app at `http://localhost:5103`
## Tech Stack
- Backend: Flask (Python)
- Database: SQLite
- Cache: Redis
- Frontend: Tailwind CSS, Chart.js
- Deployment: Docker
## License
MIT

View file

@ -0,0 +1,87 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_bcrypt import Bcrypt
import redis
import os
from datetime import timedelta
db = SQLAlchemy()
bcrypt = Bcrypt()
login_manager = LoginManager()
redis_client = None
def create_app():
app = Flask(__name__)
# Configuration
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:///data/fina.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
app.config['UPLOAD_FOLDER'] = os.path.abspath('uploads')
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)
app.config['WTF_CSRF_TIME_LIMIT'] = None
# Initialize extensions
db.init_app(app)
bcrypt.init_app(app)
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
# Redis connection
global redis_client
try:
redis_url = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
redis_client = redis.from_url(redis_url, decode_responses=True)
except Exception as e:
print(f"Redis connection failed: {e}")
redis_client = None
# Create upload directories
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'documents'), exist_ok=True)
os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'avatars'), exist_ok=True)
os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'receipts'), exist_ok=True)
os.makedirs('data', exist_ok=True)
# Register blueprints
from app.routes import auth, main, expenses, admin, documents, settings
app.register_blueprint(auth.bp)
app.register_blueprint(main.bp)
app.register_blueprint(expenses.bp)
app.register_blueprint(admin.bp)
app.register_blueprint(documents.bp)
app.register_blueprint(settings.bp)
# Serve uploaded files
from flask import send_from_directory, url_for
@app.route('/uploads/<path:filename>')
def uploaded_file(filename):
"""Serve uploaded files (avatars, documents)"""
upload_dir = os.path.join(app.root_path, '..', app.config['UPLOAD_FOLDER'])
return send_from_directory(upload_dir, filename)
# Add avatar_url filter for templates
@app.template_filter('avatar_url')
def avatar_url_filter(avatar_path):
"""Generate correct URL for avatar (either static or uploaded)"""
if avatar_path.startswith('icons/'):
# Default avatar in static folder
return url_for('static', filename=avatar_path)
else:
# Uploaded avatar
return '/' + avatar_path
# Create database tables
with app.app_context():
db.create_all()
return app
from app.models import User
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))

136
backup/fina-2/app/models.py Normal file
View file

@ -0,0 +1,136 @@
from app import db
from flask_login import UserMixin
from datetime import datetime
import json
class User(db.Model, UserMixin):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(128), nullable=False)
is_admin = db.Column(db.Boolean, default=False)
totp_secret = db.Column(db.String(32), nullable=True)
two_factor_enabled = db.Column(db.Boolean, default=False)
backup_codes = db.Column(db.Text, nullable=True) # JSON array of hashed backup codes
language = db.Column(db.String(5), default='en')
currency = db.Column(db.String(3), default='USD')
avatar = db.Column(db.String(255), default='icons/avatars/avatar-1.svg')
monthly_budget = db.Column(db.Float, default=0.0)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
expenses = db.relationship('Expense', backref='user', lazy='dynamic', cascade='all, delete-orphan')
categories = db.relationship('Category', backref='user', lazy='dynamic', cascade='all, delete-orphan')
documents = db.relationship('Document', backref='user', lazy='dynamic', cascade='all, delete-orphan')
def __repr__(self):
return f'<User {self.username}>'
class Category(db.Model):
__tablename__ = 'categories'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), nullable=False)
color = db.Column(db.String(7), default='#2b8cee')
icon = db.Column(db.String(50), default='category')
display_order = db.Column(db.Integer, default=0)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
expenses = db.relationship('Expense', backref='category', lazy='dynamic')
def __repr__(self):
return f'<Category {self.name}>'
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'color': self.color,
'icon': self.icon,
'display_order': self.display_order,
'created_at': self.created_at.isoformat()
}
class Expense(db.Model):
__tablename__ = 'expenses'
id = db.Column(db.Integer, primary_key=True)
amount = db.Column(db.Float, nullable=False)
currency = db.Column(db.String(3), default='USD')
description = db.Column(db.String(200), nullable=False)
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
tags = db.Column(db.Text, default='[]') # JSON array of tags
receipt_path = db.Column(db.String(255), nullable=True)
date = db.Column(db.DateTime, default=datetime.utcnow)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f'<Expense {self.description} - {self.amount} {self.currency}>'
def get_tags(self):
try:
return json.loads(self.tags)
except:
return []
def set_tags(self, tags_list):
self.tags = json.dumps(tags_list)
def to_dict(self):
return {
'id': self.id,
'amount': self.amount,
'currency': self.currency,
'description': self.description,
'category_id': self.category_id,
'category_name': self.category.name if self.category else None,
'category_color': self.category.color if self.category else None,
'tags': self.get_tags(),
'receipt_path': f'/uploads/{self.receipt_path}' if self.receipt_path else None,
'date': self.date.isoformat(),
'created_at': self.created_at.isoformat()
}
class Document(db.Model):
"""
Model for storing user documents (bank statements, receipts, invoices, etc.)
Security: All queries filtered by user_id to ensure users only see their own documents
"""
__tablename__ = 'documents'
id = db.Column(db.Integer, primary_key=True)
filename = db.Column(db.String(255), nullable=False)
original_filename = db.Column(db.String(255), nullable=False)
file_path = db.Column(db.String(500), nullable=False)
file_size = db.Column(db.Integer, nullable=False) # in bytes
file_type = db.Column(db.String(50), nullable=False) # PDF, CSV, XLSX, etc.
mime_type = db.Column(db.String(100), nullable=False)
document_category = db.Column(db.String(100), nullable=True) # Bank Statement, Invoice, Receipt, Contract, etc.
status = db.Column(db.String(50), default='uploaded') # uploaded, processing, analyzed, error
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f'<Document {self.filename} - {self.user_id}>'
def to_dict(self):
return {
'id': self.id,
'filename': self.original_filename,
'original_filename': self.original_filename,
'file_size': self.file_size,
'file_type': self.file_type,
'mime_type': self.mime_type,
'document_category': self.document_category,
'status': self.status,
'created_at': self.created_at.isoformat(),
'updated_at': self.updated_at.isoformat()
}

View file

@ -0,0 +1,110 @@
from flask import Blueprint, request, jsonify
from flask_login import login_required, current_user
from app import db, bcrypt
from app.models import User, Expense, Category
from functools import wraps
bp = Blueprint('admin', __name__, url_prefix='/api/admin')
def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_admin:
return jsonify({'success': False, 'message': 'Admin access required'}), 403
return f(*args, **kwargs)
return decorated_function
@bp.route('/users', methods=['GET'])
@login_required
@admin_required
def get_users():
users = User.query.all()
return jsonify({
'users': [{
'id': user.id,
'username': user.username,
'email': user.email,
'is_admin': user.is_admin,
'language': user.language,
'currency': user.currency,
'two_factor_enabled': user.two_factor_enabled,
'created_at': user.created_at.isoformat()
} for user in users]
})
@bp.route('/users', methods=['POST'])
@login_required
@admin_required
def create_user():
data = request.get_json()
if not data.get('username') or not data.get('email') or not data.get('password'):
return jsonify({'success': False, 'message': 'Missing required fields'}), 400
# Check if user exists
if User.query.filter_by(email=data['email']).first():
return jsonify({'success': False, 'message': 'Email already exists'}), 400
if User.query.filter_by(username=data['username']).first():
return jsonify({'success': False, 'message': 'Username already exists'}), 400
# Create user
password_hash = bcrypt.generate_password_hash(data['password']).decode('utf-8')
user = User(
username=data['username'],
email=data['email'],
password_hash=password_hash,
is_admin=data.get('is_admin', False),
language=data.get('language', 'en'),
currency=data.get('currency', 'USD')
)
db.session.add(user)
db.session.commit()
# Create default categories
from app.utils import create_default_categories
create_default_categories(user.id)
return jsonify({
'success': True,
'user': {
'id': user.id,
'username': user.username,
'email': user.email
}
}), 201
@bp.route('/users/<int:user_id>', methods=['DELETE'])
@login_required
@admin_required
def delete_user(user_id):
if user_id == current_user.id:
return jsonify({'success': False, 'message': 'Cannot delete yourself'}), 400
user = User.query.get(user_id)
if not user:
return jsonify({'success': False, 'message': 'User not found'}), 404
db.session.delete(user)
db.session.commit()
return jsonify({'success': True, 'message': 'User deleted'})
@bp.route('/stats', methods=['GET'])
@login_required
@admin_required
def get_stats():
total_users = User.query.count()
total_expenses = Expense.query.count()
total_categories = Category.query.count()
return jsonify({
'total_users': total_users,
'total_expenses': total_expenses,
'total_categories': total_categories
})

View file

@ -0,0 +1,360 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, session, send_file, make_response
from flask_login import login_user, logout_user, login_required, current_user
from app import db, bcrypt
from app.models import User
import pyotp
import qrcode
import io
import base64
import secrets
import json
from datetime import datetime
bp = Blueprint('auth', __name__, url_prefix='/auth')
def generate_backup_codes(count=10):
"""Generate backup codes for 2FA"""
codes = []
for _ in range(count):
# Generate 8-character alphanumeric code
code = ''.join(secrets.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(8))
# Format as XXXX-XXXX for readability
formatted_code = f"{code[:4]}-{code[4:]}"
codes.append(formatted_code)
return codes
def hash_backup_codes(codes):
"""Hash backup codes for secure storage"""
return [bcrypt.generate_password_hash(code).decode('utf-8') for code in codes]
def verify_backup_code(user, code):
"""Verify a backup code and mark it as used"""
if not user.backup_codes:
return False
stored_codes = json.loads(user.backup_codes)
for i, hashed_code in enumerate(stored_codes):
if bcrypt.check_password_hash(hashed_code, code):
# Remove used code
stored_codes.pop(i)
user.backup_codes = json.dumps(stored_codes)
db.session.commit()
return True
return False
@bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
data = request.get_json() if request.is_json else request.form
username = data.get('username')
password = data.get('password')
two_factor_code = data.get('two_factor_code')
remember = data.get('remember', False)
# Accept both username and email
user = User.query.filter((User.username == username) | (User.email == username)).first()
if user and bcrypt.check_password_hash(user.password_hash, password):
# Check 2FA if enabled
if user.two_factor_enabled:
if not two_factor_code:
if request.is_json:
return {'success': False, 'requires_2fa': True}, 200
session['pending_user_id'] = user.id
return render_template('auth/two_factor.html')
# Try TOTP code first
totp = pyotp.TOTP(user.totp_secret)
is_valid = totp.verify(two_factor_code)
# If TOTP fails, try backup code (format: XXXX-XXXX or XXXXXXXX)
if not is_valid:
is_valid = verify_backup_code(user, two_factor_code)
if not is_valid:
if request.is_json:
return {'success': False, 'message': 'Invalid 2FA code'}, 401
flash('Invalid 2FA code', 'error')
return render_template('auth/login.html')
login_user(user, remember=remember)
session.permanent = remember
if request.is_json:
return {'success': True, 'redirect': url_for('main.dashboard')}
next_page = request.args.get('next')
return redirect(next_page if next_page else url_for('main.dashboard'))
if request.is_json:
return {'success': False, 'message': 'Invalid username or password'}, 401
flash('Invalid username or password', 'error')
return render_template('auth/login.html')
@bp.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
data = request.get_json() if request.is_json else request.form
username = data.get('username')
email = data.get('email')
password = data.get('password')
language = data.get('language', 'en')
currency = data.get('currency', 'USD')
# Check if user exists
if User.query.filter_by(email=email).first():
if request.is_json:
return {'success': False, 'message': 'Email already registered'}, 400
flash('Email already registered', 'error')
return render_template('auth/register.html')
if User.query.filter_by(username=username).first():
if request.is_json:
return {'success': False, 'message': 'Username already taken'}, 400
flash('Username already taken', 'error')
return render_template('auth/register.html')
# Check if this is the first user (make them admin)
is_first_user = User.query.count() == 0
# Create user
password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
user = User(
username=username,
email=email,
password_hash=password_hash,
is_admin=is_first_user,
language=language,
currency=currency
)
db.session.add(user)
db.session.commit()
# Create default categories
from app.utils import create_default_categories
create_default_categories(user.id)
login_user(user)
if request.is_json:
return {'success': True, 'redirect': url_for('main.dashboard')}
flash('Registration successful!', 'success')
return redirect(url_for('main.dashboard'))
return render_template('auth/register.html')
@bp.route('/logout')
@login_required
def logout():
logout_user()
return redirect(url_for('auth.login'))
@bp.route('/setup-2fa', methods=['GET', 'POST'])
@login_required
def setup_2fa():
if request.method == 'POST':
data = request.get_json() if request.is_json else request.form
code = data.get('code')
if not current_user.totp_secret:
secret = pyotp.random_base32()
current_user.totp_secret = secret
totp = pyotp.TOTP(current_user.totp_secret)
if totp.verify(code):
# Generate backup codes
backup_codes_plain = generate_backup_codes(10)
backup_codes_hashed = hash_backup_codes(backup_codes_plain)
current_user.two_factor_enabled = True
current_user.backup_codes = json.dumps(backup_codes_hashed)
db.session.commit()
# Store plain backup codes in session for display
session['backup_codes'] = backup_codes_plain
if request.is_json:
return {'success': True, 'message': '2FA enabled successfully', 'backup_codes': backup_codes_plain}
flash('2FA enabled successfully', 'success')
return redirect(url_for('auth.show_backup_codes'))
if request.is_json:
return {'success': False, 'message': 'Invalid code'}, 400
flash('Invalid code', 'error')
# Generate QR code
if not current_user.totp_secret:
current_user.totp_secret = pyotp.random_base32()
db.session.commit()
totp = pyotp.TOTP(current_user.totp_secret)
provisioning_uri = totp.provisioning_uri(
name=current_user.email,
issuer_name='FINA'
)
# Generate QR code image
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(provisioning_uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
qr_code_base64 = base64.b64encode(buf.getvalue()).decode()
return render_template('auth/setup_2fa.html',
qr_code=qr_code_base64,
secret=current_user.totp_secret)
@bp.route('/backup-codes', methods=['GET'])
@login_required
def show_backup_codes():
"""Display backup codes after 2FA setup"""
backup_codes = session.get('backup_codes', [])
if not backup_codes:
flash('No backup codes available', 'error')
return redirect(url_for('main.settings'))
return render_template('auth/backup_codes.html',
backup_codes=backup_codes,
username=current_user.username)
@bp.route('/backup-codes/download', methods=['GET'])
@login_required
def download_backup_codes_pdf():
"""Download backup codes as PDF"""
backup_codes = session.get('backup_codes', [])
if not backup_codes:
flash('No backup codes available', 'error')
return redirect(url_for('main.settings'))
try:
from reportlab.lib.pagesizes import letter
from reportlab.lib.units import inch
from reportlab.pdfgen import canvas
from reportlab.lib import colors
# Create PDF in memory
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=letter)
width, height = letter
# Title
c.setFont("Helvetica-Bold", 24)
c.drawCentredString(width/2, height - 1*inch, "FINA")
c.setFont("Helvetica-Bold", 18)
c.drawCentredString(width/2, height - 1.5*inch, "Two-Factor Authentication")
c.drawCentredString(width/2, height - 1.9*inch, "Backup Codes")
# User info
c.setFont("Helvetica", 12)
c.drawString(1*inch, height - 2.5*inch, f"User: {current_user.username}")
c.drawString(1*inch, height - 2.8*inch, f"Email: {current_user.email}")
c.drawString(1*inch, height - 3.1*inch, f"Generated: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}")
# Warning message
c.setFillColorRGB(0.8, 0.2, 0.2)
c.setFont("Helvetica-Bold", 11)
c.drawString(1*inch, height - 3.7*inch, "IMPORTANT: Store these codes in a secure location!")
c.setFillColorRGB(0, 0, 0)
c.setFont("Helvetica", 10)
c.drawString(1*inch, height - 4.0*inch, "Each code can only be used once. Use them if you lose access to your authenticator app.")
# Backup codes in two columns
c.setFont("Courier-Bold", 14)
y_position = height - 4.8*inch
x_left = 1.5*inch
x_right = 4.5*inch
for i, code in enumerate(backup_codes):
if i % 2 == 0:
c.drawString(x_left, y_position, f"{i+1:2d}. {code}")
else:
c.drawString(x_right, y_position, f"{i+1:2d}. {code}")
y_position -= 0.4*inch
# Footer
c.setFont("Helvetica", 8)
c.setFillColorRGB(0.5, 0.5, 0.5)
c.drawCentredString(width/2, 0.5*inch, "Keep this document secure and do not share these codes with anyone.")
c.save()
buffer.seek(0)
# Clear backup codes from session after download
session.pop('backup_codes', None)
# Create response with PDF
response = make_response(buffer.getvalue())
response.headers['Content-Type'] = 'application/pdf'
response.headers['Content-Disposition'] = f'attachment; filename=FINA_BackupCodes_{current_user.username}_{datetime.utcnow().strftime("%Y%m%d")}.pdf'
return response
except ImportError:
# If reportlab is not installed, return codes as text file
text_content = f"FINA - Two-Factor Authentication Backup Codes\n\n"
text_content += f"User: {current_user.username}\n"
text_content += f"Email: {current_user.email}\n"
text_content += f"Generated: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}\n\n"
text_content += "IMPORTANT: Store these codes in a secure location!\n"
text_content += "Each code can only be used once.\n\n"
text_content += "Backup Codes:\n"
text_content += "-" * 40 + "\n"
for i, code in enumerate(backup_codes, 1):
text_content += f"{i:2d}. {code}\n"
text_content += "-" * 40 + "\n"
text_content += "\nKeep this document secure and do not share these codes with anyone."
# Clear backup codes from session
session.pop('backup_codes', None)
response = make_response(text_content)
response.headers['Content-Type'] = 'text/plain'
response.headers['Content-Disposition'] = f'attachment; filename=FINA_BackupCodes_{current_user.username}_{datetime.utcnow().strftime("%Y%m%d")}.txt'
return response
@bp.route('/disable-2fa', methods=['POST'])
@login_required
def disable_2fa():
current_user.two_factor_enabled = False
current_user.backup_codes = None
db.session.commit()
if request.is_json:
return {'success': True, 'message': '2FA disabled'}
flash('2FA disabled', 'success')
return redirect(url_for('main.settings'))

View file

@ -0,0 +1,248 @@
from flask import Blueprint, request, jsonify, send_file, current_app
from flask_login import login_required, current_user
from app import db
from app.models import Document
from werkzeug.utils import secure_filename
import os
import mimetypes
from datetime import datetime
bp = Blueprint('documents', __name__, url_prefix='/api/documents')
# Max file size: 10MB
MAX_FILE_SIZE = 10 * 1024 * 1024
# Allowed file types for documents
ALLOWED_DOCUMENT_TYPES = {
'pdf': 'application/pdf',
'csv': 'text/csv',
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'xls': 'application/vnd.ms-excel',
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg'
}
def allowed_document(filename):
"""Check if file type is allowed"""
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_DOCUMENT_TYPES.keys()
def get_file_type_icon(file_type):
"""Get material icon name for file type"""
icons = {
'pdf': 'picture_as_pdf',
'csv': 'table_view',
'xlsx': 'table_view',
'xls': 'table_view',
'png': 'image',
'jpg': 'image',
'jpeg': 'image'
}
return icons.get(file_type.lower(), 'description')
@bp.route('/', methods=['GET'])
@login_required
def get_documents():
"""
Get all documents for current user
Security: Filters by current_user.id
"""
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
search = request.args.get('search', '')
# Security: Only get documents for current user
query = Document.query.filter_by(user_id=current_user.id)
if search:
query = query.filter(Document.original_filename.ilike(f'%{search}%'))
pagination = query.order_by(Document.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return jsonify({
'documents': [doc.to_dict() for doc in pagination.items],
'pagination': {
'page': page,
'pages': pagination.pages,
'total': pagination.total,
'per_page': per_page
}
})
@bp.route('/', methods=['POST'])
@login_required
def upload_document():
"""
Upload a new document
Security: Associates document with current_user.id
"""
if 'file' not in request.files:
return jsonify({'success': False, 'message': 'No file provided'}), 400
file = request.files['file']
if not file or not file.filename:
return jsonify({'success': False, 'message': 'No file selected'}), 400
if not allowed_document(file.filename):
return jsonify({
'success': False,
'message': 'Invalid file type. Allowed: PDF, CSV, XLS, XLSX, PNG, JPG'
}), 400
# Check file size
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)
if file_size > MAX_FILE_SIZE:
return jsonify({
'success': False,
'message': f'File too large. Maximum size: {MAX_FILE_SIZE // (1024*1024)}MB'
}), 400
# Generate secure filename
original_filename = secure_filename(file.filename)
file_ext = original_filename.rsplit('.', 1)[1].lower()
timestamp = datetime.utcnow().timestamp()
filename = f"{current_user.id}_{timestamp}_{original_filename}"
# Create documents directory if it doesn't exist
documents_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'documents')
os.makedirs(documents_dir, exist_ok=True)
# Save file
file_path = os.path.join(documents_dir, filename)
file.save(file_path)
# Get document category from form data
document_category = request.form.get('category', 'Other')
# Create document record - Security: user_id is current_user.id
document = Document(
filename=filename,
original_filename=original_filename,
file_path=file_path,
file_size=file_size,
file_type=file_ext.upper(),
mime_type=ALLOWED_DOCUMENT_TYPES.get(file_ext, 'application/octet-stream'),
document_category=document_category,
status='uploaded',
user_id=current_user.id
)
db.session.add(document)
db.session.commit()
return jsonify({
'success': True,
'message': 'Document uploaded successfully',
'document': document.to_dict()
}), 201
@bp.route('/<int:document_id>/view', methods=['GET'])
@login_required
def view_document(document_id):
"""
View/preview a document (inline, not download)
Security: Checks document belongs to current_user
"""
# Security: Filter by user_id
document = Document.query.filter_by(id=document_id, user_id=current_user.id).first()
if not document:
return jsonify({'success': False, 'message': 'Document not found'}), 404
if not os.path.exists(document.file_path):
return jsonify({'success': False, 'message': 'File not found on server'}), 404
return send_file(
document.file_path,
mimetype=document.mime_type,
as_attachment=False
)
@bp.route('/<int:document_id>/download', methods=['GET'])
@login_required
def download_document(document_id):
"""
Download a document
Security: Checks document belongs to current_user
"""
# Security: Filter by user_id
document = Document.query.filter_by(id=document_id, user_id=current_user.id).first()
if not document:
return jsonify({'success': False, 'message': 'Document not found'}), 404
if not os.path.exists(document.file_path):
return jsonify({'success': False, 'message': 'File not found on server'}), 404
return send_file(
document.file_path,
mimetype=document.mime_type,
as_attachment=True,
download_name=document.original_filename
)
@bp.route('/<int:document_id>', methods=['DELETE'])
@login_required
def delete_document(document_id):
"""
Delete a document
Security: Checks document belongs to current_user
"""
# Security: Filter by user_id
document = Document.query.filter_by(id=document_id, user_id=current_user.id).first()
if not document:
return jsonify({'success': False, 'message': 'Document not found'}), 404
# Delete physical file
if os.path.exists(document.file_path):
try:
os.remove(document.file_path)
except Exception as e:
print(f"Error deleting file: {e}")
# Delete database record
db.session.delete(document)
db.session.commit()
return jsonify({'success': True, 'message': 'Document deleted successfully'})
@bp.route('/<int:document_id>/status', methods=['PUT'])
@login_required
def update_document_status(document_id):
"""
Update document status (e.g., mark as analyzed)
Security: Checks document belongs to current_user
"""
# Security: Filter by user_id
document = Document.query.filter_by(id=document_id, user_id=current_user.id).first()
if not document:
return jsonify({'success': False, 'message': 'Document not found'}), 404
data = request.get_json()
new_status = data.get('status')
if new_status not in ['uploaded', 'processing', 'analyzed', 'error']:
return jsonify({'success': False, 'message': 'Invalid status'}), 400
document.status = new_status
db.session.commit()
return jsonify({
'success': True,
'message': 'Document status updated',
'document': document.to_dict()
})

View file

@ -0,0 +1,399 @@
from flask import Blueprint, request, jsonify, send_file, current_app
from flask_login import login_required, current_user
from app import db
from app.models import Expense, Category
from werkzeug.utils import secure_filename
import os
import csv
import io
from datetime import datetime
bp = Blueprint('expenses', __name__, url_prefix='/api/expenses')
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'pdf'}
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@bp.route('/', methods=['GET'])
@login_required
def get_expenses():
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
category_id = request.args.get('category_id', type=int)
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
search = request.args.get('search', '')
query = Expense.query.filter_by(user_id=current_user.id)
if category_id:
query = query.filter_by(category_id=category_id)
if start_date:
query = query.filter(Expense.date >= datetime.fromisoformat(start_date))
if end_date:
query = query.filter(Expense.date <= datetime.fromisoformat(end_date))
if search:
query = query.filter(Expense.description.ilike(f'%{search}%'))
pagination = query.order_by(Expense.date.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return jsonify({
'expenses': [expense.to_dict() for expense in pagination.items],
'total': pagination.total,
'pages': pagination.pages,
'current_page': page
})
@bp.route('/', methods=['POST'])
@login_required
def create_expense():
# Handle both FormData and JSON requests
# When FormData is sent (even without files), request.form will have the data
# When JSON is sent, request.form will be empty
data = request.form if request.form else request.get_json()
# Validate required fields
if not data or not data.get('amount') or not data.get('category_id') or not data.get('description'):
return jsonify({'success': False, 'message': 'Missing required fields'}), 400
# Security: Verify category belongs to current user
category = Category.query.filter_by(id=int(data.get('category_id')), user_id=current_user.id).first()
if not category:
return jsonify({'success': False, 'message': 'Invalid category'}), 400
# Handle receipt upload
receipt_path = None
if 'receipt' in request.files:
file = request.files['receipt']
if file and file.filename and allowed_file(file.filename):
filename = secure_filename(f"{current_user.id}_{datetime.utcnow().timestamp()}_{file.filename}")
receipts_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'receipts')
filepath = os.path.join(receipts_dir, filename)
file.save(filepath)
receipt_path = f'receipts/{filename}'
# Create expense
expense = Expense(
amount=float(data.get('amount')),
currency=data.get('currency', current_user.currency),
description=data.get('description'),
category_id=int(data.get('category_id')),
user_id=current_user.id,
receipt_path=receipt_path,
date=datetime.fromisoformat(data.get('date')) if data.get('date') else datetime.utcnow()
)
# Handle tags
if data.get('tags'):
if isinstance(data.get('tags'), str):
import json
tags = json.loads(data.get('tags'))
else:
tags = data.get('tags')
expense.set_tags(tags)
db.session.add(expense)
db.session.commit()
return jsonify({
'success': True,
'expense': expense.to_dict()
}), 201
@bp.route('/<int:expense_id>', methods=['PUT'])
@login_required
def update_expense(expense_id):
expense = Expense.query.filter_by(id=expense_id, user_id=current_user.id).first()
if not expense:
return jsonify({'success': False, 'message': 'Expense not found'}), 404
# Handle both FormData and JSON requests
data = request.form if request.form else request.get_json()
# Update fields
if data.get('amount'):
expense.amount = float(data.get('amount'))
if data.get('currency'):
expense.currency = data.get('currency')
if data.get('description'):
expense.description = data.get('description')
if data.get('category_id'):
# Security: Verify category belongs to current user
category = Category.query.filter_by(id=int(data.get('category_id')), user_id=current_user.id).first()
if not category:
return jsonify({'success': False, 'message': 'Invalid category'}), 400
expense.category_id = int(data.get('category_id'))
if data.get('date'):
expense.date = datetime.fromisoformat(data.get('date'))
if data.get('tags') is not None:
if isinstance(data.get('tags'), str):
import json
tags = json.loads(data.get('tags'))
else:
tags = data.get('tags')
expense.set_tags(tags)
# Handle receipt upload
if 'receipt' in request.files:
file = request.files['receipt']
if file and file.filename and allowed_file(file.filename):
# Delete old receipt
if expense.receipt_path:
clean_path = expense.receipt_path.replace('/uploads/', '').lstrip('/')
old_path = os.path.join(current_app.config['UPLOAD_FOLDER'], clean_path)
if os.path.exists(old_path):
os.remove(old_path)
filename = secure_filename(f"{current_user.id}_{datetime.utcnow().timestamp()}_{file.filename}")
receipts_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'receipts')
filepath = os.path.join(receipts_dir, filename)
file.save(filepath)
expense.receipt_path = f'receipts/{filename}'
db.session.commit()
return jsonify({
'success': True,
'expense': expense.to_dict()
})
@bp.route('/<int:expense_id>', methods=['DELETE'])
@login_required
def delete_expense(expense_id):
expense = Expense.query.filter_by(id=expense_id, user_id=current_user.id).first()
if not expense:
return jsonify({'success': False, 'message': 'Expense not found'}), 404
# Delete receipt file
if expense.receipt_path:
# Remove leading slash and 'uploads/' prefix if present
clean_path = expense.receipt_path.replace('/uploads/', '').lstrip('/')
receipt_file = os.path.join(current_app.config['UPLOAD_FOLDER'], clean_path)
if os.path.exists(receipt_file):
os.remove(receipt_file)
db.session.delete(expense)
db.session.commit()
return jsonify({'success': True, 'message': 'Expense deleted'})
@bp.route('/categories', methods=['GET'])
@login_required
def get_categories():
categories = Category.query.filter_by(user_id=current_user.id).order_by(Category.display_order, Category.created_at).all()
return jsonify({
'categories': [cat.to_dict() for cat in categories]
})
@bp.route('/categories', methods=['POST'])
@login_required
def create_category():
data = request.get_json()
if not data.get('name'):
return jsonify({'success': False, 'message': 'Name is required'}), 400
# Get max display_order for user's categories
max_order = db.session.query(db.func.max(Category.display_order)).filter_by(user_id=current_user.id).scalar() or 0
category = Category(
name=data.get('name'),
color=data.get('color', '#2b8cee'),
icon=data.get('icon', 'category'),
display_order=max_order + 1,
user_id=current_user.id
)
db.session.add(category)
db.session.commit()
return jsonify({
'success': True,
'category': category.to_dict()
}), 201
@bp.route('/categories/<int:category_id>', methods=['PUT'])
@login_required
def update_category(category_id):
category = Category.query.filter_by(id=category_id, user_id=current_user.id).first()
if not category:
return jsonify({'success': False, 'message': 'Category not found'}), 404
data = request.get_json()
if data.get('name'):
category.name = data.get('name')
if data.get('color'):
category.color = data.get('color')
if data.get('icon'):
category.icon = data.get('icon')
if 'display_order' in data:
category.display_order = data.get('display_order')
db.session.commit()
return jsonify({
'success': True,
'category': category.to_dict()
})
@bp.route('/categories/<int:category_id>', methods=['DELETE'])
@login_required
def delete_category(category_id):
category = Category.query.filter_by(id=category_id, user_id=current_user.id).first()
if not category:
return jsonify({'success': False, 'message': 'Category not found'}), 404
# Check if category has expenses
if category.expenses.count() > 0:
return jsonify({'success': False, 'message': 'Cannot delete category with expenses'}), 400
db.session.delete(category)
db.session.commit()
return jsonify({'success': True, 'message': 'Category deleted'})
@bp.route('/categories/reorder', methods=['PUT'])
@login_required
def reorder_categories():
"""
Reorder categories for the current user
Expects: { "categories": [{"id": 1, "display_order": 0}, {"id": 2, "display_order": 1}, ...] }
Security: Only updates categories belonging to current_user
"""
data = request.get_json()
if not data or 'categories' not in data:
return jsonify({'success': False, 'message': 'Categories array required'}), 400
try:
for cat_data in data['categories']:
category = Category.query.filter_by(id=cat_data['id'], user_id=current_user.id).first()
if category:
category.display_order = cat_data['display_order']
db.session.commit()
return jsonify({'success': True, 'message': 'Categories reordered'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)}), 500
@bp.route('/export/csv', methods=['GET'])
@login_required
def export_csv():
expenses = Expense.query.filter_by(user_id=current_user.id).order_by(Expense.date.desc()).all()
output = io.StringIO()
writer = csv.writer(output)
# Write header
writer.writerow(['Date', 'Description', 'Amount', 'Currency', 'Category', 'Tags'])
# Write data
for expense in expenses:
writer.writerow([
expense.date.strftime('%Y-%m-%d %H:%M:%S'),
expense.description,
expense.amount,
expense.currency,
expense.category.name,
', '.join(expense.get_tags())
])
output.seek(0)
return send_file(
io.BytesIO(output.getvalue().encode('utf-8')),
mimetype='text/csv',
as_attachment=True,
download_name=f'fina_expenses_{datetime.utcnow().strftime("%Y%m%d")}.csv'
)
@bp.route('/import/csv', methods=['POST'])
@login_required
def import_csv():
if 'file' not in request.files:
return jsonify({'success': False, 'message': 'No file provided'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'success': False, 'message': 'No file selected'}), 400
if not file.filename.endswith('.csv'):
return jsonify({'success': False, 'message': 'File must be CSV'}), 400
try:
stream = io.StringIO(file.stream.read().decode('utf-8'))
reader = csv.DictReader(stream)
imported_count = 0
errors = []
for row in reader:
try:
# Find or create category
category_name = row.get('Category', 'Uncategorized')
category = Category.query.filter_by(user_id=current_user.id, name=category_name).first()
if not category:
category = Category(name=category_name, user_id=current_user.id)
db.session.add(category)
db.session.flush()
# Parse date
date_str = row.get('Date', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'))
expense_date = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S')
# Create expense
expense = Expense(
amount=float(row['Amount']),
currency=row.get('Currency', current_user.currency),
description=row['Description'],
category_id=category.id,
user_id=current_user.id,
date=expense_date
)
# Handle tags
if row.get('Tags'):
tags = [tag.strip() for tag in row['Tags'].split(',')]
expense.set_tags(tags)
db.session.add(expense)
imported_count += 1
except Exception as e:
errors.append(f"Row error: {str(e)}")
db.session.commit()
return jsonify({
'success': True,
'imported': imported_count,
'errors': errors
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'Import failed: {str(e)}'}), 500

View file

@ -0,0 +1,427 @@
from flask import Blueprint, render_template, request, jsonify
from flask_login import login_required, current_user
from app import db
from app.models import Expense, Category
from sqlalchemy import func, extract
from datetime import datetime, timedelta
from collections import defaultdict
bp = Blueprint('main', __name__)
@bp.route('/')
def index():
if current_user.is_authenticated:
return render_template('dashboard.html')
return render_template('landing.html')
@bp.route('/dashboard')
@login_required
def dashboard():
return render_template('dashboard.html')
@bp.route('/transactions')
@login_required
def transactions():
return render_template('transactions.html')
@bp.route('/reports')
@login_required
def reports():
return render_template('reports.html')
@bp.route('/settings')
@login_required
def settings():
return render_template('settings.html')
@bp.route('/documents')
@login_required
def documents():
return render_template('documents.html')
@bp.route('/admin')
@login_required
def admin():
if not current_user.is_admin:
return render_template('404.html'), 404
return render_template('admin.html')
@bp.route('/api/dashboard-stats')
@login_required
def dashboard_stats():
now = datetime.utcnow()
# Current month stats
current_month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
# Previous month stats
if now.month == 1:
prev_month_start = now.replace(year=now.year-1, month=12, day=1)
else:
prev_month_start = current_month_start.replace(month=current_month_start.month-1)
# Total spent this month (all currencies - show user's preferred currency)
current_month_expenses = Expense.query.filter(
Expense.user_id == current_user.id,
Expense.date >= current_month_start
).all()
current_month_total = sum(exp.amount for exp in current_month_expenses)
# Previous month total
prev_month_expenses = Expense.query.filter(
Expense.user_id == current_user.id,
Expense.date >= prev_month_start,
Expense.date < current_month_start
).all()
prev_month_total = sum(exp.amount for exp in prev_month_expenses)
# Calculate percentage change
if prev_month_total > 0:
percent_change = ((current_month_total - prev_month_total) / prev_month_total) * 100
else:
percent_change = 100 if current_month_total > 0 else 0
# Active categories
active_categories = Category.query.filter_by(user_id=current_user.id).count()
# Total transactions this month
total_transactions = Expense.query.filter(
Expense.user_id == current_user.id,
Expense.date >= current_month_start
).count()
# Category breakdown for entire current year (all currencies)
current_year_start = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
category_stats = db.session.query(
Category.id,
Category.name,
Category.color,
func.sum(Expense.amount).label('total'),
func.count(Expense.id).label('count')
).join(Expense).filter(
Expense.user_id == current_user.id,
Expense.date >= current_year_start
).group_by(Category.id).order_by(Category.display_order, Category.created_at).all()
# Monthly breakdown (all 12 months of current year)
monthly_data = []
for month_num in range(1, 13):
month_start = now.replace(month=month_num, day=1, hour=0, minute=0, second=0, microsecond=0)
if month_num == 12:
month_end = now.replace(year=now.year+1, month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
else:
month_end = now.replace(month=month_num+1, day=1, hour=0, minute=0, second=0, microsecond=0)
month_expenses = Expense.query.filter(
Expense.user_id == current_user.id,
Expense.date >= month_start,
Expense.date < month_end
).all()
month_total = sum(exp.amount for exp in month_expenses)
monthly_data.append({
'month': month_start.strftime('%b'),
'total': float(month_total)
})
return jsonify({
'total_spent': float(current_month_total),
'percent_change': round(percent_change, 1),
'active_categories': active_categories,
'total_transactions': total_transactions,
'currency': current_user.currency,
'category_breakdown': [
{'id': stat[0], 'name': stat[1], 'color': stat[2], 'total': float(stat[3]), 'count': stat[4]}
for stat in category_stats
],
'monthly_data': monthly_data
})
@bp.route('/api/recent-transactions')
@login_required
def recent_transactions():
limit = request.args.get('limit', 10, type=int)
expenses = Expense.query.filter_by(user_id=current_user.id)\
.order_by(Expense.date.desc())\
.limit(limit)\
.all()
return jsonify({
'transactions': [expense.to_dict() for expense in expenses]
})
@bp.route('/api/reports-stats')
@login_required
def reports_stats():
"""
Generate comprehensive financial reports
Security: Only returns data for current_user (enforced by user_id filter)
"""
period = request.args.get('period', '30') # days
category_filter = request.args.get('category_id', type=int)
try:
days = int(period)
except ValueError:
days = 30
now = datetime.utcnow()
period_start = now - timedelta(days=days)
# Query with security filter
query = Expense.query.filter(
Expense.user_id == current_user.id,
Expense.date >= period_start
)
if category_filter:
query = query.filter_by(category_id=category_filter)
expenses = query.all()
# Total spent in period (all currencies)
total_spent = sum(exp.amount for exp in expenses)
# Previous period comparison
prev_period_start = period_start - timedelta(days=days)
prev_expenses = Expense.query.filter(
Expense.user_id == current_user.id,
Expense.date >= prev_period_start,
Expense.date < period_start
).all()
prev_total = sum(exp.amount for exp in prev_expenses)
percent_change = 0
if prev_total > 0:
percent_change = ((total_spent - prev_total) / prev_total) * 100
elif total_spent > 0:
percent_change = 100
# Top category (all currencies)
category_totals = {}
for exp in expenses:
cat_name = exp.category.name
category_totals[cat_name] = category_totals.get(cat_name, 0) + exp.amount
top_category = max(category_totals.items(), key=lambda x: x[1]) if category_totals else ('None', 0)
# Average daily spending
avg_daily = total_spent / days if days > 0 else 0
prev_avg_daily = prev_total / days if days > 0 else 0
avg_change = 0
if prev_avg_daily > 0:
avg_change = ((avg_daily - prev_avg_daily) / prev_avg_daily) * 100
elif avg_daily > 0:
avg_change = 100
# Savings rate calculation based on monthly budget
if current_user.monthly_budget and current_user.monthly_budget > 0:
savings_amount = current_user.monthly_budget - total_spent
savings_rate = (savings_amount / current_user.monthly_budget) * 100
savings_rate = max(0, min(100, savings_rate)) # Clamp between 0-100%
else:
savings_rate = 0
# Category breakdown for pie chart
category_breakdown = []
for cat_name, amount in sorted(category_totals.items(), key=lambda x: x[1], reverse=True):
category = Category.query.filter_by(user_id=current_user.id, name=cat_name).first()
if category:
percentage = (amount / total_spent * 100) if total_spent > 0 else 0
category_breakdown.append({
'name': cat_name,
'color': category.color,
'amount': float(amount),
'percentage': round(percentage, 1)
})
# Daily spending trend (last 30 days)
daily_trend = []
for i in range(min(30, days)):
day_date = now - timedelta(days=i)
day_start = day_date.replace(hour=0, minute=0, second=0, microsecond=0)
day_end = day_start + timedelta(days=1)
day_expenses = Expense.query.filter(
Expense.user_id == current_user.id,
Expense.date >= day_start,
Expense.date < day_end
).all()
day_total = sum(exp.amount for exp in day_expenses)
daily_trend.insert(0, {
'date': day_date.strftime('%d %b'),
'amount': float(day_total)
})
# Monthly comparison (all 12 months of current year, all currencies)
monthly_comparison = []
current_year = now.year
for month in range(1, 13):
month_start = datetime(current_year, month, 1)
if month == 12:
month_end = datetime(current_year + 1, 1, 1)
else:
month_end = datetime(current_year, month + 1, 1)
month_expenses = Expense.query.filter(
Expense.user_id == current_user.id,
Expense.date >= month_start,
Expense.date < month_end
).all()
month_total = sum(exp.amount for exp in month_expenses)
monthly_comparison.append({
'month': month_start.strftime('%b'),
'amount': float(month_total)
})
return jsonify({
'total_spent': float(total_spent),
'percent_change': round(percent_change, 1),
'top_category': {'name': top_category[0], 'amount': float(top_category[1])},
'avg_daily': float(avg_daily),
'avg_daily_change': round(avg_change, 1),
'savings_rate': savings_rate,
'category_breakdown': category_breakdown,
'daily_trend': daily_trend,
'monthly_comparison': monthly_comparison,
'currency': current_user.currency,
'period_days': days
})
@bp.route('/api/smart-recommendations')
@login_required
def smart_recommendations():
"""
Generate smart financial recommendations based on user spending patterns
Security: Only returns recommendations for current_user
"""
now = datetime.utcnow()
# Get data for last 30 and 60 days for comparison
period_30 = now - timedelta(days=30)
period_60 = now - timedelta(days=60)
period_30_start = period_60
# Current period expenses (all currencies)
current_expenses = Expense.query.filter(
Expense.user_id == current_user.id,
Expense.date >= period_30
).all()
# Previous period expenses (all currencies)
previous_expenses = Expense.query.filter(
Expense.user_id == current_user.id,
Expense.date >= period_60,
Expense.date < period_30
).all()
current_total = sum(exp.amount for exp in current_expenses)
previous_total = sum(exp.amount for exp in previous_expenses)
# Category analysis
current_by_category = defaultdict(float)
previous_by_category = defaultdict(float)
for exp in current_expenses:
current_by_category[exp.category.name] += exp.amount
for exp in previous_expenses:
previous_by_category[exp.category.name] += exp.amount
recommendations = []
# Recommendation 1: Budget vs Spending
if current_user.monthly_budget and current_user.monthly_budget > 0:
budget_used_percent = (current_total / current_user.monthly_budget) * 100
remaining = current_user.monthly_budget - current_total
if budget_used_percent > 90:
recommendations.append({
'type': 'warning',
'icon': 'warning',
'color': 'red',
'title': 'Budget Alert' if current_user.language == 'en' else 'Alertă Buget',
'description': f'You\'ve used {budget_used_percent:.1f}% of your monthly budget. Only {abs(remaining):.2f} {current_user.currency} remaining.' if current_user.language == 'en' else f'Ai folosit {budget_used_percent:.1f}% din bugetul lunar. Mai rămân doar {abs(remaining):.2f} {current_user.currency}.'
})
elif budget_used_percent < 70 and remaining > 0:
recommendations.append({
'type': 'success',
'icon': 'trending_up',
'color': 'green',
'title': 'Great Savings Opportunity' if current_user.language == 'en' else 'Oportunitate de Economisire',
'description': f'You have {remaining:.2f} {current_user.currency} remaining from your budget. Consider saving or investing it.' if current_user.language == 'en' else f'Mai ai {remaining:.2f} {current_user.currency} din buget. Consideră să economisești sau să investești.'
})
# Recommendation 2: Category spending changes
for category_name, current_amount in current_by_category.items():
if category_name in previous_by_category:
previous_amount = previous_by_category[category_name]
if previous_amount > 0:
change_percent = ((current_amount - previous_amount) / previous_amount) * 100
if change_percent > 50: # 50% increase
recommendations.append({
'type': 'warning',
'icon': 'trending_up',
'color': 'yellow',
'title': f'{category_name} Spending Up' if current_user.language == 'en' else f'Cheltuieli {category_name} în Creștere',
'description': f'Your {category_name} spending increased by {change_percent:.0f}%. Review recent transactions.' if current_user.language == 'en' else f'Cheltuielile pentru {category_name} au crescut cu {change_percent:.0f}%. Revizuiește tranzacțiile recente.'
})
elif change_percent < -30: # 30% decrease
recommendations.append({
'type': 'success',
'icon': 'trending_down',
'color': 'green',
'title': f'{category_name} Savings' if current_user.language == 'en' else f'Economii {category_name}',
'description': f'Great job! You reduced {category_name} spending by {abs(change_percent):.0f}%.' if current_user.language == 'en' else f'Foarte bine! Ai redus cheltuielile pentru {category_name} cu {abs(change_percent):.0f}%.'
})
# Recommendation 3: Unusual transactions
if current_expenses:
category_averages = {}
for category_name, amount in current_by_category.items():
count = sum(1 for exp in current_expenses if exp.category.name == category_name)
category_averages[category_name] = amount / count if count > 0 else 0
for exp in current_expenses[-10:]: # Check last 10 transactions
category_avg = category_averages.get(exp.category.name, 0)
if category_avg > 0 and exp.amount > category_avg * 2: # 200% of average
recommendations.append({
'type': 'info',
'icon': 'info',
'color': 'blue',
'title': 'Unusual Transaction' if current_user.language == 'en' else 'Tranzacție Neobișnuită',
'description': f'A transaction of {exp.amount:.2f} {current_user.currency} in {exp.category.name} is higher than usual.' if current_user.language == 'en' else f'O tranzacție de {exp.amount:.2f} {current_user.currency} în {exp.category.name} este mai mare decât de obicei.'
})
break # Only show one unusual transaction warning
# Limit to top 3 recommendations
recommendations = recommendations[:3]
# If no recommendations, add a positive message
if not recommendations:
recommendations.append({
'type': 'success',
'icon': 'check_circle',
'color': 'green',
'title': 'Spending on Track' if current_user.language == 'en' else 'Cheltuieli sub Control',
'description': 'Your spending patterns look healthy. Keep up the good work!' if current_user.language == 'en' else 'Obiceiurile tale de cheltuieli arată bine. Continuă așa!'
})
return jsonify({
'success': True,
'recommendations': recommendations
})

View file

@ -0,0 +1,253 @@
from flask import Blueprint, request, jsonify, current_app
from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
from app import db, bcrypt
from app.models import User
import os
from datetime import datetime
bp = Blueprint('settings', __name__, url_prefix='/api/settings')
# Allowed avatar image types
ALLOWED_AVATAR_TYPES = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
MAX_AVATAR_SIZE = 20 * 1024 * 1024 # 20MB
def allowed_avatar(filename):
"""Check if file extension is allowed for avatars"""
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_AVATAR_TYPES
@bp.route('/profile', methods=['GET'])
@login_required
def get_profile():
"""
Get current user profile information
Security: Returns only current user's data
"""
return jsonify({
'success': True,
'profile': {
'username': current_user.username,
'email': current_user.email,
'language': current_user.language,
'currency': current_user.currency,
'monthly_budget': current_user.monthly_budget or 0,
'avatar': current_user.avatar,
'is_admin': current_user.is_admin,
'two_factor_enabled': current_user.two_factor_enabled,
'created_at': current_user.created_at.isoformat()
}
})
@bp.route('/profile', methods=['PUT'])
@login_required
def update_profile():
"""
Update user profile information
Security: Updates only current user's profile
"""
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'No data provided'}), 400
try:
# Update language
if 'language' in data:
if data['language'] in ['en', 'ro']:
current_user.language = data['language']
else:
return jsonify({'success': False, 'error': 'Invalid language'}), 400
# Update currency
if 'currency' in data:
current_user.currency = data['currency']
# Update monthly budget
if 'monthly_budget' in data:
try:
budget = float(data['monthly_budget'])
if budget < 0:
return jsonify({'success': False, 'error': 'Budget must be positive'}), 400
current_user.monthly_budget = budget
except (ValueError, TypeError):
return jsonify({'success': False, 'error': 'Invalid budget value'}), 400
# Update username (check uniqueness)
if 'username' in data and data['username'] != current_user.username:
existing = User.query.filter_by(username=data['username']).first()
if existing:
return jsonify({'success': False, 'error': 'Username already taken'}), 400
current_user.username = data['username']
# Update email (check uniqueness)
if 'email' in data and data['email'] != current_user.email:
existing = User.query.filter_by(email=data['email']).first()
if existing:
return jsonify({'success': False, 'error': 'Email already taken'}), 400
current_user.email = data['email']
db.session.commit()
return jsonify({
'success': True,
'message': 'Profile updated successfully',
'profile': {
'username': current_user.username,
'email': current_user.email,
'language': current_user.language,
'currency': current_user.currency,
'monthly_budget': current_user.monthly_budget,
'avatar': current_user.avatar
}
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/avatar', methods=['POST'])
@login_required
def upload_avatar():
"""
Upload custom avatar image
Security: Associates avatar with current_user.id, validates file type and size
"""
if 'avatar' not in request.files:
return jsonify({'success': False, 'error': 'No file provided'}), 400
file = request.files['avatar']
if not file or not file.filename:
return jsonify({'success': False, 'error': 'No file selected'}), 400
if not allowed_avatar(file.filename):
return jsonify({
'success': False,
'error': 'Invalid file type. Allowed: PNG, JPG, JPEG, GIF, WEBP'
}), 400
# Check file size
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)
if file_size > MAX_AVATAR_SIZE:
return jsonify({
'success': False,
'error': f'File too large. Maximum size: {MAX_AVATAR_SIZE // (1024*1024)}MB'
}), 400
try:
# Delete old custom avatar if exists (not default avatars)
if current_user.avatar and not current_user.avatar.startswith('icons/avatars/'):
old_path = os.path.join(current_app.root_path, 'static', current_user.avatar)
if os.path.exists(old_path):
os.remove(old_path)
# Generate secure filename
file_ext = file.filename.rsplit('.', 1)[1].lower()
timestamp = int(datetime.utcnow().timestamp())
filename = f"user_{current_user.id}_{timestamp}.{file_ext}"
# Create avatars directory in uploads
avatars_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'avatars')
os.makedirs(avatars_dir, exist_ok=True)
# Save file
file_path = os.path.join(avatars_dir, filename)
file.save(file_path)
# Update user avatar (store relative path from static folder)
current_user.avatar = f"uploads/avatars/{filename}"
db.session.commit()
return jsonify({
'success': True,
'message': 'Avatar uploaded successfully',
'avatar': current_user.avatar
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/avatar/default', methods=['PUT'])
@login_required
def set_default_avatar():
"""
Set avatar to one of the default avatars
Security: Updates only current user's avatar
"""
data = request.get_json()
if not data or 'avatar' not in data:
return jsonify({'success': False, 'error': 'Avatar path required'}), 400
avatar_path = data['avatar']
# Validate it's a default avatar
if not avatar_path.startswith('icons/avatars/avatar-'):
return jsonify({'success': False, 'error': 'Invalid avatar selection'}), 400
try:
# Delete old custom avatar if exists (not default avatars)
if current_user.avatar and not current_user.avatar.startswith('icons/avatars/'):
old_path = os.path.join(current_app.root_path, 'static', current_user.avatar)
if os.path.exists(old_path):
os.remove(old_path)
current_user.avatar = avatar_path
db.session.commit()
return jsonify({
'success': True,
'message': 'Avatar updated successfully',
'avatar': current_user.avatar
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/password', methods=['PUT'])
@login_required
def change_password():
"""
Change user password
Security: Requires current password verification
"""
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'No data provided'}), 400
current_password = data.get('current_password')
new_password = data.get('new_password')
if not current_password or not new_password:
return jsonify({'success': False, 'error': 'Current and new password required'}), 400
# Verify current password
if not bcrypt.check_password_hash(current_user.password_hash, current_password):
return jsonify({'success': False, 'error': 'Current password is incorrect'}), 400
if len(new_password) < 6:
return jsonify({'success': False, 'error': 'Password must be at least 6 characters'}), 400
try:
current_user.password_hash = bcrypt.generate_password_hash(new_password).decode('utf-8')
db.session.commit()
return jsonify({
'success': True,
'message': 'Password changed successfully'
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
<circle cx="50" cy="50" r="50" fill="#3B82F6"/>
<circle cx="50" cy="35" r="15" fill="white"/>
<path d="M25 75 Q25 55 50 55 Q75 55 75 75" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 240 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
<circle cx="50" cy="50" r="50" fill="#10B981"/>
<circle cx="50" cy="35" r="15" fill="white"/>
<path d="M25 75 Q25 55 50 55 Q75 55 75 75" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 240 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
<circle cx="50" cy="50" r="50" fill="#F59E0B"/>
<circle cx="50" cy="35" r="15" fill="white"/>
<path d="M25 75 Q25 55 50 55 Q75 55 75 75" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 240 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
<circle cx="50" cy="50" r="50" fill="#8B5CF6"/>
<circle cx="50" cy="35" r="15" fill="white"/>
<path d="M25 75 Q25 55 50 55 Q75 55 75 75" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 240 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
<circle cx="50" cy="50" r="50" fill="#EF4444"/>
<circle cx="50" cy="35" r="15" fill="white"/>
<path d="M25 75 Q25 55 50 55 Q75 55 75 75" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 240 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
<circle cx="50" cy="50" r="50" fill="#EC4899"/>
<circle cx="50" cy="35" r="15" fill="white"/>
<path d="M25 75 Q25 55 50 55 Q75 55 75 75" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 240 B

View file

@ -0,0 +1,87 @@
from PIL import Image, ImageDraw, ImageFont
import os
def create_fina_logo(size):
# Create image with transparent background
img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Background circle (dark blue gradient effect)
center = size // 2
for i in range(10):
radius = size // 2 - i * 2
alpha = 255 - i * 20
color = (0, 50 + i * 5, 80 + i * 8, alpha)
draw.ellipse([center - radius, center - radius, center + radius, center + radius], fill=color)
# White inner circle
inner_radius = int(size * 0.42)
draw.ellipse([center - inner_radius, center - inner_radius, center + inner_radius, center + inner_radius],
fill=(245, 245, 245, 255))
# Shield (cyan/turquoise)
shield_size = int(size * 0.25)
shield_x = int(center - shield_size * 0.5)
shield_y = int(center - shield_size * 0.3)
# Draw shield shape
shield_points = [
(shield_x, shield_y),
(shield_x + shield_size, shield_y),
(shield_x + shield_size, shield_y + int(shield_size * 0.7)),
(shield_x + shield_size // 2, shield_y + int(shield_size * 1.2)),
(shield_x, shield_y + int(shield_size * 0.7))
]
draw.polygon(shield_points, fill=(64, 224, 208, 200))
# Coins (orange/golden)
coin_radius = int(size * 0.08)
coin_x = int(center + shield_size * 0.3)
coin_y = int(center - shield_size * 0.1)
# Draw 3 stacked coins
for i in range(3):
y_offset = coin_y + i * int(coin_radius * 0.6)
# Coin shadow
draw.ellipse([coin_x - coin_radius + 2, y_offset - coin_radius + 2,
coin_x + coin_radius + 2, y_offset + coin_radius + 2],
fill=(100, 70, 0, 100))
# Coin body (gradient effect)
for j in range(5):
r = coin_radius - j
brightness = 255 - j * 20
draw.ellipse([coin_x - r, y_offset - r, coin_x + r, y_offset + r],
fill=(255, 180 - j * 10, 50 - j * 5, 255))
# Text "FINA"
try:
# Try to use a bold font
font_size = int(size * 0.15)
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size)
except:
font = ImageFont.load_default()
text = "FINA"
text_bbox = draw.textbbox((0, 0), text, font=font)
text_width = text_bbox[2] - text_bbox[0]
text_height = text_bbox[3] - text_bbox[1]
text_x = center - text_width // 2
text_y = int(center + inner_radius * 0.5)
# Text with cyan color
draw.text((text_x, text_y), text, fill=(64, 200, 224, 255), font=font)
return img
# Create logos
logo_512 = create_fina_logo(512)
logo_512.save('logo.png')
logo_512.save('icon-512x512.png')
logo_192 = create_fina_logo(192)
logo_192.save('icon-192x192.png')
logo_64 = create_fina_logo(64)
logo_64.save('favicon.png')
print("FINA logos created successfully!")

View file

@ -0,0 +1,112 @@
from PIL import Image, ImageDraw, ImageFont
import os
def create_fina_logo_round(size):
# Create image with transparent background
img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
center = size // 2
# Outer border circle (light blue/cyan ring)
border_width = int(size * 0.05)
draw.ellipse([0, 0, size, size], fill=(100, 180, 230, 255))
draw.ellipse([border_width, border_width, size - border_width, size - border_width],
fill=(0, 0, 0, 0))
# Background circle (dark blue gradient effect)
for i in range(15):
radius = (size // 2 - border_width) - i * 2
alpha = 255
color = (0, 50 + i * 3, 80 + i * 5, alpha)
draw.ellipse([center - radius, center - radius, center + radius, center + radius], fill=color)
# White inner circle
inner_radius = int(size * 0.38)
draw.ellipse([center - inner_radius, center - inner_radius, center + inner_radius, center + inner_radius],
fill=(245, 245, 245, 255))
# Shield (cyan/turquoise) - smaller for round design
shield_size = int(size * 0.22)
shield_x = int(center - shield_size * 0.6)
shield_y = int(center - shield_size * 0.4)
# Draw shield shape
shield_points = [
(shield_x, shield_y),
(shield_x + shield_size, shield_y),
(shield_x + shield_size, shield_y + int(shield_size * 0.7)),
(shield_x + shield_size // 2, shield_y + int(shield_size * 1.2)),
(shield_x, shield_y + int(shield_size * 0.7))
]
draw.polygon(shield_points, fill=(64, 224, 208, 220))
# Coins (orange/golden) - adjusted position
coin_radius = int(size * 0.07)
coin_x = int(center + shield_size * 0.35)
coin_y = int(center - shield_size * 0.15)
# Draw 3 stacked coins
for i in range(3):
y_offset = coin_y + i * int(coin_radius * 0.55)
# Coin shadow
draw.ellipse([coin_x - coin_radius + 2, y_offset - coin_radius + 2,
coin_x + coin_radius + 2, y_offset + coin_radius + 2],
fill=(100, 70, 0, 100))
# Coin body (gradient effect)
for j in range(5):
r = coin_radius - j
brightness = 255 - j * 20
draw.ellipse([coin_x - r, y_offset - r, coin_x + r, y_offset + r],
fill=(255, 180 - j * 10, 50 - j * 5, 255))
# Text "FINA"
try:
font_size = int(size * 0.13)
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size)
except:
font = ImageFont.load_default()
text = "FINA"
text_bbox = draw.textbbox((0, 0), text, font=font)
text_width = text_bbox[2] - text_bbox[0]
text_height = text_bbox[3] - text_bbox[1]
text_x = center - text_width // 2
text_y = int(center + inner_radius * 0.45)
# Text with cyan color
draw.text((text_x, text_y), text, fill=(43, 140, 238, 255), font=font)
return img
# Create all logo sizes
print("Creating round FINA logos...")
# Main logo for web app
logo_512 = create_fina_logo_round(512)
logo_512.save('logo.png')
logo_512.save('icon-512x512.png')
print("✓ Created logo.png (512x512)")
# PWA icon
logo_192 = create_fina_logo_round(192)
logo_192.save('icon-192x192.png')
print("✓ Created icon-192x192.png")
# Favicon
logo_64 = create_fina_logo_round(64)
logo_64.save('favicon.png')
print("✓ Created favicon.png (64x64)")
# Small icon for notifications
logo_96 = create_fina_logo_round(96)
logo_96.save('icon-96x96.png')
print("✓ Created icon-96x96.png")
# Apple touch icon
logo_180 = create_fina_logo_round(180)
logo_180.save('apple-touch-icon.png')
print("✓ Created apple-touch-icon.png (180x180)")
print("\nAll round FINA logos created successfully!")
print("Logos are circular/round shaped for PWA, notifications, and web app use.")

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1 @@
# Placeholder - the actual logo will be saved from the attachment

View file

@ -0,0 +1,173 @@
// Admin panel functionality
let usersData = [];
// Load users on page load
document.addEventListener('DOMContentLoaded', function() {
loadUsers();
});
async function loadUsers() {
try {
const response = await fetch('/api/admin/users');
const data = await response.json();
if (data.users) {
usersData = data.users;
updateStats();
renderUsersTable();
}
} catch (error) {
console.error('Error loading users:', error);
showToast(window.getTranslation('admin.errorLoading', 'Error loading users'), 'error');
}
}
function updateStats() {
const totalUsers = usersData.length;
const adminUsers = usersData.filter(u => u.is_admin).length;
const twoFAUsers = usersData.filter(u => u.two_factor_enabled).length;
document.getElementById('total-users').textContent = totalUsers;
document.getElementById('admin-users').textContent = adminUsers;
document.getElementById('twofa-users').textContent = twoFAUsers;
}
function renderUsersTable() {
const tbody = document.getElementById('users-table');
if (usersData.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="8" class="px-6 py-8 text-center text-text-muted dark:text-slate-400">
${window.getTranslation('admin.noUsers', 'No users found')}
</td>
</tr>
`;
return;
}
tbody.innerHTML = usersData.map(user => `
<tr class="hover:bg-background-light dark:hover:bg-slate-800/50">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-text-main dark:text-white">${escapeHtml(user.username)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted dark:text-slate-400">${escapeHtml(user.email)}</td>
<td class="px-6 py-4 whitespace-nowrap">
${user.is_admin ?
`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-800 dark:text-blue-400">
${window.getTranslation('admin.admin', 'Admin')}
</span>` :
`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-slate-700 text-gray-800 dark:text-slate-300">
${window.getTranslation('admin.user', 'User')}
</span>`
}
</td>
<td class="px-6 py-4 whitespace-nowrap">
${user.two_factor_enabled ?
`<span class="material-symbols-outlined text-green-500 text-[20px]">check_circle</span>` :
`<span class="material-symbols-outlined text-text-muted dark:text-slate-600 text-[20px]">cancel</span>`
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted dark:text-slate-400">${user.language.toUpperCase()}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted dark:text-slate-400">${user.currency}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted dark:text-slate-400">${new Date(user.created_at).toLocaleDateString()}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<div class="flex items-center gap-2">
<button onclick="editUser(${user.id})" class="text-primary hover:text-primary/80 transition-colors" title="${window.getTranslation('common.edit', 'Edit')}">
<span class="material-symbols-outlined text-[20px]">edit</span>
</button>
<button onclick="deleteUser(${user.id}, '${escapeHtml(user.username)}')" class="text-red-500 hover:text-red-600 transition-colors" title="${window.getTranslation('common.delete', 'Delete')}">
<span class="material-symbols-outlined text-[20px]">delete</span>
</button>
</div>
</td>
</tr>
`).join('');
}
function openCreateUserModal() {
document.getElementById('create-user-modal').classList.remove('hidden');
document.getElementById('create-user-modal').classList.add('flex');
}
function closeCreateUserModal() {
document.getElementById('create-user-modal').classList.add('hidden');
document.getElementById('create-user-modal').classList.remove('flex');
document.getElementById('create-user-form').reset();
}
document.getElementById('create-user-form').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(e.target);
const userData = {
username: formData.get('username'),
email: formData.get('email'),
password: formData.get('password'),
is_admin: formData.get('is_admin') === 'on'
};
try {
const response = await fetch('/api/admin/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(userData)
});
const data = await response.json();
if (data.success) {
showToast(window.getTranslation('admin.userCreated', 'User created successfully'), 'success');
closeCreateUserModal();
loadUsers();
} else {
showToast(data.message || window.getTranslation('admin.errorCreating', 'Error creating user'), 'error');
}
} catch (error) {
console.error('Error creating user:', error);
showToast(window.getTranslation('admin.errorCreating', 'Error creating user'), 'error');
}
});
async function deleteUser(userId, username) {
if (!confirm(window.getTranslation('admin.confirmDelete', 'Are you sure you want to delete user') + ` "${username}"?`)) {
return;
}
try {
const response = await fetch(`/api/admin/users/${userId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
showToast(window.getTranslation('admin.userDeleted', 'User deleted successfully'), 'success');
loadUsers();
} else {
showToast(data.message || window.getTranslation('admin.errorDeleting', 'Error deleting user'), 'error');
}
} catch (error) {
console.error('Error deleting user:', error);
showToast(window.getTranslation('admin.errorDeleting', 'Error deleting user'), 'error');
}
}
async function editUser(userId) {
// Placeholder for edit functionality
showToast(window.getTranslation('admin.editNotImplemented', 'Edit functionality coming soon'), 'info');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showToast(message, type = 'info') {
if (typeof window.showToast === 'function') {
window.showToast(message, type);
} else {
alert(message);
}
}

View file

@ -0,0 +1,181 @@
// Global utility functions
// Toast notifications
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
info: 'bg-primary',
warning: 'bg-yellow-500'
};
toast.className = `${colors[type]} text-white px-6 py-3 rounded-lg shadow-lg flex items-center gap-3 animate-slide-in`;
toast.innerHTML = `
<span class="material-symbols-outlined text-[20px]">
${type === 'success' ? 'check_circle' : type === 'error' ? 'error' : 'info'}
</span>
<span>${message}</span>
`;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Format currency
function formatCurrency(amount, currency = 'USD') {
const symbols = {
'USD': '$',
'EUR': '€',
'GBP': '£',
'RON': 'lei'
};
const symbol = symbols[currency] || currency;
const formatted = parseFloat(amount).toFixed(2);
if (currency === 'RON') {
return `${formatted} ${symbol}`;
}
return `${symbol}${formatted}`;
}
// Format date
function formatDate(dateString) {
const date = new Date(dateString);
const now = new Date();
const diff = now - date;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return window.getTranslation ? window.getTranslation('date.today', 'Today') : 'Today';
if (days === 1) return window.getTranslation ? window.getTranslation('date.yesterday', 'Yesterday') : 'Yesterday';
if (days < 7) {
const daysAgoText = window.getTranslation ? window.getTranslation('date.daysAgo', 'days ago') : 'days ago';
return `${days} ${daysAgoText}`;
}
const lang = window.getCurrentLanguage ? window.getCurrentLanguage() : 'en';
const locale = lang === 'ro' ? 'ro-RO' : 'en-US';
return date.toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric' });
}
// API helper
async function apiCall(url, options = {}) {
try {
// Don't set Content-Type header for FormData - browser will set it automatically with boundary
const headers = options.body instanceof FormData
? { ...options.headers }
: { ...options.headers, 'Content-Type': 'application/json' };
const response = await fetch(url, {
...options,
headers
});
if (!response.ok) {
// Try to get error message from response
try {
const errorData = await response.json();
const errorMsg = errorData.message || window.getTranslation('common.error', 'An error occurred. Please try again.');
showToast(errorMsg, 'error');
} catch (e) {
showToast(window.getTranslation('common.error', 'An error occurred. Please try again.'), 'error');
}
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('API call failed:', error);
if (!error.message.includes('HTTP error')) {
showToast(window.getTranslation('common.error', 'An error occurred. Please try again.'), 'error');
}
throw error;
}
}
// Theme management
function initTheme() {
// Theme is already applied in head, just update UI
const isDark = document.documentElement.classList.contains('dark');
updateThemeUI(isDark);
}
function toggleTheme() {
const isDark = document.documentElement.classList.contains('dark');
if (isDark) {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
updateThemeUI(false);
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
updateThemeUI(true);
}
// Dispatch custom event for other components to react to theme change
window.dispatchEvent(new CustomEvent('theme-changed', { detail: { isDark: !isDark } }));
}
function updateThemeUI(isDark) {
const themeIcon = document.getElementById('theme-icon');
const themeText = document.getElementById('theme-text');
if (themeIcon && themeText) {
if (isDark) {
themeIcon.textContent = 'dark_mode';
const darkModeText = window.getTranslation ? window.getTranslation('dashboard.darkMode', 'Dark Mode') : 'Dark Mode';
themeText.textContent = darkModeText;
themeText.setAttribute('data-translate', 'dashboard.darkMode');
} else {
themeIcon.textContent = 'light_mode';
const lightModeText = window.getTranslation ? window.getTranslation('dashboard.lightMode', 'Light Mode') : 'Light Mode';
themeText.textContent = lightModeText;
themeText.setAttribute('data-translate', 'dashboard.lightMode');
}
}
}
// Mobile menu toggle
document.addEventListener('DOMContentLoaded', () => {
// Initialize theme
initTheme();
// Theme toggle button
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', toggleTheme);
}
// Mobile menu
const menuToggle = document.getElementById('menu-toggle');
const sidebar = document.getElementById('sidebar');
if (menuToggle && sidebar) {
menuToggle.addEventListener('click', () => {
sidebar.classList.toggle('hidden');
sidebar.classList.toggle('flex');
sidebar.classList.toggle('absolute');
sidebar.classList.toggle('z-50');
sidebar.style.left = '0';
});
// Close sidebar when clicking outside on mobile
document.addEventListener('click', (e) => {
if (window.innerWidth < 1024) {
if (!sidebar.contains(e.target) && !menuToggle.contains(e.target)) {
sidebar.classList.add('hidden');
sidebar.classList.remove('flex');
}
}
});
}
});

View file

@ -0,0 +1,781 @@
// Dashboard JavaScript
let categoryChart, monthlyChart;
// Load dashboard data
async function loadDashboardData() {
try {
const stats = await apiCall('/api/dashboard-stats');
// Store user currency globally for use across functions
window.userCurrency = stats.currency || 'RON';
// Ensure we have valid data with defaults
const totalSpent = parseFloat(stats.total_spent || 0);
const activeCategories = parseInt(stats.active_categories || 0);
const totalTransactions = parseInt(stats.total_transactions || 0);
const categoryBreakdown = stats.category_breakdown || [];
const monthlyData = stats.monthly_data || [];
// Update KPI cards
document.getElementById('total-spent').textContent = formatCurrency(totalSpent, window.userCurrency);
document.getElementById('active-categories').textContent = activeCategories;
document.getElementById('total-transactions').textContent = totalTransactions;
// Update percent change
const percentChange = document.getElementById('percent-change');
const percentChangeValue = parseFloat(stats.percent_change || 0);
const isPositive = percentChangeValue >= 0;
percentChange.className = `${isPositive ? 'bg-red-500/10 text-red-400' : 'bg-green-500/10 text-green-400'} text-xs font-semibold px-2 py-1 rounded-full flex items-center gap-1`;
percentChange.innerHTML = `
<span class="material-symbols-outlined text-[14px]">${isPositive ? 'trending_up' : 'trending_down'}</span>
${Math.abs(percentChangeValue).toFixed(1)}%
`;
// Load charts with validated data
loadCategoryChart(categoryBreakdown);
loadMonthlyChart(monthlyData);
// Load category cards
loadCategoryCards(categoryBreakdown, totalSpent);
// Load recent transactions
loadRecentTransactions();
} catch (error) {
console.error('Failed to load dashboard data:', error);
}
}
// Category pie chart with CSS conic-gradient (beautiful & lightweight)
function loadCategoryChart(data) {
const pieChart = document.getElementById('pie-chart');
const pieTotal = document.getElementById('pie-total');
const pieLegend = document.getElementById('pie-legend');
if (!pieChart || !pieTotal || !pieLegend) return;
if (!data || data.length === 0) {
pieChart.style.background = 'conic-gradient(#233648 0% 100%)';
pieTotal.textContent = '0 lei';
pieLegend.innerHTML = '<p class="col-span-2 text-center text-text-muted dark:text-[#92adc9] text-sm">' +
(window.getTranslation ? window.getTranslation('dashboard.noData', 'No data available') : 'No data available') + '</p>';
return;
}
// Calculate total and get user currency from API response (stored globally)
const total = data.reduce((sum, cat) => sum + parseFloat(cat.total || 0), 0);
const userCurrency = window.userCurrency || 'RON';
pieTotal.textContent = formatCurrency(total, userCurrency);
// Generate conic gradient segments
let currentPercent = 0;
const gradientSegments = data.map(cat => {
const percent = total > 0 ? (parseFloat(cat.total || 0) / total) * 100 : 0;
const segment = `${cat.color} ${currentPercent}% ${currentPercent + percent}%`;
currentPercent += percent;
return segment;
});
// Apply gradient with smooth transitions
pieChart.style.background = `conic-gradient(${gradientSegments.join(', ')})`;
// Generate compact legend for 12-14 categories
pieLegend.innerHTML = data.map(cat => {
const percent = total > 0 ? ((parseFloat(cat.total || 0) / total) * 100).toFixed(1) : 0;
return `
<div class="flex items-center gap-1.5 group cursor-pointer hover:opacity-80 transition-opacity py-0.5">
<span class="size-2 rounded-full flex-shrink-0" style="background: ${cat.color};"></span>
<span class="text-text-muted dark:text-[#92adc9] text-[10px] truncate flex-1 leading-tight">${cat.name}</span>
<span class="text-text-muted dark:text-[#92adc9] text-[10px] font-medium">${percent}%</span>
</div>
`;
}).join('');
}
// Monthly bar chart - slim & elegant for 12 months PWA design
function loadMonthlyChart(data) {
const ctx = document.getElementById('monthly-chart').getContext('2d');
if (monthlyChart) {
monthlyChart.destroy();
}
monthlyChart = new Chart(ctx, {
type: 'bar',
data: {
labels: data.map(d => d.month),
datasets: [{
label: window.getTranslation ? window.getTranslation('dashboard.spending', 'Spending') : 'Spending',
data: data.map(d => d.total),
backgroundColor: '#2b8cee',
borderRadius: 6,
barPercentage: 0.5, // Make bars slimmer
categoryPercentage: 0.7 // Tighter spacing between bars
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: document.documentElement.classList.contains('dark') ? '#1a2632' : '#ffffff',
titleColor: document.documentElement.classList.contains('dark') ? '#ffffff' : '#1a2632',
bodyColor: document.documentElement.classList.contains('dark') ? '#92adc9' : '#64748b',
borderColor: document.documentElement.classList.contains('dark') ? '#233648' : '#e2e8f0',
borderWidth: 1,
padding: 12,
displayColors: false,
callbacks: {
label: function(context) {
const userCurrency = window.userCurrency || 'RON';
return formatCurrency(context.parsed.y, userCurrency);
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
color: document.documentElement.classList.contains('dark') ? '#92adc9' : '#64748b',
font: { size: 11 },
maxTicksLimit: 6
},
grid: {
color: document.documentElement.classList.contains('dark') ? '#233648' : '#e2e8f0',
drawBorder: false
},
border: { display: false }
},
x: {
ticks: {
color: document.documentElement.classList.contains('dark') ? '#92adc9' : '#64748b',
font: { size: 10 },
autoSkip: false, // Show all 12 months
maxRotation: 0,
minRotation: 0
},
grid: { display: false },
border: { display: false }
}
},
layout: {
padding: {
left: 5,
right: 5,
top: 5,
bottom: 0
}
}
}
});
}
// Load recent transactions
async function loadRecentTransactions() {
try {
const data = await apiCall('/api/recent-transactions?limit=5');
const container = document.getElementById('recent-transactions');
if (data.transactions.length === 0) {
const noTransText = window.getTranslation ? window.getTranslation('dashboard.noTransactions', 'No transactions yet') : 'No transactions yet';
container.innerHTML = `<p class="text-[#92adc9] text-sm text-center py-8">${noTransText}</p>`;
return;
}
container.innerHTML = data.transactions.map(tx => `
<div class="flex items-center justify-between p-4 rounded-lg bg-slate-50 dark:bg-[#111a22] border border-border-light dark:border-[#233648] hover:border-primary/30 transition-colors">
<div class="flex items-center gap-3 flex-1">
<div class="w-10 h-10 rounded-lg flex items-center justify-center" style="background: ${tx.category_color}20;">
<span class="material-symbols-outlined text-[20px]" style="color: ${tx.category_color};">payments</span>
</div>
<div class="flex-1">
<p class="text-text-main dark:text-white font-medium text-sm">${tx.description}</p>
<p class="text-text-muted dark:text-[#92adc9] text-xs">${tx.category_name} ${formatDate(tx.date)}</p>
</div>
</div>
<div class="text-right">
<p class="text-text-main dark:text-white font-semibold">${formatCurrency(tx.amount, window.userCurrency || 'RON')}</p>
${tx.tags.length > 0 ? `<p class="text-text-muted dark:text-[#92adc9] text-xs">${tx.tags.join(', ')}</p>` : ''}
</div>
</div>
`).join('');
} catch (error) {
console.error('Failed to load transactions:', error);
}
}
// Format currency helper
function formatCurrency(amount, currency) {
const symbols = {
'USD': '$',
'EUR': '€',
'GBP': '£',
'RON': 'lei'
};
const symbol = symbols[currency] || currency;
const formattedAmount = parseFloat(amount || 0).toFixed(2);
if (currency === 'RON') {
return `${formattedAmount} ${symbol}`;
}
return `${symbol}${formattedAmount}`;
}
// Load category cards with drag and drop (with NaN prevention)
function loadCategoryCards(categoryBreakdown, totalSpent) {
const container = document.getElementById('category-cards');
if (!container) return;
// Validate data
if (!categoryBreakdown || !Array.isArray(categoryBreakdown) || categoryBreakdown.length === 0) {
container.innerHTML = '<p class="col-span-3 text-center text-text-muted dark:text-[#92adc9] py-8">' +
(window.getTranslation ? window.getTranslation('dashboard.noCategories', 'No categories yet') : 'No categories yet') + '</p>';
return;
}
// Icon mapping
const categoryIcons = {
'Food & Dining': 'restaurant',
'Transportation': 'directions_car',
'Shopping': 'shopping_cart',
'Entertainment': 'movie',
'Bills & Utilities': 'receipt',
'Healthcare': 'medical_services',
'Education': 'school',
'Other': 'category'
};
// Ensure totalSpent is a valid number
const validTotalSpent = parseFloat(totalSpent || 0);
container.innerHTML = categoryBreakdown.map(cat => {
const total = parseFloat(cat.total || 0);
const count = parseInt(cat.count || 0);
const percentage = validTotalSpent > 0 ? ((total / validTotalSpent) * 100).toFixed(1) : 0;
const icon = categoryIcons[cat.name] || 'category';
return `
<div class="category-card bg-white dark:bg-[#0f1921] rounded-xl p-5 border border-border-light dark:border-[#233648] hover:border-primary/30 transition-all hover:shadow-lg cursor-move touch-manipulation"
draggable="true"
data-category-id="${cat.id}">
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-xl flex items-center justify-center" style="background: ${cat.color};">
<span class="material-symbols-outlined text-white text-[24px]">${icon}</span>
</div>
<div>
<h3 class="font-semibold text-text-main dark:text-white">${cat.name}</h3>
<p class="text-xs text-text-muted dark:text-[#92adc9]">${count} ${count === 1 ? (window.getTranslation ? window.getTranslation('transactions.transaction', 'transaction') : 'transaction') : (window.getTranslation ? window.getTranslation('transactions.transactions', 'transactions') : 'transactions')}</p>
</div>
</div>
<span class="text-xs font-medium text-text-muted dark:text-[#92adc9] bg-slate-100 dark:bg-[#111a22] px-2 py-1 rounded-full">${percentage}%</span>
</div>
<div class="mb-2">
<p class="text-2xl font-bold text-text-main dark:text-white">${formatCurrency(total, window.userCurrency || 'RON')}</p>
</div>
<div class="w-full bg-slate-200 dark:bg-[#111a22] rounded-full h-2">
<div class="h-2 rounded-full transition-all duration-500" style="width: ${percentage}%; background: ${cat.color};"></div>
</div>
</div>
`;
}).join('');
// Enable drag and drop on category cards
enableCategoryCardsDragDrop();
}
// Enable drag and drop for category cards on dashboard
let draggedCard = null;
function enableCategoryCardsDragDrop() {
const cards = document.querySelectorAll('.category-card');
cards.forEach(card => {
// Drag start
card.addEventListener('dragstart', function(e) {
draggedCard = this;
this.style.opacity = '0.5';
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', this.innerHTML);
});
// Drag over
card.addEventListener('dragover', function(e) {
if (e.preventDefault) {
e.preventDefault();
}
e.dataTransfer.dropEffect = 'move';
if (draggedCard !== this) {
const container = document.getElementById('category-cards');
const allCards = [...container.querySelectorAll('.category-card')];
const draggedIndex = allCards.indexOf(draggedCard);
const targetIndex = allCards.indexOf(this);
if (draggedIndex < targetIndex) {
this.parentNode.insertBefore(draggedCard, this.nextSibling);
} else {
this.parentNode.insertBefore(draggedCard, this);
}
}
return false;
});
// Drag enter
card.addEventListener('dragenter', function(e) {
if (draggedCard !== this) {
this.style.borderColor = '#2b8cee';
}
});
// Drag leave
card.addEventListener('dragleave', function(e) {
this.style.borderColor = '';
});
// Drop
card.addEventListener('drop', function(e) {
if (e.stopPropagation) {
e.stopPropagation();
}
this.style.borderColor = '';
return false;
});
// Drag end
card.addEventListener('dragend', function(e) {
this.style.opacity = '1';
// Reset all borders
const allCards = document.querySelectorAll('.category-card');
allCards.forEach(c => c.style.borderColor = '');
// Save new order
saveDashboardCategoryOrder();
});
// Touch support for mobile
card.addEventListener('touchstart', handleTouchStart, {passive: false});
card.addEventListener('touchmove', handleTouchMove, {passive: false});
card.addEventListener('touchend', handleTouchEnd, {passive: false});
});
}
// Touch event handlers for mobile drag and drop with hold-to-drag
let touchStartPos = null;
let touchedCard = null;
let holdTimer = null;
let isDraggingEnabled = false;
const HOLD_DURATION = 500; // 500ms hold required to start dragging
function handleTouchStart(e) {
// Don't interfere with scrolling initially
touchedCard = this;
touchStartPos = {
x: e.touches[0].clientX,
y: e.touches[0].clientY
};
isDraggingEnabled = false;
// Start hold timer
holdTimer = setTimeout(() => {
// After holding, enable dragging
isDraggingEnabled = true;
if (touchedCard) {
touchedCard.style.opacity = '0.5';
touchedCard.style.transform = 'scale(1.05)';
// Haptic feedback if available
if (navigator.vibrate) {
navigator.vibrate(50);
}
}
}, HOLD_DURATION);
}
function handleTouchMove(e) {
if (!touchedCard || !touchStartPos) return;
const touch = e.touches[0];
const deltaX = Math.abs(touch.clientX - touchStartPos.x);
const deltaY = Math.abs(touch.clientY - touchStartPos.y);
// If moved too much before hold timer completes, cancel hold
if (!isDraggingEnabled && (deltaX > 10 || deltaY > 10)) {
clearTimeout(holdTimer);
touchedCard = null;
touchStartPos = null;
return;
}
// Only allow dragging if hold timer completed
if (!isDraggingEnabled) return;
// Prevent scrolling when dragging
e.preventDefault();
const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY);
const targetCard = elementBelow?.closest('.category-card');
if (targetCard && targetCard !== touchedCard) {
const container = document.getElementById('category-cards');
const allCards = [...container.querySelectorAll('.category-card')];
const touchedIndex = allCards.indexOf(touchedCard);
const targetIndex = allCards.indexOf(targetCard);
if (touchedIndex < targetIndex) {
targetCard.parentNode.insertBefore(touchedCard, targetCard.nextSibling);
} else {
targetCard.parentNode.insertBefore(touchedCard, targetCard);
}
}
}
function handleTouchEnd(e) {
// Clear hold timer if touch ended early
clearTimeout(holdTimer);
if (touchedCard) {
touchedCard.style.opacity = '1';
touchedCard.style.transform = '';
// Only save if dragging actually happened
if (isDraggingEnabled) {
saveDashboardCategoryOrder();
}
touchedCard = null;
touchStartPos = null;
isDraggingEnabled = false;
}
}
// Save dashboard category card order
async function saveDashboardCategoryOrder() {
const cards = document.querySelectorAll('.category-card');
const reorderedCategories = Array.from(cards).map((card, index) => ({
id: parseInt(card.dataset.categoryId),
display_order: index
}));
try {
await apiCall('/api/expenses/categories/reorder', {
method: 'PUT',
body: JSON.stringify({ categories: reorderedCategories })
});
// Silently save - no notification to avoid disrupting UX during drag
} catch (error) {
console.error('Failed to save category order:', error);
showToast(getTranslation('common.error', 'Failed to save order'), 'error');
}
}
// Expense modal
const expenseModal = document.getElementById('expense-modal');
const addExpenseBtn = document.getElementById('add-expense-btn');
const closeModalBtn = document.getElementById('close-modal');
const expenseForm = document.getElementById('expense-form');
// Load categories for dropdown
async function loadCategories() {
try {
const data = await apiCall('/api/expenses/categories');
const select = expenseForm.querySelector('[name="category_id"]');
const selectText = window.getTranslation ? window.getTranslation('dashboard.selectCategory', 'Select category...') : 'Select category...';
// Map category names to translation keys
const categoryTranslations = {
'Food & Dining': 'categories.foodDining',
'Transportation': 'categories.transportation',
'Shopping': 'categories.shopping',
'Entertainment': 'categories.entertainment',
'Bills & Utilities': 'categories.billsUtilities',
'Healthcare': 'categories.healthcare',
'Education': 'categories.education',
'Other': 'categories.other'
};
select.innerHTML = `<option value="">${selectText}</option>` +
data.categories.map(cat => {
const translationKey = categoryTranslations[cat.name];
const translatedName = translationKey && window.getTranslation
? window.getTranslation(translationKey, cat.name)
: cat.name;
return `<option value="${cat.id}">${translatedName}</option>`;
}).join('');
} catch (error) {
console.error('Failed to load categories:', error);
}
}
// Open modal
addExpenseBtn.addEventListener('click', () => {
expenseModal.classList.remove('hidden');
loadCategories();
// Set today's date as default
const dateInput = expenseForm.querySelector('[name="date"]');
dateInput.value = new Date().toISOString().split('T')[0];
});
// Close modal
closeModalBtn.addEventListener('click', () => {
expenseModal.classList.add('hidden');
expenseForm.reset();
});
// Close modal on outside click
expenseModal.addEventListener('click', (e) => {
if (e.target === expenseModal) {
expenseModal.classList.add('hidden');
expenseForm.reset();
}
});
// Submit expense form
expenseForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(expenseForm);
// Convert tags to array
const tagsString = formData.get('tags');
if (tagsString) {
const tags = tagsString.split(',').map(t => t.trim()).filter(t => t);
formData.set('tags', JSON.stringify(tags));
}
// Convert date to ISO format
const date = new Date(formData.get('date'));
formData.set('date', date.toISOString());
try {
const result = await apiCall('/api/expenses/', {
method: 'POST',
body: formData
});
if (result.success) {
const successMsg = window.getTranslation ? window.getTranslation('dashboard.expenseAdded', 'Expense added successfully!') : 'Expense added successfully!';
showToast(successMsg, 'success');
expenseModal.classList.add('hidden');
expenseForm.reset();
loadDashboardData();
}
} catch (error) {
console.error('Failed to add expense:', error);
}
});
// Category Management Modal
const categoryModal = document.getElementById('category-modal');
const manageCategoriesBtn = document.getElementById('manage-categories-btn');
const closeCategoryModal = document.getElementById('close-category-modal');
const addCategoryForm = document.getElementById('add-category-form');
const categoriesList = document.getElementById('categories-list');
let allCategories = [];
let draggedElement = null;
// Open category modal
manageCategoriesBtn.addEventListener('click', async () => {
categoryModal.classList.remove('hidden');
await loadCategoriesManagement();
});
// Close category modal
closeCategoryModal.addEventListener('click', () => {
categoryModal.classList.add('hidden');
loadDashboardData(); // Refresh dashboard
});
categoryModal.addEventListener('click', (e) => {
if (e.target === categoryModal) {
categoryModal.classList.add('hidden');
loadDashboardData();
}
});
// Add new category
addCategoryForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(addCategoryForm);
const data = {
name: formData.get('name'),
color: formData.get('color'),
icon: formData.get('icon') || 'category'
};
try {
const result = await apiCall('/api/expenses/categories', {
method: 'POST',
body: JSON.stringify(data)
});
if (result.success) {
showToast(getTranslation('categories.created', 'Category created successfully'), 'success');
addCategoryForm.reset();
await loadCategoriesManagement();
}
} catch (error) {
console.error('Failed to create category:', error);
showToast(getTranslation('common.error', 'An error occurred'), 'error');
}
});
// Load categories for management
async function loadCategoriesManagement() {
try {
const data = await apiCall('/api/expenses/categories');
allCategories = data.categories;
renderCategoriesList();
} catch (error) {
console.error('Failed to load categories:', error);
}
}
// Render categories list with drag and drop
function renderCategoriesList() {
categoriesList.innerHTML = allCategories.map((cat, index) => `
<div class="category-item bg-white dark:bg-card-dark border border-border-light dark:border-[#233648] rounded-lg p-4 flex items-center justify-between hover:border-primary/30 transition-all cursor-move"
draggable="true"
data-id="${cat.id}"
data-order="${index}">
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-[24px] drag-handle cursor-move" style="color: ${cat.color};">drag_indicator</span>
<div class="w-10 h-10 rounded-lg flex items-center justify-center" style="background: ${cat.color};">
<span class="material-symbols-outlined text-white text-[20px]">${cat.icon}</span>
</div>
<div>
<p class="text-text-main dark:text-white font-medium">${cat.name}</p>
<p class="text-text-muted dark:text-[#92adc9] text-xs">${cat.color} ${cat.icon}</p>
</div>
</div>
<div class="flex items-center gap-2">
<button onclick="deleteCategory(${cat.id})" class="text-red-500 hover:text-red-600 p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors">
<span class="material-symbols-outlined text-[20px]">delete</span>
</button>
</div>
</div>
`).join('');
// Add drag and drop event listeners
const items = categoriesList.querySelectorAll('.category-item');
items.forEach(item => {
item.addEventListener('dragstart', handleDragStart);
item.addEventListener('dragover', handleDragOver);
item.addEventListener('drop', handleDrop);
item.addEventListener('dragend', handleDragEnd);
});
}
// Drag and drop handlers
function handleDragStart(e) {
draggedElement = this;
this.style.opacity = '0.4';
e.dataTransfer.effectAllowed = 'move';
}
function handleDragOver(e) {
if (e.preventDefault) {
e.preventDefault();
}
e.dataTransfer.dropEffect = 'move';
const afterElement = getDragAfterElement(categoriesList, e.clientY);
if (afterElement == null) {
categoriesList.appendChild(draggedElement);
} else {
categoriesList.insertBefore(draggedElement, afterElement);
}
return false;
}
function handleDrop(e) {
if (e.stopPropagation) {
e.stopPropagation();
}
return false;
}
function handleDragEnd(e) {
this.style.opacity = '1';
// Update order in backend
const items = categoriesList.querySelectorAll('.category-item');
const reorderedCategories = Array.from(items).map((item, index) => ({
id: parseInt(item.dataset.id),
display_order: index
}));
saveCategoriesOrder(reorderedCategories);
}
function getDragAfterElement(container, y) {
const draggableElements = [...container.querySelectorAll('.category-item:not([style*="opacity: 0.4"])')];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset: offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
// Save category order
async function saveCategoriesOrder(categories) {
try {
await apiCall('/api/expenses/categories/reorder', {
method: 'PUT',
body: JSON.stringify({ categories })
});
showToast(getTranslation('categories.reordered', 'Categories reordered successfully'), 'success');
} catch (error) {
console.error('Failed to reorder categories:', error);
showToast(getTranslation('common.error', 'An error occurred'), 'error');
}
}
// Delete category
async function deleteCategory(id) {
if (!confirm(getTranslation('common.delete', 'Are you sure?'))) {
return;
}
try {
const result = await apiCall(`/api/expenses/categories/${id}`, {
method: 'DELETE'
});
if (result.success) {
showToast(getTranslation('categories.deleted', 'Category deleted successfully'), 'success');
await loadCategoriesManagement();
}
} catch (error) {
console.error('Failed to delete category:', error);
if (error.message && error.message.includes('expenses')) {
showToast(getTranslation('categories.hasExpenses', 'Cannot delete category with expenses'), 'error');
} else {
showToast(getTranslation('common.error', 'An error occurred'), 'error');
}
}
}
// Make deleteCategory global
window.deleteCategory = deleteCategory;
// Initialize dashboard
document.addEventListener('DOMContentLoaded', () => {
loadDashboardData();
// Refresh data every 5 minutes
setInterval(loadDashboardData, 5 * 60 * 1000);
});

View file

@ -0,0 +1,485 @@
// Documents Page Functionality
let currentPage = 1;
const itemsPerPage = 10;
let searchQuery = '';
let allDocuments = [];
// Initialize documents page
document.addEventListener('DOMContentLoaded', () => {
loadDocuments();
setupEventListeners();
});
// Setup event listeners
function setupEventListeners() {
// File input change
const fileInput = document.getElementById('file-input');
if (fileInput) {
fileInput.addEventListener('change', handleFileSelect);
}
// Drag and drop
const uploadArea = document.getElementById('upload-area');
if (uploadArea) {
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('!border-primary', '!bg-primary/5');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('!border-primary', '!bg-primary/5');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('!border-primary', '!bg-primary/5');
const files = e.dataTransfer.files;
handleFiles(files);
});
}
// Search input
const searchInput = document.getElementById('search-input');
if (searchInput) {
let debounceTimer;
searchInput.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
searchQuery = e.target.value.toLowerCase();
currentPage = 1;
loadDocuments();
}, 300);
});
}
}
// Handle file select from input
function handleFileSelect(e) {
const files = e.target.files;
handleFiles(files);
}
// Handle file upload
async function handleFiles(files) {
if (files.length === 0) return;
const allowedTypes = ['pdf', 'csv', 'xlsx', 'xls', 'png', 'jpg', 'jpeg'];
const maxSize = 10 * 1024 * 1024; // 10MB
for (const file of files) {
const ext = file.name.split('.').pop().toLowerCase();
if (!allowedTypes.includes(ext)) {
showNotification('error', `${file.name}: Unsupported file type. Only PDF, CSV, XLS, XLSX, PNG, JPG allowed.`);
continue;
}
if (file.size > maxSize) {
showNotification('error', `${file.name}: File size exceeds 10MB limit.`);
continue;
}
await uploadFile(file);
}
// Reset file input
const fileInput = document.getElementById('file-input');
if (fileInput) fileInput.value = '';
// Reload documents list
loadDocuments();
}
// Upload file to server
async function uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/documents/', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok) {
showNotification('success', `${file.name} uploaded successfully!`);
} else {
showNotification('error', result.error || 'Upload failed');
}
} catch (error) {
console.error('Upload error:', error);
showNotification('error', 'An error occurred during upload');
}
}
// Load documents from API
async function loadDocuments() {
try {
const params = new URLSearchParams({
page: currentPage,
per_page: itemsPerPage
});
if (searchQuery) {
params.append('search', searchQuery);
}
const data = await apiCall(`/api/documents/?${params.toString()}`);
allDocuments = data.documents;
displayDocuments(data.documents);
updatePagination(data.pagination);
} catch (error) {
console.error('Error loading documents:', error);
document.getElementById('documents-list').innerHTML = `
<tr>
<td colspan="5" class="px-6 py-8 text-center text-text-muted dark:text-[#92adc9]">
<span data-translate="documents.errorLoading">Failed to load documents. Please try again.</span>
</td>
</tr>
`;
}
}
// Display documents in table
function displayDocuments(documents) {
const tbody = document.getElementById('documents-list');
if (documents.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="5" class="px-6 py-8 text-center text-text-muted dark:text-[#92adc9]">
<span data-translate="documents.noDocuments">No documents found. Upload your first document!</span>
</td>
</tr>
`;
return;
}
tbody.innerHTML = documents.map(doc => {
const statusConfig = getStatusConfig(doc.status);
const fileIcon = getFileIcon(doc.file_type);
return `
<tr class="hover:bg-slate-50 dark:hover:bg-white/[0.02] transition-colors">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-[20px] ${fileIcon.color}">${fileIcon.icon}</span>
<div class="flex flex-col">
<span class="text-text-main dark:text-white font-medium">${escapeHtml(doc.original_filename)}</span>
<span class="text-xs text-text-muted dark:text-[#92adc9]">${formatFileSize(doc.file_size)}</span>
</div>
</div>
</td>
<td class="px-6 py-4 text-text-main dark:text-white">
${formatDate(doc.created_at)}
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-slate-100 dark:bg-white/10 text-text-main dark:text-white">
${doc.document_category || 'Other'}
</span>
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${statusConfig.className}">
${statusConfig.hasIcon ? `<span class="material-symbols-outlined text-[14px]">${statusConfig.icon}</span>` : ''}
<span data-translate="documents.status${doc.status.charAt(0).toUpperCase() + doc.status.slice(1)}">${doc.status}</span>
</span>
</td>
<td class="px-6 py-4">
<div class="flex items-center justify-end gap-2">
${['PNG', 'JPG', 'JPEG', 'PDF'].includes(doc.file_type.toUpperCase()) ?
`<button onclick="viewDocument(${doc.id}, '${doc.file_type}', '${escapeHtml(doc.original_filename)}')" class="p-2 text-text-muted dark:text-[#92adc9] hover:text-primary hover:bg-primary/10 rounded-lg transition-colors" title="View">
<span class="material-symbols-outlined text-[20px]">visibility</span>
</button>` : ''
}
<button onclick="downloadDocument(${doc.id})" class="p-2 text-text-muted dark:text-[#92adc9] hover:text-primary hover:bg-primary/10 rounded-lg transition-colors" title="Download">
<span class="material-symbols-outlined text-[20px]">download</span>
</button>
<button onclick="deleteDocument(${doc.id})" class="p-2 text-text-muted dark:text-[#92adc9] hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-lg transition-colors" title="Delete">
<span class="material-symbols-outlined text-[20px]">delete</span>
</button>
</div>
</td>
</tr>
`;
}).join('');
}
// Get status configuration
function getStatusConfig(status) {
const configs = {
uploaded: {
className: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400',
icon: 'upload',
hasIcon: true
},
processing: {
className: 'bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-400 animate-pulse',
icon: 'sync',
hasIcon: true
},
analyzed: {
className: 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400',
icon: 'verified',
hasIcon: true
},
error: {
className: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400',
icon: 'error',
hasIcon: true
}
};
return configs[status] || configs.uploaded;
}
// Get file icon
function getFileIcon(fileType) {
const icons = {
pdf: { icon: 'picture_as_pdf', color: 'text-red-500' },
csv: { icon: 'table_view', color: 'text-green-500' },
xlsx: { icon: 'table_view', color: 'text-green-600' },
xls: { icon: 'table_view', color: 'text-green-600' },
png: { icon: 'image', color: 'text-blue-500' },
jpg: { icon: 'image', color: 'text-blue-500' },
jpeg: { icon: 'image', color: 'text-blue-500' }
};
return icons[fileType?.toLowerCase()] || { icon: 'description', color: 'text-gray-500' };
}
// Update pagination
function updatePagination(pagination) {
const { page, pages, total, per_page } = pagination;
// Update count display
const start = (page - 1) * per_page + 1;
const end = Math.min(page * per_page, total);
document.getElementById('page-start').textContent = total > 0 ? start : 0;
document.getElementById('page-end').textContent = end;
document.getElementById('total-count').textContent = total;
// Update pagination buttons
const paginationDiv = document.getElementById('pagination');
if (pages <= 1) {
paginationDiv.innerHTML = '';
return;
}
let buttons = '';
// Previous button
buttons += `
<button onclick="changePage(${page - 1})"
class="px-3 py-1.5 rounded-lg text-sm font-medium ${page === 1 ? 'bg-gray-100 dark:bg-white/5 text-text-muted dark:text-[#92adc9]/50 cursor-not-allowed' : 'bg-card-light dark:bg-card-dark text-text-main dark:text-white hover:bg-slate-100 dark:hover:bg-white/10 border border-border-light dark:border-[#233648]'} transition-colors"
${page === 1 ? 'disabled' : ''}>
<span class="material-symbols-outlined text-[18px]">chevron_left</span>
</button>
`;
// Page numbers
const maxButtons = 5;
let startPage = Math.max(1, page - Math.floor(maxButtons / 2));
let endPage = Math.min(pages, startPage + maxButtons - 1);
if (endPage - startPage < maxButtons - 1) {
startPage = Math.max(1, endPage - maxButtons + 1);
}
for (let i = startPage; i <= endPage; i++) {
buttons += `
<button onclick="changePage(${i})"
class="px-3 py-1.5 rounded-lg text-sm font-medium ${i === page ? 'bg-primary text-white' : 'bg-card-light dark:bg-card-dark text-text-main dark:text-white hover:bg-slate-100 dark:hover:bg-white/10 border border-border-light dark:border-[#233648]'} transition-colors">
${i}
</button>
`;
}
// Next button
buttons += `
<button onclick="changePage(${page + 1})"
class="px-3 py-1.5 rounded-lg text-sm font-medium ${page === pages ? 'bg-gray-100 dark:bg-white/5 text-text-muted dark:text-[#92adc9]/50 cursor-not-allowed' : 'bg-card-light dark:bg-card-dark text-text-main dark:text-white hover:bg-slate-100 dark:hover:bg-white/10 border border-border-light dark:border-[#233648]'} transition-colors"
${page === pages ? 'disabled' : ''}>
<span class="material-symbols-outlined text-[18px]">chevron_right</span>
</button>
`;
paginationDiv.innerHTML = buttons;
}
// Change page
function changePage(page) {
currentPage = page;
loadDocuments();
}
// View document (preview in modal)
function viewDocument(id, fileType, filename) {
const modalHtml = `
<div id="document-preview-modal" class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4" onclick="closePreviewModal(event)">
<div class="bg-card-light dark:bg-card-dark rounded-xl max-w-5xl w-full max-h-[90vh] overflow-hidden shadow-2xl" onclick="event.stopPropagation()">
<div class="flex items-center justify-between p-4 border-b border-border-light dark:border-[#233648]">
<h3 class="text-lg font-semibold text-text-main dark:text-white truncate">${escapeHtml(filename)}</h3>
<button onclick="closePreviewModal()" class="p-2 text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white rounded-lg transition-colors">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div class="p-4 overflow-auto max-h-[calc(90vh-80px)]">
${fileType.toUpperCase() === 'PDF'
? `<iframe src="/api/documents/${id}/view" class="w-full h-[70vh] border-0 rounded-lg"></iframe>`
: `<img src="/api/documents/${id}/view" alt="${escapeHtml(filename)}" class="max-w-full h-auto mx-auto rounded-lg">`
}
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
}
// Close preview modal
function closePreviewModal(event) {
if (!event || event.target.id === 'document-preview-modal' || !event.target.closest) {
const modal = document.getElementById('document-preview-modal');
if (modal) {
modal.remove();
}
}
}
// Download document
async function downloadDocument(id) {
try {
const response = await fetch(`/api/documents/${id}/download`);
if (!response.ok) {
throw new Error('Download failed');
}
const blob = await response.blob();
const contentDisposition = response.headers.get('Content-Disposition');
const filename = contentDisposition
? contentDisposition.split('filename=')[1].replace(/"/g, '')
: `document_${id}`;
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showNotification('success', 'Document downloaded successfully!');
} catch (error) {
console.error('Download error:', error);
showNotification('error', 'Failed to download document');
}
}
// Delete document
async function deleteDocument(id) {
const confirmMsg = getCurrentLanguage() === 'ro'
? 'Ești sigur că vrei să ștergi acest document? Această acțiune nu poate fi anulată.'
: 'Are you sure you want to delete this document? This action cannot be undone.';
if (!confirm(confirmMsg)) {
return;
}
try {
const response = await fetch(`/api/documents/${id}`, {
method: 'DELETE'
});
const result = await response.json();
if (response.ok) {
showNotification('success', 'Document deleted successfully!');
loadDocuments();
} else {
showNotification('error', result.error || 'Failed to delete document');
}
} catch (error) {
console.error('Delete error:', error);
showNotification('error', 'An error occurred while deleting');
}
}
// Format file size
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
// Format date
function formatDate(dateString) {
const date = new Date(dateString);
const now = new Date();
const diff = now - date;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
const hours = Math.floor(diff / (1000 * 60 * 60));
if (hours === 0) {
const minutes = Math.floor(diff / (1000 * 60));
return minutes <= 1 ? 'Just now' : `${minutes}m ago`;
}
return `${hours}h ago`;
} else if (days === 1) {
return 'Yesterday';
} else if (days < 7) {
return `${days}d ago`;
} else {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Show notification
function showNotification(type, message) {
// Create notification element
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 animate-slideIn ${
type === 'success'
? 'bg-green-500 text-white'
: 'bg-red-500 text-white'
}`;
notification.innerHTML = `
<span class="material-symbols-outlined text-[20px]">
${type === 'success' ? 'check_circle' : 'error'}
</span>
<span class="text-sm font-medium">${escapeHtml(message)}</span>
`;
document.body.appendChild(notification);
// Remove after 3 seconds
setTimeout(() => {
notification.classList.add('animate-slideOut');
setTimeout(() => {
document.body.removeChild(notification);
}, 300);
}, 3000);
}

View file

@ -0,0 +1,642 @@
// Multi-language support
const translations = {
en: {
// Navigation
'nav.dashboard': 'Dashboard',
'nav.transactions': 'Transactions',
'nav.reports': 'Reports',
'nav.admin': 'Admin',
'nav.settings': 'Settings',
'nav.logout': 'Log out',
// Dashboard
'dashboard.total_spent': 'Total Spent This Month',
'dashboard.active_categories': 'Active Categories',
'dashboard.total_transactions': 'Total Transactions',
'dashboard.vs_last_month': 'vs last month',
'dashboard.categories_in_use': 'categories in use',
'dashboard.this_month': 'current month',
'dashboard.spending_by_category': 'Spending by Category',
'dashboard.monthly_trend': 'Monthly Trend',
'dashboard.recent_transactions': 'Recent Transactions',
'dashboard.view_all': 'View All',
'dashboard.search': 'Search expenses...',
'dashboard.selectCategory': 'Select category...',
'dashboard.noTransactions': 'No transactions yet',
'dashboard.noData': 'No data available',
'dashboard.total': 'Total',
'dashboard.totalThisYear': 'Total This Year',
'dashboard.spending': 'Spending',
'dashboard.categoryBreakdownDesc': 'Breakdown by category',
'dashboard.lightMode': 'Light Mode',
'dashboard.darkMode': 'Dark Mode',
'dashboard.expenseAdded': 'Expense added successfully!',
// Login
'login.title': 'Welcome Back',
'login.tagline': 'Track your expenses, manage your finances',
'login.remember_me': 'Remember me',
'login.sign_in': 'Sign In',
'login.no_account': "Don't have an account?",
'login.register': 'Register',
// Register
'register.title': 'Create Account',
'register.tagline': 'Start managing your finances today',
'register.create_account': 'Create Account',
'register.have_account': 'Already have an account?',
'register.login': 'Login',
// Forms
'form.email': 'Email',
'form.password': 'Password',
'form.username': 'Username',
'form.language': 'Language',
'form.currency': 'Currency',
'form.monthlyBudget': 'Monthly Budget',
'form.amount': 'Amount',
'form.description': 'Description',
'form.category': 'Category',
'form.date': 'Date',
'form.tags': 'Tags (comma separated)',
'form.receipt': 'Receipt (optional)',
'form.2fa_code': '2FA Code',
'form.chooseFile': 'Choose File',
'form.noFileChosen': 'No file chosen',
// Transactions
'transactions.title': 'Transactions',
'transactions.export': 'Export CSV',
'transactions.import': 'Import CSV',
'transactions.addExpense': 'Add Expense',
'transactions.search': 'Search transactions...',
'transactions.date': 'Date',
'transactions.filters': 'Filters',
'transactions.category': 'Category',
'transactions.allCategories': 'Category',
'transactions.startDate': 'Start Date',
'transactions.endDate': 'End Date',
'transactions.tableTransaction': 'Transaction',
'transactions.tableCategory': 'Category',
'transactions.tableDate': 'Date',
'transactions.tablePayment': 'Payment',
'transactions.tableAmount': 'Amount',
'transactions.tableStatus': 'Status',
'transactions.tableActions': 'Actions',
'transactions.showing': 'Showing',
'transactions.to': 'to',
'transactions.of': 'of',
'transactions.results': 'results',
'transactions.previous': 'Previous',
'transactions.next': 'Next',
'transactions.noTransactions': 'No transactions found',
'transactions.expense': 'Expense',
'transactions.completed': 'Completed',
'transactions.pending': 'Pending',
'transactions.edit': 'Edit',
'transactions.delete': 'Delete',
'transactions.updated': 'Transaction updated successfully!',
'transactions.notFound': 'Transaction not found',
'modal.edit_expense': 'Edit Expense',
'actions.update': 'Update Expense',
'form.currentReceipt': 'Current receipt attached',
'form.receiptHelp': 'Upload a new file to replace existing receipt',
'transactions.viewReceipt': 'View Receipt',
'transactions.downloadReceipt': 'Download Receipt',
'transactions.transaction': 'transaction',
'transactions.transactions': 'transactions',
'transactions.deleteConfirm': 'Are you sure you want to delete this transaction?',
'transactions.deleted': 'Transaction deleted',
'transactions.imported': 'Imported',
'transactions.importSuccess': 'transactions',
// Actions
'actions.add_expense': 'Add Expense',
'actions.save': 'Save Expense',
// Modal
'modal.add_expense': 'Add Expense',
// Reports
'reports.title': 'Financial Reports',
'reports.export': 'Export CSV',
'reports.analysisPeriod': 'Analysis Period:',
'reports.last30Days': 'Last 30 Days',
'reports.quarter': 'Quarter',
'reports.ytd': 'YTD',
'reports.allCategories': 'All Categories',
'reports.generate': 'Generate Report',
'reports.totalSpent': 'Total Spent',
'reports.topCategory': 'Top Category',
'reports.avgDaily': 'Avg. Daily',
'reports.savingsRate': 'Savings Rate',
'reports.vsLastMonth': 'vs last period',
'reports.spentThisPeriod': 'spent this period',
'reports.placeholder': 'Placeholder',
'reports.spendingTrend': 'Spending Trend',
'reports.categoryBreakdown': 'Category Breakdown',
'reports.monthlySpending': 'Monthly Spending',
'reports.smartRecommendations': 'Smart Recommendations',
'reports.noRecommendations': 'No recommendations at this time',
// User
'user.admin': 'Admin',
'user.user': 'User',
// Documents
'nav.documents': 'Documents',
'documents.title': 'Documents',
'documents.uploadTitle': 'Upload Documents',
'documents.dragDrop': 'Drag & drop files here or click to browse',
'documents.uploadDesc': 'Upload bank statements, invoices, or receipts.',
'documents.supportedFormats': 'Supported formats: CSV, PDF, XLS, XLSX, PNG, JPG (Max 10MB)',
'documents.yourFiles': 'Your Files',
'documents.searchPlaceholder': 'Search by name...',
'documents.tableDocName': 'Document Name',
'documents.tableUploadDate': 'Upload Date',
'documents.tableType': 'Type',
'documents.tableStatus': 'Status',
'documents.tableActions': 'Actions',
'documents.statusUploaded': 'Uploaded',
'documents.statusProcessing': 'Processing',
'documents.statusAnalyzed': 'Analyzed',
'documents.statusError': 'Error',
'documents.showing': 'Showing',
'documents.of': 'of',
'documents.documents': 'documents',
'documents.noDocuments': 'No documents found. Upload your first document!',
'documents.errorLoading': 'Failed to load documents. Please try again.',
// Settings
'settings.title': 'Settings',
'settings.avatar': 'Profile Avatar',
'settings.uploadAvatar': 'Upload Custom',
'settings.avatarDesc': 'PNG, JPG, GIF, WEBP. Max 20MB',
'settings.defaultAvatars': 'Or choose a default avatar:',
'settings.profile': 'Profile Information',
'settings.saveProfile': 'Save Profile',
'settings.changePassword': 'Change Password',
'settings.currentPassword': 'Current Password',
'settings.newPassword': 'New Password',
'settings.confirmPassword': 'Confirm New Password',
'settings.updatePassword': 'Update Password',
'settings.twoFactor': 'Two-Factor Authentication',
'settings.twoFactorEnabled': '2FA is currently enabled for your account',
'settings.twoFactorDisabled': 'Add an extra layer of security to your account',
'settings.enabled': 'Enabled',
'settings.disabled': 'Disabled',
'settings.regenerateCodes': 'Regenerate Backup Codes',
'settings.enable2FA': 'Enable 2FA',
'settings.disable2FA': 'Disable 2FA',
// Two-Factor Authentication
'twofa.setupTitle': 'Setup Two-Factor Authentication',
'twofa.setupDesc': 'Scan the QR code with your authenticator app',
'twofa.step1': 'Step 1: Scan QR Code',
'twofa.step1Desc': 'Open your authenticator app (Google Authenticator, Authy, etc.) and scan this QR code:',
'twofa.manualEntry': "Can't scan? Enter code manually",
'twofa.enterManually': 'Enter this code in your authenticator app:',
'twofa.step2': 'Step 2: Verify Code',
'twofa.step2Desc': 'Enter the 6-digit code from your authenticator app:',
'twofa.enable': 'Enable 2FA',
'twofa.infoText': "After enabling 2FA, you'll receive backup codes that you can use if you lose access to your authenticator app. Keep them in a safe place!",
'twofa.setupSuccess': 'Two-Factor Authentication Enabled!',
'twofa.backupCodesDesc': 'Save these backup codes in a secure location',
'twofa.important': 'Important!',
'twofa.backupCodesWarning': "Each backup code can only be used once. Store them securely - you'll need them if you lose access to your authenticator app.",
'twofa.yourBackupCodes': 'Your Backup Codes',
'twofa.downloadPDF': 'Download as PDF',
'twofa.print': 'Print Codes',
'twofa.continueToSettings': 'Continue to Settings',
'twofa.howToUse': 'How to use backup codes:',
'twofa.useWhen': "Use a backup code when you can't access your authenticator app",
'twofa.enterCode': 'Enter the code in the 2FA field when logging in',
'twofa.oneTimeUse': 'Each code works only once - it will be deleted after use',
'twofa.regenerate': 'You can regenerate codes anytime from Settings',
// Admin
'admin.title': 'Admin Panel',
'admin.subtitle': 'Manage users and system settings',
'admin.totalUsers': 'Total Users',
'admin.adminUsers': 'Admin Users',
'admin.twoFAEnabled': '2FA Enabled',
'admin.users': 'Users',
'admin.createUser': 'Create User',
'admin.username': 'Username',
'admin.email': 'Email',
'admin.role': 'Role',
'admin.twoFA': '2FA',
'admin.language': 'Language',
'admin.currency': 'Currency',
'admin.joined': 'Joined',
'admin.actions': 'Actions',
'admin.admin': 'Admin',
'admin.user': 'User',
'admin.createNewUser': 'Create New User',
'admin.makeAdmin': 'Make admin',
'admin.create': 'Create',
'admin.noUsers': 'No users found',
'admin.errorLoading': 'Error loading users',
'admin.userCreated': 'User created successfully',
'admin.errorCreating': 'Error creating user',
'admin.confirmDelete': 'Are you sure you want to delete user',
'admin.userDeleted': 'User deleted successfully',
'admin.errorDeleting': 'Error deleting user',
'admin.editNotImplemented': 'Edit functionality coming soon',
// Categories
'categories.foodDining': 'Food & Dining',
'categories.transportation': 'Transportation',
'categories.shopping': 'Shopping',
'categories.entertainment': 'Entertainment',
'categories.billsUtilities': 'Bills & Utilities',
'categories.healthcare': 'Healthcare',
'categories.education': 'Education',
'categories.other': 'Other',
'categories.manageTitle': 'Manage Categories',
'categories.addNew': 'Add New Category',
'categories.add': 'Add',
'categories.yourCategories': 'Your Categories',
'categories.dragToReorder': 'Drag to reorder',
'categories.created': 'Category created successfully',
'categories.updated': 'Category updated successfully',
'categories.deleted': 'Category deleted successfully',
'categories.hasExpenses': 'Cannot delete category with expenses',
'categories.reordered': 'Categories reordered successfully',
// Dashboard
'dashboard.expenseCategories': 'Expense Categories',
'dashboard.manageCategories': 'Manage',
// Date formatting
'date.today': 'Today',
'date.yesterday': 'Yesterday',
'date.daysAgo': 'days ago',
// Form
'form.name': 'Name',
'form.color': 'Color',
'form.icon': 'Icon',
// Common
'common.cancel': 'Cancel',
'common.edit': 'Edit',
'common.delete': 'Delete',
'common.error': 'An error occurred. Please try again.',
'common.success': 'Operation completed successfully!',
'common.missingFields': 'Missing required fields',
'common.invalidCategory': 'Invalid category',
// Actions
'actions.cancel': 'Cancel'
},
ro: {
// Navigation
'nav.dashboard': 'Tablou de bord',
'nav.transactions': 'Tranzacții',
'nav.reports': 'Rapoarte',
'nav.admin': 'Admin',
'nav.settings': 'Setări',
'nav.logout': 'Deconectare',
// Dashboard
'dashboard.total_spent': 'Total Cheltuit Luna Aceasta',
'dashboard.active_categories': 'Categorii Active',
'dashboard.total_transactions': 'Total Tranzacții',
'dashboard.vs_last_month': 'față de luna trecută',
'dashboard.categories_in_use': 'categorii în uz',
'dashboard.this_month': 'luna curentă',
'dashboard.spending_by_category': 'Cheltuieli pe Categorii',
'dashboard.monthly_trend': 'Tendință Lunară',
'dashboard.recent_transactions': 'Tranzacții Recente',
'dashboard.view_all': 'Vezi Toate',
'dashboard.search': 'Caută cheltuieli...',
'dashboard.selectCategory': 'Selectează categoria...',
'dashboard.noTransactions': 'Nicio tranzacție încă',
'dashboard.noData': 'Nu există date disponibile',
'dashboard.total': 'Total',
'dashboard.totalThisYear': 'Total Anul Acesta',
'dashboard.spending': 'Cheltuieli',
'dashboard.categoryBreakdownDesc': 'Defalcare pe categorii',
'dashboard.lightMode': 'Mod Luminos',
'dashboard.darkMode': 'Mod Întunecat',
'dashboard.expenseAdded': 'Cheltuială adăugată cu succes!',
// Login
'login.title': 'Bine ai revenit',
'login.tagline': 'Urmărește-ți cheltuielile, gestionează-ți finanțele',
'login.remember_me': 'Ține-mă minte',
'login.sign_in': 'Conectare',
'login.no_account': 'Nu ai un cont?',
'login.register': 'Înregistrare',
// Register
'register.title': 'Creare Cont',
'register.tagline': 'Începe să îți gestionezi finanțele astăzi',
'register.create_account': 'Creează Cont',
'register.have_account': 'Ai deja un cont?',
'register.login': 'Conectare',
// Forms
'form.email': 'Email',
'form.password': 'Parolă',
'form.username': 'Nume utilizator',
'form.language': 'Limbă',
'form.currency': 'Monedă',
'form.monthlyBudget': 'Buget Lunar',
'form.amount': 'Sumă',
'form.description': 'Descriere',
'form.category': 'Categorie',
'form.date': 'Dată',
'form.tags': 'Etichete (separate prin virgulă)',
'form.receipt': 'Chitanță (opțional)',
'form.2fa_code': 'Cod 2FA',
'form.chooseFile': 'Alege Fișier',
'form.noFileChosen': 'Niciun fișier ales',
// Transactions
'transactions.title': 'Tranzacții',
'transactions.export': 'Exportă CSV',
'transactions.import': 'Importă CSV',
'transactions.addExpense': 'Adaugă Cheltuială',
'transactions.search': 'Caută tranzacții...',
'transactions.date': 'Dată',
'transactions.filters': 'Filtre',
'transactions.category': 'Categorie',
'transactions.allCategories': 'Categorie',
'transactions.startDate': 'Data Început',
'transactions.endDate': 'Data Sfârșit',
'transactions.tableTransaction': 'Tranzacție',
'transactions.tableCategory': 'Categorie',
'transactions.tableDate': 'Dată',
'transactions.tablePayment': 'Plată',
'transactions.tableAmount': 'Sumă',
'transactions.tableStatus': 'Stare',
'transactions.tableActions': 'Acțiuni',
'transactions.showing': 'Afișare',
'transactions.to': 'până la',
'transactions.of': 'din',
'transactions.results': 'rezultate',
'transactions.previous': 'Anterior',
'transactions.next': 'Următorul',
'transactions.noTransactions': 'Nu s-au găsit tranzacții',
'transactions.expense': 'Cheltuială',
'transactions.completed': 'Finalizat',
'transactions.pending': 'În așteptare',
'transactions.edit': 'Editează',
'transactions.delete': 'Șterge',
'transactions.updated': 'Tranzacție actualizată cu succes!',
'transactions.notFound': 'Tranzacție negăsită',
'modal.edit_expense': 'Editează Cheltuială',
'actions.update': 'Actualizează Cheltuială',
'form.currentReceipt': 'Chitanță curentă atașată',
'form.receiptHelp': 'Încarcă un fișier nou pentru a înlocui chitanța existentă',
'transactions.viewReceipt': 'Vezi Chitanța',
'transactions.downloadReceipt': 'Descarcă Chitanța',
'transactions.transaction': 'tranzacție',
'transactions.transactions': 'tranzacții',
'transactions.deleteConfirm': 'Ești sigur că vrei să ștergi această tranzacție?',
'transactions.deleted': 'Tranzacție ștearsă',
'transactions.imported': 'Importate',
'transactions.importSuccess': 'tranzacții',
// Actions
'actions.add_expense': 'Adaugă Cheltuială',
'actions.save': 'Salvează Cheltuiala',
// Modal
'modal.add_expense': 'Adaugă Cheltuială',
// Reports
'reports.title': 'Rapoarte Financiare',
'reports.export': 'Exportă CSV',
'reports.analysisPeriod': 'Perioadă de Analiză:',
'reports.last30Days': 'Ultimele 30 Zile',
'reports.quarter': 'Trimestru',
'reports.ytd': 'An Curent',
'reports.allCategories': 'Toate Categoriile',
'reports.generate': 'Generează Raport',
'reports.totalSpent': 'Total Cheltuit',
'reports.topCategory': 'Categorie Principală',
'reports.avgDaily': 'Medie Zilnică',
'reports.savingsRate': 'Rată Economii',
'reports.vsLastMonth': 'față de perioada anterioară',
'reports.spentThisPeriod': 'cheltuit în această perioadă',
'reports.placeholder': 'Substituent',
'reports.spendingTrend': 'Tendință Cheltuieli',
'reports.categoryBreakdown': 'Defalcare pe Categorii',
'reports.monthlySpending': 'Cheltuieli Lunare',
'reports.smartRecommendations': 'Recomandări Inteligente',
'reports.noRecommendations': 'Nicio recomandare momentan',
// User
'user.admin': 'Administrator',
'user.user': 'Utilizator',
// Documents
'nav.documents': 'Documente',
'documents.title': 'Documente',
'documents.uploadTitle': 'Încarcă Documente',
'documents.dragDrop': 'Trage și plasează fișiere aici sau click pentru a căuta',
'documents.uploadDesc': 'Încarcă extrase de cont, facturi sau chitanțe.',
'documents.supportedFormats': 'Formate suportate: CSV, PDF, XLS, XLSX, PNG, JPG (Max 10MB)',
'documents.yourFiles': 'Fișierele Tale',
'documents.searchPlaceholder': 'Caută după nume...',
'documents.tableDocName': 'Nume Document',
'documents.tableUploadDate': 'Data Încărcării',
'documents.tableType': 'Tip',
'documents.tableStatus': 'Stare',
'documents.tableActions': 'Acțiuni',
'documents.statusUploaded': 'Încărcat',
'documents.statusProcessing': 'În procesare',
'documents.statusAnalyzed': 'Analizat',
'documents.statusError': 'Eroare',
'documents.showing': 'Afișare',
'documents.of': 'din',
'documents.documents': 'documente',
'documents.noDocuments': 'Nu s-au găsit documente. Încarcă primul tău document!',
'documents.errorLoading': 'Eroare la încărcarea documentelor. Te rugăm încearcă din nou.',
// Settings
'settings.title': 'Setări',
'settings.avatar': 'Avatar Profil',
'settings.uploadAvatar': 'Încarcă Personalizat',
'settings.avatarDesc': 'PNG, JPG, GIF, WEBP. Max 20MB',
'settings.defaultAvatars': 'Sau alege un avatar prestabilit:',
'settings.profile': 'Informații Profil',
'settings.saveProfile': 'Salvează Profil',
'settings.changePassword': 'Schimbă Parola',
'settings.currentPassword': 'Parola Curentă',
'settings.newPassword': 'Parolă Nouă',
'settings.confirmPassword': 'Confirmă Parola Nouă',
'settings.updatePassword': 'Actualizează Parola',
'settings.twoFactor': 'Autentificare Doi Factori',
'settings.twoFactorEnabled': '2FA este activată pentru contul tău',
'settings.twoFactorDisabled': 'Adaugă un nivel suplimentar de securitate contului tău',
'settings.enabled': 'Activat',
'settings.disabled': 'Dezactivat',
'settings.regenerateCodes': 'Regenerează Coduri Backup',
'settings.enable2FA': 'Activează 2FA',
'settings.disable2FA': 'Dezactivează 2FA',
// Two-Factor Authentication
'twofa.setupTitle': 'Configurare Autentificare Doi Factori',
'twofa.setupDesc': 'Scanează codul QR cu aplicația ta de autentificare',
'twofa.step1': 'Pasul 1: Scanează Codul QR',
'twofa.step1Desc': 'Deschide aplicația ta de autentificare (Google Authenticator, Authy, etc.) și scanează acest cod QR:',
'twofa.manualEntry': 'Nu poți scana? Introdu codul manual',
'twofa.enterManually': 'Introdu acest cod în aplicația ta de autentificare:',
'twofa.step2': 'Pasul 2: Verifică Codul',
'twofa.step2Desc': 'Introdu codul de 6 cifre din aplicația ta de autentificare:',
'twofa.enable': 'Activează 2FA',
'twofa.infoText': 'După activarea 2FA, vei primi coduri de backup pe care le poți folosi dacă pierzi accesul la aplicația ta de autentificare. Păstrează-le într-un loc sigur!',
'twofa.setupSuccess': 'Autentificare Doi Factori Activată!',
'twofa.backupCodesDesc': 'Salvează aceste coduri de backup într-o locație sigură',
'twofa.important': 'Important!',
'twofa.backupCodesWarning': 'Fiecare cod de backup poate fi folosit o singură dată. Păstrează-le în siguranță - vei avea nevoie de ele dacă pierzi accesul la aplicația ta de autentificare.',
'twofa.yourBackupCodes': 'Codurile Tale de Backup',
'twofa.downloadPDF': 'Descarcă ca PDF',
'twofa.print': 'Tipărește Coduri',
'twofa.continueToSettings': 'Continuă la Setări',
'twofa.howToUse': 'Cum să folosești codurile de backup:',
'twofa.useWhen': 'Folosește un cod de backup când nu poți accesa aplicația ta de autentificare',
'twofa.enterCode': 'Introdu codul în câmpul 2FA când te autentifici',
'twofa.oneTimeUse': 'Fiecare cod funcționează o singură dată - va fi șters după folosire',
'twofa.regenerate': 'Poți regenera coduri oricând din Setări',
// Admin
'admin.title': 'Panou Administrare',
'admin.subtitle': 'Gestionează utilizatori și setări sistem',
'admin.totalUsers': 'Total Utilizatori',
'admin.adminUsers': 'Administratori',
'admin.twoFAEnabled': '2FA Activat',
'admin.users': 'Utilizatori',
'admin.createUser': 'Creează Utilizator',
'admin.username': 'Nume Utilizator',
'admin.email': 'Email',
'admin.role': 'Rol',
'admin.twoFA': '2FA',
'admin.language': 'Limbă',
'admin.currency': 'Monedă',
'admin.joined': 'Înregistrat',
'admin.actions': 'Acțiuni',
'admin.admin': 'Admin',
'admin.user': 'Utilizator',
'admin.createNewUser': 'Creează Utilizator Nou',
'admin.makeAdmin': 'Fă administrator',
'admin.create': 'Creează',
'admin.noUsers': 'Niciun utilizator găsit',
'admin.errorLoading': 'Eroare la încărcarea utilizatorilor',
'admin.userCreated': 'Utilizator creat cu succes',
'admin.errorCreating': 'Eroare la crearea utilizatorului',
'admin.confirmDelete': 'Sigur vrei să ștergi utilizatorul',
'admin.userDeleted': 'Utilizator șters cu succes',
'admin.errorDeleting': 'Eroare la ștergerea utilizatorului',
'admin.editNotImplemented': 'Funcționalitatea de editare va fi disponibilă în curând',
// Common
'common.cancel': 'Anulează',
'common.edit': 'Editează',
'common.delete': 'Șterge',
// Categorii
'categories.foodDining': 'Mâncare & Restaurant',
'categories.transportation': 'Transport',
'categories.shopping': 'Cumpărături',
'categories.entertainment': 'Divertisment',
'categories.billsUtilities': 'Facturi & Utilități',
'categories.healthcare': 'Sănătate',
'categories.education': 'Educație',
'categories.other': 'Altele',
'categories.manageTitle': 'Gestionează Categorii',
'categories.addNew': 'Adaugă Categorie Nouă',
'categories.add': 'Adaugă',
'categories.yourCategories': 'Categoriile Tale',
'categories.dragToReorder': 'Trage pentru a reordona',
'categories.created': 'Categorie creată cu succes',
'categories.updated': 'Categorie actualizată cu succes',
'categories.deleted': 'Categorie ștearsă cu succes',
'categories.hasExpenses': 'Nu se poate șterge categoria cu cheltuieli',
'categories.reordered': 'Categorii reordonate cu succes',
// Tablou de bord
'dashboard.expenseCategories': 'Categorii de Cheltuieli',
'dashboard.manageCategories': 'Gestionează',
// Formatare dată
'date.today': 'Astăzi',
'date.yesterday': 'Ieri',
'date.daysAgo': 'zile în urmă',
// Formular
'form.name': 'Nume',
'form.color': 'Culoare',
'form.icon': 'Iconă',
// Comune
'common.cancel': 'Anulează',
'common.edit': 'Editează',
'common.delete': 'Șterge',
'common.error': 'A apărut o eroare. Te rugăm încearcă din nou.',
'common.success': 'Operațiune finalizată cu succes!',
'common.missingFields': 'Câmpuri obligatorii lipsă',
'common.invalidCategory': 'Categorie invalidă',
// Actions
'actions.cancel': 'Anulează'
}
};
// Get current language from localStorage or default to 'en'
function getCurrentLanguage() {
return localStorage.getItem('language') || 'en';
}
// Set language
function setLanguage(lang) {
if (translations[lang]) {
localStorage.setItem('language', lang);
translatePage(lang);
}
}
// Translate all elements on page
function translatePage(lang) {
const elements = document.querySelectorAll('[data-translate]');
elements.forEach(element => {
const key = element.getAttribute('data-translate');
const translation = translations[lang][key];
if (translation) {
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
element.placeholder = translation;
} else {
element.textContent = translation;
}
}
});
}
// Initialize translations on page load
document.addEventListener('DOMContentLoaded', () => {
const currentLang = getCurrentLanguage();
translatePage(currentLang);
});
// Helper function to get translated text
function getTranslation(key, fallback = '') {
const lang = getCurrentLanguage();
return translations[lang]?.[key] || fallback || key;
}
// Make functions and translations globally accessible for other scripts
window.getCurrentLanguage = getCurrentLanguage;
window.setLanguage = setLanguage;
window.translatePage = translatePage;
window.translations = translations;
window.getTranslation = getTranslation;
// Export functions for module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = { getCurrentLanguage, setLanguage, translatePage, translations };
}

Some files were not shown because too many files have changed in this diff Show more