Files
HomeAssistantVS/custom_components/maintenance_supporter/frontend-src/components/groups-section-card.ts
T
2026-07-08 10:43:39 -04:00

341 lines
12 KiB
TypeScript

/** v2.4.0 — Interactive Groups Section Card.
*
* Replaces the read-only markdown groups card. Lets the admin add / rename /
* delete groups inline. Task assignment lives in the panel (it needs the
* full task picker), so each group has a "Manage tasks" link that deep-links
* there.
*/
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { describeWsError } from "../ws-errors";
import { sectionCardSharedStyles } from "./section-card-shared-styles";
import type { HomeAssistant } from "../types";
interface GroupEntry {
name?: string;
description?: string;
task_refs?: { entry_id: string; task_id: string }[];
}
interface GroupsResp {
groups?: Record<string, GroupEntry>;
}
interface CardConfig { type: string; title?: string; }
export class MaintenanceGroupsSectionCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config: CardConfig = { type: "" };
@state() private _groups: Record<string, GroupEntry> = {};
@state() private _loaded = false;
@state() private _busy = false;
@state() private _error = "";
@state() private _newName = "";
@state() private _editingId: string | null = null;
@state() private _editingName = "";
private _hasInitiallyLoaded = false;
setConfig(config: CardConfig): void {
this._config = config;
}
getCardSize(): number {
return 2;
}
private get _lang(): string {
return this.hass?.language || "en";
}
private get _isAdmin(): boolean {
return (this.hass?.user?.is_admin ?? true) as boolean;
}
updated(changedProps: Map<string, unknown>): void {
super.updated(changedProps);
if (changedProps.has("hass") && this.hass && !this._hasInitiallyLoaded) {
this._hasInitiallyLoaded = true;
void this._load();
void ensureLocale(this._lang).then(() => this.requestUpdate());
}
}
private async _load(): Promise<void> {
try {
const r = await this.hass.connection.sendMessagePromise<GroupsResp>({
type: "maintenance_supporter/groups",
});
this._groups = r.groups || {};
this._loaded = true;
} catch (e) {
this._error = describeWsError(e, this._lang);
}
}
private async _addGroup(): Promise<void> {
if (!this._isAdmin) return;
const name = this._newName.trim();
if (!name) return;
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/group/create",
name,
});
this._newName = "";
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
}
private _startEdit(id: string): void {
this._editingId = id;
this._editingName = this._groups[id]?.name || "";
}
private async _saveEdit(): Promise<void> {
if (!this._isAdmin || !this._editingId) return;
const name = this._editingName.trim();
if (!name) return;
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/group/update",
group_id: this._editingId,
name,
});
this._editingId = null;
this._editingName = "";
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
}
private async _deleteGroup(id: string, name: string): Promise<void> {
if (!this._isAdmin) return;
const confirmText = (t("group_delete_confirm", this._lang)
|| "Delete group \"{name}\"?").replace("{name}", name);
if (!window.confirm(confirmText)) return;
this._busy = true;
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/group/delete",
group_id: id,
});
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
}
private _onDeepLink(): void {
const path = "/maintenance-supporter?ms_action=open_groups";
history.pushState(null, "", path);
window.dispatchEvent(new CustomEvent("location-changed"));
}
private _onKeyDown(e: KeyboardEvent, action: () => void): void {
if (e.key === "Enter") {
e.preventDefault();
action();
} else if (e.key === "Escape") {
e.preventDefault();
this._editingId = null;
this._editingName = "";
}
}
render() {
const L = this._lang;
if (!this._loaded) {
return html`<ha-card><div class="loading">${t("loading", L) || "Loading…"}</div></ha-card>`;
}
const ids = Object.keys(this._groups);
return html`
<ha-card>
<div class="card-content">
<div class="header">
<div class="title">
<span class="emoji">🏷️</span>
<span>${this._config.title || (t("groups", L) || "Groups")}</span>
<span class="count">${ids.length}</span>
</div>
</div>
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
${ids.length === 0
? html`<div class="empty">${t("groups_empty", L) || "No groups yet."}</div>`
: html`
<div class="group-list">
${ids.map((id) => {
const g = this._groups[id];
const taskCount = g.task_refs?.length ?? 0;
const isEditing = this._editingId === id;
return html`
<div class="group-row">
${isEditing
? html`
<input class="edit-input" type="text"
.value=${this._editingName}
?disabled=${this._busy}
@input=${(e: Event) => {
this._editingName = (e.target as HTMLInputElement).value;
}}
@keydown=${(e: KeyboardEvent) => this._onKeyDown(e, this._saveEdit.bind(this))} />
<button class="btn small primary"
@click=${this._saveEdit}
?disabled=${this._busy || !this._editingName.trim()}>
${t("save", L) || "Save"}
</button>
<button class="btn small"
@click=${() => { this._editingId = null; }}>
${t("cancel", L) || "Cancel"}
</button>
`
: html`
<span class="group-name">${g.name || "Unnamed"}</span>
<span class="task-count">${taskCount}</span>
${this._isAdmin
? html`
<button class="icon-btn"
title="${t("edit", L) || "Edit"}"
@click=${() => this._startEdit(id)}
?disabled=${this._busy}>
<ha-icon icon="mdi:pencil"></ha-icon>
</button>
<button class="icon-btn danger"
title="${t("delete", L) || "Delete"}"
@click=${() => this._deleteGroup(id, g.name || "Unnamed")}
?disabled=${this._busy}>
<ha-icon icon="mdi:delete"></ha-icon>
</button>
`
: nothing}
`}
</div>
`;
})}
</div>
`}
${this._isAdmin
? html`
<div class="add-row">
<input type="text"
placeholder="${t("group_new_placeholder", L) || "Add group…"}"
.value=${this._newName}
?disabled=${this._busy}
@input=${(e: Event) => {
this._newName = (e.target as HTMLInputElement).value;
}}
@keydown=${(e: KeyboardEvent) => this._onKeyDown(e, this._addGroup.bind(this))} />
<button class="btn primary"
@click=${this._addGroup}
?disabled=${this._busy || !this._newName.trim()}>
<ha-icon icon="mdi:plus"></ha-icon>
${t("add", L) || "Add"}
</button>
</div>
<button class="btn link" @click=${this._onDeepLink}>
${t("groups_manage_tasks", L) || "Manage task assignments…"}
</button>
`
: html`
<button class="btn link" @click=${this._onDeepLink}>
${t("groups_open_panel", L) || "Open in panel"}
</button>
`}
</div>
</ha-card>
`;
}
static styles = [sectionCardSharedStyles, css`
.count {
font-size: 12px; color: var(--secondary-text-color);
background: var(--secondary-background-color);
padding: 2px 8px; border-radius: 999px;
}
.empty {
padding: 16px; text-align: center;
color: var(--secondary-text-color); font-style: italic;
}
.group-list { display: flex; flex-direction: column; gap: 4px; }
.group-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px; border-radius: 6px;
background: var(--secondary-background-color, rgba(255,255,255,0.03));
}
.group-name { flex: 1; font-size: 14px; }
.task-count {
font-size: 11px; color: var(--secondary-text-color);
background: var(--card-background-color, rgba(0,0,0,0.2));
padding: 1px 8px; border-radius: 999px;
font-weight: 500;
}
.edit-input {
flex: 1; padding: 4px 8px; font-size: 14px;
background: var(--card-background-color, #1c1c1c);
color: var(--primary-text-color);
border: 1px solid var(--primary-color); border-radius: 4px;
font-family: inherit;
}
.icon-btn {
background: transparent; border: none; cursor: pointer;
color: var(--secondary-text-color); padding: 4px;
border-radius: 4px;
}
.icon-btn:hover {
background: var(--state-icon-color, rgba(255,255,255,0.06));
color: var(--primary-text-color);
}
.icon-btn.danger:hover { color: var(--error-color); }
.icon-btn ha-icon { --mdc-icon-size: 18px; }
.add-row {
display: flex; gap: 6px;
padding-top: 8px; border-top: 1px solid var(--divider-color);
}
.add-row input {
flex: 1; padding: 6px 8px; font-size: 13px;
background: var(--secondary-background-color, #2c2c2c);
color: var(--primary-text-color);
border: 1px solid var(--divider-color); border-radius: 6px;
font-family: inherit;
}
/* Card-specific overrides on the shared .btn */
.btn.small { padding: 4px 8px; font-size: 12px; }
.btn ha-icon { --mdc-icon-size: 16px; }
`];
}
if (!customElements.get("maintenance-groups-section-card")) {
customElements.define(
"maintenance-groups-section-card",
MaintenanceGroupsSectionCard,
);
}
(window as { customCards?: unknown[] }).customCards =
(window as { customCards?: unknown[] }).customCards || [];
((window as { customCards?: unknown[] }).customCards!).push({
type: "maintenance-groups-section-card",
name: "Maintenance Supporter — Groups",
description: "Inline group CRUD",
preview: false,
});