393 files
This commit is contained in:
+176
-70
@@ -4,13 +4,21 @@
|
||||
* and turns each into a maintenance task that triggers while the problem is
|
||||
* active and clears when it resolves. A selection either attaches to a suggested
|
||||
* existing object or spins up a fresh object bound to the sensor's device.
|
||||
*
|
||||
* 2.94 (duplicates from adopting in a real home): a sensor whose device no
|
||||
* object is linked to goes by default to the existing object that device most
|
||||
* likely is (and links it); a sensor that object probably already watches
|
||||
* under another name starts unticked with that task's name; a summary says
|
||||
* per object how many tasks it has before and after. Typing another object
|
||||
* asks the server again (problem_sensors/preview).
|
||||
*/
|
||||
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { TRIGGER_FOR_MINUTES_RANGE } from "../helpers/setting-ranges";
|
||||
import { UserService } from "../user-service";
|
||||
import type { HAUser, HomeAssistant } from "../types";
|
||||
|
||||
@@ -25,8 +33,13 @@ interface ProblemSensor {
|
||||
suggested_object_name: string;
|
||||
suggested_part_id: string | null;
|
||||
suggested_part_name: string | null;
|
||||
candidate?: { entry_id: string; name: string; reasons: string[] } | null;
|
||||
target_entry_id?: string | null;
|
||||
covered_by?: Covered;
|
||||
}
|
||||
|
||||
type Covered = { task_id: string; name: string; reason: string } | null;
|
||||
|
||||
interface DiscoverResponse {
|
||||
sensors: ProblemSensor[];
|
||||
}
|
||||
@@ -52,7 +65,10 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
* 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 _objects: Array<{ entry_id: string; name: string; tasks: number }> = [];
|
||||
/** 2.94: the task each sensor's target probably already has for it. */
|
||||
@state() private _covered: Record<string, Covered> = {};
|
||||
private _previewTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@state() private _users: HAUser[] = [];
|
||||
@state() private _responsible = "";
|
||||
// #136: minutes the problem must persist before the created task triggers
|
||||
@@ -82,51 +98,113 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
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, 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;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
if (!this._userService) this._userService = new UserService(this.hass);
|
||||
else this._userService.updateHass(this.hass);
|
||||
const [resp, users, objs] = await Promise.all([
|
||||
runWs<DiscoverResponse>(this, { type: "maintenance_supporter/problem_sensors/discover" }, {
|
||||
onError: (m) => { this._error = m; },
|
||||
}),
|
||||
// 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 }; tasks?: unknown[] }>;
|
||||
}>({
|
||||
type: "maintenance_supporter/objects",
|
||||
})
|
||||
.catch(() => ({ objects: [] })),
|
||||
]);
|
||||
this._loading = false;
|
||||
if (resp === undefined) return;
|
||||
this._objects = (objs?.objects || [])
|
||||
.filter((o) => !o.object.archived_at)
|
||||
.map((o) => ({ entry_id: o.entry_id, name: o.object.name, tasks: (o.tasks || []).length }));
|
||||
this._sensors = resp?.sensors || [];
|
||||
this._covered = Object.fromEntries(this._sensors.map((s) => [s.entity_id, s.covered_by ?? null]));
|
||||
// What the target probably already watches starts unticked.
|
||||
this._selected = new Set(this._sensors.filter((s) => !s.covered_by).map((s) => s.entity_id));
|
||||
this._users = users;
|
||||
}
|
||||
|
||||
/** The object name a row will be adopted into (typed, else the suggestion). */
|
||||
/** The default object: the one linked to the device, else the one the
|
||||
* device most likely is (2.94), else a new one named after the device. */
|
||||
private _defaultName(s: ProblemSensor): string {
|
||||
return s.suggested_entry_id ? s.suggested_object_name : (s.candidate?.name ?? s.suggested_object_name);
|
||||
}
|
||||
|
||||
/** The object name a row will be adopted into (typed, else the default). */
|
||||
private _effectiveName(s: ProblemSensor): string {
|
||||
const typed = (this._objectNames[s.entity_id] ?? "").trim();
|
||||
return typed || s.suggested_object_name;
|
||||
return typed || this._defaultName(s);
|
||||
}
|
||||
|
||||
/** An existing object the row lands in: the suggestion's entry when the
|
||||
* name is untouched, else an existing object with exactly that name. */
|
||||
/** An existing object the row lands in: the default'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 defaultEntry = s.suggested_entry_id || s.target_entry_id || null;
|
||||
if (name === this._defaultName(s) && defaultEntry) return defaultEntry;
|
||||
const hit = this._objects.find((o) => o.name.trim().toLowerCase() === name.toLowerCase());
|
||||
return hit ? hit.entry_id : null;
|
||||
}
|
||||
|
||||
/** Another object typed: the server judges the hints again (debounced). */
|
||||
private _schedulePreview(): void {
|
||||
clearTimeout(this._previewTimer);
|
||||
this._previewTimer = setTimeout(() => void this._preview(), 350);
|
||||
}
|
||||
|
||||
private async _preview(): Promise<void> {
|
||||
const selections = this._sensors.map((s) => ({
|
||||
entity_id: s.entity_id,
|
||||
name: s.name,
|
||||
entry_id: this._existingEntryFor(s),
|
||||
}));
|
||||
const resp = await runWs<{ covered: Record<string, Covered> }>(
|
||||
this,
|
||||
{ type: "maintenance_supporter/problem_sensors/preview", selections },
|
||||
{ onError: () => {} },
|
||||
);
|
||||
if (resp?.covered) this._covered = { ...this._covered, ...resp.covered };
|
||||
}
|
||||
|
||||
/** Per target object: tasks before → after (only ticked rows count). */
|
||||
private _summary(L: string) {
|
||||
const groups = new Map<string, { name: string; entry: string | null; adding: number }>();
|
||||
for (const s of this._sensors) {
|
||||
if (!this._selected.has(s.entity_id)) continue;
|
||||
const entry = this._existingEntryFor(s);
|
||||
const name = this._effectiveName(s);
|
||||
const key = entry ?? `new:${name.toLowerCase()}`;
|
||||
const g = groups.get(key) ?? { name, entry, adding: 0 };
|
||||
g.adding += 1;
|
||||
groups.set(key, g);
|
||||
}
|
||||
if (groups.size === 0) return nothing;
|
||||
return html`
|
||||
<div class="summary">
|
||||
<div class="summary-title">${t("adopt_summary_title", L)}</div>
|
||||
${[...groups.values()].map((g) => {
|
||||
if (!g.entry) {
|
||||
return html`<div class="summary-line new">
|
||||
${t("setups_new_object_count", L).replace("{name}", g.name).replace("{count}", String(g.adding))}
|
||||
</div>`;
|
||||
}
|
||||
const before = this._objects.find((o) => o.entry_id === g.entry)?.tasks ?? 0;
|
||||
return html`<div class="summary-line">
|
||||
${t("adopt_summary_existing", L)
|
||||
.replace("{name}", g.name)
|
||||
.replace("{before}", String(before))
|
||||
.replace("{after}", String(before + g.adding))}
|
||||
</div>`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _close(): void {
|
||||
clearTimeout(this._previewTimer);
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
@@ -147,40 +225,44 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
|
||||
private _adopt = async (): Promise<void> => {
|
||||
if (this._selected.size === 0 || this._adopting) return;
|
||||
this._adopting = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const selections = this._sensors
|
||||
.filter((s) => this._selected.has(s.entity_id))
|
||||
.map((s) => ({
|
||||
entity_id: s.entity_id,
|
||||
name: s.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,
|
||||
for_minutes: parseInt(this._forMinutes, 10) > 0 ? parseInt(this._forMinutes, 10) : undefined,
|
||||
}));
|
||||
const result = await this.hass.connection.sendMessagePromise<AdoptResponse>({
|
||||
type: "maintenance_supporter/problem_sensors/adopt",
|
||||
selections,
|
||||
});
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("problem-sensors-adopted", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: result,
|
||||
}),
|
||||
);
|
||||
this._open = false;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._adopting = false;
|
||||
}
|
||||
const selections = this._sensors
|
||||
.filter((s) => this._selected.has(s.entity_id))
|
||||
.map((s) => ({
|
||||
entity_id: s.entity_id,
|
||||
name: s.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,
|
||||
for_minutes: parseInt(this._forMinutes, 10) > 0 ? parseInt(this._forMinutes, 10) : undefined,
|
||||
}));
|
||||
const result = await runWs<AdoptResponse>(
|
||||
this,
|
||||
{ type: "maintenance_supporter/problem_sensors/adopt", selections },
|
||||
{ busy: (b) => { this._adopting = b; }, onError: (m) => { this._error = m; } },
|
||||
);
|
||||
if (result === undefined) return;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("problem-sensors-adopted", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: result,
|
||||
}),
|
||||
);
|
||||
this._open = false;
|
||||
};
|
||||
|
||||
/** The target line: the found object with why it was chosen, else the name. */
|
||||
private _targetLabel(s: ProblemSensor, L: string): string {
|
||||
if (s.candidate && this._existingEntryFor(s) === s.candidate.entry_id) {
|
||||
const reasons = s.candidate.reasons.map((r) => t(`setups_reason_${r}`, L)).join(", ");
|
||||
return t("setups_target_match", L).replace("{name}", s.candidate.name).replace("{reasons}", reasons);
|
||||
}
|
||||
return this._effectiveName(s);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._open) return html``;
|
||||
const L = this._lang;
|
||||
@@ -241,19 +323,25 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
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}
|
||||
placeholder=${this._defaultName(s)}
|
||||
.value=${this._objectNames[s.entity_id] ?? this._defaultName(s)}
|
||||
@input=${(e: Event) => {
|
||||
this._objectNames = { ...this._objectNames, [s.entity_id]: (e.target as HTMLInputElement).value };
|
||||
this._schedulePreview();
|
||||
}}
|
||||
/>
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="row-target">
|
||||
→ ${this._effectiveName(s)}${this._existingEntryFor(s)
|
||||
→ ${this._targetLabel(s, L)}${this._existingEntryFor(s)
|
||||
? nothing
|
||||
: html` <span class="new-tag">${t("adopt_problem_new_object", L)}</span>`}
|
||||
</div>
|
||||
${this._covered[s.entity_id]
|
||||
? html`<div class="maybe">
|
||||
${t("setups_maybe_covered", L).replace("{name}", this._covered[s.entity_id]!.name)}
|
||||
</div>`
|
||||
: nothing}
|
||||
${s.suggested_part_name
|
||||
? html`<div class="row-part">
|
||||
<ha-icon icon="mdi:package-variant-closed"></ha-icon>
|
||||
@@ -267,6 +355,8 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
</div>
|
||||
`}
|
||||
|
||||
${!this._loading && this._sensors.length > 0 ? this._summary(L) : nothing}
|
||||
|
||||
${!this._loading && this._sensors.length > 0
|
||||
? html`
|
||||
<label class="responsible">
|
||||
@@ -274,8 +364,8 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
<input
|
||||
class="for-input"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1440"
|
||||
min=${TRIGGER_FOR_MINUTES_RANGE[0]}
|
||||
max=${TRIGGER_FOR_MINUTES_RANGE[1]}
|
||||
.value=${this._forMinutes}
|
||||
@input=${(e: Event) => (this._forMinutes = (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
@@ -437,6 +527,22 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
.new-tag {
|
||||
font-style: italic;
|
||||
}
|
||||
.maybe {
|
||||
font-size: 11px;
|
||||
color: var(--warning-color, #ff9800);
|
||||
}
|
||||
.summary {
|
||||
font-size: 12px;
|
||||
border-top: 1px dashed var(--divider-color);
|
||||
padding-top: 6px;
|
||||
}
|
||||
.summary-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.summary-line {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.chip {
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
|
||||
+37
-99
@@ -8,7 +8,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 { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { isoDateLocal } from "../helpers/calendar-bucket";
|
||||
import { px } from "../renderers/chart-utils";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
@@ -132,17 +133,27 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
}
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
this._loading = true;
|
||||
this._error = "";
|
||||
try {
|
||||
this._ov = await this.hass.connection.sendMessagePromise<Overview>({
|
||||
type: "maintenance_supporter/battery_fleet/overview",
|
||||
});
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
const ov = await runWs<Overview>(this, { type: "maintenance_supporter/battery_fleet/overview" }, {
|
||||
busy: (b) => { this._loading = b; },
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
if (ov !== undefined) this._ov = ov;
|
||||
}
|
||||
|
||||
/** One fleet action — helpers/ws-run runWs behind the `_marking` guard,
|
||||
* with the error line and the overview reload (`onDone` runs first). */
|
||||
private async _act(payload: Record<string, unknown>, onDone?: () => void): Promise<void> {
|
||||
if (this._marking) return;
|
||||
this._error = "";
|
||||
await runWs(this, payload, {
|
||||
busy: (b) => { this._marking = b; },
|
||||
reload: async () => {
|
||||
onDone?.();
|
||||
await this._load();
|
||||
},
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
}
|
||||
|
||||
private _markAll = async (): Promise<void> => {
|
||||
@@ -152,58 +163,21 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
// Re-runs the idempotent setup, which restores the fleet task's trigger
|
||||
// when a user edit wiped it (issue #106) or recreates a deleted task.
|
||||
private _repair = async (): Promise<void> => {
|
||||
if (this._marking) return;
|
||||
this._marking = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/battery_fleet/setup",
|
||||
language: this._lang,
|
||||
});
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._marking = false;
|
||||
}
|
||||
await this._act({ type: "maintenance_supporter/battery_fleet/setup", language: this._lang });
|
||||
};
|
||||
|
||||
private async _mark(entityIds: string[] | undefined): Promise<void> {
|
||||
if (this._marking) return;
|
||||
this._marking = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/battery_fleet/mark_replaced",
|
||||
...(entityIds ? { entity_ids: entityIds } : {}),
|
||||
});
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._marking = false;
|
||||
}
|
||||
await this._act({
|
||||
type: "maintenance_supporter/battery_fleet/mark_replaced",
|
||||
...(entityIds ? { entity_ids: entityIds } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Manual exclude/include (#107): a rechargeable device the heuristics
|
||||
// missed (or any battery the user never wants tracked) leaves the fleet;
|
||||
// the restore list below the section brings it back.
|
||||
private async _setExcluded(entityId: string, excluded: boolean): Promise<void> {
|
||||
if (this._marking) return;
|
||||
this._marking = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/battery_fleet/set_excluded",
|
||||
entity_id: entityId,
|
||||
excluded,
|
||||
});
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._marking = false;
|
||||
}
|
||||
await this._act({ type: "maintenance_supporter/battery_fleet/set_excluded", entity_id: entityId, excluded });
|
||||
}
|
||||
|
||||
// #135: manually ADD a battery the discovery heuristics missed. The
|
||||
@@ -211,21 +185,8 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
// server-side; picking an entity acts immediately (no extra button).
|
||||
private async _addBattery(e: CustomEvent<{ value?: string }>): Promise<void> {
|
||||
const entityId = e.detail?.value;
|
||||
if (!entityId || this._marking) return;
|
||||
this._marking = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/battery_fleet/set_included",
|
||||
entity_id: entityId,
|
||||
included: true,
|
||||
});
|
||||
await this._load();
|
||||
} catch (e2) {
|
||||
this._error = describeWsError(e2, this._lang);
|
||||
} finally {
|
||||
this._marking = false;
|
||||
}
|
||||
if (!entityId) return;
|
||||
await this._act({ type: "maintenance_supporter/battery_fleet/set_included", entity_id: entityId, included: true });
|
||||
}
|
||||
|
||||
// #135 follow-up: fleet-wide opt-in that keeps self-charging devices
|
||||
@@ -241,20 +202,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
}
|
||||
|
||||
private async _setFleetOption(command: "set_track_self_charging" | "set_due_without_sensor", enabled: boolean): Promise<void> {
|
||||
if (this._marking) return;
|
||||
this._marking = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: `maintenance_supporter/battery_fleet/${command}`,
|
||||
enabled,
|
||||
});
|
||||
await this._load();
|
||||
} catch (e2) {
|
||||
this._error = describeWsError(e2, this._lang);
|
||||
} finally {
|
||||
this._marking = false;
|
||||
}
|
||||
await this._act({ type: `maintenance_supporter/battery_fleet/${command}`, enabled });
|
||||
}
|
||||
|
||||
/** Lazy: the recorder-backed history is fetched once, when the roster is
|
||||
@@ -380,9 +328,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
* a direct Intl.DateTimeFormat call here ignored the profile and showed
|
||||
* 9/2/2026 to a DD/MM/YYYY user). */
|
||||
private _fmtDate(epochMs: number): string {
|
||||
const d = new Date(epochMs);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return formatDate(`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`, this._lang);
|
||||
return formatDate(isoDateLocal(new Date(epochMs)), this._lang);
|
||||
}
|
||||
|
||||
/** The grouped shopping quantities as CLICKABLE chips: a type filters the
|
||||
@@ -414,22 +360,14 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
* backend (#181) so the swap also consumes the type-part cells from
|
||||
* stock, exactly like the Replaced action. */
|
||||
private async _recordJump(entityId: string, jump: { at: number; device_id: string }): Promise<void> {
|
||||
if (this._marking) return;
|
||||
this._marking = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
await this._act(
|
||||
{
|
||||
type: "maintenance_supporter/battery_fleet/record_replacement",
|
||||
entity_id: entityId,
|
||||
replaced_at: new Date(jump.at * 1000).toISOString(),
|
||||
});
|
||||
this._recorded = [...this._recorded, entityId];
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._marking = false;
|
||||
}
|
||||
},
|
||||
() => { this._recorded = [...this._recorded, entityId]; },
|
||||
);
|
||||
}
|
||||
|
||||
/** Purely visual level bar next to the number — scannable at a glance.
|
||||
|
||||
+27
-35
@@ -10,7 +10,7 @@ import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, syncLocaleFromHass, currencySymbolOf, langOf, formatCost, syncCurrencyDecimals} from "../styles";
|
||||
import { registerCustomCard } from "../helpers/register-card";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { sectionCardSharedStyles } from "./section-card-shared-styles";
|
||||
import type { BudgetStatus, HomeAssistant } from "../types";
|
||||
|
||||
@@ -59,43 +59,33 @@ export class MaintenanceBudgetSectionCard extends LitElement {
|
||||
}
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<BudgetStatus>({
|
||||
type: "maintenance_supporter/budget_status",
|
||||
});
|
||||
this._status = r;
|
||||
syncCurrencyDecimals(r);
|
||||
this._localMonthly = r.monthly_budget ? String(r.monthly_budget) : "";
|
||||
this._localYearly = r.yearly_budget ? String(r.yearly_budget) : "";
|
||||
this._dirty = false;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
const r = await runWs<BudgetStatus>(this, { type: "maintenance_supporter/budget_status" }, {
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
if (!r) return;
|
||||
this._status = r;
|
||||
syncCurrencyDecimals(r);
|
||||
this._localMonthly = r.monthly_budget ? String(r.monthly_budget) : "";
|
||||
this._localYearly = r.yearly_budget ? String(r.yearly_budget) : "";
|
||||
this._dirty = false;
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this._isAdmin) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
// An emptied field means "remove this budget" and must SEND 0 (the
|
||||
// backend's off-state) — omitting the key kept the old value, so a
|
||||
// budget could never be cleared from this card (bug audit 2026-08-22).
|
||||
const m = this._localMonthly.trim() === "" ? 0 : parseFloat(this._localMonthly);
|
||||
const y = this._localYearly.trim() === "" ? 0 : parseFloat(this._localYearly);
|
||||
const settings: Record<string, number> = {};
|
||||
if (!isNaN(m) && m >= 0) settings.budget_monthly = m;
|
||||
if (!isNaN(y) && y >= 0) settings.budget_yearly = y;
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/global/update",
|
||||
settings,
|
||||
});
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
// An emptied field means "remove this budget" and must SEND 0 (the
|
||||
// backend's off-state) — omitting the key kept the old value, so a
|
||||
// budget could never be cleared from this card (bug audit 2026-08-22).
|
||||
const m = this._localMonthly.trim() === "" ? 0 : parseFloat(this._localMonthly);
|
||||
const y = this._localYearly.trim() === "" ? 0 : parseFloat(this._localYearly);
|
||||
const settings: Record<string, number> = {};
|
||||
if (!isNaN(m) && m >= 0) settings.budget_monthly = m;
|
||||
if (!isNaN(y) && y >= 0) settings.budget_yearly = y;
|
||||
await runWs(this, { type: "maintenance_supporter/global/update", settings }, {
|
||||
busy: (b) => { this._busy = b; },
|
||||
reload: () => this._load(),
|
||||
onError: (msg) => { this._error = msg; },
|
||||
});
|
||||
}
|
||||
|
||||
private _onDeepLink(): void {
|
||||
@@ -257,8 +247,10 @@ export class MaintenanceBudgetSectionCard extends LitElement {
|
||||
.bar-fill.ok { background: var(--primary-color); }
|
||||
.bar-fill.warning { background: #ff9800; }
|
||||
.bar-fill.danger { background: var(--error-color, #f44336); }
|
||||
/* minmax(0, …): a bare 1fr never shrinks below the number inputs'
|
||||
natural width — the row was 420 px wide on a phone (2026-09-27). */
|
||||
.inputs-row {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 8px;
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px;
|
||||
padding-top: 4px; border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
.input-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
@@ -268,7 +260,7 @@ export class MaintenanceBudgetSectionCard extends LitElement {
|
||||
}
|
||||
.input-wrap { position: relative; display: flex; align-items: center; }
|
||||
.input-wrap input {
|
||||
flex: 1; padding: 6px 32px 6px 8px; font-size: 13px;
|
||||
flex: 1; min-width: 0; width: 100%; box-sizing: border-box; padding: 6px 32px 6px 8px; font-size: 13px;
|
||||
background: var(--secondary-background-color, #2c2c2c);
|
||||
color: var(--primary-text-color);
|
||||
border: 1px solid var(--divider-color); border-radius: 6px;
|
||||
|
||||
+144
-95
@@ -4,15 +4,23 @@ import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { HomeAssistant, ReadingSlot, TaskPartLink } from "../types";
|
||||
import { lastReadingBefore, type ReadingHistoryEntry } from "../helpers/reading-slots";
|
||||
import { t, nativeFieldStyles, formatCost, formatNumber } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { t, nativeFieldStyles, formatCost, formatNumber, formatQty } from "../styles";
|
||||
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 { REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
|
||||
import { PhotoUploadController } from "../helpers/photo-upload-controller";
|
||||
import { parseDurationMinutes } from "../helpers/duration";
|
||||
import "./ms-date-field";
|
||||
import "./ms-photo-picker";
|
||||
import { photoPickerStyles } from "./ms-photo-picker";
|
||||
|
||||
/** The server's bounds (websocket USED_PARTS_FIELD quantity / task/complete
|
||||
* restock_quantity) — validated on save with a message, never dropped.
|
||||
* One TS source (helpers/setting-ranges), pinned to Python. */
|
||||
const USED_QTY_RANGE = PART_QTY_RANGE;
|
||||
|
||||
export class MaintenanceCompleteDialog extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@property() public entryId = "";
|
||||
@@ -88,6 +96,11 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
/** Keyed by `partLinkKey` — the (entry_id, part_id) pair — because two
|
||||
* objects can carry the same part id, so part_id alone would merge pools. */
|
||||
@state() private _usedParts: Record<string, TaskPartLink> = {};
|
||||
/** The TYPED quantity per used part (same key). The field used to write
|
||||
* back a parsed number on every keystroke — clearing "1" to type "3"
|
||||
* snapped back to "1" first, giving "13" (bug audit 2026-09-26). The
|
||||
* text is kept as typed and parsed on save. */
|
||||
@state() private _usedQtyText: Record<string, string> = {};
|
||||
|
||||
/** #73: in-cycle ticks (keyed by item TEXT) recorded on the task detail —
|
||||
* prefill the dialog so nobody re-ticks what is already done. */
|
||||
@@ -120,6 +133,15 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
// untick or adjust before completing. The whole link is kept, entry_id
|
||||
// included, so a shared pool survives the edit (#111).
|
||||
this._usedParts = Object.fromEntries(this.consumesParts.map((l) => [partLinkKey(l), { ...l }]));
|
||||
this._usedQtyText = {};
|
||||
}
|
||||
|
||||
/** "Value must be between {min} and {max}" with locale-formatted bounds. */
|
||||
private _rangeError(min: number, max: number): string {
|
||||
const L = this.lang;
|
||||
return t("settings_value_out_of_range", L)
|
||||
.replace("{min}", formatNumber(min, L, { maximumFractionDigits: 2 }))
|
||||
.replace("{max}", formatNumber(max, L, { maximumFractionDigits: 2 }));
|
||||
}
|
||||
|
||||
private _toggleCheck(idx: number): void {
|
||||
@@ -135,92 +157,110 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
}
|
||||
|
||||
private async _complete(): Promise<void> {
|
||||
this._loading = true;
|
||||
// A photo still uploading would miss the completion and stay behind as
|
||||
// an orphan (bug audit 2026-09-26 #2) — the button is disabled meanwhile;
|
||||
// this guards Enter / a programmatic click as well.
|
||||
if (this._loading || this._photos.uploading) return;
|
||||
this._error = "";
|
||||
this._photos.clearError();
|
||||
try {
|
||||
const data: Record<string, unknown> = {
|
||||
type: "maintenance_supporter/task/complete",
|
||||
entry_id: this.entryId,
|
||||
task_id: this.taskId,
|
||||
};
|
||||
if (this._notes) data.notes = this._notes;
|
||||
if (this._cost) {
|
||||
const cost = parseFloat(this._cost);
|
||||
if (!isNaN(cost) && cost >= 0) data.cost = cost;
|
||||
const data: Record<string, unknown> = {
|
||||
type: "maintenance_supporter/task/complete",
|
||||
entry_id: this.entryId,
|
||||
task_id: this.taskId,
|
||||
};
|
||||
if (this._notes) data.notes = this._notes;
|
||||
if (this._cost) {
|
||||
const cost = parseFloat(this._cost);
|
||||
if (!isNaN(cost) && cost >= 0) data.cost = cost;
|
||||
}
|
||||
// Whole minutes — the server coerces to int (helpers/duration).
|
||||
const dur = parseDurationMinutes(this._duration);
|
||||
if (dur !== null) data.duration = dur;
|
||||
if (this.checklist.length > 0) {
|
||||
data.checklist_state = this._checklistState;
|
||||
}
|
||||
if (this.adaptiveEnabled) {
|
||||
data.feedback = this._feedback;
|
||||
}
|
||||
if (this._photos.photos.length > 0) {
|
||||
data.photo_doc_ids = this._photos.ids;
|
||||
}
|
||||
// Scan fallback: the backend accepts via_tag_scan on task/complete so a
|
||||
// require_tag_scan task can still be finished from the dialog the scan
|
||||
// opened. Only ever sent when a scan actually happened.
|
||||
if (this.viaTagScan) {
|
||||
data.via_tag_scan = true;
|
||||
}
|
||||
if (this._completedAt) {
|
||||
// Client-side guard mirrors the backend rule — a picked future moment
|
||||
// fails fast with a localized message instead of a WS roundtrip.
|
||||
if (new Date(this._completedAt).getTime() > Date.now()) {
|
||||
this._error = t("completed_at_future_error", this.lang);
|
||||
return;
|
||||
}
|
||||
if (this._duration) {
|
||||
const dur = parseInt(this._duration, 10);
|
||||
if (!isNaN(dur) && dur >= 0) data.duration = dur;
|
||||
// Re-add seconds if the datetime-local input drops them (same
|
||||
// normalisation as the history-edit dialog).
|
||||
data.completed_at = this._completedAt.length === 16 ? `${this._completedAt}:00` : this._completedAt;
|
||||
}
|
||||
if (this.readings.length > 0) {
|
||||
// #161 phase 2: only the meters actually read go out; the backend
|
||||
// snapshots name + unit per slot id.
|
||||
const values: Record<string, number> = {};
|
||||
for (const slot of this.readings) {
|
||||
const raw = (this._readingValues[slot.id] ?? "").trim();
|
||||
if (raw === "") continue;
|
||||
const num = parseFloat(raw.replace(",", "."));
|
||||
if (!isNaN(num)) values[slot.id] = num;
|
||||
}
|
||||
if (this.checklist.length > 0) {
|
||||
data.checklist_state = this._checklistState;
|
||||
if (Object.keys(values).length > 0) data.reading_values = values;
|
||||
} else if (this._readingValue !== "") {
|
||||
const rv = parseFloat(this._readingValue);
|
||||
if (!isNaN(rv)) data.reading_value = rv;
|
||||
}
|
||||
if (this.restockDefault !== null && this._restockQty.trim() !== "") {
|
||||
// Fractions are real purchases (0.5 kg, 2.5 l) — the old `>= 1` guard
|
||||
// dropped them silently and the part's default quantity was stocked
|
||||
// instead. Out of the server's range: say so, send nothing.
|
||||
const rq = parseFloat(this._restockQty.replace(",", "."));
|
||||
if (!Number.isFinite(rq) || rq < RESTOCK_QTY_RANGE[0] || rq > RESTOCK_QTY_RANGE[1]) {
|
||||
this._error = this._rangeError(...RESTOCK_QTY_RANGE);
|
||||
return;
|
||||
}
|
||||
if (this.adaptiveEnabled) {
|
||||
data.feedback = this._feedback;
|
||||
}
|
||||
if (this._photos.photos.length > 0) {
|
||||
data.photo_doc_ids = this._photos.ids;
|
||||
}
|
||||
// Scan fallback: the backend accepts via_tag_scan on task/complete so a
|
||||
// require_tag_scan task can still be finished from the dialog the scan
|
||||
// opened. Only ever sent when a scan actually happened.
|
||||
if (this.viaTagScan) {
|
||||
data.via_tag_scan = true;
|
||||
}
|
||||
if (this._completedAt) {
|
||||
// Client-side guard mirrors the backend rule — a picked future moment
|
||||
// fails fast with a localized message instead of a WS roundtrip.
|
||||
if (new Date(this._completedAt).getTime() > Date.now()) {
|
||||
this._error = t("completed_at_future_error", this.lang);
|
||||
this._loading = false;
|
||||
data.restock_quantity = rq;
|
||||
}
|
||||
// #99: with a parts section shown, send the explicit selection — it
|
||||
// replaces the automatic consumes_parts deduction (empty = none used).
|
||||
// entry_id travels only when the pool is somebody else's (#111), so an
|
||||
// own-part payload is byte-identical to what shipped before.
|
||||
if (this.parts.length > 0) {
|
||||
// The typed quantities, parsed now (see _usedQtyText).
|
||||
const used: TaskPartLink[] = [];
|
||||
for (const [key, link] of Object.entries(this._usedParts)) {
|
||||
const raw = this._usedQtyText[key];
|
||||
const qty = raw === undefined ? link.quantity : parseFloat(raw.replace(",", "."));
|
||||
if (!Number.isFinite(qty) || qty < USED_QTY_RANGE[0] || qty > USED_QTY_RANGE[1]) {
|
||||
this._error = this._rangeError(...USED_QTY_RANGE);
|
||||
return;
|
||||
}
|
||||
// Re-add seconds if the datetime-local input drops them (same
|
||||
// normalisation as the history-edit dialog).
|
||||
data.completed_at = this._completedAt.length === 16 ? `${this._completedAt}:00` : this._completedAt;
|
||||
used.push({ ...link, quantity: qty });
|
||||
}
|
||||
if (this.readings.length > 0) {
|
||||
// #161 phase 2: only the meters actually read go out; the backend
|
||||
// snapshots name + unit per slot id.
|
||||
const values: Record<string, number> = {};
|
||||
for (const slot of this.readings) {
|
||||
const raw = (this._readingValues[slot.id] ?? "").trim();
|
||||
if (raw === "") continue;
|
||||
const num = parseFloat(raw.replace(",", "."));
|
||||
if (!isNaN(num)) values[slot.id] = num;
|
||||
}
|
||||
if (Object.keys(values).length > 0) data.reading_values = values;
|
||||
} else if (this._readingValue !== "") {
|
||||
const rv = parseFloat(this._readingValue);
|
||||
if (!isNaN(rv)) data.reading_value = rv;
|
||||
}
|
||||
if (this.restockDefault !== null && this._restockQty !== "") {
|
||||
const rq = parseFloat(this._restockQty);
|
||||
if (!isNaN(rq) && rq >= 1) data.restock_quantity = rq;
|
||||
}
|
||||
// #99: with a parts section shown, send the explicit selection — it
|
||||
// replaces the automatic consumes_parts deduction (empty = none used).
|
||||
// entry_id travels only when the pool is somebody else's (#111), so an
|
||||
// own-part payload is byte-identical to what shipped before.
|
||||
if (this.parts.length > 0) {
|
||||
data.used_parts = Object.values(this._usedParts)
|
||||
.filter((l) => Number.isFinite(l.quantity) && l.quantity > 0)
|
||||
.map((l) =>
|
||||
l.entry_id
|
||||
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
|
||||
: { part_id: l.part_id, quantity: l.quantity },
|
||||
);
|
||||
}
|
||||
await this.hass.connection.sendMessagePromise(data);
|
||||
this._photos.markAttached(); // attached now — Cancel cleanup must not touch them
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("task-completed"));
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this.lang, t("save_error", this.lang));
|
||||
} finally {
|
||||
this._loading = false;
|
||||
data.used_parts = used
|
||||
.map((l) =>
|
||||
l.entry_id
|
||||
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
|
||||
: { part_id: l.part_id, quantity: l.quantity },
|
||||
);
|
||||
}
|
||||
const res = await runWs(this, data, {
|
||||
busy: (b) => { this._loading = b; },
|
||||
lang: this.lang,
|
||||
fallbackKey: "save_error",
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
if (res === undefined) return;
|
||||
this._photos.markAttached(); // attached now — Cancel cleanup must not touch them
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("task-completed"));
|
||||
}
|
||||
|
||||
/** #161 phase 2: one field per slot with the previous value as a hint;
|
||||
@@ -317,9 +357,7 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
|
||||
/** Seed the backdate field with the current minute (local, seconds zeroed). */
|
||||
private _pickCompletedAt(): void {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
this._completedAt = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
|
||||
this._completedAt = isoMinuteLocal(new Date());
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -374,24 +412,33 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
@change=${(e: Event) => {
|
||||
const next = { ...this._usedParts };
|
||||
if ((e.target as HTMLInputElement).checked) next[key] = next[key] || base;
|
||||
else delete next[key];
|
||||
else {
|
||||
delete next[key];
|
||||
const text = { ...this._usedQtyText };
|
||||
delete text[key];
|
||||
this._usedQtyText = text;
|
||||
}
|
||||
this._usedParts = next;
|
||||
}} />
|
||||
<span
|
||||
>${pt.name}${pt.owner_name
|
||||
? html`<span class="used-part-owner"> (${pt.owner_name})</span>`
|
||||
: nothing}${pt.stock !== null && pt.stock !== undefined ? ` (${pt.stock}${pt.unit ? " " + pt.unit : ""})` : ""}</span
|
||||
: nothing}${pt.stock !== null && pt.stock !== undefined ? ` (${formatQty(pt.stock, pt.unit, L)})` : ""}</span
|
||||
>
|
||||
</label>
|
||||
${checked
|
||||
? html`<input class="used-part-qty" type="number" min="0.01" max="999" step="0.01"
|
||||
.value=${String(link.quantity)}
|
||||
? html`<input class="used-part-qty" type="number" min=${USED_QTY_RANGE[0]} max=${USED_QTY_RANGE[1]} step="0.01"
|
||||
.value=${this._usedQtyText[key] ?? String(link.quantity)}
|
||||
@input=${(e: Event) => {
|
||||
const v = parseFloat((e.target as HTMLInputElement).value);
|
||||
this._usedParts = {
|
||||
...this._usedParts,
|
||||
[key]: { ...base, quantity: Number.isFinite(v) && v >= 0.01 ? v : 1 },
|
||||
};
|
||||
const raw = (e.target as HTMLInputElement).value;
|
||||
this._usedQtyText = { ...this._usedQtyText, [key]: raw };
|
||||
// A valid entry also feeds the live cost suggestion;
|
||||
// an empty / partial one leaves the last number
|
||||
// alone and is judged on save.
|
||||
const v = parseFloat(raw.replace(",", "."));
|
||||
if (Number.isFinite(v) && v >= USED_QTY_RANGE[0]) {
|
||||
this._usedParts = { ...this._usedParts, [key]: { ...base, quantity: v } };
|
||||
}
|
||||
}} />`
|
||||
: nothing}
|
||||
</div>`;
|
||||
@@ -406,7 +453,7 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
? html`
|
||||
<label class="field">
|
||||
<span class="field-label">${t("restock_quantity_label", L)}</span>
|
||||
<input type="number" step="0.01" min="0.01" class="field-input"
|
||||
<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)} />
|
||||
</label>`
|
||||
@@ -432,7 +479,7 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">${t("duration_minutes", L)}${this._req("duration")}</span>
|
||||
<input type="number" step="0.01" min="0" class="field-input"
|
||||
<input type="number" step="1" min="0" inputmode="numeric" class="field-input"
|
||||
.value=${this._duration}
|
||||
@input=${(e: Event) => (this._duration = (e.target as HTMLInputElement).value)} />
|
||||
</label>
|
||||
@@ -495,8 +542,10 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._complete}
|
||||
.disabled=${this._loading || this._missingRequired.length > 0}
|
||||
title=${this._missingRequired.length
|
||||
.disabled=${this._loading || this._photos.uploading || this._missingRequired.length > 0}
|
||||
title=${this._photos.uploading
|
||||
? t("uploading", L)
|
||||
: this._missingRequired.length
|
||||
? this._missingRequired.map((f) => t("err_required", L).replace("{field}", t(REQUIRED_COMPLETION_LABELS[f] ?? f, L))).join(" · ")
|
||||
: ""}
|
||||
>
|
||||
|
||||
+93
-70
@@ -11,11 +11,11 @@ import { LitElement, html, css, nothing } from "lit";
|
||||
import { isSafeHttpUrl } from "../helpers/url";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { downloadUrl } from "../helpers/download";
|
||||
import { downloadSignedDocument, openSignedDocument, signDocumentPath } from "../helpers/document-url";
|
||||
import { formatBytes } from "../helpers/format-bytes";
|
||||
import { docDisplayName, CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
|
||||
import { docCategory, docDisplayName, CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
|
||||
import { DOC_FILTER_MIN, DOC_SORT_MODES, asDocSortMode, filterDocuments, sortDocuments, type DocSortMode } from "../helpers/document-filter";
|
||||
import { LS_KEYS, lsGet, lsSet } from "../helpers/storage-keys";
|
||||
import type { HomeAssistant } from "../types";
|
||||
@@ -66,6 +66,10 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
@state() private _dragOver = false;
|
||||
|
||||
private _loadedFor: string | null = null;
|
||||
/** Only the newest list fetch may land (bug audit 2026-09-26): the panel
|
||||
* REUSES this element when the user moves to another object, and a slow
|
||||
* answer for the previous object overwrote the new one's list. */
|
||||
private _loadSeq = 0;
|
||||
private _localeReady = false;
|
||||
|
||||
private _isImage(doc: MaintenanceDocument): boolean {
|
||||
@@ -89,34 +93,80 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
void ensureLocale(this._lang).then(() => this.requestUpdate());
|
||||
}
|
||||
if (this.hass && this.entryId && this._loadedFor !== this.entryId) {
|
||||
const switched = this._loadedFor !== null;
|
||||
this._loadedFor = this.entryId;
|
||||
if (switched) this._resetForObject();
|
||||
void this._load();
|
||||
}
|
||||
}
|
||||
|
||||
/** Another object in the same element: nothing of the previous one may
|
||||
* survive — its list, thumbnails, an open link / edit form (whose Save
|
||||
* would have written to the NEW object) or a message. */
|
||||
private _resetForObject(): void {
|
||||
this._docs = [];
|
||||
this._loaded = false;
|
||||
this._thumbs = {};
|
||||
this._filter = "";
|
||||
this._error = "";
|
||||
this._hint = "";
|
||||
this._addingLink = false;
|
||||
this._linkUrl = "";
|
||||
this._linkTitle = "";
|
||||
this._linkDescription = "";
|
||||
this._editingId = "";
|
||||
this._lightboxUrl = "";
|
||||
}
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<{ documents: MaintenanceDocument[] }>({
|
||||
type: "maintenance_supporter/documents/list",
|
||||
entry_id: this.entryId,
|
||||
});
|
||||
this._docs = r.documents || [];
|
||||
this._loaded = true;
|
||||
this._error = "";
|
||||
this._thumbs = {};
|
||||
void this._loadThumbs();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
this._loaded = true;
|
||||
const entryId = this.entryId;
|
||||
const seq = ++this._loadSeq;
|
||||
const stale = () => seq !== this._loadSeq || entryId !== this.entryId;
|
||||
let failure = "";
|
||||
const r = await runWs<{ documents: MaintenanceDocument[] }>(
|
||||
this,
|
||||
{ type: "maintenance_supporter/documents/list", entry_id: entryId },
|
||||
{ onError: (m) => { failure = m; } },
|
||||
);
|
||||
if (stale()) return;
|
||||
this._loaded = true;
|
||||
if (r === undefined) {
|
||||
this._error = failure;
|
||||
return;
|
||||
}
|
||||
this._docs = r?.documents || [];
|
||||
this._error = "";
|
||||
this._thumbs = {};
|
||||
void this._loadThumbs(stale);
|
||||
}
|
||||
|
||||
/** A document write — helpers/ws-run runWs with the busy flag, the error
|
||||
* line and the list reload (`onDone` runs first). */
|
||||
private async _write(payload: Record<string, unknown>, onDone?: () => void, fallbackKey?: string): Promise<void> {
|
||||
this._error = "";
|
||||
await runWs(this, payload, {
|
||||
busy: (b) => { this._busy = b; },
|
||||
fallbackKey,
|
||||
reload: async () => {
|
||||
onDone?.();
|
||||
await this._load();
|
||||
},
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
}
|
||||
|
||||
/** The signed open / download helpers, their failure on the error line. */
|
||||
private async _signed(call: () => Promise<unknown>): Promise<void> {
|
||||
await runWs(this, call, { onError: (m) => { this._error = m; } });
|
||||
}
|
||||
|
||||
/** Pre-sign a serve URL for each image doc so it can render as a thumbnail. */
|
||||
private async _loadThumbs(): Promise<void> {
|
||||
private async _loadThumbs(stale: () => boolean = () => false): Promise<void> {
|
||||
await Promise.all(
|
||||
this._docs.filter((d) => this._isImage(d)).map(async (d) => {
|
||||
try {
|
||||
const url = await this._sign(d);
|
||||
if (stale()) return;
|
||||
this._thumbs = { ...this._thumbs, [d.id]: url };
|
||||
} catch {
|
||||
/* leave the fallback icon */
|
||||
@@ -126,8 +176,7 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
}
|
||||
|
||||
private _category_of(doc: MaintenanceDocument): string {
|
||||
const tag = (doc.tags || []).find((x) => (CATEGORIES as readonly string[]).includes(x));
|
||||
return tag || "other";
|
||||
return docCategory(doc);
|
||||
}
|
||||
|
||||
/** Keyboard support for the file-picker <label>s (Enter/Space → open). */
|
||||
@@ -173,6 +222,9 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
|
||||
private async _uploadFiles(files: File[], category?: string): Promise<void> {
|
||||
const cat = category ?? this._category;
|
||||
// Captured: the element may be moved to another object mid-upload — the
|
||||
// remaining files still belong to the object the user dropped them on.
|
||||
const entryId = this.entryId;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
this._hint = "";
|
||||
@@ -184,7 +236,7 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
for (const file of files) {
|
||||
let doc;
|
||||
try {
|
||||
doc = await uploadDocument(this.hass, this.entryId, file, [cat]);
|
||||
doc = await uploadDocument(this.hass, entryId, file, [cat]);
|
||||
} catch (e) {
|
||||
const key = e instanceof Error ? e.message : "";
|
||||
if (key !== "doc_too_large" && key !== "doc_upload_failed") throw e;
|
||||
@@ -194,6 +246,7 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
if (doc.duplicate_in_object) dupInObject++;
|
||||
else if (doc.deduped) deduped++;
|
||||
}
|
||||
if (entryId !== this.entryId) return; // the list shown is another object's now
|
||||
if (dupInObject) this._hint = t("doc_dup_in_object", this._lang);
|
||||
else if (deduped) this._hint = t("doc_deduped", this._lang);
|
||||
await this._load();
|
||||
@@ -205,11 +258,7 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
}
|
||||
|
||||
private async _download(doc: MaintenanceDocument): Promise<void> {
|
||||
try {
|
||||
await downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document");
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
await this._signed(() => downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document"));
|
||||
}
|
||||
|
||||
/** Open a document for viewing: images in an in-app lightbox, everything else
|
||||
@@ -219,11 +268,7 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
this._lightboxUrl = this._thumbs[doc.id] || (await this._sign(doc));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openSignedDocument(this.hass, doc.id);
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
await this._signed(() => openSignedDocument(this.hass, doc.id));
|
||||
}
|
||||
|
||||
/** Open a document from a title/row click (not just the small icons): preview
|
||||
@@ -259,66 +304,44 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
(x) => !(CATEGORIES as readonly string[]).includes(x),
|
||||
);
|
||||
const tags = doc.kind === "file" ? [this._editCategory, ...freeTags] : (doc.tags ?? []);
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
await this._write(
|
||||
{
|
||||
type: "maintenance_supporter/documents/update",
|
||||
doc_id: doc.id,
|
||||
title: this._editTitle.trim() || doc.filename || doc.url || "",
|
||||
tags,
|
||||
description: this._editDescription.trim(),
|
||||
});
|
||||
this._editingId = "";
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
},
|
||||
() => { this._editingId = ""; },
|
||||
);
|
||||
}
|
||||
|
||||
private async _delete(doc: MaintenanceDocument): Promise<void> {
|
||||
const name = docDisplayName(doc);
|
||||
if (!window.confirm(t("doc_delete_confirm", this._lang).replace("{name}", name))) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/documents/delete",
|
||||
doc_id: doc.id,
|
||||
});
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
await this._write({ type: "maintenance_supporter/documents/delete", doc_id: doc.id });
|
||||
}
|
||||
|
||||
private async _addLink(): Promise<void> {
|
||||
const url = this._linkUrl.trim();
|
||||
if (!url) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
const entryId = this.entryId;
|
||||
await this._write(
|
||||
{
|
||||
type: "maintenance_supporter/documents/add_link",
|
||||
entry_id: this.entryId,
|
||||
entry_id: entryId,
|
||||
url,
|
||||
title: this._linkTitle.trim() || null,
|
||||
description: this._linkDescription.trim() || null,
|
||||
});
|
||||
this._linkUrl = "";
|
||||
this._linkTitle = "";
|
||||
this._linkDescription = "";
|
||||
this._addingLink = false;
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang, t("doc_link_invalid", this._lang));
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
},
|
||||
() => {
|
||||
this._linkUrl = "";
|
||||
this._linkTitle = "";
|
||||
this._linkDescription = "";
|
||||
this._addingLink = false;
|
||||
},
|
||||
"doc_link_invalid",
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
|
||||
import { t, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import "./ms-textfield";
|
||||
import type {
|
||||
GroupTaskRef,
|
||||
@@ -76,33 +76,29 @@ export class MaintenanceGroupDialog extends LitElement {
|
||||
this._error = t("group_name_required", this._lang);
|
||||
return;
|
||||
}
|
||||
this._loading = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const task_refs = this._buildTaskRefs();
|
||||
if (this._groupId) {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/group/update",
|
||||
group_id: this._groupId,
|
||||
name,
|
||||
description: this._description,
|
||||
task_refs,
|
||||
});
|
||||
} else {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/group/create",
|
||||
name,
|
||||
description: this._description,
|
||||
task_refs,
|
||||
});
|
||||
}
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("group-saved"));
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
const task_refs = this._buildTaskRefs();
|
||||
const res = await runWs(
|
||||
this,
|
||||
this._groupId
|
||||
? {
|
||||
type: "maintenance_supporter/group/update",
|
||||
group_id: this._groupId,
|
||||
name,
|
||||
description: this._description,
|
||||
task_refs,
|
||||
}
|
||||
: {
|
||||
type: "maintenance_supporter/group/create",
|
||||
name,
|
||||
description: this._description,
|
||||
task_refs,
|
||||
},
|
||||
{ busy: (b) => { this._loading = b; }, fallbackKey: "save_error", onError: (m) => { this._error = m; } },
|
||||
);
|
||||
if (res === undefined) return;
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("group-saved"));
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
+49
-59
@@ -10,8 +10,11 @@ import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { registerCustomCard } from "../helpers/register-card";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { sectionCardSharedStyles } from "./section-card-shared-styles";
|
||||
import { fetchSettingsOnce } from "../helpers/settings-cache";
|
||||
import { canWrite, NO_DELEGATION, type WriteAccess } from "../helpers/permissions";
|
||||
import { confirmAction } from "../helpers/confirm";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
interface GroupEntry {
|
||||
@@ -36,6 +39,8 @@ export class MaintenanceGroupsSectionCard extends LitElement {
|
||||
@state() private _newName = "";
|
||||
@state() private _editingId: string | null = null;
|
||||
@state() private _editingName = "";
|
||||
/** Operator-write delegation — group CRUD is write tier (canWrite). */
|
||||
@state() private _access: WriteAccess = NO_DELEGATION;
|
||||
|
||||
private _hasInitiallyLoaded = false;
|
||||
|
||||
@@ -51,8 +56,12 @@ export class MaintenanceGroupsSectionCard extends LitElement {
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
private get _isAdmin(): boolean {
|
||||
return (this.hass?.user?.is_admin ?? true) as boolean;
|
||||
/** Group create / rename / delete are WRITE tier on the server: admins,
|
||||
* plus allowlisted operators with delegation on — the panel's rule
|
||||
* (helpers/permissions). `is_admin` alone hid the controls from a
|
||||
* delegated operator (DRY audit 2026-09-26). */
|
||||
private get _canWrite(): boolean {
|
||||
return canWrite(this.hass?.user, this._access);
|
||||
}
|
||||
|
||||
updated(changedProps: Map<string, unknown>): void {
|
||||
@@ -60,40 +69,39 @@ export class MaintenanceGroupsSectionCard extends LitElement {
|
||||
if (changedProps.has("hass") && this.hass && !this._hasInitiallyLoaded) {
|
||||
this._hasInitiallyLoaded = true;
|
||||
void this._load();
|
||||
void fetchSettingsOnce(this.hass).then((s) => { this._access = s.access; });
|
||||
void ensureLocale(this._lang).then(() => this.requestUpdate());
|
||||
}
|
||||
}
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<GroupsResp>({
|
||||
type: "maintenance_supporter/groups",
|
||||
});
|
||||
this._groups = r.groups || {};
|
||||
this._loaded = true;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
const r = await runWs<GroupsResp>(this, { type: "maintenance_supporter/groups" }, {
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
if (r === undefined) return;
|
||||
this._groups = r?.groups || {};
|
||||
this._loaded = true;
|
||||
}
|
||||
|
||||
/** One group mutation — helpers/ws-run runWs with the busy flag, the
|
||||
* error line and the list reload (`onDone` runs first). */
|
||||
private async _act(payload: Record<string, unknown>, onDone?: () => void): Promise<void> {
|
||||
this._error = "";
|
||||
await runWs(this, payload, {
|
||||
busy: (b) => { this._busy = b; },
|
||||
reload: async () => {
|
||||
onDone?.();
|
||||
await this._load();
|
||||
},
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
}
|
||||
|
||||
private async _addGroup(): Promise<void> {
|
||||
if (!this._isAdmin) return;
|
||||
if (!this._canWrite) return;
|
||||
const name = this._newName.trim();
|
||||
if (!name) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/group/create",
|
||||
name,
|
||||
});
|
||||
this._newName = "";
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
await this._act({ type: "maintenance_supporter/group/create", name }, () => { this._newName = ""; });
|
||||
}
|
||||
|
||||
private _startEdit(id: string): void {
|
||||
@@ -102,43 +110,25 @@ export class MaintenanceGroupsSectionCard extends LitElement {
|
||||
}
|
||||
|
||||
private async _saveEdit(): Promise<void> {
|
||||
if (!this._isAdmin || !this._editingId) return;
|
||||
if (!this._canWrite || !this._editingId) return;
|
||||
const name = this._editingName.trim();
|
||||
if (!name) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/group/update",
|
||||
group_id: this._editingId,
|
||||
name,
|
||||
});
|
||||
await this._act({ type: "maintenance_supporter/group/update", group_id: this._editingId, name }, () => {
|
||||
this._editingId = null;
|
||||
this._editingName = "";
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async _deleteGroup(id: string, name: string): Promise<void> {
|
||||
if (!this._isAdmin) return;
|
||||
const confirmText = t("group_delete_confirm", this._lang).replace("{name}", name);
|
||||
if (!window.confirm(confirmText)) return;
|
||||
this._busy = true;
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/group/delete",
|
||||
group_id: id,
|
||||
});
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
if (!this._canWrite) return;
|
||||
const ok = await confirmAction(this.hass, {
|
||||
title: t("delete", this._lang),
|
||||
message: t("delete_group_confirm", this._lang).replace("{name}", name),
|
||||
confirmText: t("delete", this._lang),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
await this._act({ type: "maintenance_supporter/group/delete", group_id: id });
|
||||
}
|
||||
|
||||
private _onDeepLink(): void {
|
||||
@@ -210,7 +200,7 @@ export class MaintenanceGroupsSectionCard extends LitElement {
|
||||
: html`
|
||||
<span class="group-name">${g.name || "Unnamed"}</span>
|
||||
<span class="task-count">${taskCount}</span>
|
||||
${this._isAdmin
|
||||
${this._canWrite
|
||||
? html`
|
||||
<button class="icon-btn"
|
||||
title="${t("edit", L)}"
|
||||
@@ -233,7 +223,7 @@ export class MaintenanceGroupsSectionCard extends LitElement {
|
||||
</div>
|
||||
`}
|
||||
|
||||
${this._isAdmin
|
||||
${this._canWrite
|
||||
? html`
|
||||
<div class="add-row">
|
||||
<input type="text"
|
||||
|
||||
+146
-132
@@ -11,8 +11,12 @@ import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, langOf, formatNumber } from "../styles";
|
||||
import type { HomeAssistant, ReadingSlot, ReadingValue } from "../types";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { PART_QTY_RANGE } from "../helpers/setting-ranges";
|
||||
import { focusModalShell, modalShellStyles, renderModalShell } from "../helpers/modal-shell";
|
||||
import { PhotoUploadController } from "../helpers/photo-upload-controller";
|
||||
import { parseDurationMinutes } from "../helpers/duration";
|
||||
import { confirmAction } from "../helpers/confirm";
|
||||
import "./ms-date-field";
|
||||
import "./history-photo";
|
||||
import "./ms-photo-picker";
|
||||
@@ -76,6 +80,12 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
@state() private _partOptions: PartOption[] | null = null;
|
||||
@state() private _partQty: Record<string, number> = {};
|
||||
private _partQtyOriginal = "";
|
||||
/** Bumped by every openEdit() and close(): an async answer started under
|
||||
* an older value belongs to a dialog session that is gone. The draft's
|
||||
* identity could not tell — every keystroke replaces `_draft` (`_set`),
|
||||
* so typing before parts/overview answered dropped the parts section
|
||||
* and the save skipped used_parts (bug audit 2026-09-26 #2). */
|
||||
private _openGen = 0;
|
||||
|
||||
// #161: the edited photo list (seeded with the entry's photos; the docs
|
||||
// THIS session uploaded are dropped again by an abandoned edit, a removed
|
||||
@@ -97,6 +107,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
* pass `original_timestamp` (the entry's current timestamp before edit)
|
||||
* so the backend can find the entry. */
|
||||
public openEdit(draft: HistoryEntryDraft): void {
|
||||
this._openGen++;
|
||||
this._draft = { ...draft };
|
||||
this._originalSnapshot = { ...draft };
|
||||
this._error = "";
|
||||
@@ -147,8 +158,11 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
/** The object's own parts + pooled parts this task draws on — from the
|
||||
* instance-wide overview so pooled owners resolve without extra calls. */
|
||||
private async _loadPartOptions(): Promise<void> {
|
||||
// The entry as opened: entry_id / task_id / used_parts never change
|
||||
// while the dialog is open (only the edited fields do).
|
||||
const draft = this._draft;
|
||||
if (!draft) return;
|
||||
const gen = this._openGen;
|
||||
try {
|
||||
const result = await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/parts/overview",
|
||||
@@ -158,6 +172,11 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
consumers: Array<{ entry_id: string; task_id: string }>;
|
||||
}>;
|
||||
};
|
||||
// The dialog was closed or reopened for ANOTHER entry while this was in
|
||||
// flight: its options (and quantities) belong to that other entry now
|
||||
// (bug audit 2026-09-26 — out-of-order answers). Edits typed in the
|
||||
// meantime do NOT count as another session.
|
||||
if (gen !== this._openGen) return;
|
||||
const options: PartOption[] = [];
|
||||
for (const row of result.parts || []) {
|
||||
const own = row.entry_id === draft.entry_id;
|
||||
@@ -193,6 +212,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
this._partQty = qty;
|
||||
this._partQtyOriginal = this._partSelectionKey();
|
||||
} catch {
|
||||
if (gen !== this._openGen) return;
|
||||
this._partOptions = []; // parts UI unavailable — the rest still edits
|
||||
}
|
||||
}
|
||||
@@ -206,6 +226,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this._openGen++;
|
||||
this._open = false;
|
||||
this._error = "";
|
||||
this._draft = null;
|
||||
@@ -227,115 +248,124 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
private async _delete(): Promise<void> {
|
||||
if (!this._draft || !this._originalSnapshot) return;
|
||||
const L = this._lang;
|
||||
if (!window.confirm(t("history_delete_confirm", L))) return;
|
||||
this._saving = true;
|
||||
// The confirm dialog, not the browser's native modal — this dialog is
|
||||
// also mounted on dashboards (DRY audit 2026-09-26).
|
||||
const confirmed = await confirmAction(this.hass, {
|
||||
title: t("history_delete_entry", L),
|
||||
message: t("history_delete_confirm", L),
|
||||
confirmText: t("delete", L),
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmed || !this._draft || !this._originalSnapshot) return;
|
||||
const draft = this._draft;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/task/history/delete",
|
||||
entry_id: this._draft.entry_id,
|
||||
task_id: this._draft.task_id,
|
||||
timestamp: this._originalSnapshot.original_timestamp,
|
||||
});
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("history-entry-saved", {
|
||||
detail: { entry_id: this._draft.entry_id, task_id: this._draft.task_id, deleted: true },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, L);
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
const ok = await this._runWs({
|
||||
type: "maintenance_supporter/task/history/delete",
|
||||
entry_id: draft.entry_id,
|
||||
task_id: draft.task_id,
|
||||
timestamp: this._originalSnapshot.original_timestamp,
|
||||
});
|
||||
if (ok === undefined) return;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("history-entry-saved", {
|
||||
detail: { entry_id: draft.entry_id, task_id: draft.task_id, deleted: true },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
}
|
||||
|
||||
/** helpers/ws-run runWs bound to the Save/Delete busy flag and the error line. */
|
||||
private _runWs(payload: Record<string, unknown>): Promise<unknown> {
|
||||
return runWs(this, payload, {
|
||||
busy: (b) => { this._saving = b; },
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this._draft || !this._originalSnapshot) return;
|
||||
this._saving = true;
|
||||
// Save waits for a photo still uploading — it would miss the patch and
|
||||
// stay behind as an orphan (bug audit 2026-09-26 #2).
|
||||
if (this._saving || this._photos.uploading) return;
|
||||
this._error = "";
|
||||
this._photos.clearError();
|
||||
try {
|
||||
const patch: Record<string, unknown> = {
|
||||
type: "maintenance_supporter/task/history/update",
|
||||
entry_id: this._draft.entry_id,
|
||||
task_id: this._draft.task_id,
|
||||
original_timestamp: this._originalSnapshot.original_timestamp,
|
||||
};
|
||||
// Only send fields that actually changed — keeps the patch minimal
|
||||
// and the WS schema happy (it treats missing as "no change").
|
||||
if (this._draft.timestamp !== this._originalSnapshot.timestamp) {
|
||||
patch.timestamp = this._draft.timestamp;
|
||||
}
|
||||
if (this._draft.notes !== this._originalSnapshot.notes) {
|
||||
patch.notes = this._draft.notes;
|
||||
}
|
||||
if (this._draft.cost !== this._originalSnapshot.cost) {
|
||||
patch.cost = this._draft.cost;
|
||||
}
|
||||
if (this._draft.duration !== this._originalSnapshot.duration) {
|
||||
patch.duration = this._draft.duration;
|
||||
}
|
||||
if (this._draft.completed_by !== this._originalSnapshot.completed_by) {
|
||||
patch.completed_by = this._draft.completed_by;
|
||||
}
|
||||
// #130: send the parts selection only when it actually changed — the
|
||||
// backend reconciles stock by the delta, so a no-op must stay silent.
|
||||
if (this._partOptions !== null && this._partSelectionKey() !== this._partQtyOriginal) {
|
||||
patch.used_parts = (this._partOptions || [])
|
||||
.filter((o) => (this._partQty[`${o.entry_id}:${o.part_id}`] || 0) > 0)
|
||||
.map((o) => ({
|
||||
part_id: o.part_id,
|
||||
quantity: this._partQty[`${o.entry_id}:${o.part_id}`],
|
||||
...(o.foreign ? { entry_id: o.entry_id } : {}),
|
||||
}));
|
||||
}
|
||||
// #161 phase 2: readings. The scalar rides the changed-field check
|
||||
// like notes; the slot map is sent whole when any value differs —
|
||||
// the backend replaces the snapshot, a missing id means "unread".
|
||||
if (this._draft.reading_value !== this._originalSnapshot.reading_value) {
|
||||
patch.reading_value = this._draft.reading_value ?? null;
|
||||
}
|
||||
if (this._readingRows.length > 0 && JSON.stringify(this._readingNumbers()) !== this._readingsOriginal) {
|
||||
const numbers = this._readingNumbers();
|
||||
const map: Record<string, number | null> = {};
|
||||
for (const row of this._readingRows) map[row.id] = numbers[row.id] ?? null;
|
||||
patch.reading_values = map;
|
||||
}
|
||||
// #161: the photo list, only when it differs from what we opened with.
|
||||
if (JSON.stringify(this._photos.ids) !== this._photosOriginal) {
|
||||
patch.photo_doc_ids = this._photos.ids;
|
||||
}
|
||||
// Nothing changed → close without WS call
|
||||
const changedKeys = Object.keys(patch).filter(
|
||||
(k) => !["type", "entry_id", "task_id", "original_timestamp"].includes(k),
|
||||
);
|
||||
if (changedKeys.length === 0) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
await this.hass.connection.sendMessagePromise(patch);
|
||||
this._photos.markAttached(); // saved — they belong to the entry now
|
||||
// Notify upstream so they can refresh
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("history-entry-saved", {
|
||||
detail: {
|
||||
entry_id: this._draft.entry_id,
|
||||
task_id: this._draft.task_id,
|
||||
new_timestamp: this._draft.timestamp,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._saving = false;
|
||||
const draft = this._draft;
|
||||
const original = this._originalSnapshot;
|
||||
const patch: Record<string, unknown> = {
|
||||
type: "maintenance_supporter/task/history/update",
|
||||
entry_id: draft.entry_id,
|
||||
task_id: draft.task_id,
|
||||
original_timestamp: original.original_timestamp,
|
||||
};
|
||||
// Only send fields that actually changed — keeps the patch minimal
|
||||
// and the WS schema happy (it treats missing as "no change").
|
||||
if (draft.timestamp !== original.timestamp) {
|
||||
patch.timestamp = draft.timestamp;
|
||||
}
|
||||
if (draft.notes !== original.notes) {
|
||||
patch.notes = draft.notes;
|
||||
}
|
||||
if (draft.cost !== original.cost) {
|
||||
patch.cost = draft.cost;
|
||||
}
|
||||
if (draft.duration !== original.duration) {
|
||||
patch.duration = draft.duration;
|
||||
}
|
||||
if (draft.completed_by !== original.completed_by) {
|
||||
patch.completed_by = draft.completed_by;
|
||||
}
|
||||
// #130: send the parts selection only when it actually changed — the
|
||||
// backend reconciles stock by the delta, so a no-op must stay silent.
|
||||
if (this._partOptions !== null && this._partSelectionKey() !== this._partQtyOriginal) {
|
||||
patch.used_parts = (this._partOptions || [])
|
||||
.filter((o) => (this._partQty[`${o.entry_id}:${o.part_id}`] || 0) > 0)
|
||||
.map((o) => ({
|
||||
part_id: o.part_id,
|
||||
quantity: this._partQty[`${o.entry_id}:${o.part_id}`],
|
||||
...(o.foreign ? { entry_id: o.entry_id } : {}),
|
||||
}));
|
||||
}
|
||||
// #161 phase 2: readings. The scalar rides the changed-field check
|
||||
// like notes; the slot map is sent whole when any value differs —
|
||||
// the backend replaces the snapshot, a missing id means "unread".
|
||||
if (draft.reading_value !== original.reading_value) {
|
||||
patch.reading_value = draft.reading_value ?? null;
|
||||
}
|
||||
if (this._readingRows.length > 0 && JSON.stringify(this._readingNumbers()) !== this._readingsOriginal) {
|
||||
const numbers = this._readingNumbers();
|
||||
const map: Record<string, number | null> = {};
|
||||
for (const row of this._readingRows) map[row.id] = numbers[row.id] ?? null;
|
||||
patch.reading_values = map;
|
||||
}
|
||||
// #161: the photo list, only when it differs from what we opened with.
|
||||
if (JSON.stringify(this._photos.ids) !== this._photosOriginal) {
|
||||
patch.photo_doc_ids = this._photos.ids;
|
||||
}
|
||||
// Nothing changed → close without WS call
|
||||
const changedKeys = Object.keys(patch).filter(
|
||||
(k) => !["type", "entry_id", "task_id", "original_timestamp"].includes(k),
|
||||
);
|
||||
if (changedKeys.length === 0) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
if ((await this._runWs(patch)) === undefined) return;
|
||||
this._photos.markAttached(); // saved — they belong to the entry now
|
||||
// Notify upstream so they can refresh
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("history-entry-saved", {
|
||||
detail: {
|
||||
entry_id: draft.entry_id,
|
||||
task_id: draft.task_id,
|
||||
new_timestamp: draft.timestamp,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -343,9 +373,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
const L = this._lang;
|
||||
const d = this._draft;
|
||||
const error = this._error || this._photos.errorText(L);
|
||||
return html`
|
||||
<div class="backdrop" @click=${this.close}></div>
|
||||
<div class="dialog" role="dialog" aria-modal="true">
|
||||
return renderModalShell(() => this.close(), html`
|
||||
<h2>${t("history_edit_title", L)}</h2>
|
||||
<div class="entry-type">
|
||||
<ha-icon icon="mdi:tag-outline"></ha-icon>
|
||||
@@ -387,11 +415,11 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
</label>
|
||||
<label>
|
||||
<span>${t("duration", L)}</span>
|
||||
<input type="number" min="0"
|
||||
<input type="number" min="0" step="1" inputmode="numeric"
|
||||
.value=${d.duration != null ? String(d.duration) : ""}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
this._set("duration", v ? Number(v) : null);
|
||||
// Whole minutes, like the server stores them (helpers/duration).
|
||||
this._set("duration", parseDurationMinutes((e.target as HTMLInputElement).value));
|
||||
}} />
|
||||
</label>
|
||||
</div>
|
||||
@@ -411,7 +439,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
}} />
|
||||
<span class="part-label">${o.name}${o.foreign && o.object_name ? ` (${o.object_name})` : ""}</span>
|
||||
${qty > 0 ? html`
|
||||
<input class="part-qty" type="number" min="0.01" max="999" step="0.01"
|
||||
<input class="part-qty" type="number" min=${PART_QTY_RANGE[0]} max=${PART_QTY_RANGE[1]} step="0.01"
|
||||
.value=${String(qty)}
|
||||
@input=${(e: Event) => {
|
||||
const v = parseFloat((e.target as HTMLInputElement).value);
|
||||
@@ -449,12 +477,16 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
<button class="cancel" @click=${this.close} ?disabled=${this._saving}>
|
||||
${t("cancel", L)}
|
||||
</button>
|
||||
<button class="save" @click=${this._save} ?disabled=${this._saving}>
|
||||
<button class="save" @click=${this._save} ?disabled=${this._saving || this._photos.uploading}
|
||||
title=${this._photos.uploading ? t("uploading", L) : ""}>
|
||||
${this._saving ? t("saving", L) : t("save", L)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
`);
|
||||
}
|
||||
|
||||
protected updated(changed: Map<string, unknown>): void {
|
||||
if (changed.has("_open") && this._open) focusModalShell(this.shadowRoot);
|
||||
}
|
||||
|
||||
/** #161 phase 2: per-slot rows for a slot task (entry snapshot ∪ current
|
||||
@@ -490,26 +522,8 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
</label>`;
|
||||
}
|
||||
|
||||
static styles = [photoPickerStyles, css`
|
||||
:host { display: contents; }
|
||||
.backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 100;
|
||||
}
|
||||
.dialog {
|
||||
position: fixed; left: 50%; top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 95vw; max-width: 480px;
|
||||
background: var(--card-background-color, var(--ha-card-background, #1c1c1c));
|
||||
color: var(--primary-text-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
|
||||
padding: 20px;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
z-index: 101;
|
||||
max-height: 90vh; overflow: auto;
|
||||
}
|
||||
static styles = [photoPickerStyles, modalShellStyles, css`
|
||||
:host { display: contents; --ms-modal-max-height: 90vh; --ms-modal-gap: 12px; }
|
||||
h2 { margin: 0; font-size: 18px; }
|
||||
.entry-type {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { property, state } from "lit/decorators.js";
|
||||
import type { HomeAssistant, MaintenanceObject, MaintenanceObjectResponse } from "../types";
|
||||
import { t, langOf } from "../styles";
|
||||
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import "./ms-textfield";
|
||||
import "./ms-date-field";
|
||||
|
||||
@@ -74,48 +74,31 @@ export class MaintenanceObjectDialog extends LitElement {
|
||||
private async _save(): Promise<void> {
|
||||
if (this._loading) return; // synchronous re-entry guard (double-click)
|
||||
if (!this._name.trim()) return;
|
||||
this._loading = true;
|
||||
this._error = "";
|
||||
try {
|
||||
if (this._entryId) {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/object/update",
|
||||
entry_id: this._entryId,
|
||||
name: this._name,
|
||||
manufacturer: this._manufacturer || null,
|
||||
model: this._model || null,
|
||||
serial_number: this._serialNumber || null,
|
||||
area_id: this._areaId || null,
|
||||
installation_date: this._installationDate || null,
|
||||
warranty_expiry: this._warrantyExpiry || null,
|
||||
documentation_url: this._documentationUrl.trim() || null,
|
||||
notes: this._notes.trim() || null,
|
||||
ha_device_id: this._haDeviceId || null,
|
||||
parent_entry_id: this._parentEntryId || null,
|
||||
});
|
||||
} else {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/object/create",
|
||||
name: this._name,
|
||||
manufacturer: this._manufacturer || null,
|
||||
model: this._model || null,
|
||||
serial_number: this._serialNumber || null,
|
||||
area_id: this._areaId || null,
|
||||
installation_date: this._installationDate || null,
|
||||
warranty_expiry: this._warrantyExpiry || null,
|
||||
documentation_url: this._documentationUrl.trim() || null,
|
||||
notes: this._notes.trim() || null,
|
||||
ha_device_id: this._haDeviceId || null,
|
||||
parent_entry_id: this._parentEntryId || null,
|
||||
});
|
||||
}
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("object-saved"));
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
// One field set for create and update (the two payloads were copies).
|
||||
const fields = {
|
||||
name: this._name,
|
||||
manufacturer: this._manufacturer || null,
|
||||
model: this._model || null,
|
||||
serial_number: this._serialNumber || null,
|
||||
area_id: this._areaId || null,
|
||||
installation_date: this._installationDate || null,
|
||||
warranty_expiry: this._warrantyExpiry || null,
|
||||
documentation_url: this._documentationUrl.trim() || null,
|
||||
notes: this._notes.trim() || null,
|
||||
ha_device_id: this._haDeviceId || null,
|
||||
parent_entry_id: this._parentEntryId || null,
|
||||
};
|
||||
const res = await runWs(
|
||||
this,
|
||||
this._entryId
|
||||
? { type: "maintenance_supporter/object/update", entry_id: this._entryId, ...fields }
|
||||
: { type: "maintenance_supporter/object/create", ...fields },
|
||||
{ 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-saved"));
|
||||
}
|
||||
|
||||
private _parentChoices(): MaintenanceObjectResponse[] {
|
||||
|
||||
+77
-82
@@ -12,8 +12,13 @@ import { LitElement, html, css, nothing } from "lit";
|
||||
import { isSafeHttpUrl } from "../helpers/url";
|
||||
import { renderNotesMarkdown } from "../helpers/notes-markdown";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, STATUS_COLORS, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { t, langOf } from "../styles";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { statusColor, statusKey, statusLabel } from "../renderers/status";
|
||||
import { fetchSettingsOnce } from "../helpers/settings-cache";
|
||||
import { canWrite, NO_DELEGATION, type WriteAccess } from "../helpers/permissions";
|
||||
import { confirmAction } from "../helpers/confirm";
|
||||
import { focusModalShell, modalShellStyles, renderModalShell } from "../helpers/modal-shell";
|
||||
import type { HomeAssistant, MaintenanceObject, MaintenanceTask } from "../types";
|
||||
|
||||
interface ObjectFull {
|
||||
@@ -30,6 +35,9 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
@state() private _data: ObjectFull | null = null;
|
||||
@state() private _busy = false;
|
||||
@state() private _error = "";
|
||||
/** Operator-write delegation — Add task / Edit / Archive / Delete follow
|
||||
* canWrite() like the panel (DRY audit 2026-09-26). */
|
||||
@state() private _access: WriteAccess = NO_DELEGATION;
|
||||
|
||||
private get _lang(): string {
|
||||
return langOf(this.hass);
|
||||
@@ -39,7 +47,10 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
this._entryId = entryId;
|
||||
this._error = "";
|
||||
this._open = true;
|
||||
await this._load();
|
||||
await Promise.all([
|
||||
this._load(),
|
||||
fetchSettingsOnce(this.hass).then((s) => { this._access = s.access; }),
|
||||
]);
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
@@ -50,15 +61,21 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
if (!this._entryId) return;
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<ObjectFull>({
|
||||
type: "maintenance_supporter/object",
|
||||
entry_id: this._entryId,
|
||||
});
|
||||
this._data = r;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
const r = await runWs<ObjectFull>(
|
||||
this,
|
||||
{ type: "maintenance_supporter/object", entry_id: this._entryId },
|
||||
{ onError: (m) => { this._error = m; } },
|
||||
);
|
||||
if (r !== undefined) this._data = r;
|
||||
}
|
||||
|
||||
/** helpers/ws-run runWs bound to this dialog's busy flag and error line. */
|
||||
private _runWs(payload: Record<string, unknown>): Promise<unknown> {
|
||||
this._error = "";
|
||||
return runWs(this, payload, {
|
||||
busy: (b) => { this._busy = b; },
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
}
|
||||
|
||||
private _onEditObject(): void {
|
||||
@@ -79,58 +96,50 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
|
||||
private async _onDelete(): Promise<void> {
|
||||
if (!this._entryId || !this._data) return;
|
||||
const confirmText = t("delete_object_confirm", this._lang)
|
||||
|| `Delete "${this._data.object.name}" and all its tasks?`;
|
||||
if (!window.confirm(confirmText)) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/object/delete",
|
||||
entry_id: this._entryId,
|
||||
});
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("object-deleted", {
|
||||
detail: { entry_id: this._entryId },
|
||||
bubbles: true, composed: true,
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
const ok = await confirmAction(this.hass, {
|
||||
title: t("delete", this._lang),
|
||||
message: t("delete_object_confirm", this._lang),
|
||||
confirmText: t("delete", this._lang),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
const entryId = this._entryId;
|
||||
if ((await this._runWs({ type: "maintenance_supporter/object/delete", entry_id: entryId })) === undefined) return;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("object-deleted", {
|
||||
detail: { entry_id: entryId },
|
||||
bubbles: true, composed: true,
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
}
|
||||
|
||||
private async _onArchiveObject(): Promise<void> {
|
||||
if (!this._entryId || !this._data) return;
|
||||
const archived = !!this._data.object.archived;
|
||||
if (!archived) {
|
||||
const confirmText = t("confirm_archive_object", this._lang);
|
||||
if (!window.confirm(confirmText)) return;
|
||||
}
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: archived
|
||||
? "maintenance_supporter/object/unarchive"
|
||||
: "maintenance_supporter/object/archive",
|
||||
entry_id: this._entryId,
|
||||
const ok = await confirmAction(this.hass, {
|
||||
title: t("archive_object", this._lang),
|
||||
message: t("confirm_archive_object", this._lang),
|
||||
confirmText: t("archive_object", this._lang),
|
||||
});
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("object-changed", {
|
||||
detail: { entry_id: this._entryId },
|
||||
bubbles: true, composed: true,
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
if (!ok) return;
|
||||
}
|
||||
const entryId = this._entryId;
|
||||
const res = await this._runWs({
|
||||
type: archived
|
||||
? "maintenance_supporter/object/unarchive"
|
||||
: "maintenance_supporter/object/archive",
|
||||
entry_id: entryId,
|
||||
});
|
||||
if (res === undefined) return;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("object-changed", {
|
||||
detail: { entry_id: entryId },
|
||||
bubbles: true, composed: true,
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
}
|
||||
|
||||
private _onTaskClick(taskId: string): void {
|
||||
@@ -148,11 +157,9 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
const data = this._data;
|
||||
const obj = data?.object;
|
||||
const tasks = data?.tasks || [];
|
||||
const isAdmin = (this.hass?.user?.is_admin ?? true) as boolean;
|
||||
const writer = canWrite(this.hass?.user, this._access);
|
||||
|
||||
return html`
|
||||
<div class="backdrop" @click=${this.close}></div>
|
||||
<div class="dialog" role="dialog" aria-modal="true">
|
||||
return renderModalShell(() => this.close(), html`
|
||||
${data && obj
|
||||
? html`
|
||||
<div class="header">
|
||||
@@ -173,9 +180,9 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
<div class="task-list">
|
||||
${tasks.map((task) => html`
|
||||
<div class="task-row" @click=${() => this._onTaskClick(task.id)}>
|
||||
<span class="status-dot" style="background: ${STATUS_COLORS[task.status] || "#ccc"}"></span>
|
||||
<span class="status-dot" style="background: ${statusColor(task)}"></span>
|
||||
<span class="task-name">${task.name}</span>
|
||||
<span class="task-status">${t(task.status || "ok", L)}</span>
|
||||
<span class="task-status ${statusKey(task)}">${statusLabel(statusKey(task), L)}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
@@ -191,7 +198,7 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
`
|
||||
: nothing}
|
||||
|
||||
${isAdmin
|
||||
${writer
|
||||
? html`
|
||||
<div class="actions">
|
||||
<button class="btn primary" @click=${this._onAddTask} ?disabled=${this._busy}>
|
||||
@@ -215,8 +222,11 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
: nothing}
|
||||
`
|
||||
: html`<div class="loading">${t("loading", L)}</div>`}
|
||||
</div>
|
||||
`;
|
||||
`);
|
||||
}
|
||||
|
||||
protected updated(changed: Map<string, unknown>): void {
|
||||
if (changed.has("_open") && this._open) focusModalShell(this.shadowRoot);
|
||||
}
|
||||
|
||||
private _renderMetaRow(obj: MaintenanceObject) {
|
||||
@@ -251,23 +261,8 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
static styles = [modalShellStyles, css`
|
||||
:host { display: contents; }
|
||||
.backdrop {
|
||||
position: fixed; inset: 0; z-index: 100; background: rgba(0,0,0,0.5);
|
||||
}
|
||||
.dialog {
|
||||
position: fixed; left: 50%; top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 95vw; max-width: 480px;
|
||||
max-height: 92vh; overflow: auto;
|
||||
background: var(--card-background-color, var(--ha-card-background, #1c1c1c));
|
||||
color: var(--primary-text-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
|
||||
padding: 20px; z-index: 101;
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
}
|
||||
.header { display: flex; flex-direction: column; gap: 6px; }
|
||||
.title { font-size: 20px; font-weight: 600; }
|
||||
.meta { display: flex; flex-direction: column; gap: 4px; padding-top: 4px; border-top: 1px solid var(--divider-color); }
|
||||
@@ -311,7 +306,7 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
.btn ha-icon { --mdc-icon-size: 16px; }
|
||||
.loading { padding: 24px; text-align: center; color: var(--secondary-text-color); }
|
||||
.error { padding: 8px; border-radius: 6px; background: rgba(211,47,47,0.1); color: var(--error-color); font-size: 13px; }
|
||||
`;
|
||||
`];
|
||||
}
|
||||
|
||||
if (!customElements.get("maintenance-object-quick-actions-dialog")) {
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { isSafeHttpUrl } from "../helpers/url";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale, langOf, formatCost } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { t, ensureLocale, langOf, formatCost, formatNumber, formatQty } from "../styles";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import type { HomeAssistant, MaintenancePart } from "../types";
|
||||
// Per-part document links (v2.26) — the task-documents component in part mode.
|
||||
import "./task-documents";
|
||||
@@ -83,17 +83,14 @@ export class MaintenancePartsSection extends LitElement {
|
||||
this.dispatchEvent(new CustomEvent("parts-changed", { bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
private async _send<T>(msg: Record<string, unknown>): Promise<T | null> {
|
||||
this._busy = true;
|
||||
/** helpers/ws-run runWs bound to the busy flag and the error line;
|
||||
* undefined = refused (the reason is on the error line). */
|
||||
private _send<T>(msg: Record<string, unknown>): Promise<T | null | undefined> {
|
||||
this._error = "";
|
||||
try {
|
||||
return await this.hass.connection.sendMessagePromise<T>(msg);
|
||||
} catch (err) {
|
||||
this._error = describeWsError(err, this._lang);
|
||||
return null;
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
return runWs<T>(this, msg, {
|
||||
busy: (b) => { this._busy = b; },
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
}
|
||||
|
||||
private _openAdd(): void {
|
||||
@@ -141,11 +138,11 @@ export class MaintenancePartsSection extends LitElement {
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
const f = this._editing;
|
||||
if (!f || !f.name.trim()) return;
|
||||
if (this._busy || !f || !f.name.trim()) return;
|
||||
const payload = this._formValue(f);
|
||||
const type = f.id ? "maintenance_supporter/part/update" : "maintenance_supporter/part/create";
|
||||
const result = await this._send<{ part_id?: string }>(f.id ? { type, part_id: f.id, ...payload } : { type, ...payload });
|
||||
if (result !== null) {
|
||||
if (result !== undefined) {
|
||||
this._editing = null;
|
||||
this._notifyChanged();
|
||||
}
|
||||
@@ -160,10 +157,14 @@ export class MaintenancePartsSection extends LitElement {
|
||||
entry_id: this.entryId,
|
||||
part_id: part.id,
|
||||
});
|
||||
if (result !== null) this._notifyChanged();
|
||||
if (result !== undefined) this._notifyChanged();
|
||||
}
|
||||
|
||||
private async _restock(part: MaintenancePart): Promise<void> {
|
||||
// Enter + a click on ✓ (or a double Enter) fired two restocks — the
|
||||
// stock went up twice (bug audit 2026-09-26). _send sets _busy before
|
||||
// its first await, so the second call lands here and stops.
|
||||
if (this._busy) return;
|
||||
const qty = parseFloat(this._restockQty);
|
||||
if (!Number.isFinite(qty) || qty === 0) {
|
||||
// Don't silently swallow a no-op amount — keep the input open and mark
|
||||
@@ -179,9 +180,9 @@ export class MaintenancePartsSection extends LitElement {
|
||||
delta: qty,
|
||||
});
|
||||
this._restockFor = null;
|
||||
if (result !== null) {
|
||||
if (result !== undefined) {
|
||||
// Instant local feedback; the panel refresh follows via parts-changed.
|
||||
part.stock = result.stock;
|
||||
part.stock = result?.stock;
|
||||
this.requestUpdate();
|
||||
this._notifyChanged();
|
||||
}
|
||||
@@ -208,8 +209,8 @@ export class MaintenancePartsSection extends LitElement {
|
||||
: part.name}
|
||||
${tracked
|
||||
? html`<span class="stock-badge ${part.is_low ? "low" : ""}"
|
||||
>${part.stock}${part.unit ? ` ${part.unit}` : ""}${part.reorder_threshold != null
|
||||
? html`<span class="threshold">/${part.reorder_threshold}</span>`
|
||||
>${formatQty(part.stock!, part.unit, L)}${part.reorder_threshold != null
|
||||
? html`<span class="threshold">/${formatNumber(part.reorder_threshold, L)}</span>`
|
||||
: nothing}</span
|
||||
>`
|
||||
: nothing}
|
||||
@@ -242,7 +243,7 @@ export class MaintenancePartsSection extends LitElement {
|
||||
if (e.key === "Escape") this._restockFor = null;
|
||||
}}
|
||||
/>
|
||||
<ha-icon-button title=${t("save", L)} @click=${() => this._restock(part)}
|
||||
<ha-icon-button title=${t("save", L)} .disabled=${this._busy} @click=${() => this._restock(part)}
|
||||
><ha-icon icon="mdi:check"></ha-icon
|
||||
></ha-icon-button>
|
||||
`
|
||||
|
||||
+17
-29
@@ -13,7 +13,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import type { HomeAssistant, SavedView, SavedViewFilters } from "../types";
|
||||
|
||||
interface ViewsResponse {
|
||||
@@ -65,40 +65,28 @@ export class MaintenanceSavedViewsDialog extends LitElement {
|
||||
private _save = async (): Promise<void> => {
|
||||
const name = this._name.trim();
|
||||
if (!name || this._busy || !this._filters) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const res = await this.hass.connection.sendMessagePromise<ViewsResponse>({
|
||||
type: "maintenance_supporter/views/save",
|
||||
name,
|
||||
filters: this._filters,
|
||||
});
|
||||
this._name = "";
|
||||
this._emitChanged(res.views || []);
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
const res = await this._runWs({ type: "maintenance_supporter/views/save", name, filters: this._filters });
|
||||
if (res === undefined) return;
|
||||
this._name = "";
|
||||
this._emitChanged(res?.views || []);
|
||||
};
|
||||
|
||||
private _delete = async (viewId: string): Promise<void> => {
|
||||
if (this._busy) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const res = await this.hass.connection.sendMessagePromise<ViewsResponse>({
|
||||
type: "maintenance_supporter/views/delete",
|
||||
view_id: viewId,
|
||||
});
|
||||
this._emitChanged(res.views || []);
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
const res = await this._runWs({ type: "maintenance_supporter/views/delete", view_id: viewId });
|
||||
if (res === undefined) return;
|
||||
this._emitChanged(res?.views || []);
|
||||
};
|
||||
|
||||
/** helpers/ws-run runWs bound to the busy flag and the error line. */
|
||||
private _runWs(payload: Record<string, unknown>): Promise<ViewsResponse | null | undefined> {
|
||||
this._error = "";
|
||||
return runWs<ViewsResponse>(this, payload, {
|
||||
busy: (b) => { this._busy = b; },
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._open) return html``;
|
||||
const L = this._lang;
|
||||
|
||||
+27
-36
@@ -4,7 +4,8 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
|
||||
import { t, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { SEASONAL_FACTOR_RANGE } from "../helpers/setting-ranges";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
const MONTH_KEYS = [
|
||||
@@ -56,7 +57,7 @@ export class SeasonalOverridesDialog extends LitElement {
|
||||
this._error = `${t("month_" + ["jan","feb","mar","apr","may","jun","jul","aug","sep","oct","nov","dec"][i], this._lang)}: ${t("seasonal_override_invalid", this._lang)}`;
|
||||
return null;
|
||||
}
|
||||
if (num < 0.1 || num > 5.0) {
|
||||
if (num < SEASONAL_FACTOR_RANGE[0] || num > SEASONAL_FACTOR_RANGE[1]) {
|
||||
this._error = t("seasonal_override_range", this._lang);
|
||||
return null;
|
||||
}
|
||||
@@ -68,42 +69,32 @@ export class SeasonalOverridesDialog extends LitElement {
|
||||
private _save = async (): Promise<void> => {
|
||||
const overrides = this._buildOverrides();
|
||||
if (overrides === null) return;
|
||||
this._loading = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/task/seasonal_overrides",
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
overrides,
|
||||
});
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("overrides-saved"));
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
if ((await this._send(overrides)) === undefined) return;
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("overrides-saved"));
|
||||
};
|
||||
|
||||
private _clearAll = async (): Promise<void> => {
|
||||
this._loading = true;
|
||||
/** helpers/ws-run runWs: the overrides write with the loading flag and
|
||||
* the error line ("Save failed" for a non-WS error). */
|
||||
private _send(overrides: Record<number, number>): Promise<unknown> {
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/task/seasonal_overrides",
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
overrides: {},
|
||||
});
|
||||
this._values = new Array(12).fill("");
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("overrides-saved"));
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
return runWs(this, {
|
||||
type: "maintenance_supporter/task/seasonal_overrides",
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
overrides,
|
||||
}, {
|
||||
busy: (b) => { this._loading = b; },
|
||||
fallbackKey: "save_error",
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
}
|
||||
|
||||
private _clearAll = async (): Promise<void> => {
|
||||
if ((await this._send({})) === undefined) return;
|
||||
this._values = new Array(12).fill("");
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("overrides-saved"));
|
||||
};
|
||||
|
||||
render() {
|
||||
@@ -118,7 +109,7 @@ export class SeasonalOverridesDialog extends LitElement {
|
||||
${MONTH_KEYS.map((key, i) => html`
|
||||
<label class="month">
|
||||
<span class="mn">${t(key, L)}</span>
|
||||
<input type="number" step="0.1" min="0.1" max="5.0"
|
||||
<input type="number" step="0.1" min=${SEASONAL_FACTOR_RANGE[0]} max=${SEASONAL_FACTOR_RANGE[1]}
|
||||
placeholder="1.0"
|
||||
.value=${this._values[i]}
|
||||
@input=${(e: Event) => {
|
||||
|
||||
@@ -5,17 +5,20 @@ import { property, state } from "lit/decorators.js";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import { live } from "lit/directives/live.js";
|
||||
import type { HomeAssistant, AdvancedFeatures, BudgetStatus, HAUser } from "../types";
|
||||
import { t, langOf, personStyles, syncCurrencyDecimals} from "../styles";
|
||||
import { t, langOf, personStyles, syncCurrencyDecimals, formatNumber } from "../styles";
|
||||
import { signApiPath } from "../helpers/document-url";
|
||||
import { downloadUrl } from "../helpers/download";
|
||||
import { UserService } from "../user-service";
|
||||
import { AVATAR_PALETTE, personOf, renderPersonAvatar } from "../helpers/person";
|
||||
import { OBJECT_COLUMNS, sanitizeColumns } from "../helpers/object-columns";
|
||||
import { downloadTextFile } from "../helpers/download";
|
||||
import { invalidateSettingsCache } from "../helpers/settings-cache";
|
||||
import { SETTING_INT_RANGES, settingIntRange } from "../helpers/setting-ranges";
|
||||
import { invalidateSettingsCache, ROW_ACTION_STYLES } from "../helpers/settings-cache";
|
||||
import { SETTING_INT_RANGES, VACATION_BUFFER_DAYS_RANGE, settingIntRange } from "../helpers/setting-ranges";
|
||||
import { ToastTimer } from "../helpers/toast";
|
||||
import { isoDateLocal } from "../helpers/calendar-bucket";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { DOCS_ARCHIVE_MAX_BYTES, postMultipart } from "../helpers/photo-upload";
|
||||
import { countryName, detectionReasons, dwellingLabel, type HomeProfile } from "../helpers/home-profile";
|
||||
import "./ms-date-field";
|
||||
|
||||
/** One household member and the notify services they actually resolve to.
|
||||
@@ -37,6 +40,9 @@ interface SettingsResponse {
|
||||
objects_table_columns?: string[];
|
||||
/** v2.21: template-gallery curation — hidden template ids. */
|
||||
disabled_template_ids?: string[];
|
||||
/** v2.93: home profile dwelling type — auto | house | apartment. */
|
||||
home_type?: string;
|
||||
home_region?: string;
|
||||
general: {
|
||||
default_warning_days: number;
|
||||
notifications_enabled: boolean;
|
||||
@@ -501,9 +507,18 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _toastTimer = new ToastTimer();
|
||||
|
||||
private _showToast(msg: string): void {
|
||||
this._toast = msg;
|
||||
setTimeout(() => { this._toast = ""; }, 3000);
|
||||
this._toastTimer.schedule(() => { this._toast = ""; });
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
// A pending hide must not fire into a detached view (DRY audit 2026-09-26).
|
||||
this._toastTimer.clear();
|
||||
this._toast = "";
|
||||
}
|
||||
|
||||
/** One WS round-trip; a rejection becomes a toast carrying the server's
|
||||
@@ -595,6 +610,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
${this._renderVacation(L)}
|
||||
${this._renderPrintQr(L)}
|
||||
${this._renderImportExport(L)}
|
||||
${this._renderHomeProfile(L)}
|
||||
${this._renderTemplateToggles(L)}
|
||||
${this._toast ? html`<div class="settings-toast">${this._toast}</div>` : nothing}
|
||||
`;
|
||||
@@ -705,6 +721,8 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
|
||||
@state() private _allTemplates: Array<{ id: string; name: string; category: string; disabled?: boolean }> = [];
|
||||
@state() private _templateCategories: Record<string, Record<string, string>> = {};
|
||||
/** v2.93: served with the templates read (see helpers/home-profile.ts). */
|
||||
@state() private _homeProfile: HomeProfile | null = null;
|
||||
@state() private _tplOpenGroups: Set<string> = new Set();
|
||||
|
||||
// One-shot request guard: keyed on a plain flag, NOT on the result being
|
||||
@@ -719,15 +737,102 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
const res = await this.hass.connection.sendMessagePromise<{
|
||||
templates: Array<{ id: string; name: string; category: string; disabled?: boolean }>;
|
||||
categories: Record<string, Record<string, string>>;
|
||||
profile?: HomeProfile;
|
||||
}>({
|
||||
type: "maintenance_supporter/templates",
|
||||
language: this._lang,
|
||||
});
|
||||
this._allTemplates = res.templates || [];
|
||||
this._templateCategories = res.categories || {};
|
||||
this._homeProfile = res.profile ?? null;
|
||||
} catch { /* section renders empty; retried on next open */ }
|
||||
}
|
||||
|
||||
// --- Section: Home profile (v2.93) ---
|
||||
|
||||
private _renderHomeProfile(L: string) {
|
||||
this._loadTemplates();
|
||||
const p = this._homeProfile;
|
||||
const setting = this._settings!.home_type || "auto";
|
||||
const c = p?.climate;
|
||||
const climate = c && c.koppen
|
||||
? t("home_climate_value", L)
|
||||
.replace("{koppen}", c.koppen)
|
||||
.replace("{cold}", String(c.coldest_c ?? "–"))
|
||||
.replace("{warm}", String(c.warmest_c ?? "–"))
|
||||
: t("home_climate_unknown", L);
|
||||
const country = countryName(p?.country, L);
|
||||
return html`
|
||||
<div class="settings-section" data-section="home_profile">
|
||||
<h3>${t("home_profile_title", L)}</h3>
|
||||
<p class="section-desc">${t("home_profile_hint", L)}</p>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("home_type_label", L)}</span>
|
||||
<select class="home-type" .value=${live(setting)}
|
||||
@change=${(e: Event) => void this._setHomeType((e.target as HTMLSelectElement).value)}>
|
||||
<option value="auto" ?selected=${setting === "auto"}>
|
||||
${t("home_type_auto", L).replace("{detected}", dwellingLabel(p?.dwelling_detected ?? "unknown", L))}
|
||||
</option>
|
||||
<option value="house" ?selected=${setting === "house"}>${t("home_dwelling_house", L)}</option>
|
||||
<option value="apartment" ?selected=${setting === "apartment"}>${t("home_dwelling_apartment", L)}</option>
|
||||
</select>
|
||||
</label>
|
||||
${p && p.dwelling_reasons.length
|
||||
? html`<div class="setting-hint home-detected">
|
||||
${t("home_detected_from", L).replace("{reasons}", detectionReasons(p.dwelling_reasons, L))}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="setting-row home-climate">
|
||||
<span class="setting-label">${t("home_climate_label", L)}</span>
|
||||
<span class="home-climate-value">${country ? `${country} · ` : ""}${climate}</span>
|
||||
</div>
|
||||
${p?.regions?.length ? this._renderHomeRegion(p, L) : nothing}
|
||||
${p?.hemisphere === "south" ? html`<div class="setting-hint">${t("home_hemisphere_south", L)}</div>` : nothing}
|
||||
${p && p.traits.length
|
||||
? html`<div class="home-traits">
|
||||
${p.traits.map((tr) => html`<span class="home-trait">${t(`home_trait_${tr}`, L)}</span>`)}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/** 2.94: the state / province whose rules the templates follow — shown
|
||||
* only where the country has such rules (data/regions). */
|
||||
private _renderHomeRegion(p: HomeProfile, L: string) {
|
||||
const regions = p.regions || [];
|
||||
const setting = this._settings!.home_region || "auto";
|
||||
const value = regions.some((r) => r.code === setting) ? setting : "auto";
|
||||
const detected = p.region_detected_name || t("home_region_none", L);
|
||||
return html`
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("home_region_label", L)}</span>
|
||||
<select class="home-region" .value=${live(value)}
|
||||
@change=${(e: Event) => void this._setHomeRegion((e.target as HTMLSelectElement).value)}>
|
||||
<option value="auto" ?selected=${value === "auto"}>${t("home_type_auto", L).replace("{detected}", detected)}</option>
|
||||
${regions.map((r) => html`<option value=${r.code} ?selected=${value === r.code}>${r.name}</option>`)}
|
||||
</select>
|
||||
</label>
|
||||
<div class="setting-hint">${t("home_region_hint", L)}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _setHomeRegion(value: string): Promise<void> {
|
||||
if (await this._updateSetting("home_region", value)) {
|
||||
// The region changes intervals and which checks exist — refetch.
|
||||
this._templatesRequested = false;
|
||||
await this._loadTemplates();
|
||||
}
|
||||
}
|
||||
|
||||
private async _setHomeType(value: string): Promise<void> {
|
||||
if (await this._updateSetting("home_type", value)) {
|
||||
// The dwelling drives the recommendations — refetch the profile.
|
||||
this._templatesRequested = false;
|
||||
await this._loadTemplates();
|
||||
}
|
||||
}
|
||||
|
||||
private _renderTemplateToggles(L: string) {
|
||||
this._loadTemplates();
|
||||
const hidden = new Set(this._settings!.disabled_template_ids || []);
|
||||
@@ -916,7 +1021,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
<span class="setting-label">${t("settings_row_actions", L)}</span>
|
||||
<select .value=${live(g.row_action_style || "buttons_compact")}
|
||||
@change=${(e: Event) => this._updateSetting("row_action_style", (e.target as HTMLSelectElement).value)}>
|
||||
${(["buttons_compact", "buttons", "icons"] as const).map((v) => html`
|
||||
${ROW_ACTION_STYLES.map((v) => html`
|
||||
<option value=${v} ?selected=${(g.row_action_style || "buttons_compact") === v}>${t(`row_actions_${v}`, L)}</option>`)}
|
||||
</select>
|
||||
</label>
|
||||
@@ -1336,8 +1441,8 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
</div>
|
||||
<label class="vac-field">
|
||||
<span class="filter-label">${t("vacation_buffer", L)}</span>
|
||||
<input type="number" min="0" max="14" .value=${String(this._vacBuffer)}
|
||||
@change=${(e: Event) => this._setVacationBuffer(parseInt((e.target as HTMLInputElement).value, 10) || 0)} />
|
||||
<input type="number" min=${VACATION_BUFFER_DAYS_RANGE[0]} max=${VACATION_BUFFER_DAYS_RANGE[1]} .value=${String(this._vacBuffer)}
|
||||
@change=${(e: Event) => this._setVacationBuffer(e)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -1499,9 +1604,21 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
this._saveVacation(patch);
|
||||
}
|
||||
|
||||
private _setVacationBuffer(value: number): void {
|
||||
if (value < 0 || value > 14) return;
|
||||
this._saveVacation({ buffer_days: value });
|
||||
/** Out of range used to be dropped SILENTLY here while the Lovelace
|
||||
* vacation card sent it and showed the server's error — now both reject
|
||||
* it up front with the same message (DRY audit 2026-09-26). */
|
||||
private _setVacationBuffer(e: Event): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const [min, max] = VACATION_BUFFER_DAYS_RANGE;
|
||||
const v = Number(input.value);
|
||||
if (input.value.trim() !== "" && Number.isInteger(v) && v >= min && v <= max) {
|
||||
this._saveVacation({ buffer_days: v });
|
||||
return;
|
||||
}
|
||||
this._showToast(
|
||||
t("settings_value_out_of_range", this._lang).replace("{min}", String(min)).replace("{max}", String(max)),
|
||||
);
|
||||
input.value = String(this._vacBuffer);
|
||||
}
|
||||
|
||||
private _toggleVacationExempt(taskId: string, on: boolean): void {
|
||||
@@ -2001,24 +2118,23 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name);
|
||||
const resp = await fetch("/api/maintenance_supporter/documents/archive", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${this.hass.auth?.data?.access_token ?? ""}` },
|
||||
body: form,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
} else {
|
||||
const result = (await resp.json()) as { blobs_written: number; documents_created: number };
|
||||
this._showToast(
|
||||
t("settings_docs_import_success", this._lang)
|
||||
.replace("{blobs}", String(result.blobs_written ?? 0))
|
||||
.replace("{docs}", String(result.documents_created ?? 0))
|
||||
);
|
||||
this.dispatchEvent(new CustomEvent("settings-changed"));
|
||||
}
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
const result = await postMultipart<{ blobs_written: number; documents_created: number }>(
|
||||
this.hass, "/api/maintenance_supporter/documents/archive", form, "docs_archive_too_large",
|
||||
);
|
||||
this._showToast(
|
||||
t("settings_docs_import_success", this._lang)
|
||||
.replace("{blobs}", String(result.blobs_written ?? 0))
|
||||
.replace("{docs}", String(result.documents_created ?? 0))
|
||||
);
|
||||
this.dispatchEvent(new CustomEvent("settings-changed"));
|
||||
} catch (e) {
|
||||
// A 413 names the limit; anything else stays the generic message.
|
||||
this._showToast(
|
||||
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),
|
||||
);
|
||||
}
|
||||
input.value = "";
|
||||
this._docArchiveLoading = false;
|
||||
@@ -2061,6 +2177,12 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
.home-climate-value { font-size: 14px; color: var(--secondary-text-color); text-align: right; }
|
||||
.home-traits { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.home-trait {
|
||||
font-size: 12px; padding: 3px 8px; border-radius: 12px;
|
||||
background: var(--secondary-background-color); color: var(--primary-text-color);
|
||||
}
|
||||
.section-desc {
|
||||
font-size: 13px;
|
||||
color: var(--secondary-text-color);
|
||||
@@ -2173,7 +2295,12 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
background: var(--card-background-color, #fff);
|
||||
color: var(--primary-text-color);
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
/* May shrink: a select is as wide as its longest option, and German
|
||||
"Buttons (auf dem Handy nur Symbole)" pushed the row 75 px past a
|
||||
360 px screen (overflow sweep 2026-09-27). */
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
|
||||
+33
-29
@@ -10,7 +10,7 @@ import { LitElement, html, css, nothing } from "lit";
|
||||
import { docDisplayName } from "../helpers/document-categories";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { openSignedDocument } from "../helpers/document-url";
|
||||
import { isSafeHttpUrl } from "../helpers/url";
|
||||
import { formatBytes } from "../helpers/format-bytes";
|
||||
@@ -58,6 +58,9 @@ export class MaintenanceStorageSectionCard extends LitElement {
|
||||
|
||||
private _initiallyLoaded = false;
|
||||
private _searchTimer = 0;
|
||||
/** Only the newest query's answer may land: a slow reply for "fil" used to
|
||||
* overwrite the one for "filter" (bug audit 2026-09-26). */
|
||||
private _searchSeq = 0;
|
||||
|
||||
private get _lang(): string {
|
||||
return langOf(this.hass);
|
||||
@@ -75,18 +78,14 @@ export class MaintenanceStorageSectionCard extends LitElement {
|
||||
}
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
this._busy = true;
|
||||
try {
|
||||
this._summary = await this.hass.connection.sendMessagePromise<StorageSummary>({
|
||||
type: "maintenance_supporter/documents/storage",
|
||||
});
|
||||
this._error = "";
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._loaded = true;
|
||||
this._busy = false;
|
||||
}
|
||||
const r = await runWs<StorageSummary>(this, { type: "maintenance_supporter/documents/storage" }, {
|
||||
busy: (b) => { this._busy = b; },
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
this._loaded = true;
|
||||
if (r === undefined) return;
|
||||
this._summary = r;
|
||||
this._error = "";
|
||||
}
|
||||
|
||||
private _nameFor(objectId: string): string {
|
||||
@@ -121,20 +120,24 @@ export class MaintenanceStorageSectionCard extends LitElement {
|
||||
|
||||
private async _doSearch(): Promise<void> {
|
||||
const q = this._query.trim();
|
||||
const seq = ++this._searchSeq;
|
||||
if (!q) {
|
||||
this._results = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<{ results: SearchResult[] }>({
|
||||
type: "maintenance_supporter/documents/search",
|
||||
query: q,
|
||||
});
|
||||
this._results = r.results || [];
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
let failure = "";
|
||||
const r = await runWs<{ results: SearchResult[] }>(
|
||||
this,
|
||||
{ type: "maintenance_supporter/documents/search", query: q },
|
||||
{ onError: (m) => { failure = m; } },
|
||||
);
|
||||
if (seq !== this._searchSeq) return;
|
||||
if (r === undefined) {
|
||||
this._error = failure;
|
||||
this._results = [];
|
||||
return;
|
||||
}
|
||||
this._results = r?.results || [];
|
||||
}
|
||||
|
||||
private async _openResult(doc: SearchResult): Promise<void> {
|
||||
@@ -145,11 +148,7 @@ export class MaintenanceStorageSectionCard extends LitElement {
|
||||
if (isSafeHttpUrl(doc.url)) window.open(doc.url, "_blank", "noopener");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openSignedDocument(this.hass, doc.id);
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
await runWs(this, () => openSignedDocument(this.hass, doc.id), { onError: (m) => { this._error = m; } });
|
||||
}
|
||||
|
||||
private _renderResult(doc: SearchResult, L: string) {
|
||||
@@ -320,16 +319,21 @@ export class MaintenanceStorageSectionCard extends LitElement {
|
||||
.result-open { color: var(--secondary-text-color, #888); --mdc-icon-size: 18px; flex: none; }
|
||||
.search-empty { color: var(--secondary-text-color, #888); font-size: 13px; padding: 8px 2px; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
/* Phone width (2026-09-27): the size moves to a line of its own before
|
||||
it could slide under the refresh button; only the title shortens. */
|
||||
.toggle {
|
||||
display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0;
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 2px 8px; flex: 1; min-width: 0;
|
||||
background: none; border: none; padding: 4px 0; margin: 0; cursor: pointer;
|
||||
font: inherit; color: var(--primary-text-color); text-align: left;
|
||||
}
|
||||
.toggle:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; border-radius: 6px; }
|
||||
.chevron { --mdc-icon-size: 22px; color: var(--secondary-text-color, #888); flex: none; }
|
||||
.title-text { font-size: 16px; font-weight: 500; }
|
||||
.title-text {
|
||||
font-size: 16px; font-weight: 500; min-width: 0;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.header-summary {
|
||||
margin-left: auto; display: flex; align-items: center; gap: 8px;
|
||||
margin-left: auto; display: flex; align-items: center; gap: 8px; flex: none;
|
||||
font-size: 14px; font-weight: 600; white-space: nowrap;
|
||||
}
|
||||
.header-summary .saved { color: var(--success-color, #4caf50); font-weight: 500; }
|
||||
|
||||
+208
-135
@@ -6,13 +6,21 @@
|
||||
* every task arrives with its sensor threshold trigger PRE-WIRED (below N
|
||||
* hours left / below N % remaining, auto-resolving on replacement). The wiring
|
||||
* comes from the server-side source-verified catalog — never from this client.
|
||||
*
|
||||
* 2.94 (duplicates in a real home: the washer and dryer adopted as NEW objects
|
||||
* next to the user's own, a second wallbox, lubrication tasks under other
|
||||
* names): the target defaults to the existing object the device most likely
|
||||
* is, every task has its own tick box, a task the target probably already has
|
||||
* under another name starts unticked with that name, tasks it has by name are
|
||||
* listed as already there, and a before/after line says what adopting does.
|
||||
* Picking another target asks the server again (integration_setups/preview).
|
||||
*/
|
||||
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
interface SetupTask {
|
||||
@@ -21,6 +29,7 @@ interface SetupTask {
|
||||
entity_ids: string[];
|
||||
threshold: number;
|
||||
direction: string;
|
||||
covered_by?: { task_id: string; name: string; reason: string } | null;
|
||||
}
|
||||
|
||||
interface SuggestedSetup {
|
||||
@@ -31,7 +40,11 @@ interface SuggestedSetup {
|
||||
integration_name: string;
|
||||
suggested_entry_id: string | null;
|
||||
suggested_object_name: string;
|
||||
candidate?: { entry_id: string; name: string; reasons: string[] } | null;
|
||||
target_entry_id?: string | null;
|
||||
target_task_count?: number;
|
||||
tasks: SetupTask[];
|
||||
already?: Array<{ task_name: string; task_name_localized: string; existing_name: string }>;
|
||||
}
|
||||
|
||||
interface AdoptResponse {
|
||||
@@ -41,6 +54,9 @@ interface AdoptResponse {
|
||||
errors?: unknown[];
|
||||
}
|
||||
|
||||
/** The target select's value for "a new object for this device". */
|
||||
const NEW = "__new__";
|
||||
|
||||
export class MaintenanceSuggestedSetupsDialog extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@@ -53,9 +69,10 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
|
||||
// #102: optional counting start values, keyed "deviceId taskName".
|
||||
// Only usage_delta duties render the input; raw strings until adopt.
|
||||
@state() private _baselines: Map<string, string> = new Map();
|
||||
// #105: adopt target per device — entry_id of an existing object, or ""
|
||||
// for the default (the suggested bound object, else a new object).
|
||||
// Adopt target per device: an object's entry_id, or NEW.
|
||||
@state() private _targets: Map<string, string> = new Map();
|
||||
// Ticked tasks per device (task_name keys).
|
||||
@state() private _tasks: Map<string, Set<string>> = new Map();
|
||||
@state() private _objects: Array<{ entry_id: string; name: string }> = [];
|
||||
|
||||
private _localeReady = false;
|
||||
@@ -71,35 +88,41 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** Ticked by default: everything the target does not probably have yet. */
|
||||
private _defaultTicks(setup: SuggestedSetup): Set<string> {
|
||||
return new Set(setup.tasks.filter((task) => !task.covered_by).map((task) => task.task_name));
|
||||
}
|
||||
|
||||
public async open(): Promise<void> {
|
||||
this._open = true;
|
||||
this._loading = true;
|
||||
this._error = "";
|
||||
this._setups = [];
|
||||
this._selected = new Set();
|
||||
try {
|
||||
const resp = await this.hass.connection.sendMessagePromise<{ setups: SuggestedSetup[] }>({
|
||||
type: "maintenance_supporter/integration_setups/discover",
|
||||
});
|
||||
this._setups = resp.setups || [];
|
||||
const resp = await runWs<{ setups: SuggestedSetup[] }>(
|
||||
this,
|
||||
{ type: "maintenance_supporter/integration_setups/discover" },
|
||||
{ onError: (m) => { this._error = m; } },
|
||||
);
|
||||
if (resp !== undefined) {
|
||||
this._setups = resp?.setups || [];
|
||||
this._selected = new Set(this._setups.map((s) => s.device_id));
|
||||
this._baselines = new Map();
|
||||
this._targets = new Map();
|
||||
this._targets = new Map(this._setups.map((s) => [s.device_id, s.target_entry_id || NEW]));
|
||||
this._tasks = new Map(this._setups.map((s) => [s.device_id, this._defaultTicks(s)]));
|
||||
try {
|
||||
const objs = await this.hass.connection.sendMessagePromise<{
|
||||
objects: Array<{ entry_id: string; object: { name: string } }>;
|
||||
objects: Array<{ entry_id: string; object: { name: string; archived_at?: string | null } }>;
|
||||
}>({ type: "maintenance_supporter/objects" });
|
||||
this._objects = (objs.objects || [])
|
||||
.filter((o) => !o.object?.archived_at)
|
||||
.map((o) => ({ entry_id: o.entry_id, name: o.object?.name || o.entry_id }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} catch {
|
||||
this._objects = []; // picker degrades to the default target only
|
||||
}
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
this._loading = false;
|
||||
}
|
||||
|
||||
private _close(): void {
|
||||
@@ -113,43 +136,110 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
|
||||
this._selected = next;
|
||||
};
|
||||
|
||||
private _toggleTask(deviceId: string, taskName: string): void {
|
||||
const ticks = new Set(this._tasks.get(deviceId) ?? []);
|
||||
if (ticks.has(taskName)) ticks.delete(taskName);
|
||||
else ticks.add(taskName);
|
||||
this._tasks = new Map(this._tasks).set(deviceId, ticks);
|
||||
}
|
||||
|
||||
/** Another target: the server judges the proposals against it again. */
|
||||
private async _retarget(setup: SuggestedSetup, value: string): Promise<void> {
|
||||
this._targets = new Map(this._targets).set(setup.device_id, value);
|
||||
const fresh = await runWs<SuggestedSetup>(
|
||||
this,
|
||||
{
|
||||
type: "maintenance_supporter/integration_setups/preview",
|
||||
device_id: setup.device_id,
|
||||
entry_id: value === NEW ? null : value,
|
||||
},
|
||||
{ onError: (m) => { this._error = m; } },
|
||||
);
|
||||
// An empty answer (an older server without preview) keeps the old ticks.
|
||||
if (!fresh || !Array.isArray(fresh.tasks)) return;
|
||||
this._setups = this._setups.map((s) => (s.device_id === setup.device_id ? { ...s, ...fresh } : s));
|
||||
this._tasks = new Map(this._tasks).set(setup.device_id, this._defaultTicks(fresh));
|
||||
}
|
||||
|
||||
private _adopt = async (): Promise<void> => {
|
||||
if (this._selected.size === 0 || this._adopting) return;
|
||||
this._adopting = true;
|
||||
if (this._adopting) return;
|
||||
this._error = "";
|
||||
try {
|
||||
const result = await this.hass.connection.sendMessagePromise<AdoptResponse>({
|
||||
type: "maintenance_supporter/integration_setups/adopt",
|
||||
selections: [...this._selected].map((device_id) => {
|
||||
const sel: { device_id: string; entry_id?: string; baselines?: Record<string, number> } = {
|
||||
device_id,
|
||||
};
|
||||
const target = this._targets.get(device_id);
|
||||
if (target) sel.entry_id = target;
|
||||
const setup = this._setups.find((s) => s.device_id === device_id);
|
||||
for (const task of setup?.tasks ?? []) {
|
||||
const raw = this._baselines.get(`${device_id} ${task.task_name}`);
|
||||
const b = raw ? parseFloat(raw) : NaN;
|
||||
if (!isNaN(b) && b >= 0) (sel.baselines ??= {})[task.task_name] = b;
|
||||
}
|
||||
return sel;
|
||||
}),
|
||||
});
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("integration-setups-adopted", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: result,
|
||||
}),
|
||||
);
|
||||
this._open = false;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._adopting = false;
|
||||
}
|
||||
const selections = [...this._selected]
|
||||
.map((device_id) => {
|
||||
const ticks = [...(this._tasks.get(device_id) ?? [])];
|
||||
const sel: { device_id: string; entry_id?: string; task_names: string[]; baselines?: Record<string, number> } = {
|
||||
device_id,
|
||||
task_names: ticks,
|
||||
};
|
||||
const target = this._targets.get(device_id);
|
||||
if (target && target !== NEW) sel.entry_id = target;
|
||||
for (const name of ticks) {
|
||||
const raw = this._baselines.get(`${device_id} ${name}`);
|
||||
const b = raw ? parseFloat(raw) : NaN;
|
||||
if (!isNaN(b) && b >= 0) (sel.baselines ??= {})[name] = b;
|
||||
}
|
||||
return sel;
|
||||
})
|
||||
.filter((sel) => sel.task_names.length > 0);
|
||||
if (selections.length === 0) return;
|
||||
const result = await runWs<AdoptResponse>(
|
||||
this,
|
||||
{ type: "maintenance_supporter/integration_setups/adopt", selections },
|
||||
{ busy: (b) => { this._adopting = b; }, onError: (m) => { this._error = m; } },
|
||||
);
|
||||
if (result === undefined) return;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("integration-setups-adopted", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: result,
|
||||
}),
|
||||
);
|
||||
this._open = false;
|
||||
};
|
||||
|
||||
private get _anythingTicked(): boolean {
|
||||
return [...this._selected].some((id) => (this._tasks.get(id)?.size ?? 0) > 0);
|
||||
}
|
||||
|
||||
private _targetOptions(s: SuggestedSetup, L: string) {
|
||||
const current = this._targets.get(s.device_id) ?? NEW;
|
||||
const options: Array<{ value: string; label: string }> = [];
|
||||
if (s.suggested_entry_id) {
|
||||
options.push({ value: s.suggested_entry_id, label: s.suggested_object_name });
|
||||
} else {
|
||||
if (s.candidate) {
|
||||
const reasons = s.candidate.reasons.map((r) => t(`setups_reason_${r}`, L)).join(", ");
|
||||
options.push({
|
||||
value: s.candidate.entry_id,
|
||||
label: t("setups_target_match", L).replace("{name}", s.candidate.name).replace("{reasons}", reasons),
|
||||
});
|
||||
}
|
||||
options.push({ value: NEW, label: t("setups_target_new", L).replace("{name}", s.device_name) });
|
||||
}
|
||||
const listed = new Set(options.map((o) => o.value));
|
||||
for (const o of this._objects) if (!listed.has(o.entry_id)) options.push({ value: o.entry_id, label: o.name });
|
||||
return html`
|
||||
<select
|
||||
class="target-select"
|
||||
@click=${(e: Event) => e.stopPropagation()}
|
||||
@change=${(e: Event) => void this._retarget(s, (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
${options.map((o) => html`<option value=${o.value} ?selected=${o.value === current}>${o.label}</option>`)}
|
||||
</select>
|
||||
`;
|
||||
}
|
||||
|
||||
private _beforeAfter(s: SuggestedSetup, L: string): string {
|
||||
const ticked = this._tasks.get(s.device_id)?.size ?? 0;
|
||||
const target = this._targets.get(s.device_id) ?? NEW;
|
||||
if (target === NEW) {
|
||||
return t("setups_new_object_count", L).replace("{name}", s.device_name).replace("{count}", String(ticked));
|
||||
}
|
||||
const before = s.target_task_count ?? 0;
|
||||
return t("setups_before_after", L).replace("{before}", String(before)).replace("{after}", String(before + ticked));
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._open) return html``;
|
||||
const L = this._lang;
|
||||
@@ -170,10 +260,12 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
|
||||
${this._setups.map((s) => {
|
||||
const checked = this._selected.has(s.device_id);
|
||||
const sub = [s.integration_name, s.area_name].filter(Boolean).join(" · ");
|
||||
const ticks = this._tasks.get(s.device_id) ?? new Set<string>();
|
||||
return html`
|
||||
<label class="row">
|
||||
<div class="row ${checked ? "" : "off"}" data-device=${s.device_id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="device-check"
|
||||
.checked=${checked}
|
||||
@change=${() => this._toggle(s.device_id)}
|
||||
/>
|
||||
@@ -182,78 +274,60 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
|
||||
<span class="row-name">${s.device_name}</span>
|
||||
</div>
|
||||
<div class="row-sub">${sub}</div>
|
||||
<div class="row-target" @click=${(e: Event) => e.preventDefault()}>
|
||||
→
|
||||
${checked && this._objects.length > 0
|
||||
? html`
|
||||
<select
|
||||
class="target-select"
|
||||
@change=${(e: Event) => {
|
||||
const next = new Map(this._targets);
|
||||
const v = (e.target as HTMLSelectElement).value;
|
||||
if (v) next.set(s.device_id, v);
|
||||
else next.delete(s.device_id);
|
||||
this._targets = next;
|
||||
}}
|
||||
>
|
||||
<option value="" ?selected=${!this._targets.get(s.device_id)}>
|
||||
${s.suggested_entry_id
|
||||
? s.suggested_object_name
|
||||
: t("setups_target_new", L).replace("{name}", s.suggested_object_name)}
|
||||
</option>
|
||||
${this._objects
|
||||
.filter((o) => o.entry_id !== s.suggested_entry_id)
|
||||
.map(
|
||||
(o) => html`<option
|
||||
value=${o.entry_id}
|
||||
?selected=${this._targets.get(s.device_id) === o.entry_id}
|
||||
>
|
||||
${o.name}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
`
|
||||
: html`${s.suggested_object_name}${s.suggested_entry_id
|
||||
? nothing
|
||||
: html` <span class="new-tag">${t("adopt_problem_new_object", L)}</span>`}`}
|
||||
</div>
|
||||
<div class="row-tasks">
|
||||
${s.tasks.map(
|
||||
(task) => html`<span class="chip" title=${task.entity_ids.join(", ")}>
|
||||
<ha-icon icon="mdi:link-variant"></ha-icon>${task.task_name_localized ||
|
||||
task.task_name}
|
||||
</span>`,
|
||||
)}
|
||||
</div>
|
||||
<div class="row-target">→ ${this._targetOptions(s, L)}</div>
|
||||
${checked
|
||||
? s.tasks
|
||||
.filter((task) => task.direction === "usage_delta")
|
||||
.map((task) => {
|
||||
const key = `${s.device_id} ${task.task_name}`;
|
||||
return html`
|
||||
<div class="baseline-field" @click=${(e: Event) => e.preventDefault()}>
|
||||
<span class="baseline-label"
|
||||
>${task.task_name_localized || task.task_name} —
|
||||
${t("setups_baseline_hint", L)}</span
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
.value=${this._baselines.get(key) ?? ""}
|
||||
@click=${(e: Event) => e.preventDefault()}
|
||||
@input=${(e: Event) => {
|
||||
const next = new Map(this._baselines);
|
||||
next.set(key, (e.target as HTMLInputElement).value);
|
||||
this._baselines = next;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
? html`
|
||||
<div class="task-list">
|
||||
${s.tasks.map(
|
||||
(task) => html`
|
||||
<label class="task" title=${task.entity_ids.join(", ")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="task-check"
|
||||
.checked=${ticks.has(task.task_name)}
|
||||
@change=${() => this._toggleTask(s.device_id, task.task_name)}
|
||||
/>
|
||||
<span class="task-text">
|
||||
<span>${task.task_name_localized || task.task_name}</span>
|
||||
${task.covered_by
|
||||
? html`<span class="maybe">${t("setups_maybe_covered", L).replace("{name}", task.covered_by.name)}</span>`
|
||||
: nothing}
|
||||
</span>
|
||||
</label>
|
||||
${task.direction === "usage_delta" && ticks.has(task.task_name)
|
||||
? html`
|
||||
<div class="baseline-field">
|
||||
<span class="baseline-label">${t("setups_baseline_hint", L)}</span>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
.value=${this._baselines.get(`${s.device_id} ${task.task_name}`) ?? ""}
|
||||
@input=${(e: Event) => {
|
||||
const next = new Map(this._baselines);
|
||||
next.set(`${s.device_id} ${task.task_name}`, (e.target as HTMLInputElement).value);
|
||||
this._baselines = next;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
`,
|
||||
)}
|
||||
${s.already?.length
|
||||
? html`<div class="already">
|
||||
${t("setups_already", L).replace(
|
||||
"{names}",
|
||||
s.already.map((a) => a.existing_name).join(", "),
|
||||
)}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="before-after">${this._beforeAfter(s, L)}</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
@@ -263,10 +337,7 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
|
||||
<ha-button appearance="plain" @click=${this._close}>
|
||||
${t("cancel", L)}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._adopt}
|
||||
.disabled=${this._selected.size === 0 || this._adopting}
|
||||
>
|
||||
<ha-button @click=${this._adopt} .disabled=${!this._anythingTicked || this._adopting}>
|
||||
${t("setups_adopt", L)}
|
||||
</ha-button>
|
||||
</div>
|
||||
@@ -304,33 +375,35 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
|
||||
.hint { color: var(--secondary-text-color); font-size: 13px; }
|
||||
.error { color: var(--error-color, #f44336); font-size: 13px; }
|
||||
.loading, .empty { color: var(--secondary-text-color); font-size: 14px; padding: 12px 0; }
|
||||
.list { display: flex; flex-direction: column; gap: 6px; overflow-y: auto; max-height: 50vh; }
|
||||
.list { display: flex; flex-direction: column; gap: 6px; overflow-y: auto; max-height: 55vh; }
|
||||
.row {
|
||||
display: flex; align-items: flex-start; gap: 10px; padding: 8px;
|
||||
border: 1px solid var(--divider-color); border-radius: 6px; cursor: pointer;
|
||||
border: 1px solid var(--divider-color); border-radius: 6px;
|
||||
}
|
||||
.row.off .row-main { opacity: 0.6; }
|
||||
.row input { margin-top: 2px; cursor: pointer; }
|
||||
.row-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; }
|
||||
.row-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; }
|
||||
.row-name { font-weight: 500; font-size: 13px; }
|
||||
.row-sub, .row-target { color: var(--secondary-text-color); font-size: 12px; }
|
||||
.new-tag { font-style: italic; }
|
||||
.row-tasks { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
font-size: 11px; padding: 2px 8px; border-radius: 10px;
|
||||
background: var(--secondary-background-color, rgba(0, 0, 0, 0.06));
|
||||
color: var(--primary-text-color); white-space: nowrap;
|
||||
}
|
||||
.chip ha-icon { --mdc-icon-size: 12px; color: var(--primary-color); }
|
||||
.row-target { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.target-select {
|
||||
font-size: 12px; padding: 2px 4px; max-width: 100%;
|
||||
font-size: 12px; padding: 2px 4px; min-width: 0; max-width: 100%; flex: 1 1 auto;
|
||||
border: 1px solid var(--divider-color); border-radius: 4px;
|
||||
background: var(--card-background-color, #fff);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.task-list { display: flex; flex-direction: column; gap: 2px; margin-top: 2px; }
|
||||
.task { display: flex; align-items: flex-start; gap: 6px; font-size: 13px; cursor: pointer; }
|
||||
.task-text { display: flex; flex-direction: column; min-width: 0; }
|
||||
.maybe { font-size: 11px; color: var(--warning-color, #ff9800); }
|
||||
.already { font-size: 11px; color: var(--secondary-text-color); margin-top: 2px; }
|
||||
.before-after {
|
||||
font-size: 12px; font-weight: 500; color: var(--primary-color);
|
||||
border-top: 1px dashed var(--divider-color); padding-top: 4px; margin-top: 2px;
|
||||
}
|
||||
.baseline-field {
|
||||
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
|
||||
margin-top: 4px; font-size: 12px; color: var(--secondary-text-color);
|
||||
margin: 0 0 2px 24px; font-size: 12px; color: var(--secondary-text-color);
|
||||
}
|
||||
.baseline-field input {
|
||||
width: 110px; padding: 3px 6px; font-size: 12px;
|
||||
|
||||
@@ -13,8 +13,16 @@ import {
|
||||
TRIGGER_PICKER_DOMAINS,
|
||||
} from "../helpers/trigger-domains";
|
||||
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import {
|
||||
EWA_ALPHA_RANGE,
|
||||
PART_QTY_RANGE,
|
||||
SCHEDULE_OFFSET_MAX_DAYS,
|
||||
TRIGGER_FOR_MINUTES_RANGE,
|
||||
TRIGGER_TARGET_CHANGES_RANGE,
|
||||
WARNING_DAYS_RANGE,
|
||||
} from "../helpers/setting-ranges";
|
||||
import { parseDurationMinutes } from "../helpers/duration";
|
||||
import type { TriggerType } from "../types";
|
||||
import { REQUIRED_COMPLETION_KEYS, REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
|
||||
import "./ms-textfield";
|
||||
@@ -37,6 +45,10 @@ const TRIGGER_TYPE_KEYS = ["threshold", "counter", "state_change", "runtime"];
|
||||
const TRIGGER_TYPE_KEYS_WITH_COMPOUND = [...TRIGGER_TYPE_KEYS, "compound"];
|
||||
// Backend defaults for the adaptive tuning fields (ewa_alpha / min / max days).
|
||||
const ADAPTIVE_DEFAULTS = { alpha: "0.3", min: "7", max: "365" } as const;
|
||||
// Bounds of a consumes_parts quantity (the field's min/max, the same range
|
||||
// the completion's used_parts accept) — validated on save with a message,
|
||||
// never silently replaced. One TS source, pinned to Python.
|
||||
const CONSUMES_QTY_RANGE = PART_QTY_RANGE;
|
||||
|
||||
/** One condition of a compound trigger — a flat trigger the user edits inline.
|
||||
* String-typed like the top-level fields (form inputs); coerced on save. */
|
||||
@@ -80,11 +92,17 @@ const MANAGED_CONDITION_KEYS = new Set([
|
||||
"trigger_runtime_hours", "trigger_on_states",
|
||||
]);
|
||||
|
||||
/** Map a persisted compound condition (storage shape) to an editable draft. */
|
||||
function conditionToDraft(c: TriggerConfig): CompoundConditionDraft {
|
||||
const ids = c.entity_ids || (c.entity_id ? [c.entity_id] : []);
|
||||
/** The per-type trigger fields BOTH editors own — the flat trigger form and
|
||||
* every compound condition. The flat form used to hydrate and serialize
|
||||
* them with its own copy of the rules below, and the copies had drifted:
|
||||
* a compound state_change condition dropped its #136 hold time
|
||||
* (`trigger_for_minutes`, a managed key, so not carried either) on every
|
||||
* save (DRY audit 2026-09-26). */
|
||||
export type TriggerTypeFields = Omit<CompoundConditionDraft, "entityIds" | "carry">;
|
||||
|
||||
/** Storage → form for the shared per-type fields. */
|
||||
export function typeFieldsFromConfig(c: TriggerConfig): TriggerTypeFields {
|
||||
return {
|
||||
entityIds: ids.join(", "),
|
||||
type: c.type || "threshold",
|
||||
attribute: c.attribute || "",
|
||||
above: c.trigger_above?.toString() ?? "",
|
||||
@@ -99,6 +117,42 @@ function conditionToDraft(c: TriggerConfig): CompoundConditionDraft {
|
||||
targetChanges: c.trigger_target_changes?.toString() ?? "",
|
||||
runtimeHours: c.trigger_runtime_hours?.toString() ?? "",
|
||||
onStates: (c.trigger_on_states || []).join(", "),
|
||||
};
|
||||
}
|
||||
|
||||
/** Form → storage: writes the shared per-type fields onto `c` (only the
|
||||
* ones the type uses; unparsable numbers are left out). */
|
||||
export function applyTypeFields(c: TriggerConfig, d: TriggerTypeFields): void {
|
||||
if (d.attribute) c.attribute = d.attribute;
|
||||
const forMinutes = parseInt(d.forMinutes, 10);
|
||||
if (d.type === "threshold") {
|
||||
const a = parseFloat(d.above); if (!isNaN(a)) c.trigger_above = a;
|
||||
const b = parseFloat(d.below); if (!isNaN(b)) c.trigger_below = b;
|
||||
const eq = parseFloat(d.equals); if (!isNaN(eq)) c.trigger_equals = eq;
|
||||
const ne = parseFloat(d.notEquals); if (!isNaN(ne)) c.trigger_not_equals = ne;
|
||||
if (!isNaN(forMinutes)) c.trigger_for_minutes = forMinutes;
|
||||
} else if (d.type === "counter") {
|
||||
const v = parseFloat(d.targetValue); if (!isNaN(v)) c.trigger_target_value = v;
|
||||
c.trigger_delta_mode = d.deltaMode;
|
||||
} else if (d.type === "state_change") {
|
||||
if (d.fromState) c.trigger_from_state = d.fromState;
|
||||
if (d.toState) c.trigger_to_state = d.toState;
|
||||
const n = parseInt(d.targetChanges, 10); if (!isNaN(n)) c.trigger_target_changes = n;
|
||||
// #136: the new state must hold this long before a change counts.
|
||||
if (!isNaN(forMinutes)) c.trigger_for_minutes = forMinutes;
|
||||
} else if (d.type === "runtime") {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a persisted compound condition (storage shape) to an editable draft. */
|
||||
function conditionToDraft(c: TriggerConfig): CompoundConditionDraft {
|
||||
const ids = c.entity_ids || (c.entity_id ? [c.entity_id] : []);
|
||||
return {
|
||||
entityIds: ids.join(", "),
|
||||
...typeFieldsFromConfig(c),
|
||||
carry: Object.fromEntries(
|
||||
Object.entries(c).filter(([k]) => !MANAGED_CONDITION_KEYS.has(k) && !k.startsWith("_")),
|
||||
) as Partial<TriggerConfig>,
|
||||
@@ -111,25 +165,7 @@ function draftToCondition(d: CompoundConditionDraft): TriggerConfig | null {
|
||||
const ids = d.entityIds.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (ids.length === 0) return null;
|
||||
const c: TriggerConfig = { ...(d.carry || {}), entity_id: ids[0], entity_ids: ids, type: d.type };
|
||||
if (d.attribute) c.attribute = d.attribute;
|
||||
if (d.type === "threshold") {
|
||||
const a = parseFloat(d.above); if (!isNaN(a)) c.trigger_above = a;
|
||||
const b = parseFloat(d.below); if (!isNaN(b)) c.trigger_below = b;
|
||||
const eq = parseFloat(d.equals); if (!isNaN(eq)) c.trigger_equals = eq;
|
||||
const ne = parseFloat(d.notEquals); if (!isNaN(ne)) c.trigger_not_equals = ne;
|
||||
const f = parseInt(d.forMinutes, 10); if (!isNaN(f)) c.trigger_for_minutes = f;
|
||||
} else if (d.type === "counter") {
|
||||
const v = parseFloat(d.targetValue); if (!isNaN(v)) c.trigger_target_value = v;
|
||||
c.trigger_delta_mode = d.deltaMode;
|
||||
} else if (d.type === "state_change") {
|
||||
if (d.fromState) c.trigger_from_state = d.fromState;
|
||||
if (d.toState) c.trigger_to_state = d.toState;
|
||||
const n = parseInt(d.targetChanges, 10); if (!isNaN(n)) c.trigger_target_changes = n;
|
||||
} else if (d.type === "runtime") {
|
||||
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;
|
||||
}
|
||||
applyTypeFields(c, d);
|
||||
return c;
|
||||
}
|
||||
|
||||
@@ -277,11 +313,22 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
/** The picked links, keyed by `partLinkKey` — the (entry_id, part_id) pair,
|
||||
* since the same part id can exist on two objects (battery fleet). */
|
||||
@state() private _consumesParts: Record<string, TaskPartLink> = {};
|
||||
/** The TYPED quantity per picked part (same key), parsed on save. The
|
||||
* field wrote back a parsed number on every keystroke — clearing "2" to
|
||||
* type "3" snapped to "1" first, giving "13" (bug audit 2026-09-26 #2;
|
||||
* the complete dialog's `_usedQtyText` got the same fix). */
|
||||
@state() private _consumesQtyText: Record<string, string> = {};
|
||||
@state() private _partsLoadFailed = false;
|
||||
@state() private _availableTags: Array<{id: string; name: string}> = [];
|
||||
|
||||
// User assignment
|
||||
@state() private _responsibleUserId: string | null = null;
|
||||
// What an edit loaded for the two LIVE fields other surfaces change while
|
||||
// the dialog is open — a completion (NFC, voice, another user) moves the
|
||||
// anchor and rotates the assignee. Sent back only when edited here, else
|
||||
// Save rolled that completion back (bug audit 2026-09-26).
|
||||
private _loadedLastPerformed = "";
|
||||
private _loadedResponsibleUserId: string | null = null;
|
||||
@state() private _assigneePool: string[] = [];
|
||||
@state() private _rotationStrategy = "";
|
||||
@state() private _availableUsers: HAUser[] = [];
|
||||
@@ -435,6 +482,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._mirrorTodoEntities = [...(task.mirror_todo_entities || [])];
|
||||
this._enabled = task.enabled !== false;
|
||||
this._lastPerformed = task.last_performed || "";
|
||||
this._loadedLastPerformed = this._lastPerformed;
|
||||
this._nfcTagId = task.nfc_tag_id || "";
|
||||
this._requireTagScan = !!task.require_tag_scan;
|
||||
this._allowSkip = task.allow_skip !== false;
|
||||
@@ -446,7 +494,9 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._consumesParts = Object.fromEntries(
|
||||
(task.consumes_parts || []).map((l) => [partLinkKey(l), { ...l }]),
|
||||
);
|
||||
this._consumesQtyText = {};
|
||||
this._responsibleUserId = task.responsible_user_id || null;
|
||||
this._loadedResponsibleUserId = this._responsibleUserId;
|
||||
this._assigneePool = [...(task.assignee_pool || [])];
|
||||
this._rotationStrategy = task.rotation_strategy || "";
|
||||
|
||||
@@ -524,25 +574,13 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._triggerEntityId = tc.entity_id || (tc.entity_ids && tc.entity_ids[0]) || "";
|
||||
this._triggerEntityIds = tc.entity_ids || (tc.entity_id ? [tc.entity_id] : []);
|
||||
this._triggerEntityLogic = tc.entity_logic || "any";
|
||||
this._triggerAttribute = tc.attribute || "";
|
||||
this._triggerType = tc.type || "threshold";
|
||||
this._triggerAbove = tc.trigger_above?.toString() || "";
|
||||
this._triggerBelow = tc.trigger_below?.toString() || "";
|
||||
this._triggerEquals = tc.trigger_equals?.toString() || "";
|
||||
this._triggerNotEquals = tc.trigger_not_equals?.toString() || "";
|
||||
this._triggerForMinutes = tc.trigger_for_minutes?.toString() || "0";
|
||||
this._setTypeFields(typeFieldsFromConfig(tc));
|
||||
this._triggerCombinator = tc.trigger_combinator === "all" ? "all" : "any";
|
||||
this._triggerTargetValue = tc.trigger_target_value?.toString() || "";
|
||||
this._triggerDeltaMode = tc.trigger_delta_mode || false;
|
||||
// Flat-form-only fields (a compound condition carries them via `carry`).
|
||||
this._triggerBaselineValue = tc.trigger_baseline_value?.toString() || "";
|
||||
this._liveBaselineValue = task.trigger_baseline_value ?? null;
|
||||
this._autoCompleteOnRecovery = tc.auto_complete_on_recovery || false;
|
||||
this._triggerFromState = tc.trigger_from_state || "";
|
||||
this._triggerToState = tc.trigger_to_state || "";
|
||||
this._triggerTargetChanges = tc.trigger_target_changes?.toString() || "";
|
||||
this._triggerRuntimeHours = tc.trigger_runtime_hours?.toString() || "";
|
||||
this._triggerRuntimeMaxSession = tc.trigger_runtime_max_session_seconds?.toString() || "";
|
||||
this._triggerOnStates = (tc.trigger_on_states || []).join(", ");
|
||||
if (tc.type === "compound") {
|
||||
this._compoundLogic = tc.compound_logic === "OR" ? "OR" : "AND";
|
||||
this._compoundConditions = (tc.conditions || []).map(conditionToDraft);
|
||||
@@ -599,6 +637,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._readingUnit = "";
|
||||
this._readings = [];
|
||||
this._consumesParts = {};
|
||||
this._consumesQtyText = {};
|
||||
this._responsibleUserId = null;
|
||||
this._assigneePool = [];
|
||||
this._rotationStrategy = "";
|
||||
@@ -633,32 +672,58 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._resetTriggerFields();
|
||||
}
|
||||
|
||||
/** The flat trigger form's shared per-type fields as one struct — the
|
||||
* same shape a compound condition draft edits (typeFieldsFromConfig /
|
||||
* applyTypeFields are the one rule set for both). */
|
||||
private _typeFields(): TriggerTypeFields {
|
||||
return {
|
||||
type: this._triggerType,
|
||||
attribute: this._triggerAttribute,
|
||||
above: this._triggerAbove,
|
||||
below: this._triggerBelow,
|
||||
equals: this._triggerEquals,
|
||||
notEquals: this._triggerNotEquals,
|
||||
forMinutes: this._triggerForMinutes,
|
||||
targetValue: this._triggerTargetValue,
|
||||
deltaMode: this._triggerDeltaMode,
|
||||
fromState: this._triggerFromState,
|
||||
toState: this._triggerToState,
|
||||
targetChanges: this._triggerTargetChanges,
|
||||
runtimeHours: this._triggerRuntimeHours,
|
||||
onStates: this._triggerOnStates,
|
||||
};
|
||||
}
|
||||
|
||||
private _setTypeFields(f: TriggerTypeFields): void {
|
||||
this._triggerType = f.type;
|
||||
this._triggerAttribute = f.attribute;
|
||||
this._triggerAbove = f.above;
|
||||
this._triggerBelow = f.below;
|
||||
this._triggerEquals = f.equals;
|
||||
this._triggerNotEquals = f.notEquals;
|
||||
this._triggerForMinutes = f.forMinutes;
|
||||
this._triggerTargetValue = f.targetValue;
|
||||
this._triggerDeltaMode = f.deltaMode;
|
||||
this._triggerFromState = f.fromState;
|
||||
this._triggerToState = f.toState;
|
||||
this._triggerTargetChanges = f.targetChanges;
|
||||
this._triggerRuntimeHours = f.runtimeHours;
|
||||
this._triggerOnStates = f.onStates;
|
||||
}
|
||||
|
||||
private _resetTriggerFields(): void {
|
||||
this._triggerEntityId = "";
|
||||
this._triggerEntityIds = [];
|
||||
this._triggerEntityLogic = "any";
|
||||
this._triggerAttribute = "";
|
||||
this._suggestedAttributes = [];
|
||||
this._availableAttributes = [];
|
||||
this._entityDomain = "";
|
||||
this._triggerType = "threshold";
|
||||
this._triggerAbove = "";
|
||||
this._triggerBelow = "";
|
||||
this._triggerEquals = "";
|
||||
this._triggerNotEquals = "";
|
||||
this._triggerForMinutes = "0";
|
||||
this._setTypeFields(emptyCondition());
|
||||
this._triggerCombinator = "any";
|
||||
this._triggerTargetValue = "";
|
||||
this._triggerDeltaMode = false;
|
||||
this._triggerBaselineValue = "";
|
||||
this._liveBaselineValue = null;
|
||||
this._autoCompleteOnRecovery = false;
|
||||
this._triggerFromState = "";
|
||||
this._triggerToState = "";
|
||||
this._triggerTargetChanges = "";
|
||||
this._triggerRuntimeHours = "";
|
||||
this._triggerRuntimeMaxSession = "";
|
||||
this._triggerOnStates = "";
|
||||
this._compoundLogic = "AND";
|
||||
this._compoundConditions = [];
|
||||
}
|
||||
@@ -1060,7 +1125,12 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
@change=${(e: Event) => {
|
||||
const next = { ...this._consumesParts };
|
||||
if ((e.target as HTMLInputElement).checked) next[key] = next[key] || base;
|
||||
else delete next[key];
|
||||
else {
|
||||
delete next[key];
|
||||
const text = { ...this._consumesQtyText };
|
||||
delete text[key];
|
||||
this._consumesQtyText = text;
|
||||
}
|
||||
this._consumesParts = next;
|
||||
}}
|
||||
/>
|
||||
@@ -1070,16 +1140,19 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
? html`<input
|
||||
class="consumes-qty"
|
||||
type="number"
|
||||
min="0.01"
|
||||
max="999"
|
||||
min=${CONSUMES_QTY_RANGE[0]}
|
||||
max=${CONSUMES_QTY_RANGE[1]}
|
||||
step="0.01"
|
||||
.value=${String(link.quantity)}
|
||||
.value=${this._consumesQtyText[key] ?? String(link.quantity)}
|
||||
@input=${(e: Event) => {
|
||||
const v = parseFloat((e.target as HTMLInputElement).value);
|
||||
this._consumesParts = {
|
||||
...this._consumesParts,
|
||||
[key]: { ...base, quantity: Number.isFinite(v) && v >= 0.01 ? v : 1 },
|
||||
};
|
||||
// Keep the text as typed; a valid number also updates the
|
||||
// link, an empty / partial one is judged on save.
|
||||
const raw = (e.target as HTMLInputElement).value;
|
||||
this._consumesQtyText = { ...this._consumesQtyText, [key]: raw };
|
||||
const v = parseFloat(raw.replace(",", "."));
|
||||
if (Number.isFinite(v) && v >= CONSUMES_QTY_RANGE[0]) {
|
||||
this._consumesParts = { ...this._consumesParts, [key]: { ...base, quantity: v } };
|
||||
}
|
||||
}}
|
||||
/>`
|
||||
: nothing}
|
||||
@@ -1087,6 +1160,28 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/** Parse the typed part quantities (see _consumesQtyText) into the links
|
||||
* before a save. Out of range = a message and false (nothing is sent),
|
||||
* never a silent 1. */
|
||||
private _applyConsumesQtyText(): boolean {
|
||||
const next = { ...this._consumesParts };
|
||||
for (const key of Object.keys(next)) {
|
||||
const raw = this._consumesQtyText[key];
|
||||
if (raw === undefined) continue;
|
||||
const qty = parseFloat(raw.replace(",", "."));
|
||||
if (!Number.isFinite(qty) || qty < CONSUMES_QTY_RANGE[0] || qty > CONSUMES_QTY_RANGE[1]) {
|
||||
const fmt = (n: number) => formatNumber(n, this._lang, { maximumFractionDigits: 2 });
|
||||
this._error = t("settings_value_out_of_range", this._lang)
|
||||
.replace("{min}", fmt(CONSUMES_QTY_RANGE[0]))
|
||||
.replace("{max}", fmt(CONSUMES_QTY_RANGE[1]));
|
||||
return false;
|
||||
}
|
||||
next[key] = { ...next[key], quantity: qty };
|
||||
}
|
||||
this._consumesParts = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _toggleRequired(field: string, on: boolean): void {
|
||||
const next = new Set(this._requiredCompletion);
|
||||
if (on) next.add(field);
|
||||
@@ -1179,13 +1274,19 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
<select
|
||||
class="phase-part"
|
||||
.value=${d.partId}
|
||||
@change=${(e: Event) => this._patchPhaseDef(d.id, { partId: (e.target as HTMLSelectElement).value })}
|
||||
@change=${(e: Event) => {
|
||||
const partId = (e.target as HTMLSelectElement).value;
|
||||
this._patchPhaseDef(d.id, d.partQty ? { partId } : { partId, partQty: "1" });
|
||||
}}
|
||||
>
|
||||
<option value="">—</option>
|
||||
${this.parts.map((p) => html`<option value=${p.id} ?selected=${p.id === d.partId}>${p.name}</option>`)}
|
||||
</select>
|
||||
${d.partId ? html`
|
||||
<input class="phase-qty" type="number" min="0.01" step="0.01" .value=${d.partQty || "1"}
|
||||
<!-- The typed text as-is: a "|| '1'" here rewrote a cleared
|
||||
field to 1 and the next digit gave "13" (bug audit
|
||||
2026-09-26 #2); an empty field saves as 1. -->
|
||||
<input class="phase-qty" type="number" min=${PART_QTY_RANGE[0]} step="0.01" placeholder="1" .value=${d.partQty}
|
||||
@input=${(e: Event) => this._patchPhaseDef(d.id, { partQty: (e.target as HTMLInputElement).value })} />
|
||||
` : nothing}
|
||||
` : nothing}
|
||||
@@ -1282,253 +1383,18 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._error = t("calendar_entity_required", this._lang);
|
||||
return;
|
||||
}
|
||||
if (!this._applyConsumesQtyText()) return;
|
||||
this._loading = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const data: Record<string, unknown> = {
|
||||
type: this._taskId
|
||||
? "maintenance_supporter/task/update"
|
||||
: "maintenance_supporter/task/create",
|
||||
entry_id: this._entryId,
|
||||
name: this._name,
|
||||
task_type: this._type,
|
||||
schedule_type: this._scheduleType,
|
||||
// `0` is a legal, meaningful value — "no due-soon window, go straight
|
||||
// from ok to overdue" (backend range is 0–365). The old
|
||||
// `parseInt(...) || 7` treated it as falsy and silently rewrote a
|
||||
// stored 0 to 7 on EVERY save, even when the user never touched the
|
||||
// field. Same class as bug #42, but worse: it needed no user action.
|
||||
// Only a genuinely unparseable field falls back, and to the
|
||||
// configured default rather than a hardcoded 7.
|
||||
warning_days: Number.isNaN(parseInt(this._warningDays, 10))
|
||||
? this.defaultWarningDays
|
||||
: Math.max(0, parseInt(this._warningDays, 10)),
|
||||
};
|
||||
const ecd = this._earliestCompletionDays.trim();
|
||||
data.earliest_completion_days = ecd === "" ? null : Math.max(0, parseInt(ecd, 10) || 0);
|
||||
|
||||
if (this._taskId) data.task_id = this._taskId;
|
||||
|
||||
if (this._scheduleType === "one_time") {
|
||||
data.due_date = this._dueDate || null;
|
||||
data.interval_days = null;
|
||||
} else if (CALENDAR_KINDS.includes(this._scheduleType)) {
|
||||
// Calendar kinds are sent as the nested schedule; the backend prefers
|
||||
// it over the flat fields and clears any stale interval/due_date.
|
||||
data.schedule = { ...this._buildSchedule(), ...this._recurrenceExtras() };
|
||||
data.interval_days = null;
|
||||
if (this._taskId) data.due_date = null;
|
||||
} else {
|
||||
// Switching away from one-time clears the stale due_date on edit.
|
||||
if (this._taskId) data.due_date = null;
|
||||
if (this._scheduleType !== "manual" && this._intervalDays) {
|
||||
data.interval_days = parseInt(this._intervalDays, 10);
|
||||
data.interval_unit = this._intervalUnit;
|
||||
data.interval_anchor = this._intervalAnchor;
|
||||
// Season / finite-series ride a minimal nested schedule alongside the
|
||||
// flat interval fields; the backend carries them onto the interval it
|
||||
// derives from the flat fields (they can't be expressed flatly).
|
||||
// Always send an authoritative nested schedule for interval tasks so
|
||||
// an edit that clears season/ends actually clears them.
|
||||
if (this._scheduleType === "time_based") {
|
||||
data.schedule = { kind: "interval", ...this._recurrenceExtras() };
|
||||
}
|
||||
} else if (this._taskId) {
|
||||
data.interval_days = null;
|
||||
data.interval_anchor = "completion";
|
||||
}
|
||||
}
|
||||
|
||||
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(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
// D#183: always sent — [] clears the mirror targets.
|
||||
data.mirror_todo_entities = this._mirrorTodoEntities.filter(Boolean);
|
||||
data.enabled = this._enabled;
|
||||
data.last_performed = this._lastPerformed || null;
|
||||
data.nfc_tag_id = this._nfcTagId || null;
|
||||
data.require_tag_scan = this._requireTagScan;
|
||||
data.allow_skip = this._allowSkip;
|
||||
data.notify_enabled = this._notifyEnabled;
|
||||
data.reading_unit = this._readingUnit.trim() || null;
|
||||
// #161 phase 2: always sent — [] clears the slots (single-value task).
|
||||
// Sent as hydrated even when the editor is hidden (type != reading), so
|
||||
// a no-op edit never wipes stored slots (the #42/#50 wipe class).
|
||||
data.readings = cleanReadingSlots(this._readings);
|
||||
// Task phases (#139): always sent — null clears a removed cycle.
|
||||
{
|
||||
const defs: Record<string, unknown> = {};
|
||||
for (const d of this._phaseDefs) {
|
||||
if (!d.name.trim()) continue;
|
||||
const def: Record<string, unknown> = { ...d.carry, name: d.name.trim() };
|
||||
const items = d.checklistText.split("\n").map((s) => s.trim()).filter(Boolean);
|
||||
if (items.length) def.checklist = items;
|
||||
const links: TaskPartLink[] = [];
|
||||
if (d.partId) {
|
||||
const qty = parseFloat(d.partQty);
|
||||
links.push({ part_id: d.partId, quantity: Number.isFinite(qty) && qty > 0 ? qty : 1 });
|
||||
}
|
||||
// entry_id travels only when present (a shared pool, #111) — an
|
||||
// own-part link stays byte-identical to what shipped before.
|
||||
for (const l of d.extraParts) {
|
||||
links.push(
|
||||
l.entry_id
|
||||
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
|
||||
: { part_id: l.part_id, quantity: l.quantity },
|
||||
);
|
||||
}
|
||||
if (links.length) def.consumes_parts = links;
|
||||
// Override on → send the list, EMPTY included ("demands nothing");
|
||||
// off → omit the key so the task-level list falls through.
|
||||
if (d.reqOverride) def.required_completion_fields = [...d.reqFields];
|
||||
defs[d.id] = def;
|
||||
}
|
||||
const seq = this._phaseSeq.filter((id) => id in defs);
|
||||
data.phases = Object.keys(defs).length && seq.length ? defs : null;
|
||||
data.phase_sequence = data.phases ? seq : null;
|
||||
}
|
||||
// Only send when a picker was actually rendered. A failed parts load
|
||||
// leaves both lists empty, and sending [] then would silently wipe links
|
||||
// the user never saw. entry_id is written ONLY for a foreign pick, so an
|
||||
// own-parts task saves byte-identically to before (#111).
|
||||
if (this.parts.length || this._foreignOwners.length) {
|
||||
data.consumes_parts = Object.values(this._consumesParts).map((l) =>
|
||||
l.entry_id
|
||||
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
|
||||
: { part_id: l.part_id, quantity: l.quantity },
|
||||
);
|
||||
}
|
||||
data.responsible_user_id = this._responsibleUserId;
|
||||
data.assignee_pool = this._assigneePool;
|
||||
data.required_completion_fields = this._requiredCompletion;
|
||||
data.rotation_strategy =
|
||||
this._assigneePool.length >= 2 && this._rotationStrategy
|
||||
? this._rotationStrategy
|
||||
: null;
|
||||
|
||||
if (this._scheduleType === "sensor_based" && this._triggerType === "compound") {
|
||||
// Compound: a group of conditions joined by AND/OR. Each condition
|
||||
// carries its own entity + type + params; the top-level entity picker
|
||||
// does not apply here.
|
||||
const conditions = this._compoundConditions
|
||||
.map(draftToCondition)
|
||||
.filter((c): c is TriggerConfig => c !== null);
|
||||
if (conditions.length > 0) {
|
||||
const triggerConfig: TriggerConfig = {
|
||||
type: "compound",
|
||||
compound_logic: this._compoundLogic,
|
||||
conditions,
|
||||
};
|
||||
if (this._autoCompleteOnRecovery) triggerConfig.auto_complete_on_recovery = true;
|
||||
if (this._triggerCombinator === "all") triggerConfig.trigger_combinator = "all";
|
||||
data.trigger_config = triggerConfig;
|
||||
} else if (this._taskId) {
|
||||
data.trigger_config = null;
|
||||
}
|
||||
} else if (this._scheduleType === "sensor_based" && this._triggerEntityId) {
|
||||
const entityIds = this._triggerEntityIds.length > 0
|
||||
? this._triggerEntityIds
|
||||
: [this._triggerEntityId];
|
||||
const triggerConfig: TriggerConfig = {
|
||||
entity_id: entityIds[0],
|
||||
entity_ids: entityIds,
|
||||
type: this._triggerType,
|
||||
};
|
||||
if (this._triggerAttribute) triggerConfig.attribute = this._triggerAttribute;
|
||||
if (this._autoCompleteOnRecovery) triggerConfig.auto_complete_on_recovery = true;
|
||||
if (this._triggerCombinator === "all") triggerConfig.trigger_combinator = "all";
|
||||
|
||||
// Multi-entity: store entity_logic for all trigger types
|
||||
if (entityIds.length > 1) {
|
||||
triggerConfig.entity_logic = this._triggerEntityLogic;
|
||||
}
|
||||
|
||||
if (this._triggerType === "threshold") {
|
||||
if (this._triggerAbove) { const v = parseFloat(this._triggerAbove); if (!isNaN(v)) triggerConfig.trigger_above = v; }
|
||||
if (this._triggerBelow) { const v = parseFloat(this._triggerBelow); if (!isNaN(v)) triggerConfig.trigger_below = v; }
|
||||
if (this._triggerEquals) { const v = parseFloat(this._triggerEquals); if (!isNaN(v)) triggerConfig.trigger_equals = v; }
|
||||
if (this._triggerNotEquals) { const v = parseFloat(this._triggerNotEquals); if (!isNaN(v)) triggerConfig.trigger_not_equals = v; }
|
||||
if (this._triggerForMinutes) { const v = parseInt(this._triggerForMinutes, 10); if (!isNaN(v)) triggerConfig.trigger_for_minutes = v; }
|
||||
} else if (this._triggerType === "counter") {
|
||||
if (this._triggerTargetValue) { const v = parseFloat(this._triggerTargetValue); if (!isNaN(v)) triggerConfig.trigger_target_value = v; }
|
||||
triggerConfig.trigger_delta_mode = this._triggerDeltaMode;
|
||||
// #102: optional counting start value ("last service was at X").
|
||||
// Empty = count from the reading at creation / keep the live
|
||||
// baseline; the backend clears stale Store state when it changes.
|
||||
if (this._triggerDeltaMode && this._triggerBaselineValue) {
|
||||
const b = parseFloat(this._triggerBaselineValue);
|
||||
if (!isNaN(b) && b >= 0) triggerConfig.trigger_baseline_value = b;
|
||||
}
|
||||
} else if (this._triggerType === "state_change") {
|
||||
if (this._triggerFromState) triggerConfig.trigger_from_state = this._triggerFromState;
|
||||
if (this._triggerToState) triggerConfig.trigger_to_state = this._triggerToState;
|
||||
if (this._triggerTargetChanges) { const v = parseInt(this._triggerTargetChanges, 10); if (!isNaN(v)) triggerConfig.trigger_target_changes = v; }
|
||||
// #136: the new state must hold this long before a change counts.
|
||||
if (this._triggerForMinutes) { const v = parseInt(this._triggerForMinutes, 10); if (!isNaN(v)) triggerConfig.trigger_for_minutes = v; }
|
||||
} else if (this._triggerType === "runtime") {
|
||||
if (this._triggerRuntimeHours) { const v = parseFloat(this._triggerRuntimeHours); if (!isNaN(v)) triggerConfig.trigger_runtime_hours = v; }
|
||||
// #149: per-session cap (seconds) against sensors stuck ON.
|
||||
if (this._triggerRuntimeMaxSession) { const v = parseInt(this._triggerRuntimeMaxSession, 10); if (!isNaN(v) && v > 0) triggerConfig.trigger_runtime_max_session_seconds = v; }
|
||||
const onStates = this._triggerOnStates.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (onStates.length > 0) triggerConfig.trigger_on_states = onStates;
|
||||
}
|
||||
|
||||
data.trigger_config = triggerConfig;
|
||||
} else if (this._taskId) {
|
||||
data.trigger_config = null;
|
||||
}
|
||||
|
||||
// Schedule time only sent when feature is enabled — null clears.
|
||||
// Every date-driven kind takes one (#168), not only the interval; an
|
||||
// unticked checkbox (or a ticked one with an empty picker) clears it.
|
||||
if (this.scheduleTimeEnabled && SCHEDULE_TIME_KINDS.includes(this._scheduleType)) {
|
||||
const t = this._scheduleTimeOn ? this._scheduleTime.trim() : "";
|
||||
data.schedule_time = /^([01]\d|2[0-3]):[0-5]\d$/.test(t) ? t : null;
|
||||
}
|
||||
|
||||
if (this.checklistsEnabled) {
|
||||
const items = this._checklistText
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 100);
|
||||
data.checklist = items.length ? items : null;
|
||||
}
|
||||
|
||||
// v1.3.0: on_complete_action + quick_complete_defaults (gated)
|
||||
if (this.completionActionsEnabled) {
|
||||
const svc = this._actionService.trim();
|
||||
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 };
|
||||
const dataDict = this._buildActionData();
|
||||
if (Object.keys(dataDict).length > 0) {
|
||||
action.data = dataDict;
|
||||
}
|
||||
data.on_complete_action = action;
|
||||
} else {
|
||||
data.on_complete_action = null;
|
||||
}
|
||||
|
||||
const qcd: Record<string, unknown> = {};
|
||||
if (this._qcNotes.trim()) qcd.notes = this._qcNotes.trim();
|
||||
const cost = parseFloat(this._qcCost);
|
||||
if (!isNaN(cost) && cost >= 0) qcd.cost = cost;
|
||||
const dur = parseInt(this._qcDuration, 10);
|
||||
if (!isNaN(dur) && dur >= 0) qcd.duration = dur;
|
||||
if (this._qcFeedback) qcd.feedback = this._qcFeedback;
|
||||
data.quick_complete_defaults = Object.keys(qcd).length ? qcd : null;
|
||||
}
|
||||
|
||||
const result = await this.hass.connection.sendMessagePromise(data) as { task_id?: string };
|
||||
// helpers/ws-run runWs: a refusal (or a payload that failed to build)
|
||||
// lands on the error line as the server's reason / "Save failed".
|
||||
const result = await runWs<{ task_id?: string }>(
|
||||
this,
|
||||
() => this.hass.connection.sendMessagePromise<{ task_id?: string }>(this._savePayload()),
|
||||
{ fallbackKey: "save_error", onError: (m) => { this._error = m; } },
|
||||
);
|
||||
if (result === undefined) return;
|
||||
const savedTaskId = this._taskId || result?.task_id;
|
||||
|
||||
// Environmental entity lives in adaptive_config (Store-managed),
|
||||
@@ -1573,7 +1439,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
entry_id: this._entryId,
|
||||
task_id: savedTaskId,
|
||||
enabled: this._adaptiveEnabled,
|
||||
...(alpha >= 0.1 && alpha <= 0.9 ? { ewa_alpha: alpha } : {}),
|
||||
...(alpha >= EWA_ALPHA_RANGE[0] && alpha <= EWA_ALPHA_RANGE[1] ? { ewa_alpha: alpha } : {}),
|
||||
...(!isNaN(minIv) && minIv >= 1 ? { min_interval_days: minIv } : {}),
|
||||
...(!isNaN(maxIv) && maxIv >= 1 ? { max_interval_days: maxIv } : {}),
|
||||
seasonal_enabled: this._adaptiveSeasonal,
|
||||
@@ -1590,13 +1456,247 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._open = false;
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent("task-saved"));
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The task/create | task/update message from the form (validated above). */
|
||||
private _savePayload(): Record<string, unknown> {
|
||||
const data: Record<string, unknown> = {
|
||||
type: this._taskId
|
||||
? "maintenance_supporter/task/update"
|
||||
: "maintenance_supporter/task/create",
|
||||
entry_id: this._entryId,
|
||||
name: this._name,
|
||||
task_type: this._type,
|
||||
schedule_type: this._scheduleType,
|
||||
// `0` is a legal, meaningful value — "no due-soon window, go straight
|
||||
// from ok to overdue" (backend range is 0–365). The old
|
||||
// `parseInt(...) || 7` treated it as falsy and silently rewrote a
|
||||
// stored 0 to 7 on EVERY save, even when the user never touched the
|
||||
// field. Same class as bug #42, but worse: it needed no user action.
|
||||
// Only a genuinely unparseable field falls back, and to the
|
||||
// configured default rather than a hardcoded 7.
|
||||
warning_days: Number.isNaN(parseInt(this._warningDays, 10))
|
||||
? this.defaultWarningDays
|
||||
: Math.max(0, parseInt(this._warningDays, 10)),
|
||||
};
|
||||
const ecd = this._earliestCompletionDays.trim();
|
||||
data.earliest_completion_days = ecd === "" ? null : Math.max(0, parseInt(ecd, 10) || 0);
|
||||
|
||||
if (this._taskId) data.task_id = this._taskId;
|
||||
|
||||
if (this._scheduleType === "one_time") {
|
||||
data.due_date = this._dueDate || null;
|
||||
data.interval_days = null;
|
||||
} else if (CALENDAR_KINDS.includes(this._scheduleType)) {
|
||||
// Calendar kinds are sent as the nested schedule; the backend prefers
|
||||
// it over the flat fields and clears any stale interval/due_date.
|
||||
data.schedule = { ...this._buildSchedule(), ...this._recurrenceExtras() };
|
||||
data.interval_days = null;
|
||||
if (this._taskId) data.due_date = null;
|
||||
} else {
|
||||
// Switching away from one-time clears the stale due_date on edit.
|
||||
if (this._taskId) data.due_date = null;
|
||||
if (this._scheduleType !== "manual" && this._intervalDays) {
|
||||
data.interval_days = parseInt(this._intervalDays, 10);
|
||||
data.interval_unit = this._intervalUnit;
|
||||
data.interval_anchor = this._intervalAnchor;
|
||||
// Season / finite-series ride a minimal nested schedule alongside the
|
||||
// flat interval fields; the backend carries them onto the interval it
|
||||
// derives from the flat fields (they can't be expressed flatly).
|
||||
// Always send an authoritative nested schedule for interval tasks so
|
||||
// an edit that clears season/ends actually clears them.
|
||||
if (this._scheduleType === "time_based") {
|
||||
data.schedule = { kind: "interval", ...this._recurrenceExtras() };
|
||||
}
|
||||
} else if (this._taskId) {
|
||||
data.interval_days = null;
|
||||
data.interval_anchor = "completion";
|
||||
}
|
||||
}
|
||||
|
||||
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(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
// D#183: always sent — [] clears the mirror targets.
|
||||
data.mirror_todo_entities = this._mirrorTodoEntities.filter(Boolean);
|
||||
data.enabled = this._enabled;
|
||||
if (!this._taskId || this._lastPerformed !== this._loadedLastPerformed) {
|
||||
data.last_performed = this._lastPerformed || null;
|
||||
}
|
||||
data.nfc_tag_id = this._nfcTagId || null;
|
||||
data.require_tag_scan = this._requireTagScan;
|
||||
data.allow_skip = this._allowSkip;
|
||||
data.notify_enabled = this._notifyEnabled;
|
||||
data.reading_unit = this._readingUnit.trim() || null;
|
||||
// #161 phase 2: always sent — [] clears the slots (single-value task).
|
||||
// Sent as hydrated even when the editor is hidden (type != reading), so
|
||||
// a no-op edit never wipes stored slots (the #42/#50 wipe class).
|
||||
data.readings = cleanReadingSlots(this._readings);
|
||||
// Task phases (#139): always sent — null clears a removed cycle.
|
||||
{
|
||||
const defs: Record<string, unknown> = {};
|
||||
for (const d of this._phaseDefs) {
|
||||
if (!d.name.trim()) continue;
|
||||
const def: Record<string, unknown> = { ...d.carry, name: d.name.trim() };
|
||||
const items = d.checklistText.split("\n").map((s) => s.trim()).filter(Boolean);
|
||||
if (items.length) def.checklist = items;
|
||||
const links: TaskPartLink[] = [];
|
||||
if (d.partId) {
|
||||
const qty = parseFloat(d.partQty);
|
||||
links.push({ part_id: d.partId, quantity: Number.isFinite(qty) && qty > 0 ? qty : 1 });
|
||||
}
|
||||
// entry_id travels only when present (a shared pool, #111) — an
|
||||
// own-part link stays byte-identical to what shipped before.
|
||||
for (const l of d.extraParts) {
|
||||
links.push(
|
||||
l.entry_id
|
||||
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
|
||||
: { part_id: l.part_id, quantity: l.quantity },
|
||||
);
|
||||
}
|
||||
if (links.length) def.consumes_parts = links;
|
||||
// Override on → send the list, EMPTY included ("demands nothing");
|
||||
// off → omit the key so the task-level list falls through.
|
||||
if (d.reqOverride) def.required_completion_fields = [...d.reqFields];
|
||||
defs[d.id] = def;
|
||||
}
|
||||
const seq = this._phaseSeq.filter((id) => id in defs);
|
||||
data.phases = Object.keys(defs).length && seq.length ? defs : null;
|
||||
data.phase_sequence = data.phases ? seq : null;
|
||||
}
|
||||
// Only send when a picker was actually rendered. A failed parts load
|
||||
// leaves both lists empty, and sending [] then would silently wipe links
|
||||
// the user never saw. entry_id is written ONLY for a foreign pick, so an
|
||||
// own-parts task saves byte-identically to before (#111).
|
||||
if (this.parts.length || this._foreignOwners.length) {
|
||||
data.consumes_parts = Object.values(this._consumesParts).map((l) =>
|
||||
l.entry_id
|
||||
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
|
||||
: { part_id: l.part_id, quantity: l.quantity },
|
||||
);
|
||||
}
|
||||
if (!this._taskId || this._responsibleUserId !== this._loadedResponsibleUserId) {
|
||||
data.responsible_user_id = this._responsibleUserId;
|
||||
}
|
||||
data.assignee_pool = this._assigneePool;
|
||||
data.required_completion_fields = this._requiredCompletion;
|
||||
data.rotation_strategy =
|
||||
this._assigneePool.length >= 2 && this._rotationStrategy
|
||||
? this._rotationStrategy
|
||||
: null;
|
||||
|
||||
if (this._scheduleType === "sensor_based" && this._triggerType === "compound") {
|
||||
// Compound: a group of conditions joined by AND/OR. Each condition
|
||||
// carries its own entity + type + params; the top-level entity picker
|
||||
// does not apply here.
|
||||
const conditions = this._compoundConditions
|
||||
.map(draftToCondition)
|
||||
.filter((c): c is TriggerConfig => c !== null);
|
||||
if (conditions.length > 0) {
|
||||
const triggerConfig: TriggerConfig = {
|
||||
type: "compound",
|
||||
compound_logic: this._compoundLogic,
|
||||
conditions,
|
||||
};
|
||||
if (this._autoCompleteOnRecovery) triggerConfig.auto_complete_on_recovery = true;
|
||||
if (this._triggerCombinator === "all") triggerConfig.trigger_combinator = "all";
|
||||
data.trigger_config = triggerConfig;
|
||||
} else if (this._taskId) {
|
||||
data.trigger_config = null;
|
||||
}
|
||||
} else if (this._scheduleType === "sensor_based" && this._triggerEntityId) {
|
||||
const entityIds = this._triggerEntityIds.length > 0
|
||||
? this._triggerEntityIds
|
||||
: [this._triggerEntityId];
|
||||
const triggerConfig: TriggerConfig = {
|
||||
entity_id: entityIds[0],
|
||||
entity_ids: entityIds,
|
||||
type: this._triggerType,
|
||||
};
|
||||
// The per-type fields: the same rules a compound condition uses.
|
||||
applyTypeFields(triggerConfig, this._typeFields());
|
||||
if (this._autoCompleteOnRecovery) triggerConfig.auto_complete_on_recovery = true;
|
||||
if (this._triggerCombinator === "all") triggerConfig.trigger_combinator = "all";
|
||||
|
||||
// Multi-entity: store entity_logic for all trigger types
|
||||
if (entityIds.length > 1) {
|
||||
triggerConfig.entity_logic = this._triggerEntityLogic;
|
||||
}
|
||||
|
||||
// Flat-form-only fields.
|
||||
if (this._triggerType === "counter" && this._triggerDeltaMode && this._triggerBaselineValue) {
|
||||
// #102: optional counting start value ("last service was at X").
|
||||
// Empty = count from the reading at creation / keep the live
|
||||
// baseline; the backend clears stale Store state when it changes.
|
||||
const b = parseFloat(this._triggerBaselineValue);
|
||||
if (!isNaN(b) && b >= 0) triggerConfig.trigger_baseline_value = b;
|
||||
} else if (this._triggerType === "runtime" && this._triggerRuntimeMaxSession) {
|
||||
// #149: per-session cap (seconds) against sensors stuck ON.
|
||||
const v = parseInt(this._triggerRuntimeMaxSession, 10);
|
||||
if (!isNaN(v) && v > 0) triggerConfig.trigger_runtime_max_session_seconds = v;
|
||||
}
|
||||
|
||||
data.trigger_config = triggerConfig;
|
||||
} else if (this._taskId) {
|
||||
data.trigger_config = null;
|
||||
}
|
||||
|
||||
// Schedule time only sent when feature is enabled — null clears.
|
||||
// Every date-driven kind takes one (#168), not only the interval; an
|
||||
// unticked checkbox (or a ticked one with an empty picker) clears it.
|
||||
if (this.scheduleTimeEnabled && SCHEDULE_TIME_KINDS.includes(this._scheduleType)) {
|
||||
const t = this._scheduleTimeOn ? this._scheduleTime.trim() : "";
|
||||
data.schedule_time = /^([01]\d|2[0-3]):[0-5]\d$/.test(t) ? t : null;
|
||||
}
|
||||
|
||||
if (this.checklistsEnabled) {
|
||||
const items = this._checklistText
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 100);
|
||||
data.checklist = items.length ? items : null;
|
||||
}
|
||||
|
||||
// v1.3.0: on_complete_action + quick_complete_defaults (gated)
|
||||
if (this.completionActionsEnabled) {
|
||||
const svc = this._actionService.trim();
|
||||
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 };
|
||||
const dataDict = this._buildActionData();
|
||||
if (Object.keys(dataDict).length > 0) {
|
||||
action.data = dataDict;
|
||||
}
|
||||
data.on_complete_action = action;
|
||||
} else {
|
||||
data.on_complete_action = null;
|
||||
}
|
||||
|
||||
const qcd: Record<string, unknown> = {};
|
||||
if (this._qcNotes.trim()) qcd.notes = this._qcNotes.trim();
|
||||
const cost = parseFloat(this._qcCost);
|
||||
if (!isNaN(cost) && cost >= 0) qcd.cost = cost;
|
||||
const dur = parseDurationMinutes(this._qcDuration);
|
||||
if (dur !== null) qcd.duration = dur;
|
||||
if (this._qcFeedback) qcd.feedback = this._qcFeedback;
|
||||
data.quick_complete_defaults = Object.keys(qcd).length ? qcd : null;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private _close(): void {
|
||||
this._open = false;
|
||||
this._warning = "";
|
||||
@@ -1899,8 +1999,8 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
<ms-textfield
|
||||
label="${t("adaptive_ewa_alpha", L)}"
|
||||
type="number"
|
||||
min="0.1"
|
||||
max="0.9"
|
||||
min=${EWA_ALPHA_RANGE[0]}
|
||||
max=${EWA_ALPHA_RANGE[1]}
|
||||
step="0.1"
|
||||
.value=${this._adaptiveAlpha}
|
||||
@input=${(e: Event) => (this._adaptiveAlpha = (e.target as HTMLInputElement).value)}
|
||||
@@ -2041,6 +2141,8 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
})}
|
||||
<ms-textfield label="${t("target_changes", L)}" type="number" .value=${c.targetChanges}
|
||||
@input=${(e: Event) => this._patchCondition(i, { targetChanges: (e.target as HTMLInputElement).value })}></ms-textfield>
|
||||
<ms-textfield label="${t("for_minutes", L)}" type="number" .value=${c.forMinutes}
|
||||
@input=${(e: Event) => this._patchCondition(i, { forMinutes: (e.target as HTMLInputElement).value })}></ms-textfield>
|
||||
`;
|
||||
}
|
||||
if (c.type === "runtime") {
|
||||
@@ -2237,7 +2339,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
private _buildSchedule(): Record<string, unknown> {
|
||||
const withOffset = (schedule: Record<string, unknown>) => {
|
||||
const off = parseInt(this._calOffset, 10) || 0;
|
||||
if (off) schedule.offset = Math.max(-15, Math.min(off, 15));
|
||||
if (off) schedule.offset = Math.max(-SCHEDULE_OFFSET_MAX_DAYS, Math.min(off, SCHEDULE_OFFSET_MAX_DAYS));
|
||||
return schedule;
|
||||
};
|
||||
if (this._scheduleType === "weekdays") {
|
||||
@@ -2429,8 +2531,8 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
label="${t("recurrence_offset", L)}"
|
||||
helper="${t("recurrence_offset_help", L)}"
|
||||
type="number"
|
||||
min="-15"
|
||||
max="15"
|
||||
min=${-SCHEDULE_OFFSET_MAX_DAYS}
|
||||
max=${SCHEDULE_OFFSET_MAX_DAYS}
|
||||
.value=${this._calOffset}
|
||||
@input=${(e: Event) => (this._calOffset = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>`;
|
||||
@@ -2623,7 +2725,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
<ms-textfield
|
||||
label="${t("target_changes", L)}"
|
||||
type="number"
|
||||
min="1"
|
||||
min=${TRIGGER_TARGET_CHANGES_RANGE[0]}
|
||||
.value=${this._triggerTargetChanges}
|
||||
@input=${(e: Event) => (this._triggerTargetChanges = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
@@ -2634,7 +2736,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
<ms-textfield
|
||||
label="${t("for_at_least_minutes", L)}"
|
||||
type="number"
|
||||
min="0"
|
||||
min=${TRIGGER_FOR_MINUTES_RANGE[0]}
|
||||
.value=${this._triggerForMinutes}
|
||||
@input=${(e: Event) => (this._triggerForMinutes = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
@@ -2687,6 +2789,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
@change=${(e: Event) => {
|
||||
this._entryId = (e.target as HTMLSelectElement).value;
|
||||
this._consumesParts = {};
|
||||
this._consumesQtyText = {};
|
||||
this._loadParts();
|
||||
// The new owner drops out of the shared-pool list and the old
|
||||
// one joins it, so this has to be recomputed too (#111).
|
||||
@@ -2872,8 +2975,8 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
<ms-textfield
|
||||
label="${t("warning_days", L)}"
|
||||
type="number"
|
||||
min="0"
|
||||
max="365"
|
||||
min=${WARNING_DAYS_RANGE[0]}
|
||||
max=${WARNING_DAYS_RANGE[1]}
|
||||
.value=${this._warningDays}
|
||||
@input=${(e: Event) => (this._warningDays = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { downloadUrl } from "../helpers/download";
|
||||
import { downloadSignedDocument, openSignedDocument } from "../helpers/document-url";
|
||||
import { isSafeHttpUrl } from "../helpers/url";
|
||||
import { formatBytes } from "../helpers/format-bytes";
|
||||
import { docDisplayName, CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
|
||||
import { docCategory, docDisplayName, CATEGORY_ICONS } from "../helpers/document-categories";
|
||||
import { DOC_FILTER_MIN, filterDocuments } from "../helpers/document-filter";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
@@ -82,18 +82,31 @@ export class MaintenanceTaskDocuments extends LitElement {
|
||||
}
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<{ documents: Doc[] }>({
|
||||
type: "maintenance_supporter/documents/list",
|
||||
entry_id: this.entryId,
|
||||
});
|
||||
this._docs = r.documents || [];
|
||||
this._loaded = true;
|
||||
this._error = "";
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
this._loaded = true;
|
||||
}
|
||||
const r = await runWs<{ documents: Doc[] }>(
|
||||
this,
|
||||
{ type: "maintenance_supporter/documents/list", entry_id: this.entryId },
|
||||
{ onError: (m) => { this._error = m; } },
|
||||
);
|
||||
this._loaded = true;
|
||||
if (r === undefined) return;
|
||||
this._docs = r?.documents || [];
|
||||
this._error = "";
|
||||
}
|
||||
|
||||
/** A document write — helpers/ws-run runWs with the busy flag, the error
|
||||
* line and the list reload. */
|
||||
private async _update(payload: Record<string, unknown>): Promise<void> {
|
||||
this._error = "";
|
||||
await runWs(this, { type: "maintenance_supporter/documents/update", ...payload }, {
|
||||
busy: (b) => { this._busy = b; },
|
||||
reload: () => this._load(),
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
}
|
||||
|
||||
/** The signed open / download helpers, their failure on the error line. */
|
||||
private async _signed(call: () => Promise<unknown>): Promise<void> {
|
||||
await runWs(this, call, { onError: (m) => { this._error = m; } });
|
||||
}
|
||||
|
||||
private _links(doc: Doc): string[] {
|
||||
@@ -109,20 +122,7 @@ export class MaintenanceTaskDocuments extends LitElement {
|
||||
}
|
||||
|
||||
private async _setLinks(doc: Doc, ids: string[]): Promise<void> {
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/documents/update",
|
||||
doc_id: doc.id,
|
||||
[this._linkField]: ids,
|
||||
});
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
await this._update({ doc_id: doc.id, [this._linkField]: ids });
|
||||
}
|
||||
|
||||
private _link(): void {
|
||||
@@ -158,38 +158,17 @@ export class MaintenanceTaskDocuments extends LitElement {
|
||||
// A per-task page hint jumps straight to the relevant page via the PDF
|
||||
// viewer's #page=N fragment (client-side, so it never breaks the signature).
|
||||
const page = this._pageFor(doc);
|
||||
try {
|
||||
await openSignedDocument(this.hass, doc.id, page ? `#page=${page}` : "");
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
await this._signed(() => openSignedDocument(this.hass, doc.id, page ? `#page=${page}` : ""));
|
||||
}
|
||||
|
||||
/** Set (page >= 1) or clear (0) the jump-to page for this doc's task link. */
|
||||
private async _setPage(doc: Doc, page: number): Promise<void> {
|
||||
if (!this.taskId) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/documents/update",
|
||||
doc_id: doc.id,
|
||||
task_pages: { [this.taskId]: page },
|
||||
});
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
await this._update({ doc_id: doc.id, task_pages: { [this.taskId]: page } });
|
||||
}
|
||||
|
||||
private async _download(doc: Doc): Promise<void> {
|
||||
try {
|
||||
await downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document");
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
await this._signed(() => downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document"));
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -242,7 +221,7 @@ export class MaintenanceTaskDocuments extends LitElement {
|
||||
const isFile = doc.kind === "file";
|
||||
const isPdf = this._isPdf(doc);
|
||||
const page = this._pageFor(doc);
|
||||
const cat = (doc.tags || []).find((x) => (CATEGORIES as readonly string[]).includes(x)) || "other";
|
||||
const cat = docCategory(doc);
|
||||
const meta = isFile ? formatBytes(doc.size, L) : t("doc_link_badge", L);
|
||||
return html`
|
||||
<div class="tdoc-row">
|
||||
|
||||
+163
-157
@@ -14,15 +14,21 @@
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { sharedStyles, t, STATUS_COLORS, formatDate, formatInterval, formatRecurrence, formatCost, formatDuration, currencySymbolOf, langOf, syncCurrencyDecimals} from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { sharedStyles, t, formatDate, formatInterval, formatRecurrence, formatCost, formatDuration, currencySymbolOf, langOf, syncCurrencyDecimals} from "../styles";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
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 { buildHistoryEntryDraft } from "../helpers/history-draft";
|
||||
import { readingSlotDelta } from "../helpers/reading-slots";
|
||||
import { taskRef } from "../helpers/reference";
|
||||
import { renderHistoryEntry } from "../renderers/history";
|
||||
import { newestFirst, renderHistoryEntry } from "../renderers/history";
|
||||
import { renderStatusBadge, statusColor } from "../renderers/status";
|
||||
import { fetchSettingsOnce } from "../helpers/settings-cache";
|
||||
import { canWrite, NO_DELEGATION, type WriteAccess } from "../helpers/permissions";
|
||||
import { confirmAction } from "../helpers/confirm";
|
||||
import { ToastTimer } from "../helpers/toast";
|
||||
import { renderWeibullSection } from "../renderers/weibull";
|
||||
import { renderPredictionSection } from "../renderers/prediction";
|
||||
import { renderRecommendationBars } from "../renderers/recommendation";
|
||||
@@ -69,7 +75,10 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
completion_actions: false,
|
||||
};
|
||||
@state() private _toast = "";
|
||||
private _featuresLoaded = false;
|
||||
private readonly _toastTimer = new ToastTimer();
|
||||
/** Operator-write delegation (helpers/permissions): Edit / Archive /
|
||||
* Delete follow canWrite() like the panel, not `is_admin` alone. */
|
||||
@state() private _access: WriteAccess = NO_DELEGATION;
|
||||
/** Currency symbol for the complete dialog's cost suggestion — read off
|
||||
* the same settings response as the feature flags. */
|
||||
private _currencySymbol = "";
|
||||
@@ -81,6 +90,9 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
/** Open the dialog. Loads fresh data from /object via WS so dialog stays in
|
||||
* sync even if the underlying card has stale data. */
|
||||
public async openFor(entryId: string, taskId: string): Promise<void> {
|
||||
// Reopened for ANOTHER task while open: never show the previous one
|
||||
// under the new ids until the fresh load lands.
|
||||
if (entryId !== this._entryId || taskId !== this._taskId) this._task = null;
|
||||
this._entryId = entryId;
|
||||
this._taskId = taskId;
|
||||
this._error = "";
|
||||
@@ -97,60 +109,70 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
|
||||
/** Pull the active feature flags so adaptive sections only render when
|
||||
* Adaptive / Seasonal / Environmental are actually enabled (matches the
|
||||
* panel's behaviour). Cached after first load. */
|
||||
* panel's behaviour), plus the write delegation and currency. Through
|
||||
* the page-wide settings cache (one fetch per page, invalidated by
|
||||
* global/update) — the private fetch here bypassed it and never
|
||||
* re-read after a settings change (DRY audit 2026-09-26). A failed
|
||||
* fetch serves the all-off fallback and asks again next open. */
|
||||
private async _loadFeatures(): Promise<void> {
|
||||
if (this._featuresLoaded) return;
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<{
|
||||
features?: Partial<AdvancedFeatures>;
|
||||
budget?: { currency_symbol?: string; currency_decimals?: number };
|
||||
}>({ type: "maintenance_supporter/settings" });
|
||||
if (r?.features) {
|
||||
this._features = { ...this._features, ...r.features };
|
||||
}
|
||||
this._currencySymbol = currencySymbolOf(r?.budget);
|
||||
syncCurrencyDecimals(r?.budget);
|
||||
this._featuresLoaded = true;
|
||||
} catch {
|
||||
// Settings endpoint unavailable — leave defaults (all false), the
|
||||
// adaptive panel will then stay hidden.
|
||||
}
|
||||
const settings = await fetchSettingsOnce(this.hass);
|
||||
this._features = { ...this._features, ...settings.features };
|
||||
this._access = settings.access;
|
||||
this._currencySymbol = currencySymbolOf(settings.budget ?? undefined);
|
||||
syncCurrencyDecimals(settings.budget ?? undefined);
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this._loadSeq++; // a load still in flight belongs to the closed dialog
|
||||
this._open = false;
|
||||
this._task = null;
|
||||
this._error = "";
|
||||
this._toastTimer.clear();
|
||||
this._toast = "";
|
||||
}
|
||||
|
||||
private _showToast(msg: string, ms?: number): void {
|
||||
this._toast = msg;
|
||||
this._toastTimer.schedule(() => { this._toast = ""; }, ms);
|
||||
}
|
||||
|
||||
/** Only the newest `_loadTask` may land: the answer for a task the dialog
|
||||
* was opened for BEFORE (or after it closed) looked up the CURRENT task
|
||||
* id in the wrong object, found nothing, and left the dialog stuck on
|
||||
* "Loading" (bug audit 2026-09-26 #2). */
|
||||
private _loadSeq = 0;
|
||||
|
||||
private async _loadTask(): Promise<void> {
|
||||
if (!this._entryId || !this._taskId) return;
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<MaintenanceObjectFull>({
|
||||
type: "maintenance_supporter/object",
|
||||
entry_id: this._entryId,
|
||||
});
|
||||
this._objectName = r.object?.name || "";
|
||||
const found = (r.tasks || []).find((t) => t.id === this._taskId);
|
||||
this._task = found ?? null;
|
||||
this._taskRef = taskRef(r.object, found);
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
const entryId = this._entryId;
|
||||
const taskId = this._taskId;
|
||||
if (!entryId || !taskId) return;
|
||||
const seq = ++this._loadSeq;
|
||||
let failure = "";
|
||||
const r = await runWs<MaintenanceObjectFull>(
|
||||
this,
|
||||
{ type: "maintenance_supporter/object", entry_id: entryId },
|
||||
{ onError: (m) => { failure = m; } },
|
||||
);
|
||||
if (seq !== this._loadSeq) return;
|
||||
if (r === undefined) {
|
||||
this._error = failure;
|
||||
return;
|
||||
}
|
||||
this._objectName = r?.object?.name || "";
|
||||
const found = (r?.tasks || []).find((t) => t.id === taskId);
|
||||
this._task = found ?? null;
|
||||
this._taskRef = taskRef(r?.object, found);
|
||||
// A task deleted meanwhile: say so instead of "Loading" forever.
|
||||
if (!found) this._error = t("ws_err_not_found", this._lang);
|
||||
}
|
||||
|
||||
private async _runWs(payload: Record<string, unknown>): Promise<boolean> {
|
||||
this._busy = true;
|
||||
/** helpers/ws-run runWs bound to this dialog's busy flag and error line. */
|
||||
private _runWs<T = unknown>(payload: Record<string, unknown>): Promise<T | undefined> {
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise(payload);
|
||||
this._busy = false;
|
||||
return true;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
this._busy = false;
|
||||
return false;
|
||||
}
|
||||
return runWs<T>(this, payload, {
|
||||
busy: (b) => { this._busy = b; },
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
}
|
||||
|
||||
private _notifyChanged(action: string): void {
|
||||
@@ -195,8 +217,9 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
task,
|
||||
objects,
|
||||
lang: this._lang,
|
||||
checklist: task.checklist || [],
|
||||
adaptiveEnabled: !!task.adaptive_config?.enabled,
|
||||
// The feature switches gate the checklist and the adaptive
|
||||
// feedback exactly like the panel (DRY audit 2026-09-26).
|
||||
features: this._features,
|
||||
currencySymbol: this._currencySymbol,
|
||||
}),
|
||||
);
|
||||
@@ -215,7 +238,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
task_id: this._taskId,
|
||||
reason: this._skipReason.trim() || null,
|
||||
});
|
||||
if (ok) {
|
||||
if (ok !== undefined) {
|
||||
this._notifyChanged("skip");
|
||||
this.close();
|
||||
}
|
||||
@@ -229,7 +252,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
task_id: this._taskId,
|
||||
date: this._resetDate || undefined,
|
||||
});
|
||||
if (ok) {
|
||||
if (ok !== undefined) {
|
||||
this._notifyChanged("reset");
|
||||
this.close();
|
||||
}
|
||||
@@ -258,15 +281,19 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
|
||||
private async _onDelete(): Promise<void> {
|
||||
if (!this._entryId || !this._taskId) return;
|
||||
const confirmText = t("delete_task_confirm", this._lang)
|
||||
|| `Delete "${this._task?.name}"?`;
|
||||
if (!window.confirm(confirmText)) return;
|
||||
const confirmed = await confirmAction(this.hass, {
|
||||
title: t("delete", this._lang),
|
||||
message: t("delete_task_confirm", this._lang),
|
||||
confirmText: t("delete", this._lang),
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
const ok = await this._runWs({
|
||||
type: "maintenance_supporter/task/delete",
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
});
|
||||
if (ok) {
|
||||
if (ok !== undefined) {
|
||||
this._notifyChanged("delete");
|
||||
this.close();
|
||||
}
|
||||
@@ -279,7 +306,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
});
|
||||
if (ok) {
|
||||
if (ok !== undefined) {
|
||||
this._notifyChanged("archive");
|
||||
this.close();
|
||||
}
|
||||
@@ -292,7 +319,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
});
|
||||
if (ok) {
|
||||
if (ok !== undefined) {
|
||||
this._notifyChanged("unarchive");
|
||||
this.close();
|
||||
}
|
||||
@@ -315,41 +342,32 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
task_id: this._taskId,
|
||||
interval: this._task.suggested_interval,
|
||||
});
|
||||
if (ok) {
|
||||
this._toast = t("suggestion_applied", this._lang);
|
||||
if (ok !== undefined) {
|
||||
this._showToast(t("suggestion_applied", this._lang));
|
||||
this._notifyChanged("apply_suggestion");
|
||||
// Refresh local task so the recommendation card hides
|
||||
await this._loadTask();
|
||||
setTimeout(() => { this._toast = ""; }, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
private async _reanalyzeInterval(): Promise<void> {
|
||||
if (!this._entryId || !this._taskId) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<{
|
||||
recommended_interval: number | null;
|
||||
confidence: string;
|
||||
data_points: number;
|
||||
}>({
|
||||
type: "maintenance_supporter/task/analyze_interval",
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
});
|
||||
this._toast = 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_insufficient_data", this._lang);
|
||||
await this._loadTask();
|
||||
setTimeout(() => { this._toast = ""; }, 3500);
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
const r = await this._runWs<{
|
||||
recommended_interval: number | null;
|
||||
confidence: string;
|
||||
data_points: number;
|
||||
}>({
|
||||
type: "maintenance_supporter/task/analyze_interval",
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
});
|
||||
if (r === undefined) return;
|
||||
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_insufficient_data", this._lang));
|
||||
await this._loadTask();
|
||||
}
|
||||
|
||||
private _onEditHistoryEntry(entry: HistoryEntry): void {
|
||||
@@ -378,11 +396,13 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
task.interval_confidence || "medium", L,
|
||||
)}
|
||||
<div class="recommendation-actions">
|
||||
<button class="btn primary"
|
||||
@click=${this._applySuggestion} ?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>
|
||||
${t("apply_suggestion", L)}
|
||||
</button>
|
||||
${canWrite(this.hass?.user, this._access)
|
||||
? html`<button class="btn primary qa-apply-suggestion"
|
||||
@click=${this._applySuggestion} ?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>
|
||||
${t("apply_suggestion", L)}
|
||||
</button>`
|
||||
: nothing /* task/apply_suggestion is write tier */}
|
||||
<button class="btn"
|
||||
@click=${this._reanalyzeInterval} ?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:refresh"></ha-icon>
|
||||
@@ -440,55 +460,51 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
* existing history-edit dialog (which lives in the same dialog-mount). */
|
||||
private _renderDetails(task: MaintenanceTask) {
|
||||
const L = this._lang;
|
||||
// The list payload carries only the most recent entries (payload diet,
|
||||
// 20) — the stats come from the server's totals over the WHOLE history,
|
||||
// not from that window: a task done 30 times read "20" here (DRY audit
|
||||
// 2026-09-26). The list itself stays the recent window, newest first by
|
||||
// timestamp (a backdated #133 completion is appended last).
|
||||
const history = (task.history || []) as HistoryEntry[];
|
||||
const completed = history.filter((h) => h.type === "completed");
|
||||
const totalCost = completed.reduce(
|
||||
(s, h) => s + (typeof h.cost === "number" ? h.cost : 0),
|
||||
0,
|
||||
);
|
||||
const avgDuration = (() => {
|
||||
const durs = completed
|
||||
.map((h) => (typeof h.duration === "number" ? h.duration : null))
|
||||
.filter((d): d is number => d != null);
|
||||
if (!durs.length) return null;
|
||||
return Math.round(durs.reduce((s, d) => s + d, 0) / durs.length);
|
||||
})();
|
||||
const totalEntries = task.history_count ?? history.length;
|
||||
const recent = newestFirst(history).slice(0, 20);
|
||||
|
||||
return html`
|
||||
<div class="details">
|
||||
<div class="stats-grid">
|
||||
<div class="stat">
|
||||
<span class="stat-label">${t("times_performed", L)}</span>
|
||||
<span class="stat-value">${completed.length}</span>
|
||||
<span class="stat-value">${task.times_performed ?? 0}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">${t("total_cost", L)}</span>
|
||||
<span class="stat-value">${formatCost(totalCost, this._currencySymbol, L)}</span>
|
||||
<span class="stat-value">${formatCost(task.total_cost ?? 0, this._currencySymbol, L)}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">${t("avg_duration", L)}</span>
|
||||
<span class="stat-value">${formatDuration(avgDuration, L)}</span>
|
||||
<span class="stat-value">${formatDuration(task.average_duration != null ? Math.round(task.average_duration) : null, L)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history-header">
|
||||
<strong>${t("history", L)}</strong>
|
||||
<span class="history-count">${history.length}</span>
|
||||
<span class="history-count">${totalEntries}</span>
|
||||
</div>
|
||||
${history.length === 0
|
||||
? html`<div class="history-empty">${t("history_empty", L)}</div>`
|
||||
: html`
|
||||
<div class="history-list">
|
||||
${[...history].reverse().slice(0, 20).map((entry) => renderHistoryEntry(entry, {
|
||||
${recent.map((entry) => renderHistoryEntry(entry, {
|
||||
lang: L,
|
||||
hass: this.hass,
|
||||
currencySymbol: this._currencySymbol,
|
||||
openEdit: (e) => this._onEditHistoryEntry(e),
|
||||
// task/history/update is write tier: no pencil otherwise.
|
||||
openEdit: canWrite(this.hass?.user, this._access) ? (e) => this._onEditHistoryEntry(e) : undefined,
|
||||
readingUnit: task.reading_unit,
|
||||
readingSlotDelta: (e, slotId) => readingSlotDelta(history, e, slotId),
|
||||
taskRef: this._taskRef,
|
||||
}, { compact: true }))}
|
||||
${history.length > 20
|
||||
? html`<div class="history-more">… +${history.length - 20} ${t("older_entries", L)}</div>`
|
||||
${totalEntries > recent.length
|
||||
? html`<div class="history-more">… +${totalEntries - recent.length} ${t("older_entries", L)}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`}
|
||||
@@ -500,17 +516,16 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
if (!this._open) return nothing;
|
||||
const L = this._lang;
|
||||
const task = this._task;
|
||||
const isAdmin = (this.hass?.user?.is_admin ?? true) as boolean;
|
||||
const writer = canWrite(this.hass?.user, this._access);
|
||||
|
||||
return html`
|
||||
<div class="backdrop" @click=${this.close}></div>
|
||||
<div class="dialog" role="dialog" aria-modal="true">
|
||||
return renderModalShell(() => this.close(), html`
|
||||
${task
|
||||
? html`
|
||||
<div class="header">
|
||||
<div class="title">
|
||||
<span class="status-dot" style="background: ${STATUS_COLORS[task.status] || "#ccc"}"></span>
|
||||
<span class="status-dot" style="background: ${statusColor(task)}"></span>
|
||||
<span class="task-name">${task.name}</span>
|
||||
${renderStatusBadge(task, L)}
|
||||
</div>
|
||||
<div class="object">
|
||||
<button class="link-inline" @click=${() => {
|
||||
@@ -597,30 +612,33 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
${t("reset", L)}
|
||||
</ha-button>
|
||||
</div>
|
||||
${isAdmin
|
||||
? html`
|
||||
<div class="actions secondary-row">
|
||||
<ha-button size="small" appearance="outlined" variant="neutral" @click=${this._onEdit} .disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="mdi:pencil"></ha-icon>
|
||||
${t("edit", L)}
|
||||
</ha-button>
|
||||
<ha-button size="small" appearance="outlined" variant="neutral" @click=${this._onQr} .disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="mdi:qrcode"></ha-icon>
|
||||
${t("qr_code", L)}
|
||||
</ha-button>
|
||||
<ha-button size="small" appearance="outlined" variant="neutral"
|
||||
@click=${task.archived ? this._onUnarchive : this._onArchive}
|
||||
.disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="${task.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'}"></ha-icon>
|
||||
${task.archived ? t("unarchive", L) : t("archive", L)}
|
||||
</ha-button>
|
||||
<ha-button size="small" appearance="outlined" variant="danger" class="danger" @click=${this._onDelete} .disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="mdi:delete"></ha-icon>
|
||||
${t("delete", L)}
|
||||
</ha-button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
<!-- QR is read tier (the panel offers it to operators too);
|
||||
Edit / Archive / Delete follow canWrite() — the panel's
|
||||
operator delegation, not is_admin alone. -->
|
||||
<div class="actions secondary-row">
|
||||
${writer
|
||||
? html`<ha-button size="small" appearance="outlined" variant="neutral" class="qa-edit" @click=${this._onEdit} .disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="mdi:pencil"></ha-icon>
|
||||
${t("edit", L)}
|
||||
</ha-button>`
|
||||
: nothing}
|
||||
<ha-button size="small" appearance="outlined" variant="neutral" class="qa-qr" @click=${this._onQr} .disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="mdi:qrcode"></ha-icon>
|
||||
${t("qr_code", L)}
|
||||
</ha-button>
|
||||
${writer
|
||||
? html`<ha-button size="small" appearance="outlined" variant="neutral" class="qa-archive"
|
||||
@click=${task.archived ? this._onUnarchive : this._onArchive}
|
||||
.disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="${task.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'}"></ha-icon>
|
||||
${task.archived ? t("unarchive", L) : t("archive", L)}
|
||||
</ha-button>
|
||||
<ha-button size="small" appearance="outlined" variant="danger" class="danger qa-delete" @click=${this._onDelete} .disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="mdi:delete"></ha-icon>
|
||||
${t("delete", L)}
|
||||
</ha-button>`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="details-toggle">
|
||||
<button class="link" @click=${() => { this._showDetails = !this._showDetails; }}>
|
||||
<ha-icon icon="${this._showDetails ? 'mdi:chevron-up' : 'mdi:chevron-down'}"></ha-icon>
|
||||
@@ -649,30 +667,18 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
</div>
|
||||
`}
|
||||
`
|
||||
: html`<div class="loading">${t("loading", L)}</div>`}
|
||||
</div>
|
||||
`;
|
||||
: this._error
|
||||
? html`<div class="error">${this._error}</div>`
|
||||
: html`<div class="loading">${t("loading", L)}</div>`}
|
||||
`);
|
||||
}
|
||||
|
||||
static styles = [sharedStyles, css`
|
||||
:host { display: contents; }
|
||||
.backdrop {
|
||||
position: fixed; inset: 0; z-index: 100;
|
||||
background: rgba(0,0,0,0.5);
|
||||
}
|
||||
.dialog {
|
||||
position: fixed; left: 50%; top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 95vw; max-width: 460px;
|
||||
max-height: 92vh; overflow: auto;
|
||||
background: var(--card-background-color, var(--ha-card-background, #1c1c1c));
|
||||
color: var(--primary-text-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
|
||||
padding: 20px;
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
z-index: 101;
|
||||
}
|
||||
protected updated(changed: Map<string, unknown>): void {
|
||||
if (changed.has("_open") && this._open) focusModalShell(this.shadowRoot);
|
||||
}
|
||||
|
||||
static styles = [sharedStyles, modalShellStyles, css`
|
||||
:host { display: contents; --ms-modal-max-width: 460px; }
|
||||
.header { display: flex; flex-direction: column; gap: 6px; }
|
||||
.title { display: flex; align-items: center; gap: 10px; }
|
||||
.status-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
|
||||
|
||||
+58
-60
@@ -15,7 +15,9 @@ import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale, langOf, syncLocaleFromHass } from "../styles";
|
||||
import { registerCustomCard } from "../helpers/register-card";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { VACATION_BUFFER_DAYS_RANGE } from "../helpers/setting-ranges";
|
||||
import { confirmAction } from "../helpers/confirm";
|
||||
import { sectionCardSharedStyles } from "./section-card-shared-styles";
|
||||
import "./ms-date-field";
|
||||
import type { HomeAssistant } from "../types";
|
||||
@@ -43,7 +45,8 @@ export class MaintenanceVacationSectionCard extends LitElement {
|
||||
@state() private _error = "";
|
||||
@state() private _localStart = "";
|
||||
@state() private _localEnd = "";
|
||||
@state() private _localBuffer = 7;
|
||||
/** Typed text — validated against VACATION_BUFFER_DAYS_RANGE on Save. */
|
||||
@state() private _localBuffer = "3";
|
||||
@state() private _dirty = false;
|
||||
|
||||
private _loaded = false;
|
||||
@@ -76,72 +79,68 @@ export class MaintenanceVacationSectionCard extends LitElement {
|
||||
}
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<VacationState>({
|
||||
type: "maintenance_supporter/vacation/state",
|
||||
});
|
||||
this._state = r;
|
||||
this._localStart = r.start || "";
|
||||
this._localEnd = r.end || "";
|
||||
this._localBuffer = r.buffer_days ?? 7;
|
||||
this._dirty = false;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
const r = await runWs<VacationState>(this, { type: "maintenance_supporter/vacation/state" }, {
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
if (!r) return;
|
||||
this._state = r;
|
||||
this._localStart = r.start || "";
|
||||
this._localEnd = r.end || "";
|
||||
this._localBuffer = String(r.buffer_days ?? 3);
|
||||
this._dirty = false;
|
||||
}
|
||||
|
||||
/** One vacation write — helpers/ws-run runWs with the busy flag and the
|
||||
* error line; resolves the new state (undefined when refused). */
|
||||
private _act(payload: Record<string, unknown>): Promise<VacationState | null | undefined> {
|
||||
this._error = "";
|
||||
return runWs<VacationState>(this, payload, {
|
||||
busy: (b) => { this._busy = b; },
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
}
|
||||
|
||||
private async _toggleEnabled(on: boolean): Promise<void> {
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<VacationState>({
|
||||
type: "maintenance_supporter/vacation/update",
|
||||
enabled: on,
|
||||
});
|
||||
this._state = r;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
const r = await this._act({ type: "maintenance_supporter/vacation/update", enabled: on });
|
||||
if (r) this._state = r;
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this._isAdmin) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<VacationState>({
|
||||
type: "maintenance_supporter/vacation/update",
|
||||
start: this._localStart || null,
|
||||
end: this._localEnd || null,
|
||||
buffer_days: this._localBuffer,
|
||||
});
|
||||
this._state = r;
|
||||
this._dirty = false;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
// Same range + message as the settings view (DRY audit 2026-09-26): an
|
||||
// out-of-range buffer is named up front instead of round-tripping into
|
||||
// a voluptuous error.
|
||||
const [min, max] = VACATION_BUFFER_DAYS_RANGE;
|
||||
const buffer = Number(this._localBuffer);
|
||||
if (this._localBuffer.trim() === "" || !Number.isInteger(buffer) || buffer < min || buffer > max) {
|
||||
this._error = t("settings_value_out_of_range", this._lang).replace("{min}", String(min)).replace("{max}", String(max));
|
||||
return;
|
||||
}
|
||||
const r = await this._act({
|
||||
type: "maintenance_supporter/vacation/update",
|
||||
start: this._localStart || null,
|
||||
end: this._localEnd || null,
|
||||
buffer_days: buffer,
|
||||
});
|
||||
if (!r) return;
|
||||
this._state = r;
|
||||
this._dirty = false;
|
||||
}
|
||||
|
||||
private async _endNow(): Promise<void> {
|
||||
if (!this._isAdmin) return;
|
||||
if (!window.confirm(t("vacation_end_now_confirm", this._lang))) return;
|
||||
this._busy = true;
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<VacationState>({
|
||||
type: "maintenance_supporter/vacation/end_now",
|
||||
});
|
||||
this._state = r;
|
||||
this._localStart = r.start || "";
|
||||
this._localEnd = r.end || "";
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
// The confirm dialog, not the browser's native modal (DRY audit 2026-09-26).
|
||||
const confirmed = await confirmAction(this.hass, {
|
||||
title: t("vacation_end_now", this._lang),
|
||||
message: t("vacation_end_now_confirm", this._lang),
|
||||
confirmText: t("vacation_end_now", this._lang),
|
||||
});
|
||||
if (!confirmed) return;
|
||||
const r = await this._act({ type: "maintenance_supporter/vacation/end_now" });
|
||||
if (!r) return;
|
||||
this._state = r;
|
||||
this._localStart = r.start || "";
|
||||
this._localEnd = r.end || "";
|
||||
}
|
||||
|
||||
private _onDeepLink(): void {
|
||||
@@ -224,12 +223,11 @@ export class MaintenanceVacationSectionCard extends LitElement {
|
||||
</div>
|
||||
<div class="date-field buffer">
|
||||
<label>${t("vacation_buffer", L)}</label>
|
||||
<input type="number" min="0" max="14"
|
||||
.value=${String(this._localBuffer)}
|
||||
<input type="number" min=${VACATION_BUFFER_DAYS_RANGE[0]} max=${VACATION_BUFFER_DAYS_RANGE[1]}
|
||||
.value=${this._localBuffer}
|
||||
?disabled=${this._busy}
|
||||
@input=${(e: Event) => {
|
||||
this._localBuffer = parseInt(
|
||||
(e.target as HTMLInputElement).value, 10) || 0;
|
||||
this._localBuffer = (e.target as HTMLInputElement).value;
|
||||
this._dirty = true;
|
||||
}} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user