257 files

This commit is contained in:
Home Assistant Version Control
2026-10-02 16:06:24 +00:00
parent 7c55603942
commit c29f5cc088
258 changed files with 24621 additions and 3136 deletions
@@ -9,6 +9,8 @@ import { property, state } from "lit/decorators.js";
import { t, ensureLocale, langOf, formatDate, syncLocaleFromHass } from "../styles";
import { LS_KEYS, lsGet, lsSet } from "../helpers/storage-keys";
import { runWs } from "../helpers/ws-run";
import { canWrite, NO_DELEGATION, type WriteAccess } from "../helpers/permissions";
import { fetchSettingsOnce } from "../helpers/settings-cache";
import { isoDateLocal } from "../helpers/calendar-bucket";
import { px } from "../renderers/chart-utils";
import type { HomeAssistant } from "../types";
@@ -107,6 +109,12 @@ export class MaintenanceBatteryFleetSection extends LitElement {
@state() private _rosterSort: "name" | "urgency" = MaintenanceBatteryFleetSection._storedSort();
@state() private _typeFilter: string | null = null;
@state() private _recorded: string[] = [];
/** Every fleet action (mark replaced, record, exclude/include, add, the two
* options, repair) is WRITE tier on the server; the section offered them
* to every household member, who then got "not permitted" and a checkbox
* left flipped (audit 2026-09-29). Same rule as the panel and the other
* cards: helpers/permissions.canWrite. */
@state() private _access: WriteAccess = NO_DELEGATION;
private _historyRequested = false;
private _localeReady = false;
@@ -114,6 +122,10 @@ export class MaintenanceBatteryFleetSection extends LitElement {
return langOf(this.hass);
}
private get _canWrite(): boolean {
return canWrite(this.hass?.user, this._access);
}
connectedCallback(): void {
super.connectedCallback();
if (this.hass) this._load();
@@ -128,6 +140,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
if (changed.has("hass") && this.hass && !this._localeReady) {
this._localeReady = true;
ensureLocale(this._lang).then(() => this.requestUpdate());
void fetchSettingsOnce(this.hass).then((s) => { this._access = s.access; });
if (this._ov === null && !this._loading) this._load();
}
}
@@ -432,7 +445,8 @@ export class MaintenanceBatteryFleetSection extends LitElement {
? html`<span class="bf-offline bf-nosensor">${t("battery_fleet_no_sensor", L)}</span>`
: nothing;
const type = html`<span class="bf-type">${b.quantity}× ${b.battery_type}</span>`;
const showMark = o.mark === "always" || b.no_sensor || b.can_mark_replaced;
const writer = this._canWrite;
const showMark = writer && (o.mark === "always" || b.no_sensor || b.can_mark_replaced);
return html`
<div class="bf-row">
<span class="bf-dev">${b.device_name}</span>
@@ -461,7 +475,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
<ha-icon icon="mdi:battery-sync"></ha-icon>
</button>`
: nothing}
${o.jump ? this._jumpButton(b, L) : nothing}
${o.jump && writer ? this._jumpButton(b, L) : nothing}
${o.predicted && b.days_until != null
? html`<span
class="bf-predicted ${b.predicted_source === "trend" ? "bf-trend" : ""} ${b.forecast_overdue ? "bf-overdue" : ""}"
@@ -469,7 +483,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
>${b.forecast_overdue ? html`<ha-icon icon="mdi:calendar-alert"></ha-icon>` : nothing}~${this._predictedDate(b.days_until)}</span
>`
: nothing}
${o.exclude
${o.exclude && writer
? html`<button
class="bf-mark bf-exclude"
title=${t("battery_fleet_exclude", L)}
@@ -504,9 +518,11 @@ export class MaintenanceBatteryFleetSection extends LitElement {
? 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>
${this._canWrite
? html`<ha-button .disabled=${this._marking} @click=${this._repair}>
${t("battery_fleet_repair", L)}
</ha-button>`
: nothing}
</div>
`
: nothing}
@@ -521,11 +537,13 @@ export class MaintenanceBatteryFleetSection extends LitElement {
<div class="bf-rows">
${ov.low.map((b) => this._renderRow(b, L, { recharge: true, mark: "always", exclude: true }))}
</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>
${this._canWrite
? html`<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>`
: nothing}
`}
${ov.soon.length
@@ -564,6 +582,19 @@ export class MaintenanceBatteryFleetSection extends LitElement {
)}
</div>
<div class="bf-roster-hint">${t("battery_fleet_all_hint", L)}</div>
${this._canWrite ? this._renderRosterSettings(ov, L) : nothing}
</details>
`
: nothing}
${this._renderExcluded(ov, L)}
<div class="bf-total">${t("battery_fleet_total", L).replace("{n}", String(ov.total))}</div>
</div>
`;
}
/** Add a battery + the two fleet options — write tier, so writers only. */
private _renderRosterSettings(ov: Overview, L: string) {
return html`
<div class="bf-add">
<span class="bf-label">${t("battery_fleet_add", L)}</span>
<ha-selector
@@ -594,9 +625,12 @@ export class MaintenanceBatteryFleetSection extends LitElement {
${t("battery_fleet_due_without_sensor", L)}
</label>
<div class="bf-roster-hint">${t("battery_fleet_due_without_sensor_hint", L)}</div>
</details>
`
: nothing}
`;
}
/** Excluded devices stay listed for everyone; bringing one back is write tier. */
private _renderExcluded(ov: Overview, L: string) {
return html`
${ov.excluded?.length
? html`
<div class="bf-excluded">
@@ -605,22 +639,22 @@ export class MaintenanceBatteryFleetSection extends LitElement {
(x) => html`
<span class="bf-excluded-chip">
${x.device_name}
<button
class="bf-mark"
title=${t("battery_fleet_include", L)}
.disabled=${this._marking}
@click=${() => this._setExcluded(x.entity_id, false)}
>
<ha-icon icon="mdi:eye-outline"></ha-icon>
</button>
${this._canWrite
? html`<button
class="bf-mark"
title=${t("battery_fleet_include", L)}
.disabled=${this._marking}
@click=${() => this._setExcluded(x.entity_id, false)}
>
<ha-icon icon="mdi:eye-outline"></ha-icon>
</button>`
: nothing}
</span>
`,
)}
</div>
`
: nothing}
<div class="bf-total">${t("battery_fleet_total", L).replace("{n}", String(ov.total))}</div>
</div>
`;
}
@@ -9,6 +9,7 @@ import { runWs } from "../helpers/ws-run";
import { isoMinuteLocal } from "../helpers/calendar-bucket";
import { PART_QTY_RANGE, RESTOCK_QTY_RANGE } from "../helpers/setting-ranges";
import { partLinkKey, type LinkedPart } from "../helpers/shared-parts";
import { unitPrice, type PartsCostMode } from "../helpers/parts-cost";
import { REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
import { PhotoUploadController } from "../helpers/photo-upload-controller";
import { parseDurationMinutes } from "../helpers/duration";
@@ -45,8 +46,12 @@ export class MaintenanceCompleteDialog extends LitElement {
/** #104 follow-up: the buy task's part unit cost — powers the cost
* suggestion (restock qty × unit cost). */
@property({ attribute: false }) public restockUnitCost: number | null = null;
/** #98: one package of the part ("400 ml") — the quantity counts packages. */
@property({ attribute: false }) public restockPackage = "";
/** Currency symbol for the cost suggestion ("" = plain number). */
@property() public currencySymbol = "";
/** #104: when spare parts count ("purchase" | "use"). */
@property({ attribute: false }) public partsCostMode: PartsCostMode = "purchase";
/** #99: the parts offered on completion — enables the editable "parts used"
* section. Built by `partsForCompletion`: the object's own inventory plus
* every shared pool this task links to (#111), each tagged with its owner. */
@@ -309,45 +314,60 @@ export class MaintenanceCompleteDialog extends LitElement {
return this.requiredFields.includes(field) ? html`<span class="req-mark" aria-hidden="true">*</span>` : nothing;
}
/** #104 follow-up: suggested cost derived from the parts this completion
* touches — the SELECTED "parts used" (qty × each part's unit cost) on a
* consuming task, or restock qty × unit cost on a buy task. Null when no
* involved part carries a price. Follows the live selection, so ticking
* a part off updates the suggestion. */
private _partsCostSuggestion(): number | null {
if (this.restockDefault !== null) {
const qty = parseFloat(this._restockQty);
if (this.restockUnitCost == null || !Number.isFinite(qty) || qty <= 0) return null;
return Math.round(this.restockUnitCost * qty * 100) / 100;
}
if (!this.parts.length) return null;
/** A buy task's price: restock qty (packages) × the part's price per
* package. Null without a price. */
private _purchaseSuggestion(): number | null {
if (this.restockDefault === null) return null;
const qty = parseFloat(this._restockQty);
if (this.restockUnitCost == null || !Number.isFinite(qty) || qty <= 0) return null;
return Math.round(this.restockUnitCost * qty * 100) / 100;
}
/** #104: the value of the SELECTED parts used (qty × each part's price per
* unit — a package price spread over its contents, #98). Follows the live
* selection. Null when no selected part carries a price. */
private _partsValue(): number | null {
if (this.restockDefault !== null || !this.parts.length) return null;
let sum = 0;
let priced = false;
for (const link of Object.values(this._usedParts)) {
const def = this.parts.find(
(pt) => partLinkKey({ part_id: pt.id, entry_id: pt.entry_id }) === partLinkKey(link),
);
if (def?.cost != null) {
sum += def.cost * (link.quantity || 1);
const price = unitPrice(def);
if (price !== null) {
sum += price * (link.quantity || 1);
priced = true;
}
}
return priced ? Math.round(sum * 100) / 100 : null;
}
/** The one-click "use ≈ X from parts" chip under the cost field. Hidden
* once the user typed a cost themselves — a suggestion, never an
* overwrite. */
/** Under the cost field. A buy task gets the one-click price suggestion
* (and, when parts count when used, the note that the price becomes the
* part's price). A completion that USES parts gets their value as a line
* — booked automatically when parts count when used, or named as counted
* at purchase. Suggesting it into the cost field (2.53) double-counted a
* household that books its purchases (Discussion #104). */
private _renderCostSuggestion(L: string) {
if (this._cost.trim() !== "") return nothing;
const suggestion = this._partsCostSuggestion();
if (suggestion == null || suggestion <= 0) return nothing;
const amount = formatCost(suggestion, this.currencySymbol, L);
return html`<button
type="button"
class="cost-suggestion"
@click=${() => (this._cost = String(Math.round(suggestion * 100) / 100))}
>${t("cost_from_parts", L).replace("{amount}", amount)}</button>`;
const purchase = this._purchaseSuggestion();
if (this.restockDefault !== null) {
const hint = this.partsCostMode === "use"
? html`<div class="cost-note">${t("cost_purchase_use_hint", L)}</div>`
: nothing;
if (this._cost.trim() !== "" || purchase == null || purchase <= 0) return hint;
const amount = formatCost(purchase, this.currencySymbol, L);
return html`<button
type="button"
class="cost-suggestion"
@click=${() => (this._cost = String(Math.round(purchase * 100) / 100))}
>${t("cost_from_parts", L).replace("{amount}", amount)}</button>${hint}`;
}
const value = this._partsValue();
if (value == null || value <= 0) return nothing;
const amount = formatCost(value, this.currencySymbol, L);
const key = this.partsCostMode === "use" ? "cost_parts_booked" : "cost_parts_info";
return html`<div class="cost-note">${t(key, L).replace("{amount}", amount)}</div>`;
}
private _close(): void {
@@ -452,7 +472,7 @@ export class MaintenanceCompleteDialog extends LitElement {
${this.restockDefault !== null
? html`
<label class="field">
<span class="field-label">${t("restock_quantity_label", L)}</span>
<span class="field-label">${t("restock_quantity_label", L)}${this.restockPackage ? ` (× ${this.restockPackage})` : ""}</span>
<input type="number" step="0.01" min=${RESTOCK_QTY_RANGE[0]} max=${RESTOCK_QTY_RANGE[1]} class="field-input"
.value=${this._restockQty}
@input=${(e: Event) => (this._restockQty = (e.target as HTMLInputElement).value)} />
@@ -563,6 +583,12 @@ export class MaintenanceCompleteDialog extends LitElement {
font-weight: 600;
}
/* #104: one-click cost suggestion from parts — quiet link-style chip. */
.cost-note {
font-size: 12px;
color: var(--secondary-text-color);
margin-top: 4px;
line-height: 1.4;
}
.cost-suggestion {
align-self: flex-start;
margin-top: 4px;
@@ -1,8 +1,16 @@
/** Dialog for creating/editing a maintenance object. */
/** Dialog for creating, editing and replacing a maintenance object. */
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant, MaintenanceObject, MaintenanceObjectResponse } from "../types";
/** What a device change did to the tasks' wiring (object/update + replace). */
export interface DeviceSwap {
moved: number;
unmatched: string[];
}
type ReplaceDevice = "keep" | "other" | "none";
import { t, langOf } from "../styles";
import { runWs } from "../helpers/ws-run";
@@ -32,6 +40,11 @@ export class MaintenanceObjectDialog extends LitElement {
@state() private _haDeviceId = "";
@state() private _parentEntryId = "";
@state() private _entryId: string | null = null; // null = create, string = update
// Replace (journey N1): the successor's device. The old unit's device is
// what the object links to now; a new unit usually is a new device.
@state() private _replacing = false;
@state() private _oldDeviceId = "";
@state() private _replaceDevice: ReplaceDevice = "keep";
private get _lang(): string {
return langOf(this.hass);
@@ -50,6 +63,22 @@ export class MaintenanceObjectDialog extends LitElement {
this._notes = "";
this._haDeviceId = "";
this._parentEntryId = "";
this._replacing = false;
this._error = "";
this._open = true;
}
/** Replace a worn-out object: the old one is archived in place, a
* successor carries its tasks and documents over. Asks for the name and
* for the new unit's device — keeping the old link silently made the
* successor watch the retired machine's sensors. */
public openReplace(entryId: string, obj: MaintenanceObject): void {
this._entryId = entryId;
this._replacing = true;
this._name = obj.name || "";
this._oldDeviceId = obj.ha_device_id || "";
this._haDeviceId = "";
this._replaceDevice = "keep";
this._error = "";
this._open = true;
}
@@ -67,10 +96,104 @@ export class MaintenanceObjectDialog extends LitElement {
this._notes = obj.notes || "";
this._haDeviceId = obj.ha_device_id || "";
this._parentEntryId = obj.parent_entry_id || "";
this._replacing = false;
this._error = "";
this._open = true;
}
private async _replace(): Promise<void> {
if (this._loading || !this._entryId) return;
const msg: Record<string, unknown> = {
type: "maintenance_supporter/object/replace",
entry_id: this._entryId,
name: this._name.trim() || null,
};
if (this._oldDeviceId) {
// "keep" sends nothing: the successor stays on the same device.
if (this._replaceDevice === "other") msg.ha_device_id = this._haDeviceId;
if (this._replaceDevice === "none") msg.ha_device_id = null;
} else if (this._haDeviceId) {
msg.ha_device_id = this._haDeviceId;
}
const res = await runWs<{ entry_id?: string; device_swap?: DeviceSwap }>(this, msg, {
busy: (b) => { this._loading = b; },
fallbackKey: "save_error",
onError: (m) => { this._error = m; },
});
if (res === undefined) return;
this._open = false;
this.dispatchEvent(new CustomEvent("object-replaced", {
detail: { entry_id: res?.entry_id, device_swap: res?.device_swap },
}));
}
private _deviceName(deviceId: string): string {
const devices = (this.hass as unknown as { devices?: Record<string, { name?: string | null; name_by_user?: string | null }> })?.devices;
const device = devices?.[deviceId];
return device?.name_by_user || device?.name || deviceId;
}
private _deviceSelector(label: string) {
return html`<ha-form
.hass=${this.hass}
.data=${{ device: this._haDeviceId || undefined }}
.schema=${[{ name: "device", selector: { device: {} } }]}
.computeLabel=${() => label}
@value-changed=${(e: CustomEvent) =>
(this._haDeviceId =
((e.detail.value as { device?: string })?.device as string) || "")}
></ha-form>`;
}
private _renderReplace() {
const L = this._lang;
const choice = (value: ReplaceDevice, label: string) => html`<label class="radio-row">
<input
type="radio"
name="replace-device"
.checked=${this._replaceDevice === value}
@change=${() => { this._replaceDevice = value; }}
/>
<span>${label}</span>
</label>`;
const blocked = this._replaceDevice === "other" && !this._haDeviceId;
return html`
<ha-dialog open @closed=${this._close}>
<div class="dialog-title">${t("replace_object", L)}</div>
<div class="content">
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
<div class="hint">${t("replace_object_prompt", L)}</div>
<ms-textfield
label="${t("replace_name_label", L)}"
.value=${this._name}
@input=${(e: Event) => (this._name = (e.target as HTMLInputElement).value)}
></ms-textfield>
${this._oldDeviceId
? html`<div class="radio-group" role="radiogroup" aria-label=${t("replace_device_heading", L)}>
<div class="textarea-label">${t("replace_device_heading", L)}</div>
${choice("keep", t("replace_device_keep", L).replace("{device}", this._deviceName(this._oldDeviceId)))}
${this._replaceDevice === "keep" ? html`<div class="hint">${t("replace_device_keep_hint", L)}</div>` : nothing}
${choice("other", t("replace_device_other", L))}
${this._replaceDevice === "other"
? html`${this._deviceSelector(t("replace_device_pick", L))}
<div class="hint">${t("replace_device_other_hint", L)}</div>`
: nothing}
${choice("none", t("replace_device_none", L))}
</div>`
: this._deviceSelector(t("link_device_optional", L))}
</div>
<div class="dialog-actions">
<ha-button appearance="plain" @click=${this._close}>
${t("cancel", L)}
</ha-button>
<ha-button @click=${this._replace} .disabled=${this._loading || blocked}>
${this._loading ? t("saving", L) : t("replace_object", L)}
</ha-button>
</div>
</ha-dialog>
`;
}
private async _save(): Promise<void> {
if (this._loading) return; // synchronous re-entry guard (double-click)
if (!this._name.trim()) return;
@@ -89,7 +212,7 @@ export class MaintenanceObjectDialog extends LitElement {
ha_device_id: this._haDeviceId || null,
parent_entry_id: this._parentEntryId || null,
};
const res = await runWs(
const res = await runWs<{ device_swap?: DeviceSwap }>(
this,
this._entryId
? { type: "maintenance_supporter/object/update", entry_id: this._entryId, ...fields }
@@ -98,7 +221,9 @@ export class MaintenanceObjectDialog extends LitElement {
);
if (res === undefined) return;
this._open = false;
this.dispatchEvent(new CustomEvent("object-saved"));
// A changed device moved the tasks' sensor links along — the panel says
// how many, and which could not be placed.
this.dispatchEvent(new CustomEvent("object-saved", { detail: { device_swap: res?.device_swap } }));
}
private _parentChoices(): MaintenanceObjectResponse[] {
@@ -111,6 +236,7 @@ export class MaintenanceObjectDialog extends LitElement {
render() {
if (!this._open) return html``;
if (this._replacing) return this._renderReplace();
const L = this._lang;
const title = this._entryId ? t("edit_object", L) : t("new_object", L);
return html`
@@ -275,6 +401,26 @@ export class MaintenanceObjectDialog extends LitElement {
color: var(--error-color, #f44336);
font-size: 13px;
}
.hint {
font-size: 13px;
color: var(--secondary-text-color);
line-height: 1.4;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.radio-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
cursor: pointer;
}
.radio-row input {
margin: 0;
}
`;
}
@@ -18,6 +18,7 @@ import { property, state } from "lit/decorators.js";
import { t, ensureLocale, langOf, formatCost, formatNumber, formatQty } from "../styles";
import { runWs } from "../helpers/ws-run";
import type { HomeAssistant, MaintenancePart } from "../types";
import { restockUnits, unitPrice } from "../helpers/parts-cost";
// Per-part document links (v2.26) — the task-documents component in part mode.
import "./task-documents";
@@ -34,6 +35,7 @@ interface PartForm {
stock: string;
reorder_threshold: string;
restock_quantity: string;
package_size: string;
auto_buy_task: boolean;
notes: string;
}
@@ -50,6 +52,7 @@ const EMPTY_FORM: PartForm = {
stock: "",
reorder_threshold: "",
restock_quantity: "",
package_size: "",
auto_buy_task: true,
notes: "",
};
@@ -111,6 +114,7 @@ export class MaintenancePartsSection extends LitElement {
stock: part.stock != null ? String(part.stock) : "",
reorder_threshold: part.reorder_threshold != null ? String(part.reorder_threshold) : "",
restock_quantity: part.restock_quantity != null ? String(part.restock_quantity) : "",
package_size: part.package_size != null ? String(part.package_size) : "",
auto_buy_task: !!part.auto_buy_task,
notes: part.notes || "",
};
@@ -131,6 +135,7 @@ export class MaintenancePartsSection extends LitElement {
stock: num(f.stock),
reorder_threshold: num(f.reorder_threshold),
restock_quantity: num(f.restock_quantity),
package_size: num(f.package_size),
auto_buy_task: f.auto_buy_task,
notes: f.notes.trim() || null,
};
@@ -221,6 +226,7 @@ export class MaintenancePartsSection extends LitElement {
? html`<span class="loc"><ha-icon icon="mdi:map-marker-outline"></ha-icon>${part.storage_location}</span>`
: nothing}
</div>
${part.notes ? html`<div class="part-notes">${part.notes}</div>` : nothing}
</div>
<ha-icon-button
title=${t("documents", L)}
@@ -254,7 +260,9 @@ export class MaintenancePartsSection extends LitElement {
@click=${() => {
this._restockFor = part.id;
this._restockInvalid = false;
this._restockQty = String(part.restock_quantity || 1);
// The stock counts units: one restock is its
// packages × package size (#98 follow-up).
this._restockQty = String(restockUnits(part));
}}
><ha-icon icon="mdi:plus-minus-variant"></ha-icon
></ha-icon-button>
@@ -315,7 +323,15 @@ export class MaintenancePartsSection extends LitElement {
${this._field(t("part_cost", L), "cost", { type: "number" })}
${this._field(t("part_stock", L), "stock", { type: "number" })}
${this._field(t("part_reorder_threshold", L), "reorder_threshold", { type: "number" })}
${this._field(t("part_restock_quantity", L), "restock_quantity", { type: "number" })}
${this._field(t("part_package_size", L), "package_size", { type: "number" })}
${this._field(
f.package_size.trim() ? t("part_restock_packages", L) : t("part_restock_quantity", L),
"restock_quantity",
{ type: "number" },
)}
${f.package_size.trim()
? html`<div class="form-hint">${t("part_package_hint", L)}</div>`
: nothing}
<label class="form-field checkbox">
<input
type="checkbox"
@@ -326,6 +342,20 @@ export class MaintenancePartsSection extends LitElement {
/>
<span>${t("part_auto_buy", L)}</span>
</label>
<!-- Documented and searched since the parts feature, but the form
never had a field — only the API and imports could set it
(audit 2026-09-28). -->
<label class="form-field notes">
<span>${t("notes_label", L)}</span>
<textarea
rows="2"
maxlength="500"
.value=${f.notes}
@input=${(e: Event) => {
this._editing = { ...this._editing!, notes: (e.target as HTMLTextAreaElement).value };
}}
></textarea>
</label>
</div>
<div class="form-actions">
<ha-button appearance="plain" @click=${() => (this._editing = null)}>${t("cancel", L)}</ha-button>
@@ -343,7 +373,8 @@ export class MaintenancePartsSection extends LitElement {
private _inventoryValue(): number | null {
let sum = 0, any = false;
for (const p of this.parts) {
const cost = typeof p.cost === "number" ? p.cost : null;
// Per unit: a package price spread over its contents (#98 follow-up).
const cost = unitPrice(p);
const stock = typeof p.stock === "number" ? p.stock : null;
if (cost !== null && stock !== null) { sum += cost * stock; any = true; }
}
@@ -475,6 +506,12 @@ export class MaintenancePartsSection extends LitElement {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 8px 12px;
}
.form-hint {
grid-column: 1 / -1;
font-size: 12px;
color: var(--secondary-text-color);
line-height: 1.4;
}
.form-field {
display: flex;
flex-direction: column;
@@ -490,6 +527,25 @@ export class MaintenancePartsSection extends LitElement {
background: var(--card-background-color);
color: var(--primary-text-color);
}
.form-field.notes {
grid-column: 1 / -1;
}
.form-field textarea {
padding: 6px;
border: 1px solid var(--divider-color);
border-radius: 4px;
background: var(--card-background-color);
color: var(--primary-text-color);
font: inherit;
resize: vertical;
}
.part-notes {
font-size: 12px;
color: var(--secondary-text-color);
white-space: pre-line;
overflow-wrap: anywhere;
margin-top: 2px;
}
.form-field.checkbox {
flex-direction: row;
align-items: center;
@@ -9,7 +9,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant } from "../types";
import { t } from "../styles";
import { langOf, t } from "../styles";
import { downloadTextFile } from "../helpers/download";
interface QrResult {
@@ -43,7 +43,14 @@ function sanitizeFilename(s: string): string {
export class MaintenanceQrDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public lang = "en";
/** Set by the panel; the dashboard path (dialog-mount) leaves it empty, and
* the old "en" default then beat the user's language there (audit
* 2026-09-29) — _dialogLang falls back to hass.language instead. */
@property() public lang = "";
private get _dialogLang(): string {
return this.lang || langOf(this.hass);
}
@state() private _open = false;
@state() private _loading = false;
@@ -139,8 +146,8 @@ export class MaintenanceQrDialog extends LitElement {
const code = (err as Record<string, unknown>)?.code;
const msg = (err as Record<string, unknown>)?.message;
this._error = code === "no_url" || (typeof msg === "string" && msg.includes("No Home Assistant URL"))
? t("qr_error_no_url", this.lang)
: t("qr_error", this.lang);
? t("qr_error_no_url", this._dialogLang)
: t("qr_error", this._dialogLang);
} finally {
if (seq === this._generateSeq) this._loading = false;
}
@@ -175,7 +182,7 @@ export class MaintenanceQrDialog extends LitElement {
.join(" ");
const w = window.open("", "_blank", "width=600,height=500");
if (!w) return;
const L = this.lang || "en";
const L = this._dialogLang;
const safeTitle = escapeHtml(title);
const safeSub = escapeHtml(subtitle);
@@ -249,7 +256,7 @@ ${safeSub ? `<div class="sub">${safeSub}</div>` : ""}
render() {
if (!this._open) return html``;
const L = this.lang || this.hass?.language || "en";
const L = this._dialogLang;
const heading = this._taskName
? `${t("qr_code", L)}: ${this._objectName} — ${this._taskName}`
: `${t("qr_code", L)}: ${this._objectName}`;
@@ -113,6 +113,8 @@ interface SettingsResponse {
/** #173 follow-up: completion notifications — off | automatic | all. */
completed?: string;
extra_data?: string;
/** #44: default | object_name | task_name — was only in the Configure dialog. */
title_style?: string;
};
actions: {
complete_enabled: boolean;
@@ -132,6 +134,8 @@ interface SettingsResponse {
currency_symbol: string;
/** Decimal places for every displayed amount (0 = whole numbers). */
currency_decimals?: number;
/** #104: spare parts count when bought ("purchase") or used ("use"). */
parts_cost_mode?: string;
};
// v2.10.0 archive automation (panel-managed). Optional for forward-compat
// with a backend that predates the feature.
@@ -183,6 +187,9 @@ export class MaintenanceSettingsView extends LitElement {
@state() private _settings: SettingsResponse | null = null;
@state() private _loading = true;
@state() private _importCsv = "";
/** People an import's tasks pointed at that match nobody on this instance
* (by name) — their assignments were cleared; shown until the next import. */
@state() private _importUnmatched: string[] = [];
@state() private _importLoading = false;
@state() private _includeHistory = true;
@state() private _toast = "";
@@ -689,7 +696,6 @@ export class MaintenanceSettingsView extends LitElement {
// "groups" target also lands here as the closest match.
const items: { key: keyof AdvancedFeatures; settingKey: string; label: string; desc: string }[] = [
{ key: "adaptive", settingKey: "advanced_adaptive_visible", label: t("feat_adaptive", L), desc: t("feat_adaptive_desc", L) },
{ key: "predictions", settingKey: "advanced_predictions_visible", label: t("feat_predictions", L), desc: t("feat_predictions_desc", L) },
{ key: "seasonal", settingKey: "advanced_seasonal_visible", label: t("feat_seasonal", L), desc: t("feat_seasonal_desc", L) },
{ key: "environmental", settingKey: "advanced_environmental_visible", label: t("feat_environmental", L), desc: t("feat_environmental_desc", L) },
{ key: "budget", settingKey: "advanced_budget_visible", label: t("feat_budget", L), desc: t("feat_budget_desc", L) },
@@ -1056,6 +1062,15 @@ export class MaintenanceSettingsView extends LitElement {
</select>
</label>
<div class="setting-hint">${t("settings_currency_decimals_hint", L)}</div>
<label class="setting-row">
<span class="setting-label">${t("settings_parts_cost_mode", L)}</span>
<select class="parts-cost-mode" .value=${live(b.parts_cost_mode === "use" ? "use" : "purchase")}
@change=${(e: Event) => this._updateSetting("parts_cost_mode", (e.target as HTMLSelectElement).value)}>
<option value="purchase" ?selected=${b.parts_cost_mode !== "use"}>${t("settings_parts_cost_purchase", L)}</option>
<option value="use" ?selected=${b.parts_cost_mode === "use"}>${t("settings_parts_cost_use", L)}</option>
</select>
</label>
<div class="setting-hint">${t("settings_parts_cost_mode_hint", L)}</div>
<label class="setting-row">
<span class="setting-label">${t("settings_panel_enabled", L)}</span>
<input type="checkbox" .checked=${g.panel_enabled}
@@ -1093,7 +1108,7 @@ export class MaintenanceSettingsView extends LitElement {
<div class="setting-row">
<span class="setting-label">${t("test_notification", L)}</span>
<button class="ha-button secondary"
?disabled=${!g.notify_service || this._testingNotification}
?disabled=${(!g.notify_service && !this._settings?.notifications?.event_only) || this._testingNotification}
@click=${() => this._sendTestNotification()}>
${this._testingNotification ? t("testing", L) : t("send_test", L)}
</button>
@@ -1273,6 +1288,16 @@ export class MaintenanceSettingsView extends LitElement {
</select>
</label>
<div class="setting-hint">${t("settings_notify_completed_hint", L)}</div>
<label class="setting-row">
<span class="setting-label">${t("settings_title_style", L)}</span>
<select class="notify-title-style" .value=${live(n.title_style || "default")}
@change=${(e: Event) => this._updateSetting("notification_title_style", (e.target as HTMLSelectElement).value)}>
<option value="default">${t("title_style_default", L)}</option>
<option value="object_name">${t("title_style_object_name", L)}</option>
<option value="task_name">${t("title_style_task_name", L)}</option>
</select>
</label>
<div class="setting-hint">${t("settings_title_style_hint", L)}</div>
<div class="notify-rule">
<h4 style="margin: 16px 0 8px; font-size: 14px;">${t("settings_notify_rule", L)}</h4>
@@ -2015,6 +2040,11 @@ export class MaintenanceSettingsView extends LitElement {
${this._importLoading ? "…" : t("settings_import_btn", L)}
</button>
</div>
${this._importUnmatched.length
? html`<div class="import-notice">
${t("settings_import_unmatched_users", L).replace("{names}", this._importUnmatched.join(", "))}
</div>`
: nothing}
</div>
</div>
`;
@@ -2101,7 +2131,8 @@ export class MaintenanceSettingsView extends LitElement {
// (JSON `{`/`[` or YAML `version:`) goes to the structured importer,
// which parses JSON and YAML alike.
const isCsv = content.startsWith("object_name");
const result = await this._ws<{ created: number }>(
this._importUnmatched = [];
const result = await this._ws<{ created: number; unmatched_users?: string[] }>(
isCsv
? { type: "maintenance_supporter/csv/import", csv_content: content }
: { type: "maintenance_supporter/json/import", json_content: content },
@@ -2111,6 +2142,7 @@ export class MaintenanceSettingsView extends LitElement {
if (!result) return;
const count = result.created ?? 0;
this._showToast(t("settings_import_success", this._lang).replace("{count}", String(count)));
this._importUnmatched = Array.isArray(result.unmatched_users) ? result.unmatched_users : [];
this._importCsv = "";
this.dispatchEvent(new CustomEvent("settings-changed"));
}
@@ -2143,13 +2175,18 @@ export class MaintenanceSettingsView extends LitElement {
try {
const form = new FormData();
form.append("file", file, file.name);
const result = await postMultipart<{ blobs_written: number; documents_created: number }>(
this.hass, "/api/maintenance_supporter/documents/archive", form, "docs_archive_too_large",
const result = await postMultipart<{ blobs_written: number; documents_created: number; files_missing?: number }>(
this.hass, "/api/maintenance_supporter/documents/archive", form, "docs_archive_too_large", ["docs_archive_no_manifest"],
);
const restored = t("settings_docs_import_success", this._lang)
.replace("{blobs}", String(result.blobs_written ?? 0))
.replace("{docs}", String(result.documents_created ?? 0));
// Documents whose file the archive did not carry are not created —
// said, so a file left out when zipping again is noticed.
this._showToast(
t("settings_docs_import_success", this._lang)
.replace("{blobs}", String(result.blobs_written ?? 0))
.replace("{docs}", String(result.documents_created ?? 0))
result.files_missing
? `${restored} · ${t("settings_docs_import_missing", this._lang).replace("{n}", String(result.files_missing))}`
: restored,
);
this.dispatchEvent(new CustomEvent("settings-changed"));
} catch (e) {
@@ -2158,7 +2195,9 @@ export class MaintenanceSettingsView extends LitElement {
e instanceof Error && e.message === "docs_archive_too_large"
? t("docs_archive_too_large", this._lang)
.replace("{max}", `${formatNumber(DOCS_ARCHIVE_MAX_BYTES / (1024 * 1024), this._lang)} MB`)
: t("action_error", this._lang),
: e instanceof Error && e.message === "docs_archive_no_manifest"
? t("docs_archive_no_manifest", this._lang)
: t("action_error", this._lang),
);
}
input.value = "";
@@ -2168,6 +2207,12 @@ export class MaintenanceSettingsView extends LitElement {
// --- Styles ---
static styles = [personStyles, css`
.import-notice {
margin-top: 8px; padding: 8px 12px; border-radius: 8px;
background: rgba(255, 152, 0, 0.1);
border: 1px solid rgba(255, 152, 0, 0.35);
font-size: 13.5px; line-height: 1.45;
}
.bn-note {
display: flex; align-items: flex-start; gap: 10px;
margin: 6px 0 10px; padding: 10px 12px; border-radius: 8px;
@@ -2331,6 +2376,13 @@ export class MaintenanceSettingsView extends LitElement {
.settings-actions {
display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px;
}
/* Heading, hint and buttons stacked — as a row the hint landed between
the heading and the buttons, reading like a caption for neither. */
.docs-archive-block {
flex-direction: column;
align-items: flex-start;
}
.docs-archive-block h4, .docs-archive-block .section-desc { margin: 0; }
.settings-actions button {
padding: 8px 16px;
border-radius: 8px;
@@ -2443,7 +2495,9 @@ export class MaintenanceSettingsView extends LitElement {
}
.vac-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
/* The date fields carry a typing toggle and a clear button (2 × 36px)
next to the picker — 160px left the date itself cut to "11/1". */
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 12px;
margin-bottom: 12px;
}
@@ -18,6 +18,7 @@ import {
EWA_ALPHA_RANGE,
PART_QTY_RANGE,
SCHEDULE_OFFSET_MAX_DAYS,
TRIGGER_DAYS_BEFORE_RANGE,
TRIGGER_FOR_MINUTES_RANGE,
TRIGGER_TARGET_CHANGES_RANGE,
WARNING_DAYS_RANGE,
@@ -39,7 +40,10 @@ const CALENDAR_KINDS = ["weekdays", "nth_weekday", "day_of_month", "calendar"];
// #168: kinds that produce a due DATE and therefore take a time of day
// (mirrors SCHEDULE_TIME_KINDS in config_flow_helpers.py).
const SCHEDULE_TIME_KINDS = ["time_based", "one_time", ...CALENDAR_KINDS];
const TRIGGER_TYPE_KEYS = ["threshold", "counter", "state_change", "runtime"];
const TRIGGER_TYPE_KEYS = ["threshold", "counter", "state_change", "runtime", "due_date"];
// "due_date" alone is the one-time task's date field — the trigger type
// carries its own label.
const triggerTypeLabel = (key: string, L: string): string => t(key === "due_date" ? "trigger_type_due_date" : key, L);
// The type selector additionally offers "compound" (a group of conditions
// joined by AND/OR); its per-condition sub-type is limited to the flat kinds.
const TRIGGER_TYPE_KEYS_WITH_COMPOUND = [...TRIGGER_TYPE_KEYS, "compound"];
@@ -68,6 +72,7 @@ interface CompoundConditionDraft {
targetChanges: string;
runtimeHours: string;
onStates: string;
daysBefore: 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>;
@@ -78,7 +83,7 @@ function emptyCondition(): CompoundConditionDraft {
entityIds: "", type: "threshold", attribute: "", above: "", below: "",
equals: "", notEquals: "", forMinutes: "0",
targetValue: "", deltaMode: false, fromState: "", toState: "",
targetChanges: "", runtimeHours: "", onStates: "", carry: {},
targetChanges: "", runtimeHours: "", onStates: "", daysBefore: "0", carry: {},
};
}
@@ -90,6 +95,7 @@ const MANAGED_CONDITION_KEYS = new Set([
"trigger_target_value", "trigger_delta_mode",
"trigger_from_state", "trigger_to_state", "trigger_target_changes",
"trigger_runtime_hours", "trigger_on_states",
"trigger_days_before",
]);
/** The per-type trigger fields BOTH editors own — the flat trigger form and
@@ -117,6 +123,7 @@ export function typeFieldsFromConfig(c: TriggerConfig): TriggerTypeFields {
targetChanges: c.trigger_target_changes?.toString() ?? "",
runtimeHours: c.trigger_runtime_hours?.toString() ?? "",
onStates: (c.trigger_on_states || []).join(", "),
daysBefore: c.trigger_days_before?.toString() ?? "0",
};
}
@@ -144,6 +151,9 @@ export function applyTypeFields(c: TriggerConfig, d: TriggerTypeFields): void {
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;
} else if (d.type === "due_date") {
const days = parseInt(d.daysBefore, 10);
c.trigger_days_before = isNaN(days) ? 0 : days;
}
}
@@ -184,6 +194,13 @@ export class MaintenanceTaskDialog extends LitElement {
@property({ type: Boolean, attribute: "checklists-enabled" }) public checklistsEnabled = false;
@property({ type: Boolean, attribute: "schedule-time-enabled" }) public scheduleTimeEnabled = false;
@property({ type: Boolean, attribute: "completion-actions-enabled" }) public completionActionsEnabled = false;
/** Advanced features (Settings → Advanced Features): the dialog offers what
* the panel then shows — with Adaptive off it let you switch adaptive
* scheduling on while the feedback question and every result stayed
* hidden (audit 2026-09-28). What a task already has stays visible. */
@property({ type: Boolean, attribute: "adaptive-feature" }) public adaptiveFeature = false;
@property({ type: Boolean, attribute: "seasonal-feature" }) public seasonalFeature = false;
@property({ type: Boolean, attribute: "environmental-feature" }) public environmentalFeature = false;
@property({ type: Number, attribute: "default-warning-days" }) public defaultWarningDays = 7;
/** The object's spare parts — offered as "consumes parts" checkboxes. */
@state() private parts: Array<{ id: string; name: string; unit?: string }> = [];
@@ -285,6 +302,7 @@ export class MaintenanceTaskDialog extends LitElement {
@state() private _triggerToState = "";
@state() private _triggerTargetChanges = "";
@state() private _triggerRuntimeHours = "";
@state() private _triggerDaysBefore = "0";
@state() private _triggerRuntimeMaxSession = "";
// Comma-separated "running" states for the runtime trigger (#103) —
// empty = the backend default ["on"]. Must roundtrip on edit: adopted
@@ -366,6 +384,13 @@ export class MaintenanceTaskDialog extends LitElement {
// so ha-form can drive the data fields when the service schema is known.
@state() private _actionService = "";
@state() private _actionTargetEntity = "";
/** The stored target as it came, and the entity the field started with.
* The field edits one entity, but the API and imports accept several
* entities, devices, areas, labels and floors — rebuilding the target
* from the field dropped them on every save, a rename included (audit
* 2026-09-28). An untouched field sends the stored target back verbatim. */
private _actionTargetStored: Record<string, unknown> | null = null;
private _actionTargetInitial = "";
/** 2.95: not when the task completes itself (a reset wired by discovery). */
@state() private _actionSkipAuto = false;
/** 2.95: the task already has an action (suggested setups wire counter
@@ -382,7 +407,7 @@ export class MaintenanceTaskDialog extends LitElement {
@state() private _qcNotes = "";
@state() private _qcCost = "";
@state() private _qcDuration = "";
@state() private _qcFeedback: "" | "needed" | "not_needed" = "";
@state() private _qcFeedback: "" | "needed" | "not_needed" | "not_sure" = "";
// Environmental entity (adaptive_config)
@state() private _environmentalEntity = "";
@@ -398,6 +423,9 @@ export class MaintenanceTaskDialog extends LitElement {
@state() private _adaptiveSeasonal = true;
@state() private _adaptivePrediction = true;
private _adaptiveInitial = "";
/** The task had adaptive scheduling on when the dialog opened — its
* section stays reachable even with the Adaptive feature off. */
private _adaptiveWasEnabled = false;
private _adaptiveSnapshot(): string {
return JSON.stringify([
@@ -544,6 +572,10 @@ export class MaintenanceTaskDialog extends LitElement {
this._actionService = oca.service;
const tgt = oca.target?.entity_id;
this._actionTargetEntity = Array.isArray(tgt) ? (tgt[0] || "") : (tgt || "");
this._actionTargetStored = oca.target && typeof oca.target === "object"
? JSON.parse(JSON.stringify(oca.target)) as Record<string, unknown>
: null;
this._actionTargetInitial = this._actionTargetEntity;
this._actionData = (oca.data && typeof oca.data === "object") ? { ...oca.data } : {};
this._actionDataJsonFallback = "";
this._actionSkipAuto = oca.skip_auto === true;
@@ -551,6 +583,8 @@ export class MaintenanceTaskDialog extends LitElement {
} else {
this._actionService = "";
this._actionTargetEntity = "";
this._actionTargetStored = null;
this._actionTargetInitial = "";
this._actionData = {};
this._actionDataJsonFallback = "";
this._actionSkipAuto = false;
@@ -560,7 +594,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._qcNotes = qcd?.notes || "";
this._qcCost = qcd?.cost != null ? String(qcd.cost) : "";
this._qcDuration = qcd?.duration != null ? String(qcd.duration) : "";
this._qcFeedback = (qcd?.feedback as "needed" | "not_needed" | undefined) || "";
this._qcFeedback = (qcd?.feedback as "needed" | "not_needed" | "not_sure" | undefined) || "";
const ac: Partial<AdaptiveConfig> = task.adaptive_config || {};
this._environmentalEntity = ac.environmental_entity || "";
@@ -574,6 +608,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._adaptiveSeasonal = ac.seasonal_enabled !== false;
this._adaptivePrediction = ac.sensor_prediction_enabled !== false;
this._adaptiveInitial = this._adaptiveSnapshot();
this._adaptiveWasEnabled = this._adaptiveEnabled;
if (task.trigger_config) {
const tc = task.trigger_config;
@@ -668,9 +703,12 @@ export class MaintenanceTaskDialog extends LitElement {
this._adaptiveSeasonal = true;
this._adaptivePrediction = true;
this._adaptiveInitial = this._adaptiveSnapshot();
this._adaptiveWasEnabled = false;
// v1.3.0
this._actionService = "";
this._actionTargetEntity = "";
this._actionTargetStored = null;
this._actionTargetInitial = "";
this._actionData = {};
this._actionDataJsonFallback = "";
this._actionSkipAuto = false;
@@ -703,6 +741,7 @@ export class MaintenanceTaskDialog extends LitElement {
targetChanges: this._triggerTargetChanges,
runtimeHours: this._triggerRuntimeHours,
onStates: this._triggerOnStates,
daysBefore: this._triggerDaysBefore,
};
}
@@ -721,6 +760,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._triggerTargetChanges = f.targetChanges;
this._triggerRuntimeHours = f.runtimeHours;
this._triggerOnStates = f.onStates;
this._triggerDaysBefore = f.daysBefore;
}
private _resetTriggerFields(): void {
@@ -786,7 +826,7 @@ export class MaintenanceTaskDialog extends LitElement {
const svc = this._actionService.trim();
if (!svc || !/^[a-z][a-z0-9_]*\.[a-z0-9_]+$/.test(svc)) {
this._actionTestResult = "error";
this._actionTestError = "Invalid service format (expected 'domain.service')";
this._actionTestError = t("action_test_bad_format", this._lang);
setTimeout(() => { this._actionTestResult = ""; this._actionTestError = ""; }, 5000);
return;
}
@@ -795,7 +835,7 @@ export class MaintenanceTaskDialog extends LitElement {
// 2. Service exists?
if (!this.hass?.services?.[domain]?.[name]) {
this._actionTestResult = "error";
this._actionTestError = `Service "${svc}" is not registered in Home Assistant. Check spelling and that the integration providing it is loaded.`;
this._actionTestError = t("action_test_unknown_service", this._lang).replace("{service}", svc);
setTimeout(() => { this._actionTestResult = ""; this._actionTestError = ""; }, 8000);
return;
}
@@ -813,14 +853,19 @@ export class MaintenanceTaskDialog extends LitElement {
]);
if (entityDomain !== domain && !crossDomainServices.has(domain)) {
this._actionTestResult = "error";
this._actionTestError = `Service "${svc}" only works on ${domain}.* entities; entity "${tgt}" is in ${entityDomain}.* — pick a service that matches the entity domain (e.g. ${entityDomain}.${name})`;
this._actionTestError = t("action_test_domain_mismatch", this._lang)
.replace("{service}", svc)
.replace("{domain}", domain)
.replace("{entity}", tgt)
.replace("{entity_domain}", entityDomain)
.replace("{suggestion}", `${entityDomain}.${name}`);
setTimeout(() => { this._actionTestResult = ""; this._actionTestError = ""; }, 8000);
return;
}
// 5. Entity exists?
if (!this.hass.states?.[tgt]) {
this._actionTestResult = "error";
this._actionTestError = `Target entity "${tgt}" not found in Home Assistant — the entity may have been renamed or its integration removed.`;
this._actionTestError = t("action_test_entity_missing", this._lang).replace("{entity}", tgt);
setTimeout(() => { this._actionTestResult = ""; this._actionTestError = ""; }, 8000);
return;
}
@@ -868,6 +913,34 @@ export class MaintenanceTaskDialog extends LitElement {
}));
}
/** The stored target reaches beyond the one entity the field shows. */
private _actionTargetExtended(): boolean {
const st = this._actionTargetStored;
if (!st) return false;
return Object.entries(st).some(([key, v]) =>
key === "entity_id" ? Array.isArray(v) && v.length > 1 : Array.isArray(v) ? v.length > 0 : !!v);
}
/** Names of everything the stored target points at, for the hint. */
private _actionTargetSummary(): string {
const st = this._actionTargetStored || {};
const ids = (v: unknown): string[] =>
(Array.isArray(v) ? v : [v]).filter((s): s is string => typeof s === "string" && !!s);
const h = this.hass as unknown as {
states?: Record<string, { attributes?: { friendly_name?: string } }>;
devices?: Record<string, { name_by_user?: string | null; name?: string | null }>;
areas?: Record<string, { name?: string }>;
floors?: Record<string, { name?: string }>;
};
return [
...ids(st.entity_id).map((id) => h.states?.[id]?.attributes?.friendly_name || id),
...ids(st.device_id).map((id) => h.devices?.[id]?.name_by_user || h.devices?.[id]?.name || id),
...ids(st.area_id).map((id) => h.areas?.[id]?.name || id),
...ids(st.floor_id).map((id) => h.floors?.[id]?.name || id),
...ids(st.label_id),
].join(", ");
}
private _renderCompletionActionsSection(L: string) {
if (!this.completionActionsEnabled && !this._actionPresent) return nothing;
const schema = this._serviceSchema();
@@ -903,6 +976,9 @@ export class MaintenanceTaskDialog extends LitElement {
this._actionTargetEntity = v.target_entity || "";
}}
></ha-form>
${this._actionTargetExtended() && this._actionTargetEntity.trim() === this._actionTargetInitial
? html`<p class="field-help ca-target-more">${t("on_complete_action_target_more", L).replace("{targets}", this._actionTargetSummary())}</p>`
: nothing}
<p class="field-help ca-domain-hint">
${t("on_complete_action_target_hint", L)}
</p>
@@ -972,10 +1048,11 @@ export class MaintenanceTaskDialog extends LitElement {
></ms-textfield>
<select class="qc-feedback"
.value=${this._qcFeedback}
@change=${(e: Event) => { this._qcFeedback = (e.target as HTMLSelectElement).value as "" | "needed" | "not_needed"; }}>
@change=${(e: Event) => { this._qcFeedback = (e.target as HTMLSelectElement).value as "" | "needed" | "not_needed" | "not_sure"; }}>
<option value="">${t("quick_complete_defaults_feedback_none", L)}</option>
<option value="needed">${t("quick_complete_defaults_feedback_needed", L)}</option>
<option value="not_needed">${t("quick_complete_defaults_feedback_not_needed", L)}</option>
<option value="not_sure">${t("feedback_not_sure", L)}</option>
</select>
</details>` : nothing}
`;
@@ -1233,9 +1310,9 @@ export class MaintenanceTaskDialog extends LitElement {
.value=${s.unit || ""}
@input=${(e: Event) => this._patchReading(s.id, { unit: (e.target as HTMLInputElement).value })}
></ms-textfield>
<mwc-icon-button class="phase-remove reading-remove" @click=${() => (this._readings = this._readings.filter((x) => x.id !== s.id))}>
<ha-icon-button class="phase-remove reading-remove" @click=${() => (this._readings = this._readings.filter((x) => x.id !== s.id))}>
<ha-icon icon="mdi:delete-outline"></ha-icon>
</mwc-icon-button>
</ha-icon-button>
</div>
`)}
${this._readings.length < MAX_READING_SLOTS ? html`
@@ -1307,9 +1384,9 @@ export class MaintenanceTaskDialog extends LitElement {
@input=${(e: Event) => this._patchPhaseDef(d.id, { partQty: (e.target as HTMLInputElement).value })} />
` : nothing}
` : nothing}
<mwc-icon-button class="phase-remove" @click=${() => this._removePhaseDef(d.id)}>
<ha-icon-button class="phase-remove" @click=${() => this._removePhaseDef(d.id)}>
<ha-icon icon="mdi:delete-outline"></ha-icon>
</mwc-icon-button>
</ha-icon-button>
</div>
${this.checklistsEnabled ? html`
<textarea
@@ -1692,7 +1769,11 @@ export class MaintenanceTaskDialog extends LitElement {
if (svc && /^[a-z][a-z0-9_]*\.[a-z0-9_]+$/.test(svc)) {
const action: Record<string, unknown> = { service: svc };
const tgt = this._actionTargetEntity.trim();
if (tgt) action.target = { entity_id: tgt };
if (this._actionTargetStored && tgt === this._actionTargetInitial) {
action.target = JSON.parse(JSON.stringify(this._actionTargetStored));
} else if (tgt) {
action.target = { entity_id: tgt };
}
const dataDict = this._buildActionData();
if (Object.keys(dataDict).length > 0) {
action.data = dataDict;
@@ -1741,7 +1822,7 @@ export class MaintenanceTaskDialog extends LitElement {
@change=${(e: Event) => (this._triggerType = (e.target as HTMLSelectElement).value as TriggerType)}
>
${TRIGGER_TYPE_KEYS_WITH_COMPOUND.map(
(key) => html`<option value=${key} ?selected=${key === this._triggerType}>${t(key, L)}</option>`
(key) => html`<option value=${key} ?selected=${key === this._triggerType}>${triggerTypeLabel(key, L)}</option>`
)}
</select>
</div>
@@ -1917,7 +1998,7 @@ export class MaintenanceTaskDialog extends LitElement {
@change=${(e: Event) => this._patchCondition(i, { type: (e.target as HTMLSelectElement).value as TriggerType })}
>
${TRIGGER_TYPE_KEYS.map(
(key) => html`<option value=${key} ?selected=${key === c.type}>${t(key, L)}</option>`
(key) => html`<option value=${key} ?selected=${key === c.type}>${triggerTypeLabel(key, L)}</option>`
)}
</select>
</div>
@@ -1990,6 +2071,7 @@ export class MaintenanceTaskDialog extends LitElement {
* adapt. Collapsed unless adaptive is already enabled. */
private _renderAdaptiveSection(L: string) {
if (this._scheduleType === "one_time" || this._scheduleType === "manual") return nothing;
if (!this.adaptiveFeature && !this._adaptiveWasEnabled) return nothing;
return html`
<details class="adaptive-section" ?open=${this._adaptiveEnabled}>
<summary>${t("adaptive_section_title", L)}</summary>
@@ -2025,14 +2107,14 @@ export class MaintenanceTaskDialog extends LitElement {
.value=${this._adaptiveAlpha}
@input=${(e: Event) => (this._adaptiveAlpha = (e.target as HTMLInputElement).value)}
></ms-textfield>
<label>
${this.seasonalFeature ? html`<label>
<input
type="checkbox"
.checked=${this._adaptiveSeasonal}
@change=${(e: Event) => (this._adaptiveSeasonal = (e.target as HTMLInputElement).checked)}
/>
${t("adaptive_seasonal_enabled", L)}
</label>
</label>` : nothing}
<label>
<input
type="checkbox"
@@ -2177,6 +2259,13 @@ export class MaintenanceTaskDialog extends LitElement {
})}
`;
}
if (c.type === "due_date") {
return html`
<ms-textfield label="${t("days_before", L)}" type="number" step="1"
min=${TRIGGER_DAYS_BEFORE_RANGE[0]} max=${TRIGGER_DAYS_BEFORE_RANGE[1]} .value=${c.daysBefore}
@input=${(e: Event) => this._patchCondition(i, { daysBefore: (e.target as HTMLInputElement).value })}></ms-textfield>
`;
}
return nothing;
}
@@ -2641,6 +2730,17 @@ export class MaintenanceTaskDialog extends LitElement {
).replace("{count}", String(n)),
);
parts.push(t("trigger_hint_state_now", L).replace("{value}", String(st.state)));
} else if (this._triggerType === "due_date") {
const days = parseInt(this._triggerDaysBefore, 10) || 0;
parts.push(
days === 0 ? t("trigger_hint_due_date_same_day", L)
: days === 1 ? t("trigger_hint_due_date_one", L)
: t("trigger_hint_due_date", L).replace("{days}", String(days)),
);
const reported = raw == null ? "" : String(raw);
if (reported && !isNaN(Date.parse(reported))) {
parts.push(t("trigger_hint_due_date_now", L).replace("{date}", formatDate(reported, L)));
}
}
if (!parts.length) return overlap;
return html`<div class="trigger-live-hint">${parts.join(" ")}</div>${overlap}`;
@@ -2788,6 +2888,20 @@ export class MaintenanceTaskDialog extends LitElement {
<div class="field-help">${t("runtime_on_states_help", L)}</div>
`;
}
if (this._triggerType === "due_date") {
return html`
<ms-textfield
label="${t("days_before", L)}"
type="number"
step="1"
min=${TRIGGER_DAYS_BEFORE_RANGE[0]}
max=${TRIGGER_DAYS_BEFORE_RANGE[1]}
.value=${this._triggerDaysBefore}
@input=${(e: Event) => (this._triggerDaysBefore = (e.target as HTMLInputElement).value)}
></ms-textfield>
<div class="field-help">${t("days_before_help", L)}</div>
`;
}
return nothing;
}
@@ -3086,7 +3200,7 @@ export class MaintenanceTaskDialog extends LitElement {
</div>` : nothing}
` : nothing}
${this._renderTriggerFields()}
${this._scheduleType === "sensor_based" ? html`
${this._scheduleType === "sensor_based" && (this.environmentalFeature || this._environmentalInitial) ? html`
${this._entityPickerFallback ? html`
<ms-textfield
label="${t("environmental_entity_optional", L)}"
@@ -3389,6 +3503,8 @@ export class MaintenanceTaskDialog extends LitElement {
font-size: 13px;
}
.phase-remove {
display: inline-flex;
--ha-icon-button-size: 36px;
--mdc-icon-button-size: 36px;
color: var(--secondary-text-color);
}
@@ -6,7 +6,8 @@
*
* • Quick info — name + status + next due + last performed + interval
* • Primary actions — Complete (existing dialog), Skip (inline), Reset (inline)
* • Secondary (admin) — Edit settings (existing dialog), QR Code, Delete
* • Secondary — QR Code, Postpone (inline), Snooze for everyone; Edit
* settings (existing dialog), Archive, Delete for writers
* • Footer — "Open in Maintenance Panel" deep-link for History/Statistics
*
* Mounted via dialog-mount.openTaskQuickActions(entryId, taskId).
@@ -16,16 +17,19 @@ import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { sharedStyles, t, formatDate, formatInterval, formatRecurrence, formatCost, formatDuration, currencySymbolOf, langOf, syncCurrencyDecimals} from "../styles";
import { runWs } from "../helpers/ws-run";
import { snoozedMessage, type SnoozeResult } from "../helpers/snooze";
import { focusModalShell, modalShellStyles, renderModalShell } from "../helpers/modal-shell";
import { isoDateLocal } from "../helpers/calendar-bucket";
import { buildCompleteDialogArgs } from "../helpers/complete-dialog-args";
import { phaseLabel } from "../helpers/phases";
import { renderNotesMarkdown } from "../helpers/notes-markdown";
import { buildHistoryEntryDraft } from "../helpers/history-draft";
import { readingSlotDelta } from "../helpers/reading-slots";
import { taskRef } from "../helpers/reference";
import { newestFirst, renderHistoryEntry } from "../renderers/history";
import { renderStatusBadge, statusColor } from "../renderers/status";
import { fetchSettingsOnce } from "../helpers/settings-cache";
import type { PartsCostMode } from "../helpers/parts-cost";
import { canWrite, NO_DELEGATION, type WriteAccess } from "../helpers/permissions";
import { confirmAction } from "../helpers/confirm";
import { ToastTimer } from "../helpers/toast";
@@ -65,12 +69,14 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
@state() private _error = "";
@state() private _showSkip = false;
@state() private _showReset = false;
@state() private _showPostpone = false;
@state() private _showDetails = false;
@state() private _showAdaptive = false;
@state() private _skipReason = "";
@state() private _resetDate = "";
@state() private _postponeDate = "";
@state() private _features: AdvancedFeatures = {
adaptive: false, predictions: false, seasonal: false, environmental: false,
adaptive: false, seasonal: false, environmental: false,
budget: false, groups: false, checklists: false, schedule_time: false,
completion_actions: false,
};
@@ -98,8 +104,10 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
this._error = "";
this._showSkip = false;
this._showReset = false;
this._showPostpone = false;
this._showAdaptive = false;
this._skipReason = "";
this._postponeDate = "";
// Local calendar date — toISOString() is UTC and prefills YESTERDAY for
// users east of UTC before their morning (bug audit 2026-08-22).
this._resetDate = isoDateLocal(new Date());
@@ -120,8 +128,12 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
this._access = settings.access;
this._currencySymbol = currencySymbolOf(settings.budget ?? undefined);
syncCurrencyDecimals(settings.budget ?? undefined);
this._partsCostMode = settings.partsCostMode;
}
/** #104: when spare parts count (the complete dialog explains them). */
@state() private _partsCostMode: PartsCostMode = "purchase";
public close(): void {
this._loadSeq++; // a load still in flight belongs to the closed dialog
this._open = false;
@@ -221,6 +233,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
// feedback exactly like the panel (DRY audit 2026-09-26).
features: this._features,
currencySymbol: this._currencySymbol,
partsCostMode: this._partsCostMode,
}),
);
if (ok) {
@@ -258,6 +271,43 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
}
}
/** Postpone and Snooze are household actions like Skip and Reset
* (helpers/permissions HOUSEHOLD_ACTIONS) — the panel's ⋮ menu offered
* them, the dashboard's dialog did not (audit 2026-09-29). */
private async _onPostponeConfirm(): Promise<void> {
if (!this._entryId || !this._taskId || !this._postponeDate) return;
const ok = await this._runWs({
type: "maintenance_supporter/task/postpone",
entry_id: this._entryId,
task_id: this._taskId,
until: this._postponeDate,
});
if (ok !== undefined) {
this._notifyChanged("postpone");
this.close();
}
}
private async _onSnooze(): Promise<void> {
if (!this._entryId || !this._taskId) return;
const ok = await this._runWs<SnoozeResult>({
type: "maintenance_supporter/task/snooze",
entry_id: this._entryId,
task_id: this._taskId,
});
if (ok !== undefined) {
this._notifyChanged("snooze");
// #193: the dialog closes, so Home Assistant's own toast says for how
// long (a card has no toast of its own).
this.dispatchEvent(new CustomEvent("hass-notification", {
detail: { message: snoozedMessage(ok, this._lang) },
bubbles: true,
composed: true,
}));
this.close();
}
}
private _onEdit(): void {
if (!this._entryId || !this._taskId) return;
import("../dialog-mount").then(({ openEditTaskDialog }) => {
@@ -365,7 +415,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
this._showToast(r?.recommended_interval
// The analyzer always works in DAYS (helpers/interval_analyzer.py), so
// the unit is pinned here rather than taken from the task's own unit.
? `${t("reanalyze_result", this._lang)}: ${formatInterval(r.recommended_interval, "days", this._lang)} (${r.data_points} pts)`
? `${t("reanalyze_result", this._lang)}: ${formatInterval(r.recommended_interval, "days", this._lang)} (${r.data_points} ${t("data_points", this._lang)})`
: t("reanalyze_insufficient_data", this._lang));
await this._loadTask();
}
@@ -517,6 +567,9 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
const L = this._lang;
const task = this._task;
const writer = canWrite(this.hass?.user, this._access);
// The server refuses to postpone an archived, disabled or paused task,
// and such a task sends no reminders to snooze.
const inert = !!task && (!!task.archived || task.enabled === false || task.status === "paused");
return renderModalShell(() => this.close(), html`
${task
@@ -550,6 +603,9 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
? html`<span><strong>${t("phase_current", L)}:</strong> ${phaseLabel(task)}</span>`
: nothing}
</div>
${task.notes
? html`<div class="notes-body">${renderNotesMarkdown(task.notes)}</div>`
: nothing}
</div>
${this._error
@@ -593,6 +649,28 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
</div>
</div>
`
: this._showPostpone
? html`
<div class="inline-form">
<label>${t("postpone_date_prompt", L)}</label>
<ms-date-field
kind="date"
.hass=${this.hass}
.lang=${L}
.value=${this._postponeDate}
@value-changed=${(e: CustomEvent) => { this._postponeDate = e.detail.value as string; }}
></ms-date-field>
<div class="inline-actions">
<button class="btn cancel" @click=${() => { this._showPostpone = false; }} ?disabled=${this._busy}>
${t("cancel", L)}
</button>
<button class="btn primary qa-postpone-confirm" @click=${this._onPostponeConfirm}
?disabled=${this._busy || !this._postponeDate}>
${t("postpone", L)}
</button>
</div>
</div>
`
: html`
<div class="actions primary-row">
<ha-button appearance="accent" variant="success" @click=${this._onComplete} .disabled=${this._busy}>
@@ -626,6 +704,17 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
<ha-icon slot="start" icon="mdi:qrcode"></ha-icon>
${t("qr_code", L)}
</ha-button>
${inert
? nothing
: html`<ha-button size="small" appearance="outlined" variant="neutral" class="qa-postpone"
@click=${() => { this._showPostpone = true; }} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:calendar-arrow-right"></ha-icon>
${t("postpone", L)}…
</ha-button>
<ha-button size="small" appearance="outlined" variant="neutral" class="qa-snooze" @click=${this._onSnooze} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:bell-sleep-outline"></ha-icon>
${t("snooze", L)}
</ha-button>`}
${writer
? html`<ha-button size="small" appearance="outlined" variant="neutral" class="qa-archive"
@click=${task.archived ? this._onUnarchive : this._onArchive}
@@ -706,6 +795,11 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
.actions.secondary-row {
padding-top: 8px; border-top: 1px solid var(--divider-color);
justify-content: flex-start;
/* 360 px: Edit + QR + Archive + Delete were 388 px in a 342 px row —
Delete sat past the dialog edge (audit 2026-09-29). Wrap instead;
Delete keeps its margin-left:auto on the second line. */
flex-wrap: wrap;
row-gap: 6px;
}
.actions.secondary-row .btn.danger,
.actions.secondary-row ha-button.danger {
@@ -734,6 +828,12 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
.btn.danger { color: var(--error-color); }
.btn ha-icon { --mdc-icon-size: 18px; }
.inline-form { display: flex; flex-direction: column; gap: 8px; }
.notes-body {
margin-top: 8px; padding: 8px; border-radius: 6px;
background: var(--secondary-background-color);
font-size: 13px; white-space: pre-wrap; word-break: break-word;
}
.notes-body ha-markdown { white-space: normal; }
.inline-form label { font-size: 13px; color: var(--secondary-text-color); }
.inline-form input {
padding: 8px; font-size: 14px;