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

328 lines
11 KiB
TypeScript

/** v2.4.0 — Interactive Vacation Section Card.
*
* Replaces the read-only markdown card from Phase 5. Lets the user toggle
* vacation mode + edit start/end/buffer inline, without leaving Lovelace.
*
* Used as a section-strategy card (HA 2026.5+ sections) AND as a stand-alone
* card the user can drop onto any dashboard.
*
* Permissions: only admins see edit affordances; non-admins get a read-only
* view with a deep-link to the panel (vacation config is admin-only at the
* WS layer too).
*/
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 VacationState {
enabled?: boolean;
is_active?: boolean;
start?: string | null;
end?: string | null;
buffer_days?: number;
window_end?: string | null;
exempt_task_ids?: string[];
}
interface CardConfig {
type: string;
title?: string;
}
export class MaintenanceVacationSectionCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config: CardConfig = { type: "" };
@state() private _state: VacationState | null = null;
@state() private _busy = false;
@state() private _error = "";
@state() private _localStart = "";
@state() private _localEnd = "";
@state() private _localBuffer = 7;
@state() private _dirty = false;
private _loaded = 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._loaded) {
this._loaded = true;
void this._load();
void ensureLocale(this._lang).then(() => this.requestUpdate());
}
}
private async _load(): Promise<void> {
try {
const r = await this.hass.connection.sendMessagePromise<VacationState>({
type: "maintenance_supporter/vacation/state",
});
this._state = r;
this._localStart = r.start || "";
this._localEnd = r.end || "";
this._localBuffer = r.buffer_days ?? 7;
this._dirty = false;
} catch (e) {
this._error = describeWsError(e, this._lang);
}
}
private async _toggleEnabled(on: boolean): Promise<void> {
this._busy = true;
this._error = "";
try {
const r = await this.hass.connection.sendMessagePromise<VacationState>({
type: "maintenance_supporter/vacation/update",
enabled: on,
});
this._state = r;
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
}
private async _save(): Promise<void> {
if (!this._isAdmin) return;
this._busy = true;
this._error = "";
try {
const r = await this.hass.connection.sendMessagePromise<VacationState>({
type: "maintenance_supporter/vacation/update",
start: this._localStart || null,
end: this._localEnd || null,
buffer_days: this._localBuffer,
});
this._state = r;
this._dirty = false;
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
}
private async _endNow(): Promise<void> {
if (!this._isAdmin) return;
if (!window.confirm(t("vacation_end_now_confirm", this._lang)
|| "End vacation immediately?")) return;
this._busy = true;
try {
const r = await this.hass.connection.sendMessagePromise<VacationState>({
type: "maintenance_supporter/vacation/end_now",
});
this._state = r;
this._localStart = r.start || "";
this._localEnd = r.end || "";
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
}
private _onDeepLink(): void {
const path = "/maintenance-supporter?ms_action=open_vacation";
history.pushState(null, "", path);
window.dispatchEvent(new CustomEvent("location-changed"));
}
render() {
const L = this._lang;
const s = this._state;
if (!s) {
return html`<ha-card><div class="loading">${t("loading", L) || "Loading…"}</div></ha-card>`;
}
const active = s.is_active === true;
const enabled = s.enabled === true;
const exemptCount = s.exempt_task_ids?.length ?? 0;
const statusLabel = active
? (t("vacation_status_active", L) || "Active now")
: enabled
? (t("vacation_status_scheduled", L) || "Scheduled")
: (t("vacation_status_inactive", L) || "Inactive");
const statusClass = active ? "active" : enabled ? "scheduled" : "inactive";
return html`
<ha-card>
<div class="card-content">
<div class="header">
<div class="title">
<span class="emoji">🏖️</span>
<span>${this._config.title || (t("vacation_mode", L) || "Vacation mode")}</span>
</div>
<span class="status-pill ${statusClass}">${statusLabel}</span>
</div>
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
${this._isAdmin
? html`
<div class="row toggle-row">
<label>${t("enable", L) || "Enable"}</label>
<ha-switch
.checked=${enabled}
.disabled=${this._busy}
@change=${(e: Event) =>
this._toggleEnabled((e.target as HTMLInputElement).checked)}
></ha-switch>
</div>
<div class="dates-row">
<div class="date-field">
<label>${t("vacation_start", L) || "Start"}</label>
<input type="date" .value=${this._localStart}
?disabled=${this._busy}
@input=${(e: Event) => {
this._localStart = (e.target as HTMLInputElement).value;
this._dirty = true;
}} />
</div>
<div class="date-field">
<label>${t("vacation_end", L) || "End"}</label>
<input type="date" .value=${this._localEnd}
?disabled=${this._busy}
@input=${(e: Event) => {
this._localEnd = (e.target as HTMLInputElement).value;
this._dirty = true;
}} />
</div>
<div class="date-field buffer">
<label>${t("vacation_buffer", L) || "Buffer days"}</label>
<input type="number" min="0" max="14"
.value=${String(this._localBuffer)}
?disabled=${this._busy}
@input=${(e: Event) => {
this._localBuffer = parseInt(
(e.target as HTMLInputElement).value, 10) || 0;
this._dirty = true;
}} />
</div>
</div>
<div class="actions">
<button class="btn ${this._dirty ? "primary" : "muted"}"
@click=${this._save}
?disabled=${this._busy || !this._dirty}>
<ha-icon icon="${this._dirty ? "mdi:content-save" : "mdi:check"}"></ha-icon>
${this._dirty
? (t("save", L) || "Save")
: (t("saved", L) || "Saved")}
</button>
${active
? html`<button class="btn"
@click=${this._endNow}
?disabled=${this._busy}>
${t("vacation_end_now", L) || "End now"}
</button>`
: nothing}
${exemptCount > 0
? html`<button class="btn link"
@click=${this._onDeepLink}>
${exemptCount} ${t("vacation_exempt_count", L) || "exempt"}
</button>`
: html`<button class="btn link"
@click=${this._onDeepLink}>
${t("vacation_advanced", L) || "Advanced…"}
</button>`}
</div>
`
: html`
<div class="readonly">
${enabled && s.start && s.end
? html`<div>${s.start}${s.end}</div>`
: nothing}
<button class="btn link" @click=${this._onDeepLink}>
${t("vacation_open_panel", L) || "Open in panel"}
</button>
</div>
`}
</div>
</ha-card>
`;
}
static styles = [sectionCardSharedStyles, css`
.status-pill {
font-size: 11px; font-weight: 600;
padding: 3px 8px; border-radius: 999px;
text-transform: uppercase; letter-spacing: 0.5px;
}
.status-pill.active {
background: rgba(76, 175, 80, 0.15);
color: #4caf50;
}
.status-pill.scheduled {
background: rgba(255, 152, 0, 0.15);
color: #ff9800;
}
.status-pill.inactive {
background: rgba(158, 158, 158, 0.15);
color: var(--secondary-text-color);
}
.row.toggle-row {
display: flex; align-items: center; justify-content: space-between;
}
.row.toggle-row label {
font-size: 14px; color: var(--primary-text-color);
}
.dates-row {
display: grid; grid-template-columns: 1fr 1fr 100px; gap: 10px;
}
.date-field.buffer label { white-space: nowrap; }
.date-field { display: flex; flex-direction: column; gap: 4px; }
.date-field label {
font-size: 11px; color: var(--secondary-text-color);
text-transform: uppercase; letter-spacing: 0.3px;
}
.date-field input {
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;
}
.date-field input:disabled { opacity: 0.5; cursor: not-allowed; }
.actions {
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
}
.readonly { display: flex; flex-direction: column; gap: 8px; }
`];
}
if (!customElements.get("maintenance-vacation-section-card")) {
customElements.define(
"maintenance-vacation-section-card",
MaintenanceVacationSectionCard,
);
}
(window as { customCards?: unknown[] }).customCards =
(window as { customCards?: unknown[] }).customCards || [];
((window as { customCards?: unknown[] }).customCards!).push({
type: "maintenance-vacation-section-card",
name: "Maintenance Supporter — Vacation",
description: "Inline vacation mode toggle + dates",
preview: false,
});