217 files

This commit is contained in:
Home Assistant Version Control
2026-07-30 23:59:38 +00:00
parent d43a63ad29
commit 7b5e46e702
217 changed files with 15978 additions and 3912 deletions
@@ -11,18 +11,14 @@ import { property, state } from "lit/decorators.js";
import { t, ensureLocale, DEFAULT_CURRENCY_SYMBOL } from "../styles";
import { describeWsError } from "../ws-errors";
import { sectionCardSharedStyles } from "./section-card-shared-styles";
import type { HomeAssistant } from "../types";
interface BudgetStatus {
monthly_budget?: number;
monthly_spent?: number;
yearly_budget?: number;
yearly_spent?: number;
currency_symbol?: string;
}
import type { BudgetStatus, HomeAssistant } from "../types";
interface CardConfig { type: string; title?: string; }
/** Backend default for budget_alert_threshold (const.py / settings_registry).
* Only reached if an older core omits the field from budget_status. */
const DEFAULT_ALERT_THRESHOLD_PCT = 80;
export class MaintenanceBudgetSectionCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config: CardConfig = { type: "" };
@@ -109,10 +105,21 @@ export class MaintenanceBudgetSectionCard extends LitElement {
return html`<ha-card><div class="loading">${t("loading", L) || "Loading…"}</div></ha-card>`;
}
const sym = s.currency_symbol || DEFAULT_CURRENCY_SYMBOL;
const mPct = s.monthly_budget ? Math.min(100, ((s.monthly_spent || 0) / s.monthly_budget) * 100) : 0;
const yPct = s.yearly_budget ? Math.min(100, ((s.yearly_spent || 0) / s.yearly_budget) * 100) : 0;
const mWarn = mPct >= 100 ? "danger" : mPct >= 80 ? "warning" : "ok";
const yWarn = yPct >= 100 ? "danger" : yPct >= 80 ? "warning" : "ok";
// The amber step is the CONFIGURED budget_alert_threshold, not a literal 80
// — same rule the panel's budget bar uses (maintenance-panel._renderBudgetBar).
const threshold = s.alert_threshold_pct ?? DEFAULT_ALERT_THRESHOLD_PCT;
const tracks = [
{
label: t("budget_monthly", L) || "Monthly",
spent: s.monthly_spent || 0,
budget: s.monthly_budget || 0,
},
{
label: t("budget_yearly", L) || "Yearly",
spent: s.yearly_spent || 0,
budget: s.yearly_budget || 0,
},
];
return html`
<ha-card>
@@ -127,25 +134,34 @@ export class MaintenanceBudgetSectionCard extends LitElement {
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
<div class="track">
<div class="track-label-row">
<label>${t("budget_monthly", L) || "Monthly"}</label>
<span class="track-numbers ${mWarn}">
${(s.monthly_spent || 0).toFixed(0)} / ${(s.monthly_budget || 0).toFixed(0)} ${sym}
</span>
</div>
<div class="bar"><div class="bar-fill ${mWarn}" style="width:${mPct}%"></div></div>
</div>
<div class="track">
<div class="track-label-row">
<label>${t("budget_yearly", L) || "Yearly"}</label>
<span class="track-numbers ${yWarn}">
${(s.yearly_spent || 0).toFixed(0)} / ${(s.yearly_budget || 0).toFixed(0)} ${sym}
</span>
</div>
<div class="bar"><div class="bar-fill ${yWarn}" style="width:${yPct}%"></div></div>
</div>
${tracks.map((track) => {
// #104: budget tracking without a maximum — a bar needs a
// denominator, so show the plain spent total instead of "9 / 0 €"
// over an always-empty bar. Mirrors the panel's spent-only lines.
if (!(track.budget > 0)) {
return html`
<div class="track spent-only">
<div class="track-label-row">
<label>${track.label}</label>
<span class="track-numbers ok">${track.spent.toFixed(0)} ${sym}</span>
</div>
</div>
`;
}
const pct = Math.min(100, Math.max(0, (track.spent / track.budget) * 100));
const warn = pct >= 100 ? "danger" : pct >= threshold ? "warning" : "ok";
return html`
<div class="track">
<div class="track-label-row">
<label>${track.label}</label>
<span class="track-numbers ${warn}">
${track.spent.toFixed(0)} / ${track.budget.toFixed(0)} ${sym}
</span>
</div>
<div class="bar"><div class="bar-fill ${warn}" style="width:${pct}%"></div></div>
</div>
`;
})}
${this._isAdmin
? html`
@@ -2,9 +2,11 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant } from "../types";
import type { HomeAssistant, TaskPartLink } from "../types";
import { t, nativeFieldStyles } from "../styles";
import { describeWsError } from "../ws-errors";
import { partLinkKey, type LinkedPart } from "../helpers/shared-parts";
import { REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
export class MaintenanceCompleteDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -19,12 +21,20 @@ export class MaintenanceCompleteDialog extends LitElement {
@property() public readingUnit = "";
/** Buy task (part_ref): default restock quantity — shows an editable qty field. */
@property({ attribute: false }) public restockDefault: number | null = null;
/** #99: the object's parts — enables the editable "parts used" section. */
@property({ attribute: false }) public parts: Array<{ id: string; name: string; unit?: string | null; stock?: number | null }> = [];
/** #99: the task's fixed consumes_parts links (prefill for the section). */
@property({ attribute: false }) public consumesParts: Array<{ part_id: string; quantity: number }> = [];
/** #99: the parts offered on completion — enables the editable "parts used"
* section. Built by `partsForCompletion`: the object's own inventory plus
* every shared pool this task links to (#111), each tagged with its owner. */
@property({ attribute: false }) public parts: LinkedPart[] = [];
/** #99: the task's fixed consumes_parts links (prefill for the section).
* A link may carry an `entry_id` (#111) and MUST keep it through the edit —
* without it the completion would decrement the wrong inventory, or none. */
@property({ attribute: false }) public consumesParts: TaskPartLink[] = [];
/** "Consumes: 1× HEPA-Filter (Shelf B)" hint lines for consuming tasks. */
@property({ type: Array }) public consumesInfo: string[] = [];
/** Details this task demands before it counts as done (v2.44). The backend
* enforces the same list at every completion surface; blocking Save here
* just means the user never has to meet that rejection. */
@property({ type: Array }) public requiredFields: string[] = [];
@state() private _open = false;
@state() private _notes = "";
@state() private _cost = "";
@@ -38,7 +48,9 @@ export class MaintenanceCompleteDialog extends LitElement {
@state() private _photoUploading = false;
@state() private _readingValue = "";
@state() private _restockQty = "";
@state() private _usedParts: Record<string, number> = {};
/** Keyed by `partLinkKey` — the (entry_id, part_id) pair — because two
* objects can carry the same part id, so part_id alone would merge pools. */
@state() private _usedParts: Record<string, TaskPartLink> = {};
public open(): void {
if (this._open) return;
@@ -55,8 +67,9 @@ export class MaintenanceCompleteDialog extends LitElement {
this._readingValue = "";
this._restockQty = this.restockDefault !== null ? String(this.restockDefault) : "";
// #99: prefill "parts used" with the task's fixed links — the user can
// untick or adjust before completing.
this._usedParts = Object.fromEntries(this.consumesParts.map((l) => [l.part_id, l.quantity]));
// untick or adjust before completing. The whole link is kept, entry_id
// included, so a shared pool survives the edit (#111).
this._usedParts = Object.fromEntries(this.consumesParts.map((l) => [partLinkKey(l), { ...l }]));
}
private _toggleCheck(idx: number): void {
@@ -149,10 +162,16 @@ export class MaintenanceCompleteDialog extends LitElement {
}
// #99: with a parts section shown, send the explicit selection — it
// replaces the automatic consumes_parts deduction (empty = none used).
// entry_id travels only when the pool is somebody else's (#111), so an
// own-part payload is byte-identical to what shipped before.
if (this.parts.length > 0) {
data.used_parts = Object.entries(this._usedParts)
.filter(([, qty]) => Number.isFinite(qty) && qty > 0)
.map(([part_id, quantity]) => ({ part_id, quantity }));
data.used_parts = Object.values(this._usedParts)
.filter((l) => Number.isFinite(l.quantity) && l.quantity > 0)
.map((l) =>
l.entry_id
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
: { part_id: l.part_id, quantity: l.quantity },
);
}
await this.hass.connection.sendMessagePromise(data);
this._open = false;
@@ -164,6 +183,28 @@ export class MaintenanceCompleteDialog extends LitElement {
}
}
/** Required details the user has not supplied yet (drives Save + markers). */
private get _missingRequired(): string[] {
const filled: Record<string, boolean> = {
notes: this._notes.trim() !== "",
cost: this._cost.trim() !== "",
duration: this._duration.trim() !== "",
photo: this._photoDocId !== "",
// "Who did it" is filled in server-side from the authenticated
// connection (websocket/tasks_actions.py), so the dialog satisfies it
// as long as we ARE a logged-in user. Claiming it is always satisfied
// was how a task requiring "user" ended up unclosable: Save stayed
// enabled and the backend rejected the completion every time.
user: !!this.hass?.user,
};
return this.requiredFields.filter((f) => !filled[f]);
}
/** Marker appended to a required field's label. */
private _req(field: string) {
return this.requiredFields.includes(field) ? html`<span class="req-mark" aria-hidden="true">*</span>` : nothing;
}
private _close(): void {
this._open = false;
}
@@ -200,25 +241,36 @@ export class MaintenanceCompleteDialog extends LitElement {
? html`<div class="used-parts">
<span class="field-label">${t("complete_parts_used", L)}</span>
${this.parts.map((pt) => {
const qty = this._usedParts[pt.id];
const checked = qty !== undefined;
const key = partLinkKey({ part_id: pt.id, entry_id: pt.entry_id });
const link = this._usedParts[key];
const checked = link !== undefined;
const base: TaskPartLink = pt.entry_id
? { part_id: pt.id, quantity: 1, entry_id: pt.entry_id }
: { part_id: pt.id, quantity: 1 };
return html`<div class="used-part-row">
<label class="used-part-check">
<input type="checkbox" .checked=${checked}
@change=${(e: Event) => {
const next = { ...this._usedParts };
if ((e.target as HTMLInputElement).checked) next[pt.id] = next[pt.id] || 1;
else delete next[pt.id];
if ((e.target as HTMLInputElement).checked) next[key] = next[key] || base;
else delete next[key];
this._usedParts = next;
}} />
<span>${pt.name}${pt.stock !== null && pt.stock !== undefined ? ` (${pt.stock}${pt.unit ? " " + pt.unit : ""})` : ""}</span>
<span
>${pt.name}${pt.owner_name
? html`<span class="used-part-owner"> (${pt.owner_name})</span>`
: nothing}${pt.stock !== null && pt.stock !== undefined ? ` (${pt.stock}${pt.unit ? " " + pt.unit : ""})` : ""}</span
>
</label>
${checked
? html`<input class="used-part-qty" type="number" min="0.01" max="999" step="0.01"
.value=${String(qty)}
.value=${String(link.quantity)}
@input=${(e: Event) => {
const v = parseFloat((e.target as HTMLInputElement).value);
this._usedParts = { ...this._usedParts, [pt.id]: Number.isFinite(v) && v >= 0.01 ? v : 1 };
this._usedParts = {
...this._usedParts,
[key]: { ...base, quantity: Number.isFinite(v) && v >= 0.01 ? v : 1 },
};
}} />`
: nothing}
</div>`;
@@ -245,25 +297,25 @@ export class MaintenanceCompleteDialog extends LitElement {
only sees the title + Cancel/Complete buttons — the original
bug report. Native inputs always render. -->
<label class="field">
<span class="field-label">${t("notes_optional", L)}</span>
<span class="field-label">${t("notes_optional", L)}${this._req("notes")}</span>
<input type="text" class="field-input"
.value=${this._notes}
@input=${(e: Event) => (this._notes = (e.target as HTMLInputElement).value)} />
</label>
<label class="field">
<span class="field-label">${t("cost_optional", L)}</span>
<span class="field-label">${t("cost_optional", L)}${this._req("cost")}</span>
<input type="number" step="0.01" min="0" class="field-input"
.value=${this._cost}
@input=${(e: Event) => (this._cost = (e.target as HTMLInputElement).value)} />
</label>
<label class="field">
<span class="field-label">${t("duration_minutes", L)}</span>
<span class="field-label">${t("duration_minutes", L)}${this._req("duration")}</span>
<input type="number" step="0.01" min="0" class="field-input"
.value=${this._duration}
@input=${(e: Event) => (this._duration = (e.target as HTMLInputElement).value)} />
</label>
<div class="field">
<span class="field-label">${t("completion_photo_optional", L)}</span>
<span class="field-label">${t("completion_photo_optional", L)}${this._req("photo")}</span>
${this._photoPreview
? html`
<div class="photo-preview">
@@ -306,7 +358,10 @@ export class MaintenanceCompleteDialog extends LitElement {
</ha-button>
<ha-button
@click=${this._complete}
.disabled=${this._loading}
.disabled=${this._loading || this._missingRequired.length > 0}
title=${this._missingRequired.length
? this._missingRequired.map((f) => t("err_required", L).replace("{field}", t(REQUIRED_COMPLETION_LABELS[f] ?? f, L))).join(" · ")
: ""}
>
${this._loading ? t("completing", L) : t("complete", L)}
</ha-button>
@@ -316,6 +371,11 @@ export class MaintenanceCompleteDialog extends LitElement {
}
static styles = [nativeFieldStyles, css`
.req-mark {
color: var(--error-color, #f44336);
margin-left: 2px;
font-weight: 600;
}
.dialog-title {
font-size: 18px;
font-weight: 500;
@@ -348,6 +408,9 @@ export class MaintenanceCompleteDialog extends LitElement {
font-size: 13px; cursor: pointer;
}
.used-part-check input { cursor: pointer; }
/* #111: whose stock this row draws on. Muted but never omitted — an
unlabelled foreign pool is indistinguishable from an own part. */
.used-part-owner { color: var(--secondary-text-color); }
.used-part-qty {
width: 76px; padding: 4px 6px; border-radius: 4px; font: inherit; font-size: 13px;
border: 1px solid var(--divider-color);
@@ -155,9 +155,15 @@ export class MaintenanceQrDialog extends LitElement {
const completeLabel = escapeHtml(t("qr_action_complete", L));
w.document.write(`<!DOCTYPE html><html><head><meta charset="utf-8">
<meta name="color-scheme" content="light">
<title>${safeTitle}</title>
<style>
body{font-family:sans-serif;text-align:center;padding:20px}
/* Printable sheet — must not inherit the phone's dark theme. The QR images
carry their own white quiet zone and stay scannable either way, but the
labels below are explicit dark greys and would vanish on a WebView's dark
canvas. Same reasoning as helpers/report.ts. */
:root{color-scheme:light}
body{font-family:sans-serif;text-align:center;padding:20px;background:#fff;color:#1a1a1a}
h2{margin:0 0 4px}
.sub{color:#666;font-size:14px;margin-bottom:16px}
.qr-row{display:flex;justify-content:center;gap:24px;margin:12px 0}
@@ -9,6 +9,15 @@ import { UserService } from "../user-service";
import { OBJECT_COLUMNS, sanitizeColumns } from "../helpers/object-columns";
import { downloadTextFile } from "../helpers/download";
/** One household member and the notify services they actually resolve to.
* Empty `services` means no Companion device is linked, so their reminders
* fall back to the household notification service. */
interface PersonNotifyTarget {
user_id: string;
name: string;
services: string[];
}
/* Settings response shape from WS maintenance_supporter/settings */
interface SettingsResponse {
features: AdvancedFeatures;
@@ -27,6 +36,8 @@ interface SettingsResponse {
notify_targets?: string[];
panel_enabled: boolean;
panel_title: string;
/** Opt-in copy of the shipped Assist sentences into the config dir. */
install_assist_sentences?: boolean;
};
notifications: {
due_soon_enabled: boolean;
@@ -108,6 +119,8 @@ export class MaintenanceSettingsView extends LitElement {
@state() private _includeHistory = true;
@state() private _toast = "";
@state() private _testingNotification = false;
@state() private _personTargets: PersonNotifyTarget[] = [];
@state() private _testingUser = "";
@state() private _users: HAUser[] = [];
@state() private _savedViews: Array<{ id: string; name: string }> = [];
@@ -169,6 +182,23 @@ export class MaintenanceSettingsView extends LitElement {
} catch {
this._users = [];
}
this._loadNotifyTargets();
}
/** Which notify services each household member resolves to.
*
* Resolved by the backend through the same helper the reminder path uses,
* so the list shown here is the list that will actually be used.
*/
private async _loadNotifyTargets(): Promise<void> {
try {
const res = await this.hass.connection.sendMessagePromise<{ targets: PersonNotifyTarget[] }>({
type: "maintenance_supporter/notify/user_targets",
});
this._personTargets = res.targets || [];
} catch {
this._personTargets = [];
}
}
private async _loadSettings(): Promise<void> {
@@ -220,11 +250,13 @@ export class MaintenanceSettingsView extends LitElement {
}
}
private _sendTestNotification = async (): Promise<void> => {
this._testingNotification = true;
private _sendTestNotification = async (userId?: string): Promise<void> => {
if (userId) this._testingUser = userId;
else this._testingNotification = true;
try {
const res = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/global/test_notification",
...(userId ? { user_id: userId } : {}),
}) as { success: boolean; message?: string };
const msg = res.message
|| (res.success ? t("test_notification_success", this._lang) : t("test_notification_failed", this._lang));
@@ -232,7 +264,8 @@ export class MaintenanceSettingsView extends LitElement {
} catch {
this._showToast(t("test_notification_failed", this._lang));
} finally {
this._testingNotification = false;
if (userId) this._testingUser = "";
else this._testingNotification = false;
}
};
@@ -570,6 +603,12 @@ export class MaintenanceSettingsView extends LitElement {
@change=${(e: Event) => this._updateSetting("panel_title", (e.target as HTMLInputElement).value.trim())} />
</label>
` : ""}
<label class="setting-row">
<span class="setting-label">${t("settings_install_assist_sentences", L)}</span>
<input type="checkbox" .checked=${g.install_assist_sentences ?? false}
@change=${(e: Event) => this._updateSetting("install_assist_sentences", (e.target as HTMLInputElement).checked)} />
</label>
<div class="setting-hint">${t("settings_install_assist_sentences_hint", L)}</div>
<label class="setting-row">
<span class="setting-label">${t("settings_notifications", L)}</span>
<input type="checkbox" .checked=${g.notifications_enabled}
@@ -588,10 +627,28 @@ export class MaintenanceSettingsView extends LitElement {
<span class="setting-label">${t("test_notification", L)}</span>
<button class="ha-button secondary"
?disabled=${!g.notify_service || this._testingNotification}
@click=${this._sendTestNotification}>
@click=${() => this._sendTestNotification()}>
${this._testingNotification ? t("testing", L) : t("send_test", L)}
</button>
</div>
${this._personTargets.length ? html`
<div class="notify-per-person">
<span class="setting-label">${t("notify_per_person", L)}</span>
${this._personTargets.map((target) => html`
<div class="notify-person-row">
<span class="notify-person-name">${target.name}</span>
<span class="notify-person-target ${target.services.length ? "" : "muted"}">
${target.services.length ? target.services.join(", ") : t("notify_no_own_device", L)}
</span>
<button class="ha-button secondary"
?disabled=${!target.services.length || this._testingUser === target.user_id}
@click=${() => this._sendTestNotification(target.user_id)}>
${this._testingUser === target.user_id ? t("testing", L) : t("send_test", L)}
</button>
</div>
`)}
</div>
` : nothing}
` : nothing}
</div>
`;
@@ -1606,6 +1663,31 @@ export class MaintenanceSettingsView extends LitElement {
cursor: pointer;
gap: 12px;
}
.notify-per-person {
padding: 10px 0;
border-bottom: 1px solid var(--divider-color, #e0e0e0);
}
.notify-person-row {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 0 0;
flex-wrap: wrap;
}
.notify-person-name {
font-weight: 500;
min-width: 120px;
}
.notify-person-target {
flex: 1;
min-width: 160px;
font-size: 0.9em;
word-break: break-word;
color: var(--secondary-text-color, #727272);
}
.notify-person-target.muted {
font-style: italic;
}
/* v2.27: template gallery clustered by category */
.tpl-group { margin-top: 14px; }
.tpl-group-head {
@@ -2,11 +2,13 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TriggerConfig, HAUser } from "../types";
import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TaskPartLink, TriggerConfig, HAUser } from "../types";
import { formatDate, t, weekdayName } from "../styles";
import { UserService } from "../user-service";
import { partLinkKey } from "../helpers/shared-parts";
import { describeWsError } from "../ws-errors";
import { REQUIRED_COMPLETION_KEYS, REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
import "./ms-textfield";
const MAINTENANCE_TYPE_KEYS = ["cleaning", "inspection", "replacement", "calibration", "service", "reading", "custom"];
@@ -122,6 +124,14 @@ export class MaintenanceTaskDialog extends LitElement {
@property({ type: Number, attribute: "default-warning-days" }) public defaultWarningDays = 7;
/** The object's spare parts — offered as "consumes parts" checkboxes. */
@state() private parts: Array<{ id: string; name: string; unit?: string }> = [];
/** #111: OTHER objects that own spare parts, so a task can draw on a shared
* pool (three vacuums, one box of dust bags). Grouped by owner in the UI —
* which pool a link means must never be a guess. */
@state() private _foreignOwners: Array<{
entry_id: string;
name: string;
parts: Array<{ id: string; name: string; unit?: string }>;
}> = [];
@state() private _open = false;
@state() private _loading = false;
@state() private _error = "";
@@ -210,7 +220,9 @@ export class MaintenanceTaskDialog extends LitElement {
@state() private _nfcTagId = "";
// v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...)
@state() private _readingUnit = "";
@state() private _consumesParts: Record<string, number> = {};
/** The picked links, keyed by `partLinkKey` — the (entry_id, part_id) pair,
* since the same part id can exist on two objects (battery fleet). */
@state() private _consumesParts: Record<string, TaskPartLink> = {};
@state() private _partsLoadFailed = false;
@state() private _availableTags: Array<{id: string; name: string}> = [];
@@ -222,6 +234,9 @@ export class MaintenanceTaskDialog extends LitElement {
// Checklist (newline-separated steps, one per line)
@state() private _checklistText = "";
/** Details this task demands on completion — enforced by the backend on
* every surface, so a button press or voice command cannot bypass it. */
@state() private _requiredCompletion: string[] = [];
// Schedule time (HH:MM, advanced feature)
@state() private _scheduleTime = "";
@@ -271,7 +286,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._objectChoices = [];
}
this._resetFields();
await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts()]);
await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts(), this._loadForeignPools()]);
this._open = true;
}
@@ -321,12 +336,17 @@ export class MaintenanceTaskDialog extends LitElement {
this._lastPerformed = task.last_performed || "";
this._nfcTagId = task.nfc_tag_id || "";
this._readingUnit = task.reading_unit || "";
this._consumesParts = Object.fromEntries((task.consumes_parts || []).map((l) => [l.part_id, l.quantity]));
// Whole link, entry_id included — hydrating only part_id would turn every
// shared-pool link into an own-part link on the next save (#111).
this._consumesParts = Object.fromEntries(
(task.consumes_parts || []).map((l) => [partLinkKey(l), { ...l }]),
);
this._responsibleUserId = task.responsible_user_id || null;
this._assigneePool = [...(task.assignee_pool || [])];
this._rotationStrategy = task.rotation_strategy || "";
this._checklistText = (task.checklist || []).join("\n");
this._requiredCompletion = [...(task.required_completion_fields || [])];
this._scheduleTime = task.schedule_time || "";
// v1.3.0: hydrate on_complete_action + quick_complete_defaults
@@ -395,7 +415,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._fetchEntityAttributes(this._triggerEntityId);
}
await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts()]);
await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts(), this._loadForeignPools()]);
this._open = true;
}
@@ -434,6 +454,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._assigneePool = [];
this._rotationStrategy = "";
this._checklistText = "";
this._requiredCompletion = [];
this._scheduleTime = "";
this._environmentalEntity = "";
this._environmentalAttribute = "";
@@ -736,6 +757,40 @@ export class MaintenanceTaskDialog extends LitElement {
}
}
/** #111: the other objects' spare-part pools this task could draw on.
*
* A sibling of `_loadParts`, run in the SAME Promise.all rather than nested
* inside it: chaining it after the own-parts fetch delays the dialog opening
* by a further round trip for a list that is secondary to it.
*
* Failure is soft on purpose — the own-parts picker is the primary path and
* must not disappear because this second call did not come back. */
private async _loadForeignPools(): Promise<void> {
this._foreignOwners = [];
if (!this._entryId) return;
try {
const result = (await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/objects",
})) as {
objects?: Array<{
entry_id: string;
object?: { name?: string };
parts?: Array<{ id: string; name: string; unit?: string }>;
}>;
};
this._foreignOwners = (result.objects || [])
.filter((o) => o.entry_id !== this._entryId && (o.parts || []).length > 0)
.map((o) => ({
entry_id: o.entry_id,
name: o.object?.name || o.entry_id,
parts: o.parts || [],
}))
.sort((a, b) => a.name.localeCompare(b.name));
} catch {
this._foreignOwners = [];
}
}
private async _loadTags(): Promise<void> {
try {
const result = await this.hass.connection.sendMessagePromise({
@@ -773,6 +828,69 @@ export class MaintenanceTaskDialog extends LitElement {
}
}
/** A task already drawing on a shared pool opens that section expanded — a
* collapsed disclosure would hide a link that is very much active. */
private get _hasForeignPick(): boolean {
return Object.values(this._consumesParts).some((l) => !!l.entry_id);
}
/** One "consumes parts" checkbox + quantity.
*
* `ownerEntryId` is undefined for the object's own parts and set for a pool
* owned by another object (#111) — that argument is the ONLY difference
* between the two lists, which is why they share this renderer. */
private _renderConsumesRow(
part: { id: string; name: string; unit?: string },
ownerEntryId?: string,
) {
const key = partLinkKey({ part_id: part.id, entry_id: ownerEntryId });
const link = this._consumesParts[key];
const base: TaskPartLink = ownerEntryId
? { part_id: part.id, quantity: 1, entry_id: ownerEntryId }
: { part_id: part.id, quantity: 1 };
return html`
<div class="consumes-row">
<label class="consumes-check">
<input
type="checkbox"
.checked=${link !== undefined}
@change=${(e: Event) => {
const next = { ...this._consumesParts };
if ((e.target as HTMLInputElement).checked) next[key] = next[key] || base;
else delete next[key];
this._consumesParts = next;
}}
/>
<span>${part.name}${part.unit ? ` (${part.unit})` : ""}</span>
</label>
${link !== undefined
? html`<input
class="consumes-qty"
type="number"
min="0.01"
max="999"
step="0.01"
.value=${String(link.quantity)}
@input=${(e: Event) => {
const v = parseFloat((e.target as HTMLInputElement).value);
this._consumesParts = {
...this._consumesParts,
[key]: { ...base, quantity: Number.isFinite(v) && v >= 0.01 ? v : 1 },
};
}}
/>`
: nothing}
</div>
`;
}
private _toggleRequired(field: string, on: boolean): void {
const next = new Set(this._requiredCompletion);
if (on) next.add(field);
else next.delete(field);
this._requiredCompletion = [...next];
}
private async _save(): Promise<void> {
if (this._loading) return; // synchronous re-entry guard (double-click)
if (!this._name.trim()) return;
@@ -787,7 +905,16 @@ export class MaintenanceTaskDialog extends LitElement {
name: this._name,
task_type: this._type,
schedule_type: this._scheduleType,
warning_days: parseInt(this._warningDays, 10) || 7,
// `0` is a legal, meaningful value — "no due-soon window, go straight
// from ok to overdue" (backend range is 0365). The old
// `parseInt(...) || 7` treated it as falsy and silently rewrote a
// stored 0 to 7 on EVERY save, even when the user never touched the
// field. Same class as bug #42, but worse: it needed no user action.
// Only a genuinely unparseable field falls back, and to the
// configured default rather than a hardcoded 7.
warning_days: Number.isNaN(parseInt(this._warningDays, 10))
? this.defaultWarningDays
: Math.max(0, parseInt(this._warningDays, 10)),
};
const ecd = this._earliestCompletionDays.trim();
data.earliest_completion_days = ecd === "" ? null : Math.max(0, parseInt(ecd, 10) || 0);
@@ -836,14 +963,20 @@ export class MaintenanceTaskDialog extends LitElement {
data.last_performed = this._lastPerformed || null;
data.nfc_tag_id = this._nfcTagId || null;
data.reading_unit = this._readingUnit.trim() || null;
if (this.parts.length) {
data.consumes_parts = Object.entries(this._consumesParts).map(([part_id, quantity]) => ({
part_id,
quantity,
}));
// Only send when a picker was actually rendered. A failed parts load
// leaves both lists empty, and sending [] then would silently wipe links
// the user never saw. entry_id is written ONLY for a foreign pick, so an
// own-parts task saves byte-identically to before (#111).
if (this.parts.length || this._foreignOwners.length) {
data.consumes_parts = Object.values(this._consumesParts).map((l) =>
l.entry_id
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
: { part_id: l.part_id, quantity: l.quantity },
);
}
data.responsible_user_id = this._responsibleUserId;
data.assignee_pool = this._assigneePool;
data.required_completion_fields = this._requiredCompletion;
data.rotation_strategy =
this._assigneePool.length >= 2 && this._rotationStrategy
? this._rotationStrategy
@@ -1700,6 +1833,9 @@ export class MaintenanceTaskDialog extends LitElement {
this._entryId = (e.target as HTMLSelectElement).value;
this._consumesParts = {};
this._loadParts();
// The new owner drops out of the shared-pool list and the old
// one joins it, so this has to be recomputed too (#111).
this._loadForeignPools();
}}
>
${this._objectChoices.map(
@@ -1738,44 +1874,27 @@ export class MaintenanceTaskDialog extends LitElement {
${this._partsLoadFailed
? html`<div class="field-help parts-load-failed">${t("parts_load_failed", L)}</div>`
: nothing}
${this.parts.length
${this.parts.length || this._foreignOwners.length
? html`
<div class="field">
<label>${t("consumes_parts_label", L)}</label>
${this.parts.map((part) => {
const qty = this._consumesParts[part.id];
return html`
<div class="consumes-row">
<label class="consumes-check">
<input
type="checkbox"
.checked=${qty !== undefined}
@change=${(e: Event) => {
const next = { ...this._consumesParts };
if ((e.target as HTMLInputElement).checked) next[part.id] = next[part.id] || 1;
else delete next[part.id];
this._consumesParts = next;
}}
/>
<span>${part.name}${part.unit ? ` (${part.unit})` : ""}</span>
</label>
${qty !== undefined
? html`<input
class="consumes-qty"
type="number"
min="0.01"
max="999"
step="0.01"
.value=${String(qty)}
@input=${(e: Event) => {
const v = parseFloat((e.target as HTMLInputElement).value);
this._consumesParts = { ...this._consumesParts, [part.id]: Number.isFinite(v) && v >= 0.01 ? v : 1 };
}}
/>`
: nothing}
</div>
`;
})}
${this.parts.map((part) => this._renderConsumesRow(part))}
${this._foreignOwners.length
? html`
<details class="shared-pools" ?open=${this._hasForeignPick}>
<summary>${t("shared_parts_other_objects", L)}</summary>
<div class="field-help">${t("shared_parts_help", L)}</div>
${this._foreignOwners.map(
(owner) => html`
<div class="shared-pool-owner">${owner.name}</div>
${owner.parts.map((part) =>
this._renderConsumesRow(part, owner.entry_id),
)}
`,
)}
</details>
`
: nothing}
</div>
`
: nothing}
@@ -1857,6 +1976,8 @@ export class MaintenanceTaskDialog extends LitElement {
<ms-textfield
label="${t("warning_days", L)}"
type="number"
min="0"
max="365"
.value=${this._warningDays}
@input=${(e: Event) => (this._warningDays = (e.target as HTMLInputElement).value)}
></ms-textfield>
@@ -1879,6 +2000,19 @@ export class MaintenanceTaskDialog extends LitElement {
></textarea>
<div class="field-help">${t("checklist_help", L)}</div>
` : nothing}
<h3>${t("require_on_completion", L)}</h3>
<div class="required-completion">
${REQUIRED_COMPLETION_KEYS.map((field) => html`
<label class="req-option">
<input
type="checkbox"
.checked=${this._requiredCompletion.includes(field)}
@change=${(e: Event) => this._toggleRequired(field, (e.target as HTMLInputElement).checked)}
/>
<span>${t(REQUIRED_COMPLETION_LABELS[field], L)}</span>
</label>
`)}
</div>
<ms-textfield
label="${t("last_performed_optional", L)}"
type="date"
@@ -2127,6 +2261,24 @@ export class MaintenanceTaskDialog extends LitElement {
background: var(--card-background-color);
color: var(--primary-text-color);
}
/* #111: other objects' pools sit behind a disclosure so the object's OWN
parts stay the primary list; each group is headed by the owning object's
name, so which pool a checkbox means is never a guess. */
.shared-pools {
margin-top: 6px;
}
.shared-pools > summary {
cursor: pointer;
padding: 2px 0;
font-size: 13px;
color: var(--secondary-text-color);
}
.shared-pool-owner {
margin-top: 6px;
font-size: 12px;
font-weight: 500;
color: var(--secondary-text-color);
}
.field-help {
font-size: 12px;
color: var(--secondary-text-color);
@@ -14,7 +14,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatRecurrence } from "../styles";
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence } from "../styles";
import { describeWsError } from "../ws-errors";
import { renderWeibullSection } from "../renderers/weibull";
import { renderPredictionSection } from "../renderers/prediction";
@@ -294,7 +294,9 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
task_id: this._taskId,
});
this._toast = r.recommended_interval
? `${t("reanalyze_result", this._lang) || "Recomputed"}: ${r.recommended_interval}d (${r.data_points} pts)`
// The analyzer always works in DAYS (helpers/interval_analyzer.py), so
// the unit is pinned here rather than taken from the task's own unit.
? `${t("reanalyze_result", this._lang) || "Recomputed"}: ${formatInterval(r.recommended_interval, "days", this._lang)} (${r.data_points} pts)`
: (t("reanalyze_insufficient_data", this._lang) || "Not enough data");
await this._loadTask();
setTimeout(() => { this._toast = ""; }, 3500);