233 lines
11 KiB
HTML
233 lines
11 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Budgets{% endblock %}
|
|
{% block page_title %}Budgets{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
.progress-bar-budget { height: 8px; border-radius: 4px; background: #f1f5f9; overflow: hidden; }
|
|
.progress-bar-fill { height: 100%; border-radius: 4px; transition: width .6s ease; }
|
|
.budget-over { background: #fee2e2; border-color: #fca5a5 !important; }
|
|
{% endblock %}
|
|
|
|
{% block topbar_actions %}
|
|
<a href="{{ url_for('budgets.new', month=month) }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i>Add Budget</a>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<!-- Month nav -->
|
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
|
<a href="{{ url_for('budgets.index', month=prev_month) }}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-chevron-left"></i></a>
|
|
<h5 class="mb-0 fw-semibold">{{ month_label }}</h5>
|
|
<a href="{{ url_for('budgets.index', month=next_month) }}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-chevron-right"></i></a>
|
|
</div>
|
|
|
|
<!-- Overall summary -->
|
|
{% if total_budget > 0 %}
|
|
<div class="pcard mb-4">
|
|
<div class="row g-3 align-items-center">
|
|
<div class="col-12 col-md-8">
|
|
<div class="d-flex justify-content-between mb-2">
|
|
<span style="font-size:13px;font-weight:500;">Total Budget</span>
|
|
<span class="mono" style="font-size:13px;">
|
|
<span class="{% if total_spent > total_budget %}text-expense{% else %}text-income{% endif %}">{{ total_spent | currency }}</span>
|
|
<span class="text-muted"> / {{ total_budget | currency }}</span>
|
|
</span>
|
|
</div>
|
|
<div class="progress-bar-budget">
|
|
<div class="progress-bar-fill" style="width:{{ overall_pct }}%;background:{% if overall_pct >= 100 %}#ef4444{% elif overall_pct >= 80 %}#f59e0b{% else %}#10b981{% endif %};"></div>
|
|
</div>
|
|
<div class="d-flex justify-content-between mt-1">
|
|
<small class="text-muted">{{ overall_pct }}% used</small>
|
|
<small class="{% if total_budget - total_spent >= 0 %}text-income{% else %}text-expense{% endif %}">
|
|
{{ (total_budget - total_spent) | currency }} {% if total_budget - total_spent >= 0 %}remaining{% else %}over{% endif %}
|
|
</small>
|
|
</div>
|
|
</div>
|
|
<div class="col-12 col-md-4 text-md-end">
|
|
<!-- Copy from prev month -->
|
|
<form method="POST" action="{{ url_for('budgets.copy_month') }}" class="d-inline">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
<input type="hidden" name="from_month" value="{{ prev_month }}">
|
|
<input type="hidden" name="to_month" value="{{ month }}">
|
|
<button type="submit" class="btn btn-sm btn-outline-secondary" style="font-size:12px;"
|
|
onclick="return confirm('Copy budgets from previous month?')">
|
|
<i class="bi bi-copy me-1"></i>Copy from {{ prev_month }}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Budget vs Actual chart -->
|
|
{% set chart_cats = summary | selectattr('has_budget') | list %}
|
|
{% if chart_cats %}
|
|
<div class="pcard mb-4">
|
|
<div style="font-size:13px;font-weight:600;margin-bottom:12px;">Budget vs Actual</div>
|
|
<div style="position:relative;height:{{ [[chart_cats|length * 42, 180]|max, 380]|min }}px;">
|
|
<canvas id="budgetChart"></canvas>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Budget Items -->
|
|
{% if summary %}
|
|
<div class="pcard p-0">
|
|
<table class="pfm-table">
|
|
<thead>
|
|
<tr>
|
|
<th style="padding-left:20px;">Category</th>
|
|
<th>Spent</th>
|
|
<th class="d-none d-md-table-cell">Limit</th>
|
|
<th>Progress</th>
|
|
<th class="d-none d-md-table-cell text-end">Remaining</th>
|
|
<th class="text-end" style="padding-right:20px;">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for item in summary %}
|
|
<tr class="{% if item.is_over %}budget-over{% endif %}">
|
|
<td style="padding-left:20px;">
|
|
<div class="d-flex align-items-center gap-2">
|
|
{% if item.category %}
|
|
<div style="width:28px;height:28px;border-radius:7px;background:{{ item.category.color }}22;color:{{ item.category.color }};display:flex;align-items:center;justify-content:center;font-size:14px;">
|
|
<i class="bi {{ item.category.icon }}"></i>
|
|
</div>
|
|
<span style="font-size:13px;font-weight:500;">{{ item.category.name }}</span>
|
|
{% endif %}
|
|
{% if not item.has_budget %}
|
|
<span style="font-size:10px;background:#fef9c3;color:#854d0e;border-radius:4px;padding:1px 5px;">no budget</span>
|
|
{% endif %}
|
|
{% if item.budget and item.budget.rollover_amount and item.budget.rollover_amount > 0 %}
|
|
<span style="font-size:10px;background:#dbeafe;color:#1e40af;border-radius:4px;padding:1px 5px;" title="Includes {{ item.budget.rollover_amount | currency }} rollover">+rollover</span>
|
|
{% endif %}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<span class="mono {% if item.is_over %}text-expense{% endif %}" style="font-size:13px;font-weight:500;">{{ item.spent | currency }}</span>
|
|
</td>
|
|
<td class="d-none d-md-table-cell">
|
|
{% if item.limit is not none %}
|
|
<span class="mono text-muted" style="font-size:12px;">{{ item.limit | currency }}</span>
|
|
{% else %}
|
|
<span class="text-muted" style="font-size:12px;">—</span>
|
|
{% endif %}
|
|
</td>
|
|
<td style="min-width:120px;">
|
|
{% if item.pct is not none %}
|
|
<div class="progress-bar-budget">
|
|
<div class="progress-bar-fill" style="width:{{ item.pct }}%;background:{% if item.pct >= 100 %}#ef4444{% elif item.pct >= 80 %}#f59e0b{% else %}#10b981{% endif %};"></div>
|
|
</div>
|
|
<small class="text-muted" style="font-size:10px;">{{ item.pct }}%</small>
|
|
{% else %}
|
|
<span class="text-muted" style="font-size:12px;">—</span>
|
|
{% endif %}
|
|
</td>
|
|
<td class="d-none d-md-table-cell text-end">
|
|
{% if item.remaining is not none %}
|
|
<span class="mono {% if item.remaining >= 0 %}text-income{% else %}text-expense{% endif %}" style="font-size:12px;">{{ item.remaining | currency }}</span>
|
|
{% endif %}
|
|
</td>
|
|
<td class="text-end" style="padding-right:20px;white-space:nowrap;">
|
|
{% if item.budget %}
|
|
<a href="{{ url_for('budgets.edit', id=item.budget.id) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;padding:2px 8px;">Edit</a>
|
|
<form method="POST" action="{{ url_for('budgets.delete', id=item.budget.id) }}" style="display:inline;" onsubmit="return confirm('Remove this budget?')">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
<button type="submit" class="btn btn-sm btn-outline-danger ms-1" style="font-size:11px;padding:2px 8px;">Del</button>
|
|
</form>
|
|
{% else %}
|
|
<a href="{{ url_for('budgets.new', month=month) }}" class="btn btn-sm btn-outline-primary" style="font-size:11px;padding:2px 8px;">Set Budget</a>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% else %}
|
|
<div class="pcard text-center py-5">
|
|
<i class="bi bi-pie-chart text-muted" style="font-size:3rem;"></i>
|
|
<h5 class="mt-3 mb-1">No budgets for {{ month_label }}</h5>
|
|
<p class="text-muted small mb-3">Set spending limits per category to track your budget.</p>
|
|
<a href="{{ url_for('budgets.new', month=month) }}" class="btn btn-primary btn-sm">Set First Budget</a>
|
|
{% set prev_year, prev_mo = prev_month.split('-') %}
|
|
<form method="POST" action="{{ url_for('budgets.copy_month') }}" class="mt-2 d-inline">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
<input type="hidden" name="from_month" value="{{ prev_month }}">
|
|
<input type="hidden" name="to_month" value="{{ month }}">
|
|
<button type="submit" class="btn btn-outline-secondary btn-sm">Copy from {{ prev_month }}</button>
|
|
</form>
|
|
</div>
|
|
{% endif %}
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
{% set chart_cats = summary | selectattr('has_budget') | list %}
|
|
{% if chart_cats %}
|
|
<script>
|
|
(function() {
|
|
const labels = {{ chart_cats | map(attribute='category') | map(attribute='name') | list | tojson }};
|
|
const spent = {{ chart_cats | map(attribute='spent') | list | tojson }};
|
|
const limits = {{ chart_cats | map(attribute='limit') | list | tojson }};
|
|
const colors = {{ chart_cats | map(attribute='category') | map(attribute='color') | list | tojson }};
|
|
|
|
const ctx = document.getElementById('budgetChart');
|
|
if (!ctx) return;
|
|
new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [
|
|
{
|
|
label: 'Spent',
|
|
data: spent,
|
|
backgroundColor: colors.map((c, i) => {
|
|
const pct = limits[i] > 0 ? spent[i] / limits[i] : 0;
|
|
return pct >= 1 ? '#ef4444cc' : pct >= 0.8 ? '#f59e0bcc' : '#10b981cc';
|
|
}),
|
|
borderRadius: 4,
|
|
barPercentage: 0.55,
|
|
categoryPercentage: 0.9,
|
|
},
|
|
{
|
|
label: 'Budget',
|
|
data: limits,
|
|
backgroundColor: '#e2e8f0cc',
|
|
borderRadius: 4,
|
|
barPercentage: 0.55,
|
|
categoryPercentage: 0.9,
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
indexAxis: 'y',
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { position: 'top', labels: { boxWidth: 10, font: { size: 11 } } },
|
|
tooltip: {
|
|
callbacks: {
|
|
label: ctx => {
|
|
const sym = '{{ current_user.currency_symbol }}';
|
|
return ` ${ctx.dataset.label}: ${sym}${ctx.parsed.x.toLocaleString('en-US', {minimumFractionDigits:2, maximumFractionDigits:2})}`;
|
|
}
|
|
}
|
|
},
|
|
},
|
|
scales: {
|
|
x: {
|
|
beginAtZero: true,
|
|
grid: { color: '#f1f5f9' },
|
|
ticks: {
|
|
font: { size: 10 },
|
|
callback: v => '{{ current_user.currency_symbol }}' + v.toLocaleString(),
|
|
},
|
|
},
|
|
y: { ticks: { font: { size: 11 } } },
|
|
},
|
|
},
|
|
});
|
|
})();
|
|
</script>
|
|
{% endif %}
|
|
{% endblock %}
|