Initial Commit
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
instance/
|
||||||
|
info.db
|
||||||
|
supfin/
|
||||||
|
static/uploads/*
|
||||||
|
!static/uploads/.gitkeep
|
||||||
240
app.py
Normal file
240
app.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from flask import Flask, render_template, request, redirect, url_for, flash
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
from slugify import slugify
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///info.db'
|
||||||
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
|
app.config['UPLOAD_FOLDER'] = os.path.join(app.root_path, 'static', 'uploads')
|
||||||
|
app.config['SECRET_KEY'] = os.getenv('FLASK_SECRET_KEY', 'dev-secret-key-change-me')
|
||||||
|
|
||||||
|
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||||
|
|
||||||
|
db = SQLAlchemy(app)
|
||||||
|
|
||||||
|
ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_image(filename):
|
||||||
|
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
def unique_slug(base_title, model):
|
||||||
|
base = slugify(base_title)
|
||||||
|
slug = base
|
||||||
|
suffix = 2
|
||||||
|
while model.query.filter_by(slug=slug).first() is not None:
|
||||||
|
slug = f'{base}-{suffix}'
|
||||||
|
suffix += 1
|
||||||
|
return slug
|
||||||
|
|
||||||
|
|
||||||
|
class Goal(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
title = db.Column(db.String(255), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=False)
|
||||||
|
images = db.Column(db.Text, nullable=True)
|
||||||
|
goal_amount = db.Column(db.Integer, nullable=False)
|
||||||
|
completed = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
slug = db.Column(db.String(255), nullable=False, unique=True)
|
||||||
|
|
||||||
|
babysteps = db.relationship('BabyStep', backref='goal', lazy=True, cascade='all, delete-orphan')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def image_list(self):
|
||||||
|
return json.loads(self.images) if self.images else []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def saved_amount(self):
|
||||||
|
return sum(step.saved_amount for step in self.babysteps)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def progress_percent(self):
|
||||||
|
if not self.goal_amount:
|
||||||
|
return 0
|
||||||
|
return min(100, round(self.saved_amount / self.goal_amount * 100))
|
||||||
|
|
||||||
|
|
||||||
|
class BabyStep(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
title = db.Column(db.String(255), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=False)
|
||||||
|
monthly_amount = db.Column(db.Integer, nullable=False)
|
||||||
|
completed = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
slug = db.Column(db.String(255), nullable=False, unique=True)
|
||||||
|
goal_id = db.Column(db.Integer, db.ForeignKey('goal.id'), nullable=False)
|
||||||
|
|
||||||
|
payments = db.relationship('Payment', backref='babystep', lazy=True, cascade='all, delete-orphan')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def saved_amount(self):
|
||||||
|
return sum(payment.amount for payment in self.payments)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def progress_percent(self):
|
||||||
|
if not self.monthly_amount:
|
||||||
|
return 0
|
||||||
|
return min(100, round(self.saved_amount / self.monthly_amount * 100))
|
||||||
|
|
||||||
|
|
||||||
|
class Payment(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
description = db.Column(db.Text, nullable=False)
|
||||||
|
amount = db.Column(db.Integer, nullable=False)
|
||||||
|
received_at = db.Column(db.DateTime, server_default=db.func.now())
|
||||||
|
babystep_id = db.Column(db.Integer, db.ForeignKey('baby_step.id'), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
goals = Goal.query.order_by(Goal.id.desc()).all()
|
||||||
|
return render_template('index.html', goals=goals)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/goals')
|
||||||
|
def view_goals():
|
||||||
|
goals = Goal.query.order_by(Goal.id.desc()).all()
|
||||||
|
return render_template('goals.html', goals=goals)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/goals/<slug>')
|
||||||
|
def view_goal(slug):
|
||||||
|
goal = Goal.query.filter_by(slug=slug).first_or_404()
|
||||||
|
return render_template('goal_detail.html', goal=goal)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/babysteps')
|
||||||
|
def view_babysteps():
|
||||||
|
babysteps = BabyStep.query.order_by(BabyStep.id.desc()).all()
|
||||||
|
return render_template('babysteps.html', babysteps=babysteps)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/babysteps/<slug>')
|
||||||
|
def view_babystep(slug):
|
||||||
|
babystep = BabyStep.query.filter_by(slug=slug).first_or_404()
|
||||||
|
return render_template('babystep_detail.html', babystep=babystep)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/payments')
|
||||||
|
def view_payments():
|
||||||
|
payments = Payment.query.order_by(Payment.id.desc()).all()
|
||||||
|
return render_template('payments.html', payments=payments)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/newgoal', methods=['GET', 'POST'])
|
||||||
|
def new_goal():
|
||||||
|
if request.method == 'POST':
|
||||||
|
title = request.form.get('title', '').strip()
|
||||||
|
description = request.form.get('description', '').strip()
|
||||||
|
goal_amount = request.form.get('goal_amount', type=int)
|
||||||
|
completed = 'completed' in request.form
|
||||||
|
images = request.files.getlist('images')
|
||||||
|
|
||||||
|
if not title or goal_amount is None:
|
||||||
|
flash('Title and a valid goal amount are required.')
|
||||||
|
return render_template('new_goal.html')
|
||||||
|
|
||||||
|
image_filenames = []
|
||||||
|
for image in images:
|
||||||
|
if image.filename and allowed_image(image.filename):
|
||||||
|
filename = f'{uuid.uuid4().hex}_{secure_filename(image.filename)}'
|
||||||
|
image.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
|
||||||
|
image_filenames.append(filename)
|
||||||
|
|
||||||
|
goal = Goal(
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
images=json.dumps(image_filenames),
|
||||||
|
goal_amount=goal_amount,
|
||||||
|
completed=completed,
|
||||||
|
slug=unique_slug(title, Goal),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(goal)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Goal "{goal.title}" created.')
|
||||||
|
return redirect(url_for('view_goal', slug=goal.slug))
|
||||||
|
|
||||||
|
return render_template('new_goal.html')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/newbabystep', methods=['GET', 'POST'])
|
||||||
|
def new_babystep():
|
||||||
|
goals = Goal.query.order_by(Goal.title).all()
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
title = request.form.get('title', '').strip()
|
||||||
|
description = request.form.get('description', '').strip()
|
||||||
|
monthly_amount = request.form.get('monthly_amount', type=int)
|
||||||
|
goal_id = request.form.get('goal_id', type=int)
|
||||||
|
completed = 'completed' in request.form
|
||||||
|
|
||||||
|
goal = db.session.get(Goal, goal_id) if goal_id else None
|
||||||
|
|
||||||
|
if not title or monthly_amount is None or goal is None:
|
||||||
|
flash('Title, a valid monthly amount, and a goal are required.')
|
||||||
|
return render_template('new_babystep.html', goals=goals)
|
||||||
|
|
||||||
|
babystep = BabyStep(
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
monthly_amount=monthly_amount,
|
||||||
|
completed=completed,
|
||||||
|
slug=unique_slug(title, BabyStep),
|
||||||
|
goal_id=goal.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(babystep)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Babystep "{babystep.title}" added to "{goal.title}".')
|
||||||
|
return redirect(url_for('view_babystep', slug=babystep.slug))
|
||||||
|
|
||||||
|
return render_template('new_babystep.html', goals=goals)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/newpayment', methods=['GET', 'POST'])
|
||||||
|
def new_payment():
|
||||||
|
babysteps = BabyStep.query.order_by(BabyStep.title).all()
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
description = request.form.get('description', '').strip()
|
||||||
|
amount = request.form.get('amount', type=int)
|
||||||
|
babystep_id = request.form.get('babystep_id', type=int)
|
||||||
|
|
||||||
|
babystep = db.session.get(BabyStep, babystep_id) if babystep_id else None
|
||||||
|
|
||||||
|
if not description or amount is None or babystep is None:
|
||||||
|
flash('Description, a valid amount, and a babystep are required.')
|
||||||
|
return render_template('new_payment.html', babysteps=babysteps)
|
||||||
|
|
||||||
|
payment = Payment(
|
||||||
|
description=description,
|
||||||
|
amount=amount,
|
||||||
|
babystep_id=babystep.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(payment)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Payment of {amount} applied to "{babystep.title}".')
|
||||||
|
return redirect(url_for('view_babystep', slug=babystep.slug))
|
||||||
|
|
||||||
|
return render_template('new_payment.html', babysteps=babysteps)
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
init_db()
|
||||||
|
app.run(debug=False)
|
||||||
12
readme.md
Normal file
12
readme.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
The User will need to enter:
|
||||||
|
|
||||||
|
Multiple Financial Goals (Each Item will be a Goal_ID)
|
||||||
|
|
||||||
|
Monthly Steps Towards that goal (Baby_Step_ID)
|
||||||
|
|
||||||
|
How much they get from each paycheck (Payment_ID)
|
||||||
|
|
||||||
|
|
||||||
|
Payment ID relates to Baby Step ID, which updates each Goal ID depending on how the money is allocated.
|
||||||
|
|
||||||
|
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
Flask
|
||||||
|
Flask-SQLAlchemy
|
||||||
|
python-dotenv
|
||||||
|
python-slugify
|
||||||
0
static/uploads/.gitkeep
Normal file
0
static/uploads/.gitkeep
Normal file
29
templates/babystep_detail.html
Normal file
29
templates/babystep_detail.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ babystep.title }} · Superior Finance{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{ babystep.title }}</h1>
|
||||||
|
<p class="muted">{{ babystep.description }}</p>
|
||||||
|
<p class="muted">Toward goal: <a href="{{ url_for('view_goal', slug=babystep.goal.slug) }}">{{ babystep.goal.title }}</a></p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="meter-label">
|
||||||
|
<span>${{ babystep.saved_amount }} of ${{ babystep.monthly_amount }} / mo</span>
|
||||||
|
<span>{{ babystep.progress_percent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="meter">
|
||||||
|
<div class="meter-fill {% if babystep.completed %}complete{% endif %}" style="width: {{ babystep.progress_percent }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="margin-top:2rem;">Payments applied here</h2>
|
||||||
|
<p><a href="{{ url_for('new_payment') }}">+ Add a payment</a></p>
|
||||||
|
{% if not babystep.payments %}
|
||||||
|
<p class="empty">No payments logged yet.</p>
|
||||||
|
{% else %}
|
||||||
|
<ul class="plain">
|
||||||
|
{% for payment in babystep.payments|sort(attribute='id', reverse=True) %}
|
||||||
|
<li>{{ payment.description }} — ${{ payment.amount }} <span class="muted">({{ payment.received_at }})</span></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
22
templates/babysteps.html
Normal file
22
templates/babysteps.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Babysteps · Superior Finance{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Babysteps</h1>
|
||||||
|
{% if not babysteps %}
|
||||||
|
<p class="empty">No babysteps yet. <a href="{{ url_for('new_babystep') }}">Add one</a>.</p>
|
||||||
|
{% else %}
|
||||||
|
{% for step in babysteps %}
|
||||||
|
<div class="card">
|
||||||
|
<h2><a href="{{ url_for('view_babystep', slug=step.slug) }}">{{ step.title }}</a></h2>
|
||||||
|
<p class="muted">Toward <a href="{{ url_for('view_goal', slug=step.goal.slug) }}">{{ step.goal.title }}</a></p>
|
||||||
|
<div class="meter-label">
|
||||||
|
<span>${{ step.saved_amount }} of ${{ step.monthly_amount }} / mo</span>
|
||||||
|
<span>{{ step.progress_percent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="meter">
|
||||||
|
<div class="meter-fill {% if step.completed %}complete{% endif %}" style="width: {{ step.progress_percent }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
164
templates/base.html
Normal file
164
templates/base.html
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}Superior Finance{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--surface-1: #fcfcfb;
|
||||||
|
--page-plane: #f9f9f7;
|
||||||
|
--text-primary: #0b0b0b;
|
||||||
|
--text-secondary: #52514e;
|
||||||
|
--text-muted: #898781;
|
||||||
|
--gridline: #e1e0d9;
|
||||||
|
--border: rgba(11,11,11,0.10);
|
||||||
|
--series-1: #2a78d6;
|
||||||
|
--status-good: #0ca30c;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:where(:not([data-theme="light"])) {
|
||||||
|
color-scheme: dark;
|
||||||
|
--surface-1: #1a1a19;
|
||||||
|
--page-plane: #0d0d0d;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #c3c2b7;
|
||||||
|
--text-muted: #898781;
|
||||||
|
--gridline: #2c2c2a;
|
||||||
|
--border: rgba(255,255,255,0.10);
|
||||||
|
--series-1: #3987e5;
|
||||||
|
--status-good: #0ca30c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
background: var(--page-plane);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 0.9rem 1.5rem;
|
||||||
|
background: var(--surface-1);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
nav a {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
nav a:hover { color: var(--text-primary); }
|
||||||
|
nav a.brand { color: var(--text-primary); font-weight: 600; margin-right: auto; }
|
||||||
|
main {
|
||||||
|
max-width: 880px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.card h2 { margin-top: 0; }
|
||||||
|
.card h2 a { color: inherit; text-decoration: none; }
|
||||||
|
.card h2 a:hover { text-decoration: underline; }
|
||||||
|
.muted { color: var(--text-muted); font-size: 0.88rem; }
|
||||||
|
.flash {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--series-1);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.meter {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--gridline);
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.meter-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--series-1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.meter-fill.complete { background: var(--status-good); }
|
||||||
|
.meter-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.9rem;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
input[type="text"], input[type="number"], input[type="file"], select, textarea {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.checkbox-row {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.55rem 1.1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--series-1);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
button:hover { opacity: 0.92; }
|
||||||
|
.empty { color: var(--text-muted); font-size: 0.9rem; }
|
||||||
|
ul.plain { list-style: none; padding: 0; margin: 0; }
|
||||||
|
ul.plain li { padding: 0.5rem 0; border-bottom: 1px solid var(--gridline); }
|
||||||
|
ul.plain li:last-child { border-bottom: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a class="brand" href="{{ url_for('index') }}">Superior Finance</a>
|
||||||
|
<a href="{{ url_for('view_goals') }}">Goals</a>
|
||||||
|
<a href="{{ url_for('view_babysteps') }}">Babysteps</a>
|
||||||
|
<a href="{{ url_for('view_payments') }}">Payments</a>
|
||||||
|
<a href="{{ url_for('new_goal') }}">+ Goal</a>
|
||||||
|
<a href="{{ url_for('new_babystep') }}">+ Babystep</a>
|
||||||
|
<a href="{{ url_for('new_payment') }}">+ Payment</a>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="flash">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
43
templates/goal_detail.html
Normal file
43
templates/goal_detail.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ goal.title }} · Superior Finance{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{ goal.title }}</h1>
|
||||||
|
<p class="muted">{{ goal.description }}</p>
|
||||||
|
|
||||||
|
{% if goal.image_list %}
|
||||||
|
<div style="display:flex; gap:0.5rem; flex-wrap:wrap; margin-bottom:1rem;">
|
||||||
|
{% for image in goal.image_list %}
|
||||||
|
<img src="{{ url_for('static', filename='uploads/' ~ image) }}" alt="" style="width:120px; height:120px; object-fit:cover; border-radius:8px; border:1px solid var(--border);">
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="meter-label">
|
||||||
|
<span>${{ goal.saved_amount }} of ${{ goal.goal_amount }}</span>
|
||||||
|
<span>{{ goal.progress_percent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="meter">
|
||||||
|
<div class="meter-fill {% if goal.completed %}complete{% endif %}" style="width: {{ goal.progress_percent }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="margin-top:2rem;">Babysteps toward this goal</h2>
|
||||||
|
<p><a href="{{ url_for('new_babystep') }}">+ Add a babystep</a></p>
|
||||||
|
{% if not goal.babysteps %}
|
||||||
|
<p class="empty">No babysteps yet.</p>
|
||||||
|
{% else %}
|
||||||
|
{% for step in goal.babysteps %}
|
||||||
|
<div class="card">
|
||||||
|
<h2><a href="{{ url_for('view_babystep', slug=step.slug) }}">{{ step.title }}</a></h2>
|
||||||
|
<div class="meter-label">
|
||||||
|
<span>${{ step.saved_amount }} of ${{ step.monthly_amount }} / mo</span>
|
||||||
|
<span>{{ step.progress_percent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="meter">
|
||||||
|
<div class="meter-fill {% if step.completed %}complete{% endif %}" style="width: {{ step.progress_percent }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
22
templates/goals.html
Normal file
22
templates/goals.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Goals · Superior Finance{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Goals</h1>
|
||||||
|
{% if not goals %}
|
||||||
|
<p class="empty">No goals yet. <a href="{{ url_for('new_goal') }}">Create your first goal</a>.</p>
|
||||||
|
{% else %}
|
||||||
|
{% for goal in goals %}
|
||||||
|
<div class="card">
|
||||||
|
<h2><a href="{{ url_for('view_goal', slug=goal.slug) }}">{{ goal.title }}</a></h2>
|
||||||
|
<p class="muted">{{ goal.description }}</p>
|
||||||
|
<div class="meter-label">
|
||||||
|
<span>${{ goal.saved_amount }} of ${{ goal.goal_amount }}</span>
|
||||||
|
<span>{{ goal.progress_percent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="meter">
|
||||||
|
<div class="meter-fill {% if goal.completed %}complete{% endif %}" style="width: {{ goal.progress_percent }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
22
templates/index.html
Normal file
22
templates/index.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Dashboard · Superior Finance{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Your Goals</h1>
|
||||||
|
{% if not goals %}
|
||||||
|
<p class="empty">No goals yet. <a href="{{ url_for('new_goal') }}">Create your first goal</a>.</p>
|
||||||
|
{% else %}
|
||||||
|
{% for goal in goals %}
|
||||||
|
<div class="card">
|
||||||
|
<h2><a href="{{ url_for('view_goal', slug=goal.slug) }}">{{ goal.title }}</a></h2>
|
||||||
|
<div class="meter-label">
|
||||||
|
<span>${{ goal.saved_amount }} of ${{ goal.goal_amount }}</span>
|
||||||
|
<span>{{ goal.progress_percent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="meter">
|
||||||
|
<div class="meter-fill {% if goal.completed %}complete{% endif %}" style="width: {{ goal.progress_percent }}%"></div>
|
||||||
|
</div>
|
||||||
|
<p class="muted">{{ goal.babysteps|length }} babystep(s)</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
32
templates/new_babystep.html
Normal file
32
templates/new_babystep.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}New Babystep · Superior Finance{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>New Babystep</h1>
|
||||||
|
{% if not goals %}
|
||||||
|
<p class="empty">You need a goal before you can add a babystep. <a href="{{ url_for('new_goal') }}">Create one first</a>.</p>
|
||||||
|
{% else %}
|
||||||
|
<form method="post">
|
||||||
|
<label>Goal
|
||||||
|
<select name="goal_id" required>
|
||||||
|
{% for goal in goals %}
|
||||||
|
<option value="{{ goal.id }}">{{ goal.title }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Title
|
||||||
|
<input type="text" name="title" required>
|
||||||
|
</label>
|
||||||
|
<label>Description
|
||||||
|
<textarea name="description" rows="3"></textarea>
|
||||||
|
</label>
|
||||||
|
<label>Monthly amount ($)
|
||||||
|
<input type="number" name="monthly_amount" min="1" required>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" name="completed">
|
||||||
|
Already achieved
|
||||||
|
</label>
|
||||||
|
<button type="submit">Create Babystep</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
24
templates/new_goal.html
Normal file
24
templates/new_goal.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}New Goal · Superior Finance{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>New Goal</h1>
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
<label>Title
|
||||||
|
<input type="text" name="title" required>
|
||||||
|
</label>
|
||||||
|
<label>Description
|
||||||
|
<textarea name="description" rows="3"></textarea>
|
||||||
|
</label>
|
||||||
|
<label>Target amount ($)
|
||||||
|
<input type="number" name="goal_amount" min="1" required>
|
||||||
|
</label>
|
||||||
|
<label>Images
|
||||||
|
<input type="file" name="images" multiple accept="image/*">
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" name="completed">
|
||||||
|
Already achieved
|
||||||
|
</label>
|
||||||
|
<button type="submit">Create Goal</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
25
templates/new_payment.html
Normal file
25
templates/new_payment.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}New Payment · Superior Finance{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>New Payment</h1>
|
||||||
|
{% if not babysteps %}
|
||||||
|
<p class="empty">You need a babystep before you can log a payment. <a href="{{ url_for('new_babystep') }}">Create one first</a>.</p>
|
||||||
|
{% else %}
|
||||||
|
<form method="post">
|
||||||
|
<label>Apply to babystep
|
||||||
|
<select name="babystep_id" required>
|
||||||
|
{% for step in babysteps %}
|
||||||
|
<option value="{{ step.id }}">{{ step.title }} ({{ step.goal.title }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Description
|
||||||
|
<input type="text" name="description" placeholder="e.g. Paycheck 8/9" required>
|
||||||
|
</label>
|
||||||
|
<label>Amount ($)
|
||||||
|
<input type="number" name="amount" min="1" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Log Payment</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
17
templates/payments.html
Normal file
17
templates/payments.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Payments · Superior Finance{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Payments</h1>
|
||||||
|
{% if not payments %}
|
||||||
|
<p class="empty">No payments logged yet. <a href="{{ url_for('new_payment') }}">Log one</a>.</p>
|
||||||
|
{% else %}
|
||||||
|
<ul class="plain">
|
||||||
|
{% for payment in payments %}
|
||||||
|
<li>
|
||||||
|
{{ payment.description }} — ${{ payment.amount }}
|
||||||
|
<span class="muted">→ <a href="{{ url_for('view_babystep', slug=payment.babystep.slug) }}">{{ payment.babystep.title }}</a> ({{ payment.received_at }})</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user