/** Dialog for creating, editing and replacing a maintenance object. */ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import type { HomeAssistant, MaintenanceObject, MaintenanceObjectResponse } from "../types"; /** What a device change did to the tasks' wiring (object/update + replace). */ export interface DeviceSwap { moved: number; unmatched: string[]; } type ReplaceDevice = "keep" | "other" | "none"; import { t, langOf } from "../styles"; import { runWs } from "../helpers/ws-run"; import "./ms-textfield"; import "./ms-date-field"; export class MaintenanceObjectDialog extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; /** All objects — choices for the parent-object picker (2.19). */ @property({ attribute: false }) public objects: MaintenanceObjectResponse[] = []; @state() private _open = false; @state() private _loading = false; @state() private _error = ""; @state() private _name = ""; @state() private _manufacturer = ""; @state() private _model = ""; @state() private _serialNumber = ""; @state() private _areaId = ""; @state() private _installationDate = ""; // (#67): per-object warranty expiry date @state() private _warrantyExpiry = ""; // v1.4.0 (#43): per-object link to PDF manual / vendor page @state() private _documentationUrl = ""; // v1.4.10 (#46): free-form notes (multiline) @state() private _notes = ""; // 2.19: attach to an existing HA device / nest under another object @state() private _haDeviceId = ""; @state() private _parentEntryId = ""; @state() private _entryId: string | null = null; // null = create, string = update // Replace (journey N1): the successor's device. The old unit's device is // what the object links to now; a new unit usually is a new device. @state() private _replacing = false; @state() private _oldDeviceId = ""; @state() private _replaceDevice: ReplaceDevice = "keep"; private get _lang(): string { return langOf(this.hass); } public openCreate(): void { this._entryId = null; this._name = ""; this._manufacturer = ""; this._model = ""; this._serialNumber = ""; this._areaId = ""; this._installationDate = ""; this._warrantyExpiry = ""; this._documentationUrl = ""; this._notes = ""; this._haDeviceId = ""; this._parentEntryId = ""; this._replacing = false; this._error = ""; this._open = true; } /** Replace a worn-out object: the old one is archived in place, a * successor carries its tasks and documents over. Asks for the name and * for the new unit's device — keeping the old link silently made the * successor watch the retired machine's sensors. */ public openReplace(entryId: string, obj: MaintenanceObject): void { this._entryId = entryId; this._replacing = true; this._name = obj.name || ""; this._oldDeviceId = obj.ha_device_id || ""; this._haDeviceId = ""; this._replaceDevice = "keep"; this._error = ""; this._open = true; } public openEdit(entryId: string, obj: MaintenanceObject): void { this._entryId = entryId; this._name = obj.name || ""; this._manufacturer = obj.manufacturer || ""; this._model = obj.model || ""; this._serialNumber = obj.serial_number || ""; this._areaId = obj.area_id || ""; this._installationDate = obj.installation_date || ""; this._warrantyExpiry = obj.warranty_expiry || ""; this._documentationUrl = obj.documentation_url || ""; this._notes = obj.notes || ""; this._haDeviceId = obj.ha_device_id || ""; this._parentEntryId = obj.parent_entry_id || ""; this._replacing = false; this._error = ""; this._open = true; } private async _replace(): Promise { if (this._loading || !this._entryId) return; const msg: Record = { type: "maintenance_supporter/object/replace", entry_id: this._entryId, name: this._name.trim() || null, }; if (this._oldDeviceId) { // "keep" sends nothing: the successor stays on the same device. if (this._replaceDevice === "other") msg.ha_device_id = this._haDeviceId; if (this._replaceDevice === "none") msg.ha_device_id = null; } else if (this._haDeviceId) { msg.ha_device_id = this._haDeviceId; } const res = await runWs<{ entry_id?: string; device_swap?: DeviceSwap }>(this, msg, { busy: (b) => { this._loading = b; }, fallbackKey: "save_error", onError: (m) => { this._error = m; }, }); if (res === undefined) return; this._open = false; this.dispatchEvent(new CustomEvent("object-replaced", { detail: { entry_id: res?.entry_id, device_swap: res?.device_swap }, })); } private _deviceName(deviceId: string): string { const devices = (this.hass as unknown as { devices?: Record })?.devices; const device = devices?.[deviceId]; return device?.name_by_user || device?.name || deviceId; } private _deviceSelector(label: string) { return html` label} @value-changed=${(e: CustomEvent) => (this._haDeviceId = ((e.detail.value as { device?: string })?.device as string) || "")} >`; } private _renderReplace() { const L = this._lang; const choice = (value: ReplaceDevice, label: string) => html``; const blocked = this._replaceDevice === "other" && !this._haDeviceId; return html`
${t("replace_object", L)}
${this._error ? html`
${this._error}
` : nothing}
${t("replace_object_prompt", L)}
(this._name = (e.target as HTMLInputElement).value)} > ${this._oldDeviceId ? html`
${t("replace_device_heading", L)}
${choice("keep", t("replace_device_keep", L).replace("{device}", this._deviceName(this._oldDeviceId)))} ${this._replaceDevice === "keep" ? html`
${t("replace_device_keep_hint", L)}
` : nothing} ${choice("other", t("replace_device_other", L))} ${this._replaceDevice === "other" ? html`${this._deviceSelector(t("replace_device_pick", L))}
${t("replace_device_other_hint", L)}
` : nothing} ${choice("none", t("replace_device_none", L))}
` : this._deviceSelector(t("link_device_optional", L))}
${t("cancel", L)} ${this._loading ? t("saving", L) : t("replace_object", L)}
`; } private async _save(): Promise { if (this._loading) return; // synchronous re-entry guard (double-click) if (!this._name.trim()) return; this._error = ""; // One field set for create and update (the two payloads were copies). const fields = { name: this._name, manufacturer: this._manufacturer || null, model: this._model || null, serial_number: this._serialNumber || null, area_id: this._areaId || null, installation_date: this._installationDate || null, warranty_expiry: this._warrantyExpiry || null, documentation_url: this._documentationUrl.trim() || null, notes: this._notes.trim() || null, ha_device_id: this._haDeviceId || null, parent_entry_id: this._parentEntryId || null, }; const res = await runWs<{ device_swap?: DeviceSwap }>( this, this._entryId ? { type: "maintenance_supporter/object/update", entry_id: this._entryId, ...fields } : { type: "maintenance_supporter/object/create", ...fields }, { busy: (b) => { this._loading = b; }, fallbackKey: "save_error", onError: (m) => { this._error = m; } }, ); if (res === undefined) return; this._open = false; // A changed device moved the tasks' sensor links along — the panel says // how many, and which could not be placed. this.dispatchEvent(new CustomEvent("object-saved", { detail: { device_swap: res?.device_swap } })); } private _parentChoices(): MaintenanceObjectResponse[] { return (this.objects || []).filter((o) => o.entry_id !== this._entryId); } private _close(): void { this._open = false; } render() { if (!this._open) return html``; if (this._replacing) return this._renderReplace(); const L = this._lang; const title = this._entryId ? t("edit_object", L) : t("new_object", L); return html`
${title}
${this._error ? html`
${this._error}
` : nothing} (this._name = (e.target as HTMLInputElement).value)} > (this._manufacturer = (e.target as HTMLInputElement).value)} > (this._model = (e.target as HTMLInputElement).value)} > (this._serialNumber = (e.target as HTMLInputElement).value)} > (this._documentationUrl = (e.target as HTMLInputElement).value)} > (this._areaId = (e.detail.value as string) || "")} > (this._installationDate = e.detail.value as string)} > (this._warrantyExpiry = e.detail.value as string)} > t("link_device_optional", L)} @value-changed=${(e: CustomEvent) => (this._haDeviceId = ((e.detail.value as { device?: string })?.device as string) || "")} > ${this._parentChoices().length ? html`` : nothing}
${t("cancel", this._lang)} ${this._loading ? t("saving", this._lang) : t("save", this._lang)}
`; } static styles = css` .dialog-title { font-size: 18px; font-weight: 500; padding-bottom: 12px; } .content { display: flex; flex-direction: column; gap: 16px; min-width: 300px; } .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 16px; } ms-textfield { display: block; } .textarea-field { display: flex; flex-direction: column; gap: 4px; } .textarea-label { font-size: 12px; color: var(--secondary-text-color, #888); font-weight: 500; } .textarea-field textarea { padding: 8px 10px; font-size: 14px; font-family: inherit; background: var(--secondary-background-color, rgba(0,0,0,0.06)); color: var(--primary-text-color); border: 1px solid var(--divider-color); border-radius: 6px; resize: vertical; } .textarea-field textarea:focus { outline: none; border-color: var(--primary-color); } .md-hint { font-size: 11px; color: var(--secondary-text-color); font-style: italic; } .parent-select { padding: 8px 10px; font-size: 14px; font-family: inherit; background: var(--secondary-background-color, rgba(0,0,0,0.06)); color: var(--primary-text-color); border: 1px solid var(--divider-color); border-radius: 6px; } .error { color: var(--error-color, #f44336); font-size: 13px; } .hint { font-size: 13px; color: var(--secondary-text-color); line-height: 1.4; } .radio-group { display: flex; flex-direction: column; gap: 8px; } .radio-row { display: flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; } .radio-row input { margin: 0; } `; } if (!customElements.get("maintenance-object-dialog")) { customElements.define("maintenance-object-dialog", MaintenanceObjectDialog); }