Files
2026-07-21 11:19:17 -04:00

324 lines
9.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Battery-fleet detail section, rendered inside the single "Replace low
* batteries" task's overview tab (task.battery_fleet_task). Self-contained:
* fetches the live aggregate over Battery Notes and offers the mark-replaced
* action — the fleet's one surface, instead of 30-70 per-battery tasks. */
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { describeWsError } from "../ws-errors";
import type { HomeAssistant } from "../types";
interface BatteryRow {
entity_id: string;
device_name: string;
battery_type: string;
quantity: number;
level: number | null;
days_until: number | null;
available?: boolean;
}
interface Overview {
available: boolean;
configured: boolean;
task_ok?: boolean;
total: number;
low: BatteryRow[];
soon: BatteryRow[];
needs_now: Record<string, number>;
needs_soon: Record<string, number>;
types: string[];
}
export class MaintenanceBatteryFleetSection extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _ov: Overview | null = null;
@state() private _loading = false;
@state() private _marking = false;
@state() private _error = "";
private _localeReady = false;
private get _lang(): string {
return this.hass?.language || "en";
}
connectedCallback(): void {
super.connectedCallback();
if (this.hass) this._load();
}
updated(changed: Map<string, unknown>): void {
if (changed.has("hass") && this.hass && !this._localeReady) {
this._localeReady = true;
ensureLocale(this._lang).then(() => this.requestUpdate());
if (this._ov === null && !this._loading) this._load();
}
}
private async _load(): Promise<void> {
this._loading = true;
this._error = "";
try {
this._ov = await this.hass.connection.sendMessagePromise<Overview>({
type: "maintenance_supporter/battery_fleet/overview",
});
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._loading = false;
}
}
private _markAll = async (): Promise<void> => {
await this._mark(undefined);
};
// Re-runs the idempotent setup, which restores the fleet task's trigger
// when a user edit wiped it (issue #106) or recreates a deleted task.
private _repair = async (): Promise<void> => {
if (this._marking) return;
this._marking = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/battery_fleet/setup",
language: this._lang,
});
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._marking = false;
}
};
private async _mark(entityIds: string[] | undefined): Promise<void> {
if (this._marking) return;
this._marking = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/battery_fleet/mark_replaced",
...(entityIds ? { entity_ids: entityIds } : {}),
});
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._marking = false;
}
}
private _shoppingLine(needs: Record<string, number>): string {
return Object.entries(needs)
.map(([type, qty]) => `${qty}× ${type}`)
.join(" · ");
}
render() {
const L = this._lang;
if (this._loading && this._ov === null) return html`<div class="bf-card"><div class="bf-loading">…</div></div>`;
const ov = this._ov;
if (!ov) {
return this._error ? html`<div class="bf-card"><div class="bf-error">${this._error}</div></div>` : nothing;
}
const lowCount = ov.low.length;
return html`
<div class="bf-card">
<div class="bf-head">
<ha-icon icon="mdi:battery-alert"></ha-icon>
<span class="bf-title">${t("battery_fleet_title", L)}</span>
<span class="bf-count ${lowCount ? "bad" : "ok"}">${lowCount}</span>
</div>
${this._error ? html`<div class="bf-error">${this._error}</div>` : nothing}
${ov.configured && ov.task_ok === false
? html`
<div class="bf-repair">
<span>${t("battery_fleet_trigger_lost", L)}</span>
<ha-button .disabled=${this._marking} @click=${this._repair}>
${t("battery_fleet_repair", L)}
</ha-button>
</div>
`
: nothing}
${lowCount === 0
? html`<div class="bf-empty">${t("battery_fleet_none_low", L)}</div>`
: html`
<div class="bf-shopping">
<span class="bf-label">${t("battery_fleet_buy_now", L)}</span>
<span class="bf-list">${this._shoppingLine(ov.needs_now)}</span>
</div>
<div class="bf-rows">
${ov.low.map(
(b) => html`
<div class="bf-row">
<span class="bf-dev">${b.device_name}</span>
${b.available === false
? html`<span class="bf-offline">${t("battery_fleet_offline", L)}</span>`
: nothing}
<span class="bf-type">${b.quantity}× ${b.battery_type}</span>
${b.level != null ? html`<span class="bf-level">${b.level}%</span>` : nothing}
<button
class="bf-mark"
title=${t("battery_fleet_mark_one", L)}
.disabled=${this._marking}
@click=${() => this._mark([b.entity_id])}
>
<ha-icon icon="mdi:battery-sync"></ha-icon>
</button>
</div>
`,
)}
</div>
<div class="bf-actions">
<ha-button .disabled=${this._marking} @click=${this._markAll}>
<ha-icon icon="mdi:battery-sync"></ha-icon> ${t("battery_fleet_mark_all", L)}
</ha-button>
</div>
`}
${ov.soon.length
? html`
<div class="bf-soon">
<span class="bf-label">${t("battery_fleet_soon", L)}</span>
<span class="bf-list">${this._shoppingLine(ov.needs_soon)}</span>
<div class="bf-soon-hint">${t("battery_fleet_soon_hint", L)}</div>
</div>
`
: nothing}
<div class="bf-total">${t("battery_fleet_total", L).replace("{n}", String(ov.total))}</div>
</div>
`;
}
static styles = css`
.bf-card {
background: var(--card-background-color, #fff);
border: 1px solid var(--divider-color);
border-radius: 10px;
padding: 14px 16px;
margin: 12px 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.bf-head {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.bf-title {
flex: 1;
}
.bf-count {
font-size: 13px;
padding: 1px 9px;
border-radius: 10px;
}
.bf-count.bad {
background: var(--error-color, #f44336);
color: #fff;
}
.bf-count.ok {
background: var(--success-color, #4caf50);
color: #fff;
}
.bf-error {
color: var(--error-color, #f44336);
font-size: 13px;
}
.bf-repair {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
background: color-mix(in srgb, var(--warning-color, #ff9800) 12%, transparent);
font-size: 13px;
}
.bf-empty {
color: var(--secondary-text-color);
font-size: 14px;
}
.bf-shopping,
.bf-soon {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
}
.bf-label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--secondary-text-color);
}
.bf-list {
font-weight: 500;
}
.bf-rows {
display: flex;
flex-direction: column;
gap: 4px;
}
.bf-row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
border-bottom: 1px solid var(--divider-color);
}
.bf-dev {
flex: 1;
min-width: 0;
}
.bf-type {
color: var(--secondary-text-color);
font-size: 13px;
}
.bf-level {
font-size: 12px;
color: var(--error-color, #f44336);
}
.bf-mark {
background: transparent;
border: none;
color: var(--primary-color);
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: inline-flex;
}
.bf-mark:hover {
background: var(--secondary-background-color);
}
.bf-actions {
display: flex;
justify-content: flex-end;
}
.bf-soon {
border-top: 1px solid var(--divider-color);
padding-top: 8px;
}
.bf-soon-hint {
width: 100%;
font-size: 12px;
color: var(--secondary-text-color);
}
.bf-total {
font-size: 12px;
color: var(--secondary-text-color);
}
`;
}
if (!customElements.get("maintenance-battery-fleet-section")) {
customElements.define("maintenance-battery-fleet-section", MaintenanceBatteryFleetSection);
}