updated apps
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user