/** Dialog for editing an existing history entry (timestamp / notes / cost / * duration / completed_by / photos). Backed by maintenance_supporter/task/history/update. * * Opened from: * - Task detail page → history tab → Edit button per entry * - Calendar past-window event click (via ll-custom dispatch from * maintenance-supporter-calendar-card) */ 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 { 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"; import { photoPickerStyles } from "./ms-photo-picker"; export interface HistoryEntryDraft { entry_id: string; task_id: string; original_timestamp: string; // identifies the entry on save type: string; // for display only — read-only timestamp: string; notes: string | null; cost: number | null; duration: number | null; completed_by: string | null; // #130: the entry's recorded part consumption ({part_id, name, quantity, // entry_id? for pooled parts}); absent/empty = nothing consumed. used_parts?: Array<{ part_id: string; name?: string; quantity: number; entry_id?: string }> | null; // #161: the entry's completion photos (callers fold the pre-multi-photo // `photo_doc_id` scalar in via helpers/history-photos.historyPhotoIds). photo_doc_ids?: string[] | null; // #161 phase 2: the entry's readings — the v2.20 scalar and/or the // per-slot snapshot — plus the task's current slots, so a meter that // was skipped at completion can be filled in afterwards. task_type / // reading_unit decide whether the scalar field shows on an entry // that recorded nothing. reading_value?: number | null; reading_values?: ReadingValue[] | null; readings?: ReadingSlot[] | null; task_type?: string | null; reading_unit?: string | null; } /** One selectable part option in the edit dialog — the object's own parts * plus pooled parts this task links to, fetched via parts/overview. */ interface PartOption { part_id: string; name: string; entry_id: string; // owning object foreign: boolean; // pooled (#111) — carried into the saved link object_name: string | null; } export class MaintenanceHistoryEditDialog extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @state() private _open = false; @state() private _saving = false; @state() private _error = ""; @state() private _draft: HistoryEntryDraft | null = null; // Original snapshot so we can detect "no change" and skip the WS call private _originalSnapshot: HistoryEntryDraft | null = null; private get _lang(): string { return langOf(this.hass); } // #130: selectable parts + the edited selection (part key -> quantity; // 0/absent = not consumed). Key = `${entry_id}:${part_id}` (pool-safe). @state() private _partOptions: PartOption[] | null = null; @state() private _partQty: Record = {}; 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 // pre-existing photo is only detached — the file stays in the object's // documents); the original as a JSON key for the "did it change" check. private readonly _photos = new PhotoUploadController(this, { entryId: () => this._draft?.entry_id ?? "", hass: () => this.hass, }); private _photosOriginal = ""; // #161 phase 2: the reading rows (entry snapshot ∪ task slots) with the // typed text per slot id; the original numbers as a JSON key. @state() private _readingRows: ReadingSlot[] = []; @state() private _readingText: Record = {}; private _readingsOriginal = ""; /** Open the dialog with the given history-entry data. The caller must * 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 = ""; this._open = true; this._partOptions = null; this._partQty = {}; this._partQtyOriginal = ""; this._photos.reset(draft.photo_doc_ids ?? []); this._photosOriginal = JSON.stringify(this._photos.ids); this._seedReadings(draft); void this._loadPartOptions(); } private _seedReadings(draft: HistoryEntryDraft): void { // Task slot order first (matches the complete dialog), then snapshot // slots the task no longer has — with the entry's own name/unit. const rows: ReadingSlot[] = []; const seen = new Set(); for (const s of draft.readings ?? []) { if (seen.has(s.id)) continue; seen.add(s.id); rows.push({ id: s.id, name: s.name, unit: s.unit ?? null }); } for (const v of draft.reading_values ?? []) { if (seen.has(v.id)) continue; seen.add(v.id); rows.push({ id: v.id, name: v.name, unit: v.unit ?? null }); } this._readingRows = rows; const text: Record = {}; for (const v of draft.reading_values ?? []) text[v.id] = String(v.value); this._readingText = text; this._readingsOriginal = JSON.stringify(this._readingNumbers()); } /** {slot id: number} for every row with a parsable value. */ private _readingNumbers(): Record { const out: Record = {}; for (const row of this._readingRows) { const raw = (this._readingText[row.id] ?? "").trim(); if (raw === "") continue; const num = parseFloat(raw.replace(",", ".")); if (!isNaN(num)) out[row.id] = num; } return out; } /** 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 { // 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", }) as { parts: Array<{ part_id: string; name: string; entry_id: string; object_name: string | null; 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; const linked = row.consumers.some((c) => c.entry_id === draft.entry_id && c.task_id === draft.task_id); if (!own && !linked) continue; options.push({ part_id: row.part_id, name: row.name, entry_id: row.entry_id, foreign: !own, object_name: row.object_name, }); } // Recorded parts whose catalog entry vanished stay selectable so a // correction can still zero them out. for (const link of draft.used_parts || []) { const owner = link.entry_id || draft.entry_id; if (!options.some((o) => o.part_id === link.part_id && o.entry_id === owner)) { options.push({ part_id: link.part_id, name: link.name || link.part_id, entry_id: owner, foreign: owner !== draft.entry_id, object_name: null, }); } } const qty: Record = {}; for (const link of draft.used_parts || []) { qty[`${link.entry_id || draft.entry_id}:${link.part_id}`] = link.quantity ?? 1; } this._partOptions = options; this._partQty = qty; this._partQtyOriginal = this._partSelectionKey(); } catch { if (gen !== this._openGen) return; this._partOptions = []; // parts UI unavailable — the rest still edits } } private _partSelectionKey(): string { return JSON.stringify( Object.entries(this._partQty) .filter(([, q]) => q > 0) .sort(([a], [b]) => a.localeCompare(b)), ); } public close(): void { this._openGen++; this._open = false; this._error = ""; this._draft = null; this._originalSnapshot = null; // Cancelled after uploading: nothing references those files. this._photos.discardOrphans(); } private _set( key: K, value: HistoryEntryDraft[K], ): void { if (!this._draft) return; this._draft = { ...this._draft, [key]: value }; } /** #170: remove the entry altogether (after a confirm). The backend * re-anchors last_performed on what remains; photos stay in the object's * documents, consumed parts are not restocked. */ private async _delete(): Promise { if (!this._draft || !this._originalSnapshot) return; const L = this._lang; // 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 = ""; 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): Promise { return runWs(this, payload, { busy: (b) => { this._saving = b; }, onError: (m) => { this._error = m; }, }); } private async _save(): Promise { if (!this._draft || !this._originalSnapshot) return; // 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(); const draft = this._draft; const original = this._originalSnapshot; const patch: Record = { 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 = {}; 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() { if (!this._open || !this._draft) return nothing; const L = this._lang; const d = this._draft; const error = this._error || this._photos.errorText(L); return renderModalShell(() => this.close(), html`

${t("history_edit_title", L)}

${t(d.type, L) || d.type}
{ // Always "YYYY-MM-DDTHH:MM:SS" from the field; the naive value // means local time, exactly as the datetime-local input did. const v = e.detail.value as string; if (v) this._set("timestamp", v); }} >
${this._renderReadings(d, L)} ${this._partOptions && this._partOptions.length > 0 ? html`
${t("complete_parts_used", L)} ${this._partOptions.map((o) => { const key = `${o.entry_id}:${o.part_id}`; const qty = this._partQty[key] || 0; return html` `; })}
` : nothing}
${t("completion_photos", L)} ${this._photos.photos.length > 0 ? html`
${this._photos.photos.map((p) => html`
`)}
` : nothing} ${!this._photos.full ? html`) => this._photos.addFiles(e.detail.files)} >` : html`${t("photos_limit", L).replace("{max}", String(this._photos.max))}`} ${t("history_edit_photos_hint", L)}
${error ? html`
${error}
` : nothing}
`); } protected updated(changed: Map): void { if (changed.has("_open") && this._open) focusModalShell(this.shadowRoot); } /** #161 phase 2: per-slot rows for a slot task (entry snapshot ∪ current * slots), else the scalar field on a reading task / an entry that has one. */ private _renderReadings(d: HistoryEntryDraft, L: string) { if (this._readingRows.length > 0) { return html`
${t("readings_section", L)} ${this._readingRows.map((row) => html` `)}
`; } if (d.reading_value == null && d.task_type !== "reading") return nothing; return html` `; } 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; color: var(--secondary-text-color); font-size: 13px; } label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; } label span { color: var(--secondary-text-color); } .row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } input, textarea { padding: 8px; font-size: 14px; background: var(--secondary-background-color, #2c2c2c); color: var(--primary-text-color); border: 1px solid var(--divider-color, #444); border-radius: 6px; width: 100%; box-sizing: border-box; font-family: inherit; } .delete-entry { margin-right: auto; color: var(--error-color, #d32f2f); background: transparent; border: 1px solid var(--error-color, #d32f2f); border-radius: 6px; padding: 6px 10px; cursor: pointer; display: inline-flex; align-items: center; gap: 4px; } .delete-entry ha-icon { --mdc-icon-size: 18px; } .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; } button { padding: 8px 16px; font-size: 14px; border-radius: 6px; cursor: pointer; border: none; font-weight: 500; } button.cancel { background: transparent; color: var(--primary-text-color); border: 1px solid var(--divider-color); } button.save { background: var(--primary-color); color: var(--text-primary-color, white); } button[disabled] { opacity: 0.5; cursor: wait; } .error { color: var(--error-color, #d32f2f); font-size: 13px; padding: 8px; background: rgba(211,47,47,0.1); border-radius: 6px; } /* #130: parts on the entry */ .parts-block { display: flex; flex-direction: column; gap: 6px; border: 1px solid var(--divider-color, #444); border-radius: 6px; padding: 8px; } .parts-title { color: var(--secondary-text-color); font-size: 13px; } .part-row-edit { display: flex; flex-direction: row; align-items: center; gap: 8px; font-size: 14px; } .part-row-edit input[type="checkbox"] { width: auto; } .part-label { flex: 1; color: var(--primary-text-color); } .part-qty { width: 76px; } /* #161 phase 2: readings on the entry */ .readings-block { display: flex; flex-direction: column; gap: 6px; border: 1px solid var(--divider-color, #444); border-radius: 6px; padding: 8px; } .reading-row-edit { display: flex; flex-direction: row; align-items: center; gap: 8px; font-size: 14px; } .reading-row-name { flex: 1; color: var(--primary-text-color); min-width: 0; } .reading-row-input { width: 140px; font-variant-numeric: tabular-nums; } /* #161: photos on the entry */ .photos-block { display: flex; flex-direction: column; gap: 6px; border: 1px solid var(--divider-color, #444); border-radius: 6px; padding: 8px; } .photo-strip { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 6px; } .photo-tile { position: relative; width: fit-content; } .photo-remove { position: absolute; top: -4px; right: -8px; width: 22px; height: 22px; border-radius: 50%; border: none; background: var(--error-color, #db4437); color: #fff; cursor: pointer; font-size: 11px; line-height: 1; padding: 0; } /* the camera / gallery pickers come from photoPickerStyles (ms-photo-picker) */ .photos-hint { font-size: 12px; color: var(--secondary-text-color); } `]; } if (!customElements.get("maintenance-history-edit-dialog")) { customElements.define( "maintenance-history-edit-dialog", MaintenanceHistoryEditDialog, ); }