329 files
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { isSafeHttpUrl } from "./helpers/url";
|
||||
import { mergeSubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
|
||||
import { isStaleBundle } from "./helpers/bundle-version";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setDateTimePrefs } from "./styles";
|
||||
@@ -29,6 +30,7 @@ import type {
|
||||
StatisticsPoint,
|
||||
SavedView,
|
||||
SavedViewFilters,
|
||||
ManualDocRef,
|
||||
} from "./types";
|
||||
import { StatisticsService } from "./statistics-service";
|
||||
import { UserService } from "./user-service";
|
||||
@@ -385,10 +387,18 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
]);
|
||||
if (viewsResult) this._savedViews = (viewsResult as { views: SavedView[] }).views || [];
|
||||
if (objResult) this._objects = (objResult as { objects: MaintenanceObjectResponse[] }).objects;
|
||||
// Battery Fleet availability (Battery Notes present + not yet set up).
|
||||
// A data refresh means the open task's history may have grown (complete,
|
||||
// history edit) — the truncated list payload can't tell, so refetch.
|
||||
if (this._view === "task" && this._selectedEntryId && this._selectedTaskId) {
|
||||
this._fetchFullHistory(this._selectedEntryId, this._selectedTaskId);
|
||||
}
|
||||
// Battery Fleet availability (batteries present + not yet set up).
|
||||
// The slim status check, NOT the overview: the full overview runs the
|
||||
// trend machinery server-side (one recorder regression per healthy
|
||||
// battery on a cold cache) — far too expensive for hiding a button.
|
||||
this.hass.connection
|
||||
.sendMessagePromise<{ available: boolean; configured: boolean }>({
|
||||
type: "maintenance_supporter/battery_fleet/overview",
|
||||
type: "maintenance_supporter/battery_fleet/status",
|
||||
})
|
||||
.then((ov) => {
|
||||
this._batteryFleetSetupAvailable = !!ov.available && !ov.configured;
|
||||
@@ -591,10 +601,15 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
try {
|
||||
const unsub = await this.hass.connection.subscribeMessage(
|
||||
(msg: unknown) => {
|
||||
const data = msg as { objects: MaintenanceObjectResponse[] };
|
||||
this._objects = data.objects;
|
||||
const next = mergeSubscriptionEvent(
|
||||
this._objects,
|
||||
msg as SubscriptionEvent<MaintenanceObjectResponse>,
|
||||
);
|
||||
if (next !== null) this._objects = next;
|
||||
},
|
||||
{ type: "maintenance_supporter/subscribe" }
|
||||
// deltas: only entries whose rebuilt response actually changed —
|
||||
// no-op timer waves send nothing, a real change ships one object.
|
||||
{ type: "maintenance_supporter/subscribe", deltas: true }
|
||||
);
|
||||
// If the element was detached while the subscribe was in flight, drop the
|
||||
// now-orphaned subscription instead of storing it on a dead component.
|
||||
@@ -880,6 +895,9 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
this._activeTab = "overview";
|
||||
this._historyFilter = null;
|
||||
this._scrollContentToTop();
|
||||
// Payload diet: list responses carry only the most recent history
|
||||
// window — the detail's full timeline/charts load here, on demand.
|
||||
this._fetchFullHistory(entryId, taskId);
|
||||
|
||||
// Lazy-load statistics for the task's trigger entity
|
||||
const task = this._getTask(entryId, taskId);
|
||||
@@ -1785,6 +1803,52 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** Open a manual-tagged document from the objects table / object header:
|
||||
* web-links directly, stored files through a signed path (the same
|
||||
* Companion-safe recipe as the documents section). */
|
||||
private _openManualDoc(doc: ManualDocRef): void {
|
||||
if (doc.kind !== "file") {
|
||||
if (isSafeHttpUrl(doc.url)) window.open(doc.url!, "_blank", "noopener");
|
||||
return;
|
||||
}
|
||||
// Open the tab synchronously (inside the click gesture) so it isn't
|
||||
// popup-blocked, then point it at the freshly signed URL.
|
||||
const win = window.open("about:blank", "_blank");
|
||||
void this.hass.connection
|
||||
.sendMessagePromise<{ path: string }>({
|
||||
type: "auth/sign_path",
|
||||
path: `/api/maintenance_supporter/document/${doc.id}`,
|
||||
expires: 300,
|
||||
})
|
||||
.then((signed) => {
|
||||
if (win) win.location.href = new URL(signed.path, window.location.origin).href;
|
||||
})
|
||||
.catch(() => win?.close());
|
||||
}
|
||||
|
||||
/** #73: persist one checklist tick. Sends the FULL current state (the
|
||||
* server replaces, not merges — idempotent) and reloads so the progress
|
||||
* header and any other open surface agree. */
|
||||
private async _setChecklistItem(entryId: string, taskId: string, item: string, done: boolean): Promise<void> {
|
||||
const obj = this._getObject(entryId);
|
||||
const task = obj?.tasks.find((x) => x.id === taskId);
|
||||
if (!task) return;
|
||||
const state: Record<string, boolean> = {};
|
||||
for (const step of task.checklist || []) {
|
||||
const current = task.checklist_progress?.[step] ?? false;
|
||||
state[step] = step === item ? done : current;
|
||||
}
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/task/checklist_progress",
|
||||
entry_id: entryId, task_id: taskId, checklist_state: state,
|
||||
});
|
||||
await this._loadData();
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
}
|
||||
}
|
||||
|
||||
private _openCompleteDialog(entryId: string, taskId: string, taskName: string, checklist?: string[], adaptiveEnabled?: boolean): void {
|
||||
const dlg = this.shadowRoot!.querySelector<MaintenanceCompleteDialog>("maintenance-complete-dialog");
|
||||
if (!dlg) return;
|
||||
@@ -1801,6 +1865,8 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
?.tasks.find((tsk) => tsk.id === taskId);
|
||||
dlg.taskType = tk?.type || "";
|
||||
dlg.readingUnit = tk?.reading_unit || "";
|
||||
// #73: ticks recorded during the cycle prefill the dialog's checklist.
|
||||
dlg.checklistPrefill = tk?.checklist_progress || {};
|
||||
dlg.requiredFields = tk?.required_completion_fields || [];
|
||||
// Spare parts: a buy task gets an editable restock-qty field; a consuming
|
||||
// task shows what it will decrement (incl. the storage location).
|
||||
@@ -2765,13 +2831,22 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
const area = o.area_id ? (this.hass?.areas?.[o.area_id]?.name || o.area_id) : "—";
|
||||
return html`<td class="oc-area_id">${area}</td>`;
|
||||
}
|
||||
case "documentation_url":
|
||||
case "documentation_url": {
|
||||
// Fallback: an UPLOADED manual (category "manual") is the object's
|
||||
// manual just as much as the legacy URL field — an object with its
|
||||
// handbook attached must not render "—" here (prod: Easee vs Epson).
|
||||
const manualDoc = (o.manual_docs || [])[0];
|
||||
return html`<td class="oc-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>`
|
||||
: "—"
|
||||
: manualDoc
|
||||
? html`<a href="#" title=${manualDoc.title}
|
||||
@click=${(e: Event) => { e.preventDefault(); e.stopPropagation(); this._openManualDoc(manualDoc); }}
|
||||
><ha-icon icon="mdi:file-document-outline"></ha-icon></a>`
|
||||
: "—"
|
||||
}</td>`;
|
||||
}
|
||||
case "notes":
|
||||
return html`<td class="oc-notes" title=${o.notes || ""}>${o.notes || "—"}</td>`;
|
||||
case "task_count":
|
||||
@@ -3085,7 +3160,16 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
? html`<p class="meta">${t("documentation_url_label", L)}:
|
||||
<a href=${o.documentation_url} target="_blank" rel="noopener noreferrer">${o.documentation_url}</a>
|
||||
</p>`
|
||||
: nothing}
|
||||
: (o.manual_docs || []).length
|
||||
? html`<p class="meta">${t("documentation_url_label", L)}:
|
||||
${o.manual_docs!.slice(0, 3).map(
|
||||
(m, i) => html`${i > 0 ? " · " : ""}<a href="#"
|
||||
@click=${(e: Event) => { e.preventDefault(); this._openManualDoc(m); }}>${m.title}</a>`,
|
||||
)}${o.manual_docs!.length > 3
|
||||
? html` … +${o.manual_docs!.length - 3}`
|
||||
: nothing}
|
||||
</p>`
|
||||
: nothing}
|
||||
${o.installation_date ? html`<p class="meta">${t("installed", L)}: ${formatDate(o.installation_date, L)}</p>` : nothing}
|
||||
${o.warranty_expiry ? this._renderWarrantyMeta(o.warranty_expiry, L) : nothing}
|
||||
${o.notes
|
||||
@@ -3157,6 +3241,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
.entryId=${obj.entry_id}
|
||||
.parts=${obj.parts || []}
|
||||
.canWrite=${!isOperator}
|
||||
.currencySymbol=${this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL}
|
||||
@parts-changed=${() => this._loadData()}
|
||||
></maintenance-parts-section>
|
||||
</div>
|
||||
@@ -3221,7 +3306,16 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
const task = this._selectedEntryId && this._selectedTaskId
|
||||
? this._getObject(this._selectedEntryId)?.tasks.find((tk) => tk.id === this._selectedTaskId)
|
||||
: undefined;
|
||||
const readings = (task?.history || [])
|
||||
// Payload diet: the summary's history is truncated to the recent window —
|
||||
// the reading DELTAS must come from the full record (the oldest visible
|
||||
// reading would otherwise lose or falsify its delta), which
|
||||
// _fetchFullHistory loads for the open task.
|
||||
const fh = this._fullHistory;
|
||||
const fullHistory =
|
||||
fh && fh.entryId === this._selectedEntryId && fh.taskId === this._selectedTaskId && fh.entries.length > (task?.history || []).length
|
||||
? fh.entries
|
||||
: task?.history || [];
|
||||
const readings = fullHistory
|
||||
.filter((h) => h.reading_value != null)
|
||||
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||
return {
|
||||
@@ -3256,6 +3350,9 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
taskId,
|
||||
objectName: obj?.object.name || "",
|
||||
objectDocUrl: obj?.object?.documentation_url ?? null,
|
||||
objectManualDocs: obj?.object?.manual_docs ?? [],
|
||||
openManualDoc: (doc) => this._openManualDoc(doc),
|
||||
setChecklistItem: (item, done) => this._setChecklistItem(entryId, taskId, item, done),
|
||||
isOperator: this._isOperator,
|
||||
actionLoading: this._actionLoading,
|
||||
moreMenuOpen: this._moreMenuOpen,
|
||||
@@ -3293,12 +3390,37 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
/** Full history for the OPEN task (list payloads are truncated to the
|
||||
* most recent window). null until loaded; a failure — e.g. an older
|
||||
* backend without `task/history` — falls back to the truncated list. */
|
||||
@state() private _fullHistory: { entryId: string; taskId: string; entries: HistoryEntry[] } | null = null;
|
||||
|
||||
private async _fetchFullHistory(entryId: string, taskId: string): Promise<void> {
|
||||
try {
|
||||
const res = (await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/task/history",
|
||||
entry_id: entryId,
|
||||
task_id: taskId,
|
||||
})) as { history: HistoryEntry[] };
|
||||
if (this._selectedEntryId === entryId && this._selectedTaskId === taskId) {
|
||||
this._fullHistory = { entryId, taskId, entries: res.history || [] };
|
||||
}
|
||||
} catch {
|
||||
this._fullHistory = null;
|
||||
}
|
||||
}
|
||||
|
||||
private _renderTaskDetail() {
|
||||
if (!this._selectedEntryId || !this._selectedTaskId) return nothing;
|
||||
const task = this._getTask(this._selectedEntryId, this._selectedTaskId);
|
||||
if (!task) return html`<p>Task not found.</p>`;
|
||||
const fh = this._fullHistory;
|
||||
const detailTask =
|
||||
fh && fh.entryId === this._selectedEntryId && fh.taskId === this._selectedTaskId && fh.entries.length > (task.history || []).length
|
||||
? { ...task, history: fh.entries }
|
||||
: task;
|
||||
return html`<maintenance-task-detail-view
|
||||
.task=${task}
|
||||
.task=${detailTask}
|
||||
.ctx=${this._taskDetailCtx()}
|
||||
></maintenance-task-detail-view>`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user