189 files

This commit is contained in:
Home Assistant Version Control committed 2026-09-08 18:05:32 +00:00
1 parent 79fefc0a7c
commit 303886f212
189 files changed
+21402 -3514

No files matched your search

@@ -2,6 +2,8 @@
import { LitElement, html, nothing, type TemplateResult } from "lit";
import { isSafeHttpUrl } from "./helpers/url";
import { queryTokens, scoreFields } from "./helpers/search-match";
import { objectRef, parseRef, renderRefChip, taskRef } from "./helpers/reference";
import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
import { isStaleBundle } from "./helpers/bundle-version";
import { customElement, property, state } from "lit/decorators.js";
@@ -22,6 +24,37 @@ import { effectivePhase } from "./helpers/phases";
import { buildCompleteDialogArgs, fillAndOpenCompleteDialog } from "./helpers/complete-dialog-args";
import { describeWsError } from "./ws-errors";
import { panelStyles } from "./panel-styles";
// Global search (#171): what the panel needs to know about a hit — enough
// to render one row and to act on it (open the page / the document).
interface SearchHit {
kind: "object" | "task" | "part" | "document" | "content" | "history";
entryId: string;
taskId?: string;
docId?: string;
docKind?: string | null;
url?: string | null;
page?: number | null;
/** #170: the hit's reference ("8.3", "8.3-2") when numbered. */
ref?: string | null;
label: string;
sub: string;
snippet?: string;
score: number;
icon: string;
}
interface RemoteDocHit {
id: string; entry_id: string; object_name: string; kind: string; title?: string | null; filename?: string | null;
url?: string | null; tags?: string[]; match: "meta" | "content"; page?: number | null; snippet?: string; score: number;
}
interface RemoteHistoryHit {
entry_id: string; task_id: string; task_name: string; object_name: string; timestamp?: string | null;
type?: string | null; snippet: string; score: number; ref?: string | null;
}
const SEARCH_MIN_CHARS = 2;
const SEARCH_DEBOUNCE_MS = 250;
const SEARCH_GROUP_CAP = { objects: 6, tasks: 10, parts: 6, documents: 8, history: 6 } as const;
const MDI_MAGNIFY = "M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z";
import type {
HomeAssistant,
MaintenanceObjectResponse,
@@ -78,12 +111,16 @@ import { buildCalendarBuckets, isoDateLocal, type CalendarEvent } from "./helper
import { renderTriggerProgress, renderMiniSparkline, computeTrend } from "./renderers/progress";
import { type HistoryContext } from "./renderers/history";
import { renderUserBadge, type TaskDetailContext } from "./renderers/task-detail";
import { renderPersonChip, type PersonDisplay } from "./helpers/person";
// The task-detail sub-view as a web component (light-DOM, panel styles apply).
// Side-effect import: type-only imports get tree-shaken and the element
// would never register.
import "./components/task-detail-view";
import { computeWindow, VIRTUAL_MIN_ROWS } from "./helpers/virtual-window";
import { INITIAL_STICKY, nextStickyState, stickyStateOnSelect, stickyTop, type StickyState } from "./helpers/sticky-pane";
import { invalidateSettingsCache } from "./helpers/settings-cache";
import { historyPhotoIds } from "./helpers/history-photos";
import { entryReadingValues, readingSlotDelta } from "./helpers/reading-slots";
type View = "overview" | "object" | "task" | "all_objects" | "all_parts";
@@ -270,10 +307,15 @@ export class MaintenanceSupporterPanel extends LitElement {
catch { return new Set(); }
})();
// v2.15.0: command palette ("/" since 2.18.1 — Ctrl+K clashed with HA's own
// global search) — global fuzzy search over objects + tasks.
// global search); 2.78 (#171): the panel's global search — a magnifier in
// the header opens it too, results are grouped (objects / tasks / parts
// from the loaded data, documents + history notes from the server).
@state() private _paletteOpen = false;
@state() private _paletteQuery = "";
@state() private _paletteActive = 0;
@state() private _searchRemote: { query: string; documents: RemoteDocHit[]; history: RemoteHistoryHit[] } | null = null;
private _searchTimer: ReturnType<typeof setTimeout> | null = null;
private _searchSeq = 0;
// v2.15.0: template gallery (surfaces the config-flow object templates).
@state() private _templateGalleryOpen = false;
@state() private _templates: Array<{ id: string; name: string; category: string; tasks: unknown[]; disabled?: boolean }> = [];
@@ -628,8 +670,8 @@ export class MaintenanceSupporterPanel extends LitElement {
}
// A data refresh means the open task's history may have grown (complete,
// history edit) — the truncated list payload can't tell, so refetch.
if (this._view === "task" && this._selectedEntryId && this._selectedTaskId) {
this._fetchFullHistory(this._selectedEntryId, this._selectedTaskId);
if (this._detailOpen()) {
this._fetchFullHistory(this._selectedEntryId!, this._selectedTaskId!);
}
// Battery Fleet availability (batteries present + not yet set up).
// The slim status check, NOT the overview: the full overview runs the
@@ -693,6 +735,9 @@ export class MaintenanceSupporterPanel extends LitElement {
}
private _deepLinkHandled = false;
/** Set while an in-app deep link (see _onLocationChanged) is routed:
* _pushPanelState replaces HA's freshly pushed entry instead of pushing. */
private _deepLinkInPlace = false;
/** Objects + saved views have arrived at least once — deep links that
* reference them are routable. */
private _initialLoadDone = false;
@@ -710,7 +755,24 @@ export class MaintenanceSupporterPanel extends LitElement {
const path = window.location.pathname;
if (path !== base && !path.startsWith(`${base}/`)) return;
this._deepLinkHandled = false;
this._handleDeepLink();
// HA's navigate() already pushed the entry for this URL (state null), so
// the show*() helpers must take that entry over instead of stacking a
// second one — Back from a deep-linked task page needed two presses, the
// first landing on the state-less entry _onPopState ignores (bug review
// 2026-09-04 follow-up, same class as the overview branch).
this._deepLinkInPlace = true;
try {
this._handleDeepLink();
} finally {
this._deepLinkInPlace = false;
}
// Whatever the link did — a tab switch, a dialog, an unknown value — the
// entry has to describe the view now on screen; a later Back onto a
// state-less entry would restore nothing and leave the task page up.
const state = history.state as { msp_view?: View } | null;
if (!state?.msp_view) {
history.replaceState({ msp_view: this._view, msp_entry: this._selectedEntryId, msp_task: this._selectedTaskId }, "");
}
}
private _handleDeepLink(): void {
@@ -742,6 +804,11 @@ export class MaintenanceSupporterPanel extends LitElement {
|| msAction === "open_settings") {
this._deepLinkHandled = true;
cleanMsActionUrl();
// The Settings tab is admin-only (the tab bar hides it, the server
// refuses its writes) — a non-admin following a banner link lands on
// the dashboard instead of a tab-less settings view (bug review
// 2026-09-04).
if (!this.hass?.user?.is_admin) return;
// Switch to the Settings tab — that's where Vacation/Budget/Groups live.
this._overviewTab = "settings";
// Hint the settings-view which sub-section to scroll to (its own
@@ -774,8 +841,21 @@ export class MaintenanceSupporterPanel extends LitElement {
const status = params.get("status");
if (tab !== null || view !== null || sort !== null || status !== null) {
cleanMsActionUrl();
if (this._view !== "overview") this._showOverview();
if ((OVERVIEW_TABS as readonly string[]).includes(tab ?? "")) {
if (this._view !== "overview") {
// In-app navigation to the overview from a task/object page: HA's
// navigate() already pushed the entry for this location, so switch
// in place — _showOverview()'s own push put a blank entry between
// the two pages and Back needed two presses (bug review 2026-09-04).
this._view = "overview";
this._selectedEntryId = null;
this._selectedTaskId = null;
this._moreMenuOpen = false;
this._scrollContentToTop();
}
// `?tab=settings` for a non-admin is an unknown value: the tab bar
// hides Settings for them, and _setOverviewTab would have persisted
// the choice across reloads (bug review 2026-09-04).
if ((OVERVIEW_TABS as readonly string[]).includes(tab ?? "") && (tab !== "settings" || this.hass?.user?.is_admin)) {
this._setOverviewTab(tab as OverviewTab);
}
if (view !== null) {
@@ -921,10 +1001,10 @@ export class MaintenanceSupporterPanel extends LitElement {
// truncated list payload never wins the "longer list" comparison
// in _historyCtx, so a pushed completion stayed invisible there.
if (
this._view === "task" && this._selectedEntryId && this._selectedTaskId
this._detailOpen()
&& (!!ev.objects || (ev.delta || []).some((d) => d.entry_id === this._selectedEntryId))
) {
void this._fetchFullHistory(this._selectedEntryId, this._selectedTaskId);
void this._fetchFullHistory(this._selectedEntryId!, this._selectedTaskId!);
}
}
},
@@ -1089,10 +1169,13 @@ export class MaintenanceSupporterPanel extends LitElement {
// --- Navigation ---
/** Push a browser history entry so the back button navigates within the panel. */
/** Push a browser history entry so the back button navigates within the
* panel. An in-app deep link arrives on the entry HA's navigate() just
* pushed — that one is taken over instead (see _onLocationChanged). */
private _pushPanelState(view: View, entryId?: string | null, taskId?: string | null): void {
const state = { msp_view: view, msp_entry: entryId || null, msp_task: taskId || null };
history.pushState(state, "");
if (this._deepLinkInPlace) history.replaceState(state, "");
else history.pushState(state, "");
}
/** Handle browser back/forward button. */
@@ -1271,6 +1354,28 @@ export class MaintenanceSupporterPanel extends LitElement {
return this.split && !this.narrow && !this.tight && this._view === "overview" && this._overviewTab === "dashboard" && !this._bulkMode;
}
/** A task detail is on screen — the full page OR the docked split pane.
* The refetch gates keyed on `_view === "task"` alone left the docked
* detail's history frozen after a pushed completion or a data refresh
* (bug review 2026-09-04). */
private _detailOpen(): boolean {
return (this._view === "task" || this._splitActive()) && !!this._selectedEntryId && !!this._selectedTaskId;
}
/** The docked detail's breadcrumb opens the full task page (a no-op on
* the page itself). Assigning `_view` alone pushed no history entry —
* Back then left the panel instead of returning to the list — and kept
* the list's scroll offset (bug review 2026-09-04). The detail's tab,
* history filter and full history carry over: they are already loaded. */
private _showFullTaskPage(entryId: string, taskId: string): void {
if (this._view === "task") return;
this._pushPanelState("task", entryId, taskId);
this._view = "task";
this._selectedEntryId = entryId;
this._selectedTaskId = taskId;
this._scrollContentToTop();
}
private _showTask(entryId: string, taskId: string): void {
// Master-detail split (2026-09-01): same data prep, but no view switch
// and no history entry — the detail docks on the right like a mail
@@ -1345,10 +1450,10 @@ export class MaintenanceSupporterPanel extends LitElement {
undo?.();
}
// --- Command palette ("/") ---
// --- Global search ("/" or the header's magnifier; #171) ---
private _paletteKeydown = (e: KeyboardEvent): void => {
// "/" opens the palette (GitHub/Discourse convention; Shift+7 on a German
// "/" opens the search (GitHub/Discourse convention; Shift+7 on a German
// layout still yields key === "/"). Deliberately NOT Ctrl/Cmd+K: that is
// HA's own global-search hotkey, and this window-level preventDefault
// would shadow it whenever the panel is open.
@@ -1378,6 +1483,7 @@ export class MaintenanceSupporterPanel extends LitElement {
this._paletteQuery = "";
this._paletteActive = 0;
this._paletteOpen = true;
this._searchRemote = null;
this.updateComplete.then(() => {
this.shadowRoot?.querySelector<HTMLInputElement>(".palette-input")?.focus();
});
@@ -1386,62 +1492,211 @@ export class MaintenanceSupporterPanel extends LitElement {
private _closePalette(): void {
this._paletteOpen = false;
this._paletteQuery = "";
this._searchRemote = null;
if (this._searchTimer) { clearTimeout(this._searchTimer); this._searchTimer = null; }
}
private get _paletteResults(): Array<{ kind: "object" | "task"; entryId: string; taskId?: string; label: string; sub: string }> {
const q = this._paletteQuery.trim().toLowerCase();
const out: Array<{ kind: "object" | "task"; entryId: string; taskId?: string; label: string; sub: string }> = [];
private _onPaletteInput(value: string): void {
this._paletteQuery = value;
this._paletteActive = 0;
// Documents and history notes live on the server — ask once the user
// pauses, and drop answers that arrive for an older query.
if (this._searchTimer) clearTimeout(this._searchTimer);
const q = value.trim();
if (q.length < SEARCH_MIN_CHARS) { this._searchRemote = null; return; }
this._searchTimer = setTimeout(() => {
this._searchTimer = null;
const seq = ++this._searchSeq;
this.hass.connection.sendMessagePromise<{ documents: RemoteDocHit[]; history: RemoteHistoryHit[] }>({
type: "maintenance_supporter/search", query: q, limit: SEARCH_GROUP_CAP.documents,
}).then((res) => {
if (seq !== this._searchSeq || !this._paletteOpen) return;
this._searchRemote = { query: q, documents: res.documents || [], history: res.history || [] };
}).catch(() => { /* the local groups still answer; the server groups just stay empty */ });
}, SEARCH_DEBOUNCE_MS);
}
/** Objects, tasks and parts from the data the panel already holds, plus the
* server's document and history-note hits — grouped, each group ranked
* and capped. The flat array is what the keyboard walks. */
private get _paletteResults(): SearchHit[] {
const L = this._lang;
const q = this._paletteQuery.trim();
// A one-digit reference ("8") is a complete query; text needs two chars.
if (q.length < SEARCH_MIN_CHARS && !parseRef(q)) return [];
const tokens = queryTokens(q);
if (!tokens.length) return [];
const objects: SearchHit[] = [];
const tasks: SearchHit[] = [];
const parts: SearchHit[] = [];
// #170: a typed reference ("8", "8.3", "8.3-2") names things exactly —
// the object (with its tasks), one task, or (via the server) one
// completion. Only when nothing carries that number does the text
// matching below get its turn.
const ref = parseRef(q);
if (ref) {
for (const obj of this._objects) {
const o = obj.object;
if (o.ref_no !== ref.object) continue;
const oref = objectRef(o)!;
if (ref.task == null) {
objects.push({ kind: "object", entryId: obj.entry_id, label: o.name || "", sub: t("object", L), score: 1000, icon: "mdi:package-variant-closed", ref: oref });
}
for (const task of obj.tasks) {
if (task.archived || (ref.task != null && task.ref_no !== ref.task)) continue;
tasks.push({ kind: "task", entryId: obj.entry_id, taskId: task.id, label: task.name || "", sub: o.name || "", score: ref.task == null ? 900 : 1000, icon: "mdi:clipboard-check-outline", ref: taskRef(o, task) });
}
}
if (objects.length || tasks.length) {
const remoteRef = this._searchRemote && this._searchRemote.query === q ? this._searchRemote : null;
const hits: SearchHit[] = [...objects, ...tasks.slice(0, SEARCH_GROUP_CAP.tasks)];
for (const h of remoteRef?.history.slice(0, 1) ?? []) {
hits.push({ kind: "history", entryId: h.entry_id, taskId: h.task_id, label: h.task_name || "", sub: [h.object_name, h.timestamp ? formatDate(h.timestamp, L) : ""].filter(Boolean).join(" · "), snippet: h.snippet || "", score: h.score, icon: "mdi:note-text-outline", ref: h.ref ?? null });
}
return hits;
}
}
for (const obj of this._objects) {
const oname = obj.object.name || "";
if (!q || oname.toLowerCase().includes(q)) {
out.push({ kind: "object", entryId: obj.entry_id, label: oname, sub: t("object", this._lang) });
const o = obj.object;
if (o.archived) continue;
const oname = o.name || "";
const oscore = scoreFields(tokens, [
{ text: oname, weight: 3 }, { text: o.manufacturer, weight: 2 }, { text: o.model, weight: 2 },
{ text: o.serial_number, weight: 2 }, { text: o.notes, weight: 1 },
]);
if (oscore > 0) {
const detail = [o.manufacturer, o.model].filter(Boolean).join(" ");
objects.push({ kind: "object", entryId: obj.entry_id, label: oname, sub: detail || t("object", L), score: oscore, icon: "mdi:package-variant-closed", ref: objectRef(o) });
}
for (const task of obj.tasks) {
if (task.archived) continue;
const tname = task.name || "";
const labelHit = (task.labels || []).some((lb) => lb.toLowerCase().includes(q));
if (!q || tname.toLowerCase().includes(q) || oname.toLowerCase().includes(q) || labelHit) {
const labels = (task.labels || []).join(" ");
const tscore = scoreFields(tokens, [
{ text: task.name, weight: 3 }, { text: oname, weight: 2 }, { text: labels, weight: 2 }, { text: task.notes, weight: 1 },
]);
if (tscore > 0) {
const labelSub = (task.labels || []).length ? ` #${(task.labels || []).join(" #")}` : "";
out.push({ kind: "task", entryId: obj.entry_id, taskId: task.id, label: tname, sub: oname + labelSub });
tasks.push({ kind: "task", entryId: obj.entry_id, taskId: task.id, label: task.name || "", sub: oname + labelSub, score: tscore, icon: "mdi:clipboard-check-outline", ref: taskRef(o, task) });
}
}
for (const part of obj.parts || []) {
const pscore = scoreFields(tokens, [
{ text: part.name, weight: 3 }, { text: part.mpn, weight: 2 }, { text: part.vendor, weight: 1 },
{ text: part.storage_location, weight: 1 }, { text: part.notes, weight: 1 },
]);
if (pscore > 0) {
parts.push({ kind: "part", entryId: obj.entry_id, label: part.name || "", sub: [oname, part.mpn].filter(Boolean).join(" · "), score: pscore, icon: "mdi:cog-outline" });
}
}
if (out.length > 60) break; // cap the working set; sliced below
}
return out.slice(0, 40);
const byScore = (a: SearchHit, b: SearchHit) => b.score - a.score || a.label.localeCompare(b.label);
const out: SearchHit[] = [
...objects.sort(byScore).slice(0, SEARCH_GROUP_CAP.objects),
...tasks.sort(byScore).slice(0, SEARCH_GROUP_CAP.tasks),
...parts.sort(byScore).slice(0, SEARCH_GROUP_CAP.parts),
];
const remote = this._searchRemote && this._searchRemote.query === q ? this._searchRemote : null;
if (remote) {
// Metadata hits ("Documents") before content hits ("In documents") —
// the server ranks across both, the groups keep each ranked run.
const docs = remote.documents.slice(0, SEARCH_GROUP_CAP.documents);
for (const d of [...docs.filter((x) => x.match !== "content"), ...docs.filter((x) => x.match === "content")]) {
out.push({
kind: d.match === "content" ? "content" : "document",
entryId: d.entry_id, docId: d.id, docKind: d.kind, url: d.url, page: d.page ?? null,
label: d.title || d.filename || d.url || "", sub: d.object_name || "", snippet: d.snippet || "",
score: d.score, icon: d.kind === "weblink" ? "mdi:link-variant" : "mdi:file-document-outline",
});
}
for (const h of remote.history.slice(0, SEARCH_GROUP_CAP.history)) {
out.push({
kind: "history", entryId: h.entry_id, taskId: h.task_id, label: h.task_name || "", ref: h.ref ?? null,
sub: [h.object_name, h.timestamp ? formatDate(h.timestamp, L) : ""].filter(Boolean).join(" · "),
snippet: h.snippet || "", score: h.score, icon: h.type === "skipped" ? "mdi:skip-next-circle-outline" : "mdi:note-text-outline",
});
}
}
return out;
}
private _selectPaletteResult(r: { kind: "object" | "task"; entryId: string; taskId?: string }): void {
private _selectPaletteResult(r: SearchHit): void {
const query = this._paletteQuery.trim();
this._closePalette();
if (r.kind === "task" && r.taskId) this._showTask(r.entryId, r.taskId);
else this._showObject(r.entryId);
switch (r.kind) {
case "task":
if (r.taskId) this._showTask(r.entryId, r.taskId);
return;
case "history":
if (!r.taskId) return;
this._showTask(r.entryId, r.taskId);
// Land on the history tab with the notes filter pre-filled, so the
// matching entry is on screen instead of somewhere down the timeline.
this._activeTab = "history";
this._historySearch = query;
return;
case "document":
case "content":
if (r.docKind === "weblink") {
if (isSafeHttpUrl(r.url)) window.open(r.url!, "_blank", "noopener");
} else if (r.docId) {
// A content hit opens the PDF on the matching page (browser viewers
// honour #page=N; the Companion app's external viewer may not).
void openSignedDocument(this.hass, r.docId, r.page ? `#page=${r.page}` : "").catch(() => { /* helper closed its tab */ });
}
return;
default:
this._showObject(r.entryId);
}
}
private _renderPalette() {
if (!this._paletteOpen) return nothing;
const L = this._lang;
const results = this._paletteResults;
const q = this._paletteQuery.trim();
const groupTitle: Record<SearchHit["kind"], string> = {
object: t("objects", L), task: t("tasks", L), part: t("search_group_parts", L),
document: t("documents", L), content: t("search_group_content", L), history: t("search_group_history", L),
};
const waiting = q.length >= SEARCH_MIN_CHARS && (!this._searchRemote || this._searchRemote.query !== q);
let lastKind: SearchHit["kind"] | null = null;
return html`
<div class="palette-backdrop" @click=${() => this._closePalette()}>
<div class="palette" @click=${(e: Event) => e.stopPropagation()}>
<div class="palette" role="dialog" aria-label=${t("search_open", L)} @click=${(e: Event) => e.stopPropagation()}>
<input
class="palette-input"
type="text"
placeholder="${t("palette_placeholder", L)}"
.value=${this._paletteQuery}
@input=${(e: Event) => { this._paletteQuery = (e.target as HTMLInputElement).value; this._paletteActive = 0; }}
@input=${(e: Event) => this._onPaletteInput((e.target as HTMLInputElement).value)}
/>
<div class="palette-results">
${results.length === 0
? html`<div class="palette-empty">${t("palette_no_results", L)}</div>`
: results.map((r, i) => html`
<div class="palette-item ${i === this._paletteActive ? "active" : ""}"
@mouseenter=${() => { this._paletteActive = i; }}
@click=${() => this._selectPaletteResult(r)}>
<ha-icon icon="${r.kind === "task" ? "mdi:clipboard-check-outline" : "mdi:package-variant-closed"}"></ha-icon>
<span class="palette-label">${r.label}</span>
<span class="palette-sub">${r.sub}</span>
</div>
`)}
${q.length < SEARCH_MIN_CHARS && !parseRef(q)
? html`<div class="palette-empty">${t("search_empty_hint", L)}</div>`
: results.length === 0
? html`<div class="palette-empty">${waiting ? t("search_searching", L) : t("palette_no_results", L)}</div>`
: results.map((r, i) => {
const header = r.kind !== lastKind ? html`<div class="palette-group">${groupTitle[r.kind]}</div>` : nothing;
lastKind = r.kind;
return html`
${header}
<div class="palette-item ${i === this._paletteActive ? "active" : ""} ${r.snippet ? "has-snippet" : ""}"
@mouseenter=${() => { this._paletteActive = i; }}
@click=${() => this._selectPaletteResult(r)}>
<ha-icon icon="${r.icon}"></ha-icon>
<div class="palette-main">
<div class="palette-line">
<span class="palette-label">${r.label}</span>
${r.ref ? html`<span class="ref-chip">#${r.ref}</span>` : nothing}
${r.page ? html`<span class="palette-page">${t("search_page", L).replace("{page}", String(r.page))}</span>` : nothing}
<span class="palette-sub">${r.sub}</span>
</div>
${r.snippet ? html`<div class="palette-snippet">${r.snippet}</div>` : nothing}
</div>
</div>
`;
})}
${results.length > 0 && waiting ? html`<div class="palette-group palette-waiting">${t("search_searching", L)}</div>` : nothing}
</div>
<div class="palette-hint">${t("palette_hint", L)}</div>
</div>
@@ -2127,6 +2382,7 @@ export class MaintenanceSupporterPanel extends LitElement {
excerpt,
new Date().toISOString(),
partsLines,
taskRef(obj.object, task),
);
openHtmlInNewTab(html);
} finally {
@@ -2319,6 +2575,20 @@ export class MaintenanceSupporterPanel extends LitElement {
`;
}
/** The magnifier that opens the global search (#171) — in the header
* wherever the header is on screen, in the overview's tab bar on wide
* screens (no header there), so it is one tap away on every view. */
private _renderSearchButton(cls: string) {
const L = this._lang;
return html`<ha-icon-button
class=${cls}
.path=${MDI_MAGNIFY}
.label=${t("search_open", L)}
title=${t("search_open", L)}
@click=${() => this._openPalette()}
></ha-icon-button>`;
}
private _renderHeader() {
const crumbs: { label: string; action?: () => void }[] = [
{ label: t("maintenance", this._lang), action: () => this._showOverview() },
@@ -2359,6 +2629,7 @@ export class MaintenanceSupporterPanel extends LitElement {
`
)}
</div>
${this._renderSearchButton("header-search")}
</div>
`;
}
@@ -2431,6 +2702,7 @@ export class MaintenanceSupporterPanel extends LitElement {
${t("settings", L)}
</div>
` : nothing}
${this.narrow ? nothing : this._renderSearchButton("tab-search")}
</div>
${this._overviewTab === "today"
? this._renderToday()
@@ -2528,6 +2800,15 @@ export class MaintenanceSupporterPanel extends LitElement {
private _renderTodaySection(titleKey: string, rows: TaskRow[], cls: string) {
if (rows.length === 0) return nothing;
const L = this._lang;
// #169: name the responsible person like the dashboard rows do — as the
// member's avatar (initials in their colour) plus the name; narrow
// layouts keep only the avatar. With the person filter active every
// row belongs to the same person, so the chip would only repeat the
// filter — leave it out then.
const personOf = (row: TaskRow): PersonDisplay | null =>
this._filterUser || !row.responsible_user_id
? null
: this._userService?.getPerson(row.responsible_user_id) ?? null;
return html`
<div class="today-section">
<div class="today-section-header ${cls}">
@@ -2538,7 +2819,10 @@ export class MaintenanceSupporterPanel extends LitElement {
<span class="today-dot ${row.trigger_active ? "triggered" : row.status}"></span>
<div class="today-main">
<div class="today-task">${row.task_name}</div>
<div class="today-object">${row.object_name} · ${formatDueDays(row.days_until_due, L)}</div>
<div class="today-object">
<span class="today-object-text">${row.object_name} · ${formatDueDays(row.days_until_due, L)}</span>
${renderPersonChip(personOf(row), "today-person")}
</div>
</div>
${this._actionStyle() === "icons"
? html`
@@ -3326,6 +3610,8 @@ export class MaintenanceSupporterPanel extends LitElement {
return html`<td class="oc-notes" title=${o.notes || ""}>${o.notes || "—"}</td>`;
case "task_count":
return html`<td class="oc-task_count">${obj.tasks.length}</td>`;
case "ref_no":
return html`<td class="oc-ref_no">${objectRef(o) ? `#${objectRef(o)}` : "—"}</td>`;
case "actions":
return html`<td class="oc-actions">
<mwc-icon-button title="${t("qr_code", L)}" @click=${(e: Event) => { e.stopPropagation(); this._openQrForObject(obj.entry_id, o.name); }}>
@@ -3508,10 +3794,7 @@ export class MaintenanceSupporterPanel extends LitElement {
<span class="sub-chip">
<ha-icon icon="mdi:map-marker-outline"></ha-icon>${areaName}
</span>` : nothing}
${userName ? html`
<span class="sub-chip" title="${t("responsible_user", L)}">
<ha-icon icon="mdi:account-outline"></ha-icon>${userName}
</span>` : nothing}
${userName ? renderPersonChip(this._userService?.getPerson(row.responsible_user_id) ?? null, "sub-chip") : nothing}
${(row.labels || []).map((label) => html`
<span class="sub-chip label-chip" title="${t("labels", L)}">
<ha-icon icon="mdi:tag-outline"></ha-icon>${label}
@@ -3552,6 +3835,7 @@ export class MaintenanceSupporterPanel extends LitElement {
await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/global/update", settings });
this._rowActionNotice = false;
if (backToIcons) this._rowActionStyle = "icons";
invalidateSettingsCache();
} catch (err) {
console.warn("[maintenance-supporter] row-action notice update failed", err);
}
@@ -3635,7 +3919,7 @@ export class MaintenanceSupporterPanel extends LitElement {
return html`
<div class="detail-section">
<div class="detail-header">
<h2>${o.name}</h2>
<h2>${o.name} ${renderRefChip(objectRef(o), t("ref_number", L))}</h2>
<div class="action-buttons">
${!isOperator ? html`
<ha-button appearance="filled" @click=${() => {
@@ -3734,7 +4018,7 @@ export class MaintenanceSupporterPanel extends LitElement {
: nothing}
</span>
<span class="cell task-name" @click=${() => this._showTask(obj.entry_id, task.id)}>${task.name}</span>
<span class="task-sub${task.responsible_user_id ? '' : ' task-sub-empty'}">${renderUserBadge(task, (id) => this._userService?.getUserName(id) ?? null)}</span>
<span class="task-sub${task.responsible_user_id ? '' : ' task-sub-empty'}">${renderUserBadge(task, (id) => this._userService?.getUserName(id) ?? null, (id) => this._userService?.getPerson(id) ?? null)}</span>
<span class="cell type">${t(task.type, L)}</span>
<span class="due-cell" @click=${() => this._showTask(obj.entry_id, task.id)}>
<span class="due-text">${formatDueDays(task.days_until_due, L)}</span>
@@ -4003,6 +4287,7 @@ export class MaintenanceSupporterPanel extends LitElement {
setSearch: (s) => { this._historySearch = s; },
openEdit: (entry) => this._openHistoryEdit(entry),
readingUnit: task?.reading_unit ?? null,
taskRef: taskRef(this._selectedEntryId ? this._getObject(this._selectedEntryId)?.object : null, task),
// #139: name the phase badge on entries stamped with a phase_id.
phaseNames: Object.fromEntries(
Object.entries(task?.phases || {}).map(([id, def]) => [id, def.name]),
@@ -4012,6 +4297,9 @@ export class MaintenanceSupporterPanel extends LitElement {
if (idx <= 0) return null;
return (entry.reading_value as number) - (readings[idx - 1].reading_value as number);
},
// #161 phase 2: per-slot delta against the previous entry that
// carried the same slot (a skipped meter does not break the chain).
readingSlotDelta: (entry, slotId) => readingSlotDelta(fullHistory, entry, slotId),
};
}
@@ -4028,6 +4316,7 @@ export class MaintenanceSupporterPanel extends LitElement {
entryId,
taskId,
objectName: obj?.object.name || "",
taskRef: taskRef(obj?.object, obj?.tasks.find((tk) => tk.id === taskId)),
objectDocUrl: obj?.object?.documentation_url ?? null,
objectManualDocs: obj?.object?.manual_docs ?? [],
openManualDoc: (doc) => this._openManualDoc(doc),
@@ -4050,10 +4339,11 @@ export class MaintenanceSupporterPanel extends LitElement {
sparkline: this._sparklineCtx,
history: this._historyCtx(),
getUserName: (id) => this._userService?.getUserName(id) ?? null,
getPerson: (id) => this._userService?.getPerson(id) ?? null,
setActiveTab: (tab) => { this._activeTab = tab; },
toggleSection: (key) => this._toggleSection(key),
setCostDurationToggle: (v) => { this._costDurationToggle = v; },
showTaskView: () => { this._view = "task"; },
showTaskView: () => this._showFullTaskPage(entryId, taskId),
showObject: () => this._showObject(entryId),
toggleMoreMenu: () => this._toggleMoreMenu(),
closeMoreMenu: () => this._closeMoreMenu(),
@@ -4115,6 +4405,7 @@ export class MaintenanceSupporterPanel extends LitElement {
* for in the template to trigger a data refresh. */
private _openHistoryEdit(entry: HistoryEntry): void {
if (!this._selectedEntryId || !this._selectedTaskId) return;
const editTask = this._getTask(this._selectedEntryId, this._selectedTaskId);
const draft: HistoryEntryDraft = {
entry_id: this._selectedEntryId,
task_id: this._selectedTaskId,
@@ -4126,6 +4417,12 @@ export class MaintenanceSupporterPanel extends LitElement {
duration: entry.duration ?? null,
completed_by: entry.completed_by ?? null,
used_parts: entry.used_parts ?? null,
photo_doc_ids: historyPhotoIds(entry),
reading_value: entry.reading_value ?? null,
reading_values: entryReadingValues(entry),
readings: editTask?.readings ?? [],
task_type: editTask?.type ?? null,
reading_unit: editTask?.reading_unit ?? null,
};
this.shadowRoot
?.querySelector<MaintenanceHistoryEditDialog>("maintenance-history-edit-dialog")