182 files
This commit is contained in:
+2
-2
@@ -9,7 +9,7 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
|
||||
import { t, ensureLocale } from "../styles";
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { UserService } from "../user-service";
|
||||
import type { HAUser, HomeAssistant } from "../types";
|
||||
@@ -55,7 +55,7 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
private _userService: UserService | null = null;
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
updated(changed: Map<string, unknown>): void {
|
||||
|
||||
+6
-15
@@ -6,7 +6,8 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
|
||||
import { t, ensureLocale } from "../styles";
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { LS_KEYS, lsGet, lsSet } from "../helpers/storage-keys";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
@@ -84,7 +85,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
private _localeReady = false;
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
@@ -232,24 +233,14 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
private static readonly _SORT_KEY = "ms_bf_roster_sort";
|
||||
|
||||
private static _storedSort(): "name" | "urgency" {
|
||||
try {
|
||||
const v = localStorage.getItem(MaintenanceBatteryFleetSection._SORT_KEY);
|
||||
return v === "name" ? "name" : "urgency";
|
||||
} catch {
|
||||
return "urgency";
|
||||
}
|
||||
return lsGet(LS_KEYS.batteryRosterSort) === "name" ? "name" : "urgency";
|
||||
}
|
||||
|
||||
private _setSort(mode: "name" | "urgency"): void {
|
||||
this._rosterSort = mode;
|
||||
try {
|
||||
localStorage.setItem(MaintenanceBatteryFleetSection._SORT_KEY, mode);
|
||||
} catch {
|
||||
// storage unavailable — the toggle still works for this visit
|
||||
}
|
||||
// Storage may be unavailable — the toggle still works for this visit.
|
||||
lsSet(LS_KEYS.batteryRosterSort, mode);
|
||||
}
|
||||
|
||||
/** Urgency (the default, issue #123): low rows first — emptiest first —
|
||||
|
||||
+4
-5
@@ -8,7 +8,8 @@
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale, DEFAULT_CURRENCY_SYMBOL } from "../styles";
|
||||
import { t, ensureLocale, DEFAULT_CURRENCY_SYMBOL, langOf } from "../styles";
|
||||
import { registerCustomCard } from "../helpers/register-card";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { sectionCardSharedStyles } from "./section-card-shared-styles";
|
||||
import type { BudgetStatus, HomeAssistant } from "../types";
|
||||
@@ -40,7 +41,7 @@ export class MaintenanceBudgetSectionCard extends LitElement {
|
||||
}
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
private get _isAdmin(): boolean {
|
||||
@@ -277,9 +278,7 @@ if (!customElements.get("maintenance-budget-section-card")) {
|
||||
);
|
||||
}
|
||||
|
||||
(window as { customCards?: unknown[] }).customCards =
|
||||
(window as { customCards?: unknown[] }).customCards || [];
|
||||
((window as { customCards?: unknown[] }).customCards!).push({
|
||||
registerCustomCard({
|
||||
type: "maintenance-budget-section-card",
|
||||
name: "Maintenance Supporter — Budget",
|
||||
description: "Inline monthly + yearly budget editor",
|
||||
|
||||
+7
-31
@@ -10,10 +10,12 @@
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { isSafeHttpUrl } from "../helpers/url";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale } from "../styles";
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { downloadUrl } from "../helpers/download";
|
||||
import { downloadSignedDocument, openSignedDocument, signDocumentPath } from "../helpers/document-url";
|
||||
import { formatBytes } from "../helpers/format-bytes";
|
||||
import { CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
interface MaintenanceDocument {
|
||||
@@ -28,16 +30,6 @@ interface MaintenanceDocument {
|
||||
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;
|
||||
@@ -67,16 +59,11 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
}
|
||||
|
||||
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;
|
||||
return signDocumentPath(this.hass, doc.id);
|
||||
}
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
updated(changed: Map<string, unknown>): void {
|
||||
@@ -210,12 +197,7 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
|
||||
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");
|
||||
await downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document");
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
@@ -228,15 +210,9 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
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;
|
||||
await openSignedDocument(this.hass, doc.id);
|
||||
} catch (e) {
|
||||
if (win) win.close();
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
|
||||
import { t } from "../styles";
|
||||
import { t, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import "./ms-textfield";
|
||||
import type {
|
||||
@@ -26,7 +26,7 @@ export class MaintenanceGroupDialog extends LitElement {
|
||||
@state() private _selected: Set<string> = new Set(); // "entry_id:task_id"
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en";
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
public openCreate(): void {
|
||||
|
||||
+4
-5
@@ -8,7 +8,8 @@
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale } from "../styles";
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { registerCustomCard } from "../helpers/register-card";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { sectionCardSharedStyles } from "./section-card-shared-styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
@@ -47,7 +48,7 @@ export class MaintenanceGroupsSectionCard extends LitElement {
|
||||
}
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
private get _isAdmin(): boolean {
|
||||
@@ -330,9 +331,7 @@ if (!customElements.get("maintenance-groups-section-card")) {
|
||||
);
|
||||
}
|
||||
|
||||
(window as { customCards?: unknown[] }).customCards =
|
||||
(window as { customCards?: unknown[] }).customCards || [];
|
||||
((window as { customCards?: unknown[] }).customCards!).push({
|
||||
registerCustomCard({
|
||||
type: "maintenance-groups-section-card",
|
||||
name: "Maintenance Supporter — Groups",
|
||||
description: "Inline group CRUD",
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t } from "../styles";
|
||||
import { t, langOf } from "../styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
|
||||
@@ -50,7 +50,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
private _originalSnapshot: HistoryEntryDraft | null = null;
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
// #130: selectable parts + the edited selection (part key -> quantity;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { signDocumentPath } from "../helpers/document-url";
|
||||
|
||||
export class MaintenanceHistoryPhoto extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -29,12 +30,7 @@ export class MaintenanceHistoryPhoto extends LitElement {
|
||||
|
||||
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;
|
||||
this._url = await signDocumentPath(this.hass, this.docId);
|
||||
} catch {
|
||||
this._failed = true;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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 { t, langOf } from "../styles";
|
||||
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import "./ms-textfield";
|
||||
@@ -33,7 +33,7 @@ export class MaintenanceObjectDialog extends LitElement {
|
||||
@state() private _entryId: string | null = null; // null = create, string = update
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en";
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
public openCreate(): void {
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { isSafeHttpUrl } from "../helpers/url";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, STATUS_COLORS } from "../styles";
|
||||
import { t, STATUS_COLORS, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import type { HomeAssistant, MaintenanceObject, MaintenanceTask } from "../types";
|
||||
|
||||
@@ -31,7 +31,7 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
@state() private _error = "";
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
public async openFor(entryId: string): Promise<void> {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { isSafeHttpUrl } from "../helpers/url";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale } from "../styles";
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import type { HomeAssistant, MaintenancePart } from "../types";
|
||||
// Per-part document links (v2.26) — the task-documents component in part mode.
|
||||
@@ -70,7 +70,7 @@ export class MaintenancePartsSection extends LitElement {
|
||||
@state() private _docsFor: string | null = null;
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.locale?.language || this.hass?.language || "en";
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
|
||||
+3
-3
@@ -12,7 +12,7 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
|
||||
import { t, ensureLocale } from "../styles";
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import type { HomeAssistant, SavedView, SavedViewFilters } from "../types";
|
||||
|
||||
@@ -33,7 +33,7 @@ export class MaintenanceSavedViewsDialog extends LitElement {
|
||||
private _localeReady = false;
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
updated(changed: Map<string, unknown>): void {
|
||||
@@ -171,7 +171,7 @@ export class MaintenanceSavedViewsDialog extends LitElement {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 340px;
|
||||
min-width: min(360px, calc(100vw - 24px));
|
||||
max-width: 480px;
|
||||
width: 90vw;
|
||||
max-height: 80vh;
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
|
||||
import { t } from "../styles";
|
||||
import { t, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
@@ -23,7 +23,7 @@ export class SeasonalOverridesDialog extends LitElement {
|
||||
@state() private _values: string[] = new Array(12).fill("");
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en";
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
public open(entryId: string, taskId: string, currentOverrides: Record<number, number> | null | undefined): void {
|
||||
|
||||
@@ -4,7 +4,9 @@ import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import type { HomeAssistant, AdvancedFeatures, BudgetStatus, HAUser } from "../types";
|
||||
import { t } from "../styles";
|
||||
import { t, langOf } from "../styles";
|
||||
import { signApiPath } from "../helpers/document-url";
|
||||
import { downloadUrl } from "../helpers/download";
|
||||
import { UserService } from "../user-service";
|
||||
import { OBJECT_COLUMNS, sanitizeColumns } from "../helpers/object-columns";
|
||||
import { downloadTextFile } from "../helpers/download";
|
||||
@@ -160,7 +162,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
private _userService: UserService | null = null;
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
updated(changedProps: Map<string, unknown>): void {
|
||||
@@ -1430,6 +1432,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
<button @click=${this._exportJson}>${t("settings_export_json", L)}</button>
|
||||
<button @click=${this._exportYaml}>${t("settings_export_yaml", L)}</button>
|
||||
<button @click=${this._exportCsv}>${t("settings_export_csv", L)}</button>
|
||||
<button @click=${this._exportSettings}>${t("settings_export_settings", L)}</button>
|
||||
</div>
|
||||
<div class="settings-actions docs-archive-block">
|
||||
<h4>${t("settings_docs_archive", L)}</h4>
|
||||
@@ -1510,6 +1513,22 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** The SECOND export: the global entry's settings (groups, saved views,
|
||||
* vacation, notification/budget settings, feature toggles) — the objects
|
||||
* export deliberately excludes them. Re-import via the regular import. */
|
||||
private async _exportSettings(): Promise<void> {
|
||||
try {
|
||||
const result = await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/settings/export",
|
||||
}) as { data: string };
|
||||
const ts = new Date().toISOString().slice(0, 10);
|
||||
this._downloadFile(result.data, `maintenance_settings_${ts}.json`, "application/json");
|
||||
this._showToast(t("settings_export_success", this._lang));
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
}
|
||||
}
|
||||
|
||||
private async _exportYaml(): Promise<void> {
|
||||
try {
|
||||
const ids = this._selectedEntryIds;
|
||||
@@ -1573,17 +1592,8 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
try {
|
||||
const raw = this._selectedEntryIds;
|
||||
const q = raw ? `?entry_ids=${encodeURIComponent(raw.join(","))}` : "";
|
||||
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
|
||||
type: "auth/sign_path",
|
||||
path: `/api/maintenance_supporter/documents/archive${q}`,
|
||||
expires: 300,
|
||||
});
|
||||
const a = document.createElement("a");
|
||||
a.href = signed.path;
|
||||
a.download = "maintenance-documents.zip";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
const signed = await signApiPath(this.hass, `/api/maintenance_supporter/documents/archive${q}`);
|
||||
downloadUrl(signed, "maintenance-documents.zip");
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
}
|
||||
|
||||
+4
-11
@@ -8,8 +8,9 @@
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale } from "../styles";
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { openSignedDocument } from "../helpers/document-url";
|
||||
import { formatBytes } from "../helpers/format-bytes";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
@@ -55,7 +56,7 @@ export class MaintenanceStorageSectionCard extends LitElement {
|
||||
private _searchTimer = 0;
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
updated(changed: Map<string, unknown>): void {
|
||||
@@ -137,17 +138,9 @@ export class MaintenanceStorageSectionCard extends LitElement {
|
||||
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;
|
||||
await openSignedDocument(this.hass, doc.id);
|
||||
} catch (e) {
|
||||
if (win) win.close();
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
|
||||
import { t, ensureLocale } from "../styles";
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
@@ -61,7 +61,7 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
|
||||
private _localeReady = false;
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
updated(changed: Map<string, unknown>): void {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TaskPartLink, TriggerConfig, HAUser } from "../types";
|
||||
import { formatDate, t, weekdayName } from "../styles";
|
||||
import { formatDate, t, weekdayName, langOf } from "../styles";
|
||||
import { UserService } from "../user-service";
|
||||
import { partLinkKey } from "../helpers/shared-parts";
|
||||
import {
|
||||
@@ -24,6 +24,8 @@ const TRIGGER_TYPE_KEYS = ["threshold", "counter", "state_change", "runtime"];
|
||||
// The type selector additionally offers "compound" (a group of conditions
|
||||
// joined by AND/OR); its per-condition sub-type is limited to the flat kinds.
|
||||
const TRIGGER_TYPE_KEYS_WITH_COMPOUND = [...TRIGGER_TYPE_KEYS, "compound"];
|
||||
// Backend defaults for the adaptive tuning fields (ewa_alpha / min / max days).
|
||||
const ADAPTIVE_DEFAULTS = { alpha: "0.3", min: "7", max: "365" } as const;
|
||||
|
||||
/** One condition of a compound trigger — a flat trigger the user edits inline.
|
||||
* String-typed like the top-level fields (form inputs); coerced on save. */
|
||||
@@ -279,9 +281,9 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
// Adaptive tuning (parity with the options flow's adaptive step) — Store-
|
||||
// managed like the environmental binding, saved through task/set_adaptive.
|
||||
@state() private _adaptiveEnabled = false;
|
||||
@state() private _adaptiveAlpha = "0.3";
|
||||
@state() private _adaptiveMin = "7";
|
||||
@state() private _adaptiveMax = "365";
|
||||
@state() private _adaptiveAlpha: string = ADAPTIVE_DEFAULTS.alpha;
|
||||
@state() private _adaptiveMin: string = ADAPTIVE_DEFAULTS.min;
|
||||
@state() private _adaptiveMax: string = ADAPTIVE_DEFAULTS.max;
|
||||
@state() private _adaptiveSeasonal = true;
|
||||
@state() private _adaptivePrediction = true;
|
||||
private _adaptiveInitial = "";
|
||||
@@ -295,7 +297,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
private _userService: UserService | null = null;
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en";
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
public async openCreate(entryId: string, objects?: Array<{ entry_id: string; object: { name: string } }>): Promise<void> {
|
||||
@@ -404,9 +406,9 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._environmentalInitial = this._environmentalEntity;
|
||||
this._environmentalAttributeInitial = this._environmentalAttribute;
|
||||
this._adaptiveEnabled = !!ac.enabled;
|
||||
this._adaptiveAlpha = (ac.ewa_alpha ?? 0.3).toString();
|
||||
this._adaptiveMin = (ac.min_interval_days ?? 7).toString();
|
||||
this._adaptiveMax = (ac.max_interval_days ?? 365).toString();
|
||||
this._adaptiveAlpha = ac.ewa_alpha?.toString() ?? ADAPTIVE_DEFAULTS.alpha;
|
||||
this._adaptiveMin = ac.min_interval_days?.toString() ?? ADAPTIVE_DEFAULTS.min;
|
||||
this._adaptiveMax = ac.max_interval_days?.toString() ?? ADAPTIVE_DEFAULTS.max;
|
||||
this._adaptiveSeasonal = ac.seasonal_enabled !== false;
|
||||
this._adaptivePrediction = ac.sensor_prediction_enabled !== false;
|
||||
this._adaptiveInitial = this._adaptiveSnapshot();
|
||||
@@ -497,9 +499,9 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._environmentalInitial = "";
|
||||
this._environmentalAttributeInitial = "";
|
||||
this._adaptiveEnabled = false;
|
||||
this._adaptiveAlpha = "0.3";
|
||||
this._adaptiveMin = "7";
|
||||
this._adaptiveMax = "365";
|
||||
this._adaptiveAlpha = ADAPTIVE_DEFAULTS.alpha;
|
||||
this._adaptiveMin = ADAPTIVE_DEFAULTS.min;
|
||||
this._adaptiveMax = ADAPTIVE_DEFAULTS.max;
|
||||
this._adaptiveSeasonal = true;
|
||||
this._adaptivePrediction = true;
|
||||
this._adaptiveInitial = this._adaptiveSnapshot();
|
||||
@@ -1314,34 +1316,13 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
</select>
|
||||
</div>
|
||||
` : nothing}
|
||||
${this._availableAttributes.length > 0
|
||||
? html`
|
||||
<div class="select-row">
|
||||
<label>${t("attribute_optional", L)}</label>
|
||||
<select
|
||||
.value=${this._triggerAttribute}
|
||||
@change=${(e: Event) => (this._triggerAttribute = (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="" ?selected=${!this._triggerAttribute}>${t("use_entity_state", L)}</option>
|
||||
${this._suggestedAttributes.map(
|
||||
(attr) => html`<option value=${attr} ?selected=${attr === this._triggerAttribute}>${attr} ★</option>`
|
||||
)}
|
||||
${this._availableAttributes
|
||||
.filter((a) => !this._suggestedAttributes.includes(a.name))
|
||||
.map(
|
||||
(a) => html`<option value=${a.name} ?selected=${a.name === this._triggerAttribute}>${a.name}${a.numeric ? "" : " (non-numeric)"}</option>`
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<ms-textfield
|
||||
label="${t("attribute_optional", L)}"
|
||||
.value=${this._triggerAttribute}
|
||||
@input=${(e: Event) => (this._triggerAttribute = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
`
|
||||
}
|
||||
${this._renderAttributeSelect({
|
||||
label: t("attribute_optional", L),
|
||||
value: this._triggerAttribute,
|
||||
suggested: this._suggestedAttributes,
|
||||
available: this._availableAttributes,
|
||||
onSelect: (v) => (this._triggerAttribute = v),
|
||||
})}
|
||||
${this._renderTriggerTypeFields()}
|
||||
${this._renderTriggerLiveHint()}
|
||||
`}
|
||||
@@ -1576,76 +1557,73 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/** Shared attribute selector: "use entity state" + suggested★ + remaining
|
||||
* attributes (non-numeric flagged), textfield fallback when none known. */
|
||||
private _renderAttributeSelect(cfg: {
|
||||
label: string;
|
||||
value: string;
|
||||
suggested: string[];
|
||||
available: { name: string; numeric: boolean }[];
|
||||
onSelect: (value: string) => void;
|
||||
}) {
|
||||
const L = this._lang;
|
||||
if (cfg.available.length > 0) {
|
||||
return html`
|
||||
<div class="select-row">
|
||||
<label>${cfg.label}</label>
|
||||
<select
|
||||
.value=${cfg.value}
|
||||
@change=${(e: Event) => cfg.onSelect((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="" ?selected=${!cfg.value}>${t("use_entity_state", L)}</option>
|
||||
${cfg.suggested.map(
|
||||
(attr) => html`<option value=${attr} ?selected=${attr === cfg.value}>${attr} ★</option>`
|
||||
)}
|
||||
${cfg.available
|
||||
.filter((a) => !cfg.suggested.includes(a.name))
|
||||
.map(
|
||||
(a) => html`<option value=${a.name} ?selected=${a.name === cfg.value}>${a.name}${a.numeric ? "" : " (non-numeric)"}</option>`
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<ms-textfield
|
||||
label="${cfg.label}"
|
||||
.value=${cfg.value}
|
||||
@input=${(e: Event) => cfg.onSelect((e.target as HTMLInputElement).value.trim())}
|
||||
></ms-textfield>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Environmental attribute — the same live-fetched dropdown the flat and
|
||||
* compound attribute fields use, keyed by the environmental entity. */
|
||||
private _renderEnvironmentalAttribute(L: string) {
|
||||
this._fetchConditionAttributes(this._environmentalEntity);
|
||||
const opts = this._conditionAttrOptions[this._environmentalEntity];
|
||||
if (opts && opts.available.length > 0) {
|
||||
return html`
|
||||
<div class="select-row">
|
||||
<label>${t("environmental_attribute_optional", L)}</label>
|
||||
<select
|
||||
.value=${this._environmentalAttribute}
|
||||
@change=${(e: Event) => (this._environmentalAttribute = (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="" ?selected=${!this._environmentalAttribute}>${t("use_entity_state", L)}</option>
|
||||
${opts.suggested.map(
|
||||
(attr) => html`<option value=${attr} ?selected=${attr === this._environmentalAttribute}>${attr} ★</option>`
|
||||
)}
|
||||
${opts.available
|
||||
.filter((a) => !opts.suggested.includes(a.name))
|
||||
.map(
|
||||
(a) => html`<option value=${a.name} ?selected=${a.name === this._environmentalAttribute}>${a.name}${a.numeric ? "" : " (non-numeric)"}</option>`
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<ms-textfield
|
||||
label="${t("environmental_attribute_optional", L)}"
|
||||
.value=${this._environmentalAttribute}
|
||||
@input=${(e: Event) => (this._environmentalAttribute = (e.target as HTMLInputElement).value.trim())}
|
||||
></ms-textfield>
|
||||
`;
|
||||
return this._renderAttributeSelect({
|
||||
label: t("environmental_attribute_optional", L),
|
||||
value: this._environmentalAttribute,
|
||||
suggested: opts?.suggested ?? [],
|
||||
available: opts?.available ?? [],
|
||||
onSelect: (v) => (this._environmentalAttribute = v),
|
||||
});
|
||||
}
|
||||
|
||||
/** Attribute selector for one compound condition — the same live-fetched
|
||||
* dropdown the flat editor has, keyed by the condition's first entity. */
|
||||
private _renderConditionAttribute(c: CompoundConditionDraft, i: number) {
|
||||
const L = this._lang;
|
||||
const firstId = c.entityIds.split(",")[0]?.trim() || "";
|
||||
if (firstId) this._fetchConditionAttributes(firstId);
|
||||
const opts = firstId ? this._conditionAttrOptions[firstId] : undefined;
|
||||
if (opts && opts.available.length > 0) {
|
||||
return html`
|
||||
<div class="select-row">
|
||||
<label>${t("attribute_optional", L)}</label>
|
||||
<select
|
||||
.value=${c.attribute}
|
||||
@change=${(e: Event) => this._patchCondition(i, { attribute: (e.target as HTMLSelectElement).value })}
|
||||
>
|
||||
<option value="" ?selected=${!c.attribute}>${t("use_entity_state", L)}</option>
|
||||
${opts.suggested.map(
|
||||
(attr) => html`<option value=${attr} ?selected=${attr === c.attribute}>${attr} ★</option>`
|
||||
)}
|
||||
${opts.available
|
||||
.filter((a) => !opts.suggested.includes(a.name))
|
||||
.map(
|
||||
(a) => html`<option value=${a.name} ?selected=${a.name === c.attribute}>${a.name}${a.numeric ? "" : " (non-numeric)"}</option>`
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<ms-textfield
|
||||
label="${t("attribute_optional", L)}"
|
||||
.value=${c.attribute}
|
||||
@input=${(e: Event) => this._patchCondition(i, { attribute: (e.target as HTMLInputElement).value.trim() })}
|
||||
></ms-textfield>
|
||||
`;
|
||||
return this._renderAttributeSelect({
|
||||
label: t("attribute_optional", this._lang),
|
||||
value: c.attribute,
|
||||
suggested: opts?.suggested ?? [],
|
||||
available: opts?.available ?? [],
|
||||
onSelect: (v) => this._patchCondition(i, { attribute: v }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Type-specific inputs for a single compound condition (mirrors the flat
|
||||
|
||||
@@ -11,10 +11,12 @@
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale } from "../styles";
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { downloadUrl } from "../helpers/download";
|
||||
import { downloadSignedDocument, openSignedDocument } from "../helpers/document-url";
|
||||
import { formatBytes } from "../helpers/format-bytes";
|
||||
import { CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
interface Doc {
|
||||
@@ -31,16 +33,6 @@ interface Doc {
|
||||
part_ids?: 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 MaintenanceTaskDocuments extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@property({ attribute: false }) public entryId!: string;
|
||||
@@ -59,7 +51,7 @@ export class MaintenanceTaskDocuments extends LitElement {
|
||||
private _localeReady = false;
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
/** The id linked docs reference — a task id or (part mode) a part id. */
|
||||
@@ -158,19 +150,9 @@ export class MaintenanceTaskDocuments extends LitElement {
|
||||
// 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;
|
||||
await openSignedDocument(this.hass, doc.id, page ? `#page=${page}` : "");
|
||||
} catch (e) {
|
||||
if (win) win.close();
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
}
|
||||
@@ -196,12 +178,7 @@ export class MaintenanceTaskDocuments extends LitElement {
|
||||
|
||||
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");
|
||||
await downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document");
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence } from "../styles";
|
||||
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { renderWeibullSection } from "../renderers/weibull";
|
||||
import { renderPredictionSection } from "../renderers/prediction";
|
||||
@@ -62,7 +62,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
private _featuresLoaded = false;
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
/** Open the dialog. Loads fresh data from /object via WS so dialog stays in
|
||||
|
||||
+4
-5
@@ -13,7 +13,8 @@
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale } from "../styles";
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { registerCustomCard } from "../helpers/register-card";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { sectionCardSharedStyles } from "./section-card-shared-styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
@@ -55,7 +56,7 @@ export class MaintenanceVacationSectionCard extends LitElement {
|
||||
}
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
private get _isAdmin(): boolean {
|
||||
@@ -317,9 +318,7 @@ if (!customElements.get("maintenance-vacation-section-card")) {
|
||||
);
|
||||
}
|
||||
|
||||
(window as { customCards?: unknown[] }).customCards =
|
||||
(window as { customCards?: unknown[] }).customCards || [];
|
||||
((window as { customCards?: unknown[] }).customCards!).push({
|
||||
registerCustomCard({
|
||||
type: "maintenance-vacation-section-card",
|
||||
name: "Maintenance Supporter — Vacation",
|
||||
description: "Inline vacation mode toggle + dates",
|
||||
|
||||
@@ -18,4 +18,29 @@ export const LS_KEYS = {
|
||||
objectView: "maintenance_supporter_object_view",
|
||||
objectsCache: "msp-objects-cache",
|
||||
gettingStartedDismissed: "msp-gs-dismissed",
|
||||
batteryRosterSort: "ms_bf_roster_sort",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Guarded storage access — localStorage can THROW (Safari private mode /
|
||||
* locked-down policies raise instead of returning null), and an unguarded
|
||||
* call aborts the surrounding handler. The 2026-08 DRY audit found two
|
||||
* hand-written call sites that had missed the guard; every access now goes
|
||||
* through these two (a source tripwire forbids direct localStorage use
|
||||
* outside this module).
|
||||
*/
|
||||
export function lsGet(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function lsSet(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* private mode / storage blocked */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,6 +483,7 @@
|
||||
"settings_export_json": "Exportovat JSON",
|
||||
"settings_export_yaml": "Exportovat YAML",
|
||||
"settings_export_csv": "Exportovat CSV",
|
||||
"settings_export_settings": "Exportovat nastavení (JSON)",
|
||||
"settings_import_csv": "Importovat CSV",
|
||||
"settings_import_placeholder": "Vložte sem obsah JSON nebo CSV…",
|
||||
"settings_import_btn": "Importovat",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
"settings_export_json": "Eksporter JSON",
|
||||
"settings_export_yaml": "Eksporter YAML",
|
||||
"settings_export_csv": "Eksporter CSV",
|
||||
"settings_export_settings": "Eksportér indstillinger (JSON)",
|
||||
"settings_import_csv": "Importer CSV",
|
||||
"settings_import_placeholder": "Indsæt JSON- eller CSV-indhold her…",
|
||||
"settings_import_btn": "Importer",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
"settings_export_json": "JSON exportieren",
|
||||
"settings_export_yaml": "YAML exportieren",
|
||||
"settings_export_csv": "CSV exportieren",
|
||||
"settings_export_settings": "Einstellungen exportieren (JSON)",
|
||||
"settings_import_csv": "CSV importieren",
|
||||
"settings_import_placeholder": "JSON- oder CSV-Inhalt hier einfügen…",
|
||||
"settings_import_btn": "Importieren",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
"settings_export_json": "Export JSON",
|
||||
"settings_export_yaml": "Export YAML",
|
||||
"settings_export_csv": "Export CSV",
|
||||
"settings_export_settings": "Export settings (JSON)",
|
||||
"settings_import_csv": "Import CSV",
|
||||
"settings_import_placeholder": "Paste JSON or CSV content here…",
|
||||
"settings_import_btn": "Import",
|
||||
|
||||
@@ -483,6 +483,7 @@
|
||||
"settings_export_json": "Exportar JSON",
|
||||
"settings_export_yaml": "Exportar YAML",
|
||||
"settings_export_csv": "Exportar CSV",
|
||||
"settings_export_settings": "Exportar ajustes (JSON)",
|
||||
"settings_import_csv": "Importar CSV",
|
||||
"settings_import_placeholder": "Pegue el contenido JSON o CSV aquí…",
|
||||
"settings_import_btn": "Importar",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
"settings_export_json": "Vie JSON",
|
||||
"settings_export_yaml": "Vie YAML",
|
||||
"settings_export_csv": "Vie CSV",
|
||||
"settings_export_settings": "Vie asetukset (JSON)",
|
||||
"settings_import_csv": "Tuo CSV",
|
||||
"settings_import_placeholder": "Liitä JSON- tai CSV-sisältö tähän…",
|
||||
"settings_import_btn": "Tuo",
|
||||
|
||||
@@ -483,6 +483,7 @@
|
||||
"settings_export_json": "Exporter JSON",
|
||||
"settings_export_yaml": "Exporter YAML",
|
||||
"settings_export_csv": "Exporter CSV",
|
||||
"settings_export_settings": "Exporter les réglages (JSON)",
|
||||
"settings_import_csv": "Importer CSV",
|
||||
"settings_import_placeholder": "Collez le contenu JSON ou CSV ici…",
|
||||
"settings_import_btn": "Importer",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
"settings_export_json": "JSON निर्यात करें",
|
||||
"settings_export_yaml": "YAML निर्यात करें",
|
||||
"settings_export_csv": "CSV निर्यात करें",
|
||||
"settings_export_settings": "सेटिंग्स निर्यात करें (JSON)",
|
||||
"settings_import_csv": "CSV आयात करें",
|
||||
"settings_import_placeholder": "JSON या CSV सामग्री यहाँ चिपकाएँ…",
|
||||
"settings_import_btn": "आयात करें",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
"settings_export_json": "JSON exportálása",
|
||||
"settings_export_yaml": "YAML exportálása",
|
||||
"settings_export_csv": "CSV exportálása",
|
||||
"settings_export_settings": "Beállítások exportálása (JSON)",
|
||||
"settings_import_csv": "CSV importálása",
|
||||
"settings_import_placeholder": "Illessze be ide a JSON vagy CSV tartalmat…",
|
||||
"settings_import_btn": "Importálás",
|
||||
|
||||
@@ -483,6 +483,7 @@
|
||||
"settings_export_json": "Esporta JSON",
|
||||
"settings_export_yaml": "Esporta YAML",
|
||||
"settings_export_csv": "Esporta CSV",
|
||||
"settings_export_settings": "Esporta impostazioni (JSON)",
|
||||
"settings_import_csv": "Importa CSV",
|
||||
"settings_import_placeholder": "Incolla il contenuto JSON o CSV qui…",
|
||||
"settings_import_btn": "Importa",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
"settings_export_json": "JSONをエクスポート",
|
||||
"settings_export_yaml": "YAMLをエクスポート",
|
||||
"settings_export_csv": "CSVをエクスポート",
|
||||
"settings_export_settings": "設定をエクスポート(JSON)",
|
||||
"settings_import_csv": "CSVをインポート",
|
||||
"settings_import_placeholder": "JSONまたはCSVの内容をここに貼り付け…",
|
||||
"settings_import_btn": "インポート",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
"settings_export_json": "JSON 내보내기",
|
||||
"settings_export_yaml": "YAML 내보내기",
|
||||
"settings_export_csv": "CSV 내보내기",
|
||||
"settings_export_settings": "설정 내보내기 (JSON)",
|
||||
"settings_import_csv": "CSV 가져오기",
|
||||
"settings_import_placeholder": "JSON 또는 CSV 내용을 여기에 붙여넣으세요…",
|
||||
"settings_import_btn": "가져오기",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
"settings_export_json": "Eksporter JSON",
|
||||
"settings_export_yaml": "Eksporter YAML",
|
||||
"settings_export_csv": "Eksporter CSV",
|
||||
"settings_export_settings": "Eksporter innstillinger (JSON)",
|
||||
"settings_import_csv": "Importer CSV",
|
||||
"settings_import_placeholder": "Lim inn JSON- eller CSV-innhold her…",
|
||||
"settings_import_btn": "Importer",
|
||||
|
||||
@@ -483,6 +483,7 @@
|
||||
"settings_export_json": "JSON exporteren",
|
||||
"settings_export_yaml": "YAML exporteren",
|
||||
"settings_export_csv": "CSV exporteren",
|
||||
"settings_export_settings": "Instellingen exporteren (JSON)",
|
||||
"settings_import_csv": "CSV importeren",
|
||||
"settings_import_placeholder": "Plak JSON- of CSV-inhoud hier…",
|
||||
"settings_import_btn": "Importeren",
|
||||
|
||||
@@ -483,6 +483,7 @@
|
||||
"settings_export_json": "Eksportuj JSON",
|
||||
"settings_export_yaml": "Eksportuj YAML",
|
||||
"settings_export_csv": "Eksportuj CSV",
|
||||
"settings_export_settings": "Eksportuj ustawienia (JSON)",
|
||||
"settings_import_csv": "Importuj CSV",
|
||||
"settings_import_placeholder": "Wklej tutaj zawartość JSON lub CSV…",
|
||||
"settings_import_btn": "Importuj",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
"settings_export_json": "Exportar JSON",
|
||||
"settings_export_yaml": "Exportar YAML",
|
||||
"settings_export_csv": "Exportar CSV",
|
||||
"settings_export_settings": "Exportar configurações (JSON)",
|
||||
"settings_import_csv": "Importar CSV",
|
||||
"settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…",
|
||||
"settings_import_btn": "Importar",
|
||||
|
||||
@@ -483,6 +483,7 @@
|
||||
"settings_export_json": "Exportar JSON",
|
||||
"settings_export_yaml": "Exportar YAML",
|
||||
"settings_export_csv": "Exportar CSV",
|
||||
"settings_export_settings": "Exportar definições (JSON)",
|
||||
"settings_import_csv": "Importar CSV",
|
||||
"settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…",
|
||||
"settings_import_btn": "Importar",
|
||||
|
||||
@@ -483,6 +483,7 @@
|
||||
"settings_export_json": "Экспорт JSON",
|
||||
"settings_export_yaml": "Экспорт YAML",
|
||||
"settings_export_csv": "Экспорт CSV",
|
||||
"settings_export_settings": "Экспорт настроек (JSON)",
|
||||
"settings_import_csv": "Импорт CSV",
|
||||
"settings_import_placeholder": "Вставьте содержимое JSON или CSV здесь…",
|
||||
"settings_import_btn": "Импортировать",
|
||||
|
||||
@@ -483,6 +483,7 @@
|
||||
"settings_export_json": "Exportera JSON",
|
||||
"settings_export_yaml": "Exportera YAML",
|
||||
"settings_export_csv": "Exportera CSV",
|
||||
"settings_export_settings": "Exportera inställningar (JSON)",
|
||||
"settings_import_csv": "Importera CSV",
|
||||
"settings_import_placeholder": "Klistra in JSON- eller CSV-innehåll här…",
|
||||
"settings_import_btn": "Importera",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
"settings_export_json": "JSON dışa aktar",
|
||||
"settings_export_yaml": "YAML dışa aktar",
|
||||
"settings_export_csv": "CSV dışa aktar",
|
||||
"settings_export_settings": "Ayarları dışa aktar (JSON)",
|
||||
"settings_import_csv": "CSV içe aktar",
|
||||
"settings_import_placeholder": "JSON veya CSV içeriğini buraya yapıştırın…",
|
||||
"settings_import_btn": "İçe aktar",
|
||||
|
||||
@@ -483,6 +483,7 @@
|
||||
"settings_export_json": "Експортувати JSON",
|
||||
"settings_export_yaml": "Експортувати YAML",
|
||||
"settings_export_csv": "Експортувати CSV",
|
||||
"settings_export_settings": "Експорт налаштувань (JSON)",
|
||||
"settings_import_csv": "Імпортувати CSV",
|
||||
"settings_import_placeholder": "Вставте вміст JSON або CSV сюди…",
|
||||
"settings_import_btn": "Імпортувати",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
"settings_export_json": "导出 JSON",
|
||||
"settings_export_yaml": "导出 YAML",
|
||||
"settings_export_csv": "导出 CSV",
|
||||
"settings_export_settings": "导出设置(JSON)",
|
||||
"settings_import_csv": "导入 CSV",
|
||||
"settings_import_placeholder": "在此粘贴 JSON 或 CSV 内容…",
|
||||
"settings_import_btn": "导入",
|
||||
|
||||
@@ -34,7 +34,8 @@ import {
|
||||
type CalendarEvent,
|
||||
} from "./helpers/calendar-bucket";
|
||||
import { calendarStyles } from "./calendar-styles";
|
||||
import { sharedStyles, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays } from "./styles";
|
||||
import { sharedStyles, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays, langOf } from "./styles";
|
||||
import { registerCustomCard } from "./helpers/register-card";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
MaintenanceObjectResponse,
|
||||
@@ -121,7 +122,7 @@ export class MaintenanceCalendarCard extends LitElement {
|
||||
}
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
@@ -642,31 +643,15 @@ if (!customElements.get("maintenance-supporter-calendar-card-editor")) {
|
||||
}
|
||||
|
||||
// Register with HACS / customCards so the picker shows it
|
||||
const w = window as unknown as {
|
||||
customCards?: Array<{
|
||||
type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
preview?: boolean;
|
||||
}>;
|
||||
};
|
||||
w.customCards = w.customCards || [];
|
||||
// HA's custom-card resolver maps ``custom:X`` → element tag ``X``. Our element
|
||||
// is registered as ``maintenance-supporter-calendar-card``, so the customCards
|
||||
// type MUST match that exact suffix or the card-picker entry resolves to a
|
||||
// non-existent element and the strategy's calendar mode throws a config error.
|
||||
const CALENDAR_CARD_TYPE = "maintenance-supporter-calendar-card";
|
||||
const alreadyRegistered = w.customCards.some(
|
||||
(c) => c.type === CALENDAR_CARD_TYPE,
|
||||
);
|
||||
if (!alreadyRegistered) {
|
||||
w.customCards.push({
|
||||
type: CALENDAR_CARD_TYPE,
|
||||
name: "Maintenance Supporter — Calendar",
|
||||
description:
|
||||
"Rolling calendar of maintenance tasks with 7/14/30/365 day windows, source icons, and prediction-confidence pills.",
|
||||
preview: true,
|
||||
});
|
||||
}
|
||||
// The type MUST match the registered element tag exactly (custom:X → tag X),
|
||||
// or the picker entry resolves to a non-existent element and the strategy's
|
||||
// calendar mode throws a config error.
|
||||
registerCustomCard({
|
||||
type: "maintenance-supporter-calendar-card",
|
||||
name: "Maintenance Supporter — Calendar",
|
||||
description:
|
||||
"Rolling calendar of maintenance tasks with 7/14/30/365 day windows, source icons, and prediction-confidence pills.",
|
||||
preview: true,
|
||||
});
|
||||
|
||||
export {};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t } from "./styles";
|
||||
import { t, langOf } from "./styles";
|
||||
import type { HomeAssistant, CardConfig, MaintenanceObjectResponse, SavedView } from "./types";
|
||||
|
||||
const STATUS_KEYS = ["overdue", "triggered", "due_soon", "ok"] as const;
|
||||
@@ -18,7 +18,7 @@ export class MaintenanceSupporterCardEditor extends LitElement {
|
||||
private _objectsLoaded = false;
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
setConfig(config: CardConfig): void {
|
||||
|
||||
@@ -4,7 +4,9 @@ import { LitElement, html, css, nothing } from "lit";
|
||||
import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
|
||||
import { hydrateObjects } from "./helpers/hydrate-objects";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays } from "./styles";
|
||||
import { sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays, langOf } from "./styles";
|
||||
import { openSignedDocument } from "./helpers/document-url";
|
||||
import { registerCustomCard } from "./helpers/register-card";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
MaintenanceObjectResponse,
|
||||
@@ -54,7 +56,7 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
private _docsLoadedFor = new Set<string>();
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
static getConfigElement() {
|
||||
@@ -240,16 +242,10 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
window.open(doc.url, "_blank", "noopener");
|
||||
return;
|
||||
}
|
||||
const win = window.open("about:blank", "_blank");
|
||||
try {
|
||||
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
|
||||
type: "auth/sign_path",
|
||||
path: `/api/maintenance_supporter/document/${doc.id}`,
|
||||
expires: 300,
|
||||
});
|
||||
if (win) win.location.href = new URL(signed.path, window.location.origin).href;
|
||||
await openSignedDocument(this.hass, doc.id);
|
||||
} catch {
|
||||
if (win) win.close();
|
||||
/* silent — the card has no error surface */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,8 +692,7 @@ if (!customElements.get("maintenance-supporter-card")) {
|
||||
}
|
||||
|
||||
// Register as custom card so the Lovelace card picker lists it.
|
||||
(window as any).customCards = (window as any).customCards || [];
|
||||
(window as any).customCards.push({
|
||||
registerCustomCard({
|
||||
type: "maintenance-supporter-card",
|
||||
name: "Maintenance Supporter",
|
||||
description: "Overview of your maintenance tasks with quick actions.",
|
||||
|
||||
@@ -5,8 +5,9 @@ import { isSafeHttpUrl } from "./helpers/url";
|
||||
import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
|
||||
import { isStaleBundle } from "./helpers/bundle-version";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setDateTimePrefs } from "./styles";
|
||||
import { LS_KEYS } from "./helpers/storage-keys";
|
||||
import { sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setDateTimePrefs, langOf } from "./styles";
|
||||
import { LS_KEYS, lsGet, lsSet } from "./helpers/storage-keys";
|
||||
import { openSignedDocument, signApiPath } from "./helpers/document-url";
|
||||
import { readObjectsCache, writeObjectsCache } from "./helpers/objects-cache";
|
||||
import { hydrateObjects } from "./helpers/hydrate-objects";
|
||||
import { daysProgress } from "./helpers/interval";
|
||||
@@ -134,17 +135,22 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
@state() private _unsub: (() => void) | null = null;
|
||||
@state() private _chartRangeDays = (() => {
|
||||
try {
|
||||
const v = parseInt(localStorage.getItem(LS_KEYS.chartRange) || "", 10);
|
||||
const v = parseInt(lsGet(LS_KEYS.chartRange) || "", 10);
|
||||
return [7, 30, 90, 365].includes(v) ? v : 30;
|
||||
} catch {
|
||||
return 30;
|
||||
}
|
||||
})();
|
||||
@state() private _hideOutliers = (() => {
|
||||
try { return localStorage.getItem(LS_KEYS.chartHideOutliers) === "1"; } catch { return false; }
|
||||
try { return lsGet(LS_KEYS.chartHideOutliers) === "1"; } catch { return false; }
|
||||
})();
|
||||
@state() private _historyFilter: string | null = null;
|
||||
@state() private _budget: BudgetStatus | null = null;
|
||||
|
||||
private get _currencySymbol(): string {
|
||||
return this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL;
|
||||
}
|
||||
|
||||
@state() private _groups: Record<string, MaintenanceGroup> = {};
|
||||
@state() private _detailStatsData: Map<string, StatisticsPoint[]> = new Map();
|
||||
@state() private _miniStatsData: Map<string, StatisticsPoint[]> = new Map();
|
||||
@@ -185,7 +191,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
// Dashboard redesign state
|
||||
@state() private _overviewTab: "today" | "dashboard" | "calendar" | "settings" = (() => {
|
||||
try {
|
||||
const v = localStorage.getItem(LS_KEYS.overviewTab);
|
||||
const v = lsGet(LS_KEYS.overviewTab);
|
||||
return v === "today" || v === "calendar" ? v : "dashboard";
|
||||
} catch { return "dashboard"; }
|
||||
})();
|
||||
@@ -217,7 +223,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
// v2.15.0: collapsed analysis sections on the task-detail overview tab,
|
||||
// remembered per section across visits.
|
||||
@state() private _collapsedSections: Set<string> = (() => {
|
||||
try { return new Set(JSON.parse(localStorage.getItem(LS_KEYS.collapsedSections) || "[]")); }
|
||||
try { return new Set(JSON.parse(lsGet(LS_KEYS.collapsedSections) || "[]")); }
|
||||
catch { return new Set(); }
|
||||
})();
|
||||
// v2.15.0: command palette ("/" since 2.18.1 — Ctrl+K clashed with HA's own
|
||||
@@ -241,7 +247,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
private _lastConnection: unknown = null;
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,19 +320,19 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
// read here would abort connectedCallback and leave the panel blank. Every
|
||||
// other storage access in this file is wrapped; wrap these too.
|
||||
try {
|
||||
const saved = localStorage.getItem(LS_KEYS.taskSort);
|
||||
const saved = lsGet(LS_KEYS.taskSort);
|
||||
if (saved && ["due_date", "object", "type", "task_name", "area", "assigned_user", "group"].includes(saved)) {
|
||||
this._sortMode = saved as SortMode;
|
||||
}
|
||||
const savedObj = localStorage.getItem(LS_KEYS.objectSort);
|
||||
const savedObj = lsGet(LS_KEYS.objectSort);
|
||||
if (savedObj && ["alphabetical", "due_soonest", "task_count"].includes(savedObj)) {
|
||||
this._objectSortMode = savedObj as ObjectSortMode;
|
||||
}
|
||||
const savedGroup = localStorage.getItem(LS_KEYS.groupBy);
|
||||
const savedGroup = lsGet(LS_KEYS.groupBy);
|
||||
if (savedGroup && ["none", "area", "group", "user"].includes(savedGroup)) {
|
||||
this._groupByMode = savedGroup as GroupByMode;
|
||||
}
|
||||
const savedView = localStorage.getItem(LS_KEYS.objectView);
|
||||
const savedView = lsGet(LS_KEYS.objectView);
|
||||
if (savedView === "cards" || savedView === "table") {
|
||||
this._objectViewMode = savedView;
|
||||
}
|
||||
@@ -640,7 +646,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
private _setChartRange(days: number): void {
|
||||
if (days === this._chartRangeDays) return;
|
||||
this._chartRangeDays = days;
|
||||
try { localStorage.setItem(LS_KEYS.chartRange, String(days)); } catch { /* private mode */ }
|
||||
try { lsSet(LS_KEYS.chartRange, String(days)); } catch { /* private mode */ }
|
||||
const task = this._selectedEntryId && this._selectedTaskId
|
||||
? this._getTask(this._selectedEntryId, this._selectedTaskId)
|
||||
: null;
|
||||
@@ -659,7 +665,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
// Outlier filtering is client-side on the already-fetched series, so just
|
||||
// flip the flag and let renderChart re-filter — no re-fetch needed.
|
||||
this._hideOutliers = hide;
|
||||
try { localStorage.setItem(LS_KEYS.chartHideOutliers, hide ? "1" : "0"); } catch { /* private mode */ }
|
||||
try { lsSet(LS_KEYS.chartHideOutliers, hide ? "1" : "0"); } catch { /* private mode */ }
|
||||
}
|
||||
|
||||
private async _fetchMiniStatsForOverview(): Promise<void> {
|
||||
@@ -948,8 +954,8 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
}
|
||||
// Persist sort/group like the manual controls do, so they stick after reload.
|
||||
try {
|
||||
localStorage.setItem(LS_KEYS.taskSort, this._sortMode);
|
||||
localStorage.setItem(LS_KEYS.groupBy, this._groupByMode);
|
||||
lsSet(LS_KEYS.taskSort, this._sortMode);
|
||||
lsSet(LS_KEYS.groupBy, this._groupByMode);
|
||||
} catch {
|
||||
// ignore private-mode storage errors
|
||||
}
|
||||
@@ -1387,6 +1393,29 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
/** Run a mutating WS action: loading state, data reload, and — on failure —
|
||||
* the SERVER's error message as the toast. The ~16 hand-written action
|
||||
* methods had drifted (most swallowed the server message behind a generic
|
||||
* "Action failed", three never set _actionLoading, one skipped the reload).
|
||||
* Returns the result payload, or null when the call failed (toast shown). */
|
||||
private async _runAction<T = Record<string, unknown>>(
|
||||
msg: Record<string, unknown>,
|
||||
opts?: { successToast?: string },
|
||||
): Promise<T | null> {
|
||||
this._actionLoading = true;
|
||||
try {
|
||||
const res = await this.hass.connection.sendMessagePromise<T>(msg);
|
||||
await this._loadData();
|
||||
if (opts?.successToast) this._showToast(opts.successToast);
|
||||
return (res ?? {}) as T;
|
||||
} catch (e) {
|
||||
this._showToast(describeWsError(e, this._lang));
|
||||
return null;
|
||||
} finally {
|
||||
this._actionLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async _deleteObject(entryId: string): Promise<void> {
|
||||
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
|
||||
const ok = await dlg?.confirm({
|
||||
@@ -1396,16 +1425,11 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/object/delete",
|
||||
entry_id: entryId,
|
||||
});
|
||||
this._showOverview();
|
||||
await this._loadData();
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
}
|
||||
const res = await this._runAction({
|
||||
type: "maintenance_supporter/object/delete",
|
||||
entry_id: entryId,
|
||||
});
|
||||
if (res) this._showOverview();
|
||||
}
|
||||
|
||||
/** Open a printable maintenance report for the object in a new tab (the user
|
||||
@@ -1442,7 +1466,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
const html = buildObjectReportHtml(
|
||||
resp.object, resp.tasks, labels,
|
||||
(iso) => (iso ? formatDate(iso, L) : ""),
|
||||
this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL,
|
||||
this._currencySymbol,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
const url = URL.createObjectURL(new Blob([html], { type: "text/html" }));
|
||||
@@ -1451,20 +1475,11 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
}
|
||||
|
||||
private async _duplicateObject(entryId: string): Promise<void> {
|
||||
this._actionLoading = true;
|
||||
try {
|
||||
const res = await this.hass.connection.sendMessagePromise<{ entry_id?: string }>({
|
||||
type: "maintenance_supporter/object/duplicate",
|
||||
entry_id: entryId,
|
||||
});
|
||||
await this._loadData();
|
||||
this._showToast(t("object_duplicated", this._lang));
|
||||
if (res?.entry_id) this._showObject(res.entry_id);
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
} finally {
|
||||
this._actionLoading = false;
|
||||
}
|
||||
const res = await this._runAction<{ entry_id?: string }>(
|
||||
{ type: "maintenance_supporter/object/duplicate", entry_id: entryId },
|
||||
{ successToast: t("object_duplicated", this._lang) },
|
||||
);
|
||||
if (res?.entry_id) this._showObject(res.entry_id);
|
||||
}
|
||||
|
||||
private async _deleteTask(entryId: string, taskId: string): Promise<void> {
|
||||
@@ -1476,60 +1491,37 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/task/delete",
|
||||
entry_id: entryId,
|
||||
task_id: taskId,
|
||||
});
|
||||
this._showObject(entryId);
|
||||
await this._loadData();
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
}
|
||||
const res = await this._runAction({
|
||||
type: "maintenance_supporter/task/delete",
|
||||
entry_id: entryId,
|
||||
task_id: taskId,
|
||||
});
|
||||
if (res) this._showObject(entryId);
|
||||
}
|
||||
|
||||
// v2.10.0: archive / unarchive a single task (reversible — no confirm).
|
||||
private async _duplicateTask(entryId: string, taskId: string): Promise<void> {
|
||||
this._moreMenuOpen = false;
|
||||
this._actionLoading = true;
|
||||
try {
|
||||
const res = await this.hass.connection.sendMessagePromise<{ task_id?: string }>({
|
||||
type: "maintenance_supporter/task/duplicate",
|
||||
entry_id: entryId,
|
||||
task_id: taskId,
|
||||
});
|
||||
await this._loadData();
|
||||
this._showToast(t("task_duplicated", this._lang));
|
||||
// Jump straight to the copy so the user can rename/adjust it.
|
||||
if (res?.task_id) this._showTask(entryId, res.task_id);
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
} finally {
|
||||
this._actionLoading = false;
|
||||
}
|
||||
const res = await this._runAction<{ task_id?: string }>(
|
||||
{ type: "maintenance_supporter/task/duplicate", entry_id: entryId, task_id: taskId },
|
||||
{ successToast: t("task_duplicated", this._lang) },
|
||||
);
|
||||
// Jump straight to the copy so the user can rename/adjust it.
|
||||
if (res?.task_id) this._showTask(entryId, res.task_id);
|
||||
}
|
||||
|
||||
private async _toggleArchiveTask(entryId: string, taskId: string, archived: boolean): Promise<void> {
|
||||
this._actionLoading = true;
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: archived
|
||||
? "maintenance_supporter/task/unarchive"
|
||||
: "maintenance_supporter/task/archive",
|
||||
entry_id: entryId,
|
||||
task_id: taskId,
|
||||
});
|
||||
await this._loadData();
|
||||
// Just archived → offer a one-tap undo (unarchive) instead of a confirm.
|
||||
if (!archived) {
|
||||
this._showUndoToast(t("task_archived", this._lang),
|
||||
() => this._toggleArchiveTask(entryId, taskId, true));
|
||||
}
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
} finally {
|
||||
this._actionLoading = false;
|
||||
const res = await this._runAction({
|
||||
type: archived
|
||||
? "maintenance_supporter/task/unarchive"
|
||||
: "maintenance_supporter/task/archive",
|
||||
entry_id: entryId,
|
||||
task_id: taskId,
|
||||
});
|
||||
// Just archived → offer a one-tap undo (unarchive) instead of a confirm.
|
||||
if (res && !archived) {
|
||||
this._showUndoToast(t("task_archived", this._lang),
|
||||
() => this._toggleArchiveTask(entryId, taskId, true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1537,20 +1529,15 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
// is fully reversible, so instead of a blocking confirm we run it immediately
|
||||
// and offer an Undo toast (v2.14.0).
|
||||
private async _toggleArchiveObject(entryId: string, archived: boolean): Promise<void> {
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: archived
|
||||
? "maintenance_supporter/object/unarchive"
|
||||
: "maintenance_supporter/object/archive",
|
||||
entry_id: entryId,
|
||||
});
|
||||
await this._loadData();
|
||||
if (!archived) {
|
||||
this._showUndoToast(t("object_archived", this._lang),
|
||||
() => this._toggleArchiveObject(entryId, true));
|
||||
}
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
const res = await this._runAction({
|
||||
type: archived
|
||||
? "maintenance_supporter/object/unarchive"
|
||||
: "maintenance_supporter/object/archive",
|
||||
entry_id: entryId,
|
||||
});
|
||||
if (res && !archived) {
|
||||
this._showUndoToast(t("object_archived", this._lang),
|
||||
() => this._toggleArchiveObject(entryId, true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1567,31 +1554,21 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
inputType: "date",
|
||||
});
|
||||
if (!result?.confirmed) return;
|
||||
try {
|
||||
const msg: Record<string, unknown> = {
|
||||
type: "maintenance_supporter/object/pause",
|
||||
entry_id: entryId,
|
||||
};
|
||||
if (result.value) msg.until = result.value;
|
||||
await this.hass.connection.sendMessagePromise(msg);
|
||||
await this._loadData();
|
||||
const msg: Record<string, unknown> = {
|
||||
type: "maintenance_supporter/object/pause",
|
||||
entry_id: entryId,
|
||||
};
|
||||
if (result.value) msg.until = result.value;
|
||||
if (await this._runAction(msg)) {
|
||||
this._showUndoToast(t("object_paused", this._lang),
|
||||
() => this._togglePauseObject(entryId, true));
|
||||
} catch (e) {
|
||||
this._showToast(describeWsError(e, this._lang));
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/object/resume",
|
||||
entry_id: entryId,
|
||||
});
|
||||
await this._loadData();
|
||||
this._showToast(t("object_resumed", this._lang));
|
||||
} catch (e) {
|
||||
this._showToast(describeWsError(e, this._lang));
|
||||
}
|
||||
await this._runAction(
|
||||
{ type: "maintenance_supporter/object/resume", entry_id: entryId },
|
||||
{ successToast: t("object_resumed", this._lang) },
|
||||
);
|
||||
}
|
||||
|
||||
// v2.20 (N1): replace a worn-out object with a successor — the old one is
|
||||
@@ -1608,71 +1585,44 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
inputValue: currentName,
|
||||
});
|
||||
if (!result?.confirmed) return;
|
||||
this._actionLoading = true;
|
||||
try {
|
||||
const res = await this.hass.connection.sendMessagePromise<{ entry_id?: string }>({
|
||||
const res = await this._runAction<{ entry_id?: string }>(
|
||||
{
|
||||
type: "maintenance_supporter/object/replace",
|
||||
entry_id: entryId,
|
||||
name: result.value || currentName,
|
||||
});
|
||||
await this._loadData();
|
||||
this._showToast(t("object_replaced", this._lang));
|
||||
if (res?.entry_id) this._showObject(res.entry_id);
|
||||
} catch (e) {
|
||||
this._showToast(describeWsError(e, this._lang));
|
||||
} finally {
|
||||
this._actionLoading = false;
|
||||
}
|
||||
},
|
||||
{ successToast: t("object_replaced", this._lang) },
|
||||
);
|
||||
if (res?.entry_id) this._showObject(res.entry_id);
|
||||
}
|
||||
|
||||
private async _skipTask(entryId: string, taskId: string, reason?: string): Promise<void> {
|
||||
this._actionLoading = true;
|
||||
try {
|
||||
const msg: Record<string, unknown> = {
|
||||
type: "maintenance_supporter/task/skip",
|
||||
entry_id: entryId,
|
||||
task_id: taskId,
|
||||
};
|
||||
if (reason) msg.reason = reason;
|
||||
await this.hass.connection.sendMessagePromise(msg);
|
||||
await this._loadData();
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
} finally {
|
||||
this._actionLoading = false;
|
||||
}
|
||||
const msg: Record<string, unknown> = {
|
||||
type: "maintenance_supporter/task/skip",
|
||||
entry_id: entryId,
|
||||
task_id: taskId,
|
||||
};
|
||||
if (reason) msg.reason = reason;
|
||||
await this._runAction(msg);
|
||||
}
|
||||
|
||||
private async _resetTask(entryId: string, taskId: string, resetDate?: string): Promise<void> {
|
||||
this._actionLoading = true;
|
||||
try {
|
||||
const msg: Record<string, unknown> = {
|
||||
type: "maintenance_supporter/task/reset",
|
||||
entry_id: entryId,
|
||||
task_id: taskId,
|
||||
};
|
||||
if (resetDate) msg.date = resetDate;
|
||||
await this.hass.connection.sendMessagePromise(msg);
|
||||
await this._loadData();
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
} finally {
|
||||
this._actionLoading = false;
|
||||
}
|
||||
const msg: Record<string, unknown> = {
|
||||
type: "maintenance_supporter/task/reset",
|
||||
entry_id: entryId,
|
||||
task_id: taskId,
|
||||
};
|
||||
if (resetDate) msg.date = resetDate;
|
||||
await this._runAction(msg);
|
||||
}
|
||||
|
||||
private async _applySuggestion(entryId: string, taskId: string, interval: number): Promise<void> {
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/task/apply_suggestion",
|
||||
entry_id: entryId,
|
||||
task_id: taskId,
|
||||
interval: interval,
|
||||
});
|
||||
await this._loadData();
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
}
|
||||
await this._runAction({
|
||||
type: "maintenance_supporter/task/apply_suggestion",
|
||||
entry_id: entryId,
|
||||
task_id: taskId,
|
||||
interval: interval,
|
||||
});
|
||||
}
|
||||
|
||||
private _openSeasonalOverrides(task: MaintenanceTask): void {
|
||||
@@ -1683,28 +1633,24 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
}
|
||||
|
||||
private async _reanalyzeInterval(entryId: string, taskId: string): Promise<void> {
|
||||
try {
|
||||
const res = await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/task/analyze_interval",
|
||||
entry_id: entryId,
|
||||
task_id: taskId,
|
||||
}) as {
|
||||
recommended_interval: number | null;
|
||||
confidence: string;
|
||||
data_points: number;
|
||||
recommendation_reason: string | null;
|
||||
};
|
||||
if (res.recommended_interval) {
|
||||
this._showToast(
|
||||
`${t("reanalyze_result", this._lang)}: ${res.recommended_interval} ${t("days", this._lang)} ` +
|
||||
`(${t(`confidence_${res.confidence}`, this._lang)}, ${res.data_points} ${t("data_points", this._lang)})`,
|
||||
);
|
||||
} else {
|
||||
this._showToast(t("reanalyze_insufficient_data", this._lang));
|
||||
}
|
||||
await this._loadData();
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
const res = await this._runAction<{
|
||||
recommended_interval: number | null;
|
||||
confidence: string;
|
||||
data_points: number;
|
||||
recommendation_reason: string | null;
|
||||
}>({
|
||||
type: "maintenance_supporter/task/analyze_interval",
|
||||
entry_id: entryId,
|
||||
task_id: taskId,
|
||||
});
|
||||
if (!res) return;
|
||||
if (res.recommended_interval) {
|
||||
this._showToast(
|
||||
`${t("reanalyze_result", this._lang)}: ${res.recommended_interval} ${t("days", this._lang)} ` +
|
||||
`(${t(`confidence_${res.confidence}`, this._lang)}, ${res.data_points} ${t("data_points", this._lang)})`,
|
||||
);
|
||||
} else {
|
||||
this._showToast(t("reanalyze_insufficient_data", this._lang));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1737,21 +1683,10 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
}
|
||||
|
||||
private async _postponeTask(entryId: string, taskId: string, until: string): Promise<void> {
|
||||
this._actionLoading = true;
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/task/postpone",
|
||||
entry_id: entryId,
|
||||
task_id: taskId,
|
||||
until,
|
||||
});
|
||||
this._showToast(t("postponed", this._lang));
|
||||
await this._loadData();
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
} finally {
|
||||
this._actionLoading = false;
|
||||
}
|
||||
await this._runAction(
|
||||
{ type: "maintenance_supporter/task/postpone", entry_id: entryId, task_id: taskId, until },
|
||||
{ successToast: t("postponed", this._lang) },
|
||||
);
|
||||
}
|
||||
|
||||
private async _promptPostponeTask(entryId: string, taskId: string): Promise<void> {
|
||||
@@ -1769,19 +1704,13 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
}
|
||||
|
||||
private async _snoozeTask(entryId: string, taskId: string): Promise<void> {
|
||||
this._actionLoading = true;
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/task/snooze",
|
||||
entry_id: entryId,
|
||||
task_id: taskId,
|
||||
});
|
||||
this._showToast(t("snoozed", this._lang));
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
} finally {
|
||||
this._actionLoading = false;
|
||||
}
|
||||
// Now reloads like every sibling action — this was the one mutation that
|
||||
// skipped the refresh, leaving the snoozed due date stale until the next
|
||||
// poll.
|
||||
await this._runAction(
|
||||
{ type: "maintenance_supporter/task/snooze", entry_id: entryId, task_id: taskId },
|
||||
{ successToast: t("snoozed", this._lang) },
|
||||
);
|
||||
}
|
||||
|
||||
private _dismissSuggestion(entryId?: string, taskId?: string): void {
|
||||
@@ -1850,11 +1779,13 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
if (manual) {
|
||||
const start = manual.task_pages![taskId];
|
||||
const count = 4;
|
||||
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
|
||||
type: "auth/sign_path",
|
||||
path: `/api/maintenance_supporter/document/${manual.id}/excerpt?start=${start}&count=${count}`,
|
||||
expires: 3600,
|
||||
});
|
||||
const signed = {
|
||||
path: await signApiPath(
|
||||
this.hass,
|
||||
`/api/maintenance_supporter/document/${manual.id}/excerpt?start=${start}&count=${count}`,
|
||||
3600,
|
||||
),
|
||||
};
|
||||
excerpt = {
|
||||
title: manual.title || manual.filename || "Manual",
|
||||
startPage: start, endPage: start + count - 1,
|
||||
@@ -1920,19 +1851,9 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
if (isSafeHttpUrl(doc.url)) window.open(doc.url!, "_blank", "noopener");
|
||||
return;
|
||||
}
|
||||
// Open the tab synchronously (inside the click gesture) so it isn't
|
||||
// popup-blocked, then point it at the freshly signed URL.
|
||||
const win = window.open("about:blank", "_blank");
|
||||
void this.hass.connection
|
||||
.sendMessagePromise<{ path: string }>({
|
||||
type: "auth/sign_path",
|
||||
path: `/api/maintenance_supporter/document/${doc.id}`,
|
||||
expires: 300,
|
||||
})
|
||||
.then((signed) => {
|
||||
if (win) win.location.href = new URL(signed.path, window.location.origin).href;
|
||||
})
|
||||
.catch(() => win?.close());
|
||||
void openSignedDocument(this.hass, doc.id).catch(() => {
|
||||
/* tab already closed by the helper; the panel toast adds no value here */
|
||||
});
|
||||
}
|
||||
|
||||
/** #73: persist one checklist tick. Sends the FULL current state (the
|
||||
@@ -1947,15 +1868,10 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
const current = task.checklist_progress?.[step] ?? false;
|
||||
state[step] = step === item ? done : current;
|
||||
}
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/task/checklist_progress",
|
||||
entry_id: entryId, task_id: taskId, checklist_state: state,
|
||||
});
|
||||
await this._loadData();
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
}
|
||||
await this._runAction({
|
||||
type: "maintenance_supporter/task/checklist_progress",
|
||||
entry_id: entryId, task_id: taskId, checklist_state: state,
|
||||
});
|
||||
}
|
||||
|
||||
private _openCompleteDialog(entryId: string, taskId: string, taskName: string, checklist?: string[], adaptiveEnabled?: boolean): void {
|
||||
@@ -1988,7 +1904,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
// #104 follow-up: parts carry unit costs — the dialog offers their sum
|
||||
// as a one-click cost suggestion (buy task: restock qty × unit cost).
|
||||
dlg.restockUnitCost = tk?.part_ref ? (refPart?.cost ?? null) : null;
|
||||
dlg.currencySymbol = this._budget?.currency_symbol || "";
|
||||
dlg.currencySymbol = this._currencySymbol;
|
||||
// #111: a link may point at another object's pool — name that object, and
|
||||
// never drop a line that fails to resolve (the old .filter(Boolean) hid it).
|
||||
dlg.consumesInfo = (tk?.consumes_parts || []).map((link) =>
|
||||
@@ -2275,7 +2191,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
|
||||
private _setOverviewTab(tab: "today" | "dashboard" | "calendar" | "settings"): void {
|
||||
this._overviewTab = tab;
|
||||
try { localStorage.setItem(LS_KEYS.overviewTab, tab); } catch { /* private mode */ }
|
||||
try { lsSet(LS_KEYS.overviewTab, tab); } catch { /* private mode */ }
|
||||
this._scrollContentToTop();
|
||||
}
|
||||
|
||||
@@ -2441,7 +2357,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
@change=${(e: Event) => {
|
||||
this._sortMode = (e.target as HTMLSelectElement).value as SortMode;
|
||||
this._activeViewId = "";
|
||||
try { localStorage.setItem(LS_KEYS.taskSort, this._sortMode); } catch { /* private mode */ }
|
||||
try { lsSet(LS_KEYS.taskSort, this._sortMode); } catch { /* private mode */ }
|
||||
}}
|
||||
>
|
||||
<option value="due_date" ?selected=${this._sortMode === "due_date"}>${t("sort_due_date", L)}</option>
|
||||
@@ -2460,7 +2376,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
@change=${(e: Event) => {
|
||||
this._groupByMode = (e.target as HTMLSelectElement).value as GroupByMode;
|
||||
this._activeViewId = "";
|
||||
try { localStorage.setItem(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ }
|
||||
try { lsSet(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ }
|
||||
}}
|
||||
>
|
||||
<option value="none" ?selected=${this._groupByMode === "none"}>${t("groupby_none", L)}</option>
|
||||
@@ -2765,7 +2681,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
.value=${this._objectSortMode}
|
||||
@change=${(e: Event) => {
|
||||
this._objectSortMode = (e.target as HTMLSelectElement).value as ObjectSortMode;
|
||||
localStorage.setItem(LS_KEYS.objectSort, this._objectSortMode);
|
||||
try { lsSet(LS_KEYS.objectSort, this._objectSortMode); } catch { /* private mode */ }
|
||||
}}
|
||||
>
|
||||
<option value="alphabetical" ?selected=${this._objectSortMode === "alphabetical"}>${t("sort_alphabetical", L)}</option>
|
||||
@@ -2794,7 +2710,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
.value=${this._groupByMode}
|
||||
@change=${(e: Event) => {
|
||||
this._groupByMode = (e.target as HTMLSelectElement).value as GroupByMode;
|
||||
try { localStorage.setItem(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ }
|
||||
try { lsSet(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ }
|
||||
}}
|
||||
>
|
||||
<option value="none" ?selected=${this._groupByMode === "none"}>${t("groupby_none", L)}</option>
|
||||
@@ -2843,7 +2759,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
|
||||
private _setObjectViewMode(mode: "cards" | "table"): void {
|
||||
this._objectViewMode = mode;
|
||||
localStorage.setItem(LS_KEYS.objectView, mode);
|
||||
try { lsSet(LS_KEYS.objectView, mode); } catch { /* private mode */ }
|
||||
}
|
||||
|
||||
// ── #130: instance-wide parts overview ────────────────────────────────────
|
||||
@@ -2851,7 +2767,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
private _renderAllParts() {
|
||||
const L = this._lang;
|
||||
const rows = this._allParts;
|
||||
const currency = this._budget?.currency_symbol || "";
|
||||
const currency = this._currencySymbol;
|
||||
return html`
|
||||
<div class="breadcrumb">
|
||||
<ha-icon-button @click=${() => this._showAllObjects()}>
|
||||
@@ -3127,15 +3043,10 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
})
|
||||
: confirm(`${t("delete_group_confirm", this._lang).replace("{name}", name)}`);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/group/delete",
|
||||
group_id: groupId,
|
||||
});
|
||||
await this._loadData();
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
}
|
||||
await this._runAction({
|
||||
type: "maintenance_supporter/group/delete",
|
||||
group_id: groupId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Budget as KPI tiles in the stats strip (#125) — replaces the old
|
||||
@@ -3148,7 +3059,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
const b = this._budget;
|
||||
if (!b) return nothing;
|
||||
const L = this._lang;
|
||||
const cs = b.currency_symbol || DEFAULT_CURRENCY_SYMBOL;
|
||||
const cs = this._currencySymbol;
|
||||
const tile = (label: string, spent: number, budget: number | null) => {
|
||||
if (budget !== null) {
|
||||
const pct = Math.min(100, Math.max(0, (spent / budget) * 100));
|
||||
@@ -3415,7 +3326,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
.entryId=${obj.entry_id}
|
||||
.parts=${obj.parts || []}
|
||||
.canWrite=${!isOperator}
|
||||
.currencySymbol=${this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL}
|
||||
.currencySymbol=${this._currencySymbol}
|
||||
@parts-changed=${() => this._loadData()}
|
||||
></maintenance-parts-section>
|
||||
</div>
|
||||
@@ -3506,7 +3417,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
|
||||
private _gsDismissed(): Set<string> {
|
||||
try {
|
||||
return new Set(JSON.parse(localStorage.getItem(LS_KEYS.gettingStartedDismissed) || "[]"));
|
||||
return new Set(JSON.parse(lsGet(LS_KEYS.gettingStartedDismissed) || "[]"));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
@@ -3515,7 +3426,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
private _dismissGettingStarted(id: string): void {
|
||||
const next = this._gsDismissed();
|
||||
next.add(id);
|
||||
try { localStorage.setItem(LS_KEYS.gettingStartedDismissed, JSON.stringify([...next])); } catch { /* storage blocked */ }
|
||||
try { lsSet(LS_KEYS.gettingStartedDismissed, JSON.stringify([...next])); } catch { /* storage blocked */ }
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
@@ -3612,7 +3523,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
const next = new Set(this._collapsedSections);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
this._collapsedSections = next;
|
||||
try { localStorage.setItem(LS_KEYS.collapsedSections, JSON.stringify([...next])); } catch { /* private mode */ }
|
||||
try { lsSet(LS_KEYS.collapsedSections, JSON.stringify([...next])); } catch { /* private mode */ }
|
||||
}
|
||||
|
||||
/** Build the context the history renderers need from panel state. */
|
||||
@@ -3639,7 +3550,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
hass: this.hass,
|
||||
filter: this._historyFilter,
|
||||
search: this._historySearch,
|
||||
currencySymbol: this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL,
|
||||
currencySymbol: this._currencySymbol,
|
||||
setFilter: (f) => { this._historyFilter = f; },
|
||||
setSearch: (s) => { this._historySearch = s; },
|
||||
openEdit: (entry) => this._openHistoryEdit(entry),
|
||||
@@ -3674,7 +3585,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
moreMenuOpen: this._moreMenuOpen,
|
||||
activeTab: this._activeTab,
|
||||
features: this._features,
|
||||
currencySymbol: this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL,
|
||||
currencySymbol: this._currencySymbol,
|
||||
collapsedSections: this._collapsedSections,
|
||||
costDurationToggle: this._costDurationToggle,
|
||||
suggestionDismissed: this._dismissedSuggestions.has(`${entryId}_${taskId}`),
|
||||
|
||||
@@ -79,6 +79,17 @@ export function t(key: string, lang?: string): string {
|
||||
return STORE[l]?.[key] ?? STORE.en[key] ?? key;
|
||||
}
|
||||
|
||||
/**
|
||||
* The UI language for a hass object — the ONE rule every component's `_lang`
|
||||
* getter delegates to. Before this helper the 23 hand-copied getters had
|
||||
* drifted into three variants (`|| "en"`, `?? navigator.language...`, and a
|
||||
* `locale.language` probe), so sibling components could resolve different
|
||||
* languages from the same hass.
|
||||
*/
|
||||
export function langOf(hass?: { language?: string }): string {
|
||||
return hass?.language || "en";
|
||||
}
|
||||
|
||||
/** True when *lang*'s table is in memory (English is always bundled). */
|
||||
export function isLocaleLoaded(lang?: string): boolean {
|
||||
const l = normLang(lang);
|
||||
|
||||
Reference in New Issue
Block a user