257 files
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user