257 files
This commit is contained in:
+58
-24
@@ -9,6 +9,8 @@ import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale, langOf, formatDate, syncLocaleFromHass } from "../styles";
|
||||
import { LS_KEYS, lsGet, lsSet } from "../helpers/storage-keys";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { canWrite, NO_DELEGATION, type WriteAccess } from "../helpers/permissions";
|
||||
import { fetchSettingsOnce } from "../helpers/settings-cache";
|
||||
import { isoDateLocal } from "../helpers/calendar-bucket";
|
||||
import { px } from "../renderers/chart-utils";
|
||||
import type { HomeAssistant } from "../types";
|
||||
@@ -107,6 +109,12 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
@state() private _rosterSort: "name" | "urgency" = MaintenanceBatteryFleetSection._storedSort();
|
||||
@state() private _typeFilter: string | null = null;
|
||||
@state() private _recorded: string[] = [];
|
||||
/** Every fleet action (mark replaced, record, exclude/include, add, the two
|
||||
* options, repair) is WRITE tier on the server; the section offered them
|
||||
* to every household member, who then got "not permitted" and a checkbox
|
||||
* left flipped (audit 2026-09-29). Same rule as the panel and the other
|
||||
* cards: helpers/permissions.canWrite. */
|
||||
@state() private _access: WriteAccess = NO_DELEGATION;
|
||||
private _historyRequested = false;
|
||||
private _localeReady = false;
|
||||
|
||||
@@ -114,6 +122,10 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
private get _canWrite(): boolean {
|
||||
return canWrite(this.hass?.user, this._access);
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this.hass) this._load();
|
||||
@@ -128,6 +140,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
if (changed.has("hass") && this.hass && !this._localeReady) {
|
||||
this._localeReady = true;
|
||||
ensureLocale(this._lang).then(() => this.requestUpdate());
|
||||
void fetchSettingsOnce(this.hass).then((s) => { this._access = s.access; });
|
||||
if (this._ov === null && !this._loading) this._load();
|
||||
}
|
||||
}
|
||||
@@ -432,7 +445,8 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
? html`<span class="bf-offline bf-nosensor">${t("battery_fleet_no_sensor", L)}</span>`
|
||||
: nothing;
|
||||
const type = html`<span class="bf-type">${b.quantity}× ${b.battery_type}</span>`;
|
||||
const showMark = o.mark === "always" || b.no_sensor || b.can_mark_replaced;
|
||||
const writer = this._canWrite;
|
||||
const showMark = writer && (o.mark === "always" || b.no_sensor || b.can_mark_replaced);
|
||||
return html`
|
||||
<div class="bf-row">
|
||||
<span class="bf-dev">${b.device_name}</span>
|
||||
@@ -461,7 +475,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
<ha-icon icon="mdi:battery-sync"></ha-icon>
|
||||
</button>`
|
||||
: nothing}
|
||||
${o.jump ? this._jumpButton(b, L) : nothing}
|
||||
${o.jump && writer ? this._jumpButton(b, L) : nothing}
|
||||
${o.predicted && b.days_until != null
|
||||
? html`<span
|
||||
class="bf-predicted ${b.predicted_source === "trend" ? "bf-trend" : ""} ${b.forecast_overdue ? "bf-overdue" : ""}"
|
||||
@@ -469,7 +483,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
>${b.forecast_overdue ? html`<ha-icon icon="mdi:calendar-alert"></ha-icon>` : nothing}~${this._predictedDate(b.days_until)}</span
|
||||
>`
|
||||
: nothing}
|
||||
${o.exclude
|
||||
${o.exclude && writer
|
||||
? html`<button
|
||||
class="bf-mark bf-exclude"
|
||||
title=${t("battery_fleet_exclude", L)}
|
||||
@@ -504,9 +518,11 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
? html`
|
||||
<div class="bf-repair">
|
||||
<span>${t("battery_fleet_trigger_lost", L)}</span>
|
||||
<ha-button .disabled=${this._marking} @click=${this._repair}>
|
||||
${t("battery_fleet_repair", L)}
|
||||
</ha-button>
|
||||
${this._canWrite
|
||||
? html`<ha-button .disabled=${this._marking} @click=${this._repair}>
|
||||
${t("battery_fleet_repair", L)}
|
||||
</ha-button>`
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
@@ -521,11 +537,13 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
<div class="bf-rows">
|
||||
${ov.low.map((b) => this._renderRow(b, L, { recharge: true, mark: "always", exclude: true }))}
|
||||
</div>
|
||||
<div class="bf-actions">
|
||||
<ha-button .disabled=${this._marking} @click=${this._markAll}>
|
||||
<ha-icon icon="mdi:battery-sync"></ha-icon> ${t("battery_fleet_mark_all", L)}
|
||||
</ha-button>
|
||||
</div>
|
||||
${this._canWrite
|
||||
? html`<div class="bf-actions">
|
||||
<ha-button .disabled=${this._marking} @click=${this._markAll}>
|
||||
<ha-icon icon="mdi:battery-sync"></ha-icon> ${t("battery_fleet_mark_all", L)}
|
||||
</ha-button>
|
||||
</div>`
|
||||
: nothing}
|
||||
`}
|
||||
|
||||
${ov.soon.length
|
||||
@@ -564,6 +582,19 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
)}
|
||||
</div>
|
||||
<div class="bf-roster-hint">${t("battery_fleet_all_hint", L)}</div>
|
||||
${this._canWrite ? this._renderRosterSettings(ov, L) : nothing}
|
||||
</details>
|
||||
`
|
||||
: nothing}
|
||||
${this._renderExcluded(ov, L)}
|
||||
<div class="bf-total">${t("battery_fleet_total", L).replace("{n}", String(ov.total))}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Add a battery + the two fleet options — write tier, so writers only. */
|
||||
private _renderRosterSettings(ov: Overview, L: string) {
|
||||
return html`
|
||||
<div class="bf-add">
|
||||
<span class="bf-label">${t("battery_fleet_add", L)}</span>
|
||||
<ha-selector
|
||||
@@ -594,9 +625,12 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
${t("battery_fleet_due_without_sensor", L)}
|
||||
</label>
|
||||
<div class="bf-roster-hint">${t("battery_fleet_due_without_sensor_hint", L)}</div>
|
||||
</details>
|
||||
`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
/** Excluded devices stay listed for everyone; bringing one back is write tier. */
|
||||
private _renderExcluded(ov: Overview, L: string) {
|
||||
return html`
|
||||
${ov.excluded?.length
|
||||
? html`
|
||||
<div class="bf-excluded">
|
||||
@@ -605,22 +639,22 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
(x) => html`
|
||||
<span class="bf-excluded-chip">
|
||||
${x.device_name}
|
||||
<button
|
||||
class="bf-mark"
|
||||
title=${t("battery_fleet_include", L)}
|
||||
.disabled=${this._marking}
|
||||
@click=${() => this._setExcluded(x.entity_id, false)}
|
||||
>
|
||||
<ha-icon icon="mdi:eye-outline"></ha-icon>
|
||||
</button>
|
||||
${this._canWrite
|
||||
? html`<button
|
||||
class="bf-mark"
|
||||
title=${t("battery_fleet_include", L)}
|
||||
.disabled=${this._marking}
|
||||
@click=${() => this._setExcluded(x.entity_id, false)}
|
||||
>
|
||||
<ha-icon icon="mdi:eye-outline"></ha-icon>
|
||||
</button>`
|
||||
: nothing}
|
||||
</span>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
<div class="bf-total">${t("battery_fleet_total", L).replace("{n}", String(ov.total))}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
+53
-27
@@ -9,6 +9,7 @@ import { runWs } from "../helpers/ws-run";
|
||||
import { isoMinuteLocal } from "../helpers/calendar-bucket";
|
||||
import { PART_QTY_RANGE, RESTOCK_QTY_RANGE } from "../helpers/setting-ranges";
|
||||
import { partLinkKey, type LinkedPart } from "../helpers/shared-parts";
|
||||
import { unitPrice, type PartsCostMode } from "../helpers/parts-cost";
|
||||
import { REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
|
||||
import { PhotoUploadController } from "../helpers/photo-upload-controller";
|
||||
import { parseDurationMinutes } from "../helpers/duration";
|
||||
@@ -45,8 +46,12 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
/** #104 follow-up: the buy task's part unit cost — powers the cost
|
||||
* suggestion (restock qty × unit cost). */
|
||||
@property({ attribute: false }) public restockUnitCost: number | null = null;
|
||||
/** #98: one package of the part ("400 ml") — the quantity counts packages. */
|
||||
@property({ attribute: false }) public restockPackage = "";
|
||||
/** Currency symbol for the cost suggestion ("" = plain number). */
|
||||
@property() public currencySymbol = "";
|
||||
/** #104: when spare parts count ("purchase" | "use"). */
|
||||
@property({ attribute: false }) public partsCostMode: PartsCostMode = "purchase";
|
||||
/** #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. */
|
||||
@@ -309,45 +314,60 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
return this.requiredFields.includes(field) ? html`<span class="req-mark" aria-hidden="true">*</span>` : nothing;
|
||||
}
|
||||
|
||||
/** #104 follow-up: suggested cost derived from the parts this completion
|
||||
* touches — the SELECTED "parts used" (qty × each part's unit cost) on a
|
||||
* consuming task, or restock qty × unit cost on a buy task. Null when no
|
||||
* involved part carries a price. Follows the live selection, so ticking
|
||||
* a part off updates the suggestion. */
|
||||
private _partsCostSuggestion(): number | null {
|
||||
if (this.restockDefault !== null) {
|
||||
const qty = parseFloat(this._restockQty);
|
||||
if (this.restockUnitCost == null || !Number.isFinite(qty) || qty <= 0) return null;
|
||||
return Math.round(this.restockUnitCost * qty * 100) / 100;
|
||||
}
|
||||
if (!this.parts.length) return null;
|
||||
/** A buy task's price: restock qty (packages) × the part's price per
|
||||
* package. Null without a price. */
|
||||
private _purchaseSuggestion(): number | null {
|
||||
if (this.restockDefault === null) return null;
|
||||
const qty = parseFloat(this._restockQty);
|
||||
if (this.restockUnitCost == null || !Number.isFinite(qty) || qty <= 0) return null;
|
||||
return Math.round(this.restockUnitCost * qty * 100) / 100;
|
||||
}
|
||||
|
||||
/** #104: the value of the SELECTED parts used (qty × each part's price per
|
||||
* unit — a package price spread over its contents, #98). Follows the live
|
||||
* selection. Null when no selected part carries a price. */
|
||||
private _partsValue(): number | null {
|
||||
if (this.restockDefault !== null || !this.parts.length) return null;
|
||||
let sum = 0;
|
||||
let priced = false;
|
||||
for (const link of Object.values(this._usedParts)) {
|
||||
const def = this.parts.find(
|
||||
(pt) => partLinkKey({ part_id: pt.id, entry_id: pt.entry_id }) === partLinkKey(link),
|
||||
);
|
||||
if (def?.cost != null) {
|
||||
sum += def.cost * (link.quantity || 1);
|
||||
const price = unitPrice(def);
|
||||
if (price !== null) {
|
||||
sum += price * (link.quantity || 1);
|
||||
priced = true;
|
||||
}
|
||||
}
|
||||
return priced ? Math.round(sum * 100) / 100 : null;
|
||||
}
|
||||
|
||||
/** The one-click "use ≈ X from parts" chip under the cost field. Hidden
|
||||
* once the user typed a cost themselves — a suggestion, never an
|
||||
* overwrite. */
|
||||
/** Under the cost field. A buy task gets the one-click price suggestion
|
||||
* (and, when parts count when used, the note that the price becomes the
|
||||
* part's price). A completion that USES parts gets their value as a line
|
||||
* — booked automatically when parts count when used, or named as counted
|
||||
* at purchase. Suggesting it into the cost field (2.53) double-counted a
|
||||
* household that books its purchases (Discussion #104). */
|
||||
private _renderCostSuggestion(L: string) {
|
||||
if (this._cost.trim() !== "") return nothing;
|
||||
const suggestion = this._partsCostSuggestion();
|
||||
if (suggestion == null || suggestion <= 0) return nothing;
|
||||
const amount = formatCost(suggestion, this.currencySymbol, L);
|
||||
return html`<button
|
||||
type="button"
|
||||
class="cost-suggestion"
|
||||
@click=${() => (this._cost = String(Math.round(suggestion * 100) / 100))}
|
||||
>${t("cost_from_parts", L).replace("{amount}", amount)}</button>`;
|
||||
const purchase = this._purchaseSuggestion();
|
||||
if (this.restockDefault !== null) {
|
||||
const hint = this.partsCostMode === "use"
|
||||
? html`<div class="cost-note">${t("cost_purchase_use_hint", L)}</div>`
|
||||
: nothing;
|
||||
if (this._cost.trim() !== "" || purchase == null || purchase <= 0) return hint;
|
||||
const amount = formatCost(purchase, this.currencySymbol, L);
|
||||
return html`<button
|
||||
type="button"
|
||||
class="cost-suggestion"
|
||||
@click=${() => (this._cost = String(Math.round(purchase * 100) / 100))}
|
||||
>${t("cost_from_parts", L).replace("{amount}", amount)}</button>${hint}`;
|
||||
}
|
||||
const value = this._partsValue();
|
||||
if (value == null || value <= 0) return nothing;
|
||||
const amount = formatCost(value, this.currencySymbol, L);
|
||||
const key = this.partsCostMode === "use" ? "cost_parts_booked" : "cost_parts_info";
|
||||
return html`<div class="cost-note">${t(key, L).replace("{amount}", amount)}</div>`;
|
||||
}
|
||||
|
||||
private _close(): void {
|
||||
@@ -452,7 +472,7 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
${this.restockDefault !== null
|
||||
? html`
|
||||
<label class="field">
|
||||
<span class="field-label">${t("restock_quantity_label", L)}</span>
|
||||
<span class="field-label">${t("restock_quantity_label", L)}${this.restockPackage ? ` (× ${this.restockPackage})` : ""}</span>
|
||||
<input type="number" step="0.01" min=${RESTOCK_QTY_RANGE[0]} max=${RESTOCK_QTY_RANGE[1]} class="field-input"
|
||||
.value=${this._restockQty}
|
||||
@input=${(e: Event) => (this._restockQty = (e.target as HTMLInputElement).value)} />
|
||||
@@ -563,6 +583,12 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
font-weight: 600;
|
||||
}
|
||||
/* #104: one-click cost suggestion from parts — quiet link-style chip. */
|
||||
.cost-note {
|
||||
font-size: 12px;
|
||||
color: var(--secondary-text-color);
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.cost-suggestion {
|
||||
align-self: flex-start;
|
||||
margin-top: 4px;
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
/** Dialog for creating/editing a maintenance object. */
|
||||
/** Dialog for creating, editing and replacing a maintenance object. */
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { HomeAssistant, MaintenanceObject, MaintenanceObjectResponse } from "../types";
|
||||
|
||||
/** What a device change did to the tasks' wiring (object/update + replace). */
|
||||
export interface DeviceSwap {
|
||||
moved: number;
|
||||
unmatched: string[];
|
||||
}
|
||||
|
||||
type ReplaceDevice = "keep" | "other" | "none";
|
||||
import { t, langOf } from "../styles";
|
||||
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
@@ -32,6 +40,11 @@ export class MaintenanceObjectDialog extends LitElement {
|
||||
@state() private _haDeviceId = "";
|
||||
@state() private _parentEntryId = "";
|
||||
@state() private _entryId: string | null = null; // null = create, string = update
|
||||
// Replace (journey N1): the successor's device. The old unit's device is
|
||||
// what the object links to now; a new unit usually is a new device.
|
||||
@state() private _replacing = false;
|
||||
@state() private _oldDeviceId = "";
|
||||
@state() private _replaceDevice: ReplaceDevice = "keep";
|
||||
|
||||
private get _lang(): string {
|
||||
return langOf(this.hass);
|
||||
@@ -50,6 +63,22 @@ export class MaintenanceObjectDialog extends LitElement {
|
||||
this._notes = "";
|
||||
this._haDeviceId = "";
|
||||
this._parentEntryId = "";
|
||||
this._replacing = false;
|
||||
this._error = "";
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
/** Replace a worn-out object: the old one is archived in place, a
|
||||
* successor carries its tasks and documents over. Asks for the name and
|
||||
* for the new unit's device — keeping the old link silently made the
|
||||
* successor watch the retired machine's sensors. */
|
||||
public openReplace(entryId: string, obj: MaintenanceObject): void {
|
||||
this._entryId = entryId;
|
||||
this._replacing = true;
|
||||
this._name = obj.name || "";
|
||||
this._oldDeviceId = obj.ha_device_id || "";
|
||||
this._haDeviceId = "";
|
||||
this._replaceDevice = "keep";
|
||||
this._error = "";
|
||||
this._open = true;
|
||||
}
|
||||
@@ -67,10 +96,104 @@ export class MaintenanceObjectDialog extends LitElement {
|
||||
this._notes = obj.notes || "";
|
||||
this._haDeviceId = obj.ha_device_id || "";
|
||||
this._parentEntryId = obj.parent_entry_id || "";
|
||||
this._replacing = false;
|
||||
this._error = "";
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
private async _replace(): Promise<void> {
|
||||
if (this._loading || !this._entryId) return;
|
||||
const msg: Record<string, unknown> = {
|
||||
type: "maintenance_supporter/object/replace",
|
||||
entry_id: this._entryId,
|
||||
name: this._name.trim() || null,
|
||||
};
|
||||
if (this._oldDeviceId) {
|
||||
// "keep" sends nothing: the successor stays on the same device.
|
||||
if (this._replaceDevice === "other") msg.ha_device_id = this._haDeviceId;
|
||||
if (this._replaceDevice === "none") msg.ha_device_id = null;
|
||||
} else if (this._haDeviceId) {
|
||||
msg.ha_device_id = this._haDeviceId;
|
||||
}
|
||||
const res = await runWs<{ entry_id?: string; device_swap?: DeviceSwap }>(this, msg, {
|
||||
busy: (b) => { this._loading = b; },
|
||||
fallbackKey: "save_error",
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
if (res === undefined) return;
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("object-replaced", {
|
||||
detail: { entry_id: res?.entry_id, device_swap: res?.device_swap },
|
||||
}));
|
||||
}
|
||||
|
||||
private _deviceName(deviceId: string): string {
|
||||
const devices = (this.hass as unknown as { devices?: Record<string, { name?: string | null; name_by_user?: string | null }> })?.devices;
|
||||
const device = devices?.[deviceId];
|
||||
return device?.name_by_user || device?.name || deviceId;
|
||||
}
|
||||
|
||||
private _deviceSelector(label: string) {
|
||||
return html`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${{ device: this._haDeviceId || undefined }}
|
||||
.schema=${[{ name: "device", selector: { device: {} } }]}
|
||||
.computeLabel=${() => label}
|
||||
@value-changed=${(e: CustomEvent) =>
|
||||
(this._haDeviceId =
|
||||
((e.detail.value as { device?: string })?.device as string) || "")}
|
||||
></ha-form>`;
|
||||
}
|
||||
|
||||
private _renderReplace() {
|
||||
const L = this._lang;
|
||||
const choice = (value: ReplaceDevice, label: string) => html`<label class="radio-row">
|
||||
<input
|
||||
type="radio"
|
||||
name="replace-device"
|
||||
.checked=${this._replaceDevice === value}
|
||||
@change=${() => { this._replaceDevice = value; }}
|
||||
/>
|
||||
<span>${label}</span>
|
||||
</label>`;
|
||||
const blocked = this._replaceDevice === "other" && !this._haDeviceId;
|
||||
return html`
|
||||
<ha-dialog open @closed=${this._close}>
|
||||
<div class="dialog-title">${t("replace_object", L)}</div>
|
||||
<div class="content">
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
|
||||
<div class="hint">${t("replace_object_prompt", L)}</div>
|
||||
<ms-textfield
|
||||
label="${t("replace_name_label", L)}"
|
||||
.value=${this._name}
|
||||
@input=${(e: Event) => (this._name = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
${this._oldDeviceId
|
||||
? html`<div class="radio-group" role="radiogroup" aria-label=${t("replace_device_heading", L)}>
|
||||
<div class="textarea-label">${t("replace_device_heading", L)}</div>
|
||||
${choice("keep", t("replace_device_keep", L).replace("{device}", this._deviceName(this._oldDeviceId)))}
|
||||
${this._replaceDevice === "keep" ? html`<div class="hint">${t("replace_device_keep_hint", L)}</div>` : nothing}
|
||||
${choice("other", t("replace_device_other", L))}
|
||||
${this._replaceDevice === "other"
|
||||
? html`${this._deviceSelector(t("replace_device_pick", L))}
|
||||
<div class="hint">${t("replace_device_other_hint", L)}</div>`
|
||||
: nothing}
|
||||
${choice("none", t("replace_device_none", L))}
|
||||
</div>`
|
||||
: this._deviceSelector(t("link_device_optional", L))}
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<ha-button appearance="plain" @click=${this._close}>
|
||||
${t("cancel", L)}
|
||||
</ha-button>
|
||||
<ha-button @click=${this._replace} .disabled=${this._loading || blocked}>
|
||||
${this._loading ? t("saving", L) : t("replace_object", L)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (this._loading) return; // synchronous re-entry guard (double-click)
|
||||
if (!this._name.trim()) return;
|
||||
@@ -89,7 +212,7 @@ export class MaintenanceObjectDialog extends LitElement {
|
||||
ha_device_id: this._haDeviceId || null,
|
||||
parent_entry_id: this._parentEntryId || null,
|
||||
};
|
||||
const res = await runWs(
|
||||
const res = await runWs<{ device_swap?: DeviceSwap }>(
|
||||
this,
|
||||
this._entryId
|
||||
? { type: "maintenance_supporter/object/update", entry_id: this._entryId, ...fields }
|
||||
@@ -98,7 +221,9 @@ export class MaintenanceObjectDialog extends LitElement {
|
||||
);
|
||||
if (res === undefined) return;
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("object-saved"));
|
||||
// A changed device moved the tasks' sensor links along — the panel says
|
||||
// how many, and which could not be placed.
|
||||
this.dispatchEvent(new CustomEvent("object-saved", { detail: { device_swap: res?.device_swap } }));
|
||||
}
|
||||
|
||||
private _parentChoices(): MaintenanceObjectResponse[] {
|
||||
@@ -111,6 +236,7 @@ export class MaintenanceObjectDialog extends LitElement {
|
||||
|
||||
render() {
|
||||
if (!this._open) return html``;
|
||||
if (this._replacing) return this._renderReplace();
|
||||
const L = this._lang;
|
||||
const title = this._entryId ? t("edit_object", L) : t("new_object", L);
|
||||
return html`
|
||||
@@ -275,6 +401,26 @@ export class MaintenanceObjectDialog extends LitElement {
|
||||
color: var(--error-color, #f44336);
|
||||
font-size: 13px;
|
||||
}
|
||||
.hint {
|
||||
font-size: 13px;
|
||||
color: var(--secondary-text-color);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.radio-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.radio-row input {
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale, langOf, formatCost, formatNumber, formatQty } from "../styles";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import type { HomeAssistant, MaintenancePart } from "../types";
|
||||
import { restockUnits, unitPrice } from "../helpers/parts-cost";
|
||||
// Per-part document links (v2.26) — the task-documents component in part mode.
|
||||
import "./task-documents";
|
||||
|
||||
@@ -34,6 +35,7 @@ interface PartForm {
|
||||
stock: string;
|
||||
reorder_threshold: string;
|
||||
restock_quantity: string;
|
||||
package_size: string;
|
||||
auto_buy_task: boolean;
|
||||
notes: string;
|
||||
}
|
||||
@@ -50,6 +52,7 @@ const EMPTY_FORM: PartForm = {
|
||||
stock: "",
|
||||
reorder_threshold: "",
|
||||
restock_quantity: "",
|
||||
package_size: "",
|
||||
auto_buy_task: true,
|
||||
notes: "",
|
||||
};
|
||||
@@ -111,6 +114,7 @@ export class MaintenancePartsSection extends LitElement {
|
||||
stock: part.stock != null ? String(part.stock) : "",
|
||||
reorder_threshold: part.reorder_threshold != null ? String(part.reorder_threshold) : "",
|
||||
restock_quantity: part.restock_quantity != null ? String(part.restock_quantity) : "",
|
||||
package_size: part.package_size != null ? String(part.package_size) : "",
|
||||
auto_buy_task: !!part.auto_buy_task,
|
||||
notes: part.notes || "",
|
||||
};
|
||||
@@ -131,6 +135,7 @@ export class MaintenancePartsSection extends LitElement {
|
||||
stock: num(f.stock),
|
||||
reorder_threshold: num(f.reorder_threshold),
|
||||
restock_quantity: num(f.restock_quantity),
|
||||
package_size: num(f.package_size),
|
||||
auto_buy_task: f.auto_buy_task,
|
||||
notes: f.notes.trim() || null,
|
||||
};
|
||||
@@ -221,6 +226,7 @@ export class MaintenancePartsSection extends LitElement {
|
||||
? html`<span class="loc"><ha-icon icon="mdi:map-marker-outline"></ha-icon>${part.storage_location}</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
${part.notes ? html`<div class="part-notes">${part.notes}</div>` : nothing}
|
||||
</div>
|
||||
<ha-icon-button
|
||||
title=${t("documents", L)}
|
||||
@@ -254,7 +260,9 @@ export class MaintenancePartsSection extends LitElement {
|
||||
@click=${() => {
|
||||
this._restockFor = part.id;
|
||||
this._restockInvalid = false;
|
||||
this._restockQty = String(part.restock_quantity || 1);
|
||||
// The stock counts units: one restock is its
|
||||
// packages × package size (#98 follow-up).
|
||||
this._restockQty = String(restockUnits(part));
|
||||
}}
|
||||
><ha-icon icon="mdi:plus-minus-variant"></ha-icon
|
||||
></ha-icon-button>
|
||||
@@ -315,7 +323,15 @@ export class MaintenancePartsSection extends LitElement {
|
||||
${this._field(t("part_cost", L), "cost", { type: "number" })}
|
||||
${this._field(t("part_stock", L), "stock", { type: "number" })}
|
||||
${this._field(t("part_reorder_threshold", L), "reorder_threshold", { type: "number" })}
|
||||
${this._field(t("part_restock_quantity", L), "restock_quantity", { type: "number" })}
|
||||
${this._field(t("part_package_size", L), "package_size", { type: "number" })}
|
||||
${this._field(
|
||||
f.package_size.trim() ? t("part_restock_packages", L) : t("part_restock_quantity", L),
|
||||
"restock_quantity",
|
||||
{ type: "number" },
|
||||
)}
|
||||
${f.package_size.trim()
|
||||
? html`<div class="form-hint">${t("part_package_hint", L)}</div>`
|
||||
: nothing}
|
||||
<label class="form-field checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -326,6 +342,20 @@ export class MaintenancePartsSection extends LitElement {
|
||||
/>
|
||||
<span>${t("part_auto_buy", L)}</span>
|
||||
</label>
|
||||
<!-- Documented and searched since the parts feature, but the form
|
||||
never had a field — only the API and imports could set it
|
||||
(audit 2026-09-28). -->
|
||||
<label class="form-field notes">
|
||||
<span>${t("notes_label", L)}</span>
|
||||
<textarea
|
||||
rows="2"
|
||||
maxlength="500"
|
||||
.value=${f.notes}
|
||||
@input=${(e: Event) => {
|
||||
this._editing = { ...this._editing!, notes: (e.target as HTMLTextAreaElement).value };
|
||||
}}
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<ha-button appearance="plain" @click=${() => (this._editing = null)}>${t("cancel", L)}</ha-button>
|
||||
@@ -343,7 +373,8 @@ export class MaintenancePartsSection extends LitElement {
|
||||
private _inventoryValue(): number | null {
|
||||
let sum = 0, any = false;
|
||||
for (const p of this.parts) {
|
||||
const cost = typeof p.cost === "number" ? p.cost : null;
|
||||
// Per unit: a package price spread over its contents (#98 follow-up).
|
||||
const cost = unitPrice(p);
|
||||
const stock = typeof p.stock === "number" ? p.stock : null;
|
||||
if (cost !== null && stock !== null) { sum += cost * stock; any = true; }
|
||||
}
|
||||
@@ -475,6 +506,12 @@ export class MaintenancePartsSection extends LitElement {
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 8px 12px;
|
||||
}
|
||||
.form-hint {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 12px;
|
||||
color: var(--secondary-text-color);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -490,6 +527,25 @@ export class MaintenancePartsSection extends LitElement {
|
||||
background: var(--card-background-color);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.form-field.notes {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.form-field textarea {
|
||||
padding: 6px;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 4px;
|
||||
background: var(--card-background-color);
|
||||
color: var(--primary-text-color);
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
.part-notes {
|
||||
font-size: 12px;
|
||||
color: var(--secondary-text-color);
|
||||
white-space: pre-line;
|
||||
overflow-wrap: anywhere;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.form-field.checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { t } from "../styles";
|
||||
import { langOf, t } from "../styles";
|
||||
import { downloadTextFile } from "../helpers/download";
|
||||
|
||||
interface QrResult {
|
||||
@@ -43,7 +43,14 @@ function sanitizeFilename(s: string): string {
|
||||
|
||||
export class MaintenanceQrDialog extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@property() public lang = "en";
|
||||
/** Set by the panel; the dashboard path (dialog-mount) leaves it empty, and
|
||||
* the old "en" default then beat the user's language there (audit
|
||||
* 2026-09-29) — _dialogLang falls back to hass.language instead. */
|
||||
@property() public lang = "";
|
||||
|
||||
private get _dialogLang(): string {
|
||||
return this.lang || langOf(this.hass);
|
||||
}
|
||||
|
||||
@state() private _open = false;
|
||||
@state() private _loading = false;
|
||||
@@ -139,8 +146,8 @@ export class MaintenanceQrDialog extends LitElement {
|
||||
const code = (err as Record<string, unknown>)?.code;
|
||||
const msg = (err as Record<string, unknown>)?.message;
|
||||
this._error = code === "no_url" || (typeof msg === "string" && msg.includes("No Home Assistant URL"))
|
||||
? t("qr_error_no_url", this.lang)
|
||||
: t("qr_error", this.lang);
|
||||
? t("qr_error_no_url", this._dialogLang)
|
||||
: t("qr_error", this._dialogLang);
|
||||
} finally {
|
||||
if (seq === this._generateSeq) this._loading = false;
|
||||
}
|
||||
@@ -175,7 +182,7 @@ export class MaintenanceQrDialog extends LitElement {
|
||||
.join(" ");
|
||||
const w = window.open("", "_blank", "width=600,height=500");
|
||||
if (!w) return;
|
||||
const L = this.lang || "en";
|
||||
const L = this._dialogLang;
|
||||
const safeTitle = escapeHtml(title);
|
||||
const safeSub = escapeHtml(subtitle);
|
||||
|
||||
@@ -249,7 +256,7 @@ ${safeSub ? `<div class="sub">${safeSub}</div>` : ""}
|
||||
|
||||
render() {
|
||||
if (!this._open) return html``;
|
||||
const L = this.lang || this.hass?.language || "en";
|
||||
const L = this._dialogLang;
|
||||
const heading = this._taskName
|
||||
? `${t("qr_code", L)}: ${this._objectName} — ${this._taskName}`
|
||||
: `${t("qr_code", L)}: ${this._objectName}`;
|
||||
|
||||
@@ -113,6 +113,8 @@ interface SettingsResponse {
|
||||
/** #173 follow-up: completion notifications — off | automatic | all. */
|
||||
completed?: string;
|
||||
extra_data?: string;
|
||||
/** #44: default | object_name | task_name — was only in the Configure dialog. */
|
||||
title_style?: string;
|
||||
};
|
||||
actions: {
|
||||
complete_enabled: boolean;
|
||||
@@ -132,6 +134,8 @@ interface SettingsResponse {
|
||||
currency_symbol: string;
|
||||
/** Decimal places for every displayed amount (0 = whole numbers). */
|
||||
currency_decimals?: number;
|
||||
/** #104: spare parts count when bought ("purchase") or used ("use"). */
|
||||
parts_cost_mode?: string;
|
||||
};
|
||||
// v2.10.0 archive automation (panel-managed). Optional for forward-compat
|
||||
// with a backend that predates the feature.
|
||||
@@ -183,6 +187,9 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
@state() private _settings: SettingsResponse | null = null;
|
||||
@state() private _loading = true;
|
||||
@state() private _importCsv = "";
|
||||
/** People an import's tasks pointed at that match nobody on this instance
|
||||
* (by name) — their assignments were cleared; shown until the next import. */
|
||||
@state() private _importUnmatched: string[] = [];
|
||||
@state() private _importLoading = false;
|
||||
@state() private _includeHistory = true;
|
||||
@state() private _toast = "";
|
||||
@@ -689,7 +696,6 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
// "groups" target also lands here as the closest match.
|
||||
const items: { key: keyof AdvancedFeatures; settingKey: string; label: string; desc: string }[] = [
|
||||
{ key: "adaptive", settingKey: "advanced_adaptive_visible", label: t("feat_adaptive", L), desc: t("feat_adaptive_desc", L) },
|
||||
{ key: "predictions", settingKey: "advanced_predictions_visible", label: t("feat_predictions", L), desc: t("feat_predictions_desc", L) },
|
||||
{ key: "seasonal", settingKey: "advanced_seasonal_visible", label: t("feat_seasonal", L), desc: t("feat_seasonal_desc", L) },
|
||||
{ key: "environmental", settingKey: "advanced_environmental_visible", label: t("feat_environmental", L), desc: t("feat_environmental_desc", L) },
|
||||
{ key: "budget", settingKey: "advanced_budget_visible", label: t("feat_budget", L), desc: t("feat_budget_desc", L) },
|
||||
@@ -1056,6 +1062,15 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
</select>
|
||||
</label>
|
||||
<div class="setting-hint">${t("settings_currency_decimals_hint", L)}</div>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_parts_cost_mode", L)}</span>
|
||||
<select class="parts-cost-mode" .value=${live(b.parts_cost_mode === "use" ? "use" : "purchase")}
|
||||
@change=${(e: Event) => this._updateSetting("parts_cost_mode", (e.target as HTMLSelectElement).value)}>
|
||||
<option value="purchase" ?selected=${b.parts_cost_mode !== "use"}>${t("settings_parts_cost_purchase", L)}</option>
|
||||
<option value="use" ?selected=${b.parts_cost_mode === "use"}>${t("settings_parts_cost_use", L)}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="setting-hint">${t("settings_parts_cost_mode_hint", L)}</div>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_panel_enabled", L)}</span>
|
||||
<input type="checkbox" .checked=${g.panel_enabled}
|
||||
@@ -1093,7 +1108,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
<div class="setting-row">
|
||||
<span class="setting-label">${t("test_notification", L)}</span>
|
||||
<button class="ha-button secondary"
|
||||
?disabled=${!g.notify_service || this._testingNotification}
|
||||
?disabled=${(!g.notify_service && !this._settings?.notifications?.event_only) || this._testingNotification}
|
||||
@click=${() => this._sendTestNotification()}>
|
||||
${this._testingNotification ? t("testing", L) : t("send_test", L)}
|
||||
</button>
|
||||
@@ -1273,6 +1288,16 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
</select>
|
||||
</label>
|
||||
<div class="setting-hint">${t("settings_notify_completed_hint", L)}</div>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_title_style", L)}</span>
|
||||
<select class="notify-title-style" .value=${live(n.title_style || "default")}
|
||||
@change=${(e: Event) => this._updateSetting("notification_title_style", (e.target as HTMLSelectElement).value)}>
|
||||
<option value="default">${t("title_style_default", L)}</option>
|
||||
<option value="object_name">${t("title_style_object_name", L)}</option>
|
||||
<option value="task_name">${t("title_style_task_name", L)}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="setting-hint">${t("settings_title_style_hint", L)}</div>
|
||||
|
||||
<div class="notify-rule">
|
||||
<h4 style="margin: 16px 0 8px; font-size: 14px;">${t("settings_notify_rule", L)}</h4>
|
||||
@@ -2015,6 +2040,11 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
${this._importLoading ? "…" : t("settings_import_btn", L)}
|
||||
</button>
|
||||
</div>
|
||||
${this._importUnmatched.length
|
||||
? html`<div class="import-notice">
|
||||
${t("settings_import_unmatched_users", L).replace("{names}", this._importUnmatched.join(", "))}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -2101,7 +2131,8 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
// (JSON `{`/`[` or YAML `version:`) goes to the structured importer,
|
||||
// which parses JSON and YAML alike.
|
||||
const isCsv = content.startsWith("object_name");
|
||||
const result = await this._ws<{ created: number }>(
|
||||
this._importUnmatched = [];
|
||||
const result = await this._ws<{ created: number; unmatched_users?: string[] }>(
|
||||
isCsv
|
||||
? { type: "maintenance_supporter/csv/import", csv_content: content }
|
||||
: { type: "maintenance_supporter/json/import", json_content: content },
|
||||
@@ -2111,6 +2142,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
if (!result) return;
|
||||
const count = result.created ?? 0;
|
||||
this._showToast(t("settings_import_success", this._lang).replace("{count}", String(count)));
|
||||
this._importUnmatched = Array.isArray(result.unmatched_users) ? result.unmatched_users : [];
|
||||
this._importCsv = "";
|
||||
this.dispatchEvent(new CustomEvent("settings-changed"));
|
||||
}
|
||||
@@ -2143,13 +2175,18 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name);
|
||||
const result = await postMultipart<{ blobs_written: number; documents_created: number }>(
|
||||
this.hass, "/api/maintenance_supporter/documents/archive", form, "docs_archive_too_large",
|
||||
const result = await postMultipart<{ blobs_written: number; documents_created: number; files_missing?: number }>(
|
||||
this.hass, "/api/maintenance_supporter/documents/archive", form, "docs_archive_too_large", ["docs_archive_no_manifest"],
|
||||
);
|
||||
const restored = t("settings_docs_import_success", this._lang)
|
||||
.replace("{blobs}", String(result.blobs_written ?? 0))
|
||||
.replace("{docs}", String(result.documents_created ?? 0));
|
||||
// Documents whose file the archive did not carry are not created —
|
||||
// said, so a file left out when zipping again is noticed.
|
||||
this._showToast(
|
||||
t("settings_docs_import_success", this._lang)
|
||||
.replace("{blobs}", String(result.blobs_written ?? 0))
|
||||
.replace("{docs}", String(result.documents_created ?? 0))
|
||||
result.files_missing
|
||||
? `${restored} · ${t("settings_docs_import_missing", this._lang).replace("{n}", String(result.files_missing))}`
|
||||
: restored,
|
||||
);
|
||||
this.dispatchEvent(new CustomEvent("settings-changed"));
|
||||
} catch (e) {
|
||||
@@ -2158,7 +2195,9 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
e instanceof Error && e.message === "docs_archive_too_large"
|
||||
? t("docs_archive_too_large", this._lang)
|
||||
.replace("{max}", `${formatNumber(DOCS_ARCHIVE_MAX_BYTES / (1024 * 1024), this._lang)} MB`)
|
||||
: t("action_error", this._lang),
|
||||
: e instanceof Error && e.message === "docs_archive_no_manifest"
|
||||
? t("docs_archive_no_manifest", this._lang)
|
||||
: t("action_error", this._lang),
|
||||
);
|
||||
}
|
||||
input.value = "";
|
||||
@@ -2168,6 +2207,12 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
// --- Styles ---
|
||||
|
||||
static styles = [personStyles, css`
|
||||
.import-notice {
|
||||
margin-top: 8px; padding: 8px 12px; border-radius: 8px;
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
border: 1px solid rgba(255, 152, 0, 0.35);
|
||||
font-size: 13.5px; line-height: 1.45;
|
||||
}
|
||||
.bn-note {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
margin: 6px 0 10px; padding: 10px 12px; border-radius: 8px;
|
||||
@@ -2331,6 +2376,13 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
.settings-actions {
|
||||
display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px;
|
||||
}
|
||||
/* Heading, hint and buttons stacked — as a row the hint landed between
|
||||
the heading and the buttons, reading like a caption for neither. */
|
||||
.docs-archive-block {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.docs-archive-block h4, .docs-archive-block .section-desc { margin: 0; }
|
||||
.settings-actions button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
@@ -2443,7 +2495,9 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
}
|
||||
.vac-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
/* The date fields carry a typing toggle and a clear button (2 × 36px)
|
||||
next to the picker — 160px left the date itself cut to "11/1". */
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
EWA_ALPHA_RANGE,
|
||||
PART_QTY_RANGE,
|
||||
SCHEDULE_OFFSET_MAX_DAYS,
|
||||
TRIGGER_DAYS_BEFORE_RANGE,
|
||||
TRIGGER_FOR_MINUTES_RANGE,
|
||||
TRIGGER_TARGET_CHANGES_RANGE,
|
||||
WARNING_DAYS_RANGE,
|
||||
@@ -39,7 +40,10 @@ const CALENDAR_KINDS = ["weekdays", "nth_weekday", "day_of_month", "calendar"];
|
||||
// #168: kinds that produce a due DATE and therefore take a time of day
|
||||
// (mirrors SCHEDULE_TIME_KINDS in config_flow_helpers.py).
|
||||
const SCHEDULE_TIME_KINDS = ["time_based", "one_time", ...CALENDAR_KINDS];
|
||||
const TRIGGER_TYPE_KEYS = ["threshold", "counter", "state_change", "runtime"];
|
||||
const TRIGGER_TYPE_KEYS = ["threshold", "counter", "state_change", "runtime", "due_date"];
|
||||
// "due_date" alone is the one-time task's date field — the trigger type
|
||||
// carries its own label.
|
||||
const triggerTypeLabel = (key: string, L: string): string => t(key === "due_date" ? "trigger_type_due_date" : key, L);
|
||||
// The type selector additionally offers "compound" (a group of conditions
|
||||
// joined by AND/OR); its per-condition sub-type is limited to the flat kinds.
|
||||
const TRIGGER_TYPE_KEYS_WITH_COMPOUND = [...TRIGGER_TYPE_KEYS, "compound"];
|
||||
@@ -68,6 +72,7 @@ interface CompoundConditionDraft {
|
||||
targetChanges: string;
|
||||
runtimeHours: string;
|
||||
onStates: string;
|
||||
daysBefore: string;
|
||||
/** Original keys this editor has no fields for (attribute, baseline, ...).
|
||||
* Spread back on save so a compound roundtrip never drops them (#103 class). */
|
||||
carry: Partial<TriggerConfig>;
|
||||
@@ -78,7 +83,7 @@ function emptyCondition(): CompoundConditionDraft {
|
||||
entityIds: "", type: "threshold", attribute: "", above: "", below: "",
|
||||
equals: "", notEquals: "", forMinutes: "0",
|
||||
targetValue: "", deltaMode: false, fromState: "", toState: "",
|
||||
targetChanges: "", runtimeHours: "", onStates: "", carry: {},
|
||||
targetChanges: "", runtimeHours: "", onStates: "", daysBefore: "0", carry: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,6 +95,7 @@ const MANAGED_CONDITION_KEYS = new Set([
|
||||
"trigger_target_value", "trigger_delta_mode",
|
||||
"trigger_from_state", "trigger_to_state", "trigger_target_changes",
|
||||
"trigger_runtime_hours", "trigger_on_states",
|
||||
"trigger_days_before",
|
||||
]);
|
||||
|
||||
/** The per-type trigger fields BOTH editors own — the flat trigger form and
|
||||
@@ -117,6 +123,7 @@ export function typeFieldsFromConfig(c: TriggerConfig): TriggerTypeFields {
|
||||
targetChanges: c.trigger_target_changes?.toString() ?? "",
|
||||
runtimeHours: c.trigger_runtime_hours?.toString() ?? "",
|
||||
onStates: (c.trigger_on_states || []).join(", "),
|
||||
daysBefore: c.trigger_days_before?.toString() ?? "0",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -144,6 +151,9 @@ export function applyTypeFields(c: TriggerConfig, d: TriggerTypeFields): void {
|
||||
const h = parseFloat(d.runtimeHours); if (!isNaN(h)) c.trigger_runtime_hours = h;
|
||||
const on = (d.onStates || "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (on.length > 0) c.trigger_on_states = on;
|
||||
} else if (d.type === "due_date") {
|
||||
const days = parseInt(d.daysBefore, 10);
|
||||
c.trigger_days_before = isNaN(days) ? 0 : days;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +194,13 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
@property({ type: Boolean, attribute: "checklists-enabled" }) public checklistsEnabled = false;
|
||||
@property({ type: Boolean, attribute: "schedule-time-enabled" }) public scheduleTimeEnabled = false;
|
||||
@property({ type: Boolean, attribute: "completion-actions-enabled" }) public completionActionsEnabled = false;
|
||||
/** Advanced features (Settings → Advanced Features): the dialog offers what
|
||||
* the panel then shows — with Adaptive off it let you switch adaptive
|
||||
* scheduling on while the feedback question and every result stayed
|
||||
* hidden (audit 2026-09-28). What a task already has stays visible. */
|
||||
@property({ type: Boolean, attribute: "adaptive-feature" }) public adaptiveFeature = false;
|
||||
@property({ type: Boolean, attribute: "seasonal-feature" }) public seasonalFeature = false;
|
||||
@property({ type: Boolean, attribute: "environmental-feature" }) public environmentalFeature = false;
|
||||
@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 }> = [];
|
||||
@@ -285,6 +302,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
@state() private _triggerToState = "";
|
||||
@state() private _triggerTargetChanges = "";
|
||||
@state() private _triggerRuntimeHours = "";
|
||||
@state() private _triggerDaysBefore = "0";
|
||||
@state() private _triggerRuntimeMaxSession = "";
|
||||
// Comma-separated "running" states for the runtime trigger (#103) —
|
||||
// empty = the backend default ["on"]. Must roundtrip on edit: adopted
|
||||
@@ -366,6 +384,13 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
// so ha-form can drive the data fields when the service schema is known.
|
||||
@state() private _actionService = "";
|
||||
@state() private _actionTargetEntity = "";
|
||||
/** The stored target as it came, and the entity the field started with.
|
||||
* The field edits one entity, but the API and imports accept several
|
||||
* entities, devices, areas, labels and floors — rebuilding the target
|
||||
* from the field dropped them on every save, a rename included (audit
|
||||
* 2026-09-28). An untouched field sends the stored target back verbatim. */
|
||||
private _actionTargetStored: Record<string, unknown> | null = null;
|
||||
private _actionTargetInitial = "";
|
||||
/** 2.95: not when the task completes itself (a reset wired by discovery). */
|
||||
@state() private _actionSkipAuto = false;
|
||||
/** 2.95: the task already has an action (suggested setups wire counter
|
||||
@@ -382,7 +407,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
@state() private _qcNotes = "";
|
||||
@state() private _qcCost = "";
|
||||
@state() private _qcDuration = "";
|
||||
@state() private _qcFeedback: "" | "needed" | "not_needed" = "";
|
||||
@state() private _qcFeedback: "" | "needed" | "not_needed" | "not_sure" = "";
|
||||
|
||||
// Environmental entity (adaptive_config)
|
||||
@state() private _environmentalEntity = "";
|
||||
@@ -398,6 +423,9 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
@state() private _adaptiveSeasonal = true;
|
||||
@state() private _adaptivePrediction = true;
|
||||
private _adaptiveInitial = "";
|
||||
/** The task had adaptive scheduling on when the dialog opened — its
|
||||
* section stays reachable even with the Adaptive feature off. */
|
||||
private _adaptiveWasEnabled = false;
|
||||
|
||||
private _adaptiveSnapshot(): string {
|
||||
return JSON.stringify([
|
||||
@@ -544,6 +572,10 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._actionService = oca.service;
|
||||
const tgt = oca.target?.entity_id;
|
||||
this._actionTargetEntity = Array.isArray(tgt) ? (tgt[0] || "") : (tgt || "");
|
||||
this._actionTargetStored = oca.target && typeof oca.target === "object"
|
||||
? JSON.parse(JSON.stringify(oca.target)) as Record<string, unknown>
|
||||
: null;
|
||||
this._actionTargetInitial = this._actionTargetEntity;
|
||||
this._actionData = (oca.data && typeof oca.data === "object") ? { ...oca.data } : {};
|
||||
this._actionDataJsonFallback = "";
|
||||
this._actionSkipAuto = oca.skip_auto === true;
|
||||
@@ -551,6 +583,8 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
} else {
|
||||
this._actionService = "";
|
||||
this._actionTargetEntity = "";
|
||||
this._actionTargetStored = null;
|
||||
this._actionTargetInitial = "";
|
||||
this._actionData = {};
|
||||
this._actionDataJsonFallback = "";
|
||||
this._actionSkipAuto = false;
|
||||
@@ -560,7 +594,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._qcNotes = qcd?.notes || "";
|
||||
this._qcCost = qcd?.cost != null ? String(qcd.cost) : "";
|
||||
this._qcDuration = qcd?.duration != null ? String(qcd.duration) : "";
|
||||
this._qcFeedback = (qcd?.feedback as "needed" | "not_needed" | undefined) || "";
|
||||
this._qcFeedback = (qcd?.feedback as "needed" | "not_needed" | "not_sure" | undefined) || "";
|
||||
|
||||
const ac: Partial<AdaptiveConfig> = task.adaptive_config || {};
|
||||
this._environmentalEntity = ac.environmental_entity || "";
|
||||
@@ -574,6 +608,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._adaptiveSeasonal = ac.seasonal_enabled !== false;
|
||||
this._adaptivePrediction = ac.sensor_prediction_enabled !== false;
|
||||
this._adaptiveInitial = this._adaptiveSnapshot();
|
||||
this._adaptiveWasEnabled = this._adaptiveEnabled;
|
||||
|
||||
if (task.trigger_config) {
|
||||
const tc = task.trigger_config;
|
||||
@@ -668,9 +703,12 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._adaptiveSeasonal = true;
|
||||
this._adaptivePrediction = true;
|
||||
this._adaptiveInitial = this._adaptiveSnapshot();
|
||||
this._adaptiveWasEnabled = false;
|
||||
// v1.3.0
|
||||
this._actionService = "";
|
||||
this._actionTargetEntity = "";
|
||||
this._actionTargetStored = null;
|
||||
this._actionTargetInitial = "";
|
||||
this._actionData = {};
|
||||
this._actionDataJsonFallback = "";
|
||||
this._actionSkipAuto = false;
|
||||
@@ -703,6 +741,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
targetChanges: this._triggerTargetChanges,
|
||||
runtimeHours: this._triggerRuntimeHours,
|
||||
onStates: this._triggerOnStates,
|
||||
daysBefore: this._triggerDaysBefore,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -721,6 +760,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._triggerTargetChanges = f.targetChanges;
|
||||
this._triggerRuntimeHours = f.runtimeHours;
|
||||
this._triggerOnStates = f.onStates;
|
||||
this._triggerDaysBefore = f.daysBefore;
|
||||
}
|
||||
|
||||
private _resetTriggerFields(): void {
|
||||
@@ -786,7 +826,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
const svc = this._actionService.trim();
|
||||
if (!svc || !/^[a-z][a-z0-9_]*\.[a-z0-9_]+$/.test(svc)) {
|
||||
this._actionTestResult = "error";
|
||||
this._actionTestError = "Invalid service format (expected 'domain.service')";
|
||||
this._actionTestError = t("action_test_bad_format", this._lang);
|
||||
setTimeout(() => { this._actionTestResult = ""; this._actionTestError = ""; }, 5000);
|
||||
return;
|
||||
}
|
||||
@@ -795,7 +835,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
// 2. Service exists?
|
||||
if (!this.hass?.services?.[domain]?.[name]) {
|
||||
this._actionTestResult = "error";
|
||||
this._actionTestError = `Service "${svc}" is not registered in Home Assistant. Check spelling and that the integration providing it is loaded.`;
|
||||
this._actionTestError = t("action_test_unknown_service", this._lang).replace("{service}", svc);
|
||||
setTimeout(() => { this._actionTestResult = ""; this._actionTestError = ""; }, 8000);
|
||||
return;
|
||||
}
|
||||
@@ -813,14 +853,19 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
]);
|
||||
if (entityDomain !== domain && !crossDomainServices.has(domain)) {
|
||||
this._actionTestResult = "error";
|
||||
this._actionTestError = `Service "${svc}" only works on ${domain}.* entities; entity "${tgt}" is in ${entityDomain}.* — pick a service that matches the entity domain (e.g. ${entityDomain}.${name})`;
|
||||
this._actionTestError = t("action_test_domain_mismatch", this._lang)
|
||||
.replace("{service}", svc)
|
||||
.replace("{domain}", domain)
|
||||
.replace("{entity}", tgt)
|
||||
.replace("{entity_domain}", entityDomain)
|
||||
.replace("{suggestion}", `${entityDomain}.${name}`);
|
||||
setTimeout(() => { this._actionTestResult = ""; this._actionTestError = ""; }, 8000);
|
||||
return;
|
||||
}
|
||||
// 5. Entity exists?
|
||||
if (!this.hass.states?.[tgt]) {
|
||||
this._actionTestResult = "error";
|
||||
this._actionTestError = `Target entity "${tgt}" not found in Home Assistant — the entity may have been renamed or its integration removed.`;
|
||||
this._actionTestError = t("action_test_entity_missing", this._lang).replace("{entity}", tgt);
|
||||
setTimeout(() => { this._actionTestResult = ""; this._actionTestError = ""; }, 8000);
|
||||
return;
|
||||
}
|
||||
@@ -868,6 +913,34 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
}));
|
||||
}
|
||||
|
||||
/** The stored target reaches beyond the one entity the field shows. */
|
||||
private _actionTargetExtended(): boolean {
|
||||
const st = this._actionTargetStored;
|
||||
if (!st) return false;
|
||||
return Object.entries(st).some(([key, v]) =>
|
||||
key === "entity_id" ? Array.isArray(v) && v.length > 1 : Array.isArray(v) ? v.length > 0 : !!v);
|
||||
}
|
||||
|
||||
/** Names of everything the stored target points at, for the hint. */
|
||||
private _actionTargetSummary(): string {
|
||||
const st = this._actionTargetStored || {};
|
||||
const ids = (v: unknown): string[] =>
|
||||
(Array.isArray(v) ? v : [v]).filter((s): s is string => typeof s === "string" && !!s);
|
||||
const h = this.hass as unknown as {
|
||||
states?: Record<string, { attributes?: { friendly_name?: string } }>;
|
||||
devices?: Record<string, { name_by_user?: string | null; name?: string | null }>;
|
||||
areas?: Record<string, { name?: string }>;
|
||||
floors?: Record<string, { name?: string }>;
|
||||
};
|
||||
return [
|
||||
...ids(st.entity_id).map((id) => h.states?.[id]?.attributes?.friendly_name || id),
|
||||
...ids(st.device_id).map((id) => h.devices?.[id]?.name_by_user || h.devices?.[id]?.name || id),
|
||||
...ids(st.area_id).map((id) => h.areas?.[id]?.name || id),
|
||||
...ids(st.floor_id).map((id) => h.floors?.[id]?.name || id),
|
||||
...ids(st.label_id),
|
||||
].join(", ");
|
||||
}
|
||||
|
||||
private _renderCompletionActionsSection(L: string) {
|
||||
if (!this.completionActionsEnabled && !this._actionPresent) return nothing;
|
||||
const schema = this._serviceSchema();
|
||||
@@ -903,6 +976,9 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._actionTargetEntity = v.target_entity || "";
|
||||
}}
|
||||
></ha-form>
|
||||
${this._actionTargetExtended() && this._actionTargetEntity.trim() === this._actionTargetInitial
|
||||
? html`<p class="field-help ca-target-more">${t("on_complete_action_target_more", L).replace("{targets}", this._actionTargetSummary())}</p>`
|
||||
: nothing}
|
||||
<p class="field-help ca-domain-hint">
|
||||
${t("on_complete_action_target_hint", L)}
|
||||
</p>
|
||||
@@ -972,10 +1048,11 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
></ms-textfield>
|
||||
<select class="qc-feedback"
|
||||
.value=${this._qcFeedback}
|
||||
@change=${(e: Event) => { this._qcFeedback = (e.target as HTMLSelectElement).value as "" | "needed" | "not_needed"; }}>
|
||||
@change=${(e: Event) => { this._qcFeedback = (e.target as HTMLSelectElement).value as "" | "needed" | "not_needed" | "not_sure"; }}>
|
||||
<option value="">${t("quick_complete_defaults_feedback_none", L)}</option>
|
||||
<option value="needed">${t("quick_complete_defaults_feedback_needed", L)}</option>
|
||||
<option value="not_needed">${t("quick_complete_defaults_feedback_not_needed", L)}</option>
|
||||
<option value="not_sure">${t("feedback_not_sure", L)}</option>
|
||||
</select>
|
||||
</details>` : nothing}
|
||||
`;
|
||||
@@ -1233,9 +1310,9 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
.value=${s.unit || ""}
|
||||
@input=${(e: Event) => this._patchReading(s.id, { unit: (e.target as HTMLInputElement).value })}
|
||||
></ms-textfield>
|
||||
<mwc-icon-button class="phase-remove reading-remove" @click=${() => (this._readings = this._readings.filter((x) => x.id !== s.id))}>
|
||||
<ha-icon-button class="phase-remove reading-remove" @click=${() => (this._readings = this._readings.filter((x) => x.id !== s.id))}>
|
||||
<ha-icon icon="mdi:delete-outline"></ha-icon>
|
||||
</mwc-icon-button>
|
||||
</ha-icon-button>
|
||||
</div>
|
||||
`)}
|
||||
${this._readings.length < MAX_READING_SLOTS ? html`
|
||||
@@ -1307,9 +1384,9 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
@input=${(e: Event) => this._patchPhaseDef(d.id, { partQty: (e.target as HTMLInputElement).value })} />
|
||||
` : nothing}
|
||||
` : nothing}
|
||||
<mwc-icon-button class="phase-remove" @click=${() => this._removePhaseDef(d.id)}>
|
||||
<ha-icon-button class="phase-remove" @click=${() => this._removePhaseDef(d.id)}>
|
||||
<ha-icon icon="mdi:delete-outline"></ha-icon>
|
||||
</mwc-icon-button>
|
||||
</ha-icon-button>
|
||||
</div>
|
||||
${this.checklistsEnabled ? html`
|
||||
<textarea
|
||||
@@ -1692,7 +1769,11 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
if (svc && /^[a-z][a-z0-9_]*\.[a-z0-9_]+$/.test(svc)) {
|
||||
const action: Record<string, unknown> = { service: svc };
|
||||
const tgt = this._actionTargetEntity.trim();
|
||||
if (tgt) action.target = { entity_id: tgt };
|
||||
if (this._actionTargetStored && tgt === this._actionTargetInitial) {
|
||||
action.target = JSON.parse(JSON.stringify(this._actionTargetStored));
|
||||
} else if (tgt) {
|
||||
action.target = { entity_id: tgt };
|
||||
}
|
||||
const dataDict = this._buildActionData();
|
||||
if (Object.keys(dataDict).length > 0) {
|
||||
action.data = dataDict;
|
||||
@@ -1741,7 +1822,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
@change=${(e: Event) => (this._triggerType = (e.target as HTMLSelectElement).value as TriggerType)}
|
||||
>
|
||||
${TRIGGER_TYPE_KEYS_WITH_COMPOUND.map(
|
||||
(key) => html`<option value=${key} ?selected=${key === this._triggerType}>${t(key, L)}</option>`
|
||||
(key) => html`<option value=${key} ?selected=${key === this._triggerType}>${triggerTypeLabel(key, L)}</option>`
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
@@ -1917,7 +1998,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
@change=${(e: Event) => this._patchCondition(i, { type: (e.target as HTMLSelectElement).value as TriggerType })}
|
||||
>
|
||||
${TRIGGER_TYPE_KEYS.map(
|
||||
(key) => html`<option value=${key} ?selected=${key === c.type}>${t(key, L)}</option>`
|
||||
(key) => html`<option value=${key} ?selected=${key === c.type}>${triggerTypeLabel(key, L)}</option>`
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
@@ -1990,6 +2071,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
* adapt. Collapsed unless adaptive is already enabled. */
|
||||
private _renderAdaptiveSection(L: string) {
|
||||
if (this._scheduleType === "one_time" || this._scheduleType === "manual") return nothing;
|
||||
if (!this.adaptiveFeature && !this._adaptiveWasEnabled) return nothing;
|
||||
return html`
|
||||
<details class="adaptive-section" ?open=${this._adaptiveEnabled}>
|
||||
<summary>${t("adaptive_section_title", L)}</summary>
|
||||
@@ -2025,14 +2107,14 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
.value=${this._adaptiveAlpha}
|
||||
@input=${(e: Event) => (this._adaptiveAlpha = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
<label>
|
||||
${this.seasonalFeature ? html`<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${this._adaptiveSeasonal}
|
||||
@change=${(e: Event) => (this._adaptiveSeasonal = (e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
${t("adaptive_seasonal_enabled", L)}
|
||||
</label>
|
||||
</label>` : nothing}
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -2177,6 +2259,13 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
})}
|
||||
`;
|
||||
}
|
||||
if (c.type === "due_date") {
|
||||
return html`
|
||||
<ms-textfield label="${t("days_before", L)}" type="number" step="1"
|
||||
min=${TRIGGER_DAYS_BEFORE_RANGE[0]} max=${TRIGGER_DAYS_BEFORE_RANGE[1]} .value=${c.daysBefore}
|
||||
@input=${(e: Event) => this._patchCondition(i, { daysBefore: (e.target as HTMLInputElement).value })}></ms-textfield>
|
||||
`;
|
||||
}
|
||||
return nothing;
|
||||
}
|
||||
|
||||
@@ -2641,6 +2730,17 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
).replace("{count}", String(n)),
|
||||
);
|
||||
parts.push(t("trigger_hint_state_now", L).replace("{value}", String(st.state)));
|
||||
} else if (this._triggerType === "due_date") {
|
||||
const days = parseInt(this._triggerDaysBefore, 10) || 0;
|
||||
parts.push(
|
||||
days === 0 ? t("trigger_hint_due_date_same_day", L)
|
||||
: days === 1 ? t("trigger_hint_due_date_one", L)
|
||||
: t("trigger_hint_due_date", L).replace("{days}", String(days)),
|
||||
);
|
||||
const reported = raw == null ? "" : String(raw);
|
||||
if (reported && !isNaN(Date.parse(reported))) {
|
||||
parts.push(t("trigger_hint_due_date_now", L).replace("{date}", formatDate(reported, L)));
|
||||
}
|
||||
}
|
||||
if (!parts.length) return overlap;
|
||||
return html`<div class="trigger-live-hint">${parts.join(" ")}</div>${overlap}`;
|
||||
@@ -2788,6 +2888,20 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
<div class="field-help">${t("runtime_on_states_help", L)}</div>
|
||||
`;
|
||||
}
|
||||
if (this._triggerType === "due_date") {
|
||||
return html`
|
||||
<ms-textfield
|
||||
label="${t("days_before", L)}"
|
||||
type="number"
|
||||
step="1"
|
||||
min=${TRIGGER_DAYS_BEFORE_RANGE[0]}
|
||||
max=${TRIGGER_DAYS_BEFORE_RANGE[1]}
|
||||
.value=${this._triggerDaysBefore}
|
||||
@input=${(e: Event) => (this._triggerDaysBefore = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
<div class="field-help">${t("days_before_help", L)}</div>
|
||||
`;
|
||||
}
|
||||
return nothing;
|
||||
}
|
||||
|
||||
@@ -3086,7 +3200,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
</div>` : nothing}
|
||||
` : nothing}
|
||||
${this._renderTriggerFields()}
|
||||
${this._scheduleType === "sensor_based" ? html`
|
||||
${this._scheduleType === "sensor_based" && (this.environmentalFeature || this._environmentalInitial) ? html`
|
||||
${this._entityPickerFallback ? html`
|
||||
<ms-textfield
|
||||
label="${t("environmental_entity_optional", L)}"
|
||||
@@ -3389,6 +3503,8 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
font-size: 13px;
|
||||
}
|
||||
.phase-remove {
|
||||
display: inline-flex;
|
||||
--ha-icon-button-size: 36px;
|
||||
--mdc-icon-button-size: 36px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
+103
-3
@@ -6,7 +6,8 @@
|
||||
*
|
||||
* • Quick info — name + status + next due + last performed + interval
|
||||
* • Primary actions — Complete (existing dialog), Skip (inline), Reset (inline)
|
||||
* • Secondary (admin) — Edit settings (existing dialog), QR Code, Delete
|
||||
* • Secondary — QR Code, Postpone (inline), Snooze for everyone; Edit
|
||||
* settings (existing dialog), Archive, Delete for writers
|
||||
* • Footer — "Open in Maintenance Panel" deep-link for History/Statistics
|
||||
*
|
||||
* Mounted via dialog-mount.openTaskQuickActions(entryId, taskId).
|
||||
@@ -16,16 +17,19 @@ import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { sharedStyles, t, formatDate, formatInterval, formatRecurrence, formatCost, formatDuration, currencySymbolOf, langOf, syncCurrencyDecimals} from "../styles";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { snoozedMessage, type SnoozeResult } from "../helpers/snooze";
|
||||
import { focusModalShell, modalShellStyles, renderModalShell } from "../helpers/modal-shell";
|
||||
import { isoDateLocal } from "../helpers/calendar-bucket";
|
||||
import { buildCompleteDialogArgs } from "../helpers/complete-dialog-args";
|
||||
import { phaseLabel } from "../helpers/phases";
|
||||
import { renderNotesMarkdown } from "../helpers/notes-markdown";
|
||||
import { buildHistoryEntryDraft } from "../helpers/history-draft";
|
||||
import { readingSlotDelta } from "../helpers/reading-slots";
|
||||
import { taskRef } from "../helpers/reference";
|
||||
import { newestFirst, renderHistoryEntry } from "../renderers/history";
|
||||
import { renderStatusBadge, statusColor } from "../renderers/status";
|
||||
import { fetchSettingsOnce } from "../helpers/settings-cache";
|
||||
import type { PartsCostMode } from "../helpers/parts-cost";
|
||||
import { canWrite, NO_DELEGATION, type WriteAccess } from "../helpers/permissions";
|
||||
import { confirmAction } from "../helpers/confirm";
|
||||
import { ToastTimer } from "../helpers/toast";
|
||||
@@ -65,12 +69,14 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
@state() private _error = "";
|
||||
@state() private _showSkip = false;
|
||||
@state() private _showReset = false;
|
||||
@state() private _showPostpone = false;
|
||||
@state() private _showDetails = false;
|
||||
@state() private _showAdaptive = false;
|
||||
@state() private _skipReason = "";
|
||||
@state() private _resetDate = "";
|
||||
@state() private _postponeDate = "";
|
||||
@state() private _features: AdvancedFeatures = {
|
||||
adaptive: false, predictions: false, seasonal: false, environmental: false,
|
||||
adaptive: false, seasonal: false, environmental: false,
|
||||
budget: false, groups: false, checklists: false, schedule_time: false,
|
||||
completion_actions: false,
|
||||
};
|
||||
@@ -98,8 +104,10 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
this._error = "";
|
||||
this._showSkip = false;
|
||||
this._showReset = false;
|
||||
this._showPostpone = false;
|
||||
this._showAdaptive = false;
|
||||
this._skipReason = "";
|
||||
this._postponeDate = "";
|
||||
// Local calendar date — toISOString() is UTC and prefills YESTERDAY for
|
||||
// users east of UTC before their morning (bug audit 2026-08-22).
|
||||
this._resetDate = isoDateLocal(new Date());
|
||||
@@ -120,8 +128,12 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
this._access = settings.access;
|
||||
this._currencySymbol = currencySymbolOf(settings.budget ?? undefined);
|
||||
syncCurrencyDecimals(settings.budget ?? undefined);
|
||||
this._partsCostMode = settings.partsCostMode;
|
||||
}
|
||||
|
||||
/** #104: when spare parts count (the complete dialog explains them). */
|
||||
@state() private _partsCostMode: PartsCostMode = "purchase";
|
||||
|
||||
public close(): void {
|
||||
this._loadSeq++; // a load still in flight belongs to the closed dialog
|
||||
this._open = false;
|
||||
@@ -221,6 +233,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
// feedback exactly like the panel (DRY audit 2026-09-26).
|
||||
features: this._features,
|
||||
currencySymbol: this._currencySymbol,
|
||||
partsCostMode: this._partsCostMode,
|
||||
}),
|
||||
);
|
||||
if (ok) {
|
||||
@@ -258,6 +271,43 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** Postpone and Snooze are household actions like Skip and Reset
|
||||
* (helpers/permissions HOUSEHOLD_ACTIONS) — the panel's ⋮ menu offered
|
||||
* them, the dashboard's dialog did not (audit 2026-09-29). */
|
||||
private async _onPostponeConfirm(): Promise<void> {
|
||||
if (!this._entryId || !this._taskId || !this._postponeDate) return;
|
||||
const ok = await this._runWs({
|
||||
type: "maintenance_supporter/task/postpone",
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
until: this._postponeDate,
|
||||
});
|
||||
if (ok !== undefined) {
|
||||
this._notifyChanged("postpone");
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async _onSnooze(): Promise<void> {
|
||||
if (!this._entryId || !this._taskId) return;
|
||||
const ok = await this._runWs<SnoozeResult>({
|
||||
type: "maintenance_supporter/task/snooze",
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
});
|
||||
if (ok !== undefined) {
|
||||
this._notifyChanged("snooze");
|
||||
// #193: the dialog closes, so Home Assistant's own toast says for how
|
||||
// long (a card has no toast of its own).
|
||||
this.dispatchEvent(new CustomEvent("hass-notification", {
|
||||
detail: { message: snoozedMessage(ok, this._lang) },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
private _onEdit(): void {
|
||||
if (!this._entryId || !this._taskId) return;
|
||||
import("../dialog-mount").then(({ openEditTaskDialog }) => {
|
||||
@@ -365,7 +415,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
this._showToast(r?.recommended_interval
|
||||
// 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)}: ${formatInterval(r.recommended_interval, "days", this._lang)} (${r.data_points} pts)`
|
||||
? `${t("reanalyze_result", this._lang)}: ${formatInterval(r.recommended_interval, "days", this._lang)} (${r.data_points} ${t("data_points", this._lang)})`
|
||||
: t("reanalyze_insufficient_data", this._lang));
|
||||
await this._loadTask();
|
||||
}
|
||||
@@ -517,6 +567,9 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
const L = this._lang;
|
||||
const task = this._task;
|
||||
const writer = canWrite(this.hass?.user, this._access);
|
||||
// The server refuses to postpone an archived, disabled or paused task,
|
||||
// and such a task sends no reminders to snooze.
|
||||
const inert = !!task && (!!task.archived || task.enabled === false || task.status === "paused");
|
||||
|
||||
return renderModalShell(() => this.close(), html`
|
||||
${task
|
||||
@@ -550,6 +603,9 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
? html`<span><strong>${t("phase_current", L)}:</strong> ${phaseLabel(task)}</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
${task.notes
|
||||
? html`<div class="notes-body">${renderNotesMarkdown(task.notes)}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
${this._error
|
||||
@@ -593,6 +649,28 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: this._showPostpone
|
||||
? html`
|
||||
<div class="inline-form">
|
||||
<label>${t("postpone_date_prompt", L)}</label>
|
||||
<ms-date-field
|
||||
kind="date"
|
||||
.hass=${this.hass}
|
||||
.lang=${L}
|
||||
.value=${this._postponeDate}
|
||||
@value-changed=${(e: CustomEvent) => { this._postponeDate = e.detail.value as string; }}
|
||||
></ms-date-field>
|
||||
<div class="inline-actions">
|
||||
<button class="btn cancel" @click=${() => { this._showPostpone = false; }} ?disabled=${this._busy}>
|
||||
${t("cancel", L)}
|
||||
</button>
|
||||
<button class="btn primary qa-postpone-confirm" @click=${this._onPostponeConfirm}
|
||||
?disabled=${this._busy || !this._postponeDate}>
|
||||
${t("postpone", L)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="actions primary-row">
|
||||
<ha-button appearance="accent" variant="success" @click=${this._onComplete} .disabled=${this._busy}>
|
||||
@@ -626,6 +704,17 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
<ha-icon slot="start" icon="mdi:qrcode"></ha-icon>
|
||||
${t("qr_code", L)}
|
||||
</ha-button>
|
||||
${inert
|
||||
? nothing
|
||||
: html`<ha-button size="small" appearance="outlined" variant="neutral" class="qa-postpone"
|
||||
@click=${() => { this._showPostpone = true; }} .disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="mdi:calendar-arrow-right"></ha-icon>
|
||||
${t("postpone", L)}…
|
||||
</ha-button>
|
||||
<ha-button size="small" appearance="outlined" variant="neutral" class="qa-snooze" @click=${this._onSnooze} .disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="mdi:bell-sleep-outline"></ha-icon>
|
||||
${t("snooze", L)}
|
||||
</ha-button>`}
|
||||
${writer
|
||||
? html`<ha-button size="small" appearance="outlined" variant="neutral" class="qa-archive"
|
||||
@click=${task.archived ? this._onUnarchive : this._onArchive}
|
||||
@@ -706,6 +795,11 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
.actions.secondary-row {
|
||||
padding-top: 8px; border-top: 1px solid var(--divider-color);
|
||||
justify-content: flex-start;
|
||||
/* 360 px: Edit + QR + Archive + Delete were 388 px in a 342 px row —
|
||||
Delete sat past the dialog edge (audit 2026-09-29). Wrap instead;
|
||||
Delete keeps its margin-left:auto on the second line. */
|
||||
flex-wrap: wrap;
|
||||
row-gap: 6px;
|
||||
}
|
||||
.actions.secondary-row .btn.danger,
|
||||
.actions.secondary-row ha-button.danger {
|
||||
@@ -734,6 +828,12 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
.btn.danger { color: var(--error-color); }
|
||||
.btn ha-icon { --mdc-icon-size: 18px; }
|
||||
.inline-form { display: flex; flex-direction: column; gap: 8px; }
|
||||
.notes-body {
|
||||
margin-top: 8px; padding: 8px; border-radius: 6px;
|
||||
background: var(--secondary-background-color);
|
||||
font-size: 13px; white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.notes-body ha-markdown { white-space: normal; }
|
||||
.inline-form label { font-size: 13px; color: var(--secondary-text-color); }
|
||||
.inline-form input {
|
||||
padding: 8px; font-size: 14px;
|
||||
|
||||
Reference in New Issue
Block a user