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

97
app/__init__.py Normal file
View file

@ -0,0 +1,97 @@
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, recurring, search, budget, csv_import, income, tags
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)
app.register_blueprint(recurring.bp)
app.register_blueprint(search.bp)
app.register_blueprint(budget.bp)
app.register_blueprint(csv_import.bp)
app.register_blueprint(income.bp)
app.register_blueprint(tags.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()
# Initialize scheduler for recurring expenses
from app.scheduler import init_scheduler
init_scheduler(app)
return app
from app.models import User
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))

221
app/auto_tagger.py Normal file
View file

@ -0,0 +1,221 @@
"""
Smart Auto-Tagging Utility
Automatically generates tags for expenses based on OCR text and description
"""
import re
from typing import List, Dict
# Tag patterns for auto-detection
TAG_PATTERNS = {
# Food & Dining
'dining': {
'keywords': ['restaurant', 'cafe', 'bistro', 'diner', 'eatery', 'pizz', 'burger', 'sushi',
'food court', 'takeout', 'delivery', 'uber eats', 'doordash', 'grubhub', 'postmates',
'restaurante', 'pizzeria', 'fast food', 'kfc', 'mcdonald', 'subway', 'starbucks'],
'color': '#10b981',
'icon': 'restaurant'
},
'groceries': {
'keywords': ['supermarket', 'grocery', 'market', 'walmart', 'kroger', 'whole foods', 'trader joe',
'safeway', 'costco', 'aldi', 'lidl', 'carrefour', 'tesco', 'fresh', 'produce',
'kaufland', 'mega image', 'penny'],
'color': '#22c55e',
'icon': 'shopping_cart'
},
'coffee': {
'keywords': ['coffee', 'starbucks', 'cafe', 'caffè', 'espresso', 'latte', 'cappuccino'],
'color': '#92400e',
'icon': 'coffee'
},
# Transportation
'gas': {
'keywords': ['gas station', 'fuel', 'petrol', 'shell', 'bp', 'exxon', 'chevron', 'mobil',
'texaco', 'station', 'benzinarie', 'combustibil', 'petrom', 'omv', 'lukoil'],
'color': '#ef4444',
'icon': 'local_gas_station'
},
'parking': {
'keywords': ['parking', 'garage', 'parcare', 'parcomat'],
'color': '#f97316',
'icon': 'local_parking'
},
'transport': {
'keywords': ['uber', 'lyft', 'taxi', 'cab', 'bus', 'metro', 'train', 'subway', 'transit',
'bolt', 'autobuz', 'metrou', 'tren', 'ratb'],
'color': '#3b82f6',
'icon': 'directions_car'
},
# Shopping
'online-shopping': {
'keywords': ['amazon', 'ebay', 'aliexpress', 'online', 'emag', 'altex', 'flanco'],
'color': '#a855f7',
'icon': 'shopping_bag'
},
'clothing': {
'keywords': ['clothing', 'fashion', 'apparel', 'zara', 'h&m', 'nike', 'adidas',
'levi', 'gap', 'imbracaminte', 'haine'],
'color': '#ec4899',
'icon': 'checkroom'
},
'electronics': {
'keywords': ['electronics', 'apple', 'samsung', 'sony', 'best buy', 'media markt'],
'color': '#6366f1',
'icon': 'devices'
},
# Entertainment
'entertainment': {
'keywords': ['movie', 'cinema', 'theater', 'concert', 'show', 'ticket', 'netflix', 'spotify',
'hbo', 'disney', 'entertainment', 'cinema city', 'teatru', 'concert'],
'color': '#8b5cf6',
'icon': 'movie'
},
'gym': {
'keywords': ['gym', 'fitness', 'workout', 'sport', 'sala', 'world class'],
'color': '#14b8a6',
'icon': 'fitness_center'
},
# Bills & Utilities
'electricity': {
'keywords': ['electric', 'power', 'energie', 'enel', 'electrica'],
'color': '#fbbf24',
'icon': 'bolt'
},
'water': {
'keywords': ['water', 'apa', 'aqua'],
'color': '#06b6d4',
'icon': 'water_drop'
},
'internet': {
'keywords': ['internet', 'broadband', 'wifi', 'fiber', 'digi', 'upc', 'telekom', 'orange', 'vodafone'],
'color': '#3b82f6',
'icon': 'wifi'
},
'phone': {
'keywords': ['phone', 'mobile', 'cellular', 'telefon', 'abonament'],
'color': '#8b5cf6',
'icon': 'phone_iphone'
},
'subscription': {
'keywords': ['subscription', 'abonament', 'monthly', 'recurring'],
'color': '#f59e0b',
'icon': 'repeat'
},
# Healthcare
'pharmacy': {
'keywords': ['pharmacy', 'farmacie', 'drug', 'cvs', 'walgreens', 'catena', 'help net', 'sensiblu'],
'color': '#ef4444',
'icon': 'local_pharmacy'
},
'medical': {
'keywords': ['doctor', 'hospital', 'clinic', 'medical', 'health', 'dental', 'spital', 'clinica'],
'color': '#dc2626',
'icon': 'medical_services'
},
# Other
'insurance': {
'keywords': ['insurance', 'asigurare', 'policy'],
'color': '#64748b',
'icon': 'shield'
},
'education': {
'keywords': ['school', 'university', 'course', 'tuition', 'book', 'educatie', 'scoala', 'universitate'],
'color': '#06b6d4',
'icon': 'school'
},
'pet': {
'keywords': ['pet', 'vet', 'veterinar', 'animal'],
'color': '#f97316',
'icon': 'pets'
},
}
def extract_tags_from_text(text: str, max_tags: int = 5) -> List[Dict[str, str]]:
"""
Extract relevant tags from OCR text or description
Args:
text: The text to analyze (OCR text or expense description)
max_tags: Maximum number of tags to return
Returns:
List of tag dictionaries with name, color, and icon
"""
if not text:
return []
# Normalize text: lowercase and remove special characters
normalized_text = text.lower()
normalized_text = re.sub(r'[^\w\s]', ' ', normalized_text)
detected_tags = []
# Check each pattern
for tag_name, pattern_info in TAG_PATTERNS.items():
for keyword in pattern_info['keywords']:
# Use word boundary matching for better accuracy
if re.search(r'\b' + re.escape(keyword.lower()) + r'\b', normalized_text):
detected_tags.append({
'name': tag_name,
'color': pattern_info['color'],
'icon': pattern_info['icon']
})
break # Don't add the same tag multiple times
# Remove duplicates and limit to max_tags
unique_tags = []
seen = set()
for tag in detected_tags:
if tag['name'] not in seen:
seen.add(tag['name'])
unique_tags.append(tag)
if len(unique_tags) >= max_tags:
break
return unique_tags
def suggest_tags_for_expense(description: str, ocr_text: str = None, category_name: str = None) -> List[Dict[str, str]]:
"""
Suggest tags for an expense based on description, OCR text, and category
Args:
description: The expense description
ocr_text: OCR text from receipt (if available)
category_name: The category name (if available)
Returns:
List of suggested tag dictionaries
"""
all_text = description
# Combine all available text
if ocr_text:
all_text += " " + ocr_text
if category_name:
all_text += " " + category_name
return extract_tags_from_text(all_text, max_tags=3)
def get_tag_suggestions() -> Dict[str, List[str]]:
"""
Get all available tag patterns for UI display
Returns:
Dictionary of tag names to their keywords
"""
suggestions = {}
for tag_name, pattern_info in TAG_PATTERNS.items():
suggestions[tag_name] = {
'keywords': pattern_info['keywords'][:5], # Show first 5 keywords
'color': pattern_info['color'],
'icon': pattern_info['icon']
}
return suggestions

405
app/models.py Normal file
View file

@ -0,0 +1,405 @@
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')
income = db.relationship('Income', 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')
recurring_expenses = db.relationship('RecurringExpense', 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)
# Budget tracking fields
monthly_budget = db.Column(db.Float, nullable=True) # Monthly spending limit for this category
budget_alert_threshold = db.Column(db.Float, default=0.9) # Alert at 90% by default (0.0-2.0 range)
expenses = db.relationship('Expense', backref='category', lazy='dynamic')
def get_current_month_spending(self):
"""Calculate total spending for current month in this category"""
from datetime import datetime
now = datetime.utcnow()
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
total = db.session.query(db.func.sum(Expense.amount)).filter(
Expense.category_id == self.id,
Expense.date >= start_of_month
).scalar()
return float(total) if total else 0.0
def get_budget_status(self):
"""Get budget status with spent amount, percentage, and alert status"""
spent = self.get_current_month_spending()
if not self.monthly_budget or self.monthly_budget <= 0:
return {
'spent': spent,
'budget': 0,
'remaining': 0,
'percentage': 0,
'alert_level': 'none' # none, warning, danger, exceeded
}
percentage = (spent / self.monthly_budget) * 100
remaining = self.monthly_budget - spent
# Determine alert level
alert_level = 'none'
if percentage >= 100:
alert_level = 'exceeded'
elif percentage >= (self.budget_alert_threshold * 100):
alert_level = 'danger'
elif percentage >= ((self.budget_alert_threshold - 0.1) * 100):
alert_level = 'warning'
return {
'spent': spent,
'budget': self.monthly_budget,
'remaining': remaining,
'percentage': round(percentage, 1),
'alert_level': alert_level
}
def __repr__(self):
return f'<Category {self.name}>'
def to_dict(self):
budget_status = self.get_budget_status() if hasattr(self, 'get_budget_status') else None
return {
'id': self.id,
'name': self.name,
'color': self.color,
'icon': self.icon,
'display_order': self.display_order,
'created_at': self.created_at.isoformat(),
'monthly_budget': self.monthly_budget,
'budget_alert_threshold': self.budget_alert_threshold,
'budget_status': budget_status
}
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)
receipt_ocr_text = db.Column(db.Text, nullable=True) # Extracted text from receipt OCR for searchability
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):
"""Get tag names from the JSON tags column (legacy support)"""
try:
return json.loads(self.tags)
except:
return []
def set_tags(self, tags_list):
"""Set tags in the JSON column (legacy support)"""
self.tags = json.dumps(tags_list)
def get_tag_objects(self):
"""Get Tag objects associated with this expense"""
return self.tag_objects.all()
def add_tag(self, tag):
"""Add a tag to this expense"""
if tag not in self.tag_objects.all():
self.tag_objects.append(tag)
tag.use_count += 1
def remove_tag(self, tag):
"""Remove a tag from this expense"""
if tag in self.tag_objects.all():
self.tag_objects.remove(tag)
if tag.use_count > 0:
tag.use_count -= 1
def to_dict(self):
# Get tag objects with details
tag_list = [tag.to_dict() for tag in self.get_tag_objects()]
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(), # Legacy JSON tags
'tag_objects': tag_list, # New Tag objects
'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
ocr_text = db.Column(db.Text, nullable=True) # Extracted text from OCR for searchability
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()
}
class RecurringExpense(db.Model):
"""
Model for storing recurring expenses (subscriptions, monthly bills, etc.)
Security: All queries filtered by user_id to ensure users only see their own recurring expenses
"""
__tablename__ = 'recurring_expenses'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False)
amount = db.Column(db.Float, nullable=False)
currency = db.Column(db.String(3), default='USD')
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=False)
frequency = db.Column(db.String(20), nullable=False) # daily, weekly, monthly, yearly
day_of_period = db.Column(db.Integer, nullable=True) # day of month (1-31) or day of week (0-6)
next_due_date = db.Column(db.DateTime, nullable=False)
last_created_date = db.Column(db.DateTime, nullable=True)
auto_create = db.Column(db.Boolean, default=False) # Automatically create expense on due date
is_active = db.Column(db.Boolean, default=True)
notes = db.Column(db.Text, nullable=True)
detected = db.Column(db.Boolean, default=False) # True if auto-detected, False if manually created
confidence_score = db.Column(db.Float, default=0.0) # 0-100, for auto-detected patterns
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)
category = db.relationship('Category', backref='recurring_expenses')
def __repr__(self):
return f'<RecurringExpense {self.name} - {self.amount} {self.currency}>'
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'amount': self.amount,
'currency': self.currency,
'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,
'frequency': self.frequency,
'day_of_period': self.day_of_period,
'next_due_date': self.next_due_date.isoformat(),
'last_created_date': self.last_created_date.isoformat() if self.last_created_date else None,
'auto_create': self.auto_create,
'is_active': self.is_active,
'notes': self.notes,
'detected': self.detected,
'confidence_score': self.confidence_score,
'created_at': self.created_at.isoformat(),
'updated_at': self.updated_at.isoformat()
}
class Income(db.Model):
"""
Model for storing user income (salary, freelance, investments, etc.)
Security: All queries filtered by user_id to ensure users only see their own income
"""
__tablename__ = 'income'
id = db.Column(db.Integer, primary_key=True)
amount = db.Column(db.Float, nullable=False)
currency = db.Column(db.String(3), nullable=False)
description = db.Column(db.String(200), nullable=False)
source = db.Column(db.String(100), nullable=False) # Salary, Freelance, Investment, Rental, Gift, Other
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
tags = db.Column(db.Text, default='[]') # JSON array of tags
frequency = db.Column(db.String(50), default='once') # once, weekly, biweekly, every4weeks, monthly, custom
custom_days = db.Column(db.Integer, nullable=True) # For custom frequency
next_due_date = db.Column(db.DateTime, nullable=True) # Next date when recurring income is due
last_created_date = db.Column(db.DateTime, nullable=True) # Last date when income was auto-created
is_active = db.Column(db.Boolean, default=True) # Whether recurring income is active
auto_create = db.Column(db.Boolean, default=False) # Automatically create income entries
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'<Income {self.description} - {self.amount} {self.currency}>'
def get_frequency_days(self):
"""Calculate days until next occurrence based on frequency"""
if self.frequency == 'custom' and self.custom_days:
return self.custom_days
frequency_map = {
'once': 0, # One-time income
'weekly': 7,
'biweekly': 14,
'every4weeks': 28,
'monthly': 30,
}
return frequency_map.get(self.frequency, 0)
def is_recurring(self):
"""Check if this income is recurring"""
return self.frequency != 'once' and self.is_active
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,
'source': self.source,
'tags': self.get_tags(),
'frequency': self.frequency,
'custom_days': self.custom_days,
'next_due_date': self.next_due_date.isoformat() if self.next_due_date else None,
'last_created_date': self.last_created_date.isoformat() if self.last_created_date else None,
'is_active': self.is_active,
'auto_create': self.auto_create,
'is_recurring': self.is_recurring(),
'frequency_days': self.get_frequency_days(),
'date': self.date.isoformat(),
'created_at': self.created_at.isoformat()
}
class Tag(db.Model):
"""
Model for storing smart tags that can be applied to expenses
Security: All queries filtered by user_id to ensure users only see their own tags
"""
__tablename__ = 'tags'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), nullable=False)
color = db.Column(db.String(7), default='#6366f1')
icon = db.Column(db.String(50), default='label')
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
is_auto = db.Column(db.Boolean, default=False) # True if auto-generated from OCR
use_count = db.Column(db.Integer, default=0) # Track how often used
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationship to expenses through junction table
expenses = db.relationship('Expense', secondary='expense_tags', backref='tag_objects', lazy='dynamic')
__table_args__ = (
db.UniqueConstraint('name', 'user_id', name='unique_tag_per_user'),
)
def __repr__(self):
return f'<Tag {self.name}>'
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'color': self.color,
'icon': self.icon,
'is_auto': self.is_auto,
'use_count': self.use_count,
'created_at': self.created_at.isoformat()
}
class ExpenseTag(db.Model):
"""
Junction table for many-to-many relationship between Expenses and Tags
Security: Access controlled through Expense and Tag models
"""
__tablename__ = 'expense_tags'
id = db.Column(db.Integer, primary_key=True)
expense_id = db.Column(db.Integer, db.ForeignKey('expenses.id', ondelete='CASCADE'), nullable=False)
tag_id = db.Column(db.Integer, db.ForeignKey('tags.id', ondelete='CASCADE'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
__table_args__ = (
db.UniqueConstraint('expense_id', 'tag_id', name='unique_expense_tag'),
)
def __repr__(self):
return f'<ExpenseTag expense_id={self.expense_id} tag_id={self.tag_id}>'

173
app/ocr.py Normal file
View file

@ -0,0 +1,173 @@
"""
OCR Processing Utility
Extracts text from images and PDFs for searchability
Security: All file paths validated before processing
"""
import os
import tempfile
from PIL import Image
import pytesseract
from pdf2image import convert_from_path
import cv2
import numpy as np
def preprocess_image(image):
"""
Preprocess image to improve OCR accuracy
- Convert to grayscale
- Apply adaptive thresholding
- Denoise
"""
try:
# Convert PIL Image to numpy array
img_array = np.array(image)
# Convert to grayscale
if len(img_array.shape) == 3:
gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY)
else:
gray = img_array
# Apply adaptive thresholding
thresh = cv2.adaptiveThreshold(
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 2
)
# Denoise
denoised = cv2.fastNlMeansDenoising(thresh, None, 10, 7, 21)
# Convert back to PIL Image
return Image.fromarray(denoised)
except Exception as e:
print(f"Error preprocessing image: {str(e)}")
# Return original image if preprocessing fails
return image
def extract_text_from_image(image_path):
"""
Extract text from an image file using OCR
Supports: PNG, JPG, JPEG
Security: Validates file exists and is readable
Returns: Extracted text or empty string on failure
"""
try:
# Security: Validate file exists
if not os.path.exists(image_path):
print(f"Image file not found: {image_path}")
return ""
# Open and preprocess image
image = Image.open(image_path)
preprocessed = preprocess_image(image)
# Extract text using Tesseract with English + Romanian
text = pytesseract.image_to_string(
preprocessed,
lang='eng+ron', # Support both English and Romanian
config='--psm 6' # Assume uniform block of text
)
return text.strip()
except Exception as e:
print(f"Error extracting text from image {image_path}: {str(e)}")
return ""
def extract_text_from_pdf(pdf_path):
"""
Extract text from a PDF file using OCR
Converts PDF pages to images, then applies OCR
Security: Validates file exists and is readable
Returns: Extracted text or empty string on failure
"""
try:
# Security: Validate file exists
if not os.path.exists(pdf_path):
print(f"PDF file not found: {pdf_path}")
return ""
# Convert PDF to images (first 10 pages max to avoid memory issues)
pages = convert_from_path(pdf_path, first_page=1, last_page=10, dpi=300)
extracted_text = []
for i, page in enumerate(pages):
# Preprocess page
preprocessed = preprocess_image(page)
# Extract text
text = pytesseract.image_to_string(
preprocessed,
lang='eng+ron',
config='--psm 6'
)
if text.strip():
extracted_text.append(f"--- Page {i+1} ---\n{text.strip()}")
return "\n\n".join(extracted_text)
except Exception as e:
print(f"Error extracting text from PDF {pdf_path}: {str(e)}")
return ""
def extract_text_from_file(file_path, file_type):
"""
Extract text from any supported file type
Security: Validates file path and type before processing
Args:
file_path: Absolute path to the file
file_type: File extension (pdf, png, jpg, jpeg)
Returns:
Extracted text or empty string on failure
"""
try:
# Security: Validate file path
if not os.path.isabs(file_path):
print(f"Invalid file path (not absolute): {file_path}")
return ""
if not os.path.exists(file_path):
print(f"File not found: {file_path}")
return ""
# Normalize file type
file_type = file_type.lower().strip('.')
# Route to appropriate extractor
if file_type == 'pdf':
return extract_text_from_pdf(file_path)
elif file_type in ['png', 'jpg', 'jpeg']:
return extract_text_from_image(file_path)
else:
print(f"Unsupported file type for OCR: {file_type}")
return ""
except Exception as e:
print(f"Error in extract_text_from_file: {str(e)}")
return ""
def process_ocr_async(file_path, file_type):
"""
Wrapper for async OCR processing
Can be used with background jobs if needed
Returns:
Dictionary with success status and extracted text
"""
try:
text = extract_text_from_file(file_path, file_type)
return {
'success': True,
'text': text,
'length': len(text)
}
except Exception as e:
return {
'success': False,
'error': str(e),
'text': ''
}

110
app/routes/admin.py Normal file
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
})

360
app/routes/auth.py Normal file
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'))

198
app/routes/budget.py Normal file
View file

@ -0,0 +1,198 @@
"""
Budget Alerts API
Provides budget status, alerts, and notification management
Security: All queries filtered by user_id
"""
from flask import Blueprint, request, jsonify
from flask_login import login_required, current_user
from app.models import Category, Expense
from app import db
from datetime import datetime, timedelta
from sqlalchemy import func
bp = Blueprint('budget', __name__, url_prefix='/api/budget')
@bp.route('/status', methods=['GET'])
@login_required
def get_budget_status():
"""
Get budget status for all user categories and overall monthly budget
Security: Only returns current user's data
Returns:
- overall: Total spending vs monthly budget
- categories: Per-category budget status
- alerts: Active budget alerts
"""
# Get current month date range
now = datetime.utcnow()
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
# Calculate overall monthly spending - Security: filter by user_id
total_spent = db.session.query(func.sum(Expense.amount)).filter(
Expense.user_id == current_user.id,
Expense.date >= start_of_month
).scalar() or 0.0
overall_status = {
'spent': float(total_spent),
'budget': current_user.monthly_budget or 0,
'remaining': (current_user.monthly_budget or 0) - float(total_spent),
'percentage': 0 if not current_user.monthly_budget else round((float(total_spent) / current_user.monthly_budget) * 100, 1),
'alert_level': 'none'
}
# Determine overall alert level
if current_user.monthly_budget and current_user.monthly_budget > 0:
if overall_status['percentage'] >= 100:
overall_status['alert_level'] = 'exceeded'
elif overall_status['percentage'] >= 90:
overall_status['alert_level'] = 'danger'
elif overall_status['percentage'] >= 80:
overall_status['alert_level'] = 'warning'
# Get category budgets - Security: filter by user_id
categories = Category.query.filter_by(user_id=current_user.id).all()
category_statuses = []
active_alerts = []
for category in categories:
if category.monthly_budget and category.monthly_budget > 0:
status = category.get_budget_status()
category_statuses.append({
'category_id': category.id,
'category_name': category.name,
'category_color': category.color,
'category_icon': category.icon,
**status
})
# Add to alerts if over threshold
if status['alert_level'] in ['warning', 'danger', 'exceeded']:
active_alerts.append({
'category_id': category.id,
'category_name': category.name,
'category_color': category.color,
'alert_level': status['alert_level'],
'percentage': status['percentage'],
'spent': status['spent'],
'budget': status['budget'],
'remaining': status['remaining']
})
# Sort alerts by severity
alert_order = {'exceeded': 0, 'danger': 1, 'warning': 2}
active_alerts.sort(key=lambda x: (alert_order[x['alert_level']], -x['percentage']))
return jsonify({
'success': True,
'overall': overall_status,
'categories': category_statuses,
'alerts': active_alerts,
'alert_count': len(active_alerts)
})
@bp.route('/weekly-summary', methods=['GET'])
@login_required
def get_weekly_summary():
"""
Get weekly spending summary for notification
Security: Only returns current user's data
Returns:
- week_total: Total spent this week
- daily_average: Average per day
- top_category: Highest spending category
- comparison: vs previous week
"""
now = datetime.utcnow()
week_start = now - timedelta(days=now.weekday()) # Monday
week_start = week_start.replace(hour=0, minute=0, second=0, microsecond=0)
prev_week_start = week_start - timedelta(days=7)
# Current week spending - Security: filter by user_id
current_week_expenses = Expense.query.filter(
Expense.user_id == current_user.id,
Expense.date >= week_start
).all()
week_total = sum(e.amount for e in current_week_expenses)
daily_average = week_total / max(1, (now - week_start).days + 1)
# Previous week for comparison
prev_week_expenses = Expense.query.filter(
Expense.user_id == current_user.id,
Expense.date >= prev_week_start,
Expense.date < week_start
).all()
prev_week_total = sum(e.amount for e in prev_week_expenses)
change_percent = 0
if prev_week_total > 0:
change_percent = ((week_total - prev_week_total) / prev_week_total) * 100
# Find top category
category_totals = {}
for expense in current_week_expenses:
if expense.category:
category_totals[expense.category.name] = category_totals.get(expense.category.name, 0) + expense.amount
top_category = max(category_totals.items(), key=lambda x: x[1]) if category_totals else (None, 0)
return jsonify({
'success': True,
'week_total': float(week_total),
'daily_average': float(daily_average),
'previous_week_total': float(prev_week_total),
'change_percent': round(change_percent, 1),
'top_category': top_category[0] if top_category[0] else 'None',
'top_category_amount': float(top_category[1]),
'expense_count': len(current_week_expenses),
'week_start': week_start.isoformat(),
'currency': current_user.currency
})
@bp.route('/category/<int:category_id>/budget', methods=['PUT'])
@login_required
def update_category_budget(category_id):
"""
Update budget settings for a category
Security: Verify category belongs to current user
"""
# Security check: ensure category belongs to current user
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()
try:
if 'monthly_budget' in data:
budget = float(data['monthly_budget']) if data['monthly_budget'] else None
if budget is not None and budget < 0:
return jsonify({'success': False, 'message': 'Budget cannot be negative'}), 400
category.monthly_budget = budget
if 'budget_alert_threshold' in data:
threshold = float(data['budget_alert_threshold'])
if threshold < 0.5 or threshold > 2.0:
return jsonify({'success': False, 'message': 'Threshold must be between 0.5 (50%) and 2.0 (200%)'}), 400
category.budget_alert_threshold = threshold
db.session.commit()
return jsonify({
'success': True,
'message': 'Budget updated successfully',
'category': category.to_dict()
})
except ValueError as e:
return jsonify({'success': False, 'message': f'Invalid data: {str(e)}'}), 400
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'Error updating budget: {str(e)}'}), 500

609
app/routes/csv_import.py Normal file
View file

@ -0,0 +1,609 @@
"""
CSV/Bank Statement Import Routes for FINA
Handles file upload, parsing, duplicate detection, and category mapping
"""
from flask import Blueprint, request, jsonify
from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
from app import db
from app.models import Expense, Category
from datetime import datetime, timedelta
from sqlalchemy import and_, or_
import csv
import io
import re
import json
from decimal import Decimal
bp = Blueprint('csv_import', __name__, url_prefix='/api/import')
class CSVParser:
"""Parse CSV files with auto-detection of format"""
def __init__(self):
self.errors = []
def detect_delimiter(self, sample):
"""Auto-detect CSV delimiter"""
delimiters = [',', ';', '\t', '|']
counts = {d: sample.count(d) for d in delimiters}
return max(counts, key=counts.get)
def detect_encoding(self, file_bytes):
"""Detect file encoding"""
encodings = ['utf-8', 'utf-8-sig', 'latin-1', 'cp1252', 'iso-8859-1']
for encoding in encodings:
try:
file_bytes.decode(encoding)
return encoding
except UnicodeDecodeError:
continue
return 'utf-8'
def detect_columns(self, headers):
"""Auto-detect which columns contain date, description, amount"""
headers_lower = [h.lower().strip() if h else '' for h in headers]
mapping = {
'date': None,
'description': None,
'amount': None,
'debit': None,
'credit': None,
'category': None
}
# Date column keywords
date_keywords = ['date', 'data', 'fecha', 'datum', 'transaction date', 'trans date', 'posting date']
for idx, name in enumerate(headers_lower):
if any(keyword in name for keyword in date_keywords):
mapping['date'] = idx
break
# Description column keywords - prioritize "name" for merchant/payee names
# First try to find "name" column (commonly used for merchant/payee)
for idx, name in enumerate(headers_lower):
if name == 'name' or 'payee' in name or 'merchant name' in name:
mapping['description'] = idx
break
# If no "name" column, look for other description columns
if mapping['description'] is None:
desc_keywords = ['description', 'descriere', 'descripción', 'details', 'detalii', 'merchant',
'comerciant', 'narrative', 'memo', 'particulars', 'transaction details']
for idx, name in enumerate(headers_lower):
if any(keyword in name for keyword in desc_keywords):
mapping['description'] = idx
break
# Category column keywords (optional) - avoid generic "type" column that contains payment types
# Only use "category" explicitly, not "type" which often contains payment methods
for idx, name in enumerate(headers_lower):
if name == 'category' or 'categorie' in name or 'categoría' in name:
mapping['category'] = idx
break
# Amount columns
amount_keywords = ['amount', 'suma', 'monto', 'valoare', 'value']
debit_keywords = ['debit', 'withdrawal', 'retragere', 'spent', 'expense', 'cheltuială', 'out']
credit_keywords = ['credit', 'deposit', 'depunere', 'income', 'venit', 'in']
for idx, name in enumerate(headers_lower):
if any(keyword in name for keyword in debit_keywords):
mapping['debit'] = idx
elif any(keyword in name for keyword in credit_keywords):
mapping['credit'] = idx
elif any(keyword in name for keyword in amount_keywords) and mapping['amount'] is None:
mapping['amount'] = idx
return mapping
def parse_date(self, date_str):
"""Parse date string in various formats"""
if not date_str or not isinstance(date_str, str):
return None
date_str = date_str.strip()
if not date_str:
return None
# Common date formats
formats = [
'%d/%m/%Y', '%d-%m-%Y', '%Y-%m-%d', '%Y/%m/%d',
'%d.%m.%Y', '%m/%d/%Y', '%d %b %Y', '%d %B %Y',
'%Y%m%d', '%d-%b-%Y', '%d-%B-%Y', '%b %d, %Y',
'%B %d, %Y', '%Y-%m-%d %H:%M:%S', '%d/%m/%Y %H:%M:%S'
]
for fmt in formats:
try:
return datetime.strptime(date_str, fmt).date()
except ValueError:
continue
return None
def parse_amount(self, amount_str):
"""Parse amount string to float"""
if not amount_str:
return 0.0
if isinstance(amount_str, (int, float)):
return float(amount_str)
# Remove currency symbols and spaces
amount_str = str(amount_str).strip()
amount_str = re.sub(r'[^\d.,\-+]', '', amount_str)
if not amount_str or amount_str == '-':
return 0.0
try:
# Handle European format (1.234,56)
if ',' in amount_str and '.' in amount_str:
if amount_str.rfind(',') > amount_str.rfind('.'):
# European format: 1.234,56
amount_str = amount_str.replace('.', '').replace(',', '.')
else:
# US format: 1,234.56
amount_str = amount_str.replace(',', '')
elif ',' in amount_str:
# Could be European (1,56) or US thousands (1,234)
parts = amount_str.split(',')
if len(parts[-1]) == 2: # Likely European decimal
amount_str = amount_str.replace(',', '.')
else: # Likely US thousands
amount_str = amount_str.replace(',', '')
return abs(float(amount_str))
except (ValueError, AttributeError):
return 0.0
def parse_csv(self, file_bytes):
"""Parse CSV file and extract transactions"""
try:
# Detect encoding
encoding = self.detect_encoding(file_bytes)
content = file_bytes.decode(encoding)
# Detect delimiter
first_line = content.split('\n')[0]
delimiter = self.detect_delimiter(first_line)
# Parse CSV
stream = io.StringIO(content)
reader = csv.reader(stream, delimiter=delimiter)
# Read headers
headers = next(reader, None)
if not headers:
return {'success': False, 'error': 'CSV file is empty'}
# Detect column mapping
column_map = self.detect_columns(headers)
if column_map['date'] is None:
return {'success': False, 'error': 'Could not detect date column. Please ensure your CSV has a date column.'}
if column_map['description'] is None:
column_map['description'] = 1 if len(headers) > 1 else 0
# Parse transactions
transactions = []
row_num = 0
for row in reader:
row_num += 1
if not row or len(row) == 0:
continue
try:
transaction = self.extract_transaction(row, column_map)
if transaction:
transactions.append(transaction)
except Exception as e:
self.errors.append(f"Row {row_num}: {str(e)}")
return {
'success': True,
'transactions': transactions,
'total_found': len(transactions),
'column_mapping': {k: headers[v] if v is not None else None for k, v in column_map.items()},
'errors': self.errors
}
except Exception as e:
return {'success': False, 'error': f'Failed to parse CSV: {str(e)}'}
def extract_transaction(self, row, column_map):
"""Extract transaction data from CSV row"""
if len(row) <= max(v for v in column_map.values() if v is not None):
return None
# Parse date
date_idx = column_map['date']
trans_date = self.parse_date(row[date_idx])
if not trans_date:
return None
# Parse description
desc_idx = column_map['description']
description = row[desc_idx].strip() if desc_idx is not None and desc_idx < len(row) else 'Transaction'
if not description:
description = 'Transaction'
# Parse amount (handle debit/credit or single amount column)
amount = 0.0
trans_type = 'expense'
if column_map['debit'] is not None and column_map['credit'] is not None:
debit_val = self.parse_amount(row[column_map['debit']] if column_map['debit'] < len(row) else '0')
credit_val = self.parse_amount(row[column_map['credit']] if column_map['credit'] < len(row) else '0')
if debit_val > 0:
amount = debit_val
trans_type = 'expense'
elif credit_val > 0:
amount = credit_val
trans_type = 'income'
elif column_map['amount'] is not None:
amount_val = self.parse_amount(row[column_map['amount']] if column_map['amount'] < len(row) else '0')
amount = abs(amount_val)
# Negative amounts are expenses, positive are income
trans_type = 'expense' if amount_val < 0 or amount_val == 0 else 'income'
if amount == 0:
return None
# Get bank category if available
bank_category = None
if column_map['category'] is not None and column_map['category'] < len(row):
bank_category = row[column_map['category']].strip()
return {
'date': trans_date.isoformat(),
'description': description[:200], # Limit description length
'amount': round(amount, 2),
'type': trans_type,
'bank_category': bank_category
}
@bp.route('/parse-csv', methods=['POST'])
@login_required
def parse_csv():
"""
Parse uploaded CSV file and return transactions for review
Security: User must be authenticated, file size limited
"""
if 'file' not in request.files:
return jsonify({'success': False, 'error': 'No file uploaded'}), 400
file = request.files['file']
if not file or not file.filename:
return jsonify({'success': False, 'error': 'No file selected'}), 400
# Security: Validate filename
filename = secure_filename(file.filename)
if not filename.lower().endswith('.csv'):
return jsonify({'success': False, 'error': 'Only CSV files are supported'}), 400
# Security: Check file size (max 10MB)
file_bytes = file.read()
if len(file_bytes) > 10 * 1024 * 1024:
return jsonify({'success': False, 'error': 'File too large. Maximum size is 10MB'}), 400
# Parse CSV
parser = CSVParser()
result = parser.parse_csv(file_bytes)
if not result['success']:
return jsonify(result), 400
return jsonify(result)
@bp.route('/detect-duplicates', methods=['POST'])
@login_required
def detect_duplicates():
"""
Check for duplicate transactions in the database
Security: Only checks current user's expenses
"""
data = request.get_json()
transactions = data.get('transactions', [])
if not transactions:
return jsonify({'success': False, 'error': 'No transactions provided'}), 400
duplicates = []
for trans in transactions:
try:
trans_date = datetime.fromisoformat(trans['date']).date()
amount = float(trans['amount'])
description = trans['description']
# Look for potential duplicates within ±2 days and exact amount
date_start = trans_date - timedelta(days=2)
date_end = trans_date + timedelta(days=2)
# Security: Filter by current user only
existing = Expense.query.filter(
Expense.user_id == current_user.id,
Expense.date >= date_start,
Expense.date <= date_end,
Expense.amount == amount
).all()
# Check for similar descriptions
for exp in existing:
# Simple similarity: check if descriptions overlap significantly
desc_lower = description.lower()
exp_desc_lower = exp.description.lower()
# Check if at least 50% of words match
desc_words = set(desc_lower.split())
exp_words = set(exp_desc_lower.split())
if len(desc_words) > 0:
overlap = len(desc_words.intersection(exp_words)) / len(desc_words)
if overlap >= 0.5:
duplicates.append({
'transaction': trans,
'existing': {
'id': exp.id,
'date': exp.date.isoformat(),
'description': exp.description,
'amount': float(exp.amount),
'category': exp.category.name if exp.category else None
},
'similarity': round(overlap * 100, 0)
})
break
except Exception as e:
continue
return jsonify({
'success': True,
'duplicates': duplicates,
'duplicate_count': len(duplicates)
})
@bp.route('/import', methods=['POST'])
@login_required
def import_transactions():
"""
Import selected transactions into the database
Security: Only imports to current user's account, validates all data
"""
data = request.get_json()
transactions = data.get('transactions', [])
category_mapping = data.get('category_mapping', {})
skip_duplicates = data.get('skip_duplicates', False)
if not transactions:
return jsonify({'success': False, 'error': 'No transactions to import'}), 400
imported = []
skipped = []
errors = []
# Security: Get user's categories
user_categories = {cat.id: cat for cat in Category.query.filter_by(user_id=current_user.id).all()}
if not user_categories:
return jsonify({'success': False, 'error': 'No categories found. Please create categories first.'}), 400
# Get default category
default_category_id = list(user_categories.keys())[0]
for idx, trans in enumerate(transactions):
try:
# Skip if marked as duplicate
if skip_duplicates and trans.get('is_duplicate'):
skipped.append({'transaction': trans, 'reason': 'Duplicate'})
continue
# Parse and validate data
try:
trans_date = datetime.fromisoformat(trans['date']).date()
except (ValueError, KeyError) as e:
errors.append({'transaction': trans, 'error': f'Invalid date: {trans.get("date", "missing")}'})
continue
try:
amount = float(trans['amount'])
except (ValueError, KeyError, TypeError) as e:
errors.append({'transaction': trans, 'error': f'Invalid amount: {trans.get("amount", "missing")}'})
continue
description = trans.get('description', 'Transaction')
# Validate amount
if amount <= 0:
errors.append({'transaction': trans, 'error': f'Invalid amount: {amount}'})
continue
# Get category ID from mapping or bank category
category_id = None
bank_category = trans.get('bank_category')
# Try to get from explicit mapping
if bank_category and bank_category in category_mapping:
category_id = int(category_mapping[bank_category])
elif str(idx) in category_mapping:
category_id = int(category_mapping[str(idx)])
else:
category_id = default_category_id
# Security: Verify category belongs to user
if category_id not in user_categories:
errors.append({'transaction': trans, 'error': f'Invalid category ID: {category_id}'})
continue
# Prepare tags with bank category if available
tags = []
if bank_category:
tags.append(f'Import: {bank_category}')
# Create expense
expense = Expense(
user_id=current_user.id,
category_id=category_id,
amount=amount,
description=description,
date=trans_date,
currency=current_user.currency,
tags=json.dumps(tags)
)
db.session.add(expense)
imported.append({
'date': trans_date.isoformat(),
'description': description,
'amount': amount,
'category': user_categories[category_id].name
})
except Exception as e:
errors.append({'transaction': trans, 'error': str(e)})
# Commit all imports
try:
db.session.commit()
return jsonify({
'success': True,
'imported_count': len(imported),
'skipped_count': len(skipped),
'error_count': len(errors),
'imported': imported,
'skipped': skipped,
'errors': errors
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': f'Database error: {str(e)}'}), 500
@bp.route('/create-categories', methods=['POST'])
@login_required
def create_categories():
"""
Create missing categories from CSV bank categories
Security: Only creates for current user
"""
data = request.get_json()
bank_categories = data.get('bank_categories', [])
if not bank_categories:
return jsonify({'success': False, 'error': 'No categories provided'}), 400
# Get existing categories for user
existing_cats = {cat.name.lower(): cat for cat in Category.query.filter_by(user_id=current_user.id).all()}
created = []
mapping = {}
for bank_cat in bank_categories:
if not bank_cat or not bank_cat.strip():
continue
bank_cat_clean = bank_cat.strip()
bank_cat_lower = bank_cat_clean.lower()
# Check if category already exists
if bank_cat_lower in existing_cats:
mapping[bank_cat] = existing_cats[bank_cat_lower].id
else:
# Create new category
max_order = db.session.query(db.func.max(Category.display_order)).filter_by(user_id=current_user.id).scalar() or 0
new_cat = Category(
user_id=current_user.id,
name=bank_cat_clean,
icon='category',
color='#' + format(hash(bank_cat_clean) % 0xFFFFFF, '06x'), # Generate color from name
display_order=max_order + 1
)
db.session.add(new_cat)
db.session.flush() # Get ID without committing
created.append({
'name': bank_cat_clean,
'id': new_cat.id
})
mapping[bank_cat] = new_cat.id
existing_cats[bank_cat_lower] = new_cat
try:
db.session.commit()
return jsonify({
'success': True,
'created': created,
'mapping': mapping,
'message': f'Created {len(created)} new categories'
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': f'Failed to create categories: {str(e)}'}), 500
@bp.route('/suggest-category', methods=['POST'])
@login_required
def suggest_category():
"""
Suggest category mapping based on description and existing expenses
Uses simple keyword matching and historical patterns
"""
data = request.get_json()
description = data.get('description', '').lower()
bank_category = data.get('bank_category', '').lower()
if not description:
return jsonify({'success': False, 'error': 'No description provided'}), 400
# Security: Get only user's categories
user_categories = Category.query.filter_by(user_id=current_user.id).all()
# Look for similar expenses in user's history
similar_expenses = Expense.query.filter(
Expense.user_id == current_user.id
).order_by(Expense.date.desc()).limit(100).all()
# Score categories based on keyword matching
category_scores = {cat.id: 0 for cat in user_categories}
for expense in similar_expenses:
exp_desc = expense.description.lower()
# Simple word matching
desc_words = set(description.split())
exp_words = set(exp_desc.split())
overlap = len(desc_words.intersection(exp_words))
if overlap > 0:
category_scores[expense.category_id] += overlap
# Get best match
if max(category_scores.values()) > 0:
best_category_id = max(category_scores, key=category_scores.get)
best_category = next(cat for cat in user_categories if cat.id == best_category_id)
return jsonify({
'success': True,
'suggested_category_id': best_category.id,
'suggested_category_name': best_category.name,
'confidence': min(100, category_scores[best_category_id] * 20)
})
# No match found, return first category
return jsonify({
'success': True,
'suggested_category_id': user_categories[0].id,
'suggested_category_name': user_categories[0].name,
'confidence': 0
})

262
app/routes/documents.py Normal file
View file

@ -0,0 +1,262 @@
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
from app.ocr import extract_text_from_file
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')
# Process OCR for supported file types (PDF, PNG, JPG, JPEG)
ocr_text = ""
if file_ext in ['pdf', 'png', 'jpg', 'jpeg']:
try:
# Get absolute path for OCR processing
abs_file_path = os.path.abspath(file_path)
ocr_text = extract_text_from_file(abs_file_path, file_ext)
print(f"OCR extracted {len(ocr_text)} characters from {original_filename}")
except Exception as e:
print(f"OCR processing failed for {original_filename}: {str(e)}")
# Continue without OCR text - non-critical failure
# 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',
ocr_text=ocr_text,
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()
})

570
app/routes/expenses.py Normal file
View file

@ -0,0 +1,570 @@
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, Tag
from werkzeug.utils import secure_filename
import os
import csv
import io
from datetime import datetime
from app.ocr import extract_text_from_file
from app.auto_tagger import suggest_tags_for_expense
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', '')
tag_ids = request.args.get('tag_ids', '') # Comma-separated tag IDs
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}%'))
# Filter by tags
if tag_ids:
try:
tag_id_list = [int(tid.strip()) for tid in tag_ids.split(',') if tid.strip()]
if tag_id_list:
# Join with expense_tags to filter by tag IDs
# Security: Tags are already filtered by user through Tag.user_id
from app.models import ExpenseTag
query = query.join(ExpenseTag).filter(ExpenseTag.tag_id.in_(tag_id_list))
except ValueError:
pass # Invalid tag IDs, ignore filter
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
receipt_ocr_text = ""
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}'
# Process OCR for image receipts
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
if file_ext in ['png', 'jpg', 'jpeg', 'pdf']:
try:
abs_filepath = os.path.abspath(filepath)
receipt_ocr_text = extract_text_from_file(abs_filepath, file_ext)
print(f"OCR extracted {len(receipt_ocr_text)} characters from receipt {filename}")
except Exception as e:
print(f"OCR processing failed for receipt {filename}: {str(e)}")
# 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,
receipt_ocr_text=receipt_ocr_text,
date=datetime.fromisoformat(data.get('date')) if data.get('date') else datetime.utcnow()
)
# Handle legacy JSON 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.flush() # Get expense ID before handling tag objects
# Auto-suggest tags based on description and OCR text
enable_auto_tags = data.get('enable_auto_tags', True) # Default to True
if enable_auto_tags:
suggested_tags = suggest_tags_for_expense(
description=data.get('description'),
ocr_text=receipt_ocr_text,
category_name=category.name
)
# Create or get tags and associate with expense
for tag_data in suggested_tags:
# Check if tag exists for user
tag = Tag.query.filter_by(
user_id=current_user.id,
name=tag_data['name']
).first()
if not tag:
# Create new auto-generated tag
tag = Tag(
name=tag_data['name'],
color=tag_data['color'],
icon=tag_data['icon'],
user_id=current_user.id,
is_auto=True,
use_count=0
)
db.session.add(tag)
db.session.flush()
# Associate tag with expense
expense.add_tag(tag)
# Handle manual tag associations (tag IDs passed from frontend)
if data.get('tag_ids'):
tag_ids = data.get('tag_ids')
if isinstance(tag_ids, str):
import json
tag_ids = json.loads(tag_ids)
for tag_id in tag_ids:
# Security: Verify tag belongs to user
tag = Tag.query.filter_by(id=tag_id, user_id=current_user.id).first()
if tag:
expense.add_tag(tag)
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}'
# Process OCR for new receipt
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
if file_ext in ['png', 'jpg', 'jpeg', 'pdf']:
try:
abs_filepath = os.path.abspath(filepath)
expense.receipt_ocr_text = extract_text_from_file(abs_filepath, file_ext)
print(f"OCR extracted {len(expense.receipt_ocr_text)} characters from receipt {filename}")
except Exception as e:
print(f"OCR processing failed for receipt {filename}: {str(e)}")
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()
# Also return popular tags for quick selection
popular_tags = Tag.query.filter_by(user_id=current_user.id)\
.filter(Tag.use_count > 0)\
.order_by(Tag.use_count.desc())\
.limit(10)\
.all()
return jsonify({
'categories': [cat.to_dict() for cat in categories],
'popular_tags': [tag.to_dict() for tag in popular_tags]
})
@bp.route('/suggest-tags', methods=['POST'])
@login_required
def suggest_tags():
"""
Get tag suggestions for an expense based on description and category
"""
data = request.get_json()
description = data.get('description', '')
category_id = data.get('category_id')
ocr_text = data.get('ocr_text', '')
category_name = None
if category_id:
category = Category.query.filter_by(id=category_id, user_id=current_user.id).first()
if category:
category_name = category.name
# Get suggestions from auto-tagger
suggestions = suggest_tags_for_expense(description, ocr_text, category_name)
# Check which tags already exist for this user
existing_tags = []
if suggestions:
tag_names = [s['name'] for s in suggestions]
existing = Tag.query.filter(
Tag.user_id == current_user.id,
Tag.name.in_(tag_names)
).all()
existing_tags = [tag.to_dict() for tag in existing]
return jsonify({
'success': True,
'suggested_tags': suggestions,
'existing_tags': existing_tags
})
@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
# Sanitize inputs
name = str(data.get('name')).strip()[:50] # Limit to 50 chars
color = str(data.get('color', '#2b8cee')).strip()[:7] # Hex color format
icon = str(data.get('icon', 'category')).strip()[:50] # Limit to 50 chars, alphanumeric and underscore only
# Validate color format (must be hex)
if not color.startswith('#') or len(color) != 7:
color = '#2b8cee'
# Validate icon (alphanumeric and underscore only for security)
if not all(c.isalnum() or c == '_' for c in icon):
icon = 'category'
# 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=name,
color=color,
icon=icon,
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 = str(data.get('name')).strip()[:50]
if data.get('color'):
color = str(data.get('color')).strip()[:7]
if color.startswith('#') and len(color) == 7:
category.color = color
if data.get('icon'):
icon = str(data.get('icon')).strip()[:50]
# Validate icon (alphanumeric and underscore only for security)
if all(c.isalnum() or c == '_' for c in icon):
category.icon = icon
if 'display_order' in data:
category.display_order = int(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
data = request.get_json(silent=True) or {}
move_to_category_id = data.get('move_to_category_id')
# Count expenses in this category
expense_count = category.expenses.count()
# If category has expenses and no move_to_category_id specified, return error with count
if expense_count > 0 and not move_to_category_id:
return jsonify({
'success': False,
'message': 'Category has expenses',
'expense_count': expense_count,
'requires_reassignment': True
}), 400
# If move_to_category_id specified, reassign expenses
if expense_count > 0 and move_to_category_id:
move_to_category = Category.query.filter_by(id=move_to_category_id, user_id=current_user.id).first()
if not move_to_category:
return jsonify({'success': False, 'message': 'Target category not found'}), 404
# Reassign all expenses to the new category
for expense in category.expenses:
expense.category_id = move_to_category_id
db.session.delete(category)
db.session.commit()
return jsonify({'success': True, 'message': 'Category deleted', 'expenses_moved': expense_count})
@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

408
app/routes/income.py Normal file
View file

@ -0,0 +1,408 @@
from flask import Blueprint, request, jsonify, current_app
from flask_login import login_required, current_user
from app import db
from app.models import Income
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
import json
bp = Blueprint('income', __name__, url_prefix='/api/income')
def calculate_income_next_due_date(frequency, custom_days=None, from_date=None):
"""Calculate next due date for recurring income based on frequency
Args:
frequency: 'once', 'weekly', 'biweekly', 'every4weeks', 'monthly', 'custom'
custom_days: Number of days for custom frequency
from_date: Starting date (default: today)
Returns:
Next due date or None for one-time income
"""
if frequency == 'once':
return None
if from_date is None:
from_date = datetime.utcnow()
if frequency == 'weekly':
return from_date + timedelta(days=7)
elif frequency == 'biweekly':
return from_date + timedelta(days=14)
elif frequency == 'every4weeks':
return from_date + timedelta(days=28)
elif frequency == 'monthly':
return from_date + relativedelta(months=1)
elif frequency == 'custom' and custom_days:
return from_date + timedelta(days=custom_days)
return None
@bp.route('/', methods=['GET'])
@login_required
def get_income():
"""Get income entries with filtering and pagination
Security: Only returns income for current_user
"""
current_app.logger.info(f"Getting income for user {current_user.id}")
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
source = request.args.get('source')
search = request.args.get('search', '')
# Security: Filter by current user
query = Income.query.filter_by(user_id=current_user.id)
if source:
query = query.filter_by(source=source)
if start_date:
query = query.filter(Income.date >= datetime.fromisoformat(start_date))
if end_date:
query = query.filter(Income.date <= datetime.fromisoformat(end_date))
if search:
query = query.filter(Income.description.ilike(f'%{search}%'))
pagination = query.order_by(Income.date.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
current_app.logger.info(f"Found {pagination.total} income entries for user {current_user.id}")
return jsonify({
'income': [inc.to_dict() for inc in pagination.items],
'total': pagination.total,
'pages': pagination.pages,
'current_page': page
})
@bp.route('/', methods=['POST'])
@login_required
def create_income():
"""Create new income entry
Security: Only creates income for current_user
"""
data = request.get_json()
current_app.logger.info(f"Creating income for user {current_user.id}, data: {data}")
# Validate required fields
if not data or not data.get('amount') or not data.get('source') or not data.get('description'):
current_app.logger.warning(f"Missing required fields: {data}")
return jsonify({'success': False, 'message': 'Missing required fields'}), 400
try:
income_date = datetime.fromisoformat(data.get('date')) if data.get('date') else datetime.utcnow()
frequency = data.get('frequency', 'once')
custom_days = data.get('custom_days')
auto_create = data.get('auto_create', False)
# Calculate next due date for recurring income
next_due_date = None
if frequency != 'once' and auto_create:
next_due_date = calculate_income_next_due_date(frequency, custom_days, income_date)
# Create income entry
income = Income(
amount=float(data.get('amount')),
currency=data.get('currency', current_user.currency),
description=data.get('description'),
source=data.get('source'),
user_id=current_user.id,
tags=json.dumps(data.get('tags', [])) if isinstance(data.get('tags'), list) else data.get('tags', '[]'),
frequency=frequency,
custom_days=custom_days,
next_due_date=next_due_date,
is_active=True,
auto_create=auto_create,
date=income_date
)
current_app.logger.info(f"Adding income to session: {income.description}")
db.session.add(income)
db.session.commit()
current_app.logger.info(f"Income committed with ID: {income.id}")
# Verify it was saved
saved_income = Income.query.filter_by(id=income.id).first()
current_app.logger.info(f"Verification - Income exists: {saved_income is not None}")
return jsonify({
'success': True,
'message': 'Income added successfully',
'income': income.to_dict()
}), 201
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error creating income: {str(e)}", exc_info=True)
return jsonify({'success': False, 'message': 'Failed to create income'}), 500
@bp.route('/<int:income_id>', methods=['PUT'])
@login_required
def update_income(income_id):
"""Update income entry
Security: Only allows updating user's own income
"""
# Security check: verify income belongs to current user
income = Income.query.filter_by(id=income_id, user_id=current_user.id).first()
if not income:
return jsonify({'success': False, 'message': 'Income not found'}), 404
data = request.get_json()
try:
# Update fields
if 'amount' in data:
income.amount = float(data['amount'])
if 'currency' in data:
income.currency = data['currency']
if 'description' in data:
income.description = data['description']
if 'source' in data:
income.source = data['source']
if 'tags' in data:
income.tags = json.dumps(data['tags']) if isinstance(data['tags'], list) else data['tags']
if 'date' in data:
income.date = datetime.fromisoformat(data['date'])
# Handle frequency changes
frequency_changed = False
if 'frequency' in data and data['frequency'] != income.frequency:
income.frequency = data['frequency']
frequency_changed = True
if 'custom_days' in data:
income.custom_days = data['custom_days']
frequency_changed = True
if 'auto_create' in data:
income.auto_create = data['auto_create']
if 'is_active' in data:
income.is_active = data['is_active']
# Recalculate next_due_date if frequency changed or auto_create enabled
if (frequency_changed or 'auto_create' in data) and income.auto_create and income.is_active:
if income.frequency != 'once':
from_date = income.last_created_date if income.last_created_date else income.date
income.next_due_date = calculate_income_next_due_date(
income.frequency,
income.custom_days,
from_date
)
else:
income.next_due_date = None
elif not income.auto_create or not income.is_active:
income.next_due_date = None
income.updated_at = datetime.utcnow()
db.session.commit()
return jsonify({
'success': True,
'message': 'Income updated successfully',
'income': income.to_dict()
})
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error updating income: {str(e)}")
return jsonify({'success': False, 'message': 'Failed to update income'}), 500
@bp.route('/<int:income_id>', methods=['DELETE'])
@login_required
def delete_income(income_id):
"""Delete income entry
Security: Only allows deleting user's own income
"""
# Security check: verify income belongs to current user
income = Income.query.filter_by(id=income_id, user_id=current_user.id).first()
if not income:
return jsonify({'success': False, 'message': 'Income not found'}), 404
try:
db.session.delete(income)
db.session.commit()
return jsonify({
'success': True,
'message': 'Income deleted successfully'
})
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error deleting income: {str(e)}")
return jsonify({'success': False, 'message': 'Failed to delete income'}), 500
@bp.route('/<int:income_id>/toggle', methods=['PUT'])
@login_required
def toggle_recurring_income(income_id):
"""Toggle recurring income active status
Security: Only allows toggling user's own income
"""
# Security check: verify income belongs to current user
income = Income.query.filter_by(id=income_id, user_id=current_user.id).first()
if not income:
return jsonify({'success': False, 'message': 'Income not found'}), 404
try:
income.is_active = not income.is_active
# Clear next_due_date if deactivated
if not income.is_active:
income.next_due_date = None
elif income.auto_create and income.frequency != 'once':
# Recalculate next_due_date when reactivated
from_date = income.last_created_date if income.last_created_date else income.date
income.next_due_date = calculate_income_next_due_date(
income.frequency,
income.custom_days,
from_date
)
income.updated_at = datetime.utcnow()
db.session.commit()
return jsonify({
'success': True,
'message': f'Income {"activated" if income.is_active else "deactivated"}',
'income': income.to_dict()
})
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error toggling income: {str(e)}")
return jsonify({'success': False, 'message': 'Failed to toggle income'}), 500
@bp.route('/<int:income_id>/create-now', methods=['POST'])
@login_required
def create_income_now(income_id):
"""Manually create income entry from recurring income
Security: Only allows creating from user's own recurring income
"""
# Security check: verify income belongs to current user
recurring_income = Income.query.filter_by(id=income_id, user_id=current_user.id).first()
if not recurring_income:
return jsonify({'success': False, 'message': 'Recurring income not found'}), 404
if recurring_income.frequency == 'once':
return jsonify({'success': False, 'message': 'This is not a recurring income'}), 400
try:
# Create new income entry based on recurring income
new_income = Income(
amount=recurring_income.amount,
currency=recurring_income.currency,
description=recurring_income.description,
source=recurring_income.source,
user_id=current_user.id,
tags=recurring_income.tags,
frequency='once', # Created income is one-time
date=datetime.utcnow()
)
db.session.add(new_income)
# Update recurring income's next due date and last created date
recurring_income.last_created_date = datetime.utcnow()
if recurring_income.auto_create and recurring_income.is_active:
recurring_income.next_due_date = calculate_income_next_due_date(
recurring_income.frequency,
recurring_income.custom_days,
recurring_income.last_created_date
)
db.session.commit()
return jsonify({
'success': True,
'message': 'Income created successfully',
'income': new_income.to_dict(),
'recurring_income': recurring_income.to_dict()
}), 201
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error creating income from recurring: {str(e)}")
return jsonify({'success': False, 'message': 'Failed to create income'}), 500
@bp.route('/sources', methods=['GET'])
@login_required
def get_income_sources():
"""Get list of income sources
Returns predefined sources for consistency
"""
sources = [
{'value': 'Salary', 'label': 'Salary', 'icon': 'payments'},
{'value': 'Freelance', 'label': 'Freelance', 'icon': 'work'},
{'value': 'Investment', 'label': 'Investment', 'icon': 'trending_up'},
{'value': 'Rental', 'label': 'Rental Income', 'icon': 'home'},
{'value': 'Gift', 'label': 'Gift', 'icon': 'card_giftcard'},
{'value': 'Bonus', 'label': 'Bonus', 'icon': 'star'},
{'value': 'Refund', 'label': 'Refund', 'icon': 'refresh'},
{'value': 'Other', 'label': 'Other', 'icon': 'category'}
]
return jsonify({'sources': sources})
@bp.route('/summary', methods=['GET'])
@login_required
def get_income_summary():
"""Get income summary for dashboard
Security: Only returns data for current_user
"""
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
# Security: Filter by current user
query = Income.query.filter_by(user_id=current_user.id)
if start_date:
query = query.filter(Income.date >= datetime.fromisoformat(start_date))
if end_date:
query = query.filter(Income.date <= datetime.fromisoformat(end_date))
# Calculate totals by source
income_by_source = db.session.query(
Income.source,
db.func.sum(Income.amount).label('total'),
db.func.count(Income.id).label('count')
).filter_by(user_id=current_user.id)
if start_date:
income_by_source = income_by_source.filter(Income.date >= datetime.fromisoformat(start_date))
if end_date:
income_by_source = income_by_source.filter(Income.date <= datetime.fromisoformat(end_date))
income_by_source = income_by_source.group_by(Income.source).all()
total_income = sum(item.total for item in income_by_source)
breakdown = [
{
'source': item.source,
'total': float(item.total),
'count': item.count,
'percentage': (float(item.total) / total_income * 100) if total_income > 0 else 0
}
for item in income_by_source
]
return jsonify({
'total_income': total_income,
'count': sum(item.count for item in income_by_source),
'breakdown': breakdown
})

581
app/routes/main.py Normal file
View file

@ -0,0 +1,581 @@
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, Income
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('/recurring')
@login_required
def recurring():
return render_template('recurring.html')
@bp.route('/import')
@login_required
def import_page():
return render_template('import.html')
@bp.route('/income')
@login_required
def income():
return render_template('income.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)
# Current month income
current_month_income = Income.query.filter(
Income.user_id == current_user.id,
Income.date >= current_month_start
).all()
current_income_total = sum(inc.amount for inc in current_month_income)
# Previous month income
prev_month_income = Income.query.filter(
Income.user_id == current_user.id,
Income.date >= prev_month_start,
Income.date < current_month_start
).all()
prev_income_total = sum(inc.amount for inc in prev_month_income)
# Calculate profit/loss
current_profit = current_income_total - current_month_total
prev_profit = prev_income_total - prev_month_total
# 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,
Category.icon,
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) - including income
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)
month_income_list = Income.query.filter(
Income.user_id == current_user.id,
Income.date >= month_start,
Income.date < month_end
).all()
month_income = sum(inc.amount for inc in month_income_list)
monthly_data.append({
'month': month_start.strftime('%b'),
'expenses': float(month_total),
'income': float(month_income),
'profit': float(month_income - month_total)
})
# Add budget status to category breakdown
category_breakdown = []
for stat in category_stats:
cat = Category.query.get(stat[0])
cat_data = {
'id': stat[0],
'name': stat[1],
'color': stat[2],
'icon': stat[3],
'total': float(stat[4]),
'count': stat[5]
}
if cat:
cat_data['budget_status'] = cat.get_budget_status()
cat_data['monthly_budget'] = cat.monthly_budget
cat_data['budget_alert_threshold'] = cat.budget_alert_threshold
category_breakdown.append(cat_data)
return jsonify({
'total_spent': float(current_month_total),
'total_income': float(current_income_total),
'profit_loss': float(current_profit),
'percent_change': round(percent_change, 1),
'active_categories': active_categories,
'total_transactions': total_transactions,
'currency': current_user.currency,
'category_breakdown': category_breakdown,
'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 including income tracking
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 expenses 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()
# Query income for the same period
income_query = Income.query.filter(
Income.user_id == current_user.id,
Income.date >= period_start
)
incomes = income_query.all()
# Total spent and earned in period
total_spent = sum(exp.amount for exp in expenses)
total_income = sum(inc.amount for inc in incomes)
# Previous period comparison for expenses and income
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)
prev_incomes = Income.query.filter(
Income.user_id == current_user.id,
Income.date >= prev_period_start,
Income.date < period_start
).all()
prev_income_total = sum(inc.amount for inc in prev_incomes)
# Calculate profit/loss
current_profit = total_income - total_spent
prev_profit = prev_income_total - prev_total
percent_change = 0
if prev_total > 0:
percent_change = ((total_spent - prev_total) / prev_total) * 100
elif total_spent > 0:
percent_change = 100
# Income change percentage
income_percent_change = 0
if prev_income_total > 0:
income_percent_change = ((total_income - prev_income_total) / prev_income_total) * 100
elif total_income > 0:
income_percent_change = 100
# Profit/loss change percentage
profit_percent_change = 0
if prev_profit != 0:
profit_percent_change = ((current_profit - prev_profit) / abs(prev_profit)) * 100
elif current_profit != 0:
profit_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 income (more accurate than budget)
if total_income > 0:
savings_amount = total_income - total_spent
savings_rate = (savings_amount / total_income) * 100
savings_rate = max(-100, min(100, savings_rate)) # Clamp between -100% and 100%
else:
# Fallback to budget if no income data
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))
else:
savings_rate = 0
# Previous period savings rate
if prev_income_total > 0:
prev_savings_amount = prev_income_total - prev_total
prev_savings_rate = (prev_savings_amount / prev_income_total) * 100
prev_savings_rate = max(-100, min(100, prev_savings_rate))
else:
if current_user.monthly_budget and current_user.monthly_budget > 0:
prev_savings_amount = current_user.monthly_budget - prev_total
prev_savings_rate = (prev_savings_amount / current_user.monthly_budget) * 100
prev_savings_rate = max(0, min(100, prev_savings_rate))
else:
prev_savings_rate = 0
savings_rate_change = savings_rate - prev_savings_rate
# 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 and income 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)
day_incomes = Income.query.filter(
Income.user_id == current_user.id,
Income.date >= day_start,
Income.date < day_end
).all()
day_income = sum(inc.amount for inc in day_incomes)
daily_trend.insert(0, {
'date': day_date.strftime('%d %b'),
'expenses': float(day_total),
'income': float(day_income),
'profit': float(day_income - day_total)
})
# Monthly comparison with income (all 12 months of current year)
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)
month_incomes = Income.query.filter(
Income.user_id == current_user.id,
Income.date >= month_start,
Income.date < month_end
).all()
month_income = sum(inc.amount for inc in month_incomes)
monthly_comparison.append({
'month': month_start.strftime('%b'),
'expenses': float(month_total),
'income': float(month_income),
'profit': float(month_income - month_total)
})
# Income sources breakdown
income_by_source = {}
for inc in incomes:
source = inc.source
income_by_source[source] = income_by_source.get(source, 0) + inc.amount
income_breakdown = [{
'source': source,
'amount': float(amount),
'percentage': round((amount / total_income * 100) if total_income > 0 else 0, 1)
} for source, amount in sorted(income_by_source.items(), key=lambda x: x[1], reverse=True)]
return jsonify({
'total_spent': float(total_spent),
'total_income': float(total_income),
'profit_loss': float(current_profit),
'percent_change': round(percent_change, 1),
'income_percent_change': round(income_percent_change, 1),
'profit_percent_change': round(profit_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': round(savings_rate, 1),
'savings_rate_change': round(savings_rate_change, 1),
'category_breakdown': category_breakdown,
'income_breakdown': income_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
})

438
app/routes/recurring.py Normal file
View file

@ -0,0 +1,438 @@
from flask import Blueprint, request, jsonify, current_app
from flask_login import login_required, current_user
from app import db
from app.models import RecurringExpense, Expense, Category
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
from collections import defaultdict
import re
bp = Blueprint('recurring', __name__, url_prefix='/api/recurring')
def calculate_next_due_date(frequency, day_of_period=None, from_date=None):
"""Calculate next due date based on frequency"""
base_date = from_date or datetime.utcnow()
if frequency == 'daily':
return base_date + timedelta(days=1)
elif frequency == 'weekly':
# day_of_period is day of week (0=Monday, 6=Sunday)
target_day = day_of_period if day_of_period is not None else base_date.weekday()
days_ahead = target_day - base_date.weekday()
if days_ahead <= 0:
days_ahead += 7
return base_date + timedelta(days=days_ahead)
elif frequency == 'monthly':
# day_of_period is day of month (1-31)
target_day = day_of_period if day_of_period is not None else base_date.day
next_month = base_date + relativedelta(months=1)
try:
return next_month.replace(day=min(target_day, 28)) # Safe day
except ValueError:
# Handle months with fewer days
return next_month.replace(day=28)
elif frequency == 'yearly':
return base_date + relativedelta(years=1)
else:
return base_date + timedelta(days=30)
@bp.route('/', methods=['GET'])
@login_required
def get_recurring_expenses():
"""Get all recurring expenses for current user"""
# Security: Filter by user_id
recurring = RecurringExpense.query.filter_by(user_id=current_user.id).order_by(
RecurringExpense.is_active.desc(),
RecurringExpense.next_due_date.asc()
).all()
return jsonify({
'recurring_expenses': [r.to_dict() for r in recurring]
})
@bp.route('/', methods=['POST'])
@login_required
def create_recurring_expense():
"""Create a new recurring expense"""
data = request.get_json()
# Validate required fields
if not data or not data.get('name') or not data.get('amount') or not data.get('category_id') or not data.get('frequency'):
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
# Validate frequency
valid_frequencies = ['daily', 'weekly', 'monthly', 'yearly']
frequency = data.get('frequency')
if frequency not in valid_frequencies:
return jsonify({'success': False, 'message': 'Invalid frequency'}), 400
# Calculate next due date
day_of_period = data.get('day_of_period')
next_due_date = data.get('next_due_date')
if next_due_date:
next_due_date = datetime.fromisoformat(next_due_date)
else:
next_due_date = calculate_next_due_date(frequency, day_of_period)
# Create recurring expense
recurring = RecurringExpense(
name=data.get('name'),
amount=float(data.get('amount')),
currency=data.get('currency', current_user.currency),
category_id=int(data.get('category_id')),
frequency=frequency,
day_of_period=day_of_period,
next_due_date=next_due_date,
auto_create=data.get('auto_create', False),
is_active=data.get('is_active', True),
notes=data.get('notes'),
detected=False, # Manually created
user_id=current_user.id
)
db.session.add(recurring)
db.session.commit()
return jsonify({
'success': True,
'recurring_expense': recurring.to_dict()
}), 201
@bp.route('/<int:recurring_id>', methods=['PUT'])
@login_required
def update_recurring_expense(recurring_id):
"""Update a recurring expense"""
# Security: Filter by user_id
recurring = RecurringExpense.query.filter_by(id=recurring_id, user_id=current_user.id).first()
if not recurring:
return jsonify({'success': False, 'message': 'Recurring expense not found'}), 404
data = request.get_json()
# Update fields
if data.get('name'):
recurring.name = data.get('name')
if data.get('amount'):
recurring.amount = float(data.get('amount'))
if data.get('currency'):
recurring.currency = data.get('currency')
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
recurring.category_id = int(data.get('category_id'))
if data.get('frequency'):
valid_frequencies = ['daily', 'weekly', 'monthly', 'yearly']
if data.get('frequency') not in valid_frequencies:
return jsonify({'success': False, 'message': 'Invalid frequency'}), 400
recurring.frequency = data.get('frequency')
if 'day_of_period' in data:
recurring.day_of_period = data.get('day_of_period')
if data.get('next_due_date'):
recurring.next_due_date = datetime.fromisoformat(data.get('next_due_date'))
if 'auto_create' in data:
recurring.auto_create = data.get('auto_create')
if 'is_active' in data:
recurring.is_active = data.get('is_active')
if 'notes' in data:
recurring.notes = data.get('notes')
db.session.commit()
return jsonify({
'success': True,
'recurring_expense': recurring.to_dict()
})
@bp.route('/<int:recurring_id>', methods=['DELETE'])
@login_required
def delete_recurring_expense(recurring_id):
"""Delete a recurring expense"""
# Security: Filter by user_id
recurring = RecurringExpense.query.filter_by(id=recurring_id, user_id=current_user.id).first()
if not recurring:
return jsonify({'success': False, 'message': 'Recurring expense not found'}), 404
db.session.delete(recurring)
db.session.commit()
return jsonify({'success': True, 'message': 'Recurring expense deleted'})
@bp.route('/<int:recurring_id>/create-expense', methods=['POST'])
@login_required
def create_expense_from_recurring(recurring_id):
"""Manually create an expense from a recurring expense"""
# Security: Filter by user_id
recurring = RecurringExpense.query.filter_by(id=recurring_id, user_id=current_user.id).first()
if not recurring:
return jsonify({'success': False, 'message': 'Recurring expense not found'}), 404
# Create expense
expense = Expense(
amount=recurring.amount,
currency=recurring.currency,
description=recurring.name,
category_id=recurring.category_id,
user_id=current_user.id,
tags=['recurring', recurring.frequency],
date=datetime.utcnow()
)
expense.set_tags(['recurring', recurring.frequency])
# Update recurring expense
recurring.last_created_date = datetime.utcnow()
recurring.next_due_date = calculate_next_due_date(
recurring.frequency,
recurring.day_of_period,
recurring.next_due_date
)
db.session.add(expense)
db.session.commit()
return jsonify({
'success': True,
'expense': expense.to_dict(),
'recurring_expense': recurring.to_dict()
}), 201
@bp.route('/detect', methods=['POST'])
@login_required
def detect_recurring_patterns():
"""
Detect recurring expense patterns from historical expenses
Returns suggestions for potential recurring expenses
"""
# Get user's expenses from last 6 months
six_months_ago = datetime.utcnow() - relativedelta(months=6)
expenses = Expense.query.filter(
Expense.user_id == current_user.id,
Expense.date >= six_months_ago
).order_by(Expense.date.asc()).all()
if len(expenses) < 10:
return jsonify({
'suggestions': [],
'message': 'Not enough expense history to detect patterns'
})
# Group expenses by similar descriptions and amounts
patterns = defaultdict(list)
for expense in expenses:
# Normalize description (lowercase, remove numbers/special chars)
normalized_desc = re.sub(r'[^a-z\s]', '', expense.description.lower()).strip()
# Create a key based on normalized description and approximate amount
amount_bucket = round(expense.amount / 10) * 10 # Group by 10 currency units
key = f"{normalized_desc}_{amount_bucket}_{expense.category_id}"
patterns[key].append(expense)
suggestions = []
# Analyze patterns
for key, expense_list in patterns.items():
if len(expense_list) < 3: # Need at least 3 occurrences
continue
# Calculate intervals between expenses
intervals = []
for i in range(1, len(expense_list)):
days_diff = (expense_list[i].date - expense_list[i-1].date).days
intervals.append(days_diff)
if not intervals:
continue
avg_interval = sum(intervals) / len(intervals)
# Check variance to ensure consistency
variance = sum((x - avg_interval) ** 2 for x in intervals) / len(intervals)
std_dev = variance ** 0.5
# Determine if pattern is consistent
if std_dev / avg_interval > 0.3: # More than 30% variance
continue
# Determine frequency
frequency = None
day_of_period = None
confidence = 0
if 25 <= avg_interval <= 35: # Monthly
frequency = 'monthly'
# Get most common day of month
days = [e.date.day for e in expense_list]
day_of_period = max(set(days), key=days.count)
confidence = 90 - (std_dev / avg_interval * 100)
elif 6 <= avg_interval <= 8: # Weekly
frequency = 'weekly'
days = [e.date.weekday() for e in expense_list]
day_of_period = max(set(days), key=days.count)
confidence = 85 - (std_dev / avg_interval * 100)
elif 360 <= avg_interval <= 370: # Yearly
frequency = 'yearly'
confidence = 80 - (std_dev / avg_interval * 100)
if frequency and confidence > 60: # Only suggest if confidence > 60%
# Use most recent expense data
latest = expense_list[-1]
avg_amount = sum(e.amount for e in expense_list) / len(expense_list)
# Check if already exists as recurring expense
existing = RecurringExpense.query.filter_by(
user_id=current_user.id,
name=latest.description,
category_id=latest.category_id
).first()
if not existing:
suggestions.append({
'name': latest.description,
'amount': round(avg_amount, 2),
'currency': latest.currency,
'category_id': latest.category_id,
'category_name': latest.category.name,
'category_color': latest.category.color,
'frequency': frequency,
'day_of_period': day_of_period,
'confidence_score': round(confidence, 1),
'occurrences': len(expense_list),
'detected': True
})
# Sort by confidence score
suggestions.sort(key=lambda x: x['confidence_score'], reverse=True)
return jsonify({
'suggestions': suggestions[:10], # Return top 10
'message': f'Found {len(suggestions)} potential recurring expenses'
})
@bp.route('/accept-suggestion', methods=['POST'])
@login_required
def accept_suggestion():
"""Accept a detected recurring expense suggestion and create it"""
data = request.get_json()
if not data or not data.get('name') or not data.get('amount') or not data.get('category_id') or not data.get('frequency'):
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
# Calculate next due date
day_of_period = data.get('day_of_period')
next_due_date = calculate_next_due_date(data.get('frequency'), day_of_period)
# Create recurring expense
recurring = RecurringExpense(
name=data.get('name'),
amount=float(data.get('amount')),
currency=data.get('currency', current_user.currency),
category_id=int(data.get('category_id')),
frequency=data.get('frequency'),
day_of_period=day_of_period,
next_due_date=next_due_date,
auto_create=data.get('auto_create', False),
is_active=True,
detected=True, # Auto-detected
confidence_score=data.get('confidence_score', 0),
user_id=current_user.id
)
db.session.add(recurring)
db.session.commit()
return jsonify({
'success': True,
'recurring_expense': recurring.to_dict()
}), 201
@bp.route('/upcoming', methods=['GET'])
@login_required
def get_upcoming_recurring():
"""Get upcoming recurring expenses (next 30 days)"""
# Security: Filter by user_id
thirty_days_later = datetime.utcnow() + timedelta(days=30)
recurring = RecurringExpense.query.filter(
RecurringExpense.user_id == current_user.id,
RecurringExpense.is_active == True,
RecurringExpense.next_due_date <= thirty_days_later
).order_by(RecurringExpense.next_due_date.asc()).all()
return jsonify({
'upcoming': [r.to_dict() for r in recurring]
})
@bp.route('/process-due', methods=['POST'])
@login_required
def process_due_manual():
"""
Manually trigger processing of due recurring expenses
Admin only for security - prevents users from spamming expense creation
"""
if not current_user.is_admin:
return jsonify({'success': False, 'message': 'Unauthorized'}), 403
try:
from app.scheduler import process_due_recurring_expenses
process_due_recurring_expenses()
return jsonify({
'success': True,
'message': 'Recurring expenses processed successfully'
})
except Exception as e:
return jsonify({
'success': False,
'message': f'Error processing recurring expenses: {str(e)}'
}), 500
@bp.route('/sync-currency', methods=['POST'])
@login_required
def sync_currency():
"""
Sync all user's recurring expenses to use their current profile currency
Security: Only updates current user's recurring expenses
"""
try:
# Update all recurring expenses to match user's current currency
RecurringExpense.query.filter_by(user_id=current_user.id).update(
{'currency': current_user.currency}
)
db.session.commit()
return jsonify({
'success': True,
'message': 'All recurring expenses synced to your current currency'
})
except Exception as e:
db.session.rollback()
return jsonify({
'success': False,
'message': f'Error syncing currency: {str(e)}'
}), 500

285
app/routes/search.py Normal file
View file

@ -0,0 +1,285 @@
"""
Global Search API
Provides unified search across all app content and features
Security: All searches filtered by user_id to prevent data leakage
"""
from flask import Blueprint, request, jsonify
from flask_login import login_required, current_user
from app.models import Expense, Document, Category, RecurringExpense, Tag
from sqlalchemy import or_, func
from datetime import datetime
bp = Blueprint('search', __name__, url_prefix='/api/search')
# App features/pages for navigation
APP_FEATURES = [
{
'id': 'dashboard',
'name': 'Dashboard',
'name_ro': 'Tablou de bord',
'description': 'View your financial overview',
'description_ro': 'Vezi prezentarea generală financiară',
'icon': 'dashboard',
'url': '/dashboard',
'keywords': ['dashboard', 'tablou', 'bord', 'overview', 'home', 'start']
},
{
'id': 'transactions',
'name': 'Transactions',
'name_ro': 'Tranzacții',
'description': 'Manage your expenses and transactions',
'description_ro': 'Gestionează cheltuielile și tranzacțiile',
'icon': 'receipt_long',
'url': '/transactions',
'keywords': ['transactions', 'tranzactii', 'expenses', 'cheltuieli', 'spending']
},
{
'id': 'recurring',
'name': 'Recurring Expenses',
'name_ro': 'Cheltuieli recurente',
'description': 'Manage subscriptions and recurring bills',
'description_ro': 'Gestionează abonamente și facturi recurente',
'icon': 'repeat',
'url': '/recurring',
'keywords': ['recurring', 'recurente', 'subscriptions', 'abonamente', 'bills', 'facturi', 'monthly']
},
{
'id': 'reports',
'name': 'Reports',
'name_ro': 'Rapoarte',
'description': 'View detailed financial reports',
'description_ro': 'Vezi rapoarte financiare detaliate',
'icon': 'analytics',
'url': '/reports',
'keywords': ['reports', 'rapoarte', 'analytics', 'analize', 'statistics', 'statistici']
},
{
'id': 'documents',
'name': 'Documents',
'name_ro': 'Documente',
'description': 'Upload and manage your documents',
'description_ro': 'Încarcă și gestionează documentele',
'icon': 'description',
'url': '/documents',
'keywords': ['documents', 'documente', 'files', 'fisiere', 'upload', 'receipts', 'chitante']
},
{
'id': 'settings',
'name': 'Settings',
'name_ro': 'Setări',
'description': 'Configure your account settings',
'description_ro': 'Configurează setările contului',
'icon': 'settings',
'url': '/settings',
'keywords': ['settings', 'setari', 'preferences', 'preferinte', 'account', 'cont', 'profile', 'profil']
}
]
# Admin-only features
ADMIN_FEATURES = [
{
'id': 'admin',
'name': 'Admin Panel',
'name_ro': 'Panou Admin',
'description': 'Manage users and system settings',
'description_ro': 'Gestionează utilizatori și setări sistem',
'icon': 'admin_panel_settings',
'url': '/admin',
'keywords': ['admin', 'administration', 'users', 'utilizatori', 'system', 'sistem']
}
]
@bp.route('/', methods=['GET'])
@login_required
def global_search():
"""
Global search across all content and app features
Security: All data searches filtered by current_user.id
Query params:
- q: Search query string
- limit: Max results per category (default 5)
Returns:
- features: Matching app features/pages
- expenses: Matching expenses (by description or OCR text)
- documents: Matching documents (by filename or OCR text)
- categories: Matching categories
- recurring: Matching recurring expenses
"""
query = request.args.get('q', '').strip()
limit = request.args.get('limit', 5, type=int)
if not query or len(query) < 2:
return jsonify({
'success': False,
'message': 'Query must be at least 2 characters'
}), 400
results = {
'features': [],
'expenses': [],
'documents': [],
'categories': [],
'recurring': [],
'tags': []
}
# Search app features
query_lower = query.lower()
for feature in APP_FEATURES:
# Check if query matches any keyword
if any(query_lower in keyword.lower() for keyword in feature['keywords']):
results['features'].append({
'id': feature['id'],
'type': 'feature',
'name': feature['name'],
'name_ro': feature['name_ro'],
'description': feature['description'],
'description_ro': feature['description_ro'],
'icon': feature['icon'],
'url': feature['url']
})
# Add admin features if user is admin
if current_user.is_admin:
for feature in ADMIN_FEATURES:
if any(query_lower in keyword.lower() for keyword in feature['keywords']):
results['features'].append({
'id': feature['id'],
'type': 'feature',
'name': feature['name'],
'name_ro': feature['name_ro'],
'description': feature['description'],
'description_ro': feature['description_ro'],
'icon': feature['icon'],
'url': feature['url']
})
# Search expenses - Security: filter by user_id
expense_query = Expense.query.filter_by(user_id=current_user.id)
expense_query = expense_query.filter(
or_(
Expense.description.ilike(f'%{query}%'),
Expense.receipt_ocr_text.ilike(f'%{query}%')
)
)
expenses = expense_query.order_by(Expense.date.desc()).limit(limit).all()
for expense in expenses:
# Check if match is from OCR text
ocr_match = expense.receipt_ocr_text and query_lower in expense.receipt_ocr_text.lower()
results['expenses'].append({
'id': expense.id,
'type': 'expense',
'description': expense.description,
'amount': expense.amount,
'currency': expense.currency,
'category_name': expense.category.name if expense.category else None,
'category_color': expense.category.color if expense.category else None,
'date': expense.date.isoformat(),
'has_receipt': bool(expense.receipt_path),
'ocr_match': ocr_match,
'url': '/transactions'
})
# Search documents - Security: filter by user_id
doc_query = Document.query.filter_by(user_id=current_user.id)
doc_query = doc_query.filter(
or_(
Document.original_filename.ilike(f'%{query}%'),
Document.ocr_text.ilike(f'%{query}%')
)
)
documents = doc_query.order_by(Document.created_at.desc()).limit(limit).all()
for doc in documents:
# Check if match is from OCR text
ocr_match = doc.ocr_text and query_lower in doc.ocr_text.lower()
results['documents'].append({
'id': doc.id,
'type': 'document',
'filename': doc.original_filename,
'file_type': doc.file_type,
'file_size': doc.file_size,
'category': doc.document_category,
'created_at': doc.created_at.isoformat(),
'ocr_match': ocr_match,
'url': '/documents'
})
# Search categories - Security: filter by user_id
categories = Category.query.filter_by(user_id=current_user.id).filter(
Category.name.ilike(f'%{query}%')
).order_by(Category.display_order).limit(limit).all()
for category in categories:
results['categories'].append({
'id': category.id,
'type': 'category',
'name': category.name,
'color': category.color,
'icon': category.icon,
'url': '/transactions'
})
# Search recurring expenses - Security: filter by user_id
recurring = RecurringExpense.query.filter_by(user_id=current_user.id).filter(
or_(
RecurringExpense.name.ilike(f'%{query}%'),
RecurringExpense.notes.ilike(f'%{query}%')
)
).order_by(RecurringExpense.next_due_date).limit(limit).all()
for rec in recurring:
results['recurring'].append({
'id': rec.id,
'type': 'recurring',
'name': rec.name,
'amount': rec.amount,
'currency': rec.currency,
'frequency': rec.frequency,
'category_name': rec.category.name if rec.category else None,
'category_color': rec.category.color if rec.category else None,
'next_due_date': rec.next_due_date.isoformat(),
'is_active': rec.is_active,
'url': '/recurring'
})
# Search tags
# Security: Filtered by user_id
tags = Tag.query.filter(
Tag.user_id == current_user.id,
Tag.name.ilike(f'%{query}%')
).limit(limit).all()
for tag in tags:
results['tags'].append({
'id': tag.id,
'type': 'tag',
'name': tag.name,
'color': tag.color,
'icon': tag.icon,
'use_count': tag.use_count,
'is_auto': tag.is_auto
})
# Calculate total results
total_results = sum([
len(results['features']),
len(results['expenses']),
len(results['documents']),
len(results['categories']),
len(results['recurring']),
len(results['tags'])
])
return jsonify({
'success': True,
'query': query,
'total_results': total_results,
'results': results
})

253
app/routes/settings.py Normal file
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

322
app/routes/tags.py Normal file
View file

@ -0,0 +1,322 @@
"""
Tags API Routes
Manage smart tags for expenses with auto-tagging capabilities
Security: All operations filtered by user_id
"""
from flask import Blueprint, request, jsonify
from flask_login import login_required, current_user
from app import db
from app.models import Tag, Expense, ExpenseTag
from sqlalchemy import func, desc
import re
bp = Blueprint('tags', __name__, url_prefix='/api/tags')
@bp.route('/', methods=['GET'])
@login_required
def get_tags():
"""
Get all tags for current user
Security: Filtered by user_id
"""
# Get sort and filter parameters
sort_by = request.args.get('sort_by', 'use_count') # use_count, name, created_at
order = request.args.get('order', 'desc') # asc, desc
# Base query filtered by user
query = Tag.query.filter_by(user_id=current_user.id)
# Apply sorting
if sort_by == 'use_count':
query = query.order_by(Tag.use_count.desc() if order == 'desc' else Tag.use_count.asc())
elif sort_by == 'name':
query = query.order_by(Tag.name.asc() if order == 'asc' else Tag.name.desc())
else: # created_at
query = query.order_by(Tag.created_at.desc() if order == 'desc' else Tag.created_at.asc())
tags = query.all()
return jsonify({
'success': True,
'tags': [tag.to_dict() for tag in tags]
})
@bp.route('/', methods=['POST'])
@login_required
def create_tag():
"""
Create a new tag
Security: Only creates for current_user
"""
data = request.get_json()
if not data.get('name'):
return jsonify({'success': False, 'message': 'Tag name is required'}), 400
# Sanitize and validate input
name = str(data.get('name')).strip().lower()[:50]
# Validate name format (alphanumeric, hyphens, underscores only)
if not re.match(r'^[a-z0-9\-_]+$', name):
return jsonify({
'success': False,
'message': 'Tag name can only contain letters, numbers, hyphens, and underscores'
}), 400
# Check if tag already exists for this user
existing_tag = Tag.query.filter_by(user_id=current_user.id, name=name).first()
if existing_tag:
return jsonify({
'success': False,
'message': 'Tag already exists',
'tag': existing_tag.to_dict()
}), 409
# Sanitize color and icon
color = str(data.get('color', '#6366f1')).strip()[:7]
if not re.match(r'^#[0-9a-fA-F]{6}$', color):
color = '#6366f1'
icon = str(data.get('icon', 'label')).strip()[:50]
if not re.match(r'^[a-z0-9_]+$', icon):
icon = 'label'
# Create tag
tag = Tag(
name=name,
color=color,
icon=icon,
user_id=current_user.id,
is_auto=False
)
db.session.add(tag)
db.session.commit()
return jsonify({
'success': True,
'tag': tag.to_dict()
}), 201
@bp.route('/<int:tag_id>', methods=['PUT'])
@login_required
def update_tag(tag_id):
"""
Update a tag
Security: Only owner can update
"""
tag = Tag.query.filter_by(id=tag_id, user_id=current_user.id).first()
if not tag:
return jsonify({'success': False, 'message': 'Tag not found'}), 404
data = request.get_json()
# Update name if provided
if data.get('name'):
name = str(data.get('name')).strip().lower()[:50]
if not re.match(r'^[a-z0-9\-_]+$', name):
return jsonify({
'success': False,
'message': 'Tag name can only contain letters, numbers, hyphens, and underscores'
}), 400
# Check for duplicate name (excluding current tag)
existing = Tag.query.filter(
Tag.user_id == current_user.id,
Tag.name == name,
Tag.id != tag_id
).first()
if existing:
return jsonify({'success': False, 'message': 'Tag name already exists'}), 409
tag.name = name
# Update color if provided
if data.get('color'):
color = str(data.get('color')).strip()[:7]
if re.match(r'^#[0-9a-fA-F]{6}$', color):
tag.color = color
# Update icon if provided
if data.get('icon'):
icon = str(data.get('icon')).strip()[:50]
if re.match(r'^[a-z0-9_]+$', icon):
tag.icon = icon
db.session.commit()
return jsonify({
'success': True,
'tag': tag.to_dict()
})
@bp.route('/<int:tag_id>', methods=['DELETE'])
@login_required
def delete_tag(tag_id):
"""
Delete a tag
Security: Only owner can delete
Note: This will also remove all associations with expenses (CASCADE)
"""
tag = Tag.query.filter_by(id=tag_id, user_id=current_user.id).first()
if not tag:
return jsonify({'success': False, 'message': 'Tag not found'}), 404
db.session.delete(tag)
db.session.commit()
return jsonify({
'success': True,
'message': 'Tag deleted successfully'
})
@bp.route('/suggest', methods=['POST'])
@login_required
def suggest_tags():
"""
Suggest tags based on text (description, OCR, etc.)
Security: Only processes for current user
"""
data = request.get_json()
if not data or not data.get('text'):
return jsonify({'success': False, 'message': 'Text is required'}), 400
from app.utils.auto_tagger import extract_tags_from_text
text = str(data.get('text'))
max_tags = data.get('max_tags', 5)
suggested_tags = extract_tags_from_text(text, max_tags=max_tags)
return jsonify({
'success': True,
'suggested_tags': suggested_tags
})
@bp.route('/popular', methods=['GET'])
@login_required
def get_popular_tags():
"""
Get most popular tags for current user
Security: Filtered by user_id
"""
limit = request.args.get('limit', 10, type=int)
tags = Tag.query.filter_by(user_id=current_user.id)\
.filter(Tag.use_count > 0)\
.order_by(Tag.use_count.desc())\
.limit(limit)\
.all()
return jsonify({
'success': True,
'tags': [tag.to_dict() for tag in tags]
})
@bp.route('/stats', methods=['GET'])
@login_required
def get_tag_stats():
"""
Get tag usage statistics
Security: Filtered by user_id
"""
# Total tags count
total_tags = Tag.query.filter_by(user_id=current_user.id).count()
# Auto-generated tags count
auto_tags = Tag.query.filter_by(user_id=current_user.id, is_auto=True).count()
# Total tag uses across all expenses
total_uses = db.session.query(func.sum(Tag.use_count))\
.filter(Tag.user_id == current_user.id)\
.scalar() or 0
# Most used tag
most_used_tag = Tag.query.filter_by(user_id=current_user.id)\
.filter(Tag.use_count > 0)\
.order_by(Tag.use_count.desc())\
.first()
return jsonify({
'success': True,
'stats': {
'total_tags': total_tags,
'auto_generated_tags': auto_tags,
'manual_tags': total_tags - auto_tags,
'total_uses': int(total_uses),
'most_used_tag': most_used_tag.to_dict() if most_used_tag else None
}
})
@bp.route('/bulk-create', methods=['POST'])
@login_required
def bulk_create_tags():
"""
Create multiple tags at once
Security: Only creates for current_user
"""
data = request.get_json()
if not data or not data.get('tags') or not isinstance(data.get('tags'), list):
return jsonify({'success': False, 'message': 'Tags array is required'}), 400
created_tags = []
errors = []
for tag_data in data.get('tags'):
try:
name = str(tag_data.get('name', '')).strip().lower()[:50]
if not name or not re.match(r'^[a-z0-9\-_]+$', name):
errors.append(f"Invalid tag name: {tag_data.get('name')}")
continue
# Check if already exists
existing = Tag.query.filter_by(user_id=current_user.id, name=name).first()
if existing:
created_tags.append(existing.to_dict())
continue
# Validate color and icon
color = str(tag_data.get('color', '#6366f1')).strip()[:7]
if not re.match(r'^#[0-9a-fA-F]{6}$', color):
color = '#6366f1'
icon = str(tag_data.get('icon', 'label')).strip()[:50]
if not re.match(r'^[a-z0-9_]+$', icon):
icon = 'label'
tag = Tag(
name=name,
color=color,
icon=icon,
user_id=current_user.id,
is_auto=tag_data.get('is_auto', False)
)
db.session.add(tag)
created_tags.append(tag.to_dict())
except Exception as e:
errors.append(f"Error creating tag {tag_data.get('name')}: {str(e)}")
db.session.commit()
return jsonify({
'success': True,
'created': len(created_tags),
'tags': created_tags,
'errors': errors
})

198
app/scheduler.py Normal file
View file

@ -0,0 +1,198 @@
"""
Scheduler for background tasks like auto-creating recurring expenses and income
"""
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from datetime import datetime
from app import db
from app.models import RecurringExpense, Expense, Income
from app.routes.recurring import calculate_next_due_date
from app.routes.income import calculate_income_next_due_date
import logging
logger = logging.getLogger(__name__)
def process_due_recurring_expenses():
"""
Process all due recurring expenses and create actual expenses for them
Security: User isolation is maintained through foreign keys
"""
try:
from app import create_app
app = create_app()
with app.app_context():
today = datetime.utcnow().date()
# Find all active recurring expenses that are due today or overdue and have auto_create enabled
due_recurring = RecurringExpense.query.filter(
RecurringExpense.is_active == True,
RecurringExpense.auto_create == True,
RecurringExpense.next_due_date <= datetime.utcnow()
).all()
created_count = 0
for recurring in due_recurring:
try:
# Check if we already created an expense today for this recurring expense
# to avoid duplicates
existing_today = Expense.query.filter(
Expense.user_id == recurring.user_id,
Expense.description == recurring.name,
Expense.category_id == recurring.category_id,
db.func.date(Expense.date) == today
).first()
if existing_today:
logger.info(f"Expense already exists for recurring ID {recurring.id} today, skipping")
continue
# Create the expense
expense = Expense(
amount=recurring.amount,
currency=recurring.currency,
description=recurring.name,
category_id=recurring.category_id,
user_id=recurring.user_id,
tags=['recurring', recurring.frequency, 'auto-created'],
date=datetime.utcnow()
)
expense.set_tags(['recurring', recurring.frequency, 'auto-created'])
db.session.add(expense)
# Update recurring expense
recurring.last_created_date = datetime.utcnow()
recurring.next_due_date = calculate_next_due_date(
recurring.frequency,
recurring.day_of_period,
recurring.next_due_date
)
created_count += 1
logger.info(f"Created expense from recurring ID {recurring.id} for user {recurring.user_id}")
except Exception as e:
logger.error(f"Error processing recurring expense ID {recurring.id}: {str(e)}")
db.session.rollback()
continue
if created_count > 0:
db.session.commit()
logger.info(f"Successfully created {created_count} expenses from recurring expenses")
else:
logger.info("No recurring expenses due for processing")
except Exception as e:
logger.error(f"Error in process_due_recurring_expenses: {str(e)}")
def process_due_recurring_income():
"""
Process all due recurring income and create actual income entries for them
Security: User isolation is maintained through foreign keys
"""
try:
from app import create_app
app = create_app()
with app.app_context():
today = datetime.utcnow().date()
# Find all active recurring income that are due today or overdue and have auto_create enabled
due_recurring = Income.query.filter(
Income.is_active == True,
Income.auto_create == True,
Income.frequency != 'once',
Income.next_due_date <= datetime.utcnow()
).all()
created_count = 0
for recurring in due_recurring:
try:
# Check if we already created income today for this recurring income
# to avoid duplicates
existing_today = Income.query.filter(
Income.user_id == recurring.user_id,
Income.description == recurring.description,
Income.source == recurring.source,
Income.frequency == 'once', # Only check one-time income entries
db.func.date(Income.date) == today
).first()
if existing_today:
logger.info(f"Income already exists for recurring ID {recurring.id} today, skipping")
continue
# Create the income entry
income = Income(
amount=recurring.amount,
currency=recurring.currency,
description=recurring.description,
source=recurring.source,
user_id=recurring.user_id,
tags=recurring.tags,
frequency='once', # Created income is one-time
date=datetime.utcnow()
)
db.session.add(income)
# Update recurring income
recurring.last_created_date = datetime.utcnow()
recurring.next_due_date = calculate_income_next_due_date(
recurring.frequency,
recurring.custom_days,
recurring.last_created_date
)
created_count += 1
logger.info(f"Created income from recurring ID {recurring.id} for user {recurring.user_id}")
except Exception as e:
logger.error(f"Error processing recurring income ID {recurring.id}: {str(e)}")
db.session.rollback()
continue
if created_count > 0:
db.session.commit()
logger.info(f"Successfully created {created_count} income entries from recurring income")
else:
logger.info("No recurring income due for processing")
except Exception as e:
logger.error(f"Error in process_due_recurring_income: {str(e)}")
def init_scheduler(app):
"""Initialize the background scheduler"""
scheduler = BackgroundScheduler()
# Run every hour to check for due recurring expenses
scheduler.add_job(
func=process_due_recurring_expenses,
trigger=CronTrigger(minute=0), # Run at the start of every hour
id='process_recurring_expenses',
name='Process due recurring expenses',
replace_existing=True
)
# Run every hour to check for due recurring income
scheduler.add_job(
func=process_due_recurring_income,
trigger=CronTrigger(minute=5), # Run 5 minutes past every hour
id='process_recurring_income',
name='Process due recurring income',
replace_existing=True
)
scheduler.start()
logger.info("Scheduler initialized - recurring expenses and income will be processed hourly")
# Shut down the scheduler when exiting the app
import atexit
atexit.register(lambda: scheduler.shutdown())
return scheduler

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

BIN
app/static/icons/logo.png Normal file

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

173
app/static/js/admin.js Normal file
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);
}
}

198
app/static/js/app.js Normal file
View file

@ -0,0 +1,198 @@
// 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
let errorData;
try {
errorData = await response.json();
} catch (jsonError) {
showToast(window.getTranslation('common.error', 'An error occurred. Please try again.'), 'error');
throw new Error(`HTTP error! status: ${response.status}`);
}
const errorMsg = errorData.message || window.getTranslation('common.error', 'An error occurred. Please try again.');
// Only show toast if it's not a special case that needs custom handling
if (!errorData.requires_reassignment) {
showToast(errorMsg, 'error');
}
// Throw error with data attached for special handling (e.g., category deletion with reassignment)
const error = new Error(`HTTP error! status: ${response.status}`);
Object.assign(error, errorData);
throw error;
}
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;
}
}
// Export apiCall to window for use by other modules
window.apiCall = apiCall;
// 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');
// Only update if elements exist (not all pages have theme toggle in sidebar)
if (!themeIcon || !themeText) {
return;
}
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');
}
}
});
}
});

316
app/static/js/budget.js Normal file
View file

@ -0,0 +1,316 @@
/**
* Budget Alerts Dashboard Module
* Displays budget warnings, progress bars, and alerts
*/
class BudgetDashboard {
constructor() {
this.budgetData = null;
this.refreshInterval = null;
}
/**
* Initialize budget dashboard
*/
async init() {
await this.loadBudgetStatus();
this.renderBudgetBanner();
this.attachEventListeners();
// Refresh every 5 minutes
this.refreshInterval = setInterval(() => {
this.loadBudgetStatus();
}, 5 * 60 * 1000);
}
/**
* Load budget status from API
*/
async loadBudgetStatus() {
try {
this.budgetData = await window.apiCall('/api/budget/status', 'GET');
this.renderBudgetBanner();
this.updateCategoryBudgets();
} catch (error) {
console.error('Error loading budget status:', error);
}
}
/**
* Render budget alert banner at top of dashboard
*/
renderBudgetBanner() {
const existingBanner = document.getElementById('budgetAlertBanner');
if (existingBanner) {
existingBanner.remove();
}
if (!this.budgetData || !this.budgetData.active_alerts || this.budgetData.active_alerts.length === 0) {
return;
}
const mostSevere = this.budgetData.active_alerts[0];
const banner = document.createElement('div');
banner.id = 'budgetAlertBanner';
banner.className = `mb-6 rounded-lg p-4 ${this.getBannerClass(mostSevere.level)}`;
let message = '';
let icon = '';
switch (mostSevere.level) {
case 'warning':
icon = '<span class="material-symbols-outlined">warning</span>';
break;
case 'danger':
case 'exceeded':
icon = '<span class="material-symbols-outlined">error</span>';
break;
}
if (mostSevere.type === 'overall') {
message = window.getTranslation('budget.overallWarning')
.replace('{percentage}', mostSevere.percentage.toFixed(0))
.replace('{spent}', window.formatCurrency(mostSevere.spent))
.replace('{budget}', window.formatCurrency(mostSevere.budget));
} else if (mostSevere.type === 'category') {
message = window.getTranslation('budget.categoryWarning')
.replace('{category}', mostSevere.category_name)
.replace('{percentage}', mostSevere.percentage.toFixed(0))
.replace('{spent}', window.formatCurrency(mostSevere.spent))
.replace('{budget}', window.formatCurrency(mostSevere.budget));
}
banner.innerHTML = `
<div class="flex items-start">
<div class="flex-shrink-0 text-2xl mr-3">
${icon}
</div>
<div class="flex-1">
<h3 class="font-semibold mb-1">${window.getTranslation('budget.alert')}</h3>
<p class="text-sm">${message}</p>
${this.budgetData.active_alerts.length > 1 ? `
<button onclick="budgetDashboard.showAllAlerts()" class="mt-2 text-sm underline">
${window.getTranslation('budget.viewAllAlerts')} (${this.budgetData.active_alerts.length})
</button>
` : ''}
</div>
<button onclick="budgetDashboard.dismissBanner()" class="flex-shrink-0 ml-3">
<span class="material-symbols-outlined">close</span>
</button>
</div>
`;
// Insert at the top of main content
const mainContent = document.querySelector('main') || document.querySelector('.container');
if (mainContent && mainContent.firstChild) {
mainContent.insertBefore(banner, mainContent.firstChild);
}
}
/**
* Get banner CSS classes based on alert level
*/
getBannerClass(level) {
switch (level) {
case 'warning':
return 'bg-yellow-100 text-yellow-800 border border-yellow-300';
case 'danger':
return 'bg-orange-100 text-orange-800 border border-orange-300';
case 'exceeded':
return 'bg-red-100 text-red-800 border border-red-300';
default:
return 'bg-blue-100 text-blue-800 border border-blue-300';
}
}
/**
* Dismiss budget banner (hide for 1 hour)
*/
dismissBanner() {
const banner = document.getElementById('budgetAlertBanner');
if (banner) {
banner.remove();
}
// Store dismissal timestamp
localStorage.setItem('budgetBannerDismissed', Date.now().toString());
}
/**
* Check if banner should be shown (not dismissed in last hour)
*/
shouldShowBanner() {
const dismissed = localStorage.getItem('budgetBannerDismissed');
if (!dismissed) return true;
const dismissedTime = parseInt(dismissed);
const oneHour = 60 * 60 * 1000;
return Date.now() - dismissedTime > oneHour;
}
/**
* Show modal with all active alerts
*/
showAllAlerts() {
if (!this.budgetData || !this.budgetData.active_alerts) return;
const modal = document.createElement('div');
modal.id = 'allAlertsModal';
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
const alertsList = this.budgetData.active_alerts.map(alert => {
let message = '';
if (alert.type === 'overall') {
message = window.getTranslation('budget.overallWarning')
.replace('{percentage}', alert.percentage.toFixed(0))
.replace('{spent}', window.formatCurrency(alert.spent))
.replace('{budget}', window.formatCurrency(alert.budget));
} else {
message = window.getTranslation('budget.categoryWarning')
.replace('{category}', alert.category_name)
.replace('{percentage}', alert.percentage.toFixed(0))
.replace('{spent}', window.formatCurrency(alert.spent))
.replace('{budget}', window.formatCurrency(alert.budget));
}
return `
<div class="p-3 rounded-lg mb-3 ${this.getBannerClass(alert.level)}">
<div class="font-semibold mb-1">${alert.category_name || window.getTranslation('budget.monthlyBudget')}</div>
<div class="text-sm">${message}</div>
<div class="mt-2">
${this.renderProgressBar(alert.percentage, alert.level)}
</div>
</div>
`;
}).join('');
modal.innerHTML = `
<div class="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-y-auto">
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold">${window.getTranslation('budget.activeAlerts')}</h2>
<button onclick="document.getElementById('allAlertsModal').remove()" class="text-gray-500 hover:text-gray-700">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div>
${alertsList}
</div>
<div class="mt-6 flex justify-end">
<button onclick="document.getElementById('allAlertsModal').remove()"
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300">
${window.getTranslation('common.close')}
</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
// Close on backdrop click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
}
});
}
/**
* Render a progress bar for budget percentage
*/
renderProgressBar(percentage, level) {
const cappedPercentage = Math.min(percentage, 100);
let colorClass = 'bg-green-500';
switch (level) {
case 'warning':
colorClass = 'bg-yellow-500';
break;
case 'danger':
colorClass = 'bg-orange-500';
break;
case 'exceeded':
colorClass = 'bg-red-500';
break;
}
return `
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div class="${colorClass} h-2.5 rounded-full transition-all duration-300"
style="width: ${cappedPercentage}%"></div>
</div>
<div class="text-xs mt-1 text-right">${percentage.toFixed(0)}%</div>
`;
}
/**
* Update category cards with budget information
*/
updateCategoryBudgets() {
if (!this.budgetData || !this.budgetData.categories) return;
this.budgetData.categories.forEach(category => {
const categoryCard = document.querySelector(`[data-category-id="${category.id}"]`);
if (!categoryCard) return;
// Check if budget info already exists
let budgetInfo = categoryCard.querySelector('.budget-info');
if (!budgetInfo) {
budgetInfo = document.createElement('div');
budgetInfo.className = 'budget-info mt-2';
categoryCard.appendChild(budgetInfo);
}
if (category.budget_status && category.budget_status.budget) {
const status = category.budget_status;
budgetInfo.innerHTML = `
<div class="text-xs text-gray-600 mb-1">
${window.formatCurrency(status.spent)} / ${window.formatCurrency(status.budget)}
</div>
${this.renderProgressBar(status.percentage, status.alert_level)}
`;
} else {
budgetInfo.innerHTML = '';
}
});
}
/**
* Attach event listeners
*/
attachEventListeners() {
// Listen for expense changes to refresh budget
document.addEventListener('expenseCreated', () => {
this.loadBudgetStatus();
});
document.addEventListener('expenseUpdated', () => {
this.loadBudgetStatus();
});
document.addEventListener('expenseDeleted', () => {
this.loadBudgetStatus();
});
}
/**
* Cleanup on destroy
*/
destroy() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
}
// Create global instance
window.budgetDashboard = new BudgetDashboard();
// Initialize on dashboard page
if (window.location.pathname === '/dashboard' || window.location.pathname === '/') {
document.addEventListener('DOMContentLoaded', () => {
window.budgetDashboard.init();
});
}

1594
app/static/js/dashboard.js Normal file

File diff suppressed because it is too large Load diff

502
app/static/js/documents.js Normal file
View file

@ -0,0 +1,502 @@
// Documents Page Functionality
let currentPage = 1;
const itemsPerPage = 10;
let searchQuery = '';
let allDocuments = [];
// Initialize documents page
document.addEventListener('DOMContentLoaded', () => {
loadDocuments();
setupEventListeners();
// Check if we need to open a document from search
const docId = sessionStorage.getItem('openDocumentId');
const docType = sessionStorage.getItem('openDocumentType');
const docName = sessionStorage.getItem('openDocumentName');
if (docId && docType && docName) {
// Clear the session storage
sessionStorage.removeItem('openDocumentId');
sessionStorage.removeItem('openDocumentType');
sessionStorage.removeItem('openDocumentName');
// Open the document after a short delay to ensure page is loaded
setTimeout(() => {
viewDocument(parseInt(docId), docType, docName);
}, 500);
}
});
// 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);
}

1209
app/static/js/i18n.js Normal file

File diff suppressed because it is too large Load diff

722
app/static/js/import.js Normal file
View file

@ -0,0 +1,722 @@
/**
* CSV/Bank Statement Import Module for FINA PWA
* Handles file upload, parsing, duplicate detection, and category mapping
*/
class CSVImporter {
constructor() {
this.parsedTransactions = [];
this.duplicates = [];
this.categoryMapping = {};
this.userCategories = [];
this.currentStep = 1;
}
/**
* Initialize the importer
*/
async init() {
await this.loadUserProfile();
await this.loadUserCategories();
this.renderImportUI();
this.setupEventListeners();
}
/**
* Load user profile to get currency
*/
async loadUserProfile() {
try {
const response = await window.apiCall('/api/settings/profile');
window.userCurrency = response.profile?.currency || 'USD';
} catch (error) {
console.error('Failed to load user profile:', error);
window.userCurrency = 'USD';
}
}
/**
* Load user's categories from API
*/
async loadUserCategories() {
try {
const response = await window.apiCall('/api/expenses/categories');
this.userCategories = response.categories || [];
} catch (error) {
console.error('Failed to load categories:', error);
this.userCategories = [];
window.showToast(window.getTranslation('import.errorLoadingCategories', 'Failed to load categories'), 'error');
}
}
/**
* Setup event listeners
*/
setupEventListeners() {
// File input change
const fileInput = document.getElementById('csvFileInput');
if (fileInput) {
fileInput.addEventListener('change', (e) => this.handleFileSelect(e));
}
// Drag and drop
const dropZone = document.getElementById('csvDropZone');
if (dropZone) {
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('border-primary', 'bg-primary/5');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('border-primary', 'bg-primary/5');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('border-primary', 'bg-primary/5');
const files = e.dataTransfer.files;
if (files.length > 0) {
this.handleFile(files[0]);
}
});
}
}
/**
* Render the import UI
*/
renderImportUI() {
const container = document.getElementById('importContainer');
if (!container) return;
container.innerHTML = `
<div class="max-w-4xl mx-auto">
<!-- Progress Steps -->
<div class="mb-8">
<div class="flex items-center justify-between">
${this.renderStep(1, 'import.stepUpload', 'Upload CSV')}
${this.renderStep(2, 'import.stepReview', 'Review')}
${this.renderStep(3, 'import.stepMap', 'Map Categories')}
${this.renderStep(4, 'import.stepImport', 'Import')}
</div>
</div>
<!-- Step Content -->
<div id="stepContent" class="bg-white dark:bg-[#0f1921] rounded-xl p-6 border border-border-light dark:border-[#233648]">
${this.renderCurrentStep()}
</div>
</div>
`;
}
/**
* Render a progress step
*/
renderStep(stepNum, translationKey, fallback) {
const isActive = this.currentStep === stepNum;
const isComplete = this.currentStep > stepNum;
return `
<div class="flex items-center ${stepNum < 4 ? 'flex-1' : ''}">
<div class="flex items-center">
<div class="w-10 h-10 rounded-full flex items-center justify-center font-semibold
${isComplete ? 'bg-green-500 text-white' : ''}
${isActive ? 'bg-primary text-white' : ''}
${!isActive && !isComplete ? 'bg-slate-200 dark:bg-[#233648] text-text-muted' : ''}">
${isComplete ? '<span class="material-symbols-outlined text-[20px]">check</span>' : stepNum}
</div>
<span class="ml-2 text-sm font-medium ${isActive ? 'text-primary' : 'text-text-muted dark:text-[#92adc9]'}">
${window.getTranslation(translationKey, fallback)}
</span>
</div>
${stepNum < 4 ? '<div class="flex-1 h-0.5 bg-slate-200 dark:bg-[#233648] mx-4"></div>' : ''}
</div>
`;
}
/**
* Render content for current step
*/
renderCurrentStep() {
switch (this.currentStep) {
case 1:
return this.renderUploadStep();
case 2:
return this.renderReviewStep();
case 3:
return this.renderMappingStep();
case 4:
return this.renderImportStep();
default:
return '';
}
}
/**
* Render upload step
*/
renderUploadStep() {
return `
<div class="text-center">
<h2 class="text-2xl font-bold mb-4 text-text-main dark:text-white">
${window.getTranslation('import.uploadTitle', 'Upload CSV File')}
</h2>
<p class="text-text-muted dark:text-[#92adc9] mb-6">
${window.getTranslation('import.uploadDesc', 'Upload your bank statement or expense CSV file')}
</p>
<!-- Drop Zone -->
<div id="csvDropZone"
class="border-2 border-dashed border-border-light dark:border-[#233648] rounded-xl p-12 cursor-pointer hover:border-primary/50 transition-colors"
onclick="document.getElementById('csvFileInput').click()">
<span class="material-symbols-outlined text-6xl text-text-muted dark:text-[#92adc9] mb-4">cloud_upload</span>
<p class="text-lg font-medium text-text-main dark:text-white mb-2">
${window.getTranslation('import.dragDrop', 'Drag and drop your CSV file here')}
</p>
<p class="text-sm text-text-muted dark:text-[#92adc9] mb-4">
${window.getTranslation('import.orClick', 'or click to browse')}
</p>
<input type="file"
id="csvFileInput"
accept=".csv"
class="hidden">
</div>
<!-- Format Info -->
<div class="mt-8 text-left bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<h3 class="font-semibold text-text-main dark:text-white mb-2 flex items-center">
<span class="material-symbols-outlined text-[20px] mr-2">info</span>
${window.getTranslation('import.supportedFormats', 'Supported Formats')}
</h3>
<ul class="text-sm text-text-muted dark:text-[#92adc9] space-y-1">
<li> ${window.getTranslation('import.formatRequirement1', 'CSV files with Date, Description, and Amount columns')}</li>
<li> ${window.getTranslation('import.formatRequirement2', 'Supports comma, semicolon, or tab delimiters')}</li>
<li> ${window.getTranslation('import.formatRequirement3', 'Date formats: DD/MM/YYYY, YYYY-MM-DD, etc.')}</li>
<li> ${window.getTranslation('import.formatRequirement4', 'Maximum file size: 10MB')}</li>
</ul>
</div>
</div>
`;
}
/**
* Handle file selection
*/
async handleFileSelect(event) {
const file = event.target.files[0];
if (file) {
await this.handleFile(file);
}
}
/**
* Handle file upload and parsing
*/
async handleFile(file) {
// Validate file
if (!file.name.toLowerCase().endsWith('.csv')) {
window.showToast(window.getTranslation('import.errorInvalidFile', 'Please select a CSV file'), 'error');
return;
}
if (file.size > 10 * 1024 * 1024) {
window.showToast(window.getTranslation('import.errorFileTooLarge', 'File too large. Maximum 10MB'), 'error');
return;
}
// Show loading
const stepContent = document.getElementById('stepContent');
stepContent.innerHTML = `
<div class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
<p class="text-text-muted dark:text-[#92adc9]">${window.getTranslation('import.parsing', 'Parsing CSV file...')}</p>
</div>
`;
try {
// Upload and parse
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/import/parse-csv', {
method: 'POST',
body: formData
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to parse CSV');
}
this.parsedTransactions = result.transactions;
// Check for duplicates
await this.checkDuplicates();
// Move to review step
this.currentStep = 2;
this.renderImportUI();
} catch (error) {
console.error('Failed to parse CSV:', error);
window.showToast(error.message || window.getTranslation('import.errorParsing', 'Failed to parse CSV file'), 'error');
this.currentStep = 1;
this.renderImportUI();
}
}
/**
* Check for duplicate transactions
*/
async checkDuplicates() {
try {
const response = await fetch('/api/import/detect-duplicates', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
transactions: this.parsedTransactions
})
});
const result = await response.json();
if (result.success) {
this.duplicates = result.duplicates || [];
// Mark transactions as duplicates
this.parsedTransactions.forEach((trans, idx) => {
const isDuplicate = this.duplicates.some(d =>
d.transaction.date === trans.date &&
d.transaction.amount === trans.amount &&
d.transaction.description === trans.description
);
this.parsedTransactions[idx].is_duplicate = isDuplicate;
});
}
} catch (error) {
console.error('Failed to check duplicates:', error);
}
}
/**
* Render review step
*/
renderReviewStep() {
const duplicateCount = this.parsedTransactions.filter(t => t.is_duplicate).length;
const newCount = this.parsedTransactions.length - duplicateCount;
return `
<div>
<h2 class="text-2xl font-bold mb-4 text-text-main dark:text-white">
${window.getTranslation('import.reviewTitle', 'Review Transactions')}
</h2>
<!-- Summary -->
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400">${this.parsedTransactions.length}</div>
<div class="text-sm text-text-muted dark:text-[#92adc9]">${window.getTranslation('import.totalFound', 'Total Found')}</div>
</div>
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
<div class="text-2xl font-bold text-green-600 dark:text-green-400">${newCount}</div>
<div class="text-sm text-text-muted dark:text-[#92adc9]">${window.getTranslation('import.newTransactions', 'New')}</div>
</div>
<div class="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4">
<div class="text-2xl font-bold text-yellow-600 dark:text-yellow-400">${duplicateCount}</div>
<div class="text-sm text-text-muted dark:text-[#92adc9]">${window.getTranslation('import.duplicates', 'Duplicates')}</div>
</div>
</div>
<!-- Transactions List -->
<div class="mb-6 max-h-96 overflow-y-auto">
${this.parsedTransactions.map((trans, idx) => this.renderTransactionRow(trans, idx)).join('')}
</div>
<!-- Actions -->
<div class="flex justify-between">
<button onclick="csvImporter.goToStep(1)"
class="px-6 py-2 border border-border-light dark:border-[#233648] rounded-lg text-text-main dark:text-white hover:bg-slate-100 dark:hover:bg-[#111a22]">
${window.getTranslation('common.back', 'Back')}
</button>
<button onclick="csvImporter.goToStep(3)"
class="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90">
${window.getTranslation('import.nextMapCategories', 'Next: Map Categories')}
</button>
</div>
</div>
`;
}
/**
* Render a transaction row
*/
renderTransactionRow(trans, idx) {
const isDuplicate = trans.is_duplicate;
return `
<div class="flex items-center justify-between p-3 border-b border-border-light dark:border-[#233648] ${isDuplicate ? 'bg-yellow-50/50 dark:bg-yellow-900/10' : ''}">
<div class="flex items-center gap-4 flex-1">
<input type="checkbox"
id="trans_${idx}"
${isDuplicate ? '' : 'checked'}
onchange="csvImporter.toggleTransaction(${idx})"
class="w-5 h-5 rounded border-border-light dark:border-[#233648]">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-text-main dark:text-white">${trans.description}</span>
${isDuplicate ? '<span class="px-2 py-0.5 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 text-xs rounded-full">' + window.getTranslation('import.duplicate', 'Duplicate') + '</span>' : ''}
</div>
<div class="text-sm text-text-muted dark:text-[#92adc9]">${trans.date}</div>
</div>
</div>
<div class="font-semibold text-text-main dark:text-white">${window.formatCurrency(trans.amount, trans.currency || window.userCurrency || 'GBP')}</div>
</div>
`;
}
/**
* Check for missing categories and offer to create them
*/
async checkAndCreateCategories() {
const selectedTransactions = this.parsedTransactions.filter(t => {
const checkbox = document.getElementById(`trans_${this.parsedTransactions.indexOf(t)}`);
return !checkbox || checkbox.checked;
});
// Get unique bank categories (skip generic payment types)
const paymentTypes = ['pot transfer', 'card payment', 'direct debit', 'monzo_paid',
'faster payment', 'bacs (direct credit)', 'bacs', 'standing order'];
const bankCategories = new Set();
selectedTransactions.forEach(trans => {
if (trans.bank_category && trans.bank_category.trim()) {
const catLower = trans.bank_category.trim().toLowerCase();
// Skip if it's a generic payment type
if (!paymentTypes.includes(catLower)) {
bankCategories.add(trans.bank_category.trim());
}
}
});
if (bankCategories.size === 0) {
return; // No bank categories to create
}
// Find which categories don't exist
const existingCatNames = new Set(this.userCategories.map(c => c.name.toLowerCase()));
const missingCategories = Array.from(bankCategories).filter(
cat => !existingCatNames.has(cat.toLowerCase())
);
if (missingCategories.length > 0) {
// Show confirmation dialog
const confirmCreate = confirm(
window.getTranslation(
'import.createMissingCategories',
`Found ${missingCategories.length} new categories from your CSV:\n\n${missingCategories.join('\n')}\n\nWould you like to create these categories automatically?`
)
);
if (confirmCreate) {
try {
const response = await window.apiCall('/api/import/create-categories', {
method: 'POST',
body: JSON.stringify({
bank_categories: missingCategories
})
});
if (response.success) {
window.showToast(
window.getTranslation(
'import.categoriesCreated',
`Created ${response.created.length} new categories`
),
'success'
);
// Update category mapping with new categories
Object.assign(this.categoryMapping, response.mapping);
// Reload categories
await this.loadUserCategories();
this.renderImportUI();
this.setupEventListeners();
}
} catch (error) {
console.error('Failed to create categories:', error);
window.showToast(
window.getTranslation('import.errorCreatingCategories', 'Failed to create categories'),
'error'
);
}
}
}
}
/**
* Toggle transaction selection
*/
toggleTransaction(idx) {
const checkbox = document.getElementById(`trans_${idx}`);
this.parsedTransactions[idx].selected = checkbox.checked;
}
/**
* Render mapping step
*/
renderMappingStep() {
const selectedTransactions = this.parsedTransactions.filter(t => {
const checkbox = document.getElementById(`trans_${this.parsedTransactions.indexOf(t)}`);
return !checkbox || checkbox.checked;
});
// Get unique bank categories or descriptions for mapping (skip payment types)
const paymentTypes = ['pot transfer', 'card payment', 'direct debit', 'monzo_paid',
'faster payment', 'bacs (direct credit)', 'bacs', 'standing order'];
const needsMapping = new Set();
selectedTransactions.forEach(trans => {
if (trans.bank_category) {
const catLower = trans.bank_category.toLowerCase();
// Skip generic payment types
if (!paymentTypes.includes(catLower)) {
needsMapping.add(trans.bank_category);
}
}
});
return `
<div>
<h2 class="text-2xl font-bold mb-4 text-text-main dark:text-white">
${window.getTranslation('import.mapCategories', 'Map Categories')}
</h2>
<p class="text-text-muted dark:text-[#92adc9] mb-6">
${window.getTranslation('import.mapCategoriesDesc', 'Assign categories to your transactions')}
</p>
${needsMapping.size > 0 ? `
<div class="mb-6">
<h3 class="font-semibold mb-4 text-text-main dark:text-white">${window.getTranslation('import.bankCategoryMapping', 'Bank Category Mapping')}</h3>
${Array.from(needsMapping).map(bankCat => this.renderCategoryMapping(bankCat)).join('')}
</div>
` : ''}
<div class="mb-6">
<h3 class="font-semibold mb-4 text-text-main dark:text-white">${window.getTranslation('import.defaultCategory', 'Default Category')}</h3>
<select id="defaultCategory" class="w-full px-4 py-2 border border-border-light dark:border-[#233648] rounded-lg bg-white dark:bg-[#111a22] text-text-main dark:text-white">
${this.userCategories.map(cat => `
<option value="${cat.id}">${cat.name}</option>
`).join('')}
</select>
<p class="text-xs text-text-muted dark:text-[#92adc9] mt-2">
${window.getTranslation('import.defaultCategoryDesc', 'Used for transactions without bank category')}
</p>
</div>
<!-- Actions -->
<div class="flex justify-between">
<button onclick="csvImporter.goToStep(2)"
class="px-6 py-2 border border-border-light dark:border-[#233648] rounded-lg text-text-main dark:text-white hover:bg-slate-100 dark:hover:bg-[#111a22]">
${window.getTranslation('common.back', 'Back')}
</button>
<button onclick="csvImporter.startImport()"
class="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 flex items-center gap-2">
<span class="material-symbols-outlined text-[20px]">download</span>
${window.getTranslation('import.startImport', 'Import Transactions')}
</button>
</div>
</div>
`;
}
/**
* Render category mapping dropdown
*/
renderCategoryMapping(bankCategory) {
return `
<div class="mb-4 flex items-center gap-4">
<div class="flex-1">
<div class="font-medium text-text-main dark:text-white mb-1">${bankCategory}</div>
<div class="text-sm text-text-muted dark:text-[#92adc9]">${window.getTranslation('import.bankCategory', 'Bank Category')}</div>
</div>
<span class="text-text-muted"></span>
<select id="mapping_${bankCategory.replace(/[^a-zA-Z0-9]/g, '_')}"
onchange="csvImporter.setMapping('${bankCategory}', this.value)"
class="flex-1 px-4 py-2 border border-border-light dark:border-[#233648] rounded-lg bg-white dark:bg-[#111a22] text-text-main dark:text-white">
${this.userCategories.map(cat => `
<option value="${cat.id}">${cat.name}</option>
`).join('')}
</select>
</div>
`;
}
/**
* Set category mapping
*/
setMapping(bankCategory, categoryId) {
this.categoryMapping[bankCategory] = parseInt(categoryId);
}
/**
* Start import process
*/
async startImport() {
const selectedTransactions = this.parsedTransactions.filter((t, idx) => {
const checkbox = document.getElementById(`trans_${idx}`);
return !checkbox || checkbox.checked;
});
if (selectedTransactions.length === 0) {
window.showToast(window.getTranslation('import.noTransactionsSelected', 'No transactions selected'), 'error');
return;
}
// Show loading
this.currentStep = 4;
this.renderImportUI();
try {
const response = await window.apiCall('/api/import/import', {
method: 'POST',
body: JSON.stringify({
transactions: selectedTransactions,
category_mapping: this.categoryMapping,
skip_duplicates: true
})
});
if (response.success) {
this.renderImportComplete(response);
} else {
throw new Error(response.error || 'Import failed');
}
} catch (error) {
console.error('Import failed:', error);
window.showToast(error.message || window.getTranslation('import.errorImporting', 'Failed to import transactions'), 'error');
this.goToStep(3);
}
}
/**
* Render import complete step
*/
renderImportStep() {
return `
<div class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
<p class="text-text-muted dark:text-[#92adc9]">${window.getTranslation('import.importing', 'Importing transactions...')}</p>
</div>
`;
}
/**
* Render import complete
*/
renderImportComplete(result) {
const stepContent = document.getElementById('stepContent');
const hasErrors = result.errors && result.errors.length > 0;
stepContent.innerHTML = `
<div class="text-center">
<div class="inline-block w-20 h-20 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center mb-6">
<span class="material-symbols-outlined text-5xl text-green-600 dark:text-green-400">check_circle</span>
</div>
<h2 class="text-2xl font-bold mb-4 text-text-main dark:text-white">
${window.getTranslation('import.importComplete', 'Import Complete!')}
</h2>
<div class="grid grid-cols-3 gap-4 mb-8 max-w-2xl mx-auto">
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
<div class="text-2xl font-bold text-green-600 dark:text-green-400">${result.imported_count}</div>
<div class="text-sm text-text-muted dark:text-[#92adc9]">${window.getTranslation('import.imported', 'Imported')}</div>
</div>
<div class="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4">
<div class="text-2xl font-bold text-yellow-600 dark:text-yellow-400">${result.skipped_count}</div>
<div class="text-sm text-text-muted dark:text-[#92adc9]">${window.getTranslation('import.skipped', 'Skipped')}</div>
</div>
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4">
<div class="text-2xl font-bold text-red-600 dark:text-red-400">${result.error_count}</div>
<div class="text-sm text-text-muted dark:text-[#92adc9]">${window.getTranslation('import.errors', 'Errors')}</div>
</div>
</div>
${hasErrors ? `
<div class="mb-6 text-left max-w-2xl mx-auto">
<details class="bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-900/20 rounded-lg p-4">
<summary class="cursor-pointer font-semibold text-red-600 dark:text-red-400 mb-2">
${window.getTranslation('import.viewErrors', 'View Error Details')} (${result.error_count})
</summary>
<div class="mt-4 space-y-2 max-h-64 overflow-y-auto">
${result.errors.slice(0, 20).map((err, idx) => `
<div class="text-sm p-3 bg-white dark:bg-[#111a22] border border-red-100 dark:border-red-900/30 rounded">
<div class="font-medium text-text-main dark:text-white mb-1">
${err.transaction?.description || 'Transaction ' + (idx + 1)}
</div>
<div class="text-red-600 dark:text-red-400 text-xs">${err.error}</div>
</div>
`).join('')}
${result.errors.length > 20 ? `
<div class="text-sm text-text-muted dark:text-[#92adc9] italic p-2">
... and ${result.errors.length - 20} more errors
</div>
` : ''}
</div>
</details>
</div>
` : ''}
<div class="flex gap-4 justify-center">
<button onclick="window.location.href='/transactions'"
class="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90">
${window.getTranslation('import.viewTransactions', 'View Transactions')}
</button>
<button onclick="csvImporter.reset()"
class="px-6 py-2 border border-border-light dark:border-[#233648] rounded-lg text-text-main dark:text-white hover:bg-slate-100 dark:hover:bg-[#111a22]">
${window.getTranslation('import.importAnother', 'Import Another File')}
</button>
</div>
</div>
`;
}
/**
* Go to a specific step
*/
async goToStep(step) {
this.currentStep = step;
// If going to mapping step, check for missing categories
if (step === 3) {
await this.checkAndCreateCategories();
}
this.renderImportUI();
this.setupEventListeners();
}
/**
* Reset importer
*/
reset() {
this.parsedTransactions = [];
this.duplicates = [];
this.categoryMapping = {};
this.currentStep = 1;
this.renderImportUI();
}
}
// Create global instance
window.csvImporter = new CSVImporter();
// Initialize on import page
if (window.location.pathname === '/import' || window.location.pathname.includes('import')) {
document.addEventListener('DOMContentLoaded', () => {
window.csvImporter.init();
});
}

425
app/static/js/income.js Normal file
View file

@ -0,0 +1,425 @@
// Income Management JavaScript
let incomeData = [];
let incomeSources = [];
let currentIncomeId = null;
// Helper function for notifications
function showNotification(message, type = 'success') {
if (typeof showToast === 'function') {
showToast(message, type);
} else {
console.log(`${type.toUpperCase()}: ${message}`);
}
}
// Load user currency from profile
async function loadUserCurrency() {
try {
const profile = await apiCall('/api/settings/profile');
window.userCurrency = profile.profile.currency || 'GBP';
} catch (error) {
console.error('Failed to load user currency:', error);
// Fallback to GBP if API fails
window.userCurrency = 'GBP';
}
}
// Load income data
async function loadIncome() {
try {
console.log('Loading income data...');
const response = await apiCall('/api/income/');
console.log('Income API response:', response);
console.log('Response has income?', response.income);
console.log('Income array:', response.income);
if (response.income) {
incomeData = response.income;
console.log('Income data loaded:', incomeData.length, 'entries');
console.log('Full income data:', JSON.stringify(incomeData, null, 2));
renderIncomeTable();
} else {
console.warn('No income data in response');
incomeData = [];
renderIncomeTable();
}
} catch (error) {
console.error('Error loading income:', error);
showNotification(window.getTranslation('common.error', 'An error occurred'), 'error');
}
}
// Load income sources
async function loadIncomeSources() {
try {
const response = await apiCall('/api/income/sources');
if (response.sources) {
incomeSources = response.sources;
renderIncomeSourceOptions();
}
} catch (error) {
console.error('Error loading income sources:', error);
}
}
// Render income source options in select
function renderIncomeSourceOptions() {
const selects = document.querySelectorAll('.income-source-select');
selects.forEach(select => {
select.innerHTML = '<option value="">' + window.getTranslation('form.selectSource', 'Select source...') + '</option>';
incomeSources.forEach(source => {
select.innerHTML += `<option value="${source.value}">${source.label}</option>`;
});
});
}
// Render income table
function renderIncomeTable() {
console.log('Rendering income table with', incomeData.length, 'entries');
const tbody = document.getElementById('income-table-body');
if (!tbody) {
console.error('Income table body not found!');
return;
}
if (incomeData.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="5" class="px-6 py-12 text-center">
<span class="material-symbols-outlined text-6xl text-text-muted dark:text-[#92adc9] mb-4 block">payments</span>
<p class="text-text-muted dark:text-[#92adc9]" data-translate="income.noIncome">${window.getTranslation('income.noIncome', 'No income entries yet')}</p>
<p class="text-sm text-text-muted dark:text-[#92adc9] mt-2" data-translate="income.addFirst">${window.getTranslation('income.addFirst', 'Add your first income entry')}</p>
</td>
</tr>
`;
return;
}
tbody.innerHTML = incomeData.map(income => {
const date = new Date(income.date);
const formattedDate = formatDate(income.date);
const source = incomeSources.find(s => s.value === income.source);
const sourceLabel = source ? source.label : income.source;
const sourceIcon = source ? source.icon : 'category';
// Check if this is recurring income
const isRecurring = income.is_recurring;
const nextDueDate = income.next_due_date ? formatDate(income.next_due_date) : null;
const isActive = income.is_active;
const autoCreate = income.auto_create;
// Build recurring info badge
let recurringBadge = '';
if (isRecurring && autoCreate) {
const statusColor = isActive ? 'green' : 'gray';
const statusIcon = isActive ? 'check_circle' : 'pause_circle';
recurringBadge = `
<div class="flex items-center gap-1 text-xs text-${statusColor}-600 dark:text-${statusColor}-400">
<span class="material-symbols-outlined text-[14px]">${statusIcon}</span>
<span>${income.frequency}</span>
${nextDueDate ? `<span class="text-text-muted dark:text-[#92adc9]">• Next: ${nextDueDate}</span>` : ''}
</div>
`;
}
// Build action buttons
let actionButtons = `
<button onclick="editIncome(${income.id})" class="p-2 text-primary hover:bg-primary/10 rounded-lg transition-colors">
<span class="material-symbols-outlined text-[20px]">edit</span>
</button>
`;
if (isRecurring && autoCreate) {
actionButtons += `
<button onclick="toggleRecurringIncome(${income.id})" class="p-2 text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-500/10 rounded-lg transition-colors" title="${isActive ? 'Pause' : 'Activate'}">
<span class="material-symbols-outlined text-[20px]">${isActive ? 'pause' : 'play_arrow'}</span>
</button>
<button onclick="createIncomeNow(${income.id})" class="p-2 text-green-500 hover:bg-green-50 dark:hover:bg-green-500/10 rounded-lg transition-colors" title="Create Now">
<span class="material-symbols-outlined text-[20px]">add_circle</span>
</button>
`;
}
actionButtons += `
<button onclick="deleteIncome(${income.id})" class="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-lg transition-colors">
<span class="material-symbols-outlined text-[20px]">delete</span>
</button>
`;
return `
<tr class="border-b border-border-light dark:border-[#233648] hover:bg-slate-50 dark:hover:bg-[#111a22] transition-colors">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center">
<span class="material-symbols-outlined text-green-500 text-[20px]">${sourceIcon}</span>
</div>
<div>
<p class="font-medium text-text-main dark:text-white">${income.description}</p>
<p class="text-sm text-text-muted dark:text-[#92adc9]">${sourceLabel}</p>
${recurringBadge}
</div>
</div>
</td>
<td class="px-6 py-4 text-text-main dark:text-white">${formattedDate}</td>
<td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-500/20 dark:text-green-400">
${sourceLabel}
</span>
</td>
<td class="px-6 py-4 text-right">
<span class="font-semibold text-green-600 dark:text-green-400">
+${formatCurrency(income.amount, income.currency)}
</span>
</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end gap-2">
${actionButtons}
</div>
</td>
</tr>
`;
}).join('');
console.log('Income table rendered successfully');
}
// Open income modal
function openIncomeModal() {
const modal = document.getElementById('income-modal');
const form = document.getElementById('income-form');
const title = document.getElementById('income-modal-title');
currentIncomeId = null;
form.reset();
title.textContent = window.getTranslation('income.add', 'Add Income');
modal.classList.remove('hidden');
// Set today's date as default
const dateInput = document.getElementById('income-date');
if (dateInput) {
dateInput.valueAsDate = new Date();
}
}
// Close income modal
function closeIncomeModal() {
const modal = document.getElementById('income-modal');
modal.classList.add('hidden');
currentIncomeId = null;
}
// Edit income
function editIncome(id) {
const income = incomeData.find(i => i.id === id);
if (!income) return;
currentIncomeId = id;
const modal = document.getElementById('income-modal');
const form = document.getElementById('income-form');
const title = document.getElementById('income-modal-title');
title.textContent = window.getTranslation('income.edit', 'Edit Income');
document.getElementById('income-amount').value = income.amount;
document.getElementById('income-source').value = income.source;
document.getElementById('income-description').value = income.description;
document.getElementById('income-date').value = income.date.split('T')[0];
document.getElementById('income-tags').value = income.tags.join(', ');
document.getElementById('income-frequency').value = income.frequency || 'once';
// Show/hide custom frequency based on frequency value
const customContainer = document.getElementById('custom-frequency-container');
if (income.frequency === 'custom') {
customContainer.classList.remove('hidden');
document.getElementById('income-custom-days').value = income.custom_days || '';
} else {
customContainer.classList.add('hidden');
}
// Set auto_create checkbox
const autoCreateCheckbox = document.getElementById('income-auto-create');
if (autoCreateCheckbox) {
autoCreateCheckbox.checked = income.auto_create || false;
}
modal.classList.remove('hidden');
}
// Save income
async function saveIncome(event) {
event.preventDefault();
console.log('Saving income...');
const amount = document.getElementById('income-amount').value;
const source = document.getElementById('income-source').value;
const description = document.getElementById('income-description').value;
const date = document.getElementById('income-date').value;
const tagsInput = document.getElementById('income-tags').value;
const frequency = document.getElementById('income-frequency').value;
const customDays = document.getElementById('income-custom-days').value;
const autoCreate = document.getElementById('income-auto-create')?.checked || false;
if (!amount || !source || !description) {
showNotification(window.getTranslation('common.missingFields', 'Missing required fields'), 'error');
return;
}
// Validate custom frequency
if (frequency === 'custom' && (!customDays || customDays < 1)) {
showNotification(window.getTranslation('income.customDaysRequired', 'Please enter a valid number of days for custom frequency'), 'error');
return;
}
const tags = tagsInput ? tagsInput.split(',').map(t => t.trim()).filter(t => t) : [];
const data = {
amount: parseFloat(amount),
source: source,
description: description,
date: date,
tags: tags,
currency: window.userCurrency,
frequency: frequency,
custom_days: frequency === 'custom' ? parseInt(customDays) : null,
auto_create: autoCreate
};
console.log('Income data to save:', data);
try {
let response;
if (currentIncomeId) {
console.log('Updating income:', currentIncomeId);
response = await apiCall(`/api/income/${currentIncomeId}`, {
method: 'PUT',
body: JSON.stringify(data)
});
showNotification(window.getTranslation('income.updated', 'Income updated successfully'), 'success');
} else {
console.log('Creating new income');
response = await apiCall('/api/income/', {
method: 'POST',
body: JSON.stringify(data)
});
console.log('Income created response:', response);
showNotification(window.getTranslation('income.created', 'Income added successfully'), 'success');
}
closeIncomeModal();
console.log('Reloading income list...');
await loadIncome();
// Reload dashboard if on dashboard page
if (typeof loadDashboardData === 'function') {
loadDashboardData();
}
} catch (error) {
console.error('Error saving income:', error);
showNotification(window.getTranslation('common.error', 'An error occurred'), 'error');
}
}
// Delete income
async function deleteIncome(id) {
if (!confirm(window.getTranslation('income.deleteConfirm', 'Are you sure you want to delete this income entry?'))) {
return;
}
try {
await apiCall(`/api/income/${id}`, {
method: 'DELETE'
});
showNotification(window.getTranslation('income.deleted', 'Income deleted successfully'), 'success');
loadIncome();
// Reload dashboard if on dashboard page
if (typeof loadDashboardData === 'function') {
loadDashboardData();
}
} catch (error) {
console.error('Error deleting income:', error);
showNotification(window.getTranslation('common.error', 'An error occurred'), 'error');
}
}
// Toggle recurring income active status
async function toggleRecurringIncome(id) {
try {
const response = await apiCall(`/api/income/${id}/toggle`, {
method: 'PUT'
});
if (response.success) {
showNotification(response.message, 'success');
loadIncome();
}
} catch (error) {
console.error('Error toggling recurring income:', error);
showNotification(window.getTranslation('common.error', 'An error occurred'), 'error');
}
}
// Create income now from recurring income
async function createIncomeNow(id) {
if (!confirm(window.getTranslation('income.createNowConfirm', 'Create an income entry now from this recurring income?'))) {
return;
}
try {
const response = await apiCall(`/api/income/${id}/create-now`, {
method: 'POST'
});
if (response.success) {
showNotification(response.message, 'success');
loadIncome();
// Reload dashboard if on dashboard page
if (typeof loadDashboardData === 'function') {
loadDashboardData();
}
}
} catch (error) {
console.error('Error creating income:', error);
showNotification(window.getTranslation('common.error', 'An error occurred'), 'error');
}
}
// Initialize income page
document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('income-table-body')) {
loadUserCurrency(); // Load currency first
loadIncome();
loadIncomeSources();
// Setup form submit
const form = document.getElementById('income-form');
if (form) {
form.addEventListener('submit', saveIncome);
}
// Setup frequency change handler
const frequencySelect = document.getElementById('income-frequency');
if (frequencySelect) {
frequencySelect.addEventListener('change', (e) => {
const customContainer = document.getElementById('custom-frequency-container');
if (customContainer) {
if (e.target.value === 'custom') {
customContainer.classList.remove('hidden');
} else {
customContainer.classList.add('hidden');
}
}
});
}
}
});
// Make functions global
window.openIncomeModal = openIncomeModal;
window.closeIncomeModal = closeIncomeModal;
window.editIncome = editIncome;
window.deleteIncome = deleteIncome;
window.saveIncome = saveIncome;
window.toggleRecurringIncome = toggleRecurringIncome;
window.createIncomeNow = createIncomeNow;

View file

@ -0,0 +1,264 @@
/**
* Budget Notifications Module
* Handles PWA push notifications for budget alerts
*/
class BudgetNotifications {
constructor() {
this.notificationPermission = 'default';
this.checkPermission();
}
/**
* Check current notification permission status
*/
checkPermission() {
if ('Notification' in window) {
this.notificationPermission = Notification.permission;
}
}
/**
* Request notification permission from user
*/
async requestPermission() {
if (!('Notification' in window)) {
console.warn('This browser does not support notifications');
return false;
}
if (this.notificationPermission === 'granted') {
return true;
}
try {
const permission = await Notification.requestPermission();
this.notificationPermission = permission;
if (permission === 'granted') {
// Store permission preference
localStorage.setItem('budgetNotificationsEnabled', 'true');
return true;
}
return false;
} catch (error) {
console.error('Error requesting notification permission:', error);
return false;
}
}
/**
* Show a budget alert notification
*/
async showBudgetAlert(alert) {
if (this.notificationPermission !== 'granted') {
return;
}
try {
const icon = '/static/icons/icon-192x192.png';
const badge = '/static/icons/icon-72x72.png';
let title = '';
let body = '';
let tag = `budget-alert-${alert.type}`;
switch (alert.type) {
case 'category':
title = window.getTranslation('budget.categoryAlert');
body = window.getTranslation('budget.categoryAlertMessage')
.replace('{category}', alert.category_name)
.replace('{percentage}', alert.percentage.toFixed(0));
tag = `budget-category-${alert.category_id}`;
break;
case 'overall':
title = window.getTranslation('budget.overallAlert');
body = window.getTranslation('budget.overallAlertMessage')
.replace('{percentage}', alert.percentage.toFixed(0));
break;
case 'exceeded':
title = window.getTranslation('budget.exceededAlert');
body = window.getTranslation('budget.exceededAlertMessage')
.replace('{category}', alert.category_name);
tag = `budget-exceeded-${alert.category_id}`;
break;
}
const options = {
body: body,
icon: icon,
badge: badge,
tag: tag, // Prevents duplicate notifications
renotify: true,
requireInteraction: alert.level === 'danger' || alert.level === 'exceeded',
data: {
url: alert.type === 'overall' ? '/dashboard' : '/transactions',
categoryId: alert.category_id
}
};
// Use service worker for better notification handling
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.ready.then(registration => {
registration.showNotification(title, options);
});
} else {
// Fallback to regular notification
const notification = new Notification(title, options);
notification.onclick = function(event) {
event.preventDefault();
window.focus();
if (options.data.url) {
window.location.href = options.data.url;
}
notification.close();
};
}
} catch (error) {
console.error('Error showing notification:', error);
}
}
/**
* Show weekly spending summary notification
*/
async showWeeklySummary(summary) {
if (this.notificationPermission !== 'granted') {
return;
}
try {
const icon = '/static/icons/icon-192x192.png';
const badge = '/static/icons/icon-72x72.png';
const title = window.getTranslation('budget.weeklySummary');
const spent = window.formatCurrency(summary.current_week_spent);
const change = summary.percentage_change > 0 ? '+' : '';
const changeText = `${change}${summary.percentage_change.toFixed(0)}%`;
const body = window.getTranslation('budget.weeklySummaryMessage')
.replace('{spent}', spent)
.replace('{change}', changeText)
.replace('{category}', summary.top_category);
const options = {
body: body,
icon: icon,
badge: badge,
tag: 'weekly-summary',
data: {
url: '/reports'
}
};
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.ready.then(registration => {
registration.showNotification(title, options);
});
} else {
const notification = new Notification(title, options);
notification.onclick = function(event) {
event.preventDefault();
window.focus();
window.location.href = '/reports';
notification.close();
};
}
} catch (error) {
console.error('Error showing weekly summary:', error);
}
}
/**
* Check if notifications are enabled in settings
*/
isEnabled() {
return localStorage.getItem('budgetNotificationsEnabled') === 'true';
}
/**
* Enable/disable budget notifications
*/
async setEnabled(enabled) {
if (enabled) {
const granted = await this.requestPermission();
if (granted) {
localStorage.setItem('budgetNotificationsEnabled', 'true');
return true;
}
return false;
} else {
localStorage.setItem('budgetNotificationsEnabled', 'false');
return true;
}
}
}
// Create global instance
window.budgetNotifications = new BudgetNotifications();
/**
* Check budget status and show alerts if needed
*/
async function checkBudgetAlerts() {
if (!window.budgetNotifications.isEnabled()) {
return;
}
try {
const data = await window.apiCall('/api/budget/status', 'GET');
if (data.active_alerts && data.active_alerts.length > 0) {
// Show only the most severe alert to avoid spam
const mostSevereAlert = data.active_alerts[0];
await window.budgetNotifications.showBudgetAlert(mostSevereAlert);
}
} catch (error) {
console.error('Error checking budget alerts:', error);
}
}
/**
* Check if it's time to show weekly summary
* Shows on Monday morning if not shown this week
*/
async function checkWeeklySummary() {
if (!window.budgetNotifications.isEnabled()) {
return;
}
const lastShown = localStorage.getItem('lastWeeklySummaryShown');
const now = new Date();
const dayOfWeek = now.getDay(); // 0 = Sunday, 1 = Monday
// Show on Monday (1) between 9 AM and 11 AM
if (dayOfWeek === 1 && now.getHours() >= 9 && now.getHours() < 11) {
const today = now.toDateString();
if (lastShown !== today) {
try {
const data = await window.apiCall('/api/budget/weekly-summary', 'GET');
await window.budgetNotifications.showWeeklySummary(data);
localStorage.setItem('lastWeeklySummaryShown', today);
} catch (error) {
console.error('Error showing weekly summary:', error);
}
}
}
}
// Check budget alerts every 30 minutes
if (window.budgetNotifications.isEnabled()) {
setInterval(checkBudgetAlerts, 30 * 60 * 1000);
// Check immediately on load
setTimeout(checkBudgetAlerts, 5000);
}
// Check weekly summary once per hour
setInterval(checkWeeklySummary, 60 * 60 * 1000);
setTimeout(checkWeeklySummary, 10000);

54
app/static/js/pwa.js Normal file
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');
});

499
app/static/js/recurring.js Normal file
View file

@ -0,0 +1,499 @@
// Recurring expenses page JavaScript
let currentRecurring = [];
let detectedSuggestions = [];
// Load user profile to get currency
async function loadUserCurrency() {
try {
const profile = await apiCall('/api/settings/profile');
window.userCurrency = profile.profile.currency || 'GBP';
} catch (error) {
console.error('Failed to load user currency:', error);
window.userCurrency = 'GBP';
}
}
// Load recurring expenses
async function loadRecurringExpenses() {
try {
const data = await apiCall('/api/recurring/');
currentRecurring = data.recurring_expenses || [];
displayRecurringExpenses(currentRecurring);
} catch (error) {
console.error('Failed to load recurring expenses:', error);
showToast(window.getTranslation('recurring.errorLoading', 'Failed to load recurring expenses'), 'error');
}
}
// Display recurring expenses
function displayRecurringExpenses(recurring) {
const container = document.getElementById('recurring-list');
if (!recurring || recurring.length === 0) {
const noRecurringText = window.getTranslation('recurring.noRecurring', 'No recurring expenses yet');
const addFirstText = window.getTranslation('recurring.addFirst', 'Add your first recurring expense or detect patterns from existing expenses');
container.innerHTML = `
<div class="p-12 text-center">
<span class="material-symbols-outlined text-6xl text-[#92adc9] mb-4 block">repeat</span>
<p class="text-[#92adc9] text-lg mb-2">${noRecurringText}</p>
<p class="text-[#92adc9] text-sm">${addFirstText}</p>
</div>
`;
return;
}
// Group by active status
const active = recurring.filter(r => r.is_active);
const inactive = recurring.filter(r => !r.is_active);
let html = '';
if (active.length > 0) {
html += '<div class="mb-6"><h3 class="text-lg font-semibold text-text-main dark:text-white mb-4">' +
window.getTranslation('recurring.active', 'Active Recurring Expenses') + '</h3>';
html += '<div class="space-y-3">' + active.map(r => renderRecurringCard(r)).join('') + '</div></div>';
}
if (inactive.length > 0) {
html += '<div><h3 class="text-lg font-semibold text-text-muted dark:text-[#92adc9] mb-4">' +
window.getTranslation('recurring.inactive', 'Inactive') + '</h3>';
html += '<div class="space-y-3 opacity-60">' + inactive.map(r => renderRecurringCard(r)).join('') + '</div></div>';
}
container.innerHTML = html;
}
// Render individual recurring expense card
function renderRecurringCard(recurring) {
const nextDue = new Date(recurring.next_due_date);
const today = new Date();
const daysUntil = Math.ceil((nextDue - today) / (1000 * 60 * 60 * 24));
let dueDateClass = 'text-text-muted dark:text-[#92adc9]';
let dueDateText = '';
if (daysUntil < 0) {
dueDateClass = 'text-red-400';
dueDateText = window.getTranslation('recurring.overdue', 'Overdue');
} else if (daysUntil === 0) {
dueDateClass = 'text-orange-400';
dueDateText = window.getTranslation('recurring.dueToday', 'Due today');
} else if (daysUntil <= 7) {
dueDateClass = 'text-yellow-400';
dueDateText = window.getTranslation('recurring.dueIn', 'Due in') + ` ${daysUntil} ` +
(daysUntil === 1 ? window.getTranslation('recurring.day', 'day') : window.getTranslation('recurring.days', 'days'));
} else {
dueDateText = nextDue.toLocaleDateString();
}
const frequencyText = window.getTranslation(`recurring.frequency.${recurring.frequency}`, recurring.frequency);
const autoCreateBadge = recurring.auto_create ?
`<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-green-500/10 text-green-400 border border-green-500/20">
<span class="material-symbols-outlined text-[14px]">check_circle</span>
${window.getTranslation('recurring.autoCreate', 'Auto-create')}
</span>` : '';
const detectedBadge = recurring.detected ?
`<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-blue-500/10 text-blue-400 border border-blue-500/20">
<span class="material-symbols-outlined text-[14px]">auto_awesome</span>
${window.getTranslation('recurring.detected', 'Auto-detected')} ${Math.round(recurring.confidence_score)}%
</span>` : '';
return `
<div class="bg-white dark:bg-[#0f1419] border border-gray-200 dark:border-white/10 rounded-xl p-5 hover:shadow-lg transition-shadow">
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4 flex-1">
<div class="size-12 rounded-full flex items-center justify-center shrink-0" style="background: ${recurring.category_color}20;">
<span class="material-symbols-outlined text-[24px]" style="color: ${recurring.category_color};">repeat</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<h4 class="text-text-main dark:text-white font-semibold truncate">${recurring.name}</h4>
${autoCreateBadge}
${detectedBadge}
</div>
<div class="flex flex-wrap items-center gap-2 text-sm text-text-muted dark:text-[#92adc9] mb-2">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs" style="background: ${recurring.category_color}20; color: ${recurring.category_color};">
${recurring.category_name}
</span>
<span></span>
<span>${frequencyText}</span>
${recurring.notes ? `<span>•</span><span class="truncate">${recurring.notes}</span>` : ''}
</div>
<div class="flex items-center gap-3 text-sm flex-wrap">
<div class="${dueDateClass} font-medium">
<span class="material-symbols-outlined text-[16px] align-middle mr-1">schedule</span>
${dueDateText}
</div>
<div class="text-text-main dark:text-white font-semibold">
${formatCurrency(recurring.amount, window.userCurrency || recurring.currency)}
</div>
${recurring.last_created_date ? `
<div class="text-text-muted dark:text-[#92adc9] text-xs">
<span class="material-symbols-outlined text-[14px] align-middle mr-1">check_circle</span>
${window.getTranslation('recurring.lastCreated', 'Last created')}: ${new Date(recurring.last_created_date).toLocaleDateString()}
</div>
` : ''}
</div>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
${daysUntil <= 7 && recurring.is_active ? `
<button onclick="createExpenseFromRecurring(${recurring.id})"
class="p-2 rounded-lg hover:bg-green-500/10 text-green-400 transition-colors"
title="${window.getTranslation('recurring.createExpense', 'Create expense now')}">
<span class="material-symbols-outlined text-[20px]">add_circle</span>
</button>
` : ''}
<button onclick="editRecurring(${recurring.id})"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10 text-text-muted dark:text-[#92adc9] transition-colors"
title="${window.getTranslation('common.edit', 'Edit')}">
<span class="material-symbols-outlined text-[20px]">edit</span>
</button>
<button onclick="toggleRecurringActive(${recurring.id}, ${!recurring.is_active})"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10 text-text-muted dark:text-[#92adc9] transition-colors"
title="${recurring.is_active ? window.getTranslation('recurring.deactivate', 'Deactivate') : window.getTranslation('recurring.activate', 'Activate')}">
<span class="material-symbols-outlined text-[20px]">${recurring.is_active ? 'pause_circle' : 'play_circle'}</span>
</button>
<button onclick="deleteRecurring(${recurring.id})"
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-500/10 text-red-400 transition-colors"
title="${window.getTranslation('common.delete', 'Delete')}">
<span class="material-symbols-outlined text-[20px]">delete</span>
</button>
</div>
</div>
</div>
`;
}
// Create expense from recurring
async function createExpenseFromRecurring(recurringId) {
try {
const data = await apiCall(`/api/recurring/${recurringId}/create-expense`, {
method: 'POST'
});
showToast(window.getTranslation('recurring.expenseCreated', 'Expense created successfully!'), 'success');
loadRecurringExpenses();
} catch (error) {
console.error('Failed to create expense:', error);
showToast(window.getTranslation('recurring.errorCreating', 'Failed to create expense'), 'error');
}
}
// Toggle recurring active status
async function toggleRecurringActive(recurringId, isActive) {
try {
await apiCall(`/api/recurring/${recurringId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: isActive })
});
const statusText = isActive ?
window.getTranslation('recurring.activated', 'Recurring expense activated') :
window.getTranslation('recurring.deactivated', 'Recurring expense deactivated');
showToast(statusText, 'success');
loadRecurringExpenses();
} catch (error) {
console.error('Failed to toggle recurring status:', error);
showToast(window.getTranslation('common.error', 'An error occurred'), 'error');
}
}
// Delete recurring expense
async function deleteRecurring(recurringId) {
const confirmText = window.getTranslation('recurring.deleteConfirm', 'Are you sure you want to delete this recurring expense?');
if (!confirm(confirmText)) return;
try {
await apiCall(`/api/recurring/${recurringId}`, {
method: 'DELETE'
});
showToast(window.getTranslation('recurring.deleted', 'Recurring expense deleted'), 'success');
loadRecurringExpenses();
} catch (error) {
console.error('Failed to delete recurring expense:', error);
showToast(window.getTranslation('common.error', 'An error occurred'), 'error');
}
}
// Edit recurring expense
function editRecurring(recurringId) {
const recurring = currentRecurring.find(r => r.id === recurringId);
if (!recurring) return;
// Populate form
document.getElementById('recurring-id').value = recurring.id;
document.getElementById('recurring-name').value = recurring.name;
document.getElementById('recurring-amount').value = recurring.amount;
document.getElementById('recurring-category').value = recurring.category_id;
document.getElementById('recurring-frequency').value = recurring.frequency;
document.getElementById('recurring-day').value = recurring.day_of_period || '';
document.getElementById('recurring-next-due').value = recurring.next_due_date.split('T')[0];
document.getElementById('recurring-auto-create').checked = recurring.auto_create;
document.getElementById('recurring-notes').value = recurring.notes || '';
// Update modal title
document.getElementById('modal-title').textContent = window.getTranslation('recurring.edit', 'Edit Recurring Expense');
document.getElementById('recurring-submit-btn').textContent = window.getTranslation('actions.update', 'Update');
// Show modal
document.getElementById('add-recurring-modal').classList.remove('hidden');
}
// Show add recurring modal
function showAddRecurringModal() {
document.getElementById('recurring-form').reset();
document.getElementById('recurring-id').value = '';
document.getElementById('modal-title').textContent = window.getTranslation('recurring.add', 'Add Recurring Expense');
document.getElementById('recurring-submit-btn').textContent = window.getTranslation('actions.save', 'Save');
document.getElementById('add-recurring-modal').classList.remove('hidden');
}
// Close modal
function closeRecurringModal() {
document.getElementById('add-recurring-modal').classList.add('hidden');
}
// Save recurring expense
async function saveRecurringExpense(event) {
event.preventDefault();
const recurringId = document.getElementById('recurring-id').value;
const formData = {
name: document.getElementById('recurring-name').value,
amount: parseFloat(document.getElementById('recurring-amount').value),
// Don't send currency - let backend use current_user.currency from settings
category_id: parseInt(document.getElementById('recurring-category').value),
frequency: document.getElementById('recurring-frequency').value,
day_of_period: parseInt(document.getElementById('recurring-day').value) || null,
next_due_date: document.getElementById('recurring-next-due').value,
auto_create: document.getElementById('recurring-auto-create').checked,
notes: document.getElementById('recurring-notes').value
};
try {
if (recurringId) {
// Update
await apiCall(`/api/recurring/${recurringId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
showToast(window.getTranslation('recurring.updated', 'Recurring expense updated'), 'success');
} else {
// Create
await apiCall('/api/recurring/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
showToast(window.getTranslation('recurring.created', 'Recurring expense created'), 'success');
}
closeRecurringModal();
loadRecurringExpenses();
} catch (error) {
console.error('Failed to save recurring expense:', error);
showToast(window.getTranslation('common.error', 'An error occurred'), 'error');
}
}
// Detect recurring patterns
async function detectRecurringPatterns() {
const detectBtn = document.getElementById('detect-btn');
const originalText = detectBtn.innerHTML;
detectBtn.innerHTML = '<span class="material-symbols-outlined animate-spin">refresh</span> ' +
window.getTranslation('recurring.detecting', 'Detecting...');
detectBtn.disabled = true;
try {
const data = await apiCall('/api/recurring/detect', {
method: 'POST'
});
detectedSuggestions = data.suggestions || [];
if (detectedSuggestions.length === 0) {
showToast(window.getTranslation('recurring.noPatterns', 'No recurring patterns detected'), 'info');
} else {
displaySuggestions(detectedSuggestions);
document.getElementById('suggestions-section').classList.remove('hidden');
showToast(window.getTranslation('recurring.patternsFound', `Found ${detectedSuggestions.length} potential recurring expenses`), 'success');
}
} catch (error) {
console.error('Failed to detect patterns:', error);
showToast(window.getTranslation('recurring.errorDetecting', 'Failed to detect patterns'), 'error');
} finally {
detectBtn.innerHTML = originalText;
detectBtn.disabled = false;
}
}
// Display suggestions
function displaySuggestions(suggestions) {
const container = document.getElementById('suggestions-list');
container.innerHTML = suggestions.map((s, index) => `
<div class="bg-white dark:bg-[#0f1419] border border-blue-500/30 dark:border-blue-500/30 rounded-xl p-5">
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4 flex-1">
<div class="size-12 rounded-full flex items-center justify-center shrink-0 bg-blue-500/10">
<span class="material-symbols-outlined text-[24px] text-blue-400">auto_awesome</span>
</div>
<div class="flex-1 min-w-0">
<h4 class="text-text-main dark:text-white font-semibold mb-1">${s.name}</h4>
<div class="flex flex-wrap items-center gap-2 text-sm text-text-muted dark:text-[#92adc9] mb-2">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs" style="background: ${s.category_color}20; color: ${s.category_color};">
${s.category_name}
</span>
<span></span>
<span>${window.getTranslation(`recurring.frequency.${s.frequency}`, s.frequency)}</span>
<span></span>
<span>${s.occurrences} ${window.getTranslation('recurring.occurrences', 'occurrences')}</span>
</div>
<div class="flex items-center gap-3 text-sm">
<div class="text-text-main dark:text-white font-semibold">
${formatCurrency(s.amount, window.userCurrency || s.currency)}
</div>
<div class="text-blue-400">
<span class="material-symbols-outlined text-[16px] align-middle mr-1">verified</span>
${Math.round(s.confidence_score)}% ${window.getTranslation('recurring.confidence', 'confidence')}
</div>
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<button onclick="acceptSuggestion(${index})"
class="px-4 py-2 bg-primary hover:bg-primary-dark text-white rounded-lg transition-colors flex items-center gap-2">
<span class="material-symbols-outlined text-[18px]">add</span>
${window.getTranslation('recurring.accept', 'Accept')}
</button>
<button onclick="dismissSuggestion(${index})"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10 text-text-muted dark:text-[#92adc9] transition-colors"
title="${window.getTranslation('recurring.dismiss', 'Dismiss')}">
<span class="material-symbols-outlined text-[20px]">close</span>
</button>
</div>
</div>
</div>
`).join('');
}
// Accept suggestion
async function acceptSuggestion(index) {
const suggestion = detectedSuggestions[index];
try {
await apiCall('/api/recurring/accept-suggestion', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(suggestion)
});
showToast(window.getTranslation('recurring.suggestionAccepted', 'Recurring expense added'), 'success');
// Remove suggestion
detectedSuggestions.splice(index, 1);
if (detectedSuggestions.length === 0) {
document.getElementById('suggestions-section').classList.add('hidden');
} else {
displaySuggestions(detectedSuggestions);
}
loadRecurringExpenses();
} catch (error) {
console.error('Failed to accept suggestion:', error);
showToast(window.getTranslation('common.error', 'An error occurred'), 'error');
}
}
// Dismiss suggestion
function dismissSuggestion(index) {
detectedSuggestions.splice(index, 1);
if (detectedSuggestions.length === 0) {
document.getElementById('suggestions-section').classList.add('hidden');
} else {
displaySuggestions(detectedSuggestions);
}
}
// Load categories for dropdown
async function loadCategories() {
try {
const data = await apiCall('/api/expenses/categories');
const select = document.getElementById('recurring-category');
select.innerHTML = data.categories.map(cat =>
`<option value="${cat.id}">${cat.name}</option>`
).join('');
} catch (error) {
console.error('Failed to load categories:', error);
}
}
// Update day field based on frequency
function updateDayField() {
const frequency = document.getElementById('recurring-frequency').value;
const dayContainer = document.getElementById('day-container');
const dayInput = document.getElementById('recurring-day');
const dayLabel = document.getElementById('day-label');
if (frequency === 'weekly') {
dayContainer.classList.remove('hidden');
dayLabel.textContent = window.getTranslation('recurring.dayOfWeek', 'Day of week');
dayInput.type = 'select';
dayInput.innerHTML = `
<option value="0">${window.getTranslation('days.monday', 'Monday')}</option>
<option value="1">${window.getTranslation('days.tuesday', 'Tuesday')}</option>
<option value="2">${window.getTranslation('days.wednesday', 'Wednesday')}</option>
<option value="3">${window.getTranslation('days.thursday', 'Thursday')}</option>
<option value="4">${window.getTranslation('days.friday', 'Friday')}</option>
<option value="5">${window.getTranslation('days.saturday', 'Saturday')}</option>
<option value="6">${window.getTranslation('days.sunday', 'Sunday')}</option>
`;
} else if (frequency === 'monthly') {
dayContainer.classList.remove('hidden');
dayLabel.textContent = window.getTranslation('recurring.dayOfMonth', 'Day of month');
dayInput.type = 'number';
dayInput.min = '1';
dayInput.max = '28';
} else {
dayContainer.classList.add('hidden');
}
}
// Initialize
document.addEventListener('DOMContentLoaded', async () => {
if (document.getElementById('recurring-list')) {
await loadUserCurrency();
// Sync all recurring expenses to user's current currency
await syncRecurringCurrency();
loadRecurringExpenses();
loadCategories();
// Set default next due date to tomorrow
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
document.getElementById('recurring-next-due').valueAsDate = tomorrow;
// Event listeners
document.getElementById('recurring-form')?.addEventListener('submit', saveRecurringExpense);
document.getElementById('recurring-frequency')?.addEventListener('change', updateDayField);
}
});
// Sync recurring expenses currency with user profile
async function syncRecurringCurrency() {
try {
await apiCall('/api/recurring/sync-currency', {
method: 'POST'
});
} catch (error) {
console.error('Failed to sync currency:', error);
}
}

600
app/static/js/reports.js Normal file
View file

@ -0,0 +1,600 @@
// 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) {
// Store user currency globally
window.userCurrency = data.currency || 'GBP';
// Update KPI cards
document.getElementById('total-spent').textContent = formatCurrency(data.total_spent, window.userCurrency);
document.getElementById('total-income').textContent = formatCurrency(data.total_income, window.userCurrency);
document.getElementById('profit-loss').textContent = formatCurrency(Math.abs(data.profit_loss), window.userCurrency);
// Update profit/loss card color based on value
const profitCard = document.getElementById('profit-loss').closest('.bg-card-light, .dark\\:bg-card-dark');
if (profitCard) {
if (data.profit_loss >= 0) {
profitCard.classList.add('border-green-500/20');
profitCard.classList.remove('border-red-500/20');
document.getElementById('profit-loss').classList.add('text-green-600', 'dark:text-green-400');
document.getElementById('profit-loss').classList.remove('text-red-600', 'dark:text-red-400');
} else {
profitCard.classList.add('border-red-500/20');
profitCard.classList.remove('border-green-500/20');
document.getElementById('profit-loss').classList.add('text-red-600', 'dark:text-red-400');
document.getElementById('profit-loss').classList.remove('text-green-600', 'dark:text-green-400');
}
}
// 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)}%
`;
// Income change indicator
const incomeChange = document.getElementById('income-change');
const incomeChangeValue = data.income_percent_change || 0;
const isIncomeIncrease = incomeChangeValue > 0;
incomeChange.className = `flex items-center font-medium px-1.5 py-0.5 rounded ${
isIncomeIncrease
? 'text-green-500 dark:text-green-400 bg-green-500/10'
: 'text-red-500 dark:text-red-400 bg-red-500/10'
}`;
incomeChange.innerHTML = `
<span class="material-symbols-outlined text-[14px] mr-0.5">${isIncomeIncrease ? 'trending_up' : 'trending_down'}</span>
${Math.abs(incomeChangeValue).toFixed(1)}%
`;
// Profit/loss change indicator
const profitChange = document.getElementById('profit-change');
const profitChangeValue = data.profit_percent_change || 0;
const isProfitIncrease = profitChangeValue > 0;
profitChange.className = `flex items-center font-medium px-1.5 py-0.5 rounded ${
isProfitIncrease
? 'text-green-500 dark:text-green-400 bg-green-500/10'
: 'text-red-500 dark:text-red-400 bg-red-500/10'
}`;
profitChange.innerHTML = `
<span class="material-symbols-outlined text-[14px] mr-0.5">${isProfitIncrease ? 'trending_up' : 'trending_down'}</span>
${Math.abs(profitChangeValue).toFixed(1)}%
`;
// 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.toFixed(1)}%`;
// Savings rate change indicator
const savingsChange = document.getElementById('savings-change');
const savingsChangeValue = data.savings_rate_change;
const isSavingsIncrease = savingsChangeValue > 0;
savingsChange.className = `flex items-center font-medium px-1.5 py-0.5 rounded ${
isSavingsIncrease
? 'text-green-500 dark:text-green-400 bg-green-500/10'
: 'text-red-500 dark:text-red-400 bg-red-500/10'
}`;
savingsChange.innerHTML = `
<span class="material-symbols-outlined text-[14px] mr-0.5">${isSavingsIncrease ? 'trending_up' : 'trending_down'}</span>
${Math.abs(savingsChangeValue).toFixed(1)}%
`;
// Update charts
updateTrendChart(data.daily_trend);
updateCategoryChart(data.category_breakdown);
updateIncomeChart(data.income_breakdown);
updateMonthlyChart(data.monthly_comparison);
}
// Update trend chart - Income vs Expenses
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();
}
// Check if we have income data
const hasIncome = dailyData.length > 0 && dailyData[0].hasOwnProperty('income');
const datasets = hasIncome ? [
{
label: window.getTranslation ? window.getTranslation('nav.income', 'Income') : 'Income',
data: dailyData.map(d => d.income || 0),
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
fill: true,
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: isDark ? '#1e293b' : '#ffffff',
pointBorderColor: '#10b981',
pointBorderWidth: 2,
pointHoverRadius: 6
},
{
label: window.getTranslation ? window.getTranslation('dashboard.spending', 'Expenses') : 'Expenses',
data: dailyData.map(d => d.expenses || 0),
borderColor: '#ef4444',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
fill: true,
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: isDark ? '#1e293b' : '#ffffff',
pointBorderColor: '#ef4444',
pointBorderWidth: 2,
pointHoverRadius: 6
}
] : [{
label: window.getTranslation ? window.getTranslation('dashboard.spending', 'Spending') : 'Spending',
data: dailyData.map(d => d.amount || d.expenses || 0),
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
}];
trendChart = new Chart(ctx, {
type: 'line',
data: {
labels: dailyData.map(d => d.date),
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: textColor,
usePointStyle: true,
padding: 15
}
},
tooltip: {
backgroundColor: isDark ? '#1e293b' : '#ffffff',
titleColor: isDark ? '#f8fafc' : '#0f172a',
bodyColor: isDark ? '#94a3b8' : '#64748b',
borderColor: isDark ? '#334155' : '#e2e8f0',
borderWidth: 1,
padding: 12,
displayColors: true,
callbacks: {
label: function(context) {
return context.dataset.label + ': ' + formatCurrency(context.parsed.y, window.userCurrency || 'GBP');
}
}
}
},
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 formatCurrency(value, window.userCurrency || 'GBP');
}
}
}
}
}
});
}
// Update income sources pie chart
function updateIncomeChart(incomeBreakdown) {
const pieChart = document.getElementById('income-pie-chart');
const pieTotal = document.getElementById('income-pie-total');
const pieLegend = document.getElementById('income-legend');
if (!pieChart || !pieLegend) return;
const userCurrency = window.userCurrency || 'GBP';
if (!incomeBreakdown || incomeBreakdown.length === 0) {
pieChart.style.background = 'conic-gradient(#10b981 0% 100%)';
if (pieTotal) pieTotal.textContent = formatCurrency(0, userCurrency);
pieLegend.innerHTML = '<p class="col-span-2 text-center text-text-muted dark:text-[#92adc9] text-sm">' +
(window.getTranslation ? window.getTranslation('dashboard.noData', 'No income data') : 'No income data') + '</p>';
return;
}
// Calculate total
const total = incomeBreakdown.reduce((sum, inc) => sum + parseFloat(inc.amount || 0), 0);
if (pieTotal) pieTotal.textContent = formatCurrency(total, userCurrency);
// Income source colors
const incomeColors = {
'Salary': '#10b981',
'Freelance': '#3b82f6',
'Investment': '#8b5cf6',
'Rental': '#f59e0b',
'Gift': '#ec4899',
'Bonus': '#14b8a6',
'Refund': '#6366f1',
'Other': '#6b7280'
};
// Generate conic gradient segments
let currentPercent = 0;
const gradientSegments = incomeBreakdown.map(inc => {
const percent = inc.percentage || 0;
const color = incomeColors[inc.source] || '#10b981';
const segment = `${color} ${currentPercent}% ${currentPercent + percent}%`;
currentPercent += percent;
return segment;
});
// Apply gradient
pieChart.style.background = `conic-gradient(${gradientSegments.join(', ')})`;
// Generate compact legend
const legendHTML = incomeBreakdown.map(inc => {
const color = incomeColors[inc.source] || '#10b981';
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: ${color};"></span>
<span class="text-text-muted dark:text-[#92adc9] text-[10px] truncate flex-1 leading-tight">${inc.source}</span>
<span class="text-text-muted dark:text-[#92adc9] text-[10px] font-medium">${inc.percentage}%</span>
</div>
`;
}).join('');
pieLegend.innerHTML = legendHTML;
}
// Update category pie chart - Beautiful CSS conic-gradient design
function updateCategoryChart(categories) {
const pieChart = document.getElementById('category-pie-chart');
const pieTotal = document.getElementById('category-pie-total');
const pieLegend = document.getElementById('category-legend');
if (!pieChart || !pieLegend) return;
const userCurrency = window.userCurrency || 'GBP';
if (categories.length === 0) {
pieChart.style.background = 'conic-gradient(#233648 0% 100%)';
if (pieTotal) pieTotal.textContent = formatCurrency(0, userCurrency);
pieLegend.innerHTML = '<p class="col-span-2 text-center text-text-muted dark:text-[#92adc9] text-sm">No data available</p>';
return;
}
// Calculate total
const total = categories.reduce((sum, cat) => sum + parseFloat(cat.amount || 0), 0);
if (pieTotal) pieTotal.textContent = formatCurrency(total, userCurrency);
// Generate conic gradient segments
let currentPercent = 0;
const gradientSegments = categories.map(cat => {
const percent = total > 0 ? (parseFloat(cat.amount || 0) / total) * 100 : 0;
const segment = `${cat.color} ${currentPercent}% ${currentPercent + percent}%`;
currentPercent += percent;
return segment;
});
// Apply gradient
pieChart.style.background = `conic-gradient(${gradientSegments.join(', ')})`;
// Generate compact legend
const legendHTML = categories.map(cat => {
const percent = total > 0 ? ((parseFloat(cat.amount || 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('');
pieLegend.innerHTML = legendHTML;
}
// Update monthly chart - Income vs Expenses
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();
}
// Check if we have income data
const hasIncome = monthlyData.length > 0 && monthlyData[0].hasOwnProperty('income');
const datasets = hasIncome ? [
{
label: window.getTranslation ? window.getTranslation('nav.income', 'Income') : 'Income',
data: monthlyData.map(d => d.income || 0),
backgroundColor: '#10b981',
borderRadius: 6,
barPercentage: 0.5,
categoryPercentage: 0.7,
hoverBackgroundColor: '#059669'
},
{
label: window.getTranslation ? window.getTranslation('dashboard.spending', 'Expenses') : 'Expenses',
data: monthlyData.map(d => d.expenses || d.amount || 0),
backgroundColor: '#ef4444',
borderRadius: 6,
barPercentage: 0.5,
categoryPercentage: 0.7,
hoverBackgroundColor: '#dc2626'
}
] : [{
label: window.getTranslation ? window.getTranslation('dashboard.spending', 'Monthly Spending') : 'Monthly Spending',
data: monthlyData.map(d => d.amount || d.expenses || 0),
backgroundColor: '#2b8cee',
borderRadius: 6,
barPercentage: 0.5,
categoryPercentage: 0.7,
hoverBackgroundColor: '#1d7ad9'
}];
monthlyChart = new Chart(ctx, {
type: 'bar',
data: {
labels: monthlyData.map(d => d.month),
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: textColor,
usePointStyle: true,
padding: 15
}
},
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) {
return context.dataset.label + ': ' + formatCurrency(context.parsed.y, window.userCurrency || 'GBP');
}
}
}
},
scales: {
x: {
grid: {
display: false,
drawBorder: false
},
ticks: {
color: textColor
}
},
y: {
grid: {
color: gridColor,
drawBorder: false
},
ticks: {
color: textColor,
callback: function(value) {
return formatCurrency(value, window.userCurrency || 'GBP');
}
}
}
}
}
});
}
// 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();
}
}
// Load smart recommendations
async function loadRecommendations() {
const container = document.getElementById('recommendations-container');
if (!container) return;
try {
const data = await apiCall('/api/smart-recommendations');
if (!data.success || !data.recommendations || data.recommendations.length === 0) {
container.innerHTML = `
<div class="flex items-center justify-center py-8">
<div class="flex flex-col items-center gap-2">
<span class="material-symbols-outlined text-text-muted dark:text-[#92adc9] text-[32px]">lightbulb</span>
<p class="text-sm text-text-muted dark:text-[#92adc9]" data-translate="reports.noRecommendations">No recommendations at this time</p>
</div>
</div>
`;
return;
}
const recommendationsHTML = data.recommendations.map(rec => {
// Type-based colors
const colorClasses = {
'warning': 'border-yellow-500/20 bg-yellow-500/5 hover:bg-yellow-500/10',
'success': 'border-green-500/20 bg-green-500/5 hover:bg-green-500/10',
'info': 'border-blue-500/20 bg-blue-500/5 hover:bg-blue-500/10',
'danger': 'border-red-500/20 bg-red-500/5 hover:bg-red-500/10'
};
const iconColors = {
'warning': 'text-yellow-500',
'success': 'text-green-500',
'info': 'text-blue-500',
'danger': 'text-red-500'
};
return `
<div class="flex items-start gap-4 p-4 rounded-lg border ${colorClasses[rec.type] || 'border-border-light dark:border-[#233648]'} transition-all">
<span class="material-symbols-outlined ${iconColors[rec.type] || 'text-primary'} text-[28px] flex-shrink-0 mt-0.5">${rec.icon}</span>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-semibold text-text-main dark:text-white mb-1">${rec.title}</h4>
<p class="text-xs text-text-muted dark:text-[#92adc9] leading-relaxed">${rec.description}</p>
</div>
</div>
`;
}).join('');
container.innerHTML = recommendationsHTML;
} catch (error) {
console.error('Failed to load recommendations:', error);
container.innerHTML = `
<div class="flex items-center justify-center py-8">
<p class="text-sm text-red-500">Failed to load recommendations</p>
</div>
`;
}
}
// 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();
loadRecommendations();
});

319
app/static/js/search.js Normal file
View file

@ -0,0 +1,319 @@
// Global Search Component
// Provides unified search across all app content and features
let searchTimeout;
let currentSearchQuery = '';
// Initialize global search
document.addEventListener('DOMContentLoaded', () => {
initGlobalSearch();
});
function initGlobalSearch() {
const searchBtn = document.getElementById('global-search-btn');
const searchModal = document.getElementById('global-search-modal');
const searchInput = document.getElementById('global-search-input');
const searchResults = document.getElementById('global-search-results');
const searchClose = document.getElementById('global-search-close');
if (!searchBtn || !searchModal) return;
// Open search modal
searchBtn?.addEventListener('click', () => {
searchModal.classList.remove('hidden');
setTimeout(() => {
searchModal.classList.add('opacity-100');
searchInput?.focus();
}, 10);
});
// Close search modal
searchClose?.addEventListener('click', closeSearchModal);
// Close on escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !searchModal.classList.contains('hidden')) {
closeSearchModal();
}
// Open search with Ctrl+K or Cmd+K
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
searchBtn?.click();
}
});
// Close on backdrop click
searchModal?.addEventListener('click', (e) => {
if (e.target === searchModal) {
closeSearchModal();
}
});
// Handle search input
searchInput?.addEventListener('input', (e) => {
const query = e.target.value.trim();
// Clear previous timeout
clearTimeout(searchTimeout);
// Show loading state
if (query.length >= 2) {
searchResults.innerHTML = '<div class="p-4 text-center text-text-muted dark:text-[#92adc9]">Searching...</div>';
// Debounce search
searchTimeout = setTimeout(() => {
performSearch(query);
}, 300);
} else if (query.length === 0) {
showSearchPlaceholder();
} else {
searchResults.innerHTML = '<div class="p-4 text-center text-text-muted dark:text-[#92adc9]" data-translate="search.minChars">Type at least 2 characters to search</div>';
}
});
// Handle keyboard navigation
searchInput?.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
const firstResult = searchResults.querySelector('[data-search-result]');
firstResult?.focus();
}
});
}
function closeSearchModal() {
const searchModal = document.getElementById('global-search-modal');
const searchInput = document.getElementById('global-search-input');
searchModal?.classList.remove('opacity-100');
setTimeout(() => {
searchModal?.classList.add('hidden');
searchInput.value = '';
showSearchPlaceholder();
}, 200);
}
function showSearchPlaceholder() {
const searchResults = document.getElementById('global-search-results');
searchResults.innerHTML = `
<div class="p-8 text-center">
<span class="material-symbols-outlined text-5xl text-text-muted dark:text-[#92adc9] opacity-50 mb-3">search</span>
<p class="text-text-muted dark:text-[#92adc9] text-sm" data-translate="search.placeholder">Search for transactions, documents, categories, or features</p>
<p class="text-text-muted dark:text-[#92adc9] text-xs mt-2" data-translate="search.hint">Press Ctrl+K to open search</p>
</div>
`;
}
async function performSearch(query) {
currentSearchQuery = query;
const searchResults = document.getElementById('global-search-results');
try {
const response = await apiCall(`/api/search/?q=${encodeURIComponent(query)}`, {
method: 'GET'
});
if (response.success) {
displaySearchResults(response);
} else {
searchResults.innerHTML = `<div class="p-4 text-center text-red-500">${response.message}</div>`;
}
} catch (error) {
console.error('Search error:', error);
searchResults.innerHTML = '<div class="p-4 text-center text-red-500" data-translate="search.error">Search failed. Please try again.</div>';
}
}
function displaySearchResults(response) {
const searchResults = document.getElementById('global-search-results');
const results = response.results;
const userLang = localStorage.getItem('language') || 'en';
if (response.total_results === 0) {
searchResults.innerHTML = `
<div class="p-8 text-center">
<span class="material-symbols-outlined text-5xl text-text-muted dark:text-[#92adc9] opacity-50 mb-3">search_off</span>
<p class="text-text-muted dark:text-[#92adc9]" data-translate="search.noResults">No results found for "${response.query}"</p>
</div>
`;
return;
}
let html = '<div class="flex flex-col divide-y divide-border-light dark:divide-[#233648]">';
// Features
if (results.features && results.features.length > 0) {
html += '<div class="p-4"><h3 class="text-xs font-semibold text-text-muted dark:text-[#92adc9] uppercase mb-3" data-translate="search.features">Features</h3><div class="flex flex-col gap-2">';
results.features.forEach(feature => {
const name = userLang === 'ro' ? feature.name_ro : feature.name;
const desc = userLang === 'ro' ? feature.description_ro : feature.description;
html += `
<a href="${feature.url}" data-search-result tabindex="0" class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-[#233648] transition-colors focus:outline-none focus:ring-2 focus:ring-primary">
<span class="material-symbols-outlined text-primary text-xl">${feature.icon}</span>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-text-main dark:text-white">${name}</div>
<div class="text-xs text-text-muted dark:text-[#92adc9]">${desc}</div>
</div>
<span class="material-symbols-outlined text-text-muted text-sm">arrow_forward</span>
</a>
`;
});
html += '</div></div>';
}
// Expenses
if (results.expenses && results.expenses.length > 0) {
html += '<div class="p-4"><h3 class="text-xs font-semibold text-text-muted dark:text-[#92adc9] uppercase mb-3" data-translate="search.expenses">Expenses</h3><div class="flex flex-col gap-2">';
results.expenses.forEach(expense => {
const date = new Date(expense.date).toLocaleDateString(userLang === 'ro' ? 'ro-RO' : 'en-US', { month: 'short', day: 'numeric' });
const ocrBadge = expense.ocr_match ? '<span class="text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-2 py-0.5 rounded" data-translate="search.ocrMatch">OCR Match</span>' : '';
html += `
<a href="${expense.url}" data-search-result tabindex="0" class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-[#233648] transition-colors focus:outline-none focus:ring-2 focus:ring-primary">
<div class="size-10 rounded-lg flex items-center justify-center" style="background-color: ${expense.category_color}20">
<span class="material-symbols-outlined text-lg" style="color: ${expense.category_color}">receipt</span>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-text-main dark:text-white">${expense.description}</div>
<div class="flex items-center gap-2 text-xs text-text-muted dark:text-[#92adc9] mt-1">
<span>${expense.category_name}</span>
<span></span>
<span>${date}</span>
${ocrBadge}
</div>
</div>
<div class="text-sm font-semibold text-text-main dark:text-white">${formatCurrency(expense.amount, expense.currency)}</div>
</a>
`;
});
html += '</div></div>';
}
// Documents
if (results.documents && results.documents.length > 0) {
html += '<div class="p-4"><h3 class="text-xs font-semibold text-text-muted dark:text-[#92adc9] uppercase mb-3" data-translate="search.documents">Documents</h3><div class="flex flex-col gap-2">';
results.documents.forEach(doc => {
const date = new Date(doc.created_at).toLocaleDateString(userLang === 'ro' ? 'ro-RO' : 'en-US', { month: 'short', day: 'numeric' });
const ocrBadge = doc.ocr_match ? '<span class="text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-2 py-0.5 rounded" data-translate="search.ocrMatch">OCR Match</span>' : '';
const fileIcon = doc.file_type === 'PDF' ? 'picture_as_pdf' : 'image';
html += `
<button onclick="openDocumentFromSearch(${doc.id}, '${doc.file_type}', '${escapeHtml(doc.filename)}')" data-search-result tabindex="0" class="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-[#233648] transition-colors focus:outline-none focus:ring-2 focus:ring-primary text-left">
<span class="material-symbols-outlined text-primary text-xl">${fileIcon}</span>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-text-main dark:text-white truncate">${doc.filename}</div>
<div class="flex items-center gap-2 text-xs text-text-muted dark:text-[#92adc9] mt-1">
<span>${doc.file_type}</span>
<span></span>
<span>${date}</span>
${ocrBadge}
</div>
</div>
<span class="material-symbols-outlined text-text-muted text-sm">visibility</span>
</button>
`;
});
html += '</div></div>';
}
// Categories
if (results.categories && results.categories.length > 0) {
html += '<div class="p-4"><h3 class="text-xs font-semibold text-text-muted dark:text-[#92adc9] uppercase mb-3" data-translate="search.categories">Categories</h3><div class="flex flex-col gap-2">';
results.categories.forEach(category => {
html += `
<a href="${category.url}" data-search-result tabindex="0" class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-[#233648] transition-colors focus:outline-none focus:ring-2 focus:ring-primary">
<div class="size-10 rounded-lg flex items-center justify-center" style="background-color: ${category.color}20">
<span class="material-symbols-outlined text-lg" style="color: ${category.color}">${category.icon}</span>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-text-main dark:text-white">${category.name}</div>
</div>
<span class="material-symbols-outlined text-text-muted text-sm">arrow_forward</span>
</a>
`;
});
html += '</div></div>';
}
// Recurring Expenses
if (results.recurring && results.recurring.length > 0) {
html += '<div class="p-4"><h3 class="text-xs font-semibold text-text-muted dark:text-[#92adc9] uppercase mb-3" data-translate="search.recurring">Recurring</h3><div class="flex flex-col gap-2">';
results.recurring.forEach(rec => {
const nextDue = new Date(rec.next_due_date).toLocaleDateString(userLang === 'ro' ? 'ro-RO' : 'en-US', { month: 'short', day: 'numeric' });
const statusBadge = rec.is_active
? '<span class="text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-2 py-0.5 rounded" data-translate="recurring.active">Active</span>'
: '<span class="text-xs bg-gray-100 dark:bg-gray-800/30 text-gray-600 dark:text-gray-400 px-2 py-0.5 rounded" data-translate="recurring.inactive">Inactive</span>';
html += `
<a href="${rec.url}" data-search-result tabindex="0" class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-[#233648] transition-colors focus:outline-none focus:ring-2 focus:ring-primary">
<div class="size-10 rounded-lg flex items-center justify-center" style="background-color: ${rec.category_color}20">
<span class="material-symbols-outlined text-lg" style="color: ${rec.category_color}">repeat</span>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-text-main dark:text-white">${rec.name}</div>
<div class="flex items-center gap-2 text-xs text-text-muted dark:text-[#92adc9] mt-1">
<span>${rec.category_name}</span>
<span></span>
<span data-translate="recurring.nextDue">Next:</span>
<span>${nextDue}</span>
${statusBadge}
</div>
</div>
<div class="text-sm font-semibold text-text-main dark:text-white">${formatCurrency(rec.amount, rec.currency)}</div>
</a>
`;
});
html += '</div></div>';
}
html += '</div>';
searchResults.innerHTML = html;
// Apply translations
if (window.applyTranslations) {
window.applyTranslations();
}
// Handle keyboard navigation between results
const resultElements = searchResults.querySelectorAll('[data-search-result]');
resultElements.forEach((element, index) => {
element.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
resultElements[index + 1]?.focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (index === 0) {
document.getElementById('global-search-input')?.focus();
} else {
resultElements[index - 1]?.focus();
}
}
});
});
}
// Open document viewer from search
function openDocumentFromSearch(docId, fileType, filename) {
// Close search modal
closeSearchModal();
// Navigate to documents page and open viewer
if (window.location.pathname !== '/documents') {
// Store document to open after navigation
sessionStorage.setItem('openDocumentId', docId);
sessionStorage.setItem('openDocumentType', fileType);
sessionStorage.setItem('openDocumentName', filename);
window.location.href = '/documents';
} else {
// Already on documents page, open directly
if (typeof viewDocument === 'function') {
viewDocument(docId, fileType, filename);
}
}
}
// Helper to escape HTML
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

274
app/static/js/settings.js Normal file
View file

@ -0,0 +1,274 @@
// 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;
const monthlyBudget = document.getElementById('monthly-budget').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;
}
// Budget validation
const budget = parseFloat(monthlyBudget);
if (isNaN(budget) || budget < 0) {
showNotification('error', 'Please enter a valid budget amount');
return;
}
try {
const response = await fetch('/api/settings/profile', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username,
email,
language,
currency,
monthly_budget: budget
})
});
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;
}

309
app/static/js/tags.js Normal file
View file

@ -0,0 +1,309 @@
// Tags Management JavaScript
// Handles tag creation, editing, filtering, and display
let allTags = [];
let selectedTags = [];
// Load all tags for current user
async function loadTags() {
try {
const response = await apiCall('/api/tags/?sort_by=use_count&order=desc');
if (response.success) {
allTags = response.tags;
return allTags;
}
} catch (error) {
console.error('Failed to load tags:', error);
return [];
}
}
// Load popular tags (most used)
async function loadPopularTags(limit = 10) {
try {
const response = await apiCall(`/api/tags/popular?limit=${limit}`);
if (response.success) {
return response.tags;
}
} catch (error) {
console.error('Failed to load popular tags:', error);
return [];
}
}
// Create a new tag
async function createTag(tagData) {
try {
const response = await apiCall('/api/tags/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tagData)
});
if (response.success) {
showToast(window.getTranslation('tags.created', 'Tag created successfully'), 'success');
await loadTags();
return response.tag;
} else {
showToast(response.message || window.getTranslation('tags.errorCreating', 'Error creating tag'), 'error');
return null;
}
} catch (error) {
console.error('Failed to create tag:', error);
showToast(window.getTranslation('tags.errorCreating', 'Error creating tag'), 'error');
return null;
}
}
// Update an existing tag
async function updateTag(tagId, tagData) {
try {
const response = await apiCall(`/api/tags/${tagId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tagData)
});
if (response.success) {
showToast(window.getTranslation('tags.updated', 'Tag updated successfully'), 'success');
await loadTags();
return response.tag;
} else {
showToast(response.message || window.getTranslation('tags.errorUpdating', 'Error updating tag'), 'error');
return null;
}
} catch (error) {
console.error('Failed to update tag:', error);
showToast(window.getTranslation('tags.errorUpdating', 'Error updating tag'), 'error');
return null;
}
}
// Delete a tag
async function deleteTag(tagId) {
const confirmMsg = window.getTranslation('tags.deleteConfirm', 'Are you sure you want to delete this tag?');
if (!confirm(confirmMsg)) {
return false;
}
try {
const response = await apiCall(`/api/tags/${tagId}`, {
method: 'DELETE'
});
if (response.success) {
showToast(window.getTranslation('tags.deleted', 'Tag deleted successfully'), 'success');
await loadTags();
return true;
} else {
showToast(response.message || window.getTranslation('tags.errorDeleting', 'Error deleting tag'), 'error');
return false;
}
} catch (error) {
console.error('Failed to delete tag:', error);
showToast(window.getTranslation('tags.errorDeleting', 'Error deleting tag'), 'error');
return false;
}
}
// Get tag suggestions based on text
async function getTagSuggestions(text, maxTags = 5) {
try {
const response = await apiCall('/api/tags/suggest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, max_tags: maxTags })
});
if (response.success) {
return response.suggested_tags;
}
return [];
} catch (error) {
console.error('Failed to get tag suggestions:', error);
return [];
}
}
// Render a single tag badge
function renderTagBadge(tag, options = {}) {
const { removable = false, clickable = false, onRemove = null, onClick = null } = options;
const badge = document.createElement('span');
badge.className = 'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium transition-all';
badge.style.backgroundColor = `${tag.color}20`;
badge.style.borderColor = `${tag.color}40`;
badge.style.color = tag.color;
badge.classList.add('border');
if (clickable) {
badge.classList.add('cursor-pointer', 'hover:brightness-110');
badge.addEventListener('click', () => onClick && onClick(tag));
}
// Icon
const icon = document.createElement('span');
icon.className = 'material-symbols-outlined';
icon.style.fontSize = '14px';
icon.textContent = tag.icon || 'label';
badge.appendChild(icon);
// Tag name
const name = document.createElement('span');
name.textContent = tag.name;
badge.appendChild(name);
// Use count (optional)
if (tag.use_count > 0 && !removable) {
const count = document.createElement('span');
count.className = 'opacity-60';
count.textContent = `(${tag.use_count})`;
badge.appendChild(count);
}
// Remove button (optional)
if (removable) {
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-1 hover:bg-black hover:bg-opacity-10 rounded-full p-0.5';
removeBtn.innerHTML = '<span class="material-symbols-outlined" style="font-size: 14px;">close</span>';
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
onRemove && onRemove(tag);
});
badge.appendChild(removeBtn);
}
return badge;
}
// Render tags list in a container
function renderTagsList(tags, containerId, options = {}) {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = '';
if (tags.length === 0) {
const emptyMsg = document.createElement('p');
emptyMsg.className = 'text-text-muted dark:text-[#92adc9] text-sm';
emptyMsg.textContent = window.getTranslation('tags.noTags', 'No tags yet');
container.appendChild(emptyMsg);
return;
}
tags.forEach(tag => {
const badge = renderTagBadge(tag, options);
container.appendChild(badge);
});
}
// Create a tag filter dropdown
function createTagFilterDropdown(containerId, onSelectionChange) {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = `
<div class="relative">
<button id="tagFilterBtn" class="flex items-center gap-2 px-4 py-2 bg-white dark:bg-[#0a1628] border border-gray-200 dark:border-white/10 rounded-lg hover:bg-gray-50 dark:hover:bg-white/5 transition-colors">
<span class="material-symbols-outlined text-[20px]">label</span>
<span data-translate="tags.filterByTags">Filter by Tags</span>
<span class="material-symbols-outlined text-[16px]">expand_more</span>
</button>
<div id="tagFilterDropdown" class="absolute top-full left-0 mt-2 w-72 bg-white dark:bg-[#0a1628] border border-gray-200 dark:border-white/10 rounded-lg shadow-lg p-4 hidden z-50">
<div class="mb-3">
<input type="text" id="tagFilterSearch" placeholder="${window.getTranslation('tags.selectTags', 'Select tags...')}" class="w-full px-3 py-2 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-sm">
</div>
<div id="tagFilterList" class="max-h-64 overflow-y-auto space-y-2">
<!-- Tag checkboxes will be inserted here -->
</div>
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-white/10">
<button id="clearTagFilters" class="text-sm text-primary hover:underline">Clear all</button>
</div>
</div>
</div>
`;
const btn = container.querySelector('#tagFilterBtn');
const dropdown = container.querySelector('#tagFilterDropdown');
const searchInput = container.querySelector('#tagFilterSearch');
const listContainer = container.querySelector('#tagFilterList');
const clearBtn = container.querySelector('#clearTagFilters');
// Toggle dropdown
btn.addEventListener('click', async () => {
dropdown.classList.toggle('hidden');
if (!dropdown.classList.contains('hidden')) {
await renderTagFilterList(listContainer, searchInput, onSelectionChange);
}
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!container.contains(e.target)) {
dropdown.classList.add('hidden');
}
});
// Clear filters
clearBtn.addEventListener('click', () => {
selectedTags = [];
renderTagFilterList(listContainer, searchInput, onSelectionChange);
onSelectionChange(selectedTags);
});
}
// Render tag filter list with checkboxes
async function renderTagFilterList(listContainer, searchInput, onSelectionChange) {
const tags = await loadTags();
const renderList = (filteredTags) => {
listContainer.innerHTML = '';
filteredTags.forEach(tag => {
const item = document.createElement('label');
item.className = 'flex items-center gap-2 p-2 hover:bg-gray-50 dark:hover:bg-white/5 rounded cursor-pointer';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = tag.id;
checkbox.checked = selectedTags.includes(tag.id);
checkbox.className = 'rounded';
checkbox.addEventListener('change', (e) => {
if (e.target.checked) {
selectedTags.push(tag.id);
} else {
selectedTags = selectedTags.filter(id => id !== tag.id);
}
onSelectionChange(selectedTags);
});
const badge = renderTagBadge(tag, {});
item.appendChild(checkbox);
item.appendChild(badge);
listContainer.appendChild(item);
});
};
// Initial render
renderList(tags);
// Search functionality
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
const filtered = tags.filter(tag => tag.name.toLowerCase().includes(query));
renderList(filtered);
});
}
// Make functions globally available
window.loadTags = loadTags;
window.loadPopularTags = loadPopularTags;
window.createTag = createTag;
window.updateTag = updateTag;
window.deleteTag = deleteTag;
window.getTagSuggestions = getTagSuggestions;
window.renderTagBadge = renderTagBadge;
window.renderTagsList = renderTagsList;
window.createTagFilterDropdown = createTagFilterDropdown;

View file

@ -0,0 +1,564 @@
// Transactions page JavaScript
let currentPage = 1;
let filters = {
category_id: '',
start_date: '',
end_date: '',
search: ''
};
// Load user profile to get currency
async function loadUserCurrency() {
try {
const profile = await apiCall('/api/settings/profile');
window.userCurrency = profile.profile.currency || 'RON';
} catch (error) {
console.error('Failed to load user currency:', error);
window.userCurrency = 'RON';
}
}
// 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) {
const noTransactionsText = window.getTranslation ? window.getTranslation('transactions.noTransactions', 'No transactions found') : 'No transactions found';
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">${noTransactionsText}</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
? (window.getTranslation ? window.getTranslation('transactions.completed', 'Completed') : 'Completed')
: (window.getTranslation ? window.getTranslation('transactions.pending', 'Pending') : '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(', ') : (window.getTranslation ? window.getTranslation('transactions.expense', 'Expense') : '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> ${window.userCurrency || 'RON'}</span>
</div>
</td>
<td class="p-5 text-right">
<span class="text-text-main dark:text-white font-semibold">${formatCurrency(tx.amount, tx.currency || window.userCurrency || 'GBP')}</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="viewReceipt('${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="${window.getTranslation ? window.getTranslation('transactions.viewReceipt', 'View Receipt') : '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="${window.getTranslation ? window.getTranslation('transactions.edit', 'Edit') : '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="${window.getTranslation ? window.getTranslation('transactions.delete', 'Delete') : '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;
const prevText = window.getTranslation ? window.getTranslation('transactions.previous', 'Previous') : 'Previous';
const nextText = window.getTranslation ? window.getTranslation('transactions.next', 'Next') : 'Next';
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>
${prevText}
</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' : ''}
>
${nextText}
<span class="material-symbols-outlined text-[16px]">chevron_right</span>
</button>
`;
container.innerHTML = html;
}
// Change page
function changePage(page) {
currentPage = page;
loadTransactions();
}
// Edit transaction
let currentExpenseId = null;
let currentReceiptPath = null;
async function editTransaction(id) {
try {
// Fetch expense details
const data = await apiCall(`/api/expenses/?page=1`);
const expense = data.expenses.find(e => e.id === id);
if (!expense) {
showToast(window.getTranslation ? window.getTranslation('transactions.notFound', 'Transaction not found') : 'Transaction not found', 'error');
return;
}
// Store current expense data
currentExpenseId = id;
currentReceiptPath = expense.receipt_path;
// Update modal title
const modalTitle = document.getElementById('expense-modal-title');
modalTitle.textContent = window.getTranslation ? window.getTranslation('modal.edit_expense', 'Edit Expense') : 'Edit Expense';
// Load categories
await loadCategoriesForModal();
// Populate form fields
const form = document.getElementById('expense-form');
form.querySelector('[name="amount"]').value = expense.amount;
form.querySelector('[name="description"]').value = expense.description;
form.querySelector('[name="category_id"]').value = expense.category_id;
// Format date for input (YYYY-MM-DD)
const expenseDate = new Date(expense.date);
const dateStr = expenseDate.toISOString().split('T')[0];
form.querySelector('[name="date"]').value = dateStr;
// Populate tags
if (expense.tags && expense.tags.length > 0) {
form.querySelector('[name="tags"]').value = expense.tags.join(', ');
}
// Show current receipt info if exists
const receiptInfo = document.getElementById('current-receipt-info');
const viewReceiptBtn = document.getElementById('view-current-receipt');
if (expense.receipt_path) {
receiptInfo.classList.remove('hidden');
viewReceiptBtn.onclick = () => viewReceipt(expense.receipt_path);
} else {
receiptInfo.classList.add('hidden');
}
// Update submit button text
const submitBtn = document.getElementById('expense-submit-btn');
submitBtn.textContent = window.getTranslation ? window.getTranslation('actions.update', 'Update Expense') : 'Update Expense';
// Show modal
document.getElementById('expense-modal').classList.remove('hidden');
} catch (error) {
console.error('Failed to load transaction for editing:', error);
showToast(window.getTranslation ? window.getTranslation('common.error', 'An error occurred') : 'An error occurred', 'error');
}
}
// Make editTransaction global
window.editTransaction = editTransaction;
// Delete transaction
async function deleteTransaction(id) {
const confirmMsg = window.getTranslation ? window.getTranslation('transactions.deleteConfirm', 'Are you sure you want to delete this transaction?') : 'Are you sure you want to delete this transaction?';
const successMsg = window.getTranslation ? window.getTranslation('transactions.deleted', 'Transaction deleted') : 'Transaction deleted';
if (!confirm(confirmMsg)) {
return;
}
try {
await apiCall(`/api/expenses/${id}`, { method: 'DELETE' });
showToast(successMsg, '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');
const categoryText = window.getTranslation ? window.getTranslation('transactions.allCategories', 'Category') : 'Category';
select.innerHTML = `<option value="">${categoryText}</option>` +
data.categories.map(cat => `<option value="${cat.id}">${cat.name}</option>`).join('');
} catch (error) {
console.error('Failed to load categories:', error);
}
}
// Load categories for modal
async function loadCategoriesForModal() {
try {
const data = await apiCall('/api/expenses/categories');
const select = document.querySelector('#expense-form [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);
}
}
// 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
});
const importedText = window.getTranslation ? window.getTranslation('transactions.imported', 'Imported') : 'Imported';
const transactionsText = window.getTranslation ? window.getTranslation('transactions.importSuccess', 'transactions') : 'transactions';
showToast(`${importedText} ${result.imported} ${transactionsText}`, '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
});
// Receipt Viewer
const receiptModal = document.getElementById('receipt-modal');
const receiptContent = document.getElementById('receipt-content');
const closeReceiptModal = document.getElementById('close-receipt-modal');
function viewReceipt(receiptPath) {
const fileExt = receiptPath.split('.').pop().toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(fileExt)) {
// Display image
receiptContent.innerHTML = `<img src="${receiptPath}" alt="Receipt" class="max-w-full h-auto rounded-lg shadow-lg">`;
} else if (fileExt === 'pdf') {
// Display PDF
receiptContent.innerHTML = `<iframe src="${receiptPath}" class="w-full h-[600px] rounded-lg shadow-lg"></iframe>`;
} else {
// Unsupported format - provide download link
receiptContent.innerHTML = `
<div class="text-center">
<span class="material-symbols-outlined text-6xl text-text-muted dark:text-[#92adc9] mb-4">description</span>
<p class="text-text-main dark:text-white mb-4">Preview not available</p>
<a href="${receiptPath}" download class="bg-primary hover:bg-primary/90 text-white px-6 py-3 rounded-lg font-semibold transition-colors inline-block">
${window.getTranslation ? window.getTranslation('transactions.downloadReceipt', 'Download Receipt') : 'Download Receipt'}
</a>
</div>
`;
}
receiptModal.classList.remove('hidden');
}
closeReceiptModal.addEventListener('click', () => {
receiptModal.classList.add('hidden');
receiptContent.innerHTML = '';
});
// Close modal on outside click
receiptModal.addEventListener('click', (e) => {
if (e.target === receiptModal) {
receiptModal.classList.add('hidden');
receiptContent.innerHTML = '';
}
});
// Expense Modal Event Listeners
const expenseModal = document.getElementById('expense-modal');
const addExpenseBtn = document.getElementById('add-expense-btn');
const closeExpenseModal = document.getElementById('close-expense-modal');
const expenseForm = document.getElementById('expense-form');
// Open modal for adding new expense
addExpenseBtn.addEventListener('click', () => {
// Reset for add mode
currentExpenseId = null;
currentReceiptPath = null;
expenseForm.reset();
// Update modal title
const modalTitle = document.getElementById('expense-modal-title');
modalTitle.textContent = window.getTranslation ? window.getTranslation('modal.add_expense', 'Add Expense') : 'Add Expense';
// Update submit button
const submitBtn = document.getElementById('expense-submit-btn');
submitBtn.textContent = window.getTranslation ? window.getTranslation('actions.save', 'Save Expense') : 'Save Expense';
// Hide receipt info
document.getElementById('current-receipt-info').classList.add('hidden');
// Load categories and set today's date
loadCategoriesForModal();
const dateInput = expenseForm.querySelector('[name="date"]');
dateInput.value = new Date().toISOString().split('T')[0];
// Show modal
expenseModal.classList.remove('hidden');
});
// Close modal
closeExpenseModal.addEventListener('click', () => {
expenseModal.classList.add('hidden');
expenseForm.reset();
currentExpenseId = null;
currentReceiptPath = null;
});
// Close modal on outside click
expenseModal.addEventListener('click', (e) => {
if (e.target === expenseModal) {
expenseModal.classList.add('hidden');
expenseForm.reset();
currentExpenseId = null;
currentReceiptPath = null;
}
});
// Submit expense form (handles both add and edit)
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));
} else {
formData.set('tags', JSON.stringify([]));
}
// Convert date to ISO format
const date = new Date(formData.get('date'));
formData.set('date', date.toISOString());
// If no file selected in edit mode, remove the empty file field
const receiptFile = formData.get('receipt');
if (!receiptFile || receiptFile.size === 0) {
formData.delete('receipt');
}
try {
let result;
if (currentExpenseId) {
// Edit mode - use PUT
result = await apiCall(`/api/expenses/${currentExpenseId}`, {
method: 'PUT',
body: formData
});
const successMsg = window.getTranslation ? window.getTranslation('transactions.updated', 'Transaction updated successfully!') : 'Transaction updated successfully!';
showToast(successMsg, 'success');
} else {
// Add mode - use POST
result = await apiCall('/api/expenses/', {
method: 'POST',
body: formData
});
const successMsg = window.getTranslation ? window.getTranslation('dashboard.expenseAdded', 'Expense added successfully!') : 'Expense added successfully!';
showToast(successMsg, 'success');
}
if (result.success) {
expenseModal.classList.add('hidden');
expenseForm.reset();
currentExpenseId = null;
currentReceiptPath = null;
loadTransactions();
}
} catch (error) {
console.error('Failed to save expense:', error);
const errorMsg = window.getTranslation ? window.getTranslation('common.error', 'An error occurred') : 'An error occurred';
showToast(errorMsg, 'error');
}
});
// Initialize
document.addEventListener('DOMContentLoaded', async () => {
await loadUserCurrency();
loadTransactions();
loadCategoriesFilter();
});

63
app/static/manifest.json Normal file
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"
}
]
}
]
}

113
app/static/sw.js Normal file
View file

@ -0,0 +1,113 @@
const CACHE_NAME = 'fina-v6';
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...');
}
// Notification click handler
self.addEventListener('notificationclick', event => {
event.notification.close();
const urlToOpen = event.notification.data?.url || '/dashboard';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then(windowClients => {
// Check if there's already a window open
for (let client of windowClients) {
if (client.url === self.registration.scope + urlToOpen.substring(1) && 'focus' in client) {
return client.focus();
}
}
// No existing window, open a new one
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
});

211
app/templates/admin.html Normal file
View file

@ -0,0 +1,211 @@
{% extends "base.html" %}
{% block title %}Admin Panel - 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 %}<span data-translate="user.admin">Admin</span>{% else %}<span data-translate="user.user">User</span>{% 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 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.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.income') }}">
<span class="material-symbols-outlined text-[20px]">payments</span>
<span class="text-sm font-medium" data-translate="nav.income">Income</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.recurring') }}">
<span class="material-symbols-outlined text-[20px]">repeat</span>
<span class="text-sm font-medium" data-translate="nav.recurring">Recurring</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.import_page') }}">
<span class="material-symbols-outlined text-[20px]">file_upload</span>
<span class="text-sm font-medium" data-translate="nav.import">Import CSV</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 bg-primary/10 text-primary border border-primary/10" href="{{ url_for('main.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" data-translate="dashboard.lightMode">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>
<div>
<h2 class="text-text-main dark:text-white text-lg font-bold" data-translate="admin.title">Admin Panel</h2>
<p class="text-text-muted dark:text-slate-400 text-xs mt-0.5" data-translate="admin.subtitle">Manage users and system settings</p>
</div>
</div>
</header>
<!-- Content Area -->
<div class="flex-1 overflow-y-auto bg-background-light dark:bg-card-dark">
<div class="container mx-auto px-4 py-6 max-w-7xl">
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-slate-700 rounded-xl p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-text-muted dark:text-slate-400 text-sm" data-translate="admin.totalUsers">Total Users</p>
<p id="total-users" class="text-2xl font-bold text-text-main dark:text-white mt-1">-</p>
</div>
<span class="material-symbols-outlined text-primary text-[40px]">group</span>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-slate-700 rounded-xl p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-text-muted dark:text-slate-400 text-sm" data-translate="admin.adminUsers">Admin Users</p>
<p id="admin-users" class="text-2xl font-bold text-text-main dark:text-white mt-1">-</p>
</div>
<span class="material-symbols-outlined text-blue-500 text-[40px]">shield_person</span>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-slate-700 rounded-xl p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-text-muted dark:text-slate-400 text-sm" data-translate="admin.twoFAEnabled">2FA Enabled</p>
<p id="twofa-users" class="text-2xl font-bold text-text-main dark:text-white mt-1">-</p>
</div>
<span class="material-symbols-outlined text-green-500 text-[40px]">verified_user</span>
</div>
</div>
</div>
<!-- Users Table -->
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-slate-700 rounded-xl overflow-hidden">
<div class="p-6 border-b border-border-light dark:border-slate-700">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-text-main dark:text-white" data-translate="admin.users">Users</h2>
<button onclick="openCreateUserModal()" class="bg-primary hover:bg-primary/90 text-white px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-colors">
<span class="material-symbols-outlined text-[18px]">add</span>
<span data-translate="admin.createUser">Create User</span>
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-background-light dark:bg-slate-800/50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted dark:text-slate-400 uppercase tracking-wider" data-translate="admin.username">Username</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted dark:text-slate-400 uppercase tracking-wider" data-translate="admin.email">Email</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted dark:text-slate-400 uppercase tracking-wider" data-translate="admin.role">Role</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted dark:text-slate-400 uppercase tracking-wider" data-translate="admin.twoFA">2FA</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted dark:text-slate-400 uppercase tracking-wider" data-translate="admin.language">Language</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted dark:text-slate-400 uppercase tracking-wider" data-translate="admin.currency">Currency</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted dark:text-slate-400 uppercase tracking-wider" data-translate="admin.joined">Joined</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted dark:text-slate-400 uppercase tracking-wider" data-translate="admin.actions">Actions</th>
</tr>
</thead>
<tbody id="users-table" class="divide-y divide-border-light dark:divide-slate-700">
<!-- Users will be populated here -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- Create User Modal -->
<div id="create-user-modal" class="fixed inset-0 bg-black/50 backdrop-blur-sm hidden items-center justify-center z-50 p-4">
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-slate-700 rounded-xl max-w-md w-full p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-bold text-text-main dark:text-white" data-translate="admin.createNewUser">Create New User</h3>
<button onclick="closeCreateUserModal()" class="text-text-muted dark:text-slate-400 hover:text-text-main dark:hover:text-white">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<form id="create-user-form" class="space-y-4">
<div>
<label class="text-text-muted dark:text-slate-400 text-sm mb-2 block" data-translate="form.username">Username</label>
<input type="text" name="username" required class="w-full bg-background-light dark:bg-slate-800 border border-border-light dark:border-slate-700 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-slate-400 text-sm mb-2 block" data-translate="form.email">Email</label>
<input type="email" name="email" required class="w-full bg-background-light dark:bg-slate-800 border border-border-light dark:border-slate-700 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-slate-400 text-sm mb-2 block" data-translate="form.password">Password</label>
<input type="password" name="password" required minlength="8" class="w-full bg-background-light dark:bg-slate-800 border border-border-light dark:border-slate-700 rounded-lg px-4 py-2 text-text-main dark:text-white focus:border-primary focus:ring-1 focus:ring-primary">
</div>
<div class="flex items-center gap-2">
<input type="checkbox" name="is_admin" id="is-admin-checkbox" class="rounded border-border-light dark:border-slate-700 text-primary focus:ring-primary">
<label for="is-admin-checkbox" class="text-text-main dark:text-white text-sm" data-translate="admin.makeAdmin">Make admin</label>
</div>
<div class="flex gap-3 pt-2">
<button type="submit" class="flex-1 bg-primary hover:bg-primary/90 text-white py-2 rounded-lg font-medium transition-colors">
<span data-translate="admin.create">Create</span>
</button>
<button type="button" onclick="closeCreateUserModal()" class="flex-1 bg-background-light dark:bg-slate-800 text-text-main dark:text-white py-2 rounded-lg font-medium hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors">
<span data-translate="common.cancel">Cancel</span>
</button>
</div>
</form>
</div>
</div>
<script src="{{ url_for('static', filename='js/admin.js') }}"></script>
{% endblock %}

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,257 @@
{% 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 {
border-radius: 25px;
padding: 12px 20px;
font-size: 14px;
transition: all 0.3s ease;
}
.light .login-input {
background: #f8fafc;
border: 1px solid #e2e8f0;
color: #0f172a;
}
.dark .login-input,
:root:not(.light) .login-input {
background: #1e293b;
border: 1px solid #334155;
color: #ffffff;
}
.login-input:focus {
outline: none;
border-color: #3b82f6;
}
.light .login-input:focus {
background: #ffffff;
}
.dark .login-input:focus,
:root:not(.light) .login-input:focus {
background: #1e293b;
}
.light .login-input::placeholder {
color: #94a3b8;
}
.dark .login-input::placeholder,
:root:not(.light) .login-input::placeholder {
color: #64748b;
}
.login-input:-webkit-autofill,
.login-input:-webkit-autofill:hover,
.login-input:-webkit-autofill:focus {
transition: background-color 5000s ease-in-out 0s;
}
.light .login-input:-webkit-autofill,
.light .login-input:-webkit-autofill:hover,
.light .login-input:-webkit-autofill:focus {
-webkit-text-fill-color: #0f172a;
-webkit-box-shadow: 0 0 0px 1000px #f8fafc inset;
}
.dark .login-input:-webkit-autofill,
.dark .login-input:-webkit-autofill:hover,
.dark .login-input:-webkit-autofill:focus,
:root:not(.light) .login-input:-webkit-autofill,
:root:not(.light) .login-input:-webkit-autofill:hover,
:root:not(.light) .login-input:-webkit-autofill:focus {
-webkit-text-fill-color: #ffffff;
-webkit-box-shadow: 0 0 0px 1000px #1e293b inset;
}
.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-background-light dark: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-text-main dark: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-text-muted dark: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-text-muted dark: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-text-muted dark:text-slate-400 hover:text-primary dark: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-text-muted dark: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-text-muted dark:text-slate-300">
<input type="checkbox" name="remember" id="remember" class="mr-2 rounded border-border-light dark:border-slate-500 bg-background-light dark: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-text-muted dark:text-slate-300">
Don't have an account?
<a href="{{ url_for('auth.register') }}" class="text-primary dark:text-blue-400 hover:text-primary/80 dark: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-light dark: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-text-main dark:text-white mb-2">FINA</h1>
<p class="text-text-muted dark:text-[#92adc9]" data-translate="register.tagline">Start managing your finances today</p>
</div>
<!-- Register Form -->
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-[#233648] rounded-2xl p-8">
<h2 class="text-2xl font-bold text-text-main dark:text-white mb-6" data-translate="register.title">Create Account</h2>
<form id="register-form" class="space-y-4">
<div>
<label class="text-text-muted dark:text-[#92adc9] text-sm mb-2 block" data-translate="form.username">Username</label>
<input type="text" name="username" required class="w-full bg-background-light dark:bg-[#111a22] border border-border-light dark:border-[#233648] rounded-lg px-4 py-3 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.email">Email</label>
<input type="email" name="email" required class="w-full bg-background-light dark:bg-[#111a22] border border-border-light dark:border-[#233648] rounded-lg px-4 py-3 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.password">Password</label>
<input type="password" name="password" required minlength="8" class="w-full bg-background-light dark:bg-[#111a22] border border-border-light dark:border-[#233648] rounded-lg px-4 py-3 text-text-main dark:text-white focus:border-primary focus:ring-1 focus:ring-primary">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-text-muted dark:text-[#92adc9] text-sm mb-2 block" data-translate="form.language">Language</label>
<select name="language" class="w-full bg-background-light dark:bg-[#111a22] border border-border-light dark:border-[#233648] rounded-lg px-4 py-3 text-text-main dark: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-text-muted dark:text-[#92adc9] text-sm mb-2 block" data-translate="form.currency">Currency</label>
<select name="currency" class="w-full bg-background-light dark:bg-[#111a22] border border-border-light dark:border-[#233648] rounded-lg px-4 py-3 text-text-main dark: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-text-muted dark: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 %}

199
app/templates/base.html Normal file
View file

@ -0,0 +1,199 @@
<!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') }}">
<!-- Preconnect for faster resource loading -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preconnect" href="https://cdn.tailwindcss.com">
<!-- 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;
}
/* Fix icon picker text overflow */
#icon-grid .material-symbols-outlined {
font-size: 24px !important;
max-width: 32px !important;
max-height: 32px !important;
overflow: hidden !important;
text-overflow: clip !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
}
.glass {
background: rgba(26, 38, 50, 0.7);
backdrop-filter: blur(10px);
border: 1px solid rgba(35, 54, 72, 0.5);
}
/* Optimize rendering */
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>
{% block extra_css %}{% endblock %}
<!-- Prevent theme flashing by applying theme before page render -->
<script>
(function() {
const theme = localStorage.getItem('theme') || 'dark';
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
{% if current_user.is_authenticated %}
// Sync user language preference from database to localStorage
const dbLanguage = '{{ current_user.language }}';
const storedLanguage = localStorage.getItem('language');
// Always use database language as source of truth
if (dbLanguage && dbLanguage !== storedLanguage) {
localStorage.setItem('language', dbLanguage);
}
{% endif %}
})();
</script>
</head>
<body class="bg-background-light dark:bg-background-dark text-text-main dark:text-white font-display overflow-hidden">
{% block body %}{% endblock %}
<!-- Global Search Modal -->
<div id="global-search-modal" class="hidden fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-start justify-center pt-20 opacity-0 transition-opacity duration-200">
<div class="w-full max-w-2xl mx-4 bg-card-light dark:bg-card-dark rounded-2xl shadow-2xl overflow-hidden">
<!-- Search Input -->
<div class="p-4 border-b border-border-light dark:border-[#233648]">
<div class="relative">
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-text-muted dark:text-[#92adc9]">search</span>
<input
type="text"
id="global-search-input"
placeholder="Search everything..."
data-translate-placeholder="search.inputPlaceholder"
class="w-full bg-slate-50 dark:bg-[#111a22] border border-border-light dark:border-[#233648] rounded-lg pl-10 pr-10 py-3 text-text-main dark:text-white focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
<button id="global-search-close" class="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white transition-colors">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div class="mt-2 text-xs text-text-muted dark:text-[#92adc9] flex items-center gap-4">
<span data-translate="search.pressEnter">Press Enter to search</span>
<span></span>
<span data-translate="search.pressEsc">ESC to close</span>
</div>
</div>
<!-- Search Results -->
<div id="global-search-results" class="max-h-[60vh] overflow-y-auto">
<div class="p-8 text-center">
<span class="material-symbols-outlined text-5xl text-text-muted dark:text-[#92adc9] opacity-50 mb-3">search</span>
<p class="text-text-muted dark:text-[#92adc9] text-sm" data-translate="search.placeholder">Search for transactions, documents, categories, or features</p>
<p class="text-text-muted dark:text-[#92adc9] text-xs mt-2" data-translate="search.hint">Press Ctrl+K to open search</p>
</div>
</div>
</div>
</div>
<!-- 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/i18n.js') }}?v=2.0.3"></script>
<script src="{{ url_for('static', filename='js/app.js') }}?v=2.0.3"></script>
<script src="{{ url_for('static', filename='js/search.js') }}?v=2.0.3"></script>
<script src="{{ url_for('static', filename='js/budget.js') }}?v=2.0.3"></script>
<script src="{{ url_for('static', filename='js/notifications.js') }}?v=2.0.3"></script>
<script src="{{ url_for('static', filename='js/pwa.js') }}?v=2.0.3"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,434 @@
{% extends "base.html" %}
{% block title %}Dashboard - FINA{% endblock %}
{% block head %}
<style>
/* Custom scrollbar for pie chart legend */
#pie-legend::-webkit-scrollbar {
width: 4px;
}
#pie-legend::-webkit-scrollbar-track {
background: transparent;
}
#pie-legend::-webkit-scrollbar-thumb {
background: #92adc9;
border-radius: 2px;
}
#pie-legend::-webkit-scrollbar-thumb:hover {
background: #5f7a96;
}
.dark #pie-legend::-webkit-scrollbar-thumb {
background: #233648;
}
.dark #pie-legend::-webkit-scrollbar-thumb:hover {
background: #324d67;
}
/* Smooth transform for drag and drop */
.category-card {
transition: transform 0.2s ease, opacity 0.2s ease, box-shadow 0.2s ease;
}
/* Touch user select - prevent text selection during hold */
.category-card {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
</style>
{% 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 %}<span data-translate="user.admin">Admin</span>{% else %}<span data-translate="user.user">User</span>{% 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.income') }}">
<span class="material-symbols-outlined text-[20px]">payments</span>
<span class="text-sm font-medium" data-translate="nav.income">Income</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.recurring') }}">
<span class="material-symbols-outlined text-[20px]">repeat</span>
<span class="text-sm font-medium" data-translate="nav.recurring">Recurring</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.import_page') }}">
<span class="material-symbols-outlined text-[20px]">file_upload</span>
<span class="text-sm font-medium" data-translate="nav.import">Import CSV</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" data-translate="dashboard.lightMode">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">
<!-- Global Search Button -->
<button id="global-search-btn" 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] hover:border-primary transition-colors w-64 text-left">
<span class="material-symbols-outlined text-text-muted dark:text-[#92adc9] text-[20px]">search</span>
<span class="text-sm text-text-muted dark:text-[#92adc9] ml-2 flex-1" data-translate="search.inputPlaceholder">Search everything...</span>
<kbd class="hidden lg:inline-block px-2 py-0.5 text-xs font-semibold text-text-muted dark:text-[#92adc9] bg-slate-100 dark:bg-[#111a22] border border-border-light dark:border-[#233648] rounded">⌘K</kbd>
</button>
<!-- 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-2 lg:grid-cols-4 gap-4 lg:gap-6">
<!-- Total Income -->
<div class="p-6 rounded-xl bg-white dark:bg-card-dark border border-green-500/20 dark:border-green-500/30 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-green-500">trending_up</span>
</div>
<div>
<p class="text-text-muted dark:text-[#92adc9] text-sm font-medium" data-translate="dashboard.total_income">Total Income</p>
<h3 id="total-income" class="text-green-600 dark:text-green-400 text-3xl font-bold mt-2 tracking-tight">$0.00</h3>
</div>
<p class="text-text-muted dark:text-[#5f7a96] text-xs mt-4" data-translate="dashboard.this_month">this month</p>
</div>
<!-- Total Spent -->
<div class="p-6 rounded-xl bg-white dark:bg-card-dark border border-red-500/20 dark:border-red-500/30 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-red-500">trending_down</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-red-600 dark:text-red-400 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>
<!-- Profit/Loss -->
<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">account_balance</span>
</div>
<div>
<p class="text-text-muted dark:text-[#92adc9] text-sm font-medium" data-translate="dashboard.profit_loss">Profit/Loss</p>
<h3 id="profit-loss" class="text-text-main dark:text-white text-3xl font-bold mt-2 tracking-tight">$0.00</h3>
</div>
<p class="text-text-muted dark:text-[#5f7a96] text-xs mt-4" data-translate="dashboard.this_month">this month</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-4" data-translate="dashboard.this_month">this month</p>
</div>
</div>
<!-- Charts Row -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 lg:gap-6">
<!-- Spending by Category - Smaller, Compact -->
<div class="p-5 rounded-xl bg-white dark:bg-card-dark border border-border-light dark:border-[#233648] shadow-sm dark:shadow-none flex flex-col">
<h3 class="text-text-main dark:text-white text-base font-bold mb-1" data-translate="dashboard.spending_by_category">Spending by Category</h3>
<p class="text-text-muted dark:text-[#92adc9] text-xs mb-4" data-translate="dashboard.categoryBreakdownDesc">Breakdown by category</p>
<div class="flex items-center justify-center relative mb-4">
<!-- CSS Conic Gradient Pie Chart - Smaller Size -->
<div id="pie-chart-wrapper" class="relative flex items-center justify-center">
<div id="pie-chart" class="size-40 rounded-full relative transition-all duration-500" style="background: conic-gradient(#233648 0% 100%);">
<!-- Inner hole for donut effect -->
<div class="absolute inset-3 bg-white dark:bg-card-dark rounded-full flex flex-col items-center justify-center z-10 border border-border-light dark:border-[#233648]">
<span class="text-text-muted dark:text-[#92adc9] text-[10px] font-medium" data-translate="dashboard.totalThisYear">Total This Year</span>
<span id="pie-total" class="text-text-main dark:text-white text-base font-bold">0 lei</span>
</div>
</div>
</div>
</div>
<!-- Legend - Scrollable for 12-14 categories -->
<div id="pie-legend" class="grid grid-cols-1 gap-y-1.5 max-h-[180px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-border-light dark:scrollbar-thumb-[#233648] scrollbar-track-transparent">
<!-- Legend items will be generated here -->
</div>
</div>
<!-- Monthly Trend - Larger Space for 12 Months -->
<div class="lg:col-span-2 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: 320px;"></canvas>
</div>
</div>
<!-- Expense Categories -->
<div>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<h3 class="text-text-main dark:text-white text-lg font-bold" data-translate="dashboard.expenseCategories">Expense Categories</h3>
<span class="material-symbols-outlined text-text-muted dark:text-[#92adc9] text-[18px]" title="Drag to reorder">drag_indicator</span>
</div>
<div class="flex items-center gap-3">
<button id="manage-categories-btn" class="text-primary hover:text-primary/80 text-sm font-medium flex items-center gap-1">
<span class="material-symbols-outlined text-[18px]">tune</span>
<span data-translate="dashboard.manageCategories">Manage</span>
</button>
<a href="{{ url_for('main.transactions') }}" class="text-primary text-sm font-medium hover:text-primary/80" data-translate="dashboard.view_all">View All</a>
</div>
</div>
<div id="category-cards" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Category cards will be loaded here -->
</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" id="expense-description" 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="" data-translate="dashboard.selectCategory">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" id="expense-tags" name="tags" placeholder="coffee, dining, work..." 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>
<!-- Category Management Modal -->
<div id="category-modal" class="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
<div class="bg-white dark:bg-card-dark rounded-2xl max-w-4xl w-full max-h-[90vh] overflow-hidden shadow-2xl">
<div class="p-6 border-b border-border-light dark:border-[#233648] flex justify-between items-center">
<h3 class="text-text-main dark:text-white text-xl font-bold" data-translate="categories.manageTitle">Manage Categories</h3>
<button id="close-category-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>
<div class="p-6 overflow-y-auto max-h-[calc(90vh-140px)]">
<!-- Add New Category Form -->
<div class="mb-6 p-4 bg-slate-50 dark:bg-[#111a22] rounded-lg border border-border-light dark:border-[#233648]">
<h4 class="text-text-main dark:text-white font-semibold mb-4" data-translate="categories.addNew">Add New Category</h4>
<form id="add-category-form" class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label class="text-text-muted dark:text-[#92adc9] text-sm mb-2 block" data-translate="form.name">Name</label>
<input type="text" name="name" required class="w-full bg-white dark:bg-card-dark border border-border-light dark:border-[#233648] rounded-lg px-4 py-2 text-text-main dark:text-white" />
</div>
<div>
<label class="text-text-muted dark:text-[#92adc9] text-sm mb-2 block" data-translate="form.color">Color</label>
<input type="color" name="color" value="#2b8cee" class="w-full h-10 bg-white dark:bg-card-dark border border-border-light dark:border-[#233648] rounded-lg px-2 cursor-pointer" />
</div>
<div>
<label class="text-text-muted dark:text-[#92adc9] text-sm mb-2 block" data-translate="form.icon">Icon</label>
<input type="hidden" name="icon" value="category" />
<button type="button" onclick="openIconPicker('add-form')"
class="w-full h-10 bg-white dark:bg-card-dark border border-border-light dark:border-[#233648] rounded-lg px-4 flex items-center justify-between hover:border-primary/50 transition-colors">
<div class="flex items-center gap-2">
<span id="add-form-icon-preview" class="material-symbols-outlined text-primary">category</span>
<span id="add-form-icon-name" class="text-text-main dark:text-white text-sm">category</span>
</div>
<span class="material-symbols-outlined text-text-muted dark:text-[#92adc9] text-[16px]">expand_more</span>
</button>
</div>
<div class="flex items-end">
<button type="submit" class="w-full bg-primary hover:bg-primary/90 text-white py-2 rounded-lg font-semibold transition-colors flex items-center justify-center gap-2">
<span class="material-symbols-outlined text-[18px]">add</span>
<span data-translate="categories.add">Add</span>
</button>
</div>
</form>
</div>
<!-- Category List with Drag & Drop -->
<div>
<div class="flex items-center justify-between mb-4">
<h4 class="text-text-main dark:text-white font-semibold" data-translate="categories.yourCategories">Your Categories</h4>
<p class="text-text-muted dark:text-[#92adc9] text-sm" data-translate="categories.dragToReorder">Drag to reorder</p>
</div>
<div id="categories-list" class="space-y-2">
<!-- Categories will be loaded here -->
</div>
</div>
</div>
</div>
</div>
<!-- Icon Picker Modal -->
<div id="icon-picker-modal" class="hidden fixed inset-0 bg-black/60 z-[60] flex items-center justify-center p-4">
<div class="bg-white dark:bg-card-dark rounded-2xl max-w-3xl w-full max-h-[85vh] overflow-hidden shadow-2xl border border-border-light dark:border-[#233648] relative">
<div class="p-4 border-b border-border-light dark:border-[#233648] flex justify-between items-center sticky top-0 bg-white dark:bg-card-dark z-10">
<div class="flex-1">
<h3 class="text-text-main dark:text-white text-lg font-bold mb-2" data-translate="categories.selectIcon">Select Icon</h3>
<input type="text" id="icon-search" placeholder="Search icons..."
class="w-full bg-slate-50 dark:bg-background-dark border border-border-light dark:border-[#233648] rounded-lg px-3 py-2 text-sm text-text-main dark:text-white focus:outline-none focus:ring-2 focus:ring-primary"
data-translate-placeholder="categories.searchIcons" />
</div>
<button onclick="closeIconPicker()" class="ml-4 text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div class="p-4 overflow-y-auto max-h-[calc(85vh-140px)] bg-white dark:bg-card-dark">
<div id="icon-grid" class="grid grid-cols-5 sm:grid-cols-7 md:grid-cols-9 gap-2 relative z-10">
<!-- Icons will be populated by JavaScript -->
</div>
</div>
</div>
</div>
<!-- Category Expenses Modal -->
<div id="category-expenses-modal" class="hidden fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4" onclick="if (event.target === this) closeCategoryExpensesModal()">
<div class="bg-white dark:bg-card-dark rounded-2xl max-w-4xl w-full max-h-[90vh] overflow-hidden shadow-2xl border border-border-light dark:border-[#233648]">
<div class="p-6 border-b border-border-light dark:border-[#233648] flex justify-between items-center sticky top-0 bg-white dark:bg-card-dark z-10">
<div class="flex items-center gap-3">
<div id="modal-category-icon-container" class="w-10 h-10 rounded-lg flex items-center justify-center">
<span id="modal-category-icon" class="material-symbols-outlined text-white text-[20px]"></span>
</div>
<div>
<h3 id="modal-category-name" class="text-text-main dark:text-white text-lg font-bold"></h3>
<p id="modal-category-count" class="text-text-muted dark:text-[#92adc9] text-sm"></p>
</div>
</div>
<button onclick="closeCategoryExpensesModal()" class="text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white transition-colors">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div class="p-6 overflow-y-auto max-h-[calc(90vh-120px)]">
<div id="modal-expenses-list" class="space-y-3">
<!-- Expenses will be loaded here -->
</div>
<div id="modal-expenses-empty" class="hidden text-center py-12">
<span class="material-symbols-outlined text-[48px] text-text-muted dark:text-[#92adc9] mb-3">inbox</span>
<p class="text-text-muted dark:text-[#92adc9]" data-translate="categories.noExpenses">No expenses in this category</p>
</div>
<div id="modal-expenses-loading" class="hidden text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
</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') }}?v=2.0.3"></script>
{% endblock %}

View file

@ -0,0 +1,149 @@
{% 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.income') }}">
<span class="material-symbols-outlined text-[20px]">payments</span>
<span class="text-sm font-medium" data-translate="nav.income">Income</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.recurring') }}">
<span class="material-symbols-outlined text-[20px]">repeat</span>
<span class="text-sm font-medium" data-translate="nav.recurring">Recurring</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.import_page') }}">
<span class="material-symbols-outlined text-[20px]">file_upload</span>
<span class="text-sm font-medium" data-translate="nav.import">Import CSV</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>
{% 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-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.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>
<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-background-light 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" data-translate="dashboard.lightMode">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-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 %}

114
app/templates/import.html Normal file
View file

@ -0,0 +1,114 @@
{% extends "base.html" %}
{% block title %}Import CSV - 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.income') }}">
<span class="material-symbols-outlined text-[20px]">payments</span>
<span class="text-sm font-medium" data-translate="nav.income">Income</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.recurring') }}">
<span class="material-symbols-outlined text-[20px]">repeat</span>
<span class="text-sm font-medium" data-translate="nav.recurring">Recurring</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.import_page') }}">
<span class="material-symbols-outlined text-[20px]">file_upload</span>
<span class="text-sm font-medium" data-translate="nav.import">Import CSV</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>
{% 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-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.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>
<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-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors">
<span id="theme-icon" class="material-symbols-outlined text-[20px]">dark_mode</span>
<span id="theme-text" class="text-sm font-medium" data-translate="dashboard.darkMode">Dark 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-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-red-500 dark:text-red-400 hover:bg-red-500/10 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 -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Mobile Header -->
<div class="lg:hidden bg-white dark:bg-[#0f1921] border-b border-border-light dark:border-[#233648] p-4 flex items-center justify-between">
<button id="menu-toggle" class="text-text-main dark:text-white">
<span class="material-symbols-outlined">menu</span>
</button>
<h1 class="text-lg font-bold text-text-main dark:text-white" data-translate="nav.import">Import CSV</h1>
<div class="w-6"></div>
</div>
<div class="flex-1 overflow-y-auto bg-background-light dark:bg-background-dark pb-20">
<!-- Header -->
<div class="bg-white dark:bg-[#0f1921] border-b border-border-light dark:border-[#233648] p-6">
<div class="max-w-4xl mx-auto">
<h1 class="text-2xl font-bold text-text-main dark:text-white mb-2">
<span class="material-symbols-outlined align-middle mr-2">file_upload</span>
<span id="importTitle">Import CSV</span>
</h1>
<p class="text-text-muted dark:text-[#92adc9]" id="importSubtitle">
Import your bank statements or expense CSV files
</p>
</div>
</div>
<!-- Main Content -->
<div class="max-w-4xl mx-auto p-6">
<div id="importContainer">
<!-- Import UI will be rendered here by import.js -->
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='js/import.js') }}"></script>
{% endblock %}

320
app/templates/income.html Normal file
View file

@ -0,0 +1,320 @@
{% extends "base.html" %}
{% block title %}Income - 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 %}<span data-translate="user.admin">Admin</span>{% else %}<span data-translate="user.user">User</span>{% 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 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.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 bg-primary/10 text-primary border border-primary/10" href="{{ url_for('main.income') }}">
<span class="material-symbols-outlined text-[20px]">payments</span>
<span class="text-sm font-medium" data-translate="nav.income">Income</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.recurring') }}">
<span class="material-symbols-outlined text-[20px]">repeat</span>
<span class="text-sm font-medium" data-translate="nav.recurring">Recurring</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.import_page') }}">
<span class="material-symbols-outlined text-[20px]">file_upload</span>
<span class="text-sm font-medium" data-translate="nav.import">Import CSV</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">
<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-red-500 dark:hover:text-red-400 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">Logout</span>
</a>
</div>
</div>
</aside>
<!-- Main Content Area -->
<div class="flex-1 flex flex-col min-h-screen overflow-hidden">
<!-- Top Bar (Mobile) -->
<header class="lg:hidden bg-white dark:bg-card-dark border-b border-border-light dark:border-[#233648] p-4 flex items-center justify-between">
<button id="mobile-menu-toggle" class="text-text-main dark:text-white p-2 hover:bg-slate-100 dark:hover:bg-[#233648] rounded-lg transition-colors">
<span class="material-symbols-outlined text-[24px]">menu</span>
</button>
<h1 class="text-lg font-bold text-text-main dark:text-white" data-translate="income.title">Income</h1>
<div class="w-10"></div>
</header>
<!-- Content -->
<main class="flex-1 overflow-y-auto bg-background-light dark:bg-background-dark p-4 md:p-6 lg:p-8">
<div class="max-w-7xl mx-auto">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6 gap-4">
<div>
<h1 class="text-2xl md:text-3xl font-bold text-text-main dark:text-white mb-2" data-translate="income.title">Income</h1>
<p class="text-text-muted dark:text-[#92adc9]" data-translate="income.subtitle">Track your income sources</p>
</div>
<button onclick="openIncomeModal()" class="inline-flex items-center gap-2 bg-primary hover:bg-primary/90 text-white px-6 py-3 rounded-xl font-medium transition-all hover:shadow-lg">
<span class="material-symbols-outlined text-[20px]">add</span>
<span data-translate="income.addNew">Add Income</span>
</button>
</div>
<!-- Income Table -->
<div class="bg-white dark:bg-card-dark rounded-2xl border border-border-light dark:border-[#233648] overflow-hidden shadow-sm">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-slate-50 dark:bg-[#111a22] border-b border-border-light dark:border-[#233648]">
<tr>
<th class="px-6 py-4 text-left text-xs font-medium text-text-muted dark:text-[#92adc9] uppercase tracking-wider" data-translate="income.tableDescription">Description</th>
<th class="px-6 py-4 text-left text-xs font-medium text-text-muted dark:text-[#92adc9] uppercase tracking-wider" data-translate="income.tableDate">Date</th>
<th class="px-6 py-4 text-left text-xs font-medium text-text-muted dark:text-[#92adc9] uppercase tracking-wider" data-translate="income.tableSource">Source</th>
<th class="px-6 py-4 text-right text-xs font-medium text-text-muted dark:text-[#92adc9] uppercase tracking-wider" data-translate="income.tableAmount">Amount</th>
<th class="px-6 py-4 text-right text-xs font-medium text-text-muted dark:text-[#92adc9] uppercase tracking-wider" data-translate="income.tableActions">Actions</th>
</tr>
</thead>
<tbody id="income-table-body">
<!-- Income entries will be populated by JavaScript -->
</tbody>
</table>
</div>
</div>
</div>
</main>
</div>
</div>
<!-- Mobile Menu Overlay -->
<div id="mobile-menu" class="lg:hidden hidden fixed inset-0 bg-black/60 backdrop-blur-sm z-50">
<aside class="w-64 h-full bg-sidebar-light dark:bg-card-dark border-r border-border-light dark:border-[#233648] overflow-y-auto">
<div class="p-6 flex flex-col h-full justify-between">
<div class="flex flex-col gap-8">
<!-- Close Button -->
<button id="mobile-menu-close" class="self-end text-text-main dark:text-white p-2 hover:bg-slate-100 dark:hover:bg-[#233648] rounded-lg transition-colors">
<span class="material-symbols-outlined text-[24px]">close</span>
</button>
<!-- 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 %}<span data-translate="user.admin">Admin</span>{% else %}<span data-translate="user.user">User</span>{% 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 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.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 bg-primary/10 text-primary border border-primary/10" href="{{ url_for('main.income') }}">
<span class="material-symbols-outlined text-[20px]">payments</span>
<span class="text-sm font-medium" data-translate="nav.income">Income</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.recurring') }}">
<span class="material-symbols-outlined text-[20px]">repeat</span>
<span class="text-sm font-medium" data-translate="nav.recurring">Recurring</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.import_page') }}">
<span class="material-symbols-outlined text-[20px]">file_upload</span>
<span class="text-sm font-medium" data-translate="nav.import">Import CSV</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">
<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-red-500 dark:hover:text-red-400 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">Logout</span>
</a>
</div>
</div>
</aside>
</div>
<!-- Income Modal -->
<div id="income-modal" class="hidden fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4" onclick="if (event.target === this) closeIncomeModal()">
<div class="bg-white dark:bg-card-dark rounded-2xl max-w-lg w-full border border-border-light dark:border-[#233648] shadow-2xl">
<div class="p-6 border-b border-border-light dark:border-[#233648]">
<h3 id="income-modal-title" class="text-xl font-bold text-text-main dark:text-white" data-translate="income.add">Add Income</h3>
</div>
<form id="income-form" class="p-6">
<div class="space-y-4">
<!-- Amount -->
<div>
<label for="income-amount" class="block text-sm font-medium text-text-main dark:text-white mb-2" data-translate="form.amount">Amount</label>
<input type="number" id="income-amount" step="0.01" min="0" required
class="w-full bg-slate-50 dark:bg-background-dark border border-border-light dark:border-[#233648] rounded-lg px-4 py-3 text-text-main dark:text-white focus:outline-none focus:ring-2 focus:ring-primary">
</div>
<!-- Source -->
<div>
<label for="income-source" class="block text-sm font-medium text-text-main dark:text-white mb-2" data-translate="income.source">Source</label>
<select id="income-source" required class="income-source-select w-full bg-slate-50 dark:bg-background-dark border border-border-light dark:border-[#233648] rounded-lg px-4 py-3 text-text-main dark:text-white focus:outline-none focus:ring-2 focus:ring-primary">
<option value="">Select source...</option>
</select>
</div>
<!-- Description -->
<div>
<label for="income-description" class="block text-sm font-medium text-text-main dark:text-white mb-2" data-translate="form.description">Description</label>
<input type="text" id="income-description" required
class="w-full bg-slate-50 dark:bg-background-dark border border-border-light dark:border-[#233648] rounded-lg px-4 py-3 text-text-main dark:text-white focus:outline-none focus:ring-2 focus:ring-primary">
</div>
<!-- Date -->
<div>
<label for="income-date" class="block text-sm font-medium text-text-main dark:text-white mb-2" data-translate="form.date">Date</label>
<input type="date" id="income-date" required
class="w-full bg-slate-50 dark:bg-background-dark border border-border-light dark:border-[#233648] rounded-lg px-4 py-3 text-text-main dark:text-white focus:outline-none focus:ring-2 focus:ring-primary">
</div>
<!-- Frequency (Recurring) -->
<div>
<label for="income-frequency" class="block text-sm font-medium text-text-main dark:text-white mb-2" data-translate="income.frequency">Payment Frequency</label>
<select id="income-frequency" class="w-full bg-slate-50 dark:bg-background-dark border border-border-light dark:border-[#233648] rounded-lg px-4 py-3 text-text-main dark:text-white focus:outline-none focus:ring-2 focus:ring-primary">
<option value="once" data-translate="income.once">One-time</option>
<option value="weekly" data-translate="income.weekly">Weekly</option>
<option value="biweekly" data-translate="income.biweekly">Every 2 Weeks</option>
<option value="every4weeks" data-translate="income.every4weeks">Every 4 Weeks</option>
<option value="monthly" data-translate="income.monthly">Monthly</option>
<option value="custom" data-translate="income.custom">Custom (Freelance)</option>
</select>
</div>
<!-- Custom Frequency (shown when custom is selected) -->
<div id="custom-frequency-container" class="hidden">
<label for="income-custom-days" class="block text-sm font-medium text-text-main dark:text-white mb-2" data-translate="income.customDays">Custom Days Interval</label>
<input type="number" id="income-custom-days" min="1" placeholder="Number of days"
class="w-full bg-slate-50 dark:bg-background-dark border border-border-light dark:border-[#233648] rounded-lg px-4 py-3 text-text-main dark:text-white focus:outline-none focus:ring-2 focus:ring-primary">
<p class="text-xs text-text-muted dark:text-[#92adc9] mt-1" data-translate="income.customHelp">Enter the number of days between payments</p>
</div>
<!-- Auto-create recurring income -->
<div id="auto-create-container" class="bg-slate-50 dark:bg-[#111a22] rounded-lg p-4 border border-border-light dark:border-[#233648]">
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox" id="income-auto-create" class="mt-1 w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary focus:ring-offset-0">
<div class="flex flex-col">
<span class="text-sm font-medium text-text-main dark:text-white" data-translate="income.autoCreate">Automatically create income entries</span>
<p class="text-xs text-text-muted dark:text-[#92adc9] mt-1" data-translate="income.autoCreateHelp">When enabled, income entries will be created automatically based on the frequency. You can edit or cancel at any time.</p>
</div>
</label>
</div>
<!-- Tags -->
<div>
<label for="income-tags" class="block text-sm font-medium text-text-main dark:text-white mb-2" data-translate="form.tags">Tags (comma separated)</label>
<input type="text" id="income-tags"
class="w-full bg-slate-50 dark:bg-background-dark border border-border-light dark:border-[#233648] rounded-lg px-4 py-3 text-text-main dark:text-white focus:outline-none focus:ring-2 focus:ring-primary">
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="flex-1 bg-primary hover:bg-primary/90 text-white px-6 py-3 rounded-xl font-medium transition-all">
<span data-translate="income.save">Save Income</span>
</button>
<button type="button" onclick="closeIncomeModal()" class="flex-1 bg-slate-100 dark:bg-[#111a22] hover:bg-slate-200 dark:hover:bg-[#1a2632] text-text-main dark:text-white px-6 py-3 rounded-xl font-medium transition-all">
<span data-translate="common.cancel">Cancel</span>
</button>
</div>
</form>
</div>
</div>
<script src="{{ url_for('static', filename='js/income.js') }}"></script>
<script>
// Mobile menu toggle
document.addEventListener('DOMContentLoaded', () => {
const mobileMenuToggle = document.getElementById('mobile-menu-toggle');
const mobileMenu = document.getElementById('mobile-menu');
const mobileMenuClose = document.getElementById('mobile-menu-close');
if (mobileMenuToggle && mobileMenu) {
mobileMenuToggle.addEventListener('click', () => {
mobileMenu.classList.remove('hidden');
});
}
if (mobileMenuClose && mobileMenu) {
mobileMenuClose.addEventListener('click', () => {
mobileMenu.classList.add('hidden');
});
}
// Close mobile menu when clicking outside
if (mobileMenu) {
mobileMenu.addEventListener('click', (e) => {
if (e.target === mobileMenu) {
mobileMenu.classList.add('hidden');
}
});
}
});
</script>
{% endblock %}

121
app/templates/landing.html Normal file
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,241 @@
{% extends "base.html" %}
{% block title %}Recurring Expenses - 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">
<div class="relative">
<img src="{{ current_user.avatar | avatar_url }}" alt="Avatar" class="size-12 rounded-full object-cover border-2 border-primary">
</div>
<div class="flex-1 min-w-0">
<h2 class="text-sm font-semibold text-text-main dark:text-white truncate">{{ current_user.username }}</h2>
<p class="text-xs text-text-muted dark:text-[#92adc9] flex items-center gap-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>
<!-- Navigation Links -->
<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-slate-50 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-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.income') }}">
<span class="material-symbols-outlined text-[20px]">payments</span>
<span class="text-sm font-medium" data-translate="nav.income">Income</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="{{ url_for('main.recurring') }}">
<span class="material-symbols-outlined text-[20px]">repeat</span>
<span class="text-sm font-medium" data-translate="nav.recurring">Recurring</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.import_page') }}">
<span class="material-symbols-outlined text-[20px]">file_upload</span>
<span class="text-sm font-medium" data-translate="nav.import">Import CSV</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" data-translate="dashboard.lightMode">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 Area -->
<div class="flex-1 flex flex-col overflow-hidden">
<div class="flex-1 overflow-auto bg-background-light dark:bg-background-dark">
<!-- Header -->
<div class="bg-white dark:bg-[#0f1419] border-b border-gray-200 dark:border-white/10 sticky top-0 z-10">
<div class="max-w-7xl mx-auto px-6 py-4">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-text-main dark:text-white" data-translate="recurring.title">Recurring Expenses</h1>
<p class="text-text-muted dark:text-[#92adc9] text-sm mt-1" data-translate="recurring.subtitle">Manage subscriptions and recurring bills</p>
</div>
<div class="flex items-center gap-3">
<button onclick="detectRecurringPatterns()" id="detect-btn"
class="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors flex items-center gap-2">
<span class="material-symbols-outlined text-[20px]">auto_awesome</span>
<span data-translate="recurring.detect">Detect Patterns</span>
</button>
<button onclick="showAddRecurringModal()"
class="px-4 py-2 bg-primary hover:bg-primary-dark text-white rounded-lg transition-colors flex items-center gap-2">
<span class="material-symbols-outlined text-[20px]">add</span>
<span data-translate="recurring.addNew">Add Recurring</span>
</button>
</div>
</div>
</div>
</div>
<!-- Main Content -->
<div class="max-w-7xl mx-auto px-6 py-8">
<!-- Suggestions Section (Hidden by default) -->
<div id="suggestions-section" class="hidden mb-8">
<div class="bg-gradient-to-r from-blue-500/10 to-purple-500/10 border border-blue-500/20 rounded-xl p-6 mb-4">
<div class="flex items-start gap-3 mb-4">
<span class="material-symbols-outlined text-blue-400 text-[28px]">auto_awesome</span>
<div>
<h2 class="text-lg font-semibold text-text-main dark:text-white mb-1" data-translate="recurring.suggestionsTitle">Detected Recurring Patterns</h2>
<p class="text-text-muted dark:text-[#92adc9] text-sm" data-translate="recurring.suggestionsDesc">We found these potential recurring expenses based on your transaction history</p>
</div>
</div>
<div id="suggestions-list" class="space-y-3"></div>
</div>
</div>
<!-- Recurring Expenses List -->
<div class="bg-white dark:bg-[#0f1419] border border-gray-200 dark:border-white/10 rounded-xl overflow-hidden">
<div id="recurring-list" class="p-6"></div>
</div>
</div>
</div>
<!-- Add/Edit Recurring Modal -->
<div id="add-recurring-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-[#0f1419] rounded-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div class="sticky top-0 bg-white dark:bg-[#0f1419] border-b border-gray-200 dark:border-white/10 px-6 py-4 flex items-center justify-between">
<h2 id="modal-title" class="text-xl font-bold text-text-main dark:text-white" data-translate="recurring.add">Add Recurring Expense</h2>
<button onclick="closeRecurringModal()" class="text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white transition-colors">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<form id="recurring-form" class="p-6 space-y-5">
<input type="hidden" id="recurring-id">
<div>
<label class="block text-sm font-medium text-text-main dark:text-white mb-2" data-translate="recurring.name">
Name
</label>
<input type="text" id="recurring-name" required
class="w-full px-4 py-2.5 bg-gray-50 dark:bg-[#1a2632] border border-gray-200 dark:border-white/10 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent text-text-main dark:text-white"
placeholder="e.g., Netflix Subscription">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-text-main dark:text-white mb-2" data-translate="form.amount">
Amount
</label>
<input type="number" id="recurring-amount" step="0.01" min="0" required
class="w-full px-4 py-2.5 bg-gray-50 dark:bg-[#1a2632] border border-gray-200 dark:border-white/10 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent text-text-main dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-text-main dark:text-white mb-2" data-translate="form.category">
Category
</label>
<select id="recurring-category" required
class="w-full px-4 py-2.5 bg-gray-50 dark:bg-[#1a2632] border border-gray-200 dark:border-white/10 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent text-text-main dark:text-white">
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-text-main dark:text-white mb-2" data-translate="recurring.frequency">
Frequency
</label>
<select id="recurring-frequency" required
class="w-full px-4 py-2.5 bg-gray-50 dark:bg-[#1a2632] border border-gray-200 dark:border-white/10 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent text-text-main dark:text-white">
<option value="daily" data-translate="recurring.frequency.daily">Daily</option>
<option value="weekly" data-translate="recurring.frequency.weekly">Weekly</option>
<option value="monthly" selected data-translate="recurring.frequency.monthly">Monthly</option>
<option value="yearly" data-translate="recurring.frequency.yearly">Yearly</option>
</select>
</div>
<div id="day-container" class="hidden">
<label id="day-label" class="block text-sm font-medium text-text-main dark:text-white mb-2">
Day
</label>
<input type="number" id="recurring-day" min="1" max="28"
class="w-full px-4 py-2.5 bg-gray-50 dark:bg-[#1a2632] border border-gray-200 dark:border-white/10 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent text-text-main dark:text-white">
</div>
</div>
<div>
<label class="block text-sm font-medium text-text-main dark:text-white mb-2" data-translate="recurring.nextDue">
Next Due Date
</label>
<input type="date" id="recurring-next-due" required
class="w-full px-4 py-2.5 bg-gray-50 dark:bg-[#1a2632] border border-gray-200 dark:border-white/10 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent text-text-main dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-text-main dark:text-white mb-2" data-translate="recurring.notes">
Notes (optional)
</label>
<textarea id="recurring-notes" rows="2"
class="w-full px-4 py-2.5 bg-gray-50 dark:bg-[#1a2632] border border-gray-200 dark:border-white/10 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent text-text-main dark:text-white resize-none"></textarea>
</div>
<div class="flex items-center gap-2 p-4 bg-blue-500/5 border border-blue-500/20 rounded-lg">
<input type="checkbox" id="recurring-auto-create"
class="size-5 rounded border-gray-300 text-primary focus:ring-primary">
<div class="flex-1">
<label for="recurring-auto-create" class="text-sm font-medium text-text-main dark:text-white cursor-pointer" data-translate="recurring.autoCreate">
Auto-create expenses
</label>
<p class="text-xs text-text-muted dark:text-[#92adc9]" data-translate="recurring.autoCreateDesc">
Automatically create an expense when due date arrives
</p>
</div>
</div>
<div class="flex items-center gap-3 pt-4">
<button type="submit" id="recurring-submit-btn"
class="flex-1 px-6 py-3 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors">
<span data-translate="actions.save">Save</span>
</button>
<button type="button" onclick="closeRecurringModal()"
class="px-6 py-3 bg-gray-100 dark:bg-[#1a2632] hover:bg-gray-200 dark:hover:bg-[#243040] text-text-main dark:text-white rounded-lg font-medium transition-colors">
<span data-translate="actions.cancel">Cancel</span>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/recurring.js') }}"></script>
{% endblock %}

301
app/templates/reports.html Normal file
View file

@ -0,0 +1,301 @@
{% 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 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.income') }}">
<span class="material-symbols-outlined text-[20px]">payments</span>
<span class="text-sm font-medium" data-translate="nav.income">Income</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.recurring') }}">
<span class="material-symbols-outlined text-[20px]">repeat</span>
<span class="text-sm font-medium" data-translate="nav.recurring">Recurring</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.import_page') }}">
<span class="material-symbols-outlined text-[20px]">file_upload</span>
<span class="text-sm font-medium" data-translate="nav.import">Import CSV</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>
{% 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-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.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>
<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-background-light 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" data-translate="dashboard.lightMode">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-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-5 gap-6">
<!-- Total Income -->
<div class="bg-card-light dark:bg-card-dark p-5 rounded-xl border border-green-500/20 dark:border-green-500/30 shadow-sm hover:border-green-500/50 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.totalIncome">Total Income</span>
<h4 id="total-income" class="text-2xl font-bold text-green-600 dark:text-green-400 mt-1">$0.00</h4>
</div>
<div class="p-2 bg-green-500/10 rounded-lg text-green-600 dark:text-green-400 group-hover:bg-green-500 group-hover:text-white transition-colors">
<span class="material-symbols-outlined text-[20px]">trending_up</span>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<span id="income-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-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>
<!-- Profit/Loss -->
<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.profitLoss">Profit/Loss</span>
<h4 id="profit-loss" class="text-2xl font-bold text-text-main dark:text-white mt-1">$0.00</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]">account_balance</span>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<span id="profit-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-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 id="savings-change" 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>
0.0%
</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">
<!-- Income vs Expenses 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.incomeVsExpenses">Income vs Expenses</h3>
</div>
<div class="flex-1 min-h-[300px]">
<canvas id="trend-chart"></canvas>
</div>
</div>
<!-- Income Sources Breakdown -->
<div class="lg:col-span-1 bg-card-light dark:bg-card-dark p-5 rounded-xl border border-border-light dark:border-[#233648] shadow-sm flex flex-col">
<div class="flex justify-between items-center mb-4">
<h3 class="text-base font-bold text-text-main dark:text-white" data-translate="reports.incomeSources">Income Sources</h3>
</div>
<div class="flex items-center justify-center mb-4">
<!-- CSS Conic Gradient Pie Chart for Income -->
<div id="income-pie-chart" class="size-40 rounded-full relative transition-all duration-500" style="background: conic-gradient(#10b981 0% 100%);">
<!-- Inner hole for donut effect -->
<div class="absolute inset-3 bg-card-light dark:bg-card-dark rounded-full flex flex-col items-center justify-center z-10 border border-border-light dark:border-[#233648]">
<span class="text-text-muted dark:text-[#92adc9] text-[10px] font-medium" data-translate="dashboard.total">Total</span>
<span id="income-pie-total" class="text-green-600 dark:text-green-400 text-base font-bold">0 lei</span>
</div>
</div>
</div>
<div id="income-legend" class="grid grid-cols-1 gap-y-1.5 max-h-[200px] overflow-y-auto pr-2">
<!-- Legend items will be populated by JS -->
</div>
</div>
</div>
<!-- Category & Monthly Comparison Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Category Breakdown -->
<div class="bg-card-light dark:bg-card-dark p-5 rounded-xl border border-border-light dark:border-[#233648] shadow-sm flex flex-col">
<div class="flex justify-between items-center mb-4">
<h3 class="text-base font-bold text-text-main dark:text-white" data-translate="reports.categoryBreakdown">Expense Categories</h3>
</div>
<div class="flex items-center justify-center mb-4">
<!-- CSS Conic Gradient Pie Chart -->
<div id="category-pie-chart" class="size-40 rounded-full relative transition-all duration-500" style="background: conic-gradient(#233648 0% 100%);">
<!-- Inner hole for donut effect -->
<div class="absolute inset-3 bg-card-light dark:bg-card-dark rounded-full flex flex-col items-center justify-center z-10 border border-border-light dark:border-[#233648]">
<span class="text-text-muted dark:text-[#92adc9] text-[10px] font-medium" data-translate="dashboard.total">Total</span>
<span id="category-pie-total" class="text-text-main dark:text-white text-base font-bold">0 lei</span>
</div>
</div>
</div>
<div id="category-legend" class="grid grid-cols-1 gap-y-1.5 max-h-[200px] overflow-y-auto pr-2">
<!-- Legend items will be populated by JS -->
</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.monthlyComparison">Monthly Comparison</h3>
</div>
<div class="h-64">
<canvas id="monthly-chart"></canvas>
</div>
</div>
</div>
<!-- Smart Recommendations -->
<div class="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.smartRecommendations">Smart Recommendations</h3>
<span class="material-symbols-outlined text-primary text-[20px]">psychology</span>
</div>
<div id="recommendations-container" class="flex flex-col gap-4">
<!-- Loading state -->
<div class="flex items-center justify-center py-8">
<div class="flex flex-col items-center gap-3">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<p class="text-sm text-text-muted dark:text-[#92adc9]" data-translate="common.loading">Loading...</p>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="{{ url_for('static', filename='js/reports.js') }}"></script>
{% endblock %}

250
app/templates/settings.html Normal file
View file

@ -0,0 +1,250 @@
{% 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.income') }}">
<span class="material-symbols-outlined text-[20px]">payments</span>
<span class="text-sm font-medium" data-translate="nav.income">Income</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.recurring') }}">
<span class="material-symbols-outlined text-[20px]">repeat</span>
<span class="text-sm font-medium" data-translate="nav.recurring">Recurring</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.import_page') }}">
<span class="material-symbols-outlined text-[20px]">file_upload</span>
<span class="text-sm font-medium" data-translate="nav.import">Import CSV</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>
{% 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-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.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>
<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-background-light 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" data-translate="dashboard.lightMode">Light Mode</span>
</button>
<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-3 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>
<label class="block text-sm font-medium text-text-main dark:text-white mb-2" data-translate="form.monthlyBudget">Monthly Budget</label>
<input type="number" id="monthly-budget" value="{{ current_user.monthly_budget or 0 }}" step="0.01" min="0" 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>
<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,270 @@
{% 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 %}<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 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" 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.income') }}">
<span class="material-symbols-outlined text-[20px]">payments</span>
<span class="text-sm font-medium" data-translate="nav.income">Income</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.recurring') }}">
<span class="material-symbols-outlined text-[20px]">repeat</span>
<span class="text-sm font-medium" data-translate="nav.recurring">Recurring</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.import_page') }}">
<span class="material-symbols-outlined text-[20px]">file_upload</span>
<span class="text-sm font-medium" data-translate="nav.import">Import CSV</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>
{% 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-background-light dark:hover:bg-[#233648] hover:text-text-main dark:hover:text-white transition-colors" href="{{ url_for('main.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>
<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-background-light 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" data-translate="dashboard.lightMode">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-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/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" data-translate="transactions.title">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" data-translate="transactions.export">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" data-translate="transactions.import">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" data-translate="transactions.addExpense">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"
data-translate="transactions.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 data-translate="transactions.date">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="" data-translate="transactions.allCategories">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 data-translate="transactions.filters">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" data-translate="transactions.startDate">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" data-translate="transactions.endDate">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" data-translate="transactions.tableTransaction">Transaction</th>
<th class="p-5 text-left text-text-muted dark:text-[#92adc9] text-sm font-medium" data-translate="transactions.tableCategory">Category</th>
<th class="p-5 text-left text-text-muted dark:text-[#92adc9] text-sm font-medium" data-translate="transactions.tableDate">Date</th>
<th class="p-5 text-left text-text-muted dark:text-[#92adc9] text-sm font-medium" data-translate="transactions.tablePayment">Payment</th>
<th class="p-5 text-right text-text-muted dark:text-[#92adc9] text-sm font-medium" data-translate="transactions.tableAmount">Amount</th>
<th class="p-5 text-center text-text-muted dark:text-[#92adc9] text-sm font-medium" data-translate="transactions.tableStatus">Status</th>
<th class="p-5 text-right text-text-muted dark:text-[#92adc9] text-sm font-medium" data-translate="transactions.tableActions">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]">
<span data-translate="transactions.showing">Showing</span> <span id="page-start" class="text-text-main dark:text-white font-medium">1</span> <span data-translate="transactions.to">to</span>
<span id="page-end" class="text-text-main dark:text-white font-medium">10</span> <span data-translate="transactions.of">of</span>
<span id="total-count" class="text-text-main dark:text-white font-medium">0</span> <span data-translate="transactions.results">results</span>
</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">
<!-- Add/Edit 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 id="expense-modal-title" class="text-text-main dark:text-white text-xl font-bold" data-translate="modal.add_expense">Add Expense</h3>
<button id="close-expense-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">
<input type="hidden" id="expense-id" name="expense_id">
<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="" data-translate="dashboard.selectCategory">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>
<div id="current-receipt-info" class="hidden mb-2 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-blue-600 dark:text-blue-400 text-[20px]">attach_file</span>
<span class="text-sm text-blue-900 dark:text-blue-100" data-translate="form.currentReceipt">Current receipt attached</span>
</div>
<button type="button" id="view-current-receipt" class="text-blue-600 dark:text-blue-400 hover:underline text-sm" data-translate="transactions.viewReceipt">View</button>
</div>
</div>
<input type="file" name="receipt" id="receipt-input" 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">
<p class="text-xs text-text-muted dark:text-[#92adc9] mt-1" data-translate="form.receiptHelp">Upload a new file to replace existing receipt</p>
</div>
<button type="submit" id="expense-submit-btn" 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>
<!-- Receipt Viewer Modal -->
<div id="receipt-modal" class="hidden fixed inset-0 bg-black/70 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-4xl w-full max-h-[90vh] overflow-hidden shadow-2xl">
<div class="p-6 border-b border-border-light dark:border-[#233648] flex justify-between items-center">
<h3 class="text-text-main dark:text-white text-xl font-bold">Receipt</h3>
<button id="close-receipt-modal" class="text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white transition-colors">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div class="p-6 overflow-auto max-h-[calc(90vh-120px)]">
<div id="receipt-content" class="flex items-center justify-center min-h-[400px]">
<!-- Receipt content will be loaded here -->
</div>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/transactions.js') }}"></script>
{% endblock %}

42
app/utils.py Normal file
View file

@ -0,0 +1,42 @@
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 index, cat_data in enumerate(default_categories):
category = Category(
name=cat_data['name'],
color=cat_data['color'],
icon=cat_data['icon'],
display_order=index,
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}"

1
app/utils_init_backup.py Normal file
View file

@ -0,0 +1 @@
# Utils package