updated apps

This commit is contained in:
2026-07-14 23:57:03 -04:00
parent 6cc7212cef
commit 010e828e9c
797 changed files with 45153 additions and 4246 deletions
@@ -100,6 +100,8 @@ export class MaintenanceTaskDialog extends LitElement {
@property({ type: Boolean, attribute: "schedule-time-enabled" }) public scheduleTimeEnabled = false;
@property({ type: Boolean, attribute: "completion-actions-enabled" }) public completionActionsEnabled = 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 }> = [];
@state() private _open = false;
@state() private _loading = false;
@state() private _error = "";
@@ -171,6 +173,8 @@ 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> = {};
@state() private _partsLoadFailed = false;
@state() private _availableTags: Array<{id: string; name: string}> = [];
// User assignment
@@ -230,7 +234,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._objectChoices = [];
}
this._resetFields();
await Promise.all([this._loadUsers(), this._loadTags()]);
await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts()]);
this._open = true;
}
@@ -280,6 +284,7 @@ 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]));
this._responsibleUserId = task.responsible_user_id || null;
this._assigneePool = [...(task.assignee_pool || [])];
this._rotationStrategy = task.rotation_strategy || "";
@@ -346,7 +351,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._fetchEntityAttributes(this._triggerEntityId);
}
await Promise.all([this._loadUsers(), this._loadTags()]);
await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts()]);
this._open = true;
}
@@ -380,6 +385,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._lastPerformed = "";
this._nfcTagId = "";
this._readingUnit = "";
this._consumesParts = {};
this._responsibleUserId = null;
this._assigneePool = [];
this._rotationStrategy = "";
@@ -662,6 +668,27 @@ export class MaintenanceTaskDialog extends LitElement {
`;
}
private async _loadParts(): Promise<void> {
// The object's parts back the "consumes parts" checkboxes. Self-loaded so
// every dialog opener (panel, card, task detail) gets them without
// threading props through.
this.parts = [];
if (!this._entryId) return;
try {
const result = (await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/object",
entry_id: this._entryId,
})) as { parts?: Array<{ id: string; name: string; unit?: string }> };
this.parts = result.parts || [];
this._partsLoadFailed = false;
} catch {
// Surface the failure: an empty-but-failed load must not just hide the
// "consumes parts" section as if the object had no parts.
this.parts = [];
this._partsLoadFailed = true;
}
}
private async _loadTags(): Promise<void> {
try {
const result = await this.hass.connection.sendMessagePromise({
@@ -762,6 +789,12 @@ 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,
}));
}
data.responsible_user_id = this._responsibleUserId;
data.assignee_pool = this._assigneePool;
data.rotation_strategy =
@@ -978,6 +1011,7 @@ export class MaintenanceTaskDialog extends LitElement {
`
}
${this._renderTriggerTypeFields()}
${this._renderTriggerLiveHint()}
`}
<label>
<input
@@ -1311,6 +1345,81 @@ export class MaintenanceTaskDialog extends LitElement {
></ms-textfield>`;
}
/** Live "what happens next" hint for sensor-based triggers.
*
* Reads the bound entity's CURRENT state client-side (the dialog already
* holds `hass`) and spells out the trigger semantics against it — clearing
* the most common usage-meter confusion: a delta counter counts from the
* sensor's current reading (not from zero) and restarts after each
* completion. Renders nothing when there's no entity/state to read.
*/
private _renderTriggerLiveHint() {
if (this._triggerType === "compound") return nothing;
const entityId = this._triggerEntityId || this._triggerEntityIds[0];
if (!entityId || !this.hass?.states) return nothing;
const st = this.hass.states[entityId];
if (!st) return nothing;
const L = this._lang;
const unitAttr = st.attributes?.unit_of_measurement;
const unit = typeof unitAttr === "string" && unitAttr ? ` ${unitAttr}` : "";
const raw = this._triggerAttribute
? st.attributes?.[this._triggerAttribute]
: st.state;
const num = typeof raw === "number" ? raw : parseFloat(String(raw));
const hasNum = raw !== "unknown" && raw !== "unavailable" && raw != null && !isNaN(num);
const fmt = (v: number) => (Number.isInteger(v) ? String(v) : String(Math.round(v * 10) / 10));
const parts: string[] = [];
if (this._triggerType === "threshold") {
const above = parseFloat(this._triggerAbove);
const below = parseFloat(this._triggerBelow);
if (isNaN(above) && isNaN(below)) return nothing;
if (hasNum) parts.push(t("trigger_hint_now", L).replace("{value}", fmt(num) + unit));
if (!isNaN(above)) parts.push(t("trigger_hint_above", L).replace("{target}", fmt(above) + unit));
if (!isNaN(below)) parts.push(t("trigger_hint_below", L).replace("{target}", fmt(below) + unit));
} else if (this._triggerType === "counter") {
const target = parseFloat(this._triggerTargetValue);
if (isNaN(target)) return nothing;
if (this._triggerDeltaMode) {
if (this._taskId) {
// Editing: the baseline is the reading at the last completion (or
// task creation), not the current value — don't imply otherwise.
parts.push(t("trigger_hint_counter_delta_edit", L).replace("{target}", fmt(target) + unit));
} else if (hasNum) {
parts.push(
t("trigger_hint_counter_delta", L)
.replace("{value}", fmt(num) + unit)
.replace("{due}", fmt(num + target) + unit)
.replace("{target}", fmt(target) + unit),
);
} else {
parts.push(t("trigger_hint_counter_delta_edit", L).replace("{target}", fmt(target) + unit));
}
} else {
if (hasNum) parts.push(t("trigger_hint_now", L).replace("{value}", fmt(num) + unit));
parts.push(t("trigger_hint_counter_abs", L).replace("{target}", fmt(target) + unit));
}
} else if (this._triggerType === "runtime") {
const hours = parseFloat(this._triggerRuntimeHours);
if (isNaN(hours)) return nothing;
parts.push(t("trigger_hint_runtime", L).replace("{hours}", fmt(hours)));
parts.push(t("trigger_hint_state_now", L).replace("{value}", String(st.state)));
} else if (this._triggerType === "state_change") {
const n = parseInt(this._triggerTargetChanges, 10) || 1;
const to = this._triggerToState.trim();
parts.push(
(to
? t("trigger_hint_state_change_to", L).replace("{state}", to)
: t("trigger_hint_state_change", L)
).replace("{count}", String(n)),
);
parts.push(t("trigger_hint_state_now", L).replace("{value}", String(st.state)));
}
if (!parts.length) return nothing;
return html`<div class="trigger-live-hint">${parts.join(" ")}</div>`;
}
private _renderTriggerTypeFields() {
const L = this._lang;
if (this._triggerType === "threshold") {
@@ -1407,7 +1516,11 @@ export class MaintenanceTaskDialog extends LitElement {
<label>${t("object", L)}</label>
<select
.value=${this._entryId}
@change=${(e: Event) => (this._entryId = (e.target as HTMLSelectElement).value)}
@change=${(e: Event) => {
this._entryId = (e.target as HTMLSelectElement).value;
this._consumesParts = {};
this._loadParts();
}}
>
${this._objectChoices.map(
(o) => html`<option value=${o.entry_id} ?selected=${o.entry_id === this._entryId}>${o.name}</option>`
@@ -1442,6 +1555,49 @@ export class MaintenanceTaskDialog extends LitElement {
<div class="field-help">${t("reading_unit_help", L)}</div>
`
: nothing}
${this._partsLoadFailed
? html`<div class="field-help parts-load-failed">${t("parts_load_failed", L)}</div>`
: nothing}
${this.parts.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="1"
max="999"
.value=${String(qty)}
@input=${(e: Event) => {
const v = parseInt((e.target as HTMLInputElement).value, 10);
this._consumesParts = { ...this._consumesParts, [part.id]: Number.isFinite(v) && v >= 1 ? v : 1 };
}}
/>`
: nothing}
</div>
`;
})}
</div>
`
: nothing}
<div class="select-row">
<label>${t("priority", L)}</label>
<select
@@ -1769,10 +1925,41 @@ export class MaintenanceTaskDialog extends LitElement {
resize: vertical;
box-sizing: border-box;
}
.consumes-row {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 0;
}
.consumes-check {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
}
.consumes-qty {
width: 64px;
padding: 4px;
border: 1px solid var(--divider-color);
border-radius: 4px;
background: var(--card-background-color);
color: var(--primary-text-color);
}
.field-help {
font-size: 12px;
color: var(--secondary-text-color);
}
/* Live computed trigger hint — reads the bound sensor and explains what
happens next. Info-accented so it reads as guidance, not an error. */
.trigger-live-hint {
font-size: 12px;
color: var(--secondary-text-color);
border-left: 3px solid var(--info-color, #2196f3);
background: rgba(33, 150, 243, 0.08);
border-radius: 0 6px 6px 0;
padding: 6px 10px;
margin: 4px 0;
}
.field-help a,
.link-button {
background: none;