06/05 Optimize app
This commit is contained in:
@@ -253,6 +253,18 @@ def reconcile_api():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dashboard_bp.route('/api/health-score')
|
||||||
|
@login_required
|
||||||
|
def health_score_api():
|
||||||
|
from app.services.health_score_service import compute_health_score
|
||||||
|
try:
|
||||||
|
data = compute_health_score()
|
||||||
|
except Exception as e:
|
||||||
|
log.warning('[dashboard] health_score_api failed: %s', e)
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
@dashboard_bp.route('/api/anomalies')
|
@dashboard_bp.route('/api/anomalies')
|
||||||
@login_required
|
@login_required
|
||||||
def anomalies_api():
|
def anomalies_api():
|
||||||
|
|||||||
+16
-1
@@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from flask import (Blueprint, render_template, redirect, url_for, flash,
|
from flask import (Blueprint, render_template, redirect, url_for, flash,
|
||||||
request, current_app, send_from_directory)
|
request, current_app, send_from_directory, jsonify)
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from app.utils.audit import audit
|
from app.utils.audit import audit
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
@@ -208,6 +208,21 @@ def recurring():
|
|||||||
return render_template('settings/recurring.html', rules=rules, upcoming=upcoming)
|
return render_template('settings/recurring.html', rules=rules, upcoming=upcoming)
|
||||||
|
|
||||||
|
|
||||||
|
@settings_bp.route('/recurring/projection')
|
||||||
|
@login_required
|
||||||
|
def recurring_projection():
|
||||||
|
days = request.args.get('days', 30, type=int)
|
||||||
|
if days not in (30, 60, 90):
|
||||||
|
days = 30
|
||||||
|
from app.services.recurring_service import projected_cash_flow
|
||||||
|
data = projected_cash_flow(days)
|
||||||
|
# Serialize events (date objects → string)
|
||||||
|
data['events'] = [
|
||||||
|
{**ev, 'date': ev['date'].strftime('%b %d')} for ev in data['events']
|
||||||
|
]
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route('/recurring/new', methods=['GET', 'POST'])
|
@settings_bp.route('/recurring/new', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def recurring_new():
|
def recurring_new():
|
||||||
|
|||||||
@@ -443,12 +443,21 @@ def ocr_receipt_file():
|
|||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
|
from app.models.receipt import Receipt
|
||||||
|
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if not data or not data.get('filename'):
|
if not data or not data.get('filename'):
|
||||||
return jsonify({'error': 'No filename provided'}), 400
|
return jsonify({'error': 'No filename provided'}), 400
|
||||||
|
|
||||||
# Security: only allow basenames, no path traversal
|
# Security: only allow basenames, no path traversal
|
||||||
filename = os.path.basename(data['filename'])
|
filename = os.path.basename(data['filename'])
|
||||||
|
|
||||||
|
# Ownership check: filename must exist in receipts table (single-user, but
|
||||||
|
# prevents OCR extraction from arbitrary files on disk via a crafted request)
|
||||||
|
receipt_record = Receipt.query.filter_by(filename=filename).first()
|
||||||
|
if not receipt_record:
|
||||||
|
return jsonify({'error': 'Receipt not found'}), 404
|
||||||
|
|
||||||
upload_dir = current_app.config.get('UPLOAD_FOLDER', '/home/pfm/app/uploads')
|
upload_dir = current_app.config.get('UPLOAD_FOLDER', '/home/pfm/app/uploads')
|
||||||
file_path = os.path.join(upload_dir, filename)
|
file_path = os.path.join(upload_dir, filename)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"""
|
||||||
|
Financial Health Score — synthesises savings rate, budget adherence, goal
|
||||||
|
progress, and emergency fund coverage into a single 0–100 score.
|
||||||
|
|
||||||
|
Each component is worth 25 points. Returns the total score plus a breakdown
|
||||||
|
so the UI can show per-component detail and suggestions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
from sqlalchemy import func
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.transaction import Transaction
|
||||||
|
from app.models.goal import Goal
|
||||||
|
|
||||||
|
|
||||||
|
# ── Component scorers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _score_savings(max_pts=25):
|
||||||
|
"""
|
||||||
|
Avg savings rate over the last 3 full months.
|
||||||
|
≥ 20% → full marks; 10–20% → 18; 1–10% → 10; ≤ 0% → 0.
|
||||||
|
"""
|
||||||
|
today = date.today()
|
||||||
|
month_start = today.replace(day=1)
|
||||||
|
|
||||||
|
# Collect last 3 complete months
|
||||||
|
incomes, expenses = [], []
|
||||||
|
for i in range(1, 4):
|
||||||
|
mo_end = (month_start - relativedelta(days=1))
|
||||||
|
mo_start = mo_end.replace(day=1)
|
||||||
|
month_start = mo_start
|
||||||
|
|
||||||
|
inc = float(db.session.query(func.coalesce(func.sum(Transaction.amount), 0))
|
||||||
|
.filter(Transaction.transaction_type == 'income',
|
||||||
|
Transaction.date >= mo_start,
|
||||||
|
Transaction.date <= mo_end).scalar())
|
||||||
|
exp = float(db.session.query(func.coalesce(func.sum(Transaction.amount), 0))
|
||||||
|
.filter(Transaction.transaction_type == 'expense',
|
||||||
|
Transaction.date >= mo_start,
|
||||||
|
Transaction.date <= mo_end).scalar())
|
||||||
|
incomes.append(inc)
|
||||||
|
expenses.append(exp)
|
||||||
|
|
||||||
|
total_inc = sum(incomes)
|
||||||
|
total_exp = sum(expenses)
|
||||||
|
rate = ((total_inc - total_exp) / total_inc * 100) if total_inc > 0 else 0
|
||||||
|
|
||||||
|
if rate >= 20:
|
||||||
|
pts = max_pts
|
||||||
|
elif rate >= 10:
|
||||||
|
pts = round(max_pts * 0.72) # 18/25
|
||||||
|
elif rate > 0:
|
||||||
|
pts = round(max_pts * 0.40) # 10/25
|
||||||
|
else:
|
||||||
|
pts = 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
'points': pts,
|
||||||
|
'max': max_pts,
|
||||||
|
'value': round(rate, 1),
|
||||||
|
'label': f'{rate:+.1f}% savings rate (3-mo avg)',
|
||||||
|
'tip': None if rate >= 20 else (
|
||||||
|
'Aim for 20%+ savings rate.' if rate < 10 else
|
||||||
|
'Good start — push toward 20%.'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _score_budgets(max_pts=25):
|
||||||
|
"""
|
||||||
|
What fraction of budgeted categories are currently under their limit?
|
||||||
|
All under → full marks; scales linearly.
|
||||||
|
"""
|
||||||
|
from app.services.budget_service import get_budget_summary
|
||||||
|
month_str = date.today().strftime('%Y-%m')
|
||||||
|
summary = [s for s in get_budget_summary(month_str) if s['has_budget']]
|
||||||
|
|
||||||
|
if not summary:
|
||||||
|
return {
|
||||||
|
'points': max_pts, # no budgets set → not penalised
|
||||||
|
'max': max_pts,
|
||||||
|
'value': None,
|
||||||
|
'label': 'No budgets set',
|
||||||
|
'tip': 'Set monthly budgets to track spending limits.',
|
||||||
|
}
|
||||||
|
|
||||||
|
under = sum(1 for s in summary if not s['is_over'])
|
||||||
|
ratio = under / len(summary)
|
||||||
|
pts = round(ratio * max_pts)
|
||||||
|
|
||||||
|
over_cats = [s['category'].name for s in summary if s['is_over']]
|
||||||
|
tip = None
|
||||||
|
if over_cats:
|
||||||
|
tip = f'Over budget: {", ".join(over_cats[:3])}{"…" if len(over_cats) > 3 else ""}.'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'points': pts,
|
||||||
|
'max': max_pts,
|
||||||
|
'value': round(ratio * 100, 1),
|
||||||
|
'label': f'{under}/{len(summary)} categories under budget',
|
||||||
|
'tip': tip,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _score_goals(max_pts=25):
|
||||||
|
"""
|
||||||
|
Average completion % across active (non-completed) goals.
|
||||||
|
100% avg → full marks; scales linearly.
|
||||||
|
"""
|
||||||
|
goals = Goal.query.filter_by(is_completed=False).all()
|
||||||
|
if not goals:
|
||||||
|
completed = Goal.query.filter_by(is_completed=True).count()
|
||||||
|
return {
|
||||||
|
'points': max_pts if completed else round(max_pts * 0.5),
|
||||||
|
'max': max_pts,
|
||||||
|
'value': 100.0 if completed else 0.0,
|
||||||
|
'label': 'All goals completed!' if completed else 'No savings goals set',
|
||||||
|
'tip': None if completed else 'Create a savings goal to track progress.',
|
||||||
|
}
|
||||||
|
|
||||||
|
pcts = []
|
||||||
|
for g in goals:
|
||||||
|
target = float(g.target_amount)
|
||||||
|
if target > 0:
|
||||||
|
pcts.append(min(float(g.current_amount) / target * 100, 100))
|
||||||
|
|
||||||
|
avg = (sum(pcts) / len(pcts)) if pcts else 0
|
||||||
|
pts = round(avg / 100 * max_pts)
|
||||||
|
|
||||||
|
behind = [g.name for g, p in zip(goals, pcts) if p < 25]
|
||||||
|
tip = None
|
||||||
|
if behind:
|
||||||
|
tip = f'Behind on: {", ".join(behind[:2])}{"…" if len(behind) > 2 else ""}.'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'points': pts,
|
||||||
|
'max': max_pts,
|
||||||
|
'value': round(avg, 1),
|
||||||
|
'label': f'{round(avg, 0):.0f}% avg goal progress ({len(goals)} active)',
|
||||||
|
'tip': tip,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _score_emergency_fund(max_pts=25):
|
||||||
|
"""
|
||||||
|
Liquid assets vs 3-month expense target.
|
||||||
|
≥ 3 months → full marks; scales linearly up to 3 months.
|
||||||
|
"""
|
||||||
|
from app.services.goal_service import get_emergency_fund_status
|
||||||
|
ef = get_emergency_fund_status()
|
||||||
|
|
||||||
|
liquid = ef['liquid_assets']
|
||||||
|
target3 = ef['target_3mo']
|
||||||
|
covered = ef['months_covered']
|
||||||
|
pct3 = ef['pct_3mo'] # 0–100, capped
|
||||||
|
|
||||||
|
pts = round(pct3 / 100 * max_pts)
|
||||||
|
|
||||||
|
if covered >= 3:
|
||||||
|
tip = None
|
||||||
|
elif covered >= 1:
|
||||||
|
short = target3 - liquid
|
||||||
|
tip = f'Build to 3-month emergency fund (need ${short:,.0f} more).'
|
||||||
|
else:
|
||||||
|
tip = 'Start an emergency fund — aim for 1 month of expenses first.'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'points': pts,
|
||||||
|
'max': max_pts,
|
||||||
|
'value': round(covered, 1),
|
||||||
|
'label': f'{covered:.1f} months emergency fund',
|
||||||
|
'tip': tip,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public API ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def compute_health_score():
|
||||||
|
"""
|
||||||
|
Compute the overall financial health score.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
score: int 0–100
|
||||||
|
grade: str 'A' | 'B' | 'C' | 'D' | 'F'
|
||||||
|
color: str CSS color
|
||||||
|
components: list of component dicts
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
components = {
|
||||||
|
'savings': _score_savings(),
|
||||||
|
'budgets': _score_budgets(),
|
||||||
|
'goals': _score_goals(),
|
||||||
|
'emergency_fund': _score_emergency_fund(),
|
||||||
|
}
|
||||||
|
|
||||||
|
score = sum(c['points'] for c in components.values())
|
||||||
|
|
||||||
|
if score >= 85:
|
||||||
|
grade, color = 'A', '#10b981'
|
||||||
|
elif score >= 70:
|
||||||
|
grade, color = 'B', '#3b82f6'
|
||||||
|
elif score >= 55:
|
||||||
|
grade, color = 'C', '#f59e0b'
|
||||||
|
elif score >= 40:
|
||||||
|
grade, color = 'D', '#f97316'
|
||||||
|
else:
|
||||||
|
grade, color = 'F', '#ef4444'
|
||||||
|
|
||||||
|
# Add display names for the template
|
||||||
|
labels = {
|
||||||
|
'savings': 'Savings Rate',
|
||||||
|
'budgets': 'Budget Adherence',
|
||||||
|
'goals': 'Goal Progress',
|
||||||
|
'emergency_fund': 'Emergency Fund',
|
||||||
|
}
|
||||||
|
icons = {
|
||||||
|
'savings': 'bi-piggy-bank',
|
||||||
|
'budgets': 'bi-pie-chart',
|
||||||
|
'goals': 'bi-bullseye',
|
||||||
|
'emergency_fund': 'bi-shield-check',
|
||||||
|
}
|
||||||
|
|
||||||
|
component_list = [
|
||||||
|
{
|
||||||
|
'key': key,
|
||||||
|
'name': labels[key],
|
||||||
|
'icon': icons[key],
|
||||||
|
'points': c['points'],
|
||||||
|
'max': c['max'],
|
||||||
|
'value': c['value'],
|
||||||
|
'label': c['label'],
|
||||||
|
'tip': c['tip'],
|
||||||
|
'pct': round(c['points'] / c['max'] * 100),
|
||||||
|
}
|
||||||
|
for key, c in components.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'score': score,
|
||||||
|
'grade': grade,
|
||||||
|
'color': color,
|
||||||
|
'components': component_list,
|
||||||
|
'tips': [c['tip'] for c in component_list if c['tip']],
|
||||||
|
}
|
||||||
@@ -94,6 +94,104 @@ def process_due_rules(dry_run=False):
|
|||||||
return created
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def projected_cash_flow(days=90):
|
||||||
|
"""
|
||||||
|
Build a projected cash flow from all active recurring rules over the next
|
||||||
|
N days. Returns weekly-bucketed chart data plus a flat event list.
|
||||||
|
|
||||||
|
Returns dict:
|
||||||
|
labels — list of 'Mon DD' strings (week-start dates)
|
||||||
|
income — list of floats (income per week bucket)
|
||||||
|
expense — list of floats (expense per week bucket)
|
||||||
|
balance — list of floats (running balance at end of each bucket)
|
||||||
|
events — list of {date, description, amount, type, rule_id}
|
||||||
|
starting_balance — float
|
||||||
|
ending_balance — float
|
||||||
|
total_income — float
|
||||||
|
total_expense — float
|
||||||
|
net — float
|
||||||
|
"""
|
||||||
|
from app.models.account import Account
|
||||||
|
from sqlalchemy import func
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
cutoff = today + timedelta(days=days)
|
||||||
|
|
||||||
|
# Starting balance = sum of all active account balances
|
||||||
|
starting_balance = float(
|
||||||
|
db.session.query(func.coalesce(func.sum(Account.balance), 0))
|
||||||
|
.filter(Account.is_active == True)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enumerate all occurrences of active rules within the window
|
||||||
|
rules = RecurringRule.query.filter_by(is_active=True).all()
|
||||||
|
events = []
|
||||||
|
for rule in rules:
|
||||||
|
run_date = rule.next_run or rule.start_date
|
||||||
|
# Advance to window start if rule fires before today
|
||||||
|
while run_date < today:
|
||||||
|
run_date = next_occurrence(run_date, rule.frequency)
|
||||||
|
while run_date <= cutoff:
|
||||||
|
if rule.end_date and run_date > rule.end_date:
|
||||||
|
break
|
||||||
|
events.append({
|
||||||
|
'date': run_date,
|
||||||
|
'description': rule.description,
|
||||||
|
'amount': float(rule.amount),
|
||||||
|
'type': rule.transaction_type,
|
||||||
|
'rule_id': rule.id,
|
||||||
|
})
|
||||||
|
run_date = next_occurrence(run_date, rule.frequency)
|
||||||
|
|
||||||
|
events.sort(key=lambda e: e['date'])
|
||||||
|
|
||||||
|
# Build weekly buckets: each bucket starts on Monday
|
||||||
|
# Find the Monday on or before today
|
||||||
|
week_start = today - timedelta(days=today.weekday())
|
||||||
|
buckets = []
|
||||||
|
ws = week_start
|
||||||
|
while ws <= cutoff:
|
||||||
|
buckets.append(ws)
|
||||||
|
ws += timedelta(weeks=1)
|
||||||
|
|
||||||
|
bucket_income = [0.0] * len(buckets)
|
||||||
|
bucket_expense = [0.0] * len(buckets)
|
||||||
|
|
||||||
|
for ev in events:
|
||||||
|
# Find which bucket this event falls in
|
||||||
|
idx = (ev['date'] - week_start).days // 7
|
||||||
|
if 0 <= idx < len(buckets):
|
||||||
|
if ev['type'] == 'income':
|
||||||
|
bucket_income[idx] += ev['amount']
|
||||||
|
else:
|
||||||
|
bucket_expense[idx] += ev['amount']
|
||||||
|
|
||||||
|
# Running balance
|
||||||
|
running = starting_balance
|
||||||
|
bucket_balance = []
|
||||||
|
for inc, exp in zip(bucket_income, bucket_expense):
|
||||||
|
running += inc - exp
|
||||||
|
bucket_balance.append(round(running, 2))
|
||||||
|
|
||||||
|
total_income = sum(bucket_income)
|
||||||
|
total_expense = sum(bucket_expense)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'labels': [b.strftime('%b %d') for b in buckets],
|
||||||
|
'income': [round(v, 2) for v in bucket_income],
|
||||||
|
'expense': [round(v, 2) for v in bucket_expense],
|
||||||
|
'balance': bucket_balance,
|
||||||
|
'events': events,
|
||||||
|
'starting_balance': round(starting_balance, 2),
|
||||||
|
'ending_balance': round(bucket_balance[-1], 2) if bucket_balance else round(starting_balance, 2),
|
||||||
|
'total_income': round(total_income, 2),
|
||||||
|
'total_expense': round(total_expense, 2),
|
||||||
|
'net': round(total_income - total_expense, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_upcoming(days=30):
|
def get_upcoming(days=30):
|
||||||
"""Return list of upcoming recurring transactions in the next N days."""
|
"""Return list of upcoming recurring transactions in the next N days."""
|
||||||
today = date.today()
|
today = date.today()
|
||||||
|
|||||||
@@ -197,6 +197,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Import progress bar (shown only during chunked import) #}
|
||||||
|
<div id="import-progress-wrap" class="d-none mt-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1" style="font-size:12px;color:var(--muted);">
|
||||||
|
<span id="import-progress-label">Importing…</span>
|
||||||
|
<span id="import-progress-pct" class="mono fw-bold">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="height:6px;border-radius:3px;">
|
||||||
|
<div id="import-progress-bar" class="progress-bar bg-success" role="progressbar" style="width:0%;transition:width .3s ease;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# Parse errors (non-fatal) #}
|
{# Parse errors (non-fatal) #}
|
||||||
<div id="parse-warnings" class="alert alert-warning d-none mb-3" style="font-size:12px;">
|
<div id="parse-warnings" class="alert alert-warning d-none mb-3" style="font-size:12px;">
|
||||||
<strong>Warnings</strong> — these rows were skipped:<br>
|
<strong>Warnings</strong> — these rows were skipped:<br>
|
||||||
@@ -580,31 +591,56 @@
|
|||||||
setImportLoading(true);
|
setImportLoading(true);
|
||||||
$('import-error').classList.add('d-none');
|
$('import-error').classList.add('d-none');
|
||||||
|
|
||||||
fetch('{{ url_for("bank_import.confirm_import") }}', {
|
const CHUNK = 50;
|
||||||
method: 'POST',
|
const useChunks = rows.length > CHUNK;
|
||||||
headers: {
|
let totalImported = 0, totalSkipped = 0;
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRFToken': CSRF,
|
function setProgress(done, total) {
|
||||||
},
|
const pct = Math.round(done / total * 100);
|
||||||
body: JSON.stringify({ account_id: parseInt(accountId), skip_dupes: skipDupes, rows }),
|
$('import-progress-bar').style.width = pct + '%';
|
||||||
})
|
$('import-progress-pct').textContent = pct + '%';
|
||||||
.then(r => r.json())
|
$('import-progress-label').textContent = `Importing… ${done} of ${total}`;
|
||||||
.then(data => {
|
}
|
||||||
|
|
||||||
|
async function sendChunks() {
|
||||||
|
if (useChunks) {
|
||||||
|
$('import-progress-wrap').classList.remove('d-none');
|
||||||
|
setProgress(0, rows.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
for (let i = 0; i < rows.length; i += CHUNK) chunks.push(rows.slice(i, i + CHUNK));
|
||||||
|
let sent = 0;
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
const r = await fetch('{{ url_for("bank_import.confirm_import") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF },
|
||||||
|
body: JSON.stringify({ account_id: parseInt(accountId), skip_dupes: skipDupes, rows: chunk }),
|
||||||
|
});
|
||||||
|
const data = await r.json();
|
||||||
|
if (data.error) throw new Error(data.error);
|
||||||
|
totalImported += data.imported;
|
||||||
|
totalSkipped += data.skipped;
|
||||||
|
sent += chunk.length;
|
||||||
|
if (useChunks) setProgress(sent, rows.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendChunks()
|
||||||
|
.then(() => {
|
||||||
setImportLoading(false);
|
setImportLoading(false);
|
||||||
if (data.error) {
|
$('import-progress-wrap').classList.add('d-none');
|
||||||
$('import-error').textContent = data.error;
|
|
||||||
$('import-error').classList.remove('d-none');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$('done-title').textContent =
|
$('done-title').textContent =
|
||||||
data.imported + ' transaction' + (data.imported !== 1 ? 's' : '') + ' imported successfully';
|
totalImported + ' transaction' + (totalImported !== 1 ? 's' : '') + ' imported successfully';
|
||||||
$('done-subtitle').textContent =
|
$('done-subtitle').textContent =
|
||||||
data.skipped > 0 ? data.skipped + ' duplicate(s) skipped.' : 'All transactions were new.';
|
totalSkipped > 0 ? totalSkipped + ' duplicate(s) skipped.' : 'All transactions were new.';
|
||||||
showStep('step-done');
|
showStep('step-done');
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
setImportLoading(false);
|
setImportLoading(false);
|
||||||
$('import-error').textContent = 'Request failed: ' + err;
|
$('import-progress-wrap').classList.add('d-none');
|
||||||
|
$('import-error').textContent = 'Import failed: ' + err;
|
||||||
$('import-error').classList.remove('d-none');
|
$('import-error').classList.remove('d-none');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -198,8 +198,88 @@
|
|||||||
.sb-overlay.on { display: block; }
|
.sb-overlay.on { display: block; }
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ── Dark mode ─────────────────────────────────────────────────────── */
|
||||||
|
body.dark-mode {
|
||||||
|
--body-bg: #0f172a; --card-bg: #1e293b; --text: #e2e8f0;
|
||||||
|
--muted: #94a3b8; --border: #334155;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
body.dark-mode #topbar { background: #1e293b; border-color: #334155; }
|
||||||
|
body.dark-mode .pfm-table tbody tr:hover { background: #263548; }
|
||||||
|
body.dark-mode .pfm-table th, body.dark-mode .pfm-table td { border-color: #334155; }
|
||||||
|
body.dark-mode .form-control, body.dark-mode .form-select {
|
||||||
|
background: #0f172a; border-color: #334155; color: #e2e8f0;
|
||||||
|
}
|
||||||
|
body.dark-mode .form-control:focus, body.dark-mode .form-select:focus {
|
||||||
|
background: #0f172a; border-color: #3b82f6; color: #e2e8f0;
|
||||||
|
box-shadow: 0 0 0 .2rem rgba(59,130,246,.25);
|
||||||
|
}
|
||||||
|
body.dark-mode .form-control::placeholder { color: #475569; }
|
||||||
|
body.dark-mode .form-check-input { background-color: #334155; border-color: #475569; }
|
||||||
|
body.dark-mode .form-check-input:checked { background-color: #3b82f6; border-color: #3b82f6; }
|
||||||
|
body.dark-mode .btn-outline-secondary { color: #94a3b8; border-color: #334155; }
|
||||||
|
body.dark-mode .btn-outline-secondary:hover,
|
||||||
|
body.dark-mode .btn-outline-secondary:focus { background: #334155; color: #e2e8f0; border-color: #334155; }
|
||||||
|
body.dark-mode .btn-outline-primary { color: #93c5fd; border-color: #1e40af; }
|
||||||
|
body.dark-mode .btn-outline-primary:hover { background: #1e40af; color: #fff; }
|
||||||
|
body.dark-mode .btn-outline-warning { color: #fcd34d; border-color: #92400e; }
|
||||||
|
body.dark-mode .btn-outline-warning:hover { background: #92400e; color: #fff; }
|
||||||
|
body.dark-mode .btn-outline-danger { color: #fca5a5; border-color: #991b1b; }
|
||||||
|
body.dark-mode .btn-outline-danger:hover { background: #991b1b; color: #fff; }
|
||||||
|
body.dark-mode .dropdown-menu { background: #1e293b; border-color: #334155; }
|
||||||
|
body.dark-mode .dropdown-item { color: #e2e8f0; }
|
||||||
|
body.dark-mode .dropdown-item:hover, body.dark-mode .dropdown-item:focus { background: #334155; color: #f1f5f9; }
|
||||||
|
body.dark-mode .dropdown-divider { border-color: #334155; }
|
||||||
|
body.dark-mode .modal-content { background: #1e293b; border-color: #334155; color: #e2e8f0; }
|
||||||
|
body.dark-mode .modal-header, body.dark-mode .modal-footer { border-color: #334155; }
|
||||||
|
body.dark-mode .modal-header .btn-close { filter: invert(1) grayscale(1); }
|
||||||
|
body.dark-mode .alert-warning { background: #451a03; border-color: #92400e; color: #fcd34d; }
|
||||||
|
body.dark-mode .alert-danger { background: #450a0a; border-color: #991b1b; color: #fca5a5; }
|
||||||
|
body.dark-mode .alert-success { background: #052e16; border-color: #166534; color: #86efac; }
|
||||||
|
body.dark-mode .alert-info { background: #0c1a2e; border-color: #1e40af; color: #93c5fd; }
|
||||||
|
body.dark-mode .progress { background: #334155; }
|
||||||
|
body.dark-mode .table { color: #e2e8f0; }
|
||||||
|
body.dark-mode .input-group-text { background: #334155; border-color: #334155; color: #94a3b8; }
|
||||||
|
body.dark-mode .badge-income { background: #064e3b; color: #6ee7b7; }
|
||||||
|
body.dark-mode .badge-expense { background: #450a0a; color: #fca5a5; }
|
||||||
|
body.dark-mode .badge-transfer { background: #1e3a5f; color: #93c5fd; }
|
||||||
|
body.dark-mode #kbd-modal kbd { background: #334155; border-color: #475569; color: #e2e8f0; }
|
||||||
|
body.dark-mode .report-tab { background: #1e293b; border-color: #334155; color: #94a3b8; }
|
||||||
|
body.dark-mode .report-tab:hover { background: #334155; color: #e2e8f0; }
|
||||||
|
body.dark-mode .report-tab.active { background: #e2e8f0; color: #0f172a; border-color: #e2e8f0; }
|
||||||
|
/* Override common hardcoded light backgrounds in component inline styles */
|
||||||
|
body.dark-mode [style*="background:#f8fafc"], body.dark-mode [style*="background: #f8fafc"],
|
||||||
|
body.dark-mode [style*="background:#f1f5f9"], body.dark-mode [style*="background: #f1f5f9"]
|
||||||
|
{ background: #263548 !important; }
|
||||||
|
body.dark-mode [style*="background:#eff6ff"], body.dark-mode [style*="background: #eff6ff"]
|
||||||
|
{ background: #1e3a5f !important; }
|
||||||
|
body.dark-mode [style*="background:#fef2f2"], body.dark-mode [style*="background: #fef2f2"]
|
||||||
|
{ background: #450a0a !important; }
|
||||||
|
body.dark-mode [style*="background:#f0fdf4"], body.dark-mode [style*="background: #f0fdf4"]
|
||||||
|
{ background: #052e16 !important; }
|
||||||
|
body.dark-mode [style*="background:#fff5e6"], body.dark-mode [style*="background:#fffbeb"]
|
||||||
|
{ background: #451a03 !important; }
|
||||||
|
/* Text color overrides for hardcoded darks */
|
||||||
|
body.dark-mode [style*="color:#0f172a"] { color: #e2e8f0 !important; }
|
||||||
|
body.dark-mode [style*="color:#1e293b"] { color: #94a3b8 !important; }
|
||||||
|
body.dark-mode [style*="color:#374151"] { color: #94a3b8 !important; }
|
||||||
|
/* Border overrides */
|
||||||
|
body.dark-mode [style*="border-bottom:1px solid #e2e8f0"],
|
||||||
|
body.dark-mode [style*="border-bottom: 1px solid #e2e8f0"] { border-color: #334155 !important; }
|
||||||
|
body.dark-mode [style*="border:1px solid #e2e8f0"],
|
||||||
|
body.dark-mode [style*="border: 1px solid #e2e8f0"] { border-color: #334155 !important; }
|
||||||
|
|
||||||
{% block extra_css %}{% endblock %}
|
{% block extra_css %}{% endblock %}
|
||||||
</style>
|
</style>
|
||||||
|
<script>
|
||||||
|
/* Apply dark mode class to body before first paint to avoid flash */
|
||||||
|
(function(){ if(localStorage.getItem('pfm_dark')==='1') document.documentElement.setAttribute('data-pfm-dark','1'); })();
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
/* Instant pre-body dark (avoids FOUC) — matches body.dark-mode vars */
|
||||||
|
html[data-pfm-dark] body { --body-bg:#0f172a; --card-bg:#1e293b; --text:#e2e8f0; --muted:#94a3b8; --border:#334155; }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="sb-overlay" id="sbOverlay"></div>
|
<div class="sb-overlay" id="sbOverlay"></div>
|
||||||
@@ -318,6 +398,11 @@
|
|||||||
<span class="tb-title">{% block page_title %}{% endblock %}</span>
|
<span class="tb-title">{% block page_title %}{% endblock %}</span>
|
||||||
<div class="tb-right">
|
<div class="tb-right">
|
||||||
{% block topbar_actions %}{% endblock %}
|
{% block topbar_actions %}{% endblock %}
|
||||||
|
<button id="dark-toggle" type="button" title="Toggle dark mode"
|
||||||
|
style="background:none;border:none;color:var(--muted);font-size:15px;padding:3px 6px;border-radius:6px;cursor:pointer;line-height:1;"
|
||||||
|
class="d-inline-flex align-items-center">
|
||||||
|
<i class="bi bi-moon-stars"></i>
|
||||||
|
</button>
|
||||||
<button type="button" data-bs-toggle="modal" data-bs-target="#kbd-modal"
|
<button type="button" data-bs-toggle="modal" data-bs-target="#kbd-modal"
|
||||||
title="Keyboard shortcuts (?)"
|
title="Keyboard shortcuts (?)"
|
||||||
style="background:none;border:none;color:var(--muted);font-size:13px;padding:3px 6px;border-radius:6px;cursor:pointer;line-height:1;"
|
style="background:none;border:none;color:var(--muted);font-size:13px;padding:3px 6px;border-radius:6px;cursor:pointer;line-height:1;"
|
||||||
@@ -473,6 +558,29 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// ── Dark mode toggle ─────────────────────────────────────────────────
|
||||||
|
(function () {
|
||||||
|
var DKEY = 'pfm_dark';
|
||||||
|
var body = document.body;
|
||||||
|
var btn = document.getElementById('dark-toggle');
|
||||||
|
|
||||||
|
function applyDark(on) {
|
||||||
|
body.classList.toggle('dark-mode', on);
|
||||||
|
document.documentElement.setAttribute('data-pfm-dark', on ? '1' : '0');
|
||||||
|
if (btn) btn.querySelector('i').className = on ? 'bi bi-sun' : 'bi bi-moon-stars';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialise from storage
|
||||||
|
var saved = localStorage.getItem(DKEY) === '1';
|
||||||
|
applyDark(saved);
|
||||||
|
|
||||||
|
if (btn) btn.addEventListener('click', function () {
|
||||||
|
var next = !body.classList.contains('dark-mode');
|
||||||
|
localStorage.setItem(DKEY, next ? '1' : '0');
|
||||||
|
applyDark(next);
|
||||||
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -138,6 +138,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Financial Health Score -->
|
||||||
|
<div id="health-score-card" class="pcard mb-4" style="display:none;">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||||
|
<span class="pcard-title mb-0">Financial Health Score</span>
|
||||||
|
<span id="hs-grade-badge" class="badge fw-bold" style="font-size:13px;padding:4px 12px;"></span>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 align-items-center">
|
||||||
|
<!-- Score ring -->
|
||||||
|
<div class="col-12 col-sm-auto d-flex justify-content-center">
|
||||||
|
<div style="position:relative;width:96px;height:96px;">
|
||||||
|
<svg viewBox="0 0 36 36" style="width:96px;height:96px;transform:rotate(-90deg);">
|
||||||
|
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="var(--border)" stroke-width="3"/>
|
||||||
|
<circle id="hs-ring" cx="18" cy="18" r="15.9155" fill="none" stroke="#10b981" stroke-width="3"
|
||||||
|
stroke-dasharray="0 100" stroke-linecap="round"
|
||||||
|
style="transition:stroke-dasharray .8s ease, stroke .4s;"/>
|
||||||
|
</svg>
|
||||||
|
<div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;">
|
||||||
|
<div id="hs-score" class="mono fw-bold" style="font-size:22px;line-height:1;">—</div>
|
||||||
|
<div style="font-size:10px;color:var(--muted);">/ 100</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Component bars -->
|
||||||
|
<div class="col">
|
||||||
|
<div id="hs-components" class="d-flex flex-column gap-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Tips -->
|
||||||
|
<div id="hs-tips" class="mt-3" style="display:none;">
|
||||||
|
<div class="d-flex flex-wrap gap-2" id="hs-tips-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Charts Row -->
|
<!-- Charts Row -->
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-12 col-xl-8">
|
<div class="col-12 col-xl-8">
|
||||||
@@ -555,6 +588,71 @@ function refreshFxRate() {
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// ── Financial health score ───────────────────────────────────────────────────
|
||||||
|
(function(){
|
||||||
|
var card = document.getElementById('health-score-card');
|
||||||
|
var ring = document.getElementById('hs-ring');
|
||||||
|
var scoreEl = document.getElementById('hs-score');
|
||||||
|
var gradeEl = document.getElementById('hs-grade-badge');
|
||||||
|
var compsEl = document.getElementById('hs-components');
|
||||||
|
var tipsWrap = document.getElementById('hs-tips');
|
||||||
|
var tipsList = document.getElementById('hs-tips-list');
|
||||||
|
if (!card) return;
|
||||||
|
|
||||||
|
const SYM = '{{ current_user.currency_symbol }}';
|
||||||
|
|
||||||
|
fetch('/api/health-score')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) return;
|
||||||
|
card.style.display = '';
|
||||||
|
|
||||||
|
// Score ring
|
||||||
|
var circ = 100;
|
||||||
|
ring.style.strokeDasharray = (data.score / 100 * circ) + ' ' + circ;
|
||||||
|
ring.style.stroke = data.color;
|
||||||
|
scoreEl.textContent = data.score;
|
||||||
|
scoreEl.style.color = data.color;
|
||||||
|
|
||||||
|
// Grade badge
|
||||||
|
gradeEl.textContent = 'Grade ' + data.grade;
|
||||||
|
gradeEl.style.background = data.color + '22';
|
||||||
|
gradeEl.style.color = data.color;
|
||||||
|
|
||||||
|
// Component bars
|
||||||
|
var icons = {
|
||||||
|
savings: 'bi-piggy-bank',
|
||||||
|
budgets: 'bi-pie-chart',
|
||||||
|
goals: 'bi-bullseye',
|
||||||
|
emergency_fund: 'bi-shield-check',
|
||||||
|
};
|
||||||
|
compsEl.innerHTML = data.components.map(c => `
|
||||||
|
<div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1" style="font-size:12px;">
|
||||||
|
<span><i class="bi ${c.icon} me-1" style="color:var(--muted);"></i>${c.name}</span>
|
||||||
|
<span class="mono" style="color:var(--muted);">${c.points}/${c.max}</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="height:5px;border-radius:3px;">
|
||||||
|
<div class="progress-bar" role="progressbar"
|
||||||
|
style="width:${c.pct}%;background:${c.pct>=80?'#10b981':c.pct>=50?'#3b82f6':c.pct>=25?'#f59e0b':'#ef4444'};transition:width .6s ease;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${c.label ? `<div style="font-size:10px;color:var(--muted);margin-top:1px;">${c.label}</div>` : ''}
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
// Tips
|
||||||
|
if (data.tips && data.tips.length) {
|
||||||
|
tipsWrap.style.display = '';
|
||||||
|
tipsList.innerHTML = data.tips.map(t =>
|
||||||
|
`<span style="font-size:11px;background:#fef3c7;color:#92400e;border-radius:20px;padding:3px 10px;display:inline-block;">
|
||||||
|
<i class="bi bi-lightbulb me-1"></i>${t}
|
||||||
|
</span>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
})();
|
||||||
|
|
||||||
// ── Spending anomalies ───────────────────────────────────────────────────────
|
// ── Spending anomalies ───────────────────────────────────────────────────────
|
||||||
(function(){
|
(function(){
|
||||||
const card = document.getElementById('anomaly-card');
|
const card = document.getElementById('anomaly-card');
|
||||||
|
|||||||
@@ -94,4 +94,145 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cash Flow Projection -->
|
||||||
|
<div class="pcard mt-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||||
|
<span class="pcard-title mb-0">Projected Cash Flow</span>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<button class="btn btn-sm proj-btn btn-primary" data-days="30" style="font-size:12px;">30d</button>
|
||||||
|
<button class="btn btn-sm proj-btn btn-outline-secondary" data-days="60" style="font-size:12px;">60d</button>
|
||||||
|
<button class="btn btn-sm proj-btn btn-outline-secondary" data-days="90" style="font-size:12px;">90d</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary stats -->
|
||||||
|
<div class="row g-2 mb-3" id="proj-stats">
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="pcard pcard-sm text-center p-2" style="background:#f0fdf4;">
|
||||||
|
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">Income</div>
|
||||||
|
<div id="proj-income" class="mono text-income fw-bold" style="font-size:15px;">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="pcard pcard-sm text-center p-2" style="background:#fef2f2;">
|
||||||
|
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">Expenses</div>
|
||||||
|
<div id="proj-expense" class="mono text-expense fw-bold" style="font-size:15px;">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="pcard pcard-sm text-center p-2">
|
||||||
|
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">Net</div>
|
||||||
|
<div id="proj-net" class="mono fw-bold" style="font-size:15px;">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="pcard pcard-sm text-center p-2" style="background:#eff6ff;">
|
||||||
|
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">End Balance</div>
|
||||||
|
<div id="proj-end" class="mono text-invest fw-bold" style="font-size:15px;">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chart -->
|
||||||
|
<div id="proj-chart-wrap" style="position:relative;height:200px;">
|
||||||
|
<canvas id="projChart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div id="proj-loading" class="text-center py-4 text-muted" style="display:none;">
|
||||||
|
<span class="spinner-border spinner-border-sm me-2"></span>Loading…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event list -->
|
||||||
|
<div id="proj-events" class="mt-3" style="max-height:260px;overflow-y:auto;"></div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const SYM = '{{ current_user.currency_symbol }}';
|
||||||
|
const fmtC = v => SYM + Math.abs(v).toLocaleString(undefined,{minimumFractionDigits:0,maximumFractionDigits:0});
|
||||||
|
|
||||||
|
let projChart = null;
|
||||||
|
|
||||||
|
function loadProjection(days) {
|
||||||
|
// update active button
|
||||||
|
document.querySelectorAll('.proj-btn').forEach(b => {
|
||||||
|
const active = b.dataset.days == days;
|
||||||
|
b.className = 'btn btn-sm proj-btn ' + (active ? 'btn-primary' : 'btn-outline-secondary');
|
||||||
|
b.style.fontSize = '12px';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('proj-loading').style.display = '';
|
||||||
|
document.getElementById('proj-chart-wrap').style.display = 'none';
|
||||||
|
|
||||||
|
fetch('/settings/recurring/projection?days=' + days)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
document.getElementById('proj-loading').style.display = 'none';
|
||||||
|
document.getElementById('proj-chart-wrap').style.display = '';
|
||||||
|
|
||||||
|
// Summary stats
|
||||||
|
document.getElementById('proj-income').textContent = fmtC(data.total_income);
|
||||||
|
document.getElementById('proj-expense').textContent = fmtC(data.total_expense);
|
||||||
|
const net = data.net;
|
||||||
|
const netEl = document.getElementById('proj-net');
|
||||||
|
netEl.textContent = (net >= 0 ? '+' : '-') + fmtC(Math.abs(net));
|
||||||
|
netEl.style.color = net >= 0 ? '#10b981' : '#ef4444';
|
||||||
|
document.getElementById('proj-end').textContent = fmtC(data.ending_balance);
|
||||||
|
|
||||||
|
// Chart
|
||||||
|
if (projChart) projChart.destroy();
|
||||||
|
const ctx = document.getElementById('projChart').getContext('2d');
|
||||||
|
projChart = new Chart(ctx, {
|
||||||
|
data: {
|
||||||
|
labels: data.labels,
|
||||||
|
datasets: [
|
||||||
|
{ type:'bar', label:'Income', data:data.income, backgroundColor:'#10b98133', borderColor:'#10b981', borderWidth:1.5, borderRadius:3, yAxisID:'y' },
|
||||||
|
{ type:'bar', label:'Expense', data:data.expense, backgroundColor:'#ef444433', borderColor:'#ef4444', borderWidth:1.5, borderRadius:3, yAxisID:'y' },
|
||||||
|
{ type:'line', label:'Balance', data:data.balance, borderColor:'#3b82f6', backgroundColor:'transparent', borderWidth:2, pointRadius:3, tension:.3, yAxisID:'y2' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive:true, maintainAspectRatio:false,
|
||||||
|
interaction:{ mode:'index', intersect:false },
|
||||||
|
plugins:{ legend:{ position:'bottom', labels:{ font:{ size:11 }, boxWidth:10 } } },
|
||||||
|
scales:{
|
||||||
|
y: { position:'left', grid:{ color:'#f1f5f9' }, ticks:{ font:{size:10}, callback: v=>fmtC(v) } },
|
||||||
|
y2: { position:'right', grid:{ display:false }, ticks:{ font:{size:10}, callback: v=>fmtC(v) } },
|
||||||
|
x: { grid:{ display:false }, ticks:{ font:{size:10} } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event list
|
||||||
|
const evList = document.getElementById('proj-events');
|
||||||
|
if (!data.events.length) {
|
||||||
|
evList.innerHTML = '<p class="text-muted small">No recurring transactions in this period.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
evList.innerHTML = data.events.map(ev => `
|
||||||
|
<div class="d-flex justify-content-between align-items-center py-2" style="border-top:1px solid var(--border);font-size:13px;">
|
||||||
|
<div>
|
||||||
|
<span class="fw-medium">${ev.description}</span>
|
||||||
|
<span class="text-muted ms-2" style="font-size:11px;">${ev.date}</span>
|
||||||
|
</div>
|
||||||
|
<span class="mono ${ev.type === 'income' ? 'text-income' : 'text-expense'}" style="font-size:13px;white-space:nowrap;">
|
||||||
|
${ev.type === 'income' ? '+' : '-'}${SYM}${Math.abs(ev.amount).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})}
|
||||||
|
</span>
|
||||||
|
</div>`).join('');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
document.getElementById('proj-loading').style.display = 'none';
|
||||||
|
document.getElementById('proj-chart-wrap').style.display = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.proj-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => loadProjection(parseInt(btn.dataset.days)));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load 30-day projection on page load
|
||||||
|
loadProjection(30);
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user