189 files
This commit is contained in:
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")
|
||||
|
||||
Reference in new issue
Block a user