05/31 Phase 2

This commit is contained in:
2026-05-31 11:17:53 -04:00
parent 1c34dcbd28
commit 9f2a0acc38
18 changed files with 1817 additions and 391 deletions
+7 -2
View File
@@ -25,9 +25,15 @@ def create_app(config_name=None):
# Register blueprints
from app.routes.auth import auth_bp
from app.routes.dashboard import dashboard_bp
from app.routes.accounts import accounts_bp
from app.routes.categories import categories_bp
from app.routes.transactions import transactions_bp
app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp)
app.register_blueprint(accounts_bp)
app.register_blueprint(categories_bp)
app.register_blueprint(transactions_bp)
# Import all models so Flask-Migrate can see them
with app.app_context():
@@ -38,12 +44,11 @@ def create_app(config_name=None):
AiInsight, FxRate
)
# Jinja2 template globals
# Jinja2 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)
+105
View File
@@ -0,0 +1,105 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required
from flask_wtf import FlaskForm
from wtforms import StringField, SelectField, TextAreaField, SubmitField
from wtforms.validators import DataRequired, Length, Optional
from app.extensions import db
from app.models.account import Account
from app.services.account_service import calc_balance
accounts_bp = Blueprint('accounts', __name__, url_prefix='/accounts')
ACCOUNT_TYPES = [
('checking', 'Checking'),
('savings', 'Savings'),
('cash', 'Cash'),
('credit_card', 'Credit Card'),
('crypto', 'Crypto Wallet'),
('investment', 'Investment'),
('other', 'Other'),
]
ACCOUNT_ICONS = [
('bi-bank', 'Bank'),
('bi-wallet2', 'Wallet'),
('bi-cash-stack', 'Cash'),
('bi-credit-card', 'Credit Card'),
('bi-currency-bitcoin', 'Crypto'),
('bi-graph-up', 'Investment'),
('bi-safe', 'Safe'),
]
ACCOUNT_COLORS = [
'#4F81C7', '#10B981', '#F59E0B', '#EF4444',
'#8B5CF6', '#EC4899', '#06B6D4', '#64748B',
]
class AccountForm(FlaskForm):
name = StringField('Account Name', validators=[DataRequired(), Length(1, 100)])
account_type = SelectField('Type', choices=ACCOUNT_TYPES, validators=[DataRequired()])
color = StringField('Color', default='#4F81C7')
icon = StringField('Icon', default='bi-bank')
notes = TextAreaField('Notes', validators=[Optional(), Length(max=500)])
submit = SubmitField('Save')
@accounts_bp.route('/')
@login_required
def index():
accounts = Account.query.filter_by(is_active=True).order_by(Account.name).all()
# Recalc balances on page load
for a in accounts:
calc_balance(a.id)
accounts = Account.query.filter_by(is_active=True).order_by(Account.name).all()
return render_template('accounts/index.html', accounts=accounts)
@accounts_bp.route('/new', methods=['GET', 'POST'])
@login_required
def new():
form = AccountForm()
if form.validate_on_submit():
account = Account(
name=form.name.data.strip(),
account_type=form.account_type.data,
color=form.color.data or '#4F81C7',
icon=form.icon.data or 'bi-bank',
notes=form.notes.data,
balance=0,
)
db.session.add(account)
db.session.commit()
flash(f'Account "{account.name}" created.', 'success')
return redirect(url_for('accounts.index'))
return render_template('accounts/form.html', form=form, title='New Account',
colors=ACCOUNT_COLORS, icons=ACCOUNT_ICONS)
@accounts_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit(id):
account = db.get_or_404(Account, id)
form = AccountForm(obj=account)
if form.validate_on_submit():
account.name = form.name.data.strip()
account.account_type = form.account_type.data
account.color = form.color.data or account.color
account.icon = form.icon.data or account.icon
account.notes = form.notes.data
db.session.commit()
flash(f'Account "{account.name}" updated.', 'success')
return redirect(url_for('accounts.index'))
return render_template('accounts/form.html', form=form, title='Edit Account',
account=account, colors=ACCOUNT_COLORS, icons=ACCOUNT_ICONS)
@accounts_bp.route('/<int:id>/delete', methods=['POST'])
@login_required
def delete(id):
account = db.get_or_404(Account, id)
# Soft delete
account.is_active = False
db.session.commit()
flash(f'Account "{account.name}" removed.', 'info')
return redirect(url_for('accounts.index'))
+119
View File
@@ -0,0 +1,119 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required
from flask_wtf import FlaskForm
from wtforms import StringField, SelectField, SubmitField
from wtforms.validators import DataRequired, Length
from app.extensions import db
from app.models.category import Category
categories_bp = Blueprint('categories', __name__, url_prefix='/categories')
CATEGORY_TYPES = [
('expense', 'Expense'),
('income', 'Income'),
('both', 'Both'),
]
CATEGORY_COLORS = [
'#6366f1', '#f59e0b', '#3b82f6', '#8b5cf6', '#ef4444',
'#ec4899', '#f97316', '#14b8a6', '#64748b', '#10b981',
'#0ea5e9', '#84cc16', '#a78bfa', '#94a3b8', '#f43f5e',
]
CATEGORY_ICONS = [
'bi-house', 'bi-cup-hot', 'bi-car-front', 'bi-lightning-charge',
'bi-heart-pulse', 'bi-controller', 'bi-bag', 'bi-book',
'bi-shield-check', 'bi-person-heart', 'bi-airplane', 'bi-repeat',
'bi-gift', 'bi-three-dots', 'bi-briefcase', 'bi-laptop',
'bi-building', 'bi-graph-up-arrow', 'bi-house-door', 'bi-tag',
'bi-cart', 'bi-music-note', 'bi-phone', 'bi-tools',
]
class CategoryForm(FlaskForm):
name = StringField('Name', validators=[DataRequired(), Length(1, 100)])
category_type = SelectField('Type', choices=CATEGORY_TYPES, validators=[DataRequired()])
color = StringField('Color', default='#6B7280')
icon = StringField('Icon', default='bi-tag')
submit = SubmitField('Save')
@categories_bp.route('/')
@login_required
def index():
expense_cats = Category.query.filter(
Category.category_type.in_(['expense', 'both']),
Category.is_active == True,
Category.parent_id == None
).order_by(Category.is_system.desc(), Category.name).all()
income_cats = Category.query.filter(
Category.category_type.in_(['income', 'both']),
Category.is_active == True,
Category.parent_id == None
).order_by(Category.is_system.desc(), Category.name).all()
return render_template('categories/index.html',
expense_cats=expense_cats,
income_cats=income_cats)
@categories_bp.route('/new', methods=['GET', 'POST'])
@login_required
def new():
form = CategoryForm()
# Pre-select type from query param
if request.method == 'GET' and request.args.get('type'):
form.category_type.data = request.args.get('type')
if form.validate_on_submit():
cat = Category(
name=form.name.data.strip(),
category_type=form.category_type.data,
color=form.color.data or '#6B7280',
icon=form.icon.data or 'bi-tag',
is_system=False,
)
db.session.add(cat)
db.session.commit()
flash(f'Category "{cat.name}" created.', 'success')
return redirect(url_for('categories.index'))
return render_template('categories/form.html', form=form, title='New Category',
colors=CATEGORY_COLORS, icons=CATEGORY_ICONS)
@categories_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit(id):
cat = db.get_or_404(Category, id)
form = CategoryForm(obj=cat)
if form.validate_on_submit():
if cat.is_system and cat.name != form.name.data.strip():
flash('System category names cannot be changed.', 'warning')
else:
cat.name = form.name.data.strip()
cat.category_type = form.category_type.data
cat.color = form.color.data or cat.color
cat.icon = form.icon.data or cat.icon
db.session.commit()
flash(f'Category "{cat.name}" updated.', 'success')
return redirect(url_for('categories.index'))
return render_template('categories/form.html', form=form, title='Edit Category',
category=cat, colors=CATEGORY_COLORS, icons=CATEGORY_ICONS)
@categories_bp.route('/<int:id>/delete', methods=['POST'])
@login_required
def delete(id):
cat = db.get_or_404(Category, id)
if cat.is_system:
flash('System categories cannot be deleted.', 'warning')
return redirect(url_for('categories.index'))
# Check if in use
if cat.transactions.count() > 0:
flash('Cannot delete category with existing transactions. Deactivate instead.', 'warning')
return redirect(url_for('categories.index'))
cat.is_active = False
db.session.commit()
flash(f'Category "{cat.name}" removed.', 'info')
return redirect(url_for('categories.index'))
+151 -3
View File
@@ -1,10 +1,158 @@
from flask import Blueprint, render_template
from flask_login import login_required
from flask import Blueprint, render_template, request, jsonify
from flask_login import login_required, current_user
from sqlalchemy import func, extract
from app.extensions import db
from app.models.account import Account
from app.models.transaction import Transaction
from app.models.category import Category
from app.services.fx_service import get_today_rate, get_rate_history
from app.services.account_service import get_total_assets, get_total_liabilities
from datetime import date, datetime, timedelta
import calendar
dashboard_bp = Blueprint('dashboard', __name__)
def _parse_date_range(period):
"""Return (date_from, date_to, label) for a given period string."""
today = date.today()
if period == 'last_month':
first = (today.replace(day=1) - timedelta(days=1)).replace(day=1)
last = today.replace(day=1) - timedelta(days=1)
label = first.strftime('%B %Y')
elif period == 'custom':
try:
date_from = datetime.strptime(request.args.get('date_from', ''), '%Y-%m-%d').date()
date_to = datetime.strptime(request.args.get('date_to', ''), '%Y-%m-%d').date()
except ValueError:
date_from = today.replace(day=1)
date_to = today
return date_from, date_to, 'Custom Range'
else: # this_month (default)
first = today.replace(day=1)
last = today
label = first.strftime('%B %Y')
return first, last, label
@dashboard_bp.route('/')
@login_required
def index():
return render_template('dashboard/index.html')
period = request.args.get('period', 'this_month')
date_from, date_to, period_label = _parse_date_range(period)
# ── Summary cards ────────────────────────────────
total_income = db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.transaction_type == 'income',
Transaction.date >= date_from,
Transaction.date <= date_to,
).scalar()
total_expense = db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.transaction_type == 'expense',
Transaction.date >= date_from,
Transaction.date <= date_to,
).scalar()
net_cash_flow = float(total_income) - float(total_expense)
# ── Accounts ─────────────────────────────────────
accounts = Account.query.filter_by(is_active=True).order_by(Account.name).all()
total_assets = get_total_assets()
total_liabilities = get_total_liabilities()
net_worth = total_assets - total_liabilities
# ── Top expense categories ────────────────────────
top_categories = db.session.query(
Category.name,
Category.color,
Category.icon,
func.sum(Transaction.amount).label('total')
).join(Transaction, Transaction.category_id == Category.id)\
.filter(
Transaction.transaction_type == 'expense',
Transaction.date >= date_from,
Transaction.date <= date_to,
).group_by(Category.id)\
.order_by(func.sum(Transaction.amount).desc())\
.limit(5).all()
# ── Cash flow chart (last 6 months) ──────────────
chart_months = []
chart_income = []
chart_expense = []
today = date.today()
for i in range(5, -1, -1):
# go back i months from current
month_date = (today.replace(day=1) - timedelta(days=i * 28)).replace(day=1)
last_day = calendar.monthrange(month_date.year, month_date.month)[1]
m_start = month_date
m_end = month_date.replace(day=last_day)
inc = db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.transaction_type == 'income',
Transaction.date >= m_start,
Transaction.date <= m_end,
).scalar()
exp = db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.transaction_type == 'expense',
Transaction.date >= m_start,
Transaction.date <= m_end,
).scalar()
chart_months.append(month_date.strftime('%b %Y'))
chart_income.append(float(inc))
chart_expense.append(float(exp))
# ── Recent transactions ───────────────────────────
recent_txns = Transaction.query\
.filter(Transaction.transaction_type.in_(['income', 'expense']))\
.order_by(Transaction.date.desc(), Transaction.id.desc())\
.limit(8).all()
# ── USD/VND rate ──────────────────────────────────
fx = get_today_rate()
fx_history = get_rate_history(30)
fx_history_data = {
'dates': [r.date.strftime('%b %d') for r in fx_history],
'rates': [float(r.usd_to_vnd) for r in fx_history],
}
return render_template('dashboard/index.html',
period=period,
period_label=period_label,
date_from=date_from,
date_to=date_to,
total_income=float(total_income),
total_expense=float(total_expense),
net_cash_flow=net_cash_flow,
accounts=accounts,
total_assets=total_assets,
total_liabilities=total_liabilities,
net_worth=net_worth,
top_categories=top_categories,
chart_months=chart_months,
chart_income=chart_income,
chart_expense=chart_expense,
recent_txns=recent_txns,
fx=fx,
fx_history_data=fx_history_data)
@dashboard_bp.route('/api/fx-history')
@login_required
def fx_history_api():
history = get_rate_history(30)
return jsonify({
'dates': [r.date.strftime('%b %d') for r in history],
'rates': [float(r.usd_to_vnd) for r in history],
})
+222
View File
@@ -0,0 +1,222 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required
from flask_wtf import FlaskForm
from wtforms import StringField, SelectField, TextAreaField, SubmitField, DecimalField, DateField, HiddenField
from wtforms.validators import DataRequired, Optional, NumberRange
from app.extensions import db
from app.models.transaction import Transaction
from app.models.account import Account
from app.models.category import Category
from app.services.account_service import calc_balance
from datetime import date, datetime
from sqlalchemy import or_
transactions_bp = Blueprint('transactions', __name__, url_prefix='/transactions')
def _account_choices():
return [(str(a.id), a.name)
for a in Account.query.filter_by(is_active=True).order_by(Account.name).all()]
def _category_choices(cat_type):
cats = Category.query.filter(
Category.category_type.in_([cat_type, 'both']),
Category.is_active == True,
Category.parent_id == None
).order_by(Category.name).all()
return [('', '— None —')] + [(str(c.id), c.name) for c in cats]
class TransactionForm(FlaskForm):
transaction_type = HiddenField(default='expense')
account_id = SelectField('Account', validators=[DataRequired()])
category_id = SelectField('Category', validators=[Optional()])
amount = DecimalField('Amount', validators=[DataRequired(), NumberRange(min=0.01)],
places=2)
description = StringField('Description', validators=[DataRequired()])
date = DateField('Date', validators=[DataRequired()], default=date.today)
notes = TextAreaField('Notes', validators=[Optional()])
submit = SubmitField('Save')
class TransferForm(FlaskForm):
from_account_id = SelectField('From Account', validators=[DataRequired()])
to_account_id = SelectField('To Account', validators=[DataRequired()])
amount = DecimalField('Amount', validators=[DataRequired(), NumberRange(min=0.01)],
places=2)
description = StringField('Description', default='Transfer')
date = DateField('Date', validators=[DataRequired()], default=date.today)
notes = TextAreaField('Notes', validators=[Optional()])
submit = SubmitField('Transfer')
@transactions_bp.route('/')
@login_required
def index():
tab = request.args.get('tab', 'expense') # 'income' | 'expense'
page = request.args.get('page', 1, type=int)
search = request.args.get('q', '').strip()
category_id = request.args.get('category_id', '', type=str)
account_id = request.args.get('account_id', '', type=str)
date_from = request.args.get('date_from', '')
date_to = request.args.get('date_to', '')
query = Transaction.query.filter(
Transaction.transaction_type == tab
).order_by(Transaction.date.desc(), Transaction.id.desc())
if search:
query = query.filter(Transaction.description.ilike(f'%{search}%'))
if category_id:
query = query.filter(Transaction.category_id == int(category_id))
if account_id:
query = query.filter(Transaction.account_id == int(account_id))
if date_from:
try:
query = query.filter(Transaction.date >= datetime.strptime(date_from, '%Y-%m-%d').date())
except ValueError:
pass
if date_to:
try:
query = query.filter(Transaction.date <= datetime.strptime(date_to, '%Y-%m-%d').date())
except ValueError:
pass
pagination = query.paginate(page=page, per_page=30, error_out=False)
# Counts for tabs
income_count = Transaction.query.filter_by(transaction_type='income').count()
expense_count = Transaction.query.filter_by(transaction_type='expense').count()
accounts = Account.query.filter_by(is_active=True).order_by(Account.name).all()
categories = Category.query.filter(
Category.category_type.in_([tab, 'both']),
Category.is_active == True
).order_by(Category.name).all()
return render_template('transactions/index.html',
pagination=pagination,
transactions=pagination.items,
tab=tab,
income_count=income_count,
expense_count=expense_count,
accounts=accounts,
categories=categories,
search=search,
category_id=category_id,
account_id=account_id,
date_from=date_from,
date_to=date_to)
@transactions_bp.route('/new', methods=['GET', 'POST'])
@login_required
def new():
txn_type = request.args.get('type', 'expense')
if txn_type not in ('income', 'expense'):
txn_type = 'expense'
form = TransactionForm()
form.transaction_type.data = txn_type
form.account_id.choices = _account_choices()
form.category_id.choices = _category_choices(txn_type)
if form.validate_on_submit():
txn = Transaction(
transaction_type=form.transaction_type.data,
account_id=int(form.account_id.data),
category_id=int(form.category_id.data) if form.category_id.data else None,
amount=form.amount.data,
description=form.description.data.strip(),
date=form.date.data,
notes=form.notes.data,
)
db.session.add(txn)
db.session.commit()
calc_balance(txn.account_id)
flash(f'{"Income" if txn_type == "income" else "Expense"} added.', 'success')
return redirect(url_for('transactions.index', tab=txn_type))
return render_template('transactions/form.html',
form=form,
txn_type=txn_type,
title=f'New {"Income" if txn_type == "income" else "Expense"}')
@transactions_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit(id):
txn = db.get_or_404(Transaction, id)
form = TransactionForm(obj=txn)
form.transaction_type.data = txn.transaction_type
form.account_id.choices = _account_choices()
form.category_id.choices = _category_choices(txn.transaction_type)
# Pre-populate foreign keys as strings for SelectField
if request.method == 'GET':
form.account_id.data = str(txn.account_id)
form.category_id.data = str(txn.category_id) if txn.category_id else ''
if form.validate_on_submit():
old_account_id = txn.account_id
txn.account_id = int(form.account_id.data)
txn.category_id = int(form.category_id.data) if form.category_id.data else None
txn.amount = form.amount.data
txn.description = form.description.data.strip()
txn.date = form.date.data
txn.notes = form.notes.data
db.session.commit()
calc_balance(old_account_id)
calc_balance(txn.account_id)
flash('Transaction updated.', 'success')
return redirect(url_for('transactions.index', tab=txn.transaction_type))
return render_template('transactions/form.html',
form=form,
txn=txn,
txn_type=txn.transaction_type,
title='Edit Transaction')
@transactions_bp.route('/<int:id>/delete', methods=['POST'])
@login_required
def delete(id):
txn = db.get_or_404(Transaction, id)
account_id = txn.account_id
txn_type = txn.transaction_type
db.session.delete(txn)
db.session.commit()
calc_balance(account_id)
flash('Transaction deleted.', 'info')
return redirect(url_for('transactions.index', tab=txn_type))
@transactions_bp.route('/transfer', methods=['GET', 'POST'])
@login_required
def transfer():
form = TransferForm()
form.from_account_id.choices = _account_choices()
form.to_account_id.choices = _account_choices()
if form.validate_on_submit():
if form.from_account_id.data == form.to_account_id.data:
flash('Source and destination accounts must be different.', 'warning')
else:
txn = Transaction(
transaction_type='transfer',
account_id=int(form.from_account_id.data),
to_account_id=int(form.to_account_id.data),
amount=form.amount.data,
description=form.description.data.strip() or 'Transfer',
date=form.date.data,
notes=form.notes.data,
)
db.session.add(txn)
db.session.commit()
calc_balance(txn.account_id)
calc_balance(txn.to_account_id)
flash('Transfer recorded.', 'success')
return redirect(url_for('transactions.index'))
return render_template('transactions/transfer.html', form=form)
View File
+85
View File
@@ -0,0 +1,85 @@
"""
Account Service — balance calculated from transactions.
Balance = sum of income - sum of expenses for an account,
plus any incoming transfers minus outgoing transfers.
"""
from decimal import Decimal
from sqlalchemy import func
from app.extensions import db
from app.models.account import Account
from app.models.transaction import Transaction
def calc_balance(account_id):
"""Recalculate and persist the balance for a given account."""
# Income credited to this account
income = db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.account_id == account_id,
Transaction.transaction_type == 'income'
).scalar()
# Expenses debited from this account
expense = db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.account_id == account_id,
Transaction.transaction_type == 'expense'
).scalar()
# Transfers out (this account is source)
transfer_out = db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.account_id == account_id,
Transaction.transaction_type == 'transfer'
).scalar()
# Transfers in (this account is destination)
transfer_in = db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.to_account_id == account_id,
Transaction.transaction_type == 'transfer'
).scalar()
balance = Decimal(str(income)) - Decimal(str(expense)) \
- Decimal(str(transfer_out)) + Decimal(str(transfer_in))
account = db.session.get(Account, account_id)
if account:
account.balance = balance
db.session.commit()
return balance
def recalc_all():
"""Recalculate balances for all accounts."""
for account in Account.query.filter_by(is_active=True).all():
calc_balance(account.id)
def get_total_assets():
"""Sum of all positive-balance accounts (non-credit)."""
result = db.session.query(
func.coalesce(func.sum(Account.balance), 0)
).filter(
Account.is_active == True,
Account.account_type != 'credit_card',
Account.balance > 0
).scalar()
return float(result)
def get_total_liabilities():
"""Sum of credit card balances (negative = owed)."""
result = db.session.query(
func.coalesce(func.sum(Account.balance), 0)
).filter(
Account.is_active == True,
Account.account_type == 'credit_card',
Account.balance < 0
).scalar()
return abs(float(result))
+86
View File
@@ -0,0 +1,86 @@
"""
FX Service — fetches and caches daily USD→VND exchange rate.
Primary source: open.er-api.com (free, no key)
Fallback: last known rate from DB
"""
import requests
from datetime import date, datetime
from app.extensions import db
from app.models.fx_rate import FxRate
ER_API_URL = 'https://open.er-api.com/v6/latest/USD'
REQUEST_TIMEOUT = 8 # seconds
def get_today_rate():
"""
Return today's USD→VND rate as a dict:
{ 'rate': 25450.00, 'date': date(...), 'source': '...', 'is_stale': False }
"""
today = date.today()
# 1. Check cache
cached = FxRate.query.filter_by(date=today).first()
if cached:
return {
'rate': float(cached.usd_to_vnd),
'date': cached.date,
'source': cached.source,
'is_stale': False,
}
# 2. Fetch from API
rate, source = _fetch_from_api()
if rate:
record = FxRate(
date=today,
usd_to_vnd=rate,
source=source,
fetched_at=datetime.utcnow(),
)
db.session.add(record)
try:
db.session.commit()
except Exception:
db.session.rollback()
return {'rate': rate, 'date': today, 'source': source, 'is_stale': False}
# 3. Fallback — last known rate
last = FxRate.query.order_by(FxRate.date.desc()).first()
if last:
return {
'rate': float(last.usd_to_vnd),
'date': last.date,
'source': last.source,
'is_stale': True,
}
# 4. Nothing available
return None
def _fetch_from_api():
"""Try open.er-api.com. Returns (rate, source) or (None, None)."""
try:
resp = requests.get(ER_API_URL, timeout=REQUEST_TIMEOUT)
if resp.status_code == 200:
data = resp.json()
vnd = data.get('rates', {}).get('VND')
if vnd:
return float(vnd), 'exchangerate-api'
except Exception:
pass
return None, None
def get_rate_history(days=30):
"""Return list of FxRate records for last N days, oldest first."""
from datetime import timedelta
since = date.today() - timedelta(days=days)
return (FxRate.query
.filter(FxRate.date >= since)
.order_by(FxRate.date.asc())
.all())
+97
View File
@@ -0,0 +1,97 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block page_title %}{{ title }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-6">
<div class="pcard">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.name.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.name(class="form-control" + (" is-invalid" if form.name.errors else ""), placeholder="e.g. Main Checking") }}
{% for e in form.name.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.account_type.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.account_type(class="form-select" + (" is-invalid" if form.account_type.errors else "")) }}
</div>
<div class="mb-3">
<label class="form-label fw-medium" style="font-size:13px;">Color</label>
<div class="d-flex gap-2 flex-wrap">
{% for c in colors %}
<label style="cursor:pointer;">
<input type="radio" name="color" value="{{ c }}" style="display:none;" {% if (account and account.color == c) or (not account and loop.first) %}checked{% endif %}>
<div style="width:28px;height:28px;border-radius:7px;background:{{ c }};border:3px solid transparent;" class="color-swatch" data-color="{{ c }}"></div>
</label>
{% endfor %}
</div>
{{ form.color(type="hidden", id="colorInput") }}
</div>
<div class="mb-4">
<label class="form-label fw-medium" style="font-size:13px;">Icon</label>
<div class="d-flex gap-2 flex-wrap">
{% for icon_val, icon_label in icons %}
<label style="cursor:pointer;" title="{{ icon_label }}">
<input type="radio" name="icon" value="{{ icon_val }}" style="display:none;" {% if account and account.icon == icon_val %}checked{% elif not account and loop.first %}checked{% endif %}>
<div style="width:36px;height:36px;border-radius:8px;background:#f1f5f9;display:flex;align-items:center;justify-content:center;font-size:18px;border:2px solid transparent;" class="icon-swatch">
<i class="bi {{ icon_val }}"></i>
</div>
</label>
{% endfor %}
</div>
{{ form.icon(type="hidden", id="iconInput") }}
</div>
<div class="mb-4">
{{ form.notes.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.notes(class="form-control", rows=2, placeholder="Optional notes") }}
</div>
<div class="d-flex gap-2">
{{ form.submit(class="btn btn-primary") }}
<a href="{{ url_for('accounts.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// Color swatches
document.querySelectorAll('.color-swatch').forEach(function(sw) {
sw.closest('label').querySelector('input').addEventListener('change', function() {
document.getElementById('colorInput').value = this.value;
document.querySelectorAll('.color-swatch').forEach(s => s.style.borderColor = 'transparent');
sw.style.borderColor = '#fff';
sw.style.outline = '2px solid ' + this.value;
});
if (sw.closest('label').querySelector('input').checked) {
sw.style.borderColor = '#fff';
sw.style.outline = '2px solid ' + sw.dataset.color;
document.getElementById('colorInput').value = sw.dataset.color;
}
});
// Icon swatches
document.querySelectorAll('.icon-swatch').forEach(function(sw) {
sw.closest('label').querySelector('input').addEventListener('change', function() {
document.getElementById('iconInput').value = this.value;
document.querySelectorAll('.icon-swatch').forEach(s => { s.style.background='#f1f5f9'; s.style.borderColor='transparent'; });
sw.style.background = '#dbeafe';
sw.style.borderColor = '#3b82f6';
});
if (sw.closest('label').querySelector('input').checked) {
sw.style.background = '#dbeafe';
sw.style.borderColor = '#3b82f6';
document.getElementById('iconInput').value = sw.closest('label').querySelector('input').value;
}
});
</script>
{% endblock %}
+57
View File
@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}Accounts{% endblock %}
{% block page_title %}Accounts{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('accounts.new') }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i>New Account</a>
{% endblock %}
{% block content %}
{% if accounts %}
<div class="row g-3">
{% for acct in accounts %}
<div class="col-12 col-md-6 col-xl-4">
<div class="pcard" style="border-left: 4px solid {{ acct.color }};">
<div class="d-flex justify-content-between align-items-start mb-3">
<div class="d-flex align-items-center gap-2">
<div style="width:36px;height:36px;border-radius:9px;background:{{ acct.color }}22;color:{{ acct.color }};display:flex;align-items:center;justify-content:center;font-size:18px;">
<i class="bi {{ acct.icon }}"></i>
</div>
<div>
<div style="font-weight:600;font-size:14px;">{{ acct.name }}</div>
<div style="font-size:11px;color:var(--muted);">{{ acct.account_type | replace('_',' ') | title }}</div>
</div>
</div>
<div class="dropdown">
<button class="btn btn-sm" style="background:none;border:none;color:var(--muted);padding:2px 6px;" data-bs-toggle="dropdown"><i class="bi bi-three-dots"></i></button>
<ul class="dropdown-menu dropdown-menu-end" style="font-size:13px;">
<li><a class="dropdown-item" href="{{ url_for('accounts.edit', id=acct.id) }}"><i class="bi bi-pencil me-2"></i>Edit</a></li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="POST" action="{{ url_for('accounts.delete', id=acct.id) }}" onsubmit="return confirm('Remove this account?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="dropdown-item text-danger"><i class="bi bi-trash me-2"></i>Remove</button>
</form>
</li>
</ul>
</div>
</div>
<div class="mono {% if acct.balance >= 0 %}text-income{% else %}text-expense{% endif %}" style="font-size:26px;font-weight:600;">
{{ acct.balance | currency }}
</div>
{% if acct.notes %}
<div style="font-size:12px;color:var(--muted);margin-top:8px;">{{ acct.notes }}</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="pcard text-center py-5">
<i class="bi bi-wallet2 text-muted" style="font-size:3rem;"></i>
<h5 class="mt-3 mb-1">No accounts yet</h5>
<p class="text-muted small mb-3">Add your bank accounts, cash, and credit cards.</p>
<a href="{{ url_for('accounts.new') }}" class="btn btn-primary btn-sm">Add Account</a>
</div>
{% endif %}
{% endblock %}
+179 -381
View File
@@ -1,471 +1,269 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<html lang="en">
<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">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,wght@0,300;0,400;0,500;0,600;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--sidebar-width: 240px;
--sidebar-collapsed-width: 64px;
--sidebar-col-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;
--sidebar-hover: #1e293b;
--sidebar-active: #1e3a5f;
--accent: #3b82f6;
--topbar-h: 56px;
--body-bg: #f1f5f9;
--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);
--text: #0f172a;
--muted: #64748b;
--border: #e2e8f0;
--income: #10b981;
--expense: #ef4444;
--invest: #3b82f6;
--trans: all 0.2s cubic-bezier(.4,0,.2,1);
}
*, *::before, *::after { box-sizing: border-box; }
body { font-family: 'DM Sans', sans-serif; background: var(--body-bg); color: var(--text); margin: 0; overflow-x: hidden; font-size: 14px; }
* { 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 */
#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;
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(--trans); overflow: hidden;
}
#sidebar.collapsed { width: var(--sidebar-col-width); }
#sidebar.collapsed {
width: var(--sidebar-collapsed-width);
.sb-brand {
display: flex; align-items: center; gap: 10px;
padding: 0 16px; height: var(--topbar-h);
border-bottom: 1px solid #1e293b; flex-shrink: 0; overflow: hidden;
}
.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;
.sb-brand .b-icon {
width: 32px; height: 32px; background: var(--accent);
border-radius: 8px; display: flex; align-items: center;
justify-content: center; flex-shrink: 0; color: #fff; font-size: 16px;
}
.sb-brand .b-text { font-size: 15px; font-weight: 600; color: var(--sidebar-text-active); white-space: nowrap; transition: var(--trans); }
#sidebar.collapsed .b-text { opacity: 0; width: 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;
.sb-nav { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 6px 0; scrollbar-width: none; }
.sb-nav::-webkit-scrollbar { display: none; }
.sb-section {
font-size: 10px; font-weight: 600; letter-spacing: .1em;
text-transform: uppercase; color: #475569;
padding: 14px 20px 4px; white-space: nowrap; transition: var(--trans);
}
#sidebar.collapsed .sb-section { opacity: 0; }
.sidebar-brand .brand-text {
font-size: 15px;
font-weight: 600;
color: var(--sidebar-text-active);
letter-spacing: 0.02em;
transition: var(--transition);
opacity: 1;
.sb-link {
display: flex; align-items: center; gap: 12px;
padding: 9px 16px; color: var(--sidebar-text);
text-decoration: none; font-size: 13.5px;
border-radius: 6px; margin: 1px 8px;
white-space: nowrap; overflow: hidden; transition: var(--trans); position: relative;
}
#sidebar.collapsed .brand-text { opacity: 0; width: 0; }
.sidebar-nav {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 0;
scrollbar-width: none;
.sb-link:hover { background: var(--sidebar-hover); color: var(--sidebar-text-active); }
.sb-link.active { background: var(--sidebar-active); color: var(--sidebar-text-active); font-weight: 500; }
.sb-link.active::before {
content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
width: 3px; height: 60%; background: var(--accent); border-radius: 0 2px 2px 0;
}
.sidebar-nav::-webkit-scrollbar { display: none; }
.sb-link i { font-size: 16px; flex-shrink: 0; width: 20px; text-align: center; }
.sb-link .lt { transition: var(--trans); }
#sidebar.collapsed .sb-link .lt { opacity: 0; width: 0; overflow: hidden; }
.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; }
.sb-footer { padding: 8px; border-top: 1px solid #1e293b; flex-shrink: 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 */
#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);
position: fixed; top: 0; right: 0;
left: var(--sidebar-width); height: var(--topbar-h);
background: var(--card-bg); border-bottom: 1px solid var(--border);
display: flex; align-items: center; padding: 0 20px;
z-index: 1030; gap: 12px; transition: var(--trans);
}
#sidebar.collapsed ~ #topbar { left: var(--sidebar-col-width); }
#sidebar.collapsed ~ #topbar,
#sidebar.collapsed ~ * #topbar {
left: var(--sidebar-collapsed-width);
.tb-toggle {
background: none; border: none; color: var(--muted);
font-size: 18px; cursor: pointer; padding: 4px 6px;
border-radius: 6px; line-height: 1; transition: var(--trans);
}
.tb-toggle:hover { background: var(--border); color: var(--text); }
.tb-title { font-size: 15px; font-weight: 600; flex: 1; }
.tb-right { display: flex; align-items: center; gap: 8px; }
.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 {
/* MAIN */
#main {
margin-left: var(--sidebar-width);
margin-top: var(--topbar-height);
margin-top: var(--topbar-h);
padding: 24px;
min-height: calc(100vh - var(--topbar-height));
transition: var(--transition);
min-height: calc(100vh - var(--topbar-h));
transition: var(--trans);
}
#sidebar.collapsed ~ #main { margin-left: var(--sidebar-col-width); }
/* CARDS */
.pcard { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 20px; }
.pcard-sm { padding: 16px; }
.pcard-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 6px; }
/* STAT CARDS */
.stat-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 18px 20px; }
.stat-card .stat-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); }
.stat-card .stat-value { font-size: 24px; font-weight: 600; line-height: 1.2; margin-top: 4px; font-family: 'DM Mono', monospace; }
.stat-card .stat-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 18px; }
/* FLASH */
.flash-wrap {
position: fixed; top: calc(var(--topbar-h) + 12px); right: 16px;
z-index: 2000; display: flex; flex-direction: column; gap: 8px; max-width: 360px;
}
#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; }
/* BADGES */
.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; }
/* UTILITIES */
.text-income { color: var(--income) !important; }
.text-expense { color: var(--expense) !important; }
.text-invest { color: var(--invest) !important; }
.mono { font-family: 'DM Mono', monospace; }
/* TABLE */
.pfm-table { width: 100%; border-collapse: collapse; }
.pfm-table th { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); padding: 10px 12px; border-bottom: 1px solid var(--border); white-space: nowrap; }
.pfm-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; }
.pfm-table tbody tr:hover { background: #f8fafc; }
.pfm-table tbody tr:last-child td { border-bottom: none; }
/* RESPONSIVE */
@media (max-width: 768px) {
#sidebar { transform: translateX(-100%); width: var(--sidebar-width) !important; }
#sidebar.mob-open { transform: translateX(0); }
#topbar, #sidebar.collapsed ~ #topbar { left: 0 !important; }
#main, #sidebar.collapsed ~ #main { margin-left: 0 !important; }
}
.sb-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1039; }
.sb-overlay.on { display: block; }
{% block extra_css %}{% endblock %}
</style>
</head>
<body>
<!-- Sidebar overlay (mobile) -->
<div class="sidebar-overlay" id="sidebarOverlay"></div>
<div class="sb-overlay" id="sbOverlay"></div>
<!-- ── SIDEBAR ────────────────────────────────────── -->
<!-- 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 class="sb-brand">
<div class="b-icon"><i class="bi bi-currency-exchange"></i></div>
<span class="b-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>
<div class="sb-nav">
<a href="{{ url_for('dashboard.index') }}" class="sb-link {% if request.endpoint == 'dashboard.index' %}active{% endif %}">
<i class="bi bi-grid-1x2"></i><span class="lt">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>
<div class="sb-section">Money</div>
<a href="{{ url_for('transactions.index') }}" class="sb-link {% if request.blueprint == 'transactions' %}active{% endif %}">
<i class="bi bi-arrow-left-right"></i><span class="lt">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 href="{{ url_for('transactions.new', type='income') }}" class="sb-link">
<i class="bi bi-arrow-down-circle"></i><span class="lt">Add 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 href="{{ url_for('transactions.new', type='expense') }}" class="sb-link">
<i class="bi bi-arrow-up-circle"></i><span class="lt">Add Expense</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 href="{{ url_for('accounts.index') }}" class="sb-link {% if request.blueprint == 'accounts' %}active{% endif %}">
<i class="bi bi-wallet2"></i><span class="lt">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>
<div class="sb-section">Planning</div>
<a href="#" class="sb-link">
<i class="bi bi-pie-chart"></i><span class="lt">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 href="#" class="sb-link">
<i class="bi bi-bullseye"></i><span class="lt">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>
<div class="sb-section">Growth</div>
<a href="#" class="sb-link">
<i class="bi bi-graph-up-arrow"></i><span class="lt">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 href="#" class="sb-link">
<i class="bi bi-file-earmark-bar-graph"></i><span class="lt">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>
<div class="sb-section">AI</div>
<a href="#" class="sb-link">
<i class="bi bi-stars"></i><span class="lt">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>
<div class="sb-footer">
<a href="{{ url_for('categories.index') }}" class="sb-link {% if request.blueprint == 'categories' %}active{% endif %}">
<i class="bi bi-tags"></i><span class="lt">Categories</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 href="#" class="sb-link">
<i class="bi bi-gear"></i><span class="lt">Settings</span>
</a>
<a href="{{ url_for('auth.logout') }}" class="sb-link">
<i class="bi bi-box-arrow-right"></i><span class="lt">Logout</span>
</a>
</div>
</nav>
<!-- ── TOPBAR ──────────────────────────────────────── -->
<!-- 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>
<button class="tb-toggle" id="sbToggle"><i class="bi bi-list"></i></button>
<span class="tb-title">{% block page_title %}{% endblock %}</span>
<div class="tb-right">
{% block topbar_actions %}{% endblock %}
<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">
<!-- FLASH -->
<div class="flash-wrap">
{% 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>
{% for cat, msg in messages %}
<div class="alert alert-{{ 'danger' if cat == 'error' else cat }} alert-dismissible fade show shadow-sm mb-0 py-2 px-3" style="font-size:13px;border-radius:8px;" role="alert">
{{ msg }}<button type="button" class="btn-close" style="padding:.4rem;" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
</div>
<!-- ── MAIN CONTENT ───────────────────────────────── -->
<main id="main-content">
{% block content %}{% endblock %}
</main>
<!-- MAIN -->
<main id="main">{% 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');
(function(){
const sb = document.getElementById('sidebar');
const overlay = document.getElementById('sbOverlay');
const KEY = 'pfm_sb';
const mob = () => window.innerWidth < 769;
if (!mob() && localStorage.getItem(KEY)==='1') sb.classList.add('collapsed');
document.getElementById('sbToggle').addEventListener('click', function(){
if (mob()) { sb.classList.toggle('mob-open'); overlay.classList.toggle('on'); }
else { sb.classList.toggle('collapsed'); localStorage.setItem(KEY, sb.classList.contains('collapsed')?'1':'0'); }
});
// Auto-dismiss flash messages after 4s
document.querySelectorAll('#flashContainer .alert').forEach(function (el) {
setTimeout(function () {
const bsAlert = bootstrap.Alert.getOrCreateInstance(el);
bsAlert.close();
}, 4000);
overlay.addEventListener('click', function(){ sb.classList.remove('mob-open'); overlay.classList.remove('on'); });
document.querySelectorAll('.flash-wrap .alert').forEach(function(el){
setTimeout(function(){ bootstrap.Alert.getOrCreateInstance(el).close(); }, 4500);
});
})();
</script>
{% block extra_js %}{% endblock %}
</body>
</html>
+83
View File
@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block page_title %}{{ title }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-5">
<div class="pcard">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.name.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.name(class="form-control" + (" is-invalid" if form.name.errors else ""), placeholder="Category name") }}
{% for e in form.name.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.category_type.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.category_type(class="form-select") }}
</div>
<div class="mb-3">
<label class="form-label fw-medium" style="font-size:13px;">Color</label>
<div class="d-flex gap-2 flex-wrap">
{% for c in colors %}
<label style="cursor:pointer;">
<input type="radio" name="color" value="{{ c }}" style="display:none;" {% if (category and category.color == c) or (not category and loop.first) %}checked{% endif %}>
<div style="width:26px;height:26px;border-radius:6px;background:{{ c }};border:3px solid transparent;" class="color-swatch" data-color="{{ c }}"></div>
</label>
{% endfor %}
</div>
{{ form.color(type="hidden", id="colorInput") }}
</div>
<div class="mb-4">
<label class="form-label fw-medium" style="font-size:13px;">Icon</label>
<div class="d-flex gap-2 flex-wrap">
{% for icon_val in icons %}
<label style="cursor:pointer;" title="{{ icon_val }}">
<input type="radio" name="icon" value="{{ icon_val }}" style="display:none;" {% if category and category.icon == icon_val %}checked{% elif not category and loop.first %}checked{% endif %}>
<div style="width:34px;height:34px;border-radius:8px;background:#f1f5f9;display:flex;align-items:center;justify-content:center;font-size:16px;border:2px solid transparent;" class="icon-swatch">
<i class="bi {{ icon_val }}"></i>
</div>
</label>
{% endfor %}
</div>
{{ form.icon(type="hidden", id="iconInput") }}
</div>
<div class="d-flex gap-2">
{{ form.submit(class="btn btn-primary") }}
<a href="{{ url_for('categories.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.querySelectorAll('.color-swatch').forEach(function(sw) {
const inp = sw.closest('label').querySelector('input');
inp.addEventListener('change', function() {
document.getElementById('colorInput').value = this.value;
document.querySelectorAll('.color-swatch').forEach(s => { s.style.borderColor='transparent'; s.style.outline='none'; });
sw.style.borderColor = '#fff';
sw.style.outline = '2px solid ' + this.value;
});
if (inp.checked) { sw.style.borderColor='#fff'; sw.style.outline='2px solid '+sw.dataset.color; document.getElementById('colorInput').value=sw.dataset.color; }
});
document.querySelectorAll('.icon-swatch').forEach(function(sw) {
const inp = sw.closest('label').querySelector('input');
inp.addEventListener('change', function() {
document.getElementById('iconInput').value = this.value;
document.querySelectorAll('.icon-swatch').forEach(s => { s.style.background='#f1f5f9'; s.style.borderColor='transparent'; });
sw.style.background='#dbeafe'; sw.style.borderColor='#3b82f6';
});
if (inp.checked) { sw.style.background='#dbeafe'; sw.style.borderColor='#3b82f6'; document.getElementById('iconInput').value=inp.value; }
});
</script>
{% endblock %}
+87
View File
@@ -0,0 +1,87 @@
{% extends "base.html" %}
{% block title %}Categories{% endblock %}
{% block page_title %}Categories{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('categories.new', type='expense') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i>Expense</a>
<a href="{{ url_for('categories.new', type='income') }}" class="btn btn-sm btn-primary ms-1" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i>Income</a>
{% endblock %}
{% block content %}
<div class="row g-4">
<div class="col-12 col-lg-6">
<div class="pcard">
<div class="pcard-title mb-3">Expense Categories</div>
{% if expense_cats %}
<table class="pfm-table">
<thead><tr><th>Category</th><th>Type</th><th class="text-end">Actions</th></tr></thead>
<tbody>
{% for cat in expense_cats %}
<tr>
<td>
<div class="d-flex align-items-center gap-2">
<div style="width:28px;height:28px;border-radius:7px;background:{{ cat.color }}22;color:{{ cat.color }};display:flex;align-items:center;justify-content:center;font-size:14px;">
<i class="bi {{ cat.icon }}"></i>
</div>
<span style="font-size:13px;font-weight:500;">{{ cat.name }}</span>
{% if cat.is_system %}<span style="font-size:10px;background:#f1f5f9;color:var(--muted);border-radius:4px;padding:1px 5px;">system</span>{% endif %}
</div>
</td>
<td><span style="font-size:12px;color:var(--muted);">{{ cat.category_type | title }}</span></td>
<td class="text-end">
<a href="{{ url_for('categories.edit', id=cat.id) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;padding:2px 8px;">Edit</a>
{% if not cat.is_system %}
<form method="POST" action="{{ url_for('categories.delete', id=cat.id) }}" style="display:inline;" onsubmit="return confirm('Remove this category?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger ms-1" style="font-size:11px;padding:2px 8px;">Del</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted small">No expense categories.</p>
{% endif %}
</div>
</div>
<div class="col-12 col-lg-6">
<div class="pcard">
<div class="pcard-title mb-3">Income Categories</div>
{% if income_cats %}
<table class="pfm-table">
<thead><tr><th>Category</th><th>Type</th><th class="text-end">Actions</th></tr></thead>
<tbody>
{% for cat in income_cats %}
<tr>
<td>
<div class="d-flex align-items-center gap-2">
<div style="width:28px;height:28px;border-radius:7px;background:{{ cat.color }}22;color:{{ cat.color }};display:flex;align-items:center;justify-content:center;font-size:14px;">
<i class="bi {{ cat.icon }}"></i>
</div>
<span style="font-size:13px;font-weight:500;">{{ cat.name }}</span>
{% if cat.is_system %}<span style="font-size:10px;background:#f1f5f9;color:var(--muted);border-radius:4px;padding:1px 5px;">system</span>{% endif %}
</div>
</td>
<td><span style="font-size:12px;color:var(--muted);">{{ cat.category_type | title }}</span></td>
<td class="text-end">
<a href="{{ url_for('categories.edit', id=cat.id) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;padding:2px 8px;">Edit</a>
{% if not cat.is_system %}
<form method="POST" action="{{ url_for('categories.delete', id=cat.id) }}" style="display:inline;" onsubmit="return confirm('Remove this category?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger ms-1" style="font-size:11px;padding:2px 8px;">Del</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted small">No income categories.</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+266 -5
View File
@@ -2,14 +2,275 @@
{% block title %}Dashboard{% endblock %}
{% block page_title %}Dashboard{% endblock %}
{% block extra_css %}
.fx-card { background: #0f172a; border-color: #1e293b; color: #f1f5f9; cursor: pointer; transition: all .2s; }
.fx-card:hover { border-color: #3b82f6 !important; }
.period-btn.active { background: #3b82f6; color: #fff; border-color: #3b82f6; }
{% endblock %}
{% block topbar_actions %}
<div class="d-flex align-items-center gap-1">
<a href="?period=this_month" class="btn btn-sm btn-outline-secondary period-btn {% if period=='this_month' %}active{% endif %}" style="font-size:12px;">This Month</a>
<a href="?period=last_month" class="btn btn-sm btn-outline-secondary period-btn {% if period=='last_month' %}active{% endif %}" style="font-size:12px;">Last Month</a>
<button class="btn btn-sm btn-outline-secondary period-btn {% if period=='custom' %}active{% endif %}" style="font-size:12px;" data-bs-toggle="modal" data-bs-target="#customModal">Custom</button>
</div>
{% endblock %}
{% block content %}
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h5 class="mb-0 fw-semibold">{{ period_label }}</h5>
<small class="text-muted">{{ date_from.strftime('%b %d') }} {{ date_to.strftime('%b %d, %Y') }}</small>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('transactions.new', type='income') }}" class="btn btn-sm" style="background:#d1fae5;color:#065f46;font-size:12px;"><i class="bi bi-plus-lg me-1"></i>Income</a>
<a href="{{ url_for('transactions.new', type='expense') }}" class="btn btn-sm" style="background:#fee2e2;color:#991b1b;font-size:12px;"><i class="bi bi-plus-lg me-1"></i>Expense</a>
</div>
</div>
<!-- Summary Cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Income</div>
<div class="stat-value text-income">{{ total_income | currency }}</div>
</div>
<div class="stat-icon" style="background:#d1fae5;color:#065f46;"><i class="bi bi-arrow-down-circle"></i></div>
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Expenses</div>
<div class="stat-value text-expense">{{ total_expense | currency }}</div>
</div>
<div class="stat-icon" style="background:#fee2e2;color:#991b1b;"><i class="bi bi-arrow-up-circle"></i></div>
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Net Cash Flow</div>
<div class="stat-value {% if net_cash_flow >= 0 %}text-income{% else %}text-expense{% endif %}">{{ net_cash_flow | currency }}</div>
</div>
<div class="stat-icon" style="background:#dbeafe;color:#1e40af;"><i class="bi bi-arrow-left-right"></i></div>
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Net Worth</div>
<div class="stat-value {% if net_worth >= 0 %}text-invest{% else %}text-expense{% endif %}">{{ net_worth | currency }}</div>
</div>
<div class="stat-icon" style="background:#ede9fe;color:#5b21b6;"><i class="bi bi-bank"></i></div>
</div>
</div>
</div>
</div>
<!-- Charts Row -->
<div class="row g-3 mb-4">
<div class="col-12 col-xl-8">
<div class="pcard h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="pcard-title mb-0">Cash Flow — Last 6 Months</span>
<div class="d-flex gap-3" style="font-size:11px;color:var(--muted);">
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#10b981;margin-right:4px;"></span>Income</span>
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#ef4444;margin-right:4px;"></span>Expenses</span>
</div>
</div>
<div style="position:relative;height:220px;"><canvas id="cashflowChart"></canvas></div>
</div>
</div>
<div class="col-12 col-xl-4 d-flex flex-column gap-3">
<!-- USD/VND Widget -->
{% if fx %}
<div class="pcard fx-card" onclick="toggleFxChart()">
<div class="d-flex justify-content-between align-items-center">
<div>
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.07em;color:#475569;">USD → VND</div>
<div class="mono" style="font-size:24px;font-weight:600;margin-top:4px;">₫{{ "{:,.0f}".format(fx.rate) }}</div>
<div style="font-size:11px;color:#64748b;margin-top:2px;">
1 USD · {{ fx.date.strftime('%b %d, %Y') }}
{% if fx.is_stale %}<span title="Rate may be outdated">⚠️</span>{% endif %}
</div>
</div>
<div style="font-size:32px;opacity:.15;font-family:serif;"></div>
</div>
<div id="fxChartWrap" style="display:none;margin-top:12px;">
<div style="height:70px;"><canvas id="fxChart"></canvas></div>
</div>
</div>
{% else %}
<div class="pcard" style="background:#1e293b;border-color:#334155;">
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.07em;color:#475569;">USD → VND</div>
<div style="font-size:13px;color:#94a3b8;margin-top:6px;">Rate unavailable</div>
</div>
{% endif %}
<!-- Top Spending -->
<div class="pcard flex-grow-1">
<div class="pcard-title">Top Spending</div>
{% if top_categories %}
{% set max_val = top_categories[0].total %}
{% for cat in top_categories %}
<div class="mb-2">
<div class="d-flex justify-content-between align-items-center mb-1">
<span style="font-size:13px;"><i class="bi {{ cat.icon }} me-1" style="color:{{ cat.color }};"></i>{{ cat.name }}</span>
<span class="mono" style="font-size:12px;color:var(--muted);">{{ cat.total | currency }}</span>
</div>
<div style="height:3px;background:#f1f5f9;border-radius:2px;">
<div style="height:3px;background:{{ cat.color }};border-radius:2px;width:{{ ((cat.total / max_val) * 100)|round(1) }}%;"></div>
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted small mb-0">No expenses this period.</p>
{% endif %}
</div>
</div>
</div>
<!-- Accounts + Recent Transactions -->
<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 class="col-12 col-lg-4">
<div class="pcard h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="pcard-title mb-0">Accounts</span>
<a href="{{ url_for('accounts.new') }}" class="btn btn-sm btn-outline-primary" style="font-size:11px;padding:2px 8px;"><i class="bi bi-plus-lg"></i></a>
</div>
{% if accounts %}
{% for acct in accounts %}
<div class="d-flex justify-content-between align-items-center py-2" style="border-bottom:1px solid var(--border);">
<div class="d-flex align-items-center gap-2">
<div style="width:28px;height:28px;border-radius:7px;background:{{ acct.color }}22;color:{{ acct.color }};display:flex;align-items:center;justify-content:center;font-size:14px;">
<i class="bi {{ acct.icon }}"></i>
</div>
<div>
<div style="font-size:13px;font-weight:500;">{{ acct.name }}</div>
<div style="font-size:11px;color:var(--muted);">{{ acct.account_type | replace('_',' ') | title }}</div>
</div>
</div>
<span class="mono {% if acct.balance >= 0 %}text-income{% else %}text-expense{% endif %}" style="font-size:13px;font-weight:600;">{{ acct.balance | currency }}</span>
</div>
{% endfor %}
<div class="mt-3 pt-1" style="font-size:12px;color:var(--muted);">
<div class="d-flex justify-content-between"><span>Assets</span><span class="mono text-income">{{ total_assets | currency }}</span></div>
<div class="d-flex justify-content-between mt-1"><span>Liabilities</span><span class="mono text-expense">{{ total_liabilities | currency }}</span></div>
</div>
{% else %}
<p class="text-muted small">No accounts. <a href="{{ url_for('accounts.new') }}">Add one</a>.</p>
{% endif %}
</div>
</div>
<div class="col-12 col-lg-8">
<div class="pcard h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="pcard-title mb-0">Recent Transactions</span>
<a href="{{ url_for('transactions.index') }}" style="font-size:12px;color:#3b82f6;">View all</a>
</div>
{% if recent_txns %}
<table class="pfm-table">
<thead>
<tr><th>Date</th><th>Description</th><th>Category</th><th class="text-end">Amount</th></tr>
</thead>
<tbody>
{% for txn in recent_txns %}
<tr>
<td style="font-size:12px;color:var(--muted);white-space:nowrap;">{{ txn.date.strftime('%b %d') }}</td>
<td>
<div style="font-size:13px;font-weight:500;">{{ txn.description }}</div>
<div style="font-size:11px;color:var(--muted);">{{ txn.account.name if txn.account else '—' }}</div>
</td>
<td>
{% if txn.category %}
<span style="font-size:12px;"><i class="bi {{ txn.category.icon }}" style="color:{{ txn.category.color }};"></i> {{ txn.category.name }}</span>
{% else %}<span class="text-muted" style="font-size:12px;"></span>{% endif %}
</td>
<td class="text-end mono {% if txn.transaction_type=='income' %}text-income{% else %}text-expense{% endif %}" style="font-size:13px;font-weight:600;">
{% if txn.transaction_type=='income' %}+{% else %}-{% endif %}{{ txn.amount | currency }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted small">No transactions yet. <a href="{{ url_for('transactions.new', type='expense') }}">Add one</a>.</p>
{% endif %}
</div>
</div>
</div>
<!-- Custom Range Modal -->
<div class="modal fade" id="customModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header py-2 px-3"><h6 class="modal-title mb-0">Custom Range</h6><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<form method="GET" action="{{ url_for('dashboard.index') }}">
<input type="hidden" name="period" value="custom">
<div class="modal-body">
<div class="mb-3">
<label class="form-label" style="font-size:12px;">From</label>
<input type="date" name="date_from" class="form-control form-control-sm" value="{{ date_from.strftime('%Y-%m-%d') }}">
</div>
<div>
<label class="form-label" style="font-size:12px;">To</label>
<input type="date" name="date_to" class="form-control form-control-sm" value="{{ date_to.strftime('%Y-%m-%d') }}">
</div>
</div>
<div class="modal-footer py-2 px-3"><button type="submit" class="btn btn-primary btn-sm">Apply</button></div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script>
(function(){
const ctx = document.getElementById('cashflowChart').getContext('2d');
new Chart(ctx, {
type: 'bar',
data: {
labels: {{ chart_months | tojson }},
datasets: [
{ label:'Income', data: {{ chart_income | tojson }}, backgroundColor:'#10b98133', borderColor:'#10b981', borderWidth:2, borderRadius:4 },
{ label:'Expenses', data: {{ chart_expense | tojson }}, backgroundColor:'#ef444433', borderColor:'#ef4444', borderWidth:2, borderRadius:4 }
]
},
options: {
responsive:true, maintainAspectRatio:false,
plugins:{ legend:{ display:false } },
scales:{
x:{ grid:{ display:false }, ticks:{ font:{ size:11 } } },
y:{ grid:{ color:'#f1f5f9' }, ticks:{ font:{ size:11 }, callback: v => '{{ current_user.currency_symbol }}' + (v>=1000?(v/1000).toFixed(0)+'K':v) } }
}
}
});
})();
var fxChart = null;
function toggleFxChart(){
const wrap = document.getElementById('fxChartWrap');
wrap.style.display = wrap.style.display === 'none' ? 'block' : 'none';
if (wrap.style.display === 'block' && !fxChart) {
const h = {{ fx_history_data | tojson }};
fxChart = new Chart(document.getElementById('fxChart').getContext('2d'), {
type:'line',
data:{ labels:h.dates, datasets:[{ data:h.rates, borderColor:'#3b82f6', borderWidth:1.5, pointRadius:0, tension:.3, fill:false }] },
options:{ responsive:true, maintainAspectRatio:false, plugins:{ legend:{ display:false } },
scales:{ x:{ display:false }, y:{ grid:{ color:'#1e293b' }, ticks:{ color:'#64748b', font:{ size:9 }, callback: v=>'₫'+(v/1000).toFixed(0)+'K' } } } }
});
}
}
</script>
{% endblock %}
+60
View File
@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block page_title %}{{ title }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-6">
<div class="pcard" style="border-top: 4px solid {% if txn_type=='income' %}var(--income){% else %}var(--expense){% endif %};">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
{{ form.transaction_type() }}
<div class="mb-3">
{{ form.description.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.description(class="form-control" + (" is-invalid" if form.description.errors else ""), placeholder="What was this for?") }}
{% for e in form.description.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="row g-3 mb-3">
<div class="col-6">
{{ form.amount.label(class="form-label fw-medium", style="font-size:13px;") }}
<div class="input-group">
<span class="input-group-text" style="font-size:13px;">{{ current_user.currency_symbol }}</span>
{{ form.amount(class="form-control" + (" is-invalid" if form.amount.errors else ""), placeholder="0.00") }}
</div>
{% for e in form.amount.errors %}<div class="text-danger mt-1" style="font-size:12px;">{{ e }}</div>{% endfor %}
</div>
<div class="col-6">
{{ form.date.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.date(class="form-control" + (" is-invalid" if form.date.errors else "")) }}
</div>
</div>
<div class="mb-3">
{{ form.account_id.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.account_id(class="form-select" + (" is-invalid" if form.account_id.errors else "")) }}
{% for e in form.account_id.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.category_id.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.category_id(class="form-select") }}
</div>
<div class="mb-4">
{{ form.notes.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.notes(class="form-control", rows=2, placeholder="Optional notes") }}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn {% if txn_type=='income' %}btn-success{% else %}btn-danger{% endif %}">
Save {{ txn_type | title }}
</button>
<a href="{{ url_for('transactions.index', tab=txn_type) }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
+130
View File
@@ -0,0 +1,130 @@
{% extends "base.html" %}
{% block title %}Transactions{% endblock %}
{% block page_title %}Transactions{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('transactions.new', type='income') }}" class="btn btn-sm" style="background:#d1fae5;color:#065f46;font-size:12px;"><i class="bi bi-plus-lg me-1"></i>Income</a>
<a href="{{ url_for('transactions.new', type='expense') }}" class="btn btn-sm ms-1" style="background:#fee2e2;color:#991b1b;font-size:12px;"><i class="bi bi-plus-lg me-1"></i>Expense</a>
<a href="{{ url_for('transactions.transfer') }}" class="btn btn-sm btn-outline-secondary ms-1" style="font-size:12px;"><i class="bi bi-arrow-left-right me-1"></i>Transfer</a>
{% endblock %}
{% block content %}
<!-- Tabs -->
<div class="d-flex gap-1 mb-3">
<a href="{{ url_for('transactions.index', tab='expense', q=search, account_id=account_id, category_id=category_id, date_from=date_from, date_to=date_to) }}"
class="btn btn-sm {% if tab=='expense' %}btn-danger{% else %}btn-outline-secondary{% endif %}" style="font-size:13px;">
<i class="bi bi-arrow-up-circle me-1"></i>Expenses
<span class="badge ms-1" style="font-size:10px;background:{% if tab=='expense' %}rgba(255,255,255,.25){% else %}#fee2e2;color:#991b1b{% endif %};">{{ expense_count }}</span>
</a>
<a href="{{ url_for('transactions.index', tab='income', q=search, account_id=account_id, category_id=category_id, date_from=date_from, date_to=date_to) }}"
class="btn btn-sm {% if tab=='income' %}btn-success{% else %}btn-outline-secondary{% endif %}" style="font-size:13px;">
<i class="bi bi-arrow-down-circle me-1"></i>Income
<span class="badge ms-1" style="font-size:10px;background:{% if tab=='income' %}rgba(255,255,255,.25){% else %}#d1fae5;color:#065f46{% endif %};">{{ income_count }}</span>
</a>
</div>
<!-- Filters -->
<div class="pcard pcard-sm mb-3">
<form method="GET" action="{{ url_for('transactions.index') }}" class="row g-2 align-items-end">
<input type="hidden" name="tab" value="{{ tab }}">
<div class="col-12 col-md-3">
<input type="text" name="q" class="form-control form-control-sm" placeholder="Search description…" value="{{ search }}">
</div>
<div class="col-6 col-md-2">
<select name="category_id" class="form-select form-select-sm">
<option value="">All categories</option>
{% for cat in categories %}
<option value="{{ cat.id }}" {% if category_id == cat.id|string %}selected{% endif %}>{{ cat.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-6 col-md-2">
<select name="account_id" class="form-select form-select-sm">
<option value="">All accounts</option>
{% for acct in accounts %}
<option value="{{ acct.id }}" {% if account_id == acct.id|string %}selected{% endif %}>{{ acct.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-6 col-md-2">
<input type="date" name="date_from" class="form-control form-control-sm" value="{{ date_from }}" placeholder="From">
</div>
<div class="col-6 col-md-2">
<input type="date" name="date_to" class="form-control form-control-sm" value="{{ date_to }}" placeholder="To">
</div>
<div class="col-12 col-md-1 d-flex gap-1">
<button type="submit" class="btn btn-sm btn-primary flex-grow-1"><i class="bi bi-search"></i></button>
<a href="{{ url_for('transactions.index', tab=tab) }}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-x-lg"></i></a>
</div>
</form>
</div>
<!-- Table -->
<div class="pcard p-0">
{% if transactions %}
<table class="pfm-table">
<thead>
<tr>
<th style="padding-left:20px;">Date</th>
<th>Description</th>
<th>Category</th>
<th>Account</th>
<th class="text-end">Amount</th>
<th class="text-end" style="padding-right:20px;">Actions</th>
</tr>
</thead>
<tbody>
{% for txn in transactions %}
<tr>
<td style="padding-left:20px;font-size:12px;color:var(--muted);white-space:nowrap;">{{ txn.date.strftime('%b %d, %Y') }}</td>
<td>
<div style="font-size:13px;font-weight:500;">{{ txn.description }}</div>
{% if txn.notes %}<div style="font-size:11px;color:var(--muted);">{{ txn.notes | truncate(60) }}</div>{% endif %}
</td>
<td>
{% if txn.category %}
<span style="font-size:12px;white-space:nowrap;">
<i class="bi {{ txn.category.icon }}" style="color:{{ txn.category.color }};"></i> {{ txn.category.name }}
</span>
{% else %}<span class="text-muted" style="font-size:12px;"></span>{% endif %}
</td>
<td style="font-size:12px;color:var(--muted);">{{ txn.account.name if txn.account else '—' }}</td>
<td class="text-end mono {% if txn.transaction_type=='income' %}text-income{% else %}text-expense{% endif %}" style="font-size:13px;font-weight:600;white-space:nowrap;">
{% if txn.transaction_type=='income' %}+{% else %}-{% endif %}{{ txn.amount | currency }}
</td>
<td class="text-end" style="padding-right:20px;white-space:nowrap;">
<a href="{{ url_for('transactions.edit', id=txn.id) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;padding:2px 8px;">Edit</a>
<form method="POST" action="{{ url_for('transactions.delete', id=txn.id) }}" style="display:inline;" onsubmit="return confirm('Delete this transaction?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger ms-1" style="font-size:11px;padding:2px 8px;">Del</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Pagination -->
{% if pagination.pages > 1 %}
<div class="d-flex justify-content-between align-items-center px-4 py-3" style="border-top:1px solid var(--border);font-size:12px;color:var(--muted);">
<span>Showing {{ ((pagination.page-1)*30)+1 }}{{ [pagination.page*30, pagination.total]|min }} of {{ pagination.total }}</span>
<div class="d-flex gap-1">
{% if pagination.has_prev %}
<a href="{{ url_for('transactions.index', tab=tab, page=pagination.prev_num, q=search, category_id=category_id, account_id=account_id, date_from=date_from, date_to=date_to) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">← Prev</a>
{% endif %}
{% if pagination.has_next %}
<a href="{{ url_for('transactions.index', tab=tab, page=pagination.next_num, q=search, category_id=category_id, account_id=account_id, date_from=date_from, date_to=date_to) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">Next →</a>
{% endif %}
</div>
</div>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="bi bi-inbox text-muted" style="font-size:2.5rem;"></i>
<p class="text-muted mt-2 mb-3">No {{ tab }} transactions found.</p>
<a href="{{ url_for('transactions.new', type=tab) }}" class="btn btn-sm btn-primary">Add {{ tab | title }}</a>
</div>
{% endif %}
</div>
{% endblock %}
+58
View File
@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}Transfer{% endblock %}
{% block page_title %}Transfer Between Accounts{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-5">
<div class="pcard" style="border-top: 4px solid #3b82f6;">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.from_account_id.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.from_account_id(class="form-select") }}
</div>
<div class="text-center my-2" style="font-size:20px;color:var(--muted);">
<i class="bi bi-arrow-down"></i>
</div>
<div class="mb-3">
{{ form.to_account_id.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.to_account_id(class="form-select") }}
</div>
<div class="row g-3 mb-3">
<div class="col-6">
{{ form.amount.label(class="form-label fw-medium", style="font-size:13px;") }}
<div class="input-group">
<span class="input-group-text" style="font-size:13px;">{{ current_user.currency_symbol }}</span>
{{ form.amount(class="form-control", placeholder="0.00") }}
</div>
</div>
<div class="col-6">
{{ form.date.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.date(class="form-control") }}
</div>
</div>
<div class="mb-3">
{{ form.description.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.description(class="form-control", placeholder="Transfer description") }}
</div>
<div class="mb-4">
{{ form.notes.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.notes(class="form-control", rows=2) }}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Record Transfer</button>
<a href="{{ url_for('transactions.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""
Cron script: fetch daily USD/VND exchange rate.
Run by systemd timer pfm-fxrate.timer at 8AM daily.
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app import create_app
from app.services.fx_service import get_today_rate
app = create_app()
if __name__ == '__main__':
with app.app_context():
result = get_today_rate()
if result:
status = '(stale)' if result.get('is_stale') else ''
print(f"[fx_rate] 1 USD = {result['rate']:,.0f} VND "
f"[{result['source']}] {result['date']} {status}")
else:
print("[fx_rate] Failed to fetch rate.")
sys.exit(1)