239 lines
13 KiB
HTML
239 lines
13 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Recurring Rules{% endblock %}
|
||
{% block page_title %}Recurring Transactions{% endblock %}
|
||
|
||
{% block topbar_actions %}
|
||
<a href="{{ url_for('settings.index') }}" class="btn btn-sm btn-outline-secondary me-1" style="font-size:12px;"><i class="bi bi-arrow-left me-1"></i>Settings</a>
|
||
<form method="POST" action="{{ url_for('settings.recurring_run') }}" class="d-inline">
|
||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||
<button type="submit" class="btn btn-sm btn-outline-secondary me-1" style="font-size:12px;" title="Process all due rules now">
|
||
<i class="bi bi-play-fill me-1"></i>Run Now
|
||
</button>
|
||
</form>
|
||
<a href="{{ url_for('settings.recurring_new') }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i>New Rule</a>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="row g-3">
|
||
<div class="col-12 col-lg-7">
|
||
<div class="pcard p-0">
|
||
<div class="px-4 py-3" style="border-bottom:1px solid var(--border);">
|
||
<span class="pcard-title mb-0">Rules ({{ rules | length }})</span>
|
||
</div>
|
||
{% if rules %}
|
||
<table class="pfm-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="padding-left:20px;">Name</th>
|
||
<th>Frequency</th>
|
||
<th class="text-end">Amount</th>
|
||
<th class="text-end">Next Run</th>
|
||
<th class="text-end" style="padding-right:20px;"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for rule in rules %}
|
||
<tr style="{% if not rule.is_active %}opacity:.5;{% endif %}">
|
||
<td style="padding-left:20px;">
|
||
<div style="font-size:13px;font-weight:500;">{{ rule.name }}</div>
|
||
<div style="font-size:11px;color:var(--muted);">{{ rule.description }}</div>
|
||
</td>
|
||
<td>
|
||
<span class="badge" style="font-size:11px;background:#f1f5f9;color:var(--muted);">{{ rule.frequency | title }}</span>
|
||
<span class="badge ms-1 {% if rule.transaction_type == 'income' %}badge-income{% else %}badge-expense{% endif %}" style="font-size:11px;">{{ rule.transaction_type | title }}</span>
|
||
</td>
|
||
<td class="text-end mono {% if rule.transaction_type == 'income' %}text-income{% else %}text-expense{% endif %}" style="font-size:13px;font-weight:500;">{{ rule.amount | currency }}</td>
|
||
<td class="text-end" style="font-size:12px;color:var(--muted);white-space:nowrap;">
|
||
{{ rule.next_run.strftime('%b %d') if rule.next_run else '—' }}
|
||
</td>
|
||
<td class="text-end" style="padding-right:20px;white-space:nowrap;">
|
||
<a href="{{ url_for('settings.recurring_edit', id=rule.id) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;padding:2px 7px;">Edit</a>
|
||
<form method="POST" action="{{ url_for('settings.recurring_toggle', id=rule.id) }}" style="display:inline;">
|
||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||
<button type="submit" class="btn btn-sm btn-outline-secondary ms-1" style="font-size:11px;padding:2px 7px;">
|
||
{% if rule.is_active %}Pause{% else %}Enable{% endif %}
|
||
</button>
|
||
</form>
|
||
<form method="POST" action="{{ url_for('settings.recurring_delete', id=rule.id) }}" style="display:inline;" onsubmit="return confirm('Delete this rule?')">
|
||
<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 7px;">×</button>
|
||
</form>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
{% else %}
|
||
<div class="text-center py-5">
|
||
<i class="bi bi-repeat text-muted" style="font-size:2.5rem;"></i>
|
||
<p class="text-muted small mt-2 mb-2">No recurring rules yet.</p>
|
||
<a href="{{ url_for('settings.recurring_new') }}" class="btn btn-sm btn-primary">Create First Rule</a>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-12 col-lg-5">
|
||
<div class="pcard">
|
||
<div class="pcard-title mb-3">Upcoming (30 days)</div>
|
||
{% if upcoming %}
|
||
{% for item in upcoming[:15] %}
|
||
<div class="d-flex justify-content-between align-items-center py-2" style="border-bottom:1px solid var(--border);">
|
||
<div>
|
||
<div style="font-size:13px;">{{ item.description }}</div>
|
||
<div style="font-size:11px;color:var(--muted);">{{ item.date.strftime('%b %d, %Y') }}</div>
|
||
</div>
|
||
<span class="mono {% if item.type == 'income' %}text-income{% else %}text-expense{% endif %}" style="font-size:13px;font-weight:500;">
|
||
{% if item.type == 'income' %}+{% else %}-{% endif %}{{ item.amount | currency }}
|
||
</span>
|
||
</div>
|
||
{% endfor %}
|
||
{% else %}
|
||
<p class="text-muted small">No upcoming recurring transactions.</p>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cash Flow Projection -->
|
||
<div class="pcard mt-3">
|
||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||
<span class="pcard-title mb-0">Projected Cash Flow</span>
|
||
<div class="d-flex gap-1">
|
||
<button class="btn btn-sm proj-btn btn-primary" data-days="30" style="font-size:12px;">30d</button>
|
||
<button class="btn btn-sm proj-btn btn-outline-secondary" data-days="60" style="font-size:12px;">60d</button>
|
||
<button class="btn btn-sm proj-btn btn-outline-secondary" data-days="90" style="font-size:12px;">90d</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Summary stats -->
|
||
<div class="row g-2 mb-3" id="proj-stats">
|
||
<div class="col-6 col-md-3">
|
||
<div class="pcard pcard-sm text-center p-2" style="background:#f0fdf4;">
|
||
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">Income</div>
|
||
<div id="proj-income" class="mono text-income fw-bold" style="font-size:15px;">—</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-6 col-md-3">
|
||
<div class="pcard pcard-sm text-center p-2" style="background:#fef2f2;">
|
||
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">Expenses</div>
|
||
<div id="proj-expense" class="mono text-expense fw-bold" style="font-size:15px;">—</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-6 col-md-3">
|
||
<div class="pcard pcard-sm text-center p-2">
|
||
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">Net</div>
|
||
<div id="proj-net" class="mono fw-bold" style="font-size:15px;">—</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-6 col-md-3">
|
||
<div class="pcard pcard-sm text-center p-2" style="background:#eff6ff;">
|
||
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">End Balance</div>
|
||
<div id="proj-end" class="mono text-invest fw-bold" style="font-size:15px;">—</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Chart -->
|
||
<div id="proj-chart-wrap" class="proj-chart-wrap" style="position:relative;height:200px;">
|
||
<canvas id="projChart"></canvas>
|
||
</div>
|
||
<div id="proj-loading" class="text-center py-4 text-muted" style="display:none;">
|
||
<span class="spinner-border spinner-border-sm me-2"></span>Loading…
|
||
</div>
|
||
|
||
<!-- Event list -->
|
||
<div id="proj-events" class="mt-3" style="max-height:260px;overflow-y:auto;"></div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
||
<script>
|
||
const SYM = '{{ current_user.currency_symbol }}';
|
||
const fmtC = v => SYM + Math.abs(v).toLocaleString(undefined,{minimumFractionDigits:0,maximumFractionDigits:0});
|
||
|
||
let projChart = null;
|
||
|
||
function loadProjection(days) {
|
||
// update active button
|
||
document.querySelectorAll('.proj-btn').forEach(b => {
|
||
const active = b.dataset.days == days;
|
||
b.className = 'btn btn-sm proj-btn ' + (active ? 'btn-primary' : 'btn-outline-secondary');
|
||
b.style.fontSize = '12px';
|
||
});
|
||
|
||
document.getElementById('proj-loading').style.display = '';
|
||
document.getElementById('proj-chart-wrap').style.display = 'none';
|
||
|
||
fetch('/settings/recurring/projection?days=' + days)
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
document.getElementById('proj-loading').style.display = 'none';
|
||
document.getElementById('proj-chart-wrap').style.display = '';
|
||
|
||
// Summary stats
|
||
document.getElementById('proj-income').textContent = fmtC(data.total_income);
|
||
document.getElementById('proj-expense').textContent = fmtC(data.total_expense);
|
||
const net = data.net;
|
||
const netEl = document.getElementById('proj-net');
|
||
netEl.textContent = (net >= 0 ? '+' : '-') + fmtC(Math.abs(net));
|
||
netEl.style.color = net >= 0 ? '#10b981' : '#ef4444';
|
||
document.getElementById('proj-end').textContent = fmtC(data.ending_balance);
|
||
|
||
// Chart
|
||
if (projChart) projChart.destroy();
|
||
const ctx = document.getElementById('projChart').getContext('2d');
|
||
projChart = new Chart(ctx, {
|
||
data: {
|
||
labels: data.labels,
|
||
datasets: [
|
||
{ type:'bar', label:'Income', data:data.income, backgroundColor:'#10b98133', borderColor:'#10b981', borderWidth:1.5, borderRadius:3, yAxisID:'y' },
|
||
{ type:'bar', label:'Expense', data:data.expense, backgroundColor:'#ef444433', borderColor:'#ef4444', borderWidth:1.5, borderRadius:3, yAxisID:'y' },
|
||
{ type:'line', label:'Balance', data:data.balance, borderColor:'#3b82f6', backgroundColor:'transparent', borderWidth:2, pointRadius:3, tension:.3, yAxisID:'y2' },
|
||
]
|
||
},
|
||
options: {
|
||
responsive:true, maintainAspectRatio:false,
|
||
interaction:{ mode:'index', intersect:false },
|
||
plugins:{ legend:{ position:'bottom', labels:{ font:{ size:11 }, boxWidth:10 } } },
|
||
scales:{
|
||
y: { position:'left', grid:{ color:'#f1f5f9' }, ticks:{ font:{size:10}, callback: v=>fmtC(v) } },
|
||
y2: { position:'right', grid:{ display:false }, ticks:{ font:{size:10}, callback: v=>fmtC(v) } },
|
||
x: { grid:{ display:false }, ticks:{ font:{size:10} } },
|
||
}
|
||
}
|
||
});
|
||
|
||
// Event list
|
||
const evList = document.getElementById('proj-events');
|
||
if (!data.events.length) {
|
||
evList.innerHTML = '<p class="text-muted small">No recurring transactions in this period.</p>';
|
||
return;
|
||
}
|
||
evList.innerHTML = data.events.map(ev => `
|
||
<div class="d-flex justify-content-between align-items-center py-2" style="border-top:1px solid var(--border);font-size:13px;">
|
||
<div>
|
||
<span class="fw-medium">${ev.description}</span>
|
||
<span class="text-muted ms-2" style="font-size:11px;">${ev.date}</span>
|
||
</div>
|
||
<span class="mono ${ev.type === 'income' ? 'text-income' : 'text-expense'}" style="font-size:13px;white-space:nowrap;">
|
||
${ev.type === 'income' ? '+' : '-'}${SYM}${Math.abs(ev.amount).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})}
|
||
</span>
|
||
</div>`).join('');
|
||
})
|
||
.catch(() => {
|
||
document.getElementById('proj-loading').style.display = 'none';
|
||
document.getElementById('proj-chart-wrap').style.display = '';
|
||
});
|
||
}
|
||
|
||
document.querySelectorAll('.proj-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => loadProjection(parseInt(btn.dataset.days)));
|
||
});
|
||
|
||
// Load 30-day projection on page load
|
||
loadProjection(30);
|
||
</script>
|
||
{% endblock %}
|