278 files
This commit is contained in:
+319
-201
@@ -5,7 +5,7 @@ import { property, state } from "lit/decorators.js";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import { live } from "lit/directives/live.js";
|
||||
import type { HomeAssistant, AdvancedFeatures, BudgetStatus, HAUser } from "../types";
|
||||
import { t, langOf, personStyles } from "../styles";
|
||||
import { t, langOf, personStyles, syncCurrencyDecimals} from "../styles";
|
||||
import { signApiPath } from "../helpers/document-url";
|
||||
import { downloadUrl } from "../helpers/download";
|
||||
import { UserService } from "../user-service";
|
||||
@@ -13,6 +13,9 @@ import { AVATAR_PALETTE, personOf, renderPersonAvatar } from "../helpers/person"
|
||||
import { OBJECT_COLUMNS, sanitizeColumns } from "../helpers/object-columns";
|
||||
import { downloadTextFile } from "../helpers/download";
|
||||
import { invalidateSettingsCache } from "../helpers/settings-cache";
|
||||
import { SETTING_INT_RANGES, settingIntRange } from "../helpers/setting-ranges";
|
||||
import { isoDateLocal } from "../helpers/calendar-bucket";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import "./ms-date-field";
|
||||
|
||||
/** One household member and the notify services they actually resolve to.
|
||||
@@ -51,6 +54,18 @@ interface SettingsResponse {
|
||||
/** #146: household "low" floors (percent) for discovery + battery fleet. */
|
||||
default_consumable_threshold?: number;
|
||||
battery_low_percent?: number;
|
||||
/** D#162 follow-up: per-type lifetime overrides + the computed catalog. */
|
||||
battery_lifetime_months?: Record<string, number>;
|
||||
battery_lifetimes?: {
|
||||
type: string;
|
||||
months: number;
|
||||
source: "override" | "table" | "default";
|
||||
default_months: number;
|
||||
override_months: number | null;
|
||||
/** What the fleet learned for this type, per device model (never applied type-wide). */
|
||||
learned_models: { model: string; model_key: string; months: number; samples: number }[];
|
||||
in_fleet: boolean;
|
||||
}[];
|
||||
/** Computed: what Battery Notes reports (null when not installed). */
|
||||
battery_notes?: {
|
||||
default: number;
|
||||
@@ -60,6 +75,8 @@ interface SettingsResponse {
|
||||
} | null;
|
||||
/** #145: how task rows show Complete / Skip. */
|
||||
row_action_style?: "buttons_compact" | "buttons" | "icons";
|
||||
/** #170: reference numbers in front of names in every list. */
|
||||
ref_numbers_in_lists?: boolean;
|
||||
row_action_notice_pending?: boolean;
|
||||
};
|
||||
notifications: {
|
||||
@@ -79,6 +96,8 @@ interface SettingsResponse {
|
||||
scope_view_id: string;
|
||||
/** #165: your own notification rule — event-only delivery + extra-data template. */
|
||||
event_only?: boolean;
|
||||
/** #173 follow-up: completion notifications — off | automatic | all. */
|
||||
completed?: string;
|
||||
extra_data?: string;
|
||||
};
|
||||
actions: {
|
||||
@@ -97,6 +116,8 @@ interface SettingsResponse {
|
||||
alert_threshold_pct: number;
|
||||
currency: string;
|
||||
currency_symbol: string;
|
||||
/** Decimal places for every displayed amount (0 = whole numbers). */
|
||||
currency_decimals?: number;
|
||||
};
|
||||
// v2.10.0 archive automation (panel-managed). Optional for forward-compat
|
||||
// with a backend that predates the feature.
|
||||
@@ -135,6 +156,11 @@ const CURRENCIES = [
|
||||
"CZK", "PLN", "RUB", "SEK", "NOK", "DKK", "UAH",
|
||||
];
|
||||
|
||||
/** The currency_decimals <select> options — derived from the registry range
|
||||
* (0..3), not a literal list. */
|
||||
const CURRENCY_DECIMAL_OPTIONS: readonly number[] = (([lo, hi]) =>
|
||||
Array.from({ length: hi - lo + 1 }, (_, i) => lo + i))(SETTING_INT_RANGES.currency_decimals);
|
||||
|
||||
export class MaintenanceSettingsView extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@property({ attribute: false }) public features!: AdvancedFeatures;
|
||||
@@ -163,6 +189,9 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
@state() private _vacAllTasks: Array<{ entry_id: string; object_name: string; task_id: string; task_name: string }> = [];
|
||||
@state() private _vacPreview: VacationPreviewRow[] = [];
|
||||
@state() private _vacPreviewLoading = false;
|
||||
/** A preview row's Complete/Skip is in flight — the buttons disable so a
|
||||
* double-tap cannot send the completion twice (DRY audit 2026-09-12). */
|
||||
@state() private _previewBusy = false;
|
||||
@state() private _vacSaving = false;
|
||||
|
||||
// Print QR codes section state
|
||||
@@ -236,6 +265,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
type: "maintenance_supporter/settings",
|
||||
});
|
||||
this._settings = result as SettingsResponse;
|
||||
syncCurrencyDecimals(this._settings.budget);
|
||||
this._hydrateVacationFromSettings();
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -267,6 +297,64 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
/** #146 follow-up: what Battery Notes currently reports, with the rule
|
||||
* spelled out — warn-toned when its default sits above our floor (then a
|
||||
* lowered floor would silently change nothing for noted batteries). */
|
||||
/** D#162 follow-up: the typical-lifetime table — fleet types first, each
|
||||
* with its effective months and where they come from; editing a value
|
||||
* writes the household override, "Use default" removes it. Only batteries
|
||||
* WITHOUT a percentage use these (the hint says so). */
|
||||
private _renderBatteryLifetimes(L: string) {
|
||||
const rows = this._settings?.general?.battery_lifetimes;
|
||||
if (!rows || !rows.length) return nothing;
|
||||
const overrides = this._settings?.general?.battery_lifetime_months ?? {};
|
||||
const sourceLabel = (r: NonNullable<typeof rows>[number]) => t("lifetime_source_" + r.source, L);
|
||||
const unit = t("settings_battery_lifetime_months", L);
|
||||
const learnedLine = (r: NonNullable<typeof rows>[number]) =>
|
||||
r.learned_models.length
|
||||
? html`<div class="bl-learned">${t("settings_battery_lifetime_learned_models", L).replace(
|
||||
"{list}",
|
||||
r.learned_models.map((m) => `${m.model} ${m.months} ${unit} (${m.samples})`).join(" · "),
|
||||
)}</div>`
|
||||
: nothing;
|
||||
// No live() on the month inputs (bug audit 2026-09-12, #176 class): the
|
||||
// view re-renders on every hass assignment and live() reset a half-typed
|
||||
// value to the stored months. An out-of-range or rejected entry snaps
|
||||
// back by writing the input directly instead.
|
||||
const commit = (type: string, input: HTMLInputElement, stored: number) => {
|
||||
const months = Number.parseInt(input.value, 10);
|
||||
if (!Number.isFinite(months) || months < 1 || months > 240) { input.value = String(stored); return; }
|
||||
void this._updateSetting("battery_lifetime_months", { ...overrides, [type]: months })
|
||||
.then((ok) => { if (!ok) input.value = String(stored); });
|
||||
};
|
||||
const reset = (type: string) => {
|
||||
const next = { ...overrides };
|
||||
delete next[type];
|
||||
this._updateSetting("battery_lifetime_months", next);
|
||||
};
|
||||
const fleetRows = rows.filter((r) => r.in_fleet);
|
||||
const otherRows = rows.filter((r) => !r.in_fleet);
|
||||
const row = (r: NonNullable<typeof rows>[number]) => html`
|
||||
<div class="bl-row ${r.source === "override" ? "bl-override" : ""}">
|
||||
<span class="bl-type">${r.type}${r.in_fleet ? html` <span class="bl-fleet">${t("settings_battery_lifetime_in_fleet", L)}</span>` : nothing}</span>
|
||||
<input class="bl-months" type="number" min="1" max="240" .value=${String(r.months)}
|
||||
@change=${(e: Event) => commit(r.type, e.target as HTMLInputElement, r.months)} />
|
||||
<span class="bl-unit">${t("settings_battery_lifetime_months", L)}</span>
|
||||
<span class="bl-source">${sourceLabel(r)}</span>
|
||||
${r.source === "override"
|
||||
? html`<button type="button" class="bl-reset" @click=${() => reset(r.type)}>${t("settings_battery_lifetime_reset", L)}</button>`
|
||||
: nothing}
|
||||
${learnedLine(r)}
|
||||
</div>`;
|
||||
return html`
|
||||
<h4 class="bl-title">${t("settings_battery_lifetimes", L)}</h4>
|
||||
<div class="setting-hint">${t("settings_battery_lifetimes_hint", L)}</div>
|
||||
<div class="bl-table">
|
||||
${fleetRows.map(row)}
|
||||
${otherRows.length
|
||||
? html`<details class="bl-more"><summary>${otherRows.length} ×</summary>${otherRows.map(row)}</details>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderBatteryNotesHint(L: string) {
|
||||
const bn = this._settings?.general?.battery_notes;
|
||||
if (!bn || !bn.devices) return nothing;
|
||||
@@ -358,25 +446,33 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async _updateSetting(key: string, value: unknown): Promise<void> {
|
||||
try {
|
||||
const result = await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/global/update",
|
||||
settings: { [key]: value },
|
||||
});
|
||||
this._settings = result as SettingsResponse;
|
||||
/** Resolves true when the server accepted the value, false on a reject
|
||||
* (the caller decides how to snap its control back). */
|
||||
private async _updateSetting(key: string, value: unknown): Promise<boolean> {
|
||||
const result = await this._ws<SettingsResponse>({
|
||||
type: "maintenance_supporter/global/update",
|
||||
settings: { [key]: value },
|
||||
});
|
||||
if (result) {
|
||||
this._settings = result;
|
||||
syncCurrencyDecimals(this._settings.budget);
|
||||
// The card / Lovelace dialogs read their copy through a page-wide
|
||||
// cache — drop it so they follow the change without a reload.
|
||||
invalidateSettingsCache();
|
||||
this._showToast(t("settings_saved", this._lang));
|
||||
this.dispatchEvent(new CustomEvent("settings-changed"));
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
return true;
|
||||
} else {
|
||||
// The control already shows the rejected value (the browser changed it
|
||||
// before we asked the server). `_settings` is unchanged, so a plain
|
||||
// re-render would not touch the DOM — the selects bind their value
|
||||
// through `live()` precisely so this re-render snaps them back.
|
||||
// through `live()` precisely so this re-render snaps them back. Typed
|
||||
// inputs do NOT use live() (bug audit 2026-09-12: the view re-renders
|
||||
// on every hass assignment, and live() reset a half-typed number to
|
||||
// the stored value mid-typing — the #176 class); their callers snap
|
||||
// `input.value` back themselves when this resolves false.
|
||||
this.requestUpdate();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,15 +500,29 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
setTimeout(() => { this._toast = ""; }, 3000);
|
||||
}
|
||||
|
||||
/** One WS round-trip; a rejection becomes a toast carrying the server's
|
||||
* localized reason ("Limit reached", "Invalid date", …) and resolves
|
||||
* undefined. The silent best-effort loaders above deliberately do NOT
|
||||
* use this — they want a fallback value, not a toast. */
|
||||
private _ws<T = unknown>(
|
||||
payload: Record<string, unknown>,
|
||||
fallbackKey?: string,
|
||||
busy?: (busy: boolean) => void,
|
||||
): Promise<T | undefined> {
|
||||
return runWs<T>(this, payload, { fallbackKey, busy, onError: (msg) => this._showToast(msg) });
|
||||
}
|
||||
|
||||
/** Bounded integer settings: an out-of-range entry used to be dropped
|
||||
* silently while the field kept showing it (bug review 2026-09-04). Now
|
||||
* it is named in a toast and the field snaps back to the stored value;
|
||||
* a server reject snaps back through `live()` like the selects. */
|
||||
* a server reject snaps back the same way — by writing `input.value`
|
||||
* directly, since these inputs no longer bind through `live()` (bug
|
||||
* audit 2026-09-12). */
|
||||
private _onBoundedIntChange(e: Event, key: string, min: number, max: number, current: number): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const v = parseInt(input.value, 10);
|
||||
if (Number.isInteger(v) && v >= min && v <= max) {
|
||||
void this._updateSetting(key, v);
|
||||
void this._updateSetting(key, v).then((ok) => { if (!ok) input.value = String(current); });
|
||||
return;
|
||||
}
|
||||
this._showToast(
|
||||
@@ -421,6 +531,17 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
input.value = String(current);
|
||||
}
|
||||
|
||||
/** A bounded-integer setting input. min/max come from the registry mirror
|
||||
* (helpers/setting-ranges.ts — the backend's exact bounds, which the WS
|
||||
* sanitiser enforces by silently DROPPING an out-of-range value), and an
|
||||
* out-of-range or cleared entry snaps back with a toast instead of
|
||||
* sending a default. */
|
||||
private _intSetting(key: string, current: number) {
|
||||
const [lo, hi] = settingIntRange(key);
|
||||
return html`<input type="number" min=${lo} max=${hi} .value=${String(current)}
|
||||
@change=${(e: Event) => this._onBoundedIntChange(e, key, lo, hi, current)} />`;
|
||||
}
|
||||
|
||||
private _downloadFile(content: string, filename: string, mime: string): void {
|
||||
// Companion-app safe download (target=_blank + DOM + deferred revoke).
|
||||
downloadTextFile(content, filename, mime);
|
||||
@@ -725,24 +846,22 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
<h3>${t("settings_general", L)}</h3>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_default_warning", L)}</span>
|
||||
<input type="number" min="0" max="365" .value=${live(String(g.default_warning_days))}
|
||||
@change=${(e: Event) =>
|
||||
// 0 = no warning window (due soon only on the due date) — #145.
|
||||
this._onBoundedIntChange(e, "default_warning_days", 0, 365, g.default_warning_days)} />
|
||||
<!-- No live() on the typed number inputs below (bug audit
|
||||
2026-09-12, #176 class): a hass re-render must not reset a
|
||||
half-typed value; rejects snap back via _onBoundedIntChange. -->
|
||||
<!-- 0 = no warning window (due soon only on the due date) — #145. -->
|
||||
${this._intSetting("default_warning_days", g.default_warning_days)}
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_consumable_threshold", L)}</span>
|
||||
<input type="number" min="1" max="90" .value=${live(String(g.default_consumable_threshold ?? 10))}
|
||||
@change=${(e: Event) =>
|
||||
this._onBoundedIntChange(e, "default_consumable_threshold", 1, 90, g.default_consumable_threshold ?? 10)} />
|
||||
${this._intSetting("default_consumable_threshold", g.default_consumable_threshold ?? 10)}
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_battery_low_percent", L)}</span>
|
||||
<input type="number" min="1" max="90" .value=${live(String(g.battery_low_percent ?? 20))}
|
||||
@change=${(e: Event) =>
|
||||
this._onBoundedIntChange(e, "battery_low_percent", 1, 90, g.battery_low_percent ?? 20)} />
|
||||
${this._intSetting("battery_low_percent", g.battery_low_percent ?? 20)}
|
||||
</label>
|
||||
${this._renderBatteryNotesHint(L)}
|
||||
${this._renderBatteryLifetimes(L)}
|
||||
<div class="setting-hint">${t("settings_thresholds_hint", L)}</div>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_row_actions", L)}</span>
|
||||
@@ -752,12 +871,26 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
<option value=${v} ?selected=${(g.row_action_style || "buttons_compact") === v}>${t(`row_actions_${v}`, L)}</option>`)}
|
||||
</select>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_ref_numbers_in_lists", L)}</span>
|
||||
<input type="checkbox" class="refs-in-lists" .checked=${g.ref_numbers_in_lists === true}
|
||||
@change=${(e: Event) => this._updateSetting("ref_numbers_in_lists", (e.target as HTMLInputElement).checked)} />
|
||||
</label>
|
||||
<div class="setting-hint">${t("settings_ref_numbers_in_lists_hint", L)}</div>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_currency", L)}</span>
|
||||
<select .value=${live(b.currency)} @change=${(e: Event) => this._updateSetting("budget_currency", (e.target as HTMLSelectElement).value)}>
|
||||
${CURRENCIES.map((c) => html`<option value=${c} ?selected=${b.currency === c}>${c}</option>`)}
|
||||
</select>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_currency_decimals", L)}</span>
|
||||
<select class="currency-decimals" .value=${live(String(b.currency_decimals ?? 0))}
|
||||
@change=${(e: Event) => this._updateSetting("currency_decimals", Number((e.target as HTMLSelectElement).value))}>
|
||||
${CURRENCY_DECIMAL_OPTIONS.map((d) => html`<option value=${String(d)} ?selected=${(b.currency_decimals ?? 0) === d}>${d}</option>`)}
|
||||
</select>
|
||||
</label>
|
||||
<div class="setting-hint">${t("settings_currency_decimals_hint", L)}</div>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_panel_enabled", L)}</span>
|
||||
<input type="checkbox" .checked=${g.panel_enabled}
|
||||
@@ -861,8 +994,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
${n.due_soon_enabled ? html`
|
||||
<label class="setting-row sub-row">
|
||||
<span class="setting-desc">${t("settings_interval_hours", L)}</span>
|
||||
<input type="number" min="0" max="720" .value=${String(n.due_soon_interval_hours)}
|
||||
@change=${(e: Event) => this._updateSetting("notify_due_soon_interval_hours", parseInt((e.target as HTMLInputElement).value, 10) || 0)} />
|
||||
${this._intSetting("notify_due_soon_interval_hours", n.due_soon_interval_hours)}
|
||||
</label>
|
||||
` : nothing}
|
||||
|
||||
@@ -876,8 +1008,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
${n.overdue_enabled ? html`
|
||||
<label class="setting-row sub-row">
|
||||
<span class="setting-desc">${t("settings_interval_hours", L)}</span>
|
||||
<input type="number" min="0" max="720" .value=${String(n.overdue_interval_hours)}
|
||||
@change=${(e: Event) => this._updateSetting("notify_overdue_interval_hours", parseInt((e.target as HTMLInputElement).value, 10) || 0)} />
|
||||
${this._intSetting("notify_overdue_interval_hours", n.overdue_interval_hours)}
|
||||
</label>
|
||||
` : nothing}
|
||||
|
||||
@@ -891,8 +1022,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
${n.triggered_enabled ? html`
|
||||
<label class="setting-row sub-row">
|
||||
<span class="setting-desc">${t("settings_interval_hours", L)}</span>
|
||||
<input type="number" min="0" max="720" .value=${String(n.triggered_interval_hours)}
|
||||
@change=${(e: Event) => this._updateSetting("notify_triggered_interval_hours", parseInt((e.target as HTMLInputElement).value, 10) || 0)} />
|
||||
${this._intSetting("notify_triggered_interval_hours", n.triggered_interval_hours)}
|
||||
</label>
|
||||
` : nothing}
|
||||
|
||||
@@ -928,8 +1058,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_max_per_day", L)}</span>
|
||||
<input type="number" min="0" max="100" .value=${String(n.max_per_day)}
|
||||
@change=${(e: Event) => this._updateSetting("max_notifications_per_day", parseInt((e.target as HTMLInputElement).value, 10) || 0)} />
|
||||
${this._intSetting("max_notifications_per_day", n.max_per_day)}
|
||||
</label>
|
||||
|
||||
<label class="setting-row">
|
||||
@@ -940,8 +1069,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
${n.bundling_enabled ? html`
|
||||
<label class="setting-row sub-row">
|
||||
<span class="setting-desc">${t("settings_bundle_threshold", L)}</span>
|
||||
<input type="number" min="2" max="20" .value=${String(n.bundle_threshold)}
|
||||
@change=${(e: Event) => this._updateSetting("notification_bundle_threshold", parseInt((e.target as HTMLInputElement).value, 10) || 2)} />
|
||||
${this._intSetting("notification_bundle_threshold", n.bundle_threshold)}
|
||||
</label>
|
||||
` : nothing}
|
||||
<label class="setting-row">
|
||||
@@ -970,6 +1098,16 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
</select>
|
||||
</label>
|
||||
<div class="setting-hint">${t("settings_notify_scope_hint", L)}</div>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_notify_completed", L)}</span>
|
||||
<select class="notify-completed" .value=${live(n.completed || "off")}
|
||||
@change=${(e: Event) => this._updateSetting("notify_completed", (e.target as HTMLSelectElement).value)}>
|
||||
<option value="off">${t("notify_completed_off", L)}</option>
|
||||
<option value="automatic">${t("notify_completed_automatic", L)}</option>
|
||||
<option value="all">${t("notify_completed_all", L)}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="setting-hint">${t("settings_notify_completed_hint", L)}</div>
|
||||
|
||||
<div class="notify-rule">
|
||||
<h4 style="margin: 16px 0 8px; font-size: 14px;">${t("settings_notify_rule", L)}</h4>
|
||||
@@ -1011,8 +1149,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
${a.snooze_enabled ? html`
|
||||
<label class="setting-row sub-row">
|
||||
<span class="setting-desc">${t("settings_snooze_hours", L)}</span>
|
||||
<input type="number" min="1" max="168" .value=${String(a.snooze_duration_hours)}
|
||||
@change=${(e: Event) => this._updateSetting("snooze_duration_hours", parseInt((e.target as HTMLInputElement).value, 10) || 4)} />
|
||||
${this._intSetting("snooze_duration_hours", a.snooze_duration_hours)}
|
||||
</label>
|
||||
` : nothing}
|
||||
<label class="setting-row">
|
||||
@@ -1029,8 +1166,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
${a.warranty_reminder_enabled ? html`
|
||||
<label class="setting-row sub-row">
|
||||
<span class="setting-desc">${t("settings_warranty_reminder_days", L)}</span>
|
||||
<input type="number" min="1" max="365" .value=${String(a.warranty_reminder_days)}
|
||||
@change=${(e: Event) => this._updateSetting("warranty_reminder_days", parseInt((e.target as HTMLInputElement).value, 10) || 30)} />
|
||||
${this._intSetting("warranty_reminder_days", a.warranty_reminder_days)}
|
||||
</label>
|
||||
` : nothing}
|
||||
<div class="setting-hint">${t("settings_warranty_reminder_hint", L)}</div>
|
||||
@@ -1063,8 +1199,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
${b.alerts_enabled ? html`
|
||||
<label class="setting-row sub-row">
|
||||
<span class="setting-desc">${t("settings_budget_threshold", L)}</span>
|
||||
<input type="number" min="1" max="100" .value=${String(b.alert_threshold_pct)}
|
||||
@change=${(e: Event) => this._updateSetting("budget_alert_threshold", parseInt((e.target as HTMLInputElement).value, 10) || 80)} />
|
||||
${this._intSetting("budget_alert_threshold", b.alert_threshold_pct)}
|
||||
</label>
|
||||
` : nothing}
|
||||
</div>
|
||||
@@ -1081,13 +1216,11 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
<p class="section-desc">${t("settings_archive_desc", L)}</p>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_archive_oneoff_days", L)}</span>
|
||||
<input type="number" min="0" max="3650" step="1" .value=${String(a.oneoff_days)}
|
||||
@change=${(e: Event) => this._updateSetting("archive_oneoff_days", parseInt((e.target as HTMLInputElement).value, 10) || 0)} />
|
||||
${this._intSetting("archive_oneoff_days", a.oneoff_days)}
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_delete_archived_oneoff_days", L)}</span>
|
||||
<input type="number" min="0" max="3650" step="1" .value=${String(a.delete_archived_oneoff_days)}
|
||||
@change=${(e: Event) => this._updateSetting("delete_archived_oneoff_days", parseInt((e.target as HTMLInputElement).value, 10) || 0)} />
|
||||
${this._intSetting("delete_archived_oneoff_days", a.delete_archived_oneoff_days)}
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
@@ -1235,9 +1368,9 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
<div class="vac-preview-events">${eventLabel}</div>
|
||||
</div>
|
||||
<div class="vac-preview-actions">
|
||||
<button @click=${() => this._previewActionComplete(row)}>${t("qr_action_complete", L)}</button>
|
||||
<button .disabled=${this._previewBusy} @click=${() => this._previewActionComplete(row)}>${t("qr_action_complete", L)}</button>
|
||||
${row.kind === "time_based" && row.allow_skip !== false
|
||||
? html`<button @click=${() => this._previewActionSkip(row)}>${t("qr_action_skip", L)}</button>`
|
||||
? html`<button .disabled=${this._previewBusy} @click=${() => this._previewActionSkip(row)}>${t("qr_action_skip", L)}</button>`
|
||||
: nothing}
|
||||
<button class=${isExempt ? "vac-notify-on" : ""}
|
||||
@click=${() => this._toggleVacationExempt(row.task_id, !isExempt)}>
|
||||
@@ -1254,39 +1387,35 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
// --- Vacation actions ---
|
||||
|
||||
private async _loadAllTasksForVacation(): Promise<void> {
|
||||
try {
|
||||
const result = await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/objects",
|
||||
}) as { objects: Array<{
|
||||
entry_id: string;
|
||||
object: { name: string };
|
||||
tasks: Array<{ id: string; name: string }>;
|
||||
}> };
|
||||
const flat: typeof this._vacAllTasks = [];
|
||||
for (const obj of result.objects || []) {
|
||||
for (const t of obj.tasks || []) {
|
||||
flat.push({
|
||||
entry_id: obj.entry_id,
|
||||
object_name: obj.object.name || "",
|
||||
task_id: t.id,
|
||||
task_name: t.name || "",
|
||||
});
|
||||
}
|
||||
const result = await this._ws<{ objects: Array<{
|
||||
entry_id: string;
|
||||
object: { name: string };
|
||||
tasks: Array<{ id: string; name: string }>;
|
||||
}> }>({ type: "maintenance_supporter/objects" });
|
||||
if (!result) return;
|
||||
const flat: typeof this._vacAllTasks = [];
|
||||
for (const obj of result.objects || []) {
|
||||
for (const t of obj.tasks || []) {
|
||||
flat.push({
|
||||
entry_id: obj.entry_id,
|
||||
object_name: obj.object.name || "",
|
||||
task_id: t.id,
|
||||
task_name: t.name || "",
|
||||
});
|
||||
}
|
||||
this._vacAllTasks = flat;
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
}
|
||||
this._vacAllTasks = flat;
|
||||
}
|
||||
|
||||
private async _saveVacation(patch: Record<string, unknown>): Promise<void> {
|
||||
if (this._vacSaving) return;
|
||||
this._vacSaving = true;
|
||||
try {
|
||||
const result = await this.hass.connection.sendMessagePromise({
|
||||
const result = await this._ws<SettingsResponse["vacation"]>({
|
||||
type: "maintenance_supporter/vacation/update",
|
||||
...patch,
|
||||
}) as SettingsResponse["vacation"];
|
||||
});
|
||||
if (!result) return;
|
||||
// Server is the source of truth — re-hydrate from response.
|
||||
this._vacEnabled = result.enabled;
|
||||
this._vacStart = result.start || "";
|
||||
@@ -1297,9 +1426,6 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
this._vacWindowEnd = result.window_end;
|
||||
// Notify the panel (so the Vacation tab can appear/disappear)
|
||||
this.dispatchEvent(new CustomEvent("settings-changed"));
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { message?: string })?.message || t("action_error", this._lang);
|
||||
this._showToast(msg);
|
||||
} finally {
|
||||
this._vacSaving = false;
|
||||
}
|
||||
@@ -1327,62 +1453,58 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
}
|
||||
|
||||
private async _loadVacationPreview(): Promise<void> {
|
||||
this._vacPreviewLoading = true;
|
||||
try {
|
||||
const result = await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/vacation/preview",
|
||||
}) as { rows: VacationPreviewRow[] };
|
||||
this._vacPreview = result.rows || [];
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
} finally {
|
||||
this._vacPreviewLoading = false;
|
||||
}
|
||||
const result = await this._ws<{ rows: VacationPreviewRow[] }>(
|
||||
{ type: "maintenance_supporter/vacation/preview" },
|
||||
undefined,
|
||||
(busy) => { this._vacPreviewLoading = busy; },
|
||||
);
|
||||
if (result) this._vacPreview = result.rows || [];
|
||||
}
|
||||
|
||||
private async _previewActionComplete(row: VacationPreviewRow): Promise<void> {
|
||||
if (this._previewBusy) return;
|
||||
this._previewBusy = true;
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
const ok = await this._ws({
|
||||
type: "maintenance_supporter/task/complete",
|
||||
entry_id: row.entry_id,
|
||||
task_id: row.task_id,
|
||||
});
|
||||
if (ok === undefined) return;
|
||||
this._showToast(t("vacation_marked_complete", this._lang));
|
||||
await this._loadVacationPreview();
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
} finally {
|
||||
this._previewBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async _previewActionSkip(row: VacationPreviewRow): Promise<void> {
|
||||
if (this._previewBusy) return;
|
||||
this._previewBusy = true;
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
const ok = await this._ws({
|
||||
type: "maintenance_supporter/task/skip",
|
||||
entry_id: row.entry_id,
|
||||
task_id: row.task_id,
|
||||
reason: "Skipped before vacation",
|
||||
});
|
||||
if (ok === undefined) return;
|
||||
this._showToast(t("vacation_marked_skip", this._lang));
|
||||
await this._loadVacationPreview();
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
} finally {
|
||||
this._previewBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async _endVacationNow(): Promise<void> {
|
||||
try {
|
||||
const result = await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/vacation/end_now",
|
||||
}) as SettingsResponse["vacation"];
|
||||
this._vacEnabled = result.enabled;
|
||||
this._vacEnd = result.end || "";
|
||||
this._vacIsActive = result.is_active;
|
||||
this._vacWindowEnd = result.window_end;
|
||||
this.dispatchEvent(new CustomEvent("settings-changed"));
|
||||
this._showToast(t("vacation_ended", this._lang));
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
}
|
||||
const result = await this._ws<SettingsResponse["vacation"]>({ type: "maintenance_supporter/vacation/end_now" });
|
||||
if (!result) return;
|
||||
this._vacEnabled = result.enabled;
|
||||
this._vacEnd = result.end || "";
|
||||
this._vacIsActive = result.is_active;
|
||||
this._vacWindowEnd = result.window_end;
|
||||
this.dispatchEvent(new CustomEvent("settings-changed"));
|
||||
this._showToast(t("vacation_ended", this._lang));
|
||||
}
|
||||
|
||||
// --- Section: Print QR codes (v1.1.0) ---
|
||||
@@ -1484,19 +1606,24 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
}
|
||||
|
||||
private async _loadQrObjects(): Promise<void> {
|
||||
try {
|
||||
const result = await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/objects",
|
||||
}) as { objects: Array<{ entry_id: string; object: { name: string }; tasks: unknown[] }> };
|
||||
this._qrObjects = (result.objects || []).map((o) => ({
|
||||
entry_id: o.entry_id,
|
||||
name: o.object.name,
|
||||
task_count: (o.tasks || []).length,
|
||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
this._qrObjectsLoaded = true;
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
}
|
||||
const rows = await this._loadObjectRows();
|
||||
if (!rows) return;
|
||||
this._qrObjects = rows;
|
||||
this._qrObjectsLoaded = true;
|
||||
}
|
||||
|
||||
/** {entry_id, name, task_count} of every object, name-sorted — the QR
|
||||
* batch and the export section both pick objects from this list. */
|
||||
private async _loadObjectRows(): Promise<Array<{ entry_id: string; name: string; task_count: number }> | undefined> {
|
||||
const result = await this._ws<{ objects: Array<{ entry_id: string; object: { name: string }; tasks: unknown[] }> }>({
|
||||
type: "maintenance_supporter/objects",
|
||||
});
|
||||
if (!result) return undefined;
|
||||
return (result.objects || []).map((o) => ({
|
||||
entry_id: o.entry_id,
|
||||
name: o.object.name,
|
||||
task_count: (o.tasks || []).length,
|
||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
private _toggleQrObject(entryId: string, on: boolean): void {
|
||||
@@ -1529,20 +1656,18 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
if (this._qrSelectedEntries.size > 0) {
|
||||
msg.entry_ids = [...this._qrSelectedEntries];
|
||||
}
|
||||
const result = await this.hass.connection.sendMessagePromise<{
|
||||
const result = await this._ws<{
|
||||
qrs: Array<{
|
||||
entry_id: string; task_id: string;
|
||||
object_name: string; task_name: string;
|
||||
action: string; svg: string;
|
||||
}>;
|
||||
}>(msg);
|
||||
if (!result) return;
|
||||
this._qrBatchResults = result.qrs || [];
|
||||
if (this._qrBatchResults.length === 0) {
|
||||
this._showToast(t("qr_print_empty", this._lang));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { message?: string })?.message || t("action_error", this._lang);
|
||||
this._showToast(msg);
|
||||
} finally {
|
||||
this._qrBatchLoading = false;
|
||||
}
|
||||
@@ -1702,19 +1827,10 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
}
|
||||
|
||||
private async _loadExportObjects(): Promise<void> {
|
||||
try {
|
||||
const result = await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/objects",
|
||||
}) as { objects: Array<{ entry_id: string; object: { name: string }; tasks: unknown[] }> };
|
||||
this._exportObjects = (result.objects || []).map((o) => ({
|
||||
entry_id: o.entry_id,
|
||||
name: o.object.name,
|
||||
task_count: (o.tasks || []).length,
|
||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
this._exportObjectsLoaded = true;
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
}
|
||||
const rows = await this._loadObjectRows();
|
||||
if (!rows) return;
|
||||
this._exportObjects = rows;
|
||||
this._exportObjectsLoaded = true;
|
||||
}
|
||||
|
||||
private _toggleExportObject(entryId: string, on: boolean): void {
|
||||
@@ -1728,92 +1844,75 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
}
|
||||
|
||||
private async _exportJson(): Promise<void> {
|
||||
try {
|
||||
const ids = this._selectedEntryIds;
|
||||
const result = await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/export",
|
||||
format: "json",
|
||||
include_history: this._includeHistory,
|
||||
...(ids ? { entry_ids: ids } : {}),
|
||||
}) as { data: string };
|
||||
const ts = new Date().toISOString().slice(0, 10);
|
||||
this._downloadFile(result.data, `maintenance_export_${ts}.json`, "application/json");
|
||||
this._showToast(t("settings_export_success", this._lang));
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
}
|
||||
const ids = this._selectedEntryIds;
|
||||
const result = await this._ws<{ data: string }>({
|
||||
type: "maintenance_supporter/export",
|
||||
format: "json",
|
||||
include_history: this._includeHistory,
|
||||
...(ids ? { entry_ids: ids } : {}),
|
||||
});
|
||||
if (!result) return;
|
||||
const ts = isoDateLocal(new Date());
|
||||
this._downloadFile(result.data, `maintenance_export_${ts}.json`, "application/json");
|
||||
this._showToast(t("settings_export_success", this._lang));
|
||||
}
|
||||
|
||||
/** 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));
|
||||
}
|
||||
const result = await this._ws<{ data: string }>({ type: "maintenance_supporter/settings/export" });
|
||||
if (!result) return;
|
||||
const ts = isoDateLocal(new Date());
|
||||
this._downloadFile(result.data, `maintenance_settings_${ts}.json`, "application/json");
|
||||
this._showToast(t("settings_export_success", this._lang));
|
||||
}
|
||||
|
||||
private async _exportYaml(): Promise<void> {
|
||||
try {
|
||||
const ids = this._selectedEntryIds;
|
||||
const result = await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/export",
|
||||
format: "yaml",
|
||||
include_history: this._includeHistory,
|
||||
...(ids ? { entry_ids: ids } : {}),
|
||||
}) as { data: string };
|
||||
const ts = new Date().toISOString().slice(0, 10);
|
||||
this._downloadFile(result.data, `maintenance_export_${ts}.yaml`, "application/yaml");
|
||||
this._showToast(t("settings_export_success", this._lang));
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
}
|
||||
const ids = this._selectedEntryIds;
|
||||
const result = await this._ws<{ data: string }>({
|
||||
type: "maintenance_supporter/export",
|
||||
format: "yaml",
|
||||
include_history: this._includeHistory,
|
||||
...(ids ? { entry_ids: ids } : {}),
|
||||
});
|
||||
if (!result) return;
|
||||
const ts = isoDateLocal(new Date());
|
||||
this._downloadFile(result.data, `maintenance_export_${ts}.yaml`, "application/yaml");
|
||||
this._showToast(t("settings_export_success", this._lang));
|
||||
}
|
||||
|
||||
private async _exportCsv(): Promise<void> {
|
||||
try {
|
||||
const ids = this._selectedEntryIds;
|
||||
const result = await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/csv/export",
|
||||
...(ids ? { entry_ids: ids } : {}),
|
||||
}) as { csv: string };
|
||||
const ts = new Date().toISOString().slice(0, 10);
|
||||
this._downloadFile(result.csv, `maintenance_export_${ts}.csv`, "text/csv");
|
||||
this._showToast(t("settings_export_success", this._lang));
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
}
|
||||
const ids = this._selectedEntryIds;
|
||||
const result = await this._ws<{ csv: string }>({
|
||||
type: "maintenance_supporter/csv/export",
|
||||
...(ids ? { entry_ids: ids } : {}),
|
||||
});
|
||||
if (!result) return;
|
||||
const ts = isoDateLocal(new Date());
|
||||
this._downloadFile(result.csv, `maintenance_export_${ts}.csv`, "text/csv");
|
||||
this._showToast(t("settings_export_success", this._lang));
|
||||
}
|
||||
|
||||
private async _importCsvAction(): Promise<void> {
|
||||
const content = this._importCsv.trim();
|
||||
if (!content) return;
|
||||
this._importLoading = true;
|
||||
try {
|
||||
// CSV exports start with the "object_name" header; anything else
|
||||
// (JSON `{`/`[` or YAML `version:`) goes to the structured importer,
|
||||
// which parses JSON and YAML alike.
|
||||
const isCsv = content.startsWith("object_name");
|
||||
const result = await this.hass.connection.sendMessagePromise(
|
||||
isCsv
|
||||
? { type: "maintenance_supporter/csv/import", csv_content: content }
|
||||
: { type: "maintenance_supporter/json/import", json_content: content }
|
||||
) as { created: number };
|
||||
const count = result.created ?? 0;
|
||||
this._showToast(t("settings_import_success", this._lang).replace("{count}", String(count)));
|
||||
this._importCsv = "";
|
||||
this.dispatchEvent(new CustomEvent("settings-changed"));
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
}
|
||||
this._importLoading = false;
|
||||
// CSV exports start with the "object_name" header; anything else
|
||||
// (JSON `{`/`[` or YAML `version:`) goes to the structured importer,
|
||||
// which parses JSON and YAML alike.
|
||||
const isCsv = content.startsWith("object_name");
|
||||
const result = await this._ws<{ created: number }>(
|
||||
isCsv
|
||||
? { type: "maintenance_supporter/csv/import", csv_content: content }
|
||||
: { type: "maintenance_supporter/json/import", json_content: content },
|
||||
undefined,
|
||||
(busy) => { this._importLoading = busy; },
|
||||
);
|
||||
if (!result) return;
|
||||
const count = result.created ?? 0;
|
||||
this._showToast(t("settings_import_success", this._lang).replace("{count}", String(count)));
|
||||
this._importCsv = "";
|
||||
this.dispatchEvent(new CustomEvent("settings-changed"));
|
||||
}
|
||||
|
||||
// --- Documents archive (ZIP with file contents) ---
|
||||
@@ -2051,6 +2150,23 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
.import-section { margin-top: 16px; }
|
||||
|
||||
.setting-row-block { flex-direction: column; align-items: stretch; gap: 6px; }
|
||||
/* D#162 follow-up: battery lifetime table */
|
||||
.bl-title { margin: 16px 0 8px; font-size: 14px; }
|
||||
.bl-table { display: flex; flex-direction: column; gap: 4px; margin: 8px 0 12px; }
|
||||
.bl-row { display: grid; grid-template-columns: minmax(0, 1fr) 72px max-content minmax(0, 1.4fr) max-content; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px solid var(--divider-color); }
|
||||
.bl-type { font-weight: 500; }
|
||||
.bl-fleet { font-size: 11px; color: var(--secondary-text-color); font-weight: 400; margin-left: 4px; }
|
||||
.bl-months { width: 72px; padding: 4px 6px; border: 1px solid var(--divider-color); border-radius: 4px; background: var(--card-background-color); color: var(--primary-text-color); font: inherit; }
|
||||
.bl-unit, .bl-source { font-size: 12px; color: var(--secondary-text-color); }
|
||||
.bl-override .bl-source { color: var(--primary-color); }
|
||||
.bl-learned { grid-column: 1 / -1; font-size: 12px; color: var(--secondary-text-color); }
|
||||
.bl-reset { background: none; border: 1px solid var(--divider-color); border-radius: 12px; padding: 2px 10px; font-size: 12px; color: var(--primary-text-color); cursor: pointer; }
|
||||
.bl-more > summary { cursor: pointer; font-size: 12px; color: var(--secondary-text-color); padding: 4px 0; }
|
||||
@media (max-width: 640px) {
|
||||
.bl-row { grid-template-columns: minmax(0, 1fr) 64px max-content; }
|
||||
.bl-source { grid-column: 1 / 3; }
|
||||
.bl-reset { grid-column: 3; justify-self: end; }
|
||||
}
|
||||
.notify-extra { min-height: 72px; font-family: var(--code-font-family, monospace); font-size: 12.5px; }
|
||||
.import-area {
|
||||
width: 100%;
|
||||
@@ -2387,4 +2503,6 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
`];
|
||||
}
|
||||
|
||||
customElements.define("maintenance-settings-view", MaintenanceSettingsView);
|
||||
if (!customElements.get("maintenance-settings-view")) {
|
||||
customElements.define("maintenance-settings-view", MaintenanceSettingsView);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user