197 lines
11 KiB
HTML
197 lines
11 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Transactions{% endblock %}
|
||
{% block page_title %}Transactions{% endblock %}
|
||
|
||
{% block topbar_actions %}
|
||
<a href="{{ url_for('transactions.new', type='income') }}" class="btn btn-sm" style="background:#d1fae5;color:#065f46;font-size:12px;"><i class="bi bi-plus-lg"></i><span class="btn-label ms-1">Income</span></a>
|
||
<a href="{{ url_for('transactions.new', type='expense') }}" class="btn btn-sm ms-1" style="background:#fee2e2;color:#991b1b;font-size:12px;"><i class="bi bi-dash-lg"></i><span class="btn-label ms-1">Expense</span></a>
|
||
<a href="{{ url_for('transactions.transfer') }}" class="btn btn-sm btn-outline-secondary ms-1" style="font-size:12px;"><i class="bi bi-arrow-left-right"></i><span class="btn-label ms-1">Transfer</span></a>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<!-- Tabs -->
|
||
<div class="d-flex gap-1 mb-3">
|
||
<a href="{{ url_for('transactions.index', tab='expense', q=search, account_id=account_id, category_id=category_id, date_from=date_from, date_to=date_to) }}"
|
||
class="btn btn-sm {% if tab=='expense' %}btn-danger{% else %}btn-outline-secondary{% endif %}" style="font-size:13px;">
|
||
<i class="bi bi-arrow-up-circle me-1"></i>Expenses
|
||
<span class="badge ms-1" style="font-size:10px;background:{% if tab=='expense' %}rgba(255,255,255,.25){% else %}#fee2e2;color:#991b1b{% endif %};">{{ expense_count }}</span>
|
||
</a>
|
||
<a href="{{ url_for('transactions.index', tab='income', q=search, account_id=account_id, category_id=category_id, date_from=date_from, date_to=date_to) }}"
|
||
class="btn btn-sm {% if tab=='income' %}btn-success{% else %}btn-outline-secondary{% endif %}" style="font-size:13px;">
|
||
<i class="bi bi-arrow-down-circle me-1"></i>Income
|
||
<span class="badge ms-1" style="font-size:10px;background:{% if tab=='income' %}rgba(255,255,255,.25){% else %}#d1fae5;color:#065f46{% endif %};">{{ income_count }}</span>
|
||
</a>
|
||
</div>
|
||
|
||
<!-- Filters -->
|
||
<div class="pcard pcard-sm mb-3">
|
||
<form method="GET" action="{{ url_for('transactions.index') }}" class="row g-2 align-items-end">
|
||
<input type="hidden" name="tab" value="{{ tab }}">
|
||
<div class="col-12 col-md-3">
|
||
<input type="text" name="q" class="form-control form-control-sm" placeholder="Search description…" value="{{ search }}">
|
||
</div>
|
||
<div class="col-6 col-md-2">
|
||
<select name="category_id" class="form-select form-select-sm">
|
||
<option value="">All categories</option>
|
||
{% for cat in categories %}
|
||
<option value="{{ cat.id }}" {% if category_id == cat.id|string %}selected{% endif %}>{{ cat.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="col-6 col-md-2">
|
||
<select name="account_id" class="form-select form-select-sm">
|
||
<option value="">All accounts</option>
|
||
{% for acct in accounts %}
|
||
<option value="{{ acct.id }}" {% if account_id == acct.id|string %}selected{% endif %}>{{ acct.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="col-6 col-md-2">
|
||
<input type="date" name="date_from" class="form-control form-control-sm" value="{{ date_from }}" placeholder="From">
|
||
</div>
|
||
<div class="col-6 col-md-2">
|
||
<input type="date" name="date_to" class="form-control form-control-sm" value="{{ date_to }}" placeholder="To">
|
||
</div>
|
||
<div class="col-12 col-md-1 d-flex gap-1">
|
||
<button type="submit" class="btn btn-sm btn-primary flex-grow-1"><i class="bi bi-search"></i></button>
|
||
<a href="{{ url_for('transactions.index', tab=tab) }}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-x-lg"></i></a>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Table -->
|
||
{% set has_filter = search or category_id or account_id or date_from or date_to %}
|
||
<div class="pcard p-0">
|
||
{% if transactions %}
|
||
<table class="pfm-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="padding-left:20px;">Date</th>
|
||
<th>Description</th>
|
||
<th class="d-mob-none">Category</th>
|
||
<th class="d-mob-none">Account</th>
|
||
<th class="text-end">Amount</th>
|
||
<th class="text-end" style="padding-right:20px;">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for txn in transactions %}
|
||
<tr>
|
||
<td style="padding-left:20px;font-size:12px;color:var(--muted);white-space:nowrap;">{{ txn.date.strftime('%b %d, %Y') }}</td>
|
||
<td>
|
||
<div style="font-size:13px;font-weight:500;">{{ txn.description }}</div>
|
||
{% if txn.notes %}<div style="font-size:11px;color:var(--muted);">{{ txn.notes | truncate(60) }}</div>{% endif %}
|
||
</td>
|
||
<td class="d-mob-none" style="min-width:150px;">
|
||
<div class="d-flex align-items-center gap-1">
|
||
<i id="cat-icon-{{ txn.id }}"
|
||
class="bi {{ txn.category.icon if txn.category else 'bi-tag' }}"
|
||
style="font-size:12px;color:{{ txn.category.color if txn.category else '#94a3b8' }};flex-shrink:0;"></i>
|
||
<select class="cat-select"
|
||
data-txn-id="{{ txn.id }}"
|
||
title="Change category"
|
||
style="font-size:12px;border:1px solid transparent;border-radius:4px;background:transparent;padding:2px 4px;cursor:pointer;max-width:130px;color:var(--text);">
|
||
<option value="">— None —</option>
|
||
{% for cat in categories %}
|
||
<option value="{{ cat.id }}"
|
||
data-icon="{{ cat.icon }}"
|
||
data-color="{{ cat.color }}"
|
||
{% if txn.category_id == cat.id %}selected{% endif %}>{{ cat.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
</td>
|
||
<td class="d-mob-none" style="font-size:12px;color:var(--muted);">{{ txn.account.name if txn.account else '—' }}</td>
|
||
<td class="text-end mono {% if txn.transaction_type=='income' %}text-income{% else %}text-expense{% endif %}" style="font-size:13px;font-weight:600;white-space:nowrap;">
|
||
{% if txn.transaction_type=='income' %}+{% else %}-{% endif %}{{ txn.amount | currency }}
|
||
</td>
|
||
<td class="text-end" style="padding-right:20px;white-space:nowrap;">
|
||
<a href="{{ url_for('transactions.edit', id=txn.id, next=request.full_path) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;padding:2px 8px;">Edit</a>
|
||
<form method="POST" action="{{ url_for('transactions.delete', id=txn.id) }}" style="display:inline;" onsubmit="return confirm('Delete this transaction?')">
|
||
<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>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
|
||
<!-- Pagination -->
|
||
{% if pagination.pages > 1 %}
|
||
<div class="d-flex justify-content-between align-items-center px-4 py-3" style="border-top:1px solid var(--border);font-size:12px;color:var(--muted);">
|
||
<span>Page {{ pagination.page }} of {{ pagination.pages }} · {{ ((pagination.page-1)*30)+1 }}–{{ [pagination.page*30, pagination.total]|min }} of {{ pagination.total }}</span>
|
||
<div class="d-flex gap-1">
|
||
{% if pagination.has_prev %}
|
||
<a href="{{ url_for('transactions.index', tab=tab, page=pagination.prev_num, q=search, category_id=category_id, account_id=account_id, date_from=date_from, date_to=date_to) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">← Prev</a>
|
||
{% endif %}
|
||
{% if pagination.has_next %}
|
||
<a href="{{ url_for('transactions.index', tab=tab, page=pagination.next_num, q=search, category_id=category_id, account_id=account_id, date_from=date_from, date_to=date_to) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">Next →</a>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% else %}
|
||
<div class="text-center py-5 px-3">
|
||
{% if has_filter %}
|
||
<i class="bi bi-funnel text-muted" style="font-size:2.5rem;opacity:.4;"></i>
|
||
<p class="fw-semibold mt-3 mb-1">No matching transactions</p>
|
||
<p class="text-muted small mb-3">Try adjusting your filters or search term.</p>
|
||
<a href="{{ url_for('transactions.index', tab=tab) }}" class="btn btn-sm btn-outline-secondary">
|
||
<i class="bi bi-x-lg me-1"></i>Clear filters
|
||
</a>
|
||
{% else %}
|
||
<i class="bi bi-arrow-left-right text-muted" style="font-size:2.5rem;opacity:.4;"></i>
|
||
<p class="fw-semibold mt-3 mb-1">No {{ tab }} transactions yet</p>
|
||
<p class="text-muted small mb-3">Record your first {{ tab }} to start tracking.</p>
|
||
<a href="{{ url_for('transactions.new', type=tab) }}" class="btn btn-sm btn-primary">
|
||
<i class="bi bi-plus-lg me-1"></i>Add {{ tab | title }}
|
||
</a>
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<style>
|
||
.cat-select:hover { border-color: var(--border) !important; background: #f8fafc !important; }
|
||
.cat-select:focus { outline: none; border-color: #3b82f6 !important; background: #fff !important; }
|
||
</style>
|
||
<script>
|
||
const CSRF = document.querySelector('meta[name="csrf-token"]').content;
|
||
|
||
document.querySelectorAll('.cat-select').forEach(sel => {
|
||
sel.addEventListener('change', function () {
|
||
const txnId = this.dataset.txnId;
|
||
const catId = this.value || null;
|
||
const opt = this.options[this.selectedIndex];
|
||
const icon = document.getElementById('cat-icon-' + txnId);
|
||
|
||
if (icon) {
|
||
icon.className = 'bi ' + (catId ? opt.dataset.icon : 'bi-tag');
|
||
icon.style.color = catId ? opt.dataset.color : '#94a3b8';
|
||
}
|
||
|
||
fetch(`/transactions/${txnId}/set-category`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF },
|
||
body: JSON.stringify({ category_id: catId ? parseInt(catId) : null }),
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.ok) {
|
||
sel.style.color = '#10b981';
|
||
setTimeout(() => sel.style.color = '', 1500);
|
||
}
|
||
})
|
||
.catch(() => {
|
||
sel.style.color = '#ef4444';
|
||
setTimeout(() => sel.style.color = '', 2000);
|
||
});
|
||
});
|
||
});
|
||
</script>
|
||
{% endblock %}
|