updated apps

This commit is contained in:
2026-07-14 23:57:03 -04:00
parent 6cc7212cef
commit 010e828e9c
797 changed files with 45153 additions and 4246 deletions
@@ -1,8 +1,10 @@
/** Maintenance Supporter Sidebar Panel. */
import { LitElement, html, nothing } from "lit";
import { isSafeHttpUrl } from "./helpers/url";
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 { LS_KEYS } from "./helpers/storage-keys";
import { daysProgress } from "./helpers/interval";
import { buildObjectReportHtml, type ReportLabels } from "./helpers/report";
import { warrantyStatus } from "./helpers/warranty";
@@ -23,12 +25,15 @@ import type {
HistoryEntry,
TriggerConfig,
StatisticsPoint,
SavedView,
SavedViewFilters,
} 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/parts-section";
import "./components/task-documents";
import "./components/task-dialog";
import type { MaintenanceTaskDialog } from "./components/task-dialog";
@@ -36,6 +41,8 @@ import "./components/complete-dialog";
import type { MaintenanceCompleteDialog } from "./components/complete-dialog";
import "./components/qr-dialog";
import type { MaintenanceQrDialog } from "./components/qr-dialog";
import "./components/adopt-problem-sensors-dialog";
import type { MaintenanceAdoptProblemSensorsDialog } from "./components/adopt-problem-sensors-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";
@@ -53,6 +60,8 @@ 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 "./components/saved-views-dialog";
import type { MaintenanceSavedViewsDialog } from "./components/saved-views-dialog";
import { type SparklineContext } from "./renderers/sparkline";
import { buildCalendarBuckets, isoDateLocal, type CalendarEvent } from "./helpers/calendar-bucket";
import { renderTriggerProgress, renderMiniSparkline } from "./renderers/progress";
@@ -84,17 +93,22 @@ export class MaintenanceSupporterPanel extends LitElement {
@state() private _selectedTaskId: string | null = null;
@state() private _filterStatus = "";
@state() private _filterUser: string | null = null;
@state() private _filterLabel: string | null = null;
// v2.24: shared saved filter views + the id of the one currently applied ("" =
// none; cleared the moment the user hand-edits any filter control).
@state() private _savedViews: SavedView[] = [];
@state() private _activeViewId = "";
@state() private _unsub: (() => void) | null = null;
@state() private _chartRangeDays = (() => {
try {
const v = parseInt(localStorage.getItem("msp-chart-range") || "", 10);
const v = parseInt(localStorage.getItem(LS_KEYS.chartRange) || "", 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; }
try { return localStorage.getItem(LS_KEYS.chartHideOutliers) === "1"; } catch { return false; }
})();
@state() private _historyFilter: string | null = null;
@state() private _budget: BudgetStatus | null = null;
@@ -121,7 +135,7 @@ export class MaintenanceSupporterPanel extends LitElement {
// Dashboard redesign state
@state() private _overviewTab: "today" | "dashboard" | "calendar" | "settings" = (() => {
try {
const v = localStorage.getItem("msp-overview-tab");
const v = localStorage.getItem(LS_KEYS.overviewTab);
return v === "today" || v === "calendar" ? v : "dashboard";
} catch { return "dashboard"; }
})();
@@ -153,7 +167,7 @@ export class MaintenanceSupporterPanel extends LitElement {
// 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") || "[]")); }
try { return new Set(JSON.parse(localStorage.getItem(LS_KEYS.collapsedSections) || "[]")); }
catch { return new Set(); }
})();
// v2.15.0: command palette ("/" since 2.18.1 — Ctrl+K clashed with HA's own
@@ -208,21 +222,29 @@ export class MaintenanceSupporterPanel extends LitElement {
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;
// localStorage.getItem can THROW (Safari private mode / locked-down policies
// where storage access raises rather than returning null) — an unguarded
// read here would abort connectedCallback and leave the panel blank. Every
// other storage access in this file is wrapped; wrap these too.
try {
const saved = localStorage.getItem(LS_KEYS.taskSort);
if (saved && ["due_date", "object", "type", "task_name", "area", "assigned_user", "group"].includes(saved)) {
this._sortMode = saved as SortMode;
}
const savedObj = localStorage.getItem(LS_KEYS.objectSort);
if (savedObj && ["alphabetical", "due_soonest", "task_count"].includes(savedObj)) {
this._objectSortMode = savedObj as ObjectSortMode;
}
const savedGroup = localStorage.getItem(LS_KEYS.groupBy);
if (savedGroup && ["none", "area", "group", "user"].includes(savedGroup)) {
this._groupByMode = savedGroup as GroupByMode;
}
const savedView = localStorage.getItem(LS_KEYS.objectView);
if (savedView === "cards" || savedView === "table") {
this._objectViewMode = savedView;
}
} catch {
// storage blocked — keep the defaults
}
}
@@ -334,13 +356,15 @@ export class MaintenanceSupporterPanel extends LitElement {
}
private async _loadData(): Promise<void> {
const [objResult, statsResult, budgetResult, groupsResult, settingsResult] = await Promise.all([
const [objResult, statsResult, budgetResult, groupsResult, settingsResult, viewsResult] = 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),
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/views/list" }).catch(() => null),
]);
if (viewsResult) this._savedViews = (viewsResult as { views: SavedView[] }).views || [];
if (objResult) this._objects = (objResult as { objects: MaintenanceObjectResponse[] }).objects;
if (statsResult) this._stats = statsResult as StatisticsResponse;
if (budgetResult) this._budget = budgetResult as BudgetStatus;
@@ -479,7 +503,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private _setChartRange(days: number): void {
if (days === this._chartRangeDays) return;
this._chartRangeDays = days;
try { localStorage.setItem("msp-chart-range", String(days)); } catch { /* private mode */ }
try { localStorage.setItem(LS_KEYS.chartRange, String(days)); } catch { /* private mode */ }
const task = this._selectedEntryId && this._selectedTaskId
? this._getTask(this._selectedEntryId, this._selectedTaskId)
: null;
@@ -498,7 +522,7 @@ export class MaintenanceSupporterPanel extends LitElement {
// 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 */ }
try { localStorage.setItem(LS_KEYS.chartHideOutliers, hide ? "1" : "0"); } catch { /* private mode */ }
}
private async _fetchMiniStatsForOverview(): Promise<void> {
@@ -557,6 +581,9 @@ export class MaintenanceSupporterPanel extends LitElement {
if (task.responsible_user_id !== userId) continue;
}
// Label filter (v2.26 — also captured by saved views)
if (this._filterLabel && !(task.labels || []).includes(this._filterLabel)) continue;
// Collect groups that contain this task
const groupNames: string[] = [];
for (const group of Object.values(this._groups)) {
@@ -695,6 +722,7 @@ export class MaintenanceSupporterPanel extends LitElement {
* the task list. Empty string clears the filter (used by "Tasks" KPI). */
private _filterByStatus(status: string): void {
this._filterStatus = status;
this._activeViewId = "";
// 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") {
@@ -703,6 +731,75 @@ export class MaintenanceSupporterPanel extends LitElement {
this._scrollContentToTop();
}
// ── v2.24: saved filter views ──────────────────────────────────────────────
/** Distinct labels across all loaded tasks (for the label-filter dropdown). */
private get _allLabels(): string[] {
const seen = new Set<string>();
for (const obj of this._objects) {
for (const task of obj.tasks) {
for (const lb of task.labels || []) seen.add(lb);
}
}
return [...seen].sort((a, b) => a.localeCompare(b));
}
/** The panel's current task-list filter state, in the shape a view stores. */
private get _currentFilters(): SavedViewFilters {
return {
status: this._filterStatus,
user_id: this._filterUser,
label: this._filterLabel,
archived: this._showArchived,
sort_mode: this._sortMode,
group_by: this._groupByMode,
};
}
/** Apply a saved view's filters to the live controls (or clear on ""). */
private _applyView(viewId: string): void {
this._activeViewId = viewId;
if (!viewId) return;
const view = this._savedViews.find((v) => v.id === viewId);
if (!view) return;
const f = view.filters;
this._filterStatus = f.status || "";
this._filterUser = f.user_id || null;
this._filterLabel = f.label || null;
this._showArchived = !!f.archived;
// Validate against the current enums before assigning — a view saved by an
// older/renamed build could carry a mode no longer valid, which would then
// be persisted to localStorage and silently break sorting/grouping.
if (["due_date", "object", "type", "task_name", "area", "assigned_user", "group"].includes(f.sort_mode)) {
this._sortMode = f.sort_mode as SortMode;
}
if (["none", "area", "group", "user"].includes(f.group_by)) {
this._groupByMode = f.group_by as GroupByMode;
}
// Persist sort/group like the manual controls do, so they stick after reload.
try {
localStorage.setItem(LS_KEYS.taskSort, this._sortMode);
localStorage.setItem(LS_KEYS.groupBy, this._groupByMode);
} catch {
// ignore private-mode storage errors
}
if (this._overviewTab !== "dashboard") this._overviewTab = "dashboard";
}
private _openSavedViewsDialog(): void {
this.shadowRoot!
.querySelector<MaintenanceSavedViewsDialog>("maintenance-saved-views-dialog")
?.open(this._currentFilters, this._savedViews);
}
private _onSavedViewsChanged(e: CustomEvent<{ views: SavedView[] }>): void {
this._savedViews = e.detail.views || [];
// If the applied view was deleted, drop the stale selection.
if (this._activeViewId && !this._savedViews.some((v) => v.id === this._activeViewId)) {
this._activeViewId = "";
}
}
/** 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.
@@ -880,6 +977,20 @@ export class MaintenanceSupporterPanel extends LitElement {
`;
}
// --- Adopt problem sensors ---
private _openAdoptProblemSensors(): void {
this.shadowRoot!
.querySelector<MaintenanceAdoptProblemSensorsDialog>("maintenance-adopt-problem-sensors-dialog")
?.open();
}
private _onProblemSensorsAdopted(e: CustomEvent): void {
const tasks = e.detail?.tasks_created ?? 0;
this._showToast(t("adopt_problem_done", this._lang).replace("{tasks}", String(tasks)));
this._loadData();
}
// --- Template gallery ---
private async _openTemplateGallery(): Promise<void> {
@@ -1545,7 +1656,19 @@ export class MaintenanceSupporterPanel extends LitElement {
never: t("worksheet_never", L),
typeLabel: (ty: string) => t(ty, L),
statusLabel: (st: string) => t(st, L),
parts: t("consumes_parts_label", L),
};
// Required parts as checkable lines: qty × name (stock unit) — location.
const wsParts = obj.parts || [];
const partsLines = (task.consumes_parts || [])
.map((link) => {
const pt = wsParts.find((x) => x.id === link.part_id);
if (!pt) return "";
const stock = pt.stock !== null && pt.stock !== undefined ? ` (${pt.stock}${pt.unit ? " " + pt.unit : ""})` : "";
const loc = pt.storage_location ? `${pt.storage_location}` : "";
return `${link.quantity}× ${pt.name}${stock}${loc}`;
})
.filter(Boolean);
const html = buildTaskWorksheetHtml(
task, obj.object.name, labels,
(iso) => formatDate(iso, L),
@@ -1554,6 +1677,7 @@ export class MaintenanceSupporterPanel extends LitElement {
qrComplete?.svg_data_uri || null,
excerpt,
new Date().toISOString(),
partsLines,
);
const url = URL.createObjectURL(new Blob([html], { type: "text/html" }));
window.open(url, "_blank");
@@ -1579,6 +1703,21 @@ export class MaintenanceSupporterPanel extends LitElement {
?.tasks.find((tsk) => tsk.id === taskId);
dlg.taskType = tk?.type || "";
dlg.readingUnit = tk?.reading_unit || "";
// Spare parts: a buy task gets an editable restock-qty field; a consuming
// task shows what it will decrement (incl. the storage location).
const objParts = this._objects.find((o) => o.entry_id === entryId)?.parts || [];
const partById = new Map(objParts.map((pt) => [pt.id, pt]));
const refPart = tk?.part_ref ? partById.get(tk.part_ref.part_id) : undefined;
dlg.restockDefault = tk?.part_ref ? (refPart?.restock_quantity ?? 1) : null;
dlg.consumesInfo = (tk?.consumes_parts || [])
.map((link) => {
const pt = partById.get(link.part_id);
if (!pt) return "";
const loc = pt.storage_location ? `${pt.storage_location}` : "";
const stock = pt.stock !== null && pt.stock !== undefined ? ` (${pt.stock}${pt.unit ? " " + pt.unit : ""})` : "";
return `${link.quantity}× ${pt.name}${stock}${loc}`;
})
.filter(Boolean);
dlg.open();
}
@@ -1649,6 +1788,14 @@ export class MaintenanceSupporterPanel extends LitElement {
.objects=${this._objects}
@group-saved=${this._onDialogEvent}
></maintenance-group-dialog>
<maintenance-adopt-problem-sensors-dialog
.hass=${this.hass}
@problem-sensors-adopted=${(e: CustomEvent) => this._onProblemSensorsAdopted(e)}
></maintenance-adopt-problem-sensors-dialog>
<maintenance-saved-views-dialog
.hass=${this.hass}
@saved-views-changed=${(e: CustomEvent<{ views: SavedView[] }>) => this._onSavedViewsChanged(e)}
></maintenance-saved-views-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}
@@ -1828,7 +1975,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private _setOverviewTab(tab: "today" | "dashboard" | "calendar" | "settings"): void {
this._overviewTab = tab;
try { localStorage.setItem("msp-overview-tab", tab); } catch { /* private mode */ }
try { localStorage.setItem(LS_KEYS.overviewTab, tab); } catch { /* private mode */ }
this._scrollContentToTop();
}
@@ -1901,11 +2048,32 @@ export class MaintenanceSupporterPanel extends LitElement {
${this._features.budget ? this._renderBudgetBar() : nothing}
<div class="filter-bar">
<label class="filter-field">
<span class="filter-label">${t("views_label", L)}</span>
<select
.value=${this._activeViewId}
@change=${(e: Event) => this._applyView((e.target as HTMLSelectElement).value)}
>
<option value="">${t("views_none", L)}</option>
${this._savedViews.map(
(v) => html`<option value=${v.id} ?selected=${this._activeViewId === v.id}>${v.name}</option>`,
)}
</select>
</label>
${!isOperator ? html`
<ha-icon-button
class="views-save-btn"
.path=${"M15,9H5V5H15M12,19A3,3 0 0,1 9,16A3,3 0 0,1 12,13A3,3 0 0,1 15,16A3,3 0 0,1 12,19M17,3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V7L17,3Z"}
.label=${t("views_manage", L)}
title=${t("views_manage", L)}
@click=${() => this._openSavedViewsDialog()}
></ha-icon-button>
` : nothing}
<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)}
@change=${(e: Event) => { this._filterStatus = (e.target as HTMLSelectElement).value; this._activeViewId = ""; }}
>
<option value="">${t("all", L)}</option>
<option value="overdue">${t("overdue", L)}</option>
@@ -1921,19 +2089,39 @@ export class MaintenanceSupporterPanel extends LitElement {
@change=${(e: Event) => {
const val = (e.target as HTMLSelectElement).value;
this._filterUser = val || null;
this._activeViewId = "";
}}
>
<option value="">${t("all_users", L)}</option>
<option value="current_user">${t("my_tasks", L)}</option>
</select>
</label>
${this._allLabels.length > 0 ? html`
<label class="filter-field">
<span class="filter-label">${t("label_filter", L)}</span>
<select
.value=${this._filterLabel || ""}
@change=${(e: Event) => {
const val = (e.target as HTMLSelectElement).value;
this._filterLabel = val || null;
this._activeViewId = "";
}}
>
<option value="">${t("all_labels", L)}</option>
${this._allLabels.map(
(lb) => html`<option value=${lb} ?selected=${this._filterLabel === lb}>${lb}</option>`
)}
</select>
</label>
` : nothing}
<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);
this._activeViewId = "";
try { localStorage.setItem(LS_KEYS.taskSort, this._sortMode); } catch { /* private mode */ }
}}
>
<option value="due_date" ?selected=${this._sortMode === "due_date"}>${t("sort_due_date", L)}</option>
@@ -1951,7 +2139,8 @@ export class MaintenanceSupporterPanel extends LitElement {
.value=${this._groupByMode}
@change=${(e: Event) => {
this._groupByMode = (e.target as HTMLSelectElement).value as GroupByMode;
localStorage.setItem("maintenance_supporter_groupby", this._groupByMode);
this._activeViewId = "";
try { localStorage.setItem(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ }
}}
>
<option value="none" ?selected=${this._groupByMode === "none"}>${t("groupby_none", L)}</option>
@@ -1963,7 +2152,7 @@ export class MaintenanceSupporterPanel extends LitElement {
${archivedCount > 0 ? html`
<ha-button
class="archived-toggle ${this._showArchived ? "active" : ""}"
@click=${() => { this._showArchived = !this._showArchived; }}
@click=${() => { this._showArchived = !this._showArchived; this._activeViewId = ""; }}
>
<ha-icon icon="mdi:archive-outline"></ha-icon>
${this._showArchived ? t("hide_archived", L) : `${t("show_archived", L)} (${archivedCount})`}
@@ -1987,6 +2176,9 @@ export class MaintenanceSupporterPanel extends LitElement {
<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._openAdoptProblemSensors()}>
<ha-icon icon="mdi:alert-circle-check-outline"></ha-icon> ${t("adopt_problem_button", L)}
</ha-button>
<ha-button
@click=${() => this.shadowRoot!.querySelector<MaintenanceTaskDialog>("maintenance-task-dialog")?.openCreate("", this._objects)}
>
@@ -2265,7 +2457,7 @@ export class MaintenanceSupporterPanel extends LitElement {
.value=${this._objectSortMode}
@change=${(e: Event) => {
this._objectSortMode = (e.target as HTMLSelectElement).value as ObjectSortMode;
localStorage.setItem("maintenance_supporter_object_sort", this._objectSortMode);
localStorage.setItem(LS_KEYS.objectSort, this._objectSortMode);
}}
>
<option value="alphabetical" ?selected=${this._objectSortMode === "alphabetical"}>${t("sort_alphabetical", L)}</option>
@@ -2294,7 +2486,7 @@ export class MaintenanceSupporterPanel extends LitElement {
.value=${this._groupByMode}
@change=${(e: Event) => {
this._groupByMode = (e.target as HTMLSelectElement).value as GroupByMode;
localStorage.setItem("maintenance_supporter_groupby", this._groupByMode);
try { localStorage.setItem(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ }
}}
>
<option value="none" ?selected=${this._groupByMode === "none"}>${t("groupby_none", L)}</option>
@@ -2343,7 +2535,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private _setObjectViewMode(mode: "cards" | "table"): void {
this._objectViewMode = mode;
localStorage.setItem("maintenance_supporter_object_view", mode);
localStorage.setItem(LS_KEYS.objectView, mode);
}
// (#67 / Phase 3) Download all objects as a one-row-per-object CSV.
@@ -2417,7 +2609,7 @@ export class MaintenanceSupporterPanel extends LitElement {
}
case "documentation_url":
return html`<td class="oc-documentation_url">${
o.documentation_url && /^https?:\/\//i.test(o.documentation_url)
isSafeHttpUrl(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>`
: "—"
@@ -2716,7 +2908,7 @@ export class MaintenanceSupporterPanel extends LitElement {
? html`<p class="meta">${[o.manufacturer, o.model].filter(Boolean).join(" ")}</p>`
: nothing}
${o.serial_number ? html`<p class="meta">${t("serial_number_label", L)}: ${o.serial_number}</p>` : nothing}
${o.documentation_url && /^https?:\/\//i.test(o.documentation_url)
${isSafeHttpUrl(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>`
@@ -2730,12 +2922,6 @@ export class MaintenanceSupporterPanel extends LitElement {
</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" : ""}"
@@ -2762,6 +2948,9 @@ export class MaintenanceSupporterPanel extends LitElement {
${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}
${task.document_count
? html`<span class="doc-badge" title="${task.document_count} ${t("documents", L)}"><ha-icon icon="mdi:paperclip"></ha-icon>${task.document_count}</span>`
: nothing}
</span>
<span class="cell task-name" @click=${() => this._showTask(obj.entry_id, task.id)}>${task.name}</span>
<span class="task-sub${task.responsible_user_id ? '' : ' task-sub-empty'}">${renderUserBadge(task, (id) => this._userService?.getUserName(id) ?? null)}</span>
@@ -2783,6 +2972,20 @@ export class MaintenanceSupporterPanel extends LitElement {
</span>
</div>
`)}</div>`}
<maintenance-documents-section
.hass=${this.hass}
.entryId=${obj.entry_id}
.canWrite=${!isOperator}
></maintenance-documents-section>
<maintenance-parts-section
.hass=${this.hass}
.entryId=${obj.entry_id}
.parts=${obj.parts || []}
.canWrite=${!isOperator}
@parts-changed=${() => this._loadData()}
></maintenance-parts-section>
</div>
`;
}
@@ -2820,7 +3023,7 @@ export class MaintenanceSupporterPanel extends LitElement {
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 */ }
try { localStorage.setItem(LS_KEYS.collapsedSections, JSON.stringify([...next])); } catch { /* private mode */ }
}
/** Build the context the history renderers need from panel state. */