05/31 Phase 1: initial codes

This commit is contained in:
2026-05-31 10:03:28 -04:00
parent b0a5ce5399
commit b0c0bd363b
35 changed files with 2871 additions and 164 deletions
+55
View File
@@ -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
+45
View File
@@ -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,
}
+13
View File
@@ -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'
+12
View File
@@ -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
+32
View File
@@ -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}>'
+22
View File
@@ -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}>'
+23
View File
@@ -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}>'
+28
View File
@@ -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}>'
+15
View File
@@ -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>'
+50
View File
@@ -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}>'
+69
View File
@@ -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}>'
+18
View File
@@ -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}>'
+18
View File
@@ -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}>'
+35
View File
@@ -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}>'
+37
View File
@@ -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}>'
+34
View File
@@ -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))
View File
+44
View File
@@ -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'))
+10
View File
@@ -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')
+251
View File
@@ -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>
+471
View File
@@ -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>
+15
View File
@@ -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 %}
View File
+13
View File
@@ -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
+47
View File
@@ -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}"