/** 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, langOf } from "../styles"; import { registerCustomCard } from "../helpers/register-card"; import { runWs } from "../helpers/ws-run"; import { sectionCardSharedStyles } from "./section-card-shared-styles"; import { fetchSettingsOnce } from "../helpers/settings-cache"; import { canWrite, NO_DELEGATION, type WriteAccess } from "../helpers/permissions"; import { confirmAction } from "../helpers/confirm"; import type { HomeAssistant } from "../types"; interface GroupEntry { name?: string; description?: string; task_refs?: { entry_id: string; task_id: string }[]; } interface GroupsResp { groups?: Record; } 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 = {}; @state() private _loaded = false; @state() private _busy = false; @state() private _error = ""; @state() private _newName = ""; @state() private _editingId: string | null = null; @state() private _editingName = ""; /** Operator-write delegation — group CRUD is write tier (canWrite). */ @state() private _access: WriteAccess = NO_DELEGATION; private _hasInitiallyLoaded = false; setConfig(config: CardConfig): void { this._config = config; } getCardSize(): number { return 2; } private get _lang(): string { return langOf(this.hass); } /** Group create / rename / delete are WRITE tier on the server: admins, * plus allowlisted operators with delegation on — the panel's rule * (helpers/permissions). `is_admin` alone hid the controls from a * delegated operator (DRY audit 2026-09-26). */ private get _canWrite(): boolean { return canWrite(this.hass?.user, this._access); } updated(changedProps: Map): void { super.updated(changedProps); if (changedProps.has("hass") && this.hass && !this._hasInitiallyLoaded) { this._hasInitiallyLoaded = true; void this._load(); void fetchSettingsOnce(this.hass).then((s) => { this._access = s.access; }); void ensureLocale(this._lang).then(() => this.requestUpdate()); } } private async _load(): Promise { const r = await runWs(this, { type: "maintenance_supporter/groups" }, { onError: (m) => { this._error = m; }, }); if (r === undefined) return; this._groups = r?.groups || {}; this._loaded = true; } /** One group mutation — helpers/ws-run runWs with the busy flag, the * error line and the list reload (`onDone` runs first). */ private async _act(payload: Record, onDone?: () => void): Promise { this._error = ""; await runWs(this, payload, { busy: (b) => { this._busy = b; }, reload: async () => { onDone?.(); await this._load(); }, onError: (m) => { this._error = m; }, }); } private async _addGroup(): Promise { if (!this._canWrite) return; const name = this._newName.trim(); if (!name) return; await this._act({ type: "maintenance_supporter/group/create", name }, () => { this._newName = ""; }); } private _startEdit(id: string): void { this._editingId = id; this._editingName = this._groups[id]?.name || ""; } private async _saveEdit(): Promise { if (!this._canWrite || !this._editingId) return; const name = this._editingName.trim(); if (!name) return; await this._act({ type: "maintenance_supporter/group/update", group_id: this._editingId, name }, () => { this._editingId = null; this._editingName = ""; }); } private async _deleteGroup(id: string, name: string): Promise { if (!this._canWrite) return; const ok = await confirmAction(this.hass, { title: t("delete", this._lang), message: t("delete_group_confirm", this._lang).replace("{name}", name), confirmText: t("delete", this._lang), danger: true, }); if (!ok) return; await this._act({ type: "maintenance_supporter/group/delete", group_id: id }); } 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`
${t("loading", L)}
`; } const ids = Object.keys(this._groups); return html`
🏷️ ${this._config.title || t("groups", L)} ${ids.length}
${this._error ? html`
${this._error}
` : nothing} ${ids.length === 0 ? html`
${t("groups_empty", L)}
` : html`
${ids.map((id) => { const g = this._groups[id]; const taskCount = g.task_refs?.length ?? 0; const isEditing = this._editingId === id; return html`
${isEditing ? html` { this._editingName = (e.target as HTMLInputElement).value; }} @keydown=${(e: KeyboardEvent) => this._onKeyDown(e, this._saveEdit.bind(this))} /> ` : html` ${g.name || "Unnamed"} ${taskCount} ${this._canWrite ? html` ` : nothing} `}
`; })}
`} ${this._canWrite ? html`
{ this._newName = (e.target as HTMLInputElement).value; }} @keydown=${(e: KeyboardEvent) => this._onKeyDown(e, this._addGroup.bind(this))} />
` : html` `}
`; } 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, ); } registerCustomCard({ type: "maintenance-groups-section-card", name: "Maintenance Supporter — Groups", description: "Inline group CRUD", preview: false, });