257 files

This commit is contained in:
Home Assistant Version Control
2026-10-02 16:06:24 +00:00
parent 7c55603942
commit c29f5cc088
258 changed files with 24621 additions and 3136 deletions
@@ -61,6 +61,7 @@ const SEARCH_GROUP_CAP = { objects: 6, tasks: 10, parts: 6, documents: 8, histor
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,
MaintenanceObject,
MaintenanceObjectResponse,
MaintenanceTask,
MaintenanceGroup,
@@ -81,7 +82,7 @@ import { UserService } from "./user-service";
// esbuild code-split chunks loaded by _ensureLazyUi() right after mount
// (roadmap perf wave 2, item 5). Only their types are imported — esbuild
// tree-shakes type-only imports, so they cost the entry bundle nothing.
import type { MaintenanceObjectDialog } from "./components/object-dialog";
import type { DeviceSwap, MaintenanceObjectDialog } from "./components/object-dialog";
import "./components/documents-section";
import "./components/parts-section";
import "./components/object-history-section";
@@ -123,12 +124,14 @@ 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, parseSettings, type SettingsWire } from "./helpers/settings-cache";
import type { PartsCostMode } from "./helpers/parts-cost";
import { canWrite } from "./helpers/permissions";
import { renderStatusBadge } from "./renderers/status";
import { TOAST_MS, ACTION_TOAST_MS } from "./helpers/toast";
import { buildHistoryEntryDraft } from "./helpers/history-draft";
import { readingSlotDelta } from "./helpers/reading-slots";
import { bulkResultMessage, runWs, runWsEach } from "./helpers/ws-run";
import { snoozedMessage, type SnoozeResult } from "./helpers/snooze";
import { STATUS_ORDER, statusRank } from "./status-constants";
// #191: all_areas = the areas page, area = one area's history and costs.
@@ -144,6 +147,8 @@ interface PartsOverviewRow {
name: string;
unit?: string | null;
cost?: number | null;
/** #98 follow-up: cost is then per package of this many units. */
package_size?: number | null;
storage_location?: string | null;
vendor?: string | null;
reorder_threshold?: number | null;
@@ -256,7 +261,7 @@ export class MaintenanceSupporterPanel extends LitElement {
@state() private _groups: Record<string, MaintenanceGroup> = {};
@state() private _detailStatsData: Map<string, StatisticsPoint[]> = new Map();
@state() private _miniStatsData: Map<string, StatisticsPoint[]> = new Map();
@state() private _features: AdvancedFeatures = { adaptive: false, predictions: false, seasonal: false, environmental: false, budget: false, groups: false, checklists: false, schedule_time: false, completion_actions: false };
@state() private _features: AdvancedFeatures = { adaptive: false, seasonal: false, environmental: false, budget: false, groups: false, checklists: false, schedule_time: false, completion_actions: false };
// HA user IDs (UUIDs) granted full panel access despite not being HA admins.
@state() private _adminPanelUserIds: string[] = [];
// v2.8.4: master switch — the allowlist only grants the full panel when this
@@ -270,6 +275,8 @@ export class MaintenanceSupporterPanel extends LitElement {
@state() private _rowActionStyle = "buttons_compact";
/** #170: reference numbers in front of names in the lists (global setting). */
@state() private _refsInLists = false;
/** #104: when spare parts count — what the complete dialog says about them. */
@state() private _partsCostMode: PartsCostMode = "purchase";
/** #145: one-time "rows look different now" notice for existing installs. */
@state() private _rowActionNotice = false;
@state() private _actionLoading = false;
@@ -591,7 +598,8 @@ export class MaintenanceSupporterPanel extends LitElement {
if (!this._userService) {
this._userService = new UserService(this.hass);
this._userService.getUsers();
// Names feed the person filter and the row chips — render once known.
void this._userService.getUsers().then(() => this.requestUpdate());
} else {
this._userService.updateHass(this.hass);
}
@@ -794,6 +802,7 @@ export class MaintenanceSupporterPanel extends LitElement {
this._rowActionNotice = s.rowActionNoticePending;
this._refsInLists = s.refsInLists;
this._objectsTableColumns = sanitizeColumns(s.objectsTableColumns);
this._partsCostMode = s.partsCostMode;
}
// Fetch mini-sparkline data for overview (non-blocking)
@@ -1520,6 +1529,18 @@ export class MaintenanceSupporterPanel extends LitElement {
/** #179: `section` opens one collapsed section for this visit (a search
* hit, a `?section=` link) and scrolls to it; the saved set is untouched. */
/** Replacement lineage (object ⋮ → Replace): the retired unit and its
* successor point at each other — stored since the feature shipped, but
* neither page linked the other (audit 2026-09-28). An object that is no
* longer loaded (deleted) shows nothing. */
private _renderLineageLink(entryId: string | null | undefined, label: string) {
const other = entryId ? this._objects.find((x) => x.entry_id === entryId) : undefined;
if (!other) return nothing;
return html`<p class="meta">${label}:
<a href="#" class="object-lineage-link" @click=${(e: Event) => { e.preventDefault(); this._showObject(other.entry_id); }}
>${other.object.name}</a></p>`;
}
private _showObject(entryId: string, section: ObjectSection | null = null): void {
this._pushPanelState("object", entryId);
this._view = "object";
@@ -2494,6 +2515,33 @@ export class MaintenanceSupporterPanel extends LitElement {
}
}
// #193: pause ONE task (items used in rotation: the filter comes out of use
// for a while) — the object pause, per task. Pausing asks for an optional
// resume date; resuming starts a fresh cycle from that day.
private async _togglePauseTask(entryId: string, taskId: string, paused: boolean): Promise<void> {
if (!paused) {
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
const result = await dlg?.prompt({
title: t("pause_task", this._lang),
message: t("pause_task_prompt", this._lang),
confirmText: t("pause_task", this._lang),
inputLabel: t("pause_until_label", this._lang),
inputType: "date",
});
if (!result?.confirmed) return;
const msg: Record<string, unknown> = { type: "maintenance_supporter/task/pause", entry_id: entryId, task_id: taskId };
if (result.value) msg.until = result.value;
if (await this._runAction(msg)) {
this._showUndoToast(t("task_paused", this._lang), () => this._togglePauseTask(entryId, taskId, true));
}
return;
}
await this._runAction(
{ type: "maintenance_supporter/task/resume", entry_id: entryId, task_id: taskId },
{ successToast: t("task_resumed", this._lang) },
);
}
// v2.10.0: archive / unarchive an object. Archiving cascades to its tasks but
// is fully reversible, so instead of a blocking confirm we run it immediately
// and offer an Undo toast (v2.14.0).
@@ -2542,27 +2590,34 @@ export class MaintenanceSupporterPanel extends LitElement {
// v2.20 (N1): replace a worn-out object with a successor — the old one is
// archived in place (history/costs stay browsable), the new one starts as a
// pre-filled fresh unit with tasks and documents carried over.
private async _replaceObject(entryId: string, currentName: string): Promise<void> {
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
const result = await dlg?.prompt({
title: t("replace_object", this._lang),
message: t("replace_object_prompt", this._lang),
confirmText: t("replace_object", this._lang),
inputLabel: t("replace_name_label", this._lang),
inputType: "text",
inputValue: currentName,
});
if (!result?.confirmed) return;
const res = await this._runAction<{ entry_id?: string }>(
{
type: "maintenance_supporter/object/replace",
entry_id: entryId,
name: result.value || currentName,
},
{ successToast: t("object_replaced", this._lang) },
);
if (res?.entry_id) this._showObject(res.entry_id);
// pre-filled fresh unit with tasks and documents carried over. The object
// dialog asks for the name AND the new unit's device (2.96).
private _replaceObject(entryId: string, obj: MaintenanceObject): void {
void this._ui<MaintenanceObjectDialog>("maintenance-object-dialog").then((d) => d?.openReplace(entryId, obj));
}
private _onObjectReplaced = async (e: CustomEvent<{ entry_id?: string; device_swap?: DeviceSwap }>): Promise<void> => {
// " · ": the replaced line carries no full stop in every language.
this._showToast([t("object_replaced", this._lang), this._deviceSwapText(e.detail.device_swap)].filter(Boolean).join(" · "), "info");
try { await this._loadData(); } catch { /* subscription will sync */ }
if (e.detail.entry_id) this._showObject(e.detail.entry_id);
};
private _onObjectSaved = async (e: CustomEvent<{ device_swap?: DeviceSwap }>): Promise<void> => {
const swap = this._deviceSwapText(e.detail?.device_swap);
if (swap) this._showToast(swap, "info");
await this._onDialogEvent();
};
/** "3 sensor links moved to the new device. 1 could not be matched." */
private _deviceSwapText(swap: DeviceSwap | undefined): string {
if (!swap || (!swap.moved && !swap.unmatched.length)) return "";
const parts: string[] = [];
if (swap.moved) parts.push(this._countText("device_swap_moved", "device_swap_moved_one", "count", swap.moved));
if (swap.unmatched.length) {
parts.push(this._countText("device_swap_unmatched", "device_swap_unmatched_one", "count", swap.unmatched.length));
}
return parts.join(" ");
}
private async _skipTask(entryId: string, taskId: string, reason?: string): Promise<void> {
@@ -2677,10 +2732,11 @@ export class MaintenanceSupporterPanel extends LitElement {
// Now reloads like every sibling action — this was the one mutation that
// skipped the refresh, leaving the snoozed due date stale until the next
// poll.
await this._runAction(
// #193: say for how long — the task itself shows no change.
const res = await this._runAction<SnoozeResult>(
{ type: "maintenance_supporter/task/snooze", entry_id: entryId, task_id: taskId },
{ successToast: t("snoozed", this._lang) },
);
if (res) this._showToast(snoozedMessage(res, this._lang), "info");
}
private _dismissSuggestion(entryId?: string, taskId?: string): void {
@@ -2921,6 +2977,7 @@ export class MaintenanceSupporterPanel extends LitElement {
adaptiveEnabled,
currencySymbol: this._currencySymbol,
viaTagScan: opts?.viaTagScan,
partsCostMode: this._partsCostMode,
}),
this._lang,
);
@@ -2990,13 +3047,17 @@ export class MaintenanceSupporterPanel extends LitElement {
<maintenance-object-dialog
.hass=${this.hass}
.objects=${this._objects}
@object-saved=${this._onDialogEvent}
@object-saved=${this._onObjectSaved}
@object-replaced=${this._onObjectReplaced}
></maintenance-object-dialog>
<maintenance-task-dialog
.hass=${this.hass}
.checklistsEnabled=${this._features.checklists}
.scheduleTimeEnabled=${this._features.schedule_time}
.completionActionsEnabled=${this._features.completion_actions}
.adaptiveFeature=${this._features.adaptive}
.seasonalFeature=${this._features.seasonal}
.environmentalFeature=${this._features.environmental}
.defaultWarningDays=${this._defaultWarningDays}
@task-saved=${this._onDialogEvent}
></maintenance-task-dialog>
@@ -3313,6 +3374,18 @@ export class MaintenanceSupporterPanel extends LitElement {
const archivedCount = this._objects.reduce(
(n, o) => n + o.tasks.filter((tk) => tk.archived).length, 0,
);
// The people someone can filter by: everyone responsible for a task, plus
// the person a saved view narrows to — otherwise that view leaves the
// select blank while it filters.
const filterPeople = new Set<string>();
for (const o of this._objects) {
for (const tk of o.tasks) if (tk.responsible_user_id) filterPeople.add(tk.responsible_user_id);
}
if (this._filterUser && this._filterUser !== "current_user") filterPeople.add(this._filterUser);
const personOptions = [...filterPeople]
.map((id) => ({ id, name: this._userService?.getUserName(id) ?? null }))
.filter((p) => p.name !== null || p.id === this._filterUser)
.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
// Filters actively narrowing the list — shown on the collapsed toggle so
// "why is my list short?" has a visible answer even with filters hidden.
@@ -3381,7 +3454,8 @@ export class MaintenanceSupporterPanel extends LitElement {
}}
>
<option value="">${t("all_users", L)}</option>
<option value="current_user">${t("my_tasks", L)}</option>
<option value="current_user" ?selected=${this._filterUser === "current_user"}>${t("my_tasks", L)}</option>
${personOptions.map((p) => html`<option value=${p.id} ?selected=${this._filterUser === p.id}>${p.name ?? p.id}</option>`)}
</select>
</label>
${this._allLabels.length > 0 ? html`
@@ -3962,7 +4036,11 @@ export class MaintenanceSupporterPanel extends LitElement {
<td>${row.object_name || "—"}</td>
<td>${row.stock !== null ? formatQty(row.stock, row.unit, L) : "—"}</td>
<td>${row.reorder_threshold ?? "—"}</td>
<td>${row.cost != null ? formatCost(row.cost, currency, L) : "—"}</td>
<td>${row.cost != null
? row.package_size
? `${formatCost(row.cost, currency, L)} / ${formatQty(row.package_size, row.unit ?? undefined, L)}`
: formatCost(row.cost, currency, L)
: "—"}</td>
<td>${row.storage_location || "—"}</td>
<td>
${row.consumers.length === 0
@@ -4157,9 +4235,9 @@ export class MaintenanceSupporterPanel extends LitElement {
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); }}>
<ha-icon-button class="obj-table-qr" title="${t("qr_code", L)}" @click=${(e: Event) => { e.stopPropagation(); this._openQrForObject(obj.entry_id, o.name); }}>
<ha-icon icon="mdi:qrcode"></ha-icon>
</mwc-icon-button>
</ha-icon-button>
</td>`;
default:
return html`<td></td>`;
@@ -4204,12 +4282,12 @@ export class MaintenanceSupporterPanel extends LitElement {
<div class="group-card-head">
<div class="group-card-name">${group.name}</div>
<div class="group-card-actions">
<mwc-icon-button title="${t("edit", L)}" @click=${() => this._openGroupEdit(gid)}>
<ha-icon-button title="${t("edit", L)}" @click=${() => this._openGroupEdit(gid)}>
<ha-svg-icon path="M20.71 7.04c.39-.39.39-1.04 0-1.41l-2.34-2.34c-.37-.39-1.02-.39-1.41 0l-1.84 1.83 3.75 3.75M3 17.25V21h3.75L17.81 9.93l-3.75-3.75L3 17.25z"></ha-svg-icon>
</mwc-icon-button>
<mwc-icon-button title="${t("delete", L)}" @click=${() => this._deleteGroup(gid, group.name)}>
</ha-icon-button>
<ha-icon-button title="${t("delete", L)}" @click=${() => this._deleteGroup(gid, group.name)}>
<ha-svg-icon path="M19 4h-3.5l-1-1h-5l-1 1H5v2h14M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6v12z"></ha-svg-icon>
</mwc-icon-button>
</ha-icon-button>
</div>
</div>
${group.description ? html`<div class="group-card-desc">${group.description}</div>` : nothing}
@@ -4416,13 +4494,13 @@ export class MaintenanceSupporterPanel extends LitElement {
}
return html`
<span class="row-actions">
<mwc-icon-button class="btn-complete" title="${t("complete", L)}" @click=${(e: Event) => { e.stopPropagation(); onComplete(); }}>
<ha-icon-button class="btn-complete" title="${t("complete", L)}" @click=${(e: Event) => { e.stopPropagation(); onComplete(); }}>
<ha-icon icon="mdi:check"></ha-icon>
</mwc-icon-button>
</ha-icon-button>
${allowSkip ? html`
<mwc-icon-button class="btn-skip" title="${t("skip", L)}" .disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip?.(); }}>
<ha-icon-button class="btn-skip" title="${t("skip", L)}" .disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip?.(); }}>
<ha-icon icon="mdi:skip-next"></ha-icon>
</mwc-icon-button>` : nothing}
</ha-icon-button>` : nothing}
</span>`;
}
@@ -4480,7 +4558,7 @@ export class MaintenanceSupporterPanel extends LitElement {
<div class="popup-menu-item" @click=${() => { this._closeObjMenu(); this._duplicateObject(obj.entry_id); }}>${t("duplicate", L)}</div>
${!o.archived ? html`
<div class="popup-menu-item" @click=${() => { this._closeObjMenu(); this._togglePauseObject(obj.entry_id, !!o.paused); }}>${o.paused ? t("resume_object", L) : t("pause_object", L)}</div>
<div class="popup-menu-item" @click=${() => { this._closeObjMenu(); this._replaceObject(obj.entry_id, o.name); }}>${t("replace_object", L)}</div>
<div class="popup-menu-item" @click=${() => { this._closeObjMenu(); this._replaceObject(obj.entry_id, o); }}>${t("replace_object", L)}</div>
` : nothing}
<div class="popup-menu-item" @click=${() => { this._closeObjMenu(); this._toggleArchiveObject(obj.entry_id, !!o.archived); }}>${o.archived ? t("unarchive_object", L) : t("archive_object", L)}</div>
<div class="popup-menu-divider"></div>
@@ -4503,6 +4581,8 @@ export class MaintenanceSupporterPanel extends LitElement {
? html`<p class="meta">${[o.manufacturer, o.model].filter(Boolean).join(" ")}</p>`
: nothing}
${o.serial_number ? html`<p class="meta">${t("serial_number_label", L)}: ${o.serial_number}</p>` : nothing}
${this._renderLineageLink(o.predecessor_entry_id, t("object_replaces", L))}
${this._renderLineageLink(o.replaced_by_entry_id, t("object_replaced_by", L))}
${o.area_id
? html`<p class="meta">${t("area", L)}:
<a href="#" class="object-area-link" @click=${(e: Event) => { e.preventDefault(); this._showArea(o.area_id!); }}
@@ -4950,6 +5030,7 @@ export class MaintenanceSupporterPanel extends LitElement {
openComplete: (tk) => this._openCompleteDialog(entryId, taskId, tk.name, this._features.checklists ? tk.checklist : undefined, this._features.adaptive && !!tk.adaptive_config?.enabled),
promptSkip: () => this._promptSkipTask(entryId, taskId),
toggleArchive: (archived) => this._toggleArchiveTask(entryId, taskId, archived),
togglePause: (paused) => this._togglePauseTask(entryId, taskId, paused),
openQr: (taskName) => this._openQrForTask(entryId, taskId, obj?.object.name || "", taskName),
duplicateTask: () => this._duplicateTask(entryId, taskId),
moveTask: () => this._moveTask(entryId, taskId),