288 lines
11 KiB
TypeScript
288 lines
11 KiB
TypeScript
/** 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<string, unknown>): 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<void> {
|
|
try {
|
|
const r = await this.hass.connection.sendMessagePromise<BudgetStatus>({
|
|
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<void> {
|
|
if (!this._isAdmin) return;
|
|
this._busy = true;
|
|
this._error = "";
|
|
try {
|
|
const m = parseFloat(this._localMonthly);
|
|
const y = parseFloat(this._localYearly);
|
|
const settings: Record<string, number> = {};
|
|
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`<ha-card><div class="loading">${t("loading", L) || "Loading…"}</div></ha-card>`;
|
|
}
|
|
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`
|
|
<ha-card>
|
|
<div class="card-content">
|
|
<div class="header">
|
|
<div class="title">
|
|
<span class="emoji">💰</span>
|
|
<span>${this._config.title || t("settings_budget", L) || "Budget"}</span>
|
|
</div>
|
|
<span class="currency">${sym}</span>
|
|
</div>
|
|
|
|
${this._error ? html`<div class="error">${this._error}</div>` : 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`
|
|
<div class="track spent-only">
|
|
<div class="track-label-row">
|
|
<label>${track.label}</label>
|
|
<span class="track-numbers ok">${track.spent.toFixed(0)} ${sym}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
const pct = Math.min(100, Math.max(0, (track.spent / track.budget) * 100));
|
|
const warn = pct >= 100 ? "danger" : pct >= threshold ? "warning" : "ok";
|
|
return html`
|
|
<div class="track">
|
|
<div class="track-label-row">
|
|
<label>${track.label}</label>
|
|
<span class="track-numbers ${warn}">
|
|
${track.spent.toFixed(0)} / ${track.budget.toFixed(0)} ${sym}
|
|
</span>
|
|
</div>
|
|
<div class="bar"><div class="bar-fill ${warn}" style="width:${pct}%"></div></div>
|
|
</div>
|
|
`;
|
|
})}
|
|
|
|
${this._isAdmin
|
|
? html`
|
|
<div class="inputs-row">
|
|
<div class="input-field">
|
|
<label>${t("budget_monthly_set", L) || "Set monthly"}</label>
|
|
<div class="input-wrap">
|
|
<input type="number" min="0" step="1"
|
|
.value=${this._localMonthly}
|
|
?disabled=${this._busy}
|
|
@input=${(e: Event) => {
|
|
this._localMonthly = (e.target as HTMLInputElement).value;
|
|
this._dirty = true;
|
|
}} />
|
|
<span class="input-suffix">${sym}</span>
|
|
</div>
|
|
</div>
|
|
<div class="input-field">
|
|
<label>${t("budget_yearly_set", L) || "Set yearly"}</label>
|
|
<div class="input-wrap">
|
|
<input type="number" min="0" step="1"
|
|
.value=${this._localYearly}
|
|
?disabled=${this._busy}
|
|
@input=${(e: Event) => {
|
|
this._localYearly = (e.target as HTMLInputElement).value;
|
|
this._dirty = true;
|
|
}} />
|
|
<span class="input-suffix">${sym}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="btn ${this._dirty ? "primary" : "muted"}"
|
|
@click=${this._save}
|
|
?disabled=${this._busy || !this._dirty}>
|
|
<ha-icon icon="${this._dirty ? "mdi:content-save" : "mdi:check"}"></ha-icon>
|
|
${this._dirty
|
|
? (t("save", L) || "Save")
|
|
: (t("saved", L) || "Saved")}
|
|
</button>
|
|
<button class="btn link" @click=${this._onDeepLink}>
|
|
${t("budget_advanced", L) || "Currency, alerts…"}
|
|
</button>
|
|
</div>
|
|
`
|
|
: html`
|
|
<button class="btn link" @click=${this._onDeepLink}>
|
|
${t("budget_open_panel", L) || "Open in panel"}
|
|
</button>
|
|
`}
|
|
</div>
|
|
</ha-card>
|
|
`;
|
|
}
|
|
|
|
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,
|
|
});
|