Initial commit — Moon Household Budget
This commit is contained in:
486
templates/assets.html
Normal file
486
templates/assets.html
Normal file
@@ -0,0 +1,486 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<div class="page-header">
|
||||
<h1>Assets</h1>
|
||||
<input type="text" id="asset-search" placeholder="Search assets…" class="budget-input" style="width:220px;" oninput="filterAssets(this.value)">
|
||||
</div>
|
||||
|
||||
<!-- Net worth summary -->
|
||||
{% if net_worth_assets %}
|
||||
<div class="sinking-total-bar" style="margin-bottom:1.75rem;">
|
||||
<div>
|
||||
<div class="sinking-total-label">Total Tracked Asset Value</div>
|
||||
<div class="sinking-total-amount">${{ "{:,.2f}".format(net_worth_assets) }}</div>
|
||||
</div>
|
||||
<div class="sinking-total-note">Based on estimated / current values entered below</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Add Asset button -->
|
||||
<details class="add-debt-details" style="margin-bottom:2rem;">
|
||||
<summary class="btn btn-primary" style="display:inline-block;cursor:pointer;">+ Add Asset</summary>
|
||||
<div class="card-wide" style="margin-top:0.75rem;">
|
||||
<h3 style="margin-bottom:1rem;font-family:'DM Serif Display',serif;font-weight:400;">What type of asset?</h3>
|
||||
<div class="asset-type-grid">
|
||||
|
||||
<!-- Vehicle -->
|
||||
<details class="add-debt-details">
|
||||
<summary class="asset-type-btn">🚗 Vehicle</summary>
|
||||
<form method="POST" action="{{ url_for('vehicle_add') }}" style="margin-top:1rem;">
|
||||
<div class="bill-form-grid">
|
||||
<div class="form-group" style="grid-column:1/-1;">
|
||||
<label>Vehicle Type</label>
|
||||
<div style="display:flex;gap:1rem;margin-top:0.25rem;">
|
||||
<label style="display:flex;align-items:center;gap:0.4rem;font-weight:400;cursor:pointer;">
|
||||
<input type="radio" name="vehicle_type" value="personal" checked> Personal / Daily Driver
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:0.4rem;font-weight:400;cursor:pointer;">
|
||||
<input type="radio" name="vehicle_type" value="flip"> Flip Car
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>Year</label><input type="number" name="year" placeholder="e.g. 2020" required></div>
|
||||
<div class="form-group"><label>Make</label><input type="text" name="make" placeholder="e.g. Toyota" required></div>
|
||||
<div class="form-group"><label>Model</label><input type="text" name="model" placeholder="e.g. Camry" required></div>
|
||||
<div class="form-group"><label>Trim</label><input type="text" name="trim" placeholder="e.g. LE, 2.5L"></div>
|
||||
<div class="form-group"><label>Color</label><input type="text" name="color"></div>
|
||||
<div class="form-group"><label>Owner</label>
|
||||
<select name="owner">
|
||||
<option value="Bonna">Bonna</option>
|
||||
<option value="Tony">Tony</option>
|
||||
<option value="Both">Both</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group"><label>Purchase Price</label><input type="number" name="purchase_price" step="0.01" placeholder="0.00"></div>
|
||||
<div class="form-group"><label>Purchase Date</label><input type="date" name="purchase_date"></div>
|
||||
<div class="form-group"><label>Estimated Current Value</label><input type="number" name="estimated_value" step="0.01" placeholder="0.00"></div>
|
||||
<div class="form-group"><label>Notes</label><input type="text" name="notes"></div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Vehicle</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<!-- Investments -->
|
||||
<details class="add-debt-details">
|
||||
<summary class="asset-type-btn">📈 Investments</summary>
|
||||
<form method="POST" action="{{ url_for('other_asset_add') }}" style="margin-top:1rem;">
|
||||
<input type="hidden" name="category" value="Investments">
|
||||
<div class="bill-form-grid">
|
||||
<div class="form-group"><label>Account / Asset Name</label><input type="text" name="name" placeholder="e.g. Roth IRA, 401k, Brokerage" required></div>
|
||||
<div class="form-group"><label>Owner</label><select name="owner"><option value="Bonna">Bonna</option><option value="Tony">Tony</option><option value="Both">Both</option></select></div>
|
||||
<div class="form-group"><label>Date Opened</label><input type="date" name="purchase_date"></div>
|
||||
<div class="form-group"><label>Current Value</label><input type="number" name="value" step="0.01" placeholder="0.00"></div>
|
||||
<div class="form-group"><label>Notes</label><input type="text" name="notes" placeholder="e.g. Fidelity, employer match, contribution limit..."></div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Investment</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<!-- Real Estate -->
|
||||
<details class="add-debt-details">
|
||||
<summary class="asset-type-btn">🏠 Real Estate</summary>
|
||||
<form method="POST" action="{{ url_for('other_asset_add') }}" style="margin-top:1rem;">
|
||||
<input type="hidden" name="category" value="Real Estate">
|
||||
<div class="bill-form-grid">
|
||||
<div class="form-group"><label>Description</label><input type="text" name="name" placeholder="e.g. Primary Home, Rental Property" required></div>
|
||||
<div class="form-group"><label>Owner</label><select name="owner"><option value="Both">Both</option><option value="Bonna">Bonna</option><option value="Tony">Tony</option></select></div>
|
||||
<div class="form-group"><label>Purchase Price</label><input type="number" name="purchase_price" step="0.01" placeholder="0.00"></div>
|
||||
<div class="form-group"><label>Purchase Date</label><input type="date" name="purchase_date"></div>
|
||||
<div class="form-group"><label>Current Estimated Value</label><input type="number" name="value" step="0.01" placeholder="0.00"></div>
|
||||
<div class="form-group"><label>Notes</label><input type="text" name="notes" placeholder="Address, details..."></div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Real Estate</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<!-- Equipment -->
|
||||
<details class="add-debt-details">
|
||||
<summary class="asset-type-btn">🔧 Equipment</summary>
|
||||
<form method="POST" action="{{ url_for('other_asset_add') }}" style="margin-top:1rem;">
|
||||
<input type="hidden" name="category" value="Equipment">
|
||||
<div class="bill-form-grid">
|
||||
<div class="form-group"><label>Item Name</label><input type="text" name="name" placeholder="e.g. Studio Camera, Stand Mixer" required></div>
|
||||
<div class="form-group"><label>Owner</label><select name="owner"><option value="Bonna">Bonna</option><option value="Tony">Tony</option><option value="Both">Both</option></select></div>
|
||||
<div class="form-group"><label>Purchase Price</label><input type="number" name="purchase_price" step="0.01" placeholder="0.00"></div>
|
||||
<div class="form-group"><label>Purchase Date</label><input type="date" name="purchase_date"></div>
|
||||
<div class="form-group"><label>Current Value</label><input type="number" name="value" step="0.01" placeholder="0.00"></div>
|
||||
<div class="form-group"><label>Notes</label><input type="text" name="notes" placeholder="Serial #, warranty..."></div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Equipment</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<!-- Other Valuables -->
|
||||
<details class="add-debt-details">
|
||||
<summary class="asset-type-btn">✨ Other Valuables</summary>
|
||||
<form method="POST" action="{{ url_for('other_asset_add') }}" style="margin-top:1rem;">
|
||||
<input type="hidden" name="category" value="Other Valuables">
|
||||
<div class="bill-form-grid">
|
||||
<div class="form-group"><label>Item Name</label><input type="text" name="name" placeholder="e.g. Engagement Ring, Art" required></div>
|
||||
<div class="form-group"><label>Owner</label><select name="owner"><option value="Bonna">Bonna</option><option value="Tony">Tony</option><option value="Both">Both</option></select></div>
|
||||
<div class="form-group"><label>Purchase Price</label><input type="number" name="purchase_price" step="0.01" placeholder="0.00"></div>
|
||||
<div class="form-group"><label>Purchase Date</label><input type="date" name="purchase_date"></div>
|
||||
<div class="form-group"><label>Current / Appraised Value</label><input type="number" name="value" step="0.01" placeholder="0.00"></div>
|
||||
<div class="form-group"><label>Notes</label><input type="text" name="notes"></div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Item</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Vehicles -->
|
||||
<section class="section">
|
||||
<h2>Vehicles</h2>
|
||||
|
||||
<h3 class="asset-subheading">Personal</h3>
|
||||
<div class="asset-grid">
|
||||
{% for car in personal %}
|
||||
<div class="asset-card">
|
||||
<div class="asset-card-header">
|
||||
<div>
|
||||
<div class="asset-car-name">{{ car.display_name }}</div>
|
||||
<div class="asset-car-trim muted">{{ car.trim }}{% if car.color %} · {{ car.color }}{% endif %}</div>
|
||||
</div>
|
||||
<span class="owner-tag" style="background:{{ owner_colors.get(car.owner, '#ddd') }};">{{ car.owner }}</span>
|
||||
</div>
|
||||
<div class="asset-stat-row">
|
||||
<div class="asset-stat"><div class="asset-stat-label">Total Spent</div><div class="asset-stat-value">${{ car.total_spent | money }}</div></div>
|
||||
<div class="asset-stat"><div class="asset-stat-label">Est. Value</div><div class="asset-stat-value">{% if car.estimated_value %}${{ "{:,.0f}".format(car.estimated_value) }}{% else %}<span class="muted">—</span>{% endif %}</div></div>
|
||||
<div class="asset-stat"><div class="asset-stat-label">Gas Logs</div><div class="asset-stat-value">{{ car.gas_log | length }}</div></div>
|
||||
<div class="asset-stat"><div class="asset-stat-label">Service Logs</div><div class="asset-stat-value">{{ car.maintenance_log | length }}</div></div>
|
||||
</div>
|
||||
<details class="asset-details"><summary class="asset-details-toggle">+ Log Gas Fill-up</summary>
|
||||
<form method="POST" action="{{ url_for('personal_car_log', car_id=car.id) }}" class="asset-log-form">
|
||||
<input type="hidden" name="log_type" value="gas">
|
||||
<div class="asset-log-grid">
|
||||
<div class="form-group"><label>Date</label><input type="date" name="date" required></div>
|
||||
<div class="form-group"><label>Amount Paid</label><input type="number" name="amount" step="0.01" placeholder="0.00" required></div>
|
||||
<div class="form-group"><label>Gallons <span class="optional">(optional)</span></label><input type="number" name="gallons" step="0.001" placeholder="0.000"></div>
|
||||
<div class="form-group"><label>Notes</label><input type="text" name="notes" placeholder="Station, grade..."></div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-accept">Save</button>
|
||||
</form>
|
||||
</details>
|
||||
<details class="asset-details"><summary class="asset-details-toggle">+ Log Maintenance / Repair</summary>
|
||||
<form method="POST" action="{{ url_for('personal_car_log', car_id=car.id) }}" class="asset-log-form">
|
||||
<input type="hidden" name="log_type" value="maintenance">
|
||||
<div class="asset-log-grid">
|
||||
<div class="form-group"><label>Date</label><input type="date" name="date" required></div>
|
||||
<div class="form-group"><label>Amount</label><input type="number" name="amount" step="0.01" placeholder="0.00" required></div>
|
||||
<div class="form-group"><label>Description</label><input type="text" name="description" placeholder="e.g. Oil change" required></div>
|
||||
<div class="form-group"><label>Notes</label><input type="text" name="notes" placeholder="Shop, details..."></div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-accept">Save</button>
|
||||
</form>
|
||||
</details>
|
||||
{% set recent_gas = car.gas_log[-3:] | reverse | list %}
|
||||
{% set recent_maint = car.maintenance_log[-3:] | reverse | list %}
|
||||
{% if recent_gas or recent_maint %}
|
||||
<details class="asset-details"><summary class="asset-details-toggle">Recent Activity</summary>
|
||||
{% if recent_gas %}<div class="asset-log-section-label">Gas</div>{% for e in recent_gas %}<div class="asset-log-entry"><span class="muted">{{ e.date }}</span><span>${{ e.amount | money }}</span><span class="muted">{% if e.gallons %}{{ e.gallons }} gal{% endif %} {{ e.notes }}</span></div>{% endfor %}{% endif %}
|
||||
{% if recent_maint %}<div class="asset-log-section-label" style="margin-top:0.5rem;">Maintenance</div>{% for e in recent_maint %}<div class="asset-log-entry"><span class="muted">{{ e.date }}</span><span>${{ e.amount | money }}</span><span class="muted">{{ e.get('description','') }} {{ e.notes }}</span></div>{% endfor %}{% endif %}
|
||||
</details>
|
||||
{% endif %}
|
||||
<details class="asset-details"><summary class="asset-details-toggle">Edit Details</summary>
|
||||
<form method="POST" action="{{ url_for('personal_car_update', car_id=car.id) }}" class="asset-log-form">
|
||||
<div class="asset-log-grid">
|
||||
<div class="form-group"><label>Color</label><input type="text" name="color" value="{{ car.color }}"></div>
|
||||
<div class="form-group"><label>Owner</label><select name="owner">{% for o in ['Bonna','Tony','Both'] %}<option value="{{ o }}" {% if car.owner == o %}selected{% endif %}>{{ o }}</option>{% endfor %}</select></div>
|
||||
<div class="form-group"><label>Purchase Price</label><input type="number" name="purchase_price" step="0.01" value="{{ car.purchase_price or '' }}" placeholder="0.00"></div>
|
||||
<div class="form-group"><label>Purchase Date</label><input type="date" name="purchase_date" value="{{ car.purchase_date or '' }}"></div>
|
||||
<div class="form-group"><label>Estimated Value (KBB)</label><input type="number" name="estimated_value" step="0.01" value="{{ car.estimated_value or '' }}" placeholder="0.00"></div>
|
||||
<div class="form-group"><label>Notes</label><input type="text" name="notes" value="{{ car.notes or '' }}"></div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if active_flips %}
|
||||
<h3 class="asset-subheading" style="margin-top:1.5rem;">Flip Inventory</h3>
|
||||
<div style="margin-bottom:1rem;">
|
||||
<details class="add-debt-details">
|
||||
<summary class="btn btn-powder-blue" style="display:inline-block;cursor:pointer;">+ Add Flip Car</summary>
|
||||
<div class="card-wide" style="margin-top:0.75rem;">
|
||||
<form method="POST" action="{{ url_for('flip_add') }}" class="bill-form">
|
||||
<div class="bill-form-grid">
|
||||
<div class="form-group"><label>Year</label><input type="number" name="year" placeholder="e.g. 2018" required></div>
|
||||
<div class="form-group"><label>Make</label><input type="text" name="make" required></div>
|
||||
<div class="form-group"><label>Model</label><input type="text" name="model" required></div>
|
||||
<div class="form-group"><label>Trim</label><input type="text" name="trim"></div>
|
||||
<div class="form-group"><label>Color</label><input type="text" name="color"></div>
|
||||
<div class="form-group"><label>Purchase Price</label><input type="number" name="purchase_price" step="0.01" placeholder="0.00" required></div>
|
||||
<div class="form-group"><label>Purchase Date</label><input type="date" name="purchase_date"></div>
|
||||
<div class="form-group"><label>Personal Funds Used</label><input type="number" name="personal_contribution" step="0.01" placeholder="0.00"></div>
|
||||
<div class="form-group"><label>Notes</label><input type="text" name="notes"></div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-powder-blue">Add Flip Car</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="asset-grid">
|
||||
{% for flip in active_flips %}
|
||||
<div class="asset-card asset-flip-card">
|
||||
<div class="asset-card-header">
|
||||
<div><div class="asset-car-name">{{ flip.display_name }}</div><div class="asset-car-trim muted">{{ flip.trim }}{% if flip.color %} · {{ flip.color }}{% endif %}</div></div>
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||
<span class="flip-badge">Flip</span>
|
||||
<button class="edit-link" style="background:none;border:none;cursor:pointer;font-size:0.78rem;"
|
||||
onclick="openEditFlip('{{ flip.id }}','{{ flip.year }}','{{ flip.make }}','{{ flip.model }}','{{ flip.trim or '' }}','{{ flip.color or '' }}','{{ flip.purchase_price or 0 }}','{{ flip.purchase_date or '' }}','{{ flip.notes or '' }}')">
|
||||
✏️ Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="asset-stat-row">
|
||||
<div class="asset-stat"><div class="asset-stat-label">Purchased</div><div class="asset-stat-value">${{ "{:,.0f}".format(flip.purchase_price) }}</div></div>
|
||||
<div class="asset-stat"><div class="asset-stat-label">Total In</div><div class="asset-stat-value">${{ "{:,.0f}".format(flip.total_invested) }}</div></div>
|
||||
<div class="asset-stat"><div class="asset-stat-label">Expenses</div><div class="asset-stat-value">{{ flip.expense_log | length }}</div></div>
|
||||
</div>
|
||||
<details class="asset-details"><summary class="asset-details-toggle">+ Log Personal Expense</summary>
|
||||
<form method="POST" action="{{ url_for('flip_expense', flip_id=flip.id) }}" class="asset-log-form">
|
||||
<div class="asset-log-grid">
|
||||
<div class="form-group"><label>Date</label><input type="date" name="date" required></div>
|
||||
<div class="form-group"><label>Amount</label><input type="number" name="amount" step="0.01" placeholder="0.00" required></div>
|
||||
<div class="form-group"><label>Description</label><input type="text" name="description" placeholder="Parts, paint..." required></div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-accept">Save</button>
|
||||
</form>
|
||||
</details>
|
||||
{% if flip.expense_log %}<details class="asset-details"><summary class="asset-details-toggle">Expense Log</summary>{% for e in flip.expense_log | reverse %}<div class="asset-log-entry"><span class="muted">{{ e.date }}</span><span>${{ e.amount | money }}</span><span class="muted">{{ e.description }}</span></div>{% endfor %}</details>{% endif %}
|
||||
<details class="asset-details"><summary class="asset-details-toggle" style="color:#7A9E78;">✓ Mark as Sold</summary>
|
||||
<form method="POST" action="{{ url_for('flip_sell', flip_id=flip.id) }}" class="asset-log-form">
|
||||
<div class="asset-log-grid">
|
||||
<div class="form-group"><label>Sale Price</label><input type="number" name="sale_price" step="0.01" placeholder="0.00" required></div>
|
||||
<div class="form-group"><label>Sale Date</label><input type="date" name="sold_date" required></div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Mark Sold</button>
|
||||
</form>
|
||||
</details>
|
||||
<form method="POST" action="{{ url_for('flip_delete', flip_id=flip.id) }}"
|
||||
onsubmit="return confirm('Remove {{ flip.display_name }} from flips? This cannot be undone.');">
|
||||
<button type="submit" class="debt-delete-btn">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<h3 class="asset-subheading" style="margin-top:1.5rem;">Flip Inventory</h3>
|
||||
<details class="add-debt-details" style="margin-bottom:1rem;">
|
||||
<summary class="btn btn-powder-blue" style="display:inline-block;cursor:pointer;">+ Add Flip Car</summary>
|
||||
<div class="card-wide" style="margin-top:0.75rem;">
|
||||
<form method="POST" action="{{ url_for('flip_add') }}" class="bill-form">
|
||||
<div class="bill-form-grid">
|
||||
<div class="form-group"><label>Year</label><input type="number" name="year" placeholder="e.g. 2018" required></div>
|
||||
<div class="form-group"><label>Make</label><input type="text" name="make" required></div>
|
||||
<div class="form-group"><label>Model</label><input type="text" name="model" required></div>
|
||||
<div class="form-group"><label>Trim</label><input type="text" name="trim"></div>
|
||||
<div class="form-group"><label>Color</label><input type="text" name="color"></div>
|
||||
<div class="form-group"><label>Purchase Price</label><input type="number" name="purchase_price" step="0.01" placeholder="0.00" required></div>
|
||||
<div class="form-group"><label>Purchase Date</label><input type="date" name="purchase_date"></div>
|
||||
<div class="form-group"><label>Personal Funds Used</label><input type="number" name="personal_contribution" step="0.01" placeholder="0.00"></div>
|
||||
<div class="form-group"><label>Notes</label><input type="text" name="notes"></div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-powder-blue">Add Flip Car</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
<p class="muted">No active flips.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if sold_flips %}
|
||||
<h3 class="asset-subheading" style="margin-top:1.5rem;">Sold Flips</h3>
|
||||
<table class="txn-table txn-table-full">
|
||||
<thead><tr><th>Car</th><th>Bought For</th><th>Total Invested</th><th>Sold For</th><th>Profit / Loss</th><th>Sold</th></tr></thead>
|
||||
<tbody>
|
||||
{% for flip in sold_flips %}
|
||||
{% set profit = flip.profit %}
|
||||
<tr>
|
||||
<td>{{ flip.display_name }}{% if flip.color %} <span class="muted">{{ flip.color }}</span>{% endif %}</td>
|
||||
<td class="amount">${{ "{:,.2f}".format(flip.purchase_price) }}</td>
|
||||
<td class="amount">${{ "{:,.2f}".format(flip.total_invested) }}</td>
|
||||
<td class="amount">${{ "{:,.2f}".format(flip.sale_price) }}</td>
|
||||
<td class="amount {% if profit and profit > 0 %}accent{% elif profit and profit < 0 %}text-red{% endif %}">{% if profit is not none %}{{ '+' if profit > 0 else '' }}${{ "{:,.2f}".format(profit) }}{% else %}—{% endif %}</td>
|
||||
<td class="muted">{{ flip.sold_date }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<!-- Investment tracking instructions -->
|
||||
<div class="card-wide" style="margin-bottom:1.5rem;">
|
||||
<h3 style="margin-bottom:0.5rem;">📈 How Investment Tracking Works</h3>
|
||||
<p class="helper-text" style="margin-bottom:0.5rem;">Add each investment account (Roth IRA, 401k, brokerage, etc.) using the <strong>+ Add Asset</strong> button above and select <strong>Investments</strong>.</p>
|
||||
<p class="helper-text" style="margin-bottom:0.5rem;">Once added, each card has an editable <strong>Current Value</strong> field. Update it whenever you check your account — once a month is a good habit. Every time you update the value, the app logs a snapshot with today's date.</p>
|
||||
<p class="helper-text">After two or more updates on different days, a <strong>growth chart</strong> will appear on the card automatically, showing your balance over time in green (growth) or red (decline) with the total change displayed.</p>
|
||||
</div>
|
||||
|
||||
<!-- Other asset categories -->
|
||||
{% for cat in asset_categories %}
|
||||
<section class="section">
|
||||
<h2>{{ cat }}</h2>
|
||||
{% set cat_assets = by_category[cat] %}
|
||||
{% if cat_assets %}
|
||||
<div class="asset-grid">
|
||||
{% for a in cat_assets %}
|
||||
<div class="asset-card">
|
||||
<div class="asset-card-header">
|
||||
<div><div class="asset-car-name">{{ a.name }}</div>{% if a.notes %}<div class="asset-car-trim muted">{{ a.notes }}</div>{% endif %}</div>
|
||||
<span class="owner-tag" style="background:{{ owner_colors.get(a.owner, '#ddd') }};">{{ a.owner }}</span>
|
||||
</div>
|
||||
<div class="asset-stat-row">
|
||||
<div class="asset-stat"><div class="asset-stat-label">Paid</div><div class="asset-stat-value">{% if a.purchase_price %}${{ "{:,.0f}".format(a.purchase_price) }}{% else %}<span class="muted">—</span>{% endif %}</div></div>
|
||||
<div class="asset-stat">
|
||||
<div class="asset-stat-label">Current Value</div>
|
||||
<div class="asset-stat-value">
|
||||
$<input type="number" step="0.01" min="0"
|
||||
value="{{ a.value or 0 }}"
|
||||
class="budget-input" style="width:90px;display:inline;"
|
||||
onchange="updateAssetValue('{{ a.id }}', this.value)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="asset-stat"><div class="asset-stat-label">Date</div><div class="asset-stat-value muted" style="font-size:0.78rem;">{{ a.purchase_date or '—' }}</div></div>
|
||||
</div>
|
||||
{% if a.value_history and a.value_history | length > 1 %}
|
||||
<div style="margin:0.75rem 0 0.5rem;">
|
||||
<div style="font-size:0.72rem;color:var(--text-light);margin-bottom:0.25rem;">
|
||||
Value History
|
||||
{% set first = a.value_history[0].value | float %}
|
||||
{% set last = a.value_history[-1].value | float %}
|
||||
{% set change = last - first %}
|
||||
{% if change >= 0 %}
|
||||
<span style="color:#5A9E68;margin-left:0.4rem;">▲ ${{ change | money }}</span>
|
||||
{% else %}
|
||||
<span style="color:#c07070;margin-left:0.4rem;">▼ ${{ change | abs | money }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<canvas id="chart-{{ a.id }}" height="60"
|
||||
data-labels="{{ a.value_history | map(attribute='date') | list | tojson }}"
|
||||
data-values="{{ a.value_history | map(attribute='value') | list | tojson }}">
|
||||
</canvas>
|
||||
</div>
|
||||
{% elif a.value_history and a.value_history | length == 1 %}
|
||||
<div style="font-size:0.72rem;color:var(--text-light);margin:0.5rem 0;">Update value again next month to start seeing growth chart.</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="{{ url_for('other_asset_delete', asset_id=a.id) }}"
|
||||
onsubmit="return confirm('Remove {{ a.name }}?');">
|
||||
<button type="submit" class="debt-delete-btn">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted" style="margin-bottom:0.5rem;">No {{ cat | lower }} added yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Edit Flip Modal -->
|
||||
<div id="edit-flip-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:200;align-items:center;justify-content:center;">
|
||||
<div style="background:#fff;border-radius:12px;padding:1.5rem;width:90%;max-width:480px;box-shadow:0 8px 32px rgba(0,0,0,0.18);">
|
||||
<h3 style="margin:0 0 1rem;" id="edit-flip-title">Edit Flip</h3>
|
||||
<form method="POST" id="edit-flip-form" action="">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;">
|
||||
<div class="form-group"><label style="font-size:0.85rem;">Year</label><input type="number" name="year" id="ef-year" class="budget-input" style="width:100%;" required></div>
|
||||
<div class="form-group"><label style="font-size:0.85rem;">Make</label><input type="text" name="make" id="ef-make" class="budget-input" style="width:100%;" required></div>
|
||||
<div class="form-group"><label style="font-size:0.85rem;">Model</label><input type="text" name="model" id="ef-model" class="budget-input" style="width:100%;" required></div>
|
||||
<div class="form-group"><label style="font-size:0.85rem;">Trim</label><input type="text" name="trim" id="ef-trim" class="budget-input" style="width:100%;"></div>
|
||||
<div class="form-group"><label style="font-size:0.85rem;">Color</label><input type="text" name="color" id="ef-color" class="budget-input" style="width:100%;"></div>
|
||||
<div class="form-group"><label style="font-size:0.85rem;">Purchase Price</label><input type="number" step="0.01" name="purchase_price" id="ef-price" class="budget-input" style="width:100%;"></div>
|
||||
<div class="form-group"><label style="font-size:0.85rem;">Purchase Date</label><input type="date" name="purchase_date" id="ef-date" class="budget-input" style="width:100%;"></div>
|
||||
<div class="form-group"><label style="font-size:0.85rem;">Notes</label><input type="text" name="notes" id="ef-notes" class="budget-input" style="width:100%;"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.75rem;justify-content:flex-end;margin-top:1rem;">
|
||||
<button type="button" class="btn btn-ghost" onclick="closeEditFlip()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function updateAssetValue(id, value) {
|
||||
fetch('/assets/other/update/' + id, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({value: value})
|
||||
}).then(() => location.reload());
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('canvas[id^="chart-"]').forEach(canvas => {
|
||||
const labels = JSON.parse(canvas.dataset.labels);
|
||||
const values = JSON.parse(canvas.dataset.values);
|
||||
const color = values[values.length-1] >= values[0] ? '#5A9E68' : '#c07070';
|
||||
new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: values,
|
||||
borderColor: color,
|
||||
backgroundColor: color + '22',
|
||||
borderWidth: 2,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: color,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
plugins: { legend: { display: false }, tooltip: {
|
||||
callbacks: { label: ctx => '$' + ctx.parsed.y.toFixed(2) }
|
||||
}},
|
||||
scales: {
|
||||
x: { display: false },
|
||||
y: { display: false }
|
||||
},
|
||||
animation: false,
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function openEditFlip(id, year, make, model, trim, color, price, purchaseDate, notes) {
|
||||
document.getElementById('edit-flip-title').textContent = 'Edit — ' + year + ' ' + make + ' ' + model;
|
||||
document.getElementById('edit-flip-form').action = '/assets/flip/edit/' + id;
|
||||
document.getElementById('ef-year').value = year;
|
||||
document.getElementById('ef-make').value = make;
|
||||
document.getElementById('ef-model').value = model;
|
||||
document.getElementById('ef-trim').value = trim;
|
||||
document.getElementById('ef-color').value = color;
|
||||
document.getElementById('ef-price').value = price != '0' ? price : '';
|
||||
document.getElementById('ef-date').value = purchaseDate;
|
||||
document.getElementById('ef-notes').value = notes;
|
||||
document.getElementById('edit-flip-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeEditFlip() {
|
||||
document.getElementById('edit-flip-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
document.getElementById('edit-flip-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeEditFlip();
|
||||
});
|
||||
|
||||
function filterAssets(q) {
|
||||
q = q.toLowerCase();
|
||||
document.querySelectorAll('.sinking-card, .debt-card, .asset-card').forEach(card => {
|
||||
card.style.display = card.textContent.toLowerCase().includes(q) ? '' : 'none';
|
||||
});
|
||||
// Also filter table rows in the flips section
|
||||
document.querySelectorAll('tbody tr').forEach(row => {
|
||||
row.style.display = row.textContent.toLowerCase().includes(q) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
71
templates/base.html
Normal file
71
templates/base.html
Normal file
@@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🌙 Moon Household Budget</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<div class="nav-brand">🌙 Moon Household Budget</div>
|
||||
<div class="nav-links">
|
||||
<a href="{{ url_for('weekly_overview') }}">Weekly</a>
|
||||
<a href="{{ url_for('index') }}">Overview</a>
|
||||
<a href="{{ url_for('bill_calendar') }}">Calendar</a>
|
||||
<a href="{{ url_for('transactions_view') }}">Transactions</a>
|
||||
<a href="{{ url_for('income_view') }}">Income</a>
|
||||
<a href="{{ url_for('taxes_view') }}">Taxes</a>
|
||||
<a href="{{ url_for('bills_list') }}">Bills</a>
|
||||
<a href="{{ url_for('sinking_view') }}">Savings</a>
|
||||
<a href="{{ url_for('assets_view') }}">Assets</a>
|
||||
<a href="{{ url_for('mortgage_view') }}">Mortgage</a>
|
||||
<a href="{{ url_for('debts_view') }}">Debt</a>
|
||||
<a href="{{ url_for('net_worth_view') }}">Net Worth</a>
|
||||
<a href="{{ url_for('summary') }}">Spending Report</a>
|
||||
</div>
|
||||
</nav>
|
||||
<main>
|
||||
{% if unreviewed_count > 0 %}
|
||||
<div class="alert-banner" style="margin-bottom:1rem;">
|
||||
<span>You have <strong>{{ unreviewed_count }}</strong> transaction{{ 's' if unreviewed_count != 1 }} waiting to be categorized.</span>
|
||||
<a href="{{ url_for('review') }}" class="btn btn-primary">Review Now</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Wrap all number budget-inputs with a $ prefix and format on blur
|
||||
document.querySelectorAll('input[type="number"].budget-input').forEach(function (input) {
|
||||
// Skip if already wrapped or explicitly opted out
|
||||
if (input.classList.contains('no-dollar') || input.parentElement.classList.contains('dollar-wrap')) return;
|
||||
|
||||
// Wrap in dollar-sign container
|
||||
const wrap = document.createElement('span');
|
||||
wrap.className = 'dollar-wrap';
|
||||
wrap.style.width = input.style.width || '100%';
|
||||
input.parentNode.insertBefore(wrap, input);
|
||||
wrap.appendChild(input);
|
||||
|
||||
// Format existing value on load
|
||||
if (input.value !== '' && !isNaN(parseFloat(input.value))) {
|
||||
input.value = parseFloat(input.value).toFixed(2);
|
||||
}
|
||||
|
||||
// Format to 2 decimal places on blur
|
||||
input.addEventListener('blur', function () {
|
||||
if (this.value !== '' && !isNaN(parseFloat(this.value))) {
|
||||
this.value = parseFloat(this.value).toFixed(2);
|
||||
}
|
||||
});
|
||||
|
||||
// On focus, select all for easy overtyping
|
||||
input.addEventListener('focus', function () {
|
||||
this.select();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
159
templates/bills.html
Normal file
159
templates/bills.html
Normal file
@@ -0,0 +1,159 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Manage Bills</h1>
|
||||
<a href="{{ url_for('bill_calendar') }}" class="btn btn-ghost">View Calendar</a>
|
||||
</div>
|
||||
|
||||
<div class="card-wide">
|
||||
<h2>Add a Bill</h2>
|
||||
<form method="POST" action="{{ url_for('bill_add') }}" class="bill-form">
|
||||
<div class="bill-form-grid">
|
||||
<div class="form-group">
|
||||
<label>Bill Name</label>
|
||||
<input type="text" name="name" placeholder="e.g. Verizon, Affirm (home)" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Amount</label>
|
||||
<input type="number" name="amount" step="0.01" min="0" placeholder="0.00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Due Day of Month</label>
|
||||
<input type="number" name="due_day" min="1" max="31" placeholder="1–31" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Category</label>
|
||||
<select name="category" id="add-category" onchange="updateAddSubcats()">
|
||||
<option value="">— select —</option>
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat }}">{{ cat }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Subcategory <span class="optional">(optional)</span></label>
|
||||
<select name="subcategory" id="add-subcategory">
|
||||
<option value="">— none —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Notes <span class="optional">(optional)</span></label>
|
||||
<input type="text" name="notes" placeholder="e.g. T=Theresa, B=both">
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;align-items:center;gap:0.5rem;padding-top:0.5rem;">
|
||||
<input type="checkbox" name="autopay" id="autopay-new">
|
||||
<label for="autopay-new" style="margin:0;font-weight:400;">On autopay (no reminder needed)</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Bill</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if bills %}
|
||||
<section class="section">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.75rem;margin-bottom:0.75rem;">
|
||||
<h2>Your Bills <span class="muted">({{ bills | length }} total)</span></h2>
|
||||
<input type="text" id="bills-search" placeholder="Search bills…" class="budget-input" style="width:220px;" oninput="filterBills(this.value)">
|
||||
</div>
|
||||
<table class="txn-table txn-table-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Due Day</th>
|
||||
<th>Name</th>
|
||||
<th>Amount</th>
|
||||
<th>Category</th>
|
||||
<th>Autopay</th>
|
||||
<th>Remind Me</th>
|
||||
<th>Notes</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in bills | sort(attribute='due_day') %}
|
||||
<tr>
|
||||
<td class="amount">{{ b.due_day }}</td>
|
||||
<td>{{ b.name }}</td>
|
||||
<td class="amount">{% if b.amount %}${{ b.amount | money }}{% else %}<span class="muted">varies</span>{% endif %}</td>
|
||||
<td>
|
||||
{% if b.category %}
|
||||
<span class="tag" style="background: {{ colors.get(b.category, '#ddd') }};">
|
||||
{{ b.category }}{% if b.subcategory %} › {{ b.subcategory }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button class="toggle-btn {% if b.get('autopay') %}toggle-on{% endif %}"
|
||||
onclick="toggle('{{ b.id }}', 'autopay', this)">
|
||||
{{ '✓ Yes' if b.get('autopay') else 'No' }}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button class="toggle-btn {% if b.get('remind', not b.get('autopay')) %}toggle-on{% endif %}"
|
||||
onclick="toggle('{{ b.id }}', 'remind', this)">
|
||||
{{ '🔔 Yes' if b.get('remind', not b.get('autopay')) else 'Off' }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="muted">{{ b.notes }}</td>
|
||||
<td>
|
||||
<form method="POST" action="{{ url_for('bill_delete', bill_id=b.id) }}"
|
||||
onsubmit="return confirm('Delete {{ b.name }}?');" style="display:inline;">
|
||||
<button type="submit" class="edit-link" style="background:none;border:none;cursor:pointer;color:#C5A0A0;">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>No bills yet. Add your first one above.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="reminder-bar">
|
||||
<span>Run reminders now to test your email & Mac notifications:</span>
|
||||
<button class="btn btn-ghost" onclick="runReminders(this)">Send Test Reminder</button>
|
||||
<span class="reminder-result" id="reminder-result"></span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const subcats = {{ categories | tojson }};
|
||||
|
||||
function updateAddSubcats() {
|
||||
const cat = document.getElementById('add-category').value;
|
||||
const sel = document.getElementById('add-subcategory');
|
||||
sel.innerHTML = '<option value="">— none —</option>';
|
||||
(subcats[cat] || []).forEach(s => {
|
||||
const o = document.createElement('option');
|
||||
o.value = s; o.textContent = s;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
}
|
||||
|
||||
function toggle(billId, field, btn) {
|
||||
fetch(`/bills/toggle/${billId}/${field}`, { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => location.reload());
|
||||
}
|
||||
|
||||
function filterBills(q) {
|
||||
q = q.toLowerCase();
|
||||
document.querySelectorAll('.txn-table tbody tr').forEach(row => {
|
||||
row.style.display = row.textContent.toLowerCase().includes(q) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function runReminders(btn) {
|
||||
btn.textContent = 'Sending…';
|
||||
btn.disabled = true;
|
||||
fetch('/reminders/run', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
document.getElementById('reminder-result').textContent = data.message;
|
||||
btn.textContent = 'Send Test Reminder';
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
50
templates/calendar.html
Normal file
50
templates/calendar.html
Normal file
@@ -0,0 +1,50 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>{{ month_name }} {{ year }} — Bill Calendar</h1>
|
||||
<div class="month-nav">
|
||||
<a href="{{ url_for('bill_calendar', year=prev_year, month=prev_month) }}" class="btn btn-ghost">‹ Prev</a>
|
||||
<a href="{{ url_for('bill_calendar', year=next_year, month=next_month) }}" class="btn btn-ghost">Next ›</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cal-wrap">
|
||||
<div class="cal-grid">
|
||||
{% for day_name in ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'] %}
|
||||
<div class="cal-header">{{ day_name }}</div>
|
||||
{% endfor %}
|
||||
|
||||
{% for week in grid %}
|
||||
{% for day in week %}
|
||||
{% if day is none %}
|
||||
<div class="cal-cell cal-empty"></div>
|
||||
{% else %}
|
||||
<div class="cal-cell {% if today.day == day and today.month == month and today.year == year %}cal-today{% endif %}">
|
||||
<div class="cal-day-num">{{ day }}</div>
|
||||
{% for bill in bills_by_day.get(day, []) %}
|
||||
<div class="cal-bill" style="background: {{ colors.get(bill.category, '#ddd') }};{% if bill.get('paid') %}opacity:0.45;{% endif %}">
|
||||
<span class="cal-bill-name" style="{% if bill.get('paid') %}text-decoration:line-through;{% endif %}">{{ bill.name }}</span>
|
||||
{% if bill.amount %}<span class="cal-bill-amt">${{ bill.amount | money }}</span>{% endif %}
|
||||
{% if bill.get('paid') %}<span style="font-size:0.65rem;"> ✓</span>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cal-legend">
|
||||
{% for cat, color in colors.items() %}
|
||||
<span class="legend-item">
|
||||
<span class="legend-dot" style="background: {{ color }};"></span>{{ cat }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="cal-actions">
|
||||
<a href="{{ url_for('bills_list') }}" class="btn btn-primary">Manage Bills</a>
|
||||
<a href="{{ url_for('weekly_overview', year=year, month=month) }}" class="btn btn-ghost">Weekly Overview →</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
774
templates/debts.html
Normal file
774
templates/debts.html
Normal file
@@ -0,0 +1,774 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Debt Tracker — {{ month_name }} {{ year }}</h1>
|
||||
<div class="month-nav">
|
||||
<a href="{{ url_for('debts_view', year=prev_year, month=prev_month) }}" class="btn btn-ghost">‹ Prev</a>
|
||||
<a href="{{ url_for('debts_view', year=next_year, month=next_month) }}" class="btn btn-ghost">Next ›</a>
|
||||
<a href="{{ url_for('debt_strategy') }}" class="btn" style="background:#C47A4A;color:#fff;border:none;">📊 Debt Payoff Strategy</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Forms -->
|
||||
<div style="display:flex;gap:0.75rem;margin-bottom:1.5rem;flex-wrap:wrap;">
|
||||
|
||||
<details class="add-debt-details">
|
||||
<summary class="btn btn-primary" style="display:inline-block;cursor:pointer;">+ Add Credit Card</summary>
|
||||
<div class="card-wide" style="margin-top:0.75rem;">
|
||||
<form method="POST" action="{{ url_for('debt_add') }}" class="bill-form">
|
||||
<input type="hidden" name="debt_category" value="Credit Cards">
|
||||
<div class="bill-form-grid">
|
||||
<div class="form-group">
|
||||
<label>Card Name</label>
|
||||
<input type="text" name="name" placeholder="e.g. Discover, Chase, Indigo" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Owner</label>
|
||||
<select name="owner">
|
||||
<option value="Bonna">Bonna</option>
|
||||
<option value="Tony">Tony</option>
|
||||
<option value="Both">Both</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Current Balance</label>
|
||||
<input type="number" name="current_balance" step="0.01" min="0" placeholder="0.00" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>APR % <span class="optional">(leave blank if unknown)</span></label>
|
||||
<input type="number" name="apr" step="0.01" min="0" max="100" placeholder="e.g. 24.99">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Minimum Payment</label>
|
||||
<input type="number" name="minimum" step="0.01" min="0" placeholder="0.00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Account Name in App <span class="optional">(to match transactions)</span></label>
|
||||
<input type="text" name="account_match" placeholder="e.g. Discover">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Notes <span class="optional">(optional)</span></label>
|
||||
<input type="text" name="notes" placeholder="e.g. balance transfer, 0% promo ends Aug">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Debt</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="add-debt-details">
|
||||
<summary class="btn btn-dusty-rose" style="display:inline-block;cursor:pointer;">+ Add Medical Debt</summary>
|
||||
<div class="card-wide" style="margin-top:0.75rem;">
|
||||
<form method="POST" action="{{ url_for('debt_add') }}" class="bill-form">
|
||||
<input type="hidden" name="debt_category" value="Medical Payment Plans">
|
||||
<div class="bill-form-grid">
|
||||
<div class="form-group">
|
||||
<label>Provider / Institution</label>
|
||||
<input type="text" name="name" placeholder="e.g. Marshfield Clinic, Oakleaf" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Owner</label>
|
||||
<select name="owner">
|
||||
<option value="Bonna">Bonna</option>
|
||||
<option value="Tony">Tony</option>
|
||||
<option value="Both">Both</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Current Balance</label>
|
||||
<input type="number" name="current_balance" step="0.01" min="0" placeholder="0.00" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>APR % <span class="optional">(0% if interest-free plan)</span></label>
|
||||
<input type="number" name="apr" step="0.01" min="0" max="100" placeholder="0.00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Monthly Payment</label>
|
||||
<input type="number" name="minimum" step="0.01" min="0" placeholder="0.00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Account Name in App <span class="optional">(optional)</span></label>
|
||||
<input type="text" name="account_match" placeholder="e.g. Marshfield">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Notes <span class="optional">(optional)</span></label>
|
||||
<input type="text" name="notes" placeholder="e.g. interest-free, started Jan 2026">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Medical Debt</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="add-debt-details">
|
||||
<summary class="btn btn-powder-blue" style="display:inline-block;cursor:pointer;">+ Add Affirm / Klarna / BNPL Plan</summary>
|
||||
<div class="card-wide" style="margin-top:0.75rem;">
|
||||
<form method="POST" action="{{ url_for('bnpl_add') }}" class="bill-form">
|
||||
<div class="bill-form-grid">
|
||||
<div class="form-group">
|
||||
<label>Provider</label>
|
||||
<select name="bnpl_provider">
|
||||
<option>Affirm</option>
|
||||
<option>Klarna</option>
|
||||
<option>Afterpay</option>
|
||||
<option>Zip</option>
|
||||
<option>Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>What was it for?</label>
|
||||
<input type="text" name="description" placeholder="e.g. Living room couch" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Merchant / Store</label>
|
||||
<input type="text" name="merchant" placeholder="e.g. Wayfair, Amazon">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Owner</label>
|
||||
<select name="owner">
|
||||
<option value="Bonna">Bonna</option>
|
||||
<option value="Tony">Tony</option>
|
||||
<option value="Both">Both</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Original Purchase Amount</label>
|
||||
<input type="number" name="original_amount" step="0.01" min="0" placeholder="0.00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Payment Amount</label>
|
||||
<input type="number" name="payment_amount" step="0.01" min="0" placeholder="0.00" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Total Number of Payments</label>
|
||||
<input type="number" name="total_payments" min="1" placeholder="e.g. 4 or 6 or 12" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Payments Already Made</label>
|
||||
<input type="number" name="payments_made" min="0" value="0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Payment Frequency</label>
|
||||
<select name="frequency">
|
||||
<option value="biweekly">Every 2 weeks</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="4-payment">4 payments (every 2 weeks)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Next Payment Date</label>
|
||||
<input type="date" name="next_payment_date" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Notes <span class="optional">(optional)</span></label>
|
||||
<input type="text" name="notes" placeholder="e.g. for bedroom, holiday gift">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add BNPL Plan</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="add-debt-details">
|
||||
<summary class="btn btn-moss" style="display:inline-block;cursor:pointer;">📋 Add Card by Pasting Statement</summary>
|
||||
<div class="card-wide" style="margin-top:0.75rem;">
|
||||
<p class="muted" style="font-size:0.85rem;margin-bottom:1rem;">Fill in the card name and owner, paste your statement text, and we'll create the card and fill in all the numbers at once.</p>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
|
||||
<div class="form-group">
|
||||
<label>Card Name</label>
|
||||
<input type="text" id="paste-card-name" placeholder="e.g. Self Visa, Discover" class="budget-input" style="width:100%;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Owner</label>
|
||||
<select id="paste-card-owner" class="budget-input" style="width:100%;">
|
||||
<option value="Bonna">Bonna</option>
|
||||
<option value="Tony">Tony</option>
|
||||
<option value="Both">Both</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Paste Statement Text</label>
|
||||
<textarea id="paste-new-statement" style="width:100%;height:160px;border:1px solid var(--border);border-radius:8px;padding:0.75rem;font-size:0.82rem;resize:vertical;" placeholder="Copy all the text from your statement and paste it here…"></textarea>
|
||||
</div>
|
||||
<div id="paste-new-result" style="font-size:0.85rem;margin-bottom:0.75rem;"></div>
|
||||
<button class="btn btn-primary" onclick="addCardFromStatement()">Add Card & Import Statement</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Debt Summary Breakdown -->
|
||||
<div class="debt-summary-block">
|
||||
<table class="debt-summary-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Balance</th>
|
||||
<th>Minimums</th>
|
||||
<th>Paid This Month</th>
|
||||
<th>Extra</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for cat in debt_categories %}
|
||||
{% set ct = totals[cat] %}
|
||||
{% if by_category[cat] %}
|
||||
<tr>
|
||||
<td>{{ cat }}</td>
|
||||
<td class="amount">${{ ct.balance | money }}</td>
|
||||
<td class="amount">${{ ct.minimum | money }}</td>
|
||||
<td class="amount">${{ ct.paid | money }}</td>
|
||||
<td class="amount accent">${{ ct.extra | money }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td><strong>Grand Total</strong></td>
|
||||
<td class="amount"><strong>${{ grand.balance | money }}</strong></td>
|
||||
<td class="amount"><strong>${{ grand.minimum | money }}</strong></td>
|
||||
<td class="amount"><strong>${{ grand.paid | money }}</strong></td>
|
||||
<td class="amount accent"><strong>${{ grand.extra | money }}</strong></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Debt-to-Income Ratio -->
|
||||
{% if dti is not none %}
|
||||
{% if dti <= 20 %}
|
||||
{% set dti_color = "#5A9E68" %}{% set dti_label = "Healthy" %}{% set dti_tip = "Under 20% is excellent." %}
|
||||
{% elif dti <= 36 %}
|
||||
{% set dti_color = "#D4A96A" %}{% set dti_label = "Manageable" %}{% set dti_tip = "20–36% is average. Keep an eye on it." %}
|
||||
{% elif dti <= 50 %}
|
||||
{% set dti_color = "#E8956A" %}{% set dti_label = "High" %}{% set dti_tip = "36–50% is high. Prioritize paying down debt." %}
|
||||
{% else %}
|
||||
{% set dti_color = "#C57A7A" %}{% set dti_label = "Critical" %}{% set dti_tip = "Over 50% is a red flag. Focus on reducing debt ASAP." %}
|
||||
{% endif %}
|
||||
<div style="display:flex;align-items:center;gap:1.5rem;background:#fff;border:1px solid var(--border);border-radius:var(--radius);padding:1rem 1.25rem;margin-bottom:1rem;flex-wrap:wrap;">
|
||||
<div>
|
||||
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-light);margin-bottom:0.2rem;">Debt-to-Income Ratio</div>
|
||||
<div style="font-size:1.8rem;font-weight:700;color:{{ dti_color }};">{{ dti }}%</div>
|
||||
</div>
|
||||
<div style="flex:1;min-width:180px;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.78rem;margin-bottom:0.3rem;">
|
||||
<span style="color:{{ dti_color }};font-weight:600;">{{ dti_label }}</span>
|
||||
<span class="muted">${{ "%.0f" | format(grand.minimum) }} min / ${{ "%.0f" | format(monthly_income) }} income</span>
|
||||
</div>
|
||||
<div style="background:var(--border);border-radius:99px;height:8px;overflow:hidden;">
|
||||
<div style="height:100%;width:{{ [dti, 100] | min }}%;background:{{ dti_color }};border-radius:99px;transition:width 0.3s;"></div>
|
||||
</div>
|
||||
<div style="font-size:0.75rem;color:var(--text-light);margin-top:0.3rem;">{{ dti_tip }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="background:#fff;border:1px solid var(--border);border-radius:var(--radius);padding:0.75rem 1.25rem;margin-bottom:1rem;font-size:0.85rem;color:var(--text-light);">
|
||||
DTI ratio unavailable — <a href="{{ url_for('income_view') }}">log this month's income</a> to see your debt-to-income ratio.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Search -->
|
||||
<div style="margin-bottom:1rem;">
|
||||
<input type="text" id="debt-search" placeholder="Search debts…" class="budget-input" style="width:240px;" oninput="filterDebts(this.value)">
|
||||
</div>
|
||||
|
||||
<!-- By Category -->
|
||||
{% for cat in debt_categories %}
|
||||
{% set debts = by_category[cat] %}
|
||||
{% set cat_totals = totals[cat] %}
|
||||
{% if debts %}
|
||||
<section class="section">
|
||||
<div class="debt-cat-header" onclick="toggleDebtSection('debt-section-{{ loop.index }}')" style="cursor:pointer;user-select:none;">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||
<span class="debt-collapse-arrow" id="arrow-debt-section-{{ loop.index }}">▾</span>
|
||||
<h2 style="margin:0;">{{ cat }}</h2>
|
||||
</div>
|
||||
<div class="debt-cat-totals">
|
||||
<span>Balance: <strong>${{ cat_totals.balance | money }}</strong></span>
|
||||
<span>Minimums: <strong>${{ cat_totals.minimum | money }}</strong></span>
|
||||
<span>Paid: <strong>${{ cat_totals.paid | money }}</strong></span>
|
||||
<span class="accent">Extra: <strong>${{ cat_totals.extra | money }}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="debt-cards" id="debt-section-{{ loop.index }}">
|
||||
{% for d in debts %}
|
||||
<div class="debt-card">
|
||||
<div class="debt-card-top">
|
||||
<div>
|
||||
<div class="debt-card-name">{{ d.name }}</div>
|
||||
{% if d.notes %}<div class="debt-card-notes">{{ d.notes }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="debt-card-badges">
|
||||
<span class="owner-tag" style="background: {{ owner_colors.get(d.owner, '#ddd') }};">{{ d.owner }}</span>
|
||||
{% if d.apr %}<span class="apr-tag">{{ d.apr }}% APR</span>{% endif %}
|
||||
{% if d.get('is_bnpl') %}
|
||||
<button class="edit-link" style="background:none;border:none;cursor:pointer;font-size:0.78rem;"
|
||||
onclick="openEditBNPL('{{ d.id }}','{{ d.name }}','{{ d.merchant or '' }}','{{ d.bnpl_provider or 'Affirm' }}','{{ d.owner }}','{{ d.original_amount or 0 }}','{{ d.payment_amount or 0 }}','{{ d.total_payments or 0 }}','{{ d.payments_made or 0 }}','{{ d.frequency or 'biweekly' }}','{{ d.next_payment_date or '' }}','{{ d.notes or '' }}')">
|
||||
✏️ Edit
|
||||
</button>
|
||||
{% else %}
|
||||
<button class="edit-link" style="background:none;border:none;cursor:pointer;font-size:0.78rem;"
|
||||
onclick="openEditDebt('{{ d.id }}','{{ d.name }}','{{ d.owner }}','{{ d.apr or 0 }}','{{ d.minimum or 0 }}','{{ d.current_balance or 0 }}','{{ d.credit_limit or 0 }}','{{ d.notes or '' }}')">
|
||||
✏️ Edit
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="debt-card-balance">
|
||||
<span class="debt-balance-label">Balance</span>
|
||||
<span class="debt-balance-value" id="balance-{{ d.id }}">${{ d.ending | money }}</span>
|
||||
</div>
|
||||
{% if d.credit_limit %}
|
||||
{% set available = d.credit_limit - d.ending %}
|
||||
{% set util_pct = [(d.ending / d.credit_limit * 100), 100] | min | int %}
|
||||
<div style="margin-bottom:0.75rem;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.8rem;margin-bottom:0.3rem;">
|
||||
<span style="color:var(--text-light);">Available credit</span>
|
||||
<span style="font-weight:600;color:{% if available < 0 %}#c07070{% elif util_pct > 80 %}#c07070{% elif util_pct > 50 %}#b8943f{% else %}#5A9E68{% endif %};">
|
||||
${{ available | money }} of ${{ d.credit_limit | money }}
|
||||
</span>
|
||||
</div>
|
||||
<div style="background:#EDE8E2;border-radius:4px;height:6px;">
|
||||
<div style="background:{% if util_pct > 80 %}#c07070{% elif util_pct > 50 %}#d4a843{% else %}#5A9E68{% endif %};border-radius:4px;height:6px;width:{{ util_pct }}%;"></div>
|
||||
</div>
|
||||
<div style="font-size:0.73rem;color:var(--text-light);margin-top:0.2rem;">{{ util_pct }}% utilized</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if d.payoff_str and not d.get('is_bnpl') %}
|
||||
<div style="margin-bottom:0.75rem;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.8rem;margin-bottom:0.3rem;">
|
||||
<span style="color:var(--text-light);">Payoff projection</span>
|
||||
<span style="font-weight:600;color:#5A9E68;">{{ d.payoff_str }}</span>
|
||||
</div>
|
||||
{% if d.months_left and d.months_left <= 36 %}
|
||||
{% set payoff_pct = [100 - (d.months_left / 36 * 100), 5] | max | int %}
|
||||
<div style="background:#EDE8E2;border-radius:4px;height:6px;">
|
||||
<div style="background:#5A9E68;border-radius:4px;height:6px;width:{{ payoff_pct }}%;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div style="font-size:0.73rem;color:var(--text-light);margin-top:0.2rem;">
|
||||
{{ d.months_left }} month{{ 's' if d.months_left != 1 }} at minimum payments
|
||||
{% if d.apr %} · {{ d.apr }}% APR{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Statement pending toggle -->
|
||||
{% if not d.get('is_bnpl') %}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
|
||||
<button class="toggle-btn {% if d.get('statement_pending') %}toggle-on{% endif %}"
|
||||
onclick="togglePending('{{ d.id }}', this)"
|
||||
style="font-size:0.75rem;padding:0.2rem 0.6rem;">
|
||||
{{ '⏳ Statement Pending' if d.get('statement_pending') else 'Statement closed' }}
|
||||
</button>
|
||||
{% if d.get('statement_pending') %}
|
||||
<span style="font-size:0.75rem;color:var(--text-light);">numbers may not reflect current statement</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if d.get('is_bnpl') %}
|
||||
{% set pct = ((d.payments_made / d.total_payments) * 100) | int if d.total_payments else 0 %}
|
||||
{% set remaining_payments = d.total_payments - d.payments_made %}
|
||||
{% set payoff_str, _ = bnpl_payoff_date(d.payments_made, d.total_payments, d.frequency_days) %}
|
||||
<div class="bnpl-progress-wrap">
|
||||
<div class="bnpl-progress-bar">
|
||||
<div class="bnpl-progress-fill" style="width: {{ pct }}%;"></div>
|
||||
</div>
|
||||
<div class="bnpl-progress-label">{{ d.payments_made }} of {{ d.total_payments }} payments made</div>
|
||||
</div>
|
||||
<table class="debt-month-table">
|
||||
<tr><td>Payment Amount</td><td class="amount">${{ d.payment_amount | money }}</td></tr>
|
||||
<tr><td>Payments Remaining</td><td class="amount">{{ remaining_payments }}</td></tr>
|
||||
<tr><td>Still Owed</td><td class="amount">${{ d.current_balance | money }}</td></tr>
|
||||
{% if payoff_str %}<tr><td>Payoff Date</td><td class="amount accent">{{ payoff_str }}</td></tr>{% endif %}
|
||||
</table>
|
||||
<form method="POST" action="{{ url_for('bnpl_payment', debt_id=d.id) }}" style="margin-bottom:0.5rem;">
|
||||
<button type="submit" class="btn btn-accept" onclick="return confirm('Mark one payment as made?')">
|
||||
✓ Mark Payment Made
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<table class="debt-month-table">
|
||||
<tr>
|
||||
<td>Starting Balance</td>
|
||||
<td><input type="number" step="0.01" class="budget-input"
|
||||
value="{{ d.starting }}"
|
||||
onchange="logDebt('{{ d.id }}', {{ year }}, {{ month }}, 'starting_balance', this.value, '{{ d.id }}')"
|
||||
placeholder="0.00"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Interest Charged</td>
|
||||
<td><input type="number" step="0.01" class="budget-input"
|
||||
value="{{ d.interest }}"
|
||||
onchange="logDebt('{{ d.id }}', {{ year }}, {{ month }}, 'interest', this.value, '{{ d.id }}')"
|
||||
placeholder="auto-calculated"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>New Charges</td>
|
||||
<td><input type="number" step="0.01" class="budget-input"
|
||||
value="{{ d.new_charges or '' }}"
|
||||
onchange="logDebt('{{ d.id }}', {{ year }}, {{ month }}, 'new_charges', this.value, '{{ d.id }}')"
|
||||
placeholder="0.00"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Minimum Due</td>
|
||||
<td class="amount">${{ d.minimum | money }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Payment Made</td>
|
||||
<td><input type="number" step="0.01" class="budget-input"
|
||||
value="{{ d.payment or '' }}"
|
||||
onchange="logDebt('{{ d.id }}', {{ year }}, {{ month }}, 'payment', this.value, '{{ d.id }}')"
|
||||
placeholder="0.00"></td>
|
||||
</tr>
|
||||
<tr class="debt-extra-row">
|
||||
{% set underpaid = d.payment > 0 and d.payment < d.minimum and not d.get('statement_pending') %}
|
||||
<td>{% if underpaid %}⚠️ Underpaid Minimum{% else %}Extra Paid Above Minimum{% endif %}</td>
|
||||
<td class="amount {% if underpaid %}{% else %}accent{% endif %}" id="extra-{{ d.id }}"
|
||||
{% if underpaid %}style="color:#c07070;"{% endif %}>
|
||||
{% if underpaid %}
|
||||
-${{ d.minimum - d.payment | money }} short
|
||||
{% elif d.get('statement_pending') %}
|
||||
<span class="muted">—</span>
|
||||
{% else %}
|
||||
${{ d.extra | money }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if not d.get('is_bnpl') %}
|
||||
<button class="btn btn-ghost" style="font-size:0.78rem;margin-bottom:0.5rem;width:100%;"
|
||||
onclick="openStatementModal('{{ d.id }}', '{{ d.name }}')">📋 Paste Statement</button>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="{{ url_for('debt_delete', debt_id=d.id) }}"
|
||||
onsubmit="return confirm('Remove {{ d.name }} from debt tracker?');">
|
||||
<button type="submit" class="debt-delete-btn">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if not (by_category.values() | map('list') | sum(start=[])) %}
|
||||
<div class="empty-state">
|
||||
<p>No debts added yet. Click "+ Add a Debt" above to get started.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function toggleDebtSection(id) {
|
||||
const el = document.getElementById(id);
|
||||
const arrow = document.getElementById('arrow-' + id);
|
||||
if (!el) return;
|
||||
const collapsed = el.style.display === 'none';
|
||||
el.style.display = collapsed ? '' : 'none';
|
||||
if (arrow) arrow.textContent = collapsed ? '▾' : '▸';
|
||||
}
|
||||
|
||||
function togglePending(debtId, btn) {
|
||||
fetch(`/debts/toggle-pending/${debtId}`, {method: 'POST'})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
btn.textContent = data.pending ? '⏳ Statement Pending' : 'Statement closed';
|
||||
btn.classList.toggle('toggle-on', data.pending);
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
function filterDebts(q) {
|
||||
q = q.toLowerCase();
|
||||
document.querySelectorAll('.debt-card').forEach(card => {
|
||||
card.style.display = card.textContent.toLowerCase().includes(q) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function logDebt(debtId, year, month, field, value, cardId) {
|
||||
const body = new URLSearchParams({debt_id: debtId, year, month, field, value});
|
||||
fetch('/debts/log', {method: 'POST', body})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ending !== undefined) {
|
||||
const bal = document.getElementById('balance-' + cardId);
|
||||
if (bal) bal.textContent = '$' + data.ending.toFixed(2);
|
||||
}
|
||||
if (data.extra !== undefined) {
|
||||
const ext = document.getElementById('extra-' + cardId);
|
||||
if (ext) ext.textContent = '$' + data.extra.toFixed(2);
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Edit Debt Modal -->
|
||||
<div id="edit-debt-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:200;align-items:center;justify-content:center;">
|
||||
<div style="background:#fff;border-radius:12px;padding:1.5rem;width:90%;max-width:480px;box-shadow:0 8px 32px rgba(0,0,0,0.18);">
|
||||
<h3 style="margin:0 0 1rem;" id="edit-debt-title">Edit Debt</h3>
|
||||
<form method="POST" id="edit-debt-form" action="">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;">
|
||||
<div class="form-group">
|
||||
<label style="font-size:0.85rem;">Card / Debt Name</label>
|
||||
<input type="text" name="name" id="edit-name" class="budget-input" style="width:100%;" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="font-size:0.85rem;">Owner</label>
|
||||
<select name="owner" id="edit-owner" class="budget-input" style="width:100%;">
|
||||
<option value="Bonna">Bonna</option>
|
||||
<option value="Tony">Tony</option>
|
||||
<option value="Both">Both</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="font-size:0.85rem;">Current Balance</label>
|
||||
<input type="number" name="current_balance" id="edit-balance" step="0.01" min="0" class="budget-input" style="width:100%;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="font-size:0.85rem;">APR %</label>
|
||||
<input type="number" name="apr" id="edit-apr" step="0.01" min="0" class="budget-input" style="width:100%;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="font-size:0.85rem;">Minimum Payment</label>
|
||||
<input type="number" name="minimum" id="edit-minimum" step="0.01" min="0" class="budget-input" style="width:100%;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="font-size:0.85rem;">Credit Limit</label>
|
||||
<input type="number" name="credit_limit" id="edit-credit-limit" step="0.01" min="0" class="budget-input" style="width:100%;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:0.5rem;">
|
||||
<label style="font-size:0.85rem;">Notes</label>
|
||||
<input type="text" name="notes" id="edit-notes" class="budget-input" style="width:100%;" placeholder="optional">
|
||||
</div>
|
||||
<div style="display:flex;gap:0.75rem;justify-content:flex-end;margin-top:1rem;">
|
||||
<button type="button" class="btn btn-ghost" onclick="closeEditDebt()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit BNPL Modal -->
|
||||
<div id="edit-bnpl-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:200;align-items:center;justify-content:center;">
|
||||
<div style="background:#fff;border-radius:12px;padding:1.5rem;width:90%;max-width:520px;box-shadow:0 8px 32px rgba(0,0,0,0.18);max-height:90vh;overflow-y:auto;">
|
||||
<h3 style="margin:0 0 1rem;" id="edit-bnpl-title">Edit BNPL Plan</h3>
|
||||
<form method="POST" id="edit-bnpl-form" action="">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;">
|
||||
<div class="form-group">
|
||||
<label style="font-size:0.85rem;">What is it for?</label>
|
||||
<input type="text" name="description" id="ebnpl-description" class="budget-input" style="width:100%;" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="font-size:0.85rem;">Merchant / Store</label>
|
||||
<input type="text" name="merchant" id="ebnpl-merchant" class="budget-input" style="width:100%;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="font-size:0.85rem;">Provider</label>
|
||||
<select name="bnpl_provider" id="ebnpl-provider" class="budget-input" style="width:100%;">
|
||||
<option>Affirm</option>
|
||||
<option>Klarna</option>
|
||||
<option>Afterpay</option>
|
||||
<option>Zip</option>
|
||||
<option>Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="font-size:0.85rem;">Owner</label>
|
||||
<select name="owner" id="ebnpl-owner" class="budget-input" style="width:100%;">
|
||||
<option value="Bonna">Bonna</option>
|
||||
<option value="Tony">Tony</option>
|
||||
<option value="Both">Both</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="font-size:0.85rem;">Original Purchase Amount</label>
|
||||
<input type="number" step="0.01" name="original_amount" id="ebnpl-original" class="budget-input" style="width:100%;" placeholder="0.00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="font-size:0.85rem;">Payment Amount</label>
|
||||
<input type="number" step="0.01" name="payment_amount" id="ebnpl-payment" class="budget-input" style="width:100%;" placeholder="0.00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="font-size:0.85rem;">Total Payments</label>
|
||||
<input type="number" min="1" name="total_payments" id="ebnpl-total" class="budget-input" style="width:100%;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="font-size:0.85rem;">Payments Already Made</label>
|
||||
<input type="number" min="0" name="payments_made" id="ebnpl-made" class="budget-input" style="width:100%;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="font-size:0.85rem;">Frequency</label>
|
||||
<select name="frequency" id="ebnpl-frequency" class="budget-input" style="width:100%;">
|
||||
<option value="biweekly">Every 2 weeks</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="4-payment">4 payments (every 2 weeks)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="font-size:0.85rem;">Next Payment Date</label>
|
||||
<input type="date" name="next_payment_date" id="ebnpl-next-date" class="budget-input" style="width:100%;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:0.5rem;">
|
||||
<label style="font-size:0.85rem;">Notes</label>
|
||||
<input type="text" name="notes" id="ebnpl-notes" class="budget-input" style="width:100%;" placeholder="optional">
|
||||
</div>
|
||||
<p class="muted" style="font-size:0.78rem;margin-top:0.75rem;">Saving will delete any unpaid calendar entries for this plan and regenerate them based on the updated dates.</p>
|
||||
<div style="display:flex;gap:0.75rem;justify-content:flex-end;margin-top:1rem;">
|
||||
<button type="button" class="btn btn-ghost" onclick="closeEditBNPL()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paste Statement Modal -->
|
||||
<div id="statement-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:200;align-items:center;justify-content:center;">
|
||||
<div style="background:#fff;border-radius:12px;padding:1.5rem;width:90%;max-width:540px;box-shadow:0 8px 32px rgba(0,0,0,0.18);">
|
||||
<h3 style="margin:0 0 0.25rem;" id="statement-modal-title">Paste Statement</h3>
|
||||
<p class="muted" style="font-size:0.82rem;margin-bottom:1rem;">Copy all the text from your statement and paste it below. The app will extract the numbers automatically.</p>
|
||||
<textarea id="statement-text" style="width:100%;height:180px;border:1px solid var(--border);border-radius:8px;padding:0.75rem;font-size:0.82rem;resize:vertical;" placeholder="Paste statement text here…"></textarea>
|
||||
<div id="statement-result" style="margin:0.75rem 0;font-size:0.85rem;"></div>
|
||||
<div style="display:flex;gap:0.75rem;justify-content:flex-end;margin-top:0.75rem;">
|
||||
<button class="btn btn-ghost" onclick="closeStatementModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="parseStatement()">Import</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let activeDebtId = null;
|
||||
|
||||
function addCardFromStatement() {
|
||||
const name = document.getElementById('paste-card-name').value.trim();
|
||||
const owner = document.getElementById('paste-card-owner').value;
|
||||
const text = document.getElementById('paste-new-statement').value.trim();
|
||||
const resultEl = document.getElementById('paste-new-result');
|
||||
|
||||
if (!name) { resultEl.style.color='#c07070'; resultEl.textContent='Please enter a card name.'; return; }
|
||||
if (!text) { resultEl.style.color='#c07070'; resultEl.textContent='Please paste your statement text.'; return; }
|
||||
|
||||
resultEl.style.color='inherit';
|
||||
resultEl.textContent='Creating card and importing…';
|
||||
|
||||
fetch('/debts/add-from-statement', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({name, owner, text, year: {{ year }}, month: {{ month }}})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.error) { resultEl.style.color='#c07070'; resultEl.textContent=data.error; return; }
|
||||
resultEl.style.color='#5A9E68';
|
||||
resultEl.textContent='✓ Card added and statement imported!';
|
||||
setTimeout(() => location.reload(), 1200);
|
||||
});
|
||||
}
|
||||
|
||||
function openStatementModal(debtId, debtName) {
|
||||
activeDebtId = debtId;
|
||||
document.getElementById('statement-modal-title').textContent = 'Paste Statement — ' + debtName;
|
||||
document.getElementById('statement-text').value = '';
|
||||
document.getElementById('statement-result').textContent = '';
|
||||
document.getElementById('statement-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeStatementModal() {
|
||||
document.getElementById('statement-modal').style.display = 'none';
|
||||
activeDebtId = null;
|
||||
}
|
||||
|
||||
function parseStatement() {
|
||||
const text = document.getElementById('statement-text').value;
|
||||
if (!text.trim()) return;
|
||||
const resultEl = document.getElementById('statement-result');
|
||||
resultEl.textContent = 'Parsing…';
|
||||
|
||||
fetch('/debts/parse-statement', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({text, debt_id: activeDebtId, year: {{ year }}, month: {{ month }}})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
resultEl.style.color = '#c07070';
|
||||
resultEl.textContent = 'Could not parse: ' + data.error;
|
||||
return;
|
||||
}
|
||||
resultEl.style.color = '#5A9E68';
|
||||
resultEl.innerHTML = `
|
||||
✓ Imported:<br>
|
||||
Previous balance: <strong>$${data.previous_balance}</strong>
|
||||
Purchases: <strong>$${data.purchases}</strong>
|
||||
Interest: <strong>$${data.interest}</strong>
|
||||
Payment: <strong>$${data.payment}</strong>
|
||||
Minimum due: <strong>$${data.minimum}</strong>
|
||||
New balance: <strong>$${data.new_balance}</strong>
|
||||
${data.credit_limit ? ' Credit limit: <strong>$' + data.credit_limit + '</strong>' : ''}
|
||||
${data.available_credit ? ' Available: <strong>$' + data.available_credit + '</strong>' : ''}
|
||||
`;
|
||||
// Reload to show updated values
|
||||
setTimeout(() => { closeStatementModal(); location.reload(); }, 1800);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('statement-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeStatementModal();
|
||||
});
|
||||
|
||||
function openEditDebt(id, name, owner, apr, minimum, balance, creditLimit, notes) {
|
||||
document.getElementById('edit-debt-title').textContent = 'Edit — ' + name;
|
||||
document.getElementById('edit-debt-form').action = '/debts/edit/' + id;
|
||||
document.getElementById('edit-name').value = name;
|
||||
document.getElementById('edit-owner').value = owner;
|
||||
document.getElementById('edit-apr').value = apr != '0' ? apr : '';
|
||||
document.getElementById('edit-minimum').value = minimum != '0' ? minimum : '';
|
||||
document.getElementById('edit-balance').value = balance != '0' ? balance : '';
|
||||
document.getElementById('edit-credit-limit').value = creditLimit != '0' ? creditLimit : '';
|
||||
document.getElementById('edit-notes').value = notes;
|
||||
document.getElementById('edit-debt-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeEditDebt() {
|
||||
document.getElementById('edit-debt-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
document.getElementById('edit-debt-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeEditDebt();
|
||||
});
|
||||
|
||||
function openEditBNPL(id, description, merchant, provider, owner, original, payment, total, made, frequency, nextDate, notes) {
|
||||
document.getElementById('edit-bnpl-title').textContent = 'Edit — ' + description;
|
||||
document.getElementById('edit-bnpl-form').action = '/debts/bnpl-edit/' + id;
|
||||
document.getElementById('ebnpl-description').value = description;
|
||||
document.getElementById('ebnpl-merchant').value = merchant;
|
||||
document.getElementById('ebnpl-provider').value = provider;
|
||||
document.getElementById('ebnpl-owner').value = owner;
|
||||
document.getElementById('ebnpl-original').value = original != '0' ? original : '';
|
||||
document.getElementById('ebnpl-payment').value = payment != '0' ? payment : '';
|
||||
document.getElementById('ebnpl-total').value = total;
|
||||
document.getElementById('ebnpl-made').value = made;
|
||||
document.getElementById('ebnpl-frequency').value = frequency;
|
||||
document.getElementById('ebnpl-next-date').value = nextDate;
|
||||
document.getElementById('ebnpl-notes').value = notes;
|
||||
document.getElementById('edit-bnpl-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeEditBNPL() {
|
||||
document.getElementById('edit-bnpl-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
document.getElementById('edit-bnpl-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeEditBNPL();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
234
templates/edit.html
Normal file
234
templates/edit.html
Normal file
@@ -0,0 +1,234 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Edit Transaction</h1>
|
||||
</div>
|
||||
|
||||
<div class="review-card">
|
||||
<div class="review-meta">
|
||||
<span class="review-date">{{ transaction.date }}</span>
|
||||
<span class="review-account">{{ transaction.account }}</span>
|
||||
</div>
|
||||
<div class="review-description">{{ transaction.description }}</div>
|
||||
<div class="review-amount">${{ transaction.amount | money }}</div>
|
||||
|
||||
<form method="POST" class="categorize-form">
|
||||
<div class="form-group">
|
||||
<label>Category</label>
|
||||
<div class="category-grid">
|
||||
{% for cat, subcats in categories.items() %}
|
||||
<label class="cat-option" style="--cat-color: {{ colors.get(cat, '#ddd') }};">
|
||||
<input type="radio" name="category" value="{{ cat }}"
|
||||
{% if transaction.category == cat %}checked{% endif %}
|
||||
onchange="updateSubcats(this.value)">
|
||||
<span>{{ cat }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="subcat-group">
|
||||
<label>Subcategory <span class="optional">(optional)</span></label>
|
||||
<select name="subcategory" id="subcat-select">
|
||||
<option value="">— none —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Notes <span class="optional">(optional)</span></label>
|
||||
<input type="text" name="notes" value="{{ transaction.notes }}" placeholder="e.g. Costco run, birthday gift...">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
<button type="button" class="btn btn-ghost" onclick="toggleSplit()">Split transaction</button>
|
||||
<a href="{{ url_for('transactions_view') }}" class="btn btn-ghost">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Split transaction panel -->
|
||||
<div id="split-panel" style="display:none;margin-top:1.5rem;">
|
||||
<div class="budget-section">
|
||||
<div class="budget-section-header" style="border-left-color:#D4A96A;">
|
||||
<h3>Split Transaction — ${{ transaction.amount | money }} total</h3>
|
||||
</div>
|
||||
<p class="muted" style="margin-bottom:1rem;font-size:0.85rem;">Divide this charge across multiple categories. Amounts must add up to ${{ transaction.amount | money }}.</p>
|
||||
<form method="POST" action="{{ url_for('split_transaction') }}">
|
||||
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||
<input type="hidden" name="redirect" value="transactions">
|
||||
<table class="budget-table">
|
||||
<thead><tr><th>Category</th><th>Subcategory</th><th>Amount</th><th>Note</th><th></th></tr></thead>
|
||||
<tbody id="split-rows">
|
||||
{% if transaction.splits %}
|
||||
{% for s in transaction.splits %}
|
||||
<tr class="split-row">
|
||||
<td>
|
||||
<select name="split_cat_{{ loop.index0 }}" class="budget-input split-cat" onchange="updateSplitSubcat(this, {{ loop.index0 }})" style="width:130px;">
|
||||
<option value="">— pick —</option>
|
||||
{% for cat in categories %}<option value="{{ cat }}" {% if s.category == cat %}selected{% endif %}>{{ cat }}</option>{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td><select name="split_subcat_{{ loop.index0 }}" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
|
||||
<td><input type="number" name="split_amt_{{ loop.index0 }}" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" value="{{ s.amount }}" oninput="updateRemaining()"></td>
|
||||
<td><input type="text" name="split_note_{{ loop.index0 }}" class="budget-input" style="width:110px;" value="{{ s.note or '' }}"></td>
|
||||
<td><button type="button" onclick="this.closest('tr').remove();updateRemaining();" style="background:none;border:none;color:#c07070;cursor:pointer;font-size:1rem;">✕</button></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr class="split-row">
|
||||
<td><select name="split_cat_0" class="budget-input split-cat" onchange="updateSplitSubcat(this,0)" style="width:130px;"><option value="">— pick —</option>{% for cat in categories %}<option value="{{ cat }}">{{ cat }}</option>{% endfor %}</select></td>
|
||||
<td><select name="split_subcat_0" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
|
||||
<td><input type="number" name="split_amt_0" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" placeholder="0.00" oninput="updateRemaining()"></td>
|
||||
<td><input type="text" name="split_note_0" class="budget-input" style="width:110px;" placeholder="optional"></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr class="split-row">
|
||||
<td><select name="split_cat_1" class="budget-input split-cat" onchange="updateSplitSubcat(this,1)" style="width:130px;"><option value="">— pick —</option>{% for cat in categories %}<option value="{{ cat }}">{{ cat }}</option>{% endfor %}</select></td>
|
||||
<td><select name="split_subcat_1" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
|
||||
<td><input type="number" name="split_amt_1" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" placeholder="0.00" oninput="updateRemaining()"></td>
|
||||
<td><input type="text" name="split_note_1" class="budget-input" style="width:110px;" placeholder="optional"></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="margin:0.75rem 0;font-size:0.85rem;">
|
||||
Remaining: <strong id="split-remaining">${{ transaction.amount | money }}</strong>
|
||||
<button type="button" class="btn btn-ghost" style="margin-left:1rem;font-size:0.78rem;" onclick="addSplitRow()">+ Add row</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.75rem;">
|
||||
<button type="submit" class="btn btn-primary">Save Split</button>
|
||||
<button type="button" class="btn btn-ghost" onclick="toggleSplit()">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:1.25rem;padding-top:1.25rem;border-top:1px solid var(--border);display:flex;gap:0.5rem;flex-wrap:wrap;">
|
||||
<form method="POST" action="{{ url_for('ignore_transaction') }}">
|
||||
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||
<input type="hidden" name="redirect" value="transactions">
|
||||
<button type="submit" class="btn btn-ghost" style="color:var(--text-light);font-size:0.82rem;">✓ Already tracked — ignore</button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-ghost" style="background:#e8f5e9;color:#3a7d44;border:1px solid #a5d6a7;font-size:0.82rem;" onclick="document.getElementById('sinking-picker').style.display='block';this.style.display='none';">🏦 Sinking Fund Transfer</button>
|
||||
<div id="sinking-picker" style="display:none;background:#e8f5e9;border:1px solid #a5d6a7;border-radius:var(--radius);padding:0.75rem 1rem;width:100%;margin-top:0.25rem;">
|
||||
<form method="POST" action="{{ url_for('categorize_sinking_transfer') }}">
|
||||
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||
<input type="hidden" name="redirect" value="transactions">
|
||||
<label style="font-size:0.82rem;font-weight:600;display:block;margin-bottom:0.4rem;">Which sinking fund?</label>
|
||||
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
|
||||
<select name="sinking_fund_name" class="budget-input" style="min-width:180px;" required>
|
||||
<option value="">— select fund —</option>
|
||||
{% for f in sinking_funds %}
|
||||
<option value="{{ f.name }}">{{ f.name }} (${{ (f.balance or 0) | money }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;">Save</button>
|
||||
<button type="button" class="btn btn-ghost" style="font-size:0.82rem;" onclick="document.getElementById('sinking-picker').style.display='none';this.closest('div').previousElementSibling.style.display='inline-block';">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('ignore_transaction') }}">
|
||||
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||
<input type="hidden" name="label" value="Investment Transfer">
|
||||
<input type="hidden" name="redirect" value="transactions">
|
||||
<button type="submit" class="btn btn-ghost" style="background:#e3f2fd;color:#1565c0;border:1px solid #90caf9;font-size:0.82rem;">📈 Investment Transfer</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('ignore_transaction') }}">
|
||||
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||
<input type="hidden" name="label" value="Savings Transfer">
|
||||
<input type="hidden" name="redirect" value="transactions">
|
||||
<button type="submit" class="btn btn-ghost" style="background:#f3e5f5;color:#6a1b9a;border:1px solid #ce93d8;font-size:0.82rem;">💰 Savings Transfer</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const subcats = {{ categories | tojson }};
|
||||
const current = {{ transaction | tojson }};
|
||||
|
||||
function updateSubcats(cat) {
|
||||
const select = document.getElementById('subcat-select');
|
||||
const group = document.getElementById('subcat-group');
|
||||
const list = subcats[cat] || [];
|
||||
select.innerHTML = '<option value="">— none —</option>';
|
||||
list.forEach(s => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s;
|
||||
opt.textContent = s;
|
||||
if (current.subcategory === s) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
group.style.display = list.length ? 'block' : 'none';
|
||||
}
|
||||
|
||||
const checked = document.querySelector('input[name="category"]:checked');
|
||||
if (checked) updateSubcats(checked.value);
|
||||
document.querySelectorAll('input[name="category"]').forEach(r => {
|
||||
r.addEventListener('change', () => updateSubcats(r.value));
|
||||
});
|
||||
|
||||
// Split transaction
|
||||
const totalAmount = {{ transaction.amount }};
|
||||
const allSubcats = {{ categories | tojson }};
|
||||
let splitRowCount = {{ transaction.splits | length if transaction.splits else 2 }};
|
||||
|
||||
function toggleSplit() {
|
||||
const panel = document.getElementById('split-panel');
|
||||
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
|
||||
if (panel.style.display === 'block') updateRemaining();
|
||||
}
|
||||
|
||||
function updateSplitSubcat(selectEl, idx) {
|
||||
const cat = selectEl.value;
|
||||
const subcatSel = document.querySelector(`select[name="split_subcat_${idx}"]`);
|
||||
const list = allSubcats[cat] || [];
|
||||
const current = subcatSel ? subcatSel.dataset.current || '' : '';
|
||||
subcatSel.innerHTML = '<option value="">— none —</option>';
|
||||
list.forEach(s => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s; opt.textContent = s;
|
||||
if (s === current) opt.selected = true;
|
||||
subcatSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function updateRemaining() {
|
||||
const inputs = document.querySelectorAll('.split-amt');
|
||||
let used = 0;
|
||||
inputs.forEach(el => used += parseFloat(el.value) || 0);
|
||||
const remaining = Math.round((totalAmount - used) * 100) / 100;
|
||||
const el = document.getElementById('split-remaining');
|
||||
el.textContent = '$' + remaining.toFixed(2);
|
||||
el.style.color = Math.abs(remaining) < 0.01 ? '#5A9E68' : (remaining < 0 ? '#c07070' : 'inherit');
|
||||
}
|
||||
|
||||
function addSplitRow() {
|
||||
const idx = splitRowCount++;
|
||||
const tbody = document.getElementById('split-rows');
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'split-row';
|
||||
row.innerHTML = `
|
||||
<td><select name="split_cat_${idx}" class="budget-input split-cat" onchange="updateSplitSubcat(this,${idx})" style="width:130px;">
|
||||
<option value="">— pick —</option>
|
||||
${Object.keys(allSubcats).map(c => `<option value="${c}">${c}</option>`).join('')}
|
||||
</select></td>
|
||||
<td><select name="split_subcat_${idx}" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
|
||||
<td><input type="number" name="split_amt_${idx}" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" placeholder="0.00" oninput="updateRemaining()"></td>
|
||||
<td><input type="text" name="split_note_${idx}" class="budget-input" style="width:110px;" placeholder="optional"></td>
|
||||
<td><button type="button" onclick="this.closest('tr').remove();updateRemaining();" style="background:none;border:none;color:#c07070;cursor:pointer;font-size:1rem;">✕</button></td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
|
||||
// Pre-populate subcats for existing splits
|
||||
document.querySelectorAll('.split-cat').forEach((sel, idx) => {
|
||||
if (sel.value) updateSplitSubcat(sel, idx);
|
||||
});
|
||||
|
||||
// Open split panel automatically if transaction already has splits
|
||||
{% if transaction.splits %}
|
||||
toggleSplit();
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
50
templates/import.html
Normal file
50
templates/import.html
Normal file
@@ -0,0 +1,50 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Import Statements</h1>
|
||||
</div>
|
||||
|
||||
{% if message %}
|
||||
<div class="alert-banner {{ 'alert-success' if 'Imported' in message else 'alert-error' }}">
|
||||
{{ message }}
|
||||
{% if 'Imported' in message %}
|
||||
<a href="{{ url_for('review') }}" class="btn btn-primary">Review Transactions</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card-wide">
|
||||
<h2>Upload a Bank or Credit Card Statement</h2>
|
||||
<p class="helper-text">Supports CSV exports from your bank or credit union. PDF import only works if the PDF has selectable text — scanned statements won't work.</p>
|
||||
|
||||
<form method="POST" enctype="multipart/form-data" class="import-form">
|
||||
<div class="form-group">
|
||||
<label for="account">Account Name</label>
|
||||
<input type="text" id="account" name="account" placeholder="e.g. Chase Checking, Discover, Affirm" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="statement">Statement File</label>
|
||||
<div class="file-drop-area">
|
||||
<input type="file" id="statement" name="statement" accept=".pdf,.csv" required>
|
||||
<p>Click to choose a file, or drag and drop here</p>
|
||||
<p class="file-types">PDF or CSV</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-large">Import</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card-wide tips">
|
||||
<h3>How to export your statements</h3>
|
||||
<ul>
|
||||
<li><strong>WestConsin Credit Union:</strong> Log in at westconsin.org → select your account → look for a <em>Download</em> or <em>Export</em> button near the top of your transaction list → choose CSV or Excel format → pick your date range and download</li>
|
||||
<li><strong>Most other banks:</strong> Log in → Account Details or Transaction History → Export / Download → choose CSV</li>
|
||||
<li><strong>Affirm:</strong> affirm.com → Account → Payment History → Export (note: their export is often broken — manual entry may be needed)</li>
|
||||
<li><strong>Klarna:</strong> klarna.com → Purchases → Export CSV</li>
|
||||
<li><strong>Afterpay:</strong> afterpay.com → Orders → Export</li>
|
||||
</ul>
|
||||
<p class="helper-text" style="margin-top:0.75rem;">If a CSV imports 0 transactions, let Claude know — the column format varies by bank and may need a quick fix.</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
277
templates/income.html
Normal file
277
templates/income.html
Normal file
@@ -0,0 +1,277 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<div class="page-header">
|
||||
<h1>Income — {{ month_name }} {{ year }}</h1>
|
||||
<div class="month-nav">
|
||||
<a href="{{ url_for('income_view', year=prev_year, month=prev_month) }}" class="btn btn-ghost">‹ Prev</a>
|
||||
<a href="{{ url_for('income_view', year=next_year, month=next_month) }}" class="btn btn-ghost">Next ›</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- YTD Summary Cards -->
|
||||
<div class="card-grid" style="margin-bottom:1.5rem;">
|
||||
<div class="card">
|
||||
<div class="card-label">{{ month_name }} Total</div>
|
||||
<div class="card-value">${{ month_total | money }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">{{ year }} YTD Total</div>
|
||||
<div class="card-value">${{ ytd_total | money }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Monthly Average</div>
|
||||
<div class="card-value">
|
||||
{% set months_with_income = ytd_by_month | length %}
|
||||
${{ (ytd_total / months_with_income if months_with_income else 0) | money }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tax estimate banner -->
|
||||
<div style="background:#fff8e6;border:1px solid #e8d49a;border-radius:var(--radius);padding:1rem 1.25rem;margin-bottom:1.5rem;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1rem;">
|
||||
<div>
|
||||
<div style="font-weight:600;margin-bottom:0.2rem;">🧾 {{ year }} Tax Estimate — based on ${{ ytd_total | money }} YTD income</div>
|
||||
<div style="font-size:0.82rem;color:var(--text-light);">
|
||||
~${{ tax_estimate.total | money }} owed ·
|
||||
${{ tax_set_aside | money }} set aside ·
|
||||
{% if tax_still_needed > 0 %}
|
||||
<span style="color:#c07070;font-weight:600;">${{ tax_still_needed | money }} still needed</span>
|
||||
{% else %}
|
||||
<span style="color:#5A9E68;font-weight:600;">fully covered ✓</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url_for('taxes_view') }}" class="btn btn-ghost" style="font-size:0.82rem;">View Tax Details →</a>
|
||||
</div>
|
||||
|
||||
<!-- This Month by Stream -->
|
||||
<div class="budget-section">
|
||||
<div class="budget-section-header" style="border-left-color: #A0C5A8;">
|
||||
<h3>{{ month_name }} {{ year }} — by Stream</h3>
|
||||
</div>
|
||||
<table class="budget-table">
|
||||
<thead>
|
||||
<tr><th>Stream</th><th>Owner</th><th>This Month</th><th>YTD</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in income_streams %}
|
||||
<tr>
|
||||
<td><strong>{{ s.name }}</strong></td>
|
||||
<td><span class="owner-tag" style="background:{{ owner_colors.get(s.owner,'#ddd') }};font-size:0.72rem;">{{ s.owner }}</span></td>
|
||||
<td class="amount">${{ by_stream.get(s.name, 0) | money }}</td>
|
||||
<td class="amount muted">${{ ytd_by_stream.get(s.name, 0) | money }}</td>
|
||||
<td>
|
||||
<button class="btn btn-ghost" style="font-size:0.75rem;padding:0.2rem 0.5rem;"
|
||||
onclick="openLogModal('{{ s.name }}')">+ Log</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="2">Total</td>
|
||||
<td class="amount">${{ month_total | money }}</td>
|
||||
<td class="amount muted">${{ ytd_total | money }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- YTD by Month -->
|
||||
{% if ytd_by_month %}
|
||||
<div class="budget-section" style="margin-top:1.5rem;">
|
||||
<div class="budget-section-header" style="border-left-color: #C5BBA0;">
|
||||
<h3>{{ year }} — Month by Month</h3>
|
||||
</div>
|
||||
<div style="padding:1rem 0.5rem;">
|
||||
<canvas id="income-monthly-chart" height="80"></canvas>
|
||||
</div>
|
||||
<table class="budget-table" style="margin-top:0.5rem;">
|
||||
<thead><tr><th>Month</th><th>Total Income</th></tr></thead>
|
||||
<tbody>
|
||||
{% for mk in sorted_months %}
|
||||
{% set amt = ytd_by_month[mk] %}
|
||||
<tr>
|
||||
<td>{{ mk }}</td>
|
||||
<td class="amount">${{ amt | money }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td>YTD Total</td>
|
||||
<td class="amount">${{ ytd_total | money }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Log Entries for This Month -->
|
||||
{% if entries %}
|
||||
<div class="budget-section" style="margin-top:1.5rem;">
|
||||
<div class="budget-section-header" style="border-left-color: #B0CDD6;">
|
||||
<h3>{{ month_name }} Entries</h3>
|
||||
</div>
|
||||
<table class="budget-table">
|
||||
<thead><tr><th>Stream</th><th>Amount</th><th>Date Logged</th><th>Note</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for e in entries | sort(attribute='date', reverse=True) %}
|
||||
<tr>
|
||||
<td>{{ e.stream }}</td>
|
||||
<td class="amount">${{ e.amount | money }}</td>
|
||||
<td class="muted">{{ e.date }}</td>
|
||||
<td class="muted">{{ e.note or '—' }}</td>
|
||||
<td>
|
||||
<form method="POST" action="/income/delete/{{ e.id }}" style="display:inline;">
|
||||
<input type="hidden" name="year" value="{{ year }}">
|
||||
<input type="hidden" name="month" value="{{ month }}">
|
||||
<button type="submit" class="btn btn-ghost" style="font-size:0.72rem;padding:0.15rem 0.4rem;color:#c07070;"
|
||||
onclick="return confirm('Delete this entry?')">✕</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Log Income Modal -->
|
||||
<div id="income-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:100;align-items:center;justify-content:center;">
|
||||
<div style="background:#fff;border-radius:12px;padding:1.5rem;min-width:320px;max-width:420px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.18);">
|
||||
<h3 style="margin:0 0 1rem;">Log Income</h3>
|
||||
<form method="POST" action="/income/log">
|
||||
<input type="hidden" name="month_str" value="{{ '%04d-%02d' | format(year, month) }}">
|
||||
<div style="margin-bottom:0.75rem;">
|
||||
<label style="display:block;font-size:0.85rem;margin-bottom:0.25rem;">Stream</label>
|
||||
<select name="stream" id="modal-stream" class="form-input" style="width:100%;">
|
||||
{% for s in income_streams %}
|
||||
<option value="{{ s.name }}">{{ s.name }} ({{ s.owner }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div style="margin-bottom:0.75rem;">
|
||||
<label style="display:block;font-size:0.85rem;margin-bottom:0.25rem;">Amount</label>
|
||||
<input type="number" name="amount" step="0.01" min="0" class="form-input budget-input" style="width:100%;" placeholder="0.00" required>
|
||||
</div>
|
||||
<div style="margin-bottom:1rem;">
|
||||
<label style="display:block;font-size:0.85rem;margin-bottom:0.25rem;">Note (optional)</label>
|
||||
<input type="text" name="note" class="form-input budget-input" style="width:100%;" placeholder="e.g. paycheck, client payment…">
|
||||
</div>
|
||||
<div style="display:flex;gap:0.75rem;justify-content:flex-end;">
|
||||
<button type="button" class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openLogModal(stream) {
|
||||
const modal = document.getElementById('income-modal');
|
||||
modal.style.display = 'flex';
|
||||
if (stream) {
|
||||
document.getElementById('modal-stream').value = stream;
|
||||
}
|
||||
}
|
||||
function closeModal() {
|
||||
document.getElementById('income-modal').style.display = 'none';
|
||||
}
|
||||
document.getElementById('income-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeModal();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div style="margin-top:1.5rem;">
|
||||
<button class="btn btn-primary" onclick="openLogModal('')">+ Log Income</button>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Backfill -->
|
||||
<div class="budget-section" style="margin-top:2rem;">
|
||||
<div class="budget-section-header" style="border-left-color:#C5BBA0;">
|
||||
<h3>Backfill Past Months</h3>
|
||||
</div>
|
||||
<p class="muted" style="padding:0.5rem 0 1rem;">Enter totals for any month you haven't logged yet. Leave blank to skip. Existing entries won't be duplicated.</p>
|
||||
<form method="POST" action="/income/backfill">
|
||||
<input type="hidden" name="year" value="{{ year }}">
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="budget-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Month</th>
|
||||
{% for s in income_streams %}
|
||||
<th style="min-width:110px;">
|
||||
{{ s.name }}<br>
|
||||
<span class="owner-tag" style="background:{{ owner_colors.get(s.owner,'#ddd') }};font-size:0.68rem;">{{ s.owner }}</span>
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in range(1, 13) %}
|
||||
{% set mk = '%04d-%02d' | format(year, m) %}
|
||||
{% set mn = month_names[m] %}
|
||||
{% set is_current = (m == month) %}
|
||||
<tr{% if is_current %} style="background:#f5f2ee;"{% endif %}>
|
||||
<td>
|
||||
<strong>{{ mn }}</strong>
|
||||
{% if is_current %}<span class="muted" style="font-size:0.75rem;"> (current)</span>{% endif %}
|
||||
{% if ytd_by_month.get(mk) %}
|
||||
<span style="font-size:0.72rem;color:#7A9E78;margin-left:0.25rem;">✓ ${{ "%.0f"|format(ytd_by_month[mk]) }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% for s in income_streams %}
|
||||
<td>
|
||||
<input type="number" step="0.01" min="0"
|
||||
name="amt_{{ m }}_{{ s.name | replace(' ','_') }}"
|
||||
class="budget-input" style="width:100px;"
|
||||
placeholder="0.00">
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="margin-top:1rem;">
|
||||
<button type="submit" class="btn btn-primary">Save All</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{% if ytd_by_month %}
|
||||
const incomeLabels = {{ sorted_months | tojson }};
|
||||
const incomeValues = [{% for mk in sorted_months %}{{ ytd_by_month[mk] }},{% endfor %}];
|
||||
|
||||
new Chart(document.getElementById('income-monthly-chart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: incomeLabels,
|
||||
datasets: [{
|
||||
label: 'Income',
|
||||
data: incomeValues,
|
||||
backgroundColor: '#7AB87A',
|
||||
borderRadius: 6,
|
||||
borderSkipped: false,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { callbacks: { label: ctx => ' $' + ctx.parsed.y.toFixed(2) } }
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false } },
|
||||
y: { grid: { color: '#f0ece6' }, ticks: { callback: v => '$' + v.toLocaleString() } }
|
||||
},
|
||||
animation: { duration: 400 },
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
385
templates/index.html
Normal file
385
templates/index.html
Normal file
@@ -0,0 +1,385 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<div class="page-header">
|
||||
<h1>{{ month_name }} {{ year }} — Overview</h1>
|
||||
</div>
|
||||
|
||||
<!-- Top summary cards -->
|
||||
<div class="card-grid" style="margin-bottom:1.5rem;">
|
||||
<div class="card">
|
||||
<div class="card-label">Income</div>
|
||||
<div class="card-value" style="color:#5A9E68;">${{ total_income | money }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Spent</div>
|
||||
<div class="card-value">${{ (total_spending + total_bills) | money }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Saved to Funds</div>
|
||||
<div class="card-value">${{ total_sinking | money }}</div>
|
||||
</div>
|
||||
<div class="card" style="border: 2px solid {% if net >= 0 %}#A8C5A0{% else %}#C57A7A{% endif %};">
|
||||
<div class="card-label">{{ 'Left Over' if net >= 0 else 'Over Budget' }}</div>
|
||||
<div class="card-value" style="color:{% if net >= 0 %}#5A9E68{% else %}#c07070{% endif %};">
|
||||
${{ net | abs | money }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plain-English summary -->
|
||||
{% if month_summary %}
|
||||
<div style="background:var(--warm-white);border:1px solid var(--border);border-radius:var(--radius);padding:1rem 1.25rem;margin-bottom:1.5rem;">
|
||||
<div style="font-size:0.78rem;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-light);margin-bottom:0.5rem;">How's it going?</div>
|
||||
{% for line in month_summary %}
|
||||
<div style="font-size:0.9rem;color:var(--text-dark);margin-bottom:0.25rem;">{{ line | bold_md }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Cash flow bar -->
|
||||
{% if total_income > 0 %}
|
||||
{% set spent_pct = [(total_spending + total_bills) / total_income * 100, 100] | min | int %}
|
||||
{% set sinking_pct = [total_sinking / total_income * 100, 100 - spent_pct] | min | int %}
|
||||
<div style="margin-bottom:2rem;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.82rem;color:var(--text-mid);margin-bottom:0.35rem;">
|
||||
<span>Where did the money go?</span>
|
||||
<span>${{ total_income | money }} income this month</span>
|
||||
</div>
|
||||
<div style="display:flex;height:18px;border-radius:999px;overflow:hidden;background:#EDE8E2;">
|
||||
<div style="width:{{ spent_pct }}%;background:#C57A7A;" title="Spent"></div>
|
||||
<div style="width:{{ sinking_pct }}%;background:#A8C5A0;" title="Savings"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:1.25rem;font-size:0.78rem;color:var(--text-mid);margin-top:0.4rem;">
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#C57A7A;margin-right:3px;"></span>Spent {{ spent_pct }}%</span>
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#A8C5A0;margin-right:3px;"></span>Saved {{ sinking_pct }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if spending_by_cat %}
|
||||
<div style="display:flex;gap:1.5rem;align-items:center;margin-bottom:2rem;flex-wrap:wrap;">
|
||||
<div style="flex:0 0 220px;"><canvas id="overview-donut" width="220" height="220"></canvas></div>
|
||||
<div style="flex:1;min-width:180px;">
|
||||
<div style="font-size:0.82rem;font-weight:600;margin-bottom:0.5rem;color:var(--text-light);text-transform:uppercase;letter-spacing:0.05em;">{{ month_name }} Spending</div>
|
||||
{% for cat, amt in spending_by_cat | dictsort(by='value', reverse=true) %}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.3rem;font-size:0.83rem;">
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:{{ colors.get(cat,'#ddd') }};flex-shrink:0;"></span>
|
||||
<span style="flex:1;">{{ cat }}</span>
|
||||
<span style="font-weight:600;">${{ "%.0f" | format(amt) }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;">
|
||||
|
||||
<!-- Left column -->
|
||||
<div>
|
||||
|
||||
<!-- Income by stream -->
|
||||
<div class="budget-section">
|
||||
<div class="budget-section-header" style="border-left-color:#7AB87A;">
|
||||
<h3>Income</h3>
|
||||
<a href="{{ url_for('income_view', year=year, month=month) }}" class="edit-link">manage →</a>
|
||||
</div>
|
||||
{% if income_by_stream %}
|
||||
<table class="budget-table">
|
||||
<tbody>
|
||||
{% for s in income_streams %}
|
||||
{% set amt = income_by_stream.get(s.name, 0) %}
|
||||
<tr>
|
||||
<td>{{ s.name }}</td>
|
||||
<td><span class="owner-tag" style="background:{{ owner_colors.get(s.owner,'#ddd') }};font-size:0.68rem;">{{ s.owner }}</span></td>
|
||||
<td class="amount {% if amt == 0 %}muted{% endif %}">${{ amt | money }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="2">Total · <span class="muted" style="font-weight:400;font-size:0.82rem;">{{ year }} YTD ${{ "%.0f"|format(income_ytd_total) }}</span></td>
|
||||
<td class="amount">${{ total_income | money }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted" style="padding:0.75rem 0;">No income logged yet. <a href="{{ url_for('income_view', year=year, month=month) }}">Log income →</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Bills -->
|
||||
<div class="budget-section" style="margin-top:1.25rem;">
|
||||
<div class="budget-section-header" style="border-left-color:#C57A7A;">
|
||||
<h3>Bills This Month</h3>
|
||||
<a href="{{ url_for('bill_calendar', year=year, month=month) }}" class="edit-link">calendar →</a>
|
||||
</div>
|
||||
{% if monthly_bills %}
|
||||
<table class="budget-table">
|
||||
<tbody>
|
||||
{% for b in monthly_bills %}
|
||||
<tr>
|
||||
<td>{{ b.name }}</td>
|
||||
<td class="muted">day {{ b.due_day }}</td>
|
||||
<td class="amount">{% if b.amount %}${{ b.amount | money }}{% else %}<span class="muted">—</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="2">Total</td>
|
||||
<td class="amount">${{ total_bills | money }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted" style="padding:0.75rem 0;">No bills this month. <a href="{{ url_for('bills_list') }}">Manage bills →</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Sinking contributions -->
|
||||
{% if sinking_contributions %}
|
||||
<div class="budget-section" style="margin-top:1.25rem;">
|
||||
<div class="budget-section-header" style="border-left-color:#C5BBA0;">
|
||||
<h3>Saved to Funds</h3>
|
||||
<a href="{{ url_for('sinking_view') }}" class="edit-link">manage →</a>
|
||||
</div>
|
||||
<table class="budget-table">
|
||||
<tbody>
|
||||
{% for name, amt in sinking_contributions.items() %}
|
||||
<tr>
|
||||
<td>{{ name }}</td>
|
||||
<td class="amount">${{ amt | money }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td>Total</td>
|
||||
<td class="amount">${{ total_sinking | money }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Right column -->
|
||||
<div>
|
||||
|
||||
<!-- Cash accounts -->
|
||||
{% if cash_accounts %}
|
||||
<div class="budget-section" style="margin-bottom:1.25rem;">
|
||||
<div class="budget-section-header" style="border-left-color:#7AB87A;">
|
||||
<h3>Account Balances</h3>
|
||||
<a href="{{ url_for('net_worth_view') }}" class="edit-link">manage →</a>
|
||||
</div>
|
||||
<table class="budget-table">
|
||||
<tbody>
|
||||
{% for a in cash_accounts %}
|
||||
<tr>
|
||||
<td>{{ a.name }}</td>
|
||||
<td class="muted" style="font-size:0.78rem;">{{ a.type or '' }}</td>
|
||||
<td class="amount">${{ (a.balance or 0) | money }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="2">Total Cash</td>
|
||||
<td class="amount">${{ total_cash | money }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="budget-section" style="margin-bottom:1.25rem;">
|
||||
<div class="budget-section-header" style="border-left-color:#7AB87A;">
|
||||
<h3>Account Balances</h3>
|
||||
</div>
|
||||
<p class="muted" style="padding:0.5rem 0;font-size:0.85rem;">No accounts added yet. <a href="{{ url_for('net_worth_view') }}">Add accounts →</a></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Spending by category -->
|
||||
<div class="budget-section">
|
||||
<div class="budget-section-header" style="border-left-color:#D4A96A;">
|
||||
<h3>Spending by Category</h3>
|
||||
<a href="{{ url_for('transactions_view') }}" class="edit-link">all transactions →</a>
|
||||
</div>
|
||||
{% if spending_by_cat %}
|
||||
{% set max_spend = spending_by_cat.values() | max %}
|
||||
<table class="budget-table">
|
||||
<thead><tr><th>Category</th><th>Budgeted</th><th>Actual</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for cat, amt in spending_by_cat | dictsort(by='value', reverse=true) %}
|
||||
{% set budgeted = budgeted_by_cat.get(cat, 0) %}
|
||||
{% set over = budgeted > 0 and amt > budgeted %}
|
||||
<tr{% if over %} style="background:#fdf0f0;"{% endif %}>
|
||||
<td><span class="tag" style="background:{{ colors.get(cat,'#ddd') }};">{{ cat }}</span></td>
|
||||
<td class="amount muted">{% if budgeted %}${{ budgeted | money }}{% else %}<span style="color:#ccc;">—</span>{% endif %}</td>
|
||||
<td class="amount">
|
||||
${{ amt | money }}
|
||||
{% if over %}<span style="font-size:0.7rem;color:#c07070;"> over</span>{% endif %}
|
||||
</td>
|
||||
<td style="width:28%;padding-right:0.5rem;">
|
||||
<div style="background:#EDE8E2;border-radius:4px;height:8px;">
|
||||
<div style="background:{{ colors.get(cat,'#bbb') }};border-radius:4px;height:8px;width:{{ [(amt/max_spend*100)|int,100]|min }}%;"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td>Total</td>
|
||||
<td class="amount muted">{% if budgeted_by_cat %}${{ "%.2f"|format(budgeted_by_cat.values()|sum) }}{% endif %}</td>
|
||||
<td class="amount">${{ total_spending | money }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted" style="padding:0.75rem 0;">No categorized transactions yet. <a href="{{ url_for('review') }}">Review transactions →</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Recent transactions -->
|
||||
{% if recent %}
|
||||
<div class="budget-section" style="margin-top:1.25rem;">
|
||||
<div class="budget-section-header" style="border-left-color:#8AABCC;">
|
||||
<h3>Recent Transactions</h3>
|
||||
<a href="{{ url_for('transactions_view') }}" class="edit-link">view all →</a>
|
||||
</div>
|
||||
<table class="budget-table">
|
||||
<tbody>
|
||||
{% for t in recent %}
|
||||
<tr>
|
||||
<td class="muted" style="white-space:nowrap;">{{ t.date }}</td>
|
||||
<td style="max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ t.description }}</td>
|
||||
<td class="amount">${{ t.amount | money }}</td>
|
||||
<td><span class="tag" style="background:{{ colors.get(t.category,'#ddd') }};font-size:0.72rem;">{{ t.category }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Budget vs Actual chart -->
|
||||
{% if budgeted_by_cat %}
|
||||
<div class="budget-section" style="margin-top:1.5rem;">
|
||||
<div class="budget-section-header" style="border-left-color:#D4A96A;">
|
||||
<h3>Budget vs Actual — {{ month_name }}</h3>
|
||||
</div>
|
||||
<div style="position:relative;height:{{ [budgeted_by_cat | length * 42, 200] | max }}px;">
|
||||
<canvas id="budget-actual-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Tax estimate widget -->
|
||||
<div style="background:#fff8e6;border:1px solid #e8d49a;border-radius:var(--radius);padding:1rem 1.25rem;margin-top:1.5rem;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1rem;">
|
||||
<div>
|
||||
<div style="font-weight:600;margin-bottom:0.2rem;">🧾 {{ year }} Tax Estimate</div>
|
||||
<div style="font-size:0.82rem;color:var(--text-light);">
|
||||
${{ tax_estimate.total | money }} estimated owed ·
|
||||
${{ tax_set_aside | money }} set aside ·
|
||||
{% if tax_still_needed > 0 %}
|
||||
<span style="color:#c07070;font-weight:600;">${{ tax_still_needed | money }} still needed</span>
|
||||
{% else %}
|
||||
<span style="color:#5A9E68;font-weight:600;">fully covered ✓</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url_for('taxes_view') }}" class="btn btn-ghost" style="font-size:0.82rem;">View Details →</a>
|
||||
</div>
|
||||
|
||||
<!-- Net summary bar -->
|
||||
<div class="leftover-bar {% if net < 0 %}leftover-negative{% endif %}" style="margin-top:2rem;">
|
||||
<div>
|
||||
<div style="font-weight:600;">{{ month_name }} {{ year }} — Net</div>
|
||||
<div style="font-size:0.82rem;color:var(--text-light);margin-top:0.2rem;">
|
||||
${{ "%.2f"|format(total_income) }} income − ${{ "%.2f"|format(total_spending + total_bills) }} spent − ${{ "%.2f"|format(total_sinking) }} saved
|
||||
</div>
|
||||
</div>
|
||||
<div class="leftover-amount">{% if net >= 0 %}+{% endif %}${{ net | money }}</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{% if spending_by_cat %}
|
||||
const donutLabels = {{ spending_by_cat.keys() | list | tojson }};
|
||||
const donutValues = {{ spending_by_cat.values() | list | tojson }};
|
||||
const donutColors = [{% for cat in spending_by_cat %}'{{ colors.get(cat, "#ddd") }}',{% endfor %}];
|
||||
|
||||
new Chart(document.getElementById('overview-donut'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: donutLabels,
|
||||
datasets: [{
|
||||
data: donutValues,
|
||||
backgroundColor: donutColors,
|
||||
borderWidth: 2,
|
||||
borderColor: '#faf7f2',
|
||||
hoverOffset: 6,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
cutout: '65%',
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { callbacks: { label: ctx => ' $' + ctx.parsed.toFixed(2) } }
|
||||
},
|
||||
animation: { duration: 400 },
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if budgeted_by_cat %}
|
||||
const baLabels = {{ budgeted_by_cat.keys() | list | tojson }};
|
||||
const baBudgeted = {{ budgeted_by_cat.values() | list | tojson }};
|
||||
const baActual = [{% for cat in budgeted_by_cat %}{{ spending_by_cat.get(cat, 0) }},{% endfor %}];
|
||||
const baColors = [{% for cat in budgeted_by_cat %}'{{ colors.get(cat, "#ddd") }}',{% endfor %}];
|
||||
|
||||
new Chart(document.getElementById('budget-actual-chart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: baLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Budgeted',
|
||||
data: baBudgeted,
|
||||
backgroundColor: baColors.map(c => c + '55'),
|
||||
borderColor: baColors,
|
||||
borderWidth: 1.5,
|
||||
borderRadius: 4,
|
||||
},
|
||||
{
|
||||
label: 'Actual',
|
||||
data: baActual,
|
||||
backgroundColor: baColors,
|
||||
borderRadius: 4,
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'top', labels: { font: { size: 12 }, padding: 16 } },
|
||||
tooltip: { callbacks: { label: ctx => ' $' + ctx.parsed.x.toFixed(2) } }
|
||||
},
|
||||
scales: {
|
||||
x: { ticks: { callback: v => '$' + v, font: { size: 11 } }, grid: { color: '#EDE8E2' } },
|
||||
y: { ticks: { font: { size: 12 } } }
|
||||
}
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
244
templates/month.html
Normal file
244
templates/month.html
Normal file
@@ -0,0 +1,244 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>{{ month_name }} {{ year }} — Monthly Summary</h1>
|
||||
<div class="month-nav">
|
||||
<a href="{{ url_for('month_summary', year=prev_year, month=prev_month) }}" class="btn btn-ghost">‹ Prev</a>
|
||||
<a href="{{ url_for('month_summary', year=next_year, month=next_month) }}" class="btn btn-ghost">Next ›</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top-line summary cards -->
|
||||
<div class="card-grid" style="margin-bottom:1.5rem;">
|
||||
<div class="card">
|
||||
<div class="card-label">Total Income</div>
|
||||
<div class="card-value" style="color:#5A9E68;">${{ total_income | money }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Total Spent</div>
|
||||
<div class="card-value">${{ (total_spending + total_bills) | money }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Saved to Funds</div>
|
||||
<div class="card-value">${{ total_sinking | money }}</div>
|
||||
</div>
|
||||
<div class="card" style="border: 2px solid {% if net >= 0 %}#A8C5A0{% else %}#C57A7A{% endif %};">
|
||||
<div class="card-label">{{ 'Left Over' if net >= 0 else 'Over Budget' }}</div>
|
||||
<div class="card-value" style="color:{% if net >= 0 %}#5A9E68{% else %}#c07070{% endif %};">
|
||||
${{ net | abs | money }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cash flow bar -->
|
||||
{% if total_income > 0 %}
|
||||
{% set spent_pct = [(total_spending + total_bills) / total_income * 100, 100] | min | int %}
|
||||
{% set sinking_pct = [total_sinking / total_income * 100, 100 - spent_pct] | min | int %}
|
||||
<div style="margin-bottom:2rem;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.82rem;color:var(--text-mid);margin-bottom:0.35rem;">
|
||||
<span>Where did the money go?</span>
|
||||
<span>${{ total_income | money }} income</span>
|
||||
</div>
|
||||
<div style="display:flex;height:18px;border-radius:999px;overflow:hidden;background:#EDE8E2;">
|
||||
<div style="width:{{ spent_pct }}%;background:#C57A7A;" title="Spent"></div>
|
||||
<div style="width:{{ sinking_pct }}%;background:#A8C5A0;" title="Savings"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:1.25rem;font-size:0.78rem;color:var(--text-mid);margin-top:0.4rem;">
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#C57A7A;margin-right:3px;"></span>Spent {{ spent_pct }}%</span>
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#A8C5A0;margin-right:3px;"></span>Saved {{ sinking_pct }}%</span>
|
||||
{% if net > 0 %}
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#EDE8E2;border:1px solid #ccc;margin-right:3px;"></span>Left over {{ [100 - spent_pct - sinking_pct, 0] | max }}%</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;">
|
||||
|
||||
<!-- Left column -->
|
||||
<div>
|
||||
|
||||
<!-- Income -->
|
||||
<div class="budget-section">
|
||||
<div class="budget-section-header" style="border-left-color:#7AB87A;">
|
||||
<h3>Income</h3>
|
||||
<a href="{{ url_for('income_view', year=year, month=month) }}" class="edit-link">manage</a>
|
||||
</div>
|
||||
{% if income_by_stream %}
|
||||
<table class="budget-table">
|
||||
<tbody>
|
||||
{% for s in income_streams %}
|
||||
{% set amt = income_by_stream.get(s.name, 0) %}
|
||||
{% if amt > 0 %}
|
||||
<tr>
|
||||
<td>{{ s.name }}</td>
|
||||
<td><span class="owner-tag" style="background:{{ owner_colors.get(s.owner,'#ddd') }};font-size:0.68rem;">{{ s.owner }}</span></td>
|
||||
<td class="amount">${{ amt | money }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="2">Total</td>
|
||||
<td class="amount">${{ total_income | money }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted" style="padding:0.75rem 0;">No income logged yet. <a href="{{ url_for('income_view', year=year, month=month) }}">Log income →</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Bills -->
|
||||
<div class="budget-section" style="margin-top:1.25rem;">
|
||||
<div class="budget-section-header" style="border-left-color:#C57A7A;">
|
||||
<h3>Bills</h3>
|
||||
<a href="{{ url_for('bills_list') }}" class="edit-link">manage</a>
|
||||
</div>
|
||||
{% if monthly_bills %}
|
||||
<table class="budget-table">
|
||||
<tbody>
|
||||
{% for b in monthly_bills %}
|
||||
<tr>
|
||||
<td>{{ b.name }}</td>
|
||||
<td class="muted">day {{ b.due_day }}</td>
|
||||
<td class="amount">{% if b.amount %}${{ b.amount | money }}{% else %}<span class="muted">—</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="2">Total</td>
|
||||
<td class="amount">${{ total_bills | money }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted" style="padding:0.75rem 0;">No bills this month.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Sinking Funds -->
|
||||
{% if sinking_contributions %}
|
||||
<div class="budget-section" style="margin-top:1.25rem;">
|
||||
<div class="budget-section-header" style="border-left-color:#C5BBA0;">
|
||||
<h3>Sinking Fund Contributions</h3>
|
||||
<a href="{{ url_for('sinking_view') }}" class="edit-link">manage</a>
|
||||
</div>
|
||||
<table class="budget-table">
|
||||
<tbody>
|
||||
{% for name, amt in sinking_contributions.items() %}
|
||||
<tr>
|
||||
<td>{{ name }}</td>
|
||||
<td class="amount">${{ amt | money }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td>Total Saved</td>
|
||||
<td class="amount">${{ total_sinking | money }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Right column -->
|
||||
<div>
|
||||
|
||||
<!-- Spending by category -->
|
||||
<div class="budget-section">
|
||||
<div class="budget-section-header" style="border-left-color:#D4A96A;">
|
||||
<h3>Spending by Category</h3>
|
||||
<a href="{{ url_for('transactions_view') }}" class="edit-link">view transactions</a>
|
||||
</div>
|
||||
{% if spending_by_cat %}
|
||||
{% set max_spend = spending_by_cat.values() | max %}
|
||||
<table class="budget-table">
|
||||
<thead><tr><th>Category</th><th>Budgeted</th><th>Actual</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for cat, amt in spending_by_cat | dictsort(by='value', reverse=true) %}
|
||||
{% set budgeted = budgeted_by_cat.get(cat, 0) %}
|
||||
{% set over = budgeted > 0 and amt > budgeted %}
|
||||
<tr{% if over %} style="background:#fdf0f0;"{% endif %}>
|
||||
<td><span class="tag" style="background:{{ colors.get(cat,'#ddd') }};">{{ cat }}</span></td>
|
||||
<td class="amount muted">{% if budgeted %}${{ budgeted | money }}{% else %}<span style="color:#ccc;">—</span>{% endif %}</td>
|
||||
<td class="amount">
|
||||
${{ amt | money }}
|
||||
{% if over %}<span style="font-size:0.7rem;color:#c07070;"> over</span>{% endif %}
|
||||
</td>
|
||||
<td style="width:30%;padding-right:0.5rem;">
|
||||
<div style="background:#EDE8E2;border-radius:4px;height:8px;">
|
||||
<div style="background:{{ colors.get(cat,'#bbb') }};border-radius:4px;height:8px;width:{{ [(amt / max_spend * 100)|int, 100]|min }}%;"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td>Total</td>
|
||||
<td class="amount muted">{% if budgeted_by_cat %}${{ budgeted_by_cat.values( | money|sum) }}{% endif %}</td>
|
||||
<td class="amount">${{ total_spending | money }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted" style="padding:0.75rem 0;">No categorized transactions yet. <a href="{{ url_for('review') }}">Review transactions →</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Debt payments -->
|
||||
{% if debt_payments %}
|
||||
<div class="budget-section" style="margin-top:1.25rem;">
|
||||
<div class="budget-section-header" style="border-left-color:#C57A7A;">
|
||||
<h3>Debt Payments</h3>
|
||||
<a href="{{ url_for('debts_view', year=year, month=month) }}" class="edit-link">manage</a>
|
||||
</div>
|
||||
<table class="budget-table">
|
||||
<thead><tr><th>Debt</th><th>Owner</th><th>Payment</th><th>Extra</th></tr></thead>
|
||||
<tbody>
|
||||
{% for d in debt_payments %}
|
||||
<tr>
|
||||
<td>{{ d.name }}</td>
|
||||
<td><span class="owner-tag" style="background:{{ owner_colors.get(d.owner,'#ddd') }};font-size:0.68rem;">{{ d.owner }}</span></td>
|
||||
<td class="amount">${{ d.payment | money }}</td>
|
||||
<td class="amount">{% if d.extra > 0 %}<span style="color:#5A9E68;">+${{ d.extra | money }}</span>{% else %}<span class="muted">—</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="2">Total Paid</td>
|
||||
<td class="amount">${{ total_debt_paid | money }}</td>
|
||||
<td class="amount" style="color:#5A9E68;">{% if total_extra_paid > 0 %}+${{ total_extra_paid | money }} extra{% endif %}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom net summary -->
|
||||
<div class="leftover-bar {% if net < 0 %}leftover-negative{% endif %}" style="margin-top:2rem;">
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:1rem;">{{ month_name }} {{ year }} — Final Summary</div>
|
||||
<div style="font-size:0.82rem;color:var(--text-light);margin-top:0.2rem;">
|
||||
${{ "%.2f"|format(total_income) }} income
|
||||
− ${{ "%.2f"|format(total_spending + total_bills) }} spent
|
||||
− ${{ "%.2f"|format(total_sinking) }} saved
|
||||
</div>
|
||||
</div>
|
||||
<div class="leftover-amount">
|
||||
{% if net >= 0 %}+{% endif %}${{ net | money }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
157
templates/mortgage.html
Normal file
157
templates/mortgage.html
Normal file
@@ -0,0 +1,157 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Mortgage Tracker</h1>
|
||||
</div>
|
||||
|
||||
<!-- Setup form -->
|
||||
<div class="card-wide" style="margin-bottom:1.75rem;">
|
||||
<h2>Mortgage Details</h2>
|
||||
<p class="helper-text">Fill these in once you close on your home. Everything calculates automatically.</p>
|
||||
<form method="POST" class="bill-form">
|
||||
<div class="bill-form-grid">
|
||||
<div class="form-group">
|
||||
<label>Loan Amount</label>
|
||||
<input type="number" name="principal" step="0.01" min="0"
|
||||
value="{{ principal or '' }}" placeholder="e.g. 280000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Interest Rate (APR %)</label>
|
||||
<input type="number" name="annual_rate" step="0.001" min="0" max="20"
|
||||
value="{{ annual_rate or '' }}" placeholder="e.g. 6.875">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Loan Term</label>
|
||||
<select name="term_years">
|
||||
<option value="30" {% if term_years == 30 %}selected{% endif %}>30 years</option>
|
||||
<option value="20" {% if term_years == 20 %}selected{% endif %}>20 years</option>
|
||||
<option value="15" {% if term_years == 15 %}selected{% endif %}>15 years</option>
|
||||
<option value="10" {% if term_years == 10 %}selected{% endif %}>10 years</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>First Payment Date</label>
|
||||
<input type="date" name="start_date" value="{{ start_date or '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Extra Monthly Payment <span class="optional">(optional)</span></label>
|
||||
<input type="number" name="extra_monthly" step="0.01" min="0"
|
||||
value="{{ extra_monthly or '' }}" placeholder="0.00">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save & Calculate</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if principal %}
|
||||
<!-- Summary cards -->
|
||||
<div class="mortgage-summary">
|
||||
<div class="card">
|
||||
<div class="card-label">Monthly Payment</div>
|
||||
<div class="card-value">${{ base_payment | money }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Current Balance</div>
|
||||
<div class="card-value">${{ "{:,.0f}".format(current_bal) }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Total Interest (standard)</div>
|
||||
<div class="card-value">${{ "{:,.0f}".format(total_interest_standard) }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Payoff</div>
|
||||
<div class="card-value">{{ standard_months }} mo</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Strategy comparison -->
|
||||
<section class="section">
|
||||
<h2>Payment Strategies</h2>
|
||||
<div class="mortgage-strategies">
|
||||
|
||||
<!-- Standard -->
|
||||
<div class="mortgage-strategy-card" style="border-top:4px solid #B8B8B8;">
|
||||
<div class="strategy-label">Standard Monthly</div>
|
||||
<div class="strategy-subtitle">Pay ${{ base_payment | money }}/month</div>
|
||||
<div class="strategy-stat"><span>Payoff</span><strong>{{ standard_months }} months ({{ (standard_months / 12) | round(1) }} yrs)</strong></div>
|
||||
<div class="strategy-stat"><span>Total interest</span><strong>${{ "{:,.2f}".format(total_interest_standard) }}</strong></div>
|
||||
<div class="strategy-stat"><span>Interest saved</span><strong>—</strong></div>
|
||||
</div>
|
||||
|
||||
<!-- Biweekly -->
|
||||
<div class="mortgage-strategy-card" style="border-top:4px solid #A8C5A0;">
|
||||
<div class="strategy-label">Biweekly Payments</div>
|
||||
<div class="strategy-subtitle">Pay ${{ (base_payment / 2) | money }} every 2 weeks</div>
|
||||
<div class="strategy-stat"><span>Payoff</span><strong>{{ bi_months_total }} months ({{ bi_years }} yrs)</strong></div>
|
||||
<div class="strategy-stat"><span>Total interest</span><strong>${{ "{:,.2f}".format(bi_interest) }}</strong></div>
|
||||
<div class="strategy-stat"><span>Interest saved</span>
|
||||
<strong class="accent">${{ "{:,.2f}".format(interest_saved_biweekly) }}
|
||||
({{ months_saved_biweekly }} months sooner)</strong>
|
||||
</div>
|
||||
<div class="strategy-badge">One extra payment per year</div>
|
||||
</div>
|
||||
|
||||
<!-- Extra monthly -->
|
||||
{% if extra_monthly > 0 %}
|
||||
<div class="mortgage-strategy-card" style="border-top:4px solid #D4B8C5;">
|
||||
<div class="strategy-label">Extra ${{ "%.0f" | format(extra_monthly) }}/Month</div>
|
||||
<div class="strategy-subtitle">Pay ${{ (base_payment + extra_monthly) | money }}/month total</div>
|
||||
<div class="strategy-stat"><span>Payoff</span><strong>{{ extra_months }} months ({{ (extra_months / 12) | round(1) }} yrs)</strong></div>
|
||||
<div class="strategy-stat"><span>Total interest</span><strong>${{ "{:,.2f}".format(total_interest_extra) }}</strong></div>
|
||||
<div class="strategy-stat"><span>Interest saved</span>
|
||||
<strong class="accent">${{ "{:,.2f}".format(interest_saved_extra) }}
|
||||
({{ months_saved_extra }} months sooner)</strong>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mortgage-strategy-card mortgage-strategy-empty" style="border-top:4px solid #D4B8C5;">
|
||||
<div class="strategy-label">Extra Monthly Payment</div>
|
||||
<div class="strategy-subtitle">Enter an extra monthly amount above to see the impact</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Amortization schedule -->
|
||||
<section class="section">
|
||||
<details class="amortization-details">
|
||||
<summary class="amortization-toggle">
|
||||
View Full Amortization Schedule
|
||||
<span class="muted">({{ schedule | length }} payments)</span>
|
||||
</summary>
|
||||
<div class="amortization-wrap">
|
||||
<table class="txn-table txn-table-full amortization-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Date</th>
|
||||
<th>Payment</th>
|
||||
<th>Principal</th>
|
||||
<th>Interest</th>
|
||||
<th>Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in schedule %}
|
||||
<tr class="{% if row.interest > row.principal %}amort-interest-heavy{% endif %}">
|
||||
<td class="muted">{{ row.month }}</td>
|
||||
<td>{{ row.date }}</td>
|
||||
<td class="amount">${{ row.payment | money }}</td>
|
||||
<td class="amount">${{ row.principal | money }}</td>
|
||||
<td class="amount amort-interest">${{ row.interest | money }}</td>
|
||||
<td class="amount">${{ "{:,.2f}".format(row.balance) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>Enter your mortgage details above once you close on your home and everything will calculate automatically.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
225
templates/net_worth.html
Normal file
225
templates/net_worth.html
Normal file
@@ -0,0 +1,225 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Net Worth</h1>
|
||||
<span class="muted" style="font-size:0.85rem;">Assets minus Liabilities</span>
|
||||
</div>
|
||||
|
||||
<!-- Big Net Worth Number -->
|
||||
<div class="net-worth-hero {% if net_worth >= 0 %}net-worth-positive{% else %}net-worth-negative{% endif %}">
|
||||
<div class="net-worth-label">Current Net Worth</div>
|
||||
<div class="net-worth-number">${{ net_worth | money }}</div>
|
||||
<div class="net-worth-sub">
|
||||
<span>Assets: <strong>${{ total_assets | money }}</strong></span>
|
||||
<span style="margin:0 0.75rem;opacity:0.5;">−</span>
|
||||
<span>Debts: <strong>${{ total_debts | money }}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;margin-top:1.5rem;">
|
||||
|
||||
<!-- ASSETS COLUMN -->
|
||||
<div>
|
||||
<h2 style="margin-bottom:1rem;color:var(--accent);">Assets</h2>
|
||||
|
||||
<!-- Cash & Accounts -->
|
||||
<div class="budget-section">
|
||||
<div class="budget-section-header" style="border-left-color:#7AB87A;">
|
||||
<h3>Cash & Bank Accounts</h3>
|
||||
<span class="muted" style="font-size:0.82rem;">${{ total_cash | money }}</span>
|
||||
</div>
|
||||
<table class="budget-table">
|
||||
<thead><tr><th>Account</th><th>Owner</th><th>Balance</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for acc in accounts %}
|
||||
<tr>
|
||||
<td>{{ acc.name }}</td>
|
||||
<td><span class="owner-tag" style="background:{{ owner_colors.get(acc.owner,'#ddd') }};font-size:0.72rem;">{{ acc.owner }}</span></td>
|
||||
<td class="amount">
|
||||
<input type="number" step="0.01" class="budget-input"
|
||||
value="{{ acc.balance }}"
|
||||
onchange="updateAccount('{{ acc.id }}', this.value)"
|
||||
style="width:110px;">
|
||||
</td>
|
||||
<td>
|
||||
<form method="POST" action="/net-worth/account/delete/{{ acc.id }}" style="display:inline;">
|
||||
<button type="submit" class="debt-delete-btn" onclick="return confirm('Remove this account?')">✕</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="2">Total Cash</td>
|
||||
<td class="amount">${{ total_cash | money }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<!-- Add Account -->
|
||||
<details style="margin-top:0.75rem;">
|
||||
<summary class="btn btn-ghost" style="display:inline-block;cursor:pointer;font-size:0.82rem;">+ Add Account</summary>
|
||||
<form method="POST" action="/net-worth/account/add" style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-top:0.75rem;align-items:flex-end;">
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.78rem;">Account Name</label>
|
||||
<input type="text" name="name" placeholder="e.g. WestConsin Checking" class="budget-input" required>
|
||||
</div>
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.78rem;">Owner</label>
|
||||
<select name="owner" class="budget-input">
|
||||
<option value="Bonna">Bonna</option>
|
||||
<option value="Tony">Tony</option>
|
||||
<option value="Both">Both</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.78rem;">Balance</label>
|
||||
<input type="number" step="0.01" name="balance" placeholder="0.00" class="budget-input" style="width:110px;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="align-self:flex-end;">Add</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Sinking Funds -->
|
||||
<div class="budget-section" style="margin-top:1rem;">
|
||||
<div class="budget-section-header" style="border-left-color:#C5BBA0;">
|
||||
<h3>Sinking Funds</h3>
|
||||
<a href="{{ url_for('sinking_view') }}" class="edit-link">manage</a>
|
||||
<span class="muted" style="font-size:0.82rem;">${{ total_sinking | money }}</span>
|
||||
</div>
|
||||
<table class="budget-table">
|
||||
<tbody>
|
||||
{% for f in sinking_funds %}
|
||||
<tr>
|
||||
<td>{{ f.name }}</td>
|
||||
<td class="amount">${{ (f.balance or 0) | money }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td>Total</td>
|
||||
<td class="amount">${{ total_sinking | money }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Vehicles -->
|
||||
{% if personal_cars %}
|
||||
<div class="budget-section" style="margin-top:1rem;">
|
||||
<div class="budget-section-header" style="border-left-color:#D4A96A;">
|
||||
<h3>Vehicles</h3>
|
||||
<a href="{{ url_for('assets_view') }}" class="edit-link">manage</a>
|
||||
<span class="muted" style="font-size:0.82rem;">${{ total_vehicles | money }}</span>
|
||||
</div>
|
||||
<table class="budget-table">
|
||||
<tbody>
|
||||
{% for car in personal_cars %}
|
||||
<tr>
|
||||
<td>{{ car.display_name }}</td>
|
||||
<td><span class="owner-tag" style="background:{{ owner_colors.get(car.owner,'#ddd') }};font-size:0.72rem;">{{ car.owner }}</span></td>
|
||||
<td class="amount">
|
||||
{% if car.estimated_value %}
|
||||
${{ car.estimated_value | float | money }}
|
||||
{% else %}
|
||||
<span class="muted">no value set</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="2">Total</td>
|
||||
<td class="amount">${{ total_vehicles | money }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Other Assets -->
|
||||
{% if other_assets %}
|
||||
<div class="budget-section" style="margin-top:1rem;">
|
||||
<div class="budget-section-header" style="border-left-color:#A08CC5;">
|
||||
<h3>Other Assets</h3>
|
||||
<a href="{{ url_for('assets_view') }}" class="edit-link">manage</a>
|
||||
<span class="muted" style="font-size:0.82rem;">${{ total_other_assets | money }}</span>
|
||||
</div>
|
||||
<table class="budget-table">
|
||||
<tbody>
|
||||
{% for a in other_assets %}
|
||||
<tr>
|
||||
<td>{{ a.name }}</td>
|
||||
<td class="muted" style="font-size:0.8rem;">{{ a.category }}</td>
|
||||
<td class="amount">${{ a.value | float | money }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="2">Total</td>
|
||||
<td class="amount">${{ total_other_assets | money }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div><!-- end assets column -->
|
||||
|
||||
<!-- LIABILITIES COLUMN -->
|
||||
<div>
|
||||
<h2 style="margin-bottom:1rem;color:#C57A7A;">Liabilities (Debts)</h2>
|
||||
|
||||
{% for cat in debt_categories %}
|
||||
{% set cat_debts = debts_by_cat[cat] %}
|
||||
{% if cat_debts %}
|
||||
<div class="budget-section" style="margin-top:{% if loop.first %}0{% else %}1rem{% endif %};">
|
||||
<div class="budget-section-header" style="border-left-color:#C57A7A;">
|
||||
<h3>{{ cat }}</h3>
|
||||
<span class="muted" style="font-size:0.82rem;">${{ debt_cat_totals[cat] | money }}</span>
|
||||
</div>
|
||||
<table class="budget-table">
|
||||
<tbody>
|
||||
{% for d in cat_debts %}
|
||||
<tr>
|
||||
<td>{{ d.name }}</td>
|
||||
<td><span class="owner-tag" style="background:{{ owner_colors.get(d.owner,'#ddd') }};font-size:0.72rem;">{{ d.owner }}</span></td>
|
||||
<td class="amount">${{ d.current_balance | float | money }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="2">Subtotal</td>
|
||||
<td class="amount">${{ debt_cat_totals[cat] | money }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="leftover-bar" style="margin-top:1rem;">
|
||||
<span>Total Debt</span>
|
||||
<span style="font-size:1.1rem;font-weight:700;color:#C57A7A;">${{ total_debts | money }}</span>
|
||||
</div>
|
||||
</div><!-- end liabilities column -->
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function updateAccount(id, value) {
|
||||
fetch('/net-worth/account/update/' + id, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({balance: value})
|
||||
}).then(() => location.reload());
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
396
templates/review.html
Normal file
396
templates/review.html
Normal file
@@ -0,0 +1,396 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Review Transactions</h1>
|
||||
{% if remaining %}<span class="badge">{{ remaining }} remaining</span>{% endif %}
|
||||
</div>
|
||||
|
||||
{% if remaining %}
|
||||
<div style="background:var(--warm-white);border:1px solid var(--border);border-radius:var(--radius);padding:0.85rem 1.25rem;margin-bottom:1.25rem;display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;">
|
||||
<span style="font-size:0.88rem;color:var(--text-mid);">Need to bulk-clear old transactions?</span>
|
||||
<form method="POST" action="{{ url_for('bulk_ignore_before') }}" onsubmit="return confirm('Ignore all unreviewed transactions before this date?')">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||
<label style="font-size:0.85rem;">Ignore everything before</label>
|
||||
<input type="date" name="before_date" required class="budget-input" style="width:150px;">
|
||||
<button type="submit" class="btn btn-ghost" style="color:var(--text-light);font-size:0.85rem;">Bulk ignore</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not transaction %}
|
||||
<div class="empty-state card-wide">
|
||||
<div class="empty-icon">✓</div>
|
||||
<h2>All caught up!</h2>
|
||||
<p>Every transaction has been reviewed.</p>
|
||||
<a href="{{ url_for('transactions_view') }}" class="btn btn-primary">View All Transactions</a>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<div class="review-card">
|
||||
<div class="review-meta">
|
||||
<span class="review-date">{{ transaction.date }}</span>
|
||||
<span class="review-account">{{ transaction.account }}</span>
|
||||
</div>
|
||||
<div class="review-description">{{ transaction.description }}</div>
|
||||
<div class="review-amount">${{ transaction.amount | money }}</div>
|
||||
|
||||
{% if bnpl_lender and bnpl_matches %}
|
||||
<div style="background:#f0f5ff;border:1.5px solid #b0c4ef;border-radius:var(--radius);padding:1rem 1.25rem;margin-bottom:1rem;">
|
||||
<div style="font-weight:600;margin-bottom:0.5rem;">💳 Looks like a {{ bnpl_lender | title }} payment — which loan is this?</div>
|
||||
<div style="display:flex;flex-direction:column;gap:0.5rem;">
|
||||
{% for loan in bnpl_matches %}
|
||||
<form method="POST" action="{{ url_for('match_bnpl') }}">
|
||||
<input type="hidden" name="txn_id" value="{{ transaction.id }}">
|
||||
<input type="hidden" name="debt_id" value="{{ loan.id }}">
|
||||
<input type="hidden" name="category" value="Debt">
|
||||
<button type="submit" style="width:100%;text-align:left;background:{% if loan.amount_match %}#e8f0fe{% else %}#fff{% endif %};border:1px solid {% if loan.amount_match %}#7aacf0{% else %}var(--border){% endif %};border-radius:6px;padding:0.6rem 0.85rem;cursor:pointer;display:flex;justify-content:space-between;align-items:center;gap:1rem;">
|
||||
<span>
|
||||
<strong>{{ loan.name }}</strong>
|
||||
<span style="font-size:0.78rem;color:var(--text-light);margin-left:0.5rem;">{{ loan.payments_made }}/{{ loan.total_payments }} payments made</span>
|
||||
</span>
|
||||
<span style="white-space:nowrap;">
|
||||
${{ loan.payment_amount | money }}
|
||||
{% if loan.amount_match %}<span style="color:#5A9E68;font-size:0.78rem;margin-left:0.4rem;">✓ amount matches</span>{% endif %}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div style="margin-top:0.6rem;font-size:0.8rem;color:var(--text-light);">Selecting a loan will mark one payment as made and categorize this as Debt. Or ignore this and categorize manually below.</div>
|
||||
</div>
|
||||
{% elif bnpl_lender %}
|
||||
<div style="background:#fff8e6;border:1px solid #e8d49a;border-radius:var(--radius);padding:1rem 1.25rem;margin-bottom:1rem;">
|
||||
<div style="font-weight:600;margin-bottom:0.25rem;">💳 Looks like a {{ bnpl_lender | title }} payment — no active loans found</div>
|
||||
<div style="font-size:0.83rem;color:var(--text-light);margin-bottom:0.75rem;">Want to set up this loan now so future payments get tracked automatically?</div>
|
||||
<button type="button" class="btn btn-ghost" style="font-size:0.85rem;" onclick="document.getElementById('bnpl-quick-add').style.display='block';this.style.display='none';">
|
||||
+ Add {{ bnpl_lender | title }} loan now
|
||||
</button>
|
||||
<div id="bnpl-quick-add" style="display:none;margin-top:0.75rem;">
|
||||
<form method="POST" action="{{ url_for('bnpl_add') }}">
|
||||
<input type="hidden" name="bnpl_provider" value="{{ bnpl_lender | title }}">
|
||||
<input type="hidden" name="redirect_to" value="review">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;margin-bottom:0.75rem;">
|
||||
<div>
|
||||
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">What did you buy?</label>
|
||||
<input type="text" name="description" class="budget-input" style="width:100%;" placeholder="e.g. Nike Shoes" required>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Merchant (optional)</label>
|
||||
<input type="text" name="merchant" class="budget-input" style="width:100%;" placeholder="e.g. Nike">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Payment Amount</label>
|
||||
<input type="number" name="payment_amount" step="0.01" min="0" class="budget-input" style="width:100%;" value="{{ transaction.amount }}" required>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Total # of Payments</label>
|
||||
<input type="number" name="total_payments" min="1" class="budget-input" style="width:100%;" placeholder="e.g. 4" required>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Frequency</label>
|
||||
<select name="frequency" class="budget-input" style="width:100%;">
|
||||
<option value="biweekly">Every 2 weeks</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Next Payment Date</label>
|
||||
<input type="date" name="next_payment_date" class="budget-input" style="width:100%;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Who's it for?</label>
|
||||
<select name="owner" class="budget-input" style="width:100%;">
|
||||
<option value="Shared">Shared</option>
|
||||
<option value="Bonna">Bonna</option>
|
||||
<option value="Tony">Tony</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Payments already made</label>
|
||||
<input type="number" name="payments_made" min="0" value="1" class="budget-input" style="width:100%;">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.75rem;align-items:center;">
|
||||
<button type="submit" class="btn btn-primary">Save Loan & Continue</button>
|
||||
<button type="button" class="btn btn-ghost" onclick="document.getElementById('bnpl-quick-add').style.display='none';this.closest('div').previousElementSibling.style.display='inline-block';">Cancel</button>
|
||||
<span style="font-size:0.78rem;color:var(--text-light);">This transaction will still need to be categorized on the next screen</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if suggestion %}
|
||||
<div class="suggestion-banner">
|
||||
<span>Suggested: <strong>{{ suggestion.category }}{% if suggestion.subcategory %} › {{ suggestion.subcategory }}{% endif %}</strong></span>
|
||||
<form method="POST" action="{{ url_for('categorize') }}" style="display:inline;">
|
||||
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||
<input type="hidden" name="category" value="{{ suggestion.category }}">
|
||||
<input type="hidden" name="subcategory" value="{{ suggestion.subcategory or '' }}">
|
||||
<button type="submit" class="btn btn-accept">Accept</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="{{ url_for('categorize') }}" class="categorize-form">
|
||||
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label>Category</label>
|
||||
<div class="category-grid">
|
||||
{% for cat, subcats in categories.items() %}
|
||||
<label class="cat-option" style="--cat-color: {{ colors.get(cat, '#ddd') }};">
|
||||
<input type="radio" name="category" value="{{ cat }}"
|
||||
{% if suggestion and suggestion.category == cat %}checked{% endif %}
|
||||
onchange="updateSubcats(this.value)">
|
||||
<span>{{ cat }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="subcat-group">
|
||||
<label>Subcategory <span class="optional">(optional)</span></label>
|
||||
<select name="subcategory" id="subcat-select">
|
||||
<option value="">— none —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Sinking fund picker — shown when Transfer > Sinking Fund is selected -->
|
||||
<div class="form-group" id="sinking-fund-prompt" style="display:none;">
|
||||
<label>Which fund? <span class="optional">(will auto-add to that fund's balance)</span></label>
|
||||
<select name="sinking_fund_name" id="sinking-fund-select" class="budget-input">
|
||||
<option value="">— select fund —</option>
|
||||
{% for f in sinking_funds %}
|
||||
<option value="{{ f.name }}">{{ f.name }} (current: ${{ (f.balance or 0) | money }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Car prompt — shown when Gas subcategory is selected -->
|
||||
<div class="form-group" id="car-prompt" style="display:none;">
|
||||
<label>Which car? <span class="optional">(will auto-log to that car's profile)</span></label>
|
||||
<select name="car_id" id="car-select">
|
||||
<option value="">— select car —</option>
|
||||
{% for car in personal_cars %}
|
||||
<option value="{{ car.id }}">{{ car.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Notes <span class="optional">(optional)</span></label>
|
||||
<input type="text" name="notes" placeholder="e.g. Costco run, birthday gift...">
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox-group">
|
||||
<label>
|
||||
<input type="checkbox" name="remember" checked>
|
||||
Remember this for future transactions from this merchant
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Save & Next</button>
|
||||
<button type="button" class="btn btn-ghost" onclick="toggleSplit()">Split transaction</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Quick-action buttons — must be outside the main form -->
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.75rem;">
|
||||
<form method="POST" action="{{ url_for('ignore_transaction') }}">
|
||||
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||
<button type="submit" class="btn btn-ghost" style="color:var(--text-light);font-size:0.82rem;"
|
||||
title="Marks as reviewed but excludes from all totals — use for bill payments, debt payments, income deposits">
|
||||
✓ Already tracked — ignore
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-ghost" style="background:#e8f5e9;color:#3a7d44;border:1px solid #a5d6a7;font-size:0.82rem;" onclick="document.getElementById('review-sinking-picker').style.display='block';this.style.display='none';">🏦 Sinking Fund Transfer</button>
|
||||
<div id="review-sinking-picker" style="display:none;background:#e8f5e9;border:1px solid #a5d6a7;border-radius:var(--radius);padding:0.75rem 1rem;width:100%;">
|
||||
<form method="POST" action="{{ url_for('categorize_sinking_transfer') }}">
|
||||
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||
<input type="hidden" name="redirect" value="review">
|
||||
<label style="font-size:0.82rem;font-weight:600;display:block;margin-bottom:0.4rem;">Which sinking fund?</label>
|
||||
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
|
||||
<select name="sinking_fund_name" class="budget-input" style="min-width:180px;" required>
|
||||
<option value="">— select fund —</option>
|
||||
{% for f in sinking_funds %}
|
||||
<option value="{{ f.name }}">{{ f.name }} (${{ (f.balance or 0) | money }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;">Save & Next</button>
|
||||
<button type="button" class="btn btn-ghost" style="font-size:0.82rem;" onclick="document.getElementById('review-sinking-picker').style.display='none';this.closest('div').previousElementSibling.style.display='inline-block';">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('ignore_transaction') }}">
|
||||
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||
<input type="hidden" name="label" value="Investment Transfer">
|
||||
<button type="submit" class="btn btn-ghost" style="background:#e3f2fd;color:#1565c0;border:1px solid #90caf9;font-size:0.82rem;">📈 Investment Transfer</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('ignore_transaction') }}">
|
||||
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||
<input type="hidden" name="label" value="Savings Transfer">
|
||||
<button type="submit" class="btn btn-ghost" style="background:#f3e5f5;color:#6a1b9a;border:1px solid #ce93d8;font-size:0.82rem;">💰 Savings Transfer</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('skip') }}">
|
||||
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||
<button type="submit" class="btn btn-ghost" style="font-size:0.82rem;">Skip for now</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Split transaction form -->
|
||||
<div id="split-panel" style="display:none;margin-top:1.5rem;">
|
||||
<div class="budget-section">
|
||||
<div class="budget-section-header" style="border-left-color:#D4A96A;">
|
||||
<h3>Split Transaction — ${{ transaction.amount | money }} total</h3>
|
||||
</div>
|
||||
<p class="muted" style="margin-bottom:1rem;font-size:0.85rem;">Divide this charge across multiple categories. Amounts must add up to ${{ transaction.amount | money }}.</p>
|
||||
<form method="POST" action="{{ url_for('split_transaction') }}">
|
||||
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||
<table class="budget-table" id="split-table">
|
||||
<thead><tr><th>Category</th><th>Subcategory</th><th>Amount</th><th>Note</th><th></th></tr></thead>
|
||||
<tbody id="split-rows">
|
||||
<tr class="split-row">
|
||||
<td>
|
||||
<select name="split_cat_0" class="budget-input split-cat" onchange="updateSplitSubcat(this, 0)" style="width:130px;">
|
||||
<option value="">— pick —</option>
|
||||
{% for cat in categories %}<option value="{{ cat }}">{{ cat }}</option>{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td><select name="split_subcat_0" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
|
||||
<td><input type="number" name="split_amt_0" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" placeholder="0.00" oninput="updateRemaining()"></td>
|
||||
<td><input type="text" name="split_note_0" class="budget-input" style="width:110px;" placeholder="optional"></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr class="split-row">
|
||||
<td>
|
||||
<select name="split_cat_1" class="budget-input split-cat" onchange="updateSplitSubcat(this, 1)" style="width:130px;">
|
||||
<option value="">— pick —</option>
|
||||
{% for cat in categories %}<option value="{{ cat }}">{{ cat }}</option>{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td><select name="split_subcat_1" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
|
||||
<td><input type="number" name="split_amt_1" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" placeholder="0.00" oninput="updateRemaining()"></td>
|
||||
<td><input type="text" name="split_note_1" class="budget-input" style="width:110px;" placeholder="optional"></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="margin:0.75rem 0;font-size:0.85rem;">
|
||||
Remaining: <strong id="split-remaining">${{ transaction.amount | money }}</strong>
|
||||
<button type="button" class="btn btn-ghost" style="margin-left:1rem;font-size:0.78rem;" onclick="addSplitRow()">+ Add row</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.75rem;">
|
||||
<button type="submit" class="btn btn-primary">Save Split & Next</button>
|
||||
<button type="button" class="btn btn-ghost" onclick="toggleSplit()">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const subcats = {{ categories | tojson }};
|
||||
const suggestion = {{ suggestion | tojson if suggestion else 'null' }};
|
||||
|
||||
function updateSubcats(cat) {
|
||||
const select = document.getElementById('subcat-select');
|
||||
const group = document.getElementById('subcat-group');
|
||||
const list = subcats[cat] || [];
|
||||
select.innerHTML = '<option value="">— none —</option>';
|
||||
list.forEach(s => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s;
|
||||
opt.textContent = s;
|
||||
if (suggestion && suggestion.subcategory === s) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
group.style.display = list.length ? 'block' : 'none';
|
||||
checkCarPrompt(cat, select.value);
|
||||
}
|
||||
|
||||
function checkCarPrompt(cat, subcat) {
|
||||
const carPrompt = document.getElementById('car-prompt');
|
||||
if (cat === 'Car' && subcat === 'Gas') {
|
||||
carPrompt.style.display = 'block';
|
||||
} else {
|
||||
carPrompt.style.display = 'none';
|
||||
document.getElementById('car-select').value = '';
|
||||
}
|
||||
|
||||
const sinkingPrompt = document.getElementById('sinking-fund-prompt');
|
||||
if (cat === 'Transfer' && subcat === 'Sinking Fund') {
|
||||
sinkingPrompt.style.display = 'block';
|
||||
} else {
|
||||
sinkingPrompt.style.display = 'none';
|
||||
document.getElementById('sinking-fund-select').value = '';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('subcat-select').addEventListener('change', function() {
|
||||
const cat = document.querySelector('input[name="category"]:checked');
|
||||
checkCarPrompt(cat ? cat.value : '', this.value);
|
||||
});
|
||||
|
||||
// Initialize on load
|
||||
const checked = document.querySelector('input[name="category"]:checked');
|
||||
if (checked) updateSubcats(checked.value);
|
||||
document.querySelectorAll('input[name="category"]').forEach(r => {
|
||||
r.addEventListener('change', () => updateSubcats(r.value));
|
||||
});
|
||||
|
||||
// Split transaction
|
||||
const totalAmount = {{ transaction.amount }};
|
||||
const allSubcats = {{ categories | tojson }};
|
||||
let splitRowCount = 2;
|
||||
|
||||
function toggleSplit() {
|
||||
const panel = document.getElementById('split-panel');
|
||||
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function updateSplitSubcat(selectEl, idx) {
|
||||
const cat = selectEl.value;
|
||||
const subcatSel = document.querySelector(`select[name="split_subcat_${idx}"]`);
|
||||
const list = allSubcats[cat] || [];
|
||||
subcatSel.innerHTML = '<option value="">— none —</option>';
|
||||
list.forEach(s => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s; opt.textContent = s;
|
||||
subcatSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function updateRemaining() {
|
||||
const inputs = document.querySelectorAll('.split-amt');
|
||||
let used = 0;
|
||||
inputs.forEach(el => used += parseFloat(el.value) || 0);
|
||||
const remaining = Math.round((totalAmount - used) * 100) / 100;
|
||||
const el = document.getElementById('split-remaining');
|
||||
el.textContent = '$' + remaining.toFixed(2);
|
||||
el.style.color = Math.abs(remaining) < 0.01 ? '#5A9E68' : (remaining < 0 ? '#c07070' : 'inherit');
|
||||
}
|
||||
|
||||
function addSplitRow() {
|
||||
const idx = splitRowCount++;
|
||||
const tbody = document.getElementById('split-rows');
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'split-row';
|
||||
row.innerHTML = `
|
||||
<td><select name="split_cat_${idx}" class="budget-input split-cat" onchange="updateSplitSubcat(this,${idx})" style="width:130px;">
|
||||
<option value="">— pick —</option>
|
||||
${Object.keys(allSubcats).map(c => `<option value="${c}">${c}</option>`).join('')}
|
||||
</select></td>
|
||||
<td><select name="split_subcat_${idx}" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
|
||||
<td><input type="number" name="split_amt_${idx}" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" placeholder="0.00" oninput="updateRemaining()"></td>
|
||||
<td><input type="text" name="split_note_${idx}" class="budget-input" style="width:110px;" placeholder="optional"></td>
|
||||
<td><button type="button" onclick="this.closest('tr').remove();updateRemaining();" style="background:none;border:none;color:#c07070;cursor:pointer;font-size:1rem;">✕</button></td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
175
templates/sinking.html
Normal file
175
templates/sinking.html
Normal file
@@ -0,0 +1,175 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Sinking Funds</h1>
|
||||
</div>
|
||||
|
||||
<!-- Total savings banner -->
|
||||
<div class="sinking-total-bar">
|
||||
<div>
|
||||
<div class="sinking-total-label">Total Across All Funds</div>
|
||||
<div class="sinking-total-amount">${{ total | money }}</div>
|
||||
</div>
|
||||
<div class="sinking-total-note">Match this number to your savings account balance</div>
|
||||
</div>
|
||||
|
||||
<!-- Fund cards -->
|
||||
<div class="sinking-grid">
|
||||
{% for f in funds %}
|
||||
{% set color = fund_colors.get(f.name, '#C5BBA0') %}
|
||||
<div class="sinking-card" style="border-top: 4px solid {{ color }};">
|
||||
<div class="sinking-card-header">
|
||||
<div class="sinking-card-name">{{ f.name }}</div>
|
||||
<span class="sinking-type-badge {% if f.type == 'goal' %}badge-goal{% endif %}">
|
||||
{{ 'Goal' if f.type == 'goal' else 'Ongoing' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if f.notes %}
|
||||
<div class="sinking-notes">{{ f.notes }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="sinking-balance">${{ (f.balance or 0) | money }}</div>
|
||||
|
||||
{% if f.type == 'goal' and f.goal %}
|
||||
{% set pct = f.pct or 0 %}
|
||||
<div class="sinking-progress-wrap">
|
||||
<div class="sinking-progress-bar">
|
||||
<div class="sinking-progress-fill" style="width:{{ pct }}%;background:{{ color }};"></div>
|
||||
</div>
|
||||
<div class="sinking-progress-label">
|
||||
${{ (f.balance or 0) | money }} of ${{ f.goal | money }} ({{ pct }}%)
|
||||
</div>
|
||||
</div>
|
||||
{% if f.monthly_needed %}
|
||||
<div class="sinking-monthly-needed">
|
||||
Save <strong>${{ f.monthly_needed | money }}/mo</strong>
|
||||
to reach goal
|
||||
{% if f.months_left %} in {{ f.months_left }} months{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if f.monthly %}
|
||||
<div class="sinking-monthly-needed">
|
||||
Monthly contribution: <strong>${{ f.monthly | money }}</strong>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Contribute / Withdraw -->
|
||||
<div class="sinking-actions">
|
||||
<form method="POST" action="{{ url_for('sinking_contribute') }}" class="sinking-action-form">
|
||||
<input type="hidden" name="name" value="{{ f.name }}">
|
||||
<input type="number" name="amount" step="0.01" min="0" placeholder="Amount" class="sinking-amount-input">
|
||||
<button type="submit" class="btn btn-accept">+ Add</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('sinking_withdraw') }}" class="sinking-action-form">
|
||||
<input type="hidden" name="name" value="{{ f.name }}">
|
||||
<input type="number" name="amount" step="0.01" min="0" placeholder="Amount" class="sinking-amount-input">
|
||||
<button type="submit" class="btn btn-ghost">− Use</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Edit settings -->
|
||||
<details class="sinking-edit-details">
|
||||
<summary class="sinking-edit-toggle">Edit settings</summary>
|
||||
<div class="sinking-edit-form">
|
||||
<div class="form-group">
|
||||
<label>Correct Balance <span class="optional">(override if something's off)</span></label>
|
||||
<div style="display:flex;gap:0.5rem;align-items:center;">
|
||||
<input type="number" step="0.01" id="balance-override-{{ loop.index }}" placeholder="{{ '%.2f' | format(f.balance or 0) }}" style="flex:1;">
|
||||
<button type="button" class="btn btn-ghost" style="font-size:0.82rem;white-space:nowrap;"
|
||||
onclick="setBalance('{{ f.name }}', document.getElementById('balance-override-{{ loop.index }}').value)">Set</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Type</label>
|
||||
<select onchange="updateFund('{{ f.name }}', 'type', this.value)">
|
||||
<option value="ongoing" {% if f.type != 'goal' %}selected{% endif %}>Ongoing</option>
|
||||
<option value="goal" {% if f.type == 'goal' %}selected{% endif %}>Goal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Monthly Contribution</label>
|
||||
<input type="number" step="0.01" value="{{ f.monthly or '' }}" placeholder="0.00"
|
||||
onchange="updateFund('{{ f.name }}', 'monthly', this.value)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Goal Amount <span class="optional">(if goal type)</span></label>
|
||||
<input type="number" step="0.01" value="{{ f.goal or '' }}" placeholder="0.00"
|
||||
onchange="updateFund('{{ f.name }}', 'goal', this.value)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Target Date <span class="optional">(if goal type)</span></label>
|
||||
<input type="date" value="{{ f.target_date or '' }}"
|
||||
onchange="updateFund('{{ f.name }}', 'target_date', this.value)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Notes</label>
|
||||
<input type="text" value="{{ f.notes or '' }}" placeholder="Optional note..."
|
||||
onchange="updateFund('{{ f.name }}', 'notes', this.value)">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Add new fund card -->
|
||||
<div class="sinking-card sinking-add-card">
|
||||
<div class="sinking-card-name" style="margin-bottom:1rem;">+ New Fund</div>
|
||||
<form method="POST" action="{{ url_for('sinking_add') }}">
|
||||
<div class="form-group">
|
||||
<label>Fund Name</label>
|
||||
<input type="text" name="name" placeholder="e.g. New Car, Vacation" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Type</label>
|
||||
<select name="type">
|
||||
<option value="ongoing">Ongoing</option>
|
||||
<option value="goal">Goal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Starting Balance <span class="optional">(optional)</span></label>
|
||||
<input type="number" name="balance" step="0.01" min="0" placeholder="0.00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Monthly Contribution</label>
|
||||
<input type="number" name="monthly" step="0.01" min="0" placeholder="0.00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Goal Amount <span class="optional">(if goal)</span></label>
|
||||
<input type="number" name="goal" step="0.01" min="0" placeholder="0.00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Target Date <span class="optional">(if goal)</span></label>
|
||||
<input type="date" name="target_date">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Notes <span class="optional">(optional)</span></label>
|
||||
<input type="text" name="notes" placeholder="">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Fund</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function updateFund(name, field, value) {
|
||||
fetch('/sinking/update', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({name, field, value})
|
||||
});
|
||||
}
|
||||
|
||||
function setBalance(name, value) {
|
||||
if (value === '' || isNaN(parseFloat(value))) return;
|
||||
fetch('/sinking/update', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({name, field: 'balance', value})
|
||||
}).then(() => location.reload());
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
116
templates/strategy.html
Normal file
116
templates/strategy.html
Normal file
@@ -0,0 +1,116 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Debt Payoff Strategy</h1>
|
||||
<a href="{{ url_for('debts_view') }}" class="btn btn-ghost">← Back to Debts</a>
|
||||
</div>
|
||||
|
||||
<div class="card-wide" style="margin-bottom:1.5rem;">
|
||||
<h2>How much can you put toward debt each month?</h2>
|
||||
<p class="helper-text">Your current total minimums are <strong>${{ total_minimums | money }}</strong>. Enter your total monthly debt payment below — anything above the minimums gets applied strategically.</p>
|
||||
<form method="POST" style="display:flex;gap:1rem;align-items:flex-end;flex-wrap:wrap;margin-top:1rem;">
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label>Total Monthly Payment</label>
|
||||
<input type="number" name="extra_monthly" step="0.01" min="{{ total_minimums }}"
|
||||
value="{{ total_monthly or total_minimums }}" placeholder="{{ total_minimums }}">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Calculate</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if av_months %}
|
||||
<!-- Summary comparison -->
|
||||
<div class="strategy-compare">
|
||||
<div class="strategy-card strategy-avalanche">
|
||||
<div class="strategy-label">Avalanche Method</div>
|
||||
<div class="strategy-subtitle">Highest interest rate first</div>
|
||||
<div class="strategy-stat">
|
||||
<span>Debt-free in</span>
|
||||
<strong>{{ av_months }} months</strong>
|
||||
</div>
|
||||
<div class="strategy-stat">
|
||||
<span>Total interest paid</span>
|
||||
<strong>${{ av_interest | money }}</strong>
|
||||
</div>
|
||||
<div class="strategy-badge">Saves the most money</div>
|
||||
</div>
|
||||
|
||||
<div class="strategy-vs">VS</div>
|
||||
|
||||
<div class="strategy-card strategy-snowball">
|
||||
<div class="strategy-label">Snowball Method</div>
|
||||
<div class="strategy-subtitle">Smallest balance first</div>
|
||||
<div class="strategy-stat">
|
||||
<span>Debt-free in</span>
|
||||
<strong>{{ sb_months }} months</strong>
|
||||
</div>
|
||||
<div class="strategy-stat">
|
||||
<span>Total interest paid</span>
|
||||
<strong>${{ sb_interest | money }}</strong>
|
||||
</div>
|
||||
<div class="strategy-badge">Faster wins, more motivation</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if interest_saved > 0 or months_diff != 0 %}
|
||||
<div class="strategy-insight">
|
||||
{% if interest_saved > 0 %}
|
||||
<span>The Avalanche method saves you <strong>${{ interest_saved | money }}</strong> in interest</span>
|
||||
{% endif %}
|
||||
{% if months_diff > 0 %}
|
||||
<span>The Snowball method takes <strong>{{ months_diff }} month{{ 's' if months_diff != 1 }}</strong> longer</span>
|
||||
{% elif months_diff < 0 %}
|
||||
<span>The Avalanche method takes <strong>{{ months_diff|abs }} month{{ 's' if months_diff|abs != 1 }}</strong> longer</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Side by side payoff order -->
|
||||
<div class="strategy-tables">
|
||||
<div class="strategy-table-wrap">
|
||||
<h3>Avalanche Payoff Order</h3>
|
||||
<table class="txn-table">
|
||||
<thead>
|
||||
<tr><th>#</th><th>Debt</th><th>Owner</th><th>Paid Off</th><th>Interest Paid</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for d in avalanche | sort(attribute='paid_off_month') %}
|
||||
<tr>
|
||||
<td class="muted">{{ loop.index }}</td>
|
||||
<td>{{ d.name }}</td>
|
||||
<td><span class="owner-tag" style="background:{{ owner_colors.get(d.owner, '#ddd') }};">{{ d.owner }}</span></td>
|
||||
<td>{{ d.paid_off_month or '—' }}</td>
|
||||
<td class="amount">${{ d.total_interest | money }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="strategy-table-wrap">
|
||||
<h3>Snowball Payoff Order</h3>
|
||||
<table class="txn-table">
|
||||
<thead>
|
||||
<tr><th>#</th><th>Debt</th><th>Owner</th><th>Paid Off</th><th>Interest Paid</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for d in snowball | sort(attribute='paid_off_month') %}
|
||||
<tr>
|
||||
<td class="muted">{{ loop.index }}</td>
|
||||
<td>{{ d.name }}</td>
|
||||
<td><span class="owner-tag" style="background:{{ owner_colors.get(d.owner, '#ddd') }};">{{ d.owner }}</span></td>
|
||||
<td>{{ d.paid_off_month or '—' }}</td>
|
||||
<td class="amount">${{ d.total_interest | money }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>Add your debts and monthly payment amount above to see your payoff strategy.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
71
templates/summary.html
Normal file
71
templates/summary.html
Normal file
@@ -0,0 +1,71 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<div class="page-header">
|
||||
<h1>Spending Report</h1>
|
||||
</div>
|
||||
|
||||
{% if by_category %}
|
||||
|
||||
<!-- Horizontal bar chart -->
|
||||
<div class="card-wide" style="margin-bottom:1.5rem;">
|
||||
<canvas id="spending-bar-chart" height="80"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="summary-grid">
|
||||
{% for cat, data in by_category | dictsort %}
|
||||
<div class="summary-card" style="border-top: 4px solid {{ colors.get(cat, '#ddd') }};">
|
||||
<div class="summary-cat-name">{{ cat }}</div>
|
||||
<div class="summary-cat-total">${{ data.total | money }}</div>
|
||||
{% if data.subcategories %}
|
||||
<ul class="subcat-list">
|
||||
{% for sub, amt in data.subcategories | dictsort %}
|
||||
<li>
|
||||
<span>{{ sub }}</span>
|
||||
<span>${{ amt | money }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>No categorized transactions yet. <a href="{{ url_for('import_transactions') }}">Import a statement</a> to get started.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
{% if by_category %}
|
||||
const spendingLabels = {{ by_category.keys() | list | tojson }};
|
||||
const spendingValues = {{ by_category.values() | map(attribute='total') | list | tojson }};
|
||||
const spendingColors = [{% for cat in by_category %}'{{ colors.get(cat, "#ddd") }}',{% endfor %}];
|
||||
|
||||
new Chart(document.getElementById('spending-bar-chart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: spendingLabels,
|
||||
datasets: [{
|
||||
data: spendingValues,
|
||||
backgroundColor: spendingColors,
|
||||
borderRadius: 6,
|
||||
borderSkipped: false,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { callbacks: { label: ctx => ' $' + ctx.parsed.x.toFixed(2) } }
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { color: '#f0ece6' }, ticks: { callback: v => '$' + v.toLocaleString() } },
|
||||
y: { grid: { display: false } }
|
||||
},
|
||||
animation: { duration: 400 },
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
187
templates/taxes.html
Normal file
187
templates/taxes.html
Normal file
@@ -0,0 +1,187 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>{{ year }} Tax Estimator</h1>
|
||||
<span style="font-size:0.85rem;color:var(--text-light);">Mixed income household (self-employment + W-2) — Wisconsin, MFJ</span>
|
||||
</div>
|
||||
|
||||
<!-- Hero numbers -->
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin-bottom:1.5rem;">
|
||||
<div class="card-wide" style="text-align:center;padding:1.25rem;">
|
||||
<div style="font-size:0.8rem;color:var(--text-light);margin-bottom:0.25rem;">YTD Income</div>
|
||||
<div style="font-size:2rem;font-weight:700;color:var(--text-dark);">${{ ytd_total | money }}</div>
|
||||
</div>
|
||||
<div class="card-wide" style="text-align:center;padding:1.25rem;">
|
||||
<div style="font-size:0.8rem;color:var(--text-light);margin-bottom:0.25rem;">Estimated Tax Owed</div>
|
||||
<div style="font-size:2rem;font-weight:700;color:#c07070;">${{ estimate.total | money }}</div>
|
||||
<div style="font-size:0.78rem;color:var(--text-light);">~{{ estimate.effective_rate }}% effective rate</div>
|
||||
</div>
|
||||
<div class="card-wide" style="text-align:center;padding:1.25rem;">
|
||||
<div style="font-size:0.8rem;color:var(--text-light);margin-bottom:0.25rem;">Set Aside So Far</div>
|
||||
<div style="font-size:2rem;font-weight:700;color:#5A9E68;">${{ total_set_aside | money }}</div>
|
||||
</div>
|
||||
<div class="card-wide" style="text-align:center;padding:1.25rem;">
|
||||
{% if over_saved > 0 %}
|
||||
<div style="font-size:0.8rem;color:var(--text-light);margin-bottom:0.25rem;">Over-Saved</div>
|
||||
<div style="font-size:2rem;font-weight:700;color:#5A9E68;">${{ over_saved | money }}</div>
|
||||
<div style="font-size:0.78rem;color:var(--text-light);">nice work!</div>
|
||||
{% else %}
|
||||
<div style="font-size:0.8rem;color:var(--text-light);margin-bottom:0.25rem;">Still Need to Set Aside</div>
|
||||
<div style="font-size:2rem;font-weight:700;color:{% if still_needed > 500 %}#c07070{% else %}#d4a843{% endif %};">${{ still_needed | money }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
{% if estimate.total > 0 %}
|
||||
{% set pct = [(total_set_aside / estimate.total * 100), 100] | min | int %}
|
||||
<div style="background:var(--warm-white);border:1px solid var(--border);border-radius:var(--radius);padding:1rem 1.25rem;margin-bottom:1.5rem;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.85rem;margin-bottom:0.5rem;">
|
||||
<span>Tax savings progress</span>
|
||||
<span style="font-weight:600;">{{ pct }}% saved</span>
|
||||
</div>
|
||||
<div style="background:#EDE8E2;border-radius:6px;height:12px;">
|
||||
<div style="background:{% if pct >= 100 %}#5A9E68{% elif pct >= 60 %}#d4a843{% else %}#c07070{% endif %};border-radius:6px;height:12px;width:{{ pct }}%;transition:width 0.3s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Breakdown -->
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.25rem;margin-bottom:1.5rem;">
|
||||
|
||||
<!-- Tax breakdown -->
|
||||
<div class="budget-section">
|
||||
<div class="budget-section-header" style="border-left-color:#c07070;">
|
||||
<h3>How We Got There</h3>
|
||||
</div>
|
||||
<table class="budget-table">
|
||||
<tbody>
|
||||
<tr style="background:var(--cream);">
|
||||
<td colspan="2" style="font-size:0.78rem;color:var(--text-mid);padding:0.4rem 0.5rem;">
|
||||
<strong>Self-employment income</strong> (Bonna + Car Flipping): ${{ ytd_se | money }} ·
|
||||
<strong>W-2 income</strong> (Tony / Auto King): ${{ ytd_w2 | money }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Self-Employment Tax <span class="muted" style="font-size:0.78rem;">(SS + Medicare on SE income only)</span></td>
|
||||
<td class="amount">${{ estimate.se_tax | money }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Federal Income Tax <span class="muted" style="font-size:0.78rem;">(~{{ rates.federal }}% after $30k MFJ deduction)</span></td>
|
||||
<td class="amount">${{ estimate.federal | money }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Wisconsin State Tax <span class="muted" style="font-size:0.78rem;">(~{{ rates.wi_state }}% after $30k MFJ deduction)</span></td>
|
||||
<td class="amount">${{ estimate.wi_state | money }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td>Total Estimated</td>
|
||||
<td class="amount">${{ estimate.total | money }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<details style="margin-top:0.75rem;">
|
||||
<summary style="font-size:0.8rem;color:var(--text-light);cursor:pointer;">Adjust tax rates</summary>
|
||||
<form method="POST" action="{{ url_for('taxes_rates') }}" style="margin-top:0.75rem;display:grid;gap:0.5rem;">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<label style="font-size:0.82rem;width:160px;">SE Tax %</label>
|
||||
<input type="number" name="se_tax" step="0.1" value="{{ rates.se_tax }}" class="budget-input no-dollar" style="width:80px;">
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<label style="font-size:0.82rem;width:160px;">Federal %</label>
|
||||
<input type="number" name="federal" step="0.1" value="{{ rates.federal }}" class="budget-input no-dollar" style="width:80px;">
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<label style="font-size:0.82rem;width:160px;">WI State %</label>
|
||||
<input type="number" name="wi_state" step="0.1" value="{{ rates.wi_state }}" class="budget-input no-dollar" style="width:80px;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-ghost" style="width:fit-content;font-size:0.82rem;">Update Rates</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Quarterly breakdown -->
|
||||
<div class="budget-section">
|
||||
<div class="budget-section-header" style="border-left-color:#D4A96A;">
|
||||
<h3>Quarterly Estimates</h3>
|
||||
</div>
|
||||
<table class="budget-table">
|
||||
<thead><tr><th>Quarter</th><th>Income</th><th>Est. Tax</th><th>Due</th></tr></thead>
|
||||
<tbody>
|
||||
{% for q in quarters %}
|
||||
<tr{% if q.income == 0 %} style="opacity:0.5;"{% endif %}>
|
||||
<td style="font-size:0.85rem;">{{ q.label }}</td>
|
||||
<td class="amount">${{ q.income | money }}</td>
|
||||
<td class="amount">${{ q.estimate.total | money }}</td>
|
||||
<td style="font-size:0.78rem;color:var(--text-light);">{{ q.due_date }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="font-size:0.75rem;color:var(--text-light);margin-top:0.5rem;padding:0 0.25rem;">These are federal estimated payment deadlines. WI follows the same schedule.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Set aside log -->
|
||||
<div class="budget-section">
|
||||
<div class="budget-section-header" style="border-left-color:#5A9E68;">
|
||||
<h3>Tax Savings Log</h3>
|
||||
</div>
|
||||
<details class="add-debt-details" style="margin-bottom:1rem;">
|
||||
<summary class="btn btn-primary" style="display:inline-block;cursor:pointer;">+ Log Money Set Aside</summary>
|
||||
<div style="padding:1rem 0;">
|
||||
<form method="POST" action="{{ url_for('taxes_set_aside') }}" style="display:flex;gap:0.75rem;flex-wrap:wrap;align-items:flex-end;">
|
||||
<div>
|
||||
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Date</label>
|
||||
<input type="date" name="date" value="{{ today.strftime('%Y-%m-%d') }}" class="budget-input" required>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Amount</label>
|
||||
<input type="number" name="amount" step="0.01" min="0" class="budget-input" style="width:130px;" placeholder="0.00" required>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Notes</label>
|
||||
<input type="text" name="notes" class="budget-input" style="width:200px;" placeholder="e.g. moved to savings account">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{% if set_aside_log %}
|
||||
<table class="budget-table">
|
||||
<thead><tr><th>Date</th><th>Amount</th><th>Notes</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for e in set_aside_log | sort(attribute='date', reverse=True) %}
|
||||
<tr>
|
||||
<td>{{ e.date }}</td>
|
||||
<td class="amount" style="color:#5A9E68;">${{ e.amount | money }}</td>
|
||||
<td class="muted">{{ e.notes }}</td>
|
||||
<td>
|
||||
<form method="POST" action="{{ url_for('taxes_set_aside_delete', entry_id=e.id) }}">
|
||||
<button type="submit" class="edit-link" style="color:#c07070;background:none;border:none;cursor:pointer;">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td>Total Set Aside</td>
|
||||
<td class="amount" style="color:#5A9E68;">${{ total_set_aside | money }}</td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted" style="padding:0.5rem 0;">Nothing logged yet. Every time you move money to your tax savings account, record it here.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div style="background:#fff8e6;border:1px solid #e8d49a;border-radius:var(--radius);padding:1rem 1.25rem;font-size:0.83rem;color:var(--text-mid);margin-top:1rem;">
|
||||
<strong>Heads up:</strong> These are estimates based on flat rates and may not perfectly match your actual tax bill. We recommend setting aside the estimated amount and working with a tax professional when it's time to file. The SE tax calculation uses the IRS method (92.35% of net income) and deducts half of SE tax before calculating income tax, which is how the IRS does it.
|
||||
</div>
|
||||
{% endblock %}
|
||||
168
templates/transactions.html
Normal file
168
templates/transactions.html
Normal file
@@ -0,0 +1,168 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>All Transactions</h1>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert-banner {{ 'alert-success' if category == 'success' else 'alert-error' }}" style="margin-bottom:1rem;">
|
||||
{{ message }}
|
||||
{% if category == 'success' %}
|
||||
<a href="{{ url_for('review') }}" class="btn btn-primary">Review Now</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Import -->
|
||||
<details class="add-debt-details" style="margin-bottom:1.25rem;">
|
||||
<summary class="btn" style="display:inline-block;cursor:pointer;background:#7A9E7E;color:#fff;border:none;">⬆️ Import Statement</summary>
|
||||
<div class="card-wide" style="margin-top:0.75rem;">
|
||||
<p class="helper-text" style="margin-bottom:0.75rem;">Supports CSV exports from your bank or credit union. PDF import only works if the PDF has selectable text — scanned statements won't work.</p>
|
||||
<form method="POST" action="{{ url_for('import_transactions') }}" enctype="multipart/form-data" class="import-form">
|
||||
<div class="form-group">
|
||||
<label>Account Name</label>
|
||||
<input type="text" name="account" placeholder="e.g. WestConsin Checking, Discover" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Statement File</label>
|
||||
<div class="file-drop-area">
|
||||
<input type="file" name="statement" accept=".pdf,.csv" required>
|
||||
<p>Click to choose a file, or drag and drop here</p>
|
||||
<p class="file-types">PDF or CSV</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Import</button>
|
||||
</form>
|
||||
<details style="margin-top:1rem;">
|
||||
<summary class="muted" style="cursor:pointer;font-size:0.85rem;">How to export your statements ▾</summary>
|
||||
<ul style="margin-top:0.5rem;font-size:0.83rem;line-height:1.7;padding-left:1.25rem;">
|
||||
<li><strong>WestConsin Credit Union:</strong> Log in → select your account → Download/Export → choose CSV → pick date range</li>
|
||||
<li><strong>Most other banks:</strong> Log in → Account Details or Transaction History → Export / Download → choose CSV</li>
|
||||
<li><strong>Affirm:</strong> affirm.com → Account → Payment History → Export</li>
|
||||
<li><strong>Klarna:</strong> klarna.com → Purchases → Export CSV</li>
|
||||
<li><strong>Afterpay:</strong> afterpay.com → Orders → Export</li>
|
||||
</ul>
|
||||
<p class="helper-text" style="margin-top:0.5rem;">If a CSV imports 0 transactions, let Claude know — the column format varies by bank and may need a quick fix.</p>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{% if spending_by_cat %}
|
||||
<details class="add-debt-details" style="margin-bottom:1.25rem;">
|
||||
<summary class="btn btn-ghost" style="display:inline-block;cursor:pointer;">📊 {{ week_label }} Spending</summary>
|
||||
<div style="margin-top:0.75rem;background:#fff;border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;">
|
||||
<table class="budget-table">
|
||||
<thead>
|
||||
<tr><th>Category</th><th>Spent</th><th>Budgeted</th><th>Remaining</th><th style="width:25%;"></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for cat, amt in spending_by_cat.items() %}
|
||||
{% set budgeted = budgeted_by_cat.get(cat, 0) %}
|
||||
{% set remaining = budgeted - amt %}
|
||||
{% set over = budgeted > 0 and amt > budgeted %}
|
||||
{% set pct = [(amt / budgeted * 100)|int, 100]|min if budgeted > 0 else 100 %}
|
||||
<tr{% if over %} style="background:#fdf0f0;"{% endif %}>
|
||||
<td><span class="tag" style="background:{{ colors.get(cat,'#ddd') }};">{{ cat }}</span></td>
|
||||
<td class="amount">${{ amt | money }}</td>
|
||||
<td class="amount muted">{% if budgeted %}${{ budgeted | money }}{% else %}<span style="color:#ccc;">—</span>{% endif %}</td>
|
||||
<td class="amount {% if over %}{% else %}{% endif %}">
|
||||
{% if budgeted %}
|
||||
{% if over %}
|
||||
<span style="color:#c07070;">-${{ (amt - budgeted) | money }} over</span>
|
||||
{% else %}
|
||||
<span style="color:#5A9E68;">${{ remaining | money }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div style="background:#EDE8E2;border-radius:4px;height:7px;">
|
||||
<div style="background:{{ '#c07070' if over else colors.get(cat,'#bbb') }};border-radius:4px;height:7px;width:{{ pct }}%;"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td>Total</td>
|
||||
<td class="amount">${{ spending_by_cat.values() | sum | money }}</td>
|
||||
<td class="amount muted">{% if budgeted_by_cat %}${{ budgeted_by_cat.values() | sum | money }}{% endif %}</td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<form method="GET" style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;margin-bottom:1.25rem;background:var(--warm-white);padding:0.85rem 1rem;border-radius:var(--radius);border:1px solid var(--border);">
|
||||
<div style="flex:1;min-width:180px;">
|
||||
<label style="display:block;font-size:0.8rem;margin-bottom:0.2rem;">Search</label>
|
||||
<input type="text" name="search" value="{{ filter_search }}" class="budget-input" style="width:100%;" placeholder="merchant, notes…">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:0.8rem;margin-bottom:0.2rem;">From</label>
|
||||
<input type="date" name="from_date" value="{{ filter_from }}" class="budget-input">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:0.8rem;margin-bottom:0.2rem;">To</label>
|
||||
<input type="date" name="to_date" value="{{ filter_to }}" class="budget-input">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:0.8rem;margin-bottom:0.2rem;">Category</label>
|
||||
<select name="category" class="budget-input">
|
||||
<option value="">— all —</option>
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat }}" {% if filter_cat == cat %}selected{% endif %}>{{ cat }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="height:36px;">Search</button>
|
||||
{% if filter_from or filter_to or filter_cat or filter_search %}
|
||||
<a href="{{ url_for('transactions_view') }}" class="btn btn-ghost" style="height:36px;line-height:36px;padding-top:0;padding-bottom:0;">Clear</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
{% if transactions %}
|
||||
<table class="txn-table txn-table-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Description</th>
|
||||
<th>Account</th>
|
||||
<th>Amount</th>
|
||||
<th>Category</th>
|
||||
<th>Notes</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in transactions %}
|
||||
<tr>
|
||||
<td>{{ t.date }}</td>
|
||||
<td>{{ t.description }}</td>
|
||||
<td class="muted">{{ t.account }}</td>
|
||||
<td class="amount">${{ t.amount | money }}</td>
|
||||
<td>
|
||||
{% if t.category %}
|
||||
<span class="tag" style="background: {{ colors.get(t.category, '#ddd') }};">
|
||||
{{ t.category }}{% if t.subcategory %} › {{ t.subcategory }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="muted">{{ t.notes }}</td>
|
||||
<td><a href="{{ url_for('edit_transaction', txn_id=t.id) }}" class="edit-link">Edit</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>No transactions yet. <a href="{{ url_for('import_transactions') }}">Import a statement</a> to get started.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
484
templates/weekly.html
Normal file
484
templates/weekly.html
Normal file
@@ -0,0 +1,484 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>{{ month_name }} {{ year }} — Weekly Overview</h1>
|
||||
<div class="month-nav">
|
||||
<a href="{{ url_for('weekly_overview', year=prev_year, month=prev_month) }}" class="btn btn-ghost">‹ Prev</a>
|
||||
<a href="{{ url_for('weekly_overview', year=next_year, month=next_month) }}" class="btn btn-ghost">Next ›</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="week-tabs">
|
||||
{% for w in weeks %}
|
||||
<button class="week-tab" onclick="showWeek({{ w.num }})" id="tab-{{ w.num }}">
|
||||
Week {{ w.num }}<br><span>{{ w.start_label }} – {{ w.end_label }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% for w in weeks %}
|
||||
<div class="week-panel" id="week-{{ w.num }}">
|
||||
|
||||
<!-- Week summary -->
|
||||
{% if w.summary %}
|
||||
<div style="background:var(--warm-white);border:1px solid var(--border);border-radius:var(--radius);padding:0.85rem 1.1rem;margin-bottom:1rem;display:flex;flex-wrap:wrap;gap:0.5rem 1.5rem;">
|
||||
<span style="font-size:0.75rem;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-light);width:100%;">How's this week looking?</span>
|
||||
{% for line in w.summary %}
|
||||
<span style="font-size:0.88rem;color:var(--text-dark);">{{ line | bold_md }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Starting Balance -->
|
||||
<div style="display:flex;align-items:center;gap:1rem;background:var(--warm-white);border:1px solid var(--border);border-radius:var(--radius);padding:0.75rem 1rem;margin-bottom:1rem;">
|
||||
<label style="font-weight:600;white-space:nowrap;">Checking Balance at Start of Week</label>
|
||||
<input type="number" step="0.01" class="budget-input" style="max-width:160px;"
|
||||
id="starting-balance-{{ w.num }}"
|
||||
value="{{ w.starting_balance }}"
|
||||
onchange="saveStartingBalance({{ year }}, {{ month }}, '{{ w.num }}', this.value)"
|
||||
placeholder="0.00">
|
||||
<span style="font-size:0.82rem;color:var(--text-light);">Enter what's in checking when the week begins</span>
|
||||
</div>
|
||||
|
||||
<!-- 1. Income -->
|
||||
<div class="budget-section">
|
||||
<div class="budget-section-header weekly-collapsible" onclick="toggleWeeklySection(this)" style="border-left-color: {{ colors.get('Income', '#A0C5A8') }};cursor:pointer;">
|
||||
<h3><span class="weekly-arrow">▾</span> Income</h3>
|
||||
</div>
|
||||
<div class="weekly-section-body">
|
||||
<table class="budget-table">
|
||||
<thead><tr><th>Source</th><th>Budgeted</th><th>Actual</th></tr></thead>
|
||||
<tbody>
|
||||
{% for src in w.income_sources %}
|
||||
{% set inc = w.income.get(src, {}) %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ src }}
|
||||
{% if src == 'Auto King' %}
|
||||
<span class="income-friday-tag">Fridays</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><input type="number" step="0.01" class="budget-input"
|
||||
value="{{ inc.get('budgeted', '') }}"
|
||||
onchange="saveField({{ year }}, {{ month }}, '{{ w.num }}', 'income', '{{ src }}', 'budgeted', this.value)"
|
||||
placeholder="expected"></td>
|
||||
<td>
|
||||
<div class="income-actual-wrap">
|
||||
<input type="number" step="0.01" class="budget-input income-actual-input"
|
||||
id="income-actual-{{ w.num }}-{{ src | replace(' ','_') }}"
|
||||
value="{{ inc.get('actual', '') }}"
|
||||
onchange="logIncome({{ year }}, {{ month }}, '{{ w.num }}', '{{ src }}', this.value)"
|
||||
placeholder="enter when received">
|
||||
{% if inc.get('actual') %}
|
||||
<span class="income-logged-badge">✓ logged</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td>Total</td>
|
||||
<td class="amount">$<span id="w{{ w.num }}-income-budget-total">{{ (w.income.values() | map(attribute='budgeted') | select | map('float') | sum if w.income else 0) | money }}</span></td>
|
||||
<td class="amount">$<span id="w{{ w.num }}-income-actual-total">{{ w.income_total | money }}</span></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Bills -->
|
||||
<div class="budget-section">
|
||||
<div class="budget-section-header weekly-collapsible" onclick="toggleWeeklySection(this)" style="border-left-color: {{ colors.get('Debt', '#C5A0A0') }};cursor:pointer;">
|
||||
<h3><span class="weekly-arrow">▾</span> Bills Due This Week</h3>
|
||||
<a href="{{ url_for('bills_list') }}" class="edit-link" onclick="event.stopPropagation()">manage bills</a>
|
||||
</div>
|
||||
<div class="weekly-section-body">
|
||||
{% if w.bills %}
|
||||
<table class="budget-table">
|
||||
<thead><tr><th>Bill</th><th>Due</th><th>Amount</th><th>Category</th></tr></thead>
|
||||
<tbody>
|
||||
{% for b in w.bills %}
|
||||
<tr>
|
||||
<td>{{ b.name }}</td>
|
||||
<td class="muted">{{ b.due_date.strftime('%b %-d') if b.due_date else '' }}</td>
|
||||
<td class="amount">{% if b.amount %}${{ b.amount | money }}{% else %}<span class="muted">—</span>{% endif %}</td>
|
||||
<td>
|
||||
{% if b.category %}
|
||||
<span class="tag" style="background: {{ colors.get(b.category, '#ddd') }};">{{ b.category }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="2">Bills Total</td>
|
||||
<td class="amount">${{ w.bills_total | money }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted" style="padding: 0.75rem 0;">No bills due this week.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Budget Categories -->
|
||||
<div class="budget-section">
|
||||
<div class="budget-section-header weekly-collapsible" onclick="toggleWeeklySection(this)" style="border-left-color: {{ colors.get('Food', '#A8C5A0') }};cursor:pointer;">
|
||||
<h3><span class="weekly-arrow">▾</span> Budget Categories</h3>
|
||||
</div>
|
||||
<div class="weekly-section-body">
|
||||
{% set skip = ['Income', 'Debt', 'Transfer'] %}
|
||||
{% set total_budgeted = namespace(v=0) %}
|
||||
{% set total_actual = namespace(v=0) %}
|
||||
<table class="budget-table">
|
||||
<thead><tr><th>Category</th><th>Budgeted</th><th>Spent</th><th>Left to Spend</th></tr></thead>
|
||||
<tbody>
|
||||
{% for cat in categories if cat not in skip %}
|
||||
{% set cb = w.cat_budget.get(cat, {}) %}
|
||||
{% set actual = w.cat_actuals.get(cat, 0) %}
|
||||
{% set budgeted = cb.get('budgeted', '') | float %}
|
||||
{% set left = budgeted - actual %}
|
||||
{% set over = budgeted > 0 and actual > budgeted %}
|
||||
{% set total_budgeted.v = total_budgeted.v + budgeted %}
|
||||
{% set total_actual.v = total_actual.v + actual %}
|
||||
<tr{% if over %} style="background:#fdf0f0;"{% endif %}>
|
||||
<td>
|
||||
<span class="tag" style="background: {{ colors.get(cat, '#ddd') }};">{{ cat }}</span>
|
||||
</td>
|
||||
<td><input type="number" step="0.01" class="budget-input"
|
||||
value="{{ cb.get('budgeted', '') }}"
|
||||
onchange="saveField({{ year }}, {{ month }}, '{{ w.num }}', 'categories', '{{ cat }}', 'budgeted', this.value)"
|
||||
placeholder="0.00"></td>
|
||||
<td class="amount{% if actual == 0 %} muted{% endif %}">
|
||||
${{ actual | money }}
|
||||
</td>
|
||||
<td class="amount">
|
||||
{% if budgeted %}
|
||||
{% if over %}
|
||||
<span style="color:#c07070;">-${{ (actual - budgeted) | money }}</span>
|
||||
{% else %}
|
||||
<span style="color:#5A9E68;">${{ left | money }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
{% set total_left = total_budgeted.v - total_actual.v %}
|
||||
<tr class="total-row">
|
||||
<td>Totals</td>
|
||||
<td class="amount">${{ total_budgeted.v | money }}</td>
|
||||
<td class="amount">${{ total_actual.v | money }}</td>
|
||||
<td class="amount">
|
||||
{% if total_budgeted.v > 0 %}
|
||||
{% if total_left < 0 %}
|
||||
<span style="color:#c07070;font-weight:700;">-${{ total_left | abs | money }} over</span>
|
||||
{% else %}
|
||||
<span style="color:#5A9E68;font-weight:700;">${{ total_left | money }} left</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Sinking Funds -->
|
||||
<div class="budget-section">
|
||||
<div class="budget-section-header weekly-collapsible weekly-collapsed" onclick="toggleWeeklySection(this)" style="border-left-color: {{ colors.get('Travel', '#C5BBA0') }};cursor:pointer;">
|
||||
<h3><span class="weekly-arrow">▸</span> Sinking Funds</h3>
|
||||
<a href="{{ url_for('sinking_view') }}" class="edit-link" onclick="event.stopPropagation()">manage funds</a>
|
||||
</div>
|
||||
<div class="weekly-section-body" style="display:none;">
|
||||
<table class="budget-table">
|
||||
<thead><tr><th>Fund</th><th>Current Balance</th><th>Add This Week</th></tr></thead>
|
||||
<tbody>
|
||||
{% for f in sinking_funds %}
|
||||
{% set wkey = 'sinking_' ~ w.num ~ '_' ~ f.name %}
|
||||
{% set sinking_entry = w.sinking.get(f.name, {}) %}
|
||||
{% set contributed = sinking_entry.get('contributed', '') %}
|
||||
{% set from_txn = sinking_entry.get('from_transaction') %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="tag" style="background: {{ fund_colors.get(f.name, '#C5BBA0') }};">{{ f.name }}</span>
|
||||
</td>
|
||||
<td class="amount">${{ (f.balance or 0) | money }}</td>
|
||||
<td>
|
||||
<div class="income-actual-wrap">
|
||||
<input type="number" step="0.01" class="budget-input"
|
||||
value="{{ contributed }}"
|
||||
onchange="contributeSinking('{{ f.name }}', this.value, {{ year }}, {{ month }}, '{{ w.num }}')"
|
||||
placeholder="0.00"
|
||||
{% if from_txn %}title="Logged automatically from a categorized transaction"{% endif %}>
|
||||
{% if from_txn %}
|
||||
<span class="income-logged-badge" title="This was recorded when you categorized a transaction">✓ from transaction</span>
|
||||
{% elif contributed %}
|
||||
<span class="income-logged-badge">✓ added</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="2">Total Contributed This Week</td>
|
||||
<td class="amount">${{ (w.sinking.values() | selectattr('contributed') | map(attribute='contributed') | map('float') | sum) | money }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5. Extra Debt Payments -->
|
||||
<div class="budget-section">
|
||||
<div class="budget-section-header weekly-collapsible weekly-collapsed" onclick="toggleWeeklySection(this)" style="border-left-color: {{ colors.get('Debt', '#C5A0A0') }};cursor:pointer;">
|
||||
<h3><span class="weekly-arrow">▸</span> Extra Debt Payments</h3>
|
||||
<a href="{{ url_for('debts_view') }}" class="edit-link" onclick="event.stopPropagation()">manage debts</a>
|
||||
</div>
|
||||
<div class="weekly-section-body" style="display:none;">
|
||||
{% if active_debts %}
|
||||
<table class="budget-table">
|
||||
<thead><tr><th>Debt</th><th>Owner</th><th>Minimum</th><th>Extra Payment</th></tr></thead>
|
||||
<tbody>
|
||||
{% for d in active_debts %}
|
||||
{% set extra_paid = w.extra_debt.get(d.id, {}).get('extra', '') %}
|
||||
<tr>
|
||||
<td>{{ d.name }}</td>
|
||||
<td><span class="owner-tag" style="background:{{ owner_colors.get(d.owner,'#ddd') }};font-size:0.72rem;">{{ d.owner }}</span></td>
|
||||
<td class="amount muted">${{ (d.minimum or 0) | money }}</td>
|
||||
<td>
|
||||
<div class="income-actual-wrap">
|
||||
<input type="number" step="0.01" class="budget-input"
|
||||
value="{{ extra_paid }}"
|
||||
onchange="logExtraDebt('{{ d.id }}', this.value, {{ year }}, {{ month }}, '{{ w.num }}')"
|
||||
placeholder="0.00">
|
||||
{% if extra_paid %}
|
||||
<span class="income-logged-badge">✓ logged</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="3">Total Extra This Week</td>
|
||||
<td class="amount accent">${{ (w.extra_debt.values() | map(attribute='extra') | select | map('float') | sum) | money }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted" style="padding:0.75rem 0;">No debts added yet. <a href="{{ url_for('debts_view') }}">Add debts</a> to track extra payments here.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 6. Transactions -->
|
||||
<div class="budget-section">
|
||||
<div class="budget-section-header weekly-collapsible weekly-collapsed" onclick="toggleWeeklySection(this)" style="border-left-color:#D4A96A;cursor:pointer;">
|
||||
<h3><span class="weekly-arrow">▸</span> Transactions This Week{% if w.transactions %} <span style="font-weight:400;font-size:0.82rem;color:var(--text-light);">({{ w.transactions | length }})</span>{% endif %}</h3>
|
||||
</div>
|
||||
<div class="weekly-section-body" style="display:none;">
|
||||
|
||||
<!-- Inline import -->
|
||||
<details style="margin-bottom:1rem;">
|
||||
<summary class="btn" style="display:inline-block;cursor:pointer;font-size:0.85rem;background:#7A9E7E;color:#fff;border:none;">⬆️ Import Statement</summary>
|
||||
<div style="margin-top:0.75rem;background:var(--cream);border:1px solid var(--border);border-radius:var(--radius);padding:1rem;">
|
||||
<form method="POST" action="{{ url_for('import_transactions') }}" enctype="multipart/form-data">
|
||||
<input type="hidden" name="redirect_to" value="weekly">
|
||||
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;align-items:flex-end;">
|
||||
<div>
|
||||
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Account Name</label>
|
||||
<input type="text" name="account" placeholder="e.g. WestConsin Checking" class="budget-input" style="width:200px;" required>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Statement File (PDF or CSV)</label>
|
||||
<input type="file" name="statement" accept=".pdf,.csv" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="font-size:0.85rem;">Import</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{% if w.transactions %}
|
||||
{% set txns_by_cat = {} %}
|
||||
{% for t in w.transactions %}
|
||||
{% set cat = t.category or 'Uncategorized' %}
|
||||
{% if cat not in txns_by_cat %}{% set _ = txns_by_cat.update({cat: []}) %}{% endif %}
|
||||
{% set _ = txns_by_cat[cat].append(t) %}
|
||||
{% endfor %}
|
||||
<table class="budget-table">
|
||||
<thead><tr><th>Date</th><th>Description</th><th>Category</th><th>Amount</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in w.transactions | sort(attribute='date', reverse=True) %}
|
||||
<tr>
|
||||
<td class="muted" style="white-space:nowrap;font-size:0.82rem;">{{ t.date }}</td>
|
||||
<td style="font-size:0.85rem;">{{ t.description }}</td>
|
||||
<td>
|
||||
{% if t.category %}
|
||||
<span class="tag" style="background:{{ colors.get(t.category,'#ddd') }};font-size:0.75rem;">{{ t.category }}{% if t.subcategory %} › {{ t.subcategory }}{% endif %}</span>
|
||||
{% else %}
|
||||
<span class="muted" style="font-size:0.78rem;">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="amount" style="font-size:0.85rem;">${{ t.amount | money }}</td>
|
||||
<td><a href="{{ url_for('edit_transaction', txn_id=t.id) }}" class="edit-link" style="font-size:0.78rem;">Edit</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="3">Total</td>
|
||||
<td class="amount">${{ (w.transactions | map(attribute='amount') | map('float') | sum) | money }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted" style="padding:0.5rem 0;">No reviewed transactions for this week yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leftover -->
|
||||
<div class="leftover-bar {% if w.leftover < 0 %}leftover-negative{% endif %}" id="leftover-bar-{{ w.num }}">
|
||||
<div>
|
||||
<div style="font-weight:600;">Week {{ w.num }} — What's Left</div>
|
||||
<div style="font-size:0.78rem;color:var(--text-light);margin-top:0.2rem;line-height:1.7;">
|
||||
$<span id="lo-start-{{ w.num }}">{{ w.starting_balance | money }}</span> starting
|
||||
+ $<span id="lo-income-{{ w.num }}">{{ w.income_total | money }}</span> income
|
||||
− $<span id="lo-spending-{{ w.num }}">{{ w.spending_total | money }}</span> spending
|
||||
− $<span id="lo-sinking-{{ w.num }}">{{ w.sinking_total | money }}</span> savings
|
||||
− $<span id="lo-debt-{{ w.num }}">{{ w.extra_debt_total | money }}</span> extra debt
|
||||
</div>
|
||||
</div>
|
||||
<span class="leftover-amount" id="leftover-amount-{{ w.num }}"
|
||||
data-spending="{{ w.spending_total }}"
|
||||
data-sinking="{{ w.sinking_total }}"
|
||||
data-debt="{{ w.extra_debt_total }}"
|
||||
data-start="{{ w.starting_balance }}"
|
||||
>${{ w.leftover | money }}</span>
|
||||
</div>
|
||||
|
||||
</div><!-- end week-panel -->
|
||||
{% endfor %}
|
||||
|
||||
<script>
|
||||
function showWeek(num) {
|
||||
document.querySelectorAll('.week-panel').forEach(p => p.classList.remove('active'));
|
||||
document.querySelectorAll('.week-tab').forEach(t => t.classList.remove('active'));
|
||||
document.getElementById('week-' + num).classList.add('active');
|
||||
document.getElementById('tab-' + num).classList.add('active');
|
||||
}
|
||||
|
||||
function saveField(year, month, week, field, key, subkey, value) {
|
||||
fetch('/weekly/save', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({year, month, week, field, key, subkey, value})
|
||||
}).then(() => updateLeftover(week));
|
||||
}
|
||||
|
||||
function logIncome(year, month, week, source, value) {
|
||||
fetch('/weekly/save', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({year, month, week, field: 'income', key: source, subkey: 'actual', value})
|
||||
}).then(r => r.json()).then(data => {
|
||||
updateLeftover(week);
|
||||
// show ✓ badge
|
||||
const id = 'income-actual-' + week + '-' + source.replace(/ /g,'_');
|
||||
const input = document.getElementById(id);
|
||||
if (input && parseFloat(value) > 0) {
|
||||
let badge = input.parentElement.querySelector('.income-logged-badge');
|
||||
if (!badge) {
|
||||
badge = document.createElement('span');
|
||||
badge.className = 'income-logged-badge';
|
||||
badge.textContent = '✓ logged';
|
||||
input.parentElement.appendChild(badge);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateLeftover(week) {
|
||||
// Recalculate income total from inputs
|
||||
let incomeTotal = 0;
|
||||
document.querySelectorAll(`#week-${week} .income-actual-input`).forEach(el => {
|
||||
incomeTotal += parseFloat(el.value) || 0;
|
||||
});
|
||||
const totalEl = document.getElementById(`w${week}-income-actual-total`);
|
||||
if (totalEl) totalEl.textContent = incomeTotal.toFixed(2);
|
||||
|
||||
const loIncomeEl = document.getElementById(`lo-income-${week}`);
|
||||
if (loIncomeEl) loIncomeEl.textContent = incomeTotal.toFixed(2);
|
||||
|
||||
// Update leftover bar using all deductions from data attributes
|
||||
const leftoverEl = document.getElementById(`leftover-amount-${week}`);
|
||||
if (leftoverEl) {
|
||||
const startBal = parseFloat(document.getElementById(`starting-balance-${week}`)?.value) || 0;
|
||||
const spending = parseFloat(leftoverEl.dataset.spending) || 0;
|
||||
const sinking = parseFloat(leftoverEl.dataset.sinking) || 0;
|
||||
const debt = parseFloat(leftoverEl.dataset.debt) || 0;
|
||||
const leftover = startBal + incomeTotal - spending - sinking - debt;
|
||||
leftoverEl.textContent = '$' + leftover.toFixed(2);
|
||||
const bar = document.getElementById(`leftover-bar-${week}`);
|
||||
if (bar) bar.classList.toggle('leftover-negative', leftover < 0);
|
||||
}
|
||||
}
|
||||
|
||||
function logExtraDebt(debtId, value, year, month, week) {
|
||||
fetch('/weekly/extra-debt', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({debt_id: debtId, amount: value, year, month, week})
|
||||
});
|
||||
}
|
||||
|
||||
function saveStartingBalance(year, month, week, value) {
|
||||
fetch('/weekly/save', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({year, month, week, field: 'starting_balance', key: 'amount', subkey: 'value', value})
|
||||
}).then(() => {
|
||||
const loStart = document.getElementById(`lo-start-${week}`);
|
||||
if (loStart) loStart.textContent = parseFloat(value || 0).toFixed(2);
|
||||
updateLeftover(week);
|
||||
});
|
||||
}
|
||||
|
||||
function contributeSinking(fundName, value, year, month, week) {
|
||||
fetch('/sinking/contribute-weekly', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({name: fundName, amount: value, year, month, week})
|
||||
}).then(r => r.json()).then(data => {
|
||||
// update the balance cell
|
||||
document.querySelectorAll('.sinking-weekly-balance-' + fundName.replace(/ /g,'_'))
|
||||
.forEach(el => el.textContent = '$' + (data.new_balance || 0).toFixed(2));
|
||||
});
|
||||
}
|
||||
|
||||
function toggleWeeklySection(header) {
|
||||
const body = header.nextElementSibling;
|
||||
const arrow = header.querySelector('.weekly-arrow');
|
||||
const collapsed = body.style.display === 'none';
|
||||
body.style.display = collapsed ? '' : 'none';
|
||||
arrow.textContent = collapsed ? '▾' : '▸';
|
||||
}
|
||||
|
||||
// Show current week by default
|
||||
showWeek({{ current_week }});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user