/** 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 = {}; @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 = {}; @state() private _detailStatsData: Map = new Map(); @state() private _miniStatsData: Map = 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 | null = null; private _dismissedSuggestions = new Set(); // 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(); // 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 = (() => { 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 = {}; @state() private _templateBusy = false; // v1.5.0: Calendar tab state // v2.0.0: window-days + user-filter state moved into the // 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): 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(".content"); const table = this.shadowRoot?.querySelector(".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(".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 { 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 }).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("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 { 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 { 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 { 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 = { 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 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(".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`
this._closePalette()}>
e.stopPropagation()}> { this._paletteQuery = (e.target as HTMLInputElement).value; this._paletteActive = 0; }} />
${results.length === 0 ? html`
${t("palette_no_results", L)}
` : results.map((r, i) => html`
{ this._paletteActive = i; }} @click=${() => this._selectPaletteResult(r)}> ${r.label} ${r.sub}
`)}
${t("palette_hint", L)}
`; } // --- Template gallery --- private async _openTemplateGallery(): Promise { this._templateGalleryOpen = true; if (this._templates.length > 0) return; try { const res = await this.hass.connection.sendMessagePromise<{ categories: Record; 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 { 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 | 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(); for (const tpl of this._templates) { if (!byCat.has(tpl.category)) byCat.set(tpl.category, []); byCat.get(tpl.category)!.push(tpl); } return html`
{ this._templateGalleryOpen = false; }}>
`; } // --- 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, doneMsg: (n: number) => string, undo?: () => void, ): Promise { 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 { const dlg = this.shadowRoot!.querySelector("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 { 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 { const dlg = this.shadowRoot!.querySelector("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 { 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 { 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 { 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 { if (!paused) { const dlg = this.shadowRoot!.querySelector("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 = { 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 { const dlg = this.shadowRoot!.querySelector("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 { this._actionLoading = true; try { const msg: Record = { 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 { this._actionLoading = true; try { const msg: Record = { 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 { 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("maintenance-seasonal-overrides-dialog"); if (!dlg || !this._selectedEntryId) return; const overrides = task.adaptive_config?.seasonal_overrides as Record | null | undefined; dlg.open(this._selectedEntryId, task.id, overrides); } private async _reanalyzeInterval(entryId: string, taskId: string): Promise { 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 { const dlg = this.shadowRoot!.querySelector("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 { const dlg = this.shadowRoot!.querySelector("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 { 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 { const dlg = this.shadowRoot!.querySelector("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 { 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 { 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 { 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 = { 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 }>; }>({ 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("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("maintenance-qr-dialog"); dlg?.openForObject(entryId, objectName); } private _openQrForTask(entryId: string, taskId: string, objectName: string, taskName: string): void { const dlg = this.shadowRoot!.querySelector("maintenance-qr-dialog"); dlg?.openForTask(entryId, taskId, objectName, taskName); } private _onDialogEvent = async (): Promise => { try { await this._loadData(); } catch { /* subscription will sync */ } }; // --- Render --- render() { return html`
${this.narrow || this._view !== "overview" ? this._renderHeader() : nothing}
${this._view === "overview" ? this._renderOverview() : this._view === "all_objects" ? this._renderAllObjects() : this._view === "object" ? this._renderObjectDetail() : this._renderTaskDetail()}
${this._toastMessage ? html`
${this._toastMessage} ${this._toastUndo ? html`` : nothing}
` : 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`
${this.narrow ? html`` : nothing} ${this._view !== "overview" ? html` { if (this._view === "task") this._showObject(this._selectedEntryId!); else this._showOverview(); }} >` : nothing}
`; } 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`
this._showAllObjects()} title=${t("show_all_objects", L)}> ${s.total_objects} ${t("objects", L)}
this._filterByStatus("")} title=${t("show_all_tasks", L)}> ${s.total_tasks} ${t("tasks", L)}
this._filterByStatus("overdue")} title=${t("filter_to_overdue", L)}> ${s.overdue} ${t("overdue", L)}
this._filterByStatus("due_soon")} title=${t("filter_to_due_soon", L)}> ${s.due_soon} ${t("due_soon", L)}
this._filterByStatus("triggered")} title=${t("filter_to_triggered", L)}> ${s.triggered} ${t("triggered", L)}
` : nothing}
this._setOverviewTab("today")}> ${t("tab_today", L)}
this._setOverviewTab("dashboard")}> ${t("dashboard", L)}
this._setOverviewTab("calendar")}> ${t("tab_calendar", L)}
${isAdmin ? html`
this._setOverviewTab("settings")}> ${t("settings", L)}
` : nothing}
${this._overviewTab === "today" ? this._renderToday() : this._overviewTab === "dashboard" ? this._renderDashboard() : this._overviewTab === "calendar" ? html`
` : html``} `; } /** 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 // . 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`${label}`; } 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`

${t("today_all_caught_up", L)}

`; } return html`
${this._renderTodaySection("today_overdue", overdue, "overdue")} ${this._renderTodaySection("today_due_today", dueToday, "due_soon")} ${this._renderTodaySection("today_this_week", thisWeek, "")}
`; } private _renderTodaySection(titleKey: string, rows: TaskRow[], cls: string) { if (rows.length === 0) return nothing; const L = this._lang; return html`
${t(titleKey, L)}${rows.length}
${rows.map((row) => html`
this._showTask(row.entry_id, row.task_id)}>
${row.task_name}
${row.object_name} · ${formatDueDays(row.days_until_due, L)}
{ e.stopPropagation(); this._openCompleteDialogForRow(row); }}>
`)}
`; } 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}
${archivedCount > 0 ? html` { this._showArchived = !this._showArchived; }} > ${this._showArchived ? t("hide_archived", L) : `${t("show_archived", L)} (${archivedCount})`} ` : nothing} ${!isOperator && rows.length > 0 ? html` this._toggleBulkMode()} > ${this._bulkMode ? t("cancel", L) : t("bulk_select", L)} ` : nothing} ${!isOperator ? html` this.shadowRoot!.querySelector("maintenance-object-dialog")?.openCreate()} > ${t("new_object", L)} this._openTemplateGallery()}> ${t("templates_from", L)} this.shadowRoot!.querySelector("maintenance-task-dialog")?.openCreate("", this._objects)} > ${t("new_task", L)} ` : nothing}
${rows.length === 0 ? html`

${t("no_tasks", L)}

${!isOperator && this._objects.length === 0 ? html`

${t("onboard_hint", L)}

this._openTemplateGallery()}> ${t("templates_from", L)} this.shadowRoot!.querySelector("maintenance-object-dialog")?.openCreate()}> ${t("new_object", L)}
` : nothing}
` : 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`) => { const eid = e.detail?.entry_id; if (eid) this._showObject(eid); }} >` : 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`
${rows.map((row) => this._renderOverviewRow(row))}
`; } 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`
${this._renderVirtSizerRow(rows)} ${padTop > 0 ? html`
` : nothing} ${rows.slice(start, end).map((row) => this._renderOverviewRow(row))} ${padBottom > 0 ? html`
` : nothing}
`; } /** 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` `; } 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`
${t("bulk_n_selected", L).replace("{n}", String(n))} this._bulkComplete(rows)}> ${t("complete", L)} this._bulkArchive(rows)}> ${t("archive", L)}
`; } private _renderGroupedTasks(rows: TaskRow[], L: string) { const groups = new Map(); 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`
${key} (${taskRows.length})
${taskRows.map((row) => this._renderOverviewRow(row))}
`)} `; } // (#67) Localised warranty label, shared by the detail meta + table cell. private _warrantyLabel(ws: ReturnType, 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`

${t("warranty", L)}: ${this._warrantyLabel(ws, iso, L)}

`; } 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 => { const map = new Map(); 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`
this._showObject(obj.entry_id)}> ${overdue ? html`` : nothing}
${obj.object.name} ${obj.object.paused ? html` ` : nothing} ${obj.object.document_count ? html` ${obj.object.document_count} ` : nothing} ${obj.tasks.length} ${t("tasks_lower", L)}
${obj.object.manufacturer || obj.object.model ? html`
${[obj.object.manufacturer, obj.object.model].filter(Boolean).join(" ")}
` : nothing} ${obj.tasks.length === 0 ? html`
${t("no_tasks_yet", L)}
` : nothing}
`; }; return html`
${!this.narrow ? html`
` : nothing} ${!tableMode ? html` ` : nothing} ${!isOperator ? html` this.shadowRoot!.querySelector("maintenance-object-dialog")?.openCreate()} > ${t("new_object", L)} ` : nothing} this._exportObjectsCsv()}> ${t("settings_export_csv", L)} ${archivedObjCount > 0 ? html` { this._showArchived = !this._showArchived; }} > ${this._showArchived ? t("hide_archived", L) : `${t("show_archived", L)} (${archivedObjCount})`} ` : nothing}
${tableMode ? this._renderObjectsTable(sorted) : this._groupByMode === "area" ? html` ${[...groupedByArea().entries()].map(([area, objs]) => html`
${area} (${objs.length})
${objs.map(renderObject)}
`)} ` : html`
${sorted.map(renderObject)}
`} `; } 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 { 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`
${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``; })} ${objs.map((obj) => html` this._showObject(obj.entry_id)}> ${cols.map((key) => this._renderObjectCell(key, obj, L))} `)}
${label}
`; } private _renderObjectCell(key: string, obj: MaintenanceObjectResponse, L: string) { const o = obj.object; switch (key) { case "name": return html` ${o.name} ${o.document_count ? html` ${o.document_count} ` : nothing} `; case "manufacturer": return html`${o.manufacturer || "—"}`; case "model": return html`${o.model || "—"}`; case "serial_number": return html`${o.serial_number || "—"}`; case "installation_date": return html`${o.installation_date ? formatDate(o.installation_date, L) : "—"}`; case "warranty_expiry": return html`${this._renderWarrantyCell(o.warranty_expiry, L)}`; case "area_id": { const area = o.area_id ? (this.hass?.areas?.[o.area_id]?.name || o.area_id) : "—"; return html`${area}`; } case "documentation_url": return html`${ o.documentation_url && /^https?:\/\//i.test(o.documentation_url) ? html` e.stopPropagation()}>` : "—" }`; case "notes": return html`${o.notes || "—"}`; case "task_count": return html`${obj.tasks.length}`; case "actions": return html` { e.stopPropagation(); this._openQrForObject(obj.entry_id, o.name); }}> `; default: return html``; } } // 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``; return html`${this._warrantyLabel(ws, iso as string, L)}`; } private async _onSettingsChanged(): Promise { await this._loadData(); } private _renderGroupsSection() { if (!this._features.groups) return nothing; const entries = Object.entries(this._groups); const L = this._lang; return html`

${t("groups", L)}

this._openGroupCreate()}> ${t("new_group", L)}
${entries.length === 0 ? html`
${t("no_groups", L)}
` : html`
${entries.map(([gid, group]) => { const taskNames = group.task_refs .map((ref) => this._getTask(ref.entry_id, ref.task_id)?.name) .filter(Boolean); return html`
${group.name}
this._openGroupEdit(gid)}> this._deleteGroup(gid, group.name)}>
${group.description ? html`
${group.description}
` : nothing}
${taskNames.length > 0 ? taskNames.map((n) => html`${n}`) : html`${t("no_tasks_short", L)}`}
`; })}
`}
`; } private _openGroupCreate(): void { this.shadowRoot!.querySelector("maintenance-group-dialog")?.openCreate(); } private _openGroupEdit(groupId: string): void { const group = this._groups[groupId]; if (!group) return; this.shadowRoot!.querySelector("maintenance-group-dialog")?.openEdit(groupId, group); } private async _deleteGroup(groupId: string, name: string): Promise { const dlg = this.shadowRoot!.querySelector("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`
${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`
${bar.label} ${bar.spent.toFixed(2)} / ${bar.budget.toFixed(2)} ${cs}
`; })}
`; } 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`
${this._bulkMode ? html` ` : nothing} ${this._statusBadge(!!row.archived, row.is_done, row.status)} ${!row.enabled ? html`${t("disabled", L)}` : nothing} ${row.nfc_tag_id ? html`` : nothing} ${row.priority === "high" ? html`` : nothing} ${row.priority === "low" ? html`` : nothing} { e.stopPropagation(); this._showObject(row.entry_id); }}>${row.object_name} this._showTask(row.entry_id, row.task_id)}>${row.task_name} ${row.group_names.length > 0 ? html` ${row.group_names.join(", ")} ` : nothing} ${areaName ? html` ${areaName} ` : nothing} ${userName ? html` ${userName} ` : nothing} ${(row.labels || []).map((label) => html` ${label} `)} ${t(row.type, L)} this._showTask(row.entry_id, row.task_id)}> ${formatDueDays(row.days_until_due, L)} ${hasDaysBar ? html`
` : nothing} ${row.trigger_config ? renderTriggerProgress(row) : !hasDaysBar && row.trigger_active ? html`` : nothing} ${renderMiniSparkline(row, this._miniStatsData, this._lang)}
{ e.stopPropagation(); this._openCompleteDialogForRow(row); }}> { e.stopPropagation(); this._promptSkipTask(row.entry_id, row.task_id); }}>
`; } 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`

Object not found.

`; 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`

${o.name}

${!isOperator ? html` { const dlg = this.shadowRoot!.querySelector("maintenance-object-dialog"); dlg?.openEdit(obj.entry_id, o); }}>${t("edit", L)} { const dlg = this.shadowRoot!.querySelector("maintenance-task-dialog"); dlg?.openCreate(obj.entry_id); }}>${t("add_task", L)} this._duplicateObject(obj.entry_id)}> ${t("duplicate", L)} this._toggleArchiveObject(obj.entry_id, !!o.archived)}> ${o.archived ? t("unarchive_object", L) : t("archive_object", L)} ${!o.archived ? html` this._togglePauseObject(obj.entry_id, !!o.paused)}> ${o.paused ? t("resume_object", L) : t("pause_object", L)} this._replaceObject(obj.entry_id, o.name)}> ${t("replace_object", L)} ` : nothing} this._deleteObject(obj.entry_id)}>${t("delete", L)} ` : nothing} this._openQrForObject(obj.entry_id, o.name)}> ${t("qr_code", L)} this._printObjectReport(obj.entry_id)}> ${t("report_button", L)}
${o.paused ? html`

${t("object_paused_badge", L)}${o.paused_until ? html` — ${t("paused_until_label", L)} ${formatDate(o.paused_until, L)}` : nothing}

` : nothing} ${o.manufacturer || o.model ? html`

${[o.manufacturer, o.model].filter(Boolean).join(" ")}

` : nothing} ${o.serial_number ? html`

${t("serial_number_label", L)}: ${o.serial_number}

` : nothing} ${o.documentation_url && /^https?:\/\//i.test(o.documentation_url) ? html`

${t("documentation_url_label", L)}: ${o.documentation_url}

` : nothing} ${o.installation_date ? html`

${t("installed", L)}: ${formatDate(o.installation_date, L)}

` : nothing} ${o.warranty_expiry ? this._renderWarrantyMeta(o.warranty_expiry, L) : nothing} ${o.notes ? html`
${t("object_notes_label", L)}
${o.notes}
` : nothing}

${t("tasks", L)} (${visibleTasks.length})${archivedInObj > 0 ? html` { this._showArchived = !this._showArchived; }} > ${this._showArchived ? t("hide_archived", L) : `${t("show_archived", L)} (${archivedInObj})`} ` : nothing}

${obj.tasks.length === 0 ? html`

${t("no_tasks_yet", L)}

{ const dlg = this.shadowRoot!.querySelector("maintenance-task-dialog"); dlg?.openCreate(obj.entry_id); }}>${t("add_first_task", L)}
` : html`
${[...visibleTasks].sort((a, b) => { const so: Record = { 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`
${this._statusBadge(!!task.archived, !!task.is_done, task.status)} ${!task.enabled ? html`${t("disabled", L)}` : nothing} ${task.nfc_tag_id ? html`` : nothing} this._showTask(obj.entry_id, task.id)}>${task.name} ${renderUserBadge(task, (id) => this._userService?.getUserName(id) ?? null)} ${t(task.type, L)} this._showTask(obj.entry_id, task.id)}> ${formatDueDays(task.days_until_due, L)} ${task.trigger_config ? renderTriggerProgress(task) : nothing} ${renderMiniSparkline(task, this._miniStatsData, this._lang)} { e.stopPropagation(); this._openCompleteDialog(obj.entry_id, task.id, task.name, this._features.checklists ? task.checklist : undefined, this._features.adaptive && !!task.adaptive_config?.enabled); }}> { e.stopPropagation(); this._promptSkipTask(obj.entry_id, task.id); }}>
`)}
`}
`; } /** * 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("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`

Task not found.

`; return html``; } /** 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("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 => { await this._loadData(); }; static styles = [sharedStyles, panelStyles]; }