Files
Moon-Household-Budget/templates/weekly.html
2026-06-06 19:12:43 -05:00

485 lines
21 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% 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
&minus; $<span id="lo-spending-{{ w.num }}">{{ w.spending_total | money }}</span> spending
&minus; $<span id="lo-sinking-{{ w.num }}">{{ w.sinking_total | money }}</span> savings
&minus; $<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 %}