393 files
This commit is contained in:
@@ -5,17 +5,20 @@ 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} from "../styles";
|
||||
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 } from "../helpers/settings-cache";
|
||||
import { SETTING_INT_RANGES, settingIntRange } from "../helpers/setting-ranges";
|
||||
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.
|
||||
@@ -37,6 +40,9 @@ interface SettingsResponse {
|
||||
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;
|
||||
@@ -501,9 +507,18 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _toastTimer = new ToastTimer();
|
||||
|
||||
private _showToast(msg: string): void {
|
||||
this._toast = msg;
|
||||
setTimeout(() => { this._toast = ""; }, 3000);
|
||||
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
|
||||
@@ -595,6 +610,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
${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}
|
||||
`;
|
||||
@@ -705,6 +721,8 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
|
||||
@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
|
||||
@@ -719,15 +737,102 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
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 || []);
|
||||
@@ -916,7 +1021,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
<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)}>
|
||||
${(["buttons_compact", "buttons", "icons"] as const).map((v) => html`
|
||||
${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>
|
||||
@@ -1336,8 +1441,8 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
</div>
|
||||
<label class="vac-field">
|
||||
<span class="filter-label">${t("vacation_buffer", L)}</span>
|
||||
<input type="number" min="0" max="14" .value=${String(this._vacBuffer)}
|
||||
@change=${(e: Event) => this._setVacationBuffer(parseInt((e.target as HTMLInputElement).value, 10) || 0)} />
|
||||
<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>
|
||||
|
||||
@@ -1499,9 +1604,21 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
this._saveVacation(patch);
|
||||
}
|
||||
|
||||
private _setVacationBuffer(value: number): void {
|
||||
if (value < 0 || value > 14) return;
|
||||
this._saveVacation({ buffer_days: value });
|
||||
/** 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 {
|
||||
@@ -2001,24 +2118,23 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name);
|
||||
const resp = await fetch("/api/maintenance_supporter/documents/archive", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${this.hass.auth?.data?.access_token ?? ""}` },
|
||||
body: form,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
} else {
|
||||
const result = (await resp.json()) as { blobs_written: number; documents_created: number };
|
||||
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 {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
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;
|
||||
@@ -2061,6 +2177,12 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
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);
|
||||
@@ -2173,7 +2295,12 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
background: var(--card-background-color, #fff);
|
||||
color: var(--primary-text-color);
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
/* 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 {
|
||||
|
||||
Reference in New Issue
Block a user