148 files
This commit is contained in:
+59
-4
@@ -48,6 +48,11 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
@state() private _error = "";
|
||||
@state() private _sensors: ProblemSensor[] = [];
|
||||
@state() private _selected: Set<string> = new Set();
|
||||
/** #188: the object each selected sensor goes to — typed per row; the same
|
||||
* name on several rows makes them ONE object, an existing object's name
|
||||
* adds them to it. */
|
||||
@state() private _objectNames: Record<string, string> = {};
|
||||
@state() private _objects: Array<{ entry_id: string; name: string }> = [];
|
||||
@state() private _users: HAUser[] = [];
|
||||
@state() private _responsible = "";
|
||||
// #136: minutes the problem must persist before the created task triggers
|
||||
@@ -74,18 +79,28 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
this._error = "";
|
||||
this._sensors = [];
|
||||
this._selected = new Set();
|
||||
this._objectNames = {};
|
||||
this._responsible = "";
|
||||
this._forMinutes = "0";
|
||||
try {
|
||||
if (!this._userService) this._userService = new UserService(this.hass);
|
||||
else this._userService.updateHass(this.hass);
|
||||
const [resp, users] = await Promise.all([
|
||||
const [resp, users, objs] = await Promise.all([
|
||||
this.hass.connection.sendMessagePromise<DiscoverResponse>({
|
||||
type: "maintenance_supporter/problem_sensors/discover",
|
||||
}),
|
||||
// Best-effort: adoption works fine without the user list.
|
||||
this._userService.getUsers().catch(() => [] as HAUser[]),
|
||||
// Best-effort too: the existing objects feed the name suggestions.
|
||||
this.hass.connection
|
||||
.sendMessagePromise<{ objects: Array<{ entry_id: string; object: { name: string; archived_at?: string | null } }> }>({
|
||||
type: "maintenance_supporter/objects",
|
||||
})
|
||||
.catch(() => ({ objects: [] })),
|
||||
]);
|
||||
this._objects = (objs.objects || [])
|
||||
.filter((o) => !o.object.archived_at)
|
||||
.map((o) => ({ entry_id: o.entry_id, name: o.object.name }));
|
||||
this._sensors = resp.sensors || [];
|
||||
this._selected = new Set(this._sensors.map((s) => s.entity_id));
|
||||
this._users = users;
|
||||
@@ -96,6 +111,21 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** The object name a row will be adopted into (typed, else the suggestion). */
|
||||
private _effectiveName(s: ProblemSensor): string {
|
||||
const typed = (this._objectNames[s.entity_id] ?? "").trim();
|
||||
return typed || s.suggested_object_name;
|
||||
}
|
||||
|
||||
/** An existing object the row lands in: the suggestion's entry when the
|
||||
* name is untouched, else an existing object with exactly that name. */
|
||||
private _existingEntryFor(s: ProblemSensor): string | null {
|
||||
const name = this._effectiveName(s);
|
||||
if (name === s.suggested_object_name && s.suggested_entry_id) return s.suggested_entry_id;
|
||||
const hit = this._objects.find((o) => o.name.trim().toLowerCase() === name.toLowerCase());
|
||||
return hit ? hit.entry_id : null;
|
||||
}
|
||||
|
||||
private _close(): void {
|
||||
this._open = false;
|
||||
}
|
||||
@@ -125,8 +155,8 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
.map((s) => ({
|
||||
entity_id: s.entity_id,
|
||||
name: s.name,
|
||||
entry_id: s.suggested_entry_id ?? undefined,
|
||||
object_name: s.suggested_object_name,
|
||||
entry_id: this._existingEntryFor(s) ?? undefined,
|
||||
object_name: this._effectiveName(s),
|
||||
device_id: s.device_id ?? undefined,
|
||||
part_id: s.suggested_part_id ?? undefined,
|
||||
responsible_user_id: this._responsible || undefined,
|
||||
@@ -177,6 +207,10 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
/>
|
||||
<span>${t("selected", L)}: ${this._selected.size} / ${this._sensors.length}</span>
|
||||
</label>
|
||||
<div class="row-sub adopt-object-hint">${t("adopt_object_hint", L)}</div>
|
||||
<datalist id="adopt-object-names">
|
||||
${this._objects.map((o) => html`<option value=${o.name}></option>`)}
|
||||
</datalist>
|
||||
<div class="list">
|
||||
${this._sensors.map((s) => {
|
||||
const checked = this._selected.has(s.entity_id);
|
||||
@@ -201,8 +235,22 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
</span>
|
||||
</div>
|
||||
${sub ? html`<div class="row-sub">${sub}</div>` : nothing}
|
||||
${checked
|
||||
? html`<div class="row-object" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<input
|
||||
class="adopt-object"
|
||||
list="adopt-object-names"
|
||||
aria-label=${t("object", L)}
|
||||
placeholder=${s.suggested_object_name}
|
||||
.value=${this._objectNames[s.entity_id] ?? s.suggested_object_name}
|
||||
@input=${(e: Event) => {
|
||||
this._objectNames = { ...this._objectNames, [s.entity_id]: (e.target as HTMLInputElement).value };
|
||||
}}
|
||||
/>
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="row-target">
|
||||
→ ${s.suggested_object_name}${s.suggested_entry_id
|
||||
→ ${this._effectiveName(s)}${this._existingEntryFor(s)
|
||||
? nothing
|
||||
: html` <span class="new-tag">${t("adopt_problem_new_object", L)}</span>`}
|
||||
</div>
|
||||
@@ -365,6 +413,13 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 12px;
|
||||
}
|
||||
.row-object { margin-top: 6px; }
|
||||
.row-object input {
|
||||
width: 100%; box-sizing: border-box; padding: 6px 8px; font: inherit;
|
||||
border: 1px solid var(--divider-color); border-radius: 6px;
|
||||
background: var(--card-background-color); color: var(--primary-text-color);
|
||||
}
|
||||
.adopt-object-hint { margin: 0 0 8px; }
|
||||
.row-target {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -18,12 +18,16 @@ import { runWs } from "../helpers/ws-run";
|
||||
import type { TriggerType } from "../types";
|
||||
import { REQUIRED_COMPLETION_KEYS, REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
|
||||
import "./ms-textfield";
|
||||
import { defaultNotifyIcon } from "../helpers/notify-icons";
|
||||
import "./ms-date-field";
|
||||
|
||||
const MAINTENANCE_TYPE_KEYS = ["cleaning", "inspection", "replacement", "calibration", "service", "reading", "custom"];
|
||||
const PRIORITY_KEYS = ["low", "normal", "high"];
|
||||
const SCHEDULE_TYPE_KEYS = ["time_based", "weekdays", "nth_weekday", "day_of_month", "sensor_based", "one_time", "manual"];
|
||||
const CALENDAR_KINDS = ["weekdays", "nth_weekday", "day_of_month"];
|
||||
const SCHEDULE_TYPE_KEYS = ["time_based", "weekdays", "nth_weekday", "day_of_month", "calendar", "sensor_based", "one_time", "manual"];
|
||||
// The nested calendar kinds — the three date patterns plus, since #187, a HA
|
||||
// calendar entity (one occurrence per event). Mirrors _CALENDAR_KINDS in
|
||||
// helpers/schedule.py (tripwire: tests/test_cross_layer_vocab.py).
|
||||
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];
|
||||
@@ -194,6 +198,8 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
@state() private _domLastDay = false;
|
||||
@state() private _domBusiness = false;
|
||||
@state() private _calOffset = "0";
|
||||
// #187: the HA calendar entity whose events are the occurrences ("calendar" kind).
|
||||
@state() private _calendarEntity = "";
|
||||
// Recurrence extras (apply to interval + calendar kinds): a seasonal active
|
||||
// window and a finite-series end condition.
|
||||
@state() private _seasonMonths: number[] = [];
|
||||
@@ -209,6 +215,8 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
@state() private _notes = "";
|
||||
@state() private _documentationUrl = "";
|
||||
@state() private _customIcon = "";
|
||||
/** #185: mdi icon for this task's push notifications ("" = type default). */
|
||||
@state() private _notifyIcon = "";
|
||||
@state() private _priority = "normal";
|
||||
@state() private _labels = "";
|
||||
/** D#183: external todo.* lists the task is mirrored into while due. */
|
||||
@@ -402,6 +410,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._domDay = sched?.kind === "day_of_month" && (sched.day ?? 1) >= 1 ? String(sched.day ?? 1) : "1";
|
||||
this._domLastDay = sched?.kind === "day_of_month" && sched.day === -1;
|
||||
this._domBusiness = sched?.kind === "day_of_month" && sched.business === true;
|
||||
this._calendarEntity = sched?.kind === "calendar" ? (sched.entity_id || "") : "";
|
||||
this._calOffset = sched?.offset ? String(sched.offset) : "0";
|
||||
// Recurrence extras — read from the nested schedule wherever it lives.
|
||||
this._seasonMonths = Array.isArray(sched?.season_months) ? [...sched!.season_months] : [];
|
||||
@@ -420,6 +429,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._notes = task.notes || "";
|
||||
this._documentationUrl = task.documentation_url || "";
|
||||
this._customIcon = task.custom_icon || "";
|
||||
this._notifyIcon = task.notify_icon || "";
|
||||
this._priority = task.priority || "normal";
|
||||
this._labels = (task.labels || []).join(", ");
|
||||
this._mirrorTodoEntities = [...(task.mirror_todo_entities || [])];
|
||||
@@ -577,6 +587,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._notes = "";
|
||||
this._documentationUrl = "";
|
||||
this._customIcon = "";
|
||||
this._notifyIcon = "";
|
||||
this._priority = "normal";
|
||||
this._labels = "";
|
||||
this._mirrorTodoEntities = [];
|
||||
@@ -1266,6 +1277,11 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._error = t("trigger_hint_overlap", this._lang);
|
||||
return;
|
||||
}
|
||||
if (this._scheduleType === "calendar" && !this._calendarEntity.trim().startsWith("calendar.")) {
|
||||
// #187: the backend would store entity_id null (a task without dates).
|
||||
this._error = t("calendar_entity_required", this._lang);
|
||||
return;
|
||||
}
|
||||
this._loading = true;
|
||||
this._error = "";
|
||||
try {
|
||||
@@ -1326,6 +1342,8 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
data.notes = this._notes || null;
|
||||
data.documentation_url = this._documentationUrl || null;
|
||||
data.custom_icon = this._customIcon || null;
|
||||
// #185: null clears the override (the update path pops the key).
|
||||
data.notify_icon = this._notifyIcon.trim() || null;
|
||||
data.priority = this._priority;
|
||||
data.labels = this._labels
|
||||
.split(",")
|
||||
@@ -2071,6 +2089,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
if (this._scheduleType === "one_time") {
|
||||
return this._dueDate ? { kind: "one_time", due_date: this._dueDate } : null;
|
||||
}
|
||||
if (this._scheduleType === "calendar" && !this._calendarEntity.trim()) return null;
|
||||
if (CALENDAR_KINDS.includes(this._scheduleType)) {
|
||||
return { ...this._buildSchedule(), ...this._recurrenceExtras() };
|
||||
}
|
||||
@@ -2088,7 +2107,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
private static readonly _PREVIEW_RELEVANT = new Set([
|
||||
"_open", "_scheduleType", "_intervalDays", "_intervalUnit", "_intervalAnchor",
|
||||
"_dueDate", "_weekdays", "_nth", "_nthWeekday", "_domDay", "_domLastDay",
|
||||
"_domBusiness", "_calOffset", "_seasonMonths", "_endsMode", "_endsCount",
|
||||
"_domBusiness", "_calOffset", "_calendarEntity", "_seasonMonths", "_endsMode", "_endsCount",
|
||||
"_endsUntil", "_lastPerformed",
|
||||
]);
|
||||
|
||||
@@ -2231,6 +2250,10 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
weekday: parseInt(this._nthWeekday, 10),
|
||||
});
|
||||
}
|
||||
if (this._scheduleType === "calendar") {
|
||||
// #187: once per event of the picked HA calendar entity.
|
||||
return withOffset({ kind: "calendar", entity_id: this._calendarEntity.trim() });
|
||||
}
|
||||
// (#83) "last day" wins over the day number; business rolls weekends back.
|
||||
const schedule: Record<string, unknown> = {
|
||||
kind: "day_of_month",
|
||||
@@ -2345,6 +2368,33 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
</div>
|
||||
${this._renderCalOffsetField()}`;
|
||||
}
|
||||
if (this._scheduleType === "calendar") {
|
||||
// #187: pick the HA calendar. Same <ha-form> entity-selector idiom as the
|
||||
// trigger pickers (never <ha-entity-picker> directly — see the
|
||||
// dialog-no-lazy-load-elements tripwire), with the text fallback the
|
||||
// picker probe flips to in a broken context.
|
||||
return html`
|
||||
${this._entityPickerFallback ? html`
|
||||
<ms-textfield
|
||||
label="${t("calendar_entity_label", L)}"
|
||||
helper="${t("calendar_entity_hint", L)}"
|
||||
.value=${this._calendarEntity}
|
||||
@input=${(e: Event) => (this._calendarEntity = (e.target as HTMLInputElement).value.trim())}
|
||||
></ms-textfield>
|
||||
` : html`
|
||||
<ha-form
|
||||
class="entity-picker-form calendar-entity-form"
|
||||
.hass=${this.hass}
|
||||
.schema=${[{ name: "calendar_entity", selector: { entity: { domain: "calendar" } } }]}
|
||||
.data=${{ calendar_entity: this._calendarEntity }}
|
||||
.computeLabel=${() => t("calendar_entity_label", L)}
|
||||
.computeHelper=${() => t("calendar_entity_hint", L)}
|
||||
@value-changed=${(e: CustomEvent) => {
|
||||
this._calendarEntity = ((e.detail.value as { calendar_entity?: string }).calendar_entity || "").trim();
|
||||
}}
|
||||
></ha-form>`}
|
||||
${this._renderCalOffsetField()}`;
|
||||
}
|
||||
if (this._scheduleType === "day_of_month") {
|
||||
return html`
|
||||
${this._domLastDay ? nothing : html`
|
||||
@@ -2962,6 +3012,18 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
@value-changed=${(e: CustomEvent) =>
|
||||
(this._customIcon = (e.detail.value as string) || "")}
|
||||
></ha-icon-picker>
|
||||
<ha-icon-picker
|
||||
class="notify-icon-picker"
|
||||
.hass=${this.hass}
|
||||
label="${t("notify_icon", L)}"
|
||||
.value=${this._notifyIcon}
|
||||
@value-changed=${(e: CustomEvent) =>
|
||||
(this._notifyIcon = (e.detail.value as string) || "")}
|
||||
></ha-icon-picker>
|
||||
<div class="field-help notify-icon-help">
|
||||
<ha-icon icon=${this._notifyIcon.trim() || defaultNotifyIcon(this._type)}></ha-icon>
|
||||
${t("notify_icon_hint", L).replace("{default}", defaultNotifyIcon(this._type))}
|
||||
</div>
|
||||
${this._availableTags.length > 0
|
||||
? html`
|
||||
<div class="select-row">
|
||||
|
||||
Reference in New Issue
Block a user