New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
@@ -0,0 +1,271 @@
/** v2.4.0 — Interactive Budget Section Card.
*
* Replaces the read-only markdown budget card. Lets the admin set
* monthly_budget / yearly_budget inline. Read-only progress bars for
* current spending. Uses ``maintenance_supporter/global/update`` with
* the budget_monthly / budget_yearly settings keys.
*/
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale, DEFAULT_CURRENCY_SYMBOL } from "../styles";
import { describeWsError } from "../ws-errors";
import { sectionCardSharedStyles } from "./section-card-shared-styles";
import type { HomeAssistant } from "../types";
interface BudgetStatus {
monthly_budget?: number;
monthly_spent?: number;
yearly_budget?: number;
yearly_spent?: number;
currency_symbol?: string;
}
interface CardConfig { type: string; title?: string; }
export class MaintenanceBudgetSectionCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config: CardConfig = { type: "" };
@state() private _status: BudgetStatus | null = null;
@state() private _busy = false;
@state() private _error = "";
@state() private _localMonthly = "";
@state() private _localYearly = "";
@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<BudgetStatus>({
type: "maintenance_supporter/budget_status",
});
this._status = r;
this._localMonthly = r.monthly_budget ? String(r.monthly_budget) : "";
this._localYearly = r.yearly_budget ? String(r.yearly_budget) : "";
this._dirty = false;
} catch (e) {
this._error = describeWsError(e, this._lang);
}
}
private async _save(): Promise<void> {
if (!this._isAdmin) return;
this._busy = true;
this._error = "";
try {
const m = parseFloat(this._localMonthly);
const y = parseFloat(this._localYearly);
const settings: Record<string, number> = {};
if (!isNaN(m) && m >= 0) settings.budget_monthly = m;
if (!isNaN(y) && y >= 0) settings.budget_yearly = y;
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/global/update",
settings,
});
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_budget";
history.pushState(null, "", path);
window.dispatchEvent(new CustomEvent("location-changed"));
}
render() {
const L = this._lang;
const s = this._status;
if (!s) {
return html`<ha-card><div class="loading">${t("loading", L) || "Loading…"}</div></ha-card>`;
}
const sym = s.currency_symbol || DEFAULT_CURRENCY_SYMBOL;
const mPct = s.monthly_budget ? Math.min(100, ((s.monthly_spent || 0) / s.monthly_budget) * 100) : 0;
const yPct = s.yearly_budget ? Math.min(100, ((s.yearly_spent || 0) / s.yearly_budget) * 100) : 0;
const mWarn = mPct >= 100 ? "danger" : mPct >= 80 ? "warning" : "ok";
const yWarn = yPct >= 100 ? "danger" : yPct >= 80 ? "warning" : "ok";
return html`
<ha-card>
<div class="card-content">
<div class="header">
<div class="title">
<span class="emoji">💰</span>
<span>${this._config.title || t("settings_budget", L) || "Budget"}</span>
</div>
<span class="currency">${sym}</span>
</div>
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
<div class="track">
<div class="track-label-row">
<label>${t("budget_monthly", L) || "Monthly"}</label>
<span class="track-numbers ${mWarn}">
${(s.monthly_spent || 0).toFixed(0)} / ${(s.monthly_budget || 0).toFixed(0)} ${sym}
</span>
</div>
<div class="bar"><div class="bar-fill ${mWarn}" style="width:${mPct}%"></div></div>
</div>
<div class="track">
<div class="track-label-row">
<label>${t("budget_yearly", L) || "Yearly"}</label>
<span class="track-numbers ${yWarn}">
${(s.yearly_spent || 0).toFixed(0)} / ${(s.yearly_budget || 0).toFixed(0)} ${sym}
</span>
</div>
<div class="bar"><div class="bar-fill ${yWarn}" style="width:${yPct}%"></div></div>
</div>
${this._isAdmin
? html`
<div class="inputs-row">
<div class="input-field">
<label>${t("budget_monthly_set", L) || "Set monthly"}</label>
<div class="input-wrap">
<input type="number" min="0" step="1"
.value=${this._localMonthly}
?disabled=${this._busy}
@input=${(e: Event) => {
this._localMonthly = (e.target as HTMLInputElement).value;
this._dirty = true;
}} />
<span class="input-suffix">${sym}</span>
</div>
</div>
<div class="input-field">
<label>${t("budget_yearly_set", L) || "Set yearly"}</label>
<div class="input-wrap">
<input type="number" min="0" step="1"
.value=${this._localYearly}
?disabled=${this._busy}
@input=${(e: Event) => {
this._localYearly = (e.target as HTMLInputElement).value;
this._dirty = true;
}} />
<span class="input-suffix">${sym}</span>
</div>
</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>
<button class="btn link" @click=${this._onDeepLink}>
${t("budget_advanced", L) || "Currency, alerts…"}
</button>
</div>
`
: html`
<button class="btn link" @click=${this._onDeepLink}>
${t("budget_open_panel", L) || "Open in panel"}
</button>
`}
</div>
</ha-card>
`;
}
static styles = [sectionCardSharedStyles, css`
.currency {
font-size: 14px; font-weight: 600;
color: var(--secondary-text-color);
background: var(--secondary-background-color);
padding: 2px 10px; border-radius: 999px;
}
.track { display: flex; flex-direction: column; gap: 4px; }
.track-label-row {
display: flex; align-items: center; justify-content: space-between;
}
.track-label-row label {
font-size: 12px; color: var(--secondary-text-color);
text-transform: uppercase; letter-spacing: 0.5px;
}
.track-numbers { font-size: 13px; font-weight: 600; }
.track-numbers.ok { color: var(--primary-text-color); }
.track-numbers.warning { color: #ff9800; }
.track-numbers.danger { color: var(--error-color, #f44336); }
.bar {
height: 6px; background: var(--secondary-background-color);
border-radius: 3px; overflow: hidden;
}
.bar-fill { height: 100%; transition: width 0.3s; border-radius: 3px; }
.bar-fill.ok { background: var(--primary-color); }
.bar-fill.warning { background: #ff9800; }
.bar-fill.danger { background: var(--error-color, #f44336); }
.inputs-row {
display: grid; grid-template-columns: 1fr 1fr; gap: 8px;
padding-top: 4px; border-top: 1px solid var(--divider-color);
}
.input-field { display: flex; flex-direction: column; gap: 4px; }
.input-field label {
font-size: 11px; color: var(--secondary-text-color);
text-transform: uppercase; letter-spacing: 0.3px;
}
.input-wrap { position: relative; display: flex; align-items: center; }
.input-wrap input {
flex: 1; padding: 6px 32px 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;
}
.input-suffix {
position: absolute; right: 8px;
color: var(--secondary-text-color); font-size: 13px;
pointer-events: none;
}
.actions { display: flex; gap: 8px; align-items: center; }
`];
}
if (!customElements.get("maintenance-budget-section-card")) {
customElements.define(
"maintenance-budget-section-card",
MaintenanceBudgetSectionCard,
);
}
(window as { customCards?: unknown[] }).customCards =
(window as { customCards?: unknown[] }).customCards || [];
((window as { customCards?: unknown[] }).customCards!).push({
type: "maintenance-budget-section-card",
name: "Maintenance Supporter — Budget",
description: "Inline monthly + yearly budget editor",
preview: false,
});
@@ -0,0 +1,396 @@
/** Dialog for completing a maintenance task with optional notes, cost, duration. */
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant } from "../types";
import { t } from "../styles";
import { describeWsError } from "../ws-errors";
export class MaintenanceCompleteDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public entryId = "";
@property() public taskId = "";
@property() public taskName = "";
@property() public lang = "en";
@property({ type: Array }) public checklist: string[] = [];
@property({ type: Boolean }) public adaptiveEnabled = false;
// v2.20 (#83): task type + unit drive the reading-value field below.
@property() public taskType = "";
@property() public readingUnit = "";
@state() private _open = false;
@state() private _notes = "";
@state() private _cost = "";
@state() private _duration = "";
@state() private _loading = false;
@state() private _error = "";
@state() private _checklistState: Record<string, boolean> = {};
@state() private _feedback: string = "needed";
@state() private _photoDocId = "";
@state() private _photoPreview = "";
@state() private _photoUploading = false;
@state() private _readingValue = "";
public open(): void {
if (this._open) return;
this._open = true;
this._notes = "";
this._cost = "";
this._duration = "";
this._error = "";
this._checklistState = {};
this._feedback = "needed";
this._photoDocId = "";
this._photoPreview = "";
this._photoUploading = false;
this._readingValue = "";
}
private _toggleCheck(idx: number): void {
const key = String(idx);
this._checklistState = {
...this._checklistState,
[key]: !this._checklistState[key],
};
}
private _setFeedback(value: string): void {
this._feedback = value;
}
private async _onPhotoInput(e: Event): Promise<void> {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = ""; // allow re-picking the same file
if (!file) return;
this._photoUploading = true;
this._error = "";
try {
const form = new FormData();
form.append("entry_id", this.entryId);
form.append("tags", "photo");
form.append("file", file, file.name);
const resp = await fetch("/api/maintenance_supporter/document/upload", {
method: "POST",
headers: { Authorization: `Bearer ${this.hass.auth?.data?.access_token ?? ""}` },
body: form,
});
if (!resp.ok) {
this._error = resp.status === 413
? t("doc_too_large", this.lang)
: t("doc_upload_failed", this.lang);
return;
}
const doc = (await resp.json()) as { id?: string };
if (doc.id) {
this._photoDocId = doc.id;
this._photoPreview = URL.createObjectURL(file);
}
} catch {
this._error = t("doc_upload_failed", this.lang);
} finally {
this._photoUploading = false;
}
}
private _removePhoto(): void {
if (this._photoPreview) URL.revokeObjectURL(this._photoPreview);
this._photoDocId = "";
this._photoPreview = "";
}
private async _complete(): Promise<void> {
this._loading = true;
this._error = "";
try {
const data: Record<string, unknown> = {
type: "maintenance_supporter/task/complete",
entry_id: this.entryId,
task_id: this.taskId,
};
if (this._notes) data.notes = this._notes;
if (this._cost) {
const cost = parseFloat(this._cost);
if (!isNaN(cost) && cost >= 0) data.cost = cost;
}
if (this._duration) {
const dur = parseInt(this._duration, 10);
if (!isNaN(dur) && dur >= 0) data.duration = dur;
}
if (this.checklist.length > 0) {
data.checklist_state = this._checklistState;
}
if (this.adaptiveEnabled) {
data.feedback = this._feedback;
}
if (this._photoDocId) {
data.photo_doc_id = this._photoDocId;
}
if (this._readingValue !== "") {
const rv = parseFloat(this._readingValue);
if (!isNaN(rv)) data.reading_value = rv;
}
await this.hass.connection.sendMessagePromise(data);
this._open = false;
this.dispatchEvent(new CustomEvent("task-completed"));
} catch (e) {
this._error = describeWsError(e, this.lang, t("save_error", this.lang));
} finally {
this._loading = false;
}
}
private _close(): void {
this._open = false;
}
render() {
if (!this._open) return html``;
const L = this.lang || this.hass?.language || "en";
return html`
<ha-dialog open @closed=${this._close}>
<div class="dialog-title">${t("complete_title", L)}${this.taskName}</div>
<div class="content">
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
${this.checklist.length > 0 ? html`
<div class="checklist-section">
<label class="checklist-label">${t("checklist", L)}</label>
${this.checklist.map((item, idx) => html`
<label class="checklist-item" @click=${() => this._toggleCheck(idx)}>
<input type="checkbox" .checked=${!!this._checklistState[String(idx)]} />
<span>${item}</span>
</label>
`)}
</div>
` : nothing}
${this.taskType === "reading"
? html`
<label class="field">
<span class="field-label">${t("reading_value_label", L)}${this.readingUnit ? ` (${this.readingUnit})` : ""}</span>
<input type="number" step="any" class="field-input"
.value=${this._readingValue}
@input=${(e: Event) => (this._readingValue = (e.target as HTMLInputElement).value)} />
</label>`
: nothing}
<!-- Native <input>s rather than <ha-textfield>: when this dialog
is opened from a Lovelace card via dialog-mount, ha-textfield
isn't yet registered (HA loads it lazily when its own panels
need it) so the elements render with zero height and the user
only sees the title + Cancel/Complete buttons — the original
bug report. Native inputs always render. -->
<label class="field">
<span class="field-label">${t("notes_optional", L)}</span>
<input type="text" class="field-input"
.value=${this._notes}
@input=${(e: Event) => (this._notes = (e.target as HTMLInputElement).value)} />
</label>
<label class="field">
<span class="field-label">${t("cost_optional", L)}</span>
<input type="number" step="0.01" min="0" class="field-input"
.value=${this._cost}
@input=${(e: Event) => (this._cost = (e.target as HTMLInputElement).value)} />
</label>
<label class="field">
<span class="field-label">${t("duration_minutes", L)}</span>
<input type="number" step="1" min="0" class="field-input"
.value=${this._duration}
@input=${(e: Event) => (this._duration = (e.target as HTMLInputElement).value)} />
</label>
<div class="field">
<span class="field-label">${t("completion_photo_optional", L)}</span>
${this._photoPreview
? html`
<div class="photo-preview">
<img src=${this._photoPreview} alt="" />
<button type="button" class="photo-remove" @click=${this._removePhoto}
title="${t("remove", L)}">✕</button>
</div>`
: html`
<label class="photo-pick">
<ha-icon icon="mdi:camera"></ha-icon>
<span>${this._photoUploading ? t("uploading", L) : t("add_photo", L)}</span>
<input type="file" accept="image/*" capture="environment"
?disabled=${this._photoUploading}
@change=${this._onPhotoInput} />
</label>`}
</div>
${this.adaptiveEnabled ? html`
<div class="feedback-section">
<label class="feedback-label">${t("was_maintenance_needed", L)}</label>
<div class="feedback-buttons">
<button
class="feedback-btn ${this._feedback === "needed" ? "selected" : ""}"
@click=${() => this._setFeedback("needed")}
>${t("feedback_needed", L)}</button>
<button
class="feedback-btn ${this._feedback === "not_needed" ? "selected" : ""}"
@click=${() => this._setFeedback("not_needed")}
>${t("feedback_not_needed", L)}</button>
<button
class="feedback-btn ${this._feedback === "not_sure" ? "selected" : ""}"
@click=${() => this._setFeedback("not_sure")}
>${t("feedback_not_sure", L)}</button>
</div>
</div>
` : nothing}
</div>
<div class="dialog-actions">
<ha-button appearance="plain" @click=${this._close}>
${t("cancel", L)}
</ha-button>
<ha-button
@click=${this._complete}
.disabled=${this._loading}
>
${this._loading ? t("completing", L) : t("complete", L)}
</ha-button>
</div>
</ha-dialog>
`;
}
static styles = css`
.dialog-title {
font-size: 18px;
font-weight: 500;
padding-bottom: 12px;
}
.content {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 300px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
}
.error {
color: var(--error-color, #f44336);
font-size: 13px;
}
.field { display: flex; flex-direction: column; gap: 4px; }
.field-label {
font-size: 12px;
color: var(--secondary-text-color);
}
.field-input {
padding: 8px 10px; font-size: 14px;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color);
border: 1px solid var(--divider-color); border-radius: 6px;
font-family: inherit;
width: 100%; box-sizing: border-box;
}
.field-input:focus {
outline: none;
border-color: var(--primary-color);
}
.photo-pick {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px dashed var(--divider-color);
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--secondary-text-color);
width: fit-content;
}
.photo-pick:hover { border-color: var(--primary-color); }
.photo-pick input[type="file"] { display: none; }
.photo-preview {
position: relative;
width: fit-content;
}
.photo-preview img {
max-width: 160px;
max-height: 160px;
border-radius: 8px;
display: block;
}
.photo-remove {
position: absolute;
top: -8px;
right: -8px;
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
background: var(--error-color, #db4437);
color: #fff;
cursor: pointer;
font-size: 12px;
line-height: 1;
}
.checklist-section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--divider-color);
margin-bottom: 4px;
}
.checklist-label {
font-weight: 500;
font-size: 13px;
color: var(--secondary-text-color);
}
.checklist-item {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 0;
font-size: 14px;
}
.checklist-item input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.feedback-section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
border-top: 1px solid var(--divider-color);
}
.feedback-label {
font-weight: 500;
font-size: 13px;
color: var(--secondary-text-color);
}
.feedback-buttons {
display: flex;
gap: 8px;
}
.feedback-btn {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--divider-color);
border-radius: 8px;
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
font-size: 13px;
cursor: pointer;
text-align: center;
transition: all 0.2s;
}
.feedback-btn:hover {
background: var(--secondary-background-color, #f5f5f5);
}
.feedback-btn.selected {
background: var(--primary-color);
color: var(--text-primary-color, #fff);
border-color: var(--primary-color);
}
`;
}
// Safe registration — avoids duplicate define when both panel and card load
if (!customElements.get("maintenance-complete-dialog")) {
customElements.define("maintenance-complete-dialog", MaintenanceCompleteDialog);
}
@@ -0,0 +1,170 @@
/** Reusable confirmation dialog wrapping <ha-dialog>. */
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant } from "../types";
import { t } from "../styles";
export interface ConfirmOptions {
title: string;
message: string;
confirmText?: string;
danger?: boolean;
}
export interface PromptOptions extends ConfirmOptions {
inputLabel?: string;
inputType?: string; // "text" | "date" etc.
inputValue?: string;
}
export interface PromptResult {
confirmed: boolean;
value: string;
}
export class MaintenanceConfirmDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@state() private _title = "";
@state() private _message = "";
@state() private _confirmText = "";
@state() private _danger = false;
@state() private _inputLabel = "";
@state() private _inputType = "";
@state() private _inputValue = "";
private _resolve: ((value: boolean) => void) | null = null;
private _promptResolve: ((value: PromptResult) => void) | null = null;
public confirm(opts: ConfirmOptions): Promise<boolean> {
this._title = opts.title;
this._message = opts.message;
this._confirmText = opts.confirmText || "OK";
this._danger = opts.danger || false;
this._inputLabel = "";
this._inputType = "";
this._inputValue = "";
this._open = true;
return new Promise<boolean>((resolve) => {
this._resolve = resolve;
this._promptResolve = null;
});
}
public prompt(opts: PromptOptions): Promise<PromptResult> {
this._title = opts.title;
this._message = opts.message;
this._confirmText = opts.confirmText || "OK";
this._danger = opts.danger || false;
this._inputLabel = opts.inputLabel || "";
this._inputType = opts.inputType || "text";
this._inputValue = opts.inputValue || "";
this._open = true;
return new Promise<PromptResult>((resolve) => {
this._promptResolve = resolve;
this._resolve = null;
});
}
private _cancel(): void {
this._open = false;
if (this._promptResolve) {
this._promptResolve({ confirmed: false, value: "" });
this._promptResolve = null;
}
this._resolve?.(false);
this._resolve = null;
}
private _confirmAction(): void {
this._open = false;
if (this._promptResolve) {
this._promptResolve({ confirmed: true, value: this._inputValue });
this._promptResolve = null;
}
this._resolve?.(true);
this._resolve = null;
}
render() {
if (!this._open) return nothing;
const lang = this.hass?.language || "en";
return html`
<ha-dialog open @closed=${this._cancel}>
<div class="dialog-title">${this._title}</div>
<div class="content">
${this._message}
${this._inputLabel ? html`
<!-- Native <input> rather than <ha-textfield>: HA loads
ha-textfield lazily for its own panels, so inside this custom
panel it can be unregistered and render with zero height —
the prompt then shows no field at all (caught live testing
the pause/replace prompts; same fix as complete-dialog). -->
<label class="field">
<span class="field-label">${this._inputLabel}</span>
<input class="field-input"
type="${this._inputType || "text"}"
.value=${this._inputValue}
@input=${(e: Event) => (this._inputValue = (e.target as HTMLInputElement).value)} />
</label>
` : nothing}
</div>
<div class="dialog-actions">
<ha-button appearance="plain" @click=${this._cancel}>
${t("cancel", lang)}
</ha-button>
<ha-button
class="${this._danger ? "danger" : ""}"
@click=${this._confirmAction}
>
${this._confirmText}
</ha-button>
</div>
</ha-dialog>
`;
}
static styles = css`
.dialog-title {
font-size: 18px;
font-weight: 500;
padding-bottom: 12px;
}
.field { display: flex; flex-direction: column; gap: 4px; margin-top: 12px; }
.field-label { font-size: 12px; color: var(--secondary-text-color); }
.field-input {
padding: 8px 10px; font-size: 14px;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color);
border: 1px solid var(--divider-color); border-radius: 6px;
font-family: inherit; width: 100%; box-sizing: border-box;
}
.field-input:focus { outline: none; border-color: var(--primary-color); }
.content {
padding: 8px 0;
min-width: 280px;
line-height: 1.5;
display: flex;
flex-direction: column;
gap: 12px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
}
ha-textfield {
display: block;
}
ha-button.danger {
--mdc-theme-primary: var(--error-color, #f44336);
}
`;
}
if (!customElements.get("maintenance-confirm-dialog")) {
customElements.define("maintenance-confirm-dialog", MaintenanceConfirmDialog);
}
@@ -0,0 +1,643 @@
/** Documents section for an object's detail view.
*
* Self-contained: lists an object's documents (files + web-links), uploads a
* file (multipart POST to the authenticated view — a WS frame can't carry a
* binary body), downloads via a signed path (`auth/sign_path` → Companion-safe
* anchor), adds web-links, and deletes. Write actions are gated by `canWrite`
* (the server enforces it too); download/open is available to everyone.
*/
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 { downloadUrl } from "../helpers/download";
import { formatBytes } from "../helpers/format-bytes";
import type { HomeAssistant } from "../types";
interface MaintenanceDocument {
id: string;
kind: "file" | "weblink";
title: string;
filename?: string;
url?: string;
mime?: string;
size?: number;
tags?: string[];
added_at?: string;
}
const CATEGORIES = ["manual", "warranty", "invoice", "spare_parts", "photo", "other"] as const;
const CATEGORY_ICONS: Record<string, string> = {
manual: "mdi:book-open-variant",
warranty: "mdi:shield-check",
invoice: "mdi:receipt-text-outline",
spare_parts: "mdi:cog-outline",
photo: "mdi:image-outline",
other: "mdi:file-document-outline",
};
export class MaintenanceDocumentsSection extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entryId!: string;
@property({ type: Boolean }) public canWrite = false;
@state() private _docs: MaintenanceDocument[] = [];
@state() private _loaded = false;
@state() private _busy = false;
@state() private _error = "";
@state() private _hint = "";
@state() private _addingLink = false;
@state() private _linkUrl = "";
@state() private _linkTitle = "";
@state() private _category = "manual";
@state() private _thumbs: Record<string, string> = {};
@state() private _lightboxUrl = "";
@state() private _editingId = "";
@state() private _editTitle = "";
@state() private _editCategory = "manual";
@state() private _dragOver = false;
private _loadedFor: string | null = null;
private _localeReady = false;
private _isImage(doc: MaintenanceDocument): boolean {
return doc.kind === "file" && (doc.mime || "").startsWith("image/");
}
private async _sign(doc: MaintenanceDocument): Promise<string> {
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 300,
});
return signed.path;
}
private get _lang(): string {
return this.hass?.language || "en";
}
updated(changed: Map<string, unknown>): void {
super.updated(changed);
if (this.hass && !this._localeReady) {
this._localeReady = true;
// Re-render once the runtime locale JSON arrives (t() falls back to English
// until then), so a first paint before the fetch lands still localizes.
void ensureLocale(this._lang).then(() => this.requestUpdate());
}
if (this.hass && this.entryId && this._loadedFor !== this.entryId) {
this._loadedFor = this.entryId;
void this._load();
}
}
private async _load(): Promise<void> {
try {
const r = await this.hass.connection.sendMessagePromise<{ documents: MaintenanceDocument[] }>({
type: "maintenance_supporter/documents/list",
entry_id: this.entryId,
});
this._docs = r.documents || [];
this._loaded = true;
this._error = "";
this._thumbs = {};
void this._loadThumbs();
} catch (e) {
this._error = describeWsError(e, this._lang);
this._loaded = true;
}
}
/** Pre-sign a serve URL for each image doc so it can render as a thumbnail. */
private async _loadThumbs(): Promise<void> {
await Promise.all(
this._docs.filter((d) => this._isImage(d)).map(async (d) => {
try {
const url = await this._sign(d);
this._thumbs = { ...this._thumbs, [d.id]: url };
} catch {
/* leave the fallback icon */
}
}),
);
}
private _category_of(doc: MaintenanceDocument): string {
const tag = (doc.tags || []).find((x) => (CATEGORIES as readonly string[]).includes(x));
return tag || "other";
}
/** Keyboard support for the file-picker <label>s (Enter/Space → open). */
private _labelKeydown(e: KeyboardEvent): void {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
(e.currentTarget as HTMLElement).querySelector("input")?.click();
}
}
private _onFileInput(e: Event): void {
const input = e.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
if (files.length) void this._uploadFiles(files);
input.value = ""; // let the same file be re-picked
}
private _onCameraInput(e: Event): void {
const input = e.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
if (files.length) void this._uploadFiles(files, "photo");
input.value = "";
}
private _onDrop(e: DragEvent): void {
e.preventDefault();
this._dragOver = false;
if (!this.canWrite || this._busy) return;
const files = Array.from(e.dataTransfer?.files ?? []);
if (files.length) void this._uploadFiles(files);
}
private _onDragOver(e: DragEvent): void {
if (!this.canWrite) return;
e.preventDefault();
this._dragOver = true;
}
private _onDragLeave(e: DragEvent): void {
// Only clear when the pointer truly leaves the zone (not when moving over a
// child element), otherwise the overlay flickers.
const rt = e.relatedTarget as Node | null;
if (!rt || !(e.currentTarget as HTMLElement).contains(rt)) this._dragOver = false;
}
private async _uploadFiles(files: File[], category?: string): Promise<void> {
const cat = category ?? this._category;
this._busy = true;
this._error = "";
this._hint = "";
let deduped = 0;
let dupInObject = 0;
try {
for (const file of files) {
const form = new FormData();
form.append("entry_id", this.entryId);
form.append("tags", cat);
form.append("file", file, file.name);
const resp = await fetch("/api/maintenance_supporter/document/upload", {
method: "POST",
headers: { Authorization: `Bearer ${this.hass.auth?.data?.access_token ?? ""}` },
body: form,
});
if (!resp.ok) {
this._error = resp.status === 413 ? t("doc_too_large", this._lang) : t("doc_upload_failed", this._lang);
continue;
}
const doc = (await resp.json()) as { deduped?: boolean; duplicate_in_object?: string | null };
if (doc.duplicate_in_object) dupInObject++;
else if (doc.deduped) deduped++;
}
if (dupInObject) this._hint = t("doc_dup_in_object", this._lang);
else if (deduped) this._hint = t("doc_deduped", this._lang);
await this._load();
} catch {
this._error = t("doc_upload_failed", this._lang);
} finally {
this._busy = false;
}
}
private async _download(doc: MaintenanceDocument): Promise<void> {
try {
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 30,
});
downloadUrl(signed.path, doc.filename || doc.title || "document");
} catch (e) {
this._error = describeWsError(e, this._lang);
}
}
/** Open a document for viewing: images in an in-app lightbox, everything else
* inline in a new tab (the serve view sends Content-Disposition: inline). */
private async _preview(doc: MaintenanceDocument): Promise<void> {
if (this._isImage(doc)) {
this._lightboxUrl = this._thumbs[doc.id] || (await this._sign(doc));
return;
}
// Open the tab synchronously (in the click gesture) so it isn't popup-blocked,
// then point it at the freshly signed URL once it resolves.
const win = window.open("about:blank", "_blank");
try {
const url = await this._sign(doc);
// Absolute URL so it always resolves against the blank popup (about:blank).
if (win) win.location.href = new URL(url, window.location.origin).href;
} catch (e) {
if (win) win.close();
this._error = describeWsError(e, this._lang);
}
}
/** Open a document from a title/row click (not just the small icons): preview
* a file, open a web-link in a new tab. */
private _openDoc(doc: MaintenanceDocument): void {
if (doc.kind === "file") void this._preview(doc);
// Only open http(s) links — never a javascript:/data: URL (the same scheme
// guard the rest of the panel applies before opening user-supplied URLs).
else if (doc.url && /^https?:\/\//i.test(doc.url)) window.open(doc.url, "_blank", "noopener");
}
private _startEdit(doc: MaintenanceDocument): void {
this._editingId = doc.id;
this._editTitle = doc.title || "";
this._editCategory = this._category_of(doc);
this._addingLink = false;
this._error = "";
}
private _cancelEdit(): void {
this._editingId = "";
}
private async _saveEdit(doc: MaintenanceDocument): Promise<void> {
// Keep any free (non-category) tags; swap in the chosen category.
const freeTags = (doc.tags || []).filter(
(x) => !(CATEGORIES as readonly string[]).includes(x),
);
const tags = doc.kind === "file" ? [this._editCategory, ...freeTags] : (doc.tags ?? []);
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/documents/update",
doc_id: doc.id,
title: this._editTitle.trim() || doc.filename || doc.url || "",
tags,
});
this._editingId = "";
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
}
private async _delete(doc: MaintenanceDocument): Promise<void> {
const name = doc.title || doc.filename || doc.url || "";
if (!window.confirm(t("doc_delete_confirm", this._lang).replace("{name}", name))) return;
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/documents/delete",
doc_id: doc.id,
});
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
}
private async _addLink(): Promise<void> {
const url = this._linkUrl.trim();
if (!url) return;
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/documents/add_link",
entry_id: this.entryId,
url,
title: this._linkTitle.trim() || null,
});
this._linkUrl = "";
this._linkTitle = "";
this._addingLink = false;
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang, t("doc_link_invalid", this._lang));
} finally {
this._busy = false;
}
}
render() {
const L = this._lang;
return html`
<div
class="doc-zone ${this._dragOver ? "drag-over" : ""}"
@dragover=${this._onDragOver}
@dragleave=${this._onDragLeave}
@drop=${this._onDrop}
>
${this._dragOver && this.canWrite
? html`<div class="drop-overlay">
<ha-icon icon="mdi:tray-arrow-down"></ha-icon> ${t("doc_drop_hint", L)}
</div>`
: nothing}
<div class="doc-header">
<h3>${t("documents", L)} (${this._docs.length})</h3>
${this.canWrite
? html`
<div class="doc-actions">
<select
class="cat-select"
.value=${this._category}
?disabled=${this._busy}
@change=${(e: Event) => (this._category = (e.target as HTMLSelectElement).value)}
>
${CATEGORIES.map((c) => html`<option value=${c}>${t(`doc_cat_${c}`, L)}</option>`)}
</select>
<label
class="btn primary ${this._busy ? "disabled" : ""}"
role="button"
tabindex="0"
@keydown=${this._labelKeydown}
>
<ha-icon icon="mdi:upload"></ha-icon>
${this._busy ? t("doc_uploading", L) : t("doc_upload", L)}
<input type="file" multiple hidden ?disabled=${this._busy} @change=${this._onFileInput} />
</label>
<label
class="btn camera-btn ${this._busy ? "disabled" : ""}"
role="button"
tabindex="0"
aria-label=${t("doc_camera", L)}
title=${t("doc_camera", L)}
@keydown=${this._labelKeydown}
>
<ha-icon icon="mdi:camera"></ha-icon>
<input type="file" accept="image/*" capture="environment" hidden ?disabled=${this._busy} @change=${this._onCameraInput} />
</label>
<button class="btn" ?disabled=${this._busy} @click=${() => (this._addingLink = !this._addingLink)}>
<ha-icon icon="mdi:link-variant"></ha-icon> ${t("doc_add_link", L)}
</button>
</div>
`
: nothing}
</div>
${this._error ? html`<div class="doc-msg error">${this._error}</div>` : nothing}
${this._hint ? html`<div class="doc-msg hint">${this._hint}</div>` : nothing}
${this._addingLink && this.canWrite
? html`
<div class="link-form">
<input
type="url"
placeholder=${t("doc_link_url", L)}
.value=${this._linkUrl}
?disabled=${this._busy}
@input=${(e: Event) => (this._linkUrl = (e.target as HTMLInputElement).value)}
/>
<input
type="text"
placeholder=${t("doc_link_title", L)}
.value=${this._linkTitle}
?disabled=${this._busy}
@input=${(e: Event) => (this._linkTitle = (e.target as HTMLInputElement).value)}
/>
<button class="btn primary" ?disabled=${this._busy || !this._linkUrl.trim()} @click=${this._addLink}>
${t("add", L)}
</button>
<button class="btn" ?disabled=${this._busy} @click=${() => (this._addingLink = false)}>
${t("cancel", L)}
</button>
</div>
`
: nothing}
${!this._loaded
? html`<div class="doc-empty">${t("loading", L)}</div>`
: this._docs.length === 0
? html`<div class="doc-empty">${t("documents_empty", L)}</div>`
: html`
<div class="doc-list">
${this._docs.map((doc) => this._renderDoc(doc, L))}
</div>
`}
${this._lightboxUrl
? html`<div class="lightbox" @click=${() => (this._lightboxUrl = "")}>
<img class="lightbox-img" src=${this._lightboxUrl} @click=${(e: Event) => e.stopPropagation()} />
<button class="lightbox-close" title=${t("doc_close", L)} @click=${() => (this._lightboxUrl = "")}>
<ha-icon icon="mdi:close"></ha-icon>
</button>
</div>`
: nothing}
</div>
`;
}
private _renderDoc(doc: MaintenanceDocument, L: string) {
if (this._editingId === doc.id) return this._renderEdit(doc, L);
const isFile = doc.kind === "file";
const cat = this._category_of(doc);
const meta = isFile
? `${t(`doc_cat_${cat}`, L)} · ${formatBytes(doc.size)}`
: t("doc_link_badge", L);
const thumb = this._thumbs[doc.id];
return html`
<div class="doc-row">
${isFile && thumb
? html`<img
class="doc-thumb"
src=${thumb}
alt=${doc.title || ""}
title=${t("doc_open", L)}
@click=${() => this._preview(doc)}
/>`
: html`<ha-icon
class="doc-icon ${isFile ? "clickable" : ""}"
icon=${isFile ? CATEGORY_ICONS[cat] : "mdi:link-variant"}
@click=${() => isFile && this._preview(doc)}
></ha-icon>`}
<div
class="doc-info"
role="button"
tabindex="0"
title=${t("doc_open", L)}
@click=${() => this._openDoc(doc)}
@keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
this._openDoc(doc);
}
}}
>
<div class="doc-title">${doc.title || doc.filename || doc.url}</div>
<div class="doc-meta">${meta}</div>
</div>
<div class="doc-row-actions">
${isFile
? html`
<button class="icon-btn" title=${t("doc_open", L)} @click=${() => this._preview(doc)}>
<ha-icon icon="mdi:eye-outline"></ha-icon>
</button>
<button class="icon-btn" title=${t("doc_download", L)} @click=${() => this._download(doc)}>
<ha-icon icon="mdi:download"></ha-icon>
</button>`
: html`<a
class="icon-btn"
href=${doc.url && /^https?:\/\//i.test(doc.url) ? doc.url : "#"}
target="_blank"
rel="noopener noreferrer"
title=${t("doc_open", L)}
><ha-icon icon="mdi:open-in-new"></ha-icon></a>`}
${this.canWrite
? html`
<button class="icon-btn" title=${t("edit", L)} ?disabled=${this._busy} @click=${() => this._startEdit(doc)}>
<ha-icon icon="mdi:pencil"></ha-icon>
</button>
<button class="icon-btn danger" title=${t("delete", L)} ?disabled=${this._busy} @click=${() => this._delete(doc)}>
<ha-icon icon="mdi:delete"></ha-icon>
</button>`
: nothing}
</div>
</div>
`;
}
private _renderEdit(doc: MaintenanceDocument, L: string) {
const isFile = doc.kind === "file";
return html`
<div class="doc-row editing">
<input
class="edit-title"
type="text"
placeholder=${t("doc_link_title", L)}
.value=${this._editTitle}
?disabled=${this._busy}
@input=${(e: Event) => (this._editTitle = (e.target as HTMLInputElement).value)}
/>
${isFile
? html`<select
class="cat-select"
?disabled=${this._busy}
@change=${(e: Event) => (this._editCategory = (e.target as HTMLSelectElement).value)}
>
${CATEGORIES.map(
(c) => html`<option value=${c} ?selected=${c === this._editCategory}>${t(`doc_cat_${c}`, L)}</option>`,
)}
</select>`
: nothing}
<button class="icon-btn" title=${t("save", L)} ?disabled=${this._busy || !this._editTitle.trim()} @click=${() => this._saveEdit(doc)}>
<ha-icon icon="mdi:check"></ha-icon>
</button>
<button class="icon-btn" title=${t("cancel", L)} ?disabled=${this._busy} @click=${this._cancelEdit}>
<ha-icon icon="mdi:close"></ha-icon>
</button>
</div>
`;
}
static styles = css`
:host { display: block; margin: 8px 0 4px; }
.doc-zone { position: relative; }
.doc-zone.drag-over {
outline: 2px dashed var(--primary-color); outline-offset: 4px; border-radius: 8px;
}
.drop-overlay {
position: absolute; inset: 0; z-index: 5; pointer-events: none;
display: flex; align-items: center; justify-content: center; gap: 8px;
border-radius: 8px; font-size: 15px; font-weight: 600;
color: var(--primary-color); opacity: 0.95;
background: var(--card-background-color, rgba(255, 255, 255, 0.85));
}
.drop-overlay ha-icon { --mdc-icon-size: 24px; }
.camera-btn { padding: 6px 10px; }
.doc-header {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; flex-wrap: wrap;
}
h3 { margin: 8px 0; font-size: 16px; }
.doc-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.cat-select {
padding: 6px 8px; border-radius: 6px; font: inherit;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color); border: 1px solid var(--divider-color);
}
.btn {
display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
padding: 6px 12px; border-radius: 6px; font: inherit; font-size: 13px;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color); border: 1px solid var(--divider-color);
}
.btn.primary { background: var(--primary-color); color: var(--text-primary-color, #fff); border-color: var(--primary-color); }
.btn:focus-visible, .icon-btn:focus-visible {
outline: 2px solid var(--primary-color); outline-offset: 2px;
}
.btn.disabled, .btn[disabled] { opacity: 0.5; pointer-events: none; }
.btn ha-icon { --mdc-icon-size: 18px; }
.link-form { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
.link-form input {
flex: 1 1 180px; padding: 6px 10px; border-radius: 6px; font: inherit;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color); border: 1px solid var(--divider-color);
}
.doc-msg { font-size: 13px; margin: 6px 0; }
.doc-msg.error { color: var(--error-color, #f44336); }
.doc-msg.hint { color: var(--secondary-text-color, #888); }
.doc-empty { color: var(--secondary-text-color, #888); font-size: 13px; padding: 8px 0; }
.doc-list { display: flex; flex-direction: column; gap: 4px; }
.doc-row {
display: flex; align-items: center; gap: 12px; padding: 8px 10px;
border: 1px solid var(--divider-color); border-radius: 8px;
background: var(--card-background-color, transparent);
}
.doc-row.editing { gap: 8px; }
.edit-title {
flex: 1; min-width: 0; padding: 6px 10px; border-radius: 6px; font: inherit;
background: var(--secondary-background-color, rgba(0, 0, 0, 0.06));
color: var(--primary-text-color); border: 1px solid var(--divider-color);
}
.doc-icon { color: var(--primary-color); --mdc-icon-size: 24px; flex: none; }
.doc-icon.clickable { cursor: pointer; }
.doc-thumb {
width: 40px; height: 40px; object-fit: cover; border-radius: 6px; flex: none;
cursor: pointer; border: 1px solid var(--divider-color);
background: var(--secondary-background-color, rgba(0,0,0,0.06));
}
.lightbox {
position: fixed; inset: 0; z-index: 9999; cursor: zoom-out;
display: flex; align-items: center; justify-content: center;
background: rgba(0, 0, 0, 0.85);
}
.lightbox-img {
max-width: 92vw; max-height: 92vh; object-fit: contain; cursor: default;
border-radius: 8px; box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
}
.lightbox-close {
position: fixed; top: 16px; right: 16px; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
width: 44px; height: 44px; border-radius: 50%; border: none;
background: rgba(0, 0, 0, 0.5); color: #fff;
}
.lightbox-close ha-icon { --mdc-icon-size: 26px; }
.doc-info { flex: 1; min-width: 0; cursor: pointer; border-radius: 6px; }
.doc-info:hover .doc-title { text-decoration: underline; }
.doc-info:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
.doc-title { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.doc-meta { font-size: 12px; color: var(--secondary-text-color, #888); }
.doc-row-actions { display: flex; gap: 4px; flex: none; }
.icon-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 34px; height: 34px; border-radius: 8px; cursor: pointer;
background: transparent; border: none; color: var(--primary-text-color);
text-decoration: none;
}
.icon-btn:hover { background: var(--secondary-background-color, rgba(0,0,0,0.06)); }
.icon-btn.danger { color: var(--error-color, #f44336); }
.icon-btn[disabled] { opacity: 0.4; pointer-events: none; }
.icon-btn ha-icon { --mdc-icon-size: 20px; }
`;
}
if (!customElements.get("maintenance-documents-section")) {
customElements.define("maintenance-documents-section", MaintenanceDocumentsSection);
}
@@ -0,0 +1,244 @@
/** Dialog for creating/editing a maintenance group. */
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t } from "../styles";
import { describeWsError } from "../ws-errors";
import "./ms-textfield";
import type {
GroupTaskRef,
HomeAssistant,
MaintenanceGroup,
MaintenanceObjectResponse,
} from "../types";
export class MaintenanceGroupDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public objects: MaintenanceObjectResponse[] = [];
@state() private _open = false;
@state() private _loading = false;
@state() private _error = "";
@state() private _groupId: string | null = null; // null = create
@state() private _name = "";
@state() private _description = "";
@state() private _selected: Set<string> = new Set(); // "entry_id:task_id"
private get _lang(): string {
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en";
}
public openCreate(): void {
this._reset();
this._open = true;
}
public openEdit(groupId: string, group: MaintenanceGroup): void {
this._reset();
this._groupId = groupId;
this._name = group.name;
this._description = group.description || "";
this._selected = new Set(group.task_refs.map((r) => `${r.entry_id}:${r.task_id}`));
this._open = true;
}
private _reset(): void {
this._groupId = null;
this._name = "";
this._description = "";
this._selected = new Set();
this._error = "";
}
private _close(): void {
this._open = false;
}
private _toggleTask = (entryId: string, taskId: string): void => {
const key = `${entryId}:${taskId}`;
const next = new Set(this._selected);
if (next.has(key)) next.delete(key);
else next.add(key);
this._selected = next;
};
private _buildTaskRefs(): GroupTaskRef[] {
return [...this._selected].map((k) => {
const [entry_id, task_id] = k.split(":", 2);
return { entry_id, task_id };
});
}
private _save = async (): Promise<void> => {
const name = this._name.trim();
if (!name) {
this._error = t("group_name_required", this._lang);
return;
}
this._loading = true;
this._error = "";
try {
const task_refs = this._buildTaskRefs();
if (this._groupId) {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/group/update",
group_id: this._groupId,
name,
description: this._description,
task_refs,
});
} else {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/group/create",
name,
description: this._description,
task_refs,
});
}
this._open = false;
this.dispatchEvent(new CustomEvent("group-saved"));
} catch (e) {
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
} finally {
this._loading = false;
}
};
render() {
if (!this._open) return html``;
const L = this._lang;
const title = this._groupId ? t("edit_group", L) : t("new_group", L);
return html`
<ha-dialog open @closed=${this._close} heading="${title}">
<div class="content">
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
<ms-textfield
label="${t("name", L)}"
required
.value=${this._name}
@input=${(e: Event) => (this._name = (e.target as HTMLInputElement).value)}
></ms-textfield>
<ms-textfield
label="${t("description_optional", L)}"
.value=${this._description}
@input=${(e: Event) => (this._description = (e.target as HTMLInputElement).value)}
></ms-textfield>
<div class="section-title">${t("group_select_tasks", L)}</div>
${this.objects.length === 0
? html`<div class="hint">${t("no_objects", L)}</div>`
: html`
<div class="objects">
${[...this.objects]
.sort((a, b) => a.object.name.localeCompare(b.object.name))
.map((obj) => html`
<div class="object-block">
<div class="object-name">${obj.object.name}</div>
${obj.tasks.length === 0
? html`<div class="hint small">${t("no_tasks_short", L)}</div>`
: [...obj.tasks]
.sort((a, b) => a.name.localeCompare(b.name))
.map((task) => {
const key = `${obj.entry_id}:${task.id}`;
const checked = this._selected.has(key);
return html`
<label class="task-row">
<input type="checkbox"
.checked=${checked}
@change=${() => this._toggleTask(obj.entry_id, task.id)} />
<span>${task.name}</span>
</label>
`;
})}
</div>
`)}
</div>
`}
<div class="selected-count">
${t("selected", L)}: ${this._selected.size}
</div>
</div>
<div class="dialog-actions">
<ha-button appearance="plain" @click=${this._close}>
${t("cancel", L)}
</ha-button>
<ha-button @click=${this._save} .disabled=${this._loading || !this._name.trim()}>
${this._loading ? t("saving", L) : t("save", L)}
</ha-button>
</div>
</ha-dialog>
`;
}
static styles = css`
.content {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 360px;
max-width: 520px;
max-height: 60vh;
overflow-y: auto;
}
@media (max-width: 600px) {
.content {
min-width: 0;
max-width: none;
max-height: none;
}
}
ha-textfield { display: block; }
.error {
color: var(--error-color, #f44336);
font-size: 13px;
}
.section-title {
font-size: 14px;
font-weight: 500;
margin-top: 8px;
padding-bottom: 4px;
border-bottom: 1px solid var(--divider-color);
}
.hint {
color: var(--secondary-text-color);
font-size: 13px;
}
.hint.small { font-size: 12px; padding-left: 12px; }
.objects { display: flex; flex-direction: column; gap: 8px; }
.object-block {
border: 1px solid var(--divider-color);
border-radius: 6px;
padding: 8px;
}
.object-name {
font-weight: 500;
font-size: 13px;
margin-bottom: 4px;
}
.task-row {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 0;
font-size: 13px;
cursor: pointer;
}
.task-row input { cursor: pointer; }
.selected-count {
font-size: 12px;
color: var(--secondary-text-color);
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
}
`;
}
if (!customElements.get("maintenance-group-dialog")) {
customElements.define("maintenance-group-dialog", MaintenanceGroupDialog);
}
@@ -0,0 +1,340 @@
/** 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,
});
@@ -0,0 +1,259 @@
/** Dialog for editing an existing history entry (timestamp / notes / cost /
* duration / completed_by). Backed by maintenance_supporter/task/history/update.
*
* Opened from:
* - Task detail page → history tab → Edit button per entry
* - Calendar past-window event click (via ll-custom dispatch from
* maintenance-supporter-calendar-card)
*/
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t } from "../styles";
import type { HomeAssistant } from "../types";
import { describeWsError } from "../ws-errors";
export interface HistoryEntryDraft {
entry_id: string;
task_id: string;
original_timestamp: string; // identifies the entry on save
type: string; // for display only — read-only
timestamp: string;
notes: string | null;
cost: number | null;
duration: number | null;
completed_by: string | null;
}
export class MaintenanceHistoryEditDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@state() private _saving = false;
@state() private _error = "";
@state() private _draft: HistoryEntryDraft | null = null;
// Original snapshot so we can detect "no change" and skip the WS call
private _originalSnapshot: HistoryEntryDraft | null = null;
private get _lang(): string {
return this.hass?.language || "en";
}
/** Open the dialog with the given history-entry data. The caller must
* pass `original_timestamp` (the entry's current timestamp before edit)
* so the backend can find the entry. */
public openEdit(draft: HistoryEntryDraft): void {
this._draft = { ...draft };
this._originalSnapshot = { ...draft };
this._error = "";
this._open = true;
}
public close(): void {
this._open = false;
this._error = "";
this._draft = null;
this._originalSnapshot = null;
}
private _set<K extends keyof HistoryEntryDraft>(
key: K, value: HistoryEntryDraft[K],
): void {
if (!this._draft) return;
this._draft = { ...this._draft, [key]: value };
}
private async _save(): Promise<void> {
if (!this._draft || !this._originalSnapshot) return;
this._saving = true;
this._error = "";
try {
const patch: Record<string, unknown> = {
type: "maintenance_supporter/task/history/update",
entry_id: this._draft.entry_id,
task_id: this._draft.task_id,
original_timestamp: this._originalSnapshot.original_timestamp,
};
// Only send fields that actually changed — keeps the patch minimal
// and the WS schema happy (it treats missing as "no change").
if (this._draft.timestamp !== this._originalSnapshot.timestamp) {
patch.timestamp = this._draft.timestamp;
}
if (this._draft.notes !== this._originalSnapshot.notes) {
patch.notes = this._draft.notes;
}
if (this._draft.cost !== this._originalSnapshot.cost) {
patch.cost = this._draft.cost;
}
if (this._draft.duration !== this._originalSnapshot.duration) {
patch.duration = this._draft.duration;
}
if (this._draft.completed_by !== this._originalSnapshot.completed_by) {
patch.completed_by = this._draft.completed_by;
}
// Nothing changed → close without WS call
const changedKeys = Object.keys(patch).filter(
(k) => !["type", "entry_id", "task_id", "original_timestamp"].includes(k),
);
if (changedKeys.length === 0) {
this.close();
return;
}
await this.hass.connection.sendMessagePromise(patch);
// Notify upstream so they can refresh
this.dispatchEvent(
new CustomEvent("history-entry-saved", {
detail: {
entry_id: this._draft.entry_id,
task_id: this._draft.task_id,
new_timestamp: this._draft.timestamp,
},
bubbles: true,
composed: true,
}),
);
this.close();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._saving = false;
}
}
render() {
if (!this._open || !this._draft) return nothing;
const L = this._lang;
const d = this._draft;
return html`
<div class="backdrop" @click=${this.close}></div>
<div class="dialog" role="dialog" aria-modal="true">
<h2>${t("history_edit_title", L) || "Edit history entry"}</h2>
<div class="entry-type">
<ha-icon icon="mdi:tag-outline"></ha-icon>
<span>${t(d.type, L) || d.type}</span>
</div>
<label>
<span>${t("history_edit_timestamp", L) || "Timestamp"}</span>
<input type="datetime-local"
.value=${d.timestamp.length >= 16 ? d.timestamp.slice(0, 16) : d.timestamp}
@change=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
// Re-add seconds if input drops them
this._set("timestamp", v.length === 16 ? `${v}:00` : v);
}} />
</label>
<label>
<span>${t("notes_label", L)}</span>
<textarea
rows="3"
@input=${(e: Event) => {
const v = (e.target as HTMLTextAreaElement).value;
this._set("notes", v ? v : null);
}}
.value=${d.notes ?? ""}></textarea>
</label>
<div class="row">
<label>
<span>${t("cost", L) || "Cost"}</span>
<input type="number" min="0" step="0.01"
.value=${d.cost != null ? String(d.cost) : ""}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
this._set("cost", v ? Number(v) : null);
}} />
</label>
<label>
<span>${t("duration", L) || "Duration (min)"}</span>
<input type="number" min="0"
.value=${d.duration != null ? String(d.duration) : ""}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
this._set("duration", v ? Number(v) : null);
}} />
</label>
</div>
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
<div class="actions">
<button class="cancel" @click=${this.close} ?disabled=${this._saving}>
${t("cancel", L) || "Cancel"}
</button>
<button class="save" @click=${this._save} ?disabled=${this._saving}>
${this._saving ? (t("saving", L) || "Saving…") : (t("save", L) || "Save")}
</button>
</div>
</div>
`;
}
static styles = css`
:host { display: contents; }
.backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.5);
z-index: 100;
}
.dialog {
position: fixed; left: 50%; top: 50%;
transform: translate(-50%, -50%);
width: 95vw; max-width: 480px;
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;
display: flex; flex-direction: column; gap: 12px;
z-index: 101;
max-height: 90vh; overflow: auto;
}
h2 { margin: 0; font-size: 18px; }
.entry-type {
display: flex; align-items: center; gap: 6px;
color: var(--secondary-text-color); font-size: 13px;
}
label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
label span { color: var(--secondary-text-color); }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
input, textarea {
padding: 8px; font-size: 14px;
background: var(--secondary-background-color, #2c2c2c);
color: var(--primary-text-color);
border: 1px solid var(--divider-color, #444);
border-radius: 6px;
width: 100%; box-sizing: border-box;
font-family: inherit;
}
.actions {
display: flex; gap: 8px; justify-content: flex-end;
margin-top: 8px;
}
button {
padding: 8px 16px; font-size: 14px;
border-radius: 6px; cursor: pointer;
border: none; font-weight: 500;
}
button.cancel {
background: transparent;
color: var(--primary-text-color);
border: 1px solid var(--divider-color);
}
button.save {
background: var(--primary-color);
color: var(--text-primary-color, white);
}
button[disabled] { opacity: 0.5; cursor: wait; }
.error {
color: var(--error-color, #d32f2f);
font-size: 13px; padding: 8px;
background: rgba(211,47,47,0.1);
border-radius: 6px;
}
`;
}
if (!customElements.get("maintenance-history-edit-dialog")) {
customElements.define(
"maintenance-history-edit-dialog",
MaintenanceHistoryEditDialog,
);
}
@@ -0,0 +1,74 @@
/** Thumbnail for a completion photo in the task history timeline.
*
* The photo is a DocumentStore file (tagged "photo"); its bytes are served,
* auth-gated, at /api/maintenance_supporter/document/<doc_id>. An <img> can't
* send the auth header, so we mint a short-lived signed path via auth/sign_path
* (the same Companion-safe pattern documents-section uses) and point the <img>
* at that. Clicking opens the full image in a new tab.
*/
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant } from "../types";
export class MaintenanceHistoryPhoto extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public docId = "";
@state() private _url = "";
@state() private _failed = false;
private _signedFor = "";
updated(): void {
if (this.hass && this.docId && this._signedFor !== this.docId) {
this._signedFor = this.docId;
this._url = "";
this._failed = false;
void this._sign();
}
}
private async _sign(): Promise<void> {
try {
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${this.docId}`,
expires: 300,
});
this._url = signed.path;
} catch {
this._failed = true;
}
}
render() {
if (this._failed || !this.docId) return nothing;
if (!this._url) return html`<div class="ph"></div>`;
return html`
<a href=${this._url} target="_blank" rel="noopener" class="wrap">
<img src=${this._url} alt="" loading="lazy"
@error=${() => (this._failed = true)} />
</a>`;
}
static styles = css`
.wrap { display: inline-block; margin-top: 4px; }
img {
max-width: 96px;
max-height: 96px;
border-radius: 6px;
display: block;
border: 1px solid var(--divider-color);
}
.ph {
width: 96px;
height: 64px;
border-radius: 6px;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
margin-top: 4px;
}
`;
}
if (!customElements.get("maintenance-history-photo")) {
customElements.define("maintenance-history-photo", MaintenanceHistoryPhoto);
}
@@ -0,0 +1,116 @@
/** ms-textfield — drop-in for `<ha-textfield>` that always renders.
*
* Background: HA's `<ha-textfield>` is lazy-loaded by HA's frontend on first
* use. In contexts where HA hasn't yet imported it (custom panels mounted
* via panel_custom; Lovelace dialogs mounted via dialog-mount onto
* document.body), `customElements.get("ha-textfield")` returns undefined,
* the element renders as HTMLUnknownElement with `offsetHeight: 0`, and
* the user sees an apparently empty form with only the label visible.
*
* Reported manifestations:
* - Issue #50: complete-dialog notes/cost/duration invisible
* - Issue #50 follow-up: task-dialog target-entity invisible
* - Issue #46 follow-up: object-dialog name/manufacturer/model/serial/url/
* notes invisible (only ha-area-picker rendered, hence user could
* "only change the area")
*
* This wrapper uses a native `<input>` element styled to match HA's
* appearance. Same API surface as ha-textfield: `label`, `value`, `type`,
* `placeholder`, `required`, `step`, `min`, `max`, `pattern`. Fires a
* standard `input` event with `event.target.value` so existing handlers
* work unchanged.
*
* The CSS uses HA's CSS custom properties so the input visually matches
* the surrounding HA UI in both light and dark themes.
*/
import { LitElement, html, css, nothing } from "lit";
import { property } from "lit/decorators.js";
export class MsTextfield extends LitElement {
@property() public label = "";
@property() public value = "";
@property() public placeholder = "";
@property() public type: "text" | "number" | "url" | "email" | "password" | "date" | "time" | "datetime-local" = "text";
@property({ type: Boolean }) public required = false;
@property({ type: Boolean }) public disabled = false;
@property() public step?: string;
@property() public min?: string;
@property() public max?: string;
@property() public pattern?: string;
@property() public helper?: string;
/** Forwards the native input's value into our `value` property and
* re-fires as a bubbling `input` event so consumers reading
* `(e.target as HTMLInputElement).value` still work — that's the
* pattern used everywhere ha-textfield was. */
private _onInput(e: Event): void {
const v = (e.target as HTMLInputElement).value;
this.value = v;
this.dispatchEvent(
new CustomEvent("input", { bubbles: true, composed: true, detail: { value: v } }),
);
}
render() {
return html`
<label class="field">
${this.label ? html`<span class="label">${this.label}${this.required ? html`<span class="req">*</span>` : nothing}</span>` : nothing}
<input
.value=${this.value ?? ""}
.type=${this.type}
?required=${this.required}
?disabled=${this.disabled}
placeholder=${this.placeholder}
step=${this.step ?? nothing}
min=${this.min ?? nothing}
max=${this.max ?? nothing}
pattern=${this.pattern ?? nothing}
@input=${this._onInput}
@change=${this._onInput}
/>
${this.helper ? html`<span class="helper">${this.helper}</span>` : nothing}
</label>
`;
}
static styles = css`
:host { display: block; }
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.label {
font-size: 12px;
color: var(--secondary-text-color, #888);
font-weight: 500;
}
.req { color: var(--error-color, #f44336); margin-left: 2px; }
input {
padding: 8px 10px;
font-size: 14px;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color);
border: 1px solid var(--divider-color, rgba(255,255,255,0.12));
border-radius: 6px;
font-family: inherit;
width: 100%;
box-sizing: border-box;
outline: none;
}
input:focus {
border-color: var(--primary-color);
}
input:disabled { opacity: 0.5; cursor: not-allowed; }
.helper {
font-size: 11px;
color: var(--secondary-text-color);
font-style: italic;
}
`;
}
if (!customElements.get("ms-textfield")) {
customElements.define("ms-textfield", MsTextfield);
}
@@ -0,0 +1,289 @@
/** Dialog for creating/editing a maintenance object. */
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant, MaintenanceObject, MaintenanceObjectResponse } from "../types";
import { t } from "../styles";
import { describeWsError } from "../ws-errors";
import "./ms-textfield";
export class MaintenanceObjectDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
/** All objects — choices for the parent-object picker (2.19). */
@property({ attribute: false }) public objects: MaintenanceObjectResponse[] = [];
@state() private _open = false;
@state() private _loading = false;
@state() private _error = "";
@state() private _name = "";
@state() private _manufacturer = "";
@state() private _model = "";
@state() private _serialNumber = "";
@state() private _areaId = "";
@state() private _installationDate = "";
// (#67): per-object warranty expiry date
@state() private _warrantyExpiry = "";
// v1.4.0 (#43): per-object link to PDF manual / vendor page
@state() private _documentationUrl = "";
// v1.4.10 (#46): free-form notes (multiline)
@state() private _notes = "";
// 2.19: attach to an existing HA device / nest under another object
@state() private _haDeviceId = "";
@state() private _parentEntryId = "";
@state() private _entryId: string | null = null; // null = create, string = update
private get _lang(): string {
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en";
}
public openCreate(): void {
this._entryId = null;
this._name = "";
this._manufacturer = "";
this._model = "";
this._serialNumber = "";
this._areaId = "";
this._installationDate = "";
this._warrantyExpiry = "";
this._documentationUrl = "";
this._notes = "";
this._haDeviceId = "";
this._parentEntryId = "";
this._error = "";
this._open = true;
}
public openEdit(entryId: string, obj: MaintenanceObject): void {
this._entryId = entryId;
this._name = obj.name || "";
this._manufacturer = obj.manufacturer || "";
this._model = obj.model || "";
this._serialNumber = obj.serial_number || "";
this._areaId = obj.area_id || "";
this._installationDate = obj.installation_date || "";
this._warrantyExpiry = obj.warranty_expiry || "";
this._documentationUrl = obj.documentation_url || "";
this._notes = obj.notes || "";
this._haDeviceId = obj.ha_device_id || "";
this._parentEntryId = obj.parent_entry_id || "";
this._error = "";
this._open = true;
}
private async _save(): Promise<void> {
if (this._loading) return; // synchronous re-entry guard (double-click)
if (!this._name.trim()) return;
this._loading = true;
this._error = "";
try {
if (this._entryId) {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/object/update",
entry_id: this._entryId,
name: this._name,
manufacturer: this._manufacturer || null,
model: this._model || null,
serial_number: this._serialNumber || null,
area_id: this._areaId || null,
installation_date: this._installationDate || null,
warranty_expiry: this._warrantyExpiry || null,
documentation_url: this._documentationUrl.trim() || null,
notes: this._notes.trim() || null,
ha_device_id: this._haDeviceId || null,
parent_entry_id: this._parentEntryId || null,
});
} else {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/object/create",
name: this._name,
manufacturer: this._manufacturer || null,
model: this._model || null,
serial_number: this._serialNumber || null,
area_id: this._areaId || null,
installation_date: this._installationDate || null,
warranty_expiry: this._warrantyExpiry || null,
documentation_url: this._documentationUrl.trim() || null,
notes: this._notes.trim() || null,
ha_device_id: this._haDeviceId || null,
parent_entry_id: this._parentEntryId || null,
});
}
this._open = false;
this.dispatchEvent(new CustomEvent("object-saved"));
} catch (e) {
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
} finally {
this._loading = false;
}
}
private _parentChoices(): MaintenanceObjectResponse[] {
return (this.objects || []).filter((o) => o.entry_id !== this._entryId);
}
private _close(): void {
this._open = false;
}
render() {
if (!this._open) return html``;
const L = this._lang;
const title = this._entryId ? t("edit_object", L) : t("new_object", L);
return html`
<ha-dialog open @closed=${this._close}>
<div class="dialog-title">${title}</div>
<div class="content">
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
<ms-textfield
label="${t("name", L)}"
required
.value=${this._name}
@input=${(e: Event) => (this._name = (e.target as HTMLInputElement).value)}
></ms-textfield>
<ms-textfield
label="${t("manufacturer_optional", L)}"
.value=${this._manufacturer}
@input=${(e: Event) => (this._manufacturer = (e.target as HTMLInputElement).value)}
></ms-textfield>
<ms-textfield
label="${t("model_optional", L)}"
.value=${this._model}
@input=${(e: Event) => (this._model = (e.target as HTMLInputElement).value)}
></ms-textfield>
<ms-textfield
label="${t("serial_number_optional", L)}"
.value=${this._serialNumber}
@input=${(e: Event) => (this._serialNumber = (e.target as HTMLInputElement).value)}
></ms-textfield>
<ms-textfield
label="${t("documentation_url_optional", L)}"
type="url"
.value=${this._documentationUrl}
@input=${(e: Event) => (this._documentationUrl = (e.target as HTMLInputElement).value)}
></ms-textfield>
<ha-area-picker
.hass=${this.hass}
label="${t("area_id_optional", L)}"
.value=${this._areaId}
@value-changed=${(e: CustomEvent) =>
(this._areaId = (e.detail.value as string) || "")}
></ha-area-picker>
<ms-textfield
label="${t("installation_date_optional", L)}"
type="date"
.value=${this._installationDate}
@input=${(e: Event) => (this._installationDate = (e.target as HTMLInputElement).value)}
></ms-textfield>
<ms-textfield
label="${t("warranty_expiry_optional", L)}"
type="date"
.value=${this._warrantyExpiry}
@input=${(e: Event) => (this._warrantyExpiry = (e.target as HTMLInputElement).value)}
></ms-textfield>
<ha-form
.hass=${this.hass}
.data=${{ device: this._haDeviceId || undefined }}
.schema=${[{ name: "device", selector: { device: {} } }]}
.computeLabel=${() => t("link_device_optional", L)}
@value-changed=${(e: CustomEvent) =>
(this._haDeviceId =
((e.detail.value as { device?: string })?.device as string) || "")}
></ha-form>
${this._parentChoices().length
? html`<label class="textarea-field">
<span class="textarea-label">${t("parent_object_optional", L)}</span>
<select
class="parent-select"
.value=${this._parentEntryId}
@change=${(e: Event) =>
(this._parentEntryId = (e.target as HTMLSelectElement).value)}
>
<option value="" ?selected=${!this._parentEntryId}>
${t("parent_none", L)}
</option>
${this._parentChoices().map(
(o) => html`<option
value=${o.entry_id}
?selected=${this._parentEntryId === o.entry_id}
>${o.object.name}</option>`,
)}
</select>
</label>`
: nothing}
<label class="textarea-field">
<span class="textarea-label">${t("object_notes_optional", L)}</span>
<textarea
rows="3"
.value=${this._notes}
@input=${(e: Event) => (this._notes = (e.target as HTMLTextAreaElement).value)}
></textarea>
</label>
</div>
<div class="dialog-actions">
<ha-button appearance="plain" @click=${this._close}>
${t("cancel", this._lang)}
</ha-button>
<ha-button
@click=${this._save}
.disabled=${this._loading || !this._name.trim()}
>
${this._loading ? t("saving", this._lang) : t("save", this._lang)}
</ha-button>
</div>
</ha-dialog>
`;
}
static styles = css`
.dialog-title {
font-size: 18px;
font-weight: 500;
padding-bottom: 12px;
}
.content {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 300px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
}
ms-textfield {
display: block;
}
.textarea-field {
display: flex; flex-direction: column; gap: 4px;
}
.textarea-label {
font-size: 12px; color: var(--secondary-text-color, #888); font-weight: 500;
}
.textarea-field textarea {
padding: 8px 10px; font-size: 14px; font-family: inherit;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color);
border: 1px solid var(--divider-color); border-radius: 6px;
resize: vertical;
}
.textarea-field textarea:focus {
outline: none; border-color: var(--primary-color);
}
.parent-select {
padding: 8px 10px; font-size: 14px; font-family: inherit;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color);
border: 1px solid var(--divider-color); border-radius: 6px;
}
.error {
color: var(--error-color, #f44336);
font-size: 13px;
}
`;
}
if (!customElements.get("maintenance-object-dialog")) {
customElements.define("maintenance-object-dialog", MaintenanceObjectDialog);
}
@@ -0,0 +1,320 @@
/** 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,
);
}
@@ -0,0 +1,413 @@
/** Dialog for generating, printing, and downloading QR codes.
*
* For tasks: shows two QR codes side-by-side — "Info" () and "Complete" (✓)
* with embedded icons. For objects: shows a single "Info" QR.
*/
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant } from "../types";
import { t } from "../styles";
interface QrResult {
svg_data_uri: string;
url: string;
label: {
object_name: string;
manufacturer: string;
model: string;
task_name: string | null;
};
}
/** Escape HTML entities to prevent XSS in document.write contexts. */
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
/** Sanitize a data URI for safe use in an img src attribute. */
function sanitizeDataUri(uri: string): string {
if (!uri.startsWith("data:image/svg+xml,") && !uri.startsWith("data:image/png;base64,")) {
return "";
}
return escapeHtml(uri);
}
/** Sanitize a string for use in a filename (remove OS-invalid chars). */
function sanitizeFilename(s: string): string {
return s.replace(/[/\\:*?"<>|#%]+/g, "").replace(/\s+/g, "-").toLowerCase().substring(0, 100);
}
export class MaintenanceQrDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public lang = "en";
@state() private _open = false;
@state() private _loading = false;
@state() private _error = "";
@state() private _viewResult: QrResult | null = null;
@state() private _completeResult: QrResult | null = null;
@state() private _urlMode: "companion" | "local" | "server" = "companion";
private _entryId = "";
private _taskId: string | null = null;
private _objectName = "";
private _taskName = "";
private _generateSeq = 0;
public openForObject(entryId: string, objectName: string): void {
this._entryId = entryId;
this._taskId = null;
this._objectName = objectName;
this._taskName = "";
this._urlMode = "companion";
this._error = "";
this._viewResult = null;
this._completeResult = null;
this._open = true;
this._generate();
}
public openForTask(
entryId: string,
taskId: string,
objectName: string,
taskName: string,
): void {
this._entryId = entryId;
this._taskId = taskId;
this._objectName = objectName;
this._taskName = taskName;
this._urlMode = "companion";
this._error = "";
this._viewResult = null;
this._completeResult = null;
this._open = true;
this._generate();
}
private async _generate(): Promise<void> {
const seq = ++this._generateSeq;
this._loading = true;
this._error = "";
this._viewResult = null;
this._completeResult = null;
try {
const base: Record<string, unknown> = {
type: "maintenance_supporter/qr/generate",
entry_id: this._entryId,
url_mode: this._urlMode,
};
if (this._taskId) base.task_id = this._taskId;
// Always request "view" QR; also request "complete" if this is a task
const promises: Promise<unknown>[] = [
this.hass.connection.sendMessagePromise({ ...base, action: "view" }),
];
if (this._taskId) {
promises.push(
this.hass.connection.sendMessagePromise({ ...base, action: "complete" }),
);
}
const results = await Promise.all(promises);
if (seq !== this._generateSeq) return;
this._viewResult = results[0] as QrResult;
if (results.length > 1) {
this._completeResult = results[1] as QrResult;
}
} catch (err: unknown) {
if (seq !== this._generateSeq) return;
const code = (err as Record<string, unknown>)?.code;
const msg = (err as Record<string, unknown>)?.message;
this._error = code === "no_url" || (typeof msg === "string" && msg.includes("No Home Assistant URL"))
? t("qr_error_no_url", this.lang)
: t("qr_error", this.lang);
} finally {
if (seq === this._generateSeq) this._loading = false;
}
}
private _setUrlMode(mode: "companion" | "local" | "server"): void {
if (this._urlMode === mode) return;
this._urlMode = mode;
this._generate();
}
private _print(): void {
if (!this._viewResult) return;
const r = this._viewResult;
const title = r.label.task_name
? `${r.label.object_name}${r.label.task_name}`
: r.label.object_name;
const subtitle = [r.label.manufacturer, r.label.model]
.filter(Boolean)
.join(" ");
const w = window.open("", "_blank", "width=600,height=500");
if (!w) return;
const L = this.lang || "en";
const safeTitle = escapeHtml(title);
const safeSub = escapeHtml(subtitle);
const hasComplete = !!this._completeResult;
const viewLabel = escapeHtml(t("qr_action_view", L));
const completeLabel = escapeHtml(t("qr_action_complete", L));
w.document.write(`<!DOCTYPE html><html><head><meta charset="utf-8">
<title>${safeTitle}</title>
<style>
body{font-family:sans-serif;text-align:center;padding:20px}
h2{margin:0 0 4px}
.sub{color:#666;font-size:14px;margin-bottom:16px}
.qr-row{display:flex;justify-content:center;gap:24px;margin:12px 0}
.qr-col{display:flex;flex-direction:column;align-items:center;gap:6px}
.qr-col img{width:${hasComplete ? "200px" : "280px"}}
.qr-label{font-size:13px;font-weight:500;color:#333}
.url{font-size:10px;color:#999;word-break:break-all;margin-top:8px;max-width:480px}
</style></head><body>
<h2>${safeTitle}</h2>
${safeSub ? `<div class="sub">${safeSub}</div>` : ""}
<div class="qr-row">
<div class="qr-col">
<img src="${sanitizeDataUri(this._viewResult.svg_data_uri)}" alt="QR Info" />
<div class="qr-label">${viewLabel}</div>
</div>
${hasComplete ? `<div class="qr-col">
<img src="${sanitizeDataUri(this._completeResult!.svg_data_uri)}" alt="QR Complete" />
<div class="qr-label">${completeLabel}</div>
</div>` : ""}
</div>
<div class="url">${escapeHtml(this._viewResult.url)}</div>
<script>setTimeout(()=>window.print(),300)<\/script>
</body></html>`);
w.document.close();
}
private _downloadSvg(result: QrResult, suffix: string): void {
const svgContent = decodeURIComponent(
result.svg_data_uri.replace("data:image/svg+xml,", ""),
);
const blob = new Blob([svgContent], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const name = this._taskName
? `${this._objectName}-${this._taskName}`
: this._objectName;
a.download = `qr-${sanitizeFilename(name)}-${suffix}.svg`;
a.click();
URL.revokeObjectURL(url);
}
private _close(): void {
this._open = false;
this._viewResult = null;
this._completeResult = null;
this._error = "";
this._loading = false;
}
render() {
if (!this._open) return html``;
const L = this.lang || this.hass?.language || "en";
const heading = this._taskName
? `${t("qr_code", L)}: ${this._objectName}${this._taskName}`
: `${t("qr_code", L)}: ${this._objectName}`;
const hasResults = !!this._viewResult;
return html`
<ha-dialog open @closed=${this._close}>
<div class="dialog-title">${heading}</div>
<div class="content">
${this._loading
? html`<div class="loading">${t("qr_generating", L)}</div>`
: this._error
? html`<div class="error">${this._error}</div>`
: hasResults
? html`
<div class="qr-pair">
<div class="qr-item">
<img
class="qr-image ${this._completeResult ? "small" : ""}"
src="${this._viewResult!.svg_data_uri}"
alt="QR Info"
/>
<div class="qr-item-label">${t("qr_action_view", L)}</div>
<button class="dl-btn"
@click=${() => this._downloadSvg(this._viewResult!, "info")}>
<ha-icon icon="mdi:download"></ha-icon>
${t("qr_download", L)}
</button>
</div>
${this._completeResult
? html`
<div class="qr-item">
<img
class="qr-image small"
src="${this._completeResult.svg_data_uri}"
alt="QR Complete"
/>
<div class="qr-item-label">${t("qr_action_complete", L)}</div>
<button class="dl-btn"
@click=${() => this._downloadSvg(this._completeResult!, "complete")}>
<ha-icon icon="mdi:download"></ha-icon>
${t("qr_download", L)}
</button>
</div>
`
: nothing}
</div>
<div class="url-display">${this._viewResult!.url}</div>
`
: nothing}
<div class="action-row">
<label>${t("qr_url_mode", L)}</label>
<div class="action-toggle">
<button class="toggle-btn ${this._urlMode === "companion" ? "active" : ""}"
@click=${() => this._setUrlMode("companion")}>${t("qr_mode_companion", L)}</button>
<button class="toggle-btn ${this._urlMode === "local" ? "active" : ""}"
@click=${() => this._setUrlMode("local")}>${t("qr_mode_local", L)}</button>
<button class="toggle-btn ${this._urlMode === "server" ? "active" : ""}"
@click=${() => this._setUrlMode("server")}>${t("qr_mode_server", L)}</button>
</div>
</div>
</div>
<div class="dialog-actions">
<ha-button appearance="plain" @click=${this._close}>
${t("cancel", L)}
</ha-button>
<ha-button
@click=${this._print}
.disabled=${!hasResults}
>
${t("qr_print", L)}
</ha-button>
</div>
</ha-dialog>
`;
}
static styles = css`
.dialog-title {
font-size: 18px;
font-weight: 500;
padding-bottom: 12px;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
min-width: 300px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
}
.qr-pair {
display: flex;
gap: 20px;
justify-content: center;
width: 100%;
}
.qr-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.qr-image {
width: 240px;
height: 240px;
image-rendering: pixelated;
}
.qr-image.small {
width: 180px;
height: 180px;
}
.qr-item-label {
font-size: 12px;
font-weight: 500;
color: var(--secondary-text-color);
text-align: center;
}
.dl-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: 1px solid var(--divider-color, #e0e0e0);
cursor: pointer;
font-size: 13px;
color: var(--primary-text-color);
padding: 6px 14px;
border-radius: 18px;
transition: background 0.2s, border-color 0.2s;
}
.dl-btn:hover {
background: var(--secondary-background-color, #f5f5f5);
border-color: var(--primary-color);
}
.dl-btn ha-icon {
--mdc-icon-size: 18px;
}
.url-display {
font-size: 11px;
color: var(--secondary-text-color);
word-break: break-all;
text-align: center;
max-width: 400px;
}
.loading {
padding: 40px 0;
color: var(--secondary-text-color);
}
.error {
padding: 20px 0;
color: var(--error-color, #f44336);
}
.action-row {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
.action-row label {
font-size: 13px;
color: var(--secondary-text-color);
}
.action-toggle {
display: flex;
gap: 4px;
background: var(--divider-color, #e0e0e0);
border-radius: 6px;
padding: 3px;
}
.toggle-btn {
flex: 1;
padding: 8px 12px;
border: none;
background: transparent;
color: var(--primary-text-color);
cursor: pointer;
border-radius: 4px;
font-size: 13px;
transition: all 0.2s;
line-height: 1.3;
}
.toggle-btn:hover {
background: rgba(0, 0, 0, 0.05);
}
.toggle-btn.active {
background: var(--primary-color);
color: var(--text-primary-color, #fff);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
`;
}
if (!customElements.get("maintenance-qr-dialog")) {
customElements.define("maintenance-qr-dialog", MaintenanceQrDialog);
}
@@ -0,0 +1,199 @@
/** Dialog for editing manual seasonal factor overrides (12 months). */
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t } from "../styles";
import { describeWsError } from "../ws-errors";
import type { HomeAssistant } from "../types";
const MONTH_KEYS = [
"month_jan", "month_feb", "month_mar", "month_apr",
"month_may", "month_jun", "month_jul", "month_aug",
"month_sep", "month_oct", "month_nov", "month_dec",
];
export class SeasonalOverridesDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@state() private _loading = false;
@state() private _error = "";
@state() private _entryId = "";
@state() private _taskId = "";
@state() private _values: string[] = new Array(12).fill("");
private get _lang(): string {
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en";
}
public open(entryId: string, taskId: string, currentOverrides: Record<number, number> | null | undefined): void {
this._entryId = entryId;
this._taskId = taskId;
this._values = new Array(12).fill("");
if (currentOverrides) {
for (const [k, v] of Object.entries(currentOverrides)) {
const m = parseInt(k, 10);
if (m >= 1 && m <= 12 && typeof v === "number") {
this._values[m - 1] = v.toString();
}
}
}
this._error = "";
this._open = true;
}
private _close(): void {
this._open = false;
}
private _buildOverrides(): Record<number, number> | null {
const out: Record<number, number> = {};
for (let i = 0; i < 12; i++) {
const raw = this._values[i].trim();
if (!raw) continue;
const num = parseFloat(raw);
if (Number.isNaN(num)) {
this._error = `${t("month_" + ["jan","feb","mar","apr","may","jun","jul","aug","sep","oct","nov","dec"][i], this._lang)}: ${t("seasonal_override_invalid", this._lang)}`;
return null;
}
if (num < 0.1 || num > 5.0) {
this._error = t("seasonal_override_range", this._lang);
return null;
}
out[i + 1] = num;
}
return out;
}
private _save = async (): Promise<void> => {
const overrides = this._buildOverrides();
if (overrides === null) return;
this._loading = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/seasonal_overrides",
entry_id: this._entryId,
task_id: this._taskId,
overrides,
});
this._open = false;
this.dispatchEvent(new CustomEvent("overrides-saved"));
} catch (e) {
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
} finally {
this._loading = false;
}
};
private _clearAll = async (): Promise<void> => {
this._loading = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/seasonal_overrides",
entry_id: this._entryId,
task_id: this._taskId,
overrides: {},
});
this._values = new Array(12).fill("");
this._open = false;
this.dispatchEvent(new CustomEvent("overrides-saved"));
} catch (e) {
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
} finally {
this._loading = false;
}
};
render() {
if (!this._open) return html``;
const L = this._lang;
return html`
<ha-dialog open @closed=${this._close} heading="${t("seasonal_overrides_title", L)}">
<div class="content">
<p class="hint">${t("seasonal_overrides_hint", L)}</p>
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
<div class="months">
${MONTH_KEYS.map((key, i) => html`
<label class="month">
<span class="mn">${t(key, L)}</span>
<input type="number" step="0.1" min="0.1" max="5.0"
placeholder="1.0"
.value=${this._values[i]}
@input=${(e: Event) => {
const v = [...this._values];
v[i] = (e.target as HTMLInputElement).value;
this._values = v;
}} />
</label>
`)}
</div>
</div>
<div class="dialog-actions">
<ha-button appearance="plain" @click=${this._clearAll} .disabled=${this._loading}>
${t("clear_all", L)}
</ha-button>
<div class="spacer"></div>
<ha-button appearance="plain" @click=${this._close}>
${t("cancel", L)}
</ha-button>
<ha-button @click=${this._save} .disabled=${this._loading}>
${this._loading ? t("saving", L) : t("save", L)}
</ha-button>
</div>
</ha-dialog>
`;
}
static styles = css`
.content {
min-width: 320px;
max-width: 480px;
}
.hint {
color: var(--secondary-text-color);
font-size: 13px;
margin: 0 0 12px 0;
}
.error {
color: var(--error-color, #f44336);
font-size: 13px;
margin-bottom: 8px;
}
.months {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.month {
display: flex;
align-items: center;
gap: 8px;
}
.mn {
min-width: 70px;
font-size: 14px;
}
input[type="number"] {
flex: 1;
padding: 6px 8px;
font-size: 14px;
border-radius: 4px;
border: 1px solid var(--divider-color);
background: var(--card-background-color);
color: var(--primary-text-color);
}
.dialog-actions {
display: flex;
align-items: center;
gap: 8px;
padding-top: 16px;
}
.spacer { flex: 1; }
`;
}
if (!customElements.get("maintenance-seasonal-overrides-dialog")) {
customElements.define("maintenance-seasonal-overrides-dialog", SeasonalOverridesDialog);
}
@@ -0,0 +1,77 @@
/** Shared CSS for the 3 interactive section cards (vacation/budget/groups).
*
* Extracted via the DRY-audit (Tier 2). Each card was carrying ~30 LOC of
* identical button + header + emoji + error + loading styles plus tiny
* divergences in `.card-content { gap }` (12px vs 14px). This file owns
* the canonical version; cards import + extend it for their card-specific
* layout.
*
* When adding more section cards (Notifications / Panel Access etc.):
*
* static styles = [sectionCardSharedStyles, css`...card-specific...`];
*
* Don't add card-specific selectors here — they belong in the card file.
* This module is only for selectors that ALL section cards use.
*/
import { css } from "lit";
export const sectionCardSharedStyles = css`
ha-card { overflow: hidden; }
.card-content {
padding: 16px;
display: flex; flex-direction: column;
gap: 12px;
}
.header {
display: flex; align-items: center; justify-content: space-between;
gap: 12px;
}
.title {
display: flex; align-items: center; gap: 8px;
font-size: 16px; font-weight: 500;
}
.emoji { font-size: 20px; }
/* Button family — primary action / muted-saved-state / link / icon-with-text */
.btn {
padding: 6px 12px; 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; gap: 4px;
}
.btn:hover { background: var(--state-icon-color, rgba(255,255,255,0.06)); }
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
.btn.primary {
background: var(--primary-color);
color: var(--text-primary-color, white);
border-color: var(--primary-color);
}
.btn.primary[disabled] { opacity: 0.6; }
.btn.muted {
background: transparent;
color: var(--secondary-text-color);
border-style: dashed;
}
.btn.muted[disabled] { opacity: 1; cursor: default; }
.btn.muted ha-icon, .btn.primary ha-icon { --mdc-icon-size: 14px; }
.btn.link {
background: transparent; border: none; padding: 6px 4px;
color: var(--primary-color); margin-left: auto;
}
.btn.link:hover { background: transparent; text-decoration: underline; }
/* Error + loading states */
.error {
padding: 8px; border-radius: 6px;
background: rgba(211, 47, 47, 0.1);
color: var(--error-color, #d32f2f); font-size: 13px;
}
.loading {
padding: 24px; text-align: center;
color: var(--secondary-text-color);
}
`;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,361 @@
/** Document-storage overview card (panel overview).
*
* Shows the physical footprint (real backup cost), the dedup saving, and a
* per-object drill-down sorted by size. Object ids from the WS summary are
* mapped to names via the panel's already-loaded objects. Self-hides when no
* documents exist, so it never clutters the overview for non-users.
*/
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 { formatBytes } from "../helpers/format-bytes";
import type { HomeAssistant } from "../types";
interface StorageSummary {
total_bytes: number;
dedup_savings_bytes: number;
file_count: number;
link_count: number;
document_count: number;
by_object: Record<string, { bytes: number; files: number; links: number }>;
}
interface PanelObject {
entry_id: string;
object?: { id?: string; name?: string };
}
interface SearchResult {
id: string;
entry_id: string;
object_name: string;
kind: "file" | "weblink";
title: string;
filename?: string;
url?: string;
size?: number;
tags?: string[];
}
export class MaintenanceStorageSectionCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public objects: PanelObject[] = [];
@state() private _summary: StorageSummary | null = null;
@state() private _loaded = false;
@state() private _busy = false;
@state() private _error = "";
@state() private _query = "";
@state() private _results: SearchResult[] = [];
@state() private _expanded = false;
private _initiallyLoaded = false;
private _searchTimer = 0;
private get _lang(): string {
return this.hass?.language || "en";
}
updated(changed: Map<string, unknown>): void {
super.updated(changed);
if (changed.has("hass") && this.hass && !this._initiallyLoaded) {
this._initiallyLoaded = true;
void this._load();
// Re-render once the runtime locale JSON arrives (t() falls back to
// English until then; this card can paint before the panel's fetch lands).
void ensureLocale(this._lang).then(() => this.requestUpdate());
}
}
private async _load(): Promise<void> {
this._busy = true;
try {
this._summary = await this.hass.connection.sendMessagePromise<StorageSummary>({
type: "maintenance_supporter/documents/storage",
});
this._error = "";
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._loaded = true;
this._busy = false;
}
}
private _nameFor(objectId: string): string {
const o = this.objects.find((x) => x.object?.id === objectId);
return o?.object?.name || objectId.slice(0, 8);
}
private _entryFor(objectId: string): string | undefined {
return this.objects.find((x) => x.object?.id === objectId)?.entry_id;
}
private _toggle(): void {
this._expanded = !this._expanded;
}
/** Ask the panel (which owns navigation) to open an object's detail view. */
private _openObject(entryId: string): void {
this.dispatchEvent(
new CustomEvent("open-object", {
detail: { entry_id: entryId },
bubbles: true,
composed: true,
}),
);
}
private _onSearch(e: Event): void {
this._query = (e.target as HTMLInputElement).value;
clearTimeout(this._searchTimer);
this._searchTimer = window.setTimeout(() => void this._doSearch(), 250);
}
private async _doSearch(): Promise<void> {
const q = this._query.trim();
if (!q) {
this._results = [];
return;
}
try {
const r = await this.hass.connection.sendMessagePromise<{ results: SearchResult[] }>({
type: "maintenance_supporter/documents/search",
query: q,
});
this._results = r.results || [];
} catch (e) {
this._error = describeWsError(e, this._lang);
this._results = [];
}
}
private async _openResult(doc: SearchResult): Promise<void> {
if (doc.kind === "weblink") {
window.open(doc.url, "_blank", "noopener");
return;
}
const win = window.open("about:blank", "_blank");
try {
const s = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 300,
});
// Absolute URL so it navigates the blank popup reliably (about:blank base).
if (win) win.location.href = new URL(s.path, window.location.origin).href;
} catch (e) {
if (win) win.close();
this._error = describeWsError(e, this._lang);
}
}
private _renderResult(doc: SearchResult, L: string) {
return html`
<div class="obj-row result-row" title=${t("doc_open", L)} @click=${() => this._openResult(doc)}>
<ha-icon icon=${doc.kind === "weblink" ? "mdi:link-variant" : "mdi:file-document-outline"}></ha-icon>
<div class="result-info">
<div class="result-title">${doc.title || doc.filename || doc.url}</div>
<div class="result-obj">${doc.object_name}</div>
</div>
<ha-icon class="result-open" icon=${doc.kind === "weblink" ? "mdi:open-in-new" : "mdi:eye-outline"}></ha-icon>
</div>
`;
}
render() {
if (!this._loaded || !this._summary) return nothing;
const s = this._summary;
if (s.document_count === 0) return nothing; // self-hide when unused
const L = this._lang;
const rows = Object.entries(s.by_object)
.filter(([, v]) => v.files > 0 || v.links > 0)
.map(([id, v]) => ({ id, name: this._nameFor(id), entry: this._entryFor(id), ...v }))
.sort((a, b) => b.bytes - a.bytes);
return html`
<ha-card>
<div class="card-content">
<div class="header">
<button
class="toggle"
@click=${this._toggle}
aria-expanded=${this._expanded ? "true" : "false"}
aria-label=${t("doc_storage_title", L)}
>
<ha-icon class="chevron" icon=${this._expanded ? "mdi:chevron-down" : "mdi:chevron-right"}></ha-icon>
<span class="emoji">🗄️</span>
<span class="title-text">${t("doc_storage_title", L)}</span>
<span class="header-summary">
${formatBytes(s.total_bytes)}
${s.dedup_savings_bytes > 0 ? html`<span class="saved">${formatBytes(s.dedup_savings_bytes)}</span>` : nothing}
</span>
</button>
<button
class="icon-btn"
title=${t("doc_storage_refresh", L)}
?disabled=${this._busy}
@click=${this._load}
>
<ha-icon icon="mdi:refresh"></ha-icon>
</button>
</div>
${this._expanded
? html`
<div class="body">
<div class="totals">
<div class="stat">
<div class="stat-value">${formatBytes(s.total_bytes)}</div>
<div class="stat-label">
<ha-icon icon="mdi:file-document-outline"></ha-icon> ${s.file_count}
<ha-icon icon="mdi:link-variant"></ha-icon> ${s.link_count}
</div>
</div>
${s.dedup_savings_bytes > 0
? html`<div class="stat">
<div class="stat-value saved">${formatBytes(s.dedup_savings_bytes)}</div>
<div class="stat-label">${t("doc_storage_saved", L)}</div>
</div>`
: nothing}
</div>
<div class="doc-search">
<ha-icon icon="mdi:magnify"></ha-icon>
<input
type="search"
aria-label=${t("doc_search", L)}
placeholder=${t("doc_search", L)}
.value=${this._query}
@input=${this._onSearch}
/>
</div>
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
${this._query.trim()
? this._results.length
? html`<div class="obj-list">${this._results.map((d) => this._renderResult(d, L))}</div>`
: html`<div class="search-empty">${t("doc_search_none", L)}</div>`
: rows.length
? html`<div class="obj-list">${rows.map((r) => this._renderObjRow(r, L))}</div>`
: nothing}
</div>
`
: nothing}
</div>
</ha-card>
`;
}
private _renderObjRow(
r: { id: string; name: string; entry?: string; bytes: number; files: number; links: number },
L: string,
) {
const eid = r.entry;
return html`
<div
class="obj-row ${eid ? "clickable" : ""}"
role=${eid ? "button" : nothing}
tabindex=${eid ? "0" : nothing}
aria-label=${eid ? r.name : nothing}
@click=${eid ? () => this._openObject(eid) : undefined}
@keydown=${eid
? (e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
this._openObject(eid);
}
}
: undefined}
>
<span class="obj-name">${r.name}</span>
<span class="obj-meta">
${r.files > 0 ? html`<ha-icon icon="mdi:file-document-outline"></ha-icon>${r.files}` : nothing}
${r.links > 0 ? html`<ha-icon icon="mdi:link-variant"></ha-icon>${r.links}` : nothing}
</span>
<span class="obj-size">${formatBytes(r.bytes)}</span>
${eid ? html`<ha-icon class="obj-go" icon="mdi:chevron-right"></ha-icon>` : nothing}
</div>
`;
}
static styles = css`
ha-card { margin-top: 16px; }
.card-content { padding: 16px; }
.doc-search {
display: flex; align-items: center; gap: 6px; margin: 10px 0 4px;
padding: 2px 10px; border-radius: 8px;
background: var(--secondary-background-color, rgba(0, 0, 0, 0.06));
border: 1px solid var(--divider-color);
}
.doc-search ha-icon { --mdc-icon-size: 18px; color: var(--secondary-text-color, #888); }
.doc-search input {
flex: 1; border: none; background: transparent; font: inherit; outline: none;
color: var(--primary-text-color); padding: 6px 0;
}
.result-row { cursor: pointer; }
.result-row > ha-icon { color: var(--primary-color); --mdc-icon-size: 20px; flex: none; }
.result-info { flex: 1; min-width: 0; }
.result-title { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.result-obj { font-size: 12px; color: var(--secondary-text-color, #888); }
.result-open { color: var(--secondary-text-color, #888); --mdc-icon-size: 18px; flex: none; }
.search-empty { color: var(--secondary-text-color, #888); font-size: 13px; padding: 8px 2px; }
.header { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.toggle {
display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0;
background: none; border: none; padding: 4px 0; margin: 0; cursor: pointer;
font: inherit; color: var(--primary-text-color); text-align: left;
}
.toggle:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; border-radius: 6px; }
.chevron { --mdc-icon-size: 22px; color: var(--secondary-text-color, #888); flex: none; }
.title-text { font-size: 16px; font-weight: 500; }
.header-summary {
margin-left: auto; display: flex; align-items: center; gap: 8px;
font-size: 14px; font-weight: 600; white-space: nowrap;
}
.header-summary .saved { color: var(--success-color, #4caf50); font-weight: 500; }
.emoji { font-size: 18px; }
.body { margin-top: 4px; }
.totals { display: flex; gap: 24px; margin: 12px 0 8px; flex-wrap: wrap; }
.stat-value { font-size: 22px; font-weight: 600; }
.stat-value.saved { color: var(--success-color, #4caf50); }
.stat-label {
font-size: 12px; color: var(--secondary-text-color, #888);
display: flex; align-items: center; gap: 4px;
}
.stat-label ha-icon { --mdc-icon-size: 15px; }
.obj-list { display: flex; flex-direction: column; gap: 2px; margin-top: 8px; }
.obj-row {
display: flex; align-items: center; gap: 10px;
padding: 6px 8px; border-radius: 6px;
}
.obj-row:nth-child(odd) { background: var(--secondary-background-color, rgba(0,0,0,0.04)); }
.obj-row.clickable { cursor: pointer; }
.obj-row.clickable:hover { background: var(--secondary-background-color, rgba(0,0,0,0.10)); }
.obj-row.clickable:focus-visible { outline: 2px solid var(--primary-color); outline-offset: -2px; }
.obj-go { --mdc-icon-size: 18px; color: var(--secondary-text-color, #888); flex: none; }
.obj-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
.obj-meta {
display: flex; align-items: center; gap: 4px;
color: var(--secondary-text-color, #888); font-size: 13px;
}
.obj-meta ha-icon { --mdc-icon-size: 15px; }
.obj-size { font-variant-numeric: tabular-nums; font-size: 13px; min-width: 64px; text-align: right; }
.icon-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 32px; height: 32px; border-radius: 8px; cursor: pointer;
background: transparent; border: none; color: var(--primary-text-color);
}
.icon-btn:hover { background: var(--secondary-background-color, rgba(0,0,0,0.06)); }
.icon-btn[disabled] { opacity: 0.4; pointer-events: none; }
.error { color: var(--error-color, #f44336); font-size: 13px; margin-top: 6px; }
`;
}
if (!customElements.get("maintenance-storage-section-card")) {
customElements.define("maintenance-storage-section-card", MaintenanceStorageSectionCard);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,339 @@
/** Documents linked to a maintenance task.
*
* A filtered view over the object's document pool via each doc's `task_ids`:
* link/unlink existing object documents to the task and open/download them. Full
* upload + management lives at the object level (documents-section); this keeps
* the manual / spare-parts list right where the maintenance work happens. Hides
* itself entirely when the object has no documents at all.
*/
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 { downloadUrl } from "../helpers/download";
import { formatBytes } from "../helpers/format-bytes";
import type { HomeAssistant } from "../types";
interface Doc {
id: string;
kind: "file" | "weblink";
title: string;
filename?: string;
url?: string;
mime?: string;
size?: number;
tags?: string[];
task_ids?: string[];
task_pages?: Record<string, number>;
}
const CATEGORIES = ["manual", "warranty", "invoice", "spare_parts", "photo", "other"] as const;
const CATEGORY_ICONS: Record<string, string> = {
manual: "mdi:book-open-variant",
warranty: "mdi:shield-check",
invoice: "mdi:receipt-text-outline",
spare_parts: "mdi:cog-outline",
photo: "mdi:image-outline",
other: "mdi:file-document-outline",
};
export class MaintenanceTaskDocuments extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entryId!: string;
@property({ attribute: false }) public taskId!: string;
@property({ type: Boolean }) public canWrite = false;
@state() private _docs: Doc[] = [];
@state() private _loaded = false;
@state() private _busy = false;
@state() private _error = "";
@state() private _attachId = "";
private _loadedKey = "";
private _localeReady = false;
private get _lang(): string {
return this.hass?.language || "en";
}
updated(changed: Map<string, unknown>): void {
super.updated(changed);
if (this.hass && !this._localeReady) {
this._localeReady = true;
void ensureLocale(this._lang).then(() => this.requestUpdate());
}
const key = `${this.entryId}|${this.taskId}`;
if (this.hass && this.entryId && this.taskId && this._loadedKey !== key) {
this._loadedKey = key;
void this._load();
}
}
private async _load(): Promise<void> {
try {
const r = await this.hass.connection.sendMessagePromise<{ documents: Doc[] }>({
type: "maintenance_supporter/documents/list",
entry_id: this.entryId,
});
this._docs = r.documents || [];
this._loaded = true;
this._error = "";
} catch (e) {
this._error = describeWsError(e, this._lang);
this._loaded = true;
}
}
private _linked(): Doc[] {
return this._docs.filter((d) => (d.task_ids || []).includes(this.taskId));
}
private _available(): Doc[] {
return this._docs.filter((d) => !(d.task_ids || []).includes(this.taskId));
}
private async _setTaskIds(doc: Doc, taskIds: string[]): Promise<void> {
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/documents/update",
doc_id: doc.id,
task_ids: taskIds,
});
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
}
private _link(): void {
const doc = this._docs.find((d) => d.id === this._attachId);
if (!doc) return;
this._attachId = "";
void this._setTaskIds(doc, [...(doc.task_ids || []), this.taskId]);
}
private _unlink(doc: Doc): void {
void this._setTaskIds(doc, (doc.task_ids || []).filter((x) => x !== this.taskId));
}
private _isPdf(doc: Doc): boolean {
return doc.mime === "application/pdf" || (doc.filename || "").toLowerCase().endsWith(".pdf");
}
/** The page this doc should open at for the current task, if set (PDFs only). */
private _pageFor(doc: Doc): number | undefined {
return this._isPdf(doc) ? doc.task_pages?.[this.taskId] : undefined;
}
private async _open(doc: Doc): Promise<void> {
if (doc.kind === "weblink") {
window.open(doc.url, "_blank", "noopener");
return;
}
// A per-task page hint jumps straight to the relevant page via the PDF
// viewer's #page=N fragment (client-side, so it never breaks the signature).
const page = this._pageFor(doc);
const frag = page ? `#page=${page}` : "";
const win = window.open("about:blank", "_blank");
try {
const s = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 300,
});
// Absolute URL: a fragment on a *root-relative* path won't resolve against
// the blank popup's about:blank base, so it would silently stay blank.
if (win) win.location.href = new URL(s.path + frag, window.location.origin).href;
} catch (e) {
if (win) win.close();
this._error = describeWsError(e, this._lang);
}
}
/** Set (page >= 1) or clear (0) the jump-to page for this doc's task link. */
private async _setPage(doc: Doc, page: number): Promise<void> {
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/documents/update",
doc_id: doc.id,
task_pages: { [this.taskId]: page },
});
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
}
private async _download(doc: Doc): Promise<void> {
try {
const s = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 30,
});
downloadUrl(s.path, doc.filename || doc.title || "document");
} catch (e) {
this._error = describeWsError(e, this._lang);
}
}
render() {
// Hide entirely for objects with no documents — no clutter on every task.
if (!this._loaded || this._docs.length === 0) return nothing;
const L = this._lang;
const linked = this._linked();
const available = this._available();
return html`
<div class="task-docs">
<h3><ha-icon icon="mdi:paperclip"></ha-icon> ${t("documents", L)} (${linked.length})</h3>
${this._error ? html`<div class="tdoc-error">${this._error}</div>` : nothing}
${linked.length === 0
? html`<div class="tdoc-empty">${t("doc_task_none", L)}</div>`
: html`<div class="tdoc-list">${linked.map((d) => this._renderRow(d, L))}</div>`}
${this.canWrite && available.length
? html`<div class="tdoc-attach">
<select
class="tdoc-select"
?disabled=${this._busy}
@change=${(e: Event) => (this._attachId = (e.target as HTMLSelectElement).value)}
>
<option value="" ?selected=${!this._attachId}>${t("doc_link_existing", L)}</option>
${available.map(
(d) => html`<option value=${d.id} ?selected=${d.id === this._attachId}>${d.title || d.filename || d.url}</option>`,
)}
</select>
<button class="tdoc-btn" ?disabled=${this._busy || !this._attachId} @click=${this._link}>
<ha-icon icon="mdi:link-variant-plus"></ha-icon> ${t("doc_attach", L)}
</button>
</div>`
: nothing}
</div>
`;
}
private _renderRow(doc: Doc, L: string) {
const isFile = doc.kind === "file";
const isPdf = this._isPdf(doc);
const page = doc.task_pages?.[this.taskId];
const cat = (doc.tags || []).find((x) => (CATEGORIES as readonly string[]).includes(x)) || "other";
const meta = isFile ? formatBytes(doc.size) : t("doc_link_badge", L);
return html`
<div class="tdoc-row">
<ha-icon class="tdoc-icon" icon=${isFile ? CATEGORY_ICONS[cat] : "mdi:link-variant"}></ha-icon>
<div
class="tdoc-info"
role="button"
tabindex="0"
title=${page ? `${t("doc_open", L)} · ${t("doc_page", L)} ${page}` : t("doc_open", L)}
@click=${() => this._open(doc)}
@keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
void this._open(doc);
}
}}
>
<div class="tdoc-title">${doc.title || doc.filename || doc.url}</div>
<div class="tdoc-meta">
${meta}${page ? html` · <span class="tdoc-pagetag">${t("doc_page", L)} ${page}</span>` : nothing}
</div>
</div>
${this.canWrite && isPdf
? html`<input
class="tdoc-page"
type="number"
min="1"
inputmode="numeric"
aria-label=${t("doc_page", L)}
title=${t("doc_page", L)}
placeholder=${t("doc_page", L)}
.value=${page ? String(page) : ""}
?disabled=${this._busy}
@change=${(e: Event) => {
const v = parseInt((e.target as HTMLInputElement).value, 10);
void this._setPage(doc, Number.isFinite(v) && v >= 1 ? v : 0);
}}
/>`
: nothing}
<button class="icon-btn" title=${t("doc_open", L)} @click=${() => this._open(doc)}>
<ha-icon icon=${isFile ? "mdi:eye-outline" : "mdi:open-in-new"}></ha-icon>
</button>
${isFile
? html`<button class="icon-btn" title=${t("doc_download", L)} @click=${() => this._download(doc)}>
<ha-icon icon="mdi:download"></ha-icon>
</button>`
: nothing}
${this.canWrite
? html`<button class="icon-btn" title=${t("doc_unlink", L)} ?disabled=${this._busy} @click=${() => this._unlink(doc)}>
<ha-icon icon="mdi:link-variant-off"></ha-icon>
</button>`
: nothing}
</div>
`;
}
static styles = css`
:host { display: block; }
.task-docs { margin-top: 20px; }
h3 {
display: flex; align-items: center; gap: 6px; margin: 0 0 8px;
font-size: 15px; color: var(--primary-text-color);
}
h3 ha-icon { --mdc-icon-size: 18px; color: var(--secondary-text-color, #888); }
.tdoc-empty { color: var(--secondary-text-color, #888); font-size: 13px; padding: 2px 0 8px; }
.tdoc-error { color: var(--error-color, #f44336); font-size: 13px; margin: 4px 0; }
.tdoc-list { display: flex; flex-direction: column; gap: 4px; }
.tdoc-row {
display: flex; align-items: center; gap: 10px; padding: 6px 10px;
border: 1px solid var(--divider-color); border-radius: 8px;
}
.tdoc-icon { color: var(--primary-color); --mdc-icon-size: 22px; flex: none; }
.tdoc-info { flex: 1; min-width: 0; cursor: pointer; border-radius: 6px; }
.tdoc-info:hover .tdoc-title { text-decoration: underline; }
.tdoc-info:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
.tdoc-title { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tdoc-meta { font-size: 12px; color: var(--secondary-text-color, #888); }
.tdoc-pagetag { color: var(--primary-color); font-weight: 500; }
.tdoc-page {
flex: none; width: 76px; padding: 5px 8px; border-radius: 6px; font: inherit; font-size: 13px;
background: var(--secondary-background-color, rgba(0, 0, 0, 0.06));
color: var(--primary-text-color); border: 1px solid var(--divider-color);
}
.tdoc-page:disabled { opacity: 0.5; }
.icon-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 34px; height: 34px; border-radius: 8px; cursor: pointer;
background: transparent; border: none; color: var(--primary-text-color);
}
.icon-btn:hover { background: var(--secondary-background-color, rgba(0, 0, 0, 0.06)); }
.icon-btn[disabled] { opacity: 0.4; pointer-events: none; }
.icon-btn ha-icon { --mdc-icon-size: 20px; }
.tdoc-attach { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
.tdoc-select {
flex: 1; min-width: 160px; padding: 6px 10px; border-radius: 6px; font: inherit;
background: var(--secondary-background-color, rgba(0, 0, 0, 0.06));
color: var(--primary-text-color); border: 1px solid var(--divider-color);
}
.tdoc-btn {
display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
padding: 6px 12px; border-radius: 6px; font: inherit; font-size: 13px;
background: var(--secondary-background-color, rgba(0, 0, 0, 0.06));
color: var(--primary-text-color); border: 1px solid var(--divider-color);
}
.tdoc-btn ha-icon { --mdc-icon-size: 18px; }
.tdoc-btn[disabled] { opacity: 0.5; pointer-events: none; }
`;
}
if (!customElements.get("maintenance-task-documents")) {
customElements.define("maintenance-task-documents", MaintenanceTaskDocuments);
}
@@ -0,0 +1,815 @@
/** v2.3.0 — Task Quick-Actions Dialog.
*
* Opens from the Lovelace card / strategy when the user clicks a task row.
* Surfaces every action the panel's Task-Detail header offers, in-place,
* without forcing a panel-roundtrip:
*
* • Quick info — name + status + next due + last performed + interval
* • Primary actions — Complete (existing dialog), Skip (inline), Reset (inline)
* • Secondary (admin) — Edit settings (existing dialog), QR Code, Delete
* • Footer — "Open in Maintenance Panel" deep-link for History/Statistics
*
* Mounted via dialog-mount.openTaskQuickActions(entryId, taskId).
*/
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatRecurrence } from "../styles";
import { describeWsError } from "../ws-errors";
import { renderWeibullSection } from "../renderers/weibull";
import { renderPredictionSection } from "../renderers/prediction";
import { renderRecommendationBars } from "../renderers/recommendation";
import {
renderSeasonalCardCompact,
renderSeasonalCardExpanded,
} from "../renderers/seasonal";
import type {
HomeAssistant,
HistoryEntry,
MaintenanceObjectResponse,
MaintenanceTask,
AdvancedFeatures,
} from "../types";
interface MaintenanceObjectFull {
entry_id: string;
object: { id: string; name: string };
tasks: MaintenanceTask[];
}
export class MaintenanceTaskQuickActionsDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@state() private _entryId: string | null = null;
@state() private _taskId: string | null = null;
@state() private _task: MaintenanceTask | null = null;
@state() private _objectName = "";
@state() private _busy = false;
@state() private _error = "";
@state() private _showSkip = false;
@state() private _showReset = false;
@state() private _showDetails = false;
@state() private _showAdaptive = false;
@state() private _skipReason = "";
@state() private _resetDate = "";
@state() private _features: AdvancedFeatures = {
adaptive: false, predictions: false, seasonal: false, environmental: false,
budget: false, groups: false, checklists: false, schedule_time: false,
completion_actions: false,
};
@state() private _toast = "";
private _featuresLoaded = false;
private get _lang(): string {
return this.hass?.language || "en";
}
/** Open the dialog. Loads fresh data from /object via WS so dialog stays in
* sync even if the underlying card has stale data. */
public async openFor(entryId: string, taskId: string): Promise<void> {
this._entryId = entryId;
this._taskId = taskId;
this._error = "";
this._showSkip = false;
this._showReset = false;
this._showAdaptive = false;
this._skipReason = "";
this._resetDate = new Date().toISOString().slice(0, 10);
this._open = true;
await Promise.all([this._loadTask(), this._loadFeatures()]);
}
/** Pull the active feature flags so adaptive sections only render when
* Adaptive / Seasonal / Environmental are actually enabled (matches the
* panel's behaviour). Cached after first load. */
private async _loadFeatures(): Promise<void> {
if (this._featuresLoaded) return;
try {
const r = await this.hass.connection.sendMessagePromise<{
features?: Partial<AdvancedFeatures>;
}>({ type: "maintenance_supporter/settings" });
if (r?.features) {
this._features = { ...this._features, ...r.features };
}
this._featuresLoaded = true;
} catch {
// Settings endpoint unavailable — leave defaults (all false), the
// adaptive panel will then stay hidden.
}
}
public close(): void {
this._open = false;
this._task = null;
this._error = "";
}
private async _loadTask(): Promise<void> {
if (!this._entryId || !this._taskId) return;
try {
const r = await this.hass.connection.sendMessagePromise<MaintenanceObjectFull>({
type: "maintenance_supporter/object",
entry_id: this._entryId,
});
this._objectName = r.object?.name || "";
const found = (r.tasks || []).find((t) => t.id === this._taskId);
this._task = found ?? null;
} catch (e) {
this._error = describeWsError(e, this._lang);
}
}
private async _runWs(payload: Record<string, unknown>): Promise<boolean> {
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise(payload);
this._busy = false;
return true;
} catch (e) {
this._error = describeWsError(e, this._lang);
this._busy = false;
return false;
}
}
private _notifyChanged(action: string): void {
this.dispatchEvent(
new CustomEvent("task-action-fired", {
detail: { entry_id: this._entryId, task_id: this._taskId, action },
bubbles: true,
composed: true,
}),
);
}
private _onComplete(): void {
if (!this._entryId || !this._taskId || !this._task) return;
// Reuse the existing rich complete-dialog by mounting it on body
import("../dialog-mount").then(({ openCompleteDialog }) => {
const ok = openCompleteDialog({
entry_id: this._entryId!,
task_id: this._taskId!,
task_name: this._task!.name,
checklist: this._task!.checklist || [],
adaptive_enabled: !!this._task!.adaptive_config?.enabled,
});
if (ok) {
this._notifyChanged("complete");
this.close();
}
});
}
private async _onSkipConfirm(): Promise<void> {
if (!this._entryId || !this._taskId) return;
const ok = await this._runWs({
type: "maintenance_supporter/task/skip",
entry_id: this._entryId,
task_id: this._taskId,
reason: this._skipReason.trim() || null,
});
if (ok) {
this._notifyChanged("skip");
this.close();
}
}
private async _onResetConfirm(): Promise<void> {
if (!this._entryId || !this._taskId) return;
const ok = await this._runWs({
type: "maintenance_supporter/task/reset",
entry_id: this._entryId,
task_id: this._taskId,
date: this._resetDate || undefined,
});
if (ok) {
this._notifyChanged("reset");
this.close();
}
}
private _onEdit(): void {
if (!this._entryId || !this._taskId) return;
import("../dialog-mount").then(({ openEditTaskDialog }) => {
openEditTaskDialog(this._entryId!, this._taskId!);
this.close();
});
}
private _onQr(): void {
if (!this._entryId || !this._taskId || !this._task) return;
import("../dialog-mount").then(({ openQrDialog }) => {
openQrDialog({
entry_id: this._entryId!,
task_id: this._taskId!,
task_name: this._task!.name,
object_name: this._objectName,
});
this.close();
});
}
private async _onDelete(): Promise<void> {
if (!this._entryId || !this._taskId) return;
const confirmText = t("delete_task_confirm", this._lang)
|| `Delete "${this._task?.name}"?`;
if (!window.confirm(confirmText)) return;
const ok = await this._runWs({
type: "maintenance_supporter/task/delete",
entry_id: this._entryId,
task_id: this._taskId,
});
if (ok) {
this._notifyChanged("delete");
this.close();
}
}
private async _onArchive(): Promise<void> {
if (!this._entryId || !this._taskId) return;
const ok = await this._runWs({
type: "maintenance_supporter/task/archive",
entry_id: this._entryId,
task_id: this._taskId,
});
if (ok) {
this._notifyChanged("archive");
this.close();
}
}
private async _onUnarchive(): Promise<void> {
if (!this._entryId || !this._taskId) return;
const ok = await this._runWs({
type: "maintenance_supporter/task/unarchive",
entry_id: this._entryId,
task_id: this._taskId,
});
if (ok) {
this._notifyChanged("unarchive");
this.close();
}
}
private _onOpenInPanel(): void {
if (!this._entryId || !this._taskId) return;
const path = `/maintenance-supporter?entry_id=${encodeURIComponent(this._entryId)}`
+ `&task_id=${encodeURIComponent(this._taskId)}`;
history.pushState(null, "", path);
window.dispatchEvent(new CustomEvent("location-changed"));
this.close();
}
private async _applySuggestion(): Promise<void> {
if (!this._entryId || !this._taskId || !this._task?.suggested_interval) return;
const ok = await this._runWs({
type: "maintenance_supporter/task/apply_suggestion",
entry_id: this._entryId,
task_id: this._taskId,
interval: this._task.suggested_interval,
});
if (ok) {
this._toast = t("suggestion_applied", this._lang) || "Applied";
this._notifyChanged("apply_suggestion");
// Refresh local task so the recommendation card hides
await this._loadTask();
setTimeout(() => { this._toast = ""; }, 2500);
}
}
private async _reanalyzeInterval(): Promise<void> {
if (!this._entryId || !this._taskId) return;
this._busy = true;
this._error = "";
try {
const r = await this.hass.connection.sendMessagePromise<{
recommended_interval: number | null;
confidence: string;
data_points: number;
}>({
type: "maintenance_supporter/task/analyze_interval",
entry_id: this._entryId,
task_id: this._taskId,
});
this._toast = r.recommended_interval
? `${t("reanalyze_result", this._lang) || "Recomputed"}: ${r.recommended_interval}d (${r.data_points} pts)`
: (t("reanalyze_insufficient_data", this._lang) || "Not enough data");
await this._loadTask();
setTimeout(() => { this._toast = ""; }, 3500);
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
}
private _onEditHistoryEntry(entry: HistoryEntry): void {
if (!this._entryId || !this._taskId) return;
import("../dialog-mount").then(({ openHistoryEditDialog }) => {
openHistoryEditDialog({
entry_id: this._entryId!,
task_id: this._taskId!,
original_timestamp: entry.timestamp,
type: entry.type,
timestamp: entry.timestamp,
notes: entry.notes ?? null,
cost: entry.cost ?? null,
duration: entry.duration ?? null,
completed_by: entry.completed_by ?? null,
});
});
}
/** Inline recommendation card (Current vs Suggested with apply/reanalyze).
* Bars + confidence badge come from the shared renderer; the action row
* uses native <button>s because <ha-button> isn't always registered in
* the Lovelace context (same lazy-load issue that bit complete-dialog
* with ha-textfield in #50). The panel uses ha-button + adds Dismiss. */
private _renderRecommendation(task: MaintenanceTask) {
if (!this._features.adaptive
|| !task.suggested_interval
|| task.suggested_interval === task.interval_days) {
return nothing;
}
const L = this._lang;
return html`
<div class="recommendation-card">
<h4>${t("suggested_interval", L)}</h4>
${renderRecommendationBars(
task.interval_days, task.suggested_interval,
task.interval_confidence || "medium", L,
)}
<div class="recommendation-actions">
<button class="btn primary"
@click=${this._applySuggestion} ?disabled=${this._busy}>
<ha-icon icon="mdi:check"></ha-icon>
${t("apply_suggestion", L)}
</button>
<button class="btn"
@click=${this._reanalyzeInterval} ?disabled=${this._busy}>
<ha-icon icon="mdi:refresh"></ha-icon>
${t("reanalyze", L)}
</button>
</div>
</div>
`;
}
/** Adaptive section: prediction + recommendation + Weibull + seasonal,
* reusing the panel's renderers. Only renders blocks that have data. */
private _renderAdaptive(task: MaintenanceTask) {
const L = this._lang;
const hasRecommendation = this._features.adaptive
&& task.suggested_interval
&& task.suggested_interval !== task.interval_days;
const hasPrediction = (task.degradation_trend != null
&& task.degradation_trend !== "insufficient_data")
|| task.days_until_threshold != null
|| (task.environmental_factor != null && task.environmental_factor !== 1.0);
const hasWeibull = this._features.adaptive
&& task.interval_analysis?.weibull_beta != null
&& task.interval_analysis?.weibull_eta != null;
const hasSeasonal = this._features.seasonal
&& task.seasonal_factor
&& task.seasonal_factor !== 1.0;
if (!hasRecommendation && !hasPrediction && !hasWeibull && !hasSeasonal) {
return html`<div class="adaptive-empty">
${t("adaptive_no_data", L) || "Not enough completion history yet for adaptive analysis."}
</div>`;
}
return html`
<div class="adaptive-stack">
${this._toast
? html`<div class="toast">${this._toast}</div>`
: nothing}
${hasRecommendation ? this._renderRecommendation(task) : nothing}
${hasPrediction ? renderPredictionSection(task, L, this._features) : nothing}
${hasWeibull ? renderWeibullSection(task, L) : nothing}
${hasSeasonal ? html`
${renderSeasonalCardCompact(task, L, this._features)}
${task.seasonal_factors?.length === 12
|| task.interval_analysis?.seasonal_factors?.length === 12
? renderSeasonalCardExpanded(task, L)
: nothing}
` : nothing}
</div>
`;
}
/** Read-only details panel: stats + history. Shown when the user clicks
* "Show details" in the dialog. Edit-buttons on history entries open the
* existing history-edit dialog (which lives in the same dialog-mount). */
private _renderDetails(task: MaintenanceTask) {
const L = this._lang;
const history = (task.history || []) as HistoryEntry[];
const completed = history.filter((h) => h.type === "completed");
const totalCost = completed.reduce(
(s, h) => s + (typeof h.cost === "number" ? h.cost : 0),
0,
);
const avgDuration = (() => {
const durs = completed
.map((h) => (typeof h.duration === "number" ? h.duration : null))
.filter((d): d is number => d != null);
if (!durs.length) return null;
return Math.round(durs.reduce((s, d) => s + d, 0) / durs.length);
})();
return html`
<div class="details">
<div class="stats-grid">
<div class="stat">
<span class="stat-label">${t("times_performed", L) || "Performed"}</span>
<span class="stat-value">${completed.length}</span>
</div>
<div class="stat">
<span class="stat-label">${t("total_cost", L) || "Total cost"}</span>
<span class="stat-value">${totalCost.toFixed(2)}</span>
</div>
<div class="stat">
<span class="stat-label">${t("avg_duration", L) || "Avg duration"}</span>
<span class="stat-value">${avgDuration != null ? `${avgDuration}m` : "—"}</span>
</div>
</div>
<div class="history-header">
<strong>${t("history", L) || "History"}</strong>
<span class="history-count">${history.length}</span>
</div>
${history.length === 0
? html`<div class="history-empty">${t("history_empty", L) || "No history yet."}</div>`
: html`
<div class="history-list">
${[...history].reverse().slice(0, 20).map((entry) => {
const editable = ["completed", "reset", "skipped"].includes(entry.type);
return html`
<div class="history-entry">
<div class="history-line">
<span class="history-type type-${entry.type}">${t(entry.type, L)}</span>
<span class="history-date">${formatDateTime(entry.timestamp, L)}</span>
${editable
? html`<button class="history-edit"
title="${t("history_edit_button", L) || "Edit"}"
@click=${() => this._onEditHistoryEntry(entry)}>
<ha-icon icon="mdi:pencil"></ha-icon>
</button>`
: nothing}
</div>
${entry.notes
? html`<div class="history-notes">${entry.notes}</div>`
: nothing}
${entry.cost != null || entry.duration != null
? html`<div class="history-meta">
${entry.cost != null ? html`<span>💰 ${entry.cost.toFixed(2)}</span>` : nothing}
${entry.duration != null ? html`<span>⏱️ ${entry.duration}m</span>` : nothing}
</div>`
: nothing}
</div>
`;
})}
${history.length > 20
? html`<div class="history-more">… +${history.length - 20} ${t("older_entries", L) || "older"}</div>`
: nothing}
</div>
`}
</div>
`;
}
render() {
if (!this._open) return nothing;
const L = this._lang;
const task = this._task;
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">
${task
? html`
<div class="header">
<div class="title">
<span class="status-dot" style="background: ${STATUS_COLORS[task.status] || "#ccc"}"></span>
<span class="task-name">${task.name}</span>
</div>
<div class="object">
<button class="link-inline" @click=${() => {
if (!this._entryId) return;
import("../dialog-mount").then(({ openObjectQuickActions }) => {
openObjectQuickActions(this._entryId!);
this.close();
});
}}>${this._objectName}</button>
</div>
<div class="quick-info">
${task.next_due
? html`<span><strong>${t("next_due", L) || "Next due"}:</strong> ${formatDate(task.next_due, L)}</span>`
: nothing}
${task.last_performed
? html`<span><strong>${t("last_performed", L) || "Last"}:</strong> ${formatDate(task.last_performed, L)}</span>`
: nothing}
${(task.schedule?.kind && !["manual", "one_time"].includes(task.schedule.kind)) || task.interval_days != null
? html`<span><strong>${t("interval", L) || "Interval"}:</strong> ${formatRecurrence(task, L)}</span>`
: nothing}
</div>
</div>
${this._error
? html`<div class="error">${this._error}</div>`
: nothing}
${this._showSkip
? html`
<div class="inline-form">
<label>${t("skip_reason", L) || "Skip reason (optional)"}</label>
<input type="text" .value=${this._skipReason}
@input=${(e: Event) => { this._skipReason = (e.target as HTMLInputElement).value; }} />
<div class="inline-actions">
<button class="btn cancel" @click=${() => { this._showSkip = false; }} ?disabled=${this._busy}>
${t("cancel", L) || "Cancel"}
</button>
<button class="btn primary" @click=${this._onSkipConfirm} ?disabled=${this._busy}>
${t("skip", L) || "Skip"}
</button>
</div>
</div>
`
: this._showReset
? html`
<div class="inline-form">
<label>${t("reset_to_date", L) || "Reset last_performed to"}</label>
<input type="date" .value=${this._resetDate}
@input=${(e: Event) => { this._resetDate = (e.target as HTMLInputElement).value; }} />
<div class="inline-actions">
<button class="btn cancel" @click=${() => { this._showReset = false; }} ?disabled=${this._busy}>
${t("cancel", L) || "Cancel"}
</button>
<button class="btn primary" @click=${this._onResetConfirm} ?disabled=${this._busy}>
${t("reset", L) || "Reset"}
</button>
</div>
</div>
`
: html`
<div class="actions primary-row">
<button class="btn primary" @click=${this._onComplete} ?disabled=${this._busy}>
<ha-icon icon="mdi:check"></ha-icon>
${t("complete", L) || "Complete"}
</button>
<button class="btn" @click=${() => { this._showSkip = true; }} ?disabled=${this._busy}>
<ha-icon icon="mdi:skip-next"></ha-icon>
${t("skip", L) || "Skip"}
</button>
<button class="btn" @click=${() => { this._showReset = true; }} ?disabled=${this._busy}>
<ha-icon icon="mdi:restart"></ha-icon>
${t("reset", L) || "Reset"}
</button>
</div>
${isAdmin
? html`
<div class="actions secondary-row">
<button class="btn ghost" @click=${this._onEdit} ?disabled=${this._busy}>
<ha-icon icon="mdi:pencil"></ha-icon>
${t("edit", L) || "Edit"}
</button>
<button class="btn ghost" @click=${this._onQr} ?disabled=${this._busy}>
<ha-icon icon="mdi:qrcode"></ha-icon>
${t("qr_code", L) || "QR"}
</button>
<button class="btn ghost"
@click=${task.archived ? this._onUnarchive : this._onArchive}
?disabled=${this._busy}>
<ha-icon icon="${task.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'}"></ha-icon>
${task.archived ? (t("unarchive", L) || "Unarchive") : (t("archive", L) || "Archive")}
</button>
<button class="btn ghost danger" @click=${this._onDelete} ?disabled=${this._busy}>
<ha-icon icon="mdi:delete"></ha-icon>
${t("delete", L) || "Delete"}
</button>
</div>
`
: nothing}
<div class="details-toggle">
<button class="link" @click=${() => { this._showDetails = !this._showDetails; }}>
<ha-icon icon="${this._showDetails ? 'mdi:chevron-up' : 'mdi:chevron-down'}"></ha-icon>
${this._showDetails
? (t("hide_details", L) || "Hide details")
: (t("show_details", L) || "Show history + stats")}
</button>
${this._features.adaptive
|| this._features.seasonal
|| this._features.environmental
? html`<button class="link" @click=${() => { this._showAdaptive = !this._showAdaptive; }}>
<ha-icon icon="${this._showAdaptive ? 'mdi:chart-line' : 'mdi:chart-line-variant'}"></ha-icon>
${this._showAdaptive
? (t("hide_stats", L) || "Hide stats")
: (t("show_stats", L) || "Show stats + graphs")}
</button>`
: nothing}
</div>
${this._showDetails ? this._renderDetails(task) : nothing}
${this._showAdaptive ? this._renderAdaptive(task) : nothing}
<div class="footer">
<button class="link" @click=${this._onOpenInPanel}>
<ha-icon icon="mdi:open-in-new"></ha-icon>
${t("open_in_panel", L) || "Open in Maintenance panel"}
</button>
</div>
`}
`
: html`<div class="loading">${t("loading", L) || "Loading…"}</div>`}
</div>
`;
}
static styles = [sharedStyles, 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: 460px;
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;
display: flex; flex-direction: column; gap: 14px;
z-index: 101;
}
.header { display: flex; flex-direction: column; gap: 6px; }
.title { display: flex; align-items: center; gap: 10px; }
.status-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
.task-name { font-size: 18px; font-weight: 600; }
.object { font-size: 13px; color: var(--secondary-text-color); }
.link-inline {
background: transparent; border: none; padding: 0; cursor: pointer;
color: var(--primary-color); font-size: inherit; font-family: inherit;
}
.link-inline:hover { text-decoration: underline; }
.quick-info {
display: flex; flex-wrap: wrap; gap: 12px;
font-size: 12px; color: var(--secondary-text-color);
padding-top: 4px; border-top: 1px solid var(--divider-color);
}
.quick-info strong { color: var(--primary-text-color); font-weight: 500; }
.actions { display: flex; gap: 8px; }
.actions.primary-row { gap: 6px; }
.actions.primary-row .btn { flex: 1; }
/* Edit + QR are admin-tools — left-align as a group; Delete is destructive
so it gets pushed to the far right with margin-left:auto for visual
separation. Earlier this row was flex-end which left a strange empty
gap on the left (user feedback). */
.actions.secondary-row {
padding-top: 8px; border-top: 1px solid var(--divider-color);
justify-content: flex-start;
}
.actions.secondary-row .btn.danger {
margin-left: auto;
}
.btn {
padding: 8px 12px; font-size: 14px;
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; gap: 6px;
transition: background 0.12s;
}
.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.cancel { background: transparent; }
.btn.ghost { padding: 6px 10px; font-size: 13px; }
.btn.danger { color: var(--error-color); }
.btn ha-icon { --mdc-icon-size: 18px; }
.inline-form { display: flex; flex-direction: column; gap: 8px; }
.inline-form label { font-size: 13px; color: var(--secondary-text-color); }
.inline-form input {
padding: 8px; font-size: 14px;
background: var(--secondary-background-color, #2c2c2c);
color: var(--primary-text-color);
border: 1px solid var(--divider-color, #444);
border-radius: 6px;
}
.inline-actions { display: flex; gap: 8px; justify-content: flex-end; }
.footer { display: flex; justify-content: center; padding-top: 4px; }
.link {
background: transparent; border: none; cursor: pointer;
color: var(--primary-color); font-size: 13px;
display: inline-flex; align-items: center; gap: 4px;
}
.link:hover { text-decoration: underline; }
.link ha-icon { --mdc-icon-size: 14px; }
.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, #d32f2f); font-size: 13px;
}
/* Details (expandable Show details section) */
.details-toggle { display: flex; justify-content: center; margin-top: 4px; }
.details {
display: flex; flex-direction: column; gap: 12px;
border-top: 1px solid var(--divider-color);
padding-top: 12px;
}
.stats-grid {
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px;
}
.stat {
display: flex; flex-direction: column; gap: 2px;
background: var(--secondary-background-color, rgba(255,255,255,0.04));
padding: 8px; border-radius: 6px;
align-items: center;
}
.stat-label { font-size: 11px; color: var(--secondary-text-color); text-transform: uppercase; letter-spacing: 0.5px; }
.stat-value { font-size: 16px; font-weight: 600; }
.history-header {
display: flex; align-items: baseline; gap: 8px;
font-size: 14px;
}
.history-count {
font-size: 12px; color: var(--secondary-text-color);
background: var(--secondary-background-color); padding: 2px 8px; border-radius: 999px;
}
.history-empty { color: var(--secondary-text-color); font-style: italic; font-size: 13px; }
.history-list { display: flex; flex-direction: column; gap: 8px; max-height: 280px; overflow: auto; }
.history-entry {
padding: 6px 8px; border-radius: 6px;
background: var(--secondary-background-color, rgba(255,255,255,0.03));
font-size: 13px;
}
.history-line {
display: flex; align-items: center; gap: 8px;
justify-content: space-between;
}
.history-type {
font-weight: 600; font-size: 11px;
padding: 2px 6px; border-radius: 4px;
text-transform: uppercase; letter-spacing: 0.5px;
}
.type-completed { background: rgba(46,125,50,0.2); color: #66bb6a; }
.type-skipped { background: rgba(158,158,158,0.2); color: var(--secondary-text-color); }
.type-reset { background: rgba(33,150,243,0.2); color: #64b5f6; }
.type-triggered { background: rgba(255,87,34,0.2); color: #ff8a65; }
.history-date { font-size: 11px; color: var(--secondary-text-color); flex: 1; text-align: right; }
.history-edit {
background: transparent; border: none; cursor: pointer;
padding: 4px; border-radius: 4px;
color: var(--secondary-text-color);
}
.history-edit:hover { background: var(--state-icon-color, rgba(255,255,255,0.06)); color: var(--primary-color); }
.history-edit ha-icon { --mdc-icon-size: 14px; }
.history-notes { margin-top: 4px; color: var(--primary-text-color); }
.history-meta { display: flex; gap: 12px; margin-top: 4px; color: var(--secondary-text-color); font-size: 11px; }
.history-more { padding: 8px; text-align: center; font-size: 12px; color: var(--secondary-text-color); font-style: italic; }
/* Adaptive section — wraps the panel renderers (which assume sharedStyles
are present) and adds dialog-specific layout. */
.adaptive-stack {
display: flex; flex-direction: column; gap: 12px;
border-top: 1px solid var(--divider-color);
padding-top: 12px;
}
.adaptive-empty {
padding: 16px; text-align: center;
color: var(--secondary-text-color);
font-style: italic; font-size: 13px;
border-top: 1px solid var(--divider-color);
}
.toast {
padding: 8px 12px; border-radius: 6px;
background: rgba(76, 175, 80, 0.15);
color: #4caf50; font-size: 13px; font-weight: 500;
}
/* The panel's recommendation-card uses ha-button. We use plain <button>
in this dialog's button styles. Re-style the action row to match. */
.recommendation-actions {
display: flex; gap: 8px; margin-top: 8px;
}
/* Constrain SVG charts so they fit the dialog width even on mobile. */
.weibull-section, .seasonal-card-compact { max-width: 100%; }
.weibull-chart svg { max-width: 100%; height: auto; }
.details-toggle { gap: 12px; flex-wrap: wrap; }
`];
}
if (!customElements.get("maintenance-task-quick-actions-dialog")) {
customElements.define(
"maintenance-task-quick-actions-dialog",
MaintenanceTaskQuickActionsDialog,
);
}
@@ -0,0 +1,382 @@
/** Responsive sensor-history chart for the task detail view.
*
* Replaces the fixed 300x140 sparkline: renders at the card's real pixel
* width (ResizeObserver), draws round y-ticks with gridlines, shades the
* danger zone beyond a threshold (with the in-zone line segments in the
* error color via clip paths), pins a target line for counters, moves
* completion markers into a calm bottom lane, and offers a crosshair that
* follows the pointer (works for touch) plus 7d/30d/90d/1y range chips.
*
* The component is deliberately dumb: it plots the points and reference
* lines it is given. All task semantics (delta baselines, cumulative
* clamping, projections) live in renderers/sparkline.ts.
*/
import { LitElement, html, css, svg, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t } from "../styles";
import { niceTicks, fmtNum, fmtVal, fmtDateTick, fmtDateTime, timeTicks, needsYear } from "../renderers/chart-utils";
export interface ChartPoint {
ts: number;
val: number;
min?: number;
max?: number;
}
export interface ChartEvent {
ts: number;
type: string; // completed | skipped | reset
}
const H = 210;
const PAD_L = 46;
const PAD_R = 14;
const PAD_T = 12;
const LANE_H = 14; // completion-marker lane between plot and x labels
const PAD_B = 20 + LANE_H;
const RANGES = [
{ days: 7, key: "chart_range_7d" },
{ days: 30, key: "chart_range_30d" },
{ days: 90, key: "chart_range_90d" },
{ days: 365, key: "chart_range_1y" },
] as const;
export class MaintenanceTriggerChart extends LitElement {
@property({ attribute: false }) public points: ChartPoint[] = [];
@property({ attribute: false }) public events: ChartEvent[] = [];
@property() public unit = "";
@property() public lang = "en";
@property({ attribute: false }) public thresholdAbove: number | null = null;
@property({ attribute: false }) public thresholdBelow: number | null = null;
/** Absolute y of a counter target line (points are pre-transformed). */
@property({ attribute: false }) public targetValue: number | null = null;
@property({ type: Boolean }) public forceZero = false;
/** Optional dashed projection segment (e.g. degradation trend). */
@property({ attribute: false }) public projection: ChartPoint[] | null = null;
@property({ attribute: false }) public rangeDays = 30;
@property({ type: Boolean }) public showRange = true;
@property({ type: Boolean }) public busy = false;
/** Reflects the parent's "hide outliers" state (filtering happens upstream). */
@property({ type: Boolean }) public hideOutliers = false;
/** Show the outlier toggle chip (only where a raw oscillating series exists). */
@property({ type: Boolean }) public showOutlierToggle = true;
@state() private _width = 0;
@state() private _hover: { x: number; y: number; p: ChartPoint } | null = null;
private _ro: ResizeObserver | null = null;
connectedCallback(): void {
super.connectedCallback();
this._ro = new ResizeObserver((entries) => {
const w = Math.floor(entries[0]?.contentRect?.width || 0);
if (w && Math.abs(w - this._width) > 2) this._width = w;
});
this._ro.observe(this);
}
disconnectedCallback(): void {
super.disconnectedCallback();
this._ro?.disconnect();
this._ro = null;
}
private _emitRange(days: number): void {
if (days === this.rangeDays) return;
this.dispatchEvent(new CustomEvent("range-change", { detail: { days }, bubbles: true, composed: true }));
}
private _toggleOutliers(): void {
this.dispatchEvent(new CustomEvent("outlier-toggle", {
detail: { hide: !this.hideOutliers }, bubbles: true, composed: true,
}));
}
render() {
const W = this._width || 320;
const pts = [...this.points].sort((a, b) => a.ts - b.ts);
const L = this.lang;
return html`
<div class="chart-wrap">
${this.showRange
? html`<div class="range-chips" role="group">
${this.showOutlierToggle
? html`<button
class="range-chip outlier-chip ${this.hideOutliers ? "active" : ""}"
?disabled=${this.busy}
title=${t("hide_outliers", L)}
@click=${() => this._toggleOutliers()}
><ha-icon icon="mdi:filter-variant"></ha-icon></button>`
: nothing}
${RANGES.map(
(r) => html`<button
class="range-chip ${this.rangeDays === r.days ? "active" : ""}"
?disabled=${this.busy}
@click=${() => this._emitRange(r.days)}
>${t(r.key, L)}</button>`,
)}
</div>`
: nothing}
${pts.length < 2
? html`<div class="chart-empty">
<ha-icon icon="mdi:chart-line"></ha-icon> ${t("loading_chart", L)}
</div>`
: this._renderSvg(W, pts)}
</div>
`;
}
private _renderSvg(W: number, pts: ChartPoint[]) {
const L = this.lang;
const plotW = W - PAD_L - PAD_R;
const plotB = H - PAD_B;
const plotH = plotB - PAD_T;
// Domain: data min/max band reference lines optional zero floor.
let lo = Infinity;
let hi = -Infinity;
for (const p of pts) {
lo = Math.min(lo, p.min ?? p.val);
hi = Math.max(hi, p.max ?? p.val);
}
if (this.thresholdAbove != null) { lo = Math.min(lo, this.thresholdAbove); hi = Math.max(hi, this.thresholdAbove); }
if (this.thresholdBelow != null) { lo = Math.min(lo, this.thresholdBelow); hi = Math.max(hi, this.thresholdBelow); }
if (this.targetValue != null) { lo = Math.min(lo, this.targetValue); hi = Math.max(hi, this.targetValue); }
if (this.forceZero) lo = Math.min(lo, 0);
const pad = (hi - lo || 1) * 0.06;
// A progress axis never dips below zero — padding must not create a -50 tick.
const loPadded = this.forceZero && lo >= 0 ? 0 : lo - pad;
let { ticks, niceMin, niceMax } = niceTicks(loPadded, hi + pad, 4);
if (this.forceZero && lo >= 0 && niceMin < 0) {
niceMin = 0;
ticks = ticks.filter((v) => v >= 0);
}
const tsMin = pts[0].ts;
const tsMax = pts[pts.length - 1].ts;
const tsSpan = tsMax - tsMin || 1;
const withYear = needsYear(tsMin, tsMax);
const toX = (ts: number) => PAD_L + ((ts - tsMin) / tsSpan) * plotW;
const toY = (v: number) => PAD_T + (1 - (v - niceMin) / (niceMax - niceMin || 1)) * plotH;
const linePts = pts.map((p) => `${toX(p.ts).toFixed(1)},${toY(p.val).toFixed(1)}`).join(" ");
const areaPath =
`M${toX(pts[0].ts).toFixed(1)},${plotB} ` +
pts.map((p) => `L${toX(p.ts).toFixed(1)},${toY(p.val).toFixed(1)}`).join(" ") +
` L${toX(pts[pts.length - 1].ts).toFixed(1)},${plotB} Z`;
// Optional min/max band behind the line.
let bandPath = "";
const band = pts.filter((p) => p.min != null && p.max != null);
if (band.length >= 2) {
const up = band.map((p) => `${toX(p.ts).toFixed(1)},${toY(p.max!).toFixed(1)}`);
const dn = [...band].reverse().map((p) => `${toX(p.ts).toFixed(1)},${toY(p.min!).toFixed(1)}`);
bandPath = `M${up[0]} ` + up.slice(1).map((x) => `L${x}`).join(" ") + ` L${dn.join(" L")} Z`;
}
// Danger zones: rect (shading + line-color clip), the threshold line y,
// and where its label sits (just inside the zone).
const zones: { y: number; h: number; lineY: number; label: string; labelY: number }[] = [];
if (this.thresholdBelow != null) {
const zy = toY(this.thresholdBelow);
zones.push({ y: zy, h: Math.max(0, plotB - zy), lineY: zy, label: `${fmtNum(this.thresholdBelow)}`, labelY: Math.min(plotB - 4, zy + 13) });
}
if (this.thresholdAbove != null) {
const zy = toY(this.thresholdAbove);
zones.push({ y: PAD_T, h: Math.max(0, zy - PAD_T), lineY: zy, label: `${fmtNum(this.thresholdAbove)}`, labelY: Math.max(PAD_T + 11, zy - 5) });
}
const lastP = pts[pts.length - 1];
const events = (this.events || []).filter((e) => e.ts >= tsMin && e.ts <= tsMax);
const xTicks = timeTicks(tsMin, tsMax, Math.max(2, Math.min(5, Math.floor(plotW / 110) + 1)));
const hover = this._hover;
return html`
<div class="svg-holder">
<svg
class="chart-svg"
viewBox="0 0 ${W} ${H}"
width=${W}
height=${H}
role="img"
aria-label=${t("chart_sparkline", L)}
@pointermove=${(e: PointerEvent) => this._onPointer(e, pts, toX, toY, W)}
@pointerdown=${(e: PointerEvent) => this._onPointer(e, pts, toX, toY, W)}
@pointerleave=${() => (this._hover = null)}
>
<defs>
<clipPath id="plot"><rect x="${PAD_L}" y="${PAD_T}" width="${plotW}" height="${plotH}" /></clipPath>
${zones.length
? svg`<clipPath id="danger">${zones.map((z) => svg`<rect x="${PAD_L}" y="${z.y.toFixed(1)}" width="${plotW}" height="${z.h.toFixed(1)}" />`)}</clipPath>`
: nothing}
<!-- Diagonal hatch so the danger zone reads without relying on the
red tint alone (dark-theme contrast + colour-blind support). -->
<pattern id="dangerHatch" patternUnits="userSpaceOnUse" width="7" height="7" patternTransform="rotate(45)">
<rect width="7" height="7" fill="var(--error-color, #f44336)" opacity="0.10" />
<line x1="0" y1="0" x2="0" y2="7" stroke="var(--error-color, #f44336)" stroke-width="1.4" opacity="0.5" />
</pattern>
</defs>
${ticks.map((v) => {
const y = toY(v);
if (y < PAD_T - 1 || y > plotB + 1) return nothing;
return svg`
<line x1="${PAD_L}" y1="${y.toFixed(1)}" x2="${W - PAD_R}" y2="${y.toFixed(1)}"
stroke="var(--divider-color)" stroke-width="1" opacity="0.6" />
<text x="${PAD_L - 7}" y="${(y + 3.5).toFixed(1)}" text-anchor="end" class="tick-label">${fmtNum(v)}</text>`;
})}
${zones.map(
(z) => svg`<rect x="${PAD_L}" y="${z.y.toFixed(1)}" width="${plotW}" height="${z.h.toFixed(1)}"
fill="url(#dangerHatch)" />`,
)}
${bandPath ? svg`<path d="${bandPath}" fill="var(--primary-color)" opacity="0.08" clip-path="url(#plot)" />` : nothing}
<path d="${areaPath}" fill="var(--primary-color)" opacity="0.10" clip-path="url(#plot)" />
<polyline points="${linePts}" fill="none" stroke="var(--primary-color)" stroke-width="2"
stroke-linejoin="round" stroke-linecap="round" clip-path="url(#plot)" />
${zones.length
? svg`<polyline points="${linePts}" fill="none" stroke="var(--error-color, #f44336)" stroke-width="2"
stroke-linejoin="round" stroke-linecap="round" clip-path="url(#danger)" />`
: nothing}
${zones.map(
(z) => svg`
<line x1="${PAD_L}" y1="${z.lineY.toFixed(1)}" x2="${W - PAD_R}" y2="${z.lineY.toFixed(1)}"
stroke="var(--error-color, #f44336)" stroke-width="1.5" stroke-dasharray="6,4" />
<text x="${W - PAD_R - 4}" y="${z.labelY.toFixed(1)}" text-anchor="end" class="zone-label">${z.label}</text>`,
)}
${this.targetValue != null
? svg`<line x1="${PAD_L}" y1="${toY(this.targetValue).toFixed(1)}" x2="${W - PAD_R}" y2="${toY(this.targetValue).toFixed(1)}"
stroke="var(--error-color, #f44336)" stroke-width="1.5" stroke-dasharray="6,4" />
<text x="${W - PAD_R - 4}" y="${(toY(this.targetValue) - 5).toFixed(1)}" text-anchor="end" class="zone-label">◆ ${fmtNum(this.targetValue)} ${this.unit}</text>`
: nothing}
${this.projection && this.projection.length === 2
? svg`<line x1="${toX(this.projection[0].ts).toFixed(1)}" y1="${toY(this.projection[0].val).toFixed(1)}"
x2="${Math.min(toX(this.projection[1].ts), W - PAD_R).toFixed(1)}" y2="${toY(Math.max(niceMin, Math.min(niceMax, this.projection[1].val))).toFixed(1)}"
stroke="var(--warning-color, #ff9800)" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.8" />`
: nothing}
${xTicks.map((ts, i) => {
const x = toX(ts);
const anchor = i === 0 ? "start" : i === xTicks.length - 1 ? "end" : "middle";
return svg`<text x="${x.toFixed(1)}" y="${H - 5}" text-anchor="${anchor}" class="tick-label">${fmtDateTick(ts, L, withYear)}</text>`;
})}
<line x1="${PAD_L}" y1="${plotB}" x2="${W - PAD_R}" y2="${plotB}" stroke="var(--divider-color)" stroke-width="1" />
${events.map((e) => {
const x = toX(e.ts);
const color =
e.type === "completed"
? "var(--success-color, #4caf50)"
: e.type === "skipped"
? "var(--warning-color, #ff9800)"
: "var(--info-color, #2196f3)";
return svg`
<line x1="${x.toFixed(1)}" y1="${PAD_T}" x2="${x.toFixed(1)}" y2="${plotB}" stroke="${color}" stroke-width="1" opacity="0.14" />
<rect x="${(x - 1.5).toFixed(1)}" y="${plotB + 3}" width="3" height="${LANE_H - 6}" rx="1.5" fill="${color}">
<title>${fmtDateTime(e.ts, L)}</title>
</rect>`;
})}
${hover
? svg`
<line x1="${hover.x.toFixed(1)}" y1="${PAD_T}" x2="${hover.x.toFixed(1)}" y2="${plotB}"
stroke="var(--secondary-text-color)" stroke-width="1" stroke-dasharray="3,3" opacity="0.7" />
<circle cx="${hover.x.toFixed(1)}" cy="${hover.y.toFixed(1)}" r="4.5" fill="var(--primary-color)"
stroke="var(--card-background-color, #fff)" stroke-width="2" />`
: svg`<circle cx="${toX(lastP.ts).toFixed(1)}" cy="${toY(lastP.val).toFixed(1)}" r="4" fill="var(--primary-color)"
stroke="var(--card-background-color, #fff)" stroke-width="1.5" />`}
</svg>
${hover
? html`<div
class="hover-chip"
style="left:${Math.min(Math.max(hover.x, 70), W - 70)}px"
>
<div class="hover-date">${fmtDateTime(hover.p.ts, L)}</div>
<div class="hover-val">
${fmtVal(hover.p.val, this.unit, L)}
${hover.p.min != null && hover.p.max != null
? html`<span class="hover-range">(${fmtNum(hover.p.min)}${fmtNum(hover.p.max)})</span>`
: nothing}
</div>
</div>`
: nothing}
</div>
`;
}
private _onPointer(
e: PointerEvent,
pts: ChartPoint[],
toX: (ts: number) => number,
toY: (v: number) => number,
W: number,
): void {
const svgEl = e.currentTarget as SVGSVGElement;
const rect = svgEl.getBoundingClientRect();
const px = ((e.clientX - rect.left) / rect.width) * W;
if (px < PAD_L - 8 || px > W - PAD_R + 8) {
this._hover = null;
return;
}
let best = pts[0];
let bestD = Infinity;
for (const p of pts) {
const d = Math.abs(toX(p.ts) - px);
if (d < bestD) {
bestD = d;
best = p;
}
}
this._hover = { x: toX(best.ts), y: toY(best.val), p: best };
}
static styles = css`
:host { display: block; width: 100%; }
.chart-wrap { position: relative; }
.range-chips { display: flex; gap: 4px; justify-content: flex-end; margin-bottom: 2px; }
.range-chip {
font: inherit; font-size: 11.5px; padding: 2px 9px; border-radius: 12px; cursor: pointer;
border: 1px solid var(--divider-color); background: transparent;
color: var(--secondary-text-color);
}
/* Outlier toggle sits left of the range chips as an icon button. */
.outlier-chip { margin-right: auto; padding: 2px 7px; display: inline-flex; align-items: center; }
.outlier-chip ha-icon { --mdc-icon-size: 15px; }
.range-chip.active {
background: var(--primary-color); border-color: var(--primary-color);
color: var(--text-primary-color, #fff);
}
.range-chip[disabled] { opacity: 0.5; pointer-events: none; }
.svg-holder { position: relative; }
.chart-svg { display: block; touch-action: pan-y; }
.tick-label { fill: var(--secondary-text-color); font-size: 10.5px; }
.zone-label { fill: var(--error-color, #f44336); font-size: 11px; font-weight: 600; }
.chart-empty {
display: flex; align-items: center; justify-content: center; gap: 8px; height: 120px;
color: var(--secondary-text-color); font-size: 12.5px;
}
.chart-empty ha-icon { --mdc-icon-size: 17px; }
.hover-chip {
position: absolute; top: 0; transform: translateX(-50%);
background: var(--card-background-color, #fff); border: 1px solid var(--divider-color);
border-radius: 8px; padding: 4px 9px; pointer-events: none; white-space: nowrap;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); z-index: 3;
}
.hover-date { font-size: 10.5px; color: var(--secondary-text-color); }
.hover-val { font-size: 12.5px; font-weight: 600; color: var(--primary-text-color); }
.hover-range { font-weight: 400; color: var(--secondary-text-color); font-size: 11px; }
`;
}
if (!customElements.get("maintenance-trigger-chart")) {
customElements.define("maintenance-trigger-chart", MaintenanceTriggerChart);
}
@@ -0,0 +1,327 @@
/** 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,
});