New apps Added
This commit is contained in:
+271
@@ -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);
|
||||
}
|
||||
+340
@@ -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,
|
||||
});
|
||||
+259
@@ -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);
|
||||
}
|
||||
+320
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
+199
@@ -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);
|
||||
}
|
||||
+77
@@ -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
+361
@@ -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);
|
||||
}
|
||||
+815
@@ -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);
|
||||
}
|
||||
+327
@@ -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,
|
||||
});
|
||||
Reference in New Issue
Block a user