Files
HomeAssistantVS/custom_components/maintenance_supporter/frontend-src/components/object-quick-actions-dialog.ts
T

318 lines
13 KiB
TypeScript

/** 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<void> {
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<void> {
if (!this._entryId) return;
const r = await runWs<ObjectFull>(
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<string, unknown>): Promise<unknown> {
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<void> {
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<void> {
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`
<div class="header">
<div class="title">${obj.name}</div>
${this._renderMetaRow(obj)}
</div>
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
<div class="tasks-section">
<div class="section-header">
<strong>${t("tasks", L)}</strong>
<span class="count">${tasks.length}</span>
</div>
${tasks.length === 0
? html`<div class="empty">${t("no_tasks", L)}</div>`
: html`
<div class="task-list">
${tasks.map((task) => html`
<div class="task-row" @click=${() => this._onTaskClick(task.id)}>
<span class="status-dot" style="background: ${statusColor(task)}"></span>
<span class="task-name">${task.name}</span>
<span class="task-status ${statusKey(task)}">${statusLabel(statusKey(task), L)}</span>
</div>
`)}
</div>
`}
</div>
${obj.notes
? html`
<div class="notes-section">
<strong>${t("object_notes_label", L)}</strong>
<div class="notes-body">${renderNotesMarkdown(obj.notes)}</div>
</div>
`
: nothing}
${writer
? html`
<div class="actions">
<button class="btn primary" @click=${this._onAddTask} ?disabled=${this._busy}>
<ha-icon icon="mdi:plus"></ha-icon>
${t("add_task", L)}
</button>
<button class="btn" @click=${this._onEditObject} ?disabled=${this._busy}>
<ha-icon icon="mdi:pencil"></ha-icon>
${t("edit", L)}
</button>
<button class="btn" @click=${this._onArchiveObject} ?disabled=${this._busy}>
<ha-icon icon="${obj.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'}"></ha-icon>
${obj.archived ? t("unarchive_object", L) : t("archive_object", L)}
</button>
<button class="btn danger" @click=${this._onDelete} ?disabled=${this._busy}>
<ha-icon icon="mdi:delete"></ha-icon>
${t("delete", L)}
</button>
</div>
`
: nothing}
`
: html`<div class="loading">${t("loading", L)}</div>`}
`);
}
protected updated(changed: Map<string, unknown>): 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`
<div class="meta">
${items.map(
([label, value]) => html`
<div class="meta-item">
<span class="meta-label">${label}</span>
<span class="meta-value">${
// 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`<a href="${value}" target="_blank" rel="noopener noreferrer">${value}</a>`
: value
}</span>
</div>
`,
)}
</div>
`;
}
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,
);
}