Aug 17 - Update with utilities management
This commit is contained in:
@@ -322,6 +322,17 @@
|
||||
>
|
||||
<i class="bi bi-wallet2"></i><span class="lt">Accounts</span>
|
||||
</a>
|
||||
<a
|
||||
href="{{ url_for('utilities.index') }}"
|
||||
class="sb-link {% if request.blueprint == 'utilities' %}active{% endif %}"
|
||||
>
|
||||
<i class="bi bi-lightning-charge"></i
|
||||
><span class="lt">Utilities
|
||||
{% if utility_due_count > 0 %}
|
||||
<span style="margin-left:6px;background:#f59e0b;color:#fff;font-size:10px;font-weight:700;padding:1px 5px;border-radius:10px;line-height:1.4;">{{ utility_due_count }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="{{ url_for('bank_import.index') }}"
|
||||
class="sb-link {% if request.blueprint == 'bank_import' %}active{% endif %}"
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
{% block page_title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('utilities.bills') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">← Bills</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-md-9 col-lg-7">
|
||||
<div class="pcard">
|
||||
<form method="POST" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.provider_id.label(class="form-label fw-medium", style="font-size:13px;") }}
|
||||
{{ form.provider_id(class="form-select" + (" is-invalid" if form.provider_id.errors else ""), id="providerSelect") }}
|
||||
{% for e in form.provider_id.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-6">
|
||||
{{ form.period_start.label(class="form-label fw-medium", style="font-size:13px;") }}
|
||||
{{ form.period_start(class="form-control" + (" is-invalid" if form.period_start.errors else "")) }}
|
||||
{% for e in form.period_start.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="col-6">
|
||||
{{ form.period_end.label(class="form-label fw-medium", style="font-size:13px;") }}
|
||||
{{ form.period_end(class="form-control" + (" is-invalid" if form.period_end.errors else "")) }}
|
||||
{% for e in form.period_end.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6">
|
||||
{{ form.amount.label(class="form-label fw-medium", style="font-size:13px;") }}
|
||||
<div class="input-group">
|
||||
<span class="input-group-text" style="font-size:13px;">{{ current_user.currency_symbol }}</span>
|
||||
{{ form.amount(class="form-control" + (" is-invalid" if form.amount.errors else ""), placeholder="0.00", id="amountInput") }}
|
||||
</div>
|
||||
{% for e in form.amount.errors %}<div class="text-danger" style="font-size:12px;">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="col-6">
|
||||
{{ form.due_date.label(class="form-label fw-medium", style="font-size:13px;") }}
|
||||
{{ form.due_date(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage -->
|
||||
<div id="usageBlock" style="border-top:1px solid var(--border);padding-top:16px;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<label class="form-label fw-medium mb-0" style="font-size:13px;">Consumption <span class="text-muted" style="font-weight:400;">— optional</span></label>
|
||||
<span class="badge" style="background:#f1f5f9;color:#475569;font-size:11px;" id="unitBadge">—</span>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-2">
|
||||
<div class="col-12 col-sm-4">
|
||||
{{ form.usage.label(class="form-label fw-medium", style="font-size:13px;") }}
|
||||
{{ form.usage(class="form-control", placeholder="0.000", id="usageInput") }}
|
||||
</div>
|
||||
<div class="col-6 col-sm-4">
|
||||
{{ form.meter_start.label(class="form-label fw-medium", style="font-size:13px;") }}
|
||||
{{ form.meter_start(class="form-control", placeholder="Previous reading", id="meterStart") }}
|
||||
</div>
|
||||
<div class="col-6 col-sm-4">
|
||||
{{ form.meter_end.label(class="form-label fw-medium", style="font-size:13px;") }}
|
||||
{{ form.meter_end(class="form-control" + (" is-invalid" if form.meter_end.errors else ""), placeholder="Current reading", id="meterEnd") }}
|
||||
{% for e in form.meter_end.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted" style="font-size:11px;">
|
||||
Enter usage directly, or both meter readings — readings win and fill in usage automatically.
|
||||
</small>
|
||||
|
||||
<div class="mt-3 p-2" id="rateHint" style="display:none;background:#f8fafc;border-radius:8px;font-size:12px;">
|
||||
<i class="bi bi-calculator me-1"></i>Unit rate: <span class="mono fw-bold" id="rateValue"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 mt-3">
|
||||
{{ form.notes.label(class="form-label fw-medium", style="font-size:13px;") }}
|
||||
{{ form.notes(class="form-control", rows=2, placeholder="Rate plan, unusual charges, meter notes…") }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
{{ form.submit(class="btn btn-primary") }}
|
||||
{% if not bill %}
|
||||
<button type="submit" name="pay_now" value="yes" class="btn btn-outline-primary">Save & Mark Paid</button>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('utilities.bills') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
// provider id -> usage unit, so the form can label and validate consumption
|
||||
const UNITS = {{ provider_units | tojson }};
|
||||
const sym = {{ current_user.currency_symbol | tojson }};
|
||||
|
||||
const sel = document.getElementById('providerSelect');
|
||||
const block = document.getElementById('usageBlock');
|
||||
const badge = document.getElementById('unitBadge');
|
||||
const usage = document.getElementById('usageInput');
|
||||
const mStart = document.getElementById('meterStart');
|
||||
const mEnd = document.getElementById('meterEnd');
|
||||
const amount = document.getElementById('amountInput');
|
||||
const hint = document.getElementById('rateHint');
|
||||
const rateVal = document.getElementById('rateValue');
|
||||
|
||||
function unit() { return UNITS[sel.value] || ''; }
|
||||
|
||||
function syncUnit() {
|
||||
const u = unit();
|
||||
badge.textContent = u || 'no usage tracked';
|
||||
block.style.opacity = u ? '1' : '.55';
|
||||
}
|
||||
|
||||
function syncUsage() {
|
||||
const a = parseFloat(mStart.value), b = parseFloat(mEnd.value);
|
||||
if (!isNaN(a) && !isNaN(b) && b >= a) usage.value = (b - a).toFixed(3);
|
||||
syncRate();
|
||||
}
|
||||
|
||||
function syncRate() {
|
||||
const amt = parseFloat(amount.value), u = parseFloat(usage.value);
|
||||
if (!isNaN(amt) && !isNaN(u) && u > 0) {
|
||||
rateVal.textContent = sym + (amt / u).toFixed(4) + ' per ' + (unit() || 'unit');
|
||||
hint.style.display = 'block';
|
||||
} else {
|
||||
hint.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
sel.addEventListener('change', syncUnit);
|
||||
[mStart, mEnd].forEach(el => el.addEventListener('input', syncUsage));
|
||||
[amount, usage].forEach(el => el.addEventListener('input', syncRate));
|
||||
|
||||
syncUnit();
|
||||
syncRate();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,143 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Utility Bills{% endblock %}
|
||||
{% block page_title %}Utility Bills{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
.util-chip { font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 10px; text-transform: uppercase; letter-spacing: .05em; }
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('utilities.index') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">← Utilities</a>
|
||||
<a href="{{ url_for('utilities.bill_new') }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i><span class="btn-label">New Bill</span></a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="pcard mb-3">
|
||||
<form method="GET" class="row g-2 align-items-end">
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label fw-medium" style="font-size:12px;">Provider</label>
|
||||
<select name="provider_id" class="form-select form-select-sm">
|
||||
<option value="">All providers</option>
|
||||
{% for p in providers %}
|
||||
<option value="{{ p.id }}" {% if provider_id == p.id %}selected{% endif %}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label fw-medium" style="font-size:12px;">Type</label>
|
||||
<select name="utility_type" class="form-select form-select-sm">
|
||||
<option value="">All types</option>
|
||||
{% for key, meta in type_meta.items() %}
|
||||
<option value="{{ key }}" {% if utility_type == key %}selected{% endif %}>{{ meta[0] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label fw-medium" style="font-size:12px;">Status</label>
|
||||
<select name="status" class="form-select form-select-sm">
|
||||
<option value="">All</option>
|
||||
<option value="unpaid" {% if status == 'unpaid' %}selected{% endif %}>Unpaid</option>
|
||||
<option value="overdue" {% if status == 'overdue' %}selected{% endif %}>Overdue</option>
|
||||
<option value="paid" {% if status == 'paid' %}selected{% endif %}>Paid</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label fw-medium" style="font-size:12px;">Year</label>
|
||||
<select name="year" class="form-select form-select-sm">
|
||||
<option value="">All years</option>
|
||||
{% for y in years %}
|
||||
<option value="{{ y }}" {% if year == y %}selected{% endif %}>{{ y }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-funnel me-1"></i>Filter</button>
|
||||
<a href="{{ url_for('utilities.bills') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if bills %}
|
||||
<div class="pcard p-0">
|
||||
<div class="table-wrap">
|
||||
<table class="pfm-table wide">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Provider</th>
|
||||
<th>Period</th>
|
||||
<th class="d-mob-none">Due</th>
|
||||
<th class="text-end">Amount</th>
|
||||
<th class="text-end d-mob-none">Usage</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in bills %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div style="width:28px;height:28px;border-radius:7px;background:{{ b.provider.color }}22;color:{{ b.provider.color }};display:flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0;">
|
||||
<i class="bi {{ b.provider.icon }}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('utilities.provider_detail', id=b.provider_id) }}" style="font-size:13px;font-weight:600;text-decoration:none;color:inherit;">{{ b.provider.name }}</a>
|
||||
<div style="font-size:11px;color:var(--muted);">{{ b.provider.type_label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style="font-size:12px;">{{ b.period_label }}</td>
|
||||
<td class="d-mob-none" style="font-size:12px;">{{ b.due_date.strftime('%b %d, %Y') if b.due_date else '—' }}</td>
|
||||
<td class="text-end mono fw-bold" style="font-size:13px;">{{ b.amount | currency }}</td>
|
||||
<td class="text-end mono d-mob-none" style="font-size:12px;">
|
||||
{% if b.usage %}{{ '%.1f' | format(b.usage | float) }} <span style="color:var(--muted);">{{ b.usage_unit or '' }}</span>{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if b.status == 'paid' %}
|
||||
<span class="util-chip" style="background:#d1fae5;color:#065f46;">Paid</span>
|
||||
{% elif b.status == 'overdue' %}
|
||||
<span class="util-chip" style="background:#fee2e2;color:#991b1b;">{{ b.days_until_due | abs }}d late</span>
|
||||
{% elif b.status == 'due_soon' %}
|
||||
<span class="util-chip" style="background:#fef3c7;color:#92400e;">Due in {{ b.days_until_due }}d</span>
|
||||
{% else %}
|
||||
<span class="util-chip" style="background:#f1f5f9;color:#475569;">Unpaid</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{% if not b.is_paid %}
|
||||
<a href="{{ url_for('utilities.bill_pay', id=b.id) }}" class="btn btn-sm btn-primary" style="font-size:11px;padding:3px 10px;">Pay</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('utilities.bill_edit', id=b.id) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;padding:3px 8px;"><i class="bi bi-pencil"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if pagination.pages > 1 %}
|
||||
<div class="d-flex justify-content-between align-items-center p-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-2">
|
||||
{% if pagination.has_prev %}
|
||||
<a href="{{ url_for('utilities.bills', page=pagination.prev_num, provider_id=provider_id, utility_type=utility_type, status=status, year=year) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">← Prev</a>
|
||||
{% endif %}
|
||||
{% if pagination.has_next %}
|
||||
<a href="{{ url_for('utilities.bills', page=pagination.next_num, provider_id=provider_id, utility_type=utility_type, status=status, year=year) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">Next →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="pcard text-center py-5">
|
||||
<i class="bi bi-receipt text-muted" style="font-size:3rem;"></i>
|
||||
<h5 class="mt-3 mb-1">No bills match</h5>
|
||||
<p class="text-muted small mb-3">Try clearing the filters, or record a new bill.</p>
|
||||
<a href="{{ url_for('utilities.bill_new') }}" class="btn btn-primary btn-sm">New Bill</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,280 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ provider.name }}{% endblock %}
|
||||
{% block page_title %}{{ provider.name }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
.util-chip { font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 10px; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.trend-up { color: #ef4444; }
|
||||
.trend-down { color: #10b981; }
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('utilities.index') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">← Utilities</a>
|
||||
<a href="{{ url_for('utilities.provider_edit', id=provider.id) }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;"><i class="bi bi-pencil me-1"></i><span class="btn-label">Edit</span></a>
|
||||
<a href="{{ url_for('utilities.bill_new', provider_id=provider.id) }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i><span class="btn-label">Add Bill</span></a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<!-- Header -->
|
||||
<div class="pcard mb-4" style="border-left:4px solid {{ provider.color }};">
|
||||
<div class="d-flex align-items-center gap-3 flex-wrap">
|
||||
<div style="width:46px;height:46px;border-radius:11px;background:{{ provider.color }}22;color:{{ provider.color }};display:flex;align-items:center;justify-content:center;font-size:22px;flex-shrink:0;">
|
||||
<i class="bi {{ provider.icon }}"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div style="font-size:16px;font-weight:600;">{{ provider.name }}</div>
|
||||
<div style="font-size:12px;color:var(--muted);">
|
||||
{{ provider.type_label }}
|
||||
{% if provider.account_number %} · Acct <span class="mono">{{ provider.account_number }}</span>{% endif %}
|
||||
{% if provider.default_account %} · Paid from {{ provider.default_account.name }}{% endif %}
|
||||
{% if provider.billing_day %} · Bills on day {{ provider.billing_day }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if provider.notes %}
|
||||
<div style="font-size:12px;color:var(--muted);margin-top:10px;padding-top:10px;border-top:1px solid var(--border);">{{ provider.notes }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Latest Bill</div>
|
||||
<div class="stat-value">{{ stats.latest.amount | currency if stats.latest else '—' }}</div>
|
||||
{% if stats.amount_change_pct is not none %}
|
||||
<small class="{% if stats.amount_change_pct > 0 %}trend-up{% else %}trend-down{% endif %}" style="font-size:11px;">
|
||||
<i class="bi bi-arrow-{% if stats.amount_change_pct > 0 %}up{% else %}down{% endif %}-short"></i>{{ stats.amount_change_pct | abs }}% vs prev
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Average Bill</div>
|
||||
<div class="stat-value">{{ stats.avg_amount | currency }}</div>
|
||||
<small class="text-muted" style="font-size:11px;">Last 12 months</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">12-Month Total</div>
|
||||
<div class="stat-value">{{ stats.total_12mo | currency }}</div>
|
||||
<small class="text-muted" style="font-size:11px;">{{ stats.bill_count }} bill{{ '' if stats.bill_count == 1 else 's' }} on record</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{% if provider.tracks_usage %}Avg Usage{% else %}Year over Year{% endif %}</div>
|
||||
{% if provider.tracks_usage %}
|
||||
<div class="stat-value">{{ '%.1f' | format(stats.avg_usage) if stats.avg_usage else '—' }}<span style="font-size:13px;color:var(--muted);"> {{ provider.usage_unit }}</span></div>
|
||||
{% if stats.usage_change_pct is not none %}
|
||||
<small class="{% if stats.usage_change_pct > 0 %}trend-up{% else %}trend-down{% endif %}" style="font-size:11px;">
|
||||
<i class="bi bi-arrow-{% if stats.usage_change_pct > 0 %}up{% else %}down{% endif %}-short"></i>{{ stats.usage_change_pct | abs }}% latest vs prev
|
||||
</small>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="stat-value">{% if stats.yoy_change_pct is not none %}{{ '+' if stats.yoy_change_pct > 0 else '' }}{{ stats.yoy_change_pct }}%{% else %}—{% endif %}</div>
|
||||
<small class="text-muted" style="font-size:11px;">Latest vs same period last year</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart -->
|
||||
{% if series.labels %}
|
||||
<div class="pcard mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="pcard-title mb-0">Billing History</div>
|
||||
{% if series.has_usage %}
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-secondary active" style="font-size:11px;" data-mode="amount">Amount</button>
|
||||
<button class="btn btn-outline-secondary" style="font-size:11px;" data-mode="usage">Usage</button>
|
||||
<button class="btn btn-outline-secondary" style="font-size:11px;" data-mode="rate">Unit Rate</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="height:300px;"><canvas id="histChart"></canvas></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Bills -->
|
||||
<div class="pcard p-0">
|
||||
<div class="d-flex justify-content-between align-items-center p-3" style="border-bottom:1px solid var(--border);">
|
||||
<div class="pcard-title mb-0">Bills</div>
|
||||
</div>
|
||||
|
||||
{% if bills %}
|
||||
<div class="table-wrap">
|
||||
<table class="pfm-table wide">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Period</th>
|
||||
<th class="d-mob-none">Due</th>
|
||||
<th class="text-end">Amount</th>
|
||||
{% if provider.tracks_usage %}
|
||||
<th class="text-end d-mob-none">Usage</th>
|
||||
<th class="text-end d-mob-none">Rate</th>
|
||||
{% endif %}
|
||||
<th>Status</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in bills %}
|
||||
<tr>
|
||||
<td style="font-size:12px;">
|
||||
{{ b.period_label }}
|
||||
{% if b.notes %}<div style="font-size:11px;color:var(--muted);">{{ b.notes | truncate(60) }}</div>{% endif %}
|
||||
</td>
|
||||
<td class="d-mob-none" style="font-size:12px;">{{ b.due_date.strftime('%b %d, %Y') if b.due_date else '—' }}</td>
|
||||
<td class="text-end mono fw-bold" style="font-size:13px;">{{ b.amount | currency }}</td>
|
||||
{% if provider.tracks_usage %}
|
||||
<td class="text-end mono d-mob-none" style="font-size:12px;">
|
||||
{% if b.usage %}{{ '%.1f' | format(b.usage | float) }} <span style="color:var(--muted);">{{ b.usage_unit or provider.usage_unit }}</span>{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="text-end mono d-mob-none" style="font-size:12px;">
|
||||
{% if b.rate_per_unit %}{{ current_user.currency_symbol }}{{ '%.4f' | format(b.rate_per_unit) }}{% else %}—{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
{% if b.status == 'paid' %}
|
||||
<span class="util-chip" style="background:#d1fae5;color:#065f46;">Paid</span>
|
||||
{% if b.paid_date %}<div style="font-size:10px;color:var(--muted);margin-top:2px;">{{ b.paid_date.strftime('%b %d') }}</div>{% endif %}
|
||||
{% elif b.status == 'overdue' %}
|
||||
<span class="util-chip" style="background:#fee2e2;color:#991b1b;">{{ b.days_until_due | abs }}d late</span>
|
||||
{% elif b.status == 'due_soon' %}
|
||||
<span class="util-chip" style="background:#fef3c7;color:#92400e;">Due in {{ b.days_until_due }}d</span>
|
||||
{% else %}
|
||||
<span class="util-chip" style="background:#f1f5f9;color:#475569;">Unpaid</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm" style="background:none;border:none;color:var(--muted);padding:2px 6px;" data-bs-toggle="dropdown"><i class="bi bi-three-dots"></i></button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" style="font-size:13px;">
|
||||
{% if not b.is_paid %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('utilities.bill_pay', id=b.id) }}"><i class="bi bi-check2-circle me-2"></i>Mark Paid</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('utilities.bill_link', id=b.id) }}"><i class="bi bi-link-45deg me-2"></i>Link Existing Payment</a></li>
|
||||
{% else %}
|
||||
{% if b.transaction_id %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('transactions.edit', id=b.transaction_id) }}"><i class="bi bi-receipt me-2"></i>View Payment</a></li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<form method="POST" action="{{ url_for('utilities.bill_unpay', id=b.id) }}" onsubmit="return confirm('Reopen this bill? A payment transaction created by PFM will be deleted.')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="dropdown-item"><i class="bi bi-arrow-counterclockwise me-2"></i>Reopen</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('utilities.bill_edit', id=b.id) }}"><i class="bi bi-pencil me-2"></i>Edit</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<form method="POST" action="{{ url_for('utilities.bill_delete', id=b.id) }}" onsubmit="return confirm('Delete this bill?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="dropdown-item text-danger"><i class="bi bi-trash me-2"></i>Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if pagination.pages > 1 %}
|
||||
<div class="d-flex justify-content-between align-items-center p-3" style="border-top:1px solid var(--border);font-size:12px;color:var(--muted);">
|
||||
<span>Page {{ pagination.page }} of {{ pagination.pages }} · {{ pagination.total }} bills</span>
|
||||
<div class="d-flex gap-2">
|
||||
{% if pagination.has_prev %}
|
||||
<a href="{{ url_for('utilities.provider_detail', id=provider.id, page=pagination.prev_num) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">← Prev</a>
|
||||
{% endif %}
|
||||
{% if pagination.has_next %}
|
||||
<a href="{{ url_for('utilities.provider_detail', id=provider.id, page=pagination.next_num) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">Next →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-receipt text-muted" style="font-size:2.5rem;"></i>
|
||||
<h6 class="mt-3 mb-1">No bills recorded</h6>
|
||||
<p class="text-muted small mb-3">Add a bill to start tracking cost{% if provider.tracks_usage %} and usage{% endif %}.</p>
|
||||
<a href="{{ url_for('utilities.bill_new', provider_id=provider.id) }}" class="btn btn-primary btn-sm">Add First Bill</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% if series.labels %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const dark = document.documentElement.getAttribute('data-pfm-dark') === '1';
|
||||
const grid = dark ? 'rgba(148,163,184,.15)' : 'rgba(148,163,184,.25)';
|
||||
const tick = dark ? '#94a3b8' : '#64748b';
|
||||
const sym = {{ current_user.currency_symbol | tojson }};
|
||||
const color = {{ provider.color | tojson }};
|
||||
const S = {{ series | tojson }};
|
||||
|
||||
const MODES = {
|
||||
amount: { label: 'Amount', data: S.amounts, type: 'bar', fmt: function (v) { return sym + v.toFixed(2); } },
|
||||
usage: { label: 'Usage (' + S.unit + ')', data: S.usage, type: 'line', fmt: function (v) { return v.toFixed(1) + ' ' + S.unit; } },
|
||||
rate: { label: 'Rate per ' + S.unit, data: S.rates, type: 'line', fmt: function (v) { return sym + v.toFixed(4); } }
|
||||
};
|
||||
|
||||
let chart = null;
|
||||
function render(mode) {
|
||||
const m = MODES[mode];
|
||||
if (chart) chart.destroy();
|
||||
chart = new Chart(document.getElementById('histChart'), {
|
||||
type: m.type,
|
||||
data: {
|
||||
labels: S.labels,
|
||||
datasets: [{
|
||||
label: m.label,
|
||||
data: m.data,
|
||||
backgroundColor: m.type === 'bar' ? color : color + '22',
|
||||
borderColor: color,
|
||||
borderWidth: 2,
|
||||
borderRadius: 4,
|
||||
tension: .3,
|
||||
fill: m.type === 'line',
|
||||
pointRadius: 3,
|
||||
spanGaps: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { callbacks: { label: function (c) { return m.label + ': ' + m.fmt(c.parsed.y); } } }
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { font: { size: 11 }, color: tick } },
|
||||
y: { grid: { color: grid }, ticks: { font: { size: 11 }, color: tick,
|
||||
callback: function (v) { return mode === 'amount' ? sym + v : v; } } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render('amount');
|
||||
|
||||
document.querySelectorAll('[data-mode]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
document.querySelectorAll('[data-mode]').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
render(btn.dataset.mode);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,297 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Utilities{% endblock %}
|
||||
{% block page_title %}Utilities{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
.util-card { border-radius: 12px; border: 1px solid var(--border); background: var(--card-bg); padding: 16px 18px; height: 100%; }
|
||||
.util-chip { font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 10px; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.trend-up { color: #ef4444; }
|
||||
.trend-down { color: #10b981; }
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('utilities.bills') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;"><i class="bi bi-list-ul me-1"></i><span class="btn-label">All Bills</span></a>
|
||||
<a href="{{ url_for('utilities.providers') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;"><i class="bi bi-building me-1"></i><span class="btn-label">Providers</span></a>
|
||||
<a href="{{ url_for('utilities.bill_new') }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i><span class="btn-label">New Bill</span></a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<!-- Stat cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="stat-card">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="stat-label">This Month</div>
|
||||
<div class="stat-value text-expense">{{ summary.this_month | currency }}</div>
|
||||
{% if summary.month_change_pct is not none %}
|
||||
<small class="{% if summary.month_change_pct > 0 %}trend-up{% else %}trend-down{% endif %}" style="font-size:11px;">
|
||||
<i class="bi bi-arrow-{% if summary.month_change_pct > 0 %}up{% else %}down{% endif %}-short"></i>{{ summary.month_change_pct | abs }}% vs last month
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="stat-icon" style="background:#ede9fe;color:#5b21b6;"><i class="bi bi-lightning-charge"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="stat-card">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="stat-label">Monthly Average</div>
|
||||
<div class="stat-value">{{ summary.avg_monthly | currency }}</div>
|
||||
<small class="text-muted" style="font-size:11px;">Last 12 months</small>
|
||||
</div>
|
||||
<div class="stat-icon" style="background:#e0f2fe;color:#075985;"><i class="bi bi-bar-chart"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="stat-card">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="stat-label">Year to Date</div>
|
||||
<div class="stat-value">{{ summary.ytd | currency }}</div>
|
||||
</div>
|
||||
<div class="stat-icon" style="background:#fef3c7;color:#92400e;"><i class="bi bi-calendar3"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="stat-card" style="{% if summary.overdue_count %}border-color:#fecaca;{% endif %}">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="stat-label">Unpaid</div>
|
||||
<div class="stat-value {% if summary.overdue_count %}text-expense{% endif %}">{{ summary.unpaid_total | currency }}</div>
|
||||
<small class="text-muted" style="font-size:11px;">
|
||||
{{ summary.unpaid_count }} bill{{ '' if summary.unpaid_count == 1 else 's' }}{% if summary.overdue_count %} · <span class="text-expense fw-bold">{{ summary.overdue_count }} overdue</span>{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
<div class="stat-icon" style="background:{% if summary.overdue_count %}#fee2e2;color:#991b1b{% else %}#f1f5f9;color:#475569{% endif %};"><i class="bi bi-receipt"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upcoming due -->
|
||||
{% if summary.upcoming %}
|
||||
<div class="pcard mb-4" style="border-left:4px solid {% if summary.overdue_count %}#ef4444{% else %}#f59e0b{% endif %};">
|
||||
<div class="pcard-title mb-3">Bills Due</div>
|
||||
<div class="table-wrap">
|
||||
<table class="pfm-table">
|
||||
<thead><tr><th>Provider</th><th>Period</th><th class="d-mob-none">Due</th><th class="text-end">Amount</th><th class="text-end">Action</th></tr></thead>
|
||||
<tbody>
|
||||
{% for b in summary.upcoming %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div style="width:28px;height:28px;border-radius:7px;background:{{ b.provider.color }}22;color:{{ b.provider.color }};display:flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0;">
|
||||
<i class="bi {{ b.provider.icon }}"></i>
|
||||
</div>
|
||||
<a href="{{ url_for('utilities.provider_detail', id=b.provider_id) }}" style="font-size:13px;font-weight:600;text-decoration:none;color:inherit;">{{ b.provider.name }}</a>
|
||||
</div>
|
||||
</td>
|
||||
<td style="font-size:12px;color:var(--muted);">{{ b.period_label }}</td>
|
||||
<td class="d-mob-none" style="font-size:12px;">
|
||||
{{ b.due_date.strftime('%b %d') }}
|
||||
{% if b.status == 'overdue' %}
|
||||
<span class="util-chip" style="background:#fee2e2;color:#991b1b;">{{ b.days_until_due | abs }}d late</span>
|
||||
{% elif b.status == 'due_soon' %}
|
||||
<span class="util-chip" style="background:#fef3c7;color:#92400e;">in {{ b.days_until_due }}d</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end mono fw-bold" style="font-size:13px;">{{ b.amount | currency }}</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('utilities.bill_pay', id=b.id) }}" class="btn btn-sm btn-primary" style="font-size:11px;padding:3px 10px;">Pay</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- Monthly spend chart -->
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="pcard h-100">
|
||||
<div class="pcard-title mb-3">Utility Spend — Last 12 Months</div>
|
||||
{% if chart.datasets %}
|
||||
<div style="height:280px;"><canvas id="monthlyChart"></canvas></div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5" style="font-size:13px;">No bills recorded yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- By type -->
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="pcard h-100">
|
||||
<div class="pcard-title mb-3">By Type — 12 Months</div>
|
||||
{% if type_totals %}
|
||||
{% set grand = type_totals | sum(attribute='total') %}
|
||||
{% for t in type_totals %}
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span style="font-size:12px;font-weight:600;"><i class="bi {{ t.icon }} me-1" style="color:{{ t.color }};"></i>{{ t.label }}</span>
|
||||
<span class="mono" style="font-size:12px;">{{ t.total | currency }}</span>
|
||||
</div>
|
||||
<div style="height:6px;background:#f1f5f9;border-radius:3px;">
|
||||
<div style="height:6px;border-radius:3px;background:{{ t.color }};width:{{ (t.total / grand * 100) if grand else 0 }}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-4" style="font-size:13px;">No data yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Providers -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="pcard-title mb-0">Providers</div>
|
||||
<a href="{{ url_for('utilities.provider_new') }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;"><i class="bi bi-plus-lg me-1"></i>Add Provider</a>
|
||||
</div>
|
||||
|
||||
{% if providers %}
|
||||
<div class="row g-3">
|
||||
{% for p in providers %}
|
||||
{% set s = summaries[p.id] %}
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<div class="util-card" style="border-left:4px solid {{ p.color }};">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div style="width:36px;height:36px;border-radius:9px;background:{{ p.color }}22;color:{{ p.color }};display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0;">
|
||||
<i class="bi {{ p.icon }}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:14px;font-weight:600;">{{ p.name }}</div>
|
||||
<div style="font-size:11px;color:var(--muted);">{{ p.type_label }}{% if s.unpaid_count %} · <span class="text-expense fw-bold">{{ s.unpaid_count }} unpaid</span>{% endif %}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm" style="background:none;border:none;color:var(--muted);padding:2px 6px;" data-bs-toggle="dropdown"><i class="bi bi-three-dots"></i></button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" style="font-size:13px;">
|
||||
<li><a class="dropdown-item" href="{{ url_for('utilities.bill_new', provider_id=p.id) }}"><i class="bi bi-plus-circle me-2"></i>Add Bill</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('utilities.provider_detail', id=p.id) }}"><i class="bi bi-graph-up me-2"></i>History & Usage</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('utilities.provider_edit', id=p.id) }}"><i class="bi bi-pencil me-2"></i>Edit</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if s.latest %}
|
||||
<div class="d-flex justify-content-between align-items-end">
|
||||
<div>
|
||||
<div style="font-size:11px;color:var(--muted);">Latest · {{ s.latest.period_start.strftime('%b %Y') }}</div>
|
||||
<div class="mono fw-bold" style="font-size:18px;">{{ s.latest.amount | currency }}</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
{% if s.amount_change_pct is not none %}
|
||||
<div class="{% if s.amount_change_pct > 0 %}trend-up{% else %}trend-down{% endif %}" style="font-size:12px;font-weight:600;">
|
||||
<i class="bi bi-arrow-{% if s.amount_change_pct > 0 %}up{% else %}down{% endif %}-short"></i>{{ s.amount_change_pct | abs }}%
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--muted);">vs prev period</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if s.latest.usage %}
|
||||
<div class="d-flex justify-content-between mt-2 pt-2" style="border-top:1px solid var(--border);font-size:12px;">
|
||||
<span class="text-muted">Usage</span>
|
||||
<span class="mono">{{ '%.1f' | format(s.latest.usage | float) }} {{ s.latest.usage_unit or p.usage_unit }}
|
||||
{% if s.usage_change_pct is not none %}
|
||||
<span class="{% if s.usage_change_pct > 0 %}trend-up{% else %}trend-down{% endif %}" style="font-size:11px;">({{ '+' if s.usage_change_pct > 0 else '' }}{{ s.usage_change_pct }}%)</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if s.latest.rate_per_unit %}
|
||||
<div class="d-flex justify-content-between mt-1" style="font-size:12px;">
|
||||
<span class="text-muted">Unit rate</span>
|
||||
<span class="mono">{{ current_user.currency_symbol }}{{ '%.4f' | format(s.latest.rate_per_unit) }} / {{ s.latest.usage_unit or p.usage_unit }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex justify-content-between mt-1" style="font-size:12px;">
|
||||
<span class="text-muted">12-month total</span>
|
||||
<span class="mono">{{ s.total_12mo | currency }}</span>
|
||||
</div>
|
||||
|
||||
{% if s.latest.status != 'paid' %}
|
||||
<a href="{{ url_for('utilities.bill_pay', id=s.latest.id) }}" class="btn btn-sm w-100 mt-3" style="background:{{ p.color }}22;color:{{ p.color }};border:1px solid {{ p.color }}44;font-size:12px;">
|
||||
<i class="bi bi-check2-circle me-1"></i>Mark Latest Paid
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('utilities.bill_new', provider_id=p.id) }}" class="btn btn-sm w-100 mt-3" style="background:{{ p.color }}22;color:{{ p.color }};border:1px solid {{ p.color }}44;font-size:12px;">
|
||||
<i class="bi bi-plus-lg me-1"></i>Add Bill
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-3" style="font-size:12px;">No bills yet</div>
|
||||
<a href="{{ url_for('utilities.bill_new', provider_id=p.id) }}" class="btn btn-sm w-100" style="background:{{ p.color }}22;color:{{ p.color }};border:1px solid {{ p.color }}44;font-size:12px;">
|
||||
<i class="bi bi-plus-lg me-1"></i>Add First Bill
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="pcard text-center py-5">
|
||||
<i class="bi bi-lightning-charge text-muted" style="font-size:3rem;"></i>
|
||||
<h5 class="mt-3 mb-1">No utility providers yet</h5>
|
||||
<p class="text-muted small mb-3">Add your electricity, water, gas, and internet providers to track bills and usage.</p>
|
||||
<a href="{{ url_for('utilities.provider_new') }}" class="btn btn-primary btn-sm">Add First Provider</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% if chart.datasets %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const dark = document.documentElement.getAttribute('data-pfm-dark') === '1';
|
||||
const grid = dark ? 'rgba(148,163,184,.15)' : 'rgba(148,163,184,.25)';
|
||||
const tick = dark ? '#94a3b8' : '#64748b';
|
||||
const sym = {{ current_user.currency_symbol | tojson }};
|
||||
|
||||
new Chart(document.getElementById('monthlyChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: {{ chart.labels | tojson }},
|
||||
datasets: {{ chart.datasets | tojson }}.map(function (d) {
|
||||
return { label: d.label, data: d.data, backgroundColor: d.color, borderRadius: 4, borderSkipped: false };
|
||||
})
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { position: 'bottom', labels: { boxWidth: 10, boxHeight: 10, font: { size: 11 }, color: tick } },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (c) { return c.dataset.label + ': ' + sym + c.parsed.y.toFixed(2); },
|
||||
footer: function (items) {
|
||||
const total = items.reduce(function (s, i) { return s + i.parsed.y; }, 0);
|
||||
return 'Total: ' + sym + total.toFixed(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { stacked: true, grid: { display: false }, ticks: { font: { size: 11 }, color: tick } },
|
||||
y: { stacked: true, grid: { color: grid }, ticks: { font: { size: 11 }, color: tick, callback: function (v) { return sym + v; } } }
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,88 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Link Payment{% endblock %}
|
||||
{% block page_title %}Link Existing Payment{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('utilities.provider_detail', id=bill.provider_id) }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">← {{ bill.provider.name }}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-9">
|
||||
|
||||
<!-- Bill summary -->
|
||||
<div class="pcard mb-3" style="border-left:4px solid {{ bill.provider.color }};">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div style="width:42px;height:42px;border-radius:10px;background:{{ bill.provider.color }}22;color:{{ bill.provider.color }};display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0;">
|
||||
<i class="bi {{ bill.provider.icon }}"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div style="font-size:14px;font-weight:600;">{{ bill.provider.name }}</div>
|
||||
<div style="font-size:12px;color:var(--muted);">{{ bill.period_label }}{% if bill.due_date %} · due {{ bill.due_date.strftime('%b %d, %Y') }}{% endif %}</div>
|
||||
</div>
|
||||
<div class="mono fw-bold" style="font-size:20px;">{{ bill.amount | currency }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pcard p-0">
|
||||
<div class="p-3" style="border-bottom:1px solid var(--border);">
|
||||
<div class="pcard-title mb-1">Candidate Transactions</div>
|
||||
<div class="text-muted" style="font-size:12px;">
|
||||
Expenses within 45 days of the due date, closest amount first. Transactions already linked to another bill are hidden.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if candidates %}
|
||||
<form method="POST" id="linkForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="table-wrap">
|
||||
<table class="pfm-table wide">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40px;"></th>
|
||||
<th>Date</th>
|
||||
<th>Description</th>
|
||||
<th class="d-mob-none">Account</th>
|
||||
<th class="d-mob-none">Category</th>
|
||||
<th class="text-end">Amount</th>
|
||||
<th class="text-end d-mob-none">Δ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in candidates %}
|
||||
{% set diff = (t.amount | float) - (bill.amount | float) %}
|
||||
<tr style="cursor:pointer;" onclick="this.querySelector('input[type=radio]').checked = true;">
|
||||
<td><input type="radio" name="transaction_id" value="{{ t.id }}" class="form-check-input" {% if loop.first %}checked{% endif %}></td>
|
||||
<td style="font-size:12px;">{{ t.date.strftime('%b %d, %Y') }}</td>
|
||||
<td style="font-size:13px;">{{ t.description | truncate(48) }}</td>
|
||||
<td class="d-mob-none" style="font-size:12px;color:var(--muted);">{{ t.account.name if t.account else '—' }}</td>
|
||||
<td class="d-mob-none" style="font-size:12px;color:var(--muted);">{{ t.category.name if t.category else '—' }}</td>
|
||||
<td class="text-end mono fw-bold" style="font-size:13px;">{{ t.amount | currency }}</td>
|
||||
<td class="text-end mono d-mob-none" style="font-size:12px;color:{% if diff == 0 %}#10b981{% else %}var(--muted){% endif %};">
|
||||
{% if diff == 0 %}exact{% else %}{{ '+' if diff > 0 else '' }}{{ '%.2f' | format(diff) }}{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 p-3" style="border-top:1px solid var(--border);">
|
||||
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-link-45deg me-1"></i>Link Selected Payment</button>
|
||||
<a href="{{ url_for('utilities.bill_pay', id=bill.id) }}" class="btn btn-outline-primary btn-sm">Create New Transaction Instead</a>
|
||||
<a href="{{ url_for('utilities.provider_detail', id=bill.provider_id) }}" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-search text-muted" style="font-size:2.5rem;"></i>
|
||||
<h6 class="mt-3 mb-1">No matching transactions</h6>
|
||||
<p class="text-muted small mb-3">Nothing unlinked was found near this bill's due date.</p>
|
||||
<a href="{{ url_for('utilities.bill_pay', id=bill.id) }}" class="btn btn-primary btn-sm">Create a Payment Transaction</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,76 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Pay Bill{% endblock %}
|
||||
{% block page_title %}Mark Bill Paid{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('utilities.provider_detail', id=bill.provider_id) }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">← {{ bill.provider.name }}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-md-8 col-lg-5">
|
||||
|
||||
<!-- Bill summary -->
|
||||
<div class="pcard mb-3" style="border-left:4px solid {{ bill.provider.color }};">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div style="width:42px;height:42px;border-radius:10px;background:{{ bill.provider.color }}22;color:{{ bill.provider.color }};display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0;">
|
||||
<i class="bi {{ bill.provider.icon }}"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div style="font-size:14px;font-weight:600;">{{ bill.provider.name }}</div>
|
||||
<div style="font-size:12px;color:var(--muted);">{{ bill.period_label }}</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="mono fw-bold" style="font-size:20px;">{{ bill.amount | currency }}</div>
|
||||
{% if bill.due_date %}
|
||||
<div style="font-size:11px;color:var(--muted);">Due {{ bill.due_date.strftime('%b %d, %Y') }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if bill.usage %}
|
||||
<div class="d-flex justify-content-between mt-3 pt-3" style="border-top:1px solid var(--border);font-size:12px;">
|
||||
<span class="text-muted">Usage</span>
|
||||
<span class="mono">{{ '%.1f' | format(bill.usage | float) }} {{ bill.usage_unit or '' }}
|
||||
{% if bill.rate_per_unit %}· {{ current_user.currency_symbol }}{{ '%.4f' | format(bill.rate_per_unit) }} per {{ bill.usage_unit or 'unit' }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="pcard">
|
||||
<p class="text-muted" style="font-size:12px;">
|
||||
This records an expense transaction for the bill amount and links the two together.
|
||||
If the payment already came in through a bank sync,
|
||||
<a href="{{ url_for('utilities.bill_link', id=bill.id) }}">link the existing transaction</a> instead to avoid a duplicate.
|
||||
</p>
|
||||
|
||||
<form method="POST" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.account_id.label(class="form-label fw-medium", style="font-size:13px;") }}
|
||||
{{ form.account_id(class="form-select" + (" is-invalid" if form.account_id.errors else "")) }}
|
||||
{% for e in form.account_id.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-7">
|
||||
{{ form.category_id.label(class="form-label fw-medium", style="font-size:13px;") }}
|
||||
{{ form.category_id(class="form-select") }}
|
||||
</div>
|
||||
<div class="col-5">
|
||||
{{ form.paid_date.label(class="form-label fw-medium", style="font-size:13px;") }}
|
||||
{{ form.paid_date(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
{{ form.submit(class="btn btn-primary") }}
|
||||
<a href="{{ url_for('utilities.provider_detail', id=bill.provider_id) }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,148 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
{% block page_title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('utilities.providers') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">← Providers</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-md-8 col-lg-6">
|
||||
<div class="pcard">
|
||||
<form method="POST" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12 col-sm-7">
|
||||
{{ form.name.label(class="form-label fw-medium", style="font-size:13px;") }}
|
||||
{{ form.name(class="form-control" + (" is-invalid" if form.name.errors else ""), placeholder="e.g. Pacific Gas & Electric") }}
|
||||
{% for e in form.name.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="col-12 col-sm-5">
|
||||
{{ form.utility_type.label(class="form-label fw-medium", style="font-size:13px;") }}
|
||||
{{ form.utility_type(class="form-select", id="typeSelect") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12 col-sm-7">
|
||||
{{ form.account_number.label(class="form-label fw-medium", style="font-size:13px;") }}
|
||||
{{ form.account_number(class="form-control", placeholder="Customer / meter account no.") }}
|
||||
</div>
|
||||
<div class="col-6 col-sm-5">
|
||||
{{ form.billing_day.label(class="form-label fw-medium", style="font-size:13px;") }}
|
||||
{{ form.billing_day(class="form-control", type="number", min=1, max=31, placeholder="Day of month") }}
|
||||
<small class="text-muted" style="font-size:11px;">Day the bill usually arrives</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.usage_unit.label(class="form-label fw-medium", style="font-size:13px;") }}
|
||||
{{ form.usage_unit(class="form-control", id="unitInput", placeholder="kWh, m³, GB — leave blank for no usage tracking") }}
|
||||
<small class="text-muted" style="font-size:11px;">Bills for this provider will record consumption in this unit.</small>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12 col-sm-6">
|
||||
{{ form.default_account_id.label(class="form-label fw-medium", style="font-size:13px;") }}
|
||||
{{ form.default_account_id(class="form-select") }}
|
||||
</div>
|
||||
<div class="col-12 col-sm-6">
|
||||
{{ form.category_id.label(class="form-label fw-medium", style="font-size:13px;") }}
|
||||
{{ form.category_id(class="form-select") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.notes.label(class="form-label fw-medium", style="font-size:13px;") }}
|
||||
{{ form.notes(class="form-control", rows=2, placeholder="Plan details, contract end date, support number…") }}
|
||||
</div>
|
||||
|
||||
<!-- Color -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-medium" style="font-size:13px;">Color</label>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
{% for c in colors %}
|
||||
<label style="cursor:pointer;">
|
||||
<input type="radio" name="color" value="{{ c }}" style="display:none;" {% if (provider and provider.color==c) or (not provider and loop.first) %}checked{% endif %}>
|
||||
<div style="width:26px;height:26px;border-radius:50%;background:{{ c }};border:3px solid transparent;" class="color-swatch" data-color="{{ c }}"></div>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{{ form.color(type="hidden", id="colorInput") }}
|
||||
</div>
|
||||
|
||||
<!-- Icon -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-medium" style="font-size:13px;">Icon</label>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
{% for icon_val in icons %}
|
||||
<label style="cursor:pointer;">
|
||||
<input type="radio" name="icon" value="{{ icon_val }}" style="display:none;" {% if provider and provider.icon==icon_val %}checked{% elif not provider and loop.first %}checked{% endif %}>
|
||||
<div style="width:34px;height:34px;border-radius:8px;background:#f1f5f9;display:flex;align-items:center;justify-content:center;font-size:16px;border:2px solid transparent;" class="icon-swatch">
|
||||
<i class="bi {{ icon_val }}"></i>
|
||||
</div>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{{ form.icon(type="hidden", id="iconInput") }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
{{ form.submit(class="btn btn-primary") }}
|
||||
<a href="{{ url_for('utilities.providers') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Suggest a usage unit + color/icon when the utility type changes (new providers only)
|
||||
const TYPE_DEFAULTS = {{ type_defaults | tojson }};
|
||||
const isNew = {{ 'false' if provider else 'true' }};
|
||||
|
||||
document.getElementById('typeSelect').addEventListener('change', function () {
|
||||
const d = TYPE_DEFAULTS[this.value];
|
||||
if (!d) return;
|
||||
const unit = document.getElementById('unitInput');
|
||||
if (isNew || !unit.value) unit.value = d.unit;
|
||||
if (isNew) {
|
||||
document.getElementById('colorInput').value = d.color;
|
||||
document.getElementById('iconInput').value = d.icon;
|
||||
document.querySelectorAll('.color-swatch').forEach(function (s) {
|
||||
const on = s.dataset.color.toLowerCase() === d.color.toLowerCase();
|
||||
s.style.borderColor = on ? '#fff' : 'transparent';
|
||||
s.style.outline = on ? '2px solid ' + d.color : 'none';
|
||||
});
|
||||
document.querySelectorAll('.icon-swatch').forEach(function (s) {
|
||||
const on = s.querySelector('i').className.indexOf(d.icon) !== -1;
|
||||
s.style.background = on ? '#dbeafe' : '#f1f5f9';
|
||||
s.style.borderColor = on ? '#3b82f6' : 'transparent';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.color-swatch').forEach(function (sw) {
|
||||
const inp = sw.closest('label').querySelector('input');
|
||||
inp.addEventListener('change', function () {
|
||||
document.getElementById('colorInput').value = this.value;
|
||||
document.querySelectorAll('.color-swatch').forEach(s => { s.style.borderColor='transparent'; s.style.outline='none'; });
|
||||
sw.style.borderColor='#fff'; sw.style.outline='2px solid '+this.value;
|
||||
});
|
||||
if (inp.checked) { sw.style.borderColor='#fff'; sw.style.outline='2px solid '+sw.dataset.color; document.getElementById('colorInput').value=sw.dataset.color; }
|
||||
});
|
||||
document.querySelectorAll('.icon-swatch').forEach(function (sw) {
|
||||
const inp = sw.closest('label').querySelector('input');
|
||||
inp.addEventListener('change', function () {
|
||||
document.getElementById('iconInput').value = this.value;
|
||||
document.querySelectorAll('.icon-swatch').forEach(s => { s.style.background='#f1f5f9'; s.style.borderColor='transparent'; });
|
||||
sw.style.background='#dbeafe'; sw.style.borderColor='#3b82f6';
|
||||
});
|
||||
if (inp.checked) { sw.style.background='#dbeafe'; sw.style.borderColor='#3b82f6'; document.getElementById('iconInput').value=inp.value; }
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,90 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Utility Providers{% endblock %}
|
||||
{% block page_title %}Utility Providers{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('utilities.index') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">← Utilities</a>
|
||||
<a href="{{ url_for('utilities.provider_new') }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i><span class="btn-label">Add Provider</span></a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if providers %}
|
||||
<div class="pcard p-0">
|
||||
<div class="table-wrap">
|
||||
<table class="pfm-table wide">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Provider</th>
|
||||
<th>Type</th>
|
||||
<th class="d-mob-none">Account No.</th>
|
||||
<th class="d-mob-none">Unit</th>
|
||||
<th class="d-mob-none">Pays From</th>
|
||||
<th class="text-end">Bills</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in providers %}
|
||||
<tr {% if not p.is_active %}style="opacity:.55;"{% endif %}>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div style="width:30px;height:30px;border-radius:8px;background:{{ p.color }}22;color:{{ p.color }};display:flex;align-items:center;justify-content:center;font-size:15px;flex-shrink:0;">
|
||||
<i class="bi {{ p.icon }}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('utilities.provider_detail', id=p.id) }}" style="font-size:13px;font-weight:600;text-decoration:none;color:inherit;">{{ p.name }}</a>
|
||||
{% if not p.is_active %}<span class="badge bg-secondary" style="font-size:9px;">Archived</span>{% endif %}
|
||||
{% if p.billing_day %}<div style="font-size:11px;color:var(--muted);">Bills on day {{ p.billing_day }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style="font-size:12px;">{{ p.type_label }}</td>
|
||||
<td class="d-mob-none mono" style="font-size:12px;color:var(--muted);">{{ p.account_number or '—' }}</td>
|
||||
<td class="d-mob-none" style="font-size:12px;">{{ p.usage_unit or '—' }}</td>
|
||||
<td class="d-mob-none" style="font-size:12px;">{{ p.default_account.name if p.default_account else '—' }}</td>
|
||||
<td class="text-end mono" style="font-size:12px;">{{ counts[p.id] }}</td>
|
||||
<td class="text-end">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm" style="background:none;border:none;color:var(--muted);padding:2px 6px;" data-bs-toggle="dropdown"><i class="bi bi-three-dots"></i></button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" style="font-size:13px;">
|
||||
<li><a class="dropdown-item" href="{{ url_for('utilities.provider_detail', id=p.id) }}"><i class="bi bi-graph-up me-2"></i>History & Usage</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('utilities.bill_new', provider_id=p.id) }}"><i class="bi bi-plus-circle me-2"></i>Add Bill</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('utilities.provider_edit', id=p.id) }}"><i class="bi bi-pencil me-2"></i>Edit</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<form method="POST" action="{{ url_for('utilities.provider_toggle', id=p.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="dropdown-item">
|
||||
<i class="bi bi-{{ 'arrow-counterclockwise' if not p.is_active else 'archive' }} me-2"></i>{{ 'Reactivate' if not p.is_active else 'Archive' }}
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
<li>
|
||||
<form method="POST" action="{{ url_for('utilities.provider_delete', id=p.id) }}"
|
||||
onsubmit="return confirm('Delete {{ p.name }}{% if counts[p.id] %} and its {{ counts[p.id] }} bill(s){% endif %}? This cannot be undone.')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="confirm_bills" value="yes">
|
||||
<button type="submit" class="dropdown-item text-danger"><i class="bi bi-trash me-2"></i>Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted mt-3" style="font-size:12px;">
|
||||
Archiving hides a provider from the utilities dashboard but keeps its bill history. Deleting removes the bills too — payment transactions created by PFM are left in place.
|
||||
</p>
|
||||
{% else %}
|
||||
<div class="pcard text-center py-5">
|
||||
<i class="bi bi-building text-muted" style="font-size:3rem;"></i>
|
||||
<h5 class="mt-3 mb-1">No providers yet</h5>
|
||||
<p class="text-muted small mb-3">Add electricity, water, gas, and internet providers to start tracking.</p>
|
||||
<a href="{{ url_for('utilities.provider_new') }}" class="btn btn-primary btn-sm">Add First Provider</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user