184 files

This commit is contained in:
Home Assistant Version Control committed 2026-09-02 11:49:09 +00:00
1 parent 7b47edbf8d
commit 0c64e47969
184 files changed
+9608 -11572

No files matched your search

@@ -75,7 +75,7 @@ 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";
import { renderTriggerProgress, renderMiniSparkline, computeTrend } 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).
@@ -112,7 +112,7 @@ 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";
type GroupByMode = "none" | "area" | "group" | "user" | "object";
// Chart dimension constants for mini sparklines (overview)
@@ -125,6 +125,10 @@ export class MaintenanceSupporterPanel extends LitElement {
* The wide row grid then cannot hold labelled action buttons, so the
* compact (icon-only) form applies here too. Reflected for the CSS. */
@property({ type: Boolean, reflect: true }) public tight = false;
/** ≥1500px panel width (2026-09-01): master-detail split — the dashboard
* keeps the task list on the left and docks the task detail on the right
* (a mail client's preview pane). Reflected for the CSS. */
@property({ type: Boolean, reflect: true }) public split = false;
private _tightObserver: ResizeObserver | null = null;
@property({ attribute: false }) public panel: Record<string, unknown> = {};
@@ -340,6 +344,7 @@ export class MaintenanceSupporterPanel extends LitElement {
// fragments. Below ~1000 the narrow layout is the honest choice;
// from there up the chips track keeps ≥150 px of slack.
if (w > 0) this.tight = w < 1000;
if (w > 0) this.split = w >= 1500;
});
this._tightObserver.observe(this);
}
@@ -358,7 +363,7 @@ export class MaintenanceSupporterPanel extends LitElement {
this._objectSortMode = savedObj as ObjectSortMode;
}
const savedGroup = lsGet(LS_KEYS.groupBy);
if (savedGroup && ["none", "area", "group", "user"].includes(savedGroup)) {
if (savedGroup && ["none", "area", "group", "user", "object"].includes(savedGroup)) {
this._groupByMode = savedGroup as GroupByMode;
}
const savedView = lsGet(LS_KEYS.objectView);
@@ -822,6 +827,7 @@ export class MaintenanceSupporterPanel extends LitElement {
entry_id: obj.entry_id,
task_id: task.id,
object_name: obj.object.name,
allow_skip: task.allow_skip !== false,
task_name: task.name,
type: task.type,
schedule_type: task.schedule_type,
@@ -1023,7 +1029,7 @@ export class MaintenanceSupporterPanel extends LitElement {
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)) {
if (["none", "area", "group", "user", "object"].includes(f.group_by)) {
this._groupByMode = f.group_by as GroupByMode;
}
// Persist sort/group like the manual controls do, so they stick after reload.
@@ -1077,7 +1083,28 @@ export class MaintenanceSupporterPanel extends LitElement {
this._scrollContentToTop();
}
/** Master-detail split is active: wide panel, dashboard tab, no bulk. */
private _splitActive(): boolean {
return this.split && !this.narrow && !this.tight && this._view === "overview" && this._overviewTab === "dashboard" && !this._bulkMode;
}
private _showTask(entryId: string, taskId: string): void {
// Master-detail split (2026-09-01): same data prep, but no view switch
// and no history entry — the detail docks on the right like a mail
// client's preview pane. The detail's breadcrumb (showTaskView) still
// opens the full page.
if (this._splitActive()) {
this._selectedEntryId = entryId;
this._selectedTaskId = taskId;
this._activeTab = "overview";
this._historyFilter = null;
this._fetchFullHistory(entryId, taskId);
const task = this._getTask(entryId, taskId);
if (task?.trigger_config?.entity_id) {
this._fetchDetailStats(task.trigger_config.entity_id, this._isCounterEntity(task.trigger_config));
}
return;
}
this._pushPanelState("task", entryId, taskId);
this._view = "task";
this._selectedEntryId = entryId;
@@ -2496,6 +2523,7 @@ export class MaintenanceSupporterPanel extends LitElement {
<option value="area" ?selected=${this._groupByMode === "area"}>${t("groupby_area", L)}</option>
${this._features.groups ? html`<option value="group" ?selected=${this._groupByMode === "group"}>${t("groupby_group", L)}</option>` : nothing}
<option value="user" ?selected=${this._groupByMode === "user"}>${t("groupby_user", L)}</option>
<option value="object" ?selected=${this._groupByMode === "object"}>${t("groupby_object", L)}</option>
</select>
</label>
${archivedCount > 0 ? html`
@@ -2541,7 +2569,20 @@ export class MaintenanceSupporterPanel extends LitElement {
`
: html`
${this._bulkMode ? this._renderBulkBar(rows, L) : nothing}
${this._groupByMode === "none"
${this._splitActive()
? html`
<div class="split-layout">
<div class="split-list">
${this._groupByMode === "none" ? this._renderTaskTable(rows) : this._renderGroupedTasks(rows, L)}
</div>
<div class="split-pane">
${this._selectedEntryId && this._selectedTaskId && this._getTask(this._selectedEntryId, this._selectedTaskId)
? this._renderTaskDetail()
: html`<div class="split-pane-empty"><ha-icon icon="mdi:cursor-default-click-outline"></ha-icon><p>${t("split_select_hint", L)}</p></div>`}
</div>
</div>
`
: this._groupByMode === "none"
? this._renderTaskTable(rows)
: this._renderGroupedTasks(rows, L)}
`}
@@ -2661,6 +2702,9 @@ export class MaintenanceSupporterPanel extends LitElement {
keys = [u || noneLabel];
} else if (this._groupByMode === "group") {
keys = row.group_names.length > 0 ? row.group_names : [noneLabel];
} else if (this._groupByMode === "object") {
// #150(3): group the dashboard by the owning object/device.
keys = [row.object_name];
}
for (const k of keys) {
if (!groups.has(k)) groups.set(k, []);
@@ -2675,6 +2719,7 @@ export class MaintenanceSupporterPanel extends LitElement {
});
const icon = this._groupByMode === "area" ? "mdi:map-marker-outline"
: this._groupByMode === "group" ? "mdi:folder-outline"
: this._groupByMode === "object" ? "mdi:cube-outline"
: "mdi:account-outline";
return html`
${sorted.map(([key, taskRows]) => html`
@@ -3220,7 +3265,7 @@ export class MaintenanceSupporterPanel extends LitElement {
const bulkSelected = this._bulkMode && this._bulkSelected.has(this._bulkKey(row));
return html`
<div class="task-row${!row.enabled ? ' task-disabled' : ''}${bulkSelected ? ' bulk-selected' : ''}">
<div class="task-row${!row.enabled ? ' task-disabled' : ''}${bulkSelected ? ' bulk-selected' : ''}${this._splitActive() && row.entry_id === this._selectedEntryId && row.task_id === this._selectedTaskId ? ' selected' : ''}">
${this._bulkMode ? html`
<label class="cell bulk-check" @click=${(e: Event) => e.stopPropagation()}>
<input type="checkbox" .checked=${bulkSelected} @change=${() => this._toggleBulkRow(row)} />
@@ -3260,13 +3305,13 @@ export class MaintenanceSupporterPanel extends LitElement {
? html`<div class="days-bar"><div class="days-bar-fill${daysOverflow ? " overflow" : ""}" style="width:${pct}%;background:${barColor}"></div></div>`
: nothing}
${row.trigger_config
? renderTriggerProgress(row)
? renderTriggerProgress(row, { trend: computeTrend(row, this._miniStatsData), lang: L })
: !hasDaysBar && row.trigger_active
? html`<span style="color:var(--maint-triggered-color);font-weight:600">⚡</span>`
: nothing}
${renderMiniSparkline(row, this._miniStatsData, this._lang)}
</span>
${this._renderRowActions(L, () => this._openCompleteDialogForRow(row), () => this._promptSkipTask(row.entry_id, row.task_id))}
${this._renderRowActions(L, () => this._openCompleteDialogForRow(row), () => this._promptSkipTask(row.entry_id, row.task_id), row.allow_skip)}
</div>
`;
}
@@ -3296,7 +3341,7 @@ 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,
* icon-only — or the classic icon pair when the household chose "icons". */
private _renderRowActions(L: string, onComplete: () => void, onSkip: () => void): TemplateResult {
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);
@@ -3308,9 +3353,10 @@ export class MaintenanceSupporterPanel extends LitElement {
<ha-button size="small" appearance="accent" variant="success" title="${t("complete", L)}" @click=${(e: Event) => { e.stopPropagation(); onComplete(); }}>
<ha-icon icon="mdi:check"></ha-icon>
</ha-button>
<ha-icon-button .label=${t("skip", L)} title="${t("skip", L)}" .disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
<ha-icon icon="mdi:skip-next"></ha-icon>
</ha-icon-button>
${allowSkip ? html`
<ha-icon-button .label=${t("skip", L)} title="${t("skip", L)}" .disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
<ha-icon icon="mdi:skip-next"></ha-icon>
</ha-icon-button>` : nothing}
</span>`;
}
return html`
@@ -3318,9 +3364,10 @@ export class MaintenanceSupporterPanel extends LitElement {
<ha-button size="small" appearance="accent" variant="success" title="${t("complete", L)}" @click=${(e: Event) => { e.stopPropagation(); onComplete(); }}>
<ha-icon slot="start" icon="mdi:check"></ha-icon>${t("complete", L)}
</ha-button>
<ha-button size="small" appearance="plain" variant="neutral" 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>
${allowSkip ? html`
<ha-button size="small" appearance="outlined" variant="neutral" 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>`;
}
return html`
@@ -3328,9 +3375,10 @@ export class MaintenanceSupporterPanel extends LitElement {
<mwc-icon-button class="btn-complete" title="${t("complete", L)}" @click=${(e: Event) => { e.stopPropagation(); onComplete(); }}>
<ha-icon icon="mdi:check"></ha-icon>
</mwc-icon-button>
<mwc-icon-button class="btn-skip" title="${t("skip", L)}" .disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
<ha-icon icon="mdi:skip-next"></ha-icon>
</mwc-icon-button>
${allowSkip ? html`
<mwc-icon-button class="btn-skip" title="${t("skip", L)}" .disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
<ha-icon icon="mdi:skip-next"></ha-icon>
</mwc-icon-button>` : nothing}
</span>`;
}
@@ -3470,7 +3518,7 @@ export class MaintenanceSupporterPanel extends LitElement {
<span class="due-cell" @click=${() => this._showTask(obj.entry_id, task.id)}>
<span class="due-text">${formatDueDays(task.days_until_due, L)}</span>
${task.trigger_config
? renderTriggerProgress(task)
? renderTriggerProgress(task, { trend: computeTrend(task, this._miniStatsData), lang: L })
: nothing}
${renderMiniSparkline(task, this._miniStatsData, this._lang)}
</span>
@@ -3478,6 +3526,7 @@ export class MaintenanceSupporterPanel extends LitElement {
L,
() => this._openCompleteDialog(obj.entry_id, task.id, task.name, this._features.checklists ? task.checklist : undefined, this._features.adaptive && !!task.adaptive_config?.enabled),
() => this._promptSkipTask(obj.entry_id, task.id),
task.allow_skip,
)}
</div>
`)}</div>`}