393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
@@ -4,15 +4,23 @@ import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant, ReadingSlot, TaskPartLink } from "../types";
import { lastReadingBefore, type ReadingHistoryEntry } from "../helpers/reading-slots";
import { t, nativeFieldStyles, formatCost, formatNumber } from "../styles";
import { describeWsError } from "../ws-errors";
import { t, nativeFieldStyles, formatCost, formatNumber, formatQty } from "../styles";
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 { REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
import { PhotoUploadController } from "../helpers/photo-upload-controller";
import { parseDurationMinutes } from "../helpers/duration";
import "./ms-date-field";
import "./ms-photo-picker";
import { photoPickerStyles } from "./ms-photo-picker";
/** The server's bounds (websocket USED_PARTS_FIELD quantity / task/complete
* restock_quantity) — validated on save with a message, never dropped.
* One TS source (helpers/setting-ranges), pinned to Python. */
const USED_QTY_RANGE = PART_QTY_RANGE;
export class MaintenanceCompleteDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public entryId = "";
@@ -88,6 +96,11 @@ export class MaintenanceCompleteDialog extends LitElement {
/** 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> = {};
/** The TYPED quantity per used part (same key). The field used to write
* back a parsed number on every keystroke — clearing "1" to type "3"
* snapped back to "1" first, giving "13" (bug audit 2026-09-26). The
* text is kept as typed and parsed on save. */
@state() private _usedQtyText: Record<string, string> = {};
/** #73: in-cycle ticks (keyed by item TEXT) recorded on the task detail —
* prefill the dialog so nobody re-ticks what is already done. */
@@ -120,6 +133,15 @@ export class MaintenanceCompleteDialog extends LitElement {
// 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 }]));
this._usedQtyText = {};
}
/** "Value must be between {min} and {max}" with locale-formatted bounds. */
private _rangeError(min: number, max: number): string {
const L = this.lang;
return t("settings_value_out_of_range", L)
.replace("{min}", formatNumber(min, L, { maximumFractionDigits: 2 }))
.replace("{max}", formatNumber(max, L, { maximumFractionDigits: 2 }));
}
private _toggleCheck(idx: number): void {
@@ -135,92 +157,110 @@ export class MaintenanceCompleteDialog extends LitElement {
}
private async _complete(): Promise<void> {
this._loading = true;
// A photo still uploading would miss the completion and stay behind as
// an orphan (bug audit 2026-09-26 #2) — the button is disabled meanwhile;
// this guards Enter / a programmatic click as well.
if (this._loading || this._photos.uploading) return;
this._error = "";
this._photos.clearError();
try {
const data: Record<string, unknown> = {
type: "maintenance_supporter/task/complete",
entry_id: this.entryId,
task_id: this.taskId,
};
if (this._notes) data.notes = this._notes;
if (this._cost) {
const cost = parseFloat(this._cost);
if (!isNaN(cost) && cost >= 0) data.cost = cost;
const data: Record<string, unknown> = {
type: "maintenance_supporter/task/complete",
entry_id: this.entryId,
task_id: this.taskId,
};
if (this._notes) data.notes = this._notes;
if (this._cost) {
const cost = parseFloat(this._cost);
if (!isNaN(cost) && cost >= 0) data.cost = cost;
}
// Whole minutes — the server coerces to int (helpers/duration).
const dur = parseDurationMinutes(this._duration);
if (dur !== null) data.duration = dur;
if (this.checklist.length > 0) {
data.checklist_state = this._checklistState;
}
if (this.adaptiveEnabled) {
data.feedback = this._feedback;
}
if (this._photos.photos.length > 0) {
data.photo_doc_ids = this._photos.ids;
}
// Scan fallback: the backend accepts via_tag_scan on task/complete so a
// require_tag_scan task can still be finished from the dialog the scan
// opened. Only ever sent when a scan actually happened.
if (this.viaTagScan) {
data.via_tag_scan = true;
}
if (this._completedAt) {
// Client-side guard mirrors the backend rule — a picked future moment
// fails fast with a localized message instead of a WS roundtrip.
if (new Date(this._completedAt).getTime() > Date.now()) {
this._error = t("completed_at_future_error", this.lang);
return;
}
if (this._duration) {
const dur = parseInt(this._duration, 10);
if (!isNaN(dur) && dur >= 0) data.duration = dur;
// Re-add seconds if the datetime-local input drops them (same
// normalisation as the history-edit dialog).
data.completed_at = this._completedAt.length === 16 ? `${this._completedAt}:00` : this._completedAt;
}
if (this.readings.length > 0) {
// #161 phase 2: only the meters actually read go out; the backend
// snapshots name + unit per slot id.
const values: Record<string, number> = {};
for (const slot of this.readings) {
const raw = (this._readingValues[slot.id] ?? "").trim();
if (raw === "") continue;
const num = parseFloat(raw.replace(",", "."));
if (!isNaN(num)) values[slot.id] = num;
}
if (this.checklist.length > 0) {
data.checklist_state = this._checklistState;
if (Object.keys(values).length > 0) data.reading_values = values;
} else if (this._readingValue !== "") {
const rv = parseFloat(this._readingValue);
if (!isNaN(rv)) data.reading_value = rv;
}
if (this.restockDefault !== null && this._restockQty.trim() !== "") {
// Fractions are real purchases (0.5 kg, 2.5 l) — the old `>= 1` guard
// dropped them silently and the part's default quantity was stocked
// instead. Out of the server's range: say so, send nothing.
const rq = parseFloat(this._restockQty.replace(",", "."));
if (!Number.isFinite(rq) || rq < RESTOCK_QTY_RANGE[0] || rq > RESTOCK_QTY_RANGE[1]) {
this._error = this._rangeError(...RESTOCK_QTY_RANGE);
return;
}
if (this.adaptiveEnabled) {
data.feedback = this._feedback;
}
if (this._photos.photos.length > 0) {
data.photo_doc_ids = this._photos.ids;
}
// Scan fallback: the backend accepts via_tag_scan on task/complete so a
// require_tag_scan task can still be finished from the dialog the scan
// opened. Only ever sent when a scan actually happened.
if (this.viaTagScan) {
data.via_tag_scan = true;
}
if (this._completedAt) {
// Client-side guard mirrors the backend rule — a picked future moment
// fails fast with a localized message instead of a WS roundtrip.
if (new Date(this._completedAt).getTime() > Date.now()) {
this._error = t("completed_at_future_error", this.lang);
this._loading = false;
data.restock_quantity = rq;
}
// #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) {
// The typed quantities, parsed now (see _usedQtyText).
const used: TaskPartLink[] = [];
for (const [key, link] of Object.entries(this._usedParts)) {
const raw = this._usedQtyText[key];
const qty = raw === undefined ? link.quantity : parseFloat(raw.replace(",", "."));
if (!Number.isFinite(qty) || qty < USED_QTY_RANGE[0] || qty > USED_QTY_RANGE[1]) {
this._error = this._rangeError(...USED_QTY_RANGE);
return;
}
// Re-add seconds if the datetime-local input drops them (same
// normalisation as the history-edit dialog).
data.completed_at = this._completedAt.length === 16 ? `${this._completedAt}:00` : this._completedAt;
used.push({ ...link, quantity: qty });
}
if (this.readings.length > 0) {
// #161 phase 2: only the meters actually read go out; the backend
// snapshots name + unit per slot id.
const values: Record<string, number> = {};
for (const slot of this.readings) {
const raw = (this._readingValues[slot.id] ?? "").trim();
if (raw === "") continue;
const num = parseFloat(raw.replace(",", "."));
if (!isNaN(num)) values[slot.id] = num;
}
if (Object.keys(values).length > 0) data.reading_values = values;
} else if (this._readingValue !== "") {
const rv = parseFloat(this._readingValue);
if (!isNaN(rv)) data.reading_value = rv;
}
if (this.restockDefault !== null && this._restockQty !== "") {
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).
// 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.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._photos.markAttached(); // attached now — Cancel cleanup must not touch them
this._open = false;
this.dispatchEvent(new CustomEvent("task-completed"));
} catch (e) {
this._error = describeWsError(e, this.lang, t("save_error", this.lang));
} finally {
this._loading = false;
data.used_parts = used
.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 },
);
}
const res = await runWs(this, data, {
busy: (b) => { this._loading = b; },
lang: this.lang,
fallbackKey: "save_error",
onError: (m) => { this._error = m; },
});
if (res === undefined) return;
this._photos.markAttached(); // attached now — Cancel cleanup must not touch them
this._open = false;
this.dispatchEvent(new CustomEvent("task-completed"));
}
/** #161 phase 2: one field per slot with the previous value as a hint;
@@ -317,9 +357,7 @@ export class MaintenanceCompleteDialog extends LitElement {
/** Seed the backdate field with the current minute (local, seconds zeroed). */
private _pickCompletedAt(): void {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
this._completedAt = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
this._completedAt = isoMinuteLocal(new Date());
}
render() {
@@ -374,24 +412,33 @@ export class MaintenanceCompleteDialog extends LitElement {
@change=${(e: Event) => {
const next = { ...this._usedParts };
if ((e.target as HTMLInputElement).checked) next[key] = next[key] || base;
else delete next[key];
else {
delete next[key];
const text = { ...this._usedQtyText };
delete text[key];
this._usedQtyText = text;
}
this._usedParts = next;
}} />
<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
: nothing}${pt.stock !== null && pt.stock !== undefined ? ` (${formatQty(pt.stock, pt.unit, L)})` : ""}</span
>
</label>
${checked
? html`<input class="used-part-qty" type="number" min="0.01" max="999" step="0.01"
.value=${String(link.quantity)}
? html`<input class="used-part-qty" type="number" min=${USED_QTY_RANGE[0]} max=${USED_QTY_RANGE[1]} step="0.01"
.value=${this._usedQtyText[key] ?? String(link.quantity)}
@input=${(e: Event) => {
const v = parseFloat((e.target as HTMLInputElement).value);
this._usedParts = {
...this._usedParts,
[key]: { ...base, quantity: Number.isFinite(v) && v >= 0.01 ? v : 1 },
};
const raw = (e.target as HTMLInputElement).value;
this._usedQtyText = { ...this._usedQtyText, [key]: raw };
// A valid entry also feeds the live cost suggestion;
// an empty / partial one leaves the last number
// alone and is judged on save.
const v = parseFloat(raw.replace(",", "."));
if (Number.isFinite(v) && v >= USED_QTY_RANGE[0]) {
this._usedParts = { ...this._usedParts, [key]: { ...base, quantity: v } };
}
}} />`
: nothing}
</div>`;
@@ -406,7 +453,7 @@ export class MaintenanceCompleteDialog extends LitElement {
? html`
<label class="field">
<span class="field-label">${t("restock_quantity_label", L)}</span>
<input type="number" step="0.01" min="0.01" class="field-input"
<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)} />
</label>`
@@ -432,7 +479,7 @@ export class MaintenanceCompleteDialog extends LitElement {
</label>
<label class="field">
<span class="field-label">${t("duration_minutes", L)}${this._req("duration")}</span>
<input type="number" step="0.01" min="0" class="field-input"
<input type="number" step="1" min="0" inputmode="numeric" class="field-input"
.value=${this._duration}
@input=${(e: Event) => (this._duration = (e.target as HTMLInputElement).value)} />
</label>
@@ -495,8 +542,10 @@ export class MaintenanceCompleteDialog extends LitElement {
</ha-button>
<ha-button
@click=${this._complete}
.disabled=${this._loading || this._missingRequired.length > 0}
title=${this._missingRequired.length
.disabled=${this._loading || this._photos.uploading || this._missingRequired.length > 0}
title=${this._photos.uploading
? t("uploading", L)
: this._missingRequired.length
? this._missingRequired.map((f) => t("err_required", L).replace("{field}", t(REQUIRED_COMPLETION_LABELS[f] ?? f, L))).join(" · ")
: ""}
>