Files
Home-Assistant/custom_components/maintenance_supporter/frontend-src/maintenance-panel.ts
T

2944 lines
127 KiB
TypeScript

/** Maintenance Supporter Sidebar Panel. */
import { LitElement, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence } from "./styles";
import { daysProgress } from "./helpers/interval";
import { buildObjectReportHtml, type ReportLabels } from "./helpers/report";
import { warrantyStatus } from "./helpers/warranty";
import { OBJECT_COLUMNS, DEFAULT_OBJECTS_TABLE_COLUMNS, sanitizeColumns } from "./helpers/object-columns";
import { downloadTextFile } from "./helpers/download";
import { buildTaskWorksheetHtml, type WorksheetExcerpt, type WorksheetLabels } from "./helpers/worksheet";
import { describeWsError } from "./ws-errors";
import { panelStyles } from "./panel-styles";
import type {
HomeAssistant,
MaintenanceObjectResponse,
MaintenanceTask,
MaintenanceGroup,
StatisticsResponse,
BudgetStatus,
AdvancedFeatures,
TaskRow,
HistoryEntry,
TriggerConfig,
StatisticsPoint,
} from "./types";
import { StatisticsService } from "./statistics-service";
import { UserService } from "./user-service";
import "./components/object-dialog";
import type { MaintenanceObjectDialog } from "./components/object-dialog";
import "./components/documents-section";
import "./components/task-documents";
import "./components/task-dialog";
import type { MaintenanceTaskDialog } from "./components/task-dialog";
import "./components/complete-dialog";
import type { MaintenanceCompleteDialog } from "./components/complete-dialog";
import "./components/qr-dialog";
import type { MaintenanceQrDialog } from "./components/qr-dialog";
// v2.0.0: panel uses the extracted Calendar Card instead of its own
// _renderCalendar() method — single source of truth for the calendar view.
import "./maintenance-calendar-card";
// v2.2.0: in-place edit dialog for past history entries
import "./components/history-edit-dialog";
import type {
MaintenanceHistoryEditDialog,
HistoryEntryDraft,
} from "./components/history-edit-dialog";
import "./components/confirm-dialog";
import type { MaintenanceConfirmDialog } from "./components/confirm-dialog";
import "./components/settings-view";
import "./components/storage-section-card";
import "./components/seasonal-overrides-dialog";
import type { SeasonalOverridesDialog } from "./components/seasonal-overrides-dialog";
import "./components/group-dialog";
import type { MaintenanceGroupDialog } from "./components/group-dialog";
import { type SparklineContext } from "./renderers/sparkline";
import { buildCalendarBuckets, isoDateLocal, type CalendarEvent } from "./helpers/calendar-bucket";
import { renderTriggerProgress, renderMiniSparkline } from "./renderers/progress";
import { type HistoryContext } from "./renderers/history";
import { renderUserBadge, type TaskDetailContext } from "./renderers/task-detail";
// 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";
type View = "overview" | "object" | "task" | "all_objects";
type SortMode = "due_date" | "object" | "type" | "task_name" | "area" | "assigned_user" | "group";
type ObjectSortMode = "alphabetical" | "due_soonest" | "task_count";
type GroupByMode = "none" | "area" | "group" | "user";
// Chart dimension constants for mini sparklines (overview)
@customElement("maintenance-supporter-panel")
export class MaintenanceSupporterPanel extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: false }) public panel: Record<string, unknown> = {};
@state() private _objects: MaintenanceObjectResponse[] = [];
@state() private _stats: StatisticsResponse | null = null;
@state() private _view: View = "overview";
@state() private _selectedEntryId: string | null = null;
@state() private _selectedTaskId: string | null = null;
@state() private _filterStatus = "";
@state() private _filterUser: string | null = null;
@state() private _unsub: (() => void) | null = null;
@state() private _chartRangeDays = (() => {
try {
const v = parseInt(localStorage.getItem("msp-chart-range") || "", 10);
return [7, 30, 90, 365].includes(v) ? v : 30;
} catch {
return 30;
}
})();
@state() private _hideOutliers = (() => {
try { return localStorage.getItem("msp-chart-hide-outliers") === "1"; } catch { return false; }
})();
@state() private _historyFilter: string | null = null;
@state() private _budget: BudgetStatus | null = null;
@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 };
// 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
// is on (default off → every non-admin is read-only, admins unaffected).
@state() private _operatorWriteEnabled = false;
// Default warning_days from the global config entry — used as the initial
// value in the task-create dialog so the Settings → General → "Default
// warning days" choice actually flows through to new tasks.
@state() private _defaultWarningDays = 7;
@state() private _actionLoading = false;
@state() private _moreMenuOpen = false;
@state() private _toastMessage = "";
@state() private _toastUndo: (() => void) | null = null;
private _toastTimer: ReturnType<typeof setTimeout> | null = null;
private _dismissedSuggestions = new Set<string>();
// Dashboard redesign state
@state() private _overviewTab: "today" | "dashboard" | "calendar" | "settings" = (() => {
try {
const v = localStorage.getItem("msp-overview-tab");
return v === "today" || v === "calendar" ? v : "dashboard";
} catch { return "dashboard"; }
})();
@state() private _activeTab: "overview" | "history" = "overview";
@state() private _costDurationToggle: "cost" | "duration" | "both" = "both";
@state() private _historySearch = "";
@state() private _sortMode: SortMode = "due_date";
@state() private _objectSortMode: ObjectSortMode = "alphabetical";
@state() private _groupByMode: GroupByMode = "none";
// (#67): All-Objects view mode (cards|table) + the configurable table
// columns sourced from the global setting (sanitised; defaults until loaded).
@state() private _objectViewMode: "cards" | "table" = "cards";
@state() private _objectsTableColumns: string[] = DEFAULT_OBJECTS_TABLE_COLUMNS;
// v2.10.0: archived tasks/objects are hidden until this toggle is on.
@state() private _showArchived = false;
// v2.15.0: bulk selection on the dashboard task list (multi-select +
// complete/archive). Keys are `${entry_id}:${task_id}`.
@state() private _bulkMode = false;
@state() private _bulkSelected = new Set<string>();
// Virtualized dashboard task table (large installs): only rows in the
// scroll window are in the DOM; spacers keep the scrollbar honest. The
// window is recomputed from `.content` scroll/resize (rAF-throttled).
@state() private _virtStart = 0;
@state() private _virtEnd = 0; // 0 = uninitialized → initial slice
private _virtRowHeight = 53;
private _virtTotalRows = 0;
private _virtScrollAttached = false;
private _virtRaf = 0;
// v2.15.0: collapsed analysis sections on the task-detail overview tab,
// remembered per section across visits.
@state() private _collapsedSections: Set<string> = (() => {
try { return new Set(JSON.parse(localStorage.getItem("msp-collapsed-sections") || "[]")); }
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.
@state() private _paletteOpen = false;
@state() private _paletteQuery = "";
@state() private _paletteActive = 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 _templateCategories: Record<string, { icon?: string; [k: string]: unknown }> = {};
@state() private _templateBusy = false;
// v1.5.0: Calendar tab state
// v2.0.0: window-days + user-filter state moved into the
// <maintenance-supporter-calendar-card> custom element — the panel just
// renders the card and intercepts its ll-custom open-task events.
private _statsService: StatisticsService | null = null;
private _userService: UserService | null = null;
private _dataLoaded = false;
private _lastConnection: unknown = null;
private get _lang(): string {
return this.hass?.language || "en";
}
/**
* Operator mode = read-only end-user mode (hides every create/edit/delete action).
*
* Gating (full panel only when an exception applies):
* - 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)
*
* 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
* Access (either the panel's Settings tab or the HA config flow), and the
* 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));
}
private _popstateHandler = (e: PopStateEvent) => this._onPopState(e);
connectedCallback(): void {
super.connectedCallback();
window.addEventListener("popstate", this._popstateHandler);
window.addEventListener("keydown", this._paletteKeydown);
window.addEventListener("resize", this._onVirtualScroll, { passive: true });
const saved = localStorage.getItem("maintenance_supporter_sort");
if (saved && ["due_date", "object", "type", "task_name", "area", "assigned_user", "group"].includes(saved)) {
this._sortMode = saved as SortMode;
}
const savedObj = localStorage.getItem("maintenance_supporter_object_sort");
if (savedObj && ["alphabetical", "due_soonest", "task_count"].includes(savedObj)) {
this._objectSortMode = savedObj as ObjectSortMode;
}
const savedGroup = localStorage.getItem("maintenance_supporter_groupby");
if (savedGroup && ["none", "area", "group", "user"].includes(savedGroup)) {
this._groupByMode = savedGroup as GroupByMode;
}
const savedView = localStorage.getItem("maintenance_supporter_object_view");
if (savedView === "cards" || savedView === "table") {
this._objectViewMode = savedView;
}
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("popstate", this._popstateHandler);
window.removeEventListener("keydown", this._paletteKeydown);
window.removeEventListener("resize", this._onVirtualScroll);
this.shadowRoot?.querySelector(".content")?.removeEventListener("scroll", this._onVirtualScroll);
this._virtScrollAttached = false;
if (this._virtRaf) cancelAnimationFrame(this._virtRaf);
if (this._unsub) {
this._unsub();
this._unsub = null;
}
this._dataLoaded = false;
this._lastConnection = null;
this._deepLinkHandled = false;
this._statsService?.clearCache();
this._statsService = null;
}
updated(changedProps: Map<string, unknown>): void {
super.updated(changedProps);
// Lazy-load the user's UI language (non-EN tables aren't bundled) and
// re-render once it arrives. EN is bundled, so strings read in English
// until then rather than as raw keys.
const lang = this.hass?.language;
if (lang && !isLocaleLoaded(lang)) {
ensureLocale(lang).then(() => this.requestUpdate());
}
if (changedProps.has("hass") && this.hass) {
if (!this._dataLoaded) {
this._dataLoaded = true;
this._lastConnection = this.hass.connection;
// Seed initial history state so back button returns to overview
history.replaceState({ msp_view: "overview", msp_entry: null, msp_task: null }, "");
this._loadData();
this._subscribe();
} else if (this.hass.connection !== this._lastConnection) {
this._lastConnection = this.hass.connection;
if (this._unsub) {
try { this._unsub(); } catch { /* ignore */ }
this._unsub = null;
}
this._subscribe();
this._loadData();
}
if (!this._statsService) {
this._statsService = new StatisticsService(this.hass);
this._fetchMiniStatsForOverview();
} else {
this._statsService.updateHass(this.hass);
}
if (!this._userService) {
this._userService = new UserService(this.hass);
this._userService.getUsers();
} else {
this._userService.updateHass(this.hass);
}
}
// Virtualized task table: attach the scroll listener once `.content`
// exists (lit keeps the node stable across renders), then reconcile the
// window with the just-rendered DOM (row-height probe + clamps). Only
// sets state when the window actually moved, so this can't loop.
const content = this.shadowRoot?.querySelector(".content");
if (content && !this._virtScrollAttached) {
content.addEventListener("scroll", this._onVirtualScroll, { passive: true });
this._virtScrollAttached = true;
}
this._updateVirtualWindow();
}
/** rAF-throttled scroll/resize handler for the virtualized task table. */
private _onVirtualScroll = (): void => {
if (this._virtRaf) return;
this._virtRaf = requestAnimationFrame(() => {
this._virtRaf = 0;
this._updateVirtualWindow();
});
};
/** Recompute the rendered window of the virtualized task table from the
* live scroll position. No-op unless the virtual table is in the DOM. */
private _updateVirtualWindow(): void {
const content = this.shadowRoot?.querySelector<HTMLElement>(".content");
const table = this.shadowRoot?.querySelector<HTMLElement>(".task-table.virtual");
if (!content || !table) return;
// Probe the real row height from a rendered row (uniform rows; the
// hidden sizer row is height 0 and excluded).
const probe = table.querySelector<HTMLElement>(".task-row:not(.virt-sizer)");
if (probe && probe.offsetHeight > 20) this._virtRowHeight = probe.offsetHeight;
const listTop =
table.getBoundingClientRect().top - content.getBoundingClientRect().top + content.scrollTop;
const w = computeWindow({
scrollTop: content.scrollTop,
viewportHeight: content.clientHeight,
listTop,
rowHeight: this._virtRowHeight,
total: this._virtTotalRows,
});
if (w.start !== this._virtStart || w.end !== this._virtEnd) {
this._virtStart = w.start;
this._virtEnd = w.end;
}
}
private async _loadData(): Promise<void> {
const [objResult, statsResult, budgetResult, groupsResult, settingsResult] = await Promise.all([
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/objects" }).catch(() => null),
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/statistics" }).catch(() => null),
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/budget_status" }).catch(() => null),
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/groups" }).catch(() => null),
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/settings" }).catch(() => null),
]);
if (objResult) this._objects = (objResult as { objects: MaintenanceObjectResponse[] }).objects;
if (statsResult) this._stats = statsResult as StatisticsResponse;
if (budgetResult) this._budget = budgetResult as BudgetStatus;
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 };
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;
}
this._objectsTableColumns = sanitizeColumns(sr.objects_table_columns);
}
// Fetch mini-sparkline data for overview (non-blocking)
this._fetchMiniStatsForOverview();
// Handle deep-link URL parameters (from QR code scan)
this._handleDeepLink();
}
private _deepLinkHandled = false;
private _handleDeepLink(): void {
if (this._deepLinkHandled) return;
const params = new URLSearchParams(window.location.search);
// v1.8.1: ms_action deep links from the dashboard strategy's fire-dom-event
// Empty-state buttons. Cleared from the URL after one fire so refreshing
// the page doesn't re-open it.
//
// v2.3.0 Phase 5/6: also handles open_vacation / open_budget / open_groups
// / open_settings from the section strategies' status banners. Each
// routes to the right tab + scroll target inside the Settings view.
const msAction = params.get("ms_action");
const cleanMsActionUrl = () => {
const cleanUrl = window.location.pathname + window.location.hash;
history.replaceState(history.state, "", cleanUrl);
};
if (msAction === "add_object") {
this._deepLinkHandled = true;
cleanMsActionUrl();
requestAnimationFrame(() => {
this.shadowRoot
?.querySelector<MaintenanceObjectDialog>("maintenance-object-dialog")
?.openCreate();
});
return;
}
if (msAction === "open_vacation"
|| msAction === "open_budget"
|| msAction === "open_groups"
|| msAction === "open_settings") {
this._deepLinkHandled = true;
cleanMsActionUrl();
// 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
// settings-view component reads this on attribute change).
requestAnimationFrame(() => {
const settingsView = this.shadowRoot?.querySelector(
"maintenance-settings-view",
) as (HTMLElement & { scrollToSection?: (s: string) => void }) | null;
const target = msAction.replace("open_", ""); // "vacation" / "budget" / "groups" / "settings"
settingsView?.scrollToSection?.(target);
});
return;
}
const entryId = params.get("entry_id");
if (!entryId) return;
this._deepLinkHandled = true;
const taskId = params.get("task_id");
const action = params.get("action");
// Always clean URL params — they are consumed once
const cleanUrl = window.location.pathname + window.location.hash;
history.replaceState(history.state, "", cleanUrl);
// Validate that the referenced object exists
const obj = this._getObject(entryId);
if (!obj) {
this._showOverview();
return;
}
// Navigate to the right view
if (taskId) {
const task = obj.tasks.find((t) => t.id === taskId);
if (!task) {
this._showObject(entryId);
return;
}
this._showTask(entryId, taskId);
if (action === "complete") {
requestAnimationFrame(() => {
this._openCompleteDialog(entryId, taskId, task.name, this._features.checklists ? task.checklist : undefined, this._features.adaptive && !!task.adaptive_config?.enabled);
});
} else if (action === "quick_complete") {
// v1.3.0: silent complete using pre-configured defaults; falls back
// to the normal dialog if the task has none.
requestAnimationFrame(() => {
this._handleQuickComplete(entryId, taskId, task);
});
}
} else {
this._showObject(entryId);
}
}
private _isCounterEntity(tc: TriggerConfig | null | undefined): boolean {
if (!tc) return false;
const type = tc.type || "threshold";
return type === "counter" || type === "state_change";
}
private async _fetchDetailStats(entityId: string, isCounter: boolean): Promise<void> {
if (!this._statsService) return;
const points = await this._statsService.getDetailStats(entityId, isCounter, this._chartRangeDays);
const updated = new Map(this._detailStatsData);
updated.set(entityId, points);
this._detailStatsData = updated;
}
/** Chart range chips (7/30/90/365 d): persist the choice and refetch the
* open task's stats at the new window. */
private _setChartRange(days: number): void {
if (days === this._chartRangeDays) return;
this._chartRangeDays = days;
try { localStorage.setItem("msp-chart-range", String(days)); } catch { /* private mode */ }
const task = this._selectedEntryId && this._selectedTaskId
? this._getTask(this._selectedEntryId, this._selectedTaskId)
: null;
const entityId = task?.trigger_config?.entity_id;
if (entityId) {
// Drop the stale-range series so the chart shows its loading state.
const updated = new Map(this._detailStatsData);
updated.delete(entityId);
this._detailStatsData = updated;
void this._fetchDetailStats(entityId, this._isCounterEntity(task!.trigger_config));
}
}
private _setHideOutliers(hide: boolean): void {
if (hide === this._hideOutliers) return;
// Outlier filtering is client-side on the already-fetched series, so just
// flip the flag and let renderChart re-filter — no re-fetch needed.
this._hideOutliers = hide;
try { localStorage.setItem("msp-chart-hide-outliers", hide ? "1" : "0"); } catch { /* private mode */ }
}
private async _fetchMiniStatsForOverview(): Promise<void> {
if (!this._statsService) return;
const entities: Array<{ entityId: string; isCounter: boolean }> = [];
for (const obj of this._objects) {
for (const task of obj.tasks) {
const entityId = task.trigger_config?.entity_id;
if (!entityId) continue;
entities.push({ entityId, isCounter: this._isCounterEntity(task.trigger_config) });
}
}
if (entities.length === 0) return;
const batchResult = await this._statsService.getBatchMiniStats(entities);
this._miniStatsData = new Map([...this._miniStatsData, ...batchResult]);
}
private async _subscribe(): Promise<void> {
try {
const unsub = await this.hass.connection.subscribeMessage(
(msg: unknown) => {
const data = msg as { objects: MaintenanceObjectResponse[] };
this._objects = data.objects;
},
{ type: "maintenance_supporter/subscribe" }
);
// If the element was detached while the subscribe was in flight, drop the
// now-orphaned subscription instead of storing it on a dead component.
if (!this.isConnected) {
unsub();
return;
}
this._unsub = unsub;
} catch {
// Subscription failed; fall back to polling
}
}
// --- Data accessors ---
private get _taskRows(): TaskRow[] {
const rows: TaskRow[] = [];
for (const obj of this._objects) {
for (const task of obj.tasks) {
// v2.10.0: archived tasks are hidden unless the toggle reveals them.
if (!this._showArchived && task.archived) continue;
if (this._filterStatus && task.status !== this._filterStatus) continue;
// User filter
if (this._filterUser) {
const userId = this._filterUser === "current_user"
? this._userService?.getCurrentUserId()
: this._filterUser;
if (task.responsible_user_id !== userId) continue;
}
// Collect groups that contain this task
const groupNames: string[] = [];
for (const group of Object.values(this._groups)) {
if (group.task_refs?.some((r) => r.entry_id === obj.entry_id && r.task_id === task.id)) {
groupNames.push(group.name);
}
}
rows.push({
entry_id: obj.entry_id,
task_id: task.id,
object_name: obj.object.name,
task_name: task.name,
type: task.type,
schedule_type: task.schedule_type,
status: task.status,
days_until_due: task.days_until_due ?? null,
next_due: task.next_due ?? null,
trigger_active: task.trigger_active,
trigger_current_value: task.trigger_current_value ?? null,
trigger_current_delta: task.trigger_current_delta ?? null,
trigger_config: task.trigger_config ?? null,
trigger_entity_info: task.trigger_entity_info ?? null,
times_performed: task.times_performed,
total_cost: task.total_cost,
interval_days: task.interval_days ?? null,
interval_unit: task.interval_unit ?? null,
interval_anchor: task.interval_anchor ?? null,
is_done: task.is_done ?? false,
archived: task.archived ?? false,
history: task.history || [],
enabled: task.enabled,
nfc_tag_id: task.nfc_tag_id ?? null,
priority: task.priority ?? "normal",
labels: task.labels ?? [],
area_id: obj.object.area_id ?? null,
responsible_user_id: task.responsible_user_id ?? null,
group_names: groupNames,
});
}
}
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 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) =>
r.area_id ? this.hass?.areas?.[r.area_id]?.name || "" : "";
const userName = (r: TaskRow) =>
r.responsible_user_id ? this._userService?.getUserName(r.responsible_user_id) || "" : "";
const groupName = (r: TaskRow) => r.group_names[0] || "";
const sorts: Record<SortMode, (a: TaskRow, b: TaskRow) => number> = {
due_date: byDue,
object: (a, b) => a.object_name.localeCompare(b.object_name) || byDue(a, b),
type: (a, b) => a.type.localeCompare(b.type) || byDue(a, b),
task_name: (a, b) => a.task_name.localeCompare(b.task_name),
area: (a, b) => {
const an = areaName(a), bn = areaName(b);
// Empty areas at end
if (!an && bn) return 1;
if (an && !bn) return -1;
return an.localeCompare(bn) || byDue(a, b);
},
assigned_user: (a, b) => {
const an = userName(a), bn = userName(b);
if (!an && bn) return 1;
if (an && !bn) return -1;
return an.localeCompare(bn) || byDue(a, b);
},
group: (a, b) => {
const an = groupName(a), bn = groupName(b);
if (!an && bn) return 1;
if (an && !bn) return -1;
return an.localeCompare(bn) || byDue(a, b);
},
};
rows.sort(sorts[this._sortMode]);
return rows;
}
private _getObject(entryId: string): MaintenanceObjectResponse | undefined {
return this._objects.find((o) => o.entry_id === entryId);
}
private _getTask(entryId: string, taskId: string): MaintenanceTask | undefined {
const obj = this._getObject(entryId);
return obj?.tasks.find((t) => t.id === taskId);
}
// --- Navigation ---
/** Push a browser history entry so the back button navigates within the panel. */
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, "");
}
/** Handle browser back/forward button. */
private _onPopState(e: PopStateEvent): void {
const s = e.state as { msp_view?: View; msp_entry?: string; msp_task?: string } | null;
if (!s?.msp_view) {
// No panel state → we're leaving the panel, let HA handle it
return;
}
// Restore view without pushing another history entry
this._view = s.msp_view;
this._selectedEntryId = s.msp_entry || null;
this._selectedTaskId = s.msp_task || null;
this._moreMenuOpen = false;
if (s.msp_view === "task" && s.msp_entry && s.msp_task) {
this._historyFilter = null;
const task = this._getTask(s.msp_entry, s.msp_task);
if (task?.trigger_config?.entity_id) {
this._fetchDetailStats(task.trigger_config.entity_id, this._isCounterEntity(task.trigger_config));
}
}
}
private _showOverview(): void {
this._pushPanelState("overview");
this._view = "overview";
this._selectedEntryId = null;
this._selectedTaskId = null;
this._moreMenuOpen = false;
this._scrollContentToTop();
}
private _showAllObjects(): void {
this._pushPanelState("all_objects");
this._view = "all_objects";
this._selectedEntryId = null;
this._selectedTaskId = null;
this._scrollContentToTop();
}
/** v2.1.0 (Discussion #49 — @byoung79): tap a KPI value to auto-filter
* the task list. Empty string clears the filter (used by "Tasks" KPI). */
private _filterByStatus(status: string): void {
this._filterStatus = status;
// Make sure we're on the dashboard tab so the task list is actually
// visible — pointless to filter on the calendar/settings tabs.
if (this._overviewTab !== "dashboard") {
this._overviewTab = "dashboard";
}
this._scrollContentToTop();
}
/** Reset scroll on the panel's .content container. Used by every
* navigation action so the user always lands at the top of the new
* view, never at a stale scroll position from the previous view.
*
* History: an earlier version targeted .tab-bar via scrollIntoView,
* which worked for the 4 filter KPIs (stay in overview, .tab-bar
* exists) but silently no-op'd for the Objects KPI (switches to the
* all_objects view where .tab-bar isn't rendered). Targeting .content
* directly works regardless of which view is now showing. Plus same
* helper is now called from every navigation entry-point so e.g.
* clicking on a deep object in a long list lands you at the top of
* the object-detail view, not partway through it. */
private _scrollContentToTop(): void {
requestAnimationFrame(() => {
const content = this.shadowRoot?.querySelector(".content");
if (content) content.scrollTo({ top: 0, behavior: "smooth" });
});
}
private _showObject(entryId: string): void {
this._pushPanelState("object", entryId);
this._view = "object";
this._selectedEntryId = entryId;
this._selectedTaskId = null;
this._scrollContentToTop();
}
private _showTask(entryId: string, taskId: string): void {
this._pushPanelState("task", entryId, taskId);
this._view = "task";
this._selectedEntryId = entryId;
this._selectedTaskId = taskId;
this._activeTab = "overview";
this._historyFilter = null;
this._scrollContentToTop();
// Lazy-load statistics for the task's trigger entity
const task = this._getTask(entryId, taskId);
if (task?.trigger_config?.entity_id) {
const entityId = task.trigger_config.entity_id;
const isCounter = this._isCounterEntity(task.trigger_config);
this._fetchDetailStats(entityId, isCounter);
}
}
// --- Toast ---
private _showToast(msg: string): void {
if (this._toastTimer) clearTimeout(this._toastTimer);
this._toastUndo = null;
this._toastMessage = msg;
this._toastTimer = setTimeout(() => { this._toastMessage = ""; this._toastTimer = null; }, 4000);
}
/** A toast with an Undo action — used for reversible actions (archive) that
* run immediately instead of behind a confirm dialog. Longer-lived so the
* user has time to react; the undo callback dismisses it. */
private _showUndoToast(msg: string, undo: () => void): void {
if (this._toastTimer) clearTimeout(this._toastTimer);
this._toastMessage = msg;
this._toastUndo = undo;
this._toastTimer = setTimeout(() => {
this._toastMessage = ""; this._toastUndo = null; this._toastTimer = null;
}, 7000);
}
private _runToastUndo(): void {
const undo = this._toastUndo;
if (this._toastTimer) clearTimeout(this._toastTimer);
this._toastMessage = ""; this._toastUndo = null; this._toastTimer = null;
undo?.();
}
// --- Command palette ("/") ---
private _paletteKeydown = (e: KeyboardEvent): void => {
// "/" opens the palette (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.
if (
e.key === "/"
&& !e.ctrlKey && !e.metaKey && !e.altKey
&& !this._paletteOpen
) {
const target = e.composedPath()[0];
const typing = target instanceof HTMLElement
&& (target.tagName === "INPUT" || target.tagName === "TEXTAREA"
|| target.tagName === "SELECT" || target.isContentEditable);
if (typing) return;
e.preventDefault();
this._openPalette();
return;
}
if (!this._paletteOpen) return;
const results = this._paletteResults;
if (e.key === "Escape") { e.preventDefault(); this._closePalette(); }
else if (e.key === "ArrowDown") { e.preventDefault(); this._paletteActive = Math.min(this._paletteActive + 1, results.length - 1); }
else if (e.key === "ArrowUp") { e.preventDefault(); this._paletteActive = Math.max(this._paletteActive - 1, 0); }
else if (e.key === "Enter") { e.preventDefault(); const r = results[this._paletteActive]; if (r) this._selectPaletteResult(r); }
};
private _openPalette(): void {
this._paletteQuery = "";
this._paletteActive = 0;
this._paletteOpen = true;
this.updateComplete.then(() => {
this.shadowRoot?.querySelector<HTMLInputElement>(".palette-input")?.focus();
});
}
private _closePalette(): void {
this._paletteOpen = false;
this._paletteQuery = "";
}
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 }> = [];
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) });
}
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 labelSub = (task.labels || []).length ? ` #${(task.labels || []).join(" #")}` : "";
out.push({ kind: "task", entryId: obj.entry_id, taskId: task.id, label: tname, sub: oname + labelSub });
}
}
if (out.length > 60) break; // cap the working set; sliced below
}
return out.slice(0, 40);
}
private _selectPaletteResult(r: { kind: "object" | "task"; entryId: string; taskId?: string }): void {
this._closePalette();
if (r.kind === "task" && r.taskId) this._showTask(r.entryId, r.taskId);
else this._showObject(r.entryId);
}
private _renderPalette() {
if (!this._paletteOpen) return nothing;
const L = this._lang;
const results = this._paletteResults;
return html`
<div class="palette-backdrop" @click=${() => this._closePalette()}>
<div class="palette" @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; }}
/>
<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>
`)}
</div>
<div class="palette-hint">${t("palette_hint", L)}</div>
</div>
</div>
`;
}
// --- Template gallery ---
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));
}
}
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;
}
}
private _categoryName(catId: string): string {
const cat = this._templateCategories[catId] as Record<string, string> | undefined;
if (!cat) return catId;
return cat[`name_${this._lang}`] || cat["name_en"] || catId;
}
private _renderTemplateGallery() {
if (!this._templateGalleryOpen) return nothing;
const L = this._lang;
// Group templates by category, preserving category declaration order.
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);
}
return html`
<div class="palette-backdrop" @click=${() => { this._templateGalleryOpen = false; }}>
<div class="template-gallery" @click=${(e: Event) => e.stopPropagation()}>
<div class="template-gallery-head">
<span>${t("templates_title", L)}</span>
<ha-icon-button .path=${"M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"}
@click=${() => { this._templateGalleryOpen = false; }}></ha-icon-button>
</div>
<div class="template-gallery-body">
${this._templates.length === 0
? html`<div class="palette-empty">${t("loading", L)}…</div>`
: [...byCat.entries()].map(([catId, tpls]) => html`
<div class="template-cat">
<div class="template-cat-head">
<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>
`)}
</div>
</div>
</div>
`;
}
// --- Bulk selection (dashboard task list) ---
private _bulkKey(row: TaskRow): string {
return `${row.entry_id}:${row.task_id}`;
}
private _toggleBulkMode(): void {
this._bulkMode = !this._bulkMode;
if (!this._bulkMode) this._bulkSelected = new Set();
}
private _toggleBulkRow(row: TaskRow): void {
const key = this._bulkKey(row);
const next = new Set(this._bulkSelected);
if (next.has(key)) next.delete(key); else next.add(key);
this._bulkSelected = next;
}
private _bulkSelectAll(rows: TaskRow[]): void {
const keys = rows.map((r) => this._bulkKey(r));
const allSelected = keys.every((k) => this._bulkSelected.has(k));
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(
rows: TaskRow[],
build: (row: TaskRow) => Record<string, unknown>,
doneMsg: (n: number) => string,
undo?: () => 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));
}
private _bulkComplete(rows: TaskRow[]): void {
void this._runBulk(
rows,
(row) => ({ type: "maintenance_supporter/task/complete", entry_id: row.entry_id, task_id: row.task_id }),
(n) => t("bulk_completed", this._lang).replace("{n}", String(n)),
);
}
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) {
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/unarchive", entry_id: k.entry_id, task_id: k.task_id,
});
} catch { /* best effort */ }
}
await this._loadData();
},
);
}
// --- Actions ---
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),
confirmText: t("delete", this._lang),
danger: true,
});
if (!ok) return;
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/object/delete",
entry_id: entryId,
});
this._showOverview();
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
}
}
/** Open a printable maintenance report for the object in a new tab (the user
* prints / saves as PDF from there). Self-contained HTML, no dependency. */
private _printObjectReport(entryId: string): void {
const resp = this._getObject(entryId);
if (!resp) return;
const L = this._lang;
const labels: ReportLabels = {
title: t("report_title", L),
generated: t("report_generated", L),
manufacturer: t("manufacturer", L),
model: t("model", L),
serial: t("serial_number_label", L),
installed: t("installed", L),
warranty: t("warranty", L),
area: t("area", L),
notes: t("report_notes", L),
tasksHeading: t("tasks", L),
colTask: t("task_name", L),
colType: t("report_col_type", L),
colStatus: t("report_col_status", L),
colSchedule: t("report_col_schedule", L),
colLastDone: t("last_performed", L),
colNextDue: t("next_due", L),
colCost: t("cost", L),
colTimes: t("report_times_done", L),
totalCost: t("report_total_cost", L),
scheduleLabel: (task) => formatRecurrence(task, L),
none: "—",
statusLabel: (s: string) => t(s, L),
typeLabel: (ty: string) => t(ty, L),
};
const html = buildObjectReportHtml(
resp.object, resp.tasks, labels,
(iso) => (iso ? formatDate(iso, L) : ""),
this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL,
new Date().toISOString(),
);
const url = URL.createObjectURL(new Blob([html], { type: "text/html" }));
window.open(url, "_blank");
setTimeout(() => URL.revokeObjectURL(url), 60000);
}
private async _duplicateObject(entryId: string): Promise<void> {
this._actionLoading = true;
try {
const res = await this.hass.connection.sendMessagePromise<{ entry_id?: string }>({
type: "maintenance_supporter/object/duplicate",
entry_id: entryId,
});
await this._loadData();
this._showToast(t("object_duplicated", this._lang));
if (res?.entry_id) this._showObject(res.entry_id);
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._actionLoading = false;
}
}
private async _deleteTask(entryId: string, taskId: 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_task", this._lang),
confirmText: t("delete", this._lang),
danger: true,
});
if (!ok) return;
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/delete",
entry_id: entryId,
task_id: taskId,
});
this._showObject(entryId);
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
}
}
// v2.10.0: archive / unarchive a single task (reversible — no confirm).
private async _duplicateTask(entryId: string, taskId: string): Promise<void> {
this._moreMenuOpen = false;
this._actionLoading = true;
try {
const res = await this.hass.connection.sendMessagePromise<{ task_id?: string }>({
type: "maintenance_supporter/task/duplicate",
entry_id: entryId,
task_id: taskId,
});
await this._loadData();
this._showToast(t("task_duplicated", this._lang));
// Jump straight to the copy so the user can rename/adjust it.
if (res?.task_id) this._showTask(entryId, res.task_id);
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._actionLoading = false;
}
}
private async _toggleArchiveTask(entryId: string, taskId: string, archived: boolean): Promise<void> {
this._actionLoading = true;
try {
await this.hass.connection.sendMessagePromise({
type: archived
? "maintenance_supporter/task/unarchive"
: "maintenance_supporter/task/archive",
entry_id: entryId,
task_id: taskId,
});
await this._loadData();
// Just archived → offer a one-tap undo (unarchive) instead of a confirm.
if (!archived) {
this._showUndoToast(t("task_archived", this._lang),
() => this._toggleArchiveTask(entryId, taskId, true));
}
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._actionLoading = false;
}
}
// 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).
private async _toggleArchiveObject(entryId: string, archived: boolean): Promise<void> {
try {
await this.hass.connection.sendMessagePromise({
type: archived
? "maintenance_supporter/object/unarchive"
: "maintenance_supporter/object/archive",
entry_id: entryId,
});
await this._loadData();
if (!archived) {
this._showUndoToast(t("object_archived", this._lang),
() => this._toggleArchiveObject(entryId, true));
}
} catch {
this._showToast(t("action_error", this._lang));
}
}
// v2.20 (N3): seasonal pause / resume. Pausing asks for an optional
// auto-resume date; resuming re-anchors recurring tasks to a fresh cycle.
private async _togglePauseObject(entryId: string, paused: boolean): Promise<void> {
if (!paused) {
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
const result = await dlg?.prompt({
title: t("pause_object", this._lang),
message: t("pause_until_prompt", this._lang),
confirmText: t("pause_object", this._lang),
inputLabel: t("pause_until_label", this._lang),
inputType: "date",
});
if (!result?.confirmed) return;
try {
const msg: Record<string, unknown> = {
type: "maintenance_supporter/object/pause",
entry_id: entryId,
};
if (result.value) msg.until = result.value;
await this.hass.connection.sendMessagePromise(msg);
await this._loadData();
this._showUndoToast(t("object_paused", this._lang),
() => this._togglePauseObject(entryId, true));
} catch (e) {
this._showToast(describeWsError(e, this._lang));
}
return;
}
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/object/resume",
entry_id: entryId,
});
await this._loadData();
this._showToast(t("object_resumed", this._lang));
} catch (e) {
this._showToast(describeWsError(e, this._lang));
}
}
// 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;
this._actionLoading = true;
try {
const res = await this.hass.connection.sendMessagePromise<{ entry_id?: string }>({
type: "maintenance_supporter/object/replace",
entry_id: entryId,
name: result.value || currentName,
});
await this._loadData();
this._showToast(t("object_replaced", this._lang));
if (res?.entry_id) this._showObject(res.entry_id);
} catch (e) {
this._showToast(describeWsError(e, this._lang));
} finally {
this._actionLoading = false;
}
}
private async _skipTask(entryId: string, taskId: string, reason?: string): Promise<void> {
this._actionLoading = true;
try {
const msg: Record<string, unknown> = {
type: "maintenance_supporter/task/skip",
entry_id: entryId,
task_id: taskId,
};
if (reason) msg.reason = reason;
await this.hass.connection.sendMessagePromise(msg);
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._actionLoading = false;
}
}
private async _resetTask(entryId: string, taskId: string, resetDate?: string): Promise<void> {
this._actionLoading = true;
try {
const msg: Record<string, unknown> = {
type: "maintenance_supporter/task/reset",
entry_id: entryId,
task_id: taskId,
};
if (resetDate) msg.date = resetDate;
await this.hass.connection.sendMessagePromise(msg);
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._actionLoading = false;
}
}
private async _applySuggestion(entryId: string, taskId: string, interval: number): Promise<void> {
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/apply_suggestion",
entry_id: entryId,
task_id: taskId,
interval: interval,
});
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
}
}
private _openSeasonalOverrides(task: MaintenanceTask): void {
const dlg = this.shadowRoot!.querySelector<SeasonalOverridesDialog>("maintenance-seasonal-overrides-dialog");
if (!dlg || !this._selectedEntryId) return;
const overrides = task.adaptive_config?.seasonal_overrides as Record<number, number> | null | undefined;
dlg.open(this._selectedEntryId, task.id, overrides);
}
private async _reanalyzeInterval(entryId: string, taskId: string): Promise<void> {
try {
const res = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/analyze_interval",
entry_id: entryId,
task_id: taskId,
}) as {
recommended_interval: number | null;
confidence: string;
data_points: number;
recommendation_reason: string | null;
};
if (res.recommended_interval) {
this._showToast(
`${t("reanalyze_result", this._lang)}: ${res.recommended_interval} ${t("days", this._lang)} ` +
`(${t(`confidence_${res.confidence}`, this._lang)}, ${res.data_points} ${t("data_points", this._lang)})`,
);
} else {
this._showToast(t("reanalyze_insufficient_data", this._lang));
}
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
}
}
private async _promptSkipTask(entryId: string, taskId: string): Promise<void> {
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
if (!dlg) return;
const result = await dlg.prompt({
title: t("skip", this._lang),
message: t("skip_reason_prompt", this._lang),
confirmText: t("skip", this._lang),
inputLabel: t("reason_optional", this._lang),
inputType: "text",
});
if (!result.confirmed) return;
this._skipTask(entryId, taskId, result.value || undefined);
}
private async _promptResetTask(entryId: string, taskId: string): Promise<void> {
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
if (!dlg) return;
const result = await dlg.prompt({
title: t("reset", this._lang),
message: t("reset_date_prompt", this._lang),
confirmText: t("reset", this._lang),
inputLabel: t("reset_date_optional", this._lang),
inputType: "date",
});
if (!result.confirmed) return;
this._resetTask(entryId, taskId, result.value || undefined);
}
private async _postponeTask(entryId: string, taskId: string, until: string): Promise<void> {
this._actionLoading = true;
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/postpone",
entry_id: entryId,
task_id: taskId,
until,
});
this._showToast(t("postponed", this._lang));
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._actionLoading = false;
}
}
private async _promptPostponeTask(entryId: string, taskId: string): Promise<void> {
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
if (!dlg) return;
const result = await dlg.prompt({
title: t("postpone", this._lang),
message: t("postpone_date_prompt", this._lang),
confirmText: t("postpone", this._lang),
inputLabel: t("postpone_date_label", this._lang),
inputType: "date",
});
if (!result.confirmed || !result.value) return;
this._postponeTask(entryId, taskId, result.value);
}
private async _snoozeTask(entryId: string, taskId: string): Promise<void> {
this._actionLoading = true;
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/snooze",
entry_id: entryId,
task_id: taskId,
});
this._showToast(t("snoozed", this._lang));
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._actionLoading = false;
}
}
private _dismissSuggestion(entryId?: string, taskId?: string): void {
if (entryId && taskId) {
this._dismissedSuggestions.add(`${entryId}_${taskId}`);
}
this.requestUpdate();
}
// v1.3.0: silent complete-via-QR. Tries the quick endpoint first; if the
// task has no quick_complete_defaults configured, falls back to the
// normal complete dialog so the user is never stuck after a scan.
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 || "";
if (code === "no_defaults") {
// No defaults set — open the dialog so the user can fill them in.
this._openCompleteDialog(
entryId, taskId, task.name,
this._features.checklists ? task.checklist : undefined,
this._features.adaptive && !!task.adaptive_config?.enabled,
);
} else {
this._showToast(t("action_error", this._lang));
}
}
}
// v2.21: printable one-pager for a task — details, checklist tick boxes,
// QR pair, and (when a linked PDF manual has a page hint for this task) a
// link to the server-cut manual excerpt. Opens in a new tab for printing.
private async _printTaskWorksheet(entryId: string, taskId: string): Promise<void> {
const obj = this._getObject(entryId);
const task = obj?.tasks.find((tk) => tk.id === taskId);
if (!obj || !task) return;
this._actionLoading = true;
try {
const qrBase: Record<string, unknown> = {
type: "maintenance_supporter/qr/generate",
entry_id: entryId, task_id: taskId, url_mode: "server",
};
const [qrView, qrComplete] = await Promise.all([
this.hass.connection.sendMessagePromise<{ svg_data_uri?: string }>({ ...qrBase, action: "view" }).catch(() => null),
this.hass.connection.sendMessagePromise<{ svg_data_uri?: string }>({ ...qrBase, action: "complete" }).catch(() => null),
]);
// Manual excerpt: first linked PDF with a page hint for this task.
let excerpt: WorksheetExcerpt | null = null;
try {
const docs = await this.hass.connection.sendMessagePromise<{
documents: Array<{ id: string; kind: string; mime?: string; title?: string;
filename?: string; task_ids?: string[]; task_pages?: Record<string, number> }>;
}>({ type: "maintenance_supporter/documents/list", entry_id: entryId });
const manual = (docs.documents || []).find(
(d) => d.kind === "file" && d.mime === "application/pdf"
&& (d.task_ids || []).includes(taskId) && d.task_pages?.[taskId],
);
if (manual) {
const start = manual.task_pages![taskId];
const count = 4;
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${manual.id}/excerpt?start=${start}&count=${count}`,
expires: 3600,
});
excerpt = {
title: manual.title || manual.filename || "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).
url: new URL(signed.path, window.location.origin).toString(),
vendorBase: new URL("/maintenance_supporter_vendor", window.location.origin).toString(),
};
}
} catch { /* worksheet works without the excerpt */ }
const L = this._lang;
const labels: WorksheetLabels = {
title: t("worksheet", L),
object: t("object", L),
type: t("maintenance_type", L),
interval: t("interval", L),
nextDue: t("next_due", L),
lastDone: t("last_performed", L),
priority: t("priority", L),
checklist: t("checklist", L),
notes: t("notes_label", L),
scanView: t("worksheet_scan_view", L),
scanComplete: t("worksheet_scan_complete", L),
manualExcerpt: t("worksheet_manual_excerpt", L),
pages: t("worksheet_pages", L),
printedOn: t("worksheet_printed", L),
never: t("worksheet_never", L),
typeLabel: (ty: string) => t(ty, L),
statusLabel: (st: string) => t(st, L),
};
const html = buildTaskWorksheetHtml(
task, obj.object.name, labels,
(iso) => formatDate(iso, L),
(tk) => formatRecurrence(tk, L),
qrView?.svg_data_uri || null,
qrComplete?.svg_data_uri || null,
excerpt,
new Date().toISOString(),
);
const url = URL.createObjectURL(new Blob([html], { type: "text/html" }));
window.open(url, "_blank");
setTimeout(() => URL.revokeObjectURL(url), 60000);
} finally {
this._actionLoading = false;
}
}
private _openCompleteDialog(entryId: string, taskId: string, taskName: string, checklist?: string[], adaptiveEnabled?: boolean): void {
const dlg = this.shadowRoot!.querySelector<MaintenanceCompleteDialog>("maintenance-complete-dialog");
if (!dlg) return;
dlg.entryId = entryId;
dlg.taskId = taskId;
dlg.taskName = taskName;
dlg.lang = this._lang;
dlg.checklist = checklist || [];
dlg.adaptiveEnabled = !!adaptiveEnabled;
// v2.20 (#83): reading tasks get a value field — resolve type + unit here
// so none of the many call sites need to thread them through.
const tk = this._objects
.find((o) => o.entry_id === entryId)
?.tasks.find((tsk) => tsk.id === taskId);
dlg.taskType = tk?.type || "";
dlg.readingUnit = tk?.reading_unit || "";
dlg.open();
}
private _openQrForObject(entryId: string, objectName: string): void {
const dlg = this.shadowRoot!.querySelector<MaintenanceQrDialog>("maintenance-qr-dialog");
dlg?.openForObject(entryId, objectName);
}
private _openQrForTask(entryId: string, taskId: string, objectName: string, taskName: string): void {
const dlg = this.shadowRoot!.querySelector<MaintenanceQrDialog>("maintenance-qr-dialog");
dlg?.openForTask(entryId, taskId, objectName, taskName);
}
private _onDialogEvent = async (): Promise<void> => {
try { await this._loadData(); } catch { /* subscription will sync */ }
};
// --- Render ---
render() {
return html`
<div class="panel">
${this.narrow || this._view !== "overview" ? this._renderHeader() : nothing}
<div class="content">
${this._view === "overview"
? this._renderOverview()
: this._view === "all_objects"
? this._renderAllObjects()
: this._view === "object"
? this._renderObjectDetail()
: this._renderTaskDetail()}
</div>
</div>
<maintenance-object-dialog
.hass=${this.hass}
.objects=${this._objects}
@object-saved=${this._onDialogEvent}
></maintenance-object-dialog>
<maintenance-task-dialog
.hass=${this.hass}
.checklistsEnabled=${this._features.checklists}
.scheduleTimeEnabled=${this._features.schedule_time}
.completionActionsEnabled=${this._features.completion_actions}
.defaultWarningDays=${this._defaultWarningDays}
@task-saved=${this._onDialogEvent}
></maintenance-task-dialog>
<maintenance-complete-dialog
.hass=${this.hass}
@task-completed=${this._onDialogEvent}
></maintenance-complete-dialog>
<maintenance-history-edit-dialog
.hass=${this.hass}
@history-entry-saved=${this._onHistoryEntrySaved}
></maintenance-history-edit-dialog>
<maintenance-qr-dialog
.hass=${this.hass}
.lang=${this._lang}
></maintenance-qr-dialog>
<maintenance-confirm-dialog
.hass=${this.hass}
></maintenance-confirm-dialog>
<maintenance-seasonal-overrides-dialog
.hass=${this.hass}
@overrides-saved=${this._onDialogEvent}
></maintenance-seasonal-overrides-dialog>
<maintenance-group-dialog
.hass=${this.hass}
.objects=${this._objects}
@group-saved=${this._onDialogEvent}
></maintenance-group-dialog>
${this._toastMessage ? html`<div class="toast">
<span>${this._toastMessage}</span>
${this._toastUndo ? html`<button class="toast-undo" @click=${() => this._runToastUndo()}>${t("undo", this._lang)}</button>` : nothing}
</div>` : nothing}
${this._renderPalette()}
${this._renderTemplateGallery()}
`;
}
private _renderHeader() {
const crumbs: { label: string; action?: () => void }[] = [
{ label: t("maintenance", this._lang), action: () => this._showOverview() },
];
if (this._view === "object" && this._selectedEntryId) {
const obj = this._getObject(this._selectedEntryId);
crumbs.push({ label: obj?.object.name || "Object" });
}
if (this._view === "task" && this._selectedEntryId && this._selectedTaskId) {
const obj = this._getObject(this._selectedEntryId);
crumbs.push({
label: obj?.object.name || "Object",
action: () => this._showObject(this._selectedEntryId!),
});
const task = this._getTask(this._selectedEntryId, this._selectedTaskId);
crumbs.push({ label: task?.name || "Task" });
}
return html`
<div class="header">
${this.narrow ? html`<ha-menu-button .hass=${this.hass} .narrow=${this.narrow}></ha-menu-button>` : nothing}
${this._view !== "overview"
? html`<ha-icon-button
.path=${"M20,11V13H8L13.5,18.5L12.08,19.92L4.16,12L12.08,4.08L13.5,5.5L8,11H20Z"}
@click=${() => {
if (this._view === "task") this._showObject(this._selectedEntryId!);
else this._showOverview();
}}
></ha-icon-button>`
: nothing}
<div class="breadcrumbs">
${crumbs.map(
(c, i) => html`
${i > 0 ? html`<span class="sep">/</span>` : nothing}
${c.action
? html`<a @click=${c.action}>${c.label}</a>`
: html`<span class="current">${c.label}</span>`}
`
)}
</div>
</div>
`;
}
private _renderOverview() {
const L = this._lang;
const isAdmin = !!this.hass?.user?.is_admin;
const s = this._stats;
// Only true HA admins get the Settings tab: global config, feature toggles,
// vacation and import live there and stay admin-only server-side. Operators
// (incl. allowlisted ones with full content CRUD) and plain users can't open
// it. The Calendar tab IS visible to everyone (read-only view).
if (!isAdmin && this._overviewTab === "settings") {
this._overviewTab = "dashboard";
}
return html`
${s
? html`
<div class="stats-bar">
<div class="stat-item clickable"
@click=${() => this._showAllObjects()}
title=${t("show_all_objects", L)}>
<span class="stat-value">${s.total_objects}</span>
<span class="stat-label">${t("objects", L)}</span>
</div>
<div class="stat-item clickable"
@click=${() => this._filterByStatus("")}
title=${t("show_all_tasks", L)}>
<span class="stat-value">${s.total_tasks}</span>
<span class="stat-label">${t("tasks", L)}</span>
</div>
<div class="stat-item clickable ${this._filterStatus === "overdue" && this._overviewTab === "dashboard" ? "active" : ""}"
@click=${() => this._filterByStatus("overdue")}
title=${t("filter_to_overdue", L)}>
<span class="stat-value" style="color: var(--error-color)">${s.overdue}</span>
<span class="stat-label">${t("overdue", L)}</span>
</div>
<div class="stat-item clickable ${this._filterStatus === "due_soon" && this._overviewTab === "dashboard" ? "active" : ""}"
@click=${() => this._filterByStatus("due_soon")}
title=${t("filter_to_due_soon", L)}>
<span class="stat-value" style="color: var(--warning-color)">${s.due_soon}</span>
<span class="stat-label">${t("due_soon", L)}</span>
</div>
<div class="stat-item clickable ${this._filterStatus === "triggered" && this._overviewTab === "dashboard" ? "active" : ""}"
@click=${() => this._filterByStatus("triggered")}
title=${t("filter_to_triggered", L)}>
<span class="stat-value" style="color: #ff5722">${s.triggered}</span>
<span class="stat-label">${t("triggered", L)}</span>
</div>
</div>
`
: nothing}
<div class="tab-bar">
<div class="tab ${this._overviewTab === "today" ? "active" : ""}"
@click=${() => this._setOverviewTab("today")}>
${t("tab_today", L)}
</div>
<div class="tab ${this._overviewTab === "dashboard" ? "active" : ""}"
@click=${() => this._setOverviewTab("dashboard")}>
${t("dashboard", L)}
</div>
<div class="tab ${this._overviewTab === "calendar" ? "active" : ""}"
@click=${() => this._setOverviewTab("calendar")}>
${t("tab_calendar", L)}
</div>
${isAdmin ? html`
<div class="tab ${this._overviewTab === "settings" ? "active" : ""}"
@click=${() => this._setOverviewTab("settings")}>
${t("settings", L)}
</div>
` : nothing}
</div>
${this._overviewTab === "today"
? this._renderToday()
: this._overviewTab === "dashboard"
? this._renderDashboard()
: this._overviewTab === "calendar"
? html`
<div @ll-custom=${this._onCalendarLlCustom}>
<maintenance-supporter-calendar-card
.hass=${this.hass}
></maintenance-supporter-calendar-card>
</div>
`
: html`<maintenance-settings-view
.hass=${this.hass}
.features=${this._features}
.budget=${this._budget}
@settings-changed=${this._onSettingsChanged}
></maintenance-settings-view>`}
`;
}
/** v2.0.0: intercept ll-custom open-task events from the embedded calendar
* card so we navigate within the panel (using _showTask) instead of
* letting the document-level dialog-mount handler open a duplicate
* dialog on top. The handler stops propagation so the document listener
* (registered by the dashboard-strategy bundle) doesn't also fire. */
private _onCalendarLlCustom = (e: Event): void => {
const detail = (e as CustomEvent<{ type?: string; entry_id?: string; task_id?: string }>).detail;
if (
detail?.type === "maintenance-supporter:open-task" &&
detail.entry_id &&
detail.task_id
) {
e.stopPropagation();
this._showTask(detail.entry_id, detail.task_id);
}
};
// v2.0.0: _renderCalendar() removed — replaced by an embedded
// <maintenance-supporter-calendar-card>. The card holds all the same
// logic (window chips, source icons, prediction pills, projected events)
// and is the single source of truth for calendar rendering across the
// panel and stand-alone Lovelace use. Kept for reference: the old
// implementation rendered ~120 lines of Lit template literals here,
// duplicating what the card now does.
/** Status badge with a shape icon so status isn't conveyed by colour alone
* (colour-blind accessibility) — icon + text + colour together. */
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}"><ha-icon icon="${STATUS_ICONS[iconKey] || "mdi:circle-medium"}"></ha-icon>${label}</span>`;
}
private _setOverviewTab(tab: "today" | "dashboard" | "calendar" | "settings"): void {
this._overviewTab = tab;
try { localStorage.setItem("msp-overview-tab", tab); } catch { /* private mode */ }
this._scrollContentToTop();
}
/** Mobile-first focus list: what needs attention now, grouped by urgency. */
private _renderToday() {
const L = this._lang;
const rows = this._taskRows; // already excludes archived + disabled
const key = (r: TaskRow) => `${r.entry_id}:${r.task_id}`;
const overdue = rows.filter((r) => r.status === "overdue" || r.trigger_active);
const claimed = new Set(overdue.map(key));
const dueToday = rows.filter((r) => !claimed.has(key(r)) && r.days_until_due === 0);
dueToday.forEach((r) => claimed.add(key(r)));
const thisWeek = rows.filter((r) => !claimed.has(key(r))
&& r.days_until_due != null && r.days_until_due > 0 && r.days_until_due <= 7);
if (overdue.length + dueToday.length + thisWeek.length === 0) {
return html`
<div class="today-empty">
<ha-icon icon="mdi:check-circle-outline"></ha-icon>
<p>${t("today_all_caught_up", L)}</p>
</div>
`;
}
return html`
<div class="today-view">
${this._renderTodaySection("today_overdue", overdue, "overdue")}
${this._renderTodaySection("today_due_today", dueToday, "due_soon")}
${this._renderTodaySection("today_this_week", thisWeek, "")}
</div>
`;
}
private _renderTodaySection(titleKey: string, rows: TaskRow[], cls: string) {
if (rows.length === 0) return nothing;
const L = this._lang;
return html`
<div class="today-section">
<div class="today-section-header ${cls}">
<span>${t(titleKey, L)}</span><span class="today-badge">${rows.length}</span>
</div>
${rows.map((row) => html`
<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">${row.task_name}</div>
<div class="today-object">${row.object_name} · ${formatDueDays(row.days_until_due, L)}</div>
</div>
<mwc-icon-button class="btn-complete" title="${t("complete", L)}"
@click=${(e: Event) => { e.stopPropagation(); this._openCompleteDialogForRow(row); }}>
<ha-icon icon="mdi:check"></ha-icon>
</mwc-icon-button>
</div>
`)}
</div>
`;
}
private _renderDashboard() {
const s = this._stats;
const rows = this._taskRows;
const L = this._lang;
const isOperator = this._isOperator;
// v2.10.0: count archived tasks so the reveal toggle can show how many are
// hidden (and hide itself entirely when there is nothing archived).
const archivedCount = this._objects.reduce(
(n, o) => n + o.tasks.filter((tk) => tk.archived).length, 0,
);
return html`
${this._features.budget ? this._renderBudgetBar() : nothing}
<div class="filter-bar">
<label class="filter-field">
<span class="filter-label">${t("filter_label", L)}</span>
<select
.value=${this._filterStatus}
@change=${(e: Event) => (this._filterStatus = (e.target as HTMLSelectElement).value)}
>
<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>
</select>
</label>
<label class="filter-field">
<span class="filter-label">${t("user_label", L)}</span>
<select
.value=${this._filterUser || ""}
@change=${(e: Event) => {
const val = (e.target as HTMLSelectElement).value;
this._filterUser = val || null;
}}
>
<option value="">${t("all_users", L)}</option>
<option value="current_user">${t("my_tasks", L)}</option>
</select>
</label>
<label class="filter-field">
<span class="filter-label">${t("sort_label", L)}</span>
<select
.value=${this._sortMode}
@change=${(e: Event) => {
this._sortMode = (e.target as HTMLSelectElement).value as SortMode;
localStorage.setItem("maintenance_supporter_sort", this._sortMode);
}}
>
<option value="due_date" ?selected=${this._sortMode === "due_date"}>${t("sort_due_date", L)}</option>
<option value="object" ?selected=${this._sortMode === "object"}>${t("sort_object", L)}</option>
<option value="type" ?selected=${this._sortMode === "type"}>${t("sort_type", L)}</option>
<option value="task_name" ?selected=${this._sortMode === "task_name"}>${t("sort_task_name", L)}</option>
<option value="area" ?selected=${this._sortMode === "area"}>${t("sort_area", L)}</option>
<option value="assigned_user" ?selected=${this._sortMode === "assigned_user"}>${t("sort_assigned_user", L)}</option>
<option value="group" ?selected=${this._sortMode === "group"}>${t("sort_group", L)}</option>
</select>
</label>
<label class="filter-field">
<span class="filter-label">${t("group_by_label", L)}</span>
<select
.value=${this._groupByMode}
@change=${(e: Event) => {
this._groupByMode = (e.target as HTMLSelectElement).value as GroupByMode;
localStorage.setItem("maintenance_supporter_groupby", this._groupByMode);
}}
>
<option value="none" ?selected=${this._groupByMode === "none"}>${t("groupby_none", L)}</option>
<option value="area" ?selected=${this._groupByMode === "area"}>${t("groupby_area", L)}</option>
${this._features.groups ? html`<option value="group" ?selected=${this._groupByMode === "group"}>${t("groupby_group", L)}</option>` : nothing}
<option value="user" ?selected=${this._groupByMode === "user"}>${t("groupby_user", L)}</option>
</select>
</label>
${archivedCount > 0 ? html`
<ha-button
class="archived-toggle ${this._showArchived ? "active" : ""}"
@click=${() => { this._showArchived = !this._showArchived; }}
>
<ha-icon icon="mdi:archive-outline"></ha-icon>
${this._showArchived ? t("hide_archived", L) : `${t("show_archived", L)} (${archivedCount})`}
</ha-button>
` : nothing}
${!isOperator && rows.length > 0 ? html`
<ha-button
class="bulk-toggle ${this._bulkMode ? "active" : ""}"
@click=${() => this._toggleBulkMode()}
>
<ha-icon icon="mdi:checkbox-multiple-marked-outline"></ha-icon>
${this._bulkMode ? t("cancel", L) : t("bulk_select", L)}
</ha-button>
` : nothing}
${!isOperator ? html`
<ha-button
@click=${() => this.shadowRoot!.querySelector<MaintenanceObjectDialog>("maintenance-object-dialog")?.openCreate()}
>
${t("new_object", L)}
</ha-button>
<ha-button @click=${() => this._openTemplateGallery()}>
<ha-icon icon="mdi:view-grid-plus-outline"></ha-icon> ${t("templates_from", L)}
</ha-button>
<ha-button
@click=${() => this.shadowRoot!.querySelector<MaintenanceTaskDialog>("maintenance-task-dialog")?.openCreate("", this._objects)}
>
${t("new_task", L)}
</ha-button>
` : nothing}
</div>
${rows.length === 0
? html`
<div class="empty-state">
<ha-svg-icon path="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"></ha-svg-icon>
<p>${t("no_tasks", L)}</p>
${!isOperator && this._objects.length === 0 ? html`
<p class="empty-onboard-hint">${t("onboard_hint", L)}</p>
<div class="empty-onboard-actions">
<ha-button appearance="filled" @click=${() => this._openTemplateGallery()}>
<ha-icon icon="mdi:view-grid-plus-outline"></ha-icon> ${t("templates_from", L)}
</ha-button>
<ha-button appearance="plain" @click=${() => this.shadowRoot!.querySelector<MaintenanceObjectDialog>("maintenance-object-dialog")?.openCreate()}>
${t("new_object", L)}
</ha-button>
</div>
` : nothing}
</div>
`
: html`
${this._bulkMode ? this._renderBulkBar(rows, L) : nothing}
${this._groupByMode === "none"
? this._renderTaskTable(rows)
: this._renderGroupedTasks(rows, L)}
`}
${this._features.groups && !isOperator ? this._renderGroupsSection() : nothing}
${!isOperator
? html`<maintenance-storage-section-card
.hass=${this.hass}
.objects=${this._objects}
@open-object=${(e: CustomEvent<{ entry_id?: string }>) => {
const eid = e.detail?.entry_id;
if (eid) this._showObject(eid);
}}
></maintenance-storage-section-card>`
: nothing}
`;
}
/** The flat dashboard task table. Below VIRTUAL_MIN_ROWS (or on narrow
* layouts, where rows aren't uniform-height) every row renders directly.
* Above it, only the scroll window is in the DOM: two grid-spanning
* spacers keep the scrollbar honest, and a hidden "sizer" row pins the
* content-sized badge column to the widest badge set across ALL rows so
* the shared subgrid tracks can't jitter as rows enter/leave the window. */
private _renderTaskTable(rows: TaskRow[]) {
const bulkCls = this._bulkMode ? " bulk" : "";
this._virtTotalRows = rows.length;
if (this.narrow || rows.length < VIRTUAL_MIN_ROWS) {
return html`
<div class="task-table${bulkCls}">
${rows.map((row) => this._renderOverviewRow(row))}
</div>
`;
}
const total = rows.length;
const rh = this._virtRowHeight;
let start = Math.max(0, Math.min(this._virtStart, total));
let end = this._virtEnd > 0 ? Math.min(this._virtEnd, total) : Math.min(total, 40);
if (end < start) { start = 0; end = Math.min(total, 40); }
const padTop = start * rh;
const padBottom = (total - end) * rh;
return html`
<div class="task-table${bulkCls} virtual">
${this._renderVirtSizerRow(rows)}
${padTop > 0 ? html`<div class="virt-spacer" style="height:${padTop}px"></div>` : nothing}
${rows.slice(start, end).map((row) => this._renderOverviewRow(row))}
${padBottom > 0 ? html`<div class="virt-spacer" style="height:${padBottom}px"></div>` : nothing}
</div>
`;
}
/** Invisible zero-height row whose badge cell carries the union of the
* widest badges across ALL rows. Because the subgrid's `auto` badge track
* sizes to the max over rendered rows, including this row makes the track
* width independent of which data rows happen to be in the window. */
private _renderVirtSizerRow(rows: TaskRow[]) {
const L = this._lang;
let widest = "";
let anyDisabled = false;
let anyNfc = false;
let anyPriority = false;
for (const r of rows) {
const label = r.archived ? t("archived", L) : (r.is_done ? t("completed", L) : t(r.status, L));
if (label.length > widest.length) widest = label;
if (!r.enabled) anyDisabled = true;
if (r.nfc_tag_id) anyNfc = true;
if (r.priority === "high" || r.priority === "low") anyPriority = true;
}
return html`
<div class="task-row virt-sizer" aria-hidden="true">
${this._bulkMode ? html`<span></span>` : nothing}
<span class="cell-badges">
<span class="status-badge"><ha-icon icon="mdi:circle-medium"></ha-icon>${widest}</span>
${anyDisabled ? html`<span class="badge-disabled">${t("disabled", L)}</span>` : nothing}
${anyNfc ? html`<span class="nfc-badge"><ha-icon icon="mdi:nfc-variant"></ha-icon></span>` : nothing}
${anyPriority ? html`<span class="priority-badge"><ha-icon icon="mdi:chevron-double-up"></ha-icon></span>` : nothing}
</span>
<span></span><span></span><span></span><span></span><span></span><span></span>
</div>
`;
}
private _renderBulkBar(rows: TaskRow[], L: string) {
const n = this._bulkSelected.size;
const allSelected = rows.length > 0 && rows.every((r) => this._bulkSelected.has(this._bulkKey(r)));
return html`
<div class="bulk-bar">
<label class="bulk-selectall">
<input type="checkbox" .checked=${allSelected} @change=${() => this._bulkSelectAll(rows)} />
${t("bulk_select_all", L)}
</label>
<span class="bulk-count">${t("bulk_n_selected", L).replace("{n}", String(n))}</span>
<span class="bulk-actions">
<ha-button appearance="filled" .disabled=${n === 0 || this._actionLoading}
@click=${() => this._bulkComplete(rows)}>
<ha-icon icon="mdi:check"></ha-icon> ${t("complete", L)}
</ha-button>
<ha-button appearance="plain" .disabled=${n === 0 || this._actionLoading}
@click=${() => this._bulkArchive(rows)}>
<ha-icon icon="mdi:archive-outline"></ha-icon> ${t("archive", L)}
</ha-button>
</span>
</div>
`;
}
private _renderGroupedTasks(rows: TaskRow[], L: string) {
const groups = new Map<string, TaskRow[]>();
const noneLabel = t("unassigned", L);
for (const row of rows) {
let keys: string[] = [];
if (this._groupByMode === "area") {
const a = row.area_id ? this.hass?.areas?.[row.area_id]?.name : null;
keys = [a || noneLabel];
} else if (this._groupByMode === "user") {
const u = row.responsible_user_id ? this._userService?.getUserName(row.responsible_user_id) : null;
keys = [u || noneLabel];
} else if (this._groupByMode === "group") {
keys = row.group_names.length > 0 ? row.group_names : [noneLabel];
}
for (const k of keys) {
if (!groups.has(k)) groups.set(k, []);
groups.get(k)!.push(row);
}
}
const sorted = [...groups.entries()].sort(([a], [b]) => {
// Push "unassigned" / "no area" to bottom
if (a === noneLabel && b !== noneLabel) return 1;
if (b === noneLabel && a !== noneLabel) return -1;
return a.localeCompare(b);
});
const icon = this._groupByMode === "area" ? "mdi:map-marker-outline"
: this._groupByMode === "group" ? "mdi:folder-outline"
: "mdi:account-outline";
return html`
${sorted.map(([key, taskRows]) => html`
<details class="group-section" open>
<summary class="group-section-header">
<ha-icon icon="${icon}"></ha-icon>
<span>${key}</span>
<span class="group-section-count">(${taskRows.length})</span>
</summary>
<div class="task-table${this._bulkMode ? " bulk" : ""}">
${taskRows.map((row) => this._renderOverviewRow(row))}
</div>
</details>
`)}
`;
}
// (#67) Localised warranty label, shared by the detail meta + table cell.
private _warrantyLabel(ws: ReturnType<typeof warrantyStatus>, iso: string, L: string): string {
if (ws.kind === "expired") return t("warranty_expired", L);
if (ws.kind === "expiring") {
return t("warranty_expires_in", L).replace("{days}", String(ws.days ?? 0));
}
return t("warranty_valid_until", L).replace("{date}", formatDate(iso, L));
}
// (#67) Warranty status as a coloured chip in the object detail meta.
private _renderWarrantyMeta(iso: string, L: string) {
const ws = warrantyStatus(iso);
return html`<p class="meta">${t("warranty", L)}:
<span class="warranty-chip warranty-${ws.kind}">${this._warrantyLabel(ws, iso, L)}</span></p>`;
}
private _renderAllObjects() {
const L = this._lang;
const isOperator = this._isOperator;
// (#67) Table mode is desktop-only; narrow viewports always fall back to
// cards (the table's many columns don't fit a phone).
const tableMode = this._objectViewMode === "table" && !this.narrow;
// v2.10.0: how many objects are archived (drives the reveal toggle below).
const archivedObjCount = this._objects.filter((o) => o.object.archived).length;
// Sort + group helpers
const minDays = (obj: MaintenanceObjectResponse): number => {
let m = Infinity;
for (const t of obj.tasks) {
const d = t.days_until_due;
if (d !== null && d !== undefined && d < m) m = d;
}
return m;
};
const sorted = this._objects.filter((o) => this._showArchived || !o.object.archived);
if (this._objectSortMode === "alphabetical") {
sorted.sort((a, b) => a.object.name.localeCompare(b.object.name));
} else if (this._objectSortMode === "task_count") {
sorted.sort((a, b) => b.tasks.length - a.tasks.length || a.object.name.localeCompare(b.object.name));
} else {
// due_soonest
sorted.sort((a, b) => minDays(a) - minDays(b) || a.object.name.localeCompare(b.object.name));
}
// Group by area for objects view
const groupedByArea = (): Map<string, MaintenanceObjectResponse[]> => {
const map = new Map<string, MaintenanceObjectResponse[]>();
for (const obj of sorted) {
const areaId = obj.object.area_id;
const key = areaId ? this.hass?.areas?.[areaId]?.name || t("unassigned", L) : t("no_area", L);
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(obj);
}
return new Map([...map.entries()].sort(([a], [b]) => a.localeCompare(b)));
};
const renderObject = (obj: MaintenanceObjectResponse) => {
const overdue = obj.tasks.some(t => t.status === "overdue" || t.status === "triggered");
return html`
<div class="object-card${overdue ? ' object-card-overdue' : ''}" @click=${() => this._showObject(obj.entry_id)}>
${overdue ? html`<span class="overdue-dot" title="${t("has_overdue", L)}"></span>` : nothing}
<div class="object-card-header">
<span class="object-card-name">${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}` : ""}">
<ha-icon icon="mdi:pause-circle-outline"></ha-icon>
</span>`
: nothing}
${obj.object.document_count
? html`<span class="doc-badge" title="${obj.object.document_count} ${t("documents", L)}">
<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>
</div>
${obj.object.manufacturer || obj.object.model
? html`<div class="object-card-meta">${[obj.object.manufacturer, obj.object.model].filter(Boolean).join(" ")}</div>`
: nothing}
${obj.tasks.length === 0
? html`<div class="object-card-empty">${t("no_tasks_yet", L)}</div>`
: nothing}
</div>
`;
};
return html`
<div class="breadcrumb">
<ha-icon-button @click=${() => this._showOverview()}>
<ha-icon icon="mdi:arrow-left"></ha-icon>
</ha-icon-button>
<span>${t("all_objects", L)}</span>
</div>
<div class="filter-bar">
<label class="filter-field">
<span class="filter-label">${t("sort_label", L)}</span>
<select
.value=${this._objectSortMode}
@change=${(e: Event) => {
this._objectSortMode = (e.target as HTMLSelectElement).value as ObjectSortMode;
localStorage.setItem("maintenance_supporter_object_sort", this._objectSortMode);
}}
>
<option value="alphabetical" ?selected=${this._objectSortMode === "alphabetical"}>${t("sort_alphabetical", L)}</option>
<option value="due_soonest" ?selected=${this._objectSortMode === "due_soonest"}>${t("sort_due_soonest", L)}</option>
<option value="task_count" ?selected=${this._objectSortMode === "task_count"}>${t("sort_task_count", L)}</option>
</select>
</label>
${!this.narrow ? html`
<div class="view-toggle" role="group" aria-label="${t("view_mode_label", L)}">
<button
class="view-toggle-btn${!tableMode ? ' active' : ''}"
title="${t("view_cards", L)}"
@click=${() => this._setObjectViewMode("cards")}
><ha-icon icon="mdi:view-grid-outline"></ha-icon></button>
<button
class="view-toggle-btn${tableMode ? ' active' : ''}"
title="${t("view_table", L)}"
@click=${() => this._setObjectViewMode("table")}
><ha-icon icon="mdi:table"></ha-icon></button>
</div>
` : nothing}
${!tableMode ? html`
<label class="filter-field">
<span class="filter-label">${t("group_by_label", L)}</span>
<select
.value=${this._groupByMode}
@change=${(e: Event) => {
this._groupByMode = (e.target as HTMLSelectElement).value as GroupByMode;
localStorage.setItem("maintenance_supporter_groupby", this._groupByMode);
}}
>
<option value="none" ?selected=${this._groupByMode === "none"}>${t("groupby_none", L)}</option>
<option value="area" ?selected=${this._groupByMode === "area"}>${t("groupby_area", L)}</option>
</select>
</label>
` : nothing}
${!isOperator ? html`
<ha-button
@click=${() => this.shadowRoot!.querySelector<MaintenanceObjectDialog>("maintenance-object-dialog")?.openCreate()}
>
${t("new_object", L)}
</ha-button>
` : nothing}
<ha-button appearance="plain" @click=${() => this._exportObjectsCsv()}>
<ha-icon icon="mdi:file-delimited-outline"></ha-icon> ${t("settings_export_csv", L)}
</ha-button>
${archivedObjCount > 0 ? html`
<ha-button
class="archived-toggle ${this._showArchived ? "active" : ""}"
@click=${() => { this._showArchived = !this._showArchived; }}
>
<ha-icon icon="mdi:archive-outline"></ha-icon>
${this._showArchived ? t("hide_archived", L) : `${t("show_archived", L)} (${archivedObjCount})`}
</ha-button>
` : nothing}
</div>
${tableMode
? this._renderObjectsTable(sorted)
: this._groupByMode === "area"
? html`
${[...groupedByArea().entries()].map(([area, objs]) => html`
<details class="group-section" open>
<summary class="group-section-header">
<ha-icon icon="mdi:map-marker-outline"></ha-icon>
<span>${area}</span>
<span class="group-section-count">(${objs.length})</span>
</summary>
<div class="objects-grid">${objs.map(renderObject)}</div>
</details>
`)}
`
: html`<div class="objects-grid">${sorted.map(renderObject)}</div>`}
`;
}
private _setObjectViewMode(mode: "cards" | "table"): void {
this._objectViewMode = mode;
localStorage.setItem("maintenance_supporter_object_view", mode);
}
// (#67 / Phase 3) Download all objects as a one-row-per-object CSV.
private async _exportObjectsCsv(): Promise<void> {
try {
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/objects/csv",
}) as { csv: string };
const ts = new Date().toISOString().slice(0, 10);
downloadTextFile(result.csv, `maintenance_objects_${ts}.csv`, "text/csv;charset=utf-8");
} catch {
this._showToast(t("action_error", this._lang));
}
}
// (#67) Tabular All-Objects view honouring the configured columns. Desktop
// only — the caller falls back to cards on narrow viewports.
private _renderObjectsTable(objs: MaintenanceObjectResponse[]) {
const L = this._lang;
const cols = this._objectsTableColumns;
return html`
<div class="objects-table-wrap">
<table class="objects-table">
<thead>
<tr>
${cols.map((key) => {
const def = OBJECT_COLUMNS.find((c) => c.key === key);
// The actions column header stays blank (icon-only cells).
const label = def && def.key !== "actions" ? t(def.labelKey, L) : "";
return html`<th class="oc-${key}">${label}</th>`;
})}
</tr>
</thead>
<tbody>
${objs.map((obj) => html`
<tr class="objects-table-row" @click=${() => this._showObject(obj.entry_id)}>
${cols.map((key) => this._renderObjectCell(key, obj, L))}
</tr>
`)}
</tbody>
</table>
</div>
`;
}
private _renderObjectCell(key: string, obj: MaintenanceObjectResponse, L: string) {
const o = obj.object;
switch (key) {
case "name":
return html`<td class="oc-name">
<span class="objects-table-name">${o.name}</span>
${o.document_count
? html`<span class="doc-badge" title="${o.document_count} ${t("documents", L)}">
<ha-icon icon="mdi:paperclip"></ha-icon>${o.document_count}
</span>`
: nothing}
</td>`;
case "manufacturer":
return html`<td class="oc-manufacturer">${o.manufacturer || "—"}</td>`;
case "model":
return html`<td class="oc-model">${o.model || "—"}</td>`;
case "serial_number":
return html`<td class="oc-serial_number">${o.serial_number || "—"}</td>`;
case "installation_date":
return html`<td class="oc-installation_date">${o.installation_date ? formatDate(o.installation_date, L) : "—"}</td>`;
case "warranty_expiry":
return html`<td class="oc-warranty_expiry">${this._renderWarrantyCell(o.warranty_expiry, L)}</td>`;
case "area_id": {
const area = o.area_id ? (this.hass?.areas?.[o.area_id]?.name || o.area_id) : "—";
return html`<td class="oc-area_id">${area}</td>`;
}
case "documentation_url":
return html`<td class="oc-documentation_url">${
o.documentation_url && /^https?:\/\//i.test(o.documentation_url)
? html`<a href=${o.documentation_url} target="_blank" rel="noopener noreferrer"
@click=${(e: Event) => e.stopPropagation()}><ha-icon icon="mdi:file-document-outline"></ha-icon></a>`
: "—"
}</td>`;
case "notes":
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 "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 icon="mdi:qrcode"></ha-icon>
</mwc-icon-button>
</td>`;
default:
return html`<td></td>`;
}
}
// Warranty chip for a table cell — like _renderWarrantyMeta but renders an
// em-dash placeholder when the object has no warranty date.
private _renderWarrantyCell(iso: string | null | undefined, L: string) {
const ws = warrantyStatus(iso);
if (ws.kind === "none") return html`<span class="warranty-none">—</span>`;
return html`<span class="warranty-chip warranty-${ws.kind}">${this._warrantyLabel(ws, iso as string, L)}</span>`;
}
private async _onSettingsChanged(): Promise<void> {
await this._loadData();
}
private _renderGroupsSection() {
if (!this._features.groups) return nothing;
const entries = Object.entries(this._groups);
const L = this._lang;
return html`
<div class="groups-section">
<div class="groups-header">
<h3>${t("groups", L)}</h3>
<ha-button appearance="plain" @click=${() => this._openGroupCreate()}>
${t("new_group", L)}
</ha-button>
</div>
${entries.length === 0
? html`<div class="hint">${t("no_groups", L)}</div>`
: html`
<div class="groups-grid">
${entries.map(([gid, group]) => {
const taskNames = group.task_refs
.map((ref) => this._getTask(ref.entry_id, ref.task_id)?.name)
.filter(Boolean);
return html`
<div class="group-card">
<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-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-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>
</div>
</div>
${group.description ? html`<div class="group-card-desc">${group.description}</div>` : nothing}
<div class="group-card-tasks">
${taskNames.length > 0
? taskNames.map((n) => html`<span class="group-task-chip">${n}</span>`)
: html`<span style="font-size:12px;color:var(--secondary-text-color)">${t("no_tasks_short", L)}</span>`}
</div>
</div>
`;
})}
</div>
`}
</div>
`;
}
private _openGroupCreate(): void {
this.shadowRoot!.querySelector<MaintenanceGroupDialog>("maintenance-group-dialog")?.openCreate();
}
private _openGroupEdit(groupId: string): void {
const group = this._groups[groupId];
if (!group) return;
this.shadowRoot!.querySelector<MaintenanceGroupDialog>("maintenance-group-dialog")?.openEdit(groupId, group);
}
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)}`);
if (!ok) return;
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/group/delete",
group_id: groupId,
});
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
}
}
private _renderBudgetBar() {
const b = this._budget;
if (!b) return nothing;
const L = this._lang;
const cs = b.currency_symbol || DEFAULT_CURRENCY_SYMBOL;
const bars: { label: string; spent: number; budget: number }[] = [];
if (b.monthly_budget > 0) bars.push({ label: t("budget_monthly", L), spent: b.monthly_spent, budget: b.monthly_budget });
if (b.yearly_budget > 0) bars.push({ label: t("budget_yearly", L), spent: b.yearly_spent, budget: b.yearly_budget });
if (bars.length === 0) return nothing;
return html`
<div class="budget-bars">
${bars.map((bar) => {
const pct = Math.min(100, Math.max(0, (bar.spent / bar.budget) * 100));
const color = pct >= 100 ? "var(--error-color, #f44336)" : pct >= b.alert_threshold_pct ? "var(--warning-color, #ff9800)" : "var(--success-color, #4caf50)";
return html`
<div class="budget-item">
<div class="budget-label">
<span>${bar.label}</span>
<span>${bar.spent.toFixed(2)} / ${bar.budget.toFixed(2)} ${cs}</span>
</div>
<div class="budget-bar">
<div class="budget-bar-fill" style="width:${pct}%; background:${color}"></div>
</div>
</div>
`;
})}
</div>
`;
}
private _renderOverviewRow(row: TaskRow) {
const L = this._lang;
// Compute days progress bar
const hasDaysBar = row.schedule_type === "time_based" && row.interval_days && row.interval_days > 0;
let pct = 0;
let barColor = STATUS_COLORS.ok;
let daysOverflow = false;
if (hasDaysBar && row.days_until_due !== null) {
const p = daysProgress(row.interval_days, row.days_until_due, row.interval_unit);
pct = p.pct;
daysOverflow = p.overflow;
if (row.status === "overdue") barColor = STATUS_COLORS.overdue;
else if (row.status === "due_soon") barColor = STATUS_COLORS.due_soon;
}
const areaName = row.area_id ? this.hass?.areas?.[row.area_id]?.name : null;
const userName = row.responsible_user_id ? this._userService?.getUserName(row.responsible_user_id) : null;
const hasSub = row.group_names.length > 0 || areaName || userName;
const bulkSelected = this._bulkMode && this._bulkSelected.has(this._bulkKey(row));
return html`
<div class="task-row${!row.enabled ? ' task-disabled' : ''}${bulkSelected ? ' bulk-selected' : ''}">
${this._bulkMode ? html`
<label class="cell bulk-check" @click=${(e: Event) => e.stopPropagation()}>
<input type="checkbox" .checked=${bulkSelected} @change=${() => this._toggleBulkRow(row)} />
</label>
` : nothing}
<span class="cell-badges">
${this._statusBadge(!!row.archived, row.is_done, row.status)}
${!row.enabled ? html`<span class="badge-disabled">${t("disabled", L)}</span>` : nothing}
${row.nfc_tag_id ? html`<span class="nfc-badge" title="${t("nfc_linked", L)}"><ha-icon icon="mdi:nfc-variant"></ha-icon></span>` : nothing}
${row.priority === "high" ? html`<span class="priority-badge priority-high" title="${t("priority_high", L)}"><ha-icon icon="mdi:chevron-double-up"></ha-icon></span>` : nothing}
${row.priority === "low" ? html`<span class="priority-badge priority-low" title="${t("priority_low", L)}"><ha-icon icon="mdi:chevron-double-down"></ha-icon></span>` : nothing}
</span>
<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)}>${row.task_name}</span>
<span class="task-sub${hasSub ? '' : ' task-sub-empty'}">
${row.group_names.length > 0 ? html`
<span class="sub-chip" title="${t("groups", L)}">
<ha-icon icon="mdi:folder-outline"></ha-icon>${row.group_names.join(", ")}
</span>` : nothing}
${areaName ? html`
<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}
${(row.labels || []).map((label) => html`
<span class="sub-chip label-chip" title="${t("labels", L)}">
<ha-icon icon="mdi:tag-outline"></ha-icon>${label}
</span>`)}
</span>
<span class="cell type">${t(row.type, L)}</span>
<span class="due-cell" @click=${() => this._showTask(row.entry_id, row.task_id)}>
<span class="due-text">${formatDueDays(row.days_until_due, L)}</span>
${hasDaysBar
? html`<div class="days-bar"><div class="days-bar-fill${daysOverflow ? " overflow" : ""}" style="width:${pct}%;background:${barColor}"></div></div>`
: nothing}
${row.trigger_config
? renderTriggerProgress(row)
: !hasDaysBar && row.trigger_active
? html`<span style="color:var(--maint-triggered-color);font-weight:600">⚡</span>`
: nothing}
${renderMiniSparkline(row, this._miniStatsData, this._lang)}
</span>
<span class="row-actions">
<mwc-icon-button class="btn-complete" title="${t("complete", L)}" @click=${(e: Event) => { e.stopPropagation(); this._openCompleteDialogForRow(row); }}>
<ha-icon icon="mdi:check"></ha-icon>
</mwc-icon-button>
<mwc-icon-button class="btn-skip" title="${t("skip", L)}" .disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); this._promptSkipTask(row.entry_id, row.task_id); }}>
<ha-icon icon="mdi:skip-next"></ha-icon>
</mwc-icon-button>
</span>
</div>
`;
}
private _openCompleteDialogForRow(row: TaskRow): void {
const obj = this._objects.find(o => o.entry_id === row.entry_id);
const task = obj?.tasks.find(t => t.id === row.task_id);
this._openCompleteDialog(
row.entry_id, row.task_id, row.task_name,
this._features.checklists ? task?.checklist : undefined,
this._features.adaptive && !!task?.adaptive_config?.enabled
);
}
/**
* Render a compact trigger progress bar for overview rows.
* Works for threshold (value vs limit), counter (value/delta vs target), state_change (count vs target).
*/
private _renderObjectDetail() {
if (!this._selectedEntryId) return nothing;
const obj = this._getObject(this._selectedEntryId);
if (!obj) return html`<p>Object not found.</p>`;
const o = obj.object;
const L = this._lang;
const isOperator = this._isOperator;
// v2.10.0: archived tasks are hidden in the object detail too, unless the
// shared show-archived toggle is on; a per-detail toggle reveals them when
// this object has any (e.g. a task archived individually on an active object).
const archivedInObj = obj.tasks.filter((tk) => tk.archived).length;
const visibleTasks = obj.tasks.filter((tk) => this._showArchived || !tk.archived);
return html`
<div class="detail-section">
<div class="detail-header">
<h2>${o.name}</h2>
<div class="action-buttons">
${!isOperator ? html`
<ha-button appearance="plain" @click=${() => {
const dlg = this.shadowRoot!.querySelector<MaintenanceObjectDialog>("maintenance-object-dialog");
dlg?.openEdit(obj.entry_id, o);
}}>${t("edit", L)}</ha-button>
<ha-button appearance="filled" @click=${() => {
const dlg = this.shadowRoot!.querySelector<MaintenanceTaskDialog>("maintenance-task-dialog");
dlg?.openCreate(obj.entry_id);
}}>${t("add_task", L)}</ha-button>
<ha-button appearance="plain" .disabled=${this._actionLoading} @click=${() => this._duplicateObject(obj.entry_id)}>
<ha-icon icon="mdi:content-copy"></ha-icon> ${t("duplicate", L)}
</ha-button>
<ha-button appearance="plain" @click=${() => this._toggleArchiveObject(obj.entry_id, !!o.archived)}>
<ha-icon icon="${o.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'}"></ha-icon>
${o.archived ? t("unarchive_object", L) : t("archive_object", L)}
</ha-button>
${!o.archived ? html`
<ha-button appearance="plain" @click=${() => this._togglePauseObject(obj.entry_id, !!o.paused)}>
<ha-icon icon="${o.paused ? 'mdi:play-circle-outline' : 'mdi:pause-circle-outline'}"></ha-icon>
${o.paused ? t("resume_object", L) : t("pause_object", L)}
</ha-button>
<ha-button appearance="plain" .disabled=${this._actionLoading} @click=${() => this._replaceObject(obj.entry_id, o.name)}>
<ha-icon icon="mdi:swap-horizontal"></ha-icon> ${t("replace_object", L)}
</ha-button>
` : nothing}
<ha-button variant="danger" appearance="plain" @click=${() => this._deleteObject(obj.entry_id)}>${t("delete", L)}</ha-button>
` : nothing}
<ha-button appearance="plain" @click=${() => this._openQrForObject(obj.entry_id, o.name)}><ha-icon icon="mdi:qrcode"></ha-icon> ${t("qr_code", L)}</ha-button>
<ha-button appearance="plain" @click=${() => this._printObjectReport(obj.entry_id)}><ha-icon icon="mdi:file-document-outline"></ha-icon> ${t("report_button", L)}</ha-button>
</div>
</div>
${o.paused
? html`<p class="meta paused-meta">
<ha-icon icon="mdi:pause-circle-outline"></ha-icon>
${t("object_paused_badge", L)}${o.paused_until
? html` — ${t("paused_until_label", L)} ${formatDate(o.paused_until, L)}`
: nothing}
</p>`
: nothing}
${o.manufacturer || o.model
? 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}
${o.documentation_url && /^https?:\/\//i.test(o.documentation_url)
? html`<p class="meta">${t("documentation_url_label", L)}:
<a href=${o.documentation_url} target="_blank" rel="noopener noreferrer">${o.documentation_url}</a>
</p>`
: nothing}
${o.installation_date ? html`<p class="meta">${t("installed", L)}: ${formatDate(o.installation_date, L)}</p>` : nothing}
${o.warranty_expiry ? this._renderWarrantyMeta(o.warranty_expiry, L) : nothing}
${o.notes
? html`<div class="object-notes">
<div class="object-notes-label">${t("object_notes_label", L)}</div>
<div class="object-notes-body">${o.notes}</div>
</div>`
: nothing}
<maintenance-documents-section
.hass=${this.hass}
.entryId=${obj.entry_id}
.canWrite=${!isOperator}
></maintenance-documents-section>
<h3>${t("tasks", L)} (${visibleTasks.length})${archivedInObj > 0 ? html`
<ha-button
class="archived-toggle ${this._showArchived ? "active" : ""}"
appearance="plain"
@click=${() => { this._showArchived = !this._showArchived; }}
>
<ha-icon icon="mdi:archive-outline"></ha-icon>
${this._showArchived ? t("hide_archived", L) : `${t("show_archived", L)} (${archivedInObj})`}
</ha-button>` : nothing}</h3>
${obj.tasks.length === 0
? html`<div class="empty-state-centered">
<p class="empty">${t("no_tasks_yet", L)}</p>
<ha-button appearance="filled" @click=${() => {
const dlg = this.shadowRoot!.querySelector<MaintenanceTaskDialog>("maintenance-task-dialog");
dlg?.openCreate(obj.entry_id);
}}>${t("add_first_task", L)}</ha-button>
</div>`
: html`<div class="task-table">${[...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`
<div class="task-row${!task.enabled ? ' task-disabled' : ''}">
<span class="cell-badges">
${this._statusBadge(!!task.archived, !!task.is_done, task.status)}
${!task.enabled ? html`<span class="badge-disabled">${t("disabled", L)}</span>` : nothing}
${task.nfc_tag_id ? html`<span class="nfc-badge" title="${t("nfc_linked", L)}"><ha-icon icon="mdi:nfc-variant"></ha-icon></span>` : 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="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>
${task.trigger_config
? renderTriggerProgress(task)
: nothing}
${renderMiniSparkline(task, this._miniStatsData, this._lang)}
</span>
<span class="row-actions">
<mwc-icon-button class="btn-complete" title="${t("complete", L)}" @click=${(e: Event) => { e.stopPropagation(); this._openCompleteDialog(obj.entry_id, task.id, task.name, this._features.checklists ? task.checklist : undefined, this._features.adaptive && !!task.adaptive_config?.enabled); }}>
<ha-icon icon="mdi:check"></ha-icon>
</mwc-icon-button>
<mwc-icon-button class="btn-skip" title="${t("skip", L)}" .disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); this._promptSkipTask(obj.entry_id, task.id); }}>
<ha-icon icon="mdi:skip-next"></ha-icon>
</mwc-icon-button>
</span>
</div>
`)}</div>`}
</div>
`;
}
/**
* Render compact task header with status chip and action buttons.
*/
private _toggleMoreMenu(): void {
this._moreMenuOpen = !this._moreMenuOpen;
if (this._moreMenuOpen) {
// Close menu on next outside click
const handler = () => { this._moreMenuOpen = false; document.removeEventListener("click", handler); };
setTimeout(() => document.addEventListener("click", handler, { once: true }), 0);
}
}
private _closeMoreMenu(): void {
this._moreMenuOpen = false;
}
private get _sparklineCtx(): SparklineContext {
return {
lang: this._lang,
detailStatsData: this._detailStatsData,
hasStatsService: !!this._statsService,
isCounterEntity: (tc) => this._isCounterEntity(tc),
rangeDays: this._chartRangeDays,
setRangeDays: (days) => this._setChartRange(days),
hideOutliers: this._hideOutliers,
setHideOutliers: (hide) => this._setHideOutliers(hide),
};
}
private _toggleSection(key: string): void {
const next = new Set(this._collapsedSections);
if (next.has(key)) next.delete(key); else next.add(key);
this._collapsedSections = next;
try { localStorage.setItem("msp-collapsed-sections", JSON.stringify([...next])); } catch { /* private mode */ }
}
/** Build the context the history renderers need from panel state. */
private _historyCtx(): HistoryContext {
// v2.20 (#83): reading tasks show value + delta per entry. The delta is
// against the chronologically PREVIOUS entry that carries a reading.
const task = this._selectedEntryId && this._selectedTaskId
? this._getObject(this._selectedEntryId)?.tasks.find((tk) => tk.id === this._selectedTaskId)
: undefined;
const readings = (task?.history || [])
.filter((h) => h.reading_value != null)
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
return {
lang: this._lang,
hass: this.hass,
filter: this._historyFilter,
search: this._historySearch,
currencySymbol: this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL,
setFilter: (f) => { this._historyFilter = f; },
setSearch: (s) => { this._historySearch = s; },
openEdit: (entry) => this._openHistoryEdit(entry),
readingUnit: task?.reading_unit ?? null,
readingDelta: (entry) => {
const idx = readings.findIndex((h) => h.timestamp === entry.timestamp);
if (idx <= 0) return null;
return (entry.reading_value as number) - (readings[idx - 1].reading_value as number);
},
};
}
/** Build the context the task-detail renderers need from panel state.
* Dialog-opening callbacks stay panel-side (the dialogs live in THIS
* shadow root), so the extracted module never touches a dialog itself. */
private _taskDetailCtx(): TaskDetailContext {
const entryId = this._selectedEntryId!;
const taskId = this._selectedTaskId!;
const obj = this._getObject(entryId);
return {
lang: this._lang,
hass: this.hass,
entryId,
taskId,
objectName: obj?.object.name || "",
objectDocUrl: obj?.object?.documentation_url ?? null,
isOperator: this._isOperator,
actionLoading: this._actionLoading,
moreMenuOpen: this._moreMenuOpen,
activeTab: this._activeTab,
features: this._features,
currencySymbol: this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL,
collapsedSections: this._collapsedSections,
costDurationToggle: this._costDurationToggle,
suggestionDismissed: this._dismissedSuggestions.has(`${entryId}_${taskId}`),
sparkline: this._sparklineCtx,
history: this._historyCtx(),
getUserName: (id) => this._userService?.getUserName(id) ?? null,
setActiveTab: (tab) => { this._activeTab = tab; },
toggleSection: (key) => this._toggleSection(key),
setCostDurationToggle: (v) => { this._costDurationToggle = v; },
showTaskView: () => { this._view = "task"; },
showObject: () => this._showObject(entryId),
toggleMoreMenu: () => this._toggleMoreMenu(),
closeMoreMenu: () => this._closeMoreMenu(),
openEdit: (tk) => { this.shadowRoot!.querySelector<MaintenanceTaskDialog>("maintenance-task-dialog")?.openEdit(entryId, tk); },
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),
openQr: (taskName) => this._openQrForTask(entryId, taskId, obj?.object.name || "", taskName),
duplicateTask: () => this._duplicateTask(entryId, taskId),
promptReset: () => this._promptResetTask(entryId, taskId),
promptPostpone: () => this._promptPostponeTask(entryId, taskId),
snoozeTask: () => this._snoozeTask(entryId, taskId),
printWorksheet: () => this._printTaskWorksheet(entryId, taskId),
deleteTask: () => this._deleteTask(entryId, taskId),
applySuggestion: (interval) => this._applySuggestion(entryId, taskId, interval),
reanalyze: () => this._reanalyzeInterval(entryId, taskId),
dismissSuggestion: () => this._dismissSuggestion(entryId, taskId),
openSeasonalOverrides: (tk) => this._openSeasonalOverrides(tk),
};
}
private _renderTaskDetail() {
if (!this._selectedEntryId || !this._selectedTaskId) return nothing;
const task = this._getTask(this._selectedEntryId, this._selectedTaskId);
if (!task) return html`<p>Task not found.</p>`;
return html`<maintenance-task-detail-view
.task=${task}
.ctx=${this._taskDetailCtx()}
></maintenance-task-detail-view>`;
}
/** v2.2.0: open the in-place history-edit dialog for the given entry.
* After save, the dialog fires `history-entry-saved` which we listen
* for in the template to trigger a data refresh. */
private _openHistoryEdit(entry: HistoryEntry): void {
if (!this._selectedEntryId || !this._selectedTaskId) return;
const draft: HistoryEntryDraft = {
entry_id: this._selectedEntryId,
task_id: this._selectedTaskId,
original_timestamp: entry.timestamp,
type: entry.type,
timestamp: entry.timestamp,
notes: entry.notes ?? null,
cost: entry.cost ?? null,
duration: entry.duration ?? null,
completed_by: entry.completed_by ?? null,
};
this.shadowRoot
?.querySelector<MaintenanceHistoryEditDialog>("maintenance-history-edit-dialog")
?.openEdit(draft);
}
/** Triggered by the dialog after a successful WS save. Refresh the loaded
* data so the user sees the updated entry. */
private _onHistoryEntrySaved = async (): Promise<void> => {
await this._loadData();
};
static styles = [sharedStyles, panelStyles];
}