393 files

This commit is contained in:
Home Assistant Version Control committed 2026-09-27 17:16:54 +00:00
1 parent b05b7a897e
commit 39d97ac2db
404 files changed
+54006 -11034

No files matched your search

@@ -11,8 +11,12 @@ import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, langOf, formatNumber } from "../styles";
import type { HomeAssistant, ReadingSlot, ReadingValue } from "../types";
import { describeWsError } from "../ws-errors";
import { runWs } from "../helpers/ws-run";
import { PART_QTY_RANGE } from "../helpers/setting-ranges";
import { focusModalShell, modalShellStyles, renderModalShell } from "../helpers/modal-shell";
import { PhotoUploadController } from "../helpers/photo-upload-controller";
import { parseDurationMinutes } from "../helpers/duration";
import { confirmAction } from "../helpers/confirm";
import "./ms-date-field";
import "./history-photo";
import "./ms-photo-picker";
@@ -76,6 +80,12 @@ export class MaintenanceHistoryEditDialog extends LitElement {
@state() private _partOptions: PartOption[] | null = null;
@state() private _partQty: Record<string, number> = {};
private _partQtyOriginal = "";
/** Bumped by every openEdit() and close(): an async answer started under
* an older value belongs to a dialog session that is gone. The draft's
* identity could not tell — every keystroke replaces `_draft` (`_set`),
* so typing before parts/overview answered dropped the parts section
* and the save skipped used_parts (bug audit 2026-09-26 #2). */
private _openGen = 0;
// #161: the edited photo list (seeded with the entry's photos; the docs
// THIS session uploaded are dropped again by an abandoned edit, a removed
@@ -97,6 +107,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
* pass `original_timestamp` (the entry's current timestamp before edit)
* so the backend can find the entry. */
public openEdit(draft: HistoryEntryDraft): void {
this._openGen++;
this._draft = { ...draft };
this._originalSnapshot = { ...draft };
this._error = "";
@@ -147,8 +158,11 @@ export class MaintenanceHistoryEditDialog extends LitElement {
/** The object's own parts + pooled parts this task draws on — from the
* instance-wide overview so pooled owners resolve without extra calls. */
private async _loadPartOptions(): Promise<void> {
// The entry as opened: entry_id / task_id / used_parts never change
// while the dialog is open (only the edited fields do).
const draft = this._draft;
if (!draft) return;
const gen = this._openGen;
try {
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/parts/overview",
@@ -158,6 +172,11 @@ export class MaintenanceHistoryEditDialog extends LitElement {
consumers: Array<{ entry_id: string; task_id: string }>;
}>;
};
// The dialog was closed or reopened for ANOTHER entry while this was in
// flight: its options (and quantities) belong to that other entry now
// (bug audit 2026-09-26 — out-of-order answers). Edits typed in the
// meantime do NOT count as another session.
if (gen !== this._openGen) return;
const options: PartOption[] = [];
for (const row of result.parts || []) {
const own = row.entry_id === draft.entry_id;
@@ -193,6 +212,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
this._partQty = qty;
this._partQtyOriginal = this._partSelectionKey();
} catch {
if (gen !== this._openGen) return;
this._partOptions = []; // parts UI unavailable — the rest still edits
}
}
@@ -206,6 +226,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
}
public close(): void {
this._openGen++;
this._open = false;
this._error = "";
this._draft = null;
@@ -227,115 +248,124 @@ export class MaintenanceHistoryEditDialog extends LitElement {
private async _delete(): Promise<void> {
if (!this._draft || !this._originalSnapshot) return;
const L = this._lang;
if (!window.confirm(t("history_delete_confirm", L))) return;
this._saving = true;
// The confirm dialog, not the browser's native modal — this dialog is
// also mounted on dashboards (DRY audit 2026-09-26).
const confirmed = await confirmAction(this.hass, {
title: t("history_delete_entry", L),
message: t("history_delete_confirm", L),
confirmText: t("delete", L),
danger: true,
});
if (!confirmed || !this._draft || !this._originalSnapshot) return;
const draft = this._draft;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/history/delete",
entry_id: this._draft.entry_id,
task_id: this._draft.task_id,
timestamp: this._originalSnapshot.original_timestamp,
});
this.dispatchEvent(
new CustomEvent("history-entry-saved", {
detail: { entry_id: this._draft.entry_id, task_id: this._draft.task_id, deleted: true },
bubbles: true,
composed: true,
}),
);
this.close();
} catch (e) {
this._error = describeWsError(e, L);
} finally {
this._saving = false;
}
const ok = await this._runWs({
type: "maintenance_supporter/task/history/delete",
entry_id: draft.entry_id,
task_id: draft.task_id,
timestamp: this._originalSnapshot.original_timestamp,
});
if (ok === undefined) return;
this.dispatchEvent(
new CustomEvent("history-entry-saved", {
detail: { entry_id: draft.entry_id, task_id: draft.task_id, deleted: true },
bubbles: true,
composed: true,
}),
);
this.close();
}
/** helpers/ws-run runWs bound to the Save/Delete busy flag and the error line. */
private _runWs(payload: Record<string, unknown>): Promise<unknown> {
return runWs(this, payload, {
busy: (b) => { this._saving = b; },
onError: (m) => { this._error = m; },
});
}
private async _save(): Promise<void> {
if (!this._draft || !this._originalSnapshot) return;
this._saving = true;
// Save waits for a photo still uploading — it would miss the patch and
// stay behind as an orphan (bug audit 2026-09-26 #2).
if (this._saving || this._photos.uploading) return;
this._error = "";
this._photos.clearError();
try {
const patch: Record<string, unknown> = {
type: "maintenance_supporter/task/history/update",
entry_id: this._draft.entry_id,
task_id: this._draft.task_id,
original_timestamp: this._originalSnapshot.original_timestamp,
};
// Only send fields that actually changed — keeps the patch minimal
// and the WS schema happy (it treats missing as "no change").
if (this._draft.timestamp !== this._originalSnapshot.timestamp) {
patch.timestamp = this._draft.timestamp;
}
if (this._draft.notes !== this._originalSnapshot.notes) {
patch.notes = this._draft.notes;
}
if (this._draft.cost !== this._originalSnapshot.cost) {
patch.cost = this._draft.cost;
}
if (this._draft.duration !== this._originalSnapshot.duration) {
patch.duration = this._draft.duration;
}
if (this._draft.completed_by !== this._originalSnapshot.completed_by) {
patch.completed_by = this._draft.completed_by;
}
// #130: send the parts selection only when it actually changed — the
// backend reconciles stock by the delta, so a no-op must stay silent.
if (this._partOptions !== null && this._partSelectionKey() !== this._partQtyOriginal) {
patch.used_parts = (this._partOptions || [])
.filter((o) => (this._partQty[`${o.entry_id}:${o.part_id}`] || 0) > 0)
.map((o) => ({
part_id: o.part_id,
quantity: this._partQty[`${o.entry_id}:${o.part_id}`],
...(o.foreign ? { entry_id: o.entry_id } : {}),
}));
}
// #161 phase 2: readings. The scalar rides the changed-field check
// like notes; the slot map is sent whole when any value differs —
// the backend replaces the snapshot, a missing id means "unread".
if (this._draft.reading_value !== this._originalSnapshot.reading_value) {
patch.reading_value = this._draft.reading_value ?? null;
}
if (this._readingRows.length > 0 && JSON.stringify(this._readingNumbers()) !== this._readingsOriginal) {
const numbers = this._readingNumbers();
const map: Record<string, number | null> = {};
for (const row of this._readingRows) map[row.id] = numbers[row.id] ?? null;
patch.reading_values = map;
}
// #161: the photo list, only when it differs from what we opened with.
if (JSON.stringify(this._photos.ids) !== this._photosOriginal) {
patch.photo_doc_ids = this._photos.ids;
}
// Nothing changed → close without WS call
const changedKeys = Object.keys(patch).filter(
(k) => !["type", "entry_id", "task_id", "original_timestamp"].includes(k),
);
if (changedKeys.length === 0) {
this.close();
return;
}
await this.hass.connection.sendMessagePromise(patch);
this._photos.markAttached(); // saved — they belong to the entry now
// Notify upstream so they can refresh
this.dispatchEvent(
new CustomEvent("history-entry-saved", {
detail: {
entry_id: this._draft.entry_id,
task_id: this._draft.task_id,
new_timestamp: this._draft.timestamp,
},
bubbles: true,
composed: true,
}),
);
this.close();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._saving = false;
const draft = this._draft;
const original = this._originalSnapshot;
const patch: Record<string, unknown> = {
type: "maintenance_supporter/task/history/update",
entry_id: draft.entry_id,
task_id: draft.task_id,
original_timestamp: original.original_timestamp,
};
// Only send fields that actually changed — keeps the patch minimal
// and the WS schema happy (it treats missing as "no change").
if (draft.timestamp !== original.timestamp) {
patch.timestamp = draft.timestamp;
}
if (draft.notes !== original.notes) {
patch.notes = draft.notes;
}
if (draft.cost !== original.cost) {
patch.cost = draft.cost;
}
if (draft.duration !== original.duration) {
patch.duration = draft.duration;
}
if (draft.completed_by !== original.completed_by) {
patch.completed_by = draft.completed_by;
}
// #130: send the parts selection only when it actually changed — the
// backend reconciles stock by the delta, so a no-op must stay silent.
if (this._partOptions !== null && this._partSelectionKey() !== this._partQtyOriginal) {
patch.used_parts = (this._partOptions || [])
.filter((o) => (this._partQty[`${o.entry_id}:${o.part_id}`] || 0) > 0)
.map((o) => ({
part_id: o.part_id,
quantity: this._partQty[`${o.entry_id}:${o.part_id}`],
...(o.foreign ? { entry_id: o.entry_id } : {}),
}));
}
// #161 phase 2: readings. The scalar rides the changed-field check
// like notes; the slot map is sent whole when any value differs —
// the backend replaces the snapshot, a missing id means "unread".
if (draft.reading_value !== original.reading_value) {
patch.reading_value = draft.reading_value ?? null;
}
if (this._readingRows.length > 0 && JSON.stringify(this._readingNumbers()) !== this._readingsOriginal) {
const numbers = this._readingNumbers();
const map: Record<string, number | null> = {};
for (const row of this._readingRows) map[row.id] = numbers[row.id] ?? null;
patch.reading_values = map;
}
// #161: the photo list, only when it differs from what we opened with.
if (JSON.stringify(this._photos.ids) !== this._photosOriginal) {
patch.photo_doc_ids = this._photos.ids;
}
// Nothing changed → close without WS call
const changedKeys = Object.keys(patch).filter(
(k) => !["type", "entry_id", "task_id", "original_timestamp"].includes(k),
);
if (changedKeys.length === 0) {
this.close();
return;
}
if ((await this._runWs(patch)) === undefined) return;
this._photos.markAttached(); // saved — they belong to the entry now
// Notify upstream so they can refresh
this.dispatchEvent(
new CustomEvent("history-entry-saved", {
detail: {
entry_id: draft.entry_id,
task_id: draft.task_id,
new_timestamp: draft.timestamp,
},
bubbles: true,
composed: true,
}),
);
this.close();
}
render() {
@@ -343,9 +373,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
const L = this._lang;
const d = this._draft;
const error = this._error || this._photos.errorText(L);
return html`
<div class="backdrop" @click=${this.close}></div>
<div class="dialog" role="dialog" aria-modal="true">
return renderModalShell(() => this.close(), html`
<h2>${t("history_edit_title", L)}</h2>
<div class="entry-type">
<ha-icon icon="mdi:tag-outline"></ha-icon>
@@ -387,11 +415,11 @@ export class MaintenanceHistoryEditDialog extends LitElement {
</label>
<label>
<span>${t("duration", L)}</span>
<input type="number" min="0"
<input type="number" min="0" step="1" inputmode="numeric"
.value=${d.duration != null ? String(d.duration) : ""}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
this._set("duration", v ? Number(v) : null);
// Whole minutes, like the server stores them (helpers/duration).
this._set("duration", parseDurationMinutes((e.target as HTMLInputElement).value));
}} />
</label>
</div>
@@ -411,7 +439,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
}} />
<span class="part-label">${o.name}${o.foreign && o.object_name ? ` (${o.object_name})` : ""}</span>
${qty > 0 ? html`
<input class="part-qty" type="number" min="0.01" max="999" step="0.01"
<input class="part-qty" type="number" min=${PART_QTY_RANGE[0]} max=${PART_QTY_RANGE[1]} step="0.01"
.value=${String(qty)}
@input=${(e: Event) => {
const v = parseFloat((e.target as HTMLInputElement).value);
@@ -449,12 +477,16 @@ export class MaintenanceHistoryEditDialog extends LitElement {
<button class="cancel" @click=${this.close} ?disabled=${this._saving}>
${t("cancel", L)}
</button>
<button class="save" @click=${this._save} ?disabled=${this._saving}>
<button class="save" @click=${this._save} ?disabled=${this._saving || this._photos.uploading}
title=${this._photos.uploading ? t("uploading", L) : ""}>
${this._saving ? t("saving", L) : t("save", L)}
</button>
</div>
</div>
`;
`);
}
protected updated(changed: Map<string, unknown>): void {
if (changed.has("_open") && this._open) focusModalShell(this.shadowRoot);
}
/** #161 phase 2: per-slot rows for a slot task (entry snapshot ∪ current
@@ -490,26 +522,8 @@ export class MaintenanceHistoryEditDialog extends LitElement {
</label>`;
}
static styles = [photoPickerStyles, css`
:host { display: contents; }
.backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.5);
z-index: 100;
}
.dialog {
position: fixed; left: 50%; top: 50%;
transform: translate(-50%, -50%);
width: 95vw; max-width: 480px;
background: var(--card-background-color, var(--ha-card-background, #1c1c1c));
color: var(--primary-text-color);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
padding: 20px;
display: flex; flex-direction: column; gap: 12px;
z-index: 101;
max-height: 90vh; overflow: auto;
}
static styles = [photoPickerStyles, modalShellStyles, css`
:host { display: contents; --ms-modal-max-height: 90vh; --ms-modal-gap: 12px; }
h2 { margin: 0; font-size: 18px; }
.entry-type {
display: flex; align-items: center; gap: 6px;