393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
@@ -7,10 +7,10 @@ import { objectRef, parseRef, renderRefChip, taskRef, type HasRef } from "./help
import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
import { isStaleBundle } from "./helpers/bundle-version";
import { customElement, property, state } from "lit/decorators.js";
import { syncLocaleFromHass, sharedStyles, STATUS_COLORS, STATUS_ICONS, currencySymbolOf, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setProfilePrefs, langOf, formatCost, syncCurrencyDecimals} from "./styles";
import { syncLocaleFromHass, sharedStyles, STATUS_COLORS, currencySymbolOf, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setProfilePrefs, langOf, formatCost, formatQty, syncCurrencyDecimals} from "./styles";
import { OVERVIEW_TABS, type OverviewTab } from "./helpers/overview-tabs";
import { LS_KEYS, lsGet, lsSet } from "./helpers/storage-keys";
import { openHtmlInNewTab, openSignedDocument, signApiPath } from "./helpers/document-url";
import { openHtmlInNewTab, openSignedDocument, preopenTab, signApiPath } from "./helpers/document-url";
import { readObjectsCache, writeObjectsCache } from "./helpers/objects-cache";
import { hydrateObjects } from "./helpers/hydrate-objects";
import { daysProgress } from "./helpers/interval";
@@ -21,9 +21,11 @@ import { downloadTextFile } from "./helpers/download";
import { renderNotesMarkdown } from "./helpers/notes-markdown";
import { buildTaskWorksheetHtml, type WorksheetExcerpt, type WorksheetLabels } from "./helpers/worksheet";
import { describePartLink } from "./helpers/shared-parts";
import { docDisplayName } from "./helpers/document-categories";
import { effectivePhase } from "./helpers/phases";
import { renderEventTitles } from "./helpers/event-titles";
import { recommendationReason, type HomeProfile, type TemplateRecommendation } from "./helpers/home-profile";
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
@@ -119,10 +121,14 @@ import { renderPersonChip, type PersonDisplay } from "./helpers/person";
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 { invalidateSettingsCache, parseSettings, type SettingsWire } from "./helpers/settings-cache";
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 { runWs } from "./helpers/ws-run";
import { bulkResultMessage, runWs, runWsEach } from "./helpers/ws-run";
import { STATUS_ORDER, statusRank } from "./status-constants";
type View = "overview" | "object" | "task" | "all_objects" | "all_parts";
@@ -161,8 +167,10 @@ type GroupByMode = "none" | "area" | "group" | "user" | "object";
const SORT_MODES: readonly SortMode[] = ["due_date", "object", "type", "task_name", "area", "assigned_user", "group"];
const GROUP_BY_MODES: readonly GroupByMode[] = ["none", "area", "group", "user", "object"];
// (OVERVIEW_TABS: imported from helpers/overview-tabs.ts.)
/** The dashboard list's status filter options (the <select> in the toolbar). */
const STATUS_FILTERS: readonly string[] = ["overdue", "due_soon", "triggered", "ok"];
/** The dashboard list's status filter options (the <select> in the toolbar)
* — in the urgency order every list sorts by (it offered due soon before
* triggered, the one copy out of line; DRY audit 2026-09-26). */
const STATUS_FILTERS: readonly string[] = STATUS_ORDER;
/** #179: the object page's collapsible sections — the ids saved under
* LS_KEYS.objectSections and accepted by the `?section=` deep link. */
type ObjectSection = "tasks" | "documents" | "parts" | "history";
@@ -363,7 +371,8 @@ export class MaintenanceSupporterPanel extends LitElement {
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 }> = [];
@state() private _templates: Array<{ id: string; name: string; category: string; tasks: unknown[]; disabled?: boolean } & TemplateRecommendation> = [];
@state() private _homeProfile: HomeProfile | null = null;
@state() private _templateCategories: Record<string, { icon?: string; [k: string]: unknown }> = {};
@state() private _templateBusy = false;
// v1.5.0: Calendar tab state
@@ -387,7 +396,9 @@ export class MaintenanceSupporterPanel extends LitElement {
* - admins (incl. the owner) always see the full panel
* - a non-admin sees the full panel only when operator-write delegation is
* enabled AND their ID is in the `admin_panel_user_ids` allowlist
* - everyone else sees operator mode (Complete / Skip only)
* - everyone else sees operator mode (the household actions only:
* Complete / Skip / Reset / Postpone / Snooze — helpers/permissions
* HOUSEHOLD_ACTIONS, read tier on the server)
*
* Delegation defaults OFF, so out of the box every non-admin is read-only.
* The switch + allowlist are managed by an admin under Settings → Panel
@@ -395,10 +406,11 @@ export class MaintenanceSupporterPanel extends LitElement {
* server mirrors this exact rule in helpers/permissions.user_may_write.
*/
private get _isOperator(): boolean {
const u = this.hass?.user;
if (!u) return true; // pre-hass state: render safe default
if (u.is_admin) return false;
return !(this._operatorWriteEnabled && this._adminPanelUserIds.includes(u.id));
// No user yet (pre-hass state) → read-only, the safe default.
return !canWrite(this.hass?.user, {
operatorWriteEnabled: this._operatorWriteEnabled,
operatorIds: this._adminPanelUserIds,
});
}
private _popstateHandler = (e: PopStateEvent) => this._onPopState(e);
@@ -760,25 +772,17 @@ export class MaintenanceSupporterPanel extends LitElement {
if (budgetResult) { this._budget = budgetResult as BudgetStatus; syncCurrencyDecimals(this._budget); }
if (groupsResult) this._groups = (groupsResult as { groups: Record<string, MaintenanceGroup> }).groups || {};
if (settingsResult) {
const sr = settingsResult as {
features: AdvancedFeatures;
admin_panel_user_ids?: string[];
operator_write_enabled?: boolean;
general?: { default_warning_days?: number; row_action_style?: string; row_action_notice_pending?: boolean; ref_numbers_in_lists?: boolean };
objects_table_columns?: string[];
};
this._features = sr.features;
this._adminPanelUserIds = sr.admin_panel_user_ids || [];
this._operatorWriteEnabled = sr.operator_write_enabled ?? false;
const dwd = sr.general?.default_warning_days;
if (typeof dwd === "number" && dwd >= 0 && dwd <= 365) {
this._defaultWarningDays = dwd;
}
const ras = sr.general?.row_action_style;
this._rowActionStyle = ras === "icons" || ras === "buttons" ? ras : "buttons_compact";
this._rowActionNotice = sr.general?.row_action_notice_pending === true;
this._refsInLists = sr.general?.ref_numbers_in_lists === true;
this._objectsTableColumns = sanitizeColumns(sr.objects_table_columns);
// The one settings parser (helpers/settings-cache) — the panel kept its
// own copy of the field rules (DRY audit 2026-09-26).
const s = parseSettings(settingsResult as SettingsWire);
this._features = s.features;
this._adminPanelUserIds = [...s.access.operatorIds];
this._operatorWriteEnabled = s.access.operatorWriteEnabled;
this._defaultWarningDays = s.defaultWarningDays;
this._rowActionStyle = s.rowActionStyle;
this._rowActionNotice = s.rowActionNoticePending;
this._refsInLists = s.refsInLists;
this._objectsTableColumns = sanitizeColumns(s.objectsTableColumns);
}
// Fetch mini-sparkline data for overview (non-blocking)
@@ -993,8 +997,17 @@ export class MaintenanceSupporterPanel extends LitElement {
}
this._showTask(entryId, taskId);
if (action === "complete") {
// The printed "Complete" QR IS the scan (docs: proof of presence) —
// without via_tag_scan a require_tag_scan task refused the very
// completion its own sticker opened (bug audit 2026-09-26).
requestAnimationFrame(() => {
this._openCompleteDialog(entryId, taskId, task.name, this._features.checklists ? task.checklist : undefined, this._features.adaptive && !!task.adaptive_config?.enabled);
this._openCompleteDialog(entryId, taskId, task.name, this._features.checklists ? task.checklist : undefined, this._features.adaptive && !!task.adaptive_config?.enabled, { viaTagScan: true });
});
} else if (action === "skip") {
// The settings view prints "Skip" QR codes too; the scan used to
// land on the plain task page with no skip prompt.
requestAnimationFrame(() => {
if (task.allow_skip !== false) void this._promptSkipTask(entryId, taskId);
});
} else if (action === "quick_complete") {
// v1.3.0: silent complete using pre-configured defaults; falls back
@@ -1014,9 +1027,17 @@ export class MaintenanceSupporterPanel extends LitElement {
return type === "counter" || type === "state_change";
}
/** Newest request per entity — quick range-chip clicks (7 → 30 → 90) put
* several fetches in flight, and a slower OLDER answer landed last and
* showed the wrong window under the new chip (bug audit 2026-09-26). */
private _detailStatsSeq = new Map<string, number>();
private async _fetchDetailStats(entityId: string, isCounter: boolean): Promise<void> {
if (!this._statsService) return;
const seq = (this._detailStatsSeq.get(entityId) ?? 0) + 1;
this._detailStatsSeq.set(entityId, seq);
const points = await this._statsService.getDetailStats(entityId, isCounter, this._chartRangeDays);
if (this._detailStatsSeq.get(entityId) !== seq) return;
const updated = new Map(this._detailStatsData);
updated.set(entityId, points);
this._detailStatsData = updated;
@@ -1185,6 +1206,7 @@ export class MaintenanceSupporterPanel extends LitElement {
status: task.status,
days_until_due: task.days_until_due ?? null,
next_due: task.next_due ?? null,
next_event_titles: task.next_event_titles ?? [],
trigger_active: task.trigger_active,
trigger_current_value: task.trigger_current_value ?? null,
trigger_current_delta: task.trigger_current_delta ?? null,
@@ -1208,8 +1230,7 @@ export class MaintenanceSupporterPanel extends LitElement {
});
}
}
const statusOrder: Record<string, number> = { overdue: 0, triggered: 1, due_soon: 2, ok: 3 };
const byStatus = (a: TaskRow, b: TaskRow) => (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9);
const byStatus = (a: TaskRow, b: TaskRow) => statusRank(a.status) - statusRank(b.status);
const byDays = (a: TaskRow, b: TaskRow) => (a.days_until_due ?? 99999) - (b.days_until_due ?? 99999);
const byDue = (a: TaskRow, b: TaskRow) => byStatus(a, b) || byDays(a, b);
const areaName = (r: TaskRow) =>
@@ -1552,7 +1573,7 @@ export class MaintenanceSupporterPanel extends LitElement {
this._toastUndo = null;
this._toastActionLabel = "";
this._toastMessage = msg;
this._toastTimer = setTimeout(() => { this._toastMessage = ""; this._toastTimer = null; }, 4000);
this._toastTimer = setTimeout(() => { this._toastMessage = ""; this._toastTimer = null; }, TOAST_MS);
}
/** A toast with an action button (label defaults to Undo). Used for
@@ -1571,7 +1592,7 @@ export class MaintenanceSupporterPanel extends LitElement {
this._toastUndo = undo;
this._toastTimer = setTimeout(() => {
this._toastMessage = ""; this._toastUndo = null; this._toastTimer = null;
}, 7000);
}, ACTION_TOAST_MS);
}
private _runToastUndo(): void {
@@ -1747,7 +1768,7 @@ export class MaintenanceSupporterPanel extends LitElement {
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 || "",
label: docDisplayName(d), sub: d.object_name || "", snippet: d.snippet || "",
score: d.score, icon: d.kind === "weblink" ? "mdi:link-variant" : "mdi:file-document-outline",
});
}
@@ -1886,23 +1907,25 @@ export class MaintenanceSupporterPanel extends LitElement {
// --- Suggested setups (integration signatures, v2.28) ---
private async _setupBatteryFleet(): Promise<void> {
try {
const res = await this.hass.connection.sendMessagePromise<{ entry_id: string; task_id?: string }>({
type: "maintenance_supporter/battery_fleet/setup",
language: this.hass.language || "en",
});
this._batteryFleetSetupAvailable = false;
await this._loadData();
// Jump to the fleet task so the user lands on its battery detail section.
const obj = this._objects.find((o) => o.entry_id === res.entry_id);
const tk = obj?.tasks.find((t2) => t2.id === res.task_id) || obj?.tasks[0];
if (obj && tk) {
this._showTask(obj.entry_id, tk.id);
}
this._showToast(t("battery_fleet_setup_done", this._lang));
} catch (e) {
this._showToast(describeWsError(e, this._lang));
const res = await runWs<{ entry_id: string; task_id?: string }>(
this,
{ type: "maintenance_supporter/battery_fleet/setup", language: this.hass.language || "en" },
{
reload: async () => {
this._batteryFleetSetupAvailable = false;
await this._loadData();
},
onError: (m) => this._showToast(m),
},
);
if (res === undefined) return;
// Jump to the fleet task so the user lands on its battery detail section.
const obj = this._objects.find((o) => o.entry_id === res?.entry_id);
const tk = obj?.tasks.find((t2) => t2.id === res?.task_id) || obj?.tasks[0];
if (obj && tk) {
this._showTask(obj.entry_id, tk.id);
}
this._showToast(t("battery_fleet_setup_done", this._lang));
}
private _openSuggestedSetups(): void {
@@ -1920,37 +1943,39 @@ export class MaintenanceSupporterPanel extends LitElement {
private async _openTemplateGallery(): Promise<void> {
this._templateGalleryOpen = true;
if (this._templates.length > 0) return;
try {
const res = await this.hass.connection.sendMessagePromise<{
categories: Record<string, { icon?: string }>;
templates: Array<{ id: string; name: string; category: string; tasks: unknown[]; disabled?: boolean }>;
}>({ type: "maintenance_supporter/templates", language: this._lang });
this._templateCategories = res.categories || {};
// v2.21: admin-hidden templates stay out of the gallery.
this._templates = (res.templates || []).filter((tpl) => !tpl.disabled);
} catch {
this._showToast(t("action_error", this._lang));
}
// Refetched on every open (the last list shows meanwhile): the home
// profile behind the recommendations can change in Settings. A refusal
// toasts the server's reason, like every _runAction path (DRY audit
// 2026-09-26).
const res = await runWs<{
categories: Record<string, { icon?: string }>;
templates: Array<{ id: string; name: string; category: string; tasks: unknown[]; disabled?: boolean } & TemplateRecommendation>;
profile?: HomeProfile;
}>(this, { type: "maintenance_supporter/templates", language: this._lang }, { onError: (m) => this._showToast(m) });
if (res === undefined) return;
this._templateCategories = res?.categories || {};
// v2.21: admin-hidden templates stay out of the gallery.
this._templates = (res?.templates || []).filter((tpl) => !tpl.disabled);
this._homeProfile = res?.profile ?? null;
}
private async _createFromTemplate(templateId: string): Promise<void> {
this._templateBusy = true;
try {
const res = await this.hass.connection.sendMessagePromise<{ entry_id?: string }>({
type: "maintenance_supporter/object/from_template",
language: this._lang,
template_id: templateId,
});
this._templateGalleryOpen = false;
await this._loadData();
this._showToast(t("template_created", this._lang));
if (res?.entry_id) this._showObject(res.entry_id);
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._templateBusy = false;
}
// e.g. "Limit reached" / a create_failed reason instead of "Action failed".
const res = await runWs<{ entry_id?: string }>(
this,
{ type: "maintenance_supporter/object/from_template", language: this._lang, template_id: templateId },
{
busy: (b) => { this._templateBusy = b; },
reload: async () => {
this._templateGalleryOpen = false;
await this._loadData();
},
successToast: t("template_created", this._lang),
onSuccess: (m) => this._showToast(m),
onError: (m) => this._showToast(m),
},
);
if (res?.entry_id) this._showObject(res.entry_id);
}
private _categoryName(catId: string): string {
@@ -1959,15 +1984,44 @@ export class MaintenanceSupporterPanel extends LitElement {
return cat[`name_${this._lang}`] || cat["name_en"] || catId;
}
private _renderTemplateCard(tpl: (typeof this._templates)[number], withReasons = false) {
const L = this._lang;
const country = this._homeProfile?.country ?? null;
return html`
<button class="template-card ${tpl.dwelling_mismatch ? "not-typical" : ""}" .disabled=${this._templateBusy}
title=${tpl.dwelling_mismatch ? t("templates_not_typical", L) : ""}
@click=${() => this._createFromTemplate(tpl.id)}>
<span class="template-card-name">${tpl.name}</span>
<span class="template-card-count">${tpl.tasks.length === 1
? t("templates_task_count_one", L)
: t("templates_task_count", L).replace("{n}", String(tpl.tasks.length))}</span>
${withReasons && tpl.reasons?.length
? html`<span class="template-card-reasons">
${tpl.reasons.map((r) => html`<span class="template-card-reason">${recommendationReason(r, L, country)}</span>`)}
</span>`
: nothing}
${tpl.set_up
? html`<span class="template-card-setup"><ha-icon icon="mdi:check-circle-outline"></ha-icon>${t("templates_set_up", L)}</span>`
: nothing}
</button>
`;
}
private _renderTemplateGallery() {
if (!this._templateGalleryOpen) return nothing;
const L = this._lang;
// Group templates by category, preserving category declaration order.
// v2.93: templates untypical for the home's dwelling move to the end of
// their category (still available — never hidden).
const byCat = new Map<string, typeof this._templates>();
for (const tpl of this._templates) {
if (!byCat.has(tpl.category)) byCat.set(tpl.category, []);
byCat.get(tpl.category)!.push(tpl);
}
for (const tpls of byCat.values()) tpls.sort((a, b) => Number(!!a.dwelling_mismatch) - Number(!!b.dwelling_mismatch));
// 2.94: a template the home already uses is not recommended again (the
// server says so too; kept here so an older server answer can't list it).
const recommended = this._templates.filter((tpl) => tpl.recommended && !tpl.set_up);
return html`
<div class="palette-backdrop" @click=${() => { this._templateGalleryOpen = false; }}>
<div class="template-gallery" @click=${(e: Event) => e.stopPropagation()}>
@@ -1977,6 +2031,21 @@ export class MaintenanceSupporterPanel extends LitElement {
@click=${() => { this._templateGalleryOpen = false; }}></ha-icon-button>
</div>
<div class="template-gallery-body">
<div class="template-legal">
<ha-icon icon="mdi:scale-balance"></ha-icon>
<span>${t("templates_legal_hint", L)}</span>
</div>
${recommended.length > 0
? html`
<div class="template-cat recommended">
<div class="template-cat-head">
<ha-icon icon="mdi:home-heart"></ha-icon>
${t("templates_recommended_title", L)}
</div>
<div class="template-cat-hint">${t("templates_recommended_hint", L)}</div>
<div class="template-grid">${recommended.map((tpl) => this._renderTemplateCard(tpl, true))}</div>
</div>`
: nothing}
${this._templates.length === 0
? html`<div class="palette-empty">${t("loading", L)}…</div>`
: [...byCat.entries()].map(([catId, tpls]) => html`
@@ -1985,15 +2054,7 @@ export class MaintenanceSupporterPanel extends LitElement {
<ha-icon icon="${(this._templateCategories[catId]?.icon as string) || "mdi:folder-outline"}"></ha-icon>
${this._categoryName(catId)}
</div>
<div class="template-grid">
${tpls.map((tpl) => html`
<button class="template-card" .disabled=${this._templateBusy}
@click=${() => this._createFromTemplate(tpl.id)}>
<span class="template-card-name">${tpl.name}</span>
<span class="template-card-count">${t("templates_task_count", L).replace("{n}", String(tpl.tasks.length))}</span>
</button>
`)}
</div>
<div class="template-grid">${tpls.map((tpl) => this._renderTemplateCard(tpl))}</div>
</div>
`)}
</div>
@@ -2027,30 +2088,47 @@ export class MaintenanceSupporterPanel extends LitElement {
this._bulkSelected = allSelected ? new Set() : new Set(keys);
}
/** Run one WS message per selected row (the endpoints are per-task); reports
* how many succeeded and refreshes once at the end. */
private async _runBulk(
/** THE bulk loop of both selection modes (task rows, objects): one WS call
* per item (helpers/ws-run runWsEach — the endpoints are per item), ONE
* refresh at the end, and one toast with the successful count plus how
* many failed and why. The task and the object loop were identical
* copies that swallowed every reason (DRY audit 2026-09-26). The undo
* receives the items that went through. */
private async _runBulkItems<K>(
items: K[],
build: (item: K) => Record<string, unknown>,
doneMsg: (n: number) => string,
endSelection: () => void,
undo?: (done: K[]) => void,
): Promise<void> {
if (items.length === 0) return;
this._actionLoading = true;
const { done, failed } = await runWsEach(this, items, build);
this._actionLoading = false;
endSelection();
await this._loadData();
const msg = bulkResultMessage(doneMsg(done.length), failed, this._lang);
if (undo && done.length > 0) this._showUndoToast(msg, () => undo(done));
else this._showToast(msg);
}
/** One WS message per selected task row. */
private _runBulk(
rows: TaskRow[],
build: (row: TaskRow) => Record<string, unknown>,
doneMsg: (n: number) => string,
undo?: () => void,
undo?: (done: TaskRow[]) => void,
): Promise<void> {
const selected = rows.filter((r) => this._bulkSelected.has(this._bulkKey(r)));
if (selected.length === 0) return;
this._actionLoading = true;
let ok = 0;
for (const row of selected) {
try {
await this.hass.connection.sendMessagePromise(build(row));
ok++;
} catch { /* keep going; report the successful count */ }
}
this._actionLoading = false;
this._bulkSelected = new Set();
this._bulkMode = false;
await this._loadData();
if (undo && ok > 0) this._showUndoToast(doneMsg(ok), undo);
else this._showToast(doneMsg(ok));
return this._runBulkItems(
rows.filter((r) => this._bulkSelected.has(this._bulkKey(r))),
build,
doneMsg,
() => {
this._bulkSelected = new Set();
this._bulkMode = false;
},
undo,
);
}
/** #188: move every selected task to one object (the single-task move's
@@ -2098,24 +2176,18 @@ export class MaintenanceSupporterPanel extends LitElement {
}
/** One WS call per selected object, sequentially (config entries are
* removed one at a time on the backend); the successful count is reported. */
private async _runObjBulk(type: string, doneMsg: (n: number) => string, undo?: (ids: string[]) => Promise<void>): Promise<void> {
const ids = [...this._objBulkSelected];
if (ids.length === 0) return;
this._actionLoading = true;
const done: string[] = [];
for (const id of ids) {
try {
await this.hass.connection.sendMessagePromise({ type, entry_id: id });
done.push(id);
} catch { /* keep going; report the successful count */ }
}
this._actionLoading = false;
this._objBulkSelected = new Set();
this._objBulkMode = false;
await this._loadData();
if (undo && done.length > 0) this._showUndoToast(doneMsg(done.length), () => { void undo(done); });
else this._showToast(doneMsg(done.length));
* removed one at a time on the backend). */
private _runObjBulk(type: string, doneMsg: (n: number) => string, undo?: (ids: string[]) => Promise<void>): Promise<void> {
return this._runBulkItems(
[...this._objBulkSelected],
(id) => ({ type, entry_id: id }),
doneMsg,
() => {
this._objBulkSelected = new Set();
this._objBulkMode = false;
},
undo ? (done) => { void undo(done); } : undefined,
);
}
private async _objBulkDelete(): Promise<void> {
@@ -2178,15 +2250,13 @@ export class MaintenanceSupporterPanel extends LitElement {
}
private _bulkArchive(rows: TaskRow[]): void {
// Capture the selection so the undo can unarchive exactly those.
const keys = rows.filter((r) => this._bulkSelected.has(this._bulkKey(r)))
.map((r) => ({ entry_id: r.entry_id, task_id: r.task_id }));
void this._runBulk(
rows,
(row) => ({ type: "maintenance_supporter/task/archive", entry_id: row.entry_id, task_id: row.task_id }),
(n) => t("bulk_archived", this._lang).replace("{n}", String(n)),
async () => {
for (const k of keys) {
// The undo unarchives exactly the rows that were archived.
async (done) => {
for (const k of done) {
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/unarchive", entry_id: k.entry_id, task_id: k.task_id,
@@ -2209,25 +2279,22 @@ export class MaintenanceSupporterPanel extends LitElement {
msg: Record<string, unknown>,
opts?: { successToast?: string },
): Promise<T | null> {
this._actionLoading = true;
try {
const res = await this.hass.connection.sendMessagePromise<T>(msg);
await this._loadData();
if (opts?.successToast) this._showToast(opts.successToast);
return (res ?? {}) as T;
} catch (e) {
this._showToast(describeWsError(e, this._lang));
return null;
} finally {
this._actionLoading = false;
}
// helpers/ws-run runWs bound to the panel's busy flag, reload and toast.
const res = await runWs<T>(this, msg, {
busy: (b) => { this._actionLoading = b; },
reload: () => this._loadData(),
successToast: opts?.successToast,
onSuccess: (m) => this._showToast(m),
onError: (m) => this._showToast(m),
});
return res === undefined ? null : ((res ?? {}) as T);
}
private async _deleteObject(entryId: string): Promise<void> {
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
const ok = await dlg?.confirm({
title: t("delete", this._lang),
message: t("confirm_delete_object", this._lang),
message: t("delete_object_confirm", this._lang),
confirmText: t("delete", this._lang),
danger: true,
});
@@ -2291,7 +2358,7 @@ export class MaintenanceSupporterPanel extends LitElement {
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
const ok = await dlg?.confirm({
title: t("delete", this._lang),
message: t("confirm_delete_task", this._lang),
message: t("delete_task_confirm", this._lang),
confirmText: t("delete", this._lang),
danger: true,
});
@@ -2557,14 +2624,17 @@ export class MaintenanceSupporterPanel extends LitElement {
private async _handleQuickComplete(
entryId: string, taskId: string, task: MaintenanceTask,
): Promise<void> {
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/quick_complete",
entry_id: entryId, task_id: taskId,
});
this._showToast(t("quick_complete_success", this._lang));
} catch (e: unknown) {
const code = (e as { code?: string })?.code || "";
let failure: { message: string; code: string } | null = null;
const res = await runWs(
this,
{ type: "maintenance_supporter/task/quick_complete", entry_id: entryId, task_id: taskId },
{
fallbackKey: "action_error",
onError: (message, e) => { failure = { message, code: (e as { code?: string })?.code || "" }; },
},
);
if (res === undefined) {
const { message, code } = failure ?? { message: "", code: "" };
// Both refusals mean "the dialog has to collect something": no
// quick-complete defaults configured, or the task demands completion
// details a silent complete cannot supply. The dialog opens as the
@@ -2579,10 +2649,11 @@ export class MaintenanceSupporterPanel extends LitElement {
} else {
// Relay the server's reason (too_early carries the earliest date)
// instead of a generic "action failed".
this._showToast(describeWsError(e, this._lang, t("action_error", this._lang)));
this._showToast(message);
}
return;
}
this._showToast(t("quick_complete_success", this._lang));
// Silent success: the list must reflect the completion right away — the
// subscription delta may lag or be coalesced.
try { await this._loadData(); } catch { /* subscription will sync */ }
@@ -2595,6 +2666,11 @@ export class MaintenanceSupporterPanel extends LitElement {
const obj = this._getObject(entryId);
const task = obj?.tasks.find((tk) => tk.id === taskId);
if (!obj || !task) return;
// The tab opens NOW, in the click's synchronous part — after the QR /
// documents awaits below a window.open is no gesture any more and
// popup blockers swallowed the sheet (bug audit 2026-09-26 #2).
const tab = preopenTab();
let shown = false;
this._actionLoading = true;
try {
const qrBase: Record<string, unknown> = {
@@ -2628,7 +2704,7 @@ export class MaintenanceSupporterPanel extends LitElement {
),
};
excerpt = {
title: manual.title || manual.filename || "Manual",
title: docDisplayName(manual) || "Manual",
startPage: start, endPage: start + count - 1,
// Absolute URL: the sheet lives on a blob: page, where a
// relative /api/... href cannot resolve (caught live).
@@ -2677,8 +2753,10 @@ export class MaintenanceSupporterPanel extends LitElement {
partsLines,
taskRef(obj.object, task),
);
openHtmlInNewTab(html);
openHtmlInNewTab(html, tab);
shown = true;
} finally {
if (!shown) tab?.close();
this._actionLoading = false;
}
}
@@ -2696,24 +2774,47 @@ export class MaintenanceSupporterPanel extends LitElement {
});
}
/** Ticks not yet confirmed by a reload, per task — and the chain that
* sends them one after another. Two quick ticks each built the FULL
* state from the not-yet-reloaded task, so the second send wrote the
* first item back to unticked (bug audit 2026-09-26). Now every send is
* built at send time from the fresh task PLUS the pending ticks, and the
* sends are serialized so the server applies them in click order. */
private _checklistPending = new Map<string, Record<string, boolean>>();
private _checklistChain: Promise<unknown> = Promise.resolve();
/** #73: persist one checklist tick. Sends the FULL current state (the
* server replaces, not merges — idempotent) and reloads so the progress
* header and any other open surface agree. */
private async _setChecklistItem(entryId: string, taskId: string, item: string, done: boolean): Promise<void> {
const obj = this._getObject(entryId);
const task = obj?.tasks.find((x) => x.id === taskId);
if (!task) return;
const state: Record<string, boolean> = {};
// #139: a phased task ticks the checklist of the phase currently due.
const steps = effectivePhase(task)?.checklist ?? (task.checklist || []);
for (const step of steps) {
const current = task.checklist_progress?.[step] ?? false;
state[step] = step === item ? done : current;
}
await this._runAction({
type: "maintenance_supporter/task/checklist_progress",
entry_id: entryId, task_id: taskId, checklist_state: state,
private _setChecklistItem(entryId: string, taskId: string, item: string, done: boolean): Promise<void> {
const key = `${entryId}/${taskId}`;
this._checklistPending.set(key, { ...(this._checklistPending.get(key) ?? {}), [item]: done });
const run = this._checklistChain.then(async () => {
const task = this._getObject(entryId)?.tasks.find((x) => x.id === taskId);
if (!task) return;
const pending = this._checklistPending.get(key) ?? {};
const state: Record<string, boolean> = {};
// #139: a phased task ticks the checklist of the phase currently due.
const steps = effectivePhase(task)?.checklist ?? (task.checklist || []);
for (const step of steps) {
state[step] = step in pending ? pending[step] : (task.checklist_progress?.[step] ?? false);
}
await this._runAction({
type: "maintenance_supporter/task/checklist_progress",
entry_id: entryId, task_id: taskId, checklist_state: state,
});
// _runAction reloaded: the ticks sent so far are in the task now —
// drop exactly those, keep any tick made while this send was out.
const now = this._checklistPending.get(key);
if (now) {
for (const [step, value] of Object.entries(state)) {
if (step in now && now[step] === value) delete now[step];
}
if (Object.keys(now).length === 0) this._checklistPending.delete(key);
}
});
this._checklistChain = run.catch(() => undefined);
return run;
}
private _openCompleteDialog(
@@ -2744,7 +2845,7 @@ export class MaintenanceSupporterPanel extends LitElement {
objects: this._objects,
lang: this._lang,
checklist,
checklistsEnabled: this._features.checklists,
features: this._features,
adaptiveEnabled,
currencySymbol: this._currencySymbol,
viaTagScan: opts?.viaTagScan,
@@ -3048,11 +3149,7 @@ export class MaintenanceSupporterPanel extends LitElement {
* is its own span so narrow/tight rows can drop it (#150: the pill ate a
* third of a phone row) while `title`/`aria-label` keep the text reachable. */
private _statusBadge(archived: boolean, isDone: boolean, status: string) {
const L = this._lang;
const cls = archived ? "archived" : (isDone ? "done" : status);
const iconKey = archived ? "archived" : (isDone ? "completed" : status);
const label = archived ? t("archived", L) : (isDone ? t("completed", L) : t(status, L));
return html`<span class="status-badge ${cls}" role="img" title="${label}" aria-label="${label}"><ha-icon icon="${STATUS_ICONS[iconKey] || "mdi:circle-medium"}"></ha-icon><span class="status-label">${label}</span></span>`;
return renderStatusBadge({ archived, is_done: isDone, status }, this._lang);
}
private _setOverviewTab(tab: OverviewTab): void {
@@ -3111,7 +3208,7 @@ export class MaintenanceSupporterPanel extends LitElement {
<div class="today-row" @click=${() => this._showTask(row.entry_id, row.task_id)}>
<span class="today-dot ${row.trigger_active ? "triggered" : row.status}"></span>
<div class="today-main">
<div class="today-task">${this._listRef(row.entry_id, row.task_id)}${row.task_name}</div>
<div class="today-task">${this._listRef(row.entry_id, row.task_id)}${row.task_name}${renderEventTitles(row.next_event_titles)}</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")}
@@ -3188,10 +3285,7 @@ export class MaintenanceSupporterPanel extends LitElement {
@change=${(e: Event) => { this._filterStatus = (e.target as HTMLSelectElement).value; this._activeViewId = ""; }}
>
<option value="">${t("all", L)}</option>
<option value="overdue">${t("overdue", L)}</option>
<option value="due_soon">${t("due_soon", L)}</option>
<option value="triggered">${t("triggered", L)}</option>
<option value="ok">${t("ok", L)}</option>
${STATUS_FILTERS.map((s) => html`<option value=${s} ?selected=${this._filterStatus === s}>${t(s, L)}</option>`)}
</select>
</label>
<label class="filter-field">
@@ -3600,7 +3694,7 @@ export class MaintenanceSupporterPanel extends LitElement {
<div class="object-card-header">
<span class="object-card-name">${this._objRef(obj.object)}${obj.object.name}</span>
${obj.object.paused
? html`<span class="paused-badge" title="${t("object_paused_badge", L)}${obj.object.paused_until ? ` — ${obj.object.paused_until}` : ""}">
? html`<span class="paused-badge" title="${t("object_paused_badge", L)}${obj.object.paused_until ? ` — ${formatDate(obj.object.paused_until, L)}` : ""}">
<ha-icon icon="mdi:pause-circle-outline"></ha-icon>
</span>`
: nothing}
@@ -3609,7 +3703,9 @@ export class MaintenanceSupporterPanel extends LitElement {
<ha-icon icon="mdi:paperclip"></ha-icon>${obj.object.document_count}
</span>`
: nothing}
<span class="object-card-count">${obj.tasks.length} ${t("tasks_lower", L)}</span>
<span class="object-card-count">${obj.tasks.length === 1
? t("templates_task_count_one", L)
: t("templates_task_count", L).replace("{n}", String(obj.tasks.length))}</span>
</div>
${obj.object.manufacturer || obj.object.model
? html`<div class="object-card-meta">${[obj.object.manufacturer, obj.object.model].filter(Boolean).join(" ")}</div>`
@@ -3776,7 +3872,7 @@ export class MaintenanceSupporterPanel extends LitElement {
: nothing}
</td>
<td>${row.object_name || "—"}</td>
<td>${row.stock !== null ? `${row.stock}${row.unit ? ` ${row.unit}` : ""}` : "—"}</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.storage_location || "—"}</td>
@@ -4002,13 +4098,12 @@ export class MaintenanceSupporterPanel extends LitElement {
private async _deleteGroup(groupId: string, name: string): Promise<void> {
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
const ok = dlg
? await dlg.confirm({
title: t("delete_group", this._lang),
message: t("delete_group_confirm", this._lang).replace("{name}", name),
confirmText: t("delete", this._lang),
})
: confirm(`${t("delete_group_confirm", this._lang).replace("{name}", name)}`);
const ok = await dlg?.confirm({
title: t("delete_group", this._lang),
message: t("delete_group_confirm", this._lang).replace("{name}", name),
confirmText: t("delete", this._lang),
danger: true,
});
if (!ok) return;
await this._runAction({
type: "maintenance_supporter/group/delete",
@@ -4089,7 +4184,7 @@ export class MaintenanceSupporterPanel extends LitElement {
</span>
<span class="row-head">
<span class="cell object-name" @click=${(e: Event) => { e.stopPropagation(); this._showObject(row.entry_id); }}>${row.object_name}</span>
<span class="cell task-name" @click=${() => this._showTask(row.entry_id, row.task_id)}>${this._listRef(row.entry_id, row.task_id)}${row.task_name}</span>
<span class="cell task-name" @click=${() => this._showTask(row.entry_id, row.task_id)}>${this._listRef(row.entry_id, row.task_id)}${row.task_name}${renderEventTitles(row.next_event_titles)}</span>
</span>
<span class="task-sub${hasSub ? '' : ' task-sub-empty'}">
${row.group_names.length > 0 ? html`
@@ -4308,10 +4403,9 @@ export class MaintenanceSupporterPanel extends LitElement {
.then((d) => d?.openCreate(obj.entry_id));
}}>${t("add_first_task", L)}</ha-button>
</div>`
: html`<div class="task-table object-tasks">${[...visibleTasks].sort((a, b) => {
const so: Record<string, number> = { overdue: 0, triggered: 1, due_soon: 2, ok: 3 };
return (so[a.status] ?? 9) - (so[b.status] ?? 9) || (a.days_until_due ?? 99999) - (b.days_until_due ?? 99999);
}).map((task) => html`
: html`<div class="task-table object-tasks">${[...visibleTasks].sort((a, b) =>
statusRank(a.status) - statusRank(b.status) || (a.days_until_due ?? 99999) - (b.days_until_due ?? 99999),
).map((task) => html`
<div class="task-row${!task.enabled ? ' task-disabled' : ''}">
<span class="cell-badges">
${this._statusBadge(!!task.archived, !!task.is_done, task.status)}
@@ -4321,7 +4415,7 @@ export class MaintenanceSupporterPanel extends LitElement {
? html`<span class="doc-badge" title="${task.document_count} ${t("documents", L)}"><ha-icon icon="mdi:paperclip"></ha-icon>${task.document_count}</span>`
: nothing}
</span>
<span class="cell task-name" @click=${() => this._showTask(obj.entry_id, task.id)}>${this._listRef(obj.entry_id, task.id)}${task.name}</span>
<span class="cell task-name" @click=${() => this._showTask(obj.entry_id, task.id)}>${this._listRef(obj.entry_id, task.id)}${task.name}${renderEventTitles(task.next_event_titles)}</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)}>
@@ -4626,7 +4720,8 @@ export class MaintenanceSupporterPanel extends LitElement {
currencySymbol: this._currencySymbol,
setFilter: (f) => { this._historyFilter = f; },
setSearch: (s) => { this._historySearch = s; },
openEdit: (entry) => this._openHistoryEdit(entry),
// Write tier (task/history/update): no pencil for a read-only member.
openEdit: this._isOperator ? undefined : (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.