Initial commit
97
app/__init__.py
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
BIN
app/static/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
5
app/static/icons/avatars/avatar-1.svg
Normal 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 |
5
app/static/icons/avatars/avatar-2.svg
Normal 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 |
5
app/static/icons/avatars/avatar-3.svg
Normal 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 |
5
app/static/icons/avatars/avatar-4.svg
Normal 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 |
5
app/static/icons/avatars/avatar-5.svg
Normal 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 |
5
app/static/icons/avatars/avatar-6.svg
Normal 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 |
87
app/static/icons/create_logo.py
Normal 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!")
|
||||
112
app/static/icons/create_round_logo.py
Normal 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.")
|
||||
BIN
app/static/icons/favicon.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
app/static/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
app/static/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
app/static/icons/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/static/icons/logo.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
1
app/static/icons/logo.png.base64
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Placeholder - the actual logo will be saved from the attachment
|
||||
173
app/static/js/admin.js
Normal 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
|
|
@ -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
|
|
@ -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
502
app/static/js/documents.js
Normal 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
722
app/static/js/import.js
Normal 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
|
|
@ -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;
|
||||
264
app/static/js/notifications.js
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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;
|
||||
564
app/static/js/transactions.js
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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 %}
|
||||
126
app/templates/auth/backup_codes.html
Normal 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 %}
|
||||
257
app/templates/auth/login.html
Normal 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 %}
|
||||
93
app/templates/auth/register.html
Normal 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 %}
|
||||
100
app/templates/auth/setup_2fa.html
Normal 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
|
|
@ -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>
|
||||
434
app/templates/dashboard.html
Normal 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 %}
|
||||
149
app/templates/documents.html
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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>© 2025 FINA. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
241
app/templates/recurring.html
Normal 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
|
|
@ -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
|
|
@ -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 %}
|
||||
270
app/templates/transactions.html
Normal 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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
# Utils package
|
||||