217 files

This commit is contained in:
Home Assistant Version Control
2026-07-30 23:59:38 +00:00
parent d43a63ad29
commit 7b5e46e702
217 changed files with 15978 additions and 3912 deletions
@@ -2,11 +2,13 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TriggerConfig, HAUser } from "../types";
import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TaskPartLink, TriggerConfig, HAUser } from "../types";
import { formatDate, t, weekdayName } from "../styles";
import { UserService } from "../user-service";
import { partLinkKey } from "../helpers/shared-parts";
import { describeWsError } from "../ws-errors";
import { REQUIRED_COMPLETION_KEYS, REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
import "./ms-textfield";
const MAINTENANCE_TYPE_KEYS = ["cleaning", "inspection", "replacement", "calibration", "service", "reading", "custom"];
@@ -122,6 +124,14 @@ export class MaintenanceTaskDialog extends LitElement {
@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 }> = [];
/** #111: OTHER objects that own spare parts, so a task can draw on a shared
* pool (three vacuums, one box of dust bags). Grouped by owner in the UI —
* which pool a link means must never be a guess. */
@state() private _foreignOwners: Array<{
entry_id: string;
name: string;
parts: Array<{ id: string; name: string; unit?: string }>;
}> = [];
@state() private _open = false;
@state() private _loading = false;
@state() private _error = "";
@@ -210,7 +220,9 @@ export class MaintenanceTaskDialog extends LitElement {
@state() private _nfcTagId = "";
// v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...)
@state() private _readingUnit = "";
@state() private _consumesParts: Record<string, number> = {};
/** The picked links, keyed by `partLinkKey` — the (entry_id, part_id) pair,
* since the same part id can exist on two objects (battery fleet). */
@state() private _consumesParts: Record<string, TaskPartLink> = {};
@state() private _partsLoadFailed = false;
@state() private _availableTags: Array<{id: string; name: string}> = [];
@@ -222,6 +234,9 @@ export class MaintenanceTaskDialog extends LitElement {
// Checklist (newline-separated steps, one per line)
@state() private _checklistText = "";
/** Details this task demands on completion — enforced by the backend on
* every surface, so a button press or voice command cannot bypass it. */
@state() private _requiredCompletion: string[] = [];
// Schedule time (HH:MM, advanced feature)
@state() private _scheduleTime = "";
@@ -271,7 +286,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._objectChoices = [];
}
this._resetFields();
await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts()]);
await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts(), this._loadForeignPools()]);
this._open = true;
}
@@ -321,12 +336,17 @@ export class MaintenanceTaskDialog extends LitElement {
this._lastPerformed = task.last_performed || "";
this._nfcTagId = task.nfc_tag_id || "";
this._readingUnit = task.reading_unit || "";
this._consumesParts = Object.fromEntries((task.consumes_parts || []).map((l) => [l.part_id, l.quantity]));
// Whole link, entry_id included — hydrating only part_id would turn every
// shared-pool link into an own-part link on the next save (#111).
this._consumesParts = Object.fromEntries(
(task.consumes_parts || []).map((l) => [partLinkKey(l), { ...l }]),
);
this._responsibleUserId = task.responsible_user_id || null;
this._assigneePool = [...(task.assignee_pool || [])];
this._rotationStrategy = task.rotation_strategy || "";
this._checklistText = (task.checklist || []).join("\n");
this._requiredCompletion = [...(task.required_completion_fields || [])];
this._scheduleTime = task.schedule_time || "";
// v1.3.0: hydrate on_complete_action + quick_complete_defaults
@@ -395,7 +415,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._fetchEntityAttributes(this._triggerEntityId);
}
await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts()]);
await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts(), this._loadForeignPools()]);
this._open = true;
}
@@ -434,6 +454,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._assigneePool = [];
this._rotationStrategy = "";
this._checklistText = "";
this._requiredCompletion = [];
this._scheduleTime = "";
this._environmentalEntity = "";
this._environmentalAttribute = "";
@@ -736,6 +757,40 @@ export class MaintenanceTaskDialog extends LitElement {
}
}
/** #111: the other objects' spare-part pools this task could draw on.
*
* A sibling of `_loadParts`, run in the SAME Promise.all rather than nested
* inside it: chaining it after the own-parts fetch delays the dialog opening
* by a further round trip for a list that is secondary to it.
*
* Failure is soft on purpose — the own-parts picker is the primary path and
* must not disappear because this second call did not come back. */
private async _loadForeignPools(): Promise<void> {
this._foreignOwners = [];
if (!this._entryId) return;
try {
const result = (await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/objects",
})) as {
objects?: Array<{
entry_id: string;
object?: { name?: string };
parts?: Array<{ id: string; name: string; unit?: string }>;
}>;
};
this._foreignOwners = (result.objects || [])
.filter((o) => o.entry_id !== this._entryId && (o.parts || []).length > 0)
.map((o) => ({
entry_id: o.entry_id,
name: o.object?.name || o.entry_id,
parts: o.parts || [],
}))
.sort((a, b) => a.name.localeCompare(b.name));
} catch {
this._foreignOwners = [];
}
}
private async _loadTags(): Promise<void> {
try {
const result = await this.hass.connection.sendMessagePromise({
@@ -773,6 +828,69 @@ export class MaintenanceTaskDialog extends LitElement {
}
}
/** A task already drawing on a shared pool opens that section expanded — a
* collapsed disclosure would hide a link that is very much active. */
private get _hasForeignPick(): boolean {
return Object.values(this._consumesParts).some((l) => !!l.entry_id);
}
/** One "consumes parts" checkbox + quantity.
*
* `ownerEntryId` is undefined for the object's own parts and set for a pool
* owned by another object (#111) — that argument is the ONLY difference
* between the two lists, which is why they share this renderer. */
private _renderConsumesRow(
part: { id: string; name: string; unit?: string },
ownerEntryId?: string,
) {
const key = partLinkKey({ part_id: part.id, entry_id: ownerEntryId });
const link = this._consumesParts[key];
const base: TaskPartLink = ownerEntryId
? { part_id: part.id, quantity: 1, entry_id: ownerEntryId }
: { part_id: part.id, quantity: 1 };
return html`
<div class="consumes-row">
<label class="consumes-check">
<input
type="checkbox"
.checked=${link !== undefined}
@change=${(e: Event) => {
const next = { ...this._consumesParts };
if ((e.target as HTMLInputElement).checked) next[key] = next[key] || base;
else delete next[key];
this._consumesParts = next;
}}
/>
<span>${part.name}${part.unit ? ` (${part.unit})` : ""}</span>
</label>
${link !== undefined
? html`<input
class="consumes-qty"
type="number"
min="0.01"
max="999"
step="0.01"
.value=${String(link.quantity)}
@input=${(e: Event) => {
const v = parseFloat((e.target as HTMLInputElement).value);
this._consumesParts = {
...this._consumesParts,
[key]: { ...base, quantity: Number.isFinite(v) && v >= 0.01 ? v : 1 },
};
}}
/>`
: nothing}
</div>
`;
}
private _toggleRequired(field: string, on: boolean): void {
const next = new Set(this._requiredCompletion);
if (on) next.add(field);
else next.delete(field);
this._requiredCompletion = [...next];
}
private async _save(): Promise<void> {
if (this._loading) return; // synchronous re-entry guard (double-click)
if (!this._name.trim()) return;
@@ -787,7 +905,16 @@ export class MaintenanceTaskDialog extends LitElement {
name: this._name,
task_type: this._type,
schedule_type: this._scheduleType,
warning_days: parseInt(this._warningDays, 10) || 7,
// `0` is a legal, meaningful value — "no due-soon window, go straight
// from ok to overdue" (backend range is 0365). The old
// `parseInt(...) || 7` treated it as falsy and silently rewrote a
// stored 0 to 7 on EVERY save, even when the user never touched the
// field. Same class as bug #42, but worse: it needed no user action.
// Only a genuinely unparseable field falls back, and to the
// configured default rather than a hardcoded 7.
warning_days: Number.isNaN(parseInt(this._warningDays, 10))
? this.defaultWarningDays
: Math.max(0, parseInt(this._warningDays, 10)),
};
const ecd = this._earliestCompletionDays.trim();
data.earliest_completion_days = ecd === "" ? null : Math.max(0, parseInt(ecd, 10) || 0);
@@ -836,14 +963,20 @@ export class MaintenanceTaskDialog extends LitElement {
data.last_performed = this._lastPerformed || null;
data.nfc_tag_id = this._nfcTagId || null;
data.reading_unit = this._readingUnit.trim() || null;
if (this.parts.length) {
data.consumes_parts = Object.entries(this._consumesParts).map(([part_id, quantity]) => ({
part_id,
quantity,
}));
// Only send when a picker was actually rendered. A failed parts load
// leaves both lists empty, and sending [] then would silently wipe links
// the user never saw. entry_id is written ONLY for a foreign pick, so an
// own-parts task saves byte-identically to before (#111).
if (this.parts.length || this._foreignOwners.length) {
data.consumes_parts = Object.values(this._consumesParts).map((l) =>
l.entry_id
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
: { part_id: l.part_id, quantity: l.quantity },
);
}
data.responsible_user_id = this._responsibleUserId;
data.assignee_pool = this._assigneePool;
data.required_completion_fields = this._requiredCompletion;
data.rotation_strategy =
this._assigneePool.length >= 2 && this._rotationStrategy
? this._rotationStrategy
@@ -1700,6 +1833,9 @@ export class MaintenanceTaskDialog extends LitElement {
this._entryId = (e.target as HTMLSelectElement).value;
this._consumesParts = {};
this._loadParts();
// The new owner drops out of the shared-pool list and the old
// one joins it, so this has to be recomputed too (#111).
this._loadForeignPools();
}}
>
${this._objectChoices.map(
@@ -1738,44 +1874,27 @@ export class MaintenanceTaskDialog extends LitElement {
${this._partsLoadFailed
? html`<div class="field-help parts-load-failed">${t("parts_load_failed", L)}</div>`
: nothing}
${this.parts.length
${this.parts.length || this._foreignOwners.length
? html`
<div class="field">
<label>${t("consumes_parts_label", L)}</label>
${this.parts.map((part) => {
const qty = this._consumesParts[part.id];
return html`
<div class="consumes-row">
<label class="consumes-check">
<input
type="checkbox"
.checked=${qty !== undefined}
@change=${(e: Event) => {
const next = { ...this._consumesParts };
if ((e.target as HTMLInputElement).checked) next[part.id] = next[part.id] || 1;
else delete next[part.id];
this._consumesParts = next;
}}
/>
<span>${part.name}${part.unit ? ` (${part.unit})` : ""}</span>
</label>
${qty !== undefined
? html`<input
class="consumes-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._consumesParts = { ...this._consumesParts, [part.id]: Number.isFinite(v) && v >= 0.01 ? v : 1 };
}}
/>`
: nothing}
</div>
`;
})}
${this.parts.map((part) => this._renderConsumesRow(part))}
${this._foreignOwners.length
? html`
<details class="shared-pools" ?open=${this._hasForeignPick}>
<summary>${t("shared_parts_other_objects", L)}</summary>
<div class="field-help">${t("shared_parts_help", L)}</div>
${this._foreignOwners.map(
(owner) => html`
<div class="shared-pool-owner">${owner.name}</div>
${owner.parts.map((part) =>
this._renderConsumesRow(part, owner.entry_id),
)}
`,
)}
</details>
`
: nothing}
</div>
`
: nothing}
@@ -1857,6 +1976,8 @@ export class MaintenanceTaskDialog extends LitElement {
<ms-textfield
label="${t("warning_days", L)}"
type="number"
min="0"
max="365"
.value=${this._warningDays}
@input=${(e: Event) => (this._warningDays = (e.target as HTMLInputElement).value)}
></ms-textfield>
@@ -1879,6 +2000,19 @@ export class MaintenanceTaskDialog extends LitElement {
></textarea>
<div class="field-help">${t("checklist_help", L)}</div>
` : nothing}
<h3>${t("require_on_completion", L)}</h3>
<div class="required-completion">
${REQUIRED_COMPLETION_KEYS.map((field) => html`
<label class="req-option">
<input
type="checkbox"
.checked=${this._requiredCompletion.includes(field)}
@change=${(e: Event) => this._toggleRequired(field, (e.target as HTMLInputElement).checked)}
/>
<span>${t(REQUIRED_COMPLETION_LABELS[field], L)}</span>
</label>
`)}
</div>
<ms-textfield
label="${t("last_performed_optional", L)}"
type="date"
@@ -2127,6 +2261,24 @@ export class MaintenanceTaskDialog extends LitElement {
background: var(--card-background-color);
color: var(--primary-text-color);
}
/* #111: other objects' pools sit behind a disclosure so the object's OWN
parts stay the primary list; each group is headed by the owning object's
name, so which pool a checkbox means is never a guess. */
.shared-pools {
margin-top: 6px;
}
.shared-pools > summary {
cursor: pointer;
padding: 2px 0;
font-size: 13px;
color: var(--secondary-text-color);
}
.shared-pool-owner {
margin-top: 6px;
font-size: 12px;
font-weight: 500;
color: var(--secondary-text-color);
}
.field-help {
font-size: 12px;
color: var(--secondary-text-color);