324 lines
9.3 KiB
TypeScript
324 lines
9.3 KiB
TypeScript
/** 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);
|
||
}
|