05/31 Phase 2
This commit is contained in:
+7
-2
@@ -25,9 +25,15 @@ def create_app(config_name=None):
|
|||||||
# Register blueprints
|
# Register blueprints
|
||||||
from app.routes.auth import auth_bp
|
from app.routes.auth import auth_bp
|
||||||
from app.routes.dashboard import dashboard_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(auth_bp)
|
||||||
app.register_blueprint(dashboard_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
|
# Import all models so Flask-Migrate can see them
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
@@ -38,12 +44,11 @@ def create_app(config_name=None):
|
|||||||
AiInsight, FxRate
|
AiInsight, FxRate
|
||||||
)
|
)
|
||||||
|
|
||||||
# Jinja2 template globals
|
# Jinja2 globals
|
||||||
app.jinja_env.globals['format_currency'] = format_currency
|
app.jinja_env.globals['format_currency'] = format_currency
|
||||||
app.jinja_env.globals['format_percent'] = format_percent
|
app.jinja_env.globals['format_percent'] = format_percent
|
||||||
app.jinja_env.globals['format_large_number'] = format_large_number
|
app.jinja_env.globals['format_large_number'] = format_large_number
|
||||||
|
|
||||||
# Jinja2 filters
|
|
||||||
@app.template_filter('currency')
|
@app.template_filter('currency')
|
||||||
def currency_filter(value, symbol=None):
|
def currency_filter(value, symbol=None):
|
||||||
return format_currency(value, symbol)
|
return format_currency(value, symbol)
|
||||||
|
|||||||
@@ -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'))
|
||||||
@@ -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
@@ -1,10 +1,158 @@
|
|||||||
from flask import Blueprint, render_template
|
from flask import Blueprint, render_template, request, jsonify
|
||||||
from flask_login import login_required
|
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__)
|
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('/')
|
@dashboard_bp.route('/')
|
||||||
@login_required
|
@login_required
|
||||||
def index():
|
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],
|
||||||
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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))
|
||||||
@@ -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())
|
||||||
@@ -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 %}
|
||||||
@@ -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
@@ -1,471 +1,269 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" data-bs-theme="light">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}PFM{% endblock %} — Personal Finance</title>
|
<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">
|
<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">
|
<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:ital,wght@0,300;0,400;0,500;0,600;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
<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>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--sidebar-width: 240px;
|
--sidebar-width: 240px;
|
||||||
--sidebar-collapsed-width: 64px;
|
--sidebar-col-width: 64px;
|
||||||
--sidebar-bg: #0f172a;
|
--sidebar-bg: #0f172a;
|
||||||
--sidebar-text: #94a3b8;
|
--sidebar-text: #94a3b8;
|
||||||
--sidebar-text-active: #f1f5f9;
|
--sidebar-text-active: #f1f5f9;
|
||||||
--sidebar-hover-bg: #1e293b;
|
--sidebar-hover: #1e293b;
|
||||||
--sidebar-active-bg: #1e3a5f;
|
--sidebar-active: #1e3a5f;
|
||||||
--sidebar-accent: #3b82f6;
|
--accent: #3b82f6;
|
||||||
--topbar-height: 56px;
|
--topbar-h: 56px;
|
||||||
--body-bg: #f8fafc;
|
--body-bg: #f1f5f9;
|
||||||
--card-bg: #ffffff;
|
--card-bg: #ffffff;
|
||||||
--text-primary: #0f172a;
|
--text: #0f172a;
|
||||||
--text-muted: #64748b;
|
--muted: #64748b;
|
||||||
--border-color: #e2e8f0;
|
--border: #e2e8f0;
|
||||||
--income-color: #10b981;
|
--income: #10b981;
|
||||||
--expense-color: #ef4444;
|
--expense: #ef4444;
|
||||||
--investment-color: #3b82f6;
|
--invest: #3b82f6;
|
||||||
--transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
--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; }
|
/* SIDEBAR */
|
||||||
|
|
||||||
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;
|
position: fixed; top: 0; left: 0; height: 100vh;
|
||||||
top: 0;
|
width: var(--sidebar-width); background: var(--sidebar-bg);
|
||||||
left: 0;
|
display: flex; flex-direction: column; z-index: 1040;
|
||||||
height: 100vh;
|
transition: var(--trans); overflow: hidden;
|
||||||
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-col-width); }
|
||||||
|
|
||||||
#sidebar.collapsed {
|
.sb-brand {
|
||||||
width: var(--sidebar-collapsed-width);
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 0 16px; height: var(--topbar-h);
|
||||||
|
border-bottom: 1px solid #1e293b; flex-shrink: 0; overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.sb-brand .b-icon {
|
||||||
.sidebar-brand {
|
width: 32px; height: 32px; background: var(--accent);
|
||||||
display: flex;
|
border-radius: 8px; display: flex; align-items: center;
|
||||||
align-items: center;
|
justify-content: center; flex-shrink: 0; color: #fff; font-size: 16px;
|
||||||
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-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 {
|
.sb-nav { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 6px 0; scrollbar-width: none; }
|
||||||
width: 32px;
|
.sb-nav::-webkit-scrollbar { display: none; }
|
||||||
height: 32px;
|
|
||||||
background: var(--sidebar-accent);
|
.sb-section {
|
||||||
border-radius: 8px;
|
font-size: 10px; font-weight: 600; letter-spacing: .1em;
|
||||||
display: flex;
|
text-transform: uppercase; color: #475569;
|
||||||
align-items: center;
|
padding: 14px 20px 4px; white-space: nowrap; transition: var(--trans);
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 16px;
|
|
||||||
color: #fff;
|
|
||||||
}
|
}
|
||||||
|
#sidebar.collapsed .sb-section { opacity: 0; }
|
||||||
|
|
||||||
.sidebar-brand .brand-text {
|
.sb-link {
|
||||||
font-size: 15px;
|
display: flex; align-items: center; gap: 12px;
|
||||||
font-weight: 600;
|
padding: 9px 16px; color: var(--sidebar-text);
|
||||||
color: var(--sidebar-text-active);
|
text-decoration: none; font-size: 13.5px;
|
||||||
letter-spacing: 0.02em;
|
border-radius: 6px; margin: 1px 8px;
|
||||||
transition: var(--transition);
|
white-space: nowrap; overflow: hidden; transition: var(--trans); position: relative;
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
.sb-link:hover { background: var(--sidebar-hover); color: var(--sidebar-text-active); }
|
||||||
#sidebar.collapsed .brand-text { opacity: 0; width: 0; }
|
.sb-link.active { background: var(--sidebar-active); color: var(--sidebar-text-active); font-weight: 500; }
|
||||||
|
.sb-link.active::before {
|
||||||
.sidebar-nav {
|
content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||||||
flex: 1;
|
width: 3px; height: 60%; background: var(--accent); border-radius: 0 2px 2px 0;
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
padding: 8px 0;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
}
|
||||||
.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 {
|
.sb-footer { padding: 8px; border-top: 1px solid #1e293b; flex-shrink: 0; }
|
||||||
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 {
|
/* TOPBAR */
|
||||||
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;
|
position: fixed; top: 0; right: 0;
|
||||||
top: 0;
|
left: var(--sidebar-width); height: var(--topbar-h);
|
||||||
left: var(--sidebar-width);
|
background: var(--card-bg); border-bottom: 1px solid var(--border);
|
||||||
right: 0;
|
display: flex; align-items: center; padding: 0 20px;
|
||||||
height: var(--topbar-height);
|
z-index: 1030; gap: 12px; transition: var(--trans);
|
||||||
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 { left: var(--sidebar-col-width); }
|
||||||
|
|
||||||
#sidebar.collapsed ~ #topbar,
|
.tb-toggle {
|
||||||
#sidebar.collapsed ~ * #topbar {
|
background: none; border: none; color: var(--muted);
|
||||||
left: var(--sidebar-collapsed-width);
|
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 {
|
/* MAIN */
|
||||||
background: none;
|
#main {
|
||||||
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-left: var(--sidebar-width);
|
||||||
margin-top: var(--topbar-height);
|
margin-top: var(--topbar-h);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
min-height: calc(100vh - var(--topbar-height));
|
min-height: calc(100vh - var(--topbar-h));
|
||||||
transition: var(--transition);
|
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 {
|
/* BADGES */
|
||||||
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-income { background: #d1fae5; color: #065f46; }
|
||||||
.badge-expense { background: #fee2e2; color: #991b1b; }
|
.badge-expense { background: #fee2e2; color: #991b1b; }
|
||||||
.badge-transfer { background: #dbeafe; color: #1e40af; }
|
.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 %}
|
{% block extra_css %}{% endblock %}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- Sidebar overlay (mobile) -->
|
<div class="sb-overlay" id="sbOverlay"></div>
|
||||||
<div class="sidebar-overlay" id="sidebarOverlay"></div>
|
|
||||||
|
|
||||||
<!-- ── SIDEBAR ────────────────────────────────────── -->
|
<!-- SIDEBAR -->
|
||||||
<nav id="sidebar">
|
<nav id="sidebar">
|
||||||
<div class="sidebar-brand">
|
<div class="sb-brand">
|
||||||
<div class="brand-icon"><i class="bi bi-currency-exchange"></i></div>
|
<div class="b-icon"><i class="bi bi-currency-exchange"></i></div>
|
||||||
<span class="brand-text">PFM</span>
|
<span class="b-text">PFM</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sb-nav">
|
||||||
<div class="sidebar-nav">
|
<a href="{{ url_for('dashboard.index') }}" class="sb-link {% if request.endpoint == 'dashboard.index' %}active{% endif %}">
|
||||||
<a href="{{ url_for('dashboard.index') }}"
|
<i class="bi bi-grid-1x2"></i><span class="lt">Dashboard</span>
|
||||||
class="sidebar-link {% if request.endpoint == 'dashboard.index' %}active{% endif %}">
|
|
||||||
<i class="bi bi-grid-1x2"></i>
|
|
||||||
<span class="link-text">Dashboard</span>
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="nav-section-label">Money</div>
|
<div class="sb-section">Money</div>
|
||||||
|
<a href="{{ url_for('transactions.index') }}" class="sb-link {% if request.blueprint == 'transactions' %}active{% endif %}">
|
||||||
<a href="#" class="sidebar-link {% if 'transactions' in request.endpoint|default('') %}active{% endif %}">
|
<i class="bi bi-arrow-left-right"></i><span class="lt">Transactions</span>
|
||||||
<i class="bi bi-arrow-left-right"></i>
|
|
||||||
<span class="link-text">Transactions</span>
|
|
||||||
</a>
|
</a>
|
||||||
<a href="#" class="sidebar-link {% if 'income' in request.endpoint|default('') %}active{% endif %}">
|
<a href="{{ url_for('transactions.new', type='income') }}" class="sb-link">
|
||||||
<i class="bi bi-arrow-down-circle"></i>
|
<i class="bi bi-arrow-down-circle"></i><span class="lt">Add Income</span>
|
||||||
<span class="link-text">Income</span>
|
|
||||||
</a>
|
</a>
|
||||||
<a href="#" class="sidebar-link {% if 'expenses' in request.endpoint|default('') %}active{% endif %}">
|
<a href="{{ url_for('transactions.new', type='expense') }}" class="sb-link">
|
||||||
<i class="bi bi-arrow-up-circle"></i>
|
<i class="bi bi-arrow-up-circle"></i><span class="lt">Add Expense</span>
|
||||||
<span class="link-text">Expenses</span>
|
|
||||||
</a>
|
</a>
|
||||||
<a href="#" class="sidebar-link {% if 'accounts' in request.endpoint|default('') %}active{% endif %}">
|
<a href="{{ url_for('accounts.index') }}" class="sb-link {% if request.blueprint == 'accounts' %}active{% endif %}">
|
||||||
<i class="bi bi-wallet2"></i>
|
<i class="bi bi-wallet2"></i><span class="lt">Accounts</span>
|
||||||
<span class="link-text">Accounts</span>
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="nav-section-label">Planning</div>
|
<div class="sb-section">Planning</div>
|
||||||
|
<a href="#" class="sb-link">
|
||||||
<a href="#" class="sidebar-link {% if 'budgets' in request.endpoint|default('') %}active{% endif %}">
|
<i class="bi bi-pie-chart"></i><span class="lt">Budgets</span>
|
||||||
<i class="bi bi-pie-chart"></i>
|
|
||||||
<span class="link-text">Budgets</span>
|
|
||||||
</a>
|
</a>
|
||||||
<a href="#" class="sidebar-link {% if 'goals' in request.endpoint|default('') %}active{% endif %}">
|
<a href="#" class="sb-link">
|
||||||
<i class="bi bi-bullseye"></i>
|
<i class="bi bi-bullseye"></i><span class="lt">Goals</span>
|
||||||
<span class="link-text">Goals</span>
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="nav-section-label">Growth</div>
|
<div class="sb-section">Growth</div>
|
||||||
|
<a href="#" class="sb-link">
|
||||||
<a href="#" class="sidebar-link {% if 'investments' in request.endpoint|default('') %}active{% endif %}">
|
<i class="bi bi-graph-up-arrow"></i><span class="lt">Investments</span>
|
||||||
<i class="bi bi-graph-up-arrow"></i>
|
|
||||||
<span class="link-text">Investments</span>
|
|
||||||
</a>
|
</a>
|
||||||
<a href="#" class="sidebar-link {% if 'reports' in request.endpoint|default('') %}active{% endif %}">
|
<a href="#" class="sb-link">
|
||||||
<i class="bi bi-file-earmark-bar-graph"></i>
|
<i class="bi bi-file-earmark-bar-graph"></i><span class="lt">Reports</span>
|
||||||
<span class="link-text">Reports</span>
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="nav-section-label">AI</div>
|
<div class="sb-section">AI</div>
|
||||||
|
<a href="#" class="sb-link">
|
||||||
<a href="#" class="sidebar-link {% if 'ai' in request.endpoint|default('') %}active{% endif %}">
|
<i class="bi bi-stars"></i><span class="lt">AI Assistant</span>
|
||||||
<i class="bi bi-stars"></i>
|
|
||||||
<span class="link-text">AI Assistant</span>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sb-footer">
|
||||||
<div class="sidebar-footer">
|
<a href="{{ url_for('categories.index') }}" class="sb-link {% if request.blueprint == 'categories' %}active{% endif %}">
|
||||||
<a href="{{ url_for('settings.index') if 'settings' in request.blueprints else '#' }}"
|
<i class="bi bi-tags"></i><span class="lt">Categories</span>
|
||||||
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>
|
||||||
<a href="{{ url_for('auth.logout') }}" class="sidebar-link">
|
<a href="#" class="sb-link">
|
||||||
<i class="bi bi-box-arrow-right"></i>
|
<i class="bi bi-gear"></i><span class="lt">Settings</span>
|
||||||
<span class="link-text">Logout</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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- ── TOPBAR ──────────────────────────────────────── -->
|
<!-- TOPBAR -->
|
||||||
<header id="topbar">
|
<header id="topbar">
|
||||||
<button class="topbar-toggle" id="sidebarToggle" title="Toggle sidebar">
|
<button class="tb-toggle" id="sbToggle"><i class="bi bi-list"></i></button>
|
||||||
<i class="bi bi-list"></i>
|
<span class="tb-title">{% block page_title %}{% endblock %}</span>
|
||||||
</button>
|
<div class="tb-right">
|
||||||
<span class="topbar-title">{% block page_title %}{% endblock %}</span>
|
{% block topbar_actions %}{% endblock %}
|
||||||
<div class="topbar-right">
|
<span class="d-none d-sm-inline small text-muted mono">{{ current_user.display_name or current_user.username }}</span>
|
||||||
<span class="d-none d-sm-inline small text-muted mono">
|
|
||||||
{{ current_user.display_name or current_user.username }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- ── FLASH MESSAGES ─────────────────────────────── -->
|
<!-- FLASH -->
|
||||||
<div class="flash-container" id="flashContainer">
|
<div class="flash-wrap">
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
{% for category, message in messages %}
|
{% for cat, msg in messages %}
|
||||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show shadow-sm mb-0 py-2 px-3"
|
<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">
|
||||||
style="font-size:13.5px; border-radius:8px;" role="alert">
|
{{ msg }}<button type="button" class="btn-close" style="padding:.4rem;" data-bs-dismiss="alert"></button>
|
||||||
{{ message }}
|
|
||||||
<button type="button" class="btn-close btn-close-sm" data-bs-dismiss="alert"></button>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── MAIN CONTENT ───────────────────────────────── -->
|
<!-- MAIN -->
|
||||||
<main id="main-content">
|
<main id="main">{% block content %}{% endblock %}</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 src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function(){
|
||||||
const sidebar = document.getElementById('sidebar');
|
const sb = document.getElementById('sidebar');
|
||||||
const toggleBtn = document.getElementById('sidebarToggle');
|
const overlay = document.getElementById('sbOverlay');
|
||||||
const overlay = document.getElementById('sidebarOverlay');
|
const KEY = 'pfm_sb';
|
||||||
const STORAGE_KEY = 'pfm_sidebar_collapsed';
|
const mob = () => window.innerWidth < 769;
|
||||||
const isMobile = () => window.innerWidth < 769;
|
if (!mob() && localStorage.getItem(KEY)==='1') sb.classList.add('collapsed');
|
||||||
|
document.getElementById('sbToggle').addEventListener('click', function(){
|
||||||
// Restore desktop collapsed state
|
if (mob()) { sb.classList.toggle('mob-open'); overlay.classList.toggle('on'); }
|
||||||
if (!isMobile() && localStorage.getItem(STORAGE_KEY) === '1') {
|
else { sb.classList.toggle('collapsed'); localStorage.setItem(KEY, sb.classList.contains('collapsed')?'1':'0'); }
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
overlay.addEventListener('click', function(){ sb.classList.remove('mob-open'); overlay.classList.remove('on'); });
|
||||||
// Auto-dismiss flash messages after 4s
|
document.querySelectorAll('.flash-wrap .alert').forEach(function(el){
|
||||||
document.querySelectorAll('#flashContainer .alert').forEach(function (el) {
|
setTimeout(function(){ bootstrap.Alert.getOrCreateInstance(el).close(); }, 4500);
|
||||||
setTimeout(function () {
|
|
||||||
const bsAlert = bootstrap.Alert.getOrCreateInstance(el);
|
|
||||||
bsAlert.close();
|
|
||||||
}, 4000);
|
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -2,14 +2,275 @@
|
|||||||
{% block title %}Dashboard{% endblock %}
|
{% block title %}Dashboard{% endblock %}
|
||||||
{% block page_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 %}
|
{% 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="row g-3">
|
||||||
<div class="col-12">
|
<div class="col-12 col-lg-4">
|
||||||
<div class="pfm-card text-center py-5">
|
<div class="pcard h-100">
|
||||||
<i class="bi bi-grid-1x2 text-muted" style="font-size:3rem;"></i>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h5 class="mt-3 mb-1">Dashboard</h5>
|
<span class="pcard-title mb-0">Accounts</span>
|
||||||
<p class="text-muted small">Phase 2 will populate this with live data.</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user