/** v2.3.0 Phase 3 — Object Quick-Actions Dialog. * * In-place dialog for object-level actions: Edit settings / Add task / * Delete object, plus a read-only display of all metadata + a compact * task-list for the object. Mirrors what the panel's object-detail page * offers, accessible without panel-roundtrip. * * Mounted via dialog-mount.openObjectQuickActions(entryId). */ import { LitElement, html, css, nothing } from "lit"; import { isSafeHttpUrl } from "../helpers/url"; import { renderNotesMarkdown } from "../helpers/notes-markdown"; import { property, state } from "lit/decorators.js"; import { t, langOf } from "../styles"; import { runWs } from "../helpers/ws-run"; import { statusColor, statusKey, statusLabel } from "../renderers/status"; import { fetchSettingsOnce } from "../helpers/settings-cache"; import { canWrite, NO_DELEGATION, type WriteAccess } from "../helpers/permissions"; import { confirmAction } from "../helpers/confirm"; import { focusModalShell, modalShellStyles, renderModalShell } from "../helpers/modal-shell"; import type { HomeAssistant, MaintenanceObject, MaintenanceTask } from "../types"; interface ObjectFull { entry_id: string; object: MaintenanceObject; tasks: MaintenanceTask[]; } export class MaintenanceObjectQuickActionsDialog extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @state() private _open = false; @state() private _entryId: string | null = null; @state() private _data: ObjectFull | null = null; @state() private _busy = false; @state() private _error = ""; /** Operator-write delegation — Add task / Edit / Archive / Delete follow * canWrite() like the panel (DRY audit 2026-09-26). */ @state() private _access: WriteAccess = NO_DELEGATION; private get _lang(): string { return langOf(this.hass); } public async openFor(entryId: string): Promise { this._entryId = entryId; this._error = ""; this._open = true; await Promise.all([ this._load(), fetchSettingsOnce(this.hass).then((s) => { this._access = s.access; }), ]); } public close(): void { this._open = false; this._data = null; this._error = ""; } private async _load(): Promise { if (!this._entryId) return; const r = await runWs( this, { type: "maintenance_supporter/object", entry_id: this._entryId }, { onError: (m) => { this._error = m; } }, ); if (r !== undefined) this._data = r; } /** helpers/ws-run runWs bound to this dialog's busy flag and error line. */ private _runWs(payload: Record): Promise { this._error = ""; return runWs(this, payload, { busy: (b) => { this._busy = b; }, onError: (m) => { this._error = m; }, }); } private _onEditObject(): void { if (!this._entryId || !this._data) return; import("../dialog-mount").then(({ openEditObjectDialog }) => { openEditObjectDialog(this._entryId!, this._data!.object); this.close(); }); } private _onAddTask(): void { if (!this._entryId) return; import("../dialog-mount").then(({ openCreateTaskDialog }) => { openCreateTaskDialog(this._entryId!); this.close(); }); } private async _onDelete(): Promise { if (!this._entryId || !this._data) return; const ok = await confirmAction(this.hass, { title: t("delete", this._lang), message: t("delete_object_confirm", this._lang), confirmText: t("delete", this._lang), danger: true, }); if (!ok) return; const entryId = this._entryId; if ((await this._runWs({ type: "maintenance_supporter/object/delete", entry_id: entryId })) === undefined) return; this.dispatchEvent( new CustomEvent("object-deleted", { detail: { entry_id: entryId }, bubbles: true, composed: true, }), ); this.close(); } private async _onArchiveObject(): Promise { if (!this._entryId || !this._data) return; const archived = !!this._data.object.archived; if (!archived) { const ok = await confirmAction(this.hass, { title: t("archive_object", this._lang), message: t("confirm_archive_object", this._lang), confirmText: t("archive_object", this._lang), }); if (!ok) return; } const entryId = this._entryId; const res = await this._runWs({ type: archived ? "maintenance_supporter/object/unarchive" : "maintenance_supporter/object/archive", entry_id: entryId, }); if (res === undefined) return; this.dispatchEvent( new CustomEvent("object-changed", { detail: { entry_id: entryId }, bubbles: true, composed: true, }), ); this.close(); } private _onTaskClick(taskId: string): void { if (!this._entryId) return; // Open the task quick-actions for this task — reuses existing dialog import("../dialog-mount").then(({ openTaskQuickActions }) => { openTaskQuickActions(this._entryId!, taskId); // Keep this dialog open so user lands back on object after closing task dialog }); } render() { if (!this._open) return nothing; const L = this._lang; const data = this._data; const obj = data?.object; const tasks = data?.tasks || []; const writer = canWrite(this.hass?.user, this._access); return renderModalShell(() => this.close(), html` ${data && obj ? html`
${obj.name}
${this._renderMetaRow(obj)}
${this._error ? html`
${this._error}
` : nothing}
${t("tasks", L)} ${tasks.length}
${tasks.length === 0 ? html`
${t("no_tasks", L)}
` : html`
${tasks.map((task) => html`
this._onTaskClick(task.id)}> ${task.name} ${statusLabel(statusKey(task), L)}
`)}
`}
${obj.notes ? html`
${t("object_notes_label", L)}
${renderNotesMarkdown(obj.notes)}
` : nothing} ${writer ? html`
` : nothing} ` : html`
${t("loading", L)}
`} `); } protected updated(changed: Map): void { if (changed.has("_open") && this._open) focusModalShell(this.shadowRoot); } private _renderMetaRow(obj: MaintenanceObject) { const L = this._lang; const items: Array<[string, string]> = []; if (obj.area_id) items.push([t("area", L), obj.area_id]); if (obj.manufacturer) items.push([t("manufacturer", L), obj.manufacturer]); if (obj.model) items.push([t("model", L), obj.model]); if (obj.serial_number) items.push([t("serial_number_label", L), obj.serial_number]); if (obj.installation_date) items.push([t("installed", L), obj.installation_date]); if (obj.warranty_expiry) items.push([t("warranty", L), obj.warranty_expiry]); if (obj.documentation_url) items.push([t("documentation_url_label", L), obj.documentation_url]); if (items.length === 0) return nothing; return html`
${items.map( ([label, value]) => html`
${label} ${ // Only render http(s) values as links (never javascript:/data:); // value-based so it works in every UI language, not just English. isSafeHttpUrl(value) ? html`${value}` : value }
`, )}
`; } static styles = [modalShellStyles, css` :host { display: contents; } .header { display: flex; flex-direction: column; gap: 6px; } .title { font-size: 20px; font-weight: 600; } .meta { display: flex; flex-direction: column; gap: 4px; padding-top: 4px; border-top: 1px solid var(--divider-color); } .meta-item { display: flex; gap: 8px; font-size: 12px; } .meta-label { color: var(--secondary-text-color); min-width: 100px; } .meta-value { color: var(--primary-text-color); flex: 1; word-break: break-word; } .meta-value a { color: var(--primary-color); } .tasks-section, .notes-section { display: flex; flex-direction: column; gap: 6px; } .section-header { display: flex; align-items: baseline; gap: 8px; } .count { font-size: 11px; color: var(--secondary-text-color); background: var(--secondary-background-color); padding: 2px 8px; border-radius: 999px; } .empty { color: var(--secondary-text-color); font-style: italic; font-size: 13px; padding: 8px 0; } .task-list { display: flex; flex-direction: column; gap: 4px; max-height: 200px; overflow: auto; } .task-row { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 6px; cursor: pointer; background: var(--secondary-background-color, rgba(255,255,255,0.03)); transition: background 0.12s; } .task-row:hover { background: var(--state-icon-color, rgba(255,255,255,0.06)); } .status-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } .task-name { flex: 1; font-size: 14px; } .task-status { font-size: 11px; color: var(--secondary-text-color); text-transform: uppercase; } .notes-body { white-space: pre-wrap; font-size: 13px; padding: 8px; background: var(--secondary-background-color); border-radius: 6px; } .notes-body ha-markdown { white-space: normal; } .actions { display: flex; gap: 8px; padding-top: 8px; border-top: 1px solid var(--divider-color); } .actions .btn { flex: 1; } .btn { padding: 8px; font-size: 13px; border-radius: 6px; cursor: pointer; border: 1px solid var(--divider-color); background: var(--secondary-background-color, transparent); color: var(--primary-text-color); font-weight: 500; display: inline-flex; align-items: center; justify-content: center; gap: 6px; } .btn:hover { background: var(--state-icon-color, rgba(255,255,255,0.06)); } .btn[disabled] { opacity: 0.5; cursor: wait; } .btn.primary { background: var(--primary-color); color: var(--text-primary-color, white); border-color: var(--primary-color); } .btn.danger { color: var(--error-color); } .btn ha-icon { --mdc-icon-size: 16px; } .loading { padding: 24px; text-align: center; color: var(--secondary-text-color); } .error { padding: 8px; border-radius: 6px; background: rgba(211,47,47,0.1); color: var(--error-color); font-size: 13px; } `]; } if (!customElements.get("maintenance-object-quick-actions-dialog")) { customElements.define( "maintenance-object-quick-actions-dialog", MaintenanceObjectQuickActionsDialog, ); }