Aug 17 - Update with utilities management
This commit is contained in:
+14
-2
@@ -116,6 +116,7 @@ def create_app(config_name=None):
|
||||
from app.routes.plaid import plaid_bp
|
||||
from app.routes.logs import logs_bp
|
||||
from app.routes.bank_import import bank_import_bp
|
||||
from app.routes.utilities import utilities_bp
|
||||
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(auth_bp)
|
||||
@@ -134,13 +135,14 @@ def create_app(config_name=None):
|
||||
app.register_blueprint(plaid_bp)
|
||||
app.register_blueprint(logs_bp)
|
||||
app.register_blueprint(bank_import_bp)
|
||||
app.register_blueprint(utilities_bp)
|
||||
|
||||
with app.app_context():
|
||||
from app.models import (
|
||||
User, Account, Category, Receipt, RecurringRule,
|
||||
Transaction, Budget, Goal, GoalContribution,
|
||||
Investment, InvestmentTransaction, NetWorthSnapshot,
|
||||
AiInsight, FxRate
|
||||
AiInsight, FxRate, UtilityProvider, UtilityBill
|
||||
)
|
||||
from app.models.teller_enrollment import TellerEnrollment, TellerAccount
|
||||
from app.models.schwab_connection import SchwabConnection, SchwabAccount
|
||||
@@ -152,7 +154,7 @@ def create_app(config_name=None):
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
from flask_login import current_user as _u
|
||||
ctx = {'plaid_review_count': 0}
|
||||
ctx = {'plaid_review_count': 0, 'utility_due_count': 0}
|
||||
if _u.is_authenticated:
|
||||
try:
|
||||
from app.models.transaction import Transaction as _T
|
||||
@@ -162,6 +164,16 @@ def create_app(config_name=None):
|
||||
).count()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from app.models.utility import UtilityBill as _UB, DUE_SOON_DAYS
|
||||
from datetime import date as _d, timedelta as _td
|
||||
ctx['utility_due_count'] = _UB.query.filter(
|
||||
_UB.is_paid == False,
|
||||
_UB.due_date.isnot(None),
|
||||
_UB.due_date <= _d.today() + _td(days=DUE_SOON_DAYS),
|
||||
).count()
|
||||
except Exception:
|
||||
pass
|
||||
return ctx
|
||||
|
||||
# ── Session idle timeout ──────────────────────────────────────────────────
|
||||
|
||||
@@ -10,5 +10,10 @@ from app.models.investment import Investment, InvestmentTransaction
|
||||
from app.models.net_worth_snapshot import NetWorthSnapshot
|
||||
from app.models.ai_insight import AiInsight
|
||||
from app.models.fx_rate import FxRate
|
||||
from app.models.utility import UtilityProvider, UtilityBill
|
||||
|
||||
from app.models.teller_enrollment import TellerEnrollment, TellerAccount
|
||||
from app.models.schwab_connection import SchwabConnection, SchwabAccount
|
||||
from app.models.plaid_item import PlaidItem, PlaidAccount, PlaidSyncPreview
|
||||
from app.models.app_log import AppLog
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime, date
|
||||
|
||||
|
||||
# type key -> (label, icon, color, default usage unit)
|
||||
UTILITY_TYPE_META = {
|
||||
'electricity': ('Electricity', 'bi-lightning-charge', '#f59e0b', 'kWh'),
|
||||
'water': ('Water', 'bi-droplet', '#0ea5e9', 'm³'),
|
||||
'gas': ('Gas', 'bi-fire', '#ef4444', 'therms'),
|
||||
'internet': ('Internet', 'bi-wifi', '#6366f1', 'GB'),
|
||||
'phone': ('Phone', 'bi-phone', '#8b5cf6', 'GB'),
|
||||
'trash': ('Trash', 'bi-trash3', '#64748b', ''),
|
||||
'other': ('Other', 'bi-plug', '#94a3b8', ''),
|
||||
}
|
||||
|
||||
UTILITY_TYPES = list(UTILITY_TYPE_META.keys())
|
||||
|
||||
# A bill is flagged "due soon" this many days before its due date
|
||||
DUE_SOON_DAYS = 7
|
||||
|
||||
|
||||
class UtilityProvider(db.Model):
|
||||
"""A utility company / service you receive bills from (PG&E, Comcast, ...)."""
|
||||
|
||||
__tablename__ = 'utility_providers'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
utility_type = db.Column(
|
||||
db.Enum(*UTILITY_TYPES),
|
||||
nullable=False,
|
||||
default='electricity'
|
||||
)
|
||||
account_number = db.Column(db.String(100), nullable=True) # customer/meter account no.
|
||||
usage_unit = db.Column(db.String(20), nullable=True) # kWh, m³, GB — blank = no usage tracking
|
||||
|
||||
# Where bills get paid from, and what expense category they land in
|
||||
default_account_id = db.Column(db.Integer, db.ForeignKey('accounts.id'), nullable=True)
|
||||
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True)
|
||||
|
||||
billing_day = db.Column(db.Integer, nullable=True) # day of month the bill arrives
|
||||
color = db.Column(db.String(7), default='#8b5cf6')
|
||||
icon = db.Column(db.String(50), default='bi-lightning-charge')
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
default_account = db.relationship('Account')
|
||||
category = db.relationship('Category')
|
||||
bills = db.relationship('UtilityBill', back_populates='provider',
|
||||
lazy='dynamic', cascade='all, delete-orphan')
|
||||
|
||||
@property
|
||||
def type_label(self):
|
||||
return UTILITY_TYPE_META.get(self.utility_type, UTILITY_TYPE_META['other'])[0]
|
||||
|
||||
@property
|
||||
def tracks_usage(self):
|
||||
return bool(self.usage_unit)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<UtilityProvider {self.name} ({self.utility_type})>'
|
||||
|
||||
|
||||
class UtilityBill(db.Model):
|
||||
"""One billing period from a provider — amount, due date, and consumption."""
|
||||
|
||||
__tablename__ = 'utility_bills'
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('provider_id', 'period_start', name='uq_utility_bill_period'),
|
||||
)
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
provider_id = db.Column(db.Integer, db.ForeignKey('utility_providers.id'), nullable=False)
|
||||
|
||||
period_start = db.Column(db.Date, nullable=False, index=True)
|
||||
period_end = db.Column(db.Date, nullable=False)
|
||||
amount = db.Column(db.Numeric(15, 2), nullable=False)
|
||||
due_date = db.Column(db.Date, nullable=True, index=True)
|
||||
|
||||
is_paid = db.Column(db.Boolean, default=False, index=True)
|
||||
paid_date = db.Column(db.Date, nullable=True)
|
||||
transaction_id = db.Column(db.Integer, db.ForeignKey('transactions.id'), nullable=True)
|
||||
|
||||
# Consumption. usage is either typed directly or derived from meter readings.
|
||||
# Column is named usage_amount — USAGE is a reserved word in MySQL.
|
||||
usage = db.Column('usage_amount', db.Numeric(15, 3), nullable=True)
|
||||
usage_unit = db.Column(db.String(20), nullable=True) # snapshot of provider unit at entry time
|
||||
meter_start = db.Column(db.Numeric(15, 3), nullable=True)
|
||||
meter_end = db.Column(db.Numeric(15, 3), nullable=True)
|
||||
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
provider = db.relationship('UtilityProvider', back_populates='bills')
|
||||
transaction = db.relationship('Transaction')
|
||||
|
||||
# ── derived ──────────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def period_label(self):
|
||||
if self.period_start.year == self.period_end.year:
|
||||
return f"{self.period_start.strftime('%b %d')} – {self.period_end.strftime('%b %d, %Y')}"
|
||||
return f"{self.period_start.strftime('%b %d, %Y')} – {self.period_end.strftime('%b %d, %Y')}"
|
||||
|
||||
@property
|
||||
def period_month(self):
|
||||
"""Month the bill is attributed to, for grouping — the period start month."""
|
||||
return self.period_start.strftime('%Y-%m')
|
||||
|
||||
@property
|
||||
def days_until_due(self):
|
||||
if not self.due_date or self.is_paid:
|
||||
return None
|
||||
return (self.due_date - date.today()).days
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
"""paid | overdue | due_soon | unpaid"""
|
||||
if self.is_paid:
|
||||
return 'paid'
|
||||
days = self.days_until_due
|
||||
if days is None:
|
||||
return 'unpaid'
|
||||
if days < 0:
|
||||
return 'overdue'
|
||||
if days <= DUE_SOON_DAYS:
|
||||
return 'due_soon'
|
||||
return 'unpaid'
|
||||
|
||||
@property
|
||||
def rate_per_unit(self):
|
||||
"""Cost per kWh / m³ / GB for this period, or None when usage isn't tracked."""
|
||||
if not self.usage:
|
||||
return None
|
||||
u = float(self.usage)
|
||||
if u <= 0:
|
||||
return None
|
||||
return float(self.amount) / u
|
||||
|
||||
@property
|
||||
def days_in_period(self):
|
||||
return max((self.period_end - self.period_start).days + 1, 1)
|
||||
|
||||
@property
|
||||
def daily_cost(self):
|
||||
return float(self.amount) / self.days_in_period
|
||||
|
||||
def sync_usage_from_meter(self):
|
||||
"""If both meter readings are present, usage is the difference between them."""
|
||||
if self.meter_start is not None and self.meter_end is not None:
|
||||
diff = float(self.meter_end) - float(self.meter_start)
|
||||
if diff >= 0:
|
||||
self.usage = diff
|
||||
return self.usage
|
||||
|
||||
def __repr__(self):
|
||||
return f'<UtilityBill provider={self.provider_id} {self.period_start} {self.amount}>'
|
||||
@@ -0,0 +1,533 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||
from flask_login import login_required
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import (StringField, DecimalField, DateField, SelectField, IntegerField,
|
||||
TextAreaField, SubmitField)
|
||||
from wtforms.validators import DataRequired, Optional, NumberRange, Length
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from datetime import date, timedelta
|
||||
import logging
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.utility import (UtilityProvider, UtilityBill,
|
||||
UTILITY_TYPE_META, UTILITY_TYPES)
|
||||
from app.models.account import Account
|
||||
from app.models.category import Category
|
||||
from app.models.transaction import Transaction
|
||||
from app.services.account_service import calc_balance
|
||||
from app.services import utility_service
|
||||
|
||||
utilities_bp = Blueprint('utilities', __name__, url_prefix='/utilities')
|
||||
|
||||
log = logging.getLogger('app.utilities')
|
||||
|
||||
UTILITY_COLORS = ['#f59e0b', '#0ea5e9', '#ef4444', '#6366f1', '#8b5cf6', '#10b981', '#ec4899', '#64748b']
|
||||
UTILITY_ICONS = [
|
||||
'bi-lightning-charge', 'bi-droplet', 'bi-fire', 'bi-wifi', 'bi-phone',
|
||||
'bi-trash3', 'bi-plug', 'bi-thermometer-half', 'bi-tv', 'bi-router',
|
||||
]
|
||||
|
||||
|
||||
def _account_choices():
|
||||
accts = Account.query.filter_by(is_active=True).order_by(Account.name).all()
|
||||
return [('', '— None —')] + [(str(a.id), a.name) for a in accts]
|
||||
|
||||
|
||||
def _category_choices():
|
||||
cats = (Category.query
|
||||
.filter(Category.is_active == True,
|
||||
Category.category_type.in_(['expense', 'both']))
|
||||
.order_by(Category.name).all())
|
||||
return [('', '— None —')] + [(str(c.id), c.name) for c in cats]
|
||||
|
||||
|
||||
def _provider_choices(include_inactive=False):
|
||||
q = UtilityProvider.query
|
||||
if not include_inactive:
|
||||
q = q.filter_by(is_active=True)
|
||||
provs = q.order_by(UtilityProvider.name).all()
|
||||
return [(str(p.id), f'{p.name} · {p.type_label}') for p in provs]
|
||||
|
||||
|
||||
def _type_defaults():
|
||||
"""Per-type unit/icon/color suggestions for the provider form's JS."""
|
||||
return {k: {'unit': v[3], 'icon': v[1], 'color': v[2]}
|
||||
for k, v in UTILITY_TYPE_META.items()}
|
||||
|
||||
|
||||
def _provider_units():
|
||||
"""provider id -> usage unit, so the bill form can label consumption fields."""
|
||||
return {str(p.id): (p.usage_unit or '') for p in UtilityProvider.query.all()}
|
||||
|
||||
|
||||
def _safe_int(value, default=None):
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
# ── forms ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class ProviderForm(FlaskForm):
|
||||
name = StringField('Provider Name', validators=[DataRequired(), Length(1, 100)])
|
||||
utility_type = SelectField('Utility Type', validators=[DataRequired()],
|
||||
choices=[(k, v[0]) for k, v in UTILITY_TYPE_META.items()])
|
||||
account_number = StringField('Account Number', validators=[Optional(), Length(max=100)])
|
||||
usage_unit = StringField('Usage Unit', validators=[Optional(), Length(max=20)])
|
||||
default_account_id = SelectField('Pay From Account', validators=[Optional()])
|
||||
category_id = SelectField('Expense Category', validators=[Optional()])
|
||||
billing_day = IntegerField('Billing Day', validators=[Optional(), NumberRange(min=1, max=31)])
|
||||
color = StringField('Color', default='#8b5cf6')
|
||||
icon = StringField('Icon', default='bi-lightning-charge')
|
||||
notes = TextAreaField('Notes', validators=[Optional()])
|
||||
submit = SubmitField('Save Provider')
|
||||
|
||||
|
||||
class BillForm(FlaskForm):
|
||||
provider_id = SelectField('Provider', validators=[DataRequired()])
|
||||
period_start = DateField('Period Start', validators=[DataRequired()])
|
||||
period_end = DateField('Period End', validators=[DataRequired()])
|
||||
amount = DecimalField('Amount', validators=[DataRequired(), NumberRange(min=0)], places=2)
|
||||
due_date = DateField('Due Date', validators=[Optional()])
|
||||
usage = DecimalField('Usage', validators=[Optional(), NumberRange(min=0)], places=3)
|
||||
meter_start = DecimalField('Meter Start', validators=[Optional(), NumberRange(min=0)], places=3)
|
||||
meter_end = DecimalField('Meter End', validators=[Optional(), NumberRange(min=0)], places=3)
|
||||
notes = TextAreaField('Notes', validators=[Optional()])
|
||||
submit = SubmitField('Save Bill')
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
if not super().validate(extra_validators):
|
||||
return False
|
||||
ok = True
|
||||
if self.period_end.data and self.period_start.data and \
|
||||
self.period_end.data < self.period_start.data:
|
||||
self.period_end.errors.append('Period end must be on or after period start.')
|
||||
ok = False
|
||||
if self.meter_start.data is not None and self.meter_end.data is not None and \
|
||||
self.meter_end.data < self.meter_start.data:
|
||||
self.meter_end.errors.append('Meter end reading is lower than the start reading.')
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
|
||||
class PayForm(FlaskForm):
|
||||
account_id = SelectField('Pay From Account', validators=[DataRequired()])
|
||||
category_id = SelectField('Category', validators=[Optional()])
|
||||
paid_date = DateField('Payment Date', validators=[DataRequired()], default=date.today)
|
||||
submit = SubmitField('Mark Paid')
|
||||
|
||||
|
||||
# ── index ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@utilities_bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
providers = (UtilityProvider.query
|
||||
.filter_by(is_active=True)
|
||||
.order_by(UtilityProvider.utility_type, UtilityProvider.name)
|
||||
.all())
|
||||
|
||||
summary = utility_service.dashboard_summary()
|
||||
summaries = {p.id: utility_service.provider_summary(p) for p in providers}
|
||||
|
||||
return render_template('utilities/index.html',
|
||||
providers=providers,
|
||||
summaries=summaries,
|
||||
summary=summary,
|
||||
chart=utility_service.monthly_series(12),
|
||||
type_totals=utility_service.type_totals(12),
|
||||
type_meta=UTILITY_TYPE_META)
|
||||
|
||||
|
||||
# ── providers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@utilities_bp.route('/providers')
|
||||
@login_required
|
||||
def providers():
|
||||
provs = (UtilityProvider.query
|
||||
.order_by(UtilityProvider.is_active.desc(),
|
||||
UtilityProvider.utility_type, UtilityProvider.name)
|
||||
.all())
|
||||
counts = {p.id: p.bills.count() for p in provs}
|
||||
return render_template('utilities/providers.html', providers=provs, counts=counts)
|
||||
|
||||
|
||||
@utilities_bp.route('/providers/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def provider_new():
|
||||
form = ProviderForm()
|
||||
form.default_account_id.choices = _account_choices()
|
||||
form.category_id.choices = _category_choices()
|
||||
|
||||
if request.method == 'GET':
|
||||
# Default to the seeded "Utilities" expense category when it exists
|
||||
util_cat = Category.query.filter(Category.name == 'Utilities').first()
|
||||
if util_cat:
|
||||
form.category_id.data = str(util_cat.id)
|
||||
|
||||
if form.validate_on_submit():
|
||||
meta = UTILITY_TYPE_META.get(form.utility_type.data, UTILITY_TYPE_META['other'])
|
||||
prov = UtilityProvider(
|
||||
name=form.name.data.strip(),
|
||||
utility_type=form.utility_type.data,
|
||||
account_number=(form.account_number.data or '').strip() or None,
|
||||
usage_unit=(form.usage_unit.data or '').strip() or None,
|
||||
default_account_id=_safe_int(form.default_account_id.data),
|
||||
category_id=_safe_int(form.category_id.data),
|
||||
billing_day=form.billing_day.data,
|
||||
color=form.color.data or meta[2],
|
||||
icon=form.icon.data or meta[1],
|
||||
notes=form.notes.data,
|
||||
)
|
||||
db.session.add(prov)
|
||||
db.session.commit()
|
||||
flash(f'Provider "{prov.name}" added.', 'success')
|
||||
return redirect(url_for('utilities.provider_detail', id=prov.id))
|
||||
|
||||
return render_template('utilities/provider_form.html', form=form, title='New Provider',
|
||||
colors=UTILITY_COLORS, icons=UTILITY_ICONS,
|
||||
type_defaults=_type_defaults())
|
||||
|
||||
|
||||
@utilities_bp.route('/providers/<int:id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def provider_edit(id):
|
||||
prov = db.get_or_404(UtilityProvider, id)
|
||||
form = ProviderForm(obj=prov)
|
||||
form.default_account_id.choices = _account_choices()
|
||||
form.category_id.choices = _category_choices()
|
||||
|
||||
if request.method == 'GET':
|
||||
form.default_account_id.data = str(prov.default_account_id) if prov.default_account_id else ''
|
||||
form.category_id.data = str(prov.category_id) if prov.category_id else ''
|
||||
|
||||
if form.validate_on_submit():
|
||||
prov.name = form.name.data.strip()
|
||||
prov.utility_type = form.utility_type.data
|
||||
prov.account_number = (form.account_number.data or '').strip() or None
|
||||
prov.usage_unit = (form.usage_unit.data or '').strip() or None
|
||||
prov.default_account_id = _safe_int(form.default_account_id.data)
|
||||
prov.category_id = _safe_int(form.category_id.data)
|
||||
prov.billing_day = form.billing_day.data
|
||||
prov.color = form.color.data or prov.color
|
||||
prov.icon = form.icon.data or prov.icon
|
||||
prov.notes = form.notes.data
|
||||
db.session.commit()
|
||||
flash('Provider updated.', 'success')
|
||||
return redirect(url_for('utilities.provider_detail', id=prov.id))
|
||||
|
||||
return render_template('utilities/provider_form.html', form=form, title='Edit Provider',
|
||||
provider=prov, colors=UTILITY_COLORS, icons=UTILITY_ICONS,
|
||||
type_defaults=_type_defaults())
|
||||
|
||||
|
||||
@utilities_bp.route('/providers/<int:id>/toggle', methods=['POST'])
|
||||
@login_required
|
||||
def provider_toggle(id):
|
||||
prov = db.get_or_404(UtilityProvider, id)
|
||||
prov.is_active = not prov.is_active
|
||||
db.session.commit()
|
||||
flash(f'Provider "{prov.name}" {"reactivated" if prov.is_active else "archived"}.', 'info')
|
||||
return redirect(url_for('utilities.providers'))
|
||||
|
||||
|
||||
@utilities_bp.route('/providers/<int:id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def provider_delete(id):
|
||||
prov = db.get_or_404(UtilityProvider, id)
|
||||
count = prov.bills.count()
|
||||
if count and request.form.get('confirm_bills') != 'yes':
|
||||
flash(f'"{prov.name}" still has {count} bill(s). Archive it instead, '
|
||||
f'or confirm deletion from the provider page.', 'warning')
|
||||
return redirect(url_for('utilities.providers'))
|
||||
|
||||
name = prov.name
|
||||
db.session.delete(prov) # cascades to its bills
|
||||
db.session.commit()
|
||||
flash(f'Provider "{name}" and {count} bill(s) deleted.', 'info')
|
||||
return redirect(url_for('utilities.providers'))
|
||||
|
||||
|
||||
@utilities_bp.route('/providers/<int:id>')
|
||||
@login_required
|
||||
def provider_detail(id):
|
||||
prov = db.get_or_404(UtilityProvider, id)
|
||||
page = _safe_int(request.args.get('page'), 1) or 1
|
||||
pagination = (prov.bills
|
||||
.order_by(UtilityBill.period_start.desc())
|
||||
.paginate(page=page, per_page=24, error_out=False))
|
||||
|
||||
return render_template('utilities/detail.html',
|
||||
provider=prov,
|
||||
bills=pagination.items,
|
||||
pagination=pagination,
|
||||
stats=utility_service.provider_summary(prov),
|
||||
series=utility_service.usage_series(prov, 24))
|
||||
|
||||
|
||||
# ── bills ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@utilities_bp.route('/bills')
|
||||
@login_required
|
||||
def bills():
|
||||
page = _safe_int(request.args.get('page'), 1) or 1
|
||||
provider_id = _safe_int(request.args.get('provider_id'))
|
||||
utility_type = request.args.get('utility_type') or ''
|
||||
status = request.args.get('status') or ''
|
||||
year = _safe_int(request.args.get('year'))
|
||||
|
||||
q = UtilityBill.query.join(UtilityProvider)
|
||||
|
||||
if provider_id:
|
||||
q = q.filter(UtilityBill.provider_id == provider_id)
|
||||
if utility_type in UTILITY_TYPES:
|
||||
q = q.filter(UtilityProvider.utility_type == utility_type)
|
||||
if year:
|
||||
q = q.filter(UtilityBill.period_start >= date(year, 1, 1),
|
||||
UtilityBill.period_start <= date(year, 12, 31))
|
||||
if status == 'paid':
|
||||
q = q.filter(UtilityBill.is_paid == True)
|
||||
elif status == 'unpaid':
|
||||
q = q.filter(UtilityBill.is_paid == False)
|
||||
elif status == 'overdue':
|
||||
q = q.filter(UtilityBill.is_paid == False,
|
||||
UtilityBill.due_date.isnot(None),
|
||||
UtilityBill.due_date < date.today())
|
||||
|
||||
pagination = (q.order_by(UtilityBill.period_start.desc(), UtilityBill.id.desc())
|
||||
.paginate(page=page, per_page=30, error_out=False))
|
||||
|
||||
years = sorted({row[0].year for row in
|
||||
db.session.query(UtilityBill.period_start).all()}, reverse=True)
|
||||
|
||||
return render_template('utilities/bills.html',
|
||||
bills=pagination.items,
|
||||
pagination=pagination,
|
||||
providers=UtilityProvider.query.order_by(UtilityProvider.name).all(),
|
||||
type_meta=UTILITY_TYPE_META,
|
||||
provider_id=provider_id, utility_type=utility_type,
|
||||
status=status, year=year, years=years)
|
||||
|
||||
|
||||
def _apply_bill_form(bill, form):
|
||||
bill.provider_id = int(form.provider_id.data)
|
||||
bill.period_start = form.period_start.data
|
||||
bill.period_end = form.period_end.data
|
||||
bill.amount = form.amount.data
|
||||
bill.due_date = form.due_date.data
|
||||
bill.meter_start = form.meter_start.data
|
||||
bill.meter_end = form.meter_end.data
|
||||
bill.usage = form.usage.data
|
||||
bill.notes = form.notes.data
|
||||
|
||||
provider = db.session.get(UtilityProvider, bill.provider_id)
|
||||
bill.usage_unit = provider.usage_unit if provider else None
|
||||
# Meter readings win over a typed usage figure
|
||||
bill.sync_usage_from_meter()
|
||||
|
||||
|
||||
@utilities_bp.route('/bills/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def bill_new():
|
||||
form = BillForm()
|
||||
form.provider_id.choices = _provider_choices()
|
||||
|
||||
if not form.provider_id.choices:
|
||||
flash('Add a utility provider before recording bills.', 'warning')
|
||||
return redirect(url_for('utilities.provider_new'))
|
||||
|
||||
preset = _safe_int(request.args.get('provider_id'))
|
||||
if request.method == 'GET':
|
||||
if preset:
|
||||
form.provider_id.data = str(preset)
|
||||
# Default to last month's billing period
|
||||
today = date.today()
|
||||
first_this = today.replace(day=1)
|
||||
form.period_end.data = first_this - timedelta(days=1)
|
||||
form.period_start.data = form.period_end.data.replace(day=1)
|
||||
|
||||
if form.validate_on_submit():
|
||||
bill = UtilityBill()
|
||||
_apply_bill_form(bill, form)
|
||||
db.session.add(bill)
|
||||
try:
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
flash('A bill for that provider and period start already exists.', 'danger')
|
||||
return render_template('utilities/bill_form.html', form=form, title='New Bill',
|
||||
provider_units=_provider_units())
|
||||
|
||||
flash('Bill recorded.', 'success')
|
||||
if request.form.get('pay_now') == 'yes':
|
||||
return redirect(url_for('utilities.bill_pay', id=bill.id))
|
||||
return redirect(url_for('utilities.provider_detail', id=bill.provider_id))
|
||||
|
||||
return render_template('utilities/bill_form.html', form=form, title='New Bill',
|
||||
provider_units=_provider_units())
|
||||
|
||||
|
||||
@utilities_bp.route('/bills/<int:id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def bill_edit(id):
|
||||
bill = db.get_or_404(UtilityBill, id)
|
||||
form = BillForm(obj=bill)
|
||||
form.provider_id.choices = _provider_choices(include_inactive=True)
|
||||
|
||||
if request.method == 'GET':
|
||||
form.provider_id.data = str(bill.provider_id)
|
||||
|
||||
if form.validate_on_submit():
|
||||
_apply_bill_form(bill, form)
|
||||
try:
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
flash('A bill for that provider and period start already exists.', 'danger')
|
||||
return render_template('utilities/bill_form.html', form=form, title='Edit Bill',
|
||||
bill=bill, provider_units=_provider_units())
|
||||
|
||||
flash('Bill updated.', 'success')
|
||||
return redirect(url_for('utilities.provider_detail', id=bill.provider_id))
|
||||
|
||||
return render_template('utilities/bill_form.html', form=form, title='Edit Bill',
|
||||
bill=bill, provider_units=_provider_units())
|
||||
|
||||
|
||||
@utilities_bp.route('/bills/<int:id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def bill_delete(id):
|
||||
bill = db.get_or_404(UtilityBill, id)
|
||||
provider_id = bill.provider_id
|
||||
|
||||
# Only remove the payment transaction if this feature created it
|
||||
txn = bill.transaction
|
||||
drop_txn = utility_service.is_generated_payment(bill)
|
||||
account_id = txn.account_id if txn else None
|
||||
|
||||
db.session.delete(bill)
|
||||
if drop_txn:
|
||||
db.session.delete(txn)
|
||||
db.session.commit()
|
||||
|
||||
if drop_txn and account_id:
|
||||
calc_balance(account_id)
|
||||
flash('Bill and its payment transaction deleted.', 'info')
|
||||
else:
|
||||
flash('Bill deleted.', 'info')
|
||||
return redirect(url_for('utilities.provider_detail', id=provider_id))
|
||||
|
||||
|
||||
# ── payment ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@utilities_bp.route('/bills/<int:id>/pay', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def bill_pay(id):
|
||||
bill = db.get_or_404(UtilityBill, id)
|
||||
if bill.is_paid:
|
||||
flash('That bill is already marked paid.', 'info')
|
||||
return redirect(url_for('utilities.provider_detail', id=bill.provider_id))
|
||||
|
||||
form = PayForm()
|
||||
form.account_id.choices = [c for c in _account_choices() if c[0]]
|
||||
form.category_id.choices = _category_choices()
|
||||
|
||||
if not form.account_id.choices:
|
||||
flash('Create an active account before paying bills.', 'warning')
|
||||
return redirect(url_for('accounts.index'))
|
||||
|
||||
if request.method == 'GET':
|
||||
prov = bill.provider
|
||||
if prov.default_account_id:
|
||||
form.account_id.data = str(prov.default_account_id)
|
||||
if prov.category_id:
|
||||
form.category_id.data = str(prov.category_id)
|
||||
form.paid_date.data = bill.due_date or date.today()
|
||||
|
||||
if form.validate_on_submit():
|
||||
txn = utility_service.build_payment_transaction(
|
||||
bill,
|
||||
account_id=int(form.account_id.data),
|
||||
paid_date=form.paid_date.data,
|
||||
category_id=_safe_int(form.category_id.data),
|
||||
)
|
||||
db.session.flush() # need txn.id before linking
|
||||
|
||||
bill.is_paid = True
|
||||
bill.paid_date = form.paid_date.data
|
||||
bill.transaction_id = txn.id
|
||||
db.session.commit()
|
||||
|
||||
calc_balance(txn.account_id)
|
||||
log.info('[utilities] bill %s marked paid — txn %s', bill.id, txn.id)
|
||||
|
||||
from app.services.alert_service import check_and_flash_budget_alerts
|
||||
check_and_flash_budget_alerts(flash)
|
||||
|
||||
flash(f'Bill paid — expense of {bill.amount} recorded.', 'success')
|
||||
return redirect(url_for('utilities.provider_detail', id=bill.provider_id))
|
||||
|
||||
return render_template('utilities/pay.html', form=form, bill=bill)
|
||||
|
||||
|
||||
@utilities_bp.route('/bills/<int:id>/link', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def bill_link(id):
|
||||
bill = db.get_or_404(UtilityBill, id)
|
||||
|
||||
if request.method == 'POST':
|
||||
txn_id = _safe_int(request.form.get('transaction_id'))
|
||||
txn = db.session.get(Transaction, txn_id) if txn_id else None
|
||||
if not txn:
|
||||
flash('Select a transaction to link.', 'warning')
|
||||
return redirect(url_for('utilities.bill_link', id=bill.id))
|
||||
|
||||
clash = UtilityBill.query.filter(UtilityBill.transaction_id == txn.id,
|
||||
UtilityBill.id != bill.id).first()
|
||||
if clash:
|
||||
flash('That transaction is already linked to another bill.', 'danger')
|
||||
return redirect(url_for('utilities.bill_link', id=bill.id))
|
||||
|
||||
bill.transaction_id = txn.id
|
||||
bill.is_paid = True
|
||||
bill.paid_date = txn.date
|
||||
db.session.commit()
|
||||
flash('Payment linked.', 'success')
|
||||
return redirect(url_for('utilities.provider_detail', id=bill.provider_id))
|
||||
|
||||
return render_template('utilities/link.html', bill=bill,
|
||||
candidates=utility_service.candidate_transactions(bill))
|
||||
|
||||
|
||||
@utilities_bp.route('/bills/<int:id>/unpay', methods=['POST'])
|
||||
@login_required
|
||||
def bill_unpay(id):
|
||||
bill = db.get_or_404(UtilityBill, id)
|
||||
txn = bill.transaction
|
||||
drop_txn = utility_service.is_generated_payment(bill)
|
||||
account_id = txn.account_id if txn else None
|
||||
|
||||
bill.is_paid = False
|
||||
bill.paid_date = None
|
||||
bill.transaction_id = None
|
||||
if drop_txn:
|
||||
db.session.delete(txn)
|
||||
db.session.commit()
|
||||
|
||||
if drop_txn and account_id:
|
||||
calc_balance(account_id)
|
||||
flash('Bill reopened — its payment transaction was removed.', 'info')
|
||||
else:
|
||||
flash('Bill reopened — the linked transaction was left in place.', 'info')
|
||||
return redirect(url_for('utilities.provider_detail', id=bill.provider_id))
|
||||
|
||||
|
||||
# ── api ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@utilities_bp.route('/api/usage/<int:provider_id>')
|
||||
@login_required
|
||||
def api_usage(provider_id):
|
||||
prov = db.get_or_404(UtilityProvider, provider_id)
|
||||
months = _safe_int(request.args.get('months'), 24) or 24
|
||||
return jsonify(utility_service.usage_series(prov, min(max(months, 3), 60)))
|
||||
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
Utility Service — bill roll-ups, usage trends, and payment matching.
|
||||
|
||||
All aggregation is done in Python rather than SQL: a household has a few
|
||||
hundred bills at most, and grouping by billing period in the DB would mean
|
||||
MySQL-specific date functions.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.utility import UtilityProvider, UtilityBill, UTILITY_TYPE_META
|
||||
from app.models.transaction import Transaction
|
||||
|
||||
|
||||
def _month_keys(months):
|
||||
"""['2026-01', ...] ending with the current month."""
|
||||
today = date.today().replace(day=1)
|
||||
return [(today - relativedelta(months=i)).strftime('%Y-%m')
|
||||
for i in range(months - 1, -1, -1)]
|
||||
|
||||
|
||||
def _pct_change(current, previous):
|
||||
if previous in (None, 0) or current is None:
|
||||
return None
|
||||
return round(((float(current) - float(previous)) / abs(float(previous))) * 100, 1)
|
||||
|
||||
|
||||
# ── dashboard ────────────────────────────────────────────────────────────────
|
||||
|
||||
def dashboard_summary():
|
||||
"""Headline numbers for the utilities index page."""
|
||||
today = date.today()
|
||||
month_start = today.replace(day=1)
|
||||
last_month_start = month_start - relativedelta(months=1)
|
||||
year_start = today.replace(month=1, day=1)
|
||||
|
||||
bills = UtilityBill.query.all()
|
||||
|
||||
this_month = sum(float(b.amount) for b in bills if b.period_start >= month_start)
|
||||
last_month = sum(float(b.amount) for b in bills
|
||||
if last_month_start <= b.period_start < month_start)
|
||||
ytd = sum(float(b.amount) for b in bills if b.period_start >= year_start)
|
||||
|
||||
unpaid = [b for b in bills if not b.is_paid]
|
||||
overdue = [b for b in unpaid if b.status == 'overdue']
|
||||
|
||||
# Next bill coming due — unpaid, has a due date, soonest first
|
||||
upcoming = sorted([b for b in unpaid if b.due_date], key=lambda b: b.due_date)
|
||||
|
||||
# 12-month average of full months (excludes the in-progress current month)
|
||||
twelve_ago = month_start - relativedelta(months=12)
|
||||
past = [b for b in bills if twelve_ago <= b.period_start < month_start]
|
||||
months_span = len({b.period_month for b in past}) or 1
|
||||
avg_monthly = sum(float(b.amount) for b in past) / months_span
|
||||
|
||||
return {
|
||||
'this_month': this_month,
|
||||
'last_month': last_month,
|
||||
'month_change_pct': _pct_change(this_month, last_month),
|
||||
'ytd': ytd,
|
||||
'avg_monthly': avg_monthly,
|
||||
'unpaid_count': len(unpaid),
|
||||
'unpaid_total': sum(float(b.amount) for b in unpaid),
|
||||
'overdue_count': len(overdue),
|
||||
'next_due': upcoming[0] if upcoming else None,
|
||||
'upcoming': upcoming[:5],
|
||||
}
|
||||
|
||||
|
||||
def monthly_series(months=12):
|
||||
"""
|
||||
Stacked bar data: one dataset per utility type, one point per month.
|
||||
Returns {labels, datasets:[{label, key, color, data}]}.
|
||||
"""
|
||||
keys = _month_keys(months)
|
||||
index = {k: i for i, k in enumerate(keys)}
|
||||
cutoff = date.today().replace(day=1) - relativedelta(months=months - 1)
|
||||
|
||||
bills = (UtilityBill.query
|
||||
.join(UtilityProvider)
|
||||
.filter(UtilityBill.period_start >= cutoff)
|
||||
.all())
|
||||
|
||||
buckets = {}
|
||||
for b in bills:
|
||||
i = index.get(b.period_month)
|
||||
if i is None:
|
||||
continue
|
||||
t = b.provider.utility_type
|
||||
buckets.setdefault(t, [0.0] * len(keys))[i] += float(b.amount)
|
||||
|
||||
datasets = []
|
||||
for t, meta in UTILITY_TYPE_META.items():
|
||||
if t not in buckets:
|
||||
continue
|
||||
datasets.append({
|
||||
'label': meta[0],
|
||||
'key': t,
|
||||
'color': meta[2],
|
||||
'data': [round(v, 2) for v in buckets[t]],
|
||||
})
|
||||
|
||||
labels = [date(int(k[:4]), int(k[5:]), 1).strftime('%b %y') for k in keys]
|
||||
return {'labels': labels, 'datasets': datasets}
|
||||
|
||||
|
||||
def type_totals(months=12):
|
||||
"""Spend per utility type over the window, biggest first."""
|
||||
cutoff = date.today().replace(day=1) - relativedelta(months=months - 1)
|
||||
rows = (db.session.query(
|
||||
UtilityProvider.utility_type,
|
||||
func.coalesce(func.sum(UtilityBill.amount), 0))
|
||||
.join(UtilityBill, UtilityBill.provider_id == UtilityProvider.id)
|
||||
.filter(UtilityBill.period_start >= cutoff)
|
||||
.group_by(UtilityProvider.utility_type)
|
||||
.all())
|
||||
|
||||
out = []
|
||||
for t, total in rows:
|
||||
meta = UTILITY_TYPE_META.get(t, UTILITY_TYPE_META['other'])
|
||||
out.append({'key': t, 'label': meta[0], 'icon': meta[1],
|
||||
'color': meta[2], 'total': float(total)})
|
||||
out.sort(key=lambda r: r['total'], reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
# ── per-provider ─────────────────────────────────────────────────────────────
|
||||
|
||||
def provider_summary(provider):
|
||||
"""Latest bill, averages, and period-over-period movement for one provider."""
|
||||
bills = provider.bills.order_by(UtilityBill.period_start.desc()).all()
|
||||
if not bills:
|
||||
return {
|
||||
'latest': None, 'previous': None, 'year_ago': None,
|
||||
'bill_count': 0, 'avg_amount': 0, 'avg_usage': None,
|
||||
'amount_change_pct': None, 'usage_change_pct': None,
|
||||
'yoy_change_pct': None, 'total_12mo': 0, 'unpaid_count': 0,
|
||||
}
|
||||
|
||||
latest = bills[0]
|
||||
previous = bills[1] if len(bills) > 1 else None
|
||||
|
||||
# Same period one year earlier (within a 20-day window of the start date)
|
||||
target = latest.period_start - relativedelta(years=1)
|
||||
year_ago = next((b for b in bills if abs((b.period_start - target).days) <= 20), None)
|
||||
|
||||
cutoff = date.today() - relativedelta(months=12)
|
||||
recent = [b for b in bills if b.period_start >= cutoff]
|
||||
with_usage = [b for b in recent if b.usage]
|
||||
|
||||
return {
|
||||
'latest': latest,
|
||||
'previous': previous,
|
||||
'year_ago': year_ago,
|
||||
'bill_count': len(bills),
|
||||
'avg_amount': (sum(float(b.amount) for b in recent) / len(recent)) if recent else 0,
|
||||
'avg_usage': (sum(float(b.usage) for b in with_usage) / len(with_usage)) if with_usage else None,
|
||||
'amount_change_pct': _pct_change(latest.amount, previous.amount) if previous else None,
|
||||
'usage_change_pct': (_pct_change(latest.usage, previous.usage)
|
||||
if previous and latest.usage and previous.usage else None),
|
||||
'yoy_change_pct': _pct_change(latest.amount, year_ago.amount) if year_ago else None,
|
||||
'total_12mo': sum(float(b.amount) for b in recent),
|
||||
'unpaid_count': sum(1 for b in bills if not b.is_paid),
|
||||
}
|
||||
|
||||
|
||||
def usage_series(provider, months=24):
|
||||
"""Amount / usage / unit-rate history for a provider's detail chart."""
|
||||
cutoff = date.today() - relativedelta(months=months)
|
||||
bills = (provider.bills
|
||||
.filter(UtilityBill.period_start >= cutoff)
|
||||
.order_by(UtilityBill.period_start.asc())
|
||||
.all())
|
||||
|
||||
return {
|
||||
'labels': [b.period_start.strftime('%b %y') for b in bills],
|
||||
'amounts': [float(b.amount) for b in bills],
|
||||
'usage': [float(b.usage) if b.usage else None for b in bills],
|
||||
'rates': [round(b.rate_per_unit, 4) if b.rate_per_unit else None for b in bills],
|
||||
'unit': provider.usage_unit or '',
|
||||
'has_usage': any(b.usage for b in bills),
|
||||
}
|
||||
|
||||
|
||||
# ── payment matching ─────────────────────────────────────────────────────────
|
||||
|
||||
def candidate_transactions(bill, window_days=45, limit=25):
|
||||
"""
|
||||
Expense transactions that plausibly paid this bill: near the due date (or
|
||||
period end), not already attached to another bill. Closest amount first.
|
||||
"""
|
||||
anchor = bill.due_date or bill.period_end
|
||||
start = anchor - timedelta(days=window_days)
|
||||
end = anchor + timedelta(days=window_days)
|
||||
|
||||
linked = {row[0] for row in
|
||||
db.session.query(UtilityBill.transaction_id)
|
||||
.filter(UtilityBill.transaction_id.isnot(None),
|
||||
UtilityBill.id != bill.id).all()}
|
||||
|
||||
q = (Transaction.query
|
||||
.filter(Transaction.transaction_type == 'expense',
|
||||
Transaction.date >= start,
|
||||
Transaction.date <= end)
|
||||
.order_by(Transaction.date.desc()))
|
||||
|
||||
target = float(bill.amount)
|
||||
rows = [t for t in q.limit(300).all() if t.id not in linked]
|
||||
rows.sort(key=lambda t: (abs(float(t.amount) - target), abs((t.date - anchor).days)))
|
||||
return rows[:limit]
|
||||
|
||||
|
||||
def build_payment_transaction(bill, account_id, paid_date, category_id=None):
|
||||
"""Create (but don't commit) the expense transaction for a bill payment."""
|
||||
provider = bill.provider
|
||||
txn = Transaction(
|
||||
account_id=account_id,
|
||||
category_id=category_id if category_id else provider.category_id,
|
||||
transaction_type='expense',
|
||||
amount=bill.amount,
|
||||
description=f'{provider.name} — {provider.type_label}',
|
||||
date=paid_date,
|
||||
notes=f'Utility:{bill.id}',
|
||||
)
|
||||
db.session.add(txn)
|
||||
return txn
|
||||
|
||||
|
||||
def is_generated_payment(bill):
|
||||
"""True when the linked transaction was created by mark-paid (so unpaying may delete it)."""
|
||||
txn = bill.transaction
|
||||
return bool(txn and (txn.notes or '').strip().startswith(f'Utility:{bill.id}'))
|
||||
@@ -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