Initial commit
This commit is contained in:
commit
983cee0320
322 changed files with 57174 additions and 0 deletions
91
backup/first -fina app/app/__init__.py
Executable file
91
backup/first -fina app/app/__init__.py
Executable file
|
|
@ -0,0 +1,91 @@
|
|||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
import redis
|
||||
import os
|
||||
|
||||
db = SQLAlchemy()
|
||||
login_manager = LoginManager()
|
||||
csrf = CSRFProtect()
|
||||
redis_client = None
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
|
||||
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///finance.db'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
|
||||
|
||||
@app.after_request
|
||||
def set_csp(response):
|
||||
response.headers['Content-Security-Policy'] = "default-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cdn.jsdelivr.net"
|
||||
return response
|
||||
|
||||
db.init_app(app)
|
||||
csrf.init_app(app)
|
||||
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = 'auth.login'
|
||||
|
||||
global redis_client
|
||||
redis_host = os.environ.get('REDIS_HOST', 'redis')
|
||||
redis_port = int(os.environ.get('REDIS_PORT', 6369))
|
||||
redis_client = redis.Redis(host=redis_host, port=redis_port, decode_responses=True)
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
# Register currency filter for templates
|
||||
from app.utils import format_currency
|
||||
from app.translations import get_translation
|
||||
|
||||
@app.template_filter('currency')
|
||||
def currency_filter(amount, currency_code=None):
|
||||
from flask_login import current_user
|
||||
if currency_code is None and current_user.is_authenticated:
|
||||
currency_code = current_user.currency
|
||||
return format_currency(amount, currency_code or 'USD')
|
||||
|
||||
# Register translation function for templates
|
||||
@app.template_global('_')
|
||||
def translate(key):
|
||||
from flask_login import current_user
|
||||
lang = 'en'
|
||||
if current_user.is_authenticated and hasattr(current_user, 'language'):
|
||||
lang = current_user.language or 'en'
|
||||
return get_translation(key, lang)
|
||||
|
||||
# Make get_translation available in templates
|
||||
@app.context_processor
|
||||
def utility_processor():
|
||||
from flask_login import current_user
|
||||
def get_lang():
|
||||
if current_user.is_authenticated and hasattr(current_user, 'language'):
|
||||
return current_user.language or 'en'
|
||||
return 'en'
|
||||
return dict(get_lang=get_lang)
|
||||
|
||||
from app.routes import auth, main, settings, language, subscriptions
|
||||
app.register_blueprint(auth.bp)
|
||||
app.register_blueprint(main.bp)
|
||||
app.register_blueprint(settings.bp)
|
||||
app.register_blueprint(language.bp)
|
||||
app.register_blueprint(subscriptions.bp)
|
||||
|
||||
# Register PWA routes
|
||||
from app.pwa import register_pwa_routes
|
||||
register_pwa_routes(app)
|
||||
|
||||
# Initialize budget alert system
|
||||
from app.budget_alerts import init_mail
|
||||
init_mail(app)
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
return app
|
||||
480
backup/first -fina app/app/bank_import.py
Normal file
480
backup/first -fina app/app/bank_import.py
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
"""
|
||||
Bank Statement Import Module for FINA Finance Tracker
|
||||
Parses PDF and CSV bank statements and extracts transactions
|
||||
"""
|
||||
import re
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
import PyPDF2
|
||||
|
||||
|
||||
class BankStatementParser:
|
||||
"""Base parser class for bank statements"""
|
||||
|
||||
def __init__(self):
|
||||
self.transactions = []
|
||||
self.detected_format = None
|
||||
self.total_transactions = 0
|
||||
self.parse_errors = []
|
||||
|
||||
def parse(self, file_content, file_type):
|
||||
"""
|
||||
Main parse method - detects format and extracts transactions
|
||||
|
||||
Args:
|
||||
file_content: File content (bytes for PDF, string for CSV)
|
||||
file_type: 'pdf' or 'csv'
|
||||
|
||||
Returns:
|
||||
dict with transactions and metadata
|
||||
"""
|
||||
if file_type == 'pdf':
|
||||
return self.parse_pdf(file_content)
|
||||
elif file_type == 'csv':
|
||||
return self.parse_csv(file_content)
|
||||
else:
|
||||
return {'success': False, 'error': 'Unsupported file type'}
|
||||
|
||||
def parse_pdf(self, pdf_bytes):
|
||||
"""
|
||||
Parse PDF bank statement
|
||||
Extracts transactions using pattern matching
|
||||
"""
|
||||
try:
|
||||
pdf_reader = PyPDF2.PdfReader(io.BytesIO(pdf_bytes))
|
||||
text = ""
|
||||
|
||||
# Extract text from all pages
|
||||
for page in pdf_reader.pages:
|
||||
text += page.extract_text() + "\n"
|
||||
|
||||
# Detect bank format
|
||||
bank_format = self.detect_bank_format(text)
|
||||
|
||||
# Parse transactions based on detected format
|
||||
if bank_format == 'generic':
|
||||
transactions = self.parse_generic_pdf(text)
|
||||
else:
|
||||
transactions = self.parse_generic_pdf(text) # Fallback to generic
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'transactions': transactions,
|
||||
'total_found': len(transactions),
|
||||
'bank_format': bank_format,
|
||||
'parse_errors': self.parse_errors
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'PDF parsing failed: {str(e)}',
|
||||
'transactions': []
|
||||
}
|
||||
|
||||
def parse_csv(self, csv_string):
|
||||
"""
|
||||
Parse CSV bank statement
|
||||
Auto-detects column mapping
|
||||
"""
|
||||
try:
|
||||
# Try different delimiters
|
||||
delimiter = self.detect_csv_delimiter(csv_string)
|
||||
|
||||
stream = io.StringIO(csv_string)
|
||||
csv_reader = csv.DictReader(stream, delimiter=delimiter)
|
||||
|
||||
# Auto-detect column names
|
||||
fieldnames = csv_reader.fieldnames
|
||||
column_map = self.detect_csv_columns(fieldnames)
|
||||
|
||||
transactions = []
|
||||
row_num = 0
|
||||
|
||||
for row in csv_reader:
|
||||
row_num += 1
|
||||
try:
|
||||
transaction = self.extract_transaction_from_csv_row(row, column_map)
|
||||
if transaction:
|
||||
transactions.append(transaction)
|
||||
except Exception as e:
|
||||
self.parse_errors.append(f"Row {row_num}: {str(e)}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'transactions': transactions,
|
||||
'total_found': len(transactions),
|
||||
'column_mapping': column_map,
|
||||
'parse_errors': self.parse_errors
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'CSV parsing failed: {str(e)}',
|
||||
'transactions': []
|
||||
}
|
||||
|
||||
def detect_bank_format(self, text):
|
||||
"""Detect which bank format the PDF uses"""
|
||||
text_lower = text.lower()
|
||||
|
||||
# Add patterns for specific banks
|
||||
if 'revolut' in text_lower:
|
||||
return 'revolut'
|
||||
elif 'ing' in text_lower or 'ing bank' in text_lower:
|
||||
return 'ing'
|
||||
elif 'bcr' in text_lower or 'banca comercială' in text_lower:
|
||||
return 'bcr'
|
||||
elif 'brd' in text_lower:
|
||||
return 'brd'
|
||||
else:
|
||||
return 'generic'
|
||||
|
||||
def parse_generic_pdf(self, text):
|
||||
"""
|
||||
Parse PDF using generic patterns
|
||||
Looks for common transaction patterns across banks
|
||||
"""
|
||||
transactions = []
|
||||
lines = text.split('\n')
|
||||
|
||||
# Common patterns for transactions
|
||||
# Date patterns: DD/MM/YYYY, DD-MM-YYYY, YYYY-MM-DD
|
||||
date_patterns = [
|
||||
r'(\d{2}[/-]\d{2}[/-]\d{4})', # DD/MM/YYYY or DD-MM-YYYY
|
||||
r'(\d{4}[/-]\d{2}[/-]\d{2})', # YYYY-MM-DD
|
||||
]
|
||||
|
||||
# Amount patterns: -123.45, 123.45, 123,45, -123,45
|
||||
amount_patterns = [
|
||||
r'[-]?\d{1,10}[.,]\d{2}', # With 2 decimals
|
||||
r'[-]?\d{1,10}\s*(?:RON|EUR|USD|GBP|LEI)', # With currency
|
||||
]
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
# Skip header lines
|
||||
if any(word in line.lower() for word in ['sold', 'balance', 'iban', 'account', 'statement']):
|
||||
continue
|
||||
|
||||
# Look for date in line
|
||||
date_match = None
|
||||
for pattern in date_patterns:
|
||||
match = re.search(pattern, line)
|
||||
if match:
|
||||
date_match = match.group(1)
|
||||
break
|
||||
|
||||
if not date_match:
|
||||
continue
|
||||
|
||||
# Parse date
|
||||
trans_date = self.parse_date(date_match)
|
||||
if not trans_date:
|
||||
continue
|
||||
|
||||
# Look for amount in this line and nearby lines
|
||||
amount = None
|
||||
description = line
|
||||
|
||||
# Check current line and next 2 lines for amount
|
||||
for j in range(i, min(i + 3, len(lines))):
|
||||
amounts_found = re.findall(r'[-]?\d{1,10}[.,]\d{2}', lines[j])
|
||||
if amounts_found:
|
||||
# Take the last amount (usually the transaction amount)
|
||||
amount_str = amounts_found[-1]
|
||||
amount = self.parse_amount(amount_str)
|
||||
break
|
||||
|
||||
if not amount or amount == 0:
|
||||
continue
|
||||
|
||||
# Clean description
|
||||
description = self.clean_description(line, date_match, str(amount))
|
||||
|
||||
if description:
|
||||
transactions.append({
|
||||
'date': trans_date,
|
||||
'description': description,
|
||||
'amount': abs(amount), # Always positive, type determined by sign
|
||||
'type': 'expense' if amount < 0 else 'income',
|
||||
'original_amount': amount
|
||||
})
|
||||
|
||||
# Deduplicate based on date + amount + description similarity
|
||||
transactions = self.deduplicate_transactions(transactions)
|
||||
|
||||
return transactions
|
||||
|
||||
def detect_csv_delimiter(self, csv_string):
|
||||
"""Detect CSV delimiter (comma, semicolon, tab)"""
|
||||
first_line = csv_string.split('\n')[0]
|
||||
|
||||
comma_count = first_line.count(',')
|
||||
semicolon_count = first_line.count(';')
|
||||
tab_count = first_line.count('\t')
|
||||
|
||||
if semicolon_count > comma_count and semicolon_count > tab_count:
|
||||
return ';'
|
||||
elif tab_count > comma_count:
|
||||
return '\t'
|
||||
else:
|
||||
return ','
|
||||
|
||||
def detect_csv_columns(self, fieldnames):
|
||||
"""
|
||||
Auto-detect which columns contain date, description, amount
|
||||
Returns mapping of column indices
|
||||
"""
|
||||
fieldnames_lower = [f.lower() if f else '' for f in fieldnames]
|
||||
|
||||
column_map = {
|
||||
'date': None,
|
||||
'description': None,
|
||||
'amount': None,
|
||||
'debit': None,
|
||||
'credit': None
|
||||
}
|
||||
|
||||
# Date column keywords
|
||||
date_keywords = ['date', 'data', 'fecha', 'datum', 'transaction date']
|
||||
for idx, name in enumerate(fieldnames_lower):
|
||||
if any(keyword in name for keyword in date_keywords):
|
||||
column_map['date'] = fieldnames[idx]
|
||||
break
|
||||
|
||||
# Description column keywords
|
||||
desc_keywords = ['description', 'descriere', 'descripción', 'details', 'detalii', 'merchant', 'comerciant']
|
||||
for idx, name in enumerate(fieldnames_lower):
|
||||
if any(keyword in name for keyword in desc_keywords):
|
||||
column_map['description'] = fieldnames[idx]
|
||||
break
|
||||
|
||||
# Amount columns
|
||||
amount_keywords = ['amount', 'suma', 'monto', 'valoare']
|
||||
debit_keywords = ['debit', 'withdrawal', 'retragere', 'retiro', 'spent']
|
||||
credit_keywords = ['credit', 'deposit', 'depunere', 'ingreso', 'income']
|
||||
|
||||
for idx, name in enumerate(fieldnames_lower):
|
||||
if any(keyword in name for keyword in amount_keywords):
|
||||
column_map['amount'] = fieldnames[idx]
|
||||
elif any(keyword in name for keyword in debit_keywords):
|
||||
column_map['debit'] = fieldnames[idx]
|
||||
elif any(keyword in name for keyword in credit_keywords):
|
||||
column_map['credit'] = fieldnames[idx]
|
||||
|
||||
return column_map
|
||||
|
||||
def extract_transaction_from_csv_row(self, row, column_map):
|
||||
"""Extract transaction data from CSV row using column mapping"""
|
||||
# Get date
|
||||
date_col = column_map.get('date')
|
||||
if not date_col or date_col not in row:
|
||||
return None
|
||||
|
||||
trans_date = self.parse_date(row[date_col])
|
||||
if not trans_date:
|
||||
return None
|
||||
|
||||
# Get description
|
||||
desc_col = column_map.get('description')
|
||||
description = row.get(desc_col, 'Transaction') if desc_col else 'Transaction'
|
||||
|
||||
# Get amount
|
||||
amount = 0
|
||||
trans_type = 'expense'
|
||||
|
||||
# Check if we have separate debit/credit columns
|
||||
if column_map.get('debit') and column_map.get('credit'):
|
||||
debit_val = self.parse_amount(row.get(column_map['debit'], '0'))
|
||||
credit_val = self.parse_amount(row.get(column_map['credit'], '0'))
|
||||
|
||||
if debit_val > 0:
|
||||
amount = debit_val
|
||||
trans_type = 'expense'
|
||||
elif credit_val > 0:
|
||||
amount = credit_val
|
||||
trans_type = 'income'
|
||||
elif column_map.get('amount'):
|
||||
amount_val = self.parse_amount(row.get(column_map['amount'], '0'))
|
||||
amount = abs(amount_val)
|
||||
trans_type = 'expense' if amount_val < 0 else 'income'
|
||||
|
||||
if amount == 0:
|
||||
return None
|
||||
|
||||
return {
|
||||
'date': trans_date,
|
||||
'description': description.strip(),
|
||||
'amount': amount,
|
||||
'type': trans_type
|
||||
}
|
||||
|
||||
def parse_date(self, date_str):
|
||||
"""Parse date string in various formats"""
|
||||
date_str = date_str.strip()
|
||||
|
||||
# Try different 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'
|
||||
]
|
||||
|
||||
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
|
||||
|
||||
# Remove currency symbols and whitespace
|
||||
amount_str = str(amount_str).strip()
|
||||
amount_str = re.sub(r'[^\d.,-]', '', amount_str)
|
||||
|
||||
if not amount_str:
|
||||
return 0.0
|
||||
|
||||
# Handle comma as decimal separator (European format)
|
||||
if ',' in amount_str and '.' in amount_str:
|
||||
# Format: 1.234,56 -> remove dots, replace comma with dot
|
||||
amount_str = amount_str.replace('.', '').replace(',', '.')
|
||||
elif ',' in amount_str:
|
||||
# Format: 1234,56 -> replace comma with dot
|
||||
amount_str = amount_str.replace(',', '.')
|
||||
|
||||
try:
|
||||
return float(amount_str)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
def clean_description(self, text, date_str, amount_str):
|
||||
"""Clean transaction description by removing date and amount"""
|
||||
# Remove date
|
||||
text = text.replace(date_str, '')
|
||||
# Remove amount
|
||||
text = text.replace(amount_str, '')
|
||||
# Remove extra whitespace
|
||||
text = ' '.join(text.split())
|
||||
# Remove common keywords
|
||||
remove_words = ['transaction', 'payment', 'transfer', 'tranzactie', 'plata']
|
||||
for word in remove_words:
|
||||
text = re.sub(word, '', text, flags=re.IGNORECASE)
|
||||
|
||||
text = text.strip()
|
||||
|
||||
# If too short, return generic
|
||||
if len(text) < 3:
|
||||
return 'Bank Transaction'
|
||||
|
||||
return text[:200] # Limit length
|
||||
|
||||
def deduplicate_transactions(self, transactions):
|
||||
"""Remove duplicate transactions"""
|
||||
seen = set()
|
||||
unique = []
|
||||
|
||||
for trans in transactions:
|
||||
# Create signature: date + amount + first 20 chars of description
|
||||
signature = (
|
||||
trans['date'].isoformat(),
|
||||
round(trans['amount'], 2),
|
||||
trans['description'][:20].lower()
|
||||
)
|
||||
|
||||
if signature not in seen:
|
||||
seen.add(signature)
|
||||
unique.append(trans)
|
||||
|
||||
return unique
|
||||
|
||||
def validate_file(self, file_content, file_type, max_size_mb=10):
|
||||
"""
|
||||
Validate uploaded file
|
||||
|
||||
Args:
|
||||
file_content: File content bytes
|
||||
file_type: 'pdf' or 'csv'
|
||||
max_size_mb: Maximum file size in MB
|
||||
|
||||
Returns:
|
||||
(is_valid, error_message)
|
||||
"""
|
||||
# Check file size
|
||||
size_mb = len(file_content) / (1024 * 1024)
|
||||
if size_mb > max_size_mb:
|
||||
return False, f'File too large. Maximum size is {max_size_mb}MB'
|
||||
|
||||
# Check file type
|
||||
if file_type == 'pdf':
|
||||
# Check PDF header
|
||||
if not file_content.startswith(b'%PDF'):
|
||||
return False, 'Invalid PDF file'
|
||||
elif file_type == 'csv':
|
||||
# Try to decode as text
|
||||
try:
|
||||
file_content.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
file_content.decode('latin-1')
|
||||
except:
|
||||
return False, 'Invalid CSV file encoding'
|
||||
else:
|
||||
return False, 'Unsupported file type. Use PDF or CSV'
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def parse_bank_statement(file_content, filename):
|
||||
"""
|
||||
Main entry point for bank statement parsing
|
||||
|
||||
Args:
|
||||
file_content: File content as bytes
|
||||
filename: Original filename
|
||||
|
||||
Returns:
|
||||
Parse results dictionary
|
||||
"""
|
||||
parser = BankStatementParser()
|
||||
|
||||
# Determine file type
|
||||
file_ext = filename.lower().split('.')[-1]
|
||||
if file_ext == 'pdf':
|
||||
file_type = 'pdf'
|
||||
content = file_content
|
||||
elif file_ext == 'csv':
|
||||
file_type = 'csv'
|
||||
# Try to decode
|
||||
try:
|
||||
content = file_content.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
content = file_content.decode('latin-1', errors='ignore')
|
||||
else:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Unsupported file type. Please upload PDF or CSV files.'
|
||||
}
|
||||
|
||||
# Validate file
|
||||
is_valid, error_msg = parser.validate_file(file_content, file_type)
|
||||
if not is_valid:
|
||||
return {'success': False, 'error': error_msg}
|
||||
|
||||
# Parse file
|
||||
result = parser.parse(content, file_type)
|
||||
|
||||
return result
|
||||
287
backup/first -fina app/app/budget_alerts.py
Normal file
287
backup/first -fina app/app/budget_alerts.py
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
"""
|
||||
Budget Alert System
|
||||
Monitors spending and sends email alerts when budget limits are exceeded
|
||||
"""
|
||||
|
||||
from flask import render_template_string
|
||||
from flask_mail import Mail, Message
|
||||
from app.models.category import Category
|
||||
from app.models.user import User
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
import os
|
||||
|
||||
mail = None
|
||||
|
||||
def init_mail(app):
|
||||
"""Initialize Flask-Mail with app configuration"""
|
||||
global mail
|
||||
|
||||
# Email configuration from environment variables
|
||||
app.config['MAIL_SERVER'] = os.environ.get('MAIL_SERVER', 'smtp.gmail.com')
|
||||
app.config['MAIL_PORT'] = int(os.environ.get('MAIL_PORT', 587))
|
||||
app.config['MAIL_USE_TLS'] = os.environ.get('MAIL_USE_TLS', 'true').lower() == 'true'
|
||||
app.config['MAIL_USERNAME'] = os.environ.get('MAIL_USERNAME')
|
||||
app.config['MAIL_PASSWORD'] = os.environ.get('MAIL_PASSWORD')
|
||||
app.config['MAIL_DEFAULT_SENDER'] = os.environ.get('MAIL_DEFAULT_SENDER', 'noreply@fina.app')
|
||||
|
||||
mail = Mail(app)
|
||||
return mail
|
||||
|
||||
|
||||
def check_budget_alerts():
|
||||
"""Check all categories for budget overruns and send alerts"""
|
||||
if not mail:
|
||||
print("[Budget Alerts] Mail not configured")
|
||||
return 0
|
||||
|
||||
alerts_sent = 0
|
||||
|
||||
# Get all categories with budgets that need checking
|
||||
categories = Category.query.filter(
|
||||
Category.monthly_budget.isnot(None),
|
||||
Category.monthly_budget > 0
|
||||
).all()
|
||||
|
||||
for category in categories:
|
||||
if category.should_send_budget_alert():
|
||||
user = User.query.get(category.user_id)
|
||||
|
||||
if user and user.budget_alerts_enabled:
|
||||
if send_budget_alert(user, category):
|
||||
category.budget_alert_sent = True
|
||||
category.last_budget_check = datetime.now()
|
||||
alerts_sent += 1
|
||||
|
||||
db.session.commit()
|
||||
return alerts_sent
|
||||
|
||||
|
||||
def send_budget_alert(user, category):
|
||||
"""Send budget alert email to user"""
|
||||
if not mail:
|
||||
print(f"[Budget Alert] Mail not configured, skipping alert for {user.email}")
|
||||
return False
|
||||
|
||||
try:
|
||||
status = category.get_budget_status()
|
||||
alert_email = user.alert_email or user.email
|
||||
|
||||
# Get user's language
|
||||
lang = user.language or 'en'
|
||||
|
||||
# Email templates in multiple languages
|
||||
subjects = {
|
||||
'en': f'⚠️ Budget Alert: {category.name}',
|
||||
'ro': f'⚠️ Alertă Buget: {category.name}',
|
||||
'es': f'⚠️ Alerta de Presupuesto: {category.name}'
|
||||
}
|
||||
|
||||
# Email body template
|
||||
html_template = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
||||
.header {{ background: linear-gradient(135deg, #4c1d95 0%, #3b0764 100%);
|
||||
color: white; padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }}
|
||||
.content {{ background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px; }}
|
||||
.alert-box {{ background: #fef2f2; border-left: 4px solid #ef4444;
|
||||
padding: 20px; margin: 20px 0; border-radius: 5px; }}
|
||||
.budget-details {{ background: white; padding: 20px; border-radius: 8px; margin: 20px 0; }}
|
||||
.stat {{ display: inline-block; margin: 10px 20px 10px 0; }}
|
||||
.stat-label {{ color: #6b7280; font-size: 14px; }}
|
||||
.stat-value {{ font-size: 24px; font-weight: bold; color: #1f2937; }}
|
||||
.over-budget {{ color: #ef4444; }}
|
||||
.progress-bar {{ background: #e5e7eb; height: 30px; border-radius: 15px;
|
||||
overflow: hidden; margin: 20px 0; }}
|
||||
.progress-fill {{ background: {progress_color}; height: 100%;
|
||||
transition: width 0.3s; display: flex; align-items: center;
|
||||
justify-content: center; color: white; font-weight: bold; }}
|
||||
.button {{ display: inline-block; background: #5b5fc7; color: white;
|
||||
padding: 12px 30px; text-decoration: none; border-radius: 8px;
|
||||
margin: 20px 0; }}
|
||||
.footer {{ text-align: center; color: #6b7280; font-size: 14px; margin-top: 30px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🔔 {title}</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="alert-box">
|
||||
<h2 style="margin-top: 0;">⚠️ {alert_message}</h2>
|
||||
<p>{alert_description}</p>
|
||||
</div>
|
||||
|
||||
<div class="budget-details">
|
||||
<h3>{details_title}</h3>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-label">{spent_label}</div>
|
||||
<div class="stat-value over-budget">{currency}{spent:.2f}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-label">{budget_label}</div>
|
||||
<div class="stat-value">{currency}{budget:.2f}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-label">{remaining_label}</div>
|
||||
<div class="stat-value" style="color: #ef4444;">{currency}{remaining:.2f}</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {percentage}%;">
|
||||
{percentage:.1f}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 20px; color: #6b7280;">
|
||||
<strong>{category_label}:</strong> {category_name}<br>
|
||||
<strong>{threshold_label}:</strong> {threshold}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<center>
|
||||
<a href="{dashboard_url}" class="button">{button_text}</a>
|
||||
</center>
|
||||
|
||||
<div class="footer">
|
||||
<p>{footer_text}</p>
|
||||
<p style="font-size: 12px;">{disable_text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# Translations
|
||||
translations = {
|
||||
'en': {
|
||||
'title': 'Budget Alert',
|
||||
'alert_message': 'Budget Limit Exceeded!',
|
||||
'alert_description': f'Your spending in the "{category.name}" category has exceeded {int(category.budget_alert_threshold * 100)}% of your monthly budget.',
|
||||
'details_title': 'Budget Overview',
|
||||
'spent_label': 'Spent This Month',
|
||||
'budget_label': 'Monthly Budget',
|
||||
'remaining_label': 'Over Budget',
|
||||
'category_label': 'Category',
|
||||
'threshold_label': 'Alert Threshold',
|
||||
'button_text': 'View Dashboard',
|
||||
'footer_text': 'This is an automated budget alert from FINA Finance Tracker.',
|
||||
'disable_text': 'To disable budget alerts, go to Settings > Profile.'
|
||||
},
|
||||
'ro': {
|
||||
'title': 'Alertă Buget',
|
||||
'alert_message': 'Limită buget depășită!',
|
||||
'alert_description': f'Cheltuielile în categoria "{category.name}" au depășit {int(category.budget_alert_threshold * 100)}% din bugetul lunar.',
|
||||
'details_title': 'Rezumat Buget',
|
||||
'spent_label': 'Cheltuit Luna Aceasta',
|
||||
'budget_label': 'Buget Lunar',
|
||||
'remaining_label': 'Peste Buget',
|
||||
'category_label': 'Categorie',
|
||||
'threshold_label': 'Prag Alertă',
|
||||
'button_text': 'Vezi Tabloul de Bord',
|
||||
'footer_text': 'Aceasta este o alertă automată de buget de la FINA Finance Tracker.',
|
||||
'disable_text': 'Pentru a dezactiva alertele de buget, mergi la Setări > Profil.'
|
||||
},
|
||||
'es': {
|
||||
'title': 'Alerta de Presupuesto',
|
||||
'alert_message': '¡Límite de presupuesto excedido!',
|
||||
'alert_description': f'Tus gastos en la categoría "{category.name}" han superado el {int(category.budget_alert_threshold * 100)}% de tu presupuesto mensual.',
|
||||
'details_title': 'Resumen de Presupuesto',
|
||||
'spent_label': 'Gastado Este Mes',
|
||||
'budget_label': 'Presupuesto Mensual',
|
||||
'remaining_label': 'Sobre Presupuesto',
|
||||
'category_label': 'Categoría',
|
||||
'threshold_label': 'Umbral de Alerta',
|
||||
'button_text': 'Ver Panel',
|
||||
'footer_text': 'Esta es una alerta automática de presupuesto de FINA Finance Tracker.',
|
||||
'disable_text': 'Para desactivar las alertas de presupuesto, ve a Configuración > Perfil.'
|
||||
}
|
||||
}
|
||||
|
||||
t = translations.get(lang, translations['en'])
|
||||
|
||||
# Determine progress bar color
|
||||
if status['percentage'] >= 100:
|
||||
progress_color = '#ef4444' # Red
|
||||
elif status['percentage'] >= 90:
|
||||
progress_color = '#f59e0b' # Orange
|
||||
else:
|
||||
progress_color = '#10b981' # Green
|
||||
|
||||
# Dashboard URL (adjust based on your deployment)
|
||||
dashboard_url = os.environ.get('APP_URL', 'http://localhost:5001') + '/dashboard'
|
||||
|
||||
html_body = html_template.format(
|
||||
title=t['title'],
|
||||
alert_message=t['alert_message'],
|
||||
alert_description=t['alert_description'],
|
||||
details_title=t['details_title'],
|
||||
spent_label=t['spent_label'],
|
||||
budget_label=t['budget_label'],
|
||||
remaining_label=t['remaining_label'],
|
||||
category_label=t['category_label'],
|
||||
threshold_label=t['threshold_label'],
|
||||
button_text=t['button_text'],
|
||||
footer_text=t['footer_text'],
|
||||
disable_text=t['disable_text'],
|
||||
currency=user.currency,
|
||||
spent=status['spent'],
|
||||
budget=status['budget'],
|
||||
remaining=abs(status['remaining']),
|
||||
percentage=min(status['percentage'], 100),
|
||||
progress_color=progress_color,
|
||||
category_name=category.name,
|
||||
threshold=int(category.budget_alert_threshold * 100),
|
||||
dashboard_url=dashboard_url
|
||||
)
|
||||
|
||||
msg = Message(
|
||||
subject=subjects.get(lang, subjects['en']),
|
||||
recipients=[alert_email],
|
||||
html=html_body
|
||||
)
|
||||
|
||||
mail.send(msg)
|
||||
print(f"[Budget Alert] Sent to {alert_email} for category {category.name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Budget Alert] Error sending email: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def send_test_budget_alert(user_email):
|
||||
"""Send a test budget alert email"""
|
||||
if not mail:
|
||||
return False, "Mail not configured"
|
||||
|
||||
try:
|
||||
msg = Message(
|
||||
subject='Test Budget Alert - FINA',
|
||||
recipients=[user_email],
|
||||
body='This is a test email from FINA budget alert system. If you received this, email alerts are working correctly!',
|
||||
html='''
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif;">
|
||||
<h2>✅ Test Email Successful</h2>
|
||||
<p>This is a test email from the FINA budget alert system.</p>
|
||||
<p>If you received this message, your email configuration is working correctly!</p>
|
||||
<hr>
|
||||
<p style="color: #666; font-size: 12px;">FINA Finance Tracker</p>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
)
|
||||
mail.send(msg)
|
||||
return True, "Test email sent successfully"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
59
backup/first -fina app/app/init.py
Executable file
59
backup/first -fina app/app/init.py
Executable file
|
|
@ -0,0 +1,59 @@
|
|||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
import redis
|
||||
|
||||
load_dotenv()
|
||||
|
||||
db = SQLAlchemy()
|
||||
login_manager = LoginManager()
|
||||
csrf = CSRFProtect()
|
||||
redis_client = None
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
|
||||
# Security configurations
|
||||
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', os.urandom(32))
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///finance.db')
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
|
||||
app.config['UPLOAD_FOLDER'] = 'app/static/uploads'
|
||||
app.config['ALLOWED_EXTENSIONS'] = {'png', 'jpg', 'jpeg', 'pdf', 'gif'}
|
||||
|
||||
# Security headers
|
||||
@app.after_request
|
||||
def set_security_headers(response):
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
response.headers['X-Frame-Options'] = 'DENY'
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
|
||||
response.headers['Content-Security-Policy'] = "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' data:;"
|
||||
return response
|
||||
|
||||
# Initialize extensions
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
csrf.init_app(app)
|
||||
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Please log in to access this page.'
|
||||
|
||||
# Initialize Redis
|
||||
global redis_client
|
||||
redis_url = os.getenv('REDIS_URL', 'redis://localhost:6369/0')
|
||||
redis_client = redis.from_url(redis_url, decode_responses=True)
|
||||
|
||||
# Register blueprints
|
||||
from app.routes import auth, main
|
||||
app.register_blueprint(auth.bp)
|
||||
app.register_blueprint(main.bp)
|
||||
|
||||
# Create tables
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
return app
|
||||
3
backup/first -fina app/app/models/__init__.py
Executable file
3
backup/first -fina app/app/models/__init__.py
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
from app.models.user import User
|
||||
from app.models.category import Category, Expense
|
||||
__all__ = ['User', 'Category', 'Expense']
|
||||
120
backup/first -fina app/app/models/category.py
Executable file
120
backup/first -fina app/app/models/category.py
Executable file
|
|
@ -0,0 +1,120 @@
|
|||
from app import db
|
||||
from datetime import datetime
|
||||
from sqlalchemy import func, extract
|
||||
|
||||
class Category(db.Model):
|
||||
__tablename__ = 'categories'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
color = db.Column(db.String(7), default='#6366f1')
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Budget fields
|
||||
monthly_budget = db.Column(db.Float, nullable=True)
|
||||
budget_alert_sent = db.Column(db.Boolean, default=False)
|
||||
budget_alert_threshold = db.Column(db.Float, default=1.0) # 1.0 = 100%
|
||||
last_budget_check = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
expenses = db.relationship('Expense', backref='category', lazy=True, cascade='all, delete-orphan')
|
||||
|
||||
def get_total_spent(self):
|
||||
return sum(expense.amount for expense in self.expenses)
|
||||
|
||||
def get_monthly_totals(self, year=None):
|
||||
"""Get expenses grouped by month for the year"""
|
||||
if year is None:
|
||||
year = datetime.now().year
|
||||
|
||||
monthly_data = db.session.query(
|
||||
extract('month', Expense.date).label('month'),
|
||||
func.sum(Expense.amount).label('total')
|
||||
).filter(
|
||||
Expense.category_id == self.id,
|
||||
extract('year', Expense.date) == year
|
||||
).group_by('month').all()
|
||||
|
||||
# Create array with all 12 months
|
||||
result = [0] * 12
|
||||
for month, total in monthly_data:
|
||||
result[int(month) - 1] = float(total) if total else 0
|
||||
|
||||
return result
|
||||
|
||||
def get_yearly_total(self, year):
|
||||
"""Get total expenses for a specific year"""
|
||||
total = db.session.query(func.sum(Expense.amount)).filter(
|
||||
Expense.category_id == self.id,
|
||||
extract('year', Expense.date) == year
|
||||
).scalar()
|
||||
return float(total) if total else 0
|
||||
|
||||
def get_current_month_spending(self):
|
||||
"""Get total spending for current month"""
|
||||
now = datetime.now()
|
||||
total = db.session.query(func.sum(Expense.amount)).filter(
|
||||
Expense.category_id == self.id,
|
||||
extract('year', Expense.date) == now.year,
|
||||
extract('month', Expense.date) == now.month
|
||||
).scalar()
|
||||
return float(total) if total else 0
|
||||
|
||||
def get_budget_status(self):
|
||||
"""Get budget status: percentage used and over budget flag"""
|
||||
if not self.monthly_budget or self.monthly_budget <= 0:
|
||||
return {'percentage': 0, 'over_budget': False, 'remaining': 0}
|
||||
|
||||
spent = self.get_current_month_spending()
|
||||
percentage = (spent / self.monthly_budget) * 100
|
||||
over_budget = percentage >= (self.budget_alert_threshold * 100)
|
||||
remaining = self.monthly_budget - spent
|
||||
|
||||
return {
|
||||
'spent': spent,
|
||||
'budget': self.monthly_budget,
|
||||
'percentage': round(percentage, 1),
|
||||
'over_budget': over_budget,
|
||||
'remaining': remaining
|
||||
}
|
||||
|
||||
def should_send_budget_alert(self):
|
||||
"""Check if budget alert should be sent"""
|
||||
if not self.monthly_budget:
|
||||
return False
|
||||
|
||||
status = self.get_budget_status()
|
||||
|
||||
# Only send if over threshold and not already sent this month
|
||||
if status['over_budget'] and not self.budget_alert_sent:
|
||||
return True
|
||||
|
||||
# Reset alert flag at start of new month
|
||||
now = datetime.now()
|
||||
if self.last_budget_check:
|
||||
if (self.last_budget_check.month != now.month or
|
||||
self.last_budget_check.year != now.year):
|
||||
self.budget_alert_sent = False
|
||||
|
||||
return False
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Category {self.name}>'
|
||||
|
||||
class Expense(db.Model):
|
||||
__tablename__ = 'expenses'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
description = db.Column(db.String(200), nullable=False)
|
||||
amount = db.Column(db.Float, nullable=False)
|
||||
date = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
paid_by = db.Column(db.String(100))
|
||||
tags = db.Column(db.String(500))
|
||||
file_path = db.Column(db.String(500))
|
||||
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Expense {self.description}: ${self.amount}>'
|
||||
4
backup/first -fina app/app/models/init.py
Executable file
4
backup/first -fina app/app/models/init.py
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
from app.models.user import User
|
||||
from app.models.category import Category, Expense
|
||||
|
||||
__all__ = ['User', 'Category', 'Expense']
|
||||
124
backup/first -fina app/app/models/subscription.py
Normal file
124
backup/first -fina app/app/models/subscription.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
from app import db
|
||||
from datetime import datetime
|
||||
from sqlalchemy import func
|
||||
|
||||
class Subscription(db.Model):
|
||||
__tablename__ = 'subscriptions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
amount = db.Column(db.Float, nullable=False)
|
||||
frequency = db.Column(db.String(20), nullable=False) # monthly, weekly, yearly, quarterly, custom
|
||||
custom_interval_days = db.Column(db.Integer, nullable=True) # For custom frequency
|
||||
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
next_due_date = db.Column(db.Date, nullable=True)
|
||||
start_date = db.Column(db.Date, nullable=True) # First occurrence date
|
||||
end_date = db.Column(db.Date, nullable=True) # Optional end date
|
||||
total_occurrences = db.Column(db.Integer, nullable=True) # Optional limit
|
||||
occurrences_count = db.Column(db.Integer, default=0) # Current count
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
is_confirmed = db.Column(db.Boolean, default=False) # User confirmed this subscription
|
||||
auto_detected = db.Column(db.Boolean, default=False) # System detected this pattern
|
||||
auto_create_expense = db.Column(db.Boolean, default=False) # Auto-create expenses on due date
|
||||
confidence_score = db.Column(db.Float, default=0.0) # 0-100 confidence of detection
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
last_reminded = db.Column(db.DateTime, nullable=True)
|
||||
last_auto_created = db.Column(db.Date, nullable=True) # Last auto-created expense date
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Subscription {self.name}>'
|
||||
|
||||
def get_frequency_days(self):
|
||||
"""Get number of days between payments"""
|
||||
if self.frequency == 'custom' and self.custom_interval_days:
|
||||
return self.custom_interval_days
|
||||
|
||||
frequency_map = {
|
||||
'weekly': 7,
|
||||
'biweekly': 14,
|
||||
'monthly': 30,
|
||||
'quarterly': 90,
|
||||
'yearly': 365
|
||||
}
|
||||
return frequency_map.get(self.frequency, 30)
|
||||
|
||||
def should_create_expense_today(self):
|
||||
"""Check if an expense should be auto-created today"""
|
||||
if not self.auto_create_expense or not self.is_active:
|
||||
return False
|
||||
|
||||
if not self.next_due_date:
|
||||
return False
|
||||
|
||||
today = datetime.now().date()
|
||||
|
||||
# Check if today is the due date
|
||||
if self.next_due_date != today:
|
||||
return False
|
||||
|
||||
# Check if already created today
|
||||
if self.last_auto_created == today:
|
||||
return False
|
||||
|
||||
# Check if we've reached the occurrence limit
|
||||
if self.total_occurrences and self.occurrences_count >= self.total_occurrences:
|
||||
return False
|
||||
|
||||
# Check if past end date
|
||||
if self.end_date and today > self.end_date:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def advance_next_due_date(self):
|
||||
"""Move to the next due date"""
|
||||
if not self.next_due_date:
|
||||
return
|
||||
|
||||
from datetime import timedelta
|
||||
interval_days = self.get_frequency_days()
|
||||
self.next_due_date = self.next_due_date + timedelta(days=interval_days)
|
||||
self.occurrences_count += 1
|
||||
|
||||
# Check if subscription should end
|
||||
if self.total_occurrences and self.occurrences_count >= self.total_occurrences:
|
||||
self.is_active = False
|
||||
|
||||
if self.end_date and self.next_due_date > self.end_date:
|
||||
self.is_active = False
|
||||
|
||||
def get_annual_cost(self):
|
||||
"""Calculate annual cost based on frequency"""
|
||||
frequency_multiplier = {
|
||||
'weekly': 52,
|
||||
'biweekly': 26,
|
||||
'monthly': 12,
|
||||
'quarterly': 4,
|
||||
'yearly': 1
|
||||
}
|
||||
return self.amount * frequency_multiplier.get(self.frequency, 12)
|
||||
|
||||
|
||||
class RecurringPattern(db.Model):
|
||||
"""Detected recurring patterns (suggestions before confirmation)"""
|
||||
__tablename__ = 'recurring_patterns'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=False)
|
||||
suggested_name = db.Column(db.String(100), nullable=False)
|
||||
average_amount = db.Column(db.Float, nullable=False)
|
||||
detected_frequency = db.Column(db.String(20), nullable=False)
|
||||
confidence_score = db.Column(db.Float, nullable=False) # 0-100
|
||||
expense_ids = db.Column(db.Text, nullable=False) # JSON array of expense IDs
|
||||
first_occurrence = db.Column(db.Date, nullable=False)
|
||||
last_occurrence = db.Column(db.Date, nullable=False)
|
||||
occurrence_count = db.Column(db.Integer, default=0)
|
||||
is_dismissed = db.Column(db.Boolean, default=False)
|
||||
is_converted = db.Column(db.Boolean, default=False) # Converted to subscription
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<RecurringPattern {self.suggested_name}>'
|
||||
71
backup/first -fina app/app/models/user.py
Executable file
71
backup/first -fina app/app/models/user.py
Executable file
|
|
@ -0,0 +1,71 @@
|
|||
from flask_login import UserMixin
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
import pyotp
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
__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(200), nullable=False)
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
currency = db.Column(db.String(3), default='USD')
|
||||
language = db.Column(db.String(2), default='en') # en, ro, es
|
||||
|
||||
# Budget alert preferences
|
||||
budget_alerts_enabled = db.Column(db.Boolean, default=True)
|
||||
alert_email = db.Column(db.String(120), nullable=True) # Optional separate alert email
|
||||
|
||||
# 2FA fields
|
||||
totp_secret = db.Column(db.String(32), nullable=True)
|
||||
is_2fa_enabled = db.Column(db.Boolean, default=False)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
categories = db.relationship('Category', backref='user', lazy=True, cascade='all, delete-orphan')
|
||||
expenses = db.relationship('Expense', backref='user', lazy=True, cascade='all, delete-orphan')
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def generate_totp_secret(self):
|
||||
"""Generate a new TOTP secret"""
|
||||
self.totp_secret = pyotp.random_base32()
|
||||
return self.totp_secret
|
||||
|
||||
def get_totp_uri(self):
|
||||
"""Get TOTP URI for QR code"""
|
||||
if not self.totp_secret:
|
||||
self.generate_totp_secret()
|
||||
return pyotp.totp.TOTP(self.totp_secret).provisioning_uri(
|
||||
name=self.email,
|
||||
issuer_name='FINA'
|
||||
)
|
||||
|
||||
def verify_totp(self, token):
|
||||
"""Verify TOTP token"""
|
||||
if not self.totp_secret:
|
||||
return False
|
||||
totp = pyotp.TOTP(self.totp_secret)
|
||||
return totp.verify(token, valid_window=1)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.username}>'
|
||||
|
||||
class Tag(db.Model):
|
||||
__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')
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Tag {self.name}>'
|
||||
311
backup/first -fina app/app/ocr.py
Normal file
311
backup/first -fina app/app/ocr.py
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
"""
|
||||
Receipt OCR Module
|
||||
Extracts amount, date, and merchant information from receipt images using Tesseract OCR
|
||||
"""
|
||||
|
||||
import pytesseract
|
||||
from PIL import Image
|
||||
import re
|
||||
from datetime import datetime
|
||||
from dateutil import parser as date_parser
|
||||
import os
|
||||
|
||||
|
||||
def extract_receipt_data(image_path):
|
||||
"""
|
||||
Extract structured data from receipt image
|
||||
|
||||
Args:
|
||||
image_path: Path to the receipt image file
|
||||
|
||||
Returns:
|
||||
dict with extracted data: {
|
||||
'amount': float or None,
|
||||
'date': datetime or None,
|
||||
'merchant': str or None,
|
||||
'raw_text': str,
|
||||
'confidence': str ('high', 'medium', 'low')
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Open and preprocess image
|
||||
image = Image.open(image_path)
|
||||
|
||||
# Convert to grayscale for better OCR
|
||||
if image.mode != 'L':
|
||||
image = image.convert('L')
|
||||
|
||||
# Perform OCR
|
||||
text = pytesseract.image_to_string(image, config='--psm 6')
|
||||
|
||||
# Extract structured data
|
||||
amount = extract_amount(text)
|
||||
date = extract_date(text)
|
||||
merchant = extract_merchant(text)
|
||||
|
||||
# Determine confidence level
|
||||
confidence = calculate_confidence(amount, date, merchant, text)
|
||||
|
||||
return {
|
||||
'amount': amount,
|
||||
'date': date,
|
||||
'merchant': merchant,
|
||||
'raw_text': text,
|
||||
'confidence': confidence,
|
||||
'success': True
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
'amount': None,
|
||||
'date': None,
|
||||
'merchant': None,
|
||||
'raw_text': '',
|
||||
'confidence': 'none',
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
|
||||
def extract_amount(text):
|
||||
"""
|
||||
Extract monetary amount from text
|
||||
Supports multiple formats: $10.99, 10.99, 10,99, etc.
|
||||
"""
|
||||
# Common patterns for amounts
|
||||
patterns = [
|
||||
r'(?:total|suma|amount|subtotal|plata)[\s:]*[\$€£]?\s*(\d{1,6}[.,]\d{2})', # Total: $10.99
|
||||
r'[\$€£]\s*(\d{1,6}[.,]\d{2})', # $10.99
|
||||
r'(\d{1,6}[.,]\d{2})\s*(?:RON|USD|EUR|GBP|lei)', # 10.99 RON
|
||||
r'(?:^|\s)(\d{1,6}[.,]\d{2})(?:\s|$)', # Standalone 10.99
|
||||
]
|
||||
|
||||
amounts = []
|
||||
for pattern in patterns:
|
||||
matches = re.findall(pattern, text, re.IGNORECASE | re.MULTILINE)
|
||||
for match in matches:
|
||||
# Normalize comma to dot
|
||||
amount_str = match.replace(',', '.')
|
||||
try:
|
||||
amount = float(amount_str)
|
||||
if 0.01 <= amount <= 999999: # Reasonable range
|
||||
amounts.append(amount)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if amounts:
|
||||
# Return the largest amount (usually the total)
|
||||
return max(amounts)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_date(text):
|
||||
"""
|
||||
Extract date from text
|
||||
Supports multiple formats: DD/MM/YYYY, MM-DD-YYYY, DD.MM.YYYY, etc.
|
||||
"""
|
||||
# Common date patterns
|
||||
date_patterns = [
|
||||
r'\d{1,2}[/-]\d{1,2}[/-]\d{2,4}', # DD/MM/YYYY, MM-DD-YYYY
|
||||
r'\d{1,2}\.\d{1,2}\.\d{2,4}', # DD.MM.YYYY
|
||||
r'\d{4}[/-]\d{1,2}[/-]\d{1,2}', # YYYY-MM-DD
|
||||
r'(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{1,2},?\s+\d{4}', # Jan 15, 2024
|
||||
r'\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{4}', # 15 Jan 2024
|
||||
]
|
||||
|
||||
dates = []
|
||||
for pattern in date_patterns:
|
||||
matches = re.findall(pattern, text, re.IGNORECASE)
|
||||
for match in matches:
|
||||
try:
|
||||
# Try to parse the date
|
||||
parsed_date = date_parser.parse(match, fuzzy=True)
|
||||
|
||||
# Only accept dates within reasonable range
|
||||
if datetime(2000, 1, 1) <= parsed_date <= datetime.now():
|
||||
dates.append(parsed_date)
|
||||
except (ValueError, date_parser.ParserError):
|
||||
continue
|
||||
|
||||
if dates:
|
||||
# Return the most recent date (likely the transaction date)
|
||||
return max(dates)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_merchant(text):
|
||||
"""
|
||||
Extract merchant/store name from text
|
||||
Usually appears at the top of the receipt
|
||||
"""
|
||||
lines = text.strip().split('\n')
|
||||
|
||||
# Look at first few lines for merchant name
|
||||
for i, line in enumerate(lines[:5]):
|
||||
line = line.strip()
|
||||
|
||||
# Skip very short lines
|
||||
if len(line) < 3:
|
||||
continue
|
||||
|
||||
# Skip lines that look like addresses or numbers
|
||||
if re.match(r'^[\d\s\.,]+$', line):
|
||||
continue
|
||||
|
||||
# Skip common keywords
|
||||
if re.match(r'^(receipt|factura|bon|total|date|time)', line, re.IGNORECASE):
|
||||
continue
|
||||
|
||||
# If line has letters and reasonable length, likely merchant
|
||||
if re.search(r'[a-zA-Z]{3,}', line) and 3 <= len(line) <= 50:
|
||||
# Clean up the line
|
||||
cleaned = re.sub(r'[^\w\s-]', ' ', line)
|
||||
cleaned = ' '.join(cleaned.split())
|
||||
|
||||
if cleaned:
|
||||
return cleaned
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def calculate_confidence(amount, date, merchant, text):
|
||||
"""
|
||||
Calculate confidence level of extraction
|
||||
|
||||
Returns: 'high', 'medium', 'low', or 'none'
|
||||
"""
|
||||
found_count = sum([
|
||||
amount is not None,
|
||||
date is not None,
|
||||
merchant is not None
|
||||
])
|
||||
|
||||
# Check text quality
|
||||
text_quality = len(text.strip()) > 50 and len(text.split()) > 10
|
||||
|
||||
if found_count == 3 and text_quality:
|
||||
return 'high'
|
||||
elif found_count >= 2:
|
||||
return 'medium'
|
||||
elif found_count >= 1:
|
||||
return 'low'
|
||||
else:
|
||||
return 'none'
|
||||
|
||||
|
||||
def preprocess_image_for_ocr(image_path, output_path=None):
|
||||
"""
|
||||
Preprocess image to improve OCR accuracy
|
||||
|
||||
Args:
|
||||
image_path: Path to original image
|
||||
output_path: Path to save preprocessed image (optional)
|
||||
|
||||
Returns:
|
||||
PIL Image object
|
||||
"""
|
||||
from PIL import ImageEnhance, ImageFilter
|
||||
|
||||
image = Image.open(image_path)
|
||||
|
||||
# Convert to grayscale
|
||||
image = image.convert('L')
|
||||
|
||||
# Increase contrast
|
||||
enhancer = ImageEnhance.Contrast(image)
|
||||
image = enhancer.enhance(2.0)
|
||||
|
||||
# Sharpen image
|
||||
image = image.filter(ImageFilter.SHARPEN)
|
||||
|
||||
# Apply threshold (binarization)
|
||||
threshold = 128
|
||||
image = image.point(lambda p: 255 if p > threshold else 0)
|
||||
|
||||
if output_path:
|
||||
image.save(output_path)
|
||||
|
||||
return image
|
||||
|
||||
|
||||
def is_valid_receipt_image(image_path):
|
||||
"""
|
||||
Validate that uploaded file is a valid image
|
||||
|
||||
Security check to prevent malicious files
|
||||
"""
|
||||
try:
|
||||
image = Image.open(image_path)
|
||||
image.verify()
|
||||
|
||||
# Check file size (max 10MB)
|
||||
file_size = os.path.getsize(image_path)
|
||||
if file_size > 10 * 1024 * 1024:
|
||||
return False, "File too large (max 10MB)"
|
||||
|
||||
# Check image dimensions (reasonable receipt size)
|
||||
image = Image.open(image_path)
|
||||
width, height = image.size
|
||||
if width < 100 or height < 100:
|
||||
return False, "Image too small"
|
||||
if width > 8000 or height > 8000:
|
||||
return False, "Image too large"
|
||||
|
||||
# Check format
|
||||
if image.format not in ['JPEG', 'PNG', 'JPG']:
|
||||
return False, "Unsupported format (use JPEG or PNG)"
|
||||
|
||||
return True, "Valid"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Invalid image: {str(e)}"
|
||||
|
||||
|
||||
def extract_receipt_data_batch(image_paths):
|
||||
"""
|
||||
Process multiple receipt images in batch
|
||||
|
||||
Args:
|
||||
image_paths: List of image file paths
|
||||
|
||||
Returns:
|
||||
List of extraction results
|
||||
"""
|
||||
results = []
|
||||
for path in image_paths:
|
||||
result = extract_receipt_data(path)
|
||||
result['file_path'] = path
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
|
||||
def format_extraction_summary(data):
|
||||
"""
|
||||
Format extracted data for display
|
||||
|
||||
Returns: Human-readable string
|
||||
"""
|
||||
lines = []
|
||||
|
||||
if data.get('merchant'):
|
||||
lines.append(f"🏪 Merchant: {data['merchant']}")
|
||||
|
||||
if data.get('amount'):
|
||||
lines.append(f"💰 Amount: {data['amount']:.2f}")
|
||||
|
||||
if data.get('date'):
|
||||
lines.append(f"📅 Date: {data['date'].strftime('%Y-%m-%d')}")
|
||||
|
||||
if data.get('confidence'):
|
||||
confidence_emoji = {
|
||||
'high': '✅',
|
||||
'medium': '⚠️',
|
||||
'low': '❌',
|
||||
'none': '❌'
|
||||
}
|
||||
emoji = confidence_emoji.get(data['confidence'], '❓')
|
||||
lines.append(f"{emoji} Confidence: {data['confidence'].title()}")
|
||||
|
||||
return '\n'.join(lines) if lines else "No data extracted"
|
||||
373
backup/first -fina app/app/predictions.py
Normal file
373
backup/first -fina app/app/predictions.py
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
"""
|
||||
Spending Predictions Module
|
||||
Analyzes historical spending patterns and predicts future expenses
|
||||
"""
|
||||
|
||||
from app import db
|
||||
from app.models.category import Category, Expense
|
||||
from sqlalchemy import extract, func
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
import statistics
|
||||
|
||||
|
||||
def get_spending_predictions(user_id, months_ahead=3):
|
||||
"""
|
||||
Predict spending for the next X months based on historical data
|
||||
|
||||
Args:
|
||||
user_id: User ID to generate predictions for
|
||||
months_ahead: Number of months to predict (default: 3)
|
||||
|
||||
Returns:
|
||||
dict with predictions per category and total
|
||||
"""
|
||||
categories = Category.query.filter_by(user_id=user_id).all()
|
||||
|
||||
predictions = {
|
||||
'by_category': {},
|
||||
'total_months': 0,
|
||||
'insights': []
|
||||
}
|
||||
|
||||
current_date = datetime.now()
|
||||
total_predicted = 0
|
||||
total_months_data = []
|
||||
|
||||
for category in categories:
|
||||
category_prediction = predict_category_spending(
|
||||
category,
|
||||
current_date,
|
||||
months_ahead
|
||||
)
|
||||
|
||||
if category_prediction['predicted_amount'] > 0:
|
||||
# Add category_id for API calls
|
||||
category_prediction['category_id'] = category.id
|
||||
predictions['by_category'][category.name] = category_prediction
|
||||
total_predicted += category_prediction['predicted_amount']
|
||||
total_months_data.append(category_prediction['historical_months'])
|
||||
|
||||
# Calculate overall statistics
|
||||
if predictions['by_category']:
|
||||
avg_months = sum(total_months_data) / len(total_months_data)
|
||||
predictions['total_months'] = int(avg_months)
|
||||
|
||||
# Determine overall confidence
|
||||
if avg_months >= 6:
|
||||
overall_confidence = 'high'
|
||||
elif avg_months >= 3:
|
||||
overall_confidence = 'medium'
|
||||
else:
|
||||
overall_confidence = 'low'
|
||||
|
||||
# Determine overall trend
|
||||
increasing = sum(1 for p in predictions['by_category'].values() if p['trend'] == 'increasing')
|
||||
decreasing = sum(1 for p in predictions['by_category'].values() if p['trend'] == 'decreasing')
|
||||
|
||||
if increasing > decreasing:
|
||||
overall_trend = 'increasing'
|
||||
elif decreasing > increasing:
|
||||
overall_trend = 'decreasing'
|
||||
else:
|
||||
overall_trend = 'stable'
|
||||
|
||||
predictions['total'] = {
|
||||
'amount': round(total_predicted, 2),
|
||||
'confidence': overall_confidence,
|
||||
'trend': overall_trend,
|
||||
'months_of_data': int(avg_months)
|
||||
}
|
||||
else:
|
||||
predictions['total_months'] = 0
|
||||
predictions['total'] = {
|
||||
'amount': 0,
|
||||
'confidence': 'none',
|
||||
'trend': 'stable',
|
||||
'months_of_data': 0
|
||||
}
|
||||
|
||||
# Generate insights
|
||||
predictions['insights'] = generate_insights(predictions['by_category'], current_date)
|
||||
|
||||
return predictions
|
||||
|
||||
|
||||
def predict_category_spending(category, current_date, months_ahead=3):
|
||||
"""
|
||||
Predict spending for a specific category
|
||||
|
||||
Uses weighted average with more recent months having higher weight
|
||||
"""
|
||||
# Get last 12 months of data
|
||||
twelve_months_ago = current_date - timedelta(days=365)
|
||||
|
||||
monthly_spending = db.session.query(
|
||||
extract('year', Expense.date).label('year'),
|
||||
extract('month', Expense.date).label('month'),
|
||||
func.sum(Expense.amount).label('total')
|
||||
).filter(
|
||||
Expense.category_id == category.id,
|
||||
Expense.date >= twelve_months_ago
|
||||
).group_by('year', 'month').all()
|
||||
|
||||
if not monthly_spending:
|
||||
return {
|
||||
'predicted_amount': 0,
|
||||
'historical_average': 0,
|
||||
'trend': 'none',
|
||||
'historical_months': 0,
|
||||
'confidence': 'none'
|
||||
}
|
||||
|
||||
# Extract amounts and calculate statistics
|
||||
amounts = [float(row.total) for row in monthly_spending]
|
||||
historical_months = len(amounts)
|
||||
|
||||
# Calculate weighted average (recent months have more weight)
|
||||
weights = list(range(1, len(amounts) + 1))
|
||||
weighted_avg = sum(a * w for a, w in zip(amounts, weights)) / sum(weights)
|
||||
|
||||
# Calculate trend
|
||||
if len(amounts) >= 3:
|
||||
first_half = sum(amounts[:len(amounts)//2]) / (len(amounts)//2)
|
||||
second_half = sum(amounts[len(amounts)//2:]) / (len(amounts) - len(amounts)//2)
|
||||
|
||||
if second_half > first_half * 1.1:
|
||||
trend = 'increasing'
|
||||
elif second_half < first_half * 0.9:
|
||||
trend = 'decreasing'
|
||||
else:
|
||||
trend = 'stable'
|
||||
else:
|
||||
trend = 'stable'
|
||||
|
||||
# Adjust prediction based on trend
|
||||
if trend == 'increasing':
|
||||
predicted_amount = weighted_avg * 1.05 # 5% increase
|
||||
elif trend == 'decreasing':
|
||||
predicted_amount = weighted_avg * 0.95 # 5% decrease
|
||||
else:
|
||||
predicted_amount = weighted_avg
|
||||
|
||||
# Multiply by months ahead
|
||||
predicted_total = predicted_amount * months_ahead
|
||||
|
||||
# Calculate confidence based on data consistency
|
||||
if len(amounts) >= 3:
|
||||
std_dev = statistics.stdev(amounts)
|
||||
avg = statistics.mean(amounts)
|
||||
coefficient_of_variation = std_dev / avg if avg > 0 else 1
|
||||
|
||||
if coefficient_of_variation < 0.3:
|
||||
confidence = 'high'
|
||||
elif coefficient_of_variation < 0.6:
|
||||
confidence = 'medium'
|
||||
else:
|
||||
confidence = 'low'
|
||||
else:
|
||||
confidence = 'low'
|
||||
|
||||
return {
|
||||
'predicted_amount': round(predicted_total, 2),
|
||||
'monthly_average': round(predicted_amount, 2),
|
||||
'historical_average': round(statistics.mean(amounts), 2),
|
||||
'trend': trend,
|
||||
'historical_months': historical_months,
|
||||
'confidence': confidence,
|
||||
'min': round(min(amounts), 2),
|
||||
'max': round(max(amounts), 2)
|
||||
}
|
||||
|
||||
|
||||
def generate_insights(category_predictions, current_date):
|
||||
"""Generate human-readable insights from predictions"""
|
||||
insights = []
|
||||
|
||||
# Find categories with increasing trends
|
||||
increasing = [
|
||||
name for name, pred in category_predictions.items()
|
||||
if pred['trend'] == 'increasing'
|
||||
]
|
||||
if increasing:
|
||||
insights.append({
|
||||
'type': 'warning',
|
||||
'message': f"Spending is increasing in: {', '.join(increasing)}"
|
||||
})
|
||||
|
||||
# Find categories with high spending
|
||||
sorted_by_amount = sorted(
|
||||
category_predictions.items(),
|
||||
key=lambda x: x[1]['predicted_amount'],
|
||||
reverse=True
|
||||
)
|
||||
|
||||
if sorted_by_amount:
|
||||
top_category = sorted_by_amount[0]
|
||||
insights.append({
|
||||
'type': 'info',
|
||||
'message': f"Highest predicted spending: {top_category[0]}"
|
||||
})
|
||||
|
||||
# Find categories with high confidence
|
||||
high_confidence = [
|
||||
name for name, pred in category_predictions.items()
|
||||
if pred['confidence'] == 'high'
|
||||
]
|
||||
if len(high_confidence) >= 3:
|
||||
insights.append({
|
||||
'type': 'success',
|
||||
'message': f"High prediction accuracy for {len(high_confidence)} categories"
|
||||
})
|
||||
|
||||
# Seasonal insight (simple check)
|
||||
current_month = current_date.month
|
||||
if current_month in [11, 12]: # November, December
|
||||
insights.append({
|
||||
'type': 'info',
|
||||
'message': "Holiday season - spending typically increases"
|
||||
})
|
||||
elif current_month in [1, 2]: # January, February
|
||||
insights.append({
|
||||
'type': 'info',
|
||||
'message': "Post-holiday period - spending may decrease"
|
||||
})
|
||||
|
||||
return insights
|
||||
|
||||
|
||||
def get_category_forecast(category_id, user_id, months=6):
|
||||
"""
|
||||
Get detailed forecast for a specific category
|
||||
|
||||
Returns monthly predictions for next N months
|
||||
"""
|
||||
category = Category.query.filter_by(
|
||||
id=category_id,
|
||||
user_id=user_id
|
||||
).first()
|
||||
|
||||
if not category:
|
||||
return None
|
||||
|
||||
current_date = datetime.now()
|
||||
|
||||
# Get historical monthly data
|
||||
twelve_months_ago = current_date - timedelta(days=365)
|
||||
|
||||
monthly_data = db.session.query(
|
||||
extract('year', Expense.date).label('year'),
|
||||
extract('month', Expense.date).label('month'),
|
||||
func.sum(Expense.amount).label('total')
|
||||
).filter(
|
||||
Expense.category_id == category_id,
|
||||
Expense.date >= twelve_months_ago
|
||||
).group_by('year', 'month').order_by('year', 'month').all()
|
||||
|
||||
if not monthly_data:
|
||||
return {
|
||||
'category_name': category.name,
|
||||
'forecast': [],
|
||||
'message': 'Not enough data for predictions'
|
||||
}
|
||||
|
||||
# Calculate base prediction
|
||||
amounts = [float(row.total) for row in monthly_data]
|
||||
avg_spending = statistics.mean(amounts)
|
||||
|
||||
# Generate forecast for next months
|
||||
forecast = []
|
||||
for i in range(1, months + 1):
|
||||
future_date = current_date + timedelta(days=30 * i)
|
||||
|
||||
# Simple seasonal adjustment based on month
|
||||
seasonal_factor = get_seasonal_factor(future_date.month)
|
||||
predicted = avg_spending * seasonal_factor
|
||||
|
||||
forecast.append({
|
||||
'month': future_date.strftime('%B %Y'),
|
||||
'month_num': future_date.month,
|
||||
'year': future_date.year,
|
||||
'predicted_amount': round(predicted, 2)
|
||||
})
|
||||
|
||||
return {
|
||||
'category_name': category.name,
|
||||
'category_color': category.color,
|
||||
'historical_average': round(avg_spending, 2),
|
||||
'forecast': forecast
|
||||
}
|
||||
|
||||
|
||||
def get_seasonal_factor(month):
|
||||
"""
|
||||
Get seasonal adjustment factor based on month
|
||||
|
||||
This is a simplified version - could be made more sophisticated
|
||||
with actual historical data analysis
|
||||
"""
|
||||
# Holiday months (Nov, Dec) typically have higher spending
|
||||
# Summer months might vary by category
|
||||
factors = {
|
||||
1: 0.9, # January - post-holiday slowdown
|
||||
2: 0.95, # February
|
||||
3: 1.0, # March
|
||||
4: 1.0, # April
|
||||
5: 1.05, # May
|
||||
6: 1.05, # June - summer
|
||||
7: 1.05, # July - summer
|
||||
8: 1.0, # August
|
||||
9: 1.0, # September - back to school
|
||||
10: 1.05, # October
|
||||
11: 1.1, # November - holidays starting
|
||||
12: 1.15 # December - peak holiday
|
||||
}
|
||||
return factors.get(month, 1.0)
|
||||
|
||||
|
||||
def compare_with_predictions(user_id, month=None, year=None):
|
||||
"""
|
||||
Compare actual spending with predictions
|
||||
|
||||
Useful for showing accuracy of predictions
|
||||
"""
|
||||
if month is None:
|
||||
month = datetime.now().month
|
||||
if year is None:
|
||||
year = datetime.now().year
|
||||
|
||||
categories = Category.query.filter_by(user_id=user_id).all()
|
||||
|
||||
comparison = {
|
||||
'month': month,
|
||||
'year': year,
|
||||
'categories': {}
|
||||
}
|
||||
|
||||
for category in categories:
|
||||
# Get actual spending for the month
|
||||
actual = db.session.query(func.sum(Expense.amount)).filter(
|
||||
Expense.category_id == category.id,
|
||||
extract('year', Expense.date) == year,
|
||||
extract('month', Expense.date) == month
|
||||
).scalar()
|
||||
|
||||
actual = float(actual) if actual else 0
|
||||
|
||||
# Get predicted value (simplified - using average)
|
||||
prediction = predict_category_spending(category, datetime.now(), 1)
|
||||
predicted = prediction['monthly_average']
|
||||
|
||||
if predicted > 0:
|
||||
accuracy = (1 - abs(actual - predicted) / predicted) * 100
|
||||
else:
|
||||
accuracy = 0 if actual == 0 else 0
|
||||
|
||||
comparison['categories'][category.name] = {
|
||||
'actual': round(actual, 2),
|
||||
'predicted': round(predicted, 2),
|
||||
'difference': round(actual - predicted, 2),
|
||||
'accuracy': round(accuracy, 1)
|
||||
}
|
||||
|
||||
return comparison
|
||||
22
backup/first -fina app/app/pwa.py
Normal file
22
backup/first -fina app/app/pwa.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""
|
||||
Add route to serve service worker from root
|
||||
"""
|
||||
from flask import send_from_directory
|
||||
import os
|
||||
|
||||
def register_pwa_routes(app):
|
||||
@app.route('/service-worker.js')
|
||||
def service_worker():
|
||||
return send_from_directory(
|
||||
os.path.join(app.root_path, 'static', 'js'),
|
||||
'service-worker.js',
|
||||
mimetype='application/javascript'
|
||||
)
|
||||
|
||||
@app.route('/manifest.json')
|
||||
def manifest():
|
||||
return send_from_directory(
|
||||
os.path.join(app.root_path, 'static'),
|
||||
'manifest.json',
|
||||
mimetype='application/json'
|
||||
)
|
||||
1
backup/first -fina app/app/routes/__init__.py
Executable file
1
backup/first -fina app/app/routes/__init__.py
Executable file
|
|
@ -0,0 +1 @@
|
|||
# Routes package
|
||||
115
backup/first -fina app/app/routes/auth.py
Executable file
115
backup/first -fina app/app/routes/auth.py
Executable file
|
|
@ -0,0 +1,115 @@
|
|||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
|
||||
bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||
|
||||
@bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
totp_token = request.form.get('totp_token')
|
||||
|
||||
user = User.query.filter_by(username=username).first()
|
||||
|
||||
if user and user.check_password(password):
|
||||
# Check if 2FA is enabled
|
||||
if user.is_2fa_enabled:
|
||||
if not totp_token:
|
||||
# Store user ID in session for 2FA verification
|
||||
session['2fa_user_id'] = user.id
|
||||
return render_template('auth/verify_2fa.html')
|
||||
else:
|
||||
# Verify 2FA token
|
||||
if user.verify_totp(totp_token):
|
||||
login_user(user)
|
||||
session.pop('2fa_user_id', None)
|
||||
flash('Login successful!', 'success')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
else:
|
||||
flash('Invalid 2FA code', 'error')
|
||||
return render_template('auth/verify_2fa.html')
|
||||
else:
|
||||
# No 2FA, login directly
|
||||
login_user(user)
|
||||
flash('Login successful!', 'success')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
flash('Invalid username or password', 'error')
|
||||
|
||||
return render_template('auth/login.html')
|
||||
|
||||
@bp.route('/verify-2fa', methods=['POST'])
|
||||
def verify_2fa():
|
||||
user_id = session.get('2fa_user_id')
|
||||
if not user_id:
|
||||
flash('Session expired. Please login again.', 'error')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
flash('User not found', 'error')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
token = request.form.get('token')
|
||||
|
||||
if user.verify_totp(token):
|
||||
login_user(user)
|
||||
session.pop('2fa_user_id', None)
|
||||
flash('Login successful!', 'success')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
else:
|
||||
flash('Invalid 2FA code. Please try again.', 'error')
|
||||
return render_template('auth/verify_2fa.html')
|
||||
|
||||
@bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
email = request.form.get('email')
|
||||
password = request.form.get('password')
|
||||
confirm_password = request.form.get('confirm_password')
|
||||
|
||||
if password != confirm_password:
|
||||
flash('Passwords do not match', 'error')
|
||||
return redirect(url_for('auth.register'))
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash('Username already exists', 'error')
|
||||
return redirect(url_for('auth.register'))
|
||||
|
||||
if User.query.filter_by(email=email).first():
|
||||
flash('Email already exists', 'error')
|
||||
return redirect(url_for('auth.register'))
|
||||
|
||||
is_first_user = User.query.count() == 0
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
is_admin=is_first_user
|
||||
)
|
||||
user.set_password(password)
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
flash('Registration successful! Please login.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
return render_template('auth/register.html')
|
||||
|
||||
@bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
flash('Logged out successfully', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
1
backup/first -fina app/app/routes/init.py
Executable file
1
backup/first -fina app/app/routes/init.py
Executable file
|
|
@ -0,0 +1 @@
|
|||
# This file makes routes a proper Python package
|
||||
18
backup/first -fina app/app/routes/language.py
Normal file
18
backup/first -fina app/app/routes/language.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from flask import Blueprint, request, redirect, url_for
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
|
||||
bp = Blueprint('language', __name__, url_prefix='/language')
|
||||
|
||||
@bp.route('/switch/<lang>')
|
||||
@login_required
|
||||
def switch_language(lang):
|
||||
"""Switch user's language preference"""
|
||||
allowed_languages = ['en', 'ro', 'es']
|
||||
|
||||
if lang in allowed_languages:
|
||||
current_user.language = lang
|
||||
db.session.commit()
|
||||
|
||||
# Redirect back to the referring page or dashboard
|
||||
return redirect(request.referrer or url_for('main.dashboard'))
|
||||
810
backup/first -fina app/app/routes/main.py
Executable file
810
backup/first -fina app/app/routes/main.py
Executable file
|
|
@ -0,0 +1,810 @@
|
|||
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file, jsonify, current_app
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.category import Category, Expense
|
||||
from app.models.user import Tag
|
||||
from werkzeug.utils import secure_filename
|
||||
import os
|
||||
from datetime import datetime
|
||||
from sqlalchemy import extract, func
|
||||
import json
|
||||
|
||||
bp = Blueprint('main', __name__)
|
||||
|
||||
def allowed_file(filename):
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'pdf', 'gif'}
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
@bp.route('/')
|
||||
def index():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@bp.route('/dashboard')
|
||||
@login_required
|
||||
def dashboard():
|
||||
from app.models.subscription import Subscription
|
||||
from datetime import timedelta, date
|
||||
|
||||
today = date.today()
|
||||
categories = Category.query.filter_by(user_id=current_user.id).all()
|
||||
total_spent = sum(cat.get_total_spent() for cat in categories)
|
||||
|
||||
total_expenses = Expense.query.filter_by(user_id=current_user.id).count()
|
||||
|
||||
years_query = db.session.query(
|
||||
extract('year', Expense.date).label('year')
|
||||
).filter(
|
||||
Expense.user_id == current_user.id
|
||||
).distinct().all()
|
||||
|
||||
available_years = sorted([int(year[0]) for year in years_query if year[0]], reverse=True)
|
||||
if not available_years:
|
||||
available_years = [datetime.now().year]
|
||||
|
||||
current_year = datetime.now().year
|
||||
|
||||
chart_data = []
|
||||
for cat in categories:
|
||||
spent = cat.get_total_spent()
|
||||
if spent > 0:
|
||||
chart_data.append({
|
||||
'name': cat.name,
|
||||
'value': spent,
|
||||
'color': cat.color
|
||||
})
|
||||
|
||||
categories_json = [
|
||||
{
|
||||
'id': cat.id,
|
||||
'name': cat.name,
|
||||
'color': cat.color
|
||||
}
|
||||
for cat in categories
|
||||
]
|
||||
|
||||
# Get upcoming subscriptions (next 30 days)
|
||||
end_date = datetime.now().date() + timedelta(days=30)
|
||||
upcoming_subscriptions = Subscription.query.filter(
|
||||
Subscription.user_id == current_user.id,
|
||||
Subscription.is_active == True,
|
||||
Subscription.next_due_date <= end_date
|
||||
).order_by(Subscription.next_due_date).limit(5).all()
|
||||
|
||||
# Get suggestions count
|
||||
from app.smart_detection import get_user_suggestions
|
||||
suggestions_count = len(get_user_suggestions(current_user.id))
|
||||
|
||||
return render_template('dashboard.html',
|
||||
categories=categories,
|
||||
total_spent=total_spent,
|
||||
total_expenses=total_expenses,
|
||||
chart_data=chart_data,
|
||||
categories_json=categories_json,
|
||||
available_years=available_years,
|
||||
current_year=current_year,
|
||||
upcoming_subscriptions=upcoming_subscriptions,
|
||||
suggestions_count=suggestions_count,
|
||||
today=today)
|
||||
|
||||
@bp.route('/api/metrics')
|
||||
@login_required
|
||||
def get_metrics():
|
||||
category_id = request.args.get('category', 'all')
|
||||
year = int(request.args.get('year', datetime.now().year))
|
||||
|
||||
if category_id == 'all':
|
||||
categories = Category.query.filter_by(user_id=current_user.id).all()
|
||||
|
||||
monthly_data = [0] * 12
|
||||
for cat in categories:
|
||||
cat_monthly = cat.get_monthly_totals(year)
|
||||
monthly_data = [monthly_data[i] + cat_monthly[i] for i in range(12)]
|
||||
|
||||
pie_data = [cat.get_yearly_total(year) for cat in categories]
|
||||
pie_labels = [cat.name for cat in categories]
|
||||
pie_colors = [cat.color for cat in categories]
|
||||
|
||||
return jsonify({
|
||||
'category_name': 'All Categories',
|
||||
'monthly_data': monthly_data,
|
||||
'color': '#6366f1',
|
||||
'pie_data': pie_data,
|
||||
'pie_labels': pie_labels,
|
||||
'pie_colors': pie_colors
|
||||
})
|
||||
else:
|
||||
category = Category.query.filter_by(
|
||||
id=int(category_id),
|
||||
user_id=current_user.id
|
||||
).first_or_404()
|
||||
|
||||
monthly_data = category.get_monthly_totals(year)
|
||||
|
||||
categories = Category.query.filter_by(user_id=current_user.id).all()
|
||||
pie_data = [cat.get_yearly_total(year) for cat in categories]
|
||||
pie_labels = [cat.name for cat in categories]
|
||||
pie_colors = [cat.color for cat in categories]
|
||||
|
||||
return jsonify({
|
||||
'category_name': category.name,
|
||||
'monthly_data': monthly_data,
|
||||
'color': category.color,
|
||||
'pie_data': pie_data,
|
||||
'pie_labels': pie_labels,
|
||||
'pie_colors': pie_colors
|
||||
})
|
||||
|
||||
@bp.route('/category/create', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create_category():
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name')
|
||||
description = request.form.get('description')
|
||||
color = request.form.get('color', '#6366f1')
|
||||
|
||||
if not name:
|
||||
flash('Category name is required', 'error')
|
||||
return redirect(url_for('main.create_category'))
|
||||
|
||||
category = Category(
|
||||
name=name,
|
||||
description=description,
|
||||
color=color,
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
db.session.add(category)
|
||||
db.session.commit()
|
||||
|
||||
flash('Category created successfully!', 'success')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
return render_template('create_category.html')
|
||||
|
||||
@bp.route('/category/<int:category_id>')
|
||||
@login_required
|
||||
def view_category(category_id):
|
||||
category = Category.query.filter_by(id=category_id, user_id=current_user.id).first_or_404()
|
||||
expenses = Expense.query.filter_by(category_id=category_id, user_id=current_user.id).order_by(Expense.date.desc()).all()
|
||||
|
||||
total_spent = category.get_total_spent()
|
||||
|
||||
return render_template('view_category.html',
|
||||
category=category,
|
||||
expenses=expenses,
|
||||
total_spent=total_spent)
|
||||
|
||||
@bp.route('/category/<int:category_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_category(category_id):
|
||||
category = Category.query.filter_by(id=category_id, user_id=current_user.id).first_or_404()
|
||||
|
||||
if request.method == 'POST':
|
||||
category.name = request.form.get('name')
|
||||
category.description = request.form.get('description')
|
||||
category.color = request.form.get('color')
|
||||
|
||||
# Budget settings
|
||||
monthly_budget = request.form.get('monthly_budget', '').strip()
|
||||
if monthly_budget:
|
||||
try:
|
||||
category.monthly_budget = float(monthly_budget)
|
||||
if category.monthly_budget < 0:
|
||||
category.monthly_budget = None
|
||||
except ValueError:
|
||||
category.monthly_budget = None
|
||||
else:
|
||||
category.monthly_budget = None
|
||||
|
||||
# Budget alert threshold (default 100%)
|
||||
threshold = request.form.get('budget_alert_threshold', '100').strip()
|
||||
try:
|
||||
category.budget_alert_threshold = float(threshold) / 100
|
||||
if category.budget_alert_threshold < 0.5 or category.budget_alert_threshold > 2.0:
|
||||
category.budget_alert_threshold = 1.0
|
||||
except ValueError:
|
||||
category.budget_alert_threshold = 1.0
|
||||
|
||||
db.session.commit()
|
||||
flash('Category updated successfully!', 'success')
|
||||
return redirect(url_for('main.view_category', category_id=category.id))
|
||||
|
||||
return render_template('edit_category.html', category=category)
|
||||
|
||||
@bp.route('/category/<int:category_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_category(category_id):
|
||||
category = Category.query.filter_by(id=category_id, user_id=current_user.id).first_or_404()
|
||||
|
||||
for expense in category.expenses:
|
||||
if expense.file_path:
|
||||
file_path = os.path.join(current_app.root_path, 'static', expense.file_path)
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
db.session.delete(category)
|
||||
db.session.commit()
|
||||
|
||||
flash('Category deleted successfully!', 'success')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
@bp.route('/expense/create/<int:category_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create_expense(category_id):
|
||||
category = Category.query.filter_by(id=category_id, user_id=current_user.id).first_or_404()
|
||||
user_tags = Tag.query.filter_by(user_id=current_user.id).all()
|
||||
|
||||
if request.method == 'POST':
|
||||
description = request.form.get('description')
|
||||
amount = request.form.get('amount')
|
||||
date_str = request.form.get('date')
|
||||
paid_by = request.form.get('paid_by')
|
||||
tags = request.form.get('tags')
|
||||
|
||||
if not all([description, amount]):
|
||||
flash('Description and amount are required', 'error')
|
||||
return redirect(url_for('main.create_expense', category_id=category_id))
|
||||
|
||||
try:
|
||||
amount = float(amount)
|
||||
if amount <= 0:
|
||||
raise ValueError()
|
||||
except ValueError:
|
||||
flash('Please enter a valid amount', 'error')
|
||||
return redirect(url_for('main.create_expense', category_id=category_id))
|
||||
|
||||
file_path = None
|
||||
if 'file' in request.files:
|
||||
file = request.files['file']
|
||||
if file and file.filename and allowed_file(file.filename):
|
||||
filename = secure_filename(file.filename)
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f"{current_user.id}_{timestamp}_{filename}"
|
||||
upload_folder = os.path.join(current_app.root_path, 'static', 'uploads')
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
file_path = os.path.join('uploads', filename)
|
||||
file.save(os.path.join(current_app.root_path, 'static', file_path))
|
||||
|
||||
expense_date = datetime.strptime(date_str, '%Y-%m-%d') if date_str else datetime.utcnow()
|
||||
|
||||
expense = Expense(
|
||||
description=description,
|
||||
amount=amount,
|
||||
date=expense_date,
|
||||
paid_by=paid_by,
|
||||
tags=tags,
|
||||
file_path=file_path,
|
||||
category_id=category_id,
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
db.session.add(expense)
|
||||
db.session.commit()
|
||||
|
||||
# Check budget after adding expense
|
||||
from app.budget_alerts import check_budget_alerts
|
||||
try:
|
||||
check_budget_alerts()
|
||||
except Exception as e:
|
||||
print(f"[Budget Check] Error: {e}")
|
||||
|
||||
flash('Expense added successfully!', 'success')
|
||||
return redirect(url_for('main.view_category', category_id=category_id))
|
||||
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
return render_template('create_expense.html', category=category, today=today, user_tags=user_tags)
|
||||
|
||||
@bp.route('/expense/<int:expense_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_expense(expense_id):
|
||||
expense = Expense.query.filter_by(id=expense_id, user_id=current_user.id).first_or_404()
|
||||
user_tags = Tag.query.filter_by(user_id=current_user.id).all()
|
||||
|
||||
if request.method == 'POST':
|
||||
expense.description = request.form.get('description')
|
||||
expense.amount = float(request.form.get('amount'))
|
||||
expense.date = datetime.strptime(request.form.get('date'), '%Y-%m-%d')
|
||||
expense.paid_by = request.form.get('paid_by')
|
||||
expense.tags = request.form.get('tags')
|
||||
|
||||
if 'file' in request.files:
|
||||
file = request.files['file']
|
||||
if file and file.filename and allowed_file(file.filename):
|
||||
if expense.file_path:
|
||||
old_file = os.path.join(current_app.root_path, 'static', expense.file_path)
|
||||
if os.path.exists(old_file):
|
||||
try:
|
||||
os.remove(old_file)
|
||||
except:
|
||||
pass
|
||||
|
||||
filename = secure_filename(file.filename)
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f"{current_user.id}_{timestamp}_{filename}"
|
||||
upload_folder = os.path.join(current_app.root_path, 'static', 'uploads')
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
file_path = os.path.join('uploads', filename)
|
||||
file.save(os.path.join(current_app.root_path, 'static', file_path))
|
||||
expense.file_path = file_path
|
||||
|
||||
db.session.commit()
|
||||
flash('Expense updated successfully!', 'success')
|
||||
return redirect(url_for('main.view_category', category_id=expense.category_id))
|
||||
|
||||
return render_template('edit_expense.html', expense=expense, user_tags=user_tags)
|
||||
|
||||
@bp.route('/expense/<int:expense_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_expense(expense_id):
|
||||
expense = Expense.query.filter_by(id=expense_id, user_id=current_user.id).first_or_404()
|
||||
category_id = expense.category_id
|
||||
|
||||
if expense.file_path:
|
||||
file_path = os.path.join(current_app.root_path, 'static', expense.file_path)
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
db.session.delete(expense)
|
||||
db.session.commit()
|
||||
|
||||
flash('Expense deleted successfully!', 'success')
|
||||
return redirect(url_for('main.view_category', category_id=category_id))
|
||||
|
||||
@bp.route('/expense/<int:expense_id>/download')
|
||||
@login_required
|
||||
def download_file(expense_id):
|
||||
expense = Expense.query.filter_by(id=expense_id, user_id=current_user.id).first_or_404()
|
||||
|
||||
if not expense.file_path:
|
||||
flash('No file attached to this expense', 'error')
|
||||
return redirect(url_for('main.view_category', category_id=expense.category_id))
|
||||
|
||||
# Use current_app.root_path to get correct path
|
||||
file_path = os.path.join(current_app.root_path, 'static', expense.file_path)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
flash('File not found', 'error')
|
||||
return redirect(url_for('main.view_category', category_id=expense.category_id))
|
||||
|
||||
return send_file(file_path, as_attachment=True)
|
||||
|
||||
@bp.route('/expense/<int:expense_id>/view')
|
||||
@login_required
|
||||
def view_file(expense_id):
|
||||
expense = Expense.query.filter_by(id=expense_id, user_id=current_user.id).first_or_404()
|
||||
|
||||
if not expense.file_path:
|
||||
flash('No file attached to this expense', 'error')
|
||||
return redirect(url_for('main.view_category', category_id=expense.category_id))
|
||||
|
||||
file_path = os.path.join(current_app.root_path, 'static', expense.file_path)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
flash('File not found', 'error')
|
||||
return redirect(url_for('main.view_category', category_id=expense.category_id))
|
||||
|
||||
# Return file for inline viewing
|
||||
return send_file(file_path, as_attachment=False)
|
||||
|
||||
|
||||
@bp.route('/api/ocr/process', methods=['POST'])
|
||||
@login_required
|
||||
def process_receipt_ocr():
|
||||
"""
|
||||
Process uploaded receipt image with OCR
|
||||
Returns extracted data as JSON
|
||||
"""
|
||||
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
|
||||
|
||||
if not allowed_file(file.filename):
|
||||
return jsonify({'success': False, 'error': 'Invalid file type'}), 400
|
||||
|
||||
try:
|
||||
from app.ocr import extract_receipt_data, is_valid_receipt_image
|
||||
|
||||
# Save temp file
|
||||
filename = secure_filename(file.filename)
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
temp_filename = f"temp_{current_user.id}_{timestamp}_{filename}"
|
||||
upload_folder = os.path.join(current_app.root_path, 'static', 'uploads')
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
temp_path = os.path.join(upload_folder, temp_filename)
|
||||
|
||||
file.save(temp_path)
|
||||
|
||||
# Validate image
|
||||
is_valid, message = is_valid_receipt_image(temp_path)
|
||||
if not is_valid:
|
||||
os.remove(temp_path)
|
||||
return jsonify({'success': False, 'error': message}), 400
|
||||
|
||||
# Extract data with OCR
|
||||
extracted_data = extract_receipt_data(temp_path)
|
||||
|
||||
# Format response
|
||||
response = {
|
||||
'success': extracted_data['success'],
|
||||
'amount': extracted_data['amount'],
|
||||
'merchant': extracted_data['merchant'],
|
||||
'confidence': extracted_data['confidence'],
|
||||
'temp_file': temp_filename
|
||||
}
|
||||
|
||||
if extracted_data['date']:
|
||||
response['date'] = extracted_data['date'].strftime('%Y-%m-%d')
|
||||
|
||||
# Don't delete temp file - will be used if user confirms
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
except Exception as e:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/predictions')
|
||||
@login_required
|
||||
def predictions():
|
||||
"""Display spending predictions dashboard"""
|
||||
from app.predictions import get_spending_predictions, generate_insights
|
||||
from datetime import datetime
|
||||
|
||||
# Get predictions for next 3 months
|
||||
predictions_data = get_spending_predictions(current_user.id, months_ahead=3)
|
||||
|
||||
# Generate insights
|
||||
insights = generate_insights(
|
||||
predictions_data['by_category'],
|
||||
datetime.now()
|
||||
)
|
||||
|
||||
return render_template('predictions.html',
|
||||
predictions=predictions_data,
|
||||
insights=insights)
|
||||
|
||||
|
||||
@bp.route('/api/predictions')
|
||||
@login_required
|
||||
def api_predictions():
|
||||
"""Return JSON predictions for charts"""
|
||||
from app.predictions import get_spending_predictions
|
||||
|
||||
months_ahead = request.args.get('months', 3, type=int)
|
||||
|
||||
# Limit to reasonable range
|
||||
if months_ahead < 1 or months_ahead > 12:
|
||||
return jsonify({'error': 'months must be between 1 and 12'}), 400
|
||||
|
||||
predictions = get_spending_predictions(current_user.id, months_ahead)
|
||||
|
||||
return jsonify(predictions)
|
||||
|
||||
|
||||
@bp.route('/api/predictions/category/<int:category_id>')
|
||||
@login_required
|
||||
def api_category_forecast(category_id):
|
||||
"""Get detailed forecast for specific category"""
|
||||
from app.predictions import get_category_forecast
|
||||
from app.models.category import Category
|
||||
|
||||
# Security check: ensure category belongs to user
|
||||
category = Category.query.filter_by(
|
||||
id=category_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not category:
|
||||
return jsonify({'error': 'Category not found'}), 404
|
||||
|
||||
forecast = get_category_forecast(category, months=6)
|
||||
|
||||
return jsonify({
|
||||
'category': category.name,
|
||||
'forecast': forecast
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/api/search')
|
||||
@login_required
|
||||
def api_search():
|
||||
"""Global search API endpoint"""
|
||||
from app.search import search_all
|
||||
|
||||
query = request.args.get('q', '').strip()
|
||||
|
||||
if not query or len(query) < 2:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Query must be at least 2 characters',
|
||||
'results': {
|
||||
'expenses': [],
|
||||
'categories': [],
|
||||
'subscriptions': [],
|
||||
'tags': [],
|
||||
'total': 0
|
||||
}
|
||||
})
|
||||
|
||||
# Perform search with user isolation
|
||||
results = search_all(query, current_user.id, limit=50)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'results': results
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/api/search/suggestions')
|
||||
@login_required
|
||||
def api_search_suggestions():
|
||||
"""Quick search suggestions for autocomplete"""
|
||||
from app.search import quick_search_suggestions
|
||||
|
||||
query = request.args.get('q', '').strip()
|
||||
|
||||
if not query or len(query) < 2:
|
||||
return jsonify({'suggestions': []})
|
||||
|
||||
suggestions = quick_search_suggestions(query, current_user.id, limit=5)
|
||||
|
||||
return jsonify({'suggestions': suggestions})
|
||||
|
||||
|
||||
@bp.route('/search')
|
||||
@login_required
|
||||
def search_page():
|
||||
"""Search results page"""
|
||||
from app.search import search_all
|
||||
|
||||
query = request.args.get('q', '').strip()
|
||||
|
||||
if not query:
|
||||
return render_template('search.html', results=None, query='')
|
||||
|
||||
results = search_all(query, current_user.id, limit=100)
|
||||
|
||||
return render_template('search.html', results=results, query=query)
|
||||
|
||||
|
||||
@bp.route('/bank-import', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def bank_import():
|
||||
"""Bank statement import page"""
|
||||
if request.method == 'GET':
|
||||
# Get user's categories for mapping
|
||||
categories = Category.query.filter_by(user_id=current_user.id).all()
|
||||
return render_template('bank_import.html', categories=categories)
|
||||
|
||||
# POST: Store uploaded file temporarily and redirect to review
|
||||
if 'file' not in request.files:
|
||||
flash('No file uploaded', 'error')
|
||||
return redirect(url_for('main.bank_import'))
|
||||
|
||||
file = request.files['file']
|
||||
if not file or not file.filename:
|
||||
flash('No file selected', 'error')
|
||||
return redirect(url_for('main.bank_import'))
|
||||
|
||||
# Save temporarily for processing
|
||||
filename = secure_filename(file.filename)
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
temp_filename = f"bank_{current_user.id}_{timestamp}_{filename}"
|
||||
temp_folder = os.path.join(current_app.root_path, 'static', 'uploads', 'temp')
|
||||
os.makedirs(temp_folder, exist_ok=True)
|
||||
temp_path = os.path.join(temp_folder, temp_filename)
|
||||
|
||||
file.save(temp_path)
|
||||
|
||||
# Redirect to parse API then review
|
||||
return redirect(url_for('main.bank_import_review', filename=temp_filename))
|
||||
|
||||
|
||||
@bp.route('/bank-import/review/<filename>')
|
||||
@login_required
|
||||
def bank_import_review(filename):
|
||||
"""Review parsed transactions before importing"""
|
||||
from app.bank_import import parse_bank_statement
|
||||
|
||||
# Security: Verify filename belongs to current user
|
||||
if not filename.startswith(f"bank_{current_user.id}_"):
|
||||
flash('Invalid file', 'error')
|
||||
return redirect(url_for('main.bank_import'))
|
||||
|
||||
temp_folder = os.path.join(current_app.root_path, 'static', 'uploads', 'temp')
|
||||
temp_path = os.path.join(temp_folder, filename)
|
||||
|
||||
if not os.path.exists(temp_path):
|
||||
flash('File not found. Please upload again.', 'error')
|
||||
return redirect(url_for('main.bank_import'))
|
||||
|
||||
try:
|
||||
# Read file
|
||||
with open(temp_path, 'rb') as f:
|
||||
file_content = f.read()
|
||||
|
||||
# Parse bank statement
|
||||
result = parse_bank_statement(file_content, filename)
|
||||
|
||||
if not result['success']:
|
||||
flash(f"Parsing failed: {result.get('error', 'Unknown error')}", 'error')
|
||||
# Clean up temp file
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except:
|
||||
pass
|
||||
return redirect(url_for('main.bank_import'))
|
||||
|
||||
# Get user's categories
|
||||
categories = Category.query.filter_by(user_id=current_user.id).all()
|
||||
|
||||
# Store temp filename in session for confirmation
|
||||
from flask import session
|
||||
session['bank_import_file'] = filename
|
||||
|
||||
return render_template('bank_import_review.html',
|
||||
transactions=result['transactions'],
|
||||
total_found=result['total_found'],
|
||||
categories=categories,
|
||||
bank_format=result.get('bank_format', 'Unknown'),
|
||||
parse_errors=result.get('parse_errors', []))
|
||||
|
||||
except Exception as e:
|
||||
flash(f'Error processing file: {str(e)}', 'error')
|
||||
# Clean up temp file
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except:
|
||||
pass
|
||||
return redirect(url_for('main.bank_import'))
|
||||
|
||||
|
||||
@bp.route('/bank-import/confirm', methods=['POST'])
|
||||
@login_required
|
||||
def bank_import_confirm():
|
||||
"""Confirm and import selected transactions"""
|
||||
from flask import session
|
||||
|
||||
# Get temp filename from session
|
||||
filename = session.get('bank_import_file')
|
||||
if not filename or not filename.startswith(f"bank_{current_user.id}_"):
|
||||
flash('Invalid session. Please try again.', 'error')
|
||||
return redirect(url_for('main.bank_import'))
|
||||
|
||||
temp_folder = os.path.join(current_app.root_path, 'static', 'uploads', 'temp')
|
||||
temp_path = os.path.join(temp_folder, filename)
|
||||
|
||||
# Get selected transactions from form
|
||||
selected_indices = request.form.getlist('selected_transactions')
|
||||
category_mappings = {} # Map transaction index to category_id
|
||||
|
||||
for idx in selected_indices:
|
||||
category_id = request.form.get(f'category_{idx}')
|
||||
if category_id:
|
||||
category_mappings[int(idx)] = int(category_id)
|
||||
|
||||
if not selected_indices:
|
||||
flash('No transactions selected for import', 'warning')
|
||||
return redirect(url_for('main.bank_import_review', filename=filename))
|
||||
|
||||
try:
|
||||
# Re-parse to get transactions
|
||||
with open(temp_path, 'rb') as f:
|
||||
file_content = f.read()
|
||||
|
||||
from app.bank_import import parse_bank_statement
|
||||
result = parse_bank_statement(file_content, filename)
|
||||
|
||||
if not result['success']:
|
||||
raise Exception('Re-parsing failed')
|
||||
|
||||
# Import selected transactions
|
||||
imported_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for idx_str in selected_indices:
|
||||
idx = int(idx_str)
|
||||
if idx >= len(result['transactions']):
|
||||
continue
|
||||
|
||||
trans = result['transactions'][idx]
|
||||
category_id = category_mappings.get(idx)
|
||||
|
||||
if not category_id:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Check if transaction already exists (deduplication)
|
||||
existing = Expense.query.filter_by(
|
||||
user_id=current_user.id,
|
||||
date=trans['date'],
|
||||
amount=trans['amount'],
|
||||
description=trans['description'][:50] # Partial match
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Create expense
|
||||
expense = Expense(
|
||||
description=trans['description'],
|
||||
amount=trans['amount'],
|
||||
date=datetime.combine(trans['date'], datetime.min.time()),
|
||||
category_id=category_id,
|
||||
user_id=current_user.id,
|
||||
tags='imported, bank-statement'
|
||||
)
|
||||
|
||||
db.session.add(expense)
|
||||
imported_count += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Clean up temp file
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
session.pop('bank_import_file', None)
|
||||
except:
|
||||
pass
|
||||
|
||||
if imported_count > 0:
|
||||
flash(f'Successfully imported {imported_count} transactions!', 'success')
|
||||
if skipped_count > 0:
|
||||
flash(f'{skipped_count} transactions were skipped (duplicates or no category)', 'info')
|
||||
else:
|
||||
flash('No transactions were imported', 'warning')
|
||||
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Import failed: {str(e)}', 'error')
|
||||
return redirect(url_for('main.bank_import'))
|
||||
|
||||
|
||||
@bp.route('/api/bank-import/parse', methods=['POST'])
|
||||
@login_required
|
||||
def api_bank_import_parse():
|
||||
"""API endpoint for parsing bank statement (AJAX)"""
|
||||
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
|
||||
|
||||
try:
|
||||
from app.bank_import import parse_bank_statement
|
||||
|
||||
# Read file content
|
||||
file_content = file.read()
|
||||
filename = secure_filename(file.filename)
|
||||
|
||||
# Parse
|
||||
result = parse_bank_statement(file_content, filename)
|
||||
|
||||
if not result['success']:
|
||||
return jsonify(result), 400
|
||||
|
||||
# Convert dates to strings for JSON
|
||||
for trans in result['transactions']:
|
||||
trans['date'] = trans['date'].isoformat()
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
281
backup/first -fina app/app/routes/settings.py
Executable file
281
backup/first -fina app/app/routes/settings.py
Executable file
|
|
@ -0,0 +1,281 @@
|
|||
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.user import User, Tag
|
||||
from app.models.category import Category, Expense
|
||||
from werkzeug.security import generate_password_hash
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
bp = Blueprint('settings', __name__, url_prefix='/settings')
|
||||
|
||||
def admin_required(f):
|
||||
from functools import wraps
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_admin:
|
||||
flash('Admin access required', 'error')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
users = User.query.all() if current_user.is_admin else []
|
||||
tags = Tag.query.filter_by(user_id=current_user.id).all()
|
||||
return render_template('settings/index.html', users=users, tags=tags)
|
||||
|
||||
# USER MANAGEMENT
|
||||
@bp.route('/profile', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_profile():
|
||||
if request.method == 'POST':
|
||||
current_user.username = request.form.get('username')
|
||||
current_user.email = request.form.get('email')
|
||||
current_user.currency = request.form.get('currency', 'USD')
|
||||
current_user.language = request.form.get('language', 'en')
|
||||
|
||||
# Budget alert preferences
|
||||
current_user.budget_alerts_enabled = request.form.get('budget_alerts_enabled') == 'on'
|
||||
alert_email = request.form.get('alert_email', '').strip()
|
||||
current_user.alert_email = alert_email if alert_email else None
|
||||
|
||||
new_password = request.form.get('new_password')
|
||||
if new_password:
|
||||
current_user.set_password(new_password)
|
||||
|
||||
db.session.commit()
|
||||
flash('Profile updated successfully!', 'success')
|
||||
return redirect(url_for('settings.index'))
|
||||
|
||||
from app.translations import get_available_languages
|
||||
languages = get_available_languages()
|
||||
return render_template('settings/edit_profile.html', languages=languages)
|
||||
|
||||
@bp.route('/users/create', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def create_user():
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
email = request.form.get('email')
|
||||
password = request.form.get('password')
|
||||
is_admin = request.form.get('is_admin') == 'on'
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash('Username already exists', 'error')
|
||||
return redirect(url_for('settings.create_user'))
|
||||
|
||||
if User.query.filter_by(email=email).first():
|
||||
flash('Email already exists', 'error')
|
||||
return redirect(url_for('settings.create_user'))
|
||||
|
||||
user = User(username=username, email=email, is_admin=is_admin)
|
||||
user.set_password(password)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
flash(f'User {username} created successfully!', 'success')
|
||||
return redirect(url_for('settings.index'))
|
||||
|
||||
return render_template('settings/create_user.html')
|
||||
|
||||
@bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def edit_user(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
user.username = request.form.get('username')
|
||||
user.email = request.form.get('email')
|
||||
user.is_admin = request.form.get('is_admin') == 'on'
|
||||
|
||||
new_password = request.form.get('new_password')
|
||||
if new_password:
|
||||
user.set_password(new_password)
|
||||
|
||||
db.session.commit()
|
||||
flash(f'User {user.username} updated!', 'success')
|
||||
return redirect(url_for('settings.index'))
|
||||
|
||||
return render_template('settings/edit_user.html', user=user)
|
||||
|
||||
@bp.route('/users/<int:user_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_user(user_id):
|
||||
if user_id == current_user.id:
|
||||
flash('Cannot delete your own account', 'error')
|
||||
return redirect(url_for('settings.index'))
|
||||
|
||||
user = User.query.get_or_404(user_id)
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
|
||||
flash(f'User {user.username} deleted', 'success')
|
||||
return redirect(url_for('settings.index'))
|
||||
|
||||
# TAG MANAGEMENT
|
||||
@bp.route('/tags/create', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create_tag():
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name')
|
||||
color = request.form.get('color', '#6366f1')
|
||||
|
||||
tag = Tag(name=name, color=color, user_id=current_user.id)
|
||||
db.session.add(tag)
|
||||
db.session.commit()
|
||||
|
||||
flash(f'Tag "{name}" created!', 'success')
|
||||
return redirect(url_for('settings.index'))
|
||||
|
||||
return render_template('settings/create_tag.html')
|
||||
|
||||
@bp.route('/tags/<int:tag_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_tag(tag_id):
|
||||
tag = Tag.query.filter_by(id=tag_id, user_id=current_user.id).first_or_404()
|
||||
db.session.delete(tag)
|
||||
db.session.commit()
|
||||
|
||||
flash(f'Tag "{tag.name}" deleted', 'success')
|
||||
return redirect(url_for('settings.index'))
|
||||
|
||||
# IMPORT/EXPORT
|
||||
@bp.route('/export')
|
||||
@login_required
|
||||
def export_data():
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
writer.writerow(['Category', 'Description', 'Amount', 'Date', 'Paid By', 'Tags'])
|
||||
|
||||
expenses = Expense.query.filter_by(user_id=current_user.id).all()
|
||||
for expense in expenses:
|
||||
writer.writerow([
|
||||
expense.category.name,
|
||||
expense.description,
|
||||
expense.amount,
|
||||
expense.date.strftime('%Y-%m-%d'),
|
||||
expense.paid_by or '',
|
||||
expense.tags or ''
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
return send_file(
|
||||
io.BytesIO(output.getvalue().encode('utf-8')),
|
||||
mimetype='text/csv',
|
||||
as_attachment=True,
|
||||
download_name=f'expenses_{datetime.now().strftime("%Y%m%d")}.csv'
|
||||
)
|
||||
|
||||
@bp.route('/import', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def import_data():
|
||||
if request.method == 'POST':
|
||||
if 'file' not in request.files:
|
||||
flash('No file uploaded', 'error')
|
||||
return redirect(url_for('settings.import_data'))
|
||||
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
flash('No file selected', 'error')
|
||||
return redirect(url_for('settings.import_data'))
|
||||
|
||||
if not file.filename.endswith('.csv'):
|
||||
flash('Only CSV files are supported', 'error')
|
||||
return redirect(url_for('settings.import_data'))
|
||||
|
||||
try:
|
||||
stream = io.StringIO(file.stream.read().decode('UTF8'), newline=None)
|
||||
csv_reader = csv.DictReader(stream)
|
||||
|
||||
imported = 0
|
||||
for row in csv_reader:
|
||||
category_name = row.get('Category')
|
||||
category = Category.query.filter_by(name=category_name, user_id=current_user.id).first()
|
||||
|
||||
if not category:
|
||||
category = Category(name=category_name, user_id=current_user.id)
|
||||
db.session.add(category)
|
||||
db.session.flush()
|
||||
|
||||
expense = Expense(
|
||||
description=row.get('Description'),
|
||||
amount=float(row.get('Amount', 0)),
|
||||
date=datetime.strptime(row.get('Date'), '%Y-%m-%d'),
|
||||
paid_by=row.get('Paid By'),
|
||||
tags=row.get('Tags'),
|
||||
category_id=category.id,
|
||||
user_id=current_user.id
|
||||
)
|
||||
db.session.add(expense)
|
||||
imported += 1
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Successfully imported {imported} expenses!', 'success')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Import failed: {str(e)}', 'error')
|
||||
return redirect(url_for('settings.import_data'))
|
||||
|
||||
return render_template('settings/import.html')
|
||||
|
||||
# 2FA Management
|
||||
@bp.route('/2fa/setup', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def setup_2fa():
|
||||
if request.method == 'POST':
|
||||
token = request.form.get('token')
|
||||
|
||||
if not current_user.totp_secret:
|
||||
flash('2FA setup not initiated', 'error')
|
||||
return redirect(url_for('settings.setup_2fa'))
|
||||
|
||||
if current_user.verify_totp(token):
|
||||
current_user.is_2fa_enabled = True
|
||||
db.session.commit()
|
||||
flash('2FA enabled successfully!', 'success')
|
||||
return redirect(url_for('settings.index'))
|
||||
else:
|
||||
flash('Invalid code. Please try again.', 'error')
|
||||
|
||||
# Generate QR code
|
||||
if not current_user.totp_secret:
|
||||
current_user.generate_totp_secret()
|
||||
db.session.commit()
|
||||
|
||||
import qrcode
|
||||
import io
|
||||
import base64
|
||||
|
||||
uri = current_user.get_totp_uri()
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||||
qr.add_data(uri)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format='PNG')
|
||||
buffer.seek(0)
|
||||
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
|
||||
|
||||
return render_template('settings/setup_2fa.html',
|
||||
qr_code=qr_base64,
|
||||
secret=current_user.totp_secret)
|
||||
|
||||
@bp.route('/2fa/disable', methods=['POST'])
|
||||
@login_required
|
||||
def disable_2fa():
|
||||
current_user.is_2fa_enabled = False
|
||||
current_user.totp_secret = None
|
||||
db.session.commit()
|
||||
flash('2FA disabled successfully', 'success')
|
||||
return redirect(url_for('settings.index'))
|
||||
304
backup/first -fina app/app/routes/subscriptions.py
Normal file
304
backup/first -fina app/app/routes/subscriptions.py
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.subscription import Subscription, RecurringPattern
|
||||
from app.models.category import Category
|
||||
from app.smart_detection import (
|
||||
detect_recurring_expenses,
|
||||
save_detected_patterns,
|
||||
get_user_suggestions,
|
||||
convert_pattern_to_subscription,
|
||||
dismiss_pattern
|
||||
)
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
bp = Blueprint('subscriptions', __name__, url_prefix='/subscriptions')
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
"""View all subscriptions and suggestions"""
|
||||
subscriptions = Subscription.query.filter_by(
|
||||
user_id=current_user.id,
|
||||
is_active=True
|
||||
).order_by(Subscription.next_due_date).all()
|
||||
|
||||
suggestions = get_user_suggestions(current_user.id)
|
||||
|
||||
# Calculate total monthly cost
|
||||
monthly_cost = sum(
|
||||
sub.amount if sub.frequency == 'monthly' else
|
||||
sub.amount / 4 if sub.frequency == 'quarterly' else
|
||||
sub.amount / 12 if sub.frequency == 'yearly' else
|
||||
sub.amount * 4 if sub.frequency == 'weekly' else
|
||||
sub.amount * 2 if sub.frequency == 'biweekly' else
|
||||
sub.amount
|
||||
for sub in subscriptions
|
||||
)
|
||||
|
||||
yearly_cost = sum(sub.get_annual_cost() for sub in subscriptions)
|
||||
|
||||
return render_template('subscriptions/index.html',
|
||||
subscriptions=subscriptions,
|
||||
suggestions=suggestions,
|
||||
monthly_cost=monthly_cost,
|
||||
yearly_cost=yearly_cost)
|
||||
|
||||
|
||||
@bp.route('/detect', methods=['POST'])
|
||||
@login_required
|
||||
def detect():
|
||||
"""Run detection algorithm to find recurring expenses"""
|
||||
patterns = detect_recurring_expenses(current_user.id)
|
||||
|
||||
if patterns:
|
||||
saved = save_detected_patterns(patterns)
|
||||
flash(f'Found {saved} potential subscription(s)!', 'success')
|
||||
else:
|
||||
flash('No recurring patterns detected. Add more expenses to improve detection.', 'info')
|
||||
|
||||
return redirect(url_for('subscriptions.index'))
|
||||
|
||||
|
||||
@bp.route('/create', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create():
|
||||
"""Manually create a subscription"""
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name')
|
||||
amount = float(request.form.get('amount', 0))
|
||||
frequency = request.form.get('frequency')
|
||||
custom_interval_days = request.form.get('custom_interval_days')
|
||||
category_id = request.form.get('category_id')
|
||||
start_date = request.form.get('start_date')
|
||||
end_date = request.form.get('end_date')
|
||||
total_occurrences = request.form.get('total_occurrences')
|
||||
auto_create_expense = request.form.get('auto_create_expense') == 'on'
|
||||
notes = request.form.get('notes')
|
||||
|
||||
# Validate custom interval
|
||||
if frequency == 'custom':
|
||||
if not custom_interval_days:
|
||||
flash('Custom interval is required when using custom frequency', 'error')
|
||||
categories = Category.query.filter_by(user_id=current_user.id).all()
|
||||
return render_template('subscriptions/create.html', categories=categories)
|
||||
|
||||
interval_value = int(custom_interval_days)
|
||||
if interval_value < 1 or interval_value > 365:
|
||||
flash('Custom interval must be between 1 and 365 days', 'error')
|
||||
categories = Category.query.filter_by(user_id=current_user.id).all()
|
||||
return render_template('subscriptions/create.html', categories=categories)
|
||||
|
||||
# Parse dates
|
||||
start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date() if start_date else datetime.now().date()
|
||||
end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date() if end_date else None
|
||||
|
||||
subscription = Subscription(
|
||||
name=name,
|
||||
amount=amount,
|
||||
frequency=frequency,
|
||||
custom_interval_days=int(custom_interval_days) if custom_interval_days and frequency == 'custom' else None,
|
||||
category_id=category_id,
|
||||
user_id=current_user.id,
|
||||
start_date=start_date_obj,
|
||||
next_due_date=start_date_obj,
|
||||
end_date=end_date_obj,
|
||||
total_occurrences=int(total_occurrences) if total_occurrences else None,
|
||||
auto_create_expense=auto_create_expense,
|
||||
notes=notes,
|
||||
is_confirmed=True,
|
||||
auto_detected=False
|
||||
)
|
||||
|
||||
db.session.add(subscription)
|
||||
db.session.commit()
|
||||
|
||||
flash(f'Subscription "{name}" added successfully!', 'success')
|
||||
return redirect(url_for('subscriptions.index'))
|
||||
|
||||
categories = Category.query.filter_by(user_id=current_user.id).all()
|
||||
return render_template('subscriptions/create.html', categories=categories)
|
||||
|
||||
|
||||
@bp.route('/<int:subscription_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit(subscription_id):
|
||||
"""Edit a subscription"""
|
||||
subscription = Subscription.query.filter_by(
|
||||
id=subscription_id,
|
||||
user_id=current_user.id
|
||||
).first_or_404()
|
||||
|
||||
if request.method == 'POST':
|
||||
frequency = request.form.get('frequency')
|
||||
custom_interval_days = request.form.get('custom_interval_days')
|
||||
|
||||
# Validate custom interval
|
||||
if frequency == 'custom':
|
||||
if not custom_interval_days:
|
||||
flash('Custom interval is required when using custom frequency', 'error')
|
||||
categories = Category.query.filter_by(user_id=current_user.id).all()
|
||||
return render_template('subscriptions/edit.html', subscription=subscription, categories=categories)
|
||||
|
||||
interval_value = int(custom_interval_days)
|
||||
if interval_value < 1 or interval_value > 365:
|
||||
flash('Custom interval must be between 1 and 365 days', 'error')
|
||||
categories = Category.query.filter_by(user_id=current_user.id).all()
|
||||
return render_template('subscriptions/edit.html', subscription=subscription, categories=categories)
|
||||
|
||||
subscription.name = request.form.get('name')
|
||||
subscription.amount = float(request.form.get('amount', 0))
|
||||
subscription.frequency = frequency
|
||||
subscription.custom_interval_days = int(custom_interval_days) if custom_interval_days and frequency == 'custom' else None
|
||||
subscription.category_id = request.form.get('category_id')
|
||||
subscription.auto_create_expense = request.form.get('auto_create_expense') == 'on'
|
||||
subscription.notes = request.form.get('notes')
|
||||
|
||||
next_due_date = request.form.get('next_due_date')
|
||||
if next_due_date:
|
||||
subscription.next_due_date = datetime.strptime(next_due_date, '%Y-%m-%d').date()
|
||||
|
||||
end_date = request.form.get('end_date')
|
||||
subscription.end_date = datetime.strptime(end_date, '%Y-%m-%d').date() if end_date else None
|
||||
|
||||
total_occurrences = request.form.get('total_occurrences')
|
||||
subscription.total_occurrences = int(total_occurrences) if total_occurrences else None
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash(f'Subscription "{subscription.name}" updated!', 'success')
|
||||
return redirect(url_for('subscriptions.index'))
|
||||
|
||||
categories = Category.query.filter_by(user_id=current_user.id).all()
|
||||
return render_template('subscriptions/edit.html',
|
||||
subscription=subscription,
|
||||
categories=categories)
|
||||
|
||||
|
||||
@bp.route('/<int:subscription_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete(subscription_id):
|
||||
"""Delete a subscription"""
|
||||
subscription = Subscription.query.filter_by(
|
||||
id=subscription_id,
|
||||
user_id=current_user.id
|
||||
).first_or_404()
|
||||
|
||||
name = subscription.name
|
||||
db.session.delete(subscription)
|
||||
db.session.commit()
|
||||
|
||||
flash(f'Subscription "{name}" deleted!', 'success')
|
||||
return redirect(url_for('subscriptions.index'))
|
||||
|
||||
|
||||
@bp.route('/<int:subscription_id>/toggle', methods=['POST'])
|
||||
@login_required
|
||||
def toggle(subscription_id):
|
||||
"""Toggle subscription active status"""
|
||||
subscription = Subscription.query.filter_by(
|
||||
id=subscription_id,
|
||||
user_id=current_user.id
|
||||
).first_or_404()
|
||||
|
||||
subscription.is_active = not subscription.is_active
|
||||
db.session.commit()
|
||||
|
||||
status = 'activated' if subscription.is_active else 'paused'
|
||||
flash(f'Subscription "{subscription.name}" {status}!', 'success')
|
||||
|
||||
return redirect(url_for('subscriptions.index'))
|
||||
|
||||
|
||||
@bp.route('/suggestion/<int:pattern_id>/accept', methods=['POST'])
|
||||
@login_required
|
||||
def accept_suggestion(pattern_id):
|
||||
"""Accept a detected pattern and convert to subscription"""
|
||||
subscription = convert_pattern_to_subscription(pattern_id, current_user.id)
|
||||
|
||||
if subscription:
|
||||
flash(f'Subscription "{subscription.name}" added!', 'success')
|
||||
else:
|
||||
flash('Could not add subscription.', 'error')
|
||||
|
||||
return redirect(url_for('subscriptions.index'))
|
||||
|
||||
|
||||
@bp.route('/suggestion/<int:pattern_id>/dismiss', methods=['POST'])
|
||||
@login_required
|
||||
def dismiss_suggestion(pattern_id):
|
||||
"""Dismiss a detected pattern"""
|
||||
if dismiss_pattern(pattern_id, current_user.id):
|
||||
flash('Suggestion dismissed.', 'info')
|
||||
else:
|
||||
flash('Could not dismiss suggestion.', 'error')
|
||||
|
||||
return redirect(url_for('subscriptions.index'))
|
||||
|
||||
|
||||
@bp.route('/api/upcoming')
|
||||
@login_required
|
||||
def api_upcoming():
|
||||
"""API endpoint for upcoming subscriptions"""
|
||||
days = int(request.args.get('days', 30))
|
||||
|
||||
end_date = datetime.now().date() + timedelta(days=days)
|
||||
|
||||
upcoming = Subscription.query.filter(
|
||||
Subscription.user_id == current_user.id,
|
||||
Subscription.is_active == True,
|
||||
Subscription.next_due_date <= end_date
|
||||
).order_by(Subscription.next_due_date).all()
|
||||
|
||||
return jsonify({
|
||||
'subscriptions': [{
|
||||
'id': sub.id,
|
||||
'name': sub.name,
|
||||
'amount': float(sub.amount),
|
||||
'next_due_date': sub.next_due_date.isoformat(),
|
||||
'days_until': (sub.next_due_date - datetime.now().date()).days
|
||||
} for sub in upcoming]
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/auto-create', methods=['POST'])
|
||||
@login_required
|
||||
def auto_create_expenses():
|
||||
"""Auto-create expenses for due subscriptions (can be run via cron)"""
|
||||
from app.models.category import Expense
|
||||
|
||||
subscriptions = Subscription.query.filter_by(
|
||||
user_id=current_user.id,
|
||||
is_active=True,
|
||||
auto_create_expense=True
|
||||
).all()
|
||||
|
||||
created_count = 0
|
||||
|
||||
for sub in subscriptions:
|
||||
if sub.should_create_expense_today():
|
||||
# Create the expense
|
||||
expense = Expense(
|
||||
amount=sub.amount,
|
||||
description=f"{sub.name} (Auto-created)",
|
||||
date=datetime.now().date(),
|
||||
category_id=sub.category_id,
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
db.session.add(expense)
|
||||
|
||||
# Update subscription
|
||||
sub.last_auto_created = datetime.now().date()
|
||||
sub.advance_next_due_date()
|
||||
|
||||
created_count += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if created_count > 0:
|
||||
flash(f'Auto-created {created_count} expense(s) from subscriptions!', 'success')
|
||||
else:
|
||||
flash('No expenses due for auto-creation today.', 'info')
|
||||
|
||||
return redirect(url_for('subscriptions.index'))
|
||||
313
backup/first -fina app/app/search.py
Normal file
313
backup/first -fina app/app/search.py
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
"""
|
||||
Global Search Module for FINA Finance Tracker
|
||||
Provides comprehensive search across all user data with security isolation
|
||||
"""
|
||||
from app.models.category import Category, Expense
|
||||
from app.models.subscription import Subscription
|
||||
from app.models.user import User, Tag
|
||||
from sqlalchemy import or_, and_, func, cast, String
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
|
||||
def search_all(query, user_id, limit=50):
|
||||
"""
|
||||
Comprehensive search across all user data
|
||||
|
||||
Args:
|
||||
query: Search string
|
||||
user_id: Current user ID for security filtering
|
||||
limit: Maximum results per category
|
||||
|
||||
Returns:
|
||||
Dictionary with categorized results
|
||||
"""
|
||||
if not query or not query.strip():
|
||||
return {
|
||||
'expenses': [],
|
||||
'categories': [],
|
||||
'subscriptions': [],
|
||||
'tags': [],
|
||||
'total': 0
|
||||
}
|
||||
|
||||
query = query.strip()
|
||||
search_term = f'%{query}%'
|
||||
|
||||
# Try to parse as amount (e.g., "45.99", "45", "45.9")
|
||||
amount_value = None
|
||||
try:
|
||||
amount_value = float(query.replace(',', '.'))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Try to parse as date (YYYY-MM-DD, DD/MM/YYYY, etc.)
|
||||
date_value = None
|
||||
date_patterns = [
|
||||
r'(\d{4})-(\d{1,2})-(\d{1,2})', # YYYY-MM-DD
|
||||
r'(\d{1,2})/(\d{1,2})/(\d{4})', # DD/MM/YYYY
|
||||
r'(\d{1,2})-(\d{1,2})-(\d{4})', # DD-MM-YYYY
|
||||
]
|
||||
for pattern in date_patterns:
|
||||
match = re.search(pattern, query)
|
||||
if match:
|
||||
try:
|
||||
groups = match.groups()
|
||||
if len(groups[0]) == 4: # YYYY-MM-DD
|
||||
date_value = datetime(int(groups[0]), int(groups[1]), int(groups[2])).date()
|
||||
else: # DD/MM/YYYY or DD-MM-YYYY
|
||||
date_value = datetime(int(groups[2]), int(groups[1]), int(groups[0])).date()
|
||||
break
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Search Expenses
|
||||
expense_conditions = [
|
||||
Expense.user_id == user_id,
|
||||
or_(
|
||||
Expense.description.ilike(search_term),
|
||||
Expense.paid_by.ilike(search_term),
|
||||
Expense.tags.ilike(search_term),
|
||||
)
|
||||
]
|
||||
|
||||
# Add amount search if valid number
|
||||
if amount_value is not None:
|
||||
expense_conditions[1] = or_(
|
||||
expense_conditions[1],
|
||||
Expense.amount == amount_value,
|
||||
# Also search for amounts close to the value (±0.01)
|
||||
and_(Expense.amount >= amount_value - 0.01, Expense.amount <= amount_value + 0.01)
|
||||
)
|
||||
|
||||
# Add date search if valid date
|
||||
if date_value:
|
||||
expense_conditions[1] = or_(
|
||||
expense_conditions[1],
|
||||
func.date(Expense.date) == date_value
|
||||
)
|
||||
|
||||
expenses = Expense.query.filter(
|
||||
and_(*expense_conditions)
|
||||
).order_by(Expense.date.desc()).limit(limit).all()
|
||||
|
||||
# Search Categories
|
||||
categories = Category.query.filter(
|
||||
Category.user_id == user_id,
|
||||
or_(
|
||||
Category.name.ilike(search_term),
|
||||
Category.description.ilike(search_term)
|
||||
)
|
||||
).limit(limit).all()
|
||||
|
||||
# Search Subscriptions
|
||||
subscription_conditions = [
|
||||
Subscription.user_id == user_id,
|
||||
or_(
|
||||
Subscription.name.ilike(search_term),
|
||||
Subscription.notes.ilike(search_term),
|
||||
)
|
||||
]
|
||||
|
||||
# Add amount search for subscriptions
|
||||
if amount_value is not None:
|
||||
subscription_conditions[1] = or_(
|
||||
subscription_conditions[1],
|
||||
Subscription.amount == amount_value,
|
||||
and_(Subscription.amount >= amount_value - 0.01, Subscription.amount <= amount_value + 0.01)
|
||||
)
|
||||
|
||||
subscriptions = Subscription.query.filter(
|
||||
and_(*subscription_conditions)
|
||||
).limit(limit).all()
|
||||
|
||||
# Search Tags
|
||||
tags = Tag.query.filter(
|
||||
Tag.user_id == user_id,
|
||||
Tag.name.ilike(search_term)
|
||||
).limit(limit).all()
|
||||
|
||||
# Format results
|
||||
expense_results = []
|
||||
for exp in expenses:
|
||||
expense_results.append({
|
||||
'id': exp.id,
|
||||
'type': 'expense',
|
||||
'description': exp.description,
|
||||
'amount': float(exp.amount),
|
||||
'date': exp.date.strftime('%Y-%m-%d'),
|
||||
'category_name': exp.category.name if exp.category else '',
|
||||
'category_id': exp.category_id,
|
||||
'category_color': exp.category.color if exp.category else '#6366f1',
|
||||
'paid_by': exp.paid_by or '',
|
||||
'tags': exp.tags or '',
|
||||
'has_receipt': bool(exp.file_path),
|
||||
'url': f'/expense/{exp.id}/edit'
|
||||
})
|
||||
|
||||
category_results = []
|
||||
for cat in categories:
|
||||
spent = cat.get_total_spent()
|
||||
category_results.append({
|
||||
'id': cat.id,
|
||||
'type': 'category',
|
||||
'name': cat.name,
|
||||
'description': cat.description or '',
|
||||
'color': cat.color,
|
||||
'total_spent': float(spent),
|
||||
'expense_count': len(cat.expenses),
|
||||
'url': f'/category/{cat.id}'
|
||||
})
|
||||
|
||||
subscription_results = []
|
||||
for sub in subscriptions:
|
||||
subscription_results.append({
|
||||
'id': sub.id,
|
||||
'type': 'subscription',
|
||||
'name': sub.name,
|
||||
'amount': float(sub.amount),
|
||||
'frequency': sub.frequency,
|
||||
'next_due': sub.next_due_date.strftime('%Y-%m-%d') if sub.next_due_date else None,
|
||||
'is_active': sub.is_active,
|
||||
'category_name': Category.query.get(sub.category_id).name if sub.category_id else '',
|
||||
'url': f'/subscriptions/edit/{sub.id}'
|
||||
})
|
||||
|
||||
tag_results = []
|
||||
for tag in tags:
|
||||
# Count expenses with this tag
|
||||
tag_expense_count = Expense.query.filter(
|
||||
Expense.user_id == user_id,
|
||||
Expense.tags.ilike(f'%{tag.name}%')
|
||||
).count()
|
||||
|
||||
tag_results.append({
|
||||
'id': tag.id,
|
||||
'type': 'tag',
|
||||
'name': tag.name,
|
||||
'color': tag.color,
|
||||
'expense_count': tag_expense_count,
|
||||
'url': f'/settings' # Tags management is in settings
|
||||
})
|
||||
|
||||
total = len(expense_results) + len(category_results) + len(subscription_results) + len(tag_results)
|
||||
|
||||
return {
|
||||
'expenses': expense_results,
|
||||
'categories': category_results,
|
||||
'subscriptions': subscription_results,
|
||||
'tags': tag_results,
|
||||
'total': total,
|
||||
'query': query
|
||||
}
|
||||
|
||||
|
||||
def search_expenses_by_filters(user_id, category_id=None, date_from=None, date_to=None,
|
||||
min_amount=None, max_amount=None, tags=None, paid_by=None):
|
||||
"""
|
||||
Advanced expense filtering with multiple criteria
|
||||
|
||||
Args:
|
||||
user_id: Current user ID
|
||||
category_id: Filter by category
|
||||
date_from: Start date (datetime object)
|
||||
date_to: End date (datetime object)
|
||||
min_amount: Minimum amount
|
||||
max_amount: Maximum amount
|
||||
tags: Tag string to search for
|
||||
paid_by: Person who paid
|
||||
|
||||
Returns:
|
||||
List of matching expenses
|
||||
"""
|
||||
conditions = [Expense.user_id == user_id]
|
||||
|
||||
if category_id:
|
||||
conditions.append(Expense.category_id == category_id)
|
||||
|
||||
if date_from:
|
||||
conditions.append(Expense.date >= date_from)
|
||||
|
||||
if date_to:
|
||||
conditions.append(Expense.date <= date_to)
|
||||
|
||||
if min_amount is not None:
|
||||
conditions.append(Expense.amount >= min_amount)
|
||||
|
||||
if max_amount is not None:
|
||||
conditions.append(Expense.amount <= max_amount)
|
||||
|
||||
if tags:
|
||||
conditions.append(Expense.tags.ilike(f'%{tags}%'))
|
||||
|
||||
if paid_by:
|
||||
conditions.append(Expense.paid_by.ilike(f'%{paid_by}%'))
|
||||
|
||||
expenses = Expense.query.filter(and_(*conditions)).order_by(Expense.date.desc()).all()
|
||||
|
||||
return expenses
|
||||
|
||||
|
||||
def quick_search_suggestions(query, user_id, limit=5):
|
||||
"""
|
||||
Quick search for autocomplete suggestions
|
||||
Returns top matches across all types
|
||||
|
||||
Args:
|
||||
query: Search string
|
||||
user_id: Current user ID
|
||||
limit: Maximum suggestions
|
||||
|
||||
Returns:
|
||||
List of suggestion objects
|
||||
"""
|
||||
if not query or len(query) < 2:
|
||||
return []
|
||||
|
||||
search_term = f'%{query}%'
|
||||
suggestions = []
|
||||
|
||||
# Recent expenses
|
||||
recent_expenses = Expense.query.filter(
|
||||
Expense.user_id == user_id,
|
||||
Expense.description.ilike(search_term)
|
||||
).order_by(Expense.date.desc()).limit(limit).all()
|
||||
|
||||
for exp in recent_expenses:
|
||||
suggestions.append({
|
||||
'text': exp.description,
|
||||
'type': 'expense',
|
||||
'amount': float(exp.amount),
|
||||
'date': exp.date.strftime('%Y-%m-%d'),
|
||||
'icon': '💸'
|
||||
})
|
||||
|
||||
# Categories
|
||||
cats = Category.query.filter(
|
||||
Category.user_id == user_id,
|
||||
Category.name.ilike(search_term)
|
||||
).limit(limit).all()
|
||||
|
||||
for cat in cats:
|
||||
suggestions.append({
|
||||
'text': cat.name,
|
||||
'type': 'category',
|
||||
'icon': '📁',
|
||||
'color': cat.color
|
||||
})
|
||||
|
||||
# Subscriptions
|
||||
subs = Subscription.query.filter(
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.name.ilike(search_term)
|
||||
).limit(limit).all()
|
||||
|
||||
for sub in subs:
|
||||
suggestions.append({
|
||||
'text': sub.name,
|
||||
'type': 'subscription',
|
||||
'amount': float(sub.amount),
|
||||
'icon': '🔄'
|
||||
})
|
||||
|
||||
return suggestions[:limit * 2]
|
||||
354
backup/first -fina app/app/smart_detection.py
Normal file
354
backup/first -fina app/app/smart_detection.py
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
"""
|
||||
Smart detection algorithms for recurring expenses and subscriptions
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
import re
|
||||
import json
|
||||
from sqlalchemy import and_
|
||||
from app import db
|
||||
from app.models.category import Expense
|
||||
from app.models.subscription import RecurringPattern, Subscription
|
||||
|
||||
|
||||
def detect_recurring_expenses(user_id, min_occurrences=3, min_confidence=70):
|
||||
"""
|
||||
Detect recurring expenses for a user
|
||||
|
||||
Args:
|
||||
user_id: User ID to analyze
|
||||
min_occurrences: Minimum number of similar transactions to consider
|
||||
min_confidence: Minimum confidence score (0-100) to suggest
|
||||
|
||||
Returns:
|
||||
List of detected patterns
|
||||
"""
|
||||
# Get all expenses for the user from the last year
|
||||
one_year_ago = datetime.now() - timedelta(days=365)
|
||||
expenses = Expense.query.filter(
|
||||
and_(
|
||||
Expense.user_id == user_id,
|
||||
Expense.date >= one_year_ago.date()
|
||||
)
|
||||
).order_by(Expense.date).all()
|
||||
|
||||
if len(expenses) < min_occurrences:
|
||||
return []
|
||||
|
||||
# Group expenses by similarity
|
||||
patterns = []
|
||||
processed_ids = set()
|
||||
|
||||
for i, expense in enumerate(expenses):
|
||||
if expense.id in processed_ids:
|
||||
continue
|
||||
|
||||
similar_expenses = find_similar_expenses(expense, expenses[i+1:], processed_ids)
|
||||
|
||||
if len(similar_expenses) >= min_occurrences - 1: # -1 because we include the current expense
|
||||
similar_expenses.insert(0, expense)
|
||||
pattern = analyze_pattern(similar_expenses, user_id)
|
||||
|
||||
if pattern and pattern['confidence_score'] >= min_confidence:
|
||||
patterns.append(pattern)
|
||||
processed_ids.update([e.id for e in similar_expenses])
|
||||
|
||||
return patterns
|
||||
|
||||
|
||||
def find_similar_expenses(target_expense, expenses, exclude_ids):
|
||||
"""Find expenses similar to target expense"""
|
||||
similar = []
|
||||
target_amount = target_expense.amount
|
||||
target_desc = normalize_description(target_expense.description or '')
|
||||
|
||||
# Amount tolerance: 5% or $5, whichever is larger
|
||||
amount_tolerance = max(target_amount * 0.05, 5.0)
|
||||
|
||||
for expense in expenses:
|
||||
if expense.id in exclude_ids:
|
||||
continue
|
||||
|
||||
# Check category match
|
||||
if expense.category_id != target_expense.category_id:
|
||||
continue
|
||||
|
||||
# Check amount similarity
|
||||
amount_diff = abs(expense.amount - target_amount)
|
||||
if amount_diff > amount_tolerance:
|
||||
continue
|
||||
|
||||
# Check description similarity
|
||||
expense_desc = normalize_description(expense.description or '')
|
||||
if not descriptions_similar(target_desc, expense_desc):
|
||||
continue
|
||||
|
||||
similar.append(expense)
|
||||
|
||||
return similar
|
||||
|
||||
|
||||
def normalize_description(desc):
|
||||
"""Normalize description for comparison"""
|
||||
# Remove common patterns like dates, numbers at end
|
||||
desc = re.sub(r'\d{1,2}[/-]\d{1,2}[/-]\d{2,4}', '', desc)
|
||||
desc = re.sub(r'#\d+', '', desc)
|
||||
desc = re.sub(r'\s+\d+$', '', desc)
|
||||
|
||||
# Convert to lowercase and strip
|
||||
desc = desc.lower().strip()
|
||||
|
||||
# Remove common words
|
||||
common_words = ['payment', 'subscription', 'monthly', 'recurring', 'auto']
|
||||
for word in common_words:
|
||||
desc = desc.replace(word, '')
|
||||
|
||||
return desc.strip()
|
||||
|
||||
|
||||
def descriptions_similar(desc1, desc2, threshold=0.6):
|
||||
"""Check if two descriptions are similar enough"""
|
||||
if not desc1 or not desc2:
|
||||
return False
|
||||
|
||||
# Exact match
|
||||
if desc1 == desc2:
|
||||
return True
|
||||
|
||||
# Check if one contains the other
|
||||
if desc1 in desc2 or desc2 in desc1:
|
||||
return True
|
||||
|
||||
# Simple word overlap check
|
||||
words1 = set(desc1.split())
|
||||
words2 = set(desc2.split())
|
||||
|
||||
if not words1 or not words2:
|
||||
return False
|
||||
|
||||
overlap = len(words1 & words2) / max(len(words1), len(words2))
|
||||
return overlap >= threshold
|
||||
|
||||
|
||||
def analyze_pattern(expenses, user_id):
|
||||
"""Analyze a group of similar expenses to determine pattern"""
|
||||
if len(expenses) < 2:
|
||||
return None
|
||||
|
||||
# Sort by date
|
||||
expenses = sorted(expenses, key=lambda e: e.date)
|
||||
|
||||
# Calculate intervals between expenses
|
||||
intervals = []
|
||||
for i in range(len(expenses) - 1):
|
||||
days = (expenses[i + 1].date - expenses[i].date).days
|
||||
intervals.append(days)
|
||||
|
||||
if not intervals:
|
||||
return None
|
||||
|
||||
# Determine frequency
|
||||
avg_interval = sum(intervals) / len(intervals)
|
||||
frequency, confidence = determine_frequency(intervals, avg_interval)
|
||||
|
||||
if not frequency:
|
||||
return None
|
||||
|
||||
# Calculate average amount
|
||||
avg_amount = sum(e.amount for e in expenses) / len(expenses)
|
||||
amount_variance = calculate_variance([e.amount for e in expenses])
|
||||
|
||||
# Adjust confidence based on amount consistency
|
||||
if amount_variance < 0.05: # Less than 5% variance
|
||||
confidence += 10
|
||||
elif amount_variance > 0.2: # More than 20% variance
|
||||
confidence -= 10
|
||||
|
||||
confidence = min(max(confidence, 0), 100) # Clamp between 0-100
|
||||
|
||||
# Generate suggested name
|
||||
suggested_name = generate_subscription_name(expenses[0])
|
||||
|
||||
# Check if pattern already exists
|
||||
existing = RecurringPattern.query.filter_by(
|
||||
user_id=user_id,
|
||||
suggested_name=suggested_name,
|
||||
is_dismissed=False,
|
||||
is_converted=False
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return None # Don't create duplicates
|
||||
|
||||
return {
|
||||
'user_id': user_id,
|
||||
'category_id': expenses[0].category_id,
|
||||
'suggested_name': suggested_name,
|
||||
'average_amount': round(avg_amount, 2),
|
||||
'detected_frequency': frequency,
|
||||
'confidence_score': round(confidence, 1),
|
||||
'expense_ids': json.dumps([e.id for e in expenses]),
|
||||
'first_occurrence': expenses[0].date,
|
||||
'last_occurrence': expenses[-1].date,
|
||||
'occurrence_count': len(expenses)
|
||||
}
|
||||
|
||||
|
||||
def determine_frequency(intervals, avg_interval):
|
||||
"""Determine frequency from intervals"""
|
||||
# Check consistency of intervals
|
||||
variance = calculate_variance(intervals)
|
||||
|
||||
# Base confidence on consistency
|
||||
base_confidence = 70 if variance < 0.15 else 50
|
||||
|
||||
# Determine frequency based on average interval
|
||||
if 5 <= avg_interval <= 9:
|
||||
return 'weekly', base_confidence + 10
|
||||
elif 12 <= avg_interval <= 16:
|
||||
return 'biweekly', base_confidence
|
||||
elif 27 <= avg_interval <= 33:
|
||||
return 'monthly', base_confidence + 15
|
||||
elif 85 <= avg_interval <= 95:
|
||||
return 'quarterly', base_confidence
|
||||
elif 355 <= avg_interval <= 375:
|
||||
return 'yearly', base_confidence
|
||||
else:
|
||||
# Check if it's a multiple of common frequencies
|
||||
if 25 <= avg_interval <= 35:
|
||||
return 'monthly', base_confidence - 10
|
||||
elif 7 <= avg_interval <= 10:
|
||||
return 'weekly', base_confidence - 10
|
||||
|
||||
return None, 0
|
||||
|
||||
|
||||
def calculate_variance(values):
|
||||
"""Calculate coefficient of variation"""
|
||||
if not values or len(values) < 2:
|
||||
return 0
|
||||
|
||||
avg = sum(values) / len(values)
|
||||
if avg == 0:
|
||||
return 0
|
||||
|
||||
variance = sum((x - avg) ** 2 for x in values) / len(values)
|
||||
std_dev = variance ** 0.5
|
||||
|
||||
return std_dev / avg
|
||||
|
||||
|
||||
def generate_subscription_name(expense):
|
||||
"""Generate a friendly name for the subscription"""
|
||||
desc = expense.description or 'Recurring Expense'
|
||||
|
||||
# Clean up description
|
||||
desc = re.sub(r'\d{1,2}[/-]\d{1,2}[/-]\d{2,4}', '', desc)
|
||||
desc = re.sub(r'#\d+', '', desc)
|
||||
desc = re.sub(r'\s+\d+$', '', desc)
|
||||
desc = desc.strip()
|
||||
|
||||
# Capitalize first letter of each word
|
||||
desc = ' '.join(word.capitalize() for word in desc.split())
|
||||
|
||||
# Limit length
|
||||
if len(desc) > 50:
|
||||
desc = desc[:47] + '...'
|
||||
|
||||
return desc or 'Recurring Expense'
|
||||
|
||||
|
||||
def save_detected_patterns(patterns):
|
||||
"""Save detected patterns to database"""
|
||||
saved_count = 0
|
||||
|
||||
for pattern_data in patterns:
|
||||
pattern = RecurringPattern(**pattern_data)
|
||||
db.session.add(pattern)
|
||||
saved_count += 1
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
return saved_count
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"Error saving patterns: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def get_user_suggestions(user_id):
|
||||
"""Get all active suggestions for a user"""
|
||||
return RecurringPattern.query.filter_by(
|
||||
user_id=user_id,
|
||||
is_dismissed=False,
|
||||
is_converted=False
|
||||
).order_by(RecurringPattern.confidence_score.desc()).all()
|
||||
|
||||
|
||||
def convert_pattern_to_subscription(pattern_id, user_id):
|
||||
"""Convert a detected pattern to a confirmed subscription"""
|
||||
pattern = RecurringPattern.query.filter_by(
|
||||
id=pattern_id,
|
||||
user_id=user_id
|
||||
).first()
|
||||
|
||||
if not pattern or pattern.is_converted:
|
||||
return None
|
||||
|
||||
# Create subscription
|
||||
subscription = Subscription(
|
||||
name=pattern.suggested_name,
|
||||
amount=pattern.average_amount,
|
||||
frequency=pattern.detected_frequency,
|
||||
category_id=pattern.category_id,
|
||||
user_id=pattern.user_id,
|
||||
next_due_date=pattern.last_occurrence + timedelta(days=get_frequency_days(pattern.detected_frequency)),
|
||||
is_active=True,
|
||||
is_confirmed=True,
|
||||
auto_detected=True,
|
||||
confidence_score=pattern.confidence_score
|
||||
)
|
||||
|
||||
db.session.add(subscription)
|
||||
|
||||
# Mark pattern as converted
|
||||
pattern.is_converted = True
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
return subscription
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"Error converting pattern: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_frequency_days(frequency):
|
||||
"""Get number of days for frequency"""
|
||||
frequency_map = {
|
||||
'weekly': 7,
|
||||
'biweekly': 14,
|
||||
'monthly': 30,
|
||||
'quarterly': 90,
|
||||
'yearly': 365
|
||||
}
|
||||
return frequency_map.get(frequency, 30)
|
||||
|
||||
|
||||
def dismiss_pattern(pattern_id, user_id):
|
||||
"""Dismiss a detected pattern"""
|
||||
pattern = RecurringPattern.query.filter_by(
|
||||
id=pattern_id,
|
||||
user_id=user_id
|
||||
).first()
|
||||
|
||||
if pattern:
|
||||
pattern.is_dismissed = True
|
||||
try:
|
||||
db.session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"Error dismissing pattern: {e}")
|
||||
|
||||
return False
|
||||
752
backup/first -fina app/app/static/css/style.css
Executable file
752
backup/first -fina app/app/static/css/style.css
Executable file
|
|
@ -0,0 +1,752 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary: #5b5fc7;
|
||||
--primary-dark: #4338ca;
|
||||
--success: #10b981;
|
||||
--danger: #ef4444;
|
||||
--glass-bg: rgba(255, 255, 255, 0.08);
|
||||
--glass-border: rgba(255, 255, 255, 0.15);
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #4c1d95 0%, #3b0764 100%);
|
||||
min-height: 100vh;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.alert {
|
||||
position: fixed !important;
|
||||
top: 80px !important;
|
||||
right: 20px !important;
|
||||
left: auto !important;
|
||||
max-width: 350px !important;
|
||||
width: auto !important;
|
||||
padding: 1rem 1.5rem !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 15px !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5) !important;
|
||||
z-index: 9999 !important;
|
||||
animation: slideIn 0.3s ease-out !important;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(400px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideOut {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(400px); opacity: 0; }
|
||||
}
|
||||
|
||||
.alert.hiding { animation: slideOut 0.3s ease-in forwards !important; }
|
||||
.alert-success { background: rgba(16, 185, 129, 0.25) !important; border: 1px solid var(--success) !important; }
|
||||
.alert-error { background: rgba(239, 68, 68, 0.25) !important; border: 1px solid var(--danger) !important; }
|
||||
.alert-info { background: rgba(99, 102, 241, 0.25) !important; border: 1px solid var(--primary) !important; }
|
||||
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 2rem 1rem; }
|
||||
.auth-container { display: flex; justify-content: center; align-items: center; min-height: 80vh; }
|
||||
.auth-card { max-width: 500px; width: 100%; }
|
||||
.auth-card h1 { font-size: 2rem; margin-bottom: 0.5rem; text-align: center; }
|
||||
.subtitle { text-align: center; color: var(--text-secondary); margin-bottom: 2rem; }
|
||||
.auth-form { display: flex; flex-direction: column; gap: 1.5rem; }
|
||||
.form-group { display: flex; flex-direction: column; }
|
||||
.form-group label { margin-bottom: 0.5rem; font-weight: 500; color: var(--text-primary); }
|
||||
.form-group input {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
.form-group input:focus { outline: none; border-color: var(--primary); background: rgba(255, 255, 255, 0.18); }
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary { background: var(--primary); color: white; }
|
||||
.btn-primary:hover { background: var(--primary-dark); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(99, 102, 241, 0.5); }
|
||||
.btn-secondary { background: rgba(255, 255, 255, 0.12); color: var(--text-primary); border: 1px solid var(--glass-border); }
|
||||
.btn-secondary:hover { background: rgba(255, 255, 255, 0.2); }
|
||||
.btn-danger { background: var(--danger); color: white; }
|
||||
.btn-danger:hover { background: #dc2626; }
|
||||
.btn-small { padding: 0.5rem 1rem; font-size: 0.875rem; }
|
||||
|
||||
.auth-link { text-align: center; margin-top: 1rem; color: var(--text-secondary); }
|
||||
.auth-link a { color: #a5b4fc; text-decoration: none; }
|
||||
.empty-state { text-align: center; padding: 3rem 2rem; }
|
||||
.empty-state h2 { margin-bottom: 1rem; }
|
||||
.empty-state p { color: var(--text-secondary); margin-bottom: 1.5rem; }
|
||||
|
||||
.stats-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card { text-align: center; }
|
||||
.stat-card h3 { color: var(--text-secondary); font-size: 1rem; margin-bottom: 0.5rem; }
|
||||
.stat-value { font-size: 2rem; font-weight: bold; }
|
||||
|
||||
.glass-nav {
|
||||
background: rgba(59, 7, 100, 0.5);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
padding: 1rem 0;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
/* Navigation Search */
|
||||
.nav-search {
|
||||
flex: 0 1 400px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.nav-search-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-search-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 24px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.nav-search-input:focus {
|
||||
outline: none;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(102, 126, 234, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.nav-search-input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.nav-search-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.nav-search-btn:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: opacity 0.3s;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-links a:hover { opacity: 0.7; }
|
||||
|
||||
.metrics-section {
|
||||
padding: 2rem;
|
||||
margin-top: 0;
|
||||
margin-bottom: 2rem;
|
||||
max-height: 550px !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.metrics-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.metrics-header h2 { margin: 0; }
|
||||
.metrics-controls { display: flex; gap: 1rem; }
|
||||
|
||||
.metric-select {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
min-width: 150px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metric-select:focus { outline: none; border-color: var(--primary); background: rgba(255, 255, 255, 0.18); }
|
||||
.metric-select option { background: #3b0764; color: white; }
|
||||
|
||||
.charts-container {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
height: 380px !important;
|
||||
max-height: 380px !important;
|
||||
}
|
||||
|
||||
.chart-box {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 1rem;
|
||||
border-radius: 15px;
|
||||
border: 1px solid var(--glass-border);
|
||||
height: 380px !important;
|
||||
max-height: 380px !important;
|
||||
overflow: hidden !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chart-box h3 { margin: 0 0 1rem 0; font-size: 1rem; text-align: center; flex-shrink: 0; }
|
||||
.chart-box canvas { max-width: 100% !important; max-height: 320px !important; height: 320px !important; }
|
||||
.chart-box-wide { height: 380px !important; max-height: 380px !important; }
|
||||
|
||||
.categories-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.category-card { text-decoration: none; color: inherit; cursor: pointer; transition: all 0.3s ease; display: block; }
|
||||
.category-card:hover { transform: translateY(-5px); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4); }
|
||||
.category-content h3 { margin-bottom: 0.5rem; font-size: 1.3rem; }
|
||||
.category-description { color: var(--text-secondary); font-size: 0.9rem; margin: 0.5rem 0 1rem; }
|
||||
.category-amount { font-size: 2rem; font-weight: bold; color: var(--success); margin: 1rem 0; }
|
||||
.category-info { color: var(--text-secondary); font-size: 0.85rem; }
|
||||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
|
||||
.form-group textarea { padding: 0.75rem; border: 1px solid var(--glass-border); border-radius: 10px; background: rgba(255, 255, 255, 0.12); color: var(--text-primary); font-size: 1rem; font-family: inherit; resize: vertical; }
|
||||
.form-group textarea:focus { outline: none; border-color: var(--primary); background: rgba(255, 255, 255, 0.18); }
|
||||
.form-group input[type="color"] { height: 50px; cursor: pointer; }
|
||||
.form-actions { display: flex; gap: 1rem; justify-content: flex-end; margin-top: 1rem; }
|
||||
input[type="file"] { padding: 0.5rem; background: rgba(255, 255, 255, 0.12); border: 1px solid var(--glass-border); border-radius: 10px; color: var(--text-primary); }
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.charts-container { grid-template-columns: 1fr; height: auto !important; }
|
||||
.chart-box { height: 350px !important; }
|
||||
.metrics-controls { flex-direction: column; width: 100%; }
|
||||
.metric-select { width: 100%; }
|
||||
.nav-container { flex-direction: column; gap: 1rem; }
|
||||
}
|
||||
|
||||
/* SETTINGS PAGE */
|
||||
.settings-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.settings-container h1 {
|
||||
margin-bottom: 2rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 2px solid var(--glass-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 1rem 1.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
border-bottom: 3px solid transparent;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--text-primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tags-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tag-name {
|
||||
font-weight: 500;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.users-table th,
|
||||
.users-table td {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.users-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.users-table tr:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.form-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-card h1 {
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settings-tabs {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.users-table th,
|
||||
.users-table td {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* FINA Logo Styling */
|
||||
.nav-logo {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
margin-right: 0.5rem;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-brand span {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Language Switcher */
|
||||
.language-switcher {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.language-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.language-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.language-menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 0.5rem;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 10px;
|
||||
min-width: 150px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
z-index: 9999;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.language-menu.show {
|
||||
display: block;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.language-option {
|
||||
display: block;
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.language-option:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* PWA Install Prompt Styles */
|
||||
.pwa-prompt {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 500px;
|
||||
width: calc(100% - 40px);
|
||||
z-index: 10000;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateX(-50%) translateY(100px); opacity: 0; }
|
||||
to { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.pwa-prompt-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pwa-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.pwa-prompt-text {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.pwa-prompt-text h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pwa-prompt-text p {
|
||||
margin: 0.25rem 0 0 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pwa-prompt-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.pwa-prompt-actions .btn {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.pwa-prompt-content {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pwa-prompt-actions {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pwa-prompt-actions .btn {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced Mobile Responsiveness for PWA */
|
||||
@media (max-width: 768px) {
|
||||
/* Larger touch targets for mobile */
|
||||
.btn {
|
||||
min-height: 44px;
|
||||
padding: 0.875rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
min-height: 40px;
|
||||
padding: 0.625rem 1.25rem;
|
||||
}
|
||||
|
||||
/* Stack header actions vertically on mobile */
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: stretch !important;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-actions .btn,
|
||||
.header-actions form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-actions form button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Navigation adjustments for mobile */
|
||||
.nav-container {
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.nav-search {
|
||||
order: 3;
|
||||
flex: 1 1 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.nav-search-input {
|
||||
padding: 0.625rem 1rem;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
gap: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Better mobile navigation */
|
||||
.nav-links {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Subscription cards mobile-friendly */
|
||||
.subscription-item {
|
||||
flex-direction: column !important;
|
||||
align-items: flex-start !important;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.subscription-info {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.subscription-actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.subscription-actions .btn,
|
||||
.subscription-actions form {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Stats grid mobile */
|
||||
.stats-container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Form improvements */
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
font-size: 16px; /* Prevents iOS zoom */
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Categories grid mobile */
|
||||
.categories-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Suggestion cards mobile */
|
||||
.suggestion-details {
|
||||
grid-template-columns: 1fr 1fr !important;
|
||||
gap: 0.75rem !important;
|
||||
}
|
||||
|
||||
.suggestion-actions {
|
||||
flex-direction: column !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.suggestion-actions .btn,
|
||||
.suggestion-actions form {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
BIN
backup/first -fina app/app/static/favicon.ico
Executable file
BIN
backup/first -fina app/app/static/favicon.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 1 KiB |
0
backup/first -fina app/app/static/fina-icon.png
Normal file
0
backup/first -fina app/app/static/fina-icon.png
Normal file
BIN
backup/first -fina app/app/static/images/FINA.png
Executable file
BIN
backup/first -fina app/app/static/images/FINA.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 863 KiB |
BIN
backup/first -fina app/app/static/images/fina-icon-original.png
Executable file
BIN
backup/first -fina app/app/static/images/fina-icon-original.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 863 KiB |
BIN
backup/first -fina app/app/static/images/fina-logo.png
Executable file
BIN
backup/first -fina app/app/static/images/fina-logo.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 863 KiB |
20
backup/first -fina app/app/static/js/chart.min.js
vendored
Executable file
20
backup/first -fina app/app/static/js/chart.min.js
vendored
Executable file
File diff suppressed because one or more lines are too long
187
backup/first -fina app/app/static/js/script.js
Executable file
187
backup/first -fina app/app/static/js/script.js
Executable file
|
|
@ -0,0 +1,187 @@
|
|||
// PWA Service Worker Registration
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/static/js/service-worker.js')
|
||||
.then(registration => {
|
||||
console.log('[PWA] Service Worker registered successfully:', registration.scope);
|
||||
|
||||
// Check for updates
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
console.log('[PWA] New version available! Refresh to update.');
|
||||
// Optionally show a notification to the user
|
||||
if (confirm('A new version of FINA is available. Reload to update?')) {
|
||||
newWorker.postMessage({ type: 'SKIP_WAITING' });
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('[PWA] Service Worker registration failed:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// PWA Install Prompt
|
||||
let deferredPrompt;
|
||||
const installPrompt = document.getElementById('pwa-install-prompt');
|
||||
const installBtn = document.getElementById('pwa-install-btn');
|
||||
const dismissBtn = document.getElementById('pwa-dismiss-btn');
|
||||
|
||||
// Check if already installed (standalone mode)
|
||||
const isInstalled = () => {
|
||||
return window.matchMedia('(display-mode: standalone)').matches ||
|
||||
window.navigator.standalone === true ||
|
||||
document.referrer.includes('android-app://');
|
||||
};
|
||||
|
||||
// Detect iOS
|
||||
const isIOS = () => {
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
||||
};
|
||||
|
||||
// Show iOS install instructions
|
||||
function showIOSInstallPrompt() {
|
||||
if (installPrompt) {
|
||||
const promptText = installPrompt.querySelector('.pwa-prompt-text p');
|
||||
if (promptText && isIOS() && !window.navigator.standalone) {
|
||||
promptText.textContent = 'Tap Share button and then "Add to Home Screen"';
|
||||
installBtn.style.display = 'none'; // Hide install button on iOS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
// Prevent the default mini-infobar
|
||||
e.preventDefault();
|
||||
// Store the event for later use
|
||||
deferredPrompt = e;
|
||||
|
||||
// Don't show if already installed
|
||||
if (isInstalled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show custom install prompt if not dismissed
|
||||
const dismissed = localStorage.getItem('pwa-install-dismissed');
|
||||
const dismissedUntil = parseInt(dismissed || '0');
|
||||
|
||||
if (Date.now() > dismissedUntil && installPrompt) {
|
||||
installPrompt.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
// Handle iOS separately
|
||||
if (isIOS() && !isInstalled()) {
|
||||
const dismissed = localStorage.getItem('pwa-install-dismissed');
|
||||
const dismissedUntil = parseInt(dismissed || '0');
|
||||
|
||||
if (Date.now() > dismissedUntil && installPrompt) {
|
||||
setTimeout(() => {
|
||||
installPrompt.style.display = 'block';
|
||||
showIOSInstallPrompt();
|
||||
}, 2000); // Show after 2 seconds
|
||||
}
|
||||
}
|
||||
|
||||
if (installBtn) {
|
||||
installBtn.addEventListener('click', async () => {
|
||||
if (!deferredPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the install prompt
|
||||
deferredPrompt.prompt();
|
||||
|
||||
// Wait for the user's response
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
console.log(`[PWA] User response: ${outcome}`);
|
||||
|
||||
// Clear the saved prompt since it can't be used again
|
||||
deferredPrompt = null;
|
||||
|
||||
// Hide the prompt
|
||||
installPrompt.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
if (dismissBtn) {
|
||||
dismissBtn.addEventListener('click', () => {
|
||||
installPrompt.style.display = 'none';
|
||||
// Remember dismissal for 7 days
|
||||
localStorage.setItem('pwa-install-dismissed', Date.now() + (7 * 24 * 60 * 60 * 1000));
|
||||
});
|
||||
}
|
||||
|
||||
// Check if app is installed
|
||||
window.addEventListener('appinstalled', () => {
|
||||
console.log('[PWA] App installed successfully');
|
||||
if (installPrompt) {
|
||||
installPrompt.style.display = 'none';
|
||||
}
|
||||
localStorage.removeItem('pwa-install-dismissed');
|
||||
});
|
||||
|
||||
// Online/Offline status
|
||||
window.addEventListener('online', () => {
|
||||
console.log('[PWA] Back online');
|
||||
// Show notification or update UI
|
||||
showToast('Connection restored', 'success');
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
console.log('[PWA] Gone offline');
|
||||
showToast('You are offline. Some features may be limited.', 'info');
|
||||
});
|
||||
|
||||
// Toast notification function
|
||||
function showToast(message, type = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `alert alert-${type} glass-card`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('hiding');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Language menu toggle
|
||||
function toggleLanguageMenu() {
|
||||
const menu = document.getElementById('language-menu');
|
||||
menu.classList.toggle('show');
|
||||
}
|
||||
|
||||
// Close language menu when clicking outside
|
||||
document.addEventListener('click', function(event) {
|
||||
const switcher = document.querySelector('.language-switcher');
|
||||
const menu = document.getElementById('language-menu');
|
||||
|
||||
if (menu && switcher && !switcher.contains(event.target)) {
|
||||
menu.classList.remove('show');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('Finance Tracker loaded');
|
||||
|
||||
// Auto-hide flash messages after 2 seconds
|
||||
const alerts = document.querySelectorAll('.alert');
|
||||
|
||||
alerts.forEach(function(alert) {
|
||||
// Add hiding animation after 2 seconds
|
||||
setTimeout(function() {
|
||||
alert.classList.add('hiding');
|
||||
|
||||
// Remove from DOM after animation completes
|
||||
setTimeout(function() {
|
||||
alert.remove();
|
||||
}, 300); // Wait for animation to finish
|
||||
}, 2000); // Show for 2 seconds
|
||||
});
|
||||
});
|
||||
161
backup/first -fina app/app/static/js/service-worker.js
Normal file
161
backup/first -fina app/app/static/js/service-worker.js
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
const CACHE_NAME = 'fina-v1';
|
||||
const STATIC_CACHE = 'fina-static-v1';
|
||||
const DYNAMIC_CACHE = 'fina-dynamic-v1';
|
||||
|
||||
// Assets to cache on install
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/static/css/style.css',
|
||||
'/static/js/script.js',
|
||||
'/static/js/chart.min.js',
|
||||
'/static/images/fina-logo.png'
|
||||
];
|
||||
|
||||
// Install event - cache static assets
|
||||
self.addEventListener('install', event => {
|
||||
console.log('[Service Worker] Installing...');
|
||||
event.waitUntil(
|
||||
caches.open(STATIC_CACHE)
|
||||
.then(cache => {
|
||||
console.log('[Service Worker] Caching static assets');
|
||||
return cache.addAll(STATIC_ASSETS);
|
||||
})
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', event => {
|
||||
console.log('[Service Worker] Activating...');
|
||||
event.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter(name => name !== STATIC_CACHE && name !== DYNAMIC_CACHE)
|
||||
.map(name => caches.delete(name))
|
||||
);
|
||||
}).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event - network first, then cache
|
||||
self.addEventListener('fetch', event => {
|
||||
const { request } = event;
|
||||
|
||||
// Skip non-GET requests
|
||||
if (request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip chrome extension and other non-http(s) requests
|
||||
if (!request.url.startsWith('http')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// API requests - network first, cache fallback
|
||||
if (request.url.includes('/api/')) {
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then(response => {
|
||||
const responseClone = response.clone();
|
||||
caches.open(DYNAMIC_CACHE).then(cache => {
|
||||
cache.put(request, responseClone);
|
||||
});
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(request))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets - cache first, network fallback
|
||||
if (
|
||||
request.url.includes('/static/') ||
|
||||
request.url.endsWith('.css') ||
|
||||
request.url.endsWith('.js') ||
|
||||
request.url.endsWith('.png') ||
|
||||
request.url.endsWith('.jpg') ||
|
||||
request.url.endsWith('.jpeg') ||
|
||||
request.url.endsWith('.gif') ||
|
||||
request.url.endsWith('.svg')
|
||||
) {
|
||||
event.respondWith(
|
||||
caches.match(request)
|
||||
.then(cachedResponse => {
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
return fetch(request).then(response => {
|
||||
const responseClone = response.clone();
|
||||
caches.open(STATIC_CACHE).then(cache => {
|
||||
cache.put(request, responseClone);
|
||||
});
|
||||
return response;
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML pages - network first, cache fallback
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then(response => {
|
||||
const responseClone = response.clone();
|
||||
caches.open(DYNAMIC_CACHE).then(cache => {
|
||||
cache.put(request, responseClone);
|
||||
});
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
return caches.match(request).then(cachedResponse => {
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
// Return offline page if available
|
||||
return caches.match('/');
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Handle messages from clients
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
|
||||
// Background sync for offline form submissions
|
||||
self.addEventListener('sync', event => {
|
||||
if (event.tag === 'sync-expenses') {
|
||||
event.waitUntil(syncExpenses());
|
||||
}
|
||||
});
|
||||
|
||||
async function syncExpenses() {
|
||||
// Placeholder for syncing offline data
|
||||
console.log('[Service Worker] Syncing expenses...');
|
||||
}
|
||||
|
||||
// Push notifications support (for future feature)
|
||||
self.addEventListener('push', event => {
|
||||
const options = {
|
||||
body: event.data ? event.data.text() : 'New notification from FINA',
|
||||
icon: '/static/images/fina-logo.png',
|
||||
badge: '/static/images/fina-logo.png',
|
||||
vibrate: [200, 100, 200]
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification('FINA', options)
|
||||
);
|
||||
});
|
||||
|
||||
// Notification click handler
|
||||
self.addEventListener('notificationclick', event => {
|
||||
event.notification.close();
|
||||
event.waitUntil(
|
||||
clients.openWindow('/')
|
||||
);
|
||||
});
|
||||
49
backup/first -fina app/app/static/manifest.json
Normal file
49
backup/first -fina app/app/static/manifest.json
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"name": "FINA - Personal Finance Tracker",
|
||||
"short_name": "FINA",
|
||||
"description": "Track your expenses, manage categories, and visualize spending patterns",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#3b0764",
|
||||
"theme_color": "#5b5fc7",
|
||||
"orientation": "portrait-primary",
|
||||
"categories": ["finance", "productivity"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/images/fina-logo.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/images/fina-logo.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Dashboard",
|
||||
"short_name": "Dashboard",
|
||||
"description": "View your expense dashboard",
|
||||
"url": "/dashboard",
|
||||
"icons": [{ "src": "/static/images/fina-logo.png", "sizes": "96x96" }]
|
||||
},
|
||||
{
|
||||
"name": "New Category",
|
||||
"short_name": "Category",
|
||||
"description": "Create a new expense category",
|
||||
"url": "/create-category",
|
||||
"icons": [{ "src": "/static/images/fina-logo.png", "sizes": "96x96" }]
|
||||
},
|
||||
{
|
||||
"name": "Subscriptions",
|
||||
"short_name": "Subscriptions",
|
||||
"description": "Manage recurring expenses",
|
||||
"url": "/subscriptions",
|
||||
"icons": [{ "src": "/static/images/fina-logo.png", "sizes": "96x96" }]
|
||||
}
|
||||
],
|
||||
"screenshots": []
|
||||
}
|
||||
35
backup/first -fina app/app/templates/auth/login.html
Executable file
35
backup/first -fina app/app/templates/auth/login.html
Executable file
|
|
@ -0,0 +1,35 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - FINA{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<div class="glass-card auth-card">
|
||||
<div style="text-align: center; margin-bottom: 1rem;">
|
||||
<img src="{{ url_for('static', filename='images/fina-logo.png') }}" alt="FINA" style="height: 80px; border-radius: 50%;">
|
||||
</div>
|
||||
<h1>FINA</h1>
|
||||
<p class="subtitle">Login to your account</p>
|
||||
|
||||
<form method="POST" class="auth-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-link">
|
||||
Don't have an account? <a href="{{ url_for('auth.register') }}">Register</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
45
backup/first -fina app/app/templates/auth/register.html
Executable file
45
backup/first -fina app/app/templates/auth/register.html
Executable file
|
|
@ -0,0 +1,45 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Register - FINA{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<div class="glass-card auth-card">
|
||||
<div style="text-align: center; margin-bottom: 1rem;">
|
||||
<img src="{{ url_for('static', filename='images/fina-logo.png') }}" alt="FINA" style="height: 80px; border-radius: 50%;">
|
||||
</div>
|
||||
<h1>FINA</h1>
|
||||
<p class="subtitle">Create your account</p>
|
||||
|
||||
<form method="POST" class="auth-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required minlength="6">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">Confirm Password</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" required minlength="6">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Register</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-link">
|
||||
Already have an account? <a href="{{ url_for('auth.login') }}">Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
29
backup/first -fina app/app/templates/auth/verify_2fa.html
Executable file
29
backup/first -fina app/app/templates/auth/verify_2fa.html
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Verify 2FA - Finance Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<div class="glass-card auth-card">
|
||||
<h1>🔐 Two-Factor Authentication</h1>
|
||||
<p class="subtitle">Enter the code from your authenticator app</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.verify_2fa') }}" class="auth-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="token">Authentication Code</label>
|
||||
<input type="text" id="token" name="token" required autofocus
|
||||
pattern="[0-9]{6}" maxlength="6" placeholder="000000"
|
||||
style="font-size: 1.5rem; text-align: center; letter-spacing: 0.5rem;">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Verify & Login</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-link">
|
||||
<a href="{{ url_for('auth.login') }}">Back to Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
204
backup/first -fina app/app/templates/bank_import.html
Normal file
204
backup/first -fina app/app/templates/bank_import.html
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('bank.import_title') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h1><i class="fas fa-file-import"></i> {{ _('bank.import_title') }}</h1>
|
||||
<p class="text-muted">{{ _('bank.import_subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>{{ _('bank.upload_file') }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Upload Form -->
|
||||
<form method="POST" enctype="multipart/form-data" id="uploadForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-group">
|
||||
<label for="file">{{ _('bank.select_file') }}</label>
|
||||
<div class="custom-file-upload" id="dropZone">
|
||||
<input type="file"
|
||||
id="file"
|
||||
name="file"
|
||||
accept=".pdf,.csv"
|
||||
required>
|
||||
<div class="upload-placeholder">
|
||||
<i class="fas fa-cloud-upload-alt fa-3x"></i>
|
||||
<p>{{ _('bank.drag_drop') }}</p>
|
||||
<p class="text-muted">{{ _('bank.or_click') }}</p>
|
||||
<button type="button" class="btn btn-secondary" onclick="document.getElementById('file').click()">
|
||||
{{ _('bank.browse_files') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="file-selected" style="display: none;">
|
||||
<i class="fas fa-file fa-2x"></i>
|
||||
<p id="fileName"></p>
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="clearFile()">
|
||||
<i class="fas fa-times"></i> {{ _('bank.remove') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="alert alert-info">
|
||||
<strong><i class="fas fa-info-circle"></i> {{ _('bank.supported_formats') }}:</strong>
|
||||
<ul>
|
||||
<li>{{ _('bank.format_pdf') }}</li>
|
||||
<li>{{ _('bank.format_csv') }}</li>
|
||||
</ul>
|
||||
<p class="mb-0"><small>{{ _('bank.format_hint') }}</small></p>
|
||||
</div>
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle"></i> {{ _('bank.not_all_banks_supported') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
<i class="fas fa-upload"></i> {{ _('bank.upload_parse') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div id="loadingState" style="display: none;">
|
||||
<div class="text-center py-4">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="sr-only">{{ _('bank.processing') }}</span>
|
||||
</div>
|
||||
<p class="mt-3">{{ _('bank.processing') }}...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Section -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h4><i class="fas fa-question-circle"></i> {{ _('bank.how_it_works') }}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ol>
|
||||
<li>{{ _('bank.step_1') }}</li>
|
||||
<li>{{ _('bank.step_2') }}</li>
|
||||
<li>{{ _('bank.step_3') }}</li>
|
||||
<li>{{ _('bank.step_4') }}</li>
|
||||
<li>{{ _('bank.step_5') }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.custom-file-upload {
|
||||
position: relative;
|
||||
border: 2px dashed #007bff;
|
||||
border-radius: 8px;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
background: #f8f9fa;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.custom-file-upload:hover {
|
||||
border-color: #0056b3;
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.custom-file-upload.drag-over {
|
||||
border-color: #28a745;
|
||||
background: #d4edda;
|
||||
}
|
||||
|
||||
.custom-file-upload input[type="file"] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-placeholder i {
|
||||
color: #007bff;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.file-selected i {
|
||||
color: #28a745;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.custom-file-upload {
|
||||
padding: 30px 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// File selection
|
||||
document.getElementById('file').addEventListener('change', function(e) {
|
||||
if (this.files.length > 0) {
|
||||
document.querySelector('.upload-placeholder').style.display = 'none';
|
||||
document.querySelector('.file-selected').style.display = 'block';
|
||||
document.getElementById('fileName').textContent = this.files[0].name;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear file
|
||||
function clearFile() {
|
||||
document.getElementById('file').value = '';
|
||||
document.querySelector('.upload-placeholder').style.display = 'block';
|
||||
document.querySelector('.file-selected').style.display = 'none';
|
||||
}
|
||||
|
||||
// Drag and drop
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, preventDefaults, false);
|
||||
});
|
||||
|
||||
function preventDefaults(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, () => {
|
||||
dropZone.classList.add('drag-over');
|
||||
});
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, () => {
|
||||
dropZone.classList.remove('drag-over');
|
||||
});
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', function(e) {
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
document.getElementById('file').files = files;
|
||||
document.querySelector('.upload-placeholder').style.display = 'none';
|
||||
document.querySelector('.file-selected').style.display = 'block';
|
||||
document.getElementById('fileName').textContent = files[0].name;
|
||||
}
|
||||
});
|
||||
|
||||
// Show loading on submit
|
||||
document.getElementById('uploadForm').addEventListener('submit', function() {
|
||||
document.querySelector('.card-body').style.display = 'none';
|
||||
document.getElementById('loadingState').style.display = 'block';
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
304
backup/first -fina app/app/templates/bank_import_review.html
Normal file
304
backup/first -fina app/app/templates/bank_import_review.html
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('bank.review_title') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h1><i class="fas fa-check-circle"></i> {{ _('bank.review_title') }}</h1>
|
||||
<p class="text-muted">{{ _('bank.review_subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-md-3">
|
||||
<h3 class="text-primary">{{ total_found }}</h3>
|
||||
<p class="text-muted">{{ _('bank.transactions_found') }}</p>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<h3 class="text-info">{{ bank_format }}</h3>
|
||||
<p class="text-muted">{{ _('bank.detected_format') }}</p>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<h3 class="text-success" id="selectedCount">0</h3>
|
||||
<p class="text-muted">{{ _('bank.selected') }}</p>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<h3 class="text-warning" id="unmappedCount">{{ total_found }}</h3>
|
||||
<p class="text-muted">{{ _('bank.unmapped') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parse Errors (if any) -->
|
||||
{% if parse_errors %}
|
||||
<div class="alert alert-warning">
|
||||
<strong><i class="fas fa-exclamation-triangle"></i> {{ _('bank.parse_warnings') }}:</strong>
|
||||
<ul class="mb-0">
|
||||
{% for error in parse_errors %}
|
||||
<li>{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Bank Format Warning -->
|
||||
{% if bank_format in ['generic', 'unknown', 'Generic'] %}
|
||||
<div class="alert alert-warning">
|
||||
<strong><i class="fas fa-exclamation-triangle"></i> {{ _('bank.format_not_recognized') }}</strong><br>
|
||||
{{ _('bank.format_not_recognized_hint') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Import Form -->
|
||||
<form method="POST" action="{{ url_for('main.bank_import_confirm') }}" id="importForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<!-- Bulk Actions -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-6">
|
||||
<div class="form-check">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
id="selectAll"
|
||||
onchange="toggleAll(this)">
|
||||
<label class="form-check-label" for="selectAll">
|
||||
{{ _('bank.select_all') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 text-right">
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-secondary"
|
||||
onclick="selectExpenses()">
|
||||
<i class="fas fa-arrow-down"></i> {{ _('bank.select_expenses') }}
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-secondary"
|
||||
onclick="selectIncome()">
|
||||
<i class="fas fa-arrow-up"></i> {{ _('bank.select_income') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transactions Table -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>{{ _('bank.transactions_to_import') }}</h3>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover mb-0">
|
||||
<thead class="thead-dark">
|
||||
<tr>
|
||||
<th width="5%">
|
||||
<input type="checkbox" id="selectAllTable" onchange="toggleAll(this)">
|
||||
</th>
|
||||
<th width="10%">{{ _('bank.date') }}</th>
|
||||
<th width="35%">{{ _('bank.description') }}</th>
|
||||
<th width="15%">{{ _('bank.amount') }}</th>
|
||||
<th width="10%">{{ _('bank.type') }}</th>
|
||||
<th width="25%">{{ _('bank.category') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for trans in transactions %}
|
||||
<tr class="transaction-row {% if trans.amount < 0 %}expense-row{% else %}income-row{% endif %}">
|
||||
<td>
|
||||
<input type="checkbox"
|
||||
name="selected_transactions"
|
||||
value="{{ loop.index0 }}"
|
||||
class="transaction-checkbox"
|
||||
onchange="updateCounts()">
|
||||
</td>
|
||||
<td>{{ trans.date.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
<small>{{ trans.description }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="{% if trans.amount < 0 %}text-danger{% else %}text-success{% endif %}">
|
||||
{% if trans.amount < 0 %}-{% else %}+{% endif %}
|
||||
{{ "%.2f"|format(trans.amount|abs) }} {{ _('expense.currency') }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if trans.amount < 0 %}
|
||||
<span class="badge badge-danger">{{ _('bank.expense') }}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-success">{{ _('bank.income') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<select name="category_{{ loop.index0 }}"
|
||||
class="form-control form-control-sm category-select"
|
||||
onchange="updateCounts()">
|
||||
<option value="">{{ _('bank.select_category') }}</option>
|
||||
{% for category in categories %}
|
||||
<option value="{{ category.id }}">{{ category.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="mt-4">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<a href="{{ url_for('main.bank_import') }}" class="btn btn-secondary btn-block">
|
||||
<i class="fas fa-arrow-left"></i> {{ _('bank.back') }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<button type="submit" class="btn btn-primary btn-block" id="submitBtn" disabled>
|
||||
<i class="fas fa-check"></i> {{ _('bank.import_selected') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.transaction-row {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.transaction-row:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.category-select {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.expense-row {
|
||||
border-left: 3px solid #dc3545;
|
||||
}
|
||||
|
||||
.income-row {
|
||||
border-left: 3px solid #28a745;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.table {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
width: 100%;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Toggle all checkboxes
|
||||
function toggleAll(checkbox) {
|
||||
const checkboxes = document.querySelectorAll('.transaction-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = checkbox.checked;
|
||||
});
|
||||
updateCounts();
|
||||
}
|
||||
|
||||
// Select expenses only
|
||||
function selectExpenses() {
|
||||
const checkboxes = document.querySelectorAll('.expense-row .transaction-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = true;
|
||||
});
|
||||
updateCounts();
|
||||
}
|
||||
|
||||
// Select income only
|
||||
function selectIncome() {
|
||||
const checkboxes = document.querySelectorAll('.income-row .transaction-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = true;
|
||||
});
|
||||
updateCounts();
|
||||
}
|
||||
|
||||
// Update counts and enable/disable submit button
|
||||
function updateCounts() {
|
||||
const checkboxes = document.querySelectorAll('.transaction-checkbox:checked');
|
||||
const selectedCount = checkboxes.length;
|
||||
|
||||
// Count how many selected transactions have a category
|
||||
let mappedCount = 0;
|
||||
checkboxes.forEach(cb => {
|
||||
const row = cb.closest('tr');
|
||||
const categorySelect = row.querySelector('.category-select');
|
||||
if (categorySelect && categorySelect.value) {
|
||||
mappedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
const unmappedCount = selectedCount - mappedCount;
|
||||
|
||||
// Update UI
|
||||
document.getElementById('selectedCount').textContent = selectedCount;
|
||||
document.getElementById('unmappedCount').textContent = unmappedCount;
|
||||
|
||||
// Enable submit button only if at least one transaction is selected with a category
|
||||
document.getElementById('submitBtn').disabled = (mappedCount === 0);
|
||||
}
|
||||
|
||||
// Auto-select checkbox when category is selected
|
||||
document.querySelectorAll('.category-select').forEach(select => {
|
||||
select.addEventListener('change', function() {
|
||||
if (this.value) {
|
||||
const row = this.closest('tr');
|
||||
const checkbox = row.querySelector('.transaction-checkbox');
|
||||
checkbox.checked = true;
|
||||
}
|
||||
updateCounts();
|
||||
});
|
||||
});
|
||||
|
||||
// Confirm before submit
|
||||
document.getElementById('importForm').addEventListener('submit', function(e) {
|
||||
const selectedCount = document.querySelectorAll('.transaction-checkbox:checked').length;
|
||||
if (selectedCount === 0) {
|
||||
e.preventDefault();
|
||||
alert('{{ _('bank.no_transactions_selected') }}');
|
||||
return false;
|
||||
}
|
||||
|
||||
const unmappedCount = parseInt(document.getElementById('unmappedCount').textContent);
|
||||
if (unmappedCount > 0) {
|
||||
const msg = '{{ _('bank.confirm_unmapped') }}'.replace('{count}', unmappedCount);
|
||||
if (!confirm(msg)) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
document.getElementById('submitBtn').disabled = true;
|
||||
document.getElementById('submitBtn').innerHTML = '<i class="fas fa-spinner fa-spin"></i> {{ _('bank.importing') }}...';
|
||||
});
|
||||
|
||||
// Initial update
|
||||
updateCounts();
|
||||
</script>
|
||||
{% endblock %}
|
||||
108
backup/first -fina app/app/templates/base.html
Executable file
108
backup/first -fina app/app/templates/base.html
Executable file
|
|
@ -0,0 +1,108 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}FINA{% endblock %}</title>
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="description" content="FINA - Track your expenses, manage categories, and visualize spending patterns">
|
||||
<meta name="theme-color" content="#5b5fc7">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="FINA">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
|
||||
<!-- Manifest -->
|
||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/fina-logo.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='images/fina-logo.png') }}">
|
||||
<link rel="shortcut icon" href="{{ url_for('static', filename='images/fina-logo.png') }}">
|
||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='images/fina-logo.png') }}">
|
||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='images/fina-logo.png') }}">
|
||||
|
||||
<!-- Stylesheets -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
{% if current_user.is_authenticated %}
|
||||
<nav class="glass-nav">
|
||||
<div class="nav-container">
|
||||
<a href="{{ url_for('main.dashboard') }}" class="nav-brand">
|
||||
<img src="{{ url_for('static', filename='images/fina-logo.png') }}" alt="FINA" class="nav-logo">
|
||||
<span>FINA</span>
|
||||
</a>
|
||||
|
||||
<!-- Global Search Bar -->
|
||||
<div class="nav-search">
|
||||
<form method="GET" action="{{ url_for('main.search_page') }}" class="nav-search-form">
|
||||
<input type="text"
|
||||
name="q"
|
||||
class="nav-search-input"
|
||||
placeholder="{{ _('search.quick_search') }}"
|
||||
autocomplete="off">
|
||||
<button type="submit" class="nav-search-btn">🔍</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="{{ url_for('main.create_category') }}">{{ _('nav.new_category') }}</a>
|
||||
<a href="{{ url_for('subscriptions.index') }}">{{ _('nav.subscriptions') }}</a>
|
||||
<a href="{{ url_for('main.bank_import') }}">
|
||||
<i class="fas fa-file-import"></i> {{ _('bank.import_title') }}
|
||||
</a>
|
||||
<a href="{{ url_for('main.predictions') }}">
|
||||
<i class="fas fa-chart-line"></i> {{ _('predictions.title') }}
|
||||
</a>
|
||||
<a href="{{ url_for('settings.index') }}">{{ _('nav.settings') }}</a>
|
||||
<div class="language-switcher">
|
||||
<button class="language-btn" onclick="toggleLanguageMenu()">
|
||||
{% if get_lang() == 'ro' %}🇷🇴{% elif get_lang() == 'es' %}🇪🇸{% else %}🇬🇧{% endif %}
|
||||
</button>
|
||||
<div class="language-menu" id="language-menu">
|
||||
<a href="{{ url_for('language.switch_language', lang='en') }}" class="language-option">🇬🇧 English</a>
|
||||
<a href="{{ url_for('language.switch_language', lang='ro') }}" class="language-option">🇷🇴 Română</a>
|
||||
<a href="{{ url_for('language.switch_language', lang='es') }}" class="language-option">🇪🇸 Español</a>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url_for('auth.logout') }}">{{ _('nav.logout') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} glass-card">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<!-- PWA Install Prompt -->
|
||||
<div id="pwa-install-prompt" class="pwa-prompt glass-card" style="display: none;">
|
||||
<div class="pwa-prompt-content">
|
||||
<img src="{{ url_for('static', filename='images/fina-logo.png') }}" alt="FINA" class="pwa-icon">
|
||||
<div class="pwa-prompt-text">
|
||||
<h3>{{ _('pwa.install_title') }}</h3>
|
||||
<p>{{ _('pwa.install_description') }}</p>
|
||||
</div>
|
||||
<div class="pwa-prompt-actions">
|
||||
<button id="pwa-install-btn" class="btn btn-primary">{{ _('pwa.install') }}</button>
|
||||
<button id="pwa-dismiss-btn" class="btn btn-secondary">{{ _('pwa.not_now') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
35
backup/first -fina app/app/templates/create_category.html
Executable file
35
backup/first -fina app/app/templates/create_category.html
Executable file
|
|
@ -0,0 +1,35 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Create Category - Finance Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<div class="glass-card form-card">
|
||||
<h1>{{ _('category.create') }}</h1>
|
||||
|
||||
<form method="POST" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">{{ _('category.name') }} *</label>
|
||||
<input type="text" id="name" name="name" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">{{ _('category.description') }}</label>
|
||||
<textarea id="description" name="description" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="color">{{ _('category.color') }}</label>
|
||||
<input type="color" id="color" name="color" value="#6366f1">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-secondary">{{ _('common.cancel') }}</a>
|
||||
<button type="submit" class="btn btn-primary">{{ _('category.create') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
424
backup/first -fina app/app/templates/create_expense.html
Executable file
424
backup/first -fina app/app/templates/create_expense.html
Executable file
|
|
@ -0,0 +1,424 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Add Expense - Finance Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<div class="glass-card form-card">
|
||||
<h1>💸 Add Expense to {{ category.name }}</h1>
|
||||
|
||||
<form method="POST" enctype="multipart/form-data" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description *</label>
|
||||
<input type="text" id="description" name="description" required autofocus placeholder="e.g., Grocery shopping">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="amount">Amount *</label>
|
||||
<input type="number" id="amount" name="amount" step="0.01" min="0.01" required placeholder="0.00">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="date">Date *</label>
|
||||
<input type="date" id="date" name="date" value="{{ today }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="paid_by">Paid By</label>
|
||||
<input type="text" id="paid_by" name="paid_by" placeholder="e.g., John, Cash, Credit Card">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="tags">Tags</label>
|
||||
<input type="text" id="tags" name="tags" placeholder="Type or click tags below">
|
||||
|
||||
{% if user_tags %}
|
||||
<div class="tag-suggestions">
|
||||
{% for tag in user_tags %}
|
||||
<button type="button" class="tag-btn" data-tag="{{ tag.name }}" style="background: {{ tag.color }}20; border: 1px solid {{ tag.color }}; color: {{ tag.color }};">
|
||||
🏷️ {{ tag.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<small>Click tags to add them. Multiple tags can be separated by commas.</small>
|
||||
{% else %}
|
||||
<small>No tags yet. <a href="{{ url_for('settings.create_tag') }}" style="color: #a5b4fc;">Create tags in Settings</a></small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="file">{{ _('expense.receipt') }}</label>
|
||||
<div class="receipt-upload-container">
|
||||
<!-- Camera button for mobile -->
|
||||
<button type="button" class="btn btn-camera" id="cameraBtn" onclick="openCamera()">
|
||||
📸 {{ _('ocr.take_photo') }}
|
||||
</button>
|
||||
|
||||
<!-- File input -->
|
||||
<input type="file" id="file" name="file" accept="image/*,.pdf" capture="environment">
|
||||
|
||||
<!-- OCR processing indicator -->
|
||||
<div id="ocrProcessing" class="ocr-processing" style="display: none;">
|
||||
<div class="spinner"></div>
|
||||
<span>{{ _('ocr.processing') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- OCR results -->
|
||||
<div id="ocrResults" class="ocr-results" style="display: none;"></div>
|
||||
</div>
|
||||
<small>{{ _('expense.receipt_hint') }} | 🤖 {{ _('ocr.ai_extraction') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('main.view_category', category_id=category.id) }}" class="btn btn-secondary">{{ _('common.cancel') }}</a>
|
||||
<button type="submit" class="btn btn-primary">💾 {{ _('expense.create') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tag-suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tag-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.tag-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.tag-btn.active {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const tagsInput = document.getElementById('tags');
|
||||
const tagButtons = document.querySelectorAll('.tag-btn');
|
||||
|
||||
tagButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const tagName = this.getAttribute('data-tag');
|
||||
const currentTags = tagsInput.value;
|
||||
|
||||
// Check if tag already exists
|
||||
const tagsArray = currentTags.split(',').map(t => t.trim()).filter(t => t);
|
||||
|
||||
if (tagsArray.includes(tagName)) {
|
||||
// Remove tag
|
||||
const newTags = tagsArray.filter(t => t !== tagName);
|
||||
tagsInput.value = newTags.join(', ');
|
||||
this.classList.remove('active');
|
||||
} else {
|
||||
// Add tag
|
||||
if (currentTags.trim()) {
|
||||
tagsInput.value = currentTags.trim() + ', ' + tagName;
|
||||
} else {
|
||||
tagsInput.value = tagName;
|
||||
}
|
||||
this.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Update button states based on input
|
||||
tagsInput.addEventListener('input', function() {
|
||||
const tagsArray = this.value.split(',').map(t => t.trim()).filter(t => t);
|
||||
tagButtons.forEach(button => {
|
||||
const tagName = button.getAttribute('data-tag');
|
||||
if (tagsArray.includes(tagName)) {
|
||||
button.classList.add('active');
|
||||
} else {
|
||||
button.classList.remove('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// OCR functionality
|
||||
const fileInput = document.getElementById('file');
|
||||
const ocrProcessing = document.getElementById('ocrProcessing');
|
||||
const ocrResults = document.getElementById('ocrResults');
|
||||
const amountInput = document.getElementById('amount');
|
||||
const descriptionInput = document.getElementById('description');
|
||||
const dateInput = document.getElementById('date');
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', async function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// Only process images for OCR
|
||||
if (!file.type.startsWith('image/')) return;
|
||||
|
||||
// Show processing indicator
|
||||
ocrProcessing.style.display = 'flex';
|
||||
ocrResults.style.display = 'none';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ocr/process', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
ocrProcessing.style.display = 'none';
|
||||
|
||||
if (data.success) {
|
||||
displayOCRResults(data);
|
||||
} else {
|
||||
ocrResults.innerHTML = `<div class="ocr-error">❌ ${data.error || 'OCR failed'}</div>`;
|
||||
ocrResults.style.display = 'block';
|
||||
}
|
||||
} catch (error) {
|
||||
ocrProcessing.style.display = 'none';
|
||||
ocrResults.innerHTML = `<div class="ocr-error">❌ Error: ${error.message}</div>`;
|
||||
ocrResults.style.display = 'block';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function displayOCRResults(data) {
|
||||
const confidenceClass = data.confidence === 'high' ? 'success' :
|
||||
data.confidence === 'medium' ? 'warning' : 'low';
|
||||
|
||||
let html = `<div class="ocr-result-box ${confidenceClass}">`;
|
||||
html += `<div class="ocr-header">🤖 AI Detected</div>`;
|
||||
|
||||
if (data.amount) {
|
||||
html += `<div class="ocr-field">
|
||||
<strong>Amount:</strong> ${data.amount.toFixed(2)}
|
||||
<button type="button" class="btn-apply" onclick="applyOCRAmount(${data.amount})">Use This</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (data.merchant) {
|
||||
html += `<div class="ocr-field">
|
||||
<strong>Merchant:</strong> ${data.merchant}
|
||||
<button type="button" class="btn-apply" onclick="applyOCRMerchant('${escapeHtml(data.merchant)}')">Use This</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (data.date) {
|
||||
html += `<div class="ocr-field">
|
||||
<strong>Date:</strong> ${data.date}
|
||||
<button type="button" class="btn-apply" onclick="applyOCRDate('${data.date}')">Use This</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const confidenceIcons = {
|
||||
'high': '✅',
|
||||
'medium': '⚠️',
|
||||
'low': '❌',
|
||||
'none': '❌'
|
||||
};
|
||||
|
||||
html += `<div class="ocr-confidence">${confidenceIcons[data.confidence]} Confidence: ${data.confidence.toUpperCase()}</div>`;
|
||||
html += `</div>`;
|
||||
|
||||
ocrResults.innerHTML = html;
|
||||
ocrResults.style.display = 'block';
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
});
|
||||
|
||||
// Camera functionality
|
||||
function openCamera() {
|
||||
const fileInput = document.getElementById('file');
|
||||
if (fileInput) fileInput.click();
|
||||
}
|
||||
|
||||
// Apply OCR results
|
||||
function applyOCRAmount(amount) {
|
||||
const input = document.getElementById('amount');
|
||||
if (input) {
|
||||
input.value = amount.toFixed(2);
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function applyOCRMerchant(merchant) {
|
||||
const input = document.getElementById('description');
|
||||
if (input) {
|
||||
input.value = merchant;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function applyOCRDate(date) {
|
||||
const input = document.getElementById('date');
|
||||
if (input) {
|
||||
input.value = date;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Receipt upload styles */
|
||||
.receipt-upload-container {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-camera {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.btn-camera:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.ocr-processing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.ocr-results {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.ocr-result-box {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.ocr-result-box.success {
|
||||
border-color: rgba(34, 197, 94, 0.5);
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
|
||||
.ocr-result-box.warning {
|
||||
border-color: rgba(251, 191, 36, 0.5);
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
}
|
||||
|
||||
.ocr-result-box.low {
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.ocr-header {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.ocr-field {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.ocr-field:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.btn-apply {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-apply:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ocr-confidence {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ocr-error {
|
||||
padding: 0.75rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.5);
|
||||
border-radius: 8px;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* Mobile optimization */
|
||||
@media (max-width: 768px) {
|
||||
.btn-camera {
|
||||
font-size: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.ocr-field {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-apply {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
320
backup/first -fina app/app/templates/dashboard.html
Executable file
320
backup/first -fina app/app/templates/dashboard.html
Executable file
|
|
@ -0,0 +1,320 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - FINA{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard">
|
||||
|
||||
{% if categories and categories|length > 0 %}
|
||||
|
||||
<div class="metrics-section glass-card" style="margin-bottom: 2rem; margin-top: 0;">
|
||||
<div class="metrics-header">
|
||||
<h2>📈 {{ _('dashboard.metrics') }}</h2>
|
||||
<div class="metrics-controls">
|
||||
<select id="metricCategory" class="metric-select">
|
||||
<option value="all">{{ _('dashboard.all_categories') }}</option>
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat.id }}" data-color="{{ cat.color }}">{{ cat.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select id="metricYear" class="metric-select">
|
||||
{% for year in available_years %}
|
||||
<option value="{{ year }}" {% if year == current_year %}selected{% endif %}>{{ year }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-container">
|
||||
<div class="chart-box chart-box-pie">
|
||||
<h3>{{ _('dashboard.expenses_by_category') }}</h3>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="pieChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-box chart-box-bar">
|
||||
<h3>{{ _('dashboard.monthly_expenses') }}</h3>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="barChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-container">
|
||||
<div class="glass-card stat-card">
|
||||
<h3>{{ _('dashboard.total_spent') }}</h3>
|
||||
<p class="stat-value">{{ total_spent|currency }}</p>
|
||||
</div>
|
||||
|
||||
<div class="glass-card stat-card">
|
||||
<h3>{{ _('dashboard.categories_section') }}</h3>
|
||||
<p class="stat-value">{{ (categories|default([]))|length }}</p>
|
||||
</div>
|
||||
|
||||
<div class="glass-card stat-card">
|
||||
<h3>{{ _('dashboard.total_expenses') }}</h3>
|
||||
<p class="stat-value">{{ total_expenses }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-header" style="margin-top: 2rem;">
|
||||
<h2>📁 {{ _('dashboard.categories_section') }}</h2>
|
||||
</div>
|
||||
<div class="categories-grid">
|
||||
{% for category in categories %}
|
||||
<a href="{{ url_for('main.view_category', category_id=category.id) }}" class="category-card glass-card" style="border-left: 4px solid {{ category.color }}">
|
||||
<div class="category-content">
|
||||
<h3>{{ category.name }}</h3>
|
||||
{% if category.description %}
|
||||
<p class="category-description">{{ category.description }}</p>
|
||||
{% endif %}
|
||||
<div class="category-amount">{{ category.get_total_spent()|currency }}</div>
|
||||
<div class="category-info">
|
||||
<span>{{ category.expenses|length }} {{ _('category.expenses') | lower }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
|
||||
<div class="stats-container">
|
||||
<div class="glass-card stat-card">
|
||||
<h3>{{ _('dashboard.total_spent') }}</h3>
|
||||
<p class="stat-value">{{ total_spent|currency }}</p>
|
||||
</div>
|
||||
|
||||
<div class="glass-card stat-card">
|
||||
<h3>{{ _('category.expenses') }}</h3>
|
||||
<p class="stat-value">{{ (categories|default([]))|length }}</p>
|
||||
</div>
|
||||
|
||||
<div class="glass-card stat-card">
|
||||
<h3>{{ _('dashboard.total_expenses') }}</h3>
|
||||
<p class="stat-value">{{ total_expenses }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card empty-state">
|
||||
<h2>{{ _('empty.welcome_title') }}</h2>
|
||||
<p>{{ _('empty.welcome_message') }}</p>
|
||||
<a href="{{ url_for('main.create_category') }}" class="btn btn-primary">{{ _('empty.create_category') }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Upcoming Subscriptions Widget -->
|
||||
{% if upcoming_subscriptions or suggestions_count > 0 %}
|
||||
<div class="glass-card" style="margin-top: 2rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h2>🔄 {{ _('subscription.title') }}</h2>
|
||||
<a href="{{ url_for('subscriptions.index') }}" class="btn btn-secondary btn-sm">{{ _('dashboard.view_all') }}</a>
|
||||
</div>
|
||||
|
||||
{% if suggestions_count > 0 %}
|
||||
<div class="alert" style="background: rgba(245, 158, 11, 0.2); border: 1px solid #f59e0b; margin-bottom: 1rem; position: relative;">
|
||||
💡 <strong>{{ suggestions_count }}</strong> {{ _('subscription.suggestions') | lower }} -
|
||||
<a href="{{ url_for('subscriptions.index') }}" style="color: #fbbf24; text-decoration: underline;">{{ _('common.view') }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if upcoming_subscriptions %}
|
||||
<div class="subscriptions-widget">
|
||||
{% for sub in upcoming_subscriptions %}
|
||||
<div class="subscription-widget-item" style="display: flex; justify-content: space-between; padding: 0.75rem 0; border-bottom: 1px solid var(--glass-border);">
|
||||
<div>
|
||||
<strong>{{ sub.name }}</strong>
|
||||
<br>
|
||||
<small style="color: var(--text-secondary);">
|
||||
{{ sub.amount|currency }} -
|
||||
{% if sub.next_due_date %}
|
||||
{% set days_until = (sub.next_due_date - today).days %}
|
||||
{% if days_until == 0 %}
|
||||
{{ _('subscription.today') }}
|
||||
{% elif days_until == 1 %}
|
||||
{{ _('subscription.tomorrow') }}
|
||||
{% elif days_until < 7 %}
|
||||
in {{ days_until }} {{ _('subscription.days') }}
|
||||
{% else %}
|
||||
{{ sub.next_due_date.strftime('%b %d') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
<span style="font-weight: 600;">{{ sub.amount|currency }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color: var(--text-secondary); text-align: center; padding: 1rem;">
|
||||
{{ _('subscription.no_upcoming') }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.charts-container {
|
||||
display: grid;
|
||||
grid-template-columns: 400px 1fr;
|
||||
gap: 2rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.chart-box-pie {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chart-box-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chart-box-pie .chart-wrapper {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.charts-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chart-box-pie .chart-wrapper {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
const chartData = {{ chart_data|tojson }};
|
||||
const categories = {{ categories_json|tojson }};
|
||||
const currentYear = {{ current_year }};
|
||||
const currencySymbol = '{{ current_user.currency }}';
|
||||
let pieChart, barChart;
|
||||
|
||||
function initCharts() {
|
||||
const pieCtx = document.getElementById('pieChart').getContext('2d');
|
||||
pieChart = new Chart(pieCtx, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: chartData.map(d => d.name),
|
||||
datasets: [{
|
||||
data: chartData.map(d => d.value),
|
||||
backgroundColor: chartData.map(d => d.color),
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
aspectRatio: 1,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: { color: '#ffffff', padding: 15, font: { size: 12 } }
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return context.label + ': ' + formatCurrency(context.parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const barCtx = document.getElementById('barChart').getContext('2d');
|
||||
barChart = new Chart(barCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
|
||||
datasets: []
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
aspectRatio: 2,
|
||||
scales: {
|
||||
y: { beginAtZero: true, ticks: { color: '#ffffff' }, grid: { color: 'rgba(255, 255, 255, 0.1)' } },
|
||||
x: { ticks: { color: '#ffffff' }, grid: { color: 'rgba(255, 255, 255, 0.1)' } }
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return formatCurrency(context.parsed.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
updateCharts('all', currentYear);
|
||||
}
|
||||
|
||||
function formatCurrency(amount) {
|
||||
const formatted = amount.toFixed(2).replace(/\d(?=(\d{3})+\.)/g, '$&,');
|
||||
if (currencySymbol === 'RON') {
|
||||
return formatted + ' Lei';
|
||||
} else if (currencySymbol === 'EUR') {
|
||||
return '€' + formatted;
|
||||
} else if (currencySymbol === 'GBP') {
|
||||
return '£' + formatted;
|
||||
} else {
|
||||
return '$' + formatted;
|
||||
}
|
||||
}
|
||||
|
||||
function updateCharts(categoryId, year) {
|
||||
fetch(`/api/metrics?category=${categoryId}&year=${year}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
barChart.data.datasets = [{
|
||||
label: data.category_name,
|
||||
data: data.monthly_data,
|
||||
backgroundColor: data.color || '#6366f1',
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderWidth: 1
|
||||
}];
|
||||
barChart.update();
|
||||
|
||||
if (categoryId === 'all') {
|
||||
pieChart.data.labels = data.pie_labels;
|
||||
pieChart.data.datasets[0].data = data.pie_data;
|
||||
pieChart.data.datasets[0].backgroundColor = data.pie_colors;
|
||||
pieChart.update();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('metricCategory').addEventListener('change', function() {
|
||||
updateCharts(this.value, document.getElementById('metricYear').value);
|
||||
});
|
||||
|
||||
document.getElementById('metricYear').addEventListener('change', function() {
|
||||
updateCharts(document.getElementById('metricCategory').value, this.value);
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initCharts);
|
||||
</script>
|
||||
{% endblock %}
|
||||
69
backup/first -fina app/app/templates/edit_category.html
Executable file
69
backup/first -fina app/app/templates/edit_category.html
Executable file
|
|
@ -0,0 +1,69 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit {{ category.name }} - Finance Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<div class="glass-card form-card">
|
||||
<h1>✏️ Edit Category</h1>
|
||||
|
||||
<form method="POST" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">Category Name *</label>
|
||||
<input type="text" id="name" name="name" value="{{ category.name }}" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" rows="3">{{ category.description or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="color">Color</label>
|
||||
<input type="color" id="color" name="color" value="{{ category.color }}">
|
||||
</div>
|
||||
|
||||
<hr style="margin: 2rem 0; border: none; border-top: 1px solid var(--glass-border);">
|
||||
|
||||
<h3 style="margin-bottom: 1rem;">💰 Budget Settings</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="monthly_budget">{{ _('budget.monthly_limit') }} ({{ _('common.optional') }})</label>
|
||||
<input type="number" id="monthly_budget" name="monthly_budget" step="0.01" min="0"
|
||||
value="{{ category.monthly_budget if category.monthly_budget else '' }}"
|
||||
placeholder="e.g., 500">
|
||||
<small style="color: var(--text-secondary);">{{ _('budget.monthly_limit_desc') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="budget_alert_threshold">{{ _('budget.alert_threshold') }} (%)</label>
|
||||
<input type="number" id="budget_alert_threshold" name="budget_alert_threshold"
|
||||
min="50" max="200" step="5"
|
||||
value="{{ (category.budget_alert_threshold * 100)|int if category.budget_alert_threshold else 100 }}">
|
||||
<small style="color: var(--text-secondary);">{{ _('budget.alert_threshold_desc') }}</small>
|
||||
</div>
|
||||
|
||||
{% if category.monthly_budget %}
|
||||
<div style="background: rgba(99, 102, 241, 0.1); padding: 1rem; border-radius: 8px; margin-top: 1rem;">
|
||||
{% set status = category.get_budget_status() %}
|
||||
<p style="margin: 0; font-size: 0.9rem;">
|
||||
<strong>{{ _('budget.current_month') }}:</strong><br>
|
||||
{{ _('budget.spent') }}: {{ status.spent|currency }}<br>
|
||||
{{ _('budget.budget') }}: {{ status.budget|currency }}<br>
|
||||
<span style="color: {% if status.over_budget %}#ef4444{% else %}#10b981{% endif %};">
|
||||
{{ _('budget.remaining') }}: {{ status.remaining|currency }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('main.view_category', category_id=category.id) }}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">💾 Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
408
backup/first -fina app/app/templates/edit_expense.html
Executable file
408
backup/first -fina app/app/templates/edit_expense.html
Executable file
|
|
@ -0,0 +1,408 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit Expense - Finance Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<div class="glass-card form-card">
|
||||
<h1>✏️ Edit Expense</h1>
|
||||
|
||||
<form method="POST" enctype="multipart/form-data" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description *</label>
|
||||
<input type="text" id="description" name="description" value="{{ expense.description }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="amount">Amount *</label>
|
||||
<input type="number" id="amount" name="amount" step="0.01" min="0.01" value="{{ expense.amount }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="date">Date *</label>
|
||||
<input type="date" id="date" name="date" value="{{ expense.date.strftime('%Y-%m-%d') }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="paid_by">Paid By</label>
|
||||
<input type="text" id="paid_by" name="paid_by" value="{{ expense.paid_by or '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="tags">Tags</label>
|
||||
<input type="text" id="tags" name="tags" value="{{ expense.tags or '' }}" placeholder="Type or click tags below">
|
||||
|
||||
{% if user_tags %}
|
||||
<div class="tag-suggestions">
|
||||
{% for tag in user_tags %}
|
||||
<button type="button" class="tag-btn" data-tag="{{ tag.name }}" style="background: {{ tag.color }}20; border: 1px solid {{ tag.color }}; color: {{ tag.color }};">
|
||||
🏷️ {{ tag.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<small>Click tags to add/remove them. Multiple tags can be separated by commas.</small>
|
||||
{% else %}
|
||||
<small>No tags yet. <a href="{{ url_for('settings.create_tag') }}" style="color: #a5b4fc;">Create tags in Settings</a></small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="file">{{ _('expense.receipt') }}</label>
|
||||
{% if expense.file_path %}
|
||||
<p style="color: var(--text-secondary); margin-bottom: 0.5rem;">
|
||||
Current: <a href="{{ url_for('main.download_file', expense_id=expense.id) }}" style="color: #a5b4fc;">{{ _('common.download') }}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="receipt-upload-container">
|
||||
<button type="button" class="btn-camera" onclick="openCamera()">
|
||||
📸 {{ _('ocr.take_photo') }}
|
||||
</button>
|
||||
<input type="file" id="file" name="file" accept="image/*,.pdf" capture="environment">
|
||||
<div id="ocrProcessing" class="ocr-processing" style="display: none;">
|
||||
<div class="spinner"></div>
|
||||
<span>{{ _('ocr.processing') }}</span>
|
||||
</div>
|
||||
<div id="ocrResults" class="ocr-results" style="display: none;"></div>
|
||||
</div>
|
||||
<small>{{ _('expense.receipt_hint') }} | 🤖 {{ _('ocr.ai_extraction') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('main.view_category', category_id=expense.category_id) }}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">💾 Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tag-suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tag-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.tag-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.tag-btn.active {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const tagsInput = document.getElementById('tags');
|
||||
const tagButtons = document.querySelectorAll('.tag-btn');
|
||||
|
||||
// Initialize active states based on existing tags
|
||||
const existingTags = tagsInput.value.split(',').map(t => t.trim()).filter(t => t);
|
||||
tagButtons.forEach(button => {
|
||||
const tagName = button.getAttribute('data-tag');
|
||||
if (existingTags.includes(tagName)) {
|
||||
button.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
tagButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const tagName = this.getAttribute('data-tag');
|
||||
const currentTags = tagsInput.value;
|
||||
const tagsArray = currentTags.split(',').map(t => t.trim()).filter(t => t);
|
||||
|
||||
if (tagsArray.includes(tagName)) {
|
||||
const newTags = tagsArray.filter(t => t !== tagName);
|
||||
tagsInput.value = newTags.join(', ');
|
||||
this.classList.remove('active');
|
||||
} else {
|
||||
if (currentTags.trim()) {
|
||||
tagsInput.value = currentTags.trim() + ', ' + tagName;
|
||||
} else {
|
||||
tagsInput.value = tagName;
|
||||
}
|
||||
this.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
tagsInput.addEventListener('input', function() {
|
||||
const tagsArray = this.value.split(',').map(t => t.trim()).filter(t => t);
|
||||
tagButtons.forEach(button => {
|
||||
const tagName = button.getAttribute('data-tag');
|
||||
if (tagsArray.includes(tagName)) {
|
||||
button.classList.add('active');
|
||||
} else {
|
||||
button.classList.remove('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// OCR functionality (same as create form)
|
||||
const fileInput = document.getElementById('file');
|
||||
const ocrProcessing = document.getElementById('ocrProcessing');
|
||||
const ocrResults = document.getElementById('ocrResults');
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', async function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file || !file.type.startsWith('image/')) return;
|
||||
|
||||
ocrProcessing.style.display = 'flex';
|
||||
ocrResults.style.display = 'none';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ocr/process', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
ocrProcessing.style.display = 'none';
|
||||
|
||||
if (data.success) {
|
||||
displayOCRResults(data);
|
||||
} else {
|
||||
ocrResults.innerHTML = `<div class="ocr-error">❌ ${data.error || 'OCR failed'}</div>`;
|
||||
ocrResults.style.display = 'block';
|
||||
}
|
||||
} catch (error) {
|
||||
ocrProcessing.style.display = 'none';
|
||||
ocrResults.innerHTML = `<div class="ocr-error">❌ Error: ${error.message}</div>`;
|
||||
ocrResults.style.display = 'block';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function displayOCRResults(data) {
|
||||
const confidenceClass = data.confidence === 'high' ? 'success' :
|
||||
data.confidence === 'medium' ? 'warning' : 'low';
|
||||
|
||||
let html = `<div class="ocr-result-box ${confidenceClass}">`;
|
||||
html += `<div class="ocr-header">🤖 AI Detected</div>`;
|
||||
|
||||
if (data.amount) {
|
||||
html += `<div class="ocr-field">
|
||||
<strong>Amount:</strong> ${data.amount.toFixed(2)}
|
||||
<button type="button" class="btn-apply" onclick="applyOCRAmount(${data.amount})">Use This</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (data.merchant) {
|
||||
html += `<div class="ocr-field">
|
||||
<strong>Merchant:</strong> ${data.merchant}
|
||||
<button type="button" class="btn-apply" onclick="applyOCRMerchant('${escapeHtml(data.merchant)}')">Use This</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (data.date) {
|
||||
html += `<div class="ocr-field">
|
||||
<strong>Date:</strong> ${data.date}
|
||||
<button type="button" class="btn-apply" onclick="applyOCRDate('${data.date}')">Use This</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += `<div class="ocr-confidence">${data.confidence === 'high' ? '✅' : data.confidence === 'medium' ? '⚠️' : '❌'} Confidence: ${data.confidence.toUpperCase()}</div>`;
|
||||
html += `</div>`;
|
||||
|
||||
ocrResults.innerHTML = html;
|
||||
ocrResults.style.display = 'block';
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
});
|
||||
|
||||
function openCamera() {
|
||||
const fileInput = document.getElementById('file');
|
||||
if (fileInput) fileInput.click();
|
||||
}
|
||||
|
||||
function applyOCRAmount(amount) {
|
||||
const input = document.getElementById('amount');
|
||||
if (input) {
|
||||
input.value = amount.toFixed(2);
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function applyOCRMerchant(merchant) {
|
||||
const input = document.getElementById('description');
|
||||
if (input) {
|
||||
input.value = merchant;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function applyOCRDate(date) {
|
||||
const input = document.getElementById('date');
|
||||
if (input) {
|
||||
input.value = date;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Receipt OCR styles (same as create form) */
|
||||
.receipt-upload-container {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-camera {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.btn-camera:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.ocr-processing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.ocr-results {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.ocr-result-box {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.ocr-result-box.success {
|
||||
border-color: rgba(34, 197, 94, 0.5);
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
|
||||
.ocr-result-box.warning {
|
||||
border-color: rgba(251, 191, 36, 0.5);
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
}
|
||||
|
||||
.ocr-result-box.low {
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.ocr-header {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.ocr-field {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.ocr-field:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.btn-apply {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-apply:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ocr-confidence {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ocr-error {
|
||||
padding: 0.75rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.5);
|
||||
border-radius: 8px;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.btn-camera {
|
||||
font-size: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.ocr-field {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-apply {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
30
backup/first -fina app/app/templates/login.html
Executable file
30
backup/first -fina app/app/templates/login.html
Executable file
|
|
@ -0,0 +1,30 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - Finance Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<div class="glass-card auth-card">
|
||||
<h1>{{ _('auth.welcome_back') }}</h1>
|
||||
<p class="subtitle">{{ _('auth.login') }}</p>
|
||||
|
||||
<form method="POST" class="auth-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">{{ _('auth.username') }}</label>
|
||||
<input type="text" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">{{ _('auth.password') }}</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">{{ _('auth.sign_in') }}</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-link">{{ _('auth.no_account') }} <a href="{{ url_for('auth.register') }}">{{ _('auth.sign_up') }}</a></p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
355
backup/first -fina app/app/templates/predictions.html
Normal file
355
backup/first -fina app/app/templates/predictions.html
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('predictions.title') }} - FINA{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4">
|
||||
<!-- Header -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h1 class="h3 mb-2">
|
||||
<i class="fas fa-chart-line me-2"></i>
|
||||
{{ _('predictions.title') }}
|
||||
</h1>
|
||||
<p class="text-muted">{{ _('predictions.subtitle') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if predictions.total_months < 3 %}
|
||||
<!-- Not enough data warning -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info">
|
||||
<h5 class="alert-heading">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
{{ _('predictions.no_data') }}
|
||||
</h5>
|
||||
<p class="mb-0">{{ _('predictions.no_data_desc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">{{ _('predictions.total_predicted') }}</h6>
|
||||
<h3 class="mb-0">{{ predictions.total.amount|round(2) }} RON</h3>
|
||||
<small class="text-muted">
|
||||
{{ _('predictions.based_on', n=predictions.total_months) }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">{{ _('predictions.confidence') }}</h6>
|
||||
<h3 class="mb-0">
|
||||
{% if predictions.total.confidence == 'high' %}
|
||||
<span class="badge bg-success">{{ _('predictions.confidence_high') }}</span>
|
||||
{% elif predictions.total.confidence == 'medium' %}
|
||||
<span class="badge bg-warning">{{ _('predictions.confidence_medium') }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ _('predictions.confidence_low') }}</span>
|
||||
{% endif %}
|
||||
</h3>
|
||||
<small class="text-muted">{{ predictions.total.months_of_data }} {{ _('predictions.month') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">{{ _('predictions.trend') }}</h6>
|
||||
<h3 class="mb-0">
|
||||
{% if predictions.total.trend == 'increasing' %}
|
||||
<i class="fas fa-arrow-up text-danger"></i>
|
||||
{{ _('predictions.trend_increasing') }}
|
||||
{% elif predictions.total.trend == 'decreasing' %}
|
||||
<i class="fas fa-arrow-down text-success"></i>
|
||||
{{ _('predictions.trend_decreasing') }}
|
||||
{% else %}
|
||||
<i class="fas fa-minus text-info"></i>
|
||||
{{ _('predictions.trend_stable') }}
|
||||
{% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Smart Insights -->
|
||||
{% if insights %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-lightbulb me-2"></i>
|
||||
{{ _('predictions.insights') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for insight in insights %}
|
||||
<li class="mb-2">
|
||||
<i class="fas fa-check-circle text-success me-2"></i>
|
||||
{{ insight }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Predictions Chart -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{{ _('predictions.forecast') }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="predictionsChart" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Breakdown -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{{ _('predictions.by_category') }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('common.category') }}</th>
|
||||
<th>{{ _('predictions.amount') }}</th>
|
||||
<th>{{ _('predictions.confidence') }}</th>
|
||||
<th>{{ _('predictions.trend') }}</th>
|
||||
<th>{{ _('common.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for category_name, prediction in predictions.by_category.items() %}
|
||||
<tr>
|
||||
<td>
|
||||
<i class="fas fa-tag me-2"></i>
|
||||
{{ category_name }}
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ prediction.predicted_amount|round(2) }} RON</strong>
|
||||
</td>
|
||||
<td>
|
||||
{% if prediction.confidence == 'high' %}
|
||||
<span class="badge bg-success">{{ _('predictions.confidence_high') }}</span>
|
||||
{% elif prediction.confidence == 'medium' %}
|
||||
<span class="badge bg-warning">{{ _('predictions.confidence_medium') }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ _('predictions.confidence_low') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if prediction.trend == 'increasing' %}
|
||||
<i class="fas fa-arrow-up text-danger"></i>
|
||||
{{ _('predictions.trend_increasing') }}
|
||||
{% elif prediction.trend == 'decreasing' %}
|
||||
<i class="fas fa-arrow-down text-success"></i>
|
||||
{{ _('predictions.trend_decreasing') }}
|
||||
{% else %}
|
||||
<i class="fas fa-minus text-info"></i>
|
||||
{{ _('predictions.trend_stable') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
onclick="showCategoryForecast({{ prediction.category_id }}, '{{ category_name }}')">
|
||||
{{ _('predictions.view_details') }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Methodology Info -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<h6 class="mb-2">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
{{ _('predictions.methodology') }}
|
||||
</h6>
|
||||
<p class="mb-0 text-muted small">
|
||||
{{ _('predictions.methodology_desc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Category Forecast Modal -->
|
||||
<div class="modal fade" id="categoryForecastModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="categoryForecastTitle"></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<canvas id="categoryForecastChart" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Predictions data
|
||||
const predictionsData = {{ predictions|tojson }};
|
||||
|
||||
// Main predictions chart
|
||||
const ctx = document.getElementById('predictionsChart');
|
||||
if (ctx && predictionsData.by_category) {
|
||||
const categories = Object.keys(predictionsData.by_category);
|
||||
const amounts = categories.map(cat => predictionsData.by_category[cat].predicted_amount);
|
||||
const colors = [
|
||||
'rgba(255, 99, 132, 0.7)',
|
||||
'rgba(54, 162, 235, 0.7)',
|
||||
'rgba(255, 206, 86, 0.7)',
|
||||
'rgba(75, 192, 192, 0.7)',
|
||||
'rgba(153, 102, 255, 0.7)',
|
||||
'rgba(255, 159, 64, 0.7)',
|
||||
'rgba(201, 203, 207, 0.7)'
|
||||
];
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: categories,
|
||||
datasets: [{
|
||||
label: '{{ _("predictions.total_predicted") }}',
|
||||
data: amounts,
|
||||
backgroundColor: colors.slice(0, categories.length),
|
||||
borderColor: colors.slice(0, categories.length).map(c => c.replace('0.7', '1')),
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return context.parsed.y.toFixed(2) + ' RON';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return value + ' RON';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Show category forecast
|
||||
async function showCategoryForecast(categoryId, categoryName) {
|
||||
try {
|
||||
const response = await fetch(`/api/predictions/category/${categoryId}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update modal title
|
||||
document.getElementById('categoryForecastTitle').textContent =
|
||||
'{{ _("predictions.forecast") }}: ' + categoryName;
|
||||
|
||||
// Create chart
|
||||
const modalCtx = document.getElementById('categoryForecastChart');
|
||||
|
||||
// Destroy existing chart if any
|
||||
if (window.categoryChart) {
|
||||
window.categoryChart.destroy();
|
||||
}
|
||||
|
||||
window.categoryChart = new Chart(modalCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.forecast.map(f => f.month),
|
||||
datasets: [{
|
||||
label: '{{ _("predictions.amount") }}',
|
||||
data: data.forecast.map(f => f.amount),
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.2)',
|
||||
tension: 0.1,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return context.parsed.y.toFixed(2) + ' RON';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return value + ' RON';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Show modal
|
||||
new bootstrap.Modal(document.getElementById('categoryForecastModal')).show();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching forecast:', error);
|
||||
alert('{{ _("common.error") }}');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
41
backup/first -fina app/app/templates/register.html
Executable file
41
backup/first -fina app/app/templates/register.html
Executable file
|
|
@ -0,0 +1,41 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Register - Finance Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<div class="glass-card auth-card">
|
||||
<h1>{{ _('auth.create_account') }}</h1>
|
||||
<p class="subtitle">{{ _('auth.register') }}</p>
|
||||
|
||||
<form method="POST" class="auth-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">{{ _('auth.username') }}</label>
|
||||
<input type="text" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">{{ _('auth.email') }}</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">{{ _('auth.password') }}</label>
|
||||
<input type="password" id="password" name="password" required minlength="8">
|
||||
<small>At least 8 characters</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">{{ _('auth.confirm_password') }}</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">{{ _('auth.sign_up') }}</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-link">{{ _('auth.have_account') }} <a href="{{ url_for('auth.login') }}">{{ _('auth.sign_in') }}</a></p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
592
backup/first -fina app/app/templates/search.html
Normal file
592
backup/first -fina app/app/templates/search.html
Normal file
|
|
@ -0,0 +1,592 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('search.title') }} - Finance Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="search-page">
|
||||
<div class="glass-card">
|
||||
<div class="search-header">
|
||||
<h1>🔍 {{ _('search.title') }}</h1>
|
||||
<p class="search-subtitle">{{ _('search.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Search Form -->
|
||||
<form method="GET" action="{{ url_for('main.search_page') }}" class="search-form">
|
||||
<div class="search-input-wrapper">
|
||||
<input type="text"
|
||||
name="q"
|
||||
id="searchInput"
|
||||
value="{{ query }}"
|
||||
placeholder="{{ _('search.placeholder') }}"
|
||||
autocomplete="off"
|
||||
autofocus>
|
||||
<button type="submit" class="btn-search">
|
||||
<span class="desktop-text">{{ _('search.button') }}</span>
|
||||
<span class="mobile-icon">🔍</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="searchSuggestions" class="search-suggestions"></div>
|
||||
</form>
|
||||
|
||||
{% if results %}
|
||||
<div class="search-results">
|
||||
<div class="results-summary">
|
||||
<h2>{{ _('search.results_for') }} "<strong>{{ query }}</strong>"</h2>
|
||||
<p class="results-count">{{ results.total }} {{ _('search.results_found') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Expenses Results -->
|
||||
{% if results.expenses %}
|
||||
<div class="result-section">
|
||||
<h3 class="section-title">💸 {{ _('search.expenses') }} ({{ results.expenses|length }})</h3>
|
||||
<div class="result-list">
|
||||
{% for expense in results.expenses %}
|
||||
<a href="{{ expense.url }}" class="result-item">
|
||||
<div class="result-icon" style="background: {{ expense.category_color }}20; color: {{ expense.category_color }};">
|
||||
💸
|
||||
</div>
|
||||
<div class="result-content">
|
||||
<div class="result-title">{{ expense.description }}</div>
|
||||
<div class="result-meta">
|
||||
<span class="meta-badge" style="background: {{ expense.category_color }}20; color: {{ expense.category_color }};">
|
||||
{{ expense.category_name }}
|
||||
</span>
|
||||
<span>{{ expense.date }}</span>
|
||||
{% if expense.paid_by %}
|
||||
<span>👤 {{ expense.paid_by }}</span>
|
||||
{% endif %}
|
||||
{% if expense.tags %}
|
||||
<span>🏷️ {{ expense.tags }}</span>
|
||||
{% endif %}
|
||||
{% if expense.has_receipt %}
|
||||
<span>📎</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-amount">{{ expense.amount|currency }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Categories Results -->
|
||||
{% if results.categories %}
|
||||
<div class="result-section">
|
||||
<h3 class="section-title">📁 {{ _('search.categories') }} ({{ results.categories|length }})</h3>
|
||||
<div class="result-list">
|
||||
{% for category in results.categories %}
|
||||
<a href="{{ category.url }}" class="result-item">
|
||||
<div class="result-icon" style="background: {{ category.color }}20; color: {{ category.color }};">
|
||||
📁
|
||||
</div>
|
||||
<div class="result-content">
|
||||
<div class="result-title">{{ category.name }}</div>
|
||||
<div class="result-meta">
|
||||
{% if category.description %}
|
||||
<span>{{ category.description[:50] }}{% if category.description|length > 50 %}...{% endif %}</span>
|
||||
{% endif %}
|
||||
<span>{{ category.expense_count }} {{ _('search.expenses_count') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-amount">{{ category.total_spent|currency }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Subscriptions Results -->
|
||||
{% if results.subscriptions %}
|
||||
<div class="result-section">
|
||||
<h3 class="section-title">🔄 {{ _('search.subscriptions') }} ({{ results.subscriptions|length }})</h3>
|
||||
<div class="result-list">
|
||||
{% for sub in results.subscriptions %}
|
||||
<a href="{{ sub.url }}" class="result-item">
|
||||
<div class="result-icon" style="background: #10b98120; color: #10b981;">
|
||||
🔄
|
||||
</div>
|
||||
<div class="result-content">
|
||||
<div class="result-title">{{ sub.name }}</div>
|
||||
<div class="result-meta">
|
||||
<span class="meta-badge">{{ sub.frequency }}</span>
|
||||
{% if sub.next_due %}
|
||||
<span>📅 {{ sub.next_due }}</span>
|
||||
{% endif %}
|
||||
<span>{{ sub.category_name }}</span>
|
||||
{% if not sub.is_active %}
|
||||
<span class="inactive-badge">{{ _('search.inactive') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-amount">{{ sub.amount|currency }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Tags Results -->
|
||||
{% if results.tags %}
|
||||
<div class="result-section">
|
||||
<h3 class="section-title">🏷️ {{ _('search.tags') }} ({{ results.tags|length }})</h3>
|
||||
<div class="result-list">
|
||||
{% for tag in results.tags %}
|
||||
<a href="{{ tag.url }}" class="result-item">
|
||||
<div class="result-icon" style="background: {{ tag.color }}20; color: {{ tag.color }};">
|
||||
🏷️
|
||||
</div>
|
||||
<div class="result-content">
|
||||
<div class="result-title">{{ tag.name }}</div>
|
||||
<div class="result-meta">
|
||||
<span>{{ tag.expense_count }} {{ _('search.expenses_count') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if results.total == 0 %}
|
||||
<div class="no-results">
|
||||
<div class="empty-icon">🔍</div>
|
||||
<h3>{{ _('search.no_results') }}</h3>
|
||||
<p>{{ _('search.no_results_message') }}</p>
|
||||
<ul class="search-tips">
|
||||
<li>{{ _('search.tip_spelling') }}</li>
|
||||
<li>{{ _('search.tip_keywords') }}</li>
|
||||
<li>{{ _('search.tip_date') }}</li>
|
||||
<li>{{ _('search.tip_amount') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif query %}
|
||||
<div class="no-results">
|
||||
<div class="empty-icon">🔍</div>
|
||||
<h3>{{ _('search.no_results') }}</h3>
|
||||
<p>{{ _('search.no_results_message') }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="search-welcome">
|
||||
<div class="search-icon">🔍</div>
|
||||
<h2>{{ _('search.welcome_title') }}</h2>
|
||||
<p>{{ _('search.welcome_message') }}</p>
|
||||
<div class="search-examples">
|
||||
<h4>{{ _('search.examples_title') }}</h4>
|
||||
<div class="example-chips">
|
||||
<button type="button" class="example-chip" onclick="searchFor('groceries')">🛒 groceries</button>
|
||||
<button type="button" class="example-chip" onclick="searchFor('45.99')">💰 45.99</button>
|
||||
<button type="button" class="example-chip" onclick="searchFor('2024-12-15')">📅 2024-12-15</button>
|
||||
<button type="button" class="example-chip" onclick="searchFor('netflix')">🔄 netflix</button>
|
||||
<button type="button" class="example-chip" onclick="searchFor('restaurant')">🏷️ restaurant</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.search-page {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.search-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.search-header h1 {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.search-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.search-input-wrapper input {
|
||||
flex: 1;
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-primary);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.search-input-wrapper input:focus {
|
||||
border-color: #667eea;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.btn-search {
|
||||
padding: 1rem 2rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-search:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.btn-search .mobile-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 80px;
|
||||
background: rgba(30, 30, 50, 0.98);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
margin-top: 0.5rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-suggestions.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.suggestion-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.suggestion-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.results-summary {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.results-summary h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.results-count {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.result-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.result-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.result-item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.result-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.meta-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.inactive-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.result-amount {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.no-results, .search-welcome {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
|
||||
.empty-icon, .search-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.no-results h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.search-tips {
|
||||
text-align: left;
|
||||
display: inline-block;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.search-tips li {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.search-examples {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.search-examples h4 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.example-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.example-chip {
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 24px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.example-chip:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.search-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.search-input-wrapper input {
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn-search {
|
||||
padding: 0.875rem 1rem;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.btn-search .desktop-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn-search .mobile-icon {
|
||||
display: inline;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
flex-wrap: wrap;
|
||||
padding: 0.875rem;
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.result-amount {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.search-suggestions {
|
||||
right: 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
let searchTimeout;
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const suggestionsBox = document.getElementById('searchSuggestions');
|
||||
|
||||
// Auto-suggest functionality
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
const query = this.value.trim();
|
||||
|
||||
if (query.length < 2) {
|
||||
suggestionsBox.classList.remove('active');
|
||||
suggestionsBox.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(() => {
|
||||
fetch(`/api/search/suggestions?q=${encodeURIComponent(query)}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.suggestions && data.suggestions.length > 0) {
|
||||
displaySuggestions(data.suggestions);
|
||||
} else {
|
||||
suggestionsBox.classList.remove('active');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Suggestions error:', error);
|
||||
});
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Hide suggestions when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!searchInput.contains(e.target) && !suggestionsBox.contains(e.target)) {
|
||||
suggestionsBox.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function displaySuggestions(suggestions) {
|
||||
let html = '';
|
||||
suggestions.forEach(sugg => {
|
||||
html += `<div class="suggestion-item" onclick="selectSuggestion('${escapeHtml(sugg.text)}')">`;
|
||||
html += `<span>${sugg.icon} ${escapeHtml(sugg.text)}</span>`;
|
||||
if (sugg.amount) {
|
||||
html += ` <span style="float: right; color: var(--text-secondary);">${sugg.amount.toFixed(2)}</span>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
});
|
||||
suggestionsBox.innerHTML = html;
|
||||
suggestionsBox.classList.add('active');
|
||||
}
|
||||
|
||||
function selectSuggestion(text) {
|
||||
searchInput.value = text;
|
||||
suggestionsBox.classList.remove('active');
|
||||
searchInput.form.submit();
|
||||
}
|
||||
|
||||
function searchFor(query) {
|
||||
searchInput.value = query;
|
||||
searchInput.form.submit();
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
30
backup/first -fina app/app/templates/settings/create_tag.html
Executable file
30
backup/first -fina app/app/templates/settings/create_tag.html
Executable file
|
|
@ -0,0 +1,30 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Create Tag - Finance Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<div class="glass-card form-card">
|
||||
<h1>🏷️ Create Tag</h1>
|
||||
|
||||
<form method="POST" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">Tag Name *</label>
|
||||
<input type="text" id="name" name="name" required autofocus placeholder="e.g., urgent, monthly, personal">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="color">Color</label>
|
||||
<input type="color" id="color" name="color" value="#6366f1">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('settings.index') }}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">💾 Create Tag</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
41
backup/first -fina app/app/templates/settings/create_user.html
Executable file
41
backup/first -fina app/app/templates/settings/create_user.html
Executable file
|
|
@ -0,0 +1,41 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Create User - Finance Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<div class="glass-card form-card">
|
||||
<h1>👤 Create User</h1>
|
||||
|
||||
<form method="POST" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">Username *</label>
|
||||
<input type="text" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email *</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password *</label>
|
||||
<input type="password" id="password" name="password" required minlength="6">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="is_admin"> Admin User
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('settings.index') }}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">💾 Create User</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
77
backup/first -fina app/app/templates/settings/edit_profile.html
Executable file
77
backup/first -fina app/app/templates/settings/edit_profile.html
Executable file
|
|
@ -0,0 +1,77 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit Profile - Finance Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<div class="glass-card form-card">
|
||||
<h1>✏️ {{ _('settings.edit_profile') }}</h1>
|
||||
|
||||
<form method="POST" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">{{ _('auth.username') }}</label>
|
||||
<input type="text" id="username" name="username" value="{{ current_user.username }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">{{ _('auth.email') }}</label>
|
||||
<input type="email" id="email" name="email" value="{{ current_user.email }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="currency">{{ _('settings.currency') }}</label>
|
||||
<select id="currency" name="currency" class="metric-select">
|
||||
<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 class="form-group">
|
||||
<label for="language">{{ _('settings.language') }}</label>
|
||||
<select id="language" name="language" class="metric-select">
|
||||
{% for lang in languages %}
|
||||
<option value="{{ lang.code }}" {% if current_user.language == lang.code %}selected{% endif %}>
|
||||
{{ lang.flag }} {{ lang.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="new_password">{{ _('auth.password') }} (leave blank to keep current)</label>
|
||||
<input type="password" id="new_password" name="new_password" minlength="6">
|
||||
</div>
|
||||
|
||||
<hr style="margin: 2rem 0; border: none; border-top: 1px solid var(--glass-border);">
|
||||
|
||||
<h3 style="margin-bottom: 1rem;">📧 {{ _('budget.alert_settings') }}</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<input type="checkbox" id="budget_alerts_enabled" name="budget_alerts_enabled"
|
||||
{% if current_user.budget_alerts_enabled %}checked{% endif %} style="width: auto;">
|
||||
<span>{{ _('budget.enable_alerts') }}</span>
|
||||
</label>
|
||||
<small style="color: var(--text-secondary);">{{ _('budget.enable_alerts_desc') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="alert_email">{{ _('budget.alert_email') }} ({{ _('common.optional') }})</label>
|
||||
<input type="email" id="alert_email" name="alert_email"
|
||||
value="{{ current_user.alert_email or '' }}"
|
||||
placeholder="{{ current_user.email }}">
|
||||
<small style="color: var(--text-secondary);">{{ _('budget.alert_email_desc') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('settings.index') }}" class="btn btn-secondary">{{ _('settings.cancel') }}</a>
|
||||
<button type="submit" class="btn btn-primary">💾 {{ _('settings.save') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
41
backup/first -fina app/app/templates/settings/edit_user.html
Executable file
41
backup/first -fina app/app/templates/settings/edit_user.html
Executable file
|
|
@ -0,0 +1,41 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit User - Finance Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<div class="glass-card form-card">
|
||||
<h1>✏️ Edit User: {{ user.username }}</h1>
|
||||
|
||||
<form method="POST" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">Username *</label>
|
||||
<input type="text" id="username" name="username" value="{{ user.username }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email *</label>
|
||||
<input type="email" id="email" name="email" value="{{ user.email }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="new_password">New Password (leave blank to keep current)</label>
|
||||
<input type="password" id="new_password" name="new_password" minlength="6">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="is_admin" {% if user.is_admin %}checked{% endif %}> Admin User
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('settings.index') }}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">💾 Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
218
backup/first -fina app/app/templates/settings/index.html
Executable file
218
backup/first -fina app/app/templates/settings/index.html
Executable file
|
|
@ -0,0 +1,218 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Settings - Finance Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="settings-container">
|
||||
<h1>⚙️ {{ _('settings.title') }}</h1>
|
||||
|
||||
<div class="settings-tabs">
|
||||
<button class="tab-btn active" onclick="openTab(event, 'profile')">{{ _('settings.profile') }}</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'security')">{{ _('settings.security') }}</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tags')">{{ _('settings.tags') }}</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'import-export')">{{ _('settings.import_export') }}</button>
|
||||
{% if current_user.is_admin %}
|
||||
<button class="tab-btn" onclick="openTab(event, 'users')">{{ _('settings.users') }}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Profile Tab -->
|
||||
<div id="profile" class="tab-content active">
|
||||
<div class="glass-card">
|
||||
<h2>👤 {{ _('settings.profile_settings') }}</h2>
|
||||
<form method="POST" action="{{ url_for('settings.edit_profile') }}" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">{{ _('settings.username') }}</label>
|
||||
<input type="text" id="username" name="username" value="{{ current_user.username }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">{{ _('settings.email') }}</label>
|
||||
<input type="email" id="email" name="email" value="{{ current_user.email }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="currency">{{ _('settings.currency') }}</label>
|
||||
<select id="currency" name="currency" class="metric-select">
|
||||
<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 class="form-group">
|
||||
<label for="new_password">{{ _('settings.new_password') }}</label>
|
||||
<input type="password" id="new_password" name="new_password" placeholder="{{ _('settings.new_password_placeholder') }}">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">💾 {{ _('settings.save') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security Tab (2FA) -->
|
||||
<div id="security" class="tab-content">
|
||||
<div class="glass-card">
|
||||
<h2>🔐 {{ _('settings.2fa_title') }}</h2>
|
||||
|
||||
{% if current_user.is_2fa_enabled %}
|
||||
<div style="padding: 1.5rem; background: rgba(16, 185, 129, 0.1); border: 1px solid var(--success); border-radius: 10px; margin-bottom: 1.5rem;">
|
||||
<p style="color: var(--success); font-weight: 600; margin-bottom: 0.5rem;">✅ {{ _('settings.2fa_enabled') }}</p>
|
||||
<p style="color: var(--text-secondary); margin: 0;">{{ _('settings.2fa_enabled_desc') }}</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('settings.disable_2fa') }}" onsubmit="return confirm('{{ _('settings.2fa_disable_confirm') }}');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger">🔓 {{ _('settings.disable_2fa') }}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div style="padding: 1.5rem; background: rgba(239, 68, 68, 0.1); border: 1px solid var(--danger); border-radius: 10px; margin-bottom: 1.5rem;">
|
||||
<p style="color: var(--danger); font-weight: 600; margin-bottom: 0.5rem;">⚠️ {{ _('settings.2fa_disabled') }}</p>
|
||||
<p style="color: var(--text-secondary); margin: 0;">{{ _('settings.2fa_disabled_desc') }}</p>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('settings.setup_2fa') }}" class="btn btn-primary">🔒 {{ _('settings.enable_2fa') }}</a>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-top: 2rem; padding: 1rem; background: rgba(99, 102, 241, 0.1); border-radius: 10px;">
|
||||
<h3 style="margin-top: 0;">{{ _('settings.2fa_what_is') }}</h3>
|
||||
<p style="color: var(--text-secondary); margin: 0;">
|
||||
{{ _('settings.2fa_what_is_desc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags Tab -->
|
||||
<div id="tags" class="tab-content">
|
||||
<div class="glass-card">
|
||||
<div class="section-header">
|
||||
<h2>🏷️ {{ _('settings.manage_tags') }}</h2>
|
||||
<a href="{{ url_for('settings.create_tag') }}" class="btn btn-primary">+ {{ _('settings.create_tag_btn') }}</a>
|
||||
</div>
|
||||
|
||||
{% if tags %}
|
||||
<div class="tags-grid">
|
||||
{% for tag in tags %}
|
||||
<div class="tag-item glass-card" style="border-left: 4px solid {{ tag.color }}">
|
||||
<span class="tag-name">{{ tag.name }}</span>
|
||||
<form method="POST" action="{{ url_for('settings.delete_tag', tag_id=tag.id) }}" style="display: inline;" onsubmit="return confirm('{{ _('settings.delete_tag_confirm').replace('{name}', tag.name) }}');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-small btn-danger">{{ _('common.delete') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-message">{{ _('empty.no_tags_message') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import/Export Tab -->
|
||||
<div id="import-export" class="tab-content">
|
||||
<div class="glass-card" style="margin-bottom: 2rem;">
|
||||
<h2>📤 {{ _('settings.export_title') }}</h2>
|
||||
<p style="margin-bottom: 1rem;">{{ _('settings.export_desc') }}</p>
|
||||
<a href="{{ url_for('settings.export_data') }}" class="btn btn-primary">⬇️ {{ _('settings.export_btn') }}</a>
|
||||
</div>
|
||||
|
||||
<div class="glass-card">
|
||||
<h2>📥 {{ _('settings.import_title') }}</h2>
|
||||
<p style="margin-bottom: 1rem;">{{ _('settings.import_desc') }}</p>
|
||||
<form method="POST" action="{{ url_for('settings.import_data') }}" enctype="multipart/form-data" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="file">{{ _('settings.import_file_label') }}</label>
|
||||
<input type="file" id="file" name="file" accept=".csv" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">⬆️ {{ _('settings.import_btn') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Management Tab (Admin Only) -->
|
||||
{% if current_user.is_admin %}
|
||||
<div id="users" class="tab-content">
|
||||
<div class="glass-card">
|
||||
<div class="section-header">
|
||||
<h2>👥 {{ _('settings.users_title') }}</h2>
|
||||
<a href="{{ url_for('settings.create_user') }}" class="btn btn-primary">+ {{ _('settings.create_user_btn') }}</a>
|
||||
</div>
|
||||
|
||||
{% if users %}
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('settings.table_username') }}</th>
|
||||
<th>{{ _('settings.table_email') }}</th>
|
||||
<th>{{ _('settings.table_role') }}</th>
|
||||
<th>{{ _('settings.table_currency') }}</th>
|
||||
<th>{{ _('settings.table_2fa') }}</th>
|
||||
<th>{{ _('settings.table_actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user.username }}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>
|
||||
{% if user.is_admin %}
|
||||
<span style="color: #fbbf24;">⭐ {{ _('settings.role_admin') }}</span>
|
||||
{% else %}
|
||||
<span>{{ _('settings.role_user') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.currency }}</td>
|
||||
<td>
|
||||
{% if user.is_2fa_enabled %}
|
||||
<span style="color: var(--success);">✅ {{ _('settings.2fa_status_enabled') }}</span>
|
||||
{% else %}
|
||||
<span style="color: var(--text-secondary);">❌ {{ _('settings.2fa_status_disabled') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('settings.edit_user', user_id=user.id) }}" class="btn btn-small btn-secondary">{{ _('common.edit') }}</a>
|
||||
{% if user.id != current_user.id %}
|
||||
<form method="POST" action="{{ url_for('settings.delete_user', user_id=user.id) }}" style="display: inline;" onsubmit="return confirm('{{ _('settings.delete_user_confirm').replace('{name}', user.username) }}');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-small btn-danger">{{ _('common.delete') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-message">{{ _('settings.no_users') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openTab(evt, tabName) {
|
||||
var i, tabcontent, tablinks;
|
||||
tabcontent = document.getElementsByClassName("tab-content");
|
||||
for (i = 0; i < tabcontent.length; i++) {
|
||||
tabcontent[i].classList.remove("active");
|
||||
}
|
||||
tablinks = document.getElementsByClassName("tab-btn");
|
||||
for (i = 0; i < tablinks.length; i++) {
|
||||
tablinks[i].classList.remove("active");
|
||||
}
|
||||
document.getElementById(tabName).classList.add("active");
|
||||
evt.currentTarget.classList.add("active");
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
78
backup/first -fina app/app/templates/settings/setup_2fa.html
Executable file
78
backup/first -fina app/app/templates/settings/setup_2fa.html
Executable file
|
|
@ -0,0 +1,78 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Setup 2FA - Finance Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<div class="glass-card form-card">
|
||||
<h1>🔐 Setup Two-Factor Authentication</h1>
|
||||
|
||||
<div style="margin: 2rem 0;">
|
||||
<h3>Step 1: Scan QR Code</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 1rem;">
|
||||
Scan this QR code with your authenticator app (Google Authenticator, Authy, Microsoft Authenticator, etc.)
|
||||
</p>
|
||||
|
||||
{% if qr_code %}
|
||||
<div style="text-align: center; padding: 2rem; background: white; border-radius: 15px; margin: 1rem 0;">
|
||||
<img src="data:image/png;base64,{{ qr_code }}" alt="2FA QR Code" style="max-width: 300px; display: block; margin: 0 auto;">
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="padding: 2rem; background: rgba(239, 68, 68, 0.1); border: 1px solid var(--danger); border-radius: 15px; margin: 1rem 0;">
|
||||
<p style="color: var(--danger); margin: 0;">❌ QR code generation failed. Please use manual entry below.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<details style="margin-top: 1rem;">
|
||||
<summary style="cursor: pointer; color: var(--text-secondary); font-weight: 500;">Can't scan? Enter manually</summary>
|
||||
<div style="margin-top: 1rem; padding: 1.5rem; background: rgba(255, 255, 255, 0.05); border-radius: 10px;">
|
||||
<p style="margin-bottom: 0.5rem;"><strong>Secret Key:</strong></p>
|
||||
<code style="font-size: 1.2rem; color: #10b981; word-break: break-all; display: block; padding: 1rem; background: rgba(0, 0, 0, 0.2); border-radius: 8px;">{{ secret }}</code>
|
||||
<p style="margin-top: 1rem; color: var(--text-secondary); font-size: 0.9rem;">
|
||||
Enter this key manually in your authenticator app under "Enter a setup key" or "Manual entry"
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div style="margin: 2rem 0;">
|
||||
<h3>Step 2: Verify Code</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 1rem;">
|
||||
Enter the 6-digit code from your authenticator app to complete setup
|
||||
</p>
|
||||
|
||||
<form method="POST" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="token">Authentication Code *</label>
|
||||
<input type="text" id="token" name="token" required autofocus
|
||||
pattern="[0-9]{6}" maxlength="6" placeholder="000000"
|
||||
style="font-size: 2rem; text-align: center; letter-spacing: 0.8rem; font-weight: 600;">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('settings.index') }}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">✅ Enable 2FA</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 2rem; padding: 1.5rem; background: rgba(99, 102, 241, 0.1); border: 1px solid var(--primary); border-radius: 10px;">
|
||||
<p style="margin: 0;">
|
||||
<strong>💡 Important:</strong> Save your secret key in a secure location. You'll need it if you lose access to your authenticator app.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
details summary:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
input[type="text"]#token {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
34
backup/first -fina app/app/templates/setup_2fa.html
Executable file
34
backup/first -fina app/app/templates/setup_2fa.html
Executable file
|
|
@ -0,0 +1,34 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Setup 2FA - Finance Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<div class="glass-card auth-card">
|
||||
<h1>🔐 Setup Two-Factor Authentication</h1>
|
||||
<p class="subtitle">Scan this QR code with your authenticator app</p>
|
||||
|
||||
<div class="qr-container">
|
||||
<img src="data:image/png;base64,{{ qr_code }}" alt="QR Code" class="qr-code">
|
||||
</div>
|
||||
|
||||
<div class="secret-container">
|
||||
<p><strong>Manual Entry Key:</strong></p>
|
||||
<code class="secret-key">{{ secret }}</code>
|
||||
</div>
|
||||
|
||||
<p class="info-text">Use Google Authenticator, Authy, or any TOTP app</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.verify_2fa') }}" class="auth-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="token">Enter 6-digit code</label>
|
||||
<input type="text" id="token" name="token" required pattern="[0-9]{6}" maxlength="6" autofocus>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Verify & Complete Setup</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
105
backup/first -fina app/app/templates/subscriptions/create.html
Normal file
105
backup/first -fina app/app/templates/subscriptions/create.html
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('subscription.add') }} - FINA{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<div class="glass-card form-card">
|
||||
<h1>➕ {{ _('subscription.add') }}</h1>
|
||||
|
||||
<form method="POST" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">{{ _('subscription.name') }}</label>
|
||||
<input type="text" id="name" name="name" required placeholder="Netflix, Spotify, etc.">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="amount">{{ _('expense.amount') }}</label>
|
||||
<input type="number" id="amount" name="amount" step="0.01" min="0" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="frequency">{{ _('subscription.frequency') }}</label>
|
||||
<select id="frequency" name="frequency" required onchange="toggleCustomInterval()">
|
||||
<option value="weekly">{{ _('subscription.freq_weekly') }}</option>
|
||||
<option value="biweekly">{{ _('subscription.freq_biweekly') }}</option>
|
||||
<option value="monthly" selected>{{ _('subscription.freq_monthly') }}</option>
|
||||
<option value="quarterly">{{ _('subscription.freq_quarterly') }}</option>
|
||||
<option value="yearly">{{ _('subscription.freq_yearly') }}</option>
|
||||
<option value="custom">{{ _('subscription.freq_custom') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="custom-interval-group" style="display: none;">
|
||||
<label for="custom_interval_days">{{ _('subscription.custom_interval') }}</label>
|
||||
<input type="number" id="custom_interval_days" name="custom_interval_days" min="1" placeholder="e.g., 45 days">
|
||||
<small style="color: var(--text-secondary);">{{ _('subscription.custom_interval_desc') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="category_id">{{ _('category.name') }}</label>
|
||||
<select id="category_id" name="category_id" required>
|
||||
<option value="">{{ _('common.select') }}</option>
|
||||
{% for category in categories %}
|
||||
<option value="{{ category.id }}">{{ category.icon }} {{ category.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="start_date">{{ _('subscription.start_date') }}</label>
|
||||
<input type="date" id="start_date" name="start_date" value="{{ today }}" required>
|
||||
<small style="color: var(--text-secondary);">{{ _('subscription.start_date_desc') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="end_date">{{ _('subscription.end_date') }} ({{ _('common.optional') }})</label>
|
||||
<input type="date" id="end_date" name="end_date">
|
||||
<small style="color: var(--text-secondary);">{{ _('subscription.end_date_desc') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="total_occurrences">{{ _('subscription.total_occurrences') }} ({{ _('common.optional') }})</label>
|
||||
<input type="number" id="total_occurrences" name="total_occurrences" min="1" placeholder="e.g., 12">
|
||||
<small style="color: var(--text-secondary);">{{ _('subscription.total_occurrences_desc') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<input type="checkbox" id="auto_create_expense" name="auto_create_expense" style="width: auto;">
|
||||
<span>{{ _('subscription.auto_create') }}</span>
|
||||
</label>
|
||||
<small style="color: var(--text-secondary);">{{ _('subscription.auto_create_desc') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">{{ _('subscription.notes') }}</label>
|
||||
<textarea id="notes" name="notes" rows="3" placeholder="{{ _('subscription.notes_placeholder') }}"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('subscriptions.index') }}" class="btn btn-secondary">{{ _('common.cancel') }}</a>
|
||||
<button type="submit" class="btn btn-primary">{{ _('common.save') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleCustomInterval() {
|
||||
const frequency = document.getElementById('frequency').value;
|
||||
const customGroup = document.getElementById('custom-interval-group');
|
||||
const customInput = document.getElementById('custom_interval_days');
|
||||
|
||||
if (frequency === 'custom') {
|
||||
customGroup.style.display = 'block';
|
||||
customInput.required = true;
|
||||
} else {
|
||||
customGroup.style.display = 'none';
|
||||
customInput.required = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
101
backup/first -fina app/app/templates/subscriptions/edit.html
Normal file
101
backup/first -fina app/app/templates/subscriptions/edit.html
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('common.edit') }} {{ subscription.name }} - FINA{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<div class="glass-card form-card">
|
||||
<h1>✏️ {{ _('common.edit') }} {{ _('subscription.title') }}</h1>
|
||||
|
||||
<form method="POST" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">{{ _('subscription.name') }}</label>
|
||||
<input type="text" id="name" name="name" value="{{ subscription.name }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="amount">{{ _('expense.amount') }}</label>
|
||||
<input type="number" id="amount" name="amount" step="0.01" min="0" value="{{ subscription.amount }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="frequency">{{ _('subscription.frequency') }}</label>
|
||||
<select id="frequency" name="frequency" required onchange="toggleCustomInterval()">
|
||||
<option value="weekly" {% if subscription.frequency == 'weekly' %}selected{% endif %}>{{ _('subscription.freq_weekly') }}</option>
|
||||
<option value="biweekly" {% if subscription.frequency == 'biweekly' %}selected{% endif %}>{{ _('subscription.freq_biweekly') }}</option>
|
||||
<option value="monthly" {% if subscription.frequency == 'monthly' %}selected{% endif %}>{{ _('subscription.freq_monthly') }}</option>
|
||||
<option value="quarterly" {% if subscription.frequency == 'quarterly' %}selected{% endif %}>{{ _('subscription.freq_quarterly') }}</option>
|
||||
<option value="yearly" {% if subscription.frequency == 'yearly' %}selected{% endif %}>{{ _('subscription.freq_yearly') }}</option>
|
||||
<option value="custom" {% if subscription.frequency == 'custom' %}selected{% endif %}>{{ _('subscription.freq_custom') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="custom-interval-group" style="display: {% if subscription.frequency == 'custom' %}block{% else %}none{% endif %};">
|
||||
<label for="custom_interval_days">{{ _('subscription.custom_interval') }}</label>
|
||||
<input type="number" id="custom_interval_days" name="custom_interval_days" min="1" value="{{ subscription.custom_interval_days or '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="category_id">{{ _('category.name') }}</label>
|
||||
<select id="category_id" name="category_id" required>
|
||||
{% for category in categories %}
|
||||
<option value="{{ category.id }}" {% if subscription.category_id == category.id %}selected{% endif %}>{{ category.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="formend_date">{{ _('subscription.end_date') }} ({{ _('common.optional') }})</label>
|
||||
<input type="date" id="end_date" name="end_date" value="{{ subscription.end_date.strftime('%Y-%m-%d') if subscription.end_date else '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="total_occurrences">{{ _('subscription.total_occurrences') }} ({{ _('common.optional') }})</label>
|
||||
<input type="number" id="total_occurrences" name="total_occurrences" min="1" value="{{ subscription.total_occurrences or '' }}">
|
||||
<small style="color: var(--text-secondary);">{{ _('subscription.occurrences_remaining') }}: {{ (subscription.total_occurrences - subscription.occurrences_count) if subscription.total_occurrences else '∞' }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<input type="checkbox" id="auto_create_expense" name="auto_create_expense" {% if subscription.auto_create_expense %}checked{% endif %} style="width: auto;">
|
||||
<span>{{ _('subscription.auto_create') }}</span>
|
||||
</label>
|
||||
<small style="color: var(--text-secondary);">{{ _('subscription.auto_create_desc') }}</small>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleCustomInterval() {
|
||||
const frequency = document.getElementById('frequency').value;
|
||||
const customGroup = document.getElementById('custom-interval-group');
|
||||
const customInput = document.getElementById('custom_interval_days');
|
||||
|
||||
if (frequency === 'custom') {
|
||||
customGroup.style.display = 'block';
|
||||
customInput.required = true;
|
||||
} else {
|
||||
customGroup.style.display = 'none';
|
||||
customInput.required = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="-group">
|
||||
<label for="next_due_date">{{ _('subscription.next_payment') }}</label>
|
||||
<input type="date" id="next_due_date" name="next_due_date" value="{{ subscription.next_due_date.strftime('%Y-%m-%d') if subscription.next_due_date else '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">{{ _('subscription.notes') }}</label>
|
||||
<textarea id="notes" name="notes" rows="3">{{ subscription.notes or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('subscriptions.index') }}" class="btn btn-secondary">{{ _('common.cancel') }}</a>
|
||||
<button type="submit" class="btn btn-primary">{{ _('common.save') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
207
backup/first -fina app/app/templates/subscriptions/index.html
Normal file
207
backup/first -fina app/app/templates/subscriptions/index.html
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('subscription.title') }} - FINA{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="subscriptions-page">
|
||||
<div class="page-header">
|
||||
<h1>🔄 {{ _('subscription.title') }}</h1>
|
||||
<div class="header-actions">
|
||||
<form method="POST" action="{{ url_for('subscriptions.auto_create_expenses') }}" style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-secondary" title="{{ _('subscription.auto_create_tooltip') }}">⚡ {{ _('subscription.create_due') }}</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('subscriptions.detect') }}" style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-secondary">🔍 {{ _('subscription.detect') }}</button>
|
||||
</form>
|
||||
<a href="{{ url_for('subscriptions.create') }}" class="btn btn-primary">➕ {{ _('subscription.add') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="stats-container" style="margin-bottom: 2rem;">
|
||||
<div class="glass-card stat-card">
|
||||
<h3>{{ _('subscription.active') }}</h3>
|
||||
<p class="stat-value">{{ subscriptions|length }}</p>
|
||||
</div>
|
||||
|
||||
<div class="glass-card stat-card">
|
||||
<h3>{{ _('subscription.monthly_cost') }}</h3>
|
||||
<p class="stat-value">{{ monthly_cost|currency }}</p>
|
||||
</div>
|
||||
|
||||
<div class="glass-card stat-card">
|
||||
<h3>{{ _('subscription.yearly_cost') }}</h3>
|
||||
<p class="stat-value">{{ yearly_cost|currency }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Suggestions -->
|
||||
{% if suggestions %}
|
||||
<div class="glass-card suggestions-section" style="margin-bottom: 2rem;">
|
||||
<h2>💡 {{ _('subscription.suggestions') }}</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 1rem;">
|
||||
{{ _('subscription.suggestions_desc') }}
|
||||
</p>
|
||||
|
||||
{% for suggestion in suggestions %}
|
||||
<div class="suggestion-card glass-card" style="margin-bottom: 1rem; padding: 1rem; border-left: 3px solid #f59e0b;">
|
||||
<div class="suggestion-content">
|
||||
<div class="suggestion-header">
|
||||
<h3>{{ suggestion.suggested_name }}</h3>
|
||||
<span class="confidence-badge" style="background: rgba(245, 158, 11, 0.2); padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.85rem;">
|
||||
{{ suggestion.confidence_score|round(0)|int }}% {{ _('subscription.confidence') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="suggestion-details" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin: 1rem 0;">
|
||||
<div>
|
||||
<small style="color: var(--text-secondary);">{{ _('expense.amount') }}</small>
|
||||
<p style="font-weight: 600;">{{ suggestion.average_amount|currency }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<small style="color: var(--text-secondary);">{{ _('subscription.frequency') }}</small>
|
||||
<p style="font-weight: 600;">{{ _(('subscription.freq_' + suggestion.detected_frequency)) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<small style="color: var(--text-secondary);">{{ _('subscription.occurrences') }}</small>
|
||||
<p style="font-weight: 600;">{{ suggestion.occurrence_count }} {{ _('subscription.times') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<small style="color: var(--text-secondary);">{{ _('subscription.period') }}</small>
|
||||
<p style="font-weight: 600;">{{ suggestion.first_occurrence.strftime('%b %Y') }} - {{ suggestion.last_occurrence.strftime('%b %Y') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="suggestion-actions" style="display: flex; gap: 0.5rem;">
|
||||
<form method="POST" action="{{ url_for('subscriptions.accept_suggestion', pattern_id=suggestion.id) }}" style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-primary btn-sm">✅ {{ _('subscription.accept') }}</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('subscriptions.dismiss_suggestion', pattern_id=suggestion.id) }}" style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-secondary btn-sm">❌ {{ _('subscription.dismiss') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Active Subscriptions -->
|
||||
{% if subscriptions %}
|
||||
<div class="glass-card">
|
||||
<h2>{{ _('subscription.active_list') }}</h2>
|
||||
|
||||
<div class="subscriptions-list">
|
||||
{% for sub in subscriptions %}
|
||||
<div class="subscription-item" style="display: flex; justify-content: space-between; align-items: center; padding: 1rem; border-bottom: 1px solid var(--glass-border);">
|
||||
<div class="subscription-info" style="flex: 1;">
|
||||
<h3 style="margin: 0;">
|
||||
{{ sub.name }}
|
||||
{% if sub.auto_create_expense %}
|
||||
<span style="background: rgba(34, 197, 94, 0.2); color: #4ade80; padding: 0.2rem 0.5rem; border-radius: 5px; font-size: 0.75rem; margin-left: 0.5rem;" title="{{ _('subscription.auto_create_tooltip') }}">⚡ {{ _('subscription.auto') }}</span>
|
||||
{% endif %}
|
||||
</h3>
|
||||
<div style="display: flex; gap: 2rem; margin-top: 0.5rem; color: var(--text-secondary); font-size: 0.9rem; flex-wrap: wrap;">
|
||||
<span>💰 {{ sub.amount|currency }} /
|
||||
{% if sub.frequency == 'custom' %}
|
||||
{{ _('subscription.every') }} {{ sub.custom_interval_days }} {{ _('subscription.days') }}
|
||||
{% else %}
|
||||
{{ _(('subscription.freq_' + sub.frequency)) }}
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if sub.next_due_date %}
|
||||
<span>📅 {{ _('subscription.next_payment') }}: {{ sub.next_due_date.strftime('%b %d, %Y') }}</span>
|
||||
{% endif %}
|
||||
<span>📊 {{ _('subscription.annual') }}: {{ sub.get_annual_cost()|currency }}</span>
|
||||
{% if sub.total_occurrences %}
|
||||
<span>🔢 {{ sub.occurrences_count }}/{{ sub.total_occurrences }} {{ _('subscription.times') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if sub.notes %}
|
||||
<p style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--text-secondary);">{{ sub.notes }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="subscription-actions" style="display: flex; gap: 0.5rem;">
|
||||
<a href="{{ url_for('subscriptions.edit', subscription_id=sub.id) }}" class="btn btn-secondary btn-sm">{{ _('common.edit') }}</a>
|
||||
<form method="POST" action="{{ url_for('subscriptions.toggle', subscription_id=sub.id) }}" style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-secondary btn-sm">
|
||||
{% if sub.is_active %}⏸️{% else %}▶️{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('subscriptions.delete', subscription_id=sub.id) }}" onsubmit="return confirm('{{ _('subscription.delete_confirm') }}');" style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-secondary btn-sm">🗑️</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="glass-card empty-state">
|
||||
<h2>{{ _('subscription.no_subscriptions') }}</h2>
|
||||
<p>{{ _('subscription.no_subscriptions_desc') }}</p>
|
||||
<div style="display: flex; gap: 1rem; justify-content: center; margin-top: 1rem;">
|
||||
<form method="POST" action="{{ url_for('subscriptions.detect') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-primary">🔍 {{ _('subscription.detect') }}</button>
|
||||
</form>
|
||||
<a href="{{ url_for('subscriptions.create') }}" class="btn btn-secondary">➕ {{ _('subscription.add_manual') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.subscriptions-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.subscription-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.subscription-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.subscription-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
25
backup/first -fina app/app/templates/verify_login.html
Executable file
25
backup/first -fina app/app/templates/verify_login.html
Executable file
|
|
@ -0,0 +1,25 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Verify Login - Finance Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<div class="glass-card auth-card">
|
||||
<h1>🔐 Two-Factor Authentication</h1>
|
||||
<p class="subtitle">Enter the code from your authenticator app</p>
|
||||
|
||||
<form method="POST" class="auth-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="token">6-digit code</label>
|
||||
<input type="text" id="token" name="token" required pattern="[0-9]{6}" maxlength="6" autofocus>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Verify</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-link"><a href="{{ url_for('auth.login') }}">Back to login</a></p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
193
backup/first -fina app/app/templates/view_category.html
Executable file
193
backup/first -fina app/app/templates/view_category.html
Executable file
|
|
@ -0,0 +1,193 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ category.name }} - Finance Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="category-view">
|
||||
<div class="category-header glass-card" style="border-left: 4px solid {{ category.color }}">
|
||||
<div>
|
||||
<h1>{{ category.name }}</h1>
|
||||
{% if category.description %}
|
||||
<p class="category-description">{{ category.description }}</p>
|
||||
{% endif %}
|
||||
<p class="category-total">Total: <strong>{{ total_spent|currency }}</strong></p>
|
||||
</div>
|
||||
<div class="category-actions">
|
||||
<a href="{{ url_for('main.create_expense', category_id=category.id) }}" class="btn btn-primary">+ Add Expense</a>
|
||||
<a href="{{ url_for('main.edit_category', category_id=category.id) }}" class="btn btn-secondary">Edit</a>
|
||||
<form method="POST" action="{{ url_for('main.delete_category', category_id=category.id) }}" style="display: inline;" onsubmit="return confirm('Delete category and all expenses?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if expenses %}
|
||||
<div class="glass-card">
|
||||
<h2>Expenses</h2>
|
||||
<table class="expenses-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Description</th>
|
||||
<th>Amount</th>
|
||||
<th>Paid By</th>
|
||||
<th>Tags</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for expense in expenses %}
|
||||
<tr>
|
||||
<td>{{ expense.date.strftime('%Y-%m-%d') }}</td>
|
||||
<td>{{ expense.description }}</td>
|
||||
<td><strong>{{ expense.amount|currency }}</strong></td>
|
||||
<td>{{ expense.paid_by or '-' }}</td>
|
||||
<td>{{ expense.tags or '-' }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('main.edit_expense', expense_id=expense.id) }}" class="btn btn-small btn-secondary">Edit</a>
|
||||
{% if expense.file_path %}
|
||||
<button onclick="viewAttachment('{{ url_for('main.view_file', expense_id=expense.id) }}', '{{ expense.file_path }}')" class="btn btn-small btn-secondary">👁️ View</button>
|
||||
<a href="{{ url_for('main.download_file', expense_id=expense.id) }}" class="btn btn-small btn-secondary">⬇️</a>
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('main.delete_expense', expense_id=expense.id) }}" style="display: inline;" onsubmit="return confirm('Delete this expense?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-small btn-danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="glass-card empty-state">
|
||||
<h2>{{ _('empty.no_expenses_title') }}</h2>
|
||||
<p>{{ _('empty.no_expenses_message') }}</p>
|
||||
<a href="{{ url_for('main.create_expense', category_id=category.id) }}" class="btn btn-primary">+ {{ _('empty.add_expense') }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Attachment Preview Modal -->
|
||||
<div id="attachmentModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeModal()">×</span>
|
||||
<div id="attachmentViewer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.category-view { max-width: 1200px; margin: 0 auto; }
|
||||
.category-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 2rem; flex-wrap: wrap; gap: 1rem; }
|
||||
.category-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.category-total { font-size: 1.2rem; margin-top: 1rem; }
|
||||
.expenses-table { width: 100%; border-collapse: collapse; margin-top: 1.5rem; }
|
||||
.expenses-table th, .expenses-table td { padding: 1rem; text-align: left; border-bottom: 1px solid var(--glass-border); }
|
||||
.expenses-table th { font-weight: 600; color: var(--text-secondary); }
|
||||
.expenses-table tr:hover { background: rgba(255, 255, 255, 0.05); }
|
||||
|
||||
/* Modal Styles */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
animation: fadeIn 0.3s;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
margin: 2% auto;
|
||||
padding: 2rem;
|
||||
width: 90%;
|
||||
max-width: 1200px;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
background: rgba(59, 7, 100, 0.95);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1.5rem;
|
||||
color: #fff;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
#attachmentViewer img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
#attachmentViewer iframe {
|
||||
width: 100%;
|
||||
height: 80vh;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function viewAttachment(url, filePath) {
|
||||
const modal = document.getElementById('attachmentModal');
|
||||
const viewer = document.getElementById('attachmentViewer');
|
||||
|
||||
// Determine file type
|
||||
const fileExt = filePath.split('.').pop().toLowerCase();
|
||||
|
||||
if (['jpg', 'jpeg', 'png', 'gif'].includes(fileExt)) {
|
||||
// Image preview
|
||||
viewer.innerHTML = '<img src="' + url + '" alt="Attachment">';
|
||||
} else if (fileExt === 'pdf') {
|
||||
// PDF viewer
|
||||
viewer.innerHTML = '<iframe src="' + url + '"></iframe>';
|
||||
} else {
|
||||
// Fallback for other files
|
||||
viewer.innerHTML = '<p style="text-align: center; padding: 2rem;">Preview not available. <a href="' + url + '" download class="btn btn-primary">Download File</a></p>';
|
||||
}
|
||||
|
||||
modal.style.display = 'block';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const modal = document.getElementById('attachmentModal');
|
||||
modal.style.display = 'none';
|
||||
document.getElementById('attachmentViewer').innerHTML = '';
|
||||
}
|
||||
|
||||
// Close modal on click outside
|
||||
window.onclick = function(event) {
|
||||
const modal = document.getElementById('attachmentModal');
|
||||
if (event.target == modal) {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal on Escape key
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
1169
backup/first -fina app/app/translations.py
Normal file
1169
backup/first -fina app/app/translations.py
Normal file
File diff suppressed because it is too large
Load diff
22
backup/first -fina app/app/utils.py
Executable file
22
backup/first -fina app/app/utils.py
Executable file
|
|
@ -0,0 +1,22 @@
|
|||
def get_currency_symbol(currency_code):
|
||||
"""Get currency symbol for display"""
|
||||
symbols = {
|
||||
'USD': '$',
|
||||
'EUR': '€',
|
||||
'RON': 'Lei',
|
||||
'GBP': '£'
|
||||
}
|
||||
return symbols.get(currency_code, '$')
|
||||
|
||||
def format_currency(amount, currency_code='USD'):
|
||||
"""Format amount with currency symbol"""
|
||||
symbol = get_currency_symbol(currency_code)
|
||||
|
||||
# Format number with 2 decimals
|
||||
formatted_amount = f"{amount:,.2f}"
|
||||
|
||||
# Position symbol based on currency
|
||||
if currency_code == 'RON':
|
||||
return f"{formatted_amount} {symbol}" # Romanian Leu after amount
|
||||
else:
|
||||
return f"{symbol}{formatted_amount}" # Symbol before amount
|
||||
Loading…
Add table
Add a link
Reference in a new issue