Updated apps
This commit is contained in:
+56
-5
@@ -11,7 +11,8 @@ import { property, state } from "lit/decorators.js";
|
||||
|
||||
import { t, ensureLocale } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { UserService } from "../user-service";
|
||||
import type { HAUser, HomeAssistant } from "../types";
|
||||
|
||||
interface ProblemSensor {
|
||||
entity_id: string;
|
||||
@@ -33,6 +34,7 @@ interface DiscoverResponse {
|
||||
interface AdoptResponse {
|
||||
tasks_created: number;
|
||||
objects_created: number;
|
||||
created: Array<{ entry_id: string; task_id: string; name: string }>;
|
||||
total: number;
|
||||
errors?: string[];
|
||||
}
|
||||
@@ -46,8 +48,11 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
@state() private _error = "";
|
||||
@state() private _sensors: ProblemSensor[] = [];
|
||||
@state() private _selected: Set<string> = new Set();
|
||||
@state() private _users: HAUser[] = [];
|
||||
@state() private _responsible = "";
|
||||
|
||||
private _localeReady = false;
|
||||
private _userService: UserService | null = null;
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
@@ -66,12 +71,20 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
this._error = "";
|
||||
this._sensors = [];
|
||||
this._selected = new Set();
|
||||
this._responsible = "";
|
||||
try {
|
||||
const resp = await this.hass.connection.sendMessagePromise<DiscoverResponse>({
|
||||
type: "maintenance_supporter/problem_sensors/discover",
|
||||
});
|
||||
if (!this._userService) this._userService = new UserService(this.hass);
|
||||
else this._userService.updateHass(this.hass);
|
||||
const [resp, users] = await Promise.all([
|
||||
this.hass.connection.sendMessagePromise<DiscoverResponse>({
|
||||
type: "maintenance_supporter/problem_sensors/discover",
|
||||
}),
|
||||
// Best-effort: adoption works fine without the user list.
|
||||
this._userService.getUsers().catch(() => [] as HAUser[]),
|
||||
]);
|
||||
this._sensors = resp.sensors || [];
|
||||
this._selected = new Set(this._sensors.map((s) => s.entity_id));
|
||||
this._users = users;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
@@ -112,6 +125,7 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
object_name: s.suggested_object_name,
|
||||
device_id: s.device_id ?? undefined,
|
||||
part_id: s.suggested_part_id ?? undefined,
|
||||
responsible_user_id: this._responsible || undefined,
|
||||
}));
|
||||
const result = await this.hass.connection.sendMessagePromise<AdoptResponse>({
|
||||
type: "maintenance_supporter/problem_sensors/adopt",
|
||||
@@ -200,6 +214,25 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
</div>
|
||||
`}
|
||||
|
||||
${!this._loading && this._sensors.length > 0 && this._users.length > 0
|
||||
? html`
|
||||
<label class="responsible">
|
||||
<span>${t("adopt_problem_responsible", L)}</span>
|
||||
<select
|
||||
.value=${this._responsible}
|
||||
@change=${(e: Event) => {
|
||||
this._responsible = (e.target as HTMLSelectElement).value;
|
||||
}}
|
||||
>
|
||||
<option value="" ?selected=${!this._responsible}>${t("no_user_assigned", L)}</option>
|
||||
${this._users.map(
|
||||
(u) => html`<option value=${u.id} ?selected=${u.id === this._responsible}>${u.name}</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
<div class="actions">
|
||||
<ha-button appearance="plain" @click=${this._close}>
|
||||
${t("cancel", L)}
|
||||
@@ -234,7 +267,7 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 360px;
|
||||
min-width: min(360px, calc(100vw - 24px));
|
||||
max-width: 560px;
|
||||
width: 90vw;
|
||||
max-height: 80vh;
|
||||
@@ -341,6 +374,24 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
background: var(--divider-color);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.responsible {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--secondary-text-color);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.responsible select {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--divider-color);
|
||||
background: var(--card-background-color, #fff);
|
||||
color: var(--primary-text-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
/** 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",
|
||||
});
|
||||
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);
|
||||
}
|
||||
@@ -19,6 +19,10 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
@property() public readingUnit = "";
|
||||
/** Buy task (part_ref): default restock quantity — shows an editable qty field. */
|
||||
@property({ attribute: false }) public restockDefault: number | null = null;
|
||||
/** #99: the object's parts — enables the editable "parts used" section. */
|
||||
@property({ attribute: false }) public parts: Array<{ id: string; name: string; unit?: string | null; stock?: number | null }> = [];
|
||||
/** #99: the task's fixed consumes_parts links (prefill for the section). */
|
||||
@property({ attribute: false }) public consumesParts: Array<{ part_id: string; quantity: number }> = [];
|
||||
/** "Consumes: 1× HEPA-Filter (Shelf B)" hint lines for consuming tasks. */
|
||||
@property({ type: Array }) public consumesInfo: string[] = [];
|
||||
@state() private _open = false;
|
||||
@@ -34,6 +38,7 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
@state() private _photoUploading = false;
|
||||
@state() private _readingValue = "";
|
||||
@state() private _restockQty = "";
|
||||
@state() private _usedParts: Record<string, number> = {};
|
||||
|
||||
public open(): void {
|
||||
if (this._open) return;
|
||||
@@ -49,6 +54,9 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
this._photoUploading = false;
|
||||
this._readingValue = "";
|
||||
this._restockQty = this.restockDefault !== null ? String(this.restockDefault) : "";
|
||||
// #99: prefill "parts used" with the task's fixed links — the user can
|
||||
// untick or adjust before completing.
|
||||
this._usedParts = Object.fromEntries(this.consumesParts.map((l) => [l.part_id, l.quantity]));
|
||||
}
|
||||
|
||||
private _toggleCheck(idx: number): void {
|
||||
@@ -136,9 +144,16 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
if (!isNaN(rv)) data.reading_value = rv;
|
||||
}
|
||||
if (this.restockDefault !== null && this._restockQty !== "") {
|
||||
const rq = parseInt(this._restockQty, 10);
|
||||
const rq = parseFloat(this._restockQty);
|
||||
if (!isNaN(rq) && rq >= 1) data.restock_quantity = rq;
|
||||
}
|
||||
// #99: with a parts section shown, send the explicit selection — it
|
||||
// replaces the automatic consumes_parts deduction (empty = none used).
|
||||
if (this.parts.length > 0) {
|
||||
data.used_parts = Object.entries(this._usedParts)
|
||||
.filter(([, qty]) => Number.isFinite(qty) && qty > 0)
|
||||
.map(([part_id, quantity]) => ({ part_id, quantity }));
|
||||
}
|
||||
await this.hass.connection.sendMessagePromise(data);
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("task-completed"));
|
||||
@@ -181,16 +196,44 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
@input=${(e: Event) => (this._readingValue = (e.target as HTMLInputElement).value)} />
|
||||
</label>`
|
||||
: nothing}
|
||||
${this.consumesInfo.length
|
||||
? html`<div class="consumes-hint">
|
||||
${this.consumesInfo.map((line) => html`<div>${line}</div>`)}
|
||||
${this.parts.length
|
||||
? html`<div class="used-parts">
|
||||
<span class="field-label">${t("complete_parts_used", L)}</span>
|
||||
${this.parts.map((pt) => {
|
||||
const qty = this._usedParts[pt.id];
|
||||
const checked = qty !== undefined;
|
||||
return html`<div class="used-part-row">
|
||||
<label class="used-part-check">
|
||||
<input type="checkbox" .checked=${checked}
|
||||
@change=${(e: Event) => {
|
||||
const next = { ...this._usedParts };
|
||||
if ((e.target as HTMLInputElement).checked) next[pt.id] = next[pt.id] || 1;
|
||||
else delete next[pt.id];
|
||||
this._usedParts = next;
|
||||
}} />
|
||||
<span>${pt.name}${pt.stock !== null && pt.stock !== undefined ? ` (${pt.stock}${pt.unit ? " " + pt.unit : ""})` : ""}</span>
|
||||
</label>
|
||||
${checked
|
||||
? html`<input class="used-part-qty" type="number" min="0.01" max="999" step="0.01"
|
||||
.value=${String(qty)}
|
||||
@input=${(e: Event) => {
|
||||
const v = parseFloat((e.target as HTMLInputElement).value);
|
||||
this._usedParts = { ...this._usedParts, [pt.id]: Number.isFinite(v) && v >= 0.01 ? v : 1 };
|
||||
}} />`
|
||||
: nothing}
|
||||
</div>`;
|
||||
})}
|
||||
</div>`
|
||||
: nothing}
|
||||
: this.consumesInfo.length
|
||||
? html`<div class="consumes-hint">
|
||||
${this.consumesInfo.map((line) => html`<div>${line}</div>`)}
|
||||
</div>`
|
||||
: nothing}
|
||||
${this.restockDefault !== null
|
||||
? html`
|
||||
<label class="field">
|
||||
<span class="field-label">${t("restock_quantity_label", L)}</span>
|
||||
<input type="number" step="1" min="1" class="field-input"
|
||||
<input type="number" step="0.01" min="0.01" class="field-input"
|
||||
.value=${this._restockQty}
|
||||
@input=${(e: Event) => (this._restockQty = (e.target as HTMLInputElement).value)} />
|
||||
</label>`
|
||||
@@ -215,7 +258,7 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">${t("duration_minutes", L)}</span>
|
||||
<input type="number" step="1" min="0" class="field-input"
|
||||
<input type="number" step="0.01" min="0" class="field-input"
|
||||
.value=${this._duration}
|
||||
@input=${(e: Event) => (this._duration = (e.target as HTMLInputElement).value)} />
|
||||
</label>
|
||||
@@ -297,6 +340,20 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
padding: 4px 8px;
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
/* #99: editable per-completion parts selection */
|
||||
.used-parts { margin: 4px 0 8px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.used-part-row { display: flex; align-items: center; gap: 8px; }
|
||||
.used-part-check {
|
||||
display: flex; align-items: center; gap: 6px; flex: 1;
|
||||
font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.used-part-check input { cursor: pointer; }
|
||||
.used-part-qty {
|
||||
width: 76px; padding: 4px 6px; border-radius: 4px; font: inherit; font-size: 13px;
|
||||
border: 1px solid var(--divider-color);
|
||||
background: var(--card-background-color);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.error {
|
||||
color: var(--error-color, #f44336);
|
||||
font-size: 13px;
|
||||
|
||||
@@ -177,7 +177,7 @@ export class MaintenanceGroupDialog extends LitElement {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 360px;
|
||||
min-width: min(360px, calc(100vw - 24px));
|
||||
max-width: 520px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -151,6 +151,9 @@ export class MaintenancePartsSection extends LitElement {
|
||||
}
|
||||
|
||||
private async _delete(part: MaintenancePart): Promise<void> {
|
||||
// Deleting a part drops its stock tracking, task links and buy reminder —
|
||||
// destructive enough to warrant an explicit confirmation (user request).
|
||||
if (!window.confirm(t("part_delete_confirm", this._lang).replace("{name}", part.name))) return;
|
||||
const result = await this._send<{ success: boolean }>({
|
||||
type: "maintenance_supporter/part/delete",
|
||||
entry_id: this.entryId,
|
||||
@@ -160,7 +163,7 @@ export class MaintenancePartsSection extends LitElement {
|
||||
}
|
||||
|
||||
private async _restock(part: MaintenancePart): Promise<void> {
|
||||
const qty = parseInt(this._restockQty, 10);
|
||||
const qty = parseFloat(this._restockQty);
|
||||
if (!Number.isFinite(qty) || qty === 0) {
|
||||
// Don't silently swallow a no-op amount — keep the input open and mark
|
||||
// it so the user sees WHY nothing happened (0 / empty / not a number).
|
||||
|
||||
@@ -375,6 +375,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
|
||||
@state() private _allTemplates: Array<{ id: string; name: string; category: string; disabled?: boolean }> = [];
|
||||
@state() private _templateCategories: Record<string, Record<string, string>> = {};
|
||||
@state() private _tplOpenGroups: Set<string> = new Set();
|
||||
|
||||
// One-shot request guard: keyed on a plain flag, NOT on the result being
|
||||
// non-empty — an empty catalog answer would otherwise re-trigger the load
|
||||
@@ -419,9 +420,25 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
<p class="section-desc">${t("settings_templates_hint", L)}</p>
|
||||
${[...byCat.entries()].filter(([, tpls]) => tpls.length > 0).map(([catId, tpls]) => {
|
||||
const enabled = tpls.filter((tpl) => !hidden.has(tpl.id)).length;
|
||||
// Collapsed by default (user request): the gallery lists 30+
|
||||
// templates — folded groups with an enabled/total count keep the
|
||||
// settings page short; expand only what you're curating.
|
||||
const open = this._tplOpenGroups.has(catId);
|
||||
return html`
|
||||
<div class="tpl-group">
|
||||
<label class="tpl-group-head">
|
||||
<div
|
||||
class="tpl-group-head"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click=${() => this._toggleTplGroupOpen(catId)}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
this._toggleTplGroupOpen(catId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ha-icon class="tpl-chevron" icon=${open ? "mdi:chevron-down" : "mdi:chevron-right"}></ha-icon>
|
||||
<ha-icon icon=${(this._templateCategories[catId]?.icon as string) || "mdi:folder-outline"}></ha-icon>
|
||||
<span class="tpl-group-name">${catName(catId)}</span>
|
||||
<span class="tpl-group-count">${enabled}/${tpls.length}</span>
|
||||
@@ -429,20 +446,23 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
type="checkbox"
|
||||
title=${t("settings_templates_toggle_group", L)}
|
||||
.checked=${enabled === tpls.length}
|
||||
@click=${(e: Event) => e.stopPropagation()}
|
||||
@change=${(e: Event) =>
|
||||
this._toggleTemplateGroup(tpls.map((tpl) => tpl.id), (e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
</label>
|
||||
${tpls.map((tpl) => html`
|
||||
<label class="setting-row tpl-row">
|
||||
<span class="setting-label">${tpl.name}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${!hidden.has(tpl.id)}
|
||||
@change=${(e: Event) => this._toggleTemplate(tpl.id, (e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
</label>
|
||||
`)}
|
||||
</div>
|
||||
${open
|
||||
? tpls.map((tpl) => html`
|
||||
<label class="setting-row tpl-row">
|
||||
<span class="setting-label">${tpl.name}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${!hidden.has(tpl.id)}
|
||||
@change=${(e: Event) => this._toggleTemplate(tpl.id, (e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
</label>
|
||||
`)
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
@@ -457,6 +477,14 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
this._updateSetting("disabled_template_ids", [...hidden]);
|
||||
}
|
||||
|
||||
/** Expand/collapse one gallery group (collapsed by default). */
|
||||
private _toggleTplGroupOpen(catId: string): void {
|
||||
const next = new Set(this._tplOpenGroups);
|
||||
if (next.has(catId)) next.delete(catId);
|
||||
else next.add(catId);
|
||||
this._tplOpenGroups = next;
|
||||
}
|
||||
|
||||
/** Toggle-all for one category group in the template gallery. */
|
||||
private _toggleTemplateGroup(ids: string[], visible: boolean): void {
|
||||
const hidden = new Set(this._settings!.disabled_template_ids || []);
|
||||
@@ -1590,6 +1618,8 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
font-weight: 600;
|
||||
}
|
||||
.tpl-group-head ha-icon { --mdc-icon-size: 18px; color: var(--primary-color); }
|
||||
.tpl-group-head .tpl-chevron { color: var(--secondary-text-color); }
|
||||
.tpl-group-head:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
|
||||
.tpl-group-name { flex: 1; }
|
||||
.tpl-group-count {
|
||||
font-size: 12px;
|
||||
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
/** Dialog for integration-aware suggested setups (verified entity signatures).
|
||||
*
|
||||
* Lists devices of catalogued integrations (Roborock, Xiaomi Miio, Dreame,
|
||||
* IPP/Brother printers, …) whose consumable entities can back maintenance
|
||||
* tasks, and adopts the selected ones: the object is bound to the device and
|
||||
* every task arrives with its sensor threshold trigger PRE-WIRED (below N
|
||||
* hours left / below N % remaining, auto-resolving on replacement). The wiring
|
||||
* comes from the server-side source-verified catalog — never from this client.
|
||||
*/
|
||||
|
||||
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 SetupTask {
|
||||
task_name: string;
|
||||
task_name_localized?: string;
|
||||
entity_ids: string[];
|
||||
threshold: number;
|
||||
direction: string;
|
||||
}
|
||||
|
||||
interface SuggestedSetup {
|
||||
device_id: string;
|
||||
device_name: string;
|
||||
area_name: string;
|
||||
integration: string;
|
||||
integration_name: string;
|
||||
suggested_entry_id: string | null;
|
||||
suggested_object_name: string;
|
||||
tasks: SetupTask[];
|
||||
}
|
||||
|
||||
interface AdoptResponse {
|
||||
tasks_created: number;
|
||||
objects_created: number;
|
||||
total: number;
|
||||
errors?: unknown[];
|
||||
}
|
||||
|
||||
export class MaintenanceSuggestedSetupsDialog extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _open = false;
|
||||
@state() private _loading = false;
|
||||
@state() private _adopting = false;
|
||||
@state() private _error = "";
|
||||
@state() private _setups: SuggestedSetup[] = [];
|
||||
@state() private _selected: Set<string> = new Set();
|
||||
// #102: optional counting start values, keyed "deviceId taskName".
|
||||
// Only usage_delta duties render the input; raw strings until adopt.
|
||||
@state() private _baselines: Map<string, string> = new Map();
|
||||
// #105: adopt target per device — entry_id of an existing object, or ""
|
||||
// for the default (the suggested bound object, else a new object).
|
||||
@state() private _targets: Map<string, string> = new Map();
|
||||
@state() private _objects: Array<{ entry_id: string; name: string }> = [];
|
||||
|
||||
private _localeReady = false;
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
}
|
||||
|
||||
updated(changed: Map<string, unknown>): void {
|
||||
if (changed.has("hass") && this.hass && !this._localeReady) {
|
||||
this._localeReady = true;
|
||||
ensureLocale(this._lang).then(() => this.requestUpdate());
|
||||
}
|
||||
}
|
||||
|
||||
public async open(): Promise<void> {
|
||||
this._open = true;
|
||||
this._loading = true;
|
||||
this._error = "";
|
||||
this._setups = [];
|
||||
this._selected = new Set();
|
||||
try {
|
||||
const resp = await this.hass.connection.sendMessagePromise<{ setups: SuggestedSetup[] }>({
|
||||
type: "maintenance_supporter/integration_setups/discover",
|
||||
});
|
||||
this._setups = resp.setups || [];
|
||||
this._selected = new Set(this._setups.map((s) => s.device_id));
|
||||
this._baselines = new Map();
|
||||
this._targets = new Map();
|
||||
try {
|
||||
const objs = await this.hass.connection.sendMessagePromise<{
|
||||
objects: Array<{ entry_id: string; object: { name: string } }>;
|
||||
}>({ type: "maintenance_supporter/objects" });
|
||||
this._objects = (objs.objects || [])
|
||||
.map((o) => ({ entry_id: o.entry_id, name: o.object?.name || o.entry_id }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} catch {
|
||||
this._objects = []; // picker degrades to the default target only
|
||||
}
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _close(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _toggle = (deviceId: string): void => {
|
||||
const next = new Set(this._selected);
|
||||
if (next.has(deviceId)) next.delete(deviceId);
|
||||
else next.add(deviceId);
|
||||
this._selected = next;
|
||||
};
|
||||
|
||||
private _adopt = async (): Promise<void> => {
|
||||
if (this._selected.size === 0 || this._adopting) return;
|
||||
this._adopting = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const result = await this.hass.connection.sendMessagePromise<AdoptResponse>({
|
||||
type: "maintenance_supporter/integration_setups/adopt",
|
||||
selections: [...this._selected].map((device_id) => {
|
||||
const sel: { device_id: string; entry_id?: string; baselines?: Record<string, number> } = {
|
||||
device_id,
|
||||
};
|
||||
const target = this._targets.get(device_id);
|
||||
if (target) sel.entry_id = target;
|
||||
const setup = this._setups.find((s) => s.device_id === device_id);
|
||||
for (const task of setup?.tasks ?? []) {
|
||||
const raw = this._baselines.get(`${device_id} ${task.task_name}`);
|
||||
const b = raw ? parseFloat(raw) : NaN;
|
||||
if (!isNaN(b) && b >= 0) (sel.baselines ??= {})[task.task_name] = b;
|
||||
}
|
||||
return sel;
|
||||
}),
|
||||
});
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("integration-setups-adopted", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: result,
|
||||
}),
|
||||
);
|
||||
this._open = false;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._adopting = false;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this._open) return html``;
|
||||
const L = this._lang;
|
||||
|
||||
return html`
|
||||
<div class="overlay" @click=${this._close}>
|
||||
<div class="card" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="title">${t("setups_title", L)}</div>
|
||||
<div class="hint">${t("setups_hint", L)}</div>
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
|
||||
|
||||
${this._loading
|
||||
? html`<div class="loading">…</div>`
|
||||
: this._setups.length === 0
|
||||
? html`<div class="empty">${t("setups_none", L)}</div>`
|
||||
: html`
|
||||
<div class="list">
|
||||
${this._setups.map((s) => {
|
||||
const checked = this._selected.has(s.device_id);
|
||||
const sub = [s.integration_name, s.area_name].filter(Boolean).join(" · ");
|
||||
return html`
|
||||
<label class="row">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${checked}
|
||||
@change=${() => this._toggle(s.device_id)}
|
||||
/>
|
||||
<div class="row-main">
|
||||
<div class="row-top">
|
||||
<span class="row-name">${s.device_name}</span>
|
||||
</div>
|
||||
<div class="row-sub">${sub}</div>
|
||||
<div class="row-target" @click=${(e: Event) => e.preventDefault()}>
|
||||
→
|
||||
${checked && this._objects.length > 0
|
||||
? html`
|
||||
<select
|
||||
class="target-select"
|
||||
@change=${(e: Event) => {
|
||||
const next = new Map(this._targets);
|
||||
const v = (e.target as HTMLSelectElement).value;
|
||||
if (v) next.set(s.device_id, v);
|
||||
else next.delete(s.device_id);
|
||||
this._targets = next;
|
||||
}}
|
||||
>
|
||||
<option value="" ?selected=${!this._targets.get(s.device_id)}>
|
||||
${s.suggested_entry_id
|
||||
? s.suggested_object_name
|
||||
: t("setups_target_new", L).replace("{name}", s.suggested_object_name)}
|
||||
</option>
|
||||
${this._objects
|
||||
.filter((o) => o.entry_id !== s.suggested_entry_id)
|
||||
.map(
|
||||
(o) => html`<option
|
||||
value=${o.entry_id}
|
||||
?selected=${this._targets.get(s.device_id) === o.entry_id}
|
||||
>
|
||||
${o.name}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
`
|
||||
: html`${s.suggested_object_name}${s.suggested_entry_id
|
||||
? nothing
|
||||
: html` <span class="new-tag">${t("adopt_problem_new_object", L)}</span>`}`}
|
||||
</div>
|
||||
<div class="row-tasks">
|
||||
${s.tasks.map(
|
||||
(task) => html`<span class="chip" title=${task.entity_ids.join(", ")}>
|
||||
<ha-icon icon="mdi:link-variant"></ha-icon>${task.task_name_localized ||
|
||||
task.task_name}
|
||||
</span>`,
|
||||
)}
|
||||
</div>
|
||||
${checked
|
||||
? s.tasks
|
||||
.filter((task) => task.direction === "usage_delta")
|
||||
.map((task) => {
|
||||
const key = `${s.device_id} ${task.task_name}`;
|
||||
return html`
|
||||
<div class="baseline-field" @click=${(e: Event) => e.preventDefault()}>
|
||||
<span class="baseline-label"
|
||||
>${task.task_name_localized || task.task_name} —
|
||||
${t("setups_baseline_hint", L)}</span
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
.value=${this._baselines.get(key) ?? ""}
|
||||
@click=${(e: Event) => e.preventDefault()}
|
||||
@input=${(e: Event) => {
|
||||
const next = new Map(this._baselines);
|
||||
next.set(key, (e.target as HTMLInputElement).value);
|
||||
this._baselines = next;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
: nothing}
|
||||
</div>
|
||||
</label>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div class="actions">
|
||||
<ha-button appearance="plain" @click=${this._close}>
|
||||
${t("cancel", L)}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._adopt}
|
||||
.disabled=${this._selected.size === 0 || this._adopting}
|
||||
>
|
||||
${t("setups_adopt", L)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.card {
|
||||
background: var(--card-background-color, #fff);
|
||||
color: var(--primary-text-color);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: min(360px, calc(100vw - 24px));
|
||||
max-width: 560px;
|
||||
width: 90vw;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.title { font-size: 18px; font-weight: 500; }
|
||||
.hint { color: var(--secondary-text-color); font-size: 13px; }
|
||||
.error { color: var(--error-color, #f44336); font-size: 13px; }
|
||||
.loading, .empty { color: var(--secondary-text-color); font-size: 14px; padding: 12px 0; }
|
||||
.list { display: flex; flex-direction: column; gap: 6px; overflow-y: auto; max-height: 50vh; }
|
||||
.row {
|
||||
display: flex; align-items: flex-start; gap: 10px; padding: 8px;
|
||||
border: 1px solid var(--divider-color); border-radius: 6px; cursor: pointer;
|
||||
}
|
||||
.row input { margin-top: 2px; cursor: pointer; }
|
||||
.row-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; }
|
||||
.row-name { font-weight: 500; font-size: 13px; }
|
||||
.row-sub, .row-target { color: var(--secondary-text-color); font-size: 12px; }
|
||||
.new-tag { font-style: italic; }
|
||||
.row-tasks { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
font-size: 11px; padding: 2px 8px; border-radius: 10px;
|
||||
background: var(--secondary-background-color, rgba(0, 0, 0, 0.06));
|
||||
color: var(--primary-text-color); white-space: nowrap;
|
||||
}
|
||||
.chip ha-icon { --mdc-icon-size: 12px; color: var(--primary-color); }
|
||||
.target-select {
|
||||
font-size: 12px; padding: 2px 4px; max-width: 100%;
|
||||
border: 1px solid var(--divider-color); border-radius: 4px;
|
||||
background: var(--card-background-color, #fff);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.baseline-field {
|
||||
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
|
||||
margin-top: 4px; font-size: 12px; color: var(--secondary-text-color);
|
||||
}
|
||||
.baseline-field input {
|
||||
width: 110px; padding: 3px 6px; font-size: 12px;
|
||||
border: 1px solid var(--divider-color); border-radius: 4px;
|
||||
background: var(--card-background-color, #fff);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 8px; }
|
||||
`;
|
||||
}
|
||||
|
||||
if (!customElements.get("maintenance-suggested-setups-dialog")) {
|
||||
customElements.define(
|
||||
"maintenance-suggested-setups-dialog",
|
||||
MaintenanceSuggestedSetupsDialog,
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TriggerConfig, HAUser } from "../types";
|
||||
import { t, weekdayName } from "../styles";
|
||||
import { formatDate, t, weekdayName } from "../styles";
|
||||
import { UserService } from "../user-service";
|
||||
|
||||
import { describeWsError } from "../ws-errors";
|
||||
@@ -32,16 +32,30 @@ interface CompoundConditionDraft {
|
||||
toState: string;
|
||||
targetChanges: string;
|
||||
runtimeHours: string;
|
||||
onStates: string;
|
||||
/** Original keys this editor has no fields for (attribute, baseline, ...).
|
||||
* Spread back on save so a compound roundtrip never drops them (#103 class). */
|
||||
carry: Partial<TriggerConfig>;
|
||||
}
|
||||
|
||||
function emptyCondition(): CompoundConditionDraft {
|
||||
return {
|
||||
entityIds: "", type: "threshold", above: "", below: "", forMinutes: "0",
|
||||
targetValue: "", deltaMode: false, fromState: "", toState: "",
|
||||
targetChanges: "", runtimeHours: "",
|
||||
targetChanges: "", runtimeHours: "", onStates: "", carry: {},
|
||||
};
|
||||
}
|
||||
|
||||
/** Keys the compound editor owns via its own form fields — everything else
|
||||
* travels through `carry` untouched. */
|
||||
const MANAGED_CONDITION_KEYS = new Set([
|
||||
"entity_id", "entity_ids", "type",
|
||||
"trigger_above", "trigger_below", "trigger_for_minutes",
|
||||
"trigger_target_value", "trigger_delta_mode",
|
||||
"trigger_from_state", "trigger_to_state", "trigger_target_changes",
|
||||
"trigger_runtime_hours", "trigger_on_states",
|
||||
]);
|
||||
|
||||
/** Map a persisted compound condition (storage shape) to an editable draft. */
|
||||
function conditionToDraft(c: TriggerConfig): CompoundConditionDraft {
|
||||
const ids = c.entity_ids || (c.entity_id ? [c.entity_id] : []);
|
||||
@@ -57,6 +71,10 @@ function conditionToDraft(c: TriggerConfig): CompoundConditionDraft {
|
||||
toState: c.trigger_to_state || "",
|
||||
targetChanges: c.trigger_target_changes?.toString() ?? "",
|
||||
runtimeHours: c.trigger_runtime_hours?.toString() ?? "",
|
||||
onStates: (c.trigger_on_states || []).join(", "),
|
||||
carry: Object.fromEntries(
|
||||
Object.entries(c).filter(([k]) => !MANAGED_CONDITION_KEYS.has(k) && !k.startsWith("_")),
|
||||
) as Partial<TriggerConfig>,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,7 +83,7 @@ function conditionToDraft(c: TriggerConfig): CompoundConditionDraft {
|
||||
function draftToCondition(d: CompoundConditionDraft): TriggerConfig | null {
|
||||
const ids = d.entityIds.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (ids.length === 0) return null;
|
||||
const c: TriggerConfig = { entity_id: ids[0], entity_ids: ids, type: d.type };
|
||||
const c: TriggerConfig = { ...(d.carry || {}), entity_id: ids[0], entity_ids: ids, type: d.type };
|
||||
if (d.type === "threshold") {
|
||||
const a = parseFloat(d.above); if (!isNaN(a)) c.trigger_above = a;
|
||||
const b = parseFloat(d.below); if (!isNaN(b)) c.trigger_below = b;
|
||||
@@ -79,6 +97,8 @@ function draftToCondition(d: CompoundConditionDraft): TriggerConfig | null {
|
||||
const n = parseInt(d.targetChanges, 10); if (!isNaN(n)) c.trigger_target_changes = n;
|
||||
} else if (d.type === "runtime") {
|
||||
const h = parseFloat(d.runtimeHours); if (!isNaN(h)) c.trigger_runtime_hours = h;
|
||||
const on = (d.onStates || "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (on.length > 0) c.trigger_on_states = on;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
@@ -136,6 +156,12 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
@state() private _endsMode: "never" | "count" | "until" = "never";
|
||||
@state() private _endsCount = "";
|
||||
@state() private _endsUntil = "";
|
||||
// #83: live "next dates" preview — dates come from the BACKEND engine via
|
||||
// schedule/preview (never a frontend reimplementation; the #103 lesson).
|
||||
@state() private _schedulePreview: string[] = [];
|
||||
@state() private _schedulePreviewEnded = false;
|
||||
private _previewTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private _previewSeq = 0;
|
||||
@state() private _notes = "";
|
||||
@state() private _documentationUrl = "";
|
||||
@state() private _customIcon = "";
|
||||
@@ -154,11 +180,22 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
@state() private _triggerForMinutes = "0";
|
||||
@state() private _triggerTargetValue = "";
|
||||
@state() private _triggerDeltaMode = false;
|
||||
@state() private _triggerBaselineValue = "";
|
||||
// The LIVE counting anchor from the read-model (Store baseline — moves on
|
||||
// completion). Display-only: adopted delta tasks have no config baseline,
|
||||
// so without this the edit dialog would show an empty start-value field
|
||||
// even though counting is anchored at e.g. 27,000 km.
|
||||
@state() private _liveBaselineValue: number | null = null;
|
||||
@state() private _autoCompleteOnRecovery = false;
|
||||
@state() private _triggerFromState = "";
|
||||
@state() private _triggerToState = "";
|
||||
@state() private _triggerTargetChanges = "";
|
||||
@state() private _triggerRuntimeHours = "";
|
||||
// Comma-separated "running" states for the runtime trigger (#103) —
|
||||
// empty = the backend default ["on"]. Must roundtrip on edit: adopted
|
||||
// tasks ship e.g. ["mowing"], and dropping it on save silently stops
|
||||
// the accumulation.
|
||||
@state() private _triggerOnStates = "";
|
||||
// Compound trigger (type === "compound"): a list of conditions + AND/OR logic
|
||||
@state() private _compoundLogic: "AND" | "OR" = "AND";
|
||||
@state() private _compoundConditions: CompoundConditionDraft[] = [];
|
||||
@@ -320,7 +357,11 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
|
||||
if (task.trigger_config) {
|
||||
const tc = task.trigger_config;
|
||||
this._triggerEntityId = tc.entity_id || "";
|
||||
// A trigger stored with only the plural entity_ids (e.g. the Battery
|
||||
// Fleet task) must still hydrate the singular field — the save path
|
||||
// gates on _triggerEntityId and would otherwise NULL the whole trigger
|
||||
// on an unrelated edit (issue #106).
|
||||
this._triggerEntityId = tc.entity_id || (tc.entity_ids && tc.entity_ids[0]) || "";
|
||||
this._triggerEntityIds = tc.entity_ids || (tc.entity_id ? [tc.entity_id] : []);
|
||||
this._triggerEntityLogic = tc.entity_logic || "any";
|
||||
this._triggerAttribute = tc.attribute || "";
|
||||
@@ -330,11 +371,14 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._triggerForMinutes = tc.trigger_for_minutes?.toString() || "0";
|
||||
this._triggerTargetValue = tc.trigger_target_value?.toString() || "";
|
||||
this._triggerDeltaMode = tc.trigger_delta_mode || false;
|
||||
this._triggerBaselineValue = tc.trigger_baseline_value?.toString() || "";
|
||||
this._liveBaselineValue = task.trigger_baseline_value ?? null;
|
||||
this._autoCompleteOnRecovery = tc.auto_complete_on_recovery || false;
|
||||
this._triggerFromState = tc.trigger_from_state || "";
|
||||
this._triggerToState = tc.trigger_to_state || "";
|
||||
this._triggerTargetChanges = tc.trigger_target_changes?.toString() || "";
|
||||
this._triggerRuntimeHours = tc.trigger_runtime_hours?.toString() || "";
|
||||
this._triggerOnStates = (tc.trigger_on_states || []).join(", ");
|
||||
if (tc.type === "compound") {
|
||||
this._compoundLogic = tc.compound_logic === "OR" ? "OR" : "AND";
|
||||
this._compoundConditions = (tc.conditions || []).map(conditionToDraft);
|
||||
@@ -423,11 +467,14 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._triggerForMinutes = "0";
|
||||
this._triggerTargetValue = "";
|
||||
this._triggerDeltaMode = false;
|
||||
this._triggerBaselineValue = "";
|
||||
this._liveBaselineValue = null;
|
||||
this._autoCompleteOnRecovery = false;
|
||||
this._triggerFromState = "";
|
||||
this._triggerToState = "";
|
||||
this._triggerTargetChanges = "";
|
||||
this._triggerRuntimeHours = "";
|
||||
this._triggerOnStates = "";
|
||||
this._compoundLogic = "AND";
|
||||
this._compoundConditions = [];
|
||||
}
|
||||
@@ -844,12 +891,21 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
} else if (this._triggerType === "counter") {
|
||||
if (this._triggerTargetValue) { const v = parseFloat(this._triggerTargetValue); if (!isNaN(v)) triggerConfig.trigger_target_value = v; }
|
||||
triggerConfig.trigger_delta_mode = this._triggerDeltaMode;
|
||||
// #102: optional counting start value ("last service was at X").
|
||||
// Empty = count from the reading at creation / keep the live
|
||||
// baseline; the backend clears stale Store state when it changes.
|
||||
if (this._triggerDeltaMode && this._triggerBaselineValue) {
|
||||
const b = parseFloat(this._triggerBaselineValue);
|
||||
if (!isNaN(b) && b >= 0) triggerConfig.trigger_baseline_value = b;
|
||||
}
|
||||
} else if (this._triggerType === "state_change") {
|
||||
if (this._triggerFromState) triggerConfig.trigger_from_state = this._triggerFromState;
|
||||
if (this._triggerToState) triggerConfig.trigger_to_state = this._triggerToState;
|
||||
if (this._triggerTargetChanges) { const v = parseInt(this._triggerTargetChanges, 10); if (!isNaN(v)) triggerConfig.trigger_target_changes = v; }
|
||||
} else if (this._triggerType === "runtime") {
|
||||
if (this._triggerRuntimeHours) { const v = parseFloat(this._triggerRuntimeHours); if (!isNaN(v)) triggerConfig.trigger_runtime_hours = v; }
|
||||
const onStates = this._triggerOnStates.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (onStates.length > 0) triggerConfig.trigger_on_states = onStates;
|
||||
}
|
||||
|
||||
data.trigger_config = triggerConfig;
|
||||
@@ -1146,6 +1202,8 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
return html`
|
||||
<ms-textfield label="${t("runtime_hours", L)}" type="number" .value=${c.runtimeHours}
|
||||
@input=${(e: Event) => this._patchCondition(i, { runtimeHours: (e.target as HTMLInputElement).value })}></ms-textfield>
|
||||
<ms-textfield label="${t("runtime_on_states", L)}" placeholder="on" .value=${c.onStates}
|
||||
@input=${(e: Event) => this._patchCondition(i, { onStates: (e.target as HTMLInputElement).value })}></ms-textfield>
|
||||
`;
|
||||
}
|
||||
return nothing;
|
||||
@@ -1175,6 +1233,99 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
: [...this._weekdays, i];
|
||||
}
|
||||
|
||||
/** The draft schedule in engine (Schedule.to_dict) form — MIRRORS the
|
||||
* _save mapping; keep both in sync when adding schedule fields. Null =
|
||||
* nothing to preview (manual, or trigger-only without an interval). */
|
||||
private _previewScheduleDict(): Record<string, unknown> | null {
|
||||
if (this._scheduleType === "one_time") {
|
||||
return this._dueDate ? { kind: "one_time", due_date: this._dueDate } : null;
|
||||
}
|
||||
if (CALENDAR_KINDS.includes(this._scheduleType)) {
|
||||
return { ...this._buildSchedule(), ...this._recurrenceExtras() };
|
||||
}
|
||||
const every = parseInt(this._intervalDays, 10);
|
||||
if (this._scheduleType === "manual" || !every || every <= 0) return null;
|
||||
return {
|
||||
kind: "interval",
|
||||
every,
|
||||
unit: this._intervalUnit,
|
||||
anchor: this._intervalAnchor,
|
||||
...this._recurrenceExtras(),
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly _PREVIEW_RELEVANT = new Set([
|
||||
"_open", "_scheduleType", "_intervalDays", "_intervalUnit", "_intervalAnchor",
|
||||
"_dueDate", "_weekdays", "_nth", "_nthWeekday", "_domDay", "_domLastDay",
|
||||
"_domBusiness", "_calOffset", "_seasonMonths", "_endsMode", "_endsCount",
|
||||
"_endsUntil", "_lastPerformed",
|
||||
]);
|
||||
|
||||
protected updated(changed: Map<PropertyKey, unknown>): void {
|
||||
super.updated?.(changed);
|
||||
for (const key of changed.keys()) {
|
||||
if (MaintenanceTaskDialog._PREVIEW_RELEVANT.has(String(key))) {
|
||||
this._schedulePreviewRefresh();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _schedulePreviewRefresh(): void {
|
||||
if (this._previewTimer) clearTimeout(this._previewTimer);
|
||||
this._previewTimer = setTimeout(() => void this._fetchSchedulePreview(), 300);
|
||||
}
|
||||
|
||||
private async _fetchSchedulePreview(): Promise<void> {
|
||||
const sched = this._open ? this._previewScheduleDict() : null;
|
||||
if (!sched) {
|
||||
this._schedulePreview = [];
|
||||
this._schedulePreviewEnded = false;
|
||||
return;
|
||||
}
|
||||
const seq = ++this._previewSeq;
|
||||
try {
|
||||
const res = await this.hass.connection.sendMessagePromise<{
|
||||
occurrences: string[];
|
||||
series_ended: boolean;
|
||||
}>({
|
||||
type: "maintenance_supporter/schedule/preview",
|
||||
schedule: sched,
|
||||
...(this._lastPerformed ? { last_performed: this._lastPerformed } : {}),
|
||||
});
|
||||
if (seq !== this._previewSeq) return; // a newer edit superseded this
|
||||
this._schedulePreview = res.occurrences || [];
|
||||
this._schedulePreviewEnded = !!res.series_ended;
|
||||
} catch {
|
||||
// Transient WS error — keep the last preview instead of flickering.
|
||||
}
|
||||
}
|
||||
|
||||
private _renderSchedulePreview() {
|
||||
if (this._schedulePreview.length === 0) return nothing;
|
||||
const L = this._lang;
|
||||
const time = this.scheduleTimeEnabled && this._scheduleTime ? ` ${this._scheduleTime}` : "";
|
||||
const chips = this._schedulePreview
|
||||
.map((iso, i) => {
|
||||
const js = new Date(`${iso}T12:00:00`).getDay(); // 0=Sun
|
||||
const wd = weekdayName(js === 0 ? 6 : js - 1, L, "short");
|
||||
return `${wd} ${formatDate(iso, L)}${i === 0 ? time : ""}`;
|
||||
})
|
||||
.join(" · ");
|
||||
const onTime =
|
||||
this._scheduleType === "time_based" && this._intervalAnchor === "completion"
|
||||
? html`<div class="field-help">${t("schedule_preview_ontime", L)}</div>`
|
||||
: nothing;
|
||||
return html`
|
||||
<div class="trigger-live-hint schedule-preview">
|
||||
${t("schedule_preview_title", L)}: ${chips}${this._schedulePreviewEnded
|
||||
? html` <span class="field-help">${t("schedule_preview_ends", L)}</span>`
|
||||
: nothing}
|
||||
${onTime}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Build the nested `schedule` object for the selected calendar kind. */
|
||||
private _buildSchedule(): Record<string, unknown> {
|
||||
const withOffset = (schedule: Record<string, unknown>) => {
|
||||
@@ -1463,6 +1614,28 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
/>
|
||||
${t("delta_mode", L)}
|
||||
</label>
|
||||
${this._triggerDeltaMode
|
||||
? html`
|
||||
<ms-textfield
|
||||
label="${t("baseline_start_value", L)}"
|
||||
type="number"
|
||||
step="any"
|
||||
.value=${this._triggerBaselineValue}
|
||||
@input=${(e: Event) => (this._triggerBaselineValue = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
<div class="field-help">
|
||||
${this._taskId ? t("baseline_start_help_edit", L) : t("baseline_start_help", L)}
|
||||
${this._taskId && this._liveBaselineValue != null
|
||||
? html`<div class="baseline-effective">
|
||||
${t("baseline_current_effective", L).replace(
|
||||
"{value}",
|
||||
String(this._liveBaselineValue),
|
||||
)}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
if (this._triggerType === "state_change") {
|
||||
@@ -1497,6 +1670,13 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
.value=${this._triggerRuntimeHours}
|
||||
@input=${(e: Event) => (this._triggerRuntimeHours = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
<ms-textfield
|
||||
label="${t("runtime_on_states", L)}"
|
||||
placeholder="on"
|
||||
.value=${this._triggerOnStates}
|
||||
@input=${(e: Event) => (this._triggerOnStates = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
<div class="field-help">${t("runtime_on_states_help", L)}</div>
|
||||
`;
|
||||
}
|
||||
return nothing;
|
||||
@@ -1583,12 +1763,13 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
? html`<input
|
||||
class="consumes-qty"
|
||||
type="number"
|
||||
min="1"
|
||||
min="0.01"
|
||||
max="999"
|
||||
step="0.01"
|
||||
.value=${String(qty)}
|
||||
@input=${(e: Event) => {
|
||||
const v = parseInt((e.target as HTMLInputElement).value, 10);
|
||||
this._consumesParts = { ...this._consumesParts, [part.id]: Number.isFinite(v) && v >= 1 ? v : 1 };
|
||||
const v = parseFloat((e.target as HTMLInputElement).value);
|
||||
this._consumesParts = { ...this._consumesParts, [part.id]: Number.isFinite(v) && v >= 0.01 ? v : 1 };
|
||||
}}
|
||||
/>`
|
||||
: nothing}
|
||||
@@ -1672,6 +1853,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
`
|
||||
: nothing}
|
||||
${this._renderRecurrenceExtras()}
|
||||
${this._renderSchedulePreview()}
|
||||
<ms-textfield
|
||||
label="${t("warning_days", L)}"
|
||||
type="number"
|
||||
@@ -1949,6 +2131,11 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
font-size: 12px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.baseline-effective {
|
||||
margin-top: 2px;
|
||||
font-weight: 500;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
/* Live computed trigger hint — reads the bound sensor and explains what
|
||||
happens next. Info-accented so it reads as guidance, not an error. */
|
||||
.trigger-live-hint {
|
||||
|
||||
Reference in New Issue
Block a user