Files
HomeAssistantVS/custom_components/maintenance_supporter/frontend-src/components/object-quick-actions-dialog.ts
T
2026-07-08 10:43:39 -04:00

321 lines
12 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 { property, state } from "lit/decorators.js";
import { t, STATUS_COLORS } from "../styles";
import { describeWsError } from "../ws-errors";
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 = "";
private get _lang(): string {
return this.hass?.language || "en";
}
public async openFor(entryId: string): Promise<void> {
this._entryId = entryId;
this._error = "";
this._open = true;
await this._load();
}
public close(): void {
this._open = false;
this._data = null;
this._error = "";
}
private async _load(): Promise<void> {
if (!this._entryId) return;
try {
const r = await this.hass.connection.sendMessagePromise<ObjectFull>({
type: "maintenance_supporter/object",
entry_id: this._entryId,
});
this._data = r;
} catch (e) {
this._error = describeWsError(e, this._lang);
}
}
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.close();
});
}
private async _onDelete(): Promise<void> {
if (!this._entryId || !this._data) return;
const confirmText = t("delete_object_confirm", this._lang)
|| `Delete "${this._data.object.name}" and all its tasks?`;
if (!window.confirm(confirmText)) return;
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/object/delete",
entry_id: this._entryId,
});
this.dispatchEvent(
new CustomEvent("object-deleted", {
detail: { entry_id: this._entryId },
bubbles: true, composed: true,
}),
);
this.close();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
}
private async _onArchiveObject(): Promise<void> {
if (!this._entryId || !this._data) return;
const archived = !!this._data.object.archived;
if (!archived) {
const confirmText = t("confirm_archive_object", this._lang)
|| "Archive this object and its tasks?";
if (!window.confirm(confirmText)) return;
}
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: archived
? "maintenance_supporter/object/unarchive"
: "maintenance_supporter/object/archive",
entry_id: this._entryId,
});
this.dispatchEvent(
new CustomEvent("object-changed", {
detail: { entry_id: this._entryId },
bubbles: true, composed: true,
}),
);
this.close();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
}
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 isAdmin = (this.hass?.user?.is_admin ?? true) as boolean;
return html`
<div class="backdrop" @click=${this.close}></div>
<div class="dialog" role="dialog" aria-modal="true">
${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) || "Tasks"}</strong>
<span class="count">${tasks.length}</span>
</div>
${tasks.length === 0
? html`<div class="empty">${t("no_tasks", L) || "No tasks yet."}</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: ${STATUS_COLORS[task.status] || "#ccc"}"></span>
<span class="task-name">${task.name}</span>
<span class="task-status">${t(task.status || "ok", L)}</span>
</div>
`)}
</div>
`}
</div>
${obj.notes
? html`
<div class="notes-section">
<strong>${t("object_notes_label", L)}</strong>
<div class="notes-body">${obj.notes}</div>
</div>
`
: nothing}
${isAdmin
? 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) || "Add task"}
</button>
<button class="btn" @click=${this._onEditObject} ?disabled=${this._busy}>
<ha-icon icon="mdi:pencil"></ha-icon>
${t("edit", L) || "Edit"}
</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) || "Unarchive object") : (t("archive_object", L) || "Archive object")}
</button>
<button class="btn danger" @click=${this._onDelete} ?disabled=${this._busy}>
<ha-icon icon="mdi:delete"></ha-icon>
${t("delete", L) || "Delete"}
</button>
</div>
`
: nothing}
`
: html`<div class="loading">${t("loading", L) || "Loading…"}</div>`}
</div>
`;
}
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.
/^https?:\/\//i.test(value)
? html`<a href="${value}" target="_blank" rel="noopener noreferrer">${value}</a>`
: value
}</span>
</div>
`,
)}
</div>
`;
}
static styles = css`
:host { display: contents; }
.backdrop {
position: fixed; inset: 0; z-index: 100; background: rgba(0,0,0,0.5);
}
.dialog {
position: fixed; left: 50%; top: 50%;
transform: translate(-50%, -50%);
width: 95vw; max-width: 480px;
max-height: 92vh; overflow: auto;
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; z-index: 101;
display: flex; flex-direction: column; gap: 14px;
}
.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; }
.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,
);
}