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,9 +2,11 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant } from "../types";
import type { HomeAssistant, TaskPartLink } from "../types";
import { t, nativeFieldStyles } from "../styles";
import { describeWsError } from "../ws-errors";
import { partLinkKey, type LinkedPart } from "../helpers/shared-parts";
import { REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
export class MaintenanceCompleteDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -19,12 +21,20 @@ 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 }> = [];
/** #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. */
@property({ attribute: false }) public parts: LinkedPart[] = [];
/** #99: the task's fixed consumes_parts links (prefill for the section).
* A link may carry an `entry_id` (#111) and MUST keep it through the edit —
* without it the completion would decrement the wrong inventory, or none. */
@property({ attribute: false }) public consumesParts: TaskPartLink[] = [];
/** "Consumes: 1× HEPA-Filter (Shelf B)" hint lines for consuming tasks. */
@property({ type: Array }) public consumesInfo: string[] = [];
/** Details this task demands before it counts as done (v2.44). The backend
* enforces the same list at every completion surface; blocking Save here
* just means the user never has to meet that rejection. */
@property({ type: Array }) public requiredFields: string[] = [];
@state() private _open = false;
@state() private _notes = "";
@state() private _cost = "";
@@ -38,7 +48,9 @@ export class MaintenanceCompleteDialog extends LitElement {
@state() private _photoUploading = false;
@state() private _readingValue = "";
@state() private _restockQty = "";
@state() private _usedParts: Record<string, number> = {};
/** Keyed by `partLinkKey` — the (entry_id, part_id) pair — because two
* objects can carry the same part id, so part_id alone would merge pools. */
@state() private _usedParts: Record<string, TaskPartLink> = {};
public open(): void {
if (this._open) return;
@@ -55,8 +67,9 @@ export class MaintenanceCompleteDialog extends LitElement {
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]));
// untick or adjust before completing. The whole link is kept, entry_id
// included, so a shared pool survives the edit (#111).
this._usedParts = Object.fromEntries(this.consumesParts.map((l) => [partLinkKey(l), { ...l }]));
}
private _toggleCheck(idx: number): void {
@@ -149,10 +162,16 @@ export class MaintenanceCompleteDialog extends LitElement {
}
// #99: with a parts section shown, send the explicit selection — it
// replaces the automatic consumes_parts deduction (empty = none used).
// entry_id travels only when the pool is somebody else's (#111), so an
// own-part payload is byte-identical to what shipped before.
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 }));
data.used_parts = Object.values(this._usedParts)
.filter((l) => Number.isFinite(l.quantity) && l.quantity > 0)
.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 },
);
}
await this.hass.connection.sendMessagePromise(data);
this._open = false;
@@ -164,6 +183,28 @@ export class MaintenanceCompleteDialog extends LitElement {
}
}
/** Required details the user has not supplied yet (drives Save + markers). */
private get _missingRequired(): string[] {
const filled: Record<string, boolean> = {
notes: this._notes.trim() !== "",
cost: this._cost.trim() !== "",
duration: this._duration.trim() !== "",
photo: this._photoDocId !== "",
// "Who did it" is filled in server-side from the authenticated
// connection (websocket/tasks_actions.py), so the dialog satisfies it
// as long as we ARE a logged-in user. Claiming it is always satisfied
// was how a task requiring "user" ended up unclosable: Save stayed
// enabled and the backend rejected the completion every time.
user: !!this.hass?.user,
};
return this.requiredFields.filter((f) => !filled[f]);
}
/** Marker appended to a required field's label. */
private _req(field: string) {
return this.requiredFields.includes(field) ? html`<span class="req-mark" aria-hidden="true">*</span>` : nothing;
}
private _close(): void {
this._open = false;
}
@@ -200,25 +241,36 @@ export class MaintenanceCompleteDialog extends LitElement {
? 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;
const key = partLinkKey({ part_id: pt.id, entry_id: pt.entry_id });
const link = this._usedParts[key];
const checked = link !== undefined;
const base: TaskPartLink = pt.entry_id
? { part_id: pt.id, quantity: 1, entry_id: pt.entry_id }
: { part_id: pt.id, quantity: 1 };
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];
if ((e.target as HTMLInputElement).checked) next[key] = next[key] || base;
else delete next[key];
this._usedParts = next;
}} />
<span>${pt.name}${pt.stock !== null && pt.stock !== undefined ? ` (${pt.stock}${pt.unit ? " " + pt.unit : ""})` : ""}</span>
<span
>${pt.name}${pt.owner_name
? html`<span class="used-part-owner"> (${pt.owner_name})</span>`
: nothing}${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)}
.value=${String(link.quantity)}
@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 };
this._usedParts = {
...this._usedParts,
[key]: { ...base, quantity: Number.isFinite(v) && v >= 0.01 ? v : 1 },
};
}} />`
: nothing}
</div>`;
@@ -245,25 +297,25 @@ export class MaintenanceCompleteDialog extends LitElement {
only sees the title + Cancel/Complete buttons — the original
bug report. Native inputs always render. -->
<label class="field">
<span class="field-label">${t("notes_optional", L)}</span>
<span class="field-label">${t("notes_optional", L)}${this._req("notes")}</span>
<input type="text" class="field-input"
.value=${this._notes}
@input=${(e: Event) => (this._notes = (e.target as HTMLInputElement).value)} />
</label>
<label class="field">
<span class="field-label">${t("cost_optional", L)}</span>
<span class="field-label">${t("cost_optional", L)}${this._req("cost")}</span>
<input type="number" step="0.01" min="0" class="field-input"
.value=${this._cost}
@input=${(e: Event) => (this._cost = (e.target as HTMLInputElement).value)} />
</label>
<label class="field">
<span class="field-label">${t("duration_minutes", L)}</span>
<span class="field-label">${t("duration_minutes", L)}${this._req("duration")}</span>
<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>
<div class="field">
<span class="field-label">${t("completion_photo_optional", L)}</span>
<span class="field-label">${t("completion_photo_optional", L)}${this._req("photo")}</span>
${this._photoPreview
? html`
<div class="photo-preview">
@@ -306,7 +358,10 @@ export class MaintenanceCompleteDialog extends LitElement {
</ha-button>
<ha-button
@click=${this._complete}
.disabled=${this._loading}
.disabled=${this._loading || this._missingRequired.length > 0}
title=${this._missingRequired.length
? this._missingRequired.map((f) => t("err_required", L).replace("{field}", t(REQUIRED_COMPLETION_LABELS[f] ?? f, L))).join(" · ")
: ""}
>
${this._loading ? t("completing", L) : t("complete", L)}
</ha-button>
@@ -316,6 +371,11 @@ export class MaintenanceCompleteDialog extends LitElement {
}
static styles = [nativeFieldStyles, css`
.req-mark {
color: var(--error-color, #f44336);
margin-left: 2px;
font-weight: 600;
}
.dialog-title {
font-size: 18px;
font-weight: 500;
@@ -348,6 +408,9 @@ export class MaintenanceCompleteDialog extends LitElement {
font-size: 13px; cursor: pointer;
}
.used-part-check input { cursor: pointer; }
/* #111: whose stock this row draws on. Muted but never omitted — an
unlabelled foreign pool is indistinguishable from an own part. */
.used-part-owner { color: var(--secondary-text-color); }
.used-part-qty {
width: 76px; padding: 4px 6px; border-radius: 4px; font: inherit; font-size: 13px;
border: 1px solid var(--divider-color);