05/31 Phase 1: initial codes
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import os
|
||||
from flask import Flask
|
||||
from app.config import config
|
||||
from app.extensions import db, login_manager, migrate, csrf
|
||||
from app.utils.formatters import format_currency, format_percent, format_large_number
|
||||
|
||||
|
||||
def create_app(config_name=None):
|
||||
if config_name is None:
|
||||
config_name = os.environ.get('FLASK_ENV', 'development')
|
||||
if config_name == 'production':
|
||||
config_name = 'production'
|
||||
else:
|
||||
config_name = 'development'
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config[config_name])
|
||||
|
||||
# Init extensions
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
csrf.init_app(app)
|
||||
|
||||
# Register blueprints
|
||||
from app.routes.auth import auth_bp
|
||||
from app.routes.dashboard import dashboard_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(dashboard_bp)
|
||||
|
||||
# Import all models so Flask-Migrate can see them
|
||||
with app.app_context():
|
||||
from app.models import (
|
||||
User, Account, Category, Receipt, RecurringRule,
|
||||
Transaction, Budget, Goal, GoalContribution,
|
||||
Investment, InvestmentTransaction, NetWorthSnapshot,
|
||||
AiInsight, FxRate
|
||||
)
|
||||
|
||||
# Jinja2 template globals
|
||||
app.jinja_env.globals['format_currency'] = format_currency
|
||||
app.jinja_env.globals['format_percent'] = format_percent
|
||||
app.jinja_env.globals['format_large_number'] = format_large_number
|
||||
|
||||
# Jinja2 filters
|
||||
@app.template_filter('currency')
|
||||
def currency_filter(value, symbol=None):
|
||||
return format_currency(value, symbol)
|
||||
|
||||
@app.template_filter('percent')
|
||||
def percent_filter(value):
|
||||
return format_percent(value)
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,45 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-change-me')
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
WTF_CSRF_ENABLED = True
|
||||
|
||||
DATABASE_URL = os.environ.get('DATABASE_URL', '')
|
||||
SQLALCHEMY_DATABASE_URI = DATABASE_URL
|
||||
|
||||
GROQ_API_KEY = os.environ.get('GROQ_API_KEY', '')
|
||||
GROQ_MODEL = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile')
|
||||
|
||||
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', '/home/pfm/app/uploads')
|
||||
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH', 10485760))
|
||||
|
||||
APP_CURRENCY = os.environ.get('APP_CURRENCY', 'USD')
|
||||
APP_CURRENCY_SYMBOL = os.environ.get('APP_CURRENCY_SYMBOL', '$')
|
||||
APP_TIMEZONE = os.environ.get('APP_TIMEZONE', 'Asia/Ho_Chi_Minh')
|
||||
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'}
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
DEBUG = True
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||||
'DATABASE_URL',
|
||||
'mysql+pymysql://pfm_user:password@localhost/pfm_db'
|
||||
)
|
||||
SQLALCHEMY_ECHO = False # set True to log SQL queries during dev
|
||||
|
||||
|
||||
class ProductionConfig(Config):
|
||||
DEBUG = False
|
||||
SQLALCHEMY_ECHO = False
|
||||
|
||||
|
||||
config = {
|
||||
'development': DevelopmentConfig,
|
||||
'production': ProductionConfig,
|
||||
'default': DevelopmentConfig,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
from flask_migrate import Migrate
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
|
||||
db = SQLAlchemy()
|
||||
login_manager = LoginManager()
|
||||
migrate = Migrate()
|
||||
csrf = CSRFProtect()
|
||||
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Please log in to access this page.'
|
||||
login_manager.login_message_category = 'warning'
|
||||
@@ -0,0 +1,12 @@
|
||||
from app.models.user import User
|
||||
from app.models.account import Account
|
||||
from app.models.category import Category
|
||||
from app.models.receipt import Receipt
|
||||
from app.models.recurring_rule import RecurringRule
|
||||
from app.models.transaction import Transaction
|
||||
from app.models.budget import Budget
|
||||
from app.models.goal import Goal, GoalContribution
|
||||
from app.models.investment import Investment, InvestmentTransaction
|
||||
from app.models.net_worth_snapshot import NetWorthSnapshot
|
||||
from app.models.ai_insight import AiInsight
|
||||
from app.models.fx_rate import FxRate
|
||||
@@ -0,0 +1,32 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Account(db.Model):
|
||||
__tablename__ = 'accounts'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
account_type = db.Column(
|
||||
db.Enum('checking', 'savings', 'cash', 'credit_card', 'crypto', 'investment', 'other'),
|
||||
nullable=False,
|
||||
default='checking'
|
||||
)
|
||||
balance = db.Column(db.Numeric(15, 2), default=0.00, nullable=False)
|
||||
credit_limit = db.Column(db.Numeric(15, 2), nullable=True) # credit cards
|
||||
due_date = db.Column(db.Integer, nullable=True) # day of month bill due
|
||||
color = db.Column(db.String(7), default='#4F81C7') # hex color for UI
|
||||
icon = db.Column(db.String(50), default='bi-bank') # bootstrap icon name
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
transactions = db.relationship('Transaction', back_populates='account',
|
||||
lazy='dynamic', foreign_keys='Transaction.account_id')
|
||||
transfer_transactions = db.relationship('Transaction', back_populates='to_account',
|
||||
lazy='dynamic', foreign_keys='Transaction.to_account_id')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Account {self.name}>'
|
||||
@@ -0,0 +1,22 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class AiInsight(db.Model):
|
||||
__tablename__ = 'ai_insights'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
insight_date = db.Column(db.Date, nullable=False, index=True)
|
||||
insight_type = db.Column(
|
||||
db.Enum('daily_summary', 'weekly_summary', 'chat_response', 'alert'),
|
||||
nullable=False,
|
||||
default='daily_summary'
|
||||
)
|
||||
prompt_summary = db.Column(db.Text, nullable=True) # what context was sent
|
||||
content = db.Column(db.Text, nullable=False) # AI response
|
||||
model_used = db.Column(db.String(64), nullable=True)
|
||||
tokens_used = db.Column(db.Integer, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<AiInsight {self.insight_type} {self.insight_date}>'
|
||||
@@ -0,0 +1,23 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Budget(db.Model):
|
||||
__tablename__ = 'budgets'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=False)
|
||||
month = db.Column(db.String(7), nullable=False, index=True) # YYYY-MM
|
||||
limit_amount = db.Column(db.Numeric(15, 2), nullable=False)
|
||||
rollover_enabled = db.Column(db.Boolean, default=False)
|
||||
rollover_amount = db.Column(db.Numeric(15, 2), default=0.00)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
category = db.relationship('Category')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('category_id', 'month', name='uq_budget_category_month'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Budget {self.month} cat={self.category_id} limit={self.limit_amount}>'
|
||||
@@ -0,0 +1,28 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Category(db.Model):
|
||||
__tablename__ = 'categories'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
category_type = db.Column(
|
||||
db.Enum('income', 'expense', 'both'),
|
||||
nullable=False,
|
||||
default='expense'
|
||||
)
|
||||
parent_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True)
|
||||
color = db.Column(db.String(7), default='#6B7280')
|
||||
icon = db.Column(db.String(50), default='bi-tag')
|
||||
is_system = db.Column(db.Boolean, default=False) # system categories can't be deleted
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Self-referential relationship for subcategories
|
||||
subcategories = db.relationship('Category', backref=db.backref('parent', remote_side=[id]),
|
||||
lazy='dynamic')
|
||||
transactions = db.relationship('Transaction', back_populates='category', lazy='dynamic')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Category {self.name}>'
|
||||
@@ -0,0 +1,15 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class FxRate(db.Model):
|
||||
__tablename__ = 'fx_rates'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
date = db.Column(db.Date, nullable=False, unique=True, index=True)
|
||||
usd_to_vnd = db.Column(db.Numeric(12, 2), nullable=False)
|
||||
source = db.Column(db.String(50), nullable=True) # 'exchangerate-api' | 'vcb' | 'manual'
|
||||
fetched_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<FxRate {self.date} 1USD={self.usd_to_vnd}VND>'
|
||||
@@ -0,0 +1,50 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Goal(db.Model):
|
||||
__tablename__ = 'goals'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
target_amount = db.Column(db.Numeric(15, 2), nullable=False)
|
||||
current_amount = db.Column(db.Numeric(15, 2), default=0.00)
|
||||
target_date = db.Column(db.Date, nullable=True)
|
||||
linked_account_id = db.Column(db.Integer, db.ForeignKey('accounts.id'), nullable=True)
|
||||
color = db.Column(db.String(7), default='#10B981')
|
||||
icon = db.Column(db.String(50), default='bi-piggy-bank')
|
||||
is_completed = db.Column(db.Boolean, default=False)
|
||||
completed_at = db.Column(db.DateTime, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
linked_account = db.relationship('Account')
|
||||
contributions = db.relationship('GoalContribution', back_populates='goal',
|
||||
lazy='dynamic', cascade='all, delete-orphan')
|
||||
|
||||
@property
|
||||
def progress_percent(self):
|
||||
if self.target_amount <= 0:
|
||||
return 0
|
||||
pct = (float(self.current_amount) / float(self.target_amount)) * 100
|
||||
return min(round(pct, 1), 100.0)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Goal {self.name}>'
|
||||
|
||||
|
||||
class GoalContribution(db.Model):
|
||||
__tablename__ = 'goal_contributions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
goal_id = db.Column(db.Integer, db.ForeignKey('goals.id'), nullable=False)
|
||||
amount = db.Column(db.Numeric(15, 2), nullable=False)
|
||||
notes = db.Column(db.String(255), nullable=True)
|
||||
date = db.Column(db.Date, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
goal = db.relationship('Goal', back_populates='contributions')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<GoalContribution goal={self.goal_id} amount={self.amount}>'
|
||||
@@ -0,0 +1,69 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Investment(db.Model):
|
||||
__tablename__ = 'investments'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
asset_name = db.Column(db.String(100), nullable=False)
|
||||
ticker = db.Column(db.String(20), nullable=True)
|
||||
asset_type = db.Column(
|
||||
db.Enum('stock', 'etf', 'crypto', 'real_estate', 'bond', 'cash', 'other'),
|
||||
nullable=False,
|
||||
default='stock'
|
||||
)
|
||||
shares = db.Column(db.Numeric(18, 8), nullable=False, default=0)
|
||||
avg_cost_basis = db.Column(db.Numeric(15, 4), nullable=False, default=0)
|
||||
current_price = db.Column(db.Numeric(15, 4), nullable=True)
|
||||
last_price_update = db.Column(db.DateTime, nullable=True)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
inv_transactions = db.relationship('InvestmentTransaction', back_populates='investment',
|
||||
lazy='dynamic', cascade='all, delete-orphan')
|
||||
|
||||
@property
|
||||
def total_cost(self):
|
||||
return float(self.shares) * float(self.avg_cost_basis)
|
||||
|
||||
@property
|
||||
def current_value(self):
|
||||
if self.current_price:
|
||||
return float(self.shares) * float(self.current_price)
|
||||
return self.total_cost
|
||||
|
||||
@property
|
||||
def unrealized_gain(self):
|
||||
return self.current_value - self.total_cost
|
||||
|
||||
@property
|
||||
def unrealized_gain_pct(self):
|
||||
if self.total_cost == 0:
|
||||
return 0
|
||||
return round((self.unrealized_gain / self.total_cost) * 100, 2)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Investment {self.asset_name}>'
|
||||
|
||||
|
||||
class InvestmentTransaction(db.Model):
|
||||
__tablename__ = 'investment_transactions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
investment_id = db.Column(db.Integer, db.ForeignKey('investments.id'), nullable=False)
|
||||
transaction_type = db.Column(db.Enum('buy', 'sell', 'dividend', 'split'), nullable=False)
|
||||
shares = db.Column(db.Numeric(18, 8), nullable=False)
|
||||
price_per_share = db.Column(db.Numeric(15, 4), nullable=False)
|
||||
total_amount = db.Column(db.Numeric(15, 2), nullable=False)
|
||||
fees = db.Column(db.Numeric(10, 2), default=0.00)
|
||||
date = db.Column(db.Date, nullable=False, index=True)
|
||||
notes = db.Column(db.String(255), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
investment = db.relationship('Investment', back_populates='inv_transactions')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<InvTransaction {self.transaction_type} {self.shares}@{self.price_per_share}>'
|
||||
@@ -0,0 +1,18 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class NetWorthSnapshot(db.Model):
|
||||
__tablename__ = 'net_worth_snapshots'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
snapshot_date = db.Column(db.Date, nullable=False, unique=True, index=True)
|
||||
total_assets = db.Column(db.Numeric(15, 2), nullable=False, default=0)
|
||||
total_liabilities = db.Column(db.Numeric(15, 2), nullable=False, default=0)
|
||||
net_worth = db.Column(db.Numeric(15, 2), nullable=False, default=0)
|
||||
account_balances = db.Column(db.JSON, nullable=True) # snapshot of each account balance
|
||||
investment_value = db.Column(db.Numeric(15, 2), default=0)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<NetWorthSnapshot {self.snapshot_date} nw={self.net_worth}>'
|
||||
@@ -0,0 +1,18 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Receipt(db.Model):
|
||||
__tablename__ = 'receipts'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
filename = db.Column(db.String(255), nullable=False)
|
||||
original_filename = db.Column(db.String(255), nullable=False)
|
||||
file_size = db.Column(db.Integer, nullable=True)
|
||||
mime_type = db.Column(db.String(50), nullable=True)
|
||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
transaction = db.relationship('Transaction', back_populates='receipt', uselist=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Receipt {self.filename}>'
|
||||
@@ -0,0 +1,35 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class RecurringRule(db.Model):
|
||||
__tablename__ = 'recurring_rules'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
account_id = db.Column(db.Integer, db.ForeignKey('accounts.id'), nullable=False)
|
||||
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True)
|
||||
transaction_type = db.Column(
|
||||
db.Enum('income', 'expense'),
|
||||
nullable=False
|
||||
)
|
||||
amount = db.Column(db.Numeric(15, 2), nullable=False)
|
||||
description = db.Column(db.String(255), nullable=False)
|
||||
frequency = db.Column(
|
||||
db.Enum('daily', 'weekly', 'biweekly', 'monthly', 'quarterly', 'yearly'),
|
||||
nullable=False,
|
||||
default='monthly'
|
||||
)
|
||||
start_date = db.Column(db.Date, nullable=False)
|
||||
end_date = db.Column(db.Date, nullable=True)
|
||||
last_run = db.Column(db.Date, nullable=True)
|
||||
next_run = db.Column(db.Date, nullable=True, index=True)
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
transactions = db.relationship('Transaction', back_populates='recurring_rule', lazy='dynamic')
|
||||
account = db.relationship('Account')
|
||||
category = db.relationship('Category')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<RecurringRule {self.name} {self.frequency}>'
|
||||
@@ -0,0 +1,37 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Transaction(db.Model):
|
||||
__tablename__ = 'transactions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
account_id = db.Column(db.Integer, db.ForeignKey('accounts.id'), nullable=False)
|
||||
to_account_id = db.Column(db.Integer, db.ForeignKey('accounts.id'), nullable=True) # transfers
|
||||
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True)
|
||||
recurring_rule_id = db.Column(db.Integer, db.ForeignKey('recurring_rules.id'), nullable=True)
|
||||
receipt_id = db.Column(db.Integer, db.ForeignKey('receipts.id'), nullable=True)
|
||||
|
||||
transaction_type = db.Column(
|
||||
db.Enum('income', 'expense', 'transfer'),
|
||||
nullable=False
|
||||
)
|
||||
amount = db.Column(db.Numeric(15, 2), nullable=False)
|
||||
description = db.Column(db.String(255), nullable=False)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
date = db.Column(db.Date, nullable=False, index=True)
|
||||
is_recurring = db.Column(db.Boolean, default=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
account = db.relationship('Account', back_populates='transactions',
|
||||
foreign_keys=[account_id])
|
||||
to_account = db.relationship('Account', back_populates='transfer_transactions',
|
||||
foreign_keys=[to_account_id])
|
||||
category = db.relationship('Category', back_populates='transactions')
|
||||
receipt = db.relationship('Receipt', back_populates='transaction')
|
||||
recurring_rule = db.relationship('RecurringRule', back_populates='transactions')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Transaction {self.transaction_type} {self.amount} on {self.date}>'
|
||||
@@ -0,0 +1,34 @@
|
||||
from flask_login import UserMixin
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from app.extensions import db, login_manager
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||
email = db.Column(db.String(120), unique=True, nullable=True)
|
||||
password_hash = db.Column(db.String(256), nullable=False)
|
||||
display_name = db.Column(db.String(100), nullable=True)
|
||||
timezone = db.Column(db.String(64), default='Asia/Ho_Chi_Minh')
|
||||
currency = db.Column(db.String(10), default='USD')
|
||||
currency_symbol = db.Column(db.String(5), default='$')
|
||||
groq_model = db.Column(db.String(64), default='llama-3.3-70b-versatile')
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
last_login = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
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 __repr__(self):
|
||||
return f'<User {self.username}>'
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return db.session.get(User, int(user_id))
|
||||
@@ -0,0 +1,44 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, PasswordField, BooleanField, SubmitField
|
||||
from wtforms.validators import DataRequired, Length
|
||||
from app.models.user import User
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
username = StringField('Username', validators=[DataRequired(), Length(1, 64)])
|
||||
password = PasswordField('Password', validators=[DataRequired()])
|
||||
remember_me = BooleanField('Remember me')
|
||||
submit = SubmitField('Sign In')
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('dashboard.index'))
|
||||
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(username=form.username.data.strip()).first()
|
||||
if user and user.check_password(form.password.data):
|
||||
login_user(user, remember=form.remember_me.data)
|
||||
user.last_login = datetime.utcnow()
|
||||
db.session.commit()
|
||||
next_page = request.args.get('next')
|
||||
return redirect(next_page or url_for('dashboard.index'))
|
||||
flash('Invalid username or password.', 'danger')
|
||||
|
||||
return render_template('auth/login.html', form=form)
|
||||
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
flash('You have been logged out.', 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
@@ -0,0 +1,10 @@
|
||||
from flask import Blueprint, render_template
|
||||
from flask_login import login_required
|
||||
|
||||
dashboard_bp = Blueprint('dashboard', __name__)
|
||||
|
||||
|
||||
@dashboard_bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
return render_template('dashboard/index.html')
|
||||
@@ -0,0 +1,251 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sign In — PFM</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--accent: #3b82f6;
|
||||
--accent-dark: #1d4ed8;
|
||||
--bg: #0f172a;
|
||||
--card-bg: #1e293b;
|
||||
--border: #334155;
|
||||
--text: #f1f5f9;
|
||||
--muted: #94a3b8;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
background: var(--bg);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Background grid pattern */
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(59,130,246,0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(59,130,246,0.03) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Glow blob */
|
||||
body::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -200px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: radial-gradient(circle, rgba(59,130,246,0.12) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-wrap {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.login-brand .brand-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
background: var(--accent);
|
||||
border-radius: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
margin-bottom: 14px;
|
||||
box-shadow: 0 0 0 6px rgba(59,130,246,0.15);
|
||||
}
|
||||
.login-brand h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.login-brand p {
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
background: #0f172a;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.form-control:focus {
|
||||
background: #0f172a;
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
box-shadow: 0 0 0 3px rgba(59,130,246,0.2);
|
||||
}
|
||||
.form-control::placeholder { color: #475569; }
|
||||
|
||||
.input-group-text {
|
||||
background: #0f172a;
|
||||
border-color: var(--border);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 11px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.btn-login:hover {
|
||||
background: var(--accent-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(59,130,246,0.35);
|
||||
}
|
||||
.btn-login:active { transform: translateY(0); }
|
||||
|
||||
.form-check-label { font-size: 13px; color: var(--muted); }
|
||||
.form-check-input:checked { background-color: var(--accent); border-color: var(--accent); }
|
||||
|
||||
.alert {
|
||||
border-radius: 8px;
|
||||
font-size: 13.5px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.alert-danger { background: #450a0a; border-color: #991b1b; color: #fca5a5; }
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-wrap">
|
||||
<div class="login-brand">
|
||||
<div class="brand-icon"><i class="bi bi-currency-exchange"></i></div>
|
||||
<h1>Personal Finance</h1>
|
||||
<p>Sign in to your dashboard</p>
|
||||
</div>
|
||||
|
||||
<div class="login-card">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} mb-3">
|
||||
<i class="bi bi-exclamation-circle me-1"></i>{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.login') }}" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.username.label(class="form-label") }}
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-person"></i></span>
|
||||
{{ form.username(class="form-control" + (" is-invalid" if form.username.errors else ""),
|
||||
placeholder="Username", autocomplete="username") }}
|
||||
</div>
|
||||
{% for error in form.username.errors %}
|
||||
<div class="text-danger mt-1" style="font-size:12px;">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
{{ form.password.label(class="form-label") }}
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-lock"></i></span>
|
||||
{{ form.password(class="form-control" + (" is-invalid" if form.password.errors else ""),
|
||||
placeholder="Password", autocomplete="current-password",
|
||||
id="passwordInput") }}
|
||||
<button type="button" class="input-group-text" style="cursor:pointer;"
|
||||
onclick="togglePassword()">
|
||||
<i class="bi bi-eye" id="pwdToggleIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% for error in form.password.errors %}
|
||||
<div class="text-danger mt-1" style="font-size:12px;">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="form-check">
|
||||
{{ form.remember_me(class="form-check-input") }}
|
||||
{{ form.remember_me.label(class="form-check-label") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-login">
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i> Sign In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="login-footer">Self-hosted · Private · Secure</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function togglePassword() {
|
||||
const input = document.getElementById('passwordInput');
|
||||
const icon = document.getElementById('pwdToggleIcon');
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.className = 'bi bi-eye-slash';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.className = 'bi bi-eye';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,471 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}PFM{% endblock %} — Personal Finance</title>
|
||||
|
||||
<!-- Bootstrap 5 -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||
<!-- Google Fonts -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--sidebar-width: 240px;
|
||||
--sidebar-collapsed-width: 64px;
|
||||
--sidebar-bg: #0f172a;
|
||||
--sidebar-text: #94a3b8;
|
||||
--sidebar-text-active: #f1f5f9;
|
||||
--sidebar-hover-bg: #1e293b;
|
||||
--sidebar-active-bg: #1e3a5f;
|
||||
--sidebar-accent: #3b82f6;
|
||||
--topbar-height: 56px;
|
||||
--body-bg: #f8fafc;
|
||||
--card-bg: #ffffff;
|
||||
--text-primary: #0f172a;
|
||||
--text-muted: #64748b;
|
||||
--border-color: #e2e8f0;
|
||||
--income-color: #10b981;
|
||||
--expense-color: #ef4444;
|
||||
--investment-color: #3b82f6;
|
||||
--transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
background: var(--body-bg);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ── SIDEBAR ──────────────────────────────── */
|
||||
#sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
width: var(--sidebar-width);
|
||||
background: var(--sidebar-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1040;
|
||||
transition: var(--transition);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#sidebar.collapsed {
|
||||
width: var(--sidebar-collapsed-width);
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 16px;
|
||||
height: var(--topbar-height);
|
||||
border-bottom: 1px solid #1e293b;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-brand .brand-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--sidebar-accent);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar-brand .brand-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--sidebar-text-active);
|
||||
letter-spacing: 0.02em;
|
||||
transition: var(--transition);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#sidebar.collapsed .brand-text { opacity: 0; width: 0; }
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 8px 0;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.sidebar-nav::-webkit-scrollbar { display: none; }
|
||||
|
||||
.nav-section-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #475569;
|
||||
padding: 16px 20px 4px;
|
||||
white-space: nowrap;
|
||||
transition: var(--transition);
|
||||
}
|
||||
#sidebar.collapsed .nav-section-label { opacity: 0; }
|
||||
|
||||
.sidebar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 9px 16px;
|
||||
color: var(--sidebar-text);
|
||||
text-decoration: none;
|
||||
font-size: 13.5px;
|
||||
font-weight: 400;
|
||||
border-radius: 6px;
|
||||
margin: 1px 8px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
transition: var(--transition);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-link:hover {
|
||||
background: var(--sidebar-hover-bg);
|
||||
color: var(--sidebar-text-active);
|
||||
}
|
||||
|
||||
.sidebar-link.active {
|
||||
background: var(--sidebar-active-bg);
|
||||
color: var(--sidebar-text-active);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-link.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 60%;
|
||||
background: var(--sidebar-accent);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.sidebar-link i {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-link .link-text {
|
||||
opacity: 1;
|
||||
transition: var(--transition);
|
||||
}
|
||||
#sidebar.collapsed .link-text { opacity: 0; width: 0; overflow: hidden; }
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 8px;
|
||||
border-top: 1px solid #1e293b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── TOPBAR ───────────────────────────────── */
|
||||
#topbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: var(--sidebar-width);
|
||||
right: 0;
|
||||
height: var(--topbar-height);
|
||||
background: var(--card-bg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
z-index: 1030;
|
||||
gap: 12px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
#sidebar.collapsed ~ #topbar,
|
||||
#sidebar.collapsed ~ * #topbar {
|
||||
left: var(--sidebar-collapsed-width);
|
||||
}
|
||||
|
||||
.topbar-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
line-height: 1;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.topbar-toggle:hover { background: var(--border-color); color: var(--text-primary); }
|
||||
|
||||
.topbar-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ── MAIN CONTENT ─────────────────────────── */
|
||||
#main-content {
|
||||
margin-left: var(--sidebar-width);
|
||||
margin-top: var(--topbar-height);
|
||||
padding: 24px;
|
||||
min-height: calc(100vh - var(--topbar-height));
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
#sidebar.collapsed ~ #main-content {
|
||||
margin-left: var(--sidebar-collapsed-width);
|
||||
}
|
||||
|
||||
/* ── CARDS ────────────────────────────────── */
|
||||
.pfm-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.pfm-card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── FLASH MESSAGES ───────────────────────── */
|
||||
.flash-container {
|
||||
position: fixed;
|
||||
top: calc(var(--topbar-height) + 12px);
|
||||
right: 16px;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
/* ── RESPONSIVE ───────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
#sidebar {
|
||||
transform: translateX(-100%);
|
||||
width: var(--sidebar-width) !important;
|
||||
}
|
||||
#sidebar.mobile-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
#topbar {
|
||||
left: 0 !important;
|
||||
}
|
||||
#main-content {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
.sidebar-overlay {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
z-index: 1039;
|
||||
}
|
||||
.sidebar-overlay.active { display: block; }
|
||||
|
||||
/* ── UTILITIES ────────────────────────────── */
|
||||
.text-income { color: var(--income-color) !important; }
|
||||
.text-expense { color: var(--expense-color) !important; }
|
||||
.text-invest { color: var(--investment-color) !important; }
|
||||
.badge-income { background: #d1fae5; color: #065f46; }
|
||||
.badge-expense { background: #fee2e2; color: #991b1b; }
|
||||
.badge-transfer { background: #dbeafe; color: #1e40af; }
|
||||
|
||||
code, .mono { font-family: 'DM Mono', monospace; }
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Sidebar overlay (mobile) -->
|
||||
<div class="sidebar-overlay" id="sidebarOverlay"></div>
|
||||
|
||||
<!-- ── SIDEBAR ────────────────────────────────────── -->
|
||||
<nav id="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<div class="brand-icon"><i class="bi bi-currency-exchange"></i></div>
|
||||
<span class="brand-text">PFM</span>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-nav">
|
||||
<a href="{{ url_for('dashboard.index') }}"
|
||||
class="sidebar-link {% if request.endpoint == 'dashboard.index' %}active{% endif %}">
|
||||
<i class="bi bi-grid-1x2"></i>
|
||||
<span class="link-text">Dashboard</span>
|
||||
</a>
|
||||
|
||||
<div class="nav-section-label">Money</div>
|
||||
|
||||
<a href="#" class="sidebar-link {% if 'transactions' in request.endpoint|default('') %}active{% endif %}">
|
||||
<i class="bi bi-arrow-left-right"></i>
|
||||
<span class="link-text">Transactions</span>
|
||||
</a>
|
||||
<a href="#" class="sidebar-link {% if 'income' in request.endpoint|default('') %}active{% endif %}">
|
||||
<i class="bi bi-arrow-down-circle"></i>
|
||||
<span class="link-text">Income</span>
|
||||
</a>
|
||||
<a href="#" class="sidebar-link {% if 'expenses' in request.endpoint|default('') %}active{% endif %}">
|
||||
<i class="bi bi-arrow-up-circle"></i>
|
||||
<span class="link-text">Expenses</span>
|
||||
</a>
|
||||
<a href="#" class="sidebar-link {% if 'accounts' in request.endpoint|default('') %}active{% endif %}">
|
||||
<i class="bi bi-wallet2"></i>
|
||||
<span class="link-text">Accounts</span>
|
||||
</a>
|
||||
|
||||
<div class="nav-section-label">Planning</div>
|
||||
|
||||
<a href="#" class="sidebar-link {% if 'budgets' in request.endpoint|default('') %}active{% endif %}">
|
||||
<i class="bi bi-pie-chart"></i>
|
||||
<span class="link-text">Budgets</span>
|
||||
</a>
|
||||
<a href="#" class="sidebar-link {% if 'goals' in request.endpoint|default('') %}active{% endif %}">
|
||||
<i class="bi bi-bullseye"></i>
|
||||
<span class="link-text">Goals</span>
|
||||
</a>
|
||||
|
||||
<div class="nav-section-label">Growth</div>
|
||||
|
||||
<a href="#" class="sidebar-link {% if 'investments' in request.endpoint|default('') %}active{% endif %}">
|
||||
<i class="bi bi-graph-up-arrow"></i>
|
||||
<span class="link-text">Investments</span>
|
||||
</a>
|
||||
<a href="#" class="sidebar-link {% if 'reports' in request.endpoint|default('') %}active{% endif %}">
|
||||
<i class="bi bi-file-earmark-bar-graph"></i>
|
||||
<span class="link-text">Reports</span>
|
||||
</a>
|
||||
|
||||
<div class="nav-section-label">AI</div>
|
||||
|
||||
<a href="#" class="sidebar-link {% if 'ai' in request.endpoint|default('') %}active{% endif %}">
|
||||
<i class="bi bi-stars"></i>
|
||||
<span class="link-text">AI Assistant</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<a href="{{ url_for('settings.index') if 'settings' in request.blueprints else '#' }}"
|
||||
class="sidebar-link {% if 'settings' in request.endpoint|default('') %}active{% endif %}">
|
||||
<i class="bi bi-gear"></i>
|
||||
<span class="link-text">Settings</span>
|
||||
</a>
|
||||
<a href="{{ url_for('auth.logout') }}" class="sidebar-link">
|
||||
<i class="bi bi-box-arrow-right"></i>
|
||||
<span class="link-text">Logout</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ── TOPBAR ──────────────────────────────────────── -->
|
||||
<header id="topbar">
|
||||
<button class="topbar-toggle" id="sidebarToggle" title="Toggle sidebar">
|
||||
<i class="bi bi-list"></i>
|
||||
</button>
|
||||
<span class="topbar-title">{% block page_title %}{% endblock %}</span>
|
||||
<div class="topbar-right">
|
||||
<span class="d-none d-sm-inline small text-muted mono">
|
||||
{{ current_user.display_name or current_user.username }}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── FLASH MESSAGES ─────────────────────────────── -->
|
||||
<div class="flash-container" id="flashContainer">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show shadow-sm mb-0 py-2 px-3"
|
||||
style="font-size:13.5px; border-radius:8px;" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close btn-close-sm" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<!-- ── MAIN CONTENT ───────────────────────────────── -->
|
||||
<main id="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const toggleBtn = document.getElementById('sidebarToggle');
|
||||
const overlay = document.getElementById('sidebarOverlay');
|
||||
const STORAGE_KEY = 'pfm_sidebar_collapsed';
|
||||
const isMobile = () => window.innerWidth < 769;
|
||||
|
||||
// Restore desktop collapsed state
|
||||
if (!isMobile() && localStorage.getItem(STORAGE_KEY) === '1') {
|
||||
sidebar.classList.add('collapsed');
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
if (isMobile()) {
|
||||
sidebar.classList.toggle('mobile-open');
|
||||
overlay.classList.toggle('active');
|
||||
} else {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
localStorage.setItem(STORAGE_KEY, sidebar.classList.contains('collapsed') ? '1' : '0');
|
||||
}
|
||||
}
|
||||
|
||||
toggleBtn.addEventListener('click', toggleSidebar);
|
||||
overlay.addEventListener('click', function () {
|
||||
sidebar.classList.remove('mobile-open');
|
||||
overlay.classList.remove('active');
|
||||
});
|
||||
|
||||
// Auto-dismiss flash messages after 4s
|
||||
document.querySelectorAll('#flashContainer .alert').forEach(function (el) {
|
||||
setTimeout(function () {
|
||||
const bsAlert = bootstrap.Alert.getOrCreateInstance(el);
|
||||
bsAlert.close();
|
||||
}, 4000);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block page_title %}Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<div class="pfm-card text-center py-5">
|
||||
<i class="bi bi-grid-1x2 text-muted" style="font-size:3rem;"></i>
|
||||
<h5 class="mt-3 mb-1">Dashboard</h5>
|
||||
<p class="text-muted small">Phase 2 will populate this with live data.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,13 @@
|
||||
from functools import wraps
|
||||
from flask import redirect, url_for
|
||||
from flask_login import current_user
|
||||
|
||||
|
||||
def login_required_custom(f):
|
||||
"""Redundant wrapper — Flask-Login handles this, but kept for explicitness."""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for('auth.login'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
@@ -0,0 +1,47 @@
|
||||
from flask import current_app
|
||||
from flask_login import current_user
|
||||
|
||||
|
||||
def format_currency(amount, symbol=None, show_sign=False):
|
||||
"""Format a number as currency using the app's configured symbol."""
|
||||
if amount is None:
|
||||
return '—'
|
||||
try:
|
||||
amount = float(amount)
|
||||
except (TypeError, ValueError):
|
||||
return '—'
|
||||
|
||||
if symbol is None:
|
||||
try:
|
||||
symbol = current_user.currency_symbol if current_user.is_authenticated \
|
||||
else current_app.config.get('APP_CURRENCY_SYMBOL', '$')
|
||||
except Exception:
|
||||
symbol = '$'
|
||||
|
||||
formatted = f"{symbol}{abs(amount):,.2f}"
|
||||
if show_sign:
|
||||
if amount < 0:
|
||||
formatted = f"-{formatted}"
|
||||
elif amount > 0:
|
||||
formatted = f"+{formatted}"
|
||||
elif amount < 0:
|
||||
formatted = f"-{formatted}"
|
||||
return formatted
|
||||
|
||||
|
||||
def format_percent(value, decimals=1):
|
||||
if value is None:
|
||||
return '—'
|
||||
return f"{float(value):.{decimals}f}%"
|
||||
|
||||
|
||||
def format_large_number(value):
|
||||
"""Abbreviate large numbers: 1,500,000 → 1.5M"""
|
||||
if value is None:
|
||||
return '—'
|
||||
value = float(value)
|
||||
if abs(value) >= 1_000_000:
|
||||
return f"{value / 1_000_000:.1f}M"
|
||||
if abs(value) >= 1_000:
|
||||
return f"{value / 1_000:.1f}K"
|
||||
return f"{value:.2f}"
|
||||
Reference in New Issue
Block a user