187 files
This commit is contained in:
1 parent
16c9b4d7f4
commit
d6413e19b5
187 files changed
+15542
-2765
No files matched your search
@@ -5,7 +5,7 @@ import { isSafeHttpUrl } from "./helpers/url";
|
||||
import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
|
||||
import { isStaleBundle } from "./helpers/bundle-version";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { syncLocaleFromHass, sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setDateTimePrefs, langOf } from "./styles";
|
||||
import { syncLocaleFromHass, sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setProfilePrefs, langOf, formatNumber, formatCost } from "./styles";
|
||||
import { LS_KEYS, lsGet, lsSet } from "./helpers/storage-keys";
|
||||
import { openHtmlInNewTab, openSignedDocument, signApiPath } from "./helpers/document-url";
|
||||
import { readObjectsCache, writeObjectsCache } from "./helpers/objects-cache";
|
||||
@@ -83,6 +83,7 @@ import { renderUserBadge, type TaskDetailContext } from "./renderers/task-detail
|
||||
// would never register.
|
||||
import "./components/task-detail-view";
|
||||
import { computeWindow, VIRTUAL_MIN_ROWS } from "./helpers/virtual-window";
|
||||
import { INITIAL_STICKY, nextStickyState, stickyStateOnSelect, stickyTop, type StickyState } from "./helpers/sticky-pane";
|
||||
|
||||
type View = "overview" | "object" | "task" | "all_objects" | "all_parts";
|
||||
|
||||
@@ -113,6 +114,16 @@ interface PartsOverviewRow {
|
||||
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" | "object";
|
||||
type OverviewTab = "today" | "dashboard" | "calendar" | "settings";
|
||||
|
||||
// Value lists for everything that arrives as a free string — localStorage,
|
||||
// a saved view, a deep-link query — and must be validated before it lands in
|
||||
// state (an unknown mode would be persisted and silently break sorting).
|
||||
const SORT_MODES: readonly SortMode[] = ["due_date", "object", "type", "task_name", "area", "assigned_user", "group"];
|
||||
const GROUP_BY_MODES: readonly GroupByMode[] = ["none", "area", "group", "user", "object"];
|
||||
const OVERVIEW_TABS: readonly OverviewTab[] = ["today", "dashboard", "calendar", "settings"];
|
||||
/** The dashboard list's status filter options (the <select> in the toolbar). */
|
||||
const STATUS_FILTERS: readonly string[] = ["overdue", "due_soon", "triggered", "ok"];
|
||||
|
||||
// Chart dimension constants for mini sparklines (overview)
|
||||
|
||||
@@ -209,7 +220,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
private _dismissedSuggestions = new Set<string>();
|
||||
|
||||
// Dashboard redesign state
|
||||
@state() private _overviewTab: "today" | "dashboard" | "calendar" | "settings" = (() => {
|
||||
@state() private _overviewTab: OverviewTab = (() => {
|
||||
try {
|
||||
const v = lsGet(LS_KEYS.overviewTab);
|
||||
return v === "today" || v === "calendar" ? v : "dashboard";
|
||||
@@ -240,6 +251,18 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
private _virtTotalRows = 0;
|
||||
private _virtScrollAttached = false;
|
||||
private _virtRaf = 0;
|
||||
// Split view (2026-09-02): the docked detail pane is scroll-aware sticky
|
||||
// (helpers/sticky-pane) — it never scrolls on its own; a pane taller than
|
||||
// the viewport is pinned by its bottom edge while scrolling down and by
|
||||
// its top edge while scrolling up.
|
||||
private _stickyState: StickyState = INITIAL_STICKY;
|
||||
private _stickySelectPending = false;
|
||||
private _stickyRaf = 0;
|
||||
private _stickyAttached = false;
|
||||
private _stickyObserver: ResizeObserver | null = null;
|
||||
// Group-by sections collapsed by the user (keyed by section label). Cleared
|
||||
// when the group-by mode changes — labels mean something else then.
|
||||
@state() private _collapsedGroups = new Set<string>();
|
||||
// v2.15.0: collapsed analysis sections on the task-detail overview tab,
|
||||
// remembered per section across visits.
|
||||
@state() private _collapsedSections: Set<string> = (() => {
|
||||
@@ -292,6 +315,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
}
|
||||
|
||||
private _popstateHandler = (e: PopStateEvent) => this._onPopState(e);
|
||||
private _locationChangedHandler = () => this._onLocationChanged();
|
||||
|
||||
/** Lazy UI chunks (perf wave 2, item 5): the six dialogs + the settings
|
||||
* view are code-split out of the entry bundle and fetched in parallel
|
||||
@@ -333,6 +357,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
if (idle) idle(kick, { timeout: 3000 });
|
||||
else window.setTimeout(kick, 1500);
|
||||
window.addEventListener("popstate", this._popstateHandler);
|
||||
window.addEventListener("location-changed", this._locationChangedHandler);
|
||||
window.addEventListener("keydown", this._paletteKeydown);
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
this._tightObserver = new ResizeObserver((entries) => {
|
||||
@@ -355,7 +380,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
// other storage access in this file is wrapped; wrap these too.
|
||||
try {
|
||||
const saved = lsGet(LS_KEYS.taskSort);
|
||||
if (saved && ["due_date", "object", "type", "task_name", "area", "assigned_user", "group"].includes(saved)) {
|
||||
if (saved && (SORT_MODES as readonly string[]).includes(saved)) {
|
||||
this._sortMode = saved as SortMode;
|
||||
}
|
||||
const savedObj = lsGet(LS_KEYS.objectSort);
|
||||
@@ -363,7 +388,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
this._objectSortMode = savedObj as ObjectSortMode;
|
||||
}
|
||||
const savedGroup = lsGet(LS_KEYS.groupBy);
|
||||
if (savedGroup && ["none", "area", "group", "user", "object"].includes(savedGroup)) {
|
||||
if (savedGroup && (GROUP_BY_MODES as readonly string[]).includes(savedGroup)) {
|
||||
this._groupByMode = savedGroup as GroupByMode;
|
||||
}
|
||||
const savedView = lsGet(LS_KEYS.objectView);
|
||||
@@ -387,6 +412,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("popstate", this._popstateHandler);
|
||||
window.removeEventListener("location-changed", this._locationChangedHandler);
|
||||
window.removeEventListener("keydown", this._paletteKeydown);
|
||||
this._tightObserver?.disconnect();
|
||||
this._tightObserver = null;
|
||||
@@ -394,17 +420,28 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
this.shadowRoot?.querySelector(".content")?.removeEventListener("scroll", this._onVirtualScroll);
|
||||
this._virtScrollAttached = false;
|
||||
if (this._virtRaf) cancelAnimationFrame(this._virtRaf);
|
||||
this._detachStickyPane();
|
||||
if (this._unsub) {
|
||||
this._unsub();
|
||||
this._unsub = null;
|
||||
}
|
||||
this._dataLoaded = false;
|
||||
this._initialLoadDone = false;
|
||||
this._lastConnection = null;
|
||||
this._deepLinkHandled = false;
|
||||
this._statsService?.clearCache();
|
||||
this._statsService = null;
|
||||
}
|
||||
|
||||
willUpdate(changedProps: Map<string, unknown>): void {
|
||||
super.willUpdate(changedProps);
|
||||
// Section labels are mode-specific ("Kitchen" vs "Family Car"): a
|
||||
// collapsed set from the previous mode would fold random new sections.
|
||||
if (changedProps.has("_groupByMode") && this._collapsedGroups.size > 0) {
|
||||
this._collapsedGroups = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
updated(changedProps: Map<string, unknown>): void {
|
||||
super.updated(changedProps);
|
||||
syncLocaleFromHass(this, changedProps);
|
||||
@@ -451,6 +488,90 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
this._virtScrollAttached = true;
|
||||
}
|
||||
this._updateVirtualWindow();
|
||||
this._syncStickyPane(content);
|
||||
}
|
||||
|
||||
/** Split view: (de)attach the scroll-aware docking of the detail pane and
|
||||
* apply the current state to the just-rendered DOM. */
|
||||
private _syncStickyPane(content: Element | null | undefined): void {
|
||||
const pane = this.shadowRoot?.querySelector<HTMLElement>(".split-pane");
|
||||
if (!pane || !content) {
|
||||
if (this._stickyAttached) this._detachStickyPane();
|
||||
return;
|
||||
}
|
||||
if (!this._stickyAttached) {
|
||||
content.addEventListener("scroll", this._onStickyScroll, { passive: true });
|
||||
window.addEventListener("resize", this._onStickyScroll);
|
||||
if (typeof ResizeObserver !== "undefined") this._stickyObserver = new ResizeObserver(this._onStickyScroll);
|
||||
this._stickyAttached = true;
|
||||
}
|
||||
// Observing an already-observed node is a no-op; a re-created pane
|
||||
// (split left and came back) gets picked up here.
|
||||
this._stickyObserver?.observe(pane);
|
||||
this._updateStickyPane();
|
||||
}
|
||||
|
||||
private _detachStickyPane(): void {
|
||||
this.shadowRoot?.querySelector(".content")?.removeEventListener("scroll", this._onStickyScroll);
|
||||
window.removeEventListener("resize", this._onStickyScroll);
|
||||
this._stickyObserver?.disconnect();
|
||||
this._stickyObserver = null;
|
||||
if (this._stickyRaf) cancelAnimationFrame(this._stickyRaf);
|
||||
this._stickyRaf = 0;
|
||||
this._stickyAttached = false;
|
||||
this._stickyState = INITIAL_STICKY;
|
||||
this._stickySelectPending = false;
|
||||
}
|
||||
|
||||
/** rAF-throttled scroll/resize/pane-resize handler for the docked pane. */
|
||||
private _onStickyScroll = (): void => {
|
||||
if (this._stickyRaf) return;
|
||||
this._stickyRaf = requestAnimationFrame(() => {
|
||||
this._stickyRaf = 0;
|
||||
this._updateStickyPane();
|
||||
});
|
||||
};
|
||||
|
||||
/** Measure scroller/pane/list and apply the next docking state as inline
|
||||
* `top` + `margin-top` on the (sticky) pane. */
|
||||
private _updateStickyPane(): void {
|
||||
const sr = this.shadowRoot;
|
||||
const content = sr?.querySelector<HTMLElement>(".content");
|
||||
const pane = sr?.querySelector<HTMLElement>(".split-pane");
|
||||
const layout = sr?.querySelector<HTMLElement>(".split-layout");
|
||||
const list = sr?.querySelector<HTMLElement>(".split-list");
|
||||
if (!content || !pane || !layout || !list) return;
|
||||
// Viewport y of the scroller's content origin — turns client rects
|
||||
// into scrollTop-space offsets.
|
||||
const origin = content.getBoundingClientRect().top - content.scrollTop;
|
||||
const m = {
|
||||
scrollTop: content.scrollTop,
|
||||
viewH: content.clientHeight,
|
||||
paneH: pane.offsetHeight,
|
||||
listH: list.offsetHeight,
|
||||
layoutTop: layout.getBoundingClientRect().top - origin,
|
||||
renderedTop: pane.getBoundingClientRect().top - origin,
|
||||
};
|
||||
// Fresh selection: the detail view fills in asynchronously (its own
|
||||
// update + WS loads), so keep re-parking on every pane resize until the
|
||||
// user scrolls — from then on the direction machine owns the state.
|
||||
if (this._stickySelectPending && m.scrollTop === this._stickyState.lastScrollTop) {
|
||||
this._stickyState = stickyStateOnSelect(m);
|
||||
} else {
|
||||
this._stickySelectPending = false;
|
||||
this._stickyState = nextStickyState(this._stickyState, m);
|
||||
}
|
||||
const next = this._stickyState;
|
||||
pane.style.top = `${stickyTop(next.mode, m)}px`;
|
||||
pane.style.marginTop = next.marginTop > 0 ? `${next.marginTop}px` : "";
|
||||
}
|
||||
|
||||
/** A new selection parks the pane where the user is looking, whatever the
|
||||
* pane was doing before (see stickyStateOnSelect). */
|
||||
private _resetStickyPane(): void {
|
||||
const content = this.shadowRoot?.querySelector<HTMLElement>(".content");
|
||||
this._stickyState = { ...INITIAL_STICKY, lastScrollTop: content?.scrollTop ?? 0 };
|
||||
this._stickySelectPending = true;
|
||||
}
|
||||
|
||||
/** rAF-throttled scroll/resize handler for the virtualized task table. */
|
||||
@@ -567,10 +688,30 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
this._fetchMiniStatsForOverview();
|
||||
|
||||
// Handle deep-link URL parameters (from QR code scan)
|
||||
this._initialLoadDone = true;
|
||||
this._handleDeepLink();
|
||||
}
|
||||
|
||||
private _deepLinkHandled = false;
|
||||
/** Objects + saved views have arrived at least once — deep links that
|
||||
* reference them are routable. */
|
||||
private _initialLoadDone = false;
|
||||
|
||||
/** In-app navigation to the panel while it is already mounted (#160): HA's
|
||||
* navigate() — a dashboard button's navigation_path, a notification tap
|
||||
* with the app open — fires `location-changed` and, since the panel path
|
||||
* is unchanged, neither remounts us nor hands over a new route object
|
||||
* (verified on HA 2026.7: the route stays identical for a query-only
|
||||
* navigation). The URL is the only signal, so re-read the deep-link
|
||||
* params from it. Navigations away from the panel are not ours. */
|
||||
private _onLocationChanged(): void {
|
||||
if (!this._initialLoadDone || !window.location.search) return;
|
||||
const base = `/${typeof this.panel?.url_path === "string" ? this.panel.url_path : "maintenance-supporter"}`;
|
||||
const path = window.location.pathname;
|
||||
if (path !== base && !path.startsWith(`${base}/`)) return;
|
||||
this._deepLinkHandled = false;
|
||||
this._handleDeepLink();
|
||||
}
|
||||
|
||||
private _handleDeepLink(): void {
|
||||
if (this._deepLinkHandled) return;
|
||||
@@ -616,6 +757,48 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
// v2.74.0 (Discussion #160, @maisun): overview deep links — land on a tab
|
||||
// with the task list pre-filtered / pre-sorted, or on a saved view, so a
|
||||
// dashboard button or a notification tap can open e.g. the Today list:
|
||||
// ?tab=today|dashboard|calendar|settings
|
||||
// ?view=<saved view id or name> (implies the Dashboard tab)
|
||||
// ?sort=due_date|object|type|task_name|area|assigned_user|group
|
||||
// ?status=overdue|due_soon|triggered|ok (implies the Dashboard tab)
|
||||
// Explicit sort/status win over the saved view's; unknown values are
|
||||
// ignored. Like a tap, the tab (also the implied one) and the sort are
|
||||
// remembered for the next plain visit. Consumed once and stripped like
|
||||
// every other param.
|
||||
const tab = params.get("tab");
|
||||
const view = params.get("view");
|
||||
const sort = params.get("sort");
|
||||
const status = params.get("status");
|
||||
if (tab !== null || view !== null || sort !== null || status !== null) {
|
||||
cleanMsActionUrl();
|
||||
if (this._view !== "overview") this._showOverview();
|
||||
if ((OVERVIEW_TABS as readonly string[]).includes(tab ?? "")) {
|
||||
this._setOverviewTab(tab as OverviewTab);
|
||||
}
|
||||
if (view !== null) {
|
||||
const wanted = view.trim().toLowerCase();
|
||||
const match = this._savedViews.find((v) => v.id === view)
|
||||
?? this._savedViews.find((v) => v.name.trim().toLowerCase() === wanted);
|
||||
if (match) {
|
||||
// Tab first: _applyView flips to the dashboard without persisting.
|
||||
if (this._overviewTab !== "dashboard") this._setOverviewTab("dashboard");
|
||||
this._applyView(match.id);
|
||||
}
|
||||
}
|
||||
if ((SORT_MODES as readonly string[]).includes(sort ?? "")) {
|
||||
this._sortMode = sort as SortMode;
|
||||
this._activeViewId = "";
|
||||
lsSet(LS_KEYS.taskSort, this._sortMode);
|
||||
}
|
||||
if (STATUS_FILTERS.includes(status ?? "")) {
|
||||
if (this._overviewTab !== "dashboard") this._setOverviewTab("dashboard");
|
||||
this._filterByStatus(status as string);
|
||||
}
|
||||
}
|
||||
|
||||
const entryId = params.get("entry_id");
|
||||
if (!entryId) return;
|
||||
this._deepLinkHandled = true;
|
||||
@@ -1026,10 +1209,10 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
// 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)) {
|
||||
if ((SORT_MODES as readonly string[]).includes(f.sort_mode)) {
|
||||
this._sortMode = f.sort_mode as SortMode;
|
||||
}
|
||||
if (["none", "area", "group", "user", "object"].includes(f.group_by)) {
|
||||
if ((GROUP_BY_MODES as readonly string[]).includes(f.group_by)) {
|
||||
this._groupByMode = f.group_by as GroupByMode;
|
||||
}
|
||||
// Persist sort/group like the manual controls do, so they stick after reload.
|
||||
@@ -1098,6 +1281,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
this._selectedTaskId = taskId;
|
||||
this._activeTab = "overview";
|
||||
this._historyFilter = null;
|
||||
this._resetStickyPane();
|
||||
this._fetchFullHistory(entryId, taskId);
|
||||
const task = this._getTask(entryId, taskId);
|
||||
if (task?.trigger_config?.entity_id) {
|
||||
@@ -1567,7 +1751,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
const html = buildObjectReportHtml(
|
||||
resp.object, resp.tasks, labels,
|
||||
(iso) => (iso ? formatDate(iso, L) : ""),
|
||||
this._currencySymbol,
|
||||
(amount) => formatCost(amount, this._currencySymbol, L),
|
||||
new Date().toISOString(),
|
||||
);
|
||||
openHtmlInNewTab(html);
|
||||
@@ -2295,16 +2479,18 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
// 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. */
|
||||
* (colour-blind accessibility) — icon + text + colour together. The label
|
||||
* is its own span so narrow/tight rows can drop it (#150: the pill ate a
|
||||
* third of a phone row) while `title`/`aria-label` keep the text reachable. */
|
||||
private _statusBadge(archived: boolean, isDone: boolean, status: string) {
|
||||
const L = this._lang;
|
||||
const cls = archived ? "archived" : (isDone ? "done" : status);
|
||||
const iconKey = archived ? "archived" : (isDone ? "completed" : status);
|
||||
const label = archived ? t("archived", L) : (isDone ? t("completed", L) : t(status, L));
|
||||
return html`<span class="status-badge ${cls}"><ha-icon icon="${STATUS_ICONS[iconKey] || "mdi:circle-medium"}"></ha-icon>${label}</span>`;
|
||||
return html`<span class="status-badge ${cls}" role="img" title="${label}" aria-label="${label}"><ha-icon icon="${STATUS_ICONS[iconKey] || "mdi:circle-medium"}"></ha-icon><span class="status-label">${label}</span></span>`;
|
||||
}
|
||||
|
||||
private _setOverviewTab(tab: "today" | "dashboard" | "calendar" | "settings"): void {
|
||||
private _setOverviewTab(tab: OverviewTab): void {
|
||||
this._overviewTab = tab;
|
||||
try { lsSet(LS_KEYS.overviewTab, tab); } catch { /* private mode */ }
|
||||
this._scrollContentToTop();
|
||||
@@ -2655,7 +2841,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
<div class="task-row virt-sizer" aria-hidden="true">
|
||||
${this._bulkMode ? html`<span></span>` : nothing}
|
||||
<span class="cell-badges">
|
||||
<span class="status-badge"><ha-icon icon="mdi:circle-medium"></ha-icon>${widest}</span>
|
||||
<span class="status-badge"><ha-icon icon="mdi:circle-medium"></ha-icon><span class="status-label">${widest}</span></span>
|
||||
${anyDisabled ? html`<span class="badge-disabled">${t("disabled", L)}</span>` : nothing}
|
||||
${anyNfc ? html`<span class="nfc-badge"><ha-icon icon="mdi:nfc-variant"></ha-icon></span>` : nothing}
|
||||
${anyPriority ? html`<span class="priority-badge"><ha-icon icon="mdi:chevron-double-up"></ha-icon></span>` : nothing}
|
||||
@@ -2721,22 +2907,53 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
: this._groupByMode === "group" ? "mdi:folder-outline"
|
||||
: this._groupByMode === "object" ? "mdi:cube-outline"
|
||||
: "mdi:account-outline";
|
||||
// ONE grid owns the column tracks for every section (each section card
|
||||
// and its row block are column subgrids), so the badge/object/name
|
||||
// columns line up across sections no matter which badges a section
|
||||
// carries — per-section tables started the task name up to 60px apart
|
||||
// ("OK"-only vs overdue+ok sections). A <details> can't sit in that
|
||||
// chain (Chrome's ::details-content box breaks subgrid), hence the
|
||||
// div-based disclosure with its own collapsed state.
|
||||
return html`
|
||||
${sorted.map(([key, taskRows]) => html`
|
||||
<details class="group-section" open>
|
||||
<summary class="group-section-header">
|
||||
<ha-icon icon="${icon}"></ha-icon>
|
||||
<span>${key}</span>
|
||||
<span class="group-section-count">(${taskRows.length})</span>
|
||||
</summary>
|
||||
<div class="task-table${this._bulkMode ? " bulk" : ""}">
|
||||
${taskRows.map((row) => this._renderOverviewRow(row))}
|
||||
</div>
|
||||
</details>
|
||||
`)}
|
||||
<div class="task-table grouped${this._bulkMode ? " bulk" : ""}">
|
||||
${sorted.map(([key, taskRows]) => {
|
||||
const open = !this._collapsedGroups.has(key);
|
||||
return html`
|
||||
<div class="group-section" ?open=${open}>
|
||||
<div
|
||||
class="group-section-header"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-expanded=${open ? "true" : "false"}
|
||||
@click=${() => this._toggleGroup(key)}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
this._toggleGroup(key);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ha-icon icon="${icon}"></ha-icon>
|
||||
<span>${key}</span>
|
||||
<span class="group-section-count">(${taskRows.length})</span>
|
||||
</div>
|
||||
${open
|
||||
? html`<div class="group-rows">${taskRows.map((row) => this._renderOverviewRow(row))}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _toggleGroup(key: string): void {
|
||||
const next = new Set(this._collapsedGroups);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
this._collapsedGroups = next;
|
||||
}
|
||||
|
||||
// (#67) Localised warranty label, shared by the detail meta + table cell.
|
||||
private _warrantyLabel(ws: ReturnType<typeof warrantyStatus>, iso: string, L: string): string {
|
||||
if (ws.kind === "expired") return t("warranty_expired", L);
|
||||
@@ -3223,17 +3440,17 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
const pct = Math.min(100, Math.max(0, (spent / budget) * 100));
|
||||
const color = pct >= 100 ? "var(--error-color, #f44336)" : pct >= b.alert_threshold_pct ? "var(--warning-color, #ff9800)" : "var(--success-color, #4caf50)";
|
||||
return html`
|
||||
<div class="stat-item budget-tile" title="${label}: ${spent.toFixed(2)} / ${budget.toFixed(2)} ${cs}">
|
||||
<span class="stat-value budget-tile-value">${spent.toFixed(2)} ${cs}</span>
|
||||
<span class="budget-tile-max">/ ${budget.toFixed(0)} ${cs}</span>
|
||||
<div class="stat-item budget-tile" title="${label}: ${formatNumber(spent, L, 2)} / ${formatCost(budget, cs, L)}">
|
||||
<span class="stat-value budget-tile-value">${formatCost(spent, cs, L)}</span>
|
||||
<span class="budget-tile-max">/ ${formatCost(budget, cs, L, 0)}</span>
|
||||
<div class="budget-tile-bar"><div style="width:${pct}%; background:${color}"></div></div>
|
||||
<span class="stat-label">${label}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<div class="stat-item budget-tile" title="${label}: ${spent.toFixed(2)} ${cs}">
|
||||
<span class="stat-value budget-tile-value">${spent.toFixed(2)} ${cs}</span>
|
||||
<div class="stat-item budget-tile" title="${label}: ${formatCost(spent, cs, L)}">
|
||||
<span class="stat-value budget-tile-value">${formatCost(spent, cs, L)}</span>
|
||||
<span class="stat-label">${label}</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -3278,8 +3495,10 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
${row.priority === "high" ? html`<span class="priority-badge priority-high" title="${t("priority_high", L)}"><ha-icon icon="mdi:chevron-double-up"></ha-icon></span>` : nothing}
|
||||
${row.priority === "low" ? html`<span class="priority-badge priority-low" title="${t("priority_low", L)}"><ha-icon icon="mdi:chevron-double-down"></ha-icon></span>` : nothing}
|
||||
</span>
|
||||
<span class="cell object-name" @click=${(e: Event) => { e.stopPropagation(); this._showObject(row.entry_id); }}>${row.object_name}</span>
|
||||
<span class="cell task-name" @click=${() => this._showTask(row.entry_id, row.task_id)}>${row.task_name}</span>
|
||||
<span class="row-head">
|
||||
<span class="cell object-name" @click=${(e: Event) => { e.stopPropagation(); this._showObject(row.entry_id); }}>${row.object_name}</span>
|
||||
<span class="cell task-name" @click=${() => this._showTask(row.entry_id, row.task_id)}>${row.task_name}</span>
|
||||
</span>
|
||||
<span class="task-sub${hasSub ? '' : ' task-sub-empty'}">
|
||||
${row.group_names.length > 0 ? html`
|
||||
<span class="sub-chip" title="${t("groups", L)}">
|
||||
@@ -3338,25 +3557,27 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** #145: row actions as HA buttons — labelled (accent/success + plain, the
|
||||
* task-detail pairing) or, for "buttons_compact" on narrow screens,
|
||||
/** #145: row actions as HA buttons — labelled (accent/success + outlined
|
||||
* warning, the task-detail pairing) or, for "buttons_compact" on narrow screens,
|
||||
* icon-only — or the classic icon pair when the household chose "icons". */
|
||||
private _renderRowActions(L: string, onComplete: () => void, onSkip: () => void, allowSkip = true): TemplateResult {
|
||||
const style = this._actionStyle();
|
||||
if (style === "buttons" || style === "buttons_compact") {
|
||||
const iconOnly = style === "buttons_compact" && (this.narrow || this.tight);
|
||||
if (iconOnly) {
|
||||
// Compact narrow form: the primary action stays a solid ha-button
|
||||
// (icon only), the secondary one becomes HA's own icon button.
|
||||
// Compact narrow form: two icon-only ha-buttons — solid green
|
||||
// Complete, outlined orange Skip (#150). Skip wears HA's warning
|
||||
// variant on every surface, so the colour reads the same whether
|
||||
// or not the word is there.
|
||||
return html`
|
||||
<span class="row-actions as-buttons compact">
|
||||
<ha-button size="small" appearance="accent" variant="success" title="${t("complete", L)}" @click=${(e: Event) => { e.stopPropagation(); onComplete(); }}>
|
||||
<ha-button size="small" appearance="accent" variant="success" title="${t("complete", L)}" aria-label="${t("complete", L)}" @click=${(e: Event) => { e.stopPropagation(); onComplete(); }}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>
|
||||
</ha-button>
|
||||
${allowSkip ? html`
|
||||
<ha-icon-button .label=${t("skip", L)} title="${t("skip", L)}" .disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
|
||||
<ha-button size="small" appearance="outlined" variant="warning" title="${t("skip", L)}" aria-label="${t("skip", L)}" ?disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
|
||||
<ha-icon icon="mdi:skip-next"></ha-icon>
|
||||
</ha-icon-button>` : nothing}
|
||||
</ha-button>` : nothing}
|
||||
</span>`;
|
||||
}
|
||||
return html`
|
||||
@@ -3365,7 +3586,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
<ha-icon slot="start" icon="mdi:check"></ha-icon>${t("complete", L)}
|
||||
</ha-button>
|
||||
${allowSkip ? html`
|
||||
<ha-button size="small" appearance="outlined" variant="neutral" title="${t("skip", L)}" ?disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
|
||||
<ha-button size="small" appearance="outlined" variant="warning" title="${t("skip", L)}" ?disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
|
||||
<ha-icon slot="start" icon="mdi:skip-next"></ha-icon>${t("skip", L)}
|
||||
</ha-button>` : nothing}
|
||||
</span>`;
|
||||
@@ -3499,7 +3720,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
.then((d) => d?.openCreate(obj.entry_id));
|
||||
}}>${t("add_first_task", L)}</ha-button>
|
||||
</div>`
|
||||
: html`<div class="task-table">${[...visibleTasks].sort((a, b) => {
|
||||
: html`<div class="task-table object-tasks">${[...visibleTasks].sort((a, b) => {
|
||||
const so: Record<string, number> = { overdue: 0, triggered: 1, due_soon: 2, ok: 3 };
|
||||
return (so[a.status] ?? 9) - (so[b.status] ?? 9) || (a.days_until_due ?? 99999) - (b.days_until_due ?? 99999);
|
||||
}).map((task) => html`
|
||||
|
||||
Reference in new issue
Block a user