108 files

This commit is contained in:
Home Assistant Version Control
2026-08-13 23:38:20 +00:00
parent d851c2a3a5
commit 4e0539a155
108 changed files with 1977 additions and 9644 deletions
@@ -79,7 +79,32 @@ import { renderUserBadge, type TaskDetailContext } from "./renderers/task-detail
import "./components/task-detail-view";
import { computeWindow, VIRTUAL_MIN_ROWS } from "./helpers/virtual-window";
type View = "overview" | "object" | "task" | "all_objects";
type View = "overview" | "object" | "task" | "all_objects" | "all_parts";
/** One row of the instance-wide parts overview (#130) — the WS response of
* `parts/overview`: the stored part fields plus owner, live stock and the
* consuming tasks (own + pooled #111 links). */
interface PartsOverviewRow {
part_id: string;
entry_id: string;
object_name: string | null;
name: string;
unit?: string | null;
cost?: number | null;
storage_location?: string | null;
vendor?: string | null;
reorder_threshold?: number | null;
stock: number | null;
low: boolean;
consumers: Array<{
entry_id: string;
object_name: string | null;
task_id: string;
task_name: string | null;
quantity: number;
pooled: boolean;
}>;
}
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";
@@ -95,6 +120,8 @@ export class MaintenanceSupporterPanel extends LitElement {
@state() private _objects: MaintenanceObjectResponse[] = [];
@state() private _stats: StatisticsResponse | null = null;
@state() private _view: View = "overview";
// #130: rows of the all-parts view; null until first load.
@state() private _allParts: PartsOverviewRow[] | null = null;
@state() private _selectedEntryId: string | null = null;
@state() private _selectedTaskId: string | null = null;
@state() private _filterStatus = "";
@@ -813,6 +840,7 @@ export class MaintenanceSupporterPanel extends LitElement {
this._selectedEntryId = s.msp_entry || null;
this._selectedTaskId = s.msp_task || null;
this._moreMenuOpen = false;
if (s.msp_view === "all_parts") void this._loadAllParts();
if (s.msp_view === "task" && s.msp_entry && s.msp_task) {
this._historyFilter = null;
const task = this._getTask(s.msp_entry, s.msp_task);
@@ -839,6 +867,27 @@ export class MaintenanceSupporterPanel extends LitElement {
this._scrollContentToTop();
}
// #130: instance-wide parts inventory, sibling view of All objects.
private _showAllParts(): void {
this._pushPanelState("all_parts");
this._view = "all_parts";
this._selectedEntryId = null;
this._selectedTaskId = null;
this._scrollContentToTop();
void this._loadAllParts();
}
private async _loadAllParts(): Promise<void> {
try {
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/parts/overview",
}) as { parts: PartsOverviewRow[] };
this._allParts = result.parts || [];
} catch {
this._allParts = [];
}
}
/** 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 {
@@ -1990,6 +2039,8 @@ export class MaintenanceSupporterPanel extends LitElement {
? this._renderOverview()
: this._view === "all_objects"
? this._renderAllObjects()
: this._view === "all_parts"
? this._renderAllParts()
: this._view === "object"
? this._renderObjectDetail()
: this._renderTaskDetail()}
@@ -2703,6 +2754,9 @@ export class MaintenanceSupporterPanel extends LitElement {
<ha-icon icon="mdi:arrow-left"></ha-icon>
</ha-icon-button>
<span>${t("all_objects", L)}</span>
<button class="sibling-view-chip" @click=${() => this._showAllParts()}>
<ha-icon icon="mdi:package-variant-closed"></ha-icon> ${t("all_parts", L)}
</button>
</div>
<div class="filter-bar">
<label class="filter-field">
@@ -2792,6 +2846,106 @@ export class MaintenanceSupporterPanel extends LitElement {
localStorage.setItem(LS_KEYS.objectView, mode);
}
// ── #130: instance-wide parts overview ────────────────────────────────────
private _renderAllParts() {
const L = this._lang;
const rows = this._allParts;
const currency = this._budget?.currency_symbol || "";
return html`
<div class="breadcrumb">
<ha-icon-button @click=${() => this._showAllObjects()}>
<ha-icon icon="mdi:arrow-left"></ha-icon>
</ha-icon-button>
<span>${t("all_parts", L)}</span>
<button class="sibling-view-chip" @click=${() => this._showAllObjects()}>
<ha-icon icon="mdi:devices"></ha-icon> ${t("all_objects", L)}
</button>
</div>
<div class="filter-bar">
<ha-button appearance="plain" @click=${() => this._exportPartsCsv()}>
<ha-icon icon="mdi:file-delimited-outline"></ha-icon> ${t("settings_export_csv", L)}
</ha-button>
</div>
${rows === null
? html`<div class="empty-state">…</div>`
: rows.length === 0
? html`<div class="empty-state">${t("parts_section", L)}: 0</div>`
: html`
<div class="objects-table-wrap">
<table class="objects-table">
<thead>
<tr>
<th>${t("part_name", L)}</th>
<th>${t("object", L)}</th>
<th>${t("part_stock", L)}</th>
<th>${t("part_reorder_threshold", L)}</th>
<th>${t("part_cost", L)}</th>
<th>${t("part_storage_location", L)}</th>
<th>${t("parts_used_by", L)}</th>
</tr>
</thead>
<tbody>
${rows.map((row) => html`
<tr class="objects-table-row" @click=${() => this._showObject(row.entry_id)}>
<td>
<span class="objects-table-name">${row.name}</span>
${row.low
? html`<ha-icon class="part-low-icon" icon="mdi:cart-arrow-down"
title="${t("part_reorder_threshold", L)}: ${row.reorder_threshold}"></ha-icon>`
: nothing}
</td>
<td>${row.object_name || "—"}</td>
<td>${row.stock !== null ? `${row.stock}${row.unit ? ` ${row.unit}` : ""}` : "—"}</td>
<td>${row.reorder_threshold ?? "—"}</td>
<td>${row.cost != null ? `${row.cost} ${currency}`.trim() : "—"}</td>
<td>${row.storage_location || "—"}</td>
<td>
${row.consumers.length === 0
? "—"
: row.consumers.map((c) => html`
<span
class="part-consumer-chip${c.pooled ? " pooled" : ""}"
title=${`${c.object_name ?? ""}: ${c.task_name ?? c.task_id} (×${c.quantity})`}
>${c.pooled ? `${c.object_name} · ` : ""}${c.task_name ?? c.task_id}</span>
`)}
</td>
</tr>
`)}
</tbody>
</table>
</div>
`}
`;
}
/** Client-side CSV of the loaded overview rows (the objects table's CSV is
* server-built; the parts rows are already fully materialized here). */
private _exportPartsCsv(): void {
const rows = this._allParts || [];
const esc = (v: unknown): string => {
const s = v == null ? "" : String(v);
return /[",\n;]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const header = ["name", "object", "stock", "unit", "reorder_threshold", "unit_cost", "storage_location", "vendor", "used_by"];
const lines = [header.join(",")];
for (const row of rows) {
lines.push([
esc(row.name),
esc(row.object_name),
esc(row.stock),
esc(row.unit),
esc(row.reorder_threshold),
esc(row.cost),
esc(row.storage_location),
esc(row.vendor),
esc(row.consumers.map((c) => `${c.object_name ?? ""}/${c.task_name ?? c.task_id}×${c.quantity}`).join(" | ")),
].join(","));
}
const ts = new Date().toISOString().slice(0, 10);
downloadTextFile(lines.join("\n"), `maintenance_parts_${ts}.csv`, "text/csv;charset=utf-8");
}
// (#67 / Phase 3) Download all objects as a one-row-per-object CSV.
private async _exportObjectsCsv(): Promise<void> {
try {
@@ -3602,6 +3756,7 @@ export class MaintenanceSupporterPanel extends LitElement {
cost: entry.cost ?? null,
duration: entry.duration ?? null,
completed_by: entry.completed_by ?? null,
used_parts: entry.used_parts ?? null,
};
this.shadowRoot
?.querySelector<MaintenanceHistoryEditDialog>("maintenance-history-edit-dialog")