/** v2.4.0 — Interactive Budget Section Card. * * Replaces the read-only markdown budget card. Lets the admin set * monthly_budget / yearly_budget inline. Read-only progress bars for * current spending. Uses ``maintenance_supporter/global/update`` with * the budget_monthly / budget_yearly settings keys. */ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import { t, ensureLocale, DEFAULT_CURRENCY_SYMBOL } from "../styles"; import { describeWsError } from "../ws-errors"; import { sectionCardSharedStyles } from "./section-card-shared-styles"; import type { BudgetStatus, HomeAssistant } from "../types"; interface CardConfig { type: string; title?: string; } /** Backend default for budget_alert_threshold (const.py / settings_registry). * Only reached if an older core omits the field from budget_status. */ const DEFAULT_ALERT_THRESHOLD_PCT = 80; export class MaintenanceBudgetSectionCard extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @state() private _config: CardConfig = { type: "" }; @state() private _status: BudgetStatus | null = null; @state() private _busy = false; @state() private _error = ""; @state() private _localMonthly = ""; @state() private _localYearly = ""; @state() private _dirty = false; private _loaded = false; setConfig(config: CardConfig): void { this._config = config; } getCardSize(): number { return 2; } private get _lang(): string { return this.hass?.language || "en"; } private get _isAdmin(): boolean { return (this.hass?.user?.is_admin ?? true) as boolean; } updated(changedProps: Map): void { super.updated(changedProps); if (changedProps.has("hass") && this.hass && !this._loaded) { this._loaded = true; void this._load(); void ensureLocale(this._lang).then(() => this.requestUpdate()); } } private async _load(): Promise { try { const r = await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/budget_status", }); this._status = r; this._localMonthly = r.monthly_budget ? String(r.monthly_budget) : ""; this._localYearly = r.yearly_budget ? String(r.yearly_budget) : ""; this._dirty = false; } catch (e) { this._error = describeWsError(e, this._lang); } } private async _save(): Promise { if (!this._isAdmin) return; this._busy = true; this._error = ""; try { const m = parseFloat(this._localMonthly); const y = parseFloat(this._localYearly); const settings: Record = {}; if (!isNaN(m) && m >= 0) settings.budget_monthly = m; if (!isNaN(y) && y >= 0) settings.budget_yearly = y; await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/global/update", settings, }); await this._load(); } catch (e) { this._error = describeWsError(e, this._lang); } finally { this._busy = false; } } private _onDeepLink(): void { const path = "/maintenance-supporter?ms_action=open_budget"; history.pushState(null, "", path); window.dispatchEvent(new CustomEvent("location-changed")); } render() { const L = this._lang; const s = this._status; if (!s) { return html`
${t("loading", L) || "Loading…"}
`; } const sym = s.currency_symbol || DEFAULT_CURRENCY_SYMBOL; // The amber step is the CONFIGURED budget_alert_threshold, not a literal 80 // — same rule the panel's budget bar uses (maintenance-panel._renderBudgetBar). const threshold = s.alert_threshold_pct ?? DEFAULT_ALERT_THRESHOLD_PCT; const tracks = [ { label: t("budget_monthly", L) || "Monthly", spent: s.monthly_spent || 0, budget: s.monthly_budget || 0, }, { label: t("budget_yearly", L) || "Yearly", spent: s.yearly_spent || 0, budget: s.yearly_budget || 0, }, ]; return html`
💰 ${this._config.title || t("settings_budget", L) || "Budget"}
${sym}
${this._error ? html`
${this._error}
` : nothing} ${tracks.map((track) => { // #104: budget tracking without a maximum — a bar needs a // denominator, so show the plain spent total instead of "9 / 0 €" // over an always-empty bar. Mirrors the panel's spent-only lines. if (!(track.budget > 0)) { return html`
${track.spent.toFixed(0)} ${sym}
`; } const pct = Math.min(100, Math.max(0, (track.spent / track.budget) * 100)); const warn = pct >= 100 ? "danger" : pct >= threshold ? "warning" : "ok"; return html`
${track.spent.toFixed(0)} / ${track.budget.toFixed(0)} ${sym}
`; })} ${this._isAdmin ? html`
{ this._localMonthly = (e.target as HTMLInputElement).value; this._dirty = true; }} /> ${sym}
{ this._localYearly = (e.target as HTMLInputElement).value; this._dirty = true; }} /> ${sym}
` : html` `}
`; } static styles = [sectionCardSharedStyles, css` .currency { font-size: 14px; font-weight: 600; color: var(--secondary-text-color); background: var(--secondary-background-color); padding: 2px 10px; border-radius: 999px; } .track { display: flex; flex-direction: column; gap: 4px; } .track-label-row { display: flex; align-items: center; justify-content: space-between; } .track-label-row label { font-size: 12px; color: var(--secondary-text-color); text-transform: uppercase; letter-spacing: 0.5px; } .track-numbers { font-size: 13px; font-weight: 600; } .track-numbers.ok { color: var(--primary-text-color); } .track-numbers.warning { color: #ff9800; } .track-numbers.danger { color: var(--error-color, #f44336); } .bar { height: 6px; background: var(--secondary-background-color); border-radius: 3px; overflow: hidden; } .bar-fill { height: 100%; transition: width 0.3s; border-radius: 3px; } .bar-fill.ok { background: var(--primary-color); } .bar-fill.warning { background: #ff9800; } .bar-fill.danger { background: var(--error-color, #f44336); } .inputs-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding-top: 4px; border-top: 1px solid var(--divider-color); } .input-field { display: flex; flex-direction: column; gap: 4px; } .input-field label { font-size: 11px; color: var(--secondary-text-color); text-transform: uppercase; letter-spacing: 0.3px; } .input-wrap { position: relative; display: flex; align-items: center; } .input-wrap input { flex: 1; padding: 6px 32px 6px 8px; font-size: 13px; background: var(--secondary-background-color, #2c2c2c); color: var(--primary-text-color); border: 1px solid var(--divider-color); border-radius: 6px; font-family: inherit; } .input-suffix { position: absolute; right: 8px; color: var(--secondary-text-color); font-size: 13px; pointer-events: none; } .actions { display: flex; gap: 8px; align-items: center; } `]; } if (!customElements.get("maintenance-budget-section-card")) { customElements.define( "maintenance-budget-section-card", MaintenanceBudgetSectionCard, ); } (window as { customCards?: unknown[] }).customCards = (window as { customCards?: unknown[] }).customCards || []; ((window as { customCards?: unknown[] }).customCards!).push({ type: "maintenance-budget-section-card", name: "Maintenance Supporter — Budget", description: "Inline monthly + yearly budget editor", preview: false, });