Files
HomeAssistantVS/custom_components/maintenance_supporter/frontend-src/components/settings-view.ts
T

2694 lines
112 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Settings view component for the Maintenance Supporter panel. */
import { LitElement, html, css, nothing } from "lit";
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, syncCurrencyDecimals, formatNumber } from "../styles";
import { signApiPath } from "../helpers/document-url";
import { downloadUrl } from "../helpers/download";
import { UserService } from "../user-service";
import { AVATAR_PALETTE, personOf, renderPersonAvatar } from "../helpers/person";
import { OBJECT_COLUMNS, sanitizeColumns } from "../helpers/object-columns";
import { downloadTextFile } from "../helpers/download";
import { invalidateSettingsCache, ROW_ACTION_STYLES } from "../helpers/settings-cache";
import { SETTING_INT_RANGES, VACATION_BUFFER_DAYS_RANGE, settingIntRange } from "../helpers/setting-ranges";
import { ToastTimer } from "../helpers/toast";
import { isoDateLocal } from "../helpers/calendar-bucket";
import { runWs } from "../helpers/ws-run";
import { DOCS_ARCHIVE_MAX_BYTES, postMultipart } from "../helpers/photo-upload";
import { countryName, detectionReasons, dwellingLabel, type HomeProfile } from "../helpers/home-profile";
import "./ms-date-field";
/** One household member and the notify services they actually resolve to.
* Empty `services` means no Companion device is linked, so their reminders
* fall back to the household notification service. */
interface PersonNotifyTarget {
user_id: string;
name: string;
services: string[];
}
/* Settings response shape from WS maintenance_supporter/settings */
interface SettingsResponse {
features: AdvancedFeatures;
admin_panel_user_ids?: string[];
/** #169 follow-up: per-member avatar overrides. */
member_display?: Record<string, { initials?: string; color?: string }>;
operator_write_enabled?: boolean;
objects_table_columns?: string[];
/** v2.21: template-gallery curation — hidden template ids. */
disabled_template_ids?: string[];
/** v2.93: home profile dwelling type — auto | house | apartment. */
home_type?: string;
home_region?: string;
general: {
default_warning_days: number;
notifications_enabled: boolean;
notify_service: string;
// Shared pickable-target list computed server-side (build_notify_targets),
// so the picker matches the options-flow dropdown exactly. Optional for
// backward-compat with an older backend that didn't send it.
notify_targets?: string[];
/** v2.67: todo.* entity the buy-task shopping sync mirrors into ("" = off). */
shopping_list_entity?: string;
panel_enabled: boolean;
panel_title: string;
/** Opt-in copy of the shipped Assist sentences into the config dir. */
install_assist_sentences?: boolean;
/** #146: household "low" floors (percent) for discovery + battery fleet. */
default_consumable_threshold?: number;
battery_low_percent?: number;
/** #180: a low battery counts as replaced only once its level is above this. */
battery_recovered_percent?: number;
battery_auto_record_recovery?: boolean;
/** D#182: shopping-search URL with {q} ("" = automatic) + the automatic value in effect. */
part_search_url_template?: string;
part_search_url_default?: string;
/** 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;
devices: number;
overrides: { name: string; device_id: string | null; threshold: number }[];
more: number;
} | 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: {
due_soon_enabled: boolean;
due_soon_interval_hours: number;
overdue_enabled: boolean;
overdue_interval_hours: number;
triggered_enabled: boolean;
triggered_interval_hours: number;
quiet_hours_enabled: boolean;
quiet_hours_start: string;
quiet_hours_end: string;
max_per_day: number;
bundling_enabled: boolean;
bundle_threshold: number;
reminder_lead_days: number[];
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: {
complete_enabled: boolean;
skip_enabled: boolean;
snooze_enabled: boolean;
snooze_duration_hours: number;
weekly_digest_enabled: boolean;
warranty_reminder_enabled: boolean;
warranty_reminder_days: number;
};
budget: {
monthly: number;
yearly: number;
alerts_enabled: boolean;
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.
archive?: {
oneoff_days: number;
delete_archived_oneoff_days: number;
};
vacation: {
enabled: boolean;
start: string | null;
end: string | null;
buffer_days: number;
exempt_task_ids: string[];
is_active: boolean;
window_end: string | null;
};
}
interface VacationPreviewRow {
task_id: string;
entry_id: string;
object_name: string;
task_name: string;
kind: "time_based" | "sensor_based";
confidence: "deterministic" | "estimated" | "unpredictable";
events: Array<{ date: string; status: "due_soon" | "overdue" | "triggered_est" }>;
will_suppress: boolean;
/** The task's own skip rule (#150); absent on older backends = allowed. */
allow_skip?: boolean;
}
// Keep in lockstep with const.BUDGET_CURRENCIES (keys + order). Parity is
// enforced by tests/test_frontend_const_parity.py (drift fails CI).
const CURRENCIES = [
"EUR", "USD", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD", "CNY", "INR", "BRL",
"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;
@property({ attribute: false }) public budget: BudgetStatus | null = null;
@state() private _settings: SettingsResponse | null = null;
@state() private _loading = true;
@state() private _importCsv = "";
@state() private _importLoading = false;
@state() private _includeHistory = true;
@state() private _toast = "";
@state() private _testingNotification = false;
@state() private _personTargets: PersonNotifyTarget[] = [];
@state() private _testingUser = "";
@state() private _users: HAUser[] = [];
@state() private _savedViews: Array<{ id: string; name: string }> = [];
// Vacation mode section state (v1.2.0)
@state() private _vacEnabled = false;
@state() private _vacStart = "";
@state() private _vacEnd = "";
@state() private _vacBuffer = 3;
@state() private _vacExempt = new Set<string>();
@state() private _vacIsActive = false;
@state() private _vacWindowEnd: string | null = null;
@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
@state() private _qrObjects: Array<{ entry_id: string; name: string; task_count: number }> = [];
@state() private _qrSelectedEntries = new Set<string>();
@state() private _qrActions = new Set<string>(["view"]);
@state() private _qrUrlMode: "server" | "local" | "companion" = "companion";
@state() private _qrBatchLoading = false;
@state() private _qrBatchResults: Array<{
entry_id: string; task_id: string;
object_name: string; task_name: string;
action: string; svg: string;
}> = [];
@state() private _qrObjectsLoaded = false;
// Export/Import selective-object picker state
@state() private _exportObjects: Array<{ entry_id: string; name: string; task_count: number }> = [];
@state() private _exportSelectedEntries = new Set<string>();
@state() private _exportObjectsLoaded = false;
@state() private _docArchiveLoading = false;
private _loaded = false;
private _userService: UserService | null = null;
private get _lang(): string {
return langOf(this.hass);
}
updated(changedProps: Map<string, unknown>): void {
super.updated(changedProps);
if (changedProps.has("hass") && this.hass && !this._loaded) {
this._loaded = true;
this._userService = new UserService(this.hass);
this._loadSettings();
this._loadUsers();
} else if (changedProps.has("hass") && this.hass && this._userService) {
this._userService.updateHass(this.hass);
}
}
private async _loadUsers(): Promise<void> {
if (!this._userService) return;
try {
this._users = await this._userService.getUsers();
} catch {
this._users = [];
}
this._loadNotifyTargets();
}
/** Which notify services each household member resolves to.
*
* Resolved by the backend through the same helper the reminder path uses,
* so the list shown here is the list that will actually be used.
*/
private async _loadNotifyTargets(): Promise<void> {
try {
const res = await this.hass.connection.sendMessagePromise<{ targets: PersonNotifyTarget[] }>({
type: "maintenance_supporter/notify/user_targets",
});
this._personTargets = res.targets || [];
} catch {
this._personTargets = [];
}
}
private async _loadSettings(): Promise<void> {
this._loading = true;
try {
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/settings",
});
this._settings = result as SettingsResponse;
syncCurrencyDecimals(this._settings.budget);
this._hydrateVacationFromSettings();
} catch {
/* ignore */
}
// Saved views feed the notification-scope picker (best-effort).
try {
const v = await this.hass.connection.sendMessagePromise<{ views: Array<{ id: string; name: string }> }>({
type: "maintenance_supporter/views/list",
});
this._savedViews = v.views || [];
} catch {
/* ignore */
}
this._loading = false;
}
private _hydrateVacationFromSettings(): void {
const v = this._settings?.vacation;
if (!v) return;
this._vacEnabled = v.enabled;
this._vacStart = v.start || "";
this._vacEnd = v.end || "";
this._vacBuffer = v.buffer_days;
this._vacExempt = new Set(v.exempt_task_ids || []);
this._vacIsActive = v.is_active;
this._vacWindowEnd = v.window_end;
}
/** #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;
const floor = this._settings?.general?.battery_low_percent ?? 20;
const warn = bn.default > floor;
const summary = t("bn_summary", L)
.replace("{name}", "Battery Notes")
.replace("{pct}", String(bn.default))
.replace("{n}", String(bn.devices));
const rule = (warn ? t("bn_above_floor", L).replace("{name}", "Battery Notes") : t("bn_floor_decides", L))
.replace("{floor}", String(floor));
// The integration name becomes the link, the rest stays text. ja/zh
// punctuate with the fullwidth colon — an ASCII-only split linked the
// whole sentence and then duplicated its last character (bug review
// 2026-09-04).
const cut = summary.search(/[::]/);
const linkText = cut < 0 ? summary : summary.slice(0, cut);
const rest = cut < 0 ? "" : summary.slice(cut);
return html`
<div class="bn-note${warn ? " warn" : ""}">
<ha-icon icon="${warn ? "mdi:alert-outline" : "mdi:battery-heart-variant"}"></ha-icon>
<span>
<a class="bn-link" href="/config/integrations/integration/battery_notes">${linkText}</a>${rest}
${bn.overrides.length
? html` · ${t("bn_overrides", L).replace("{n}", String(bn.overrides.length + bn.more))}
${bn.overrides.map((o, i) => html`${i ? " · " : " "}${o.device_id
? html`<a class="bn-link" href="/config/devices/device/${o.device_id}">${o.name}</a>`
: o.name} (${o.threshold} %)`)}
${bn.more ? html` <span class="bn-more">${t("bn_more", L).replace("{n}", String(bn.more))}</span>` : nothing}`
: nothing}
<span class="bn-sub">${rule}</span>
</span>
</div>`;
}
/** #169 follow-up: Settings → household members — the avatar each member
* gets in task lists (initials + a palette colour). Defaults come from
* the backend (name / user id); an override is stored per user id. */
private _renderMemberAvatars(L: string) {
if (!this._users.length) return nothing;
const overrides = this._settings?.member_display || {};
return html`
<div class="settings-section member-avatars" data-section="member_avatars">
<h3>${t("member_avatars", L)}</h3>
<p class="section-desc">${t("member_avatars_hint", L)}</p>
${this._users.map((u) => {
const person = personOf(u)!;
const override = overrides[u.id] || {};
return html`
<div class="member-avatar-row">
${renderPersonAvatar(person)}
<span class="member-avatar-name">${u.name}</span>
<input class="member-initials" type="text" maxlength="3" autocomplete="off"
aria-label="${t("member_initials", L)}"
placeholder="${person.initials}"
.value=${override.initials ?? ""}
@change=${(e: Event) => this._setMemberAvatar(u.id, { initials: (e.target as HTMLInputElement).value })} />
<span class="member-palette" role="radiogroup" aria-label="${t("member_color", L)}">
${AVATAR_PALETTE.map((color) => html`
<button type="button" class="member-swatch${person.color === color ? " selected" : ""}"
style="--person-color: ${color}" title=${color} aria-label=${color}
@click=${() => this._setMemberAvatar(u.id, { color })}></button>`)}
</span>
${override.initials || override.color
? html`<button class="ha-button secondary member-reset" @click=${() => this._setMemberAvatar(u.id, null)}>${t("member_avatar_reset", L)}</button>`
: nothing}
</div>`;
})}
</div>`;
}
/** Merge one member's override into the map and save; null resets the member. */
private async _setMemberAvatar(userId: string, patch: { initials?: string; color?: string } | null): Promise<void> {
const current = { ...(this._settings?.member_display || {}) };
if (patch === null) {
delete current[userId];
} else {
const next = { ...(current[userId] || {}), ...patch };
if ("initials" in patch && !(patch.initials || "").trim()) delete next.initials;
if (Object.keys(next).length) current[userId] = next;
else delete current[userId];
}
await this._updateSetting("member_display", current);
// The avatars come back resolved through users/list — refresh the cache.
try {
this._users = (await this._userService?.getUsers(true)) ?? this._users;
} catch {
/* keep the stale list; the next open reloads it */
}
}
/** 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"));
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. 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;
}
}
private _sendTestNotification = async (userId?: string): Promise<void> => {
if (userId) this._testingUser = userId;
else this._testingNotification = true;
try {
const res = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/global/test_notification",
...(userId ? { user_id: userId } : {}),
}) as { success: boolean; message?: string };
const msg = res.message
|| (res.success ? t("test_notification_success", this._lang) : t("test_notification_failed", this._lang));
this._showToast(msg);
} catch {
this._showToast(t("test_notification_failed", this._lang));
} finally {
if (userId) this._testingUser = "";
else this._testingNotification = false;
}
};
private readonly _toastTimer = new ToastTimer();
private _showToast(msg: string): void {
this._toast = msg;
this._toastTimer.schedule(() => { this._toast = ""; });
}
disconnectedCallback(): void {
super.disconnectedCallback();
// A pending hide must not fire into a detached view (DRY audit 2026-09-26).
this._toastTimer.clear();
this._toast = "";
}
/** 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) });
}
@state() private _compacting = false;
/** #170: renumber every object, task and completion sequentially — an
* explicit, admin-only action behind a confirm (printed booklets keep the
* old numbers, so this is never done silently). */
private async _compactReferenceNumbers(): Promise<void> {
const L = this._lang;
if (this._compacting || !window.confirm(t("settings_compact_refs_confirm", L))) return;
const res = await this._ws<{ objects: number; tasks: number; completions: number }>(
{ type: "maintenance_supporter/reference_numbers/compact" },
"action_error",
(b) => { this._compacting = b; },
);
if (!res) return;
this._showToast(
t("settings_compact_refs_done", L)
.replace("{objects}", String(res.objects))
.replace("{tasks}", String(res.tasks))
.replace("{completions}", String(res.completions)),
);
}
/** 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 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).then((ok) => { if (!ok) input.value = String(current); });
return;
}
this._showToast(
t("settings_value_out_of_range", this._lang).replace("{min}", String(min)).replace("{max}", String(max)),
);
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);
}
// --- Render ---
render() {
const L = this._lang;
if (this._loading || !this._settings) {
return html`<div class="settings-loading">Loading…</div>`;
}
return html`
${this._renderFeatures(L)}
${this._renderPanelAccess(L)}
${this._renderMemberAvatars(L)}
${this._renderGeneral(L)}
${this._renderObjectsColumns(L)}
${this._settings.general.notifications_enabled ? this._renderNotifications(L) : nothing}
${this.features.budget ? this._renderBudget(L) : nothing}
${this._renderArchive(L)}
${this._renderVacation(L)}
${this._renderPrintQr(L)}
${this._renderImportExport(L)}
${this._renderHomeProfile(L)}
${this._renderTemplateToggles(L)}
${this._toast ? html`<div class="settings-toast">${this._toast}</div>` : nothing}
`;
}
/** v2.3.0 Phase 6: deep-link target. Called by the panel after a section
* strategy banner click navigates here with ms_action=open_<key>.
* Scrolls the requested settings section into view; no-op if not found. */
public scrollToSection(target: string): void {
requestAnimationFrame(() => {
const sr = this.shadowRoot;
if (!sr) return;
const el = sr.querySelector<HTMLElement>(`[data-section="${target}"]`)
?? sr.querySelector<HTMLElement>(`[data-section-alt="${target}"]`);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
});
}
// --- Section: Panel Access (1.0.44+) ---
private _renderPanelAccess(L: string) {
const selected = new Set(this._settings!.admin_panel_user_ids || []);
const nonAdmins = this._users.filter((u) => !u.is_admin);
// v2.8.4: the allowlist only takes effect while delegation is on. Default
// off → content edits stay admin-only; we hide the user list so selecting
// someone while nothing applies can't read as "they can now edit".
const writeEnabled = this._settings!.operator_write_enabled ?? false;
const toggle = (uid: string, on: boolean): void => {
const next = new Set(selected);
if (on) next.add(uid); else next.delete(uid);
this._updateSetting("admin_panel_user_ids", [...next]);
};
return html`
<div class="settings-section">
<h3>${t("settings_panel_access", L)} ${writeEnabled && selected.size > 0 ? html`<span class="section-badge">${selected.size}</span>` : nothing}</h3>
<p class="section-desc">${t("settings_panel_access_desc", L)}</p>
<label class="setting-row">
<span>
<span class="setting-label">${t("settings_operator_write", L)}</span>
<span class="setting-desc">${t("settings_operator_write_desc", L)}</span>
</span>
<input type="checkbox"
.checked=${writeEnabled}
@change=${(e: Event) => this._updateSetting("operator_write_enabled", (e.target as HTMLInputElement).checked)} />
</label>
${!writeEnabled
? nothing
: nonAdmins.length === 0
? html`<div class="setting-row hint">${t("no_non_admin_users", L)}</div>`
: nonAdmins.map((u) => html`
<label class="setting-row">
<span>
<span class="setting-label">${u.name || u.id.slice(0, 8)}</span>
<span class="setting-desc">${u.is_owner ? t("owner_label", L) : ""}</span>
</span>
<input type="checkbox"
.checked=${selected.has(u.id)}
@change=${(e: Event) => toggle(u.id, (e.target as HTMLInputElement).checked)} />
</label>
`)}
</div>
`;
}
// --- Section: Features ---
private _renderFeatures(L: string) {
const f = this._settings!.features;
// data-section="settings" makes this the default landing target for
// generic "open settings" deep links. Groups CRUD lives in the global
// options flow (Configure button on the integration entry) so a
// "groups" target also lands here as the closest match.
const items: { key: keyof AdvancedFeatures; settingKey: string; label: string; desc: string }[] = [
{ key: "adaptive", settingKey: "advanced_adaptive_visible", label: t("feat_adaptive", L), desc: t("feat_adaptive_desc", L) },
{ key: "predictions", settingKey: "advanced_predictions_visible", label: t("feat_predictions", L), desc: t("feat_predictions_desc", L) },
{ key: "seasonal", settingKey: "advanced_seasonal_visible", label: t("feat_seasonal", L), desc: t("feat_seasonal_desc", L) },
{ key: "environmental", settingKey: "advanced_environmental_visible", label: t("feat_environmental", L), desc: t("feat_environmental_desc", L) },
{ key: "budget", settingKey: "advanced_budget_visible", label: t("feat_budget", L), desc: t("feat_budget_desc", L) },
{ key: "groups", settingKey: "advanced_groups_visible", label: t("feat_groups", L), desc: t("feat_groups_desc", L) },
{ key: "checklists", settingKey: "advanced_checklists_visible", label: t("feat_checklists", L), desc: t("feat_checklists_desc", L) },
{ key: "schedule_time", settingKey: "advanced_schedule_time_visible", label: t("feat_schedule_time", L), desc: t("feat_schedule_time_desc", L) },
{ key: "completion_actions", settingKey: "advanced_completion_actions_visible", label: t("feat_completion_actions", L), desc: t("feat_completion_actions_desc", L) },
];
return html`
<div class="settings-section" data-section="settings" data-section-alt="groups">
<h3>${t("settings_features", L)}</h3>
<p class="section-desc">${t("settings_features_desc", L)}</p>
${items.map((item) => html`
<label class="setting-row">
<span>
<span class="setting-label">${item.label}</span>
<span class="setting-desc">${item.desc}</span>
</span>
<input type="checkbox" .checked=${f[item.key]}
@change=${(e: Event) => this._updateSetting(item.settingKey, (e.target as HTMLInputElement).checked)} />
</label>
`)}
</div>
`;
}
// --- Section: Objects table columns (#67) ---
// --- Section: Template gallery curation (v2.21) ---
@state() private _allTemplates: Array<{ id: string; name: string; category: string; disabled?: boolean }> = [];
@state() private _templateCategories: Record<string, Record<string, string>> = {};
/** v2.93: served with the templates read (see helpers/home-profile.ts). */
@state() private _homeProfile: HomeProfile | null = null;
@state() private _tplOpenGroups: Set<string> = new Set();
// One-shot request guard: keyed on a plain flag, NOT on the result being
// non-empty — an empty catalog answer would otherwise re-trigger the load
// from render() forever (Lit update loop; caught by the settings tests).
private _templatesRequested = false;
private async _loadTemplates(): Promise<void> {
if (this._templatesRequested) return;
this._templatesRequested = true;
try {
const res = await this.hass.connection.sendMessagePromise<{
templates: Array<{ id: string; name: string; category: string; disabled?: boolean }>;
categories: Record<string, Record<string, string>>;
profile?: HomeProfile;
}>({
type: "maintenance_supporter/templates",
language: this._lang,
});
this._allTemplates = res.templates || [];
this._templateCategories = res.categories || {};
this._homeProfile = res.profile ?? null;
} catch { /* section renders empty; retried on next open */ }
}
// --- Section: Home profile (v2.93) ---
private _renderHomeProfile(L: string) {
this._loadTemplates();
const p = this._homeProfile;
const setting = this._settings!.home_type || "auto";
const c = p?.climate;
const climate = c && c.koppen
? t("home_climate_value", L)
.replace("{koppen}", c.koppen)
.replace("{cold}", String(c.coldest_c ?? "–"))
.replace("{warm}", String(c.warmest_c ?? "–"))
: t("home_climate_unknown", L);
const country = countryName(p?.country, L);
return html`
<div class="settings-section" data-section="home_profile">
<h3>${t("home_profile_title", L)}</h3>
<p class="section-desc">${t("home_profile_hint", L)}</p>
<label class="setting-row">
<span class="setting-label">${t("home_type_label", L)}</span>
<select class="home-type" .value=${live(setting)}
@change=${(e: Event) => void this._setHomeType((e.target as HTMLSelectElement).value)}>
<option value="auto" ?selected=${setting === "auto"}>
${t("home_type_auto", L).replace("{detected}", dwellingLabel(p?.dwelling_detected ?? "unknown", L))}
</option>
<option value="house" ?selected=${setting === "house"}>${t("home_dwelling_house", L)}</option>
<option value="apartment" ?selected=${setting === "apartment"}>${t("home_dwelling_apartment", L)}</option>
</select>
</label>
${p && p.dwelling_reasons.length
? html`<div class="setting-hint home-detected">
${t("home_detected_from", L).replace("{reasons}", detectionReasons(p.dwelling_reasons, L))}
</div>`
: nothing}
<div class="setting-row home-climate">
<span class="setting-label">${t("home_climate_label", L)}</span>
<span class="home-climate-value">${country ? `${country} · ` : ""}${climate}</span>
</div>
${p?.regions?.length ? this._renderHomeRegion(p, L) : nothing}
${p?.hemisphere === "south" ? html`<div class="setting-hint">${t("home_hemisphere_south", L)}</div>` : nothing}
${p && p.traits.length
? html`<div class="home-traits">
${p.traits.map((tr) => html`<span class="home-trait">${t(`home_trait_${tr}`, L)}</span>`)}
</div>`
: nothing}
</div>
`;
}
/** 2.94: the state / province whose rules the templates follow — shown
* only where the country has such rules (data/regions). */
private _renderHomeRegion(p: HomeProfile, L: string) {
const regions = p.regions || [];
const setting = this._settings!.home_region || "auto";
const value = regions.some((r) => r.code === setting) ? setting : "auto";
const detected = p.region_detected_name || t("home_region_none", L);
return html`
<label class="setting-row">
<span class="setting-label">${t("home_region_label", L)}</span>
<select class="home-region" .value=${live(value)}
@change=${(e: Event) => void this._setHomeRegion((e.target as HTMLSelectElement).value)}>
<option value="auto" ?selected=${value === "auto"}>${t("home_type_auto", L).replace("{detected}", detected)}</option>
${regions.map((r) => html`<option value=${r.code} ?selected=${value === r.code}>${r.name}</option>`)}
</select>
</label>
<div class="setting-hint">${t("home_region_hint", L)}</div>
`;
}
private async _setHomeRegion(value: string): Promise<void> {
if (await this._updateSetting("home_region", value)) {
// The region changes intervals and which checks exist — refetch.
this._templatesRequested = false;
await this._loadTemplates();
}
}
private async _setHomeType(value: string): Promise<void> {
if (await this._updateSetting("home_type", value)) {
// The dwelling drives the recommendations — refetch the profile.
this._templatesRequested = false;
await this._loadTemplates();
}
}
private _renderTemplateToggles(L: string) {
this._loadTemplates();
const hidden = new Set(this._settings!.disabled_template_ids || []);
// v2.27: cluster by category (declaration order = display order) so the
// growing catalog stays scannable — each group gets a header with its
// icon, an enabled/total count and a toggle-all checkbox.
const byCat = new Map<string, typeof this._allTemplates>();
for (const catId of Object.keys(this._templateCategories)) byCat.set(catId, []);
for (const tpl of this._allTemplates) {
if (!byCat.has(tpl.category)) byCat.set(tpl.category, []);
byCat.get(tpl.category)!.push(tpl);
}
const catName = (catId: string) =>
(this._templateCategories[catId]?.["name_" + L] as string)
|| (this._templateCategories[catId]?.name_en as string)
|| catId;
return html`
<div class="settings-section" data-section="templates">
<h3>${t("settings_templates_label", L)}</h3>
<p class="section-desc">${t("settings_templates_hint", L)}</p>
${[...byCat.entries()].filter(([, tpls]) => tpls.length > 0).map(([catId, tpls]) => {
const enabled = tpls.filter((tpl) => !hidden.has(tpl.id)).length;
// Collapsed by default (user request): the gallery lists 30+
// templates — folded groups with an enabled/total count keep the
// settings page short; expand only what you're curating.
const open = this._tplOpenGroups.has(catId);
return html`
<div class="tpl-group">
<div
class="tpl-group-head"
role="button"
tabindex="0"
@click=${() => this._toggleTplGroupOpen(catId)}
@keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
this._toggleTplGroupOpen(catId);
}
}}
>
<ha-icon class="tpl-chevron" icon=${open ? "mdi:chevron-down" : "mdi:chevron-right"}></ha-icon>
<ha-icon icon=${(this._templateCategories[catId]?.icon as string) || "mdi:folder-outline"}></ha-icon>
<span class="tpl-group-name">${catName(catId)}</span>
<span class="tpl-group-count">${enabled}/${tpls.length}</span>
<input
type="checkbox"
title=${t("settings_templates_toggle_group", L)}
.checked=${enabled === tpls.length}
@click=${(e: Event) => e.stopPropagation()}
@change=${(e: Event) =>
this._toggleTemplateGroup(tpls.map((tpl) => tpl.id), (e.target as HTMLInputElement).checked)}
/>
</div>
${open
? tpls.map((tpl) => html`
<label class="setting-row tpl-row">
<span class="setting-label">${tpl.name}</span>
<input
type="checkbox"
.checked=${!hidden.has(tpl.id)}
@change=${(e: Event) => this._toggleTemplate(tpl.id, (e.target as HTMLInputElement).checked)}
/>
</label>
`)
: nothing}
</div>
`;
})}
</div>
`;
}
private _toggleTemplate(id: string, visible: boolean): void {
const hidden = new Set(this._settings!.disabled_template_ids || []);
if (visible) hidden.delete(id);
else hidden.add(id);
this._updateSetting("disabled_template_ids", [...hidden]);
}
/** Expand/collapse one gallery group (collapsed by default). */
private _toggleTplGroupOpen(catId: string): void {
const next = new Set(this._tplOpenGroups);
if (next.has(catId)) next.delete(catId);
else next.add(catId);
this._tplOpenGroups = next;
}
/** Toggle-all for one category group in the template gallery. */
private _toggleTemplateGroup(ids: string[], visible: boolean): void {
const hidden = new Set(this._settings!.disabled_template_ids || []);
for (const id of ids) {
if (visible) hidden.delete(id);
else hidden.add(id);
}
this._updateSetting("disabled_template_ids", [...hidden]);
}
private _renderObjectsColumns(L: string) {
const selected = sanitizeColumns(this._settings!.objects_table_columns);
return html`
<div class="settings-section" data-section="objects_table_columns">
<h3>${t("objects_table_columns_label", L)}</h3>
<p class="section-desc">${t("objects_table_columns_hint", L)}</p>
${OBJECT_COLUMNS.map((col) => html`
<label class="setting-row">
<span class="setting-label">${t(col.labelKey, L)}</span>
<input
type="checkbox"
.checked=${selected.includes(col.key)}
?disabled=${!!col.required}
@change=${(e: Event) => this._toggleColumn(col.key, (e.target as HTMLInputElement).checked)}
/>
</label>
`)}
</div>
`;
}
private _toggleColumn(key: string, on: boolean): void {
const current = new Set(sanitizeColumns(this._settings!.objects_table_columns));
if (on) current.add(key);
else current.delete(key);
// Persist in canonical order; required columns are always kept.
const next = OBJECT_COLUMNS
.filter((c) => c.required || current.has(c.key))
.map((c) => c.key);
this._updateSetting("objects_table_columns", next);
}
// --- Section: General ---
private _renderGeneral(L: string) {
const g = this._settings!.general;
// Suggestible notify targets for the picker below come from the backend
// (build_notify_targets → general.notify_targets): legacy notify services +
// notify entities minus the generic send_message, plus the current saved
// value. Sharing the server list keeps this picker in lockstep with the
// options-flow dropdown. The <datalist> keeps the input free-text so
// custom / not-yet-loaded values still work.
const notifyServices = g.notify_targets ?? [];
const b = this._settings!.budget;
return html`
<div class="settings-section">
<h3>${t("settings_general", L)}</h3>
<label class="setting-row">
<span class="setting-label">${t("settings_default_warning", L)}</span>
<!-- 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>
${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>
${this._intSetting("battery_low_percent", g.battery_low_percent ?? 20)}
</label>
<label class="setting-row">
<span class="setting-label">${t("settings_battery_recovered_percent", L)}</span>
${this._intSetting("battery_recovered_percent", g.battery_recovered_percent ?? 50)}
</label>
<div class="setting-hint">${t("settings_battery_recovered_percent_hint", L)}</div>
<label class="setting-row">
<span class="setting-label">${t("settings_battery_auto_record", L)}</span>
<input type="checkbox" class="auto-record-recovery"
.checked=${g.battery_auto_record_recovery === true}
@change=${(e: Event) => this._updateSetting("battery_auto_record_recovery", (e.target as HTMLInputElement).checked)} />
</label>
<div class="setting-hint">${t("settings_battery_auto_record_hint", L)}</div>
${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_part_search_url", L)}</span>
<!-- D#182: no live() — same typed-input rule as the numbers above; the
placeholder shows the automatic template (country, then language). -->
<input type="url" class="part-search-url" .value=${g.part_search_url_template ?? ""}
placeholder=${g.part_search_url_default ?? ""}
@change=${(e: Event) => this._updateSetting("part_search_url_template", (e.target as HTMLInputElement).value.trim())} />
</label>
<div class="setting-hint">${t("settings_part_search_url_hint", L)}</div>
<label class="setting-row">
<span class="setting-label">${t("settings_row_actions", L)}</span>
<select .value=${live(g.row_action_style || "buttons_compact")}
@change=${(e: Event) => this._updateSetting("row_action_style", (e.target as HTMLSelectElement).value)}>
${ROW_ACTION_STYLES.map((v) => html`
<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>
${this.hass?.user?.is_admin
? html`<div class="setting-row">
<span class="setting-label">${t("settings_compact_refs", L)}</span>
<button class="btn compact-refs" ?disabled=${this._compacting} @click=${this._compactReferenceNumbers}>
<ha-icon icon="mdi:sort-numeric-ascending"></ha-icon> ${t("settings_compact_refs", L)}
</button>
</div>
<div class="setting-hint">${t("settings_compact_refs_hint", L)}</div>`
: nothing}
<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}
@change=${(e: Event) => this._updateSetting("panel_enabled", (e.target as HTMLInputElement).checked)} />
</label>
${g.panel_enabled ? html`
<label class="setting-row">
<span class="setting-label">${t("settings_panel_title", L)}</span>
<input type="text" .value=${g.panel_title ?? ""}
placeholder="Maintenance"
maxlength="50"
@change=${(e: Event) => this._updateSetting("panel_title", (e.target as HTMLInputElement).value.trim())} />
</label>
` : ""}
<label class="setting-row">
<span class="setting-label">${t("settings_install_assist_sentences", L)}</span>
<input type="checkbox" .checked=${g.install_assist_sentences ?? false}
@change=${(e: Event) => this._updateSetting("install_assist_sentences", (e.target as HTMLInputElement).checked)} />
</label>
<div class="setting-hint">${t("settings_install_assist_sentences_hint", L)}</div>
<label class="setting-row">
<span class="setting-label">${t("settings_notifications", L)}</span>
<input type="checkbox" .checked=${g.notifications_enabled}
@change=${(e: Event) => this._updateSetting("notifications_enabled", (e.target as HTMLInputElement).checked)} />
</label>
${g.notifications_enabled ? html`
<label class="setting-row">
<span class="setting-label">${t("settings_notify_service", L)}</span>
<input type="text" list="ms-notify-services" .value=${g.notify_service}
@change=${(e: Event) => this._updateSetting("notify_service", (e.target as HTMLInputElement).value.trim())} />
<datalist id="ms-notify-services">
${notifyServices.map((s) => html`<option value=${s}></option>`)}
</datalist>
</label>
<div class="setting-row">
<span class="setting-label">${t("test_notification", L)}</span>
<button class="ha-button secondary"
?disabled=${!g.notify_service || this._testingNotification}
@click=${() => this._sendTestNotification()}>
${this._testingNotification ? t("testing", L) : t("send_test", L)}
</button>
</div>
${this._personTargets.length ? html`
<div class="notify-per-person">
<span class="setting-label">${t("notify_per_person", L)}</span>
${this._personTargets.map((target) => html`
<div class="notify-person-row">
<span class="notify-person-name">${target.name}</span>
<span class="notify-person-target ${target.services.length ? "" : "muted"}">
${target.services.length ? target.services.join(", ") : t("notify_no_own_device", L)}
</span>
<button class="ha-button secondary"
?disabled=${!target.services.length || this._testingUser === target.user_id}
@click=${() => this._sendTestNotification(target.user_id)}>
${this._testingUser === target.user_id ? t("testing", L) : t("send_test", L)}
</button>
</div>
`)}
</div>
` : nothing}
` : nothing}
<label class="setting-row">
<span class="setting-label" title=${t("settings_shopping_list_help", L)}>${t("settings_shopping_list", L)}</span>
<select .value=${live(g.shopping_list_entity || "")}
@change=${(e: Event) => this._updateSetting("shopping_list_entity", (e.target as HTMLSelectElement).value)}>
<option value="" ?selected=${!g.shopping_list_entity}>${t("shopping_list_none", L)}</option>
${this._todoEntities(g.shopping_list_entity || "").map((id) => html`
<option value=${id} ?selected=${g.shopping_list_entity === id}>${id}</option>
`)}
</select>
</label>
</div>
`;
}
/** All todo.* entities, plus the saved value even when its integration
* hasn't loaded (so an offline pick isn't silently dropped from the UI). */
private _todoEntities(current: string): string[] {
const ids = Object.keys(this.hass?.states || {}).filter((id) => id.startsWith("todo.")).sort();
if (current && !ids.includes(current)) ids.unshift(current);
return ids;
}
// --- Section: Notifications ---
private _renderNotifications(L: string) {
const n = this._settings!.notifications;
const a = this._settings!.actions;
return html`
<div class="settings-section">
<h3>${t("settings_notifications", L)}</h3>
<label class="setting-row">
<span>
<span class="setting-label">${t("settings_notify_due_soon", L)}</span>
</span>
<input type="checkbox" .checked=${n.due_soon_enabled}
@change=${(e: Event) => this._updateSetting("notify_due_soon_enabled", (e.target as HTMLInputElement).checked)} />
</label>
${n.due_soon_enabled ? html`
<label class="setting-row sub-row">
<span class="setting-desc">${t("settings_interval_hours", L)}</span>
${this._intSetting("notify_due_soon_interval_hours", n.due_soon_interval_hours)}
</label>
` : nothing}
<label class="setting-row">
<span>
<span class="setting-label">${t("settings_notify_overdue", L)}</span>
</span>
<input type="checkbox" .checked=${n.overdue_enabled}
@change=${(e: Event) => this._updateSetting("notify_overdue_enabled", (e.target as HTMLInputElement).checked)} />
</label>
${n.overdue_enabled ? html`
<label class="setting-row sub-row">
<span class="setting-desc">${t("settings_interval_hours", L)}</span>
${this._intSetting("notify_overdue_interval_hours", n.overdue_interval_hours)}
</label>
` : nothing}
<label class="setting-row">
<span>
<span class="setting-label">${t("settings_notify_triggered", L)}</span>
</span>
<input type="checkbox" .checked=${n.triggered_enabled}
@change=${(e: Event) => this._updateSetting("notify_triggered_enabled", (e.target as HTMLInputElement).checked)} />
</label>
${n.triggered_enabled ? html`
<label class="setting-row sub-row">
<span class="setting-desc">${t("settings_interval_hours", L)}</span>
${this._intSetting("notify_triggered_interval_hours", n.triggered_interval_hours)}
</label>
` : nothing}
<label class="setting-row">
<span class="setting-label">${t("settings_quiet_hours", L)}</span>
<input type="checkbox" .checked=${n.quiet_hours_enabled}
@change=${(e: Event) => this._updateSetting("quiet_hours_enabled", (e.target as HTMLInputElement).checked)} />
</label>
${n.quiet_hours_enabled ? html`
<div class="setting-row sub-row">
<span class="setting-desc">${t("settings_quiet_start", L)}</span>
<ms-date-field
kind="time"
required
.hass=${this.hass}
.lang=${L}
.value=${n.quiet_hours_start}
@value-changed=${(e: CustomEvent) => { const v = e.detail.value as string; if (v) this._updateSetting("quiet_hours_start", v); }}
></ms-date-field>
</div>
<div class="setting-row sub-row">
<span class="setting-desc">${t("settings_quiet_end", L)}</span>
<ms-date-field
kind="time"
required
.hass=${this.hass}
.lang=${L}
.value=${n.quiet_hours_end}
@value-changed=${(e: CustomEvent) => { const v = e.detail.value as string; if (v) this._updateSetting("quiet_hours_end", v); }}
></ms-date-field>
</div>
` : nothing}
<label class="setting-row">
<span class="setting-label">${t("settings_max_per_day", L)}</span>
${this._intSetting("max_notifications_per_day", n.max_per_day)}
</label>
<label class="setting-row">
<span class="setting-label">${t("settings_bundling", L)}</span>
<input type="checkbox" .checked=${n.bundling_enabled}
@change=${(e: Event) => this._updateSetting("notification_bundling_enabled", (e.target as HTMLInputElement).checked)} />
</label>
${n.bundling_enabled ? html`
<label class="setting-row sub-row">
<span class="setting-desc">${t("settings_bundle_threshold", L)}</span>
${this._intSetting("notification_bundle_threshold", n.bundle_threshold)}
</label>
` : nothing}
<label class="setting-row">
<span class="setting-label">${t("settings_reminder_leads", L)}</span>
<input type="text" placeholder="14, 3, 0"
.value=${(n.reminder_lead_days || []).join(", ")}
@change=${(e: Event) => {
const leads = (e.target as HTMLInputElement).value
.split(",")
.map((s) => parseInt(s.trim(), 10))
.filter((v) => Number.isInteger(v) && v >= 0 && v <= 365);
this._updateSetting("reminder_lead_days", [...new Set(leads)]);
}} />
</label>
<div class="setting-hint">${t("settings_reminder_leads_hint", L)}</div>
<label class="setting-row">
<span class="setting-label">${t("settings_notify_scope", L)}</span>
<select
.value=${live(n.scope_view_id || "")}
@change=${(e: Event) => this._updateSetting("notify_scope_view_id", (e.target as HTMLSelectElement).value)}
>
<option value="" ?selected=${!n.scope_view_id}>${t("settings_notify_scope_all", L)}</option>
${this._savedViews.map(
(v) => html`<option value=${v.id} ?selected=${n.scope_view_id === v.id}>${v.name}</option>`
)}
</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>
<div class="setting-hint">${t("settings_notify_rule_hint", L)}</div>
<label class="setting-row">
<span class="setting-label">${t("settings_notify_event_only", L)}</span>
<input type="checkbox" .checked=${!!n.event_only}
@change=${(e: Event) => this._updateSetting("notify_event_only", (e.target as HTMLInputElement).checked)} />
</label>
<div class="setting-hint">${t("settings_notify_event_only_hint", L)}</div>
<label class="setting-row setting-row-block">
<span class="setting-label">${t("settings_notify_extra_data", L)}</span>
<!-- No live(): the view re-renders on every hass update and live()
would snap the DOM back to the stored value mid-typing (#176). -->
<textarea class="import-area notify-extra" .value=${n.extra_data || ""} maxlength="2000" spellcheck="false"
placeholder=${'{"category": "maintenance", "critical": {{ priority == "high" }}, "navigate_to": "{{ url }}"}'}
@change=${(e: Event) => this._updateSetting("notify_extra_data", (e.target as HTMLTextAreaElement).value)}
></textarea>
</label>
<div class="setting-hint">${t("settings_notify_extra_data_hint", L)}</div>
</div>
<h4 style="margin: 16px 0 8px; font-size: 14px;">${t("settings_actions", L)}</h4>
<label class="setting-row">
<span class="setting-label">${t("settings_action_complete", L)}</span>
<input type="checkbox" .checked=${a.complete_enabled}
@change=${(e: Event) => this._updateSetting("action_complete_enabled", (e.target as HTMLInputElement).checked)} />
</label>
<label class="setting-row">
<span class="setting-label">${t("settings_action_skip", L)}</span>
<input type="checkbox" .checked=${a.skip_enabled}
@change=${(e: Event) => this._updateSetting("action_skip_enabled", (e.target as HTMLInputElement).checked)} />
</label>
<label class="setting-row">
<span class="setting-label">${t("settings_action_snooze", L)}</span>
<input type="checkbox" .checked=${a.snooze_enabled}
@change=${(e: Event) => this._updateSetting("action_snooze_enabled", (e.target as HTMLInputElement).checked)} />
</label>
${a.snooze_enabled ? html`
<label class="setting-row sub-row">
<span class="setting-desc">${t("settings_snooze_hours", L)}</span>
${this._intSetting("snooze_duration_hours", a.snooze_duration_hours)}
</label>
` : nothing}
<label class="setting-row">
<span class="setting-label">${t("settings_weekly_digest", L)}</span>
<input type="checkbox" .checked=${a.weekly_digest_enabled}
@change=${(e: Event) => this._updateSetting("weekly_digest_enabled", (e.target as HTMLInputElement).checked)} />
</label>
<div class="setting-hint">${t("settings_weekly_digest_hint", L)}</div>
<label class="setting-row">
<span class="setting-label">${t("settings_warranty_reminder", L)}</span>
<input type="checkbox" .checked=${a.warranty_reminder_enabled}
@change=${(e: Event) => this._updateSetting("warranty_reminder_enabled", (e.target as HTMLInputElement).checked)} />
</label>
${a.warranty_reminder_enabled ? html`
<label class="setting-row sub-row">
<span class="setting-desc">${t("settings_warranty_reminder_days", L)}</span>
${this._intSetting("warranty_reminder_days", a.warranty_reminder_days)}
</label>
` : nothing}
<div class="setting-hint">${t("settings_warranty_reminder_hint", L)}</div>
</div>
`;
}
// --- Section: Budget ---
private _renderBudget(L: string) {
const b = this._settings!.budget;
return html`
<div class="settings-section" data-section="budget">
<h3>${t("settings_budget", L)}</h3>
<label class="setting-row">
<span class="setting-label">${t("settings_budget_monthly", L)}</span>
<input type="number" min="0" step="0.01" .value=${String(b.monthly)}
@change=${(e: Event) => this._updateSetting("budget_monthly", parseFloat((e.target as HTMLInputElement).value) || 0)} />
</label>
<label class="setting-row">
<span class="setting-label">${t("settings_budget_yearly", L)}</span>
<input type="number" min="0" step="0.01" .value=${String(b.yearly)}
@change=${(e: Event) => this._updateSetting("budget_yearly", parseFloat((e.target as HTMLInputElement).value) || 0)} />
</label>
<label class="setting-row">
<span class="setting-label">${t("settings_budget_alerts", L)}</span>
<input type="checkbox" .checked=${b.alerts_enabled}
@change=${(e: Event) => this._updateSetting("budget_alerts_enabled", (e.target as HTMLInputElement).checked)} />
</label>
${b.alerts_enabled ? html`
<label class="setting-row sub-row">
<span class="setting-desc">${t("settings_budget_threshold", L)}</span>
${this._intSetting("budget_alert_threshold", b.alert_threshold_pct)}
</label>
` : nothing}
</div>
`;
}
// --- Section: Archive & retention (v2.10.0) ---
private _renderArchive(L: string) {
const a = this._settings!.archive ?? { oneoff_days: 14, delete_archived_oneoff_days: 0 };
return html`
<div class="settings-section" data-section="archive">
<h3>${t("settings_archive", L)}</h3>
<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>
${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>
${this._intSetting("delete_archived_oneoff_days", a.delete_archived_oneoff_days)}
</label>
</div>
`;
}
// --- Section: Import/Export ---
// --- Section: Vacation mode (v1.2.0) ---
private _renderVacation(L: string) {
const isStale = this._vacEnabled && !this._vacIsActive && this._vacWindowEnd
&& new Date(this._vacWindowEnd) < new Date();
const exemptCount = this._vacExempt.size;
return html`
<div class="settings-section vacation-section" data-section="vacation">
<h3>
${t("vacation_title", L)}
${this._vacIsActive
? html`<span class="vac-badge active">${t("vacation_active", L)}</span>`
: nothing}
${isStale
? html`<span class="vac-badge stale">${t("vacation_ended", L)}</span>`
: nothing}
</h3>
<p class="section-desc">${t("vacation_desc", L)}</p>
<label class="vac-toggle">
<input type="checkbox" .checked=${this._vacEnabled}
@change=${(e: Event) => this._toggleVacationEnabled((e.target as HTMLInputElement).checked)} />
${t("vacation_enable", L)}
</label>
<div class="vac-grid">
<div class="vac-field">
<span class="filter-label">${t("vacation_start", L)}</span>
<ms-date-field
kind="date"
clearable
.hass=${this.hass}
.lang=${L}
.value=${this._vacStart}
@value-changed=${(e: CustomEvent) => this._setVacationDate("start", e.detail.value as string)}
></ms-date-field>
</div>
<div class="vac-field">
<span class="filter-label">${t("vacation_end", L)}</span>
<ms-date-field
kind="date"
clearable
.hass=${this.hass}
.lang=${L}
.value=${this._vacEnd}
@value-changed=${(e: CustomEvent) => this._setVacationDate("end", e.detail.value as string)}
></ms-date-field>
</div>
<label class="vac-field">
<span class="filter-label">${t("vacation_buffer", L)}</span>
<input type="number" min=${VACATION_BUFFER_DAYS_RANGE[0]} max=${VACATION_BUFFER_DAYS_RANGE[1]} .value=${String(this._vacBuffer)}
@change=${(e: Event) => this._setVacationBuffer(e)} />
</label>
</div>
<details class="vac-exempt-panel">
<summary>
${t("vacation_exempt_title", L)}
${exemptCount > 0 ? html`<span class="section-badge">${exemptCount}</span>` : nothing}
</summary>
<p class="section-desc">${t("vacation_exempt_desc", L)}</p>
${this._vacAllTasks.length === 0
? html`<button @click=${this._loadAllTasksForVacation}>${t("vacation_load_tasks", L)}</button>`
: html`
<div class="vac-task-list">
${this._renderVacationTaskList(L)}
</div>
`}
</details>
${this._vacStart && this._vacEnd ? html`
<div class="vac-preview-toolbar">
<button @click=${this._loadVacationPreview} ?disabled=${this._vacPreviewLoading}>
${this._vacPreviewLoading ? "…" : t("vacation_preview_btn", L)}
</button>
${this._vacPreview.length > 0
? html`<span class="vac-preview-count">${this._vacPreview.length} ${t("vacation_preview_affected", L)}</span>`
: nothing}
</div>
${this._vacPreview.length > 0 ? this._renderVacationPreview(L) : nothing}
` : nothing}
${this._vacIsActive || isStale
? html`<button class="vac-end-now" @click=${this._endVacationNow}>
${t("vacation_end_now", L)}
</button>`
: nothing}
</div>
`;
}
private _renderVacationTaskList(L: string) {
// Group by object_name; within each object sort by task_name (alphabetical, #40-style)
const byObj = new Map<string, typeof this._vacAllTasks>();
for (const t of this._vacAllTasks) {
const arr = byObj.get(t.object_name) || [];
arr.push(t);
byObj.set(t.object_name, arr);
}
const sortedObjs = [...byObj.entries()]
.sort(([a], [b]) => a.localeCompare(b));
return sortedObjs.map(([objName, tasks]) => html`
<div class="vac-task-group">
<div class="vac-task-group-name">${objName || t("no_objects", L)}</div>
${tasks
.sort((a, b) => a.task_name.localeCompare(b.task_name))
.map((task) => html`
<label class="vac-task-row">
<input type="checkbox"
.checked=${this._vacExempt.has(task.task_id)}
@change=${(e: Event) => this._toggleVacationExempt(task.task_id, (e.target as HTMLInputElement).checked)} />
<span>${task.task_name}</span>
</label>
`)}
</div>
`);
}
private _renderVacationPreview(L: string) {
return html`
<div class="vac-preview-list">
${this._vacPreview.map((row) => {
const eventLabel = row.events.map((e) => {
const statusKey = `vacation_event_${e.status}`;
return `${e.date} (${t(statusKey, L)})`;
}).join(" · ");
const isExempt = !row.will_suppress;
return html`
<div class="vac-preview-row ${isExempt ? "exempt" : ""}">
<div class="vac-preview-info">
<div class="vac-preview-name">
<strong>${row.object_name}</strong> · ${row.task_name}
${row.kind === "sensor_based"
? html`<span class="vac-preview-kind">${t("vacation_sensor_based", L)}</span>`
: nothing}
</div>
<div class="vac-preview-events">${eventLabel}</div>
</div>
<div class="vac-preview-actions">
<button .disabled=${this._previewBusy} @click=${() => this._previewActionComplete(row)}>${t("qr_action_complete", L)}</button>
${row.kind === "time_based" && row.allow_skip !== false
? 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)}>
${isExempt ? t("vacation_action_unsilence", L) : t("vacation_action_notify", L)}
</button>
</div>
</div>
`;
})}
</div>
`;
}
// --- Vacation actions ---
private async _loadAllTasksForVacation(): Promise<void> {
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;
}
private async _saveVacation(patch: Record<string, unknown>): Promise<void> {
if (this._vacSaving) return;
this._vacSaving = true;
try {
const result = await this._ws<SettingsResponse["vacation"]>({
type: "maintenance_supporter/vacation/update",
...patch,
});
if (!result) return;
// Server is the source of truth — re-hydrate from response.
this._vacEnabled = result.enabled;
this._vacStart = result.start || "";
this._vacEnd = result.end || "";
this._vacBuffer = result.buffer_days;
this._vacExempt = new Set(result.exempt_task_ids || []);
this._vacIsActive = result.is_active;
this._vacWindowEnd = result.window_end;
// Notify the panel (so the Vacation tab can appear/disappear)
this.dispatchEvent(new CustomEvent("settings-changed"));
} finally {
this._vacSaving = false;
}
}
private _toggleVacationEnabled(on: boolean): void {
this._saveVacation({ enabled: on });
}
private _setVacationDate(which: "start" | "end", value: string): void {
const patch: Record<string, unknown> = {};
patch[which] = value || null;
this._saveVacation(patch);
}
/** Out of range used to be dropped SILENTLY here while the Lovelace
* vacation card sent it and showed the server's error — now both reject
* it up front with the same message (DRY audit 2026-09-26). */
private _setVacationBuffer(e: Event): void {
const input = e.target as HTMLInputElement;
const [min, max] = VACATION_BUFFER_DAYS_RANGE;
const v = Number(input.value);
if (input.value.trim() !== "" && Number.isInteger(v) && v >= min && v <= max) {
this._saveVacation({ buffer_days: v });
return;
}
this._showToast(
t("settings_value_out_of_range", this._lang).replace("{min}", String(min)).replace("{max}", String(max)),
);
input.value = String(this._vacBuffer);
}
private _toggleVacationExempt(taskId: string, on: boolean): void {
const next = new Set(this._vacExempt);
if (on) next.add(taskId); else next.delete(taskId);
this._saveVacation({ exempt_task_ids: [...next] });
}
private async _loadVacationPreview(): Promise<void> {
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 {
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();
} finally {
this._previewBusy = false;
}
}
private async _previewActionSkip(row: VacationPreviewRow): Promise<void> {
if (this._previewBusy) return;
this._previewBusy = true;
try {
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();
} finally {
this._previewBusy = false;
}
}
private async _endVacationNow(): Promise<void> {
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) ---
private _renderPrintQr(L: string) {
const selectedCount = this._qrSelectedEntries.size || this._qrObjects.length;
const actionCount = this._qrActions.size;
const estimatedQrs = selectedCount * actionCount;
const overLimit = estimatedQrs > 200;
return html`
<div class="settings-section qr-print-section">
<h3>${t("qr_print_title", L)}</h3>
<p class="section-desc">${t("qr_print_desc", L)}</p>
${!this._qrObjectsLoaded
? html`<button @click=${this._loadQrObjects}>${t("qr_print_load", L)}</button>`
: html`
<details open class="qr-filter-panel">
<summary>${t("qr_print_filter", L)}</summary>
<div class="qr-filter-group">
<div class="qr-filter-label">${t("qr_print_objects", L)}</div>
<div class="qr-object-list">
${this._qrObjects.length === 0
? html`<div class="hint">${t("no_objects", L)}</div>`
: this._qrObjects.map((obj) => html`
<label class="qr-object-row">
<input type="checkbox"
.checked=${this._qrSelectedEntries.size === 0 || this._qrSelectedEntries.has(obj.entry_id)}
@change=${(e: Event) => this._toggleQrObject(obj.entry_id, (e.target as HTMLInputElement).checked)} />
<span>${obj.name}</span>
<span class="qr-task-count">${obj.task_count}</span>
</label>
`)}
</div>
</div>
<div class="qr-filter-group">
<div class="qr-filter-label">${t("qr_print_actions", L)}</div>
<div class="qr-action-chips">
${["view", "complete", "skip"].map((a) => html`
<label class="qr-action-chip ${this._qrActions.has(a) ? "active" : ""}">
<input type="checkbox"
.checked=${this._qrActions.has(a)}
@change=${(e: Event) => this._toggleQrAction(a, (e.target as HTMLInputElement).checked)} />
${t("qr_action_" + a, L)}
</label>
`)}
</div>
</div>
<div class="qr-filter-group">
<div class="qr-filter-label">${t("qr_print_url_mode", L)}</div>
<select .value=${this._qrUrlMode}
@change=${(e: Event) => { this._qrUrlMode = (e.target as HTMLSelectElement).value as typeof this._qrUrlMode; }}>
<option value="companion">${t("qr_mode_companion", L)}</option>
<option value="local">${t("qr_mode_local", L)}</option>
<option value="server">${t("qr_mode_server", L)}</option>
</select>
</div>
<div class="qr-filter-group qr-filter-actions">
<div class="qr-estimate ${overLimit ? "error" : ""}">
${t("qr_print_estimate", L)}: <strong>${estimatedQrs}</strong>
${overLimit ? html` — ${t("qr_print_over_limit", L)}` : nothing}
</div>
<button
?disabled=${this._qrBatchLoading || overLimit || actionCount === 0}
@click=${this._generateBatch}>
${this._qrBatchLoading ? t("qr_print_generating", L) : t("qr_print_generate", L)}
</button>
</div>
</details>
${this._qrBatchResults.length > 0
? html`
<div class="qr-results-toolbar">
<span>${this._qrBatchResults.length} ${t("qr_print_ready", L)}</span>
<button @click=${this._printQrs}>${t("qr_print_print_button", L)}</button>
</div>
<div class="qr-print-grid">
${this._qrBatchResults.map((q) => html`
<div class="qr-print-cell">
<div class="qr-svg">${unsafeHTML(q.svg)}</div>
<div class="qr-label">
<div class="qr-label-obj">${q.object_name}</div>
<div class="qr-label-task">${q.task_name}</div>
<div class="qr-label-action">${t("qr_action_" + q.action, L)}</div>
</div>
</div>
`)}
</div>
`
: nothing}
`}
</div>
`;
}
private async _loadQrObjects(): Promise<void> {
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 {
const next = new Set(this._qrSelectedEntries);
// First toggle off "all implicit" by materialising the current all-selected state
if (next.size === 0) {
for (const o of this._qrObjects) next.add(o.entry_id);
}
if (on) next.add(entryId); else next.delete(entryId);
// If user re-selects everything, collapse back to "empty = all"
if (next.size === this._qrObjects.length) next.clear();
this._qrSelectedEntries = next;
}
private _toggleQrAction(action: string, on: boolean): void {
const next = new Set(this._qrActions);
if (on) next.add(action); else next.delete(action);
this._qrActions = next;
}
private async _generateBatch(): Promise<void> {
this._qrBatchLoading = true;
this._qrBatchResults = [];
try {
const msg: Record<string, unknown> = {
type: "maintenance_supporter/qr/batch_generate",
actions: [...this._qrActions],
url_mode: this._qrUrlMode,
};
if (this._qrSelectedEntries.size > 0) {
msg.entry_ids = [...this._qrSelectedEntries];
}
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));
}
} finally {
this._qrBatchLoading = false;
}
}
private _printQrs(): void {
// Open a clean popup window with only the QR grid + print stylesheet.
// The earlier `window.print()` approach inherited the full HA shell
// (sidebar, top bar, menu) because @media print rules in this Lit
// component's shadow DOM don't reach those outer elements.
if (this._qrBatchResults.length === 0) return;
const L = this._lang;
const cells = this._qrBatchResults.map((q) => {
const actionLabel = t("qr_action_" + q.action, L);
// Each cell: SVG + 3 label lines (object / task / action). The SVG
// string already comes from the backend; embed verbatim.
return `
<div class="cell">
<div class="qr">${q.svg}</div>
<div class="label">
<div class="obj">${this._escapeHtml(q.object_name)}</div>
<div class="task">${this._escapeHtml(q.task_name)}</div>
<div class="action">${this._escapeHtml(actionLabel)}</div>
</div>
</div>`;
}).join("");
const title = t("qr_print_title", L);
const html = `<!DOCTYPE html>
<html lang="${this._escapeHtml(L)}">
<head>
<meta charset="utf-8" />
<title>${this._escapeHtml(title)}</title>
<style>
@page { size: A4; margin: 10mm; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: #fff; color: #000; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
body { padding: 8mm; }
.toolbar { padding-bottom: 6mm; display: flex; justify-content: space-between; align-items: center; }
.toolbar h1 { font-size: 14pt; margin: 0; font-weight: 600; }
.toolbar button { font: inherit; padding: 6px 14px; cursor: pointer; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6mm; }
.cell { border: 1px solid #ddd; border-radius: 4px; padding: 4mm; display: flex; flex-direction: column; align-items: center; gap: 3mm; page-break-inside: avoid; break-inside: avoid; }
.cell .qr { width: 100%; max-width: 50mm; }
.cell .qr svg { width: 100%; height: auto; display: block; }
.label { text-align: center; width: 100%; font-size: 9pt; line-height: 1.25; word-break: break-word; }
.label .obj { font-weight: 600; }
.label .task { color: #444; }
.label .action { color: #777; font-size: 8pt; text-transform: uppercase; letter-spacing: 0.04em; margin-top: 2mm; }
@media print {
.toolbar { display: none; }
body { padding: 0; }
}
</style>
</head>
<body>
<div class="toolbar">
<h1>${this._escapeHtml(title)} — ${this._qrBatchResults.length}</h1>
<button onclick="window.print()">${this._escapeHtml(t("qr_print_print_button", L))}</button>
</div>
<div class="grid">${cells}</div>
<script>window.addEventListener("load", function () { setTimeout(function () { window.print(); }, 250); });</script>
</body>
</html>`;
const win = window.open("", "_blank", "width=900,height=1100");
if (!win) {
// Popup blocker — fall back to the legacy in-place print so the user
// still gets *something* (with the HA shell drawback they reported).
window.print();
return;
}
win.document.open();
win.document.write(html);
win.document.close();
}
private _escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c] as string));
}
private _renderImportExport(L: string) {
return html`
<div class="settings-section">
<h3>${t("settings_import_export", L)}</h3>
<div class="settings-actions">
<label class="export-history-toggle">
<input type="checkbox" .checked=${this._includeHistory}
@change=${(e: Event) => { this._includeHistory = (e.target as HTMLInputElement).checked; }} />
${t("settings_include_history", L)}
</label>
</div>
<div class="settings-actions">
${!this._exportObjectsLoaded
? html`<button @click=${this._loadExportObjects}>${t("settings_export_selection", L)}</button>`
: html`
<details class="qr-filter-panel">
<summary>${t("settings_export_selection", L)}</summary>
<div class="qr-object-list">
${this._exportObjects.length === 0
? html`<div class="hint">${t("no_objects", L)}</div>`
: this._exportObjects.map((obj) => html`
<label class="qr-object-row">
<input type="checkbox"
.checked=${this._exportSelectedEntries.size === 0 || this._exportSelectedEntries.has(obj.entry_id)}
@change=${(e: Event) => this._toggleExportObject(obj.entry_id, (e.target as HTMLInputElement).checked)} />
<span>${obj.name}</span>
<span class="qr-task-count">${obj.task_count}</span>
</label>
`)}
</div>
</details>
`}
</div>
<div class="settings-actions">
<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>
<p class="section-desc">${t("settings_docs_archive_hint", L)}</p>
<div class="settings-actions">
<button ?disabled=${this._docArchiveLoading} @click=${this._exportDocsArchive}>
${t("settings_docs_export_btn", L)}
</button>
<button ?disabled=${this._docArchiveLoading} @click=${this._triggerDocsArchiveImport}>
${this._docArchiveLoading ? "…" : t("settings_docs_import_btn", L)}
</button>
<input class="docs-archive-file" type="file" accept=".zip" hidden
@change=${this._importDocsArchive} />
</div>
</div>
<div class="import-section">
<textarea class="import-area" .value=${this._importCsv}
placeholder=${t("settings_import_placeholder", L)}
@input=${(e: Event) => { this._importCsv = (e.target as HTMLTextAreaElement).value; }}
></textarea>
<div class="settings-actions">
<button ?disabled=${!this._importCsv.trim() || this._importLoading}
@click=${this._importCsvAction}>
${this._importLoading ? "…" : t("settings_import_btn", L)}
</button>
</div>
</div>
</div>
`;
}
// --- Export / Import actions ---
private get _selectedEntryIds(): string[] | undefined {
return this._exportSelectedEntries.size ? [...this._exportSelectedEntries] : undefined;
}
private async _loadExportObjects(): Promise<void> {
const rows = await this._loadObjectRows();
if (!rows) return;
this._exportObjects = rows;
this._exportObjectsLoaded = true;
}
private _toggleExportObject(entryId: string, on: boolean): void {
const next = new Set(this._exportSelectedEntries);
if (next.size === 0) {
for (const o of this._exportObjects) next.add(o.entry_id);
}
if (on) next.add(entryId); else next.delete(entryId);
if (next.size === this._exportObjects.length) next.clear();
this._exportSelectedEntries = next;
}
private async _exportJson(): Promise<void> {
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> {
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> {
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> {
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;
// 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) ---
private async _exportDocsArchive(): Promise<void> {
this._docArchiveLoading = true;
try {
const raw = this._selectedEntryIds;
const q = raw ? `?entry_ids=${encodeURIComponent(raw.join(","))}` : "";
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));
}
this._docArchiveLoading = false;
}
private _triggerDocsArchiveImport(): void {
const input = this.renderRoot.querySelector(".docs-archive-file") as HTMLInputElement | null;
input?.click();
}
private async _importDocsArchive(e: Event): Promise<void> {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
this._docArchiveLoading = true;
try {
const form = new FormData();
form.append("file", file, file.name);
const result = await postMultipart<{ blobs_written: number; documents_created: number }>(
this.hass, "/api/maintenance_supporter/documents/archive", form, "docs_archive_too_large",
);
this._showToast(
t("settings_docs_import_success", this._lang)
.replace("{blobs}", String(result.blobs_written ?? 0))
.replace("{docs}", String(result.documents_created ?? 0))
);
this.dispatchEvent(new CustomEvent("settings-changed"));
} catch (e) {
// A 413 names the limit; anything else stays the generic message.
this._showToast(
e instanceof Error && e.message === "docs_archive_too_large"
? t("docs_archive_too_large", this._lang)
.replace("{max}", `${formatNumber(DOCS_ARCHIVE_MAX_BYTES / (1024 * 1024), this._lang)} MB`)
: t("action_error", this._lang),
);
}
input.value = "";
this._docArchiveLoading = false;
}
// --- Styles ---
static styles = [personStyles, css`
.bn-note {
display: flex; align-items: flex-start; gap: 10px;
margin: 6px 0 10px; padding: 10px 12px; border-radius: 8px;
background: rgba(3, 169, 244, 0.08);
border: 1px solid rgba(3, 169, 244, 0.25);
font-size: 13.5px; line-height: 1.45;
}
.bn-note ha-icon { --mdc-icon-size: 20px; flex: none; color: var(--primary-color); margin-top: 1px; }
.bn-note.warn { background: rgba(255, 167, 38, 0.08); border-color: rgba(255, 167, 38, 0.35); }
.bn-note.warn ha-icon { color: var(--warning-color, #ffa726); }
.bn-link { color: var(--primary-color); text-decoration: none; }
.bn-link:hover { text-decoration: underline; }
.bn-more, .bn-sub { color: var(--secondary-text-color); }
.bn-sub { display: block; font-size: 12.5px; margin-top: 2px; }
:host { display: block; }
.settings-loading {
text-align: center;
padding: 32px;
color: var(--secondary-text-color);
}
.settings-section {
margin-bottom: 24px;
padding: 16px;
background: var(--card-background-color, #fff);
border-radius: 12px;
border: 1px solid var(--divider-color, #e0e0e0);
}
.settings-section h3 {
margin: 0 0 4px 0;
font-size: 16px;
}
.home-climate-value { font-size: 14px; color: var(--secondary-text-color); text-align: right; }
.home-traits { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.home-trait {
font-size: 12px; padding: 3px 8px; border-radius: 12px;
background: var(--secondary-background-color); color: var(--primary-text-color);
}
.section-desc {
font-size: 13px;
color: var(--secondary-text-color);
margin: 0 0 16px 0;
}
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid var(--divider-color, #e0e0e0);
cursor: pointer;
gap: 12px;
}
.notify-per-person {
padding: 10px 0;
border-bottom: 1px solid var(--divider-color, #e0e0e0);
}
.notify-person-row {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 0 0;
flex-wrap: wrap;
}
.notify-person-name {
font-weight: 500;
min-width: 120px;
}
.notify-person-target {
flex: 1;
min-width: 160px;
font-size: 0.9em;
word-break: break-word;
color: var(--secondary-text-color, #727272);
}
.notify-person-target.muted {
font-style: italic;
}
/* #169 follow-up: member avatar editor */
.member-avatar-row { display: flex; align-items: center; gap: 10px; padding: 6px 0 0; flex-wrap: wrap; }
.member-avatar-name { font-weight: 500; min-width: 120px; }
.member-initials {
width: 4.5em; padding: 4px 6px; text-align: center; text-transform: uppercase;
border: 1px solid var(--divider-color, #e0e0e0); border-radius: 6px;
background: var(--card-background-color); color: var(--primary-text-color); font: inherit;
}
.member-palette { display: inline-flex; gap: 4px; flex-wrap: wrap; }
.member-swatch {
width: 20px; height: 20px; border-radius: 50%; border: 2px solid transparent; padding: 0;
background: var(--person-color); cursor: pointer;
}
.member-swatch.selected { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--card-background-color) inset; }
.member-reset { --ha-button-font-size: 12px; }
/* v2.27: template gallery clustered by category */
.tpl-group { margin-top: 14px; }
.tpl-group-head {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0 6px;
border-bottom: 2px solid var(--divider-color, #e0e0e0);
cursor: pointer;
font-weight: 600;
}
.tpl-group-head ha-icon { --mdc-icon-size: 18px; color: var(--primary-color); }
.tpl-group-head .tpl-chevron { color: var(--secondary-text-color); }
.tpl-group-head:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
.tpl-group-name { flex: 1; }
.tpl-group-count {
font-size: 12px;
color: var(--secondary-text-color);
font-weight: 400;
}
.tpl-row { padding-left: 26px; }
.setting-row:last-child { border-bottom: none; }
.setting-row.sub-row {
padding-left: 16px;
}
.setting-label { font-size: 14px; display: block; }
.setting-desc { font-size: 12px; color: var(--secondary-text-color); display: block; }
.setting-row input[type="checkbox"] {
width: 18px; height: 18px; flex-shrink: 0;
}
.setting-row ms-date-field {
flex: 0 0 auto;
max-width: 60%;
}
.setting-row input[type="number"],
.setting-row input[type="text"] {
width: 120px;
padding: 6px 8px;
border: 1px solid var(--divider-color, #e0e0e0);
border-radius: 4px;
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
font-size: 14px;
flex-shrink: 0;
}
.setting-row input[type="number"] {
text-align: right;
}
.setting-row select {
padding: 6px 8px;
border: 1px solid var(--divider-color, #e0e0e0);
border-radius: 4px;
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
font-size: 14px;
/* May shrink: a select is as wide as its longest option, and German
"Buttons (auf dem Handy nur Symbole)" pushed the row 75 px past a
360 px screen (overflow sweep 2026-09-27). */
flex: 0 1 auto;
min-width: 0;
max-width: 60%;
}
.settings-actions {
display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px;
}
.settings-actions button {
padding: 8px 16px;
border-radius: 8px;
border: 1px solid var(--divider-color, #e0e0e0);
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
cursor: pointer;
font-size: 14px;
}
.settings-actions button:hover {
background: var(--secondary-background-color, #f5f5f5);
}
.settings-actions button[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.export-history-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
cursor: pointer;
}
.export-history-toggle input { width: 16px; height: 16px; }
.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%;
min-height: 120px;
padding: 8px;
border: 1px solid var(--divider-color, #e0e0e0);
border-radius: 8px;
font-family: monospace;
font-size: 12px;
resize: vertical;
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
box-sizing: border-box;
}
.settings-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--primary-color, #03a9f4);
color: #fff;
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0,0,0,.3);
animation: toast-in .3s ease;
}
@keyframes toast-in {
from { opacity: 0; transform: translateX(-50%) translateY(16px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
/* ─── Vacation mode section (v1.2.0) ─── */
.vacation-section h3 {
display: flex;
align-items: center;
gap: 8px;
}
.vac-badge {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.4px;
text-transform: uppercase;
padding: 2px 8px;
border-radius: 10px;
}
.vac-badge.active {
background: var(--success-color, #4caf50);
color: #fff;
}
.vac-badge.stale {
background: var(--warning-color, #ff9800);
color: #fff;
}
.vac-toggle {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
margin: 8px 0 12px;
}
.vac-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.vac-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.vac-field input {
padding: 6px 8px;
border: 1px solid var(--divider-color);
border-radius: 4px;
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
}
.vac-exempt-panel {
border: 1px solid var(--divider-color);
border-radius: 6px;
padding: 10px;
margin: 12px 0;
}
.vac-exempt-panel summary {
cursor: pointer;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
}
.section-badge {
background: var(--primary-color, #03a9f4);
color: #fff;
font-size: 11px;
font-weight: 600;
padding: 1px 8px;
border-radius: 10px;
}
.vac-task-list {
max-height: 280px;
overflow-y: auto;
margin-top: 8px;
}
.vac-task-group {
margin: 8px 0;
}
.vac-task-group-name {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--secondary-text-color);
padding: 4px 0;
}
.vac-task-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
}
.vac-task-row:hover { background: var(--secondary-background-color, rgba(127,127,127,0.1)); }
.vac-preview-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin: 12px 0 8px;
}
.vac-preview-count {
color: var(--secondary-text-color);
font-size: 13px;
}
.vac-preview-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.vac-preview-row {
display: flex;
gap: 12px;
padding: 10px 12px;
background: var(--secondary-background-color, rgba(127,127,127,0.08));
border-radius: 6px;
border-left: 3px solid var(--warning-color, #ff9800);
}
.vac-preview-row.exempt {
border-left-color: var(--success-color, #4caf50);
}
.vac-preview-info { flex: 1; }
.vac-preview-name { font-size: 14px; }
.vac-preview-kind {
font-size: 11px;
color: var(--secondary-text-color);
margin-left: 6px;
}
.vac-preview-events {
font-size: 12px;
color: var(--secondary-text-color);
margin-top: 2px;
}
.vac-preview-actions {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
.vac-preview-actions button {
font-size: 12px;
padding: 4px 10px;
}
.vac-notify-on { background: var(--success-color, #4caf50) !important; color: #fff; }
.vac-end-now {
margin-top: 12px;
background: var(--error-color, #f44336);
color: #fff;
}
/* ─── Print QR codes section (v1.1.0) ─── */
.qr-filter-panel {
border: 1px solid var(--divider-color);
border-radius: 6px;
padding: 12px;
margin-top: 8px;
}
.qr-filter-panel > summary {
cursor: pointer;
font-weight: 500;
}
.qr-filter-group {
margin-top: 12px;
}
.qr-filter-label {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--secondary-text-color);
margin-bottom: 4px;
}
.qr-object-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 4px 12px;
max-height: 240px;
overflow-y: auto;
}
.qr-object-row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
cursor: pointer;
border-radius: 4px;
}
.qr-object-row:hover { background: var(--secondary-background-color, rgba(127,127,127,0.1)); }
.qr-object-row > span:nth-of-type(1) { flex: 1; }
.qr-task-count {
color: var(--secondary-text-color);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.qr-action-chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.qr-action-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 14px;
border: 1px solid var(--divider-color);
cursor: pointer;
user-select: none;
}
.qr-action-chip.active {
background: var(--primary-color, #03a9f4);
color: #fff;
border-color: transparent;
}
.qr-action-chip input { accent-color: currentColor; }
.qr-filter-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.qr-estimate { font-size: 13px; }
.qr-estimate.error { color: var(--error-color, #f44336); }
.qr-results-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 16px;
padding: 8px 12px;
background: var(--secondary-background-color, rgba(127,127,127,0.1));
border-radius: 6px;
}
.qr-print-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
margin-top: 16px;
}
.qr-print-cell {
border: 1px solid var(--divider-color);
border-radius: 6px;
padding: 8px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
background: #fff;
color: #000;
}
.qr-print-cell .qr-svg {
width: 100%;
max-width: 160px;
}
.qr-print-cell .qr-svg svg { width: 100%; height: auto; display: block; }
.qr-label {
margin-top: 6px;
font-size: 11px;
line-height: 1.3;
}
.qr-label-obj { font-weight: 600; }
.qr-label-task { color: #444; }
.qr-label-action {
margin-top: 2px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 10px;
color: #777;
}
/* ─── Print stylesheet ─── */
@media print {
/* Strip everything except the QR grid itself */
:host { color: #000; background: #fff; }
.qr-print-section h3,
.qr-print-section .section-desc,
.qr-filter-panel,
.qr-results-toolbar,
.settings-section:not(.qr-print-section),
.settings-toast {
display: none !important;
}
.qr-print-section { padding: 0; margin: 0; }
.qr-print-grid {
grid-template-columns: repeat(3, 1fr);
gap: 12mm 8mm;
margin: 0;
}
.qr-print-cell {
border: none;
padding: 0;
page-break-inside: avoid;
}
.qr-print-cell .qr-svg { max-width: 48mm; }
.qr-label { font-size: 9pt; }
}
`];
}
if (!customElements.get("maintenance-settings-view")) {
customElements.define("maintenance-settings-view", MaintenanceSettingsView);
}