/** 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 { describeWsError } from "../ws-errors"; import { PhotoUploadController } from "../helpers/photo-upload-controller"; 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 = ""; // #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._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 { const draft = this._draft; if (!draft) return; 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 }>; }>; }; 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 { 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._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; if (!window.confirm(t("history_delete_confirm", L))) return; this._saving = true; 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; } } private async _save(): Promise { if (!this._draft || !this._originalSnapshot) return; this._saving = true; this._error = ""; this._photos.clearError(); try { const patch: Record = { 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 = {}; 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; } } 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 html`
`; } /** #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, 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; } 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, ); }