189 files
This commit is contained in:
@@ -5,12 +5,14 @@ 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 } from "../styles";
|
||||
import { t, langOf, personStyles } 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 "./ms-date-field";
|
||||
|
||||
/** One household member and the notify services they actually resolve to.
|
||||
@@ -26,6 +28,8 @@ interface PersonNotifyTarget {
|
||||
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. */
|
||||
@@ -73,6 +77,9 @@ interface SettingsResponse {
|
||||
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;
|
||||
extra_data?: string;
|
||||
};
|
||||
actions: {
|
||||
complete_enabled: boolean;
|
||||
@@ -117,6 +124,8 @@ interface VacationPreviewRow {
|
||||
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
|
||||
@@ -269,11 +278,18 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
.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">${summary.split(":")[0]}</a>${summary.slice(summary.indexOf(":"))}
|
||||
<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
|
||||
@@ -286,6 +302,62 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
</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 */
|
||||
}
|
||||
}
|
||||
|
||||
private async _updateSetting(key: string, value: unknown): Promise<void> {
|
||||
try {
|
||||
const result = await this.hass.connection.sendMessagePromise({
|
||||
@@ -293,6 +365,9 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
settings: { [key]: value },
|
||||
});
|
||||
this._settings = result as SettingsResponse;
|
||||
// The card / Lovelace dialogs read their copy through a page-wide
|
||||
// cache — drop it so they follow the change without a reload.
|
||||
invalidateSettingsCache();
|
||||
this._showToast(t("settings_saved", this._lang));
|
||||
this.dispatchEvent(new CustomEvent("settings-changed"));
|
||||
} catch {
|
||||
@@ -329,6 +404,23 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
setTimeout(() => { this._toast = ""; }, 3000);
|
||||
}
|
||||
|
||||
/** Bounded integer settings: an out-of-range entry used to be dropped
|
||||
* silently while the field kept showing it (bug review 2026-09-04). Now
|
||||
* it is named in a toast and the field snaps back to the stored value;
|
||||
* a server reject snaps back through `live()` like the selects. */
|
||||
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);
|
||||
return;
|
||||
}
|
||||
this._showToast(
|
||||
t("settings_value_out_of_range", this._lang).replace("{min}", String(min)).replace("{max}", String(max)),
|
||||
);
|
||||
input.value = String(current);
|
||||
}
|
||||
|
||||
private _downloadFile(content: string, filename: string, mime: string): void {
|
||||
// Companion-app safe download (target=_blank + DOM + deferred revoke).
|
||||
downloadTextFile(content, filename, mime);
|
||||
@@ -345,6 +437,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
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}
|
||||
@@ -632,28 +725,22 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
<h3>${t("settings_general", L)}</h3>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_default_warning", L)}</span>
|
||||
<input type="number" min="0" max="365" .value=${String(g.default_warning_days)}
|
||||
@change=${(e: Event) => {
|
||||
const v = parseInt((e.target as HTMLInputElement).value, 10);
|
||||
<input type="number" min="0" max="365" .value=${live(String(g.default_warning_days))}
|
||||
@change=${(e: Event) =>
|
||||
// 0 = no warning window (due soon only on the due date) — #145.
|
||||
if (v >= 0 && v <= 365) this._updateSetting("default_warning_days", v);
|
||||
}} />
|
||||
this._onBoundedIntChange(e, "default_warning_days", 0, 365, g.default_warning_days)} />
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_consumable_threshold", L)}</span>
|
||||
<input type="number" min="1" max="90" .value=${String(g.default_consumable_threshold ?? 10)}
|
||||
@change=${(e: Event) => {
|
||||
const v = parseInt((e.target as HTMLInputElement).value, 10);
|
||||
if (v >= 1 && v <= 90) this._updateSetting("default_consumable_threshold", v);
|
||||
}} />
|
||||
<input type="number" min="1" max="90" .value=${live(String(g.default_consumable_threshold ?? 10))}
|
||||
@change=${(e: Event) =>
|
||||
this._onBoundedIntChange(e, "default_consumable_threshold", 1, 90, g.default_consumable_threshold ?? 10)} />
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_battery_low_percent", L)}</span>
|
||||
<input type="number" min="1" max="90" .value=${String(g.battery_low_percent ?? 20)}
|
||||
@change=${(e: Event) => {
|
||||
const v = parseInt((e.target as HTMLInputElement).value, 10);
|
||||
if (v >= 1 && v <= 90) this._updateSetting("battery_low_percent", v);
|
||||
}} />
|
||||
<input type="number" min="1" max="90" .value=${live(String(g.battery_low_percent ?? 20))}
|
||||
@change=${(e: Event) =>
|
||||
this._onBoundedIntChange(e, "battery_low_percent", 1, 90, g.battery_low_percent ?? 20)} />
|
||||
</label>
|
||||
${this._renderBatteryNotesHint(L)}
|
||||
<div class="setting-hint">${t("settings_thresholds_hint", L)}</div>
|
||||
@@ -884,6 +971,25 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
</label>
|
||||
<div class="setting-hint">${t("settings_notify_scope_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>
|
||||
<textarea class="import-area notify-extra" .value=${live(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>
|
||||
@@ -1128,7 +1234,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
</div>
|
||||
<div class="vac-preview-actions">
|
||||
<button @click=${() => this._previewActionComplete(row)}>${t("qr_action_complete", L)}</button>
|
||||
${row.kind === "time_based"
|
||||
${row.kind === "time_based" && row.allow_skip !== false
|
||||
? html`<button @click=${() => this._previewActionSkip(row)}>${t("qr_action_skip", L)}</button>`
|
||||
: nothing}
|
||||
<button class=${isExempt ? "vac-notify-on" : ""}
|
||||
@@ -1761,7 +1867,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
|
||||
// --- Styles ---
|
||||
|
||||
static styles = css`
|
||||
static styles = [personStyles, css`
|
||||
.bn-note {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
margin: 6px 0 10px; padding: 10px 12px; border-radius: 8px;
|
||||
@@ -1836,6 +1942,21 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
.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 {
|
||||
@@ -1927,6 +2048,8 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
|
||||
.import-section { margin-top: 16px; }
|
||||
|
||||
.setting-row-block { flex-direction: column; align-items: stretch; gap: 6px; }
|
||||
.notify-extra { min-height: 72px; font-family: var(--code-font-family, monospace); font-size: 12.5px; }
|
||||
.import-area {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
@@ -2259,7 +2382,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
.qr-print-cell .qr-svg { max-width: 48mm; }
|
||||
.qr-label { font-size: 9pt; }
|
||||
}
|
||||
`;
|
||||
`];
|
||||
}
|
||||
|
||||
customElements.define("maintenance-settings-view", MaintenanceSettingsView);
|
||||
|
||||
Reference in New Issue
Block a user